From f060d20b3b856d9b0c028407775a2d3de8a9d84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 11:22:08 +0800 Subject: [PATCH] fix: keep generated session titles in user language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async title refresh runs after assistant output, so provider prompts can be biased by assistant language and English title examples. The title language now resolves from the first meaningful user message before consulting the response-language fallback, then retries once when a generated title violates that language. Constraint: Desktop response-language settings still control assistant prose, not automatic title metadata when the first user message has a clear language Rejected: Follow response-language setting for every generated title | Chinese sessions flip to English when replies are configured in English Rejected: Persist mismatched generated titles after retry failure | preserves a bad UX over the safer first-message placeholder Confidence: high Scope-risk: moderate Directive: Do not change automatic title language precedence without re-testing image/text first-turn sessions and English-response-language sessions Tested: bun test src/server/__tests__/title-service.test.ts Tested: bun test src/server/__tests__/conversations.test.ts -t "refreshes the first-turn AI title" Tested: git diff --check Tested: real MiniMax-M3 /tmp session 58a47fcd-6f9d-4685-bc73-f9317e3f330e with temporary response language english; assistant replied English and final aiTitle was 标题语言验证 Not-tested: bun run check:server blocked before tests by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow --- src/server/__tests__/conversations.test.ts | 1 + src/server/__tests__/title-service.test.ts | 90 ++++++- src/server/services/titleService.ts | 284 ++++++++++++++++----- src/server/ws/handler.ts | 19 +- 4 files changed, 334 insertions(+), 60 deletions(-) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 8366a152..693c6a6f 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -1274,6 +1274,7 @@ describe('WebSocket Chat Integration', () => { expect(titleMessages[0]?.title).toBe('看一下这个搜索结果,请一条一条给我列出来') expect(titleMessages.map((msg) => msg.title)).toContain('Google 搜索企查查结果') expect(upstreamInputs.some((input) => input.includes('Echo: 看一下这个搜索结果'))).toBe(true) + expect(upstreamInputs.some((input) => input.includes('Return the title in Chinese.'))).toBe(true) } finally { titleModelServer.stop(true) if (originalProviderConfig === null) { diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index da9d3800..0614f040 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -4,7 +4,13 @@ import * as os from 'os' import * as path from 'path' import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' import { ProviderService } from '../services/providerService.js' -import { deriveTitle, generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js' +import { + deriveTitle, + generateTitle, + parseGeneratedTitleText, + resolveTitleLanguagePreference, + saveAiTitle, +} from '../services/titleService.js' import { sessionService } from '../services/sessionService.js' import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' @@ -239,6 +245,88 @@ describe('titleService', () => { } }) + test('keeps generated titles in the first user message language', async () => { + const requestBodies: Array<{ + messages?: Array<{ content?: string }> + }> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const requestBody = await req.json() as { + messages?: Array<{ content?: string }> + } + requestBodies.push(requestBody) + const title = requestBodies.length === 1 + ? 'Summarize recent changes' + : '总结最近变更' + return Response.json({ + content: [{ type: 'text', text: JSON.stringify({ title }) }], + }) + }, + }) + + try { + const providerId = 'title-language-test' + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: providerId, + providers: [ + { + id: providerId, + presetId: 'minimax', + name: 'MiniMax', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${server.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'minimax-main', + haiku: 'minimax-haiku', + sonnet: 'minimax-main', + opus: 'minimax-main', + }, + }, + ], + }, null, 2), + ) + + const languagePreference = resolveTitleLanguagePreference( + '最近我们在最近 15 天做了很多的变更,你去看一下', + 'english', + ) + await expect(generateTitle( + [ + '最近我们在最近 15 天做了很多的变更,你去看一下', + 'The assistant answered in English and summarized desktop changes.', + ].join('\n'), + undefined, + languagePreference, + )).resolves.toBe('总结最近变更') + + expect(languagePreference).toEqual({ + language: 'Chinese', + source: 'first-user-message', + }) + expect(requestBodies).toHaveLength(2) + const firstPrompt = String(requestBodies[0]?.messages?.[0]?.content ?? '') + const retryPrompt = String(requestBodies[1]?.messages?.[0]?.content ?? '') + expect(firstPrompt).toContain('Return the title in Chinese.') + expect(retryPrompt).toContain('The title must be in Chinese.') + expect(firstPrompt).not.toContain('Return the title in English.') + } finally { + server.stop(true) + } + }) + + test('falls back to response language when the first user message language is ambiguous', () => { + expect(resolveTitleLanguagePreference('/tmp/2026-06-03', 'english')).toEqual({ + language: 'English', + source: 'response-language', + }) + }) + test('generates titles when ChatGPT Official OAuth is active', async () => { const providerService = new ProviderService() await providerService.activateProvider('openai-official') diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 6947dd1b..50a7a456 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -21,6 +21,11 @@ const TITLE_MAX_LEN = 50 const TITLE_MAX_OUTPUT_TOKENS = 100 const TITLE_INPUT_MAX_LEN = 2000 +export type TitleLanguagePreference = { + language: string + source: 'first-user-message' | 'response-language' +} + export type TitleConversationTurn = { userText: string assistantText?: string @@ -47,11 +52,49 @@ export function buildConversationTitleInput(turns: TitleConversationTurn[]): str return extractConversationText(messages as any) } -function buildTitleUserPrompt(trimmed: string): string { +export function resolveTitleLanguagePreference( + firstUserMessage: string, + fallbackResponseLanguage?: string, +): TitleLanguagePreference | null { + const firstUserLanguage = inferLanguageFromText(firstUserMessage) + if (firstUserLanguage) { + return { + language: firstUserLanguage, + source: 'first-user-message', + } + } + + const fallbackLanguage = normalizeResponseLanguage(fallbackResponseLanguage) + if (!fallbackLanguage) return null + return { + language: fallbackLanguage, + source: 'response-language', + } +} + +function buildTitleUserPrompt( + trimmed: string, + languagePreference?: TitleLanguagePreference | null, + strictLanguage = false, +): string { + const languageLines = languagePreference + ? [ + strictLanguage + ? `The title must be in ${languagePreference.language}.` + : `Return the title in ${languagePreference.language}.`, + languagePreference.source === 'first-user-message' + ? 'This language was inferred from the user\'s first meaningful message; do not translate the title just because the assistant response or examples use another language.' + : 'Use this response-language setting only because the first user message language is ambiguous.', + 'Keep product names, file names, model names, and code identifiers in their original form.', + '', + ] + : [] + return [ 'Generate a title for the following conversation transcript.', 'Do not answer, continue, or summarize the conversation itself.', 'Return only JSON with a single "title" field.', + ...languageLines, '', '', trimmed.slice(0, TITLE_INPUT_MAX_LEN), @@ -80,6 +123,7 @@ export function deriveTitle(raw: string): string | undefined { export async function generateTitle( conversationText: string, providerId?: string | null, + languagePreference?: TitleLanguagePreference | null, ): Promise { const trimmed = cleanSessionTitleSource(conversationText) if (!trimmed) return null @@ -105,6 +149,7 @@ export async function generateTitle( return await generateOpenAIOfficialTitle( trimmed, resolvedProvider.models.haiku || resolvedProvider.models.main, + languagePreference, ) } @@ -112,7 +157,6 @@ export async function generateTitle( const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` - const userPrompt = buildTitleUserPrompt(trimmed) const requestHeaders = { 'Content-Type': 'application/json', 'x-api-key': resolvedProvider.apiKey, @@ -122,37 +166,26 @@ export async function generateTitle( model, max_tokens: TITLE_MAX_OUTPUT_TOKENS, system: SESSION_TITLE_PROMPT, - messages: [{ role: 'user', content: userPrompt }], } - let response = await fetch(url, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify({ - ...requestBody, - thinking: { type: 'disabled' }, - }), - signal: AbortSignal.timeout(15_000), - }) - - if (!response.ok && response.status >= 400 && response.status < 500) { - response = await fetch(url, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(15_000), - }) - } - - if (!response.ok) return null - - const body = (await response.json()) as { - content?: Array<{ type: string; text?: string }> - } - const text = body.content?.find((b) => b.type === 'text')?.text - if (!text) return null - - return parseGeneratedTitleText(text) + return await generateTitleWithLanguageRetry( + async (strictLanguage) => { + const response = await fetchAnthropicTitleResponse( + url, + requestHeaders, + { + ...requestBody, + messages: [{ + role: 'user', + content: buildTitleUserPrompt(trimmed, languagePreference, strictLanguage), + }], + }, + ) + if (!response) return null + return parseGeneratedTitleText(response) + }, + languagePreference, + ) } catch { return null } @@ -161,46 +194,181 @@ export async function generateTitle( async function generateOpenAIOfficialTitle( trimmed: string, model: string, + languagePreference?: TitleLanguagePreference | null, ): Promise { const tokens = await hahaOpenAIOAuthService.ensureFreshTokens() if (!tokens?.accessToken) return null const mappedModel = resolveOpenAICodexModel(model) - const requestBody = anthropicToOpenaiResponses({ - model: mappedModel, - max_tokens: TITLE_MAX_OUTPUT_TOKENS, - system: SESSION_TITLE_PROMPT, - messages: [{ role: 'user', content: buildTitleUserPrompt(trimmed) }], - stream: true, - thinking: { type: 'disabled' }, - }) - requestBody.stream = true - requestBody.max_output_tokens = TITLE_MAX_OUTPUT_TOKENS + return await generateTitleWithLanguageRetry( + async (strictLanguage) => { + const requestBody = anthropicToOpenaiResponses({ + model: mappedModel, + max_tokens: TITLE_MAX_OUTPUT_TOKENS, + system: SESSION_TITLE_PROMPT, + messages: [{ + role: 'user', + content: buildTitleUserPrompt(trimmed, languagePreference, strictLanguage), + }], + stream: true, + thinking: { type: 'disabled' }, + }) + requestBody.stream = true + requestBody.max_output_tokens = TITLE_MAX_OUTPUT_TOKENS - const headers = new Headers() - headers.set('Content-Type', 'application/json') - headers.set('Authorization', `Bearer ${tokens.accessToken}`) - if (tokens.accountId) { - headers.set('ChatGPT-Account-Id', tokens.accountId) - } + const headers = new Headers() + headers.set('Content-Type', 'application/json') + headers.set('Authorization', `Bearer ${tokens.accessToken}`) + if (tokens.accountId) { + headers.set('ChatGPT-Account-Id', tokens.accountId) + } - const response = await fetch(OPENAI_CODEX_API_ENDPOINT, { + const response = await fetch(OPENAI_CODEX_API_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(15_000), + }) + + if (!response.ok || !response.body) return null + + const body = await openaiResponsesStreamToAnthropicResponse( + response.body, + mappedModel, + ) + const text = body.content.find((b) => b.type === 'text')?.text + if (!text) return null + + return parseGeneratedTitleText(text) + }, + languagePreference, + ) +} + +async function fetchAnthropicTitleResponse( + url: string, + requestHeaders: Record, + requestBody: Record, +): Promise { + let response = await fetch(url, { method: 'POST', - headers, - body: JSON.stringify(requestBody), + headers: requestHeaders, + body: JSON.stringify({ + ...requestBody, + thinking: { type: 'disabled' }, + }), signal: AbortSignal.timeout(15_000), }) - if (!response.ok || !response.body) return null + if (!response.ok && response.status >= 400 && response.status < 500) { + response = await fetch(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(15_000), + }) + } - const body = await openaiResponsesStreamToAnthropicResponse( - response.body, - mappedModel, - ) - const text = body.content.find((b) => b.type === 'text')?.text - if (!text) return null + if (!response.ok) return null - return parseGeneratedTitleText(text) + const body = (await response.json()) as { + content?: Array<{ type: string; text?: string }> + } + return body.content?.find((b) => b.type === 'text')?.text ?? null +} + +async function generateTitleWithLanguageRetry( + requestTitle: (strictLanguage: boolean) => Promise, + languagePreference?: TitleLanguagePreference | null, +): Promise { + const first = await requestTitle(false) + if (!first || isTitleLanguageCompatible(first, languagePreference)) { + return first + } + + const retried = await requestTitle(true) + if (!retried || !isTitleLanguageCompatible(retried, languagePreference)) { + return null + } + return retried +} + +function inferLanguageFromText(text: string): string | null { + const clean = cleanSessionTitleSource(text) + if (!clean) return null + + if (countMatches(clean, /\p{Script=Hiragana}|\p{Script=Katakana}/gu) >= 2) { + return 'Japanese' + } + if (countMatches(clean, /\p{Script=Hangul}/gu) >= 2) { + return 'Korean' + } + if (countMatches(clean, /\p{Script=Han}/gu) >= 2) { + return 'Chinese' + } + + const latinWords = clean.match(/[A-Za-z]{2,}/g) ?? [] + const latinLength = latinWords.join('').length + if (latinWords.length >= 2 || latinLength >= 6) { + return 'English' + } + + return null +} + +function normalizeResponseLanguage(language: string | undefined): string | null { + const clean = language?.trim() + if (!clean) return null + + const lower = clean.toLowerCase() + if (lower === 'english' || lower === 'en' || lower.startsWith('en-')) { + return 'English' + } + if ( + lower === 'chinese' || + lower === 'zh' || + lower.startsWith('zh-') || + lower === '中文' + ) { + return 'Chinese' + } + if (lower === 'japanese' || lower === 'ja' || lower.startsWith('ja-')) { + return 'Japanese' + } + if (lower === 'korean' || lower === 'ko' || lower.startsWith('ko-')) { + return 'Korean' + } + return clean +} + +function isTitleLanguageCompatible( + title: string, + languagePreference?: TitleLanguagePreference | null, +): boolean { + if (!languagePreference) return true + + const clean = cleanSessionTitleSource(title) + const hasHan = /\p{Script=Han}/u.test(clean) + const hasKana = /\p{Script=Hiragana}|\p{Script=Katakana}/u.test(clean) + const hasHangul = /\p{Script=Hangul}/u.test(clean) + const hasLatinWord = /[A-Za-z]{2,}/.test(clean) + + switch (languagePreference.language.toLowerCase()) { + case 'chinese': + return hasHan || !hasLatinWord + case 'japanese': + return hasKana || hasHan || !hasLatinWord + case 'korean': + return hasHangul || !hasLatinWord + case 'english': + return hasLatinWord || !(hasHan || hasKana || hasHangul) + default: + return true + } +} + +function countMatches(text: string, pattern: RegExp): number { + return text.match(pattern)?.length ?? 0 } export function parseGeneratedTitleText(text: string): string | null { diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 2396793d..02bc6760 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -23,6 +23,7 @@ import { buildConversationTitleInput, deriveTitle, generateTitle, + resolveTitleLanguagePreference, saveAiTitle, type TitleConversationTurn, } from '../services/titleService.js' @@ -835,7 +836,16 @@ function triggerTitleGeneration( void (async () => { try { - const aiTitle = await generateTitle(text, runtimeProviderId) + const responseLanguage = await getResponseLanguageSetting() + const titleLanguagePreference = resolveTitleLanguagePreference( + state.firstUserMessage, + responseLanguage, + ) + const aiTitle = await generateTitle( + text, + runtimeProviderId, + titleLanguagePreference, + ) if (generationSeq !== state.generationSeq) return if (aiTitle) { const saved = await saveAiTitle(sessionId, aiTitle) @@ -851,6 +861,13 @@ function triggerTitleGeneration( })() } +async function getResponseLanguageSetting(): Promise { + const userSettings = await settingsService.getUserSettings().catch(() => ({})) + return typeof userSettings.language === 'string' + ? userSettings.language + : undefined +} + function sendSessionTitleUpdated( fallbackWs: ServerWebSocket, sessionId: string,