cc-haha/src/server/services/titleService.ts
程序员阿江(Relakkes) a159ee3511 fix: stream ChatGPT OAuth title requests
ChatGPT Codex requires streamed Responses API requests, including hidden non-chat paths such as title generation. Keep the OAuth adapter streaming upstream while preserving JSON responses for non-streaming Anthropic callers, and route ChatGPT Official titles through the OAuth Codex endpoint.

Constraint: ChatGPT Codex rejects non-streaming requests with HTTP 400
Rejected: Disable AI title generation for ChatGPT OAuth | would leave first-session metadata degraded
Confidence: high
Scope-risk: narrow
Tested: bun test src/services/openaiAuth/fetch.test.ts
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun run check:server
Tested: git diff --check
Not-tested: Live ChatGPT OAuth account round trip after remote build
2026-05-21 15:31:56 +08:00

256 lines
8.3 KiB
TypeScript

/**
* 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<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
}
/**
* 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<boolean> {
if (await sessionService.getCustomTitle(sessionId)) {
return false
}
await sessionService.appendAiTitle(sessionId, title)
return true
}