diff --git a/apps/desktop/src/main/maker-host/__tests__/titleOutputValidation.test.ts b/apps/desktop/src/main/maker-host/__tests__/titleOutputValidation.test.ts index e061c37958d..bf90b3368d1 100644 --- a/apps/desktop/src/main/maker-host/__tests__/titleOutputValidation.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/titleOutputValidation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { validateTitleOutput } from '../title-output-validation.js'; +import { + validateGeneratedTitleLocale, + validateTitleOutput, +} from '../title-output-validation.js'; describe('validateTitleOutput', () => { it.each([ @@ -99,3 +102,39 @@ describe('validateTitleOutput', () => { expect(validateTitleOutput('😀😀😀😀', 3)).toBeNull(); }); }); + +describe('validateGeneratedTitleLocale', () => { + it('rejects an unattributed Malayalam suffix in a Chinese title', () => { + expect( + validateGeneratedTitleLocale('夏日合照自然合成 ആവശ്യ', '请自然合成这张夏日合照', 'zh-CN'), + ).toBeNull(); + }); + + it('keeps normal Chinese and common Latin product/code names', () => { + expect(validateGeneratedTitleLocale('夏日合照自然合成', '请合成这张照片', 'zh-CN')).toBe( + '夏日合照自然合成', + ); + expect(validateGeneratedTitleLocale('修复 Mivo API 登录', '帮我排查登录问题', 'zh-CN')).toBe( + '修复 Mivo API 登录', + ); + }); + + it('allows an unexpected script when the generated title quotes the source text', () => { + expect( + validateGeneratedTitleLocale( + '处理 ആവശ്യ 字段', + '接口里的 ആവശ്യ 字段是什么意思?', + 'zh-CN', + ), + ).toBe('处理 ആവശ്യ 字段'); + }); + + it('accepts the native scripts of Japanese and Korean locales', () => { + expect(validateGeneratedTitleLocale('サーバー問題の修正', 'fix server', 'ja')).toBe( + 'サーバー問題の修正', + ); + expect(validateGeneratedTitleLocale('로그인 문제 수정', 'fix login', 'ko')).toBe( + '로그인 문제 수정', + ); + }); +}); diff --git a/apps/desktop/src/main/maker-host/title-output-validation.ts b/apps/desktop/src/main/maker-host/title-output-validation.ts index dde7edcfa0a..5a4cdb90c88 100644 --- a/apps/desktop/src/main/maker-host/title-output-validation.ts +++ b/apps/desktop/src/main/maker-host/title-output-validation.ts @@ -1,5 +1,7 @@ /** Deterministic acceptance rules for model-produced session titles. */ +import type { SupportedLocale } from '../../shared/locale.js'; + const META_PREFIX_RE = /^(?:(?:according to (?:the )?conversation|based on (?:the )?conversation|here(?:'s| is) (?:the )?title)(?:\b|\s|[,::,。.!!??])|(?:title|标题|タイトル|제목)\s*[::]|以下是|根据对话内容)/iu; const ROLE_LABEL_RE = @@ -22,6 +24,15 @@ const INSTRUCTION_ECHO_RES: readonly RegExp[] = [ /^(?:treat\s+everything\s+inside\s+the\s+(?:user_message|conversation_opening|recent_conversation)\s+delimiters\b|never\s+restate,?\s+translate,?\s+or\s+summarize\s+the\s+instructions\s+above\b|write\s+the\s+title\s+in\s+(?:simplified\s+chinese|english|japanese|korean)\b|use\s+at\s+most\s+20\s+characters\b|output\s+only\s+the\s+title,?\s+without\s+quotation\s+marks\s+or\s+ending\s+punctuation\b).*$/iu, ]; +const LETTER_RE = /\p{Letter}/u; +const LOCALE_TITLE_LETTER_RE: Record = { + 'zh-CN': /[\p{Script_Extensions=Han}\p{Script_Extensions=Latin}]/u, + 'zh-TW': /[\p{Script_Extensions=Han}\p{Script_Extensions=Latin}]/u, + en: /\p{Script_Extensions=Latin}/u, + ja: /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Latin}]/u, + ko: /[\p{Script_Extensions=Hangul}\p{Script_Extensions=Han}\p{Script_Extensions=Latin}]/u, +}; + function exceedsUnicodeCodePointLimit(value: string, maxChars: number): boolean { let count = 0; for (const _char of value) { @@ -71,3 +82,31 @@ export function validateTitleOutput( if (exceedsUnicodeCodePointLimit(title, maxChars)) return null; return title; } + +/** + * Reject letters from scripts that neither belong to the requested UI locale nor + * occur in the reference material. This is intentionally scoped to generated + * titles: manual titles and the user's fallback text must remain lossless. + * + * Latin stays valid in every supported locale for product names, file names and + * code identifiers. An otherwise unexpected letter is accepted only when the same + * code point exists in the source, so quoted multilingual names survive while a + * model cannot append an unrelated foreign-script fragment (issue #3483). + */ +export function validateGeneratedTitleLocale( + title: string, + referenceText: string, + locale: SupportedLocale, +): string | null { + const allowedLetter = LOCALE_TITLE_LETTER_RE[locale]; + const referencedUnexpectedLetters = new Set( + Array.from(referenceText).filter( + (char) => LETTER_RE.test(char) && !allowedLetter.test(char), + ), + ); + for (const char of title) { + if (!LETTER_RE.test(char) || allowedLetter.test(char)) continue; + if (!referencedUnexpectedLetters.has(char)) return null; + } + return title; +} diff --git a/apps/desktop/src/main/maker-ipc/__tests__/regenerateSessionTitle.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/regenerateSessionTitle.test.ts index ec1bc2c33ce..479c3cc545b 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/regenerateSessionTitle.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/regenerateSessionTitle.test.ts @@ -48,7 +48,7 @@ import { getResolvedMainLocale } from '../../i18n.js'; import type { RegenerateTitleMaterial } from '../../localDb/latestMessageText.js'; beforeEach(() => { - vi.mocked(getResolvedMainLocale).mockReturnValue('en'); + vi.mocked(getResolvedMainLocale).mockReturnValue('zh-CN'); vi.clearAllMocks(); }); @@ -338,6 +338,20 @@ describe('regenerateMakerSessionTitle', () => { ).rejects.toThrow(/\[INTERNAL\]/); }); + it('AI 重命名同样拒绝素材中没有依据的异文片段', async () => { + vi.mocked(getResolvedMainLocale).mockReturnValue('zh-CN'); + const deps = makeDeps({ + generateTitle: vi.fn(async () => generatedTitle('夏日合照自然合成 ആവശ്യ')), + }); + + await expect(regenerateMakerSessionTitle('s1', deps)).rejects.toThrow(/\[INTERNAL\]/); + expect(logger.warn).toHaveBeenCalledWith('regenerate session title rejected model output', { + sessionId: 's1', + agentKind: 'claude-code', + reason: 'unattributed-script', + }); + }); + it('依赖异常被脱敏为通用 INTERNAL 错误,不向 renderer 透传原始错误', async () => { const deps = makeDeps({ collectMaterial: vi.fn(async () => { @@ -394,6 +408,30 @@ describe('generateMakerSessionTitle', () => { expect(request.prompt).not.toContain(' 帮我排查登录失败'); }); + it('zh-CN 模型标题凭空混入马拉雅拉姆文字时拒绝并回落原文占位', async () => { + vi.mocked(getResolvedMainLocale).mockReturnValue('zh-CN'); + vi.mocked(generateTitleViaProvider).mockResolvedValueOnce('夏日合照自然合成 ആവശ്യ'); + + expect( + await generateMakerSessionTitle('请自然合成这张夏日合照', 'claude-code', 's1'), + ).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith('auto title rejected model output', { + sessionId: 's1', + agentKind: 'claude-code', + locale: 'zh-CN', + reason: 'unattributed-script', + }); + }); + + it('首条输入本身包含外文时允许模型保留同一段文字', async () => { + vi.mocked(getResolvedMainLocale).mockReturnValue('zh-CN'); + vi.mocked(generateTitleViaProvider).mockResolvedValueOnce('处理 ആവശ്യ 字段'); + + expect( + await generateMakerSessionTitle('接口里的 ആവശ്യ 字段是什么意思?', 'codex', 's1'), + ).toBe('处理 ആവശ്യ 字段'); + }); + it.each([ ['zh-CN', 'Simplified Chinese'], ['en', 'English'], diff --git a/apps/desktop/src/main/maker-ipc/title.ts b/apps/desktop/src/main/maker-ipc/title.ts index dfb4827f982..aa7ef7261da 100644 --- a/apps/desktop/src/main/maker-ipc/title.ts +++ b/apps/desktop/src/main/maker-ipc/title.ts @@ -31,7 +31,10 @@ import { generateTitleWithAuxiliaryModel, generateTitleWithAuxiliaryModelResult, } from '../maker-host/auxiliary-title-one-shot.js'; -import { validateTitleOutput } from '../maker-host/title-output-validation.js'; +import { + validateGeneratedTitleLocale, + validateTitleOutput, +} from '../maker-host/title-output-validation.js'; import { regenerateTitleMaterial, type RegenerateTitleMaterial, @@ -111,13 +114,14 @@ export async function generateMakerSessionTitle( // "请提供用户消息内容"式回复当标题返回。直接放弃,调用方保留默认名。 const trimmed = message.trim(); if (!trimmed) return null; - return generateTitleWithAuxiliaryModel( + const locale = getResolvedMainLocale(); + const generated = await generateTitleWithAuxiliaryModel( { sessionId: sessionId ?? '', agentKind, prompt: buildAutoTitlePrompt( trimmed.slice(0, AUTO_TITLE_MESSAGE_SLICE), - getResolvedMainLocale(), + locale, ), }, { @@ -125,6 +129,17 @@ export async function generateMakerSessionTitle( listConnectedProviders: listConnectedProvidersForAgent, }, ); + if (!generated) return null; + const title = validateGeneratedTitleLocale(generated, trimmed, locale); + if (!title) { + log.warn('auto title rejected model output', { + sessionId: sessionId ?? '', + agentKind, + locale, + reason: 'unattributed-script', + }); + } + return title; } /** regenerate 的依赖注入面——单测用内存实现替换 DB / LLM 调用。 */ @@ -213,10 +228,11 @@ export async function regenerateMakerSessionTitle( : `Assistant: ${m.text.slice(0, REGENERATE_ASSISTANT_SLICE)}`, ) .join('\n'); + const locale = getResolvedMainLocale(); const generated = await deps.generateTitle( sessionId, agentKind, - buildRegenerateTitlePrompt(openingText, transcript, getResolvedMainLocale()), + buildRegenerateTitlePrompt(openingText, transcript, locale), ); if (generated.status !== 'ok') { const context = { sessionId, agentKind, reason: generated.status }; @@ -233,12 +249,18 @@ export async function regenerateMakerSessionTitle( // Regenerate has a stricter product contract than the shared auto-title path: // one line, ≤20 Unicode characters, and no transcript/meta wrapper. The model is // not trusted to enforce this by prompt alone. - const title = validateTitleOutput(generated.title, 20); + const normalizedTitle = validateTitleOutput(generated.title, 20); + const referenceText = [openingText, ...recent.map((message) => message.text)] + .filter((text): text is string => Boolean(text)) + .join('\n'); + const title = normalizedTitle + ? validateGeneratedTitleLocale(normalizedTitle, referenceText, locale) + : null; if (!title) { log.warn('regenerate session title rejected model output', { sessionId, agentKind, - reason: 'invalid-output', + reason: normalizedTitle ? 'unattributed-script' : 'invalid-output', }); throwIpcError('INTERNAL', 'AI title generation failed'); }