From 12cc8721503582b12009ea58053b0860f45a9e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 8 Apr 2026 20:48:48 +0800 Subject: [PATCH] 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) --- desktop/src/stores/chatStore.ts | 5 ++ desktop/src/stores/sessionStore.ts | 9 +++ desktop/src/types/chat.ts | 1 + src/server/services/sessionService.ts | 27 ++++++- src/server/services/titleService.ts | 104 ++++++++++++++++++++++++++ src/server/ws/events.ts | 1 + src/server/ws/handler.ts | 69 +++++++++++++++++ 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/server/services/titleService.ts diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index e381c6d1..abfc172f 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -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((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)) { diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index fe102e65..863cb2b8 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -14,6 +14,7 @@ type SessionStore = { createSession: (workDir?: string) => Promise deleteSession: (id: string) => Promise renameSession: (id: string, title: string) => Promise + updateSessionTitle: (id: string, title: string) => void setActiveSession: (id: string | null) => void setSelectedProjects: (projects: string[]) => void } @@ -71,6 +72,14 @@ export const useSessionStore = create((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 }), })) diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 8fb8d799..d01b2a82 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -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 diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 2ab9dedc..d308af57 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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) => 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 { + 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. diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts new file mode 100644 index 00000000..0b4c134f --- /dev/null +++ b/src/server/services/titleService.ts @@ -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 { + 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 { + await sessionService.appendAiTitle(sessionId, title) +} diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 4d21f0f8..93431d9f 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -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 diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 684a8eb6..83262a66 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -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>() +/** + * Track user message count and title state per session for auto-title generation. + */ +const sessionTitleState = new Map() + 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) { sendMessage(ws, { type: 'status', state: 'idle' }) } +// ============================================================================ +// Title generation +// ============================================================================ + +function triggerTitleGeneration(ws: ServerWebSocket, 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 // ============================================================================