| |
| |
| |
| |
| |
| |
| |
|
|
| 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"}` |
|
|
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| export async function generateTitle( |
| conversationText: string, |
| providerId?: string | null, |
| ): Promise<string | null> { |
| 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<string | null> { |
| 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<string>([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<boolean> { |
| const settings = await new SettingsService().getUserSettings() |
| return settings.alwaysThinkingEnabled === false |
| } |
|
|
| |
| |
| |
| |
| |
| export async function saveAiTitle(sessionId: string, title: string): Promise<boolean> { |
| if (await sessionService.getCustomTitle(sessionId)) { |
| return false |
| } |
| await sessionService.appendAiTitle(sessionId, title) |
| return true |
| } |
|
|