feat: auto-generate session titles using AI (Haiku model)

After the first assistant response, derive a quick placeholder title from
the user message, then asynchronously call the provider's Haiku model to
generate a polished 3-7 word title. Titles update again at message 3 with
fuller conversation context. Updates push to frontend in real-time via
WebSocket `session_title_updated` event.

- extractTitle now reads `ai-title` JSONL entries (priority: custom > ai > first message)
- New titleService with deriveTitle + generateTitle using active provider config
- Handler tracks per-session message count and triggers generation on result
- Frontend sessionStore receives live title updates for sidebar + header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-08 20:48:48 +08:00
parent 2523ce0cd7
commit 12cc872150
7 changed files with 213 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import { create } from 'zustand'
import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore'
import { useSessionStore } from './sessionStore'
import { useCLITaskStore } from './cliTaskStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session'
@ -439,6 +440,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'task_update':
break
case 'session_title_updated':
useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
break
case 'system_notification':
// Cache slash commands from CLI init
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {

View File

@ -14,6 +14,7 @@ type SessionStore = {
createSession: (workDir?: string) => Promise<string>
deleteSession: (id: string) => Promise<void>
renameSession: (id: string, title: string) => Promise<void>
updateSessionTitle: (id: string, title: string) => void
setActiveSession: (id: string | null) => void
setSelectedProjects: (projects: string[]) => void
}
@ -71,6 +72,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
}))
},
updateSessionTitle: (id, title) => {
set((s) => ({
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, title } : session,
),
}))
},
setActiveSession: (id) => set({ activeSessionId: id }),
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
}))

View File

