@@ -187,20 +187,76 @@ export const parseAIResponse = (responseStr: string): any[] => {
187187export const sanitizeActions = ( actions : any [ ] ) : any [ ] => {
188188 const newActions : any [ ] = [ ]
189189
190+ // Metadata fields that should not be duplicated if we split an action into multiple ones.
191+ // These can trigger side-effects (search, profile updates, debug panels).
192+ const nonDuplicatedFields = new Set ( [
193+ 'needs_search' ,
194+ 'memory' ,
195+ 'characterProgression' ,
196+ 'debug_info'
197+ ] )
198+
199+ const looksLikeNarrationWithoutQuotes = ( rawText : string ) : boolean => {
200+ const t = ( rawText || '' ) . trim ( )
201+ if ( ! t ) return false
202+
203+ const lower = t . toLowerCase ( )
204+
205+ for ( const char of l2d ) {
206+ const name = ( char as any ) . name as string
207+ if ( ! name ) continue
208+
209+ const nameLower = name . toLowerCase ( )
210+ if ( ! lower . startsWith ( nameLower ) ) continue
211+
212+ const after = t . slice ( name . length )
213+ const nextChar = after . charAt ( 0 )
214+
215+ // Speaker label ("Name:")
216+ if ( nextChar === ':' ) return false
217+
218+ // Direct address ("Name,")
219+ if ( nextChar === ',' ) return false
220+
221+ // Possessive narration: "Name's ..." / "Name’s ..."
222+ if ( nextChar === '\'' || nextChar === '’' ) {
223+ const poss = after . slice ( 0 , 2 )
224+ if ( poss === "'s" || poss === '’s' ) return true
225+ }
226+
227+ // Strong narration clue: "Name ..., her/his/ ..." early in the sentence.
228+ if ( nextChar && / \s / . test ( nextChar ) ) {
229+ const rest = after . trimStart ( ) . slice ( 0 , 80 ) . toLowerCase ( )
230+ if ( rest . includes ( ', her ' ) || rest . includes ( ', his ' ) ) {
231+ return true
232+ }
233+ }
234+
235+ return false
236+ }
237+
238+ return false
239+ }
240+
190241 for ( const action of actions ) {
191242 if ( ! action . text || typeof action . text !== 'string' ) {
192243 newActions . push ( action )
193244 continue
194245 }
195246
196- // Check if the text contains dialogue
247+ // Check if the text contains dialogue.
248+ // Supports straight quotes ("...") and curly quotes (open “ ... close ”).
249+ // Curly quotes are asymmetric, so we match them as a pair explicitly.
197250 const text = action . text
198- const quotes = '[""“”]'
199- const dialogueRegex = new RegExp ( '(' + quotes + ')([^' + quotes + ']*)\\1' , 'g' )
200-
251+ const dialogueRegex = / ( " [ \s \S ] * ?" | “ [ \s \S ] * ?” ) / g
252+
201253 if ( ! dialogueRegex . test ( text ) ) {
202- // No dialogue detected, keep original action
203- newActions . push ( action )
254+ // No quotes. If the model marked it as speaking, keep it UNLESS it looks like third-person narration.
255+ if ( action . speaking === true && looksLikeNarrationWithoutQuotes ( text ) ) {
256+ newActions . push ( { ...action , speaking : false } )
257+ } else {
258+ newActions . push ( action )
259+ }
204260 continue
205261 }
206262
@@ -236,19 +292,30 @@ export const sanitizeActions = (actions: any[]): any[] => {
236292 for ( let i = 0 ; i < parts . length ; i ++ ) {
237293 const part = parts [ i ]
238294 if ( ! part . text . trim ( ) ) continue
295+
296+ // Preserve all original fields by default, but avoid duplicating side-effect fields.
297+ const base : any = { ...action }
298+ if ( i > 0 ) {
299+ for ( const key of Object . keys ( base ) ) {
300+ if ( nonDuplicatedFields . has ( key ) ) {
301+ if ( key === 'needs_search' ) base [ key ] = [ ]
302+ else delete base [ key ]
303+ }
304+ }
305+ }
239306
240307 if ( part . isDialogue ) {
241308 newActions . push ( {
309+ ...base ,
242310 text : part . text . trim ( ) ,
243- character : action . character ,
244- animation : action . animation ,
245311 speaking : true
246312 } )
247313 } else {
314+ // Keep the original character for narration (the narration is *about* that character).
315+ // This prevents narration like from being treated/displayed as spoken dialogue.
248316 newActions . push ( {
317+ ...base ,
249318 text : part . text . trim ( ) ,
250- character : 'narrator' ,
251- animation : 'idle' ,
252319 speaking : false
253320 } )
254321 }
@@ -265,8 +332,11 @@ export const sanitizeActions = (actions: any[]): any[] => {
265332 // Check if previous is narration, current is dialogue
266333 if ( prev . speaking === false &&
267334 curr . speaking === true &&
268- prev . animation && prev . animation !== 'idle' ) {
269- // Copy the narration animation to the dialogue
335+ prev . animation && prev . animation !== 'idle' &&
336+ prev . character && curr . character &&
337+ prev . character === curr . character &&
338+ ( ! curr . animation || curr . animation === 'idle' ) ) {
339+ // Only carry over animation for the SAME character, and only when the dialogue didn't specify one.
270340 curr . animation = prev . animation
271341 }
272342 }
0 commit comments