diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 9a231652..8366a152 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -1185,6 +1185,105 @@ describe('WebSocket Chat Integration', () => { expect(titleIndex).toBeLessThan(completionIndex) }) + it('refreshes the first-turn AI title from the completed assistant transcript', async () => { + const providerConfigPath = path.join(tmpDir, 'cc-haha', 'providers.json') + const originalProviderConfig = await fs.readFile(providerConfigPath, 'utf-8').catch(() => null) + const upstreamInputs: string[] = [] + const titleModelServer = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const body = await req.json() as { + messages?: Array<{ content?: unknown }> + } + const input = String(body.messages?.[0]?.content ?? '') + upstreamInputs.push(input) + const title = input.includes('Echo: 看一下这个搜索结果') + ? 'Google 搜索企查查结果' + : 'Premature user title' + return Response.json({ + content: [{ type: 'text', text: JSON.stringify({ title }) }], + }) + }, + }) + + try { + await fs.mkdir(path.dirname(providerConfigPath), { recursive: true }) + await fs.writeFile( + providerConfigPath, + JSON.stringify({ + activeId: 'title-transcript-provider', + providers: [ + { + id: 'title-transcript-provider', + presetId: 'minimax', + name: 'Title Transcript Provider', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${titleModelServer.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'minimax-main', + haiku: 'minimax-haiku', + sonnet: 'minimax-main', + opus: 'minimax-main', + }, + }, + ], + }, null, 2), + 'utf-8', + ) + + const sessionId = `title-transcript-${crypto.randomUUID()}` + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for transcript-backed session title')) + }, 8000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + if (msg.type === 'connected') { + ws.send(JSON.stringify({ + type: 'user_message', + content: '看一下这个搜索结果,请一条一条给我列出来', + })) + return + } + if (msg.type === 'session_title_updated' && msg.title === 'Google 搜索企查查结果') { + clearTimeout(timeout) + ws.close() + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for title transcript session ${sessionId}`)) + } + }) + + const titleMessages = messages.filter((msg) => msg.type === 'session_title_updated') + expect(titleMessages[0]?.title).toBe('看一下这个搜索结果,请一条一条给我列出来') + expect(titleMessages.map((msg) => msg.title)).toContain('Google 搜索企查查结果') + expect(upstreamInputs.some((input) => input.includes('Echo: 看一下这个搜索结果'))).toBe(true) + } finally { + titleModelServer.stop(true) + if (originalProviderConfig === null) { + await fs.rm(providerConfigPath, { force: true }) + } else { + await fs.writeFile(providerConfigPath, originalProviderConfig, 'utf-8') + } + } + }, 10000) + it('uses the /goal objective for the derived session title', async () => { const sessionId = `title-goal-${crypto.randomUUID()}` const messages: any[] = [] diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index 541c2389..da9d3800 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -27,7 +27,7 @@ describe('titleService', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - test('sends disabled thinking for opted-in providers when desktop thinking is off', async () => { + test('sends disabled thinking for title generation by default', async () => { let requestBody: Record | null = null const server = Bun.serve({ hostname: '127.0.0.1', @@ -43,10 +43,6 @@ describe('titleService', () => { try { const providerId = 'zhipu-test' await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) - await fs.writeFile( - path.join(tmpDir, 'settings.json'), - JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), - ) await fs.writeFile( path.join(tmpDir, 'cc-haha', 'providers.json'), JSON.stringify({ @@ -77,7 +73,59 @@ describe('titleService', () => { } }) - test('sends disabled thinking for DeepSeek title generation when desktop thinking is off', async () => { + test('retries title generation without thinking when the provider rejects it', async () => { + const requestBodies: Array> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const requestBody = await req.json() as Record + requestBodies.push(requestBody) + if (requestBody.thinking) { + return Response.json({ error: 'thinking is not supported' }, { status: 400 }) + } + return Response.json({ + content: [{ type: 'text', text: '{"title":"Trace ok"}' }], + }) + }, + }) + + try { + const providerId = 'fallback-thinking-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: 'custom', + name: 'Thinking Fallback', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${server.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'fallback-main', + haiku: 'fallback-haiku', + sonnet: 'fallback-main', + opus: 'fallback-main', + }, + }, + ], + }, null, 2), + ) + + await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok') + expect(requestBodies).toHaveLength(2) + expect(requestBodies[0].thinking).toEqual({ type: 'disabled' }) + expect(requestBodies[1].thinking).toBeUndefined() + } finally { + server.stop(true) + } + }) + + test('sends disabled thinking for DeepSeek title generation', async () => { let requestBody: Record | null = null const server = Bun.serve({ hostname: '127.0.0.1', @@ -93,10 +141,6 @@ describe('titleService', () => { try { const providerId = 'deepseek-test' await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) - await fs.writeFile( - path.join(tmpDir, 'settings.json'), - JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), - ) await fs.writeFile( path.join(tmpDir, 'cc-haha', 'providers.json'), JSON.stringify({ @@ -186,7 +230,10 @@ describe('titleService', () => { '@website 重新设计首页', ].join('\n'))).resolves.toBe('Redesign website') - expect(requestBody?.messages?.[0]?.content).toBe('/frontend-design @website 重新设计首页') + const titlePrompt = String(requestBody?.messages?.[0]?.content ?? '') + expect(titlePrompt).toContain('/frontend-design @website 重新设计首页') + expect(titlePrompt).toContain('') + expect(titlePrompt).not.toContain('') } finally { server.stop(true) } diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index a815f9af..6947dd1b 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -7,7 +7,6 @@ */ import { ProviderService } from './providerService.js' -import { SettingsService } from './settingsService.js' import { sessionService } from './sessionService.js' import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' @@ -16,23 +15,49 @@ import { resolveOpenAICodexModel } from '../../services/openaiAuth/models.js' import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js' import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js' +import { extractConversationText, SESSION_TITLE_PROMPT } from '../../utils/sessionTitle.js' const TITLE_MAX_LEN = 50 const TITLE_MAX_OUTPUT_TOKENS = 100 +const TITLE_INPUT_MAX_LEN = 2000 -const TITLE_SYSTEM_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. +export type TitleConversationTurn = { + userText: string + assistantText?: string +} -Return JSON with a single "title" field. +export function buildConversationTitleInput(turns: TitleConversationTurn[]): string { + const messages = turns.flatMap((turn) => { + const entries: any[] = [ + { + type: 'user', + message: { content: turn.userText }, + }, + ] + const assistantText = turn.assistantText?.trim() + if (assistantText) { + entries.push({ + type: 'assistant', + message: { content: assistantText }, + }) + } + return entries + }) -Good examples: -{"title": "Fix login button on mobile"} -{"title": "Add OAuth authentication"} -{"title": "Debug failing CI tests"} -{"title": "Refactor API client error handling"} + return extractConversationText(messages as any) +} -Bad (too vague): {"title": "Code changes"} -Bad (too long): {"title": "Investigate and fix the issue where the login button does not respond on mobile devices"} -Bad (wrong case): {"title": "Fix Login Button On Mobile"}` +function buildTitleUserPrompt(trimmed: string): string { + 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.', + '', + '', + trimmed.slice(0, TITLE_INPUT_MAX_LEN), + '', + ].join('\n') +} /** * Quick placeholder title derived from user message text. @@ -87,25 +112,38 @@ export async function generateTitle( const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` - const shouldDisableThinking = await shouldDisableThinkingForTitle() + const userPrompt = buildTitleUserPrompt(trimmed) + const requestHeaders = { + 'Content-Type': 'application/json', + 'x-api-key': resolvedProvider.apiKey, + 'anthropic-version': '2023-06-01', + } + const requestBody = { + model, + max_tokens: TITLE_MAX_OUTPUT_TOKENS, + system: SESSION_TITLE_PROMPT, + messages: [{ role: 'user', content: userPrompt }], + } - const response = await fetch(url, { + let response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': resolvedProvider.apiKey, - 'anthropic-version': '2023-06-01', - }, + headers: requestHeaders, body: JSON.stringify({ - model, - max_tokens: 100, - system: TITLE_SYSTEM_PROMPT, - messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], - ...(shouldDisableThinking && { thinking: { type: 'disabled' } }), + ...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 { @@ -131,8 +169,8 @@ async function generateOpenAIOfficialTitle( const requestBody = anthropicToOpenaiResponses({ model: mappedModel, max_tokens: TITLE_MAX_OUTPUT_TOKENS, - system: TITLE_SYSTEM_PROMPT, - messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], + system: SESSION_TITLE_PROMPT, + messages: [{ role: 'user', content: buildTitleUserPrompt(trimmed) }], stream: true, thinking: { type: 'disabled' }, }) @@ -236,11 +274,6 @@ function looksLikeStructuredTitleFragment(text: string): boolean { ) } -async function shouldDisableThinkingForTitle(): Promise { - const settings = await new SettingsService().getUserSettings() - return settings.alwaysThinkingEnabled === false -} - /** * Persist an AI-generated title to the session's JSONL file. * Returns false when a user custom title exists, because custom titles are diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 6abb0107..2396793d 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -19,7 +19,13 @@ import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js' import { diagnosticsService } from '../services/diagnosticsService.js' -import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' +import { + buildConversationTitleInput, + deriveTitle, + generateTitle, + saveAiTitle, + type TitleConversationTurn, +} from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { COMMAND_NAME_TAG, @@ -63,8 +69,10 @@ const sessionTitleState = new Map + completedTurns: TitleConversationTurn[] + activeTurn?: TitleConversationTurn & { count: number } + startedGenerationKeys: Set + generationSeq: number }>() const runtimeOverrides = new Map(), + completedTurns: [], + startedGenerationKeys: new Set(), + generationSeq: 0, } sessionTitleState.set(sessionId, titleState) } const titleInput = getTitleInputForUserMessage(message.content, desktopSlashCommand) + let titleTurnNumber: number | null = null if (titleInput) { titleState.userMessageCount++ - titleState.allUserMessages.push(titleInput) + titleTurnNumber = titleState.userMessageCount + titleState.activeTurn = { + count: titleTurnNumber, + userText: titleInput, + assistantText: '', + } if (titleState.userMessageCount === 1) { titleState.firstUserMessage = titleInput } - triggerTitleGeneration(ws, sessionId) + triggerTitleGeneration(ws, sessionId, 'user-message') } // 启动 CLI 子进程(如果还没有) @@ -359,6 +374,9 @@ async function handleUserMessage( let userMessageSent = false const shouldForwardCurrentTurnLocalCommand = createCurrentTurnLocalCommandForwarder(desktopSlashCommand) + const removeTitleOutputCallback = titleTurnNumber === null + ? null + : bindTitleSessionOutput(ws, sessionId, () => userMessageSent) bindAllClientSessionOutputs(sessionId, { shouldForward: (cliMsg) => { @@ -375,6 +393,8 @@ async function handleUserMessage( message.attachments ) if (!sent) { + removeTitleOutputCallback?.() + discardActiveTitleTurn(sessionId, titleTurnNumber) sendMessage(ws, { type: 'error', message: 'CLI process is not running. The session may have ended or the process crashed.', @@ -763,27 +783,30 @@ function handleStopGeneration(ws: ServerWebSocket) { // Title generation // ============================================================================ -function triggerTitleGeneration(ws: ServerWebSocket, sessionId: string): void { +type TitleGenerationPhase = 'user-message' | 'turn-complete' + +function triggerTitleGeneration( + ws: ServerWebSocket, + sessionId: string, + phase: TitleGenerationPhase, + completedTurnCount?: number, +): void { const state = sessionTitleState.get(sessionId) if (!state || state.hasCustomTitle) return - const count = state.userMessageCount + const count = phase === 'turn-complete' + ? completedTurnCount ?? state.userMessageCount + : state.userMessageCount - // Generate on count 1 (first response) and count 3 (with more context) - if (count !== 1 && count !== 3) return - if (state.startedGenerationCounts.has(count)) return - state.startedGenerationCounts.add(count) + if (phase === 'user-message') { + if (count !== 1) return + const key = 'placeholder:1' + if (state.startedGenerationKeys.has(key)) return + state.startedGenerationKeys.add(key) - const text = count === 1 - ? state.firstUserMessage - : state.allUserMessages.join('\n') - const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId - - // Fire-and-forget: derive quick title, then upgrade with AI - void (async () => { - try { - // Stage 1: quick placeholder (only on first message) - if (count === 1) { + void (async () => { + try { + const text = state.firstUserMessage const placeholder = deriveTitle(text) if (placeholder) { const saved = await saveAiTitle(sessionId, placeholder) @@ -791,19 +814,36 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s state.hasCustomTitle = true return } - sendMessage(ws, { type: 'session_title_updated', sessionId, title: placeholder }) + sendSessionTitleUpdated(ws, sessionId, placeholder) } + } catch (err) { + console.error(`[Title] Failed to derive title for ${sessionId}:`, err) } + })() + return + } - // Stage 2: AI-generated title + // Generate polished titles after assistant output completes on turn 1 and 3. + if (count !== 1 && count !== 3) return + const key = `complete:${count}` + if (state.startedGenerationKeys.has(key)) return + state.startedGenerationKeys.add(key) + + const text = buildConversationTitleInput(state.completedTurns) + const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId + const generationSeq = ++state.generationSeq + + void (async () => { + try { const aiTitle = await generateTitle(text, runtimeProviderId) + if (generationSeq !== state.generationSeq) return if (aiTitle) { const saved = await saveAiTitle(sessionId, aiTitle) if (!saved) { state.hasCustomTitle = true return } - sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle }) + sendSessionTitleUpdated(ws, sessionId, aiTitle) } } catch (err) { console.error(`[Title] Failed to generate title for ${sessionId}:`, err) @@ -811,6 +851,127 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s })() } +function sendSessionTitleUpdated( + fallbackWs: ServerWebSocket, + sessionId: string, + title: string, +): void { + const payload: ServerMessage = { type: 'session_title_updated', sessionId, title } + const clients = activeSessions.get(sessionId) + if (!clients?.size) { + sendMessage(fallbackWs, payload) + return + } + for (const client of clients) { + sendMessage(client, payload) + } +} + +function bindTitleSessionOutput( + ws: ServerWebSocket, + sessionId: string, + shouldProcess: () => boolean, +): () => void { + const callback = (cliMsg: any) => { + if (!shouldProcess() && !(cliMsg?.type === 'result' && cliMsg?.is_error)) { + return + } + + appendAssistantTextForTitle(sessionId, cliMsg) + + if (cliMsg?.type === 'result') { + conversationService.removeOutputCallback(sessionId, callback) + const completedTurnCount = completeActiveTitleTurn(sessionId) + if (!cliMsg.is_error) { + triggerTitleGeneration(ws, sessionId, 'turn-complete', completedTurnCount ?? undefined) + } + } + } + + conversationService.onOutput(sessionId, callback) + return () => conversationService.removeOutputCallback(sessionId, callback) +} + +function appendAssistantTextForTitle(sessionId: string, cliMsg: any): void { + const activeTurn = sessionTitleState.get(sessionId)?.activeTurn + if (!activeTurn) return + + const streamText = extractAssistantStreamTextForTitle(cliMsg) + if (streamText) { + activeTurn.assistantText = `${activeTurn.assistantText ?? ''}${streamText}` + return + } + + const assistantText = extractAssistantMessageTextForTitle(cliMsg) + if (assistantText) { + activeTurn.assistantText = activeTurn.assistantText + ? `${activeTurn.assistantText}\n${assistantText}` + : assistantText + return + } + + if ( + cliMsg?.type === 'result' && + !cliMsg.is_error && + !activeTurn.assistantText && + typeof cliMsg.result === 'string' + ) { + activeTurn.assistantText = cliMsg.result + } +} + +function extractAssistantStreamTextForTitle(cliMsg: any): string | null { + const event = cliMsg?.event + if ( + cliMsg?.type !== 'stream_event' || + event?.type !== 'content_block_delta' || + event.delta?.type !== 'text_delta' || + typeof event.delta.text !== 'string' + ) { + return null + } + return event.delta.text +} + +function extractAssistantMessageTextForTitle(cliMsg: any): string | null { + if (cliMsg?.type !== 'assistant') return null + const content = cliMsg.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + const text = content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const typedBlock = block as { type?: unknown; text?: unknown } + return typedBlock.type === 'text' && typeof typedBlock.text === 'string' + ? [typedBlock.text] + : [] + }) + .join('\n') + .trim() + return text || null +} + +function completeActiveTitleTurn(sessionId: string): number | null { + const state = sessionTitleState.get(sessionId) + const activeTurn = state?.activeTurn + if (!state || !activeTurn) return null + + state.completedTurns.push({ + userText: activeTurn.userText, + assistantText: activeTurn.assistantText?.trim(), + }) + state.activeTurn = undefined + return activeTurn.count +} + +function discardActiveTitleTurn(sessionId: string, count: number | null): void { + if (count === null) return + const state = sessionTitleState.get(sessionId) + if (state?.activeTurn?.count === count) { + state.activeTurn = undefined + } +} + // ============================================================================ // CLI message translation // ============================================================================ @@ -1889,9 +2050,6 @@ function bindClientSessionOutput( sendMessage(ws, msg) } - if (cliMsg.type === 'result') { - triggerTitleGeneration(ws, sessionId) - } } clientOutputCallbacks.set(ws, { sessionId, callback }) diff --git a/src/utils/sessionTitle.ts b/src/utils/sessionTitle.ts index f97cae3c..013706cb 100644 --- a/src/utils/sessionTitle.ts +++ b/src/utils/sessionTitle.ts @@ -55,7 +55,7 @@ export function extractConversationText(messages: Message[]): string { : text } -const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. +export const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. Return JSON with a single "title" field.