mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
be51b4cbc8
commit
f060d20b3b
@ -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) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user