/** * 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 { SettingsService } from './settingsService.js' import { sessionService } from './sessionService.js' import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' 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' const TITLE_MAX_LEN = 50 const TITLE_MAX_OUTPUT_TOKENS = 100 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 = cleanSessionTitleSource(raw) 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 session's provider Haiku model when possible. * Fire-and-forget — returns null on any failure. */ export async function generateTitle( conversationText: string, providerId?: string | null, ): Promise { const trimmed = cleanSessionTitleSource(conversationText) if (!trimmed) return null try { const providerService = new ProviderService() if (providerId === null) return null let resolvedProvider = providerId ? await providerService.getProvider(providerId) : null if (!resolvedProvider) { const { activeId, providers } = await providerService.listProviders() resolvedProvider = activeId ? isOpenAIOfficialProviderId(activeId) ? await providerService.getProvider(activeId) : providers.find((provider) => provider.id === activeId) ?? null : null } if (resolvedProvider && isOpenAIOfficialProviderId(resolvedProvider.id)) { return await generateOpenAIOfficialTitle( trimmed, resolvedProvider.models.haiku || resolvedProvider.models.main, ) } if (!resolvedProvider?.baseUrl || !resolvedProvider?.apiKey) return null const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` const shouldDisableThinking = await shouldDisableThinkingForTitle() const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': resolvedProvider.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) }], ...(shouldDisableThinking && { thinking: { type: 'disabled' } }), }), 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 return parseGeneratedTitleText(text) } catch { return null } } async function generateOpenAIOfficialTitle( trimmed: string, model: string, ): Promise { const tokens = await hahaOpenAIOAuthService.ensureFreshTokens() if (!tokens?.accessToken) return null const mappedModel = resolveOpenAICodexModel(model) const requestBody = anthropicToOpenaiResponses({ model: mappedModel, max_tokens: TITLE_MAX_OUTPUT_TOKENS, system: TITLE_SYSTEM_PROMPT, messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], stream: true, thinking: { type: 'disabled' }, }) requestBody.stream = true requestBody.max_output_tokens = TITLE_MAX_OUTPUT_TOKENS const headers = new Headers() headers.set('Content-Type', 'application/json') headers.set('Authorization', `Bearer ${tokens.accessToken}`) if (tokens.accountId) { headers.set('ChatGPT-Account-Id', tokens.accountId) } const response = await fetch(OPENAI_CODEX_API_ENDPOINT, { method: 'POST', headers, body: JSON.stringify(requestBody), signal: AbortSignal.timeout(15_000), }) if (!response.ok || !response.body) return null const body = await openaiResponsesStreamToAnthropicResponse( response.body, mappedModel, ) const text = body.content.find((b) => b.type === 'text')?.text if (!text) return null return parseGeneratedTitleText(text) } export function parseGeneratedTitleText(text: string): string | null { const trimmed = text.trim() if (!trimmed) return null const parsed = parseTitleFromStructuredText(trimmed) if (parsed) return normalizeTitle(parsed) if (looksLikeStructuredTitleFragment(trimmed)) return null return normalizeTitle(trimmed) } function parseTitleFromStructuredText(text: string): string | null { const candidates = new Set([text]) const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim() if (fenced) candidates.add(fenced) const firstBrace = text.indexOf('{') const lastBrace = text.lastIndexOf('}') if (firstBrace >= 0 && lastBrace > firstBrace) { candidates.add(text.slice(firstBrace, lastBrace + 1)) } for (const candidate of [...candidates]) { const unescaped = candidate.replace(/\\"/g, '"').replace(/\\n/g, '\n') if (unescaped !== candidate) candidates.add(unescaped) } for (const candidate of candidates) { const title = parseTitleJson(candidate) if (title) return title } return null } function parseTitleJson(candidate: string): string | null { try { const parsed = JSON.parse(candidate) if (typeof parsed === 'string') { return parseTitleFromStructuredText(parsed) } if (parsed && typeof parsed === 'object' && typeof (parsed as { title?: unknown }).title === 'string') { return (parsed as { title: string }).title } } catch { return null } return null } function normalizeTitle(title: string): string | null { const clean = cleanSessionTitleSource(title) if ( !clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean) || hasSessionTitleMarkup(clean) ) return null return clean } function looksLikeStructuredTitleFragment(text: string): boolean { return ( text.includes('```') || text.includes('{') || text.includes('}') || /\\?"title\\?"\s*:/.test(text) ) } 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 * intentional and must not be replaced by automatic title refreshes. */ export async function saveAiTitle(sessionId: string, title: string): Promise { if (await sessionService.getCustomTitle(sessionId)) { return false } await sessionService.appendAiTitle(sessionId, title) return true }