mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
2523ce0cd7
commit
12cc872150
@ -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)) {
|
||||
|
||||
@ -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 }),
|
||||
}))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
104
src/server/services/titleService.ts
Normal file
104
src/server/services/titleService.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user