cc-haha/src/utils/sessionTitle.ts
程序员阿江(Relakkes) be51b4cbc8 fix: generate session titles after assistant turns
Desktop title generation previously upgraded the first sidebar title from the raw user prompt before assistant output existed. That made image-plus-text sessions vulnerable to keeping a title based only on the user's initial wording. This moves polished title generation to completed turns, reuses the CLI title prompt and transcript extractor, and routes title calls through the configured haiku model with a retry when disabled thinking is rejected.

Constraint: The first user message still needs an immediate placeholder title so the sidebar is responsive.
Rejected: Generate the AI title before assistant output | image-derived context is only available after the assistant turn completes.
Confidence: high
Scope-risk: moderate
Directive: Keep polished automatic titles tied to completed transcript turns, not pre-response user prompts.
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 f89073ed-3467-492a-b63c-e1886c146a3a with image attachment wrote ai-title "Validate session title generation"
Not-tested: bun run check:server is blocked by existing expired quarantine manifest entries
2026-06-03 10:06:34 +08:00

142 lines
5.1 KiB
TypeScript

/**
* Session title generation via Haiku.
*
* Standalone module with minimal dependencies so it can be imported from
* print.ts (SDK control request handler) without pulling in the React/chalk/
* git dependency chain that teleport.tsx carries.
*
* This is the single source of truth for AI-generated session titles across
* all surfaces. Previously there were separate Haiku title generators:
* - teleport.tsx generateTitleAndBranch (6-word title + branch for CCR)
* - rename/generateSessionName.ts (kebab-case name for /rename)
* Each remains for backwards compat; new callers should use this module.
*/
import { z } from 'zod/v4'
import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { logEvent } from '../services/analytics/index.js'
import { queryHaiku } from '../services/api/claude.js'
import type { Message } from '../types/message.js'
import { logForDebugging } from './debug.js'
import { safeParseJSON } from './json.js'
import { lazySchema } from './lazySchema.js'
import { extractTextContent } from './messages.js'
import { cleanSessionTitleSource, hasSessionTitleMarkup } from './sessionTitleText.js'
import { asSystemPrompt } from './systemPromptType.js'
const MAX_CONVERSATION_TEXT = 1000
const MAX_GENERATED_TITLE_LENGTH = 80
/**
* Flatten a message array into a single text string for Haiku title input.
* Skips meta/non-human messages. Tail-slices to the last 1000 chars so
* recent context wins when the conversation is long.
*/
export function extractConversationText(messages: Message[]): string {
const parts: string[] = []
for (const msg of messages) {
if (msg.type !== 'user' && msg.type !== 'assistant') continue
if ('isMeta' in msg && msg.isMeta) continue
if ('origin' in msg && msg.origin && msg.origin.kind !== 'human') continue
const content = msg.message.content
if (typeof content === 'string') {
parts.push(content)
} else if (Array.isArray(content)) {
for (const block of content) {
if ('type' in block && block.type === 'text' && 'text' in block) {
parts.push(block.text as string)
}
}
}
}
const text = parts.join('\n')
return text.length > MAX_CONVERSATION_TEXT
? text.slice(-MAX_CONVERSATION_TEXT)
: text
}
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.
Good examples:
{"title": "Fix login button on mobile"}
{"title": "Add OAuth authentication"}
{"title": "Debug failing CI tests"}
{"title": "Refactor API client error handling"}
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"}`
const titleSchema = lazySchema(() => z.object({ title: z.string() }))
export function normalizeGeneratedSessionTitle(title: string): string | null {
const cleaned = cleanSessionTitleSource(title)
if (!cleaned) return null
if (cleaned.length > MAX_GENERATED_TITLE_LENGTH) return null
if (hasSessionTitleMarkup(cleaned)) return null
return cleaned
}
/**
* Generate a sentence-case session title from a description or first message.
* Returns null on error or if Haiku returns an unparseable response.
*
* @param description - The user's first message or a description of the session
* @param signal - Abort signal for cancellation
*/
export async function generateSessionTitle(
description: string,
signal: AbortSignal,
): Promise<string | null> {
const trimmed = cleanSessionTitleSource(description)
if (!trimmed) return null
try {
const result = await queryHaiku({
systemPrompt: asSystemPrompt([SESSION_TITLE_PROMPT]),
userPrompt: trimmed,
outputFormat: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
title: { type: 'string' },
},
required: ['title'],
additionalProperties: false,
},
},
signal,
options: {
querySource: 'generate_session_title',
agents: [],
// Reflect the actual session mode — this module is called from
// both the SDK print path (non-interactive) and the CCR remote
// session path via useRemoteSession (interactive).
isNonInteractiveSession: getIsNonInteractiveSession(),
hasAppendSystemPrompt: false,
mcpTools: [],
},
})
const text = extractTextContent(result.message.content)
const parsed = titleSchema().safeParse(safeParseJSON(text))
const title = parsed.success
? normalizeGeneratedSessionTitle(parsed.data.title)
: null
logEvent('tengu_session_title_generated', { success: title !== null })
return title
} catch (error) {
logForDebugging(`generateSessionTitle failed: ${error}`, {
level: 'error',
})
logEvent('tengu_session_title_generated', { success: false })
return null
}
}