Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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(
'로그인 문제 수정',
);
});
});
39 changes: 39 additions & 0 deletions apps/desktop/src/main/maker-host/title-output-validation.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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<SupportedLocale, RegExp> = {
'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) {
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'],
Expand Down
34 changes: 28 additions & 6 deletions apps/desktop/src/main/maker-ipc/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,20 +114,32 @@ 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,
),
},
{
readSessionProviderId: readSessionProviderIdFromDb,
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 调用。 */
Expand Down Expand Up @@ -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 };
Expand All @@ -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');
}
Expand Down
Loading