fix: keep generated session titles in user language

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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 11:22:08 +08:00
parent be51b4cbc8
commit f060d20b3b
4 changed files with 334 additions and 60 deletions

View File

@ -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) {

View File

@ -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')

View File

@ -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,
'',
'<conversation>',
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<string | null> {
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<string | null> {
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<string, string>,
requestBody: Record<string, unknown>,
): Promise<string | null> {
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<string | null>,
languagePreference?: TitleLanguagePreference | null,
): Promise<string | null> {
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 {

View File

@ -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<string | undefined> {
const userSettings = await settingsService.getUserSettings().catch(() => ({}))
return typeof userSettings.language === 'string'
? userSettings.language
: undefined
}
function sendSessionTitleUpdated(
fallbackWs: ServerWebSocket<WebSocketData>,
sessionId: string,