@ -45,6 +45,7 @@ export type ServerMessage =
| { type: 'team_created'; teamName: string }
| { type: 'team_deleted'; teamName: string }
| { type: 'task_update'; taskId: string; status: string; progress?: string }
| { type: 'session_title_updated'; sessionId: string; title: string }
export type TokenUsage = {
input_tokens: number

View File

@ -202,7 +202,7 @@ export class SessionService {
// --------------------------------------------------------------------------
private extractTitle(entries: RawEntry[]): string {
// 1. Look for custom title entry (appended by renameSession)
// 1. Look for custom title entry (appended by renameSession) — highest priority
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'custom-title' && e.customTitle) {
@ -210,7 +210,15 @@ export class SessionService {
}
}
// 2. Look for first non-meta user message as title
// 2. Look for AI-generated title (written by titleService)
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'ai-title' && e.aiTitle) {
return e.aiTitle as string
}
}
// 3. Look for first non-meta user message as title
for (const e of entries) {
if (e.type === 'user' && !e.isMeta && e.message?.role === 'user') {
const content = e.message.content
@ -218,7 +226,6 @@ export class SessionService {
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
// Extract text from content blocks: [{ type: 'text', text: '...' }]
const textBlock = content.find(
(block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string'
)
@ -549,6 +556,20 @@ export class SessionService {
await this.appendJsonlEntry(found.filePath, entry)
}
/**
* Append an AI-generated title entry to a session's JSONL file.
*/
async appendAiTitle(sessionId: string, title: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
await this.appendJsonlEntry(found.filePath, {
type: 'ai-title',
aiTitle: title,
timestamp: new Date().toISOString(),
})
}
/**
* Get the actual working directory for a session.
* First checks for stored session-meta entry, then falls back to desanitizePath.

View File

@ -0,0 +1,104 @@
/**
* Title Service AI-powered session title generation
*
* Two-stage approach matching the CLI:
* 1. deriveTitle() instant placeholder from first user message
* 2. generateTitle() async Haiku call for a polished 3-7 word title
*/
import { ProviderService } from './providerService.js'
import { sessionService } from './sessionService.js'
const TITLE_MAX_LEN = 50
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.
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"}`
/**
* Quick placeholder title derived from user message text.
* Returns first sentence, collapsed to single line, max 50 chars.
*/
export function deriveTitle(raw: string): string | undefined {
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
const firstSentence = /^(.*?[.!?。!?])\s/.exec(clean)?.[1] ?? clean
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return undefined
return flat.length > TITLE_MAX_LEN
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
: flat
}
/**
* Generate an AI title using the active provider's Haiku model.
* Fire-and-forget returns null on any failure.
*/
export async function generateTitle(conversationText: string): Promise<string | null> {
const trimmed = conversationText.trim()
if (!trimmed) return null
try {
const providerService = new ProviderService()
const { activeId, providers } = await providerService.listProviders()
if (!activeId) return null
const provider = providers.find((p) => p.id === activeId)
if (!provider?.baseUrl || !provider?.apiKey) return null
const model = provider.models.haiku || provider.models.main
const url = `${provider.baseUrl.replace(/\/+$/, '')}/v1/messages`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': provider.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model,
max_tokens: 100,
system: TITLE_SYSTEM_PROMPT,
messages: [{ role: 'user', content: trimmed.slice(0, 2000) }],
}),
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
// Parse JSON response
const match = text.match(/\{[^}]*"title"\s*:\s*"([^"]+)"[^}]*\}/)
if (match?.[1]) return match[1].trim()
// Fallback: if model returned plain text instead of JSON
const plain = text.trim()
if (plain.length > 0 && plain.length <= 60) return plain
return null
} catch {
return null
}
}
/**
* Persist an AI-generated title to the session's JSONL file.
*/
export async function saveAiTitle(sessionId: string, title: string): Promise<void> {
await sessionService.appendAiTitle(sessionId, title)
}

View File

@ -44,6 +44,7 @@ export type ServerMessage =
| { type: 'team_created'; teamName: string }
| { type: 'team_deleted'; teamName: string }
| { type: 'task_update'; taskId: string; status: string; progress?: string }
| { type: 'session_title_updated'; sessionId: string; title: string }
export type TokenUsage = {
input_tokens: number

View File

@ -15,6 +15,7 @@ import {
import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
const settingsService = new SettingsService()
const providerService = new ProviderService()
@ -24,6 +25,16 @@ const providerService = new ProviderService()
*/
const sessionSlashCommands = new Map<string, Array<{ name: string; description: string }>>()
/**
* Track user message count and title state per session for auto-title generation.
*/
const sessionTitleState = new Map<string, {
userMessageCount: number
hasCustomTitle: boolean
firstUserMessage: string
allUserMessages: string[]
}>()
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
return sessionSlashCommands.get(sessionId) || []
}
@ -120,6 +131,7 @@ export const handleWebSocket = {
activeSessions.delete(sessionId)
cleanupStreamState(sessionId)
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
// NOTE: Do NOT stop CLI subprocess on WS disconnect.
// The CLI process should stay alive so reconnecting reuses it.
@ -177,6 +189,18 @@ async function handleUserMessage(
}
}
// Track user message for title generation
let titleState = sessionTitleState.get(sessionId)
if (!titleState) {
titleState = { userMessageCount: 0, hasCustomTitle: false, firstUserMessage: '', allUserMessages: [] }
sessionTitleState.set(sessionId, titleState)
}
titleState.userMessageCount++
titleState.allUserMessages.push(message.content)
if (titleState.userMessageCount === 1) {
titleState.firstUserMessage = message.content
}
// (Re-)register output callback — WS object may have changed on reconnect.
// Clear old callbacks and set the current one for this WS connection.
conversationService.clearOutputCallbacks(sessionId)
@ -185,6 +209,10 @@ async function handleUserMessage(
for (const msg of serverMsgs) {
sendMessage(ws, msg)
}
// Trigger title generation on message_complete
if (cliMsg.type === 'result') {
triggerTitleGeneration(ws, sessionId)
}
})
// 将用户消息写入 CLI stdin
@ -240,6 +268,47 @@ function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
sendMessage(ws, { type: 'status', state: 'idle' })
}
// ============================================================================
// Title generation
// ============================================================================
function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: string): void {
const state = sessionTitleState.get(sessionId)
if (!state || state.hasCustomTitle) return
const count = state.userMessageCount
// Generate on count 1 (first response) and count 3 (with more context)
if (count !== 1 && count !== 3) return
const text = count === 1
? state.firstUserMessage
: state.allUserMessages.join('\n')
// Fire-and-forget: derive quick title, then upgrade with AI
void (async () => {
try {
// Stage 1: quick placeholder (only on first message)
if (count === 1) {
const placeholder = deriveTitle(text)
if (placeholder) {
await saveAiTitle(sessionId, placeholder)
sendMessage(ws, { type: 'session_title_updated', sessionId, title: placeholder })
}
}
// Stage 2: AI-generated title
const aiTitle = await generateTitle(text)
if (aiTitle) {
await saveAiTitle(sessionId, aiTitle)
sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle })
}
} catch (err) {
console.error(`[Title] Failed to generate title for ${sessionId}:`, err)
}
})()
}
// ============================================================================
// CLI message translation
// ============================================================================