1616 <div class =" chat-history" ref =" chatHistoryRef" >
1717 <div v-for =" (msg, index) in chatHistory" :key =" index" :class =" ['message', msg.role]" >
1818 <div class =" message-content" v-html =" renderMarkdown(msg.content)" ></div >
19+ <div v-if =" index === chatHistory.length - 1 && !isLoading && msg.role === 'assistant'" class =" message-top-actions" style =" right : 0 ; left : auto ;" >
20+ <n-button size =" tiny" circle type =" warning" @click =" regenerateResponse" title =" Retry this message" >
21+ <template #icon ><n-icon ><Renew /></n-icon ></template >
22+ </n-button >
23+ </div >
1924 <div v-if =" index === chatHistory.length - 1 && !isLoading && msg.role !== 'system'" class =" message-actions" >
2025 <n-button size =" tiny" circle type =" error" @click =" deleteLastMessage" title =" Delete last message" >
2126 <template #icon ><n-icon ><TrashCan /></n-icon ></template >
363368import { ref , computed , watch , nextTick , onMounted , onUnmounted } from ' vue'
364369import { onBeforeRouteLeave } from ' vue-router'
365370import { useMarket } from ' @/stores/market'
366- import { Settings , Help , Save , Upload , TrashCan , Reset } from ' @vicons/carbon'
371+ import { Settings , Help , Save , Upload , TrashCan , Reset , Renew } from ' @vicons/carbon'
367372import { NIcon , NButton , NInput , NDrawer , NDrawerContent , NForm , NFormItem , NSelect , NSwitch , NPopover , NAlert , NModal , NSpin } from ' naive-ui'
368373import l2d from ' @/utils/json/l2d.json'
369374import characterHonorifics from ' @/utils/json/honorifics.json'
@@ -372,6 +377,7 @@ import loadingMessages from '@/utils/json/loadingMessages.json'
372377import prompts from ' @/utils/json/prompts.json'
373378import { marked } from ' marked'
374379import { animationMappings } from ' @/utils/animationMappings'
380+ import { cleanWikiContent , sanitizeActions , splitNarration , parseFallback } from ' @/utils/chatUtils'
375381
376382// Helper to get honorific with fallback to "Commander"
377383const getHonorific = (characterName : string ): string => {
@@ -1013,6 +1019,17 @@ const retryLastMessage = async () => {
10131019 scrollToBottom ()
10141020}
10151021
1022+ const regenerateResponse = async () => {
1023+ if (chatHistory .value .length > 0 ) {
1024+ const lastMsg = chatHistory .value [chatHistory .value .length - 1 ]
1025+ // Only allow regenerating assistant messages
1026+ if (lastMsg .role === ' assistant' ) {
1027+ chatHistory .value .pop ()
1028+ await retryLastMessage ()
1029+ }
1030+ }
1031+ }
1032+
10161033const stopGeneration = () => {
10171034 isStopped .value = true
10181035 isLoading .value = false
@@ -1534,29 +1551,6 @@ const fetchWikiContent = async (characterName: string): Promise<string | null> =
15341551 }
15351552}
15361553
1537- // Clean up wikitext for the model
1538- const cleanWikiContent = (wikitext : string ): string => {
1539- let text = wikitext
1540- // Remove wiki markup templates like {{...}}
1541- text = text .replace (/ \{\{ [^ }] * \}\} / g , ' ' )
1542- // Remove [[ ]] link markup but keep the text
1543- text = text .replace (/ \[\[ (?:[^ |\] ] * \| )? ([^ \] ] + )\]\] / g , ' $1' )
1544- // Remove remaining brackets
1545- text = text .replace (/ \[ | \] / g , ' ' )
1546- // Remove HTML comments
1547- text = text .replace (/ <!--[\s\S ] *? -->/ g , ' ' )
1548- // Remove section headers markup (== Header ==)
1549- text = text .replace (/ ^ =+ \s * (. +? )\s * =+ $ / gm , ' $1:' )
1550- // Clean up whitespace
1551- text = text .replace (/ \n {3,} / g , ' \n\n ' )
1552- text = text .replace (/ \s + / g , ' ' ).trim ()
1553- // Limit length to avoid token limits
1554- if (text .length > 4000 ) {
1555- text = text .substring (0 , 4000 ) + ' ...'
1556- }
1557- return text
1558- }
1559-
15601554// Search for characters by fetching wiki pages directly (for models without native search)
15611555const searchForCharactersViaWikiFetch = async (characterNames : string []): Promise <void > => {
15621556 logDebug (' [searchForCharactersViaWikiFetch] Fetching wiki pages for:' , characterNames )
@@ -1724,6 +1718,7 @@ const searchForCharactersPerplexity = async (characterNames: string[]): Promise<
17241718 }
17251719
17261720 characterProfiles .value = { ... characterProfiles .value , ... profile }
1721+
17271722 logDebug (` [searchForCharactersPerplexity] Added profile for ${name }: ` , profile )
17281723 } catch (e ) {
17291724 console .error (` [searchForCharactersPerplexity] Failed to search for ${name }: ` , e )
@@ -2096,127 +2091,6 @@ const callGemini = async (messages: any[], enableWebSearch: boolean = false) =>
20962091 return textPart .text
20972092}
20982093
2099- const sanitizeActions = (actions : any []) => {
2100- const newActions: any [] = []
2101-
2102- // Helper to identify speaker labels
2103- // Updated to handle cases where bold tags wrap the colon (e.g. **Name:**)
2104- const isSpeakerLabel = (s : string ) => / ^ \s * (?:\*\* )? [^ *] +? (?:\*\* )? \s * :\s * (?:\*\* )? \s * $ / .test (s )
2105-
2106- for (const action of actions ) {
2107- if (! action .text || typeof action .text !== ' string' ) {
2108- newActions .push (action )
2109-
2110- continue
2111- }
2112-
2113- // Check if text contains quotes
2114- // We look for standard quotes " and smart quotes “ ”
2115- const hasQuotes = / ["“”] / .test (action .text )
2116-
2117- if (! hasQuotes ) {
2118- // Case 1: No quotes at all.
2119-
2120- // Filter out standalone speaker labels
2121- if (isSpeakerLabel (action .text )) {
2122-
2123- continue
2124- }
2125-
2126- // If it was marked as speaking, only force to false if it looks like a stage direction
2127- if (action .speaking ) {
2128- // Check for stage directions starting with *, (, or [
2129- if (/ ^ [\s ] * [\[\( *] / .test (action .text )) {
2130- action .speaking = false
2131- }
2132- // Otherwise, trust the AI's speaking flag even without quotes
2133- // This handles cases where the AI forgets quotes around dialogue
2134- }
2135- newActions .push (action )
2136-
2137- continue
2138- }
2139-
2140- // Case 2: Has quotes. We need to split.
2141- // Regex to match quoted sections including the quotes
2142- const splitRegex = / ([“"][^ ”"] * [”"] )/ g
2143-
2144- const parts = action .text .split (splitRegex ).filter ((p : string ) => p .trim ().length > 0 )
2145-
2146- if (parts .length === 0 ) {
2147- newActions .push (action )
2148-
2149- continue
2150- }
2151-
2152- // Helper to determine if a part is a quote
2153- // Use [\s\S] to match any character including newlines
2154- const isQuote = (s : string ) => / ^ [“"][\s\S ] * [”"] $ / .test (s .trim ())
2155-
2156- // Merge trailing punctuation into previous part to avoid tiny separate messages
2157- const mergedParts: { text: string , isQuoted: boolean , characterId: string }[] = []
2158- let effectiveCharacterId = action .character
2159-
2160- for (let i = 0 ; i < parts .length ; i ++ ) {
2161- const part = parts [i ]
2162- const quoted = isQuote (part )
2163-
2164- // Filter out standalone speaker labels (e.g. "**Anis:**")
2165- // These are often artifacts from splitting "Name: Quote"
2166-
2167- if (isSpeakerLabel (part ) && ! quoted ) {
2168- continue
2169- }
2170-
2171- // If this part is just punctuation/space and we have a previous part, merge it
2172- // This handles cases like: "Hello". -> "Hello" + .
2173- if (mergedParts .length > 0 && ! quoted && / ^ [. ,;!?\s ] + $ / .test (part )) {
2174- mergedParts [mergedParts .length - 1 ].text += part
2175- } else {
2176- mergedParts .push ({ text: part , isQuoted: quoted , characterId: effectiveCharacterId })
2177- }
2178- }
2179-
2180- for (const partObj of mergedParts ) {
2181- // Create new action
2182- const newAction = { ... action , text: partObj .text , character: partObj .characterId }
2183-
2184- if (partObj .isQuoted ) {
2185- newAction .speaking = true
2186- } else {
2187- newAction .speaking = false
2188- }
2189-
2190- // Remove fixed duration so it gets recalculated based on text length
2191- if (newAction .duration ) {
2192- delete newAction .duration
2193- }
2194-
2195- newActions .push (newAction )
2196- }
2197- }
2198-
2199- // Post-process: If a narration action (speaking=false) is followed by a dialogue action (speaking=true)
2200- // for the same character, copy the animation from the narration to the dialogue.
2201- // This ensures the emotion set during narration persists into the dialogue.
2202- for (let i = 1 ; i < newActions .length ; i ++ ) {
2203- const prev = newActions [i - 1 ]
2204- const curr = newActions [i ]
2205-
2206- // Check if same character, previous is narration, current is dialogue
2207- if (prev .character && curr .character &&
2208- prev .character === curr .character &&
2209- prev .speaking === false &&
2210- curr .speaking === true &&
2211- prev .animation && prev .animation !== ' idle' ) {
2212- // Copy the narration animation to the dialogue
2213- curr .animation = prev .animation
2214- }
2215- }
2216-
2217- return newActions
2218- }
2219-
22202094const enrichActionsWithAnimations = async (actions : any []): Promise <any []> => {
22212095 logDebug (' Enriching actions with animations...' )
22222096
@@ -2311,131 +2185,6 @@ const enrichActionsWithAnimations = async (actions: any[]): Promise<any[]> => {
23112185 })
23122186}
23132187
2314- const splitNarration = (text : string ): any [] => {
2315- const actions: any [] = []
2316- // Split into sentences, handling common punctuation.
2317- const sentences = text .match (/ [^ . !?] + [. !?] + ["'] ? | [^ . !?] + $ / g )
2318-
2319- if (! sentences ) {
2320- return [{ text , character: ' none' , animation: ' idle' , speaking: false }]
2321- }
2322-
2323- let currentAction: any = null
2324-
2325- for (const rawSentence of sentences ) {
2326- const sentence = rawSentence .trim ()
2327- if (! sentence ) continue
2328-
2329- let foundCharId = null
2330-
2331- for (const char of l2d ) {
2332- const name = char .name
2333- // Check for "Name " or "Name's" or "Name." or just "Name" at start
2334- if (sentence .toLowerCase ().startsWith (name .toLowerCase ())) {
2335- const nextChar = sentence .charAt (name .length )
2336- if (! nextChar || / [\s '’. ,!?] / .test (nextChar )) {
2337- foundCharId = char .id
2338- break
2339- }
2340- }
2341- }
2342-
2343- if (foundCharId ) {
2344- if (currentAction ) {
2345- actions .push (currentAction )
2346- }
2347- currentAction = {
2348- text: sentence ,
2349- character: foundCharId ,
2350- animation: ' idle' ,
2351- speaking: false
2352- }
2353- } else {
2354- if (currentAction ) {
2355- currentAction .text += ' ' + sentence
2356- } else {
2357- currentAction = {
2358- text: sentence ,
2359- character: ' none' ,
2360- animation: ' idle' ,
2361- speaking: false
2362- }
2363- }
2364- }
2365- }
2366-
2367- if (currentAction ) {
2368- actions .push (currentAction )
2369- }
2370-
2371- return actions
2372- }
2373-
2374- const parseFallback = (text : string ): any [] => {
2375- const actions: any [] = []
2376- // Remove bold markers to simplify regex matching
2377- const cleanText = text .replace (/ \*\* / g , ' ' )
2378-
2379- // Regex to find "Name: "Dialogue"" pattern
2380- // Matches: Name (alphanumeric+spaces+dashes) followed by colon and quoted text
2381- const regex = / ([A-Za-z0-9 \s \-\( \) ] + ):\s * (["“][\s\S ] *? ["”] )/ g
2382-
2383- let lastIndex = 0
2384- let match
2385-
2386- while ((match = regex .exec (cleanText )) !== null ) {
2387- const fullMatch = match [0 ]
2388- const name = match [1 ].trim ()
2389- const dialogue = match [2 ]
2390- const index = match .index
2391-
2392- // 1. Narration (text before the speaker)
2393- const narration = cleanText .substring (lastIndex , index ).trim ()
2394-
2395- // Resolve Character ID
2396- let charId = ' current'
2397- // Try exact match first
2398- let charObj = l2d .find (c => c .name .toLowerCase () === name .toLowerCase ())
2399- // If not found, try partial match for names with spaces
2400- if (! charObj ) {
2401- charObj = l2d .find (c => name .toLowerCase ().includes (c .name .toLowerCase ()) || c .name .toLowerCase ().includes (name .toLowerCase ()))
2402- }
2403- if (charObj ) charId = charObj .id
2404-
2405- if (narration ) {
2406- const narrationActions = splitNarration (narration )
2407-
2408- // If the last narration action has 'none' character, it might belong to the upcoming speaker
2409- if (narrationActions .length > 0 ) {
2410- const lastAction = narrationActions [narrationActions .length - 1 ]
2411- if (lastAction .character === ' none' ) {
2412- lastAction .character = charId
2413- }
2414- }
2415-
2416- actions .push (... narrationActions )
2417- }
2418-
2419- // 2. Dialogue
2420- actions .push ({
2421- text: dialogue ,
2422- character: charId ,
2423- animation: ' idle' ,
2424- speaking: true
2425- })
2426-
2427- lastIndex = index + fullMatch .length
2428- }
2429-
2430- // Trailing text
2431- const trailing = cleanText .substring (lastIndex ).trim ()
2432- if (trailing ) {
2433- actions .push (... splitNarration (trailing ))
2434- }
2435-
2436- return actions
2437- }
2438-
24392188const processAIResponse = async (responseStr : string ) => {
24402189 loadingStatus .value = " Processing response..."
24412190 logDebug (' Raw AI Response:' , responseStr )
@@ -3173,6 +2922,18 @@ const summarizeChunk = async (messages: { role: string, content: string }[]) =>
31732922 }
31742923 }
31752924
2925+ .message-top-actions {
2926+ position : absolute ;
2927+ top : -10px ;
2928+ left : 0 ;
2929+ opacity : 0.5 ;
2930+ transition : opacity 0.2s ;
2931+
2932+ & :hover {
2933+ opacity : 1 ;
2934+ }
2935+ }
2936+
31762937 .message-actions {
31772938 position : absolute ;
31782939 bottom : -10px ;
0 commit comments