Skip to content

Commit ee93fc6

Browse files
committed
- Added a button on the top right corner of the last message to regenerate the last message (useful, for example, if the model output was garbled or incorrect JSON, or simply if the user wants a different response)
- Fixed a crash with Spine Player that could occur in certain instances, such as when changing characters too quickly - Additional refactoring by moving the parsing logic to a new file.
1 parent 5a44c40 commit ee93fc6

File tree

3 files changed

+303
-272
lines changed

3 files changed

+303
-272
lines changed

src/components/views/ChatInterface.vue

Lines changed: 31 additions & 270 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
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>
@@ -363,7 +368,7 @@
363368
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
364369
import { onBeforeRouteLeave } from 'vue-router'
365370
import { 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'
367372
import { NIcon, NButton, NInput, NDrawer, NDrawerContent, NForm, NFormItem, NSelect, NSwitch, NPopover, NAlert, NModal, NSpin } from 'naive-ui'
368373
import l2d from '@/utils/json/l2d.json'
369374
import characterHonorifics from '@/utils/json/honorifics.json'
@@ -372,6 +377,7 @@ import loadingMessages from '@/utils/json/loadingMessages.json'
372377
import prompts from '@/utils/json/prompts.json'
373378
import { marked } from 'marked'
374379
import { animationMappings } from '@/utils/animationMappings'
380+
import { cleanWikiContent, sanitizeActions, splitNarration, parseFallback } from '@/utils/chatUtils'
375381
376382
// Helper to get honorific with fallback to "Commander"
377383
const 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+
10161033
const 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)
15611555
const 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-
22202094
const 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-
24392188
const 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

Comments
 (0)