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,