diff --git a/.gitignore b/.gitignore index ed4133d..16e7675 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ craft-agents-oss what_are_humans_thinking # vendor binaries -vendor \ No newline at end of file +vendor + +# local OpenSpec artifacts +openspec/ diff --git a/apps/electron/src/main/lib/agent-service.ts b/apps/electron/src/main/lib/agent-service.ts index 7d3bd19..ed290e8 100644 --- a/apps/electron/src/main/lib/agent-service.ts +++ b/apps/electron/src/main/lib/agent-service.ts @@ -32,7 +32,7 @@ import { import { decryptApiKey, getChannelById, listChannels } from './channel-manager' import { getAdapter, - fetchTitle, + fetchTitleWithDiagnostics, } from '@proma/core' import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' @@ -47,6 +47,8 @@ import { askUserService } from './agent-ask-user-service' import { getWorkspacePermissionMode } from './agent-workspace-manager' import type { PermissionRequest, PromaPermissionMode, AskUserRequest } from '@proma/shared' import { SAFE_TOOLS } from '@proma/shared' +import { deriveAgentFallbackTitle, DEFAULT_AGENT_SESSION_TITLE, MAX_AGENT_TITLE_LENGTH, isDefaultAgentTitle } from './agent-title-utils' +import { sanitizeTitleCandidate } from './title-utils' /** 活跃的 AbortController 映射(sessionId → controller) */ const activeControllers = new Map() @@ -1326,30 +1328,91 @@ export async function runAgent( const TITLE_PROMPT = '根据用户的第一条消息,生成一个简短的对话标题(10字以内)。只输出标题,不要有任何其他内容、标点符号或引号。\n\n用户消息:' /** 标题最大长度 */ -const MAX_TITLE_LENGTH = 20 - -/** 默认会话标题(用于判断是否需要自动生成) */ -const DEFAULT_SESSION_TITLE = '新 Agent 会话' +const MAX_TITLE_LENGTH = MAX_AGENT_TITLE_LENGTH + +/** 短消息阈值:低于此长度直接使用原文作为标题 */ +const SHORT_MESSAGE_THRESHOLD = 4 + +type AgentTitleReason = + | 'title_generated_remote' + | 'title_generated_fallback' + | 'title_failed_parse' + | 'title_failed_request' + +function logAgentTitleEvent( + reason: AgentTitleReason, + context: { + sessionId?: string + channelId: string + modelId: string + provider?: string + detail?: string + status?: number | null + dataPreview?: string + }, +): void { + console.log('[agent_title_event]', { reason, ...context }) +} /** * 生成 Agent 会话标题 * * 使用 Provider 适配器系统,支持 Anthropic / OpenAI / Google 等所有渠道。 - * 任何错误返回 null,不影响主流程。 + * 远端失败时回退到本地确定性标题,避免保持默认标题。 */ export async function generateAgentTitle(input: AgentGenerateTitleInput): Promise { const { userMessage, channelId, modelId } = input console.log('[Agent 标题生成] 开始生成标题:', { channelId, modelId, userMessage: userMessage.slice(0, 50) }) + const fallbackTitle = deriveAgentFallbackTitle(userMessage, MAX_TITLE_LENGTH) + + const trimmedMessage = userMessage.trim() + if (trimmedMessage.length <= SHORT_MESSAGE_THRESHOLD) { + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + detail: 'short_message', + }) + console.log('[Agent 标题生成] 消息过短,直接使用本地兜底标题:', fallbackTitle) + return fallbackTitle + } try { const channels = listChannels() const channel = channels.find((c) => c.id === channelId) if (!channel) { console.warn('[Agent 标题生成] 渠道不存在:', channelId) - return null + logAgentTitleEvent('title_failed_request', { + channelId, + modelId, + detail: 'channel_not_found', + }) + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + detail: 'channel_not_found', + }) + return fallbackTitle + } + + let apiKey: string + try { + apiKey = decryptApiKey(channelId) + } catch { + logAgentTitleEvent('title_failed_request', { + channelId, + modelId, + provider: channel.provider, + detail: 'decrypt_api_key_failed', + }) + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + provider: channel.provider, + detail: 'decrypt_api_key_failed', + }) + return fallbackTitle } - const apiKey = decryptApiKey(channelId) const adapter = getAdapter(channel.provider) const request = adapter.buildTitleRequest({ baseUrl: channel.baseUrl, @@ -1360,20 +1423,68 @@ export async function generateAgentTitle(input: AgentGenerateTitleInput): Promis const proxyUrl = await getEffectiveProxyUrl() const fetchFn = getFetchFn(proxyUrl) - const title = await fetchTitle(request, adapter, fetchFn) - if (!title) { - console.warn('[Agent 标题生成] API 返回空标题') - return null + const titleResult = await fetchTitleWithDiagnostics(request, adapter, fetchFn) + if (!titleResult.title) { + const failureReason = + titleResult.reason === 'http_non_200' + ? 'title_failed_request' + : 'title_failed_parse' + logAgentTitleEvent(failureReason, { + channelId, + modelId, + provider: channel.provider, + detail: titleResult.reason, + status: titleResult.status, + dataPreview: titleResult.dataPreview, + }) + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + provider: channel.provider, + detail: titleResult.reason, + status: titleResult.status, + dataPreview: titleResult.dataPreview, + }) + return fallbackTitle } - const cleaned = title.trim().replace(/^["'""''「《]+|["'""''」》]+$/g, '').trim() - const result = cleaned.slice(0, MAX_TITLE_LENGTH) || null + const result = sanitizeTitleCandidate(titleResult.title, MAX_TITLE_LENGTH) + if (!result) { + logAgentTitleEvent('title_failed_parse', { + channelId, + modelId, + provider: channel.provider, + detail: 'invalid_remote_title', + }) + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + provider: channel.provider, + detail: 'invalid_remote_title', + }) + return fallbackTitle + } + logAgentTitleEvent('title_generated_remote', { + channelId, + modelId, + provider: channel.provider, + }) console.log(`[Agent 标题生成] 生成标题成功: "${result}"`) return result } catch (error) { console.warn('[Agent 标题生成] 生成失败:', error) - return null + logAgentTitleEvent('title_failed_request', { + channelId, + modelId, + detail: error instanceof Error ? error.message : 'unknown_error', + }) + logAgentTitleEvent('title_generated_fallback', { + channelId, + modelId, + detail: 'remote_request_failed', + }) + return fallbackTitle } } @@ -1393,7 +1504,7 @@ async function autoGenerateTitle( ): Promise { try { const meta = getAgentSessionMeta(sessionId) - if (!meta || meta.title !== DEFAULT_SESSION_TITLE) return + if (!meta || !isDefaultAgentTitle(meta.title)) return const title = await generateAgentTitle({ userMessage, channelId, modelId }) if (!title) return diff --git a/apps/electron/src/main/lib/agent-title-utils.test.ts b/apps/electron/src/main/lib/agent-title-utils.test.ts new file mode 100644 index 0000000..bda7967 --- /dev/null +++ b/apps/electron/src/main/lib/agent-title-utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test' +import { + DEFAULT_AGENT_SESSION_TITLE, + deriveAgentFallbackTitle, + isDefaultAgentTitle, + MAX_AGENT_TITLE_LENGTH, +} from './agent-title-utils' + +describe('agent-title-utils', () => { + it('derives deterministic fallback title from first user message', () => { + expect(deriveAgentFallbackTitle(' hello agent ')).toBe('hello agent') + expect(deriveAgentFallbackTitle('《Agent 会话测试》')).toBe('Agent 会话测试') + }) + + it('returns non-default fallback when user message is empty', () => { + expect(deriveAgentFallbackTitle(' ')).not.toBe(DEFAULT_AGENT_SESSION_TITLE) + }) + + it('keeps fallback title under max length', () => { + const title = deriveAgentFallbackTitle('x'.repeat(MAX_AGENT_TITLE_LENGTH + 20)) + expect(title.length).toBeLessThanOrEqual(MAX_AGENT_TITLE_LENGTH) + }) + + it('detects default agent title safely', () => { + expect(isDefaultAgentTitle(DEFAULT_AGENT_SESSION_TITLE)).toBeTrue() + expect(isDefaultAgentTitle(' 新 Agent 会话 ')).toBeTrue() + expect(isDefaultAgentTitle('自定义会话')).toBeFalse() + expect(isDefaultAgentTitle(undefined)).toBeTrue() + }) +}) + diff --git a/apps/electron/src/main/lib/agent-title-utils.ts b/apps/electron/src/main/lib/agent-title-utils.ts new file mode 100644 index 0000000..80312bc --- /dev/null +++ b/apps/electron/src/main/lib/agent-title-utils.ts @@ -0,0 +1,37 @@ +/** + * Agent 会话标题辅助工具 + */ + +import { + MAX_CHAT_TITLE_LENGTH, + normalizeTitleWhitespace, + sanitizeTitleCandidate, +} from './title-utils' + +/** Agent 默认会话标题(用于判断是否允许自动覆盖) */ +export const DEFAULT_AGENT_SESSION_TITLE = '新 Agent 会话' + +/** Agent 标题最大长度 */ +export const MAX_AGENT_TITLE_LENGTH = MAX_CHAT_TITLE_LENGTH + +/** Agent 兜底标题(用户首条消息为空时) */ +const EMPTY_AGENT_FALLBACK_TITLE = '未命名会话' + +/** + * Agent 本地兜底标题:由首条用户消息确定性生成。 + * 即使用户消息为空,也返回非默认占位标题。 + */ +export function deriveAgentFallbackTitle( + userMessage: string, + maxLength = MAX_AGENT_TITLE_LENGTH, +): string { + const candidate = sanitizeTitleCandidate(userMessage, maxLength) + return candidate ?? EMPTY_AGENT_FALLBACK_TITLE +} + +/** 是否仍为 Agent 默认标题 */ +export function isDefaultAgentTitle(title: string | null | undefined): boolean { + if (!title) return true + return normalizeTitleWhitespace(title) === DEFAULT_AGENT_SESSION_TITLE +} + diff --git a/apps/electron/src/main/lib/chat-service.ts b/apps/electron/src/main/lib/chat-service.ts index f8329ef..8300a99 100644 --- a/apps/electron/src/main/lib/chat-service.ts +++ b/apps/electron/src/main/lib/chat-service.ts @@ -18,7 +18,7 @@ import type { ChatSendInput, ChatMessage, GenerateTitleInput, FileAttachment } f import { getAdapter, streamSSE, - fetchTitle, + fetchTitleWithDiagnostics, } from '@proma/core' import type { ImageAttachmentData } from '@proma/core' import { listChannels, decryptApiKey } from './channel-manager' @@ -27,6 +27,7 @@ import { readAttachmentAsBase64, isImageAttachment } from './attachment-service' import { extractTextFromAttachment, isDocumentAttachment } from './document-parser' import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' +import { deriveFallbackTitle, MAX_CHAT_TITLE_LENGTH, sanitizeTitleCandidate } from './title-utils' /** 活跃的 AbortController 映射(conversationId → controller) */ const activeControllers = new Map() @@ -393,7 +394,28 @@ const TITLE_PROMPT = '根据用户的第一条消息,生成一个简短的对 const SHORT_MESSAGE_THRESHOLD = 4 /** 最大标题长度 */ -const MAX_TITLE_LENGTH = 20 +const MAX_TITLE_LENGTH = MAX_CHAT_TITLE_LENGTH + +type TitleReason = + | 'title_generated_remote' + | 'title_generated_fallback' + | 'title_failed_parse' + | 'title_failed_request' + +function logTitleEvent( + reason: TitleReason, + context: { + conversationId?: string + channelId: string + modelId: string + provider?: string + detail?: string + status?: number | null + dataPreview?: string + }, +): void { + console.log('[title_event]', { reason, ...context }) +} /** * 调用 AI 生成对话标题 @@ -405,15 +427,22 @@ const MAX_TITLE_LENGTH = 20 * @returns 生成的标题,失败时返回 null */ export async function generateTitle(input: GenerateTitleInput): Promise { - const { userMessage, channelId, modelId } = input - console.log('[标题生成] 开始生成标题:', { channelId, modelId, userMessage: userMessage.slice(0, 50) }) + const { conversationId, userMessage, channelId, modelId } = input + console.log('[标题生成] 开始生成标题:', { conversationId, channelId, modelId, userMessage: userMessage.slice(0, 50) }) + + const fallbackTitle = deriveFallbackTitle(userMessage, MAX_TITLE_LENGTH) // 短消息直接使用原文作为标题,避免 AI 幻觉 const trimmedMessage = userMessage.trim() if (trimmedMessage.length <= SHORT_MESSAGE_THRESHOLD) { - const shortTitle = trimmedMessage.slice(0, MAX_TITLE_LENGTH) - console.log('[标题生成] 消息过短,直接使用原文作为标题:', shortTitle) - return shortTitle + logTitleEvent('title_generated_fallback', { + conversationId, + channelId, + modelId, + detail: 'short_message', + }) + console.log('[标题生成] 消息过短,直接使用本地兜底标题:', fallbackTitle) + return fallbackTitle } // 查找渠道 @@ -421,7 +450,19 @@ export async function generateTitle(input: GenerateTitleInput): Promise c.id === channelId) if (!channel) { console.warn('[标题生成] 渠道不存在:', channelId) - return null + logTitleEvent('title_failed_request', { + conversationId, + channelId, + modelId, + detail: 'channel_not_found', + }) + logTitleEvent('title_generated_fallback', { + conversationId, + channelId, + modelId, + detail: 'channel_not_found', + }) + return fallbackTitle } // 解密 API Key @@ -430,7 +471,21 @@ export async function generateTitle(input: GenerateTitleInput): Promise { + it('sanitizes wrapping punctuation and whitespace', () => { + expect(sanitizeTitleCandidate(' " 你好 世界 " ')).toBe('你好 世界') + expect(sanitizeTitleCandidate('《测试标题》')).toBe('测试标题') + }) + + it('truncates long titles', () => { + const source = 'abcdefghijklmnopqrstuvwxyz' + expect(sanitizeTitleCandidate(source, 10)).toBe('abcdefghij') + }) + + it('returns null for empty candidate', () => { + expect(sanitizeTitleCandidate(' ')).toBeNull() + }) + + it('derives deterministic fallback title', () => { + expect(deriveFallbackTitle(' hello world ')).toBe('hello world') + expect(deriveFallbackTitle(' ')).not.toBe(DEFAULT_CHAT_TITLE) + }) + + it('keeps fallback under max length', () => { + const fallback = deriveFallbackTitle('x'.repeat(MAX_CHAT_TITLE_LENGTH + 50)) + expect(fallback.length).toBeLessThanOrEqual(MAX_CHAT_TITLE_LENGTH) + }) + + it('detects default title safely', () => { + expect(isDefaultChatTitle(DEFAULT_CHAT_TITLE)).toBeTrue() + expect(isDefaultChatTitle(' 新对话 ')).toBeTrue() + expect(isDefaultChatTitle('自定义标题')).toBeFalse() + expect(isDefaultChatTitle(undefined)).toBeTrue() + }) +}) diff --git a/apps/electron/src/main/lib/title-utils.ts b/apps/electron/src/main/lib/title-utils.ts new file mode 100644 index 0000000..94c109d --- /dev/null +++ b/apps/electron/src/main/lib/title-utils.ts @@ -0,0 +1,55 @@ +/** + * 标题生成辅助工具 + */ + +/** 聊天默认标题(用于判断是否允许自动覆盖) */ +export const DEFAULT_CHAT_TITLE = '新对话' + +/** 远端标题或本地兜底标题的最大长度 */ +export const MAX_CHAT_TITLE_LENGTH = 20 + +/** 兜底标题(用户首条消息为空时) */ +const EMPTY_FALLBACK_TITLE = '未命名对话' + +/** 清理首尾包裹引号/书名号/括号等符号 */ +function stripWrappingPunctuation(value: string): string { + return value + .replace(/^[\s"'“”‘’「」《》()\[\]{}【】]+/, '') + .replace(/[\s"'“”‘’「」《》()\[\]{}【】]+$/, '') +} + +/** 统一空白字符并裁剪 */ +export function normalizeTitleWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +/** + * 清洗并裁剪标题候选文本。 + * 返回 null 表示候选文本无效。 + */ +export function sanitizeTitleCandidate(raw: string, maxLength = MAX_CHAT_TITLE_LENGTH): string | null { + const normalized = normalizeTitleWhitespace(raw) + if (!normalized) return null + + const stripped = stripWrappingPunctuation(normalized) + const cleaned = normalizeTitleWhitespace(stripped) + if (!cleaned) return null + + const truncated = cleaned.slice(0, maxLength).trim() + return truncated || null +} + +/** + * 本地兜底标题:由首条用户消息确定性生成。 + * 即使用户消息为空,也返回非默认占位标题。 + */ +export function deriveFallbackTitle(userMessage: string, maxLength = MAX_CHAT_TITLE_LENGTH): string { + const candidate = sanitizeTitleCandidate(userMessage, maxLength) + return candidate ?? EMPTY_FALLBACK_TITLE +} + +/** 是否仍为默认聊天标题 */ +export function isDefaultChatTitle(title: string | null | undefined): boolean { + if (!title) return true + return normalizeTitleWhitespace(title) === DEFAULT_CHAT_TITLE +} diff --git a/apps/electron/src/renderer/components/chat/ChatView.tsx b/apps/electron/src/renderer/components/chat/ChatView.tsx index df6bbc3..7b00f6a 100644 --- a/apps/electron/src/renderer/components/chat/ChatView.tsx +++ b/apps/electron/src/renderer/components/chat/ChatView.tsx @@ -19,6 +19,7 @@ import { ChatHeader } from './ChatHeader' import { ChatMessages } from './ChatMessages' import { ChatInput } from './ChatInput' import type { InlineEditSubmitPayload } from './ChatMessageItem' +import { decideTitleTrigger } from './title-trigger' import { currentConversationIdAtom, currentConversationAtom, @@ -49,9 +50,16 @@ import type { AttachmentSaveInput, } from '@proma/shared' +const DEFAULT_CONVERSATION_TITLE = '新对话' + +function logTitleEvent(reason: string, context: Record): void { + console.log('[title_event]', { reason, ...context }) +} + export function ChatView(): React.ReactElement { const currentConversationId = useAtomValue(currentConversationIdAtom) const currentConversation = useAtomValue(currentConversationAtom) + const conversations = useAtomValue(conversationsAtom) const [currentMessages, setCurrentMessages] = useAtom(currentMessagesAtom) const setStreamingStates = useSetAtom(streamingStatesAtom) const [selectedModel, setSelectedModel] = useAtom(selectedModelAtom) @@ -69,6 +77,12 @@ export function ChatView(): React.ReactElement { // 首条消息标题生成相关 ref(支持多对话并行) const pendingTitleRef = React.useRef>(new Map()) + const firstTurnTriggeredRef = React.useRef>(new Set()) + const conversationsRef = React.useRef(conversations) + + React.useEffect(() => { + conversationsRef.current = conversations + }, [conversations]) // 当前对话 ID 的 ref,供 IPC 回调使用(避免闭包捕获旧值) const currentConvIdRef = React.useRef(currentConversationId) @@ -89,6 +103,10 @@ export function ChatView(): React.ReactElement { return } + // 会话切换后先清空当前列表,避免旧会话消息残留导致首条消息判定错误 + setCurrentMessages([]) + setHasMoreMessages(false) + // 仅加载最近 N 条消息,避免大量消息导致渲染卡顿 window.electronAPI .getRecentMessages(currentConversationId, INITIAL_MESSAGE_LIMIT) @@ -175,32 +193,65 @@ export function ChatView(): React.ReactElement { } // 刷新对话列表(updatedAt 已更新) - window.electronAPI + const refreshConversationsPromise = window.electronAPI .listConversations() - .then(setConversations) - .catch(console.error) + .then((list) => { + setConversations(list) + return list + }) + .catch((error) => { + console.error(error) + return conversationsRef.current + }) // 第一条消息回复完成后,生成对话标题 const titleInput = pendingTitleRef.current.get(event.conversationId) console.log('[ChatView] 流式完成 - conversationId:', event.conversationId, 'titleInput:', titleInput) if (titleInput) { pendingTitleRef.current.delete(event.conversationId) + firstTurnTriggeredRef.current.delete(event.conversationId) console.log('[ChatView] 开始生成标题:', titleInput) window.electronAPI.generateTitle(titleInput).then((title) => { console.log('[ChatView] 标题生成结果:', title) - if (!title) return - window.electronAPI - .updateConversationTitle(event.conversationId, title) - .then((updated) => { - console.log('[ChatView] 标题更新成功:', updated.title) - setConversations((prev) => - prev.map((c) => (c.id === updated.id ? updated : c)) - ) + if (!title) { + logTitleEvent('title_not_triggered', { + conversationId: event.conversationId, + reason: 'empty_generated_title', + }) + return + } + + refreshConversationsPromise + .then((latestConversations) => { + const latest = latestConversations.find((c) => c.id === event.conversationId) + if (latest && latest.title !== DEFAULT_CONVERSATION_TITLE) { + logTitleEvent('title_not_triggered', { + conversationId: event.conversationId, + reason: 'title_already_customized', + currentTitle: latest.title, + }) + return + } + + window.electronAPI + .updateConversationTitle(event.conversationId, title) + .then((updated) => { + console.log('[ChatView] 标题更新成功:', updated.title) + setConversations((prev) => + prev.map((c) => (c.id === updated.id ? updated : c)) + ) + }) + .catch(console.error) }) .catch(console.error) }).catch((error) => { console.error('[ChatView] 标题生成失败:', error) }) + } else { + logTitleEvent('title_not_triggered', { + conversationId: event.conversationId, + reason: 'missing_pending_title_input', + }) } } ) @@ -212,6 +263,16 @@ export function ChatView(): React.ReactElement { // 清理 Map 中的流式状态 removeState(event.conversationId) + // 首轮失败时清理触发态,允许下次发送重新触发自动标题 + if (pendingTitleRef.current.has(event.conversationId)) { + pendingTitleRef.current.delete(event.conversationId) + firstTurnTriggeredRef.current.delete(event.conversationId) + logTitleEvent('title_not_triggered', { + conversationId: event.conversationId, + reason: 'stream_error_before_title_generation', + }) + } + // 存储错误消息,供 UI 显示 setChatStreamErrors((prev) => { const map = new Map(prev) @@ -268,11 +329,13 @@ export function ChatView(): React.ReactElement { consumePendingAttachments?: boolean messageCountBeforeSend?: number contextDividersOverride?: string[] + skipAutoTitle?: boolean }, ): Promise => { if (!currentConversationId || !selectedModel) return const consumePending = options?.consumePendingAttachments ?? true + const skipAutoTitle = options?.skipAutoTitle ?? false // 清除当前对话的错误消息 setChatStreamErrors((prev) => { @@ -282,17 +345,40 @@ export function ChatView(): React.ReactElement { return map }) - // 判断是否为第一条消息(发送前历史为空) - const messageCountBeforeSend = options?.messageCountBeforeSend ?? currentMessages.length - const isFirstMessage = messageCountBeforeSend === 0 - console.log('[ChatView] 发送消息 - isFirstMessage:', isFirstMessage, 'messageCountBeforeSend:', messageCountBeforeSend, 'conversationId:', currentConversationId) - if (isFirstMessage && content) { + // 判断是否为第一条消息:优先使用调用方提供值,否则查询持久化消息总数,避免切会话时读到旧状态 + let messageCountBeforeSend = options?.messageCountBeforeSend + if (messageCountBeforeSend === undefined) { + try { + const recent = await window.electronAPI.getRecentMessages(currentConversationId, 1) + messageCountBeforeSend = recent.total + } catch (error) { + console.warn('[ChatView] 获取消息总数失败,回退到内存计数:', error) + messageCountBeforeSend = currentMessages.length + } + } + + const decision = decideTitleTrigger({ + skipAutoTitle, + messageCountBeforeSend, + content, + alreadyTriggered: firstTurnTriggeredRef.current.has(currentConversationId), + }) + console.log('[ChatView] 发送消息 - titleDecision:', decision.reason, 'messageCountBeforeSend:', messageCountBeforeSend, 'conversationId:', currentConversationId) + if (!decision.shouldQueue) { + logTitleEvent('title_not_triggered', { + conversationId: currentConversationId, + reason: decision.reason, + messageCountBeforeSend, + }) + } else { console.log('[ChatView] 设置待生成标题:', { conversationId: currentConversationId, userMessage: content.slice(0, 50) }) pendingTitleRef.current.set(currentConversationId, { + conversationId: currentConversationId, userMessage: content, channelId: selectedModel.channelId, modelId: selectedModel.modelId, }) + firstTurnTriggeredRef.current.add(currentConversationId) } let savedAttachments: FileAttachment[] = options?.attachments ?? [] @@ -370,6 +456,14 @@ export function ChatView(): React.ReactElement { window.electronAPI.sendMessage(input).catch((error) => { console.error('[ChatView] 发送消息失败:', error) + if (pendingTitleRef.current.has(currentConversationId)) { + pendingTitleRef.current.delete(currentConversationId) + firstTurnTriggeredRef.current.delete(currentConversationId) + logTitleEvent('title_not_triggered', { + conversationId: currentConversationId, + reason: 'send_message_rejected', + }) + } setStreamingStates((prev) => { if (!prev.has(currentConversationId)) return prev const map = new Map(prev) @@ -486,6 +580,7 @@ export function ChatView(): React.ReactElement { consumePendingAttachments: false, messageCountBeforeSend: truncated.messageCountBeforeSend, contextDividersOverride: truncated.contextDividersAfterTruncate, + skipAutoTitle: true, }) } catch (error) { console.error('[ChatView] 重新发送失败:', error) @@ -539,6 +634,7 @@ export function ChatView(): React.ReactElement { consumePendingAttachments: false, messageCountBeforeSend: truncated.messageCountBeforeSend, contextDividersOverride: truncated.contextDividersAfterTruncate, + skipAutoTitle: true, }) setInlineEditingMessageId(null) } catch (error) { diff --git a/apps/electron/src/renderer/components/chat/title-trigger.test.ts b/apps/electron/src/renderer/components/chat/title-trigger.test.ts new file mode 100644 index 0000000..f5714b6 --- /dev/null +++ b/apps/electron/src/renderer/components/chat/title-trigger.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test' +import { decideTitleTrigger } from './title-trigger' + +describe('decideTitleTrigger', () => { + it('queues title generation on real first message', () => { + const decision = decideTitleTrigger({ + skipAutoTitle: false, + messageCountBeforeSend: 0, + content: 'hello world', + alreadyTriggered: false, + }) + expect(decision).toEqual({ shouldQueue: true, reason: 'should_queue' }) + }) + + it('does not queue when skip flag is set (resend/edit path)', () => { + const decision = decideTitleTrigger({ + skipAutoTitle: true, + messageCountBeforeSend: 0, + content: 'hello world', + alreadyTriggered: false, + }) + expect(decision.reason).toBe('skip_auto_title_option') + expect(decision.shouldQueue).toBeFalse() + }) + + it('does not queue when not first message', () => { + const decision = decideTitleTrigger({ + skipAutoTitle: false, + messageCountBeforeSend: 3, + content: 'hello world', + alreadyTriggered: false, + }) + expect(decision.reason).toBe('not_first_message') + expect(decision.shouldQueue).toBeFalse() + }) + + it('does not queue for empty message', () => { + const decision = decideTitleTrigger({ + skipAutoTitle: false, + messageCountBeforeSend: 0, + content: ' ', + alreadyTriggered: false, + }) + expect(decision.reason).toBe('empty_user_message') + expect(decision.shouldQueue).toBeFalse() + }) + + it('does not queue duplicate first-turn attempts', () => { + const decision = decideTitleTrigger({ + skipAutoTitle: false, + messageCountBeforeSend: 0, + content: 'hello world', + alreadyTriggered: true, + }) + expect(decision.reason).toBe('duplicate_first_turn_guard') + expect(decision.shouldQueue).toBeFalse() + }) +}) diff --git a/apps/electron/src/renderer/components/chat/title-trigger.ts b/apps/electron/src/renderer/components/chat/title-trigger.ts new file mode 100644 index 0000000..64639c7 --- /dev/null +++ b/apps/electron/src/renderer/components/chat/title-trigger.ts @@ -0,0 +1,40 @@ +/** + * 标题触发判定(纯函数,便于测试) + */ + +export interface TitleTriggerInput { + skipAutoTitle: boolean + messageCountBeforeSend: number + content: string + alreadyTriggered: boolean +} + +export interface TitleTriggerDecision { + shouldQueue: boolean + reason: + | 'should_queue' + | 'skip_auto_title_option' + | 'not_first_message' + | 'empty_user_message' + | 'duplicate_first_turn_guard' +} + +export function decideTitleTrigger(input: TitleTriggerInput): TitleTriggerDecision { + if (input.skipAutoTitle) { + return { shouldQueue: false, reason: 'skip_auto_title_option' } + } + + if (input.messageCountBeforeSend !== 0) { + return { shouldQueue: false, reason: 'not_first_message' } + } + + if (!input.content.trim()) { + return { shouldQueue: false, reason: 'empty_user_message' } + } + + if (input.alreadyTriggered) { + return { shouldQueue: false, reason: 'duplicate_first_turn_guard' } + } + + return { shouldQueue: true, reason: 'should_queue' } +} diff --git a/packages/core/src/providers/anthropic-adapter.ts b/packages/core/src/providers/anthropic-adapter.ts index 2e79b14..d5395b1 100644 --- a/packages/core/src/providers/anthropic-adapter.ts +++ b/packages/core/src/providers/anthropic-adapter.ts @@ -18,6 +18,7 @@ import type { ImageAttachmentData, } from './types.ts' import { normalizeAnthropicBaseUrl } from './url-utils.ts' +import { extractTitleFromCommonResponse } from './title-extract.ts' // ===== Anthropic 特有类型 ===== @@ -56,6 +57,7 @@ interface AnthropicTitleResponse { type: string text?: string thinking?: string + content?: string }> } @@ -209,34 +211,36 @@ export class AnthropicAdapter implements ProviderAdapter { model: input.modelId, max_tokens: 50, messages: [{ role: 'user', content: input.prompt }], - // 禁用 extended thinking(MiniMax 等供应商也会遵循此设置) - thinking: { type: 'disabled' }, + // 兼容代理网关:标题请求不显式传 thinking,避免部分网关校验失败(422) }), } } parseTitleResponse(responseBody: unknown): string | null { const data = responseBody as AnthropicTitleResponse - if (!data.content || data.content.length === 0) return null - - // 优先查找 type === "text" 的块 - const textBlock = data.content.find((block) => block.type === 'text') - if (textBlock?.text) return textBlock.text - - // 如果没有 text 块,尝试从第一个 thinking 块中提取(MiniMax 兼容) - const thinkingBlock = data.content.find((block) => block.type === 'thinking') - if (thinkingBlock?.thinking) { - // thinking 内容可能很长,尝试提取最后一行或关键部分 - const lines = thinkingBlock.thinking.trim().split('\n') - const lastLine = lines[lines.length - 1]?.trim() - // 如果最后一行以 "- " 开头,提取它(常见的标题格式) - if (lastLine?.startsWith('- ')) { - return lastLine.slice(2).trim() + if (data.content && data.content.length > 0) { + // 优先查找 type === "text" 的块 + const textBlock = data.content.find((block) => block.type === 'text') + if (textBlock?.text) return textBlock.text + + // 兼容一些网关把文本放在 content 字段 + if (textBlock?.content?.trim()) return textBlock.content + + // 如果没有 text 块,尝试从第一个 thinking 块中提取(MiniMax 兼容) + const thinkingBlock = data.content.find((block) => block.type === 'thinking') + if (thinkingBlock?.thinking) { + // thinking 内容可能很长,尝试提取最后一行或关键部分 + const lines = thinkingBlock.thinking.trim().split('\n') + const lastLine = lines[lines.length - 1]?.trim() + // 如果最后一行以 "- " 开头,提取它(常见的标题格式) + if (lastLine?.startsWith('- ')) { + return lastLine.slice(2).trim() + } + // 否则返回最后一行 + return lastLine || null } - // 否则返回最后一行 - return lastLine || null } - return null + return extractTitleFromCommonResponse(responseBody) } } diff --git a/packages/core/src/providers/google-adapter.ts b/packages/core/src/providers/google-adapter.ts index 516c85c..1bcda3d 100644 --- a/packages/core/src/providers/google-adapter.ts +++ b/packages/core/src/providers/google-adapter.ts @@ -19,6 +19,7 @@ import type { ImageAttachmentData, } from './types.ts' import { normalizeBaseUrl } from './url-utils.ts' +import { extractTextFromContentLike, extractTitleFromCommonResponse } from './title-extract.ts' // ===== Google 特有类型 ===== @@ -53,9 +54,11 @@ interface GoogleStreamData { /** Google 标题响应 */ interface GoogleTitleResponse { + text?: string candidates?: Array<{ + text?: string content?: { - parts?: Array<{ text?: string }> + parts?: Array<{ text?: string; thought?: boolean }> } }> } @@ -210,6 +213,15 @@ export class GoogleAdapter implements ProviderAdapter { parseTitleResponse(responseBody: unknown): string | null { const data = responseBody as GoogleTitleResponse - return data.candidates?.[0]?.content?.parts?.[0]?.text ?? null + const fromParts = extractTextFromContentLike(data.candidates?.[0]?.content?.parts) + if (fromParts) return fromParts + + const fromCandidate = extractTextFromContentLike(data.candidates?.[0]?.text) + if (fromCandidate) return fromCandidate + + const fromRoot = extractTextFromContentLike(data.text) + if (fromRoot) return fromRoot + + return extractTitleFromCommonResponse(responseBody) } } diff --git a/packages/core/src/providers/openai-adapter.ts b/packages/core/src/providers/openai-adapter.ts index 627d926..578c9e9 100644 --- a/packages/core/src/providers/openai-adapter.ts +++ b/packages/core/src/providers/openai-adapter.ts @@ -19,6 +19,7 @@ import type { ImageAttachmentData, } from './types.ts' import { normalizeBaseUrl } from './url-utils.ts' +import { extractTextFromContentLike, extractTitleFromCommonResponse } from './title-extract.ts' // ===== OpenAI 特有类型 ===== @@ -45,7 +46,13 @@ interface OpenAIChunkData { /** OpenAI 标题响应 */ interface OpenAITitleResponse { - choices?: Array<{ message?: { content?: string } }> + output_text?: string + choices?: Array<{ + text?: string + message?: { + content?: string | Array<{ type?: string; text?: string }> + } + }> } // ===== 消息转换 ===== @@ -179,6 +186,16 @@ export class OpenAIAdapter implements ProviderAdapter { parseTitleResponse(responseBody: unknown): string | null { const data = responseBody as OpenAITitleResponse - return data.choices?.[0]?.message?.content ?? null + const primary = extractTextFromContentLike(data.choices?.[0]?.message?.content) + if (primary) return primary + + const alt = data.choices?.[0]?.text + if (typeof alt === 'string' && alt.trim()) return alt + + if (typeof data.output_text === 'string' && data.output_text.trim()) { + return data.output_text + } + + return extractTitleFromCommonResponse(responseBody) } } diff --git a/packages/core/src/providers/sse-reader.ts b/packages/core/src/providers/sse-reader.ts index 48dd11f..b9603ef 100644 --- a/packages/core/src/providers/sse-reader.ts +++ b/packages/core/src/providers/sse-reader.ts @@ -10,6 +10,7 @@ */ import type { ProviderAdapter, ProviderRequest, StreamEventCallback } from './types.ts' +import { extractTitleFromCommonResponse } from './title-extract.ts' // ===== 流式请求 ===== @@ -114,18 +115,61 @@ export async function streamSSE(options: StreamSSEOptions): Promise 4 || value == null) return false + if (typeof value === 'string') return true + if (Array.isArray(value)) { + return value.some((item) => hasKnownTitleShape(item, depth + 1)) + } + if (typeof value !== 'object') return false + + const record = value as Record + if ( + 'output_text' in record || + 'text' in record || + 'content' in record || + 'choices' in record || + 'candidates' in record || + 'message' in record || + 'delta' in record + ) { + return true + } + + if ('data' in record) { + return hasKnownTitleShape(record.data, depth + 1) + } + + return Object.values(record).some((item) => hasKnownTitleShape(item, depth + 1)) +} + /** - * 执行非流式标题生成请求 - * - * @param request 构建好的 HTTP 请求配置 - * @param adapter 供应商适配器(用于解析响应) - * @returns 提取的标题文本,失败返回 null + * 执行非流式标题请求,并返回诊断信息(失败原因、HTTP 状态、响应预览)。 */ -export async function fetchTitle( +export async function fetchTitleWithDiagnostics( request: ProviderRequest, adapter: ProviderAdapter, fetchFn: typeof globalThis.fetch = fetch, -): Promise { +): Promise { + let status: number | null = null + try { console.log('[fetchTitle] 发送请求:', { url: request.url, @@ -138,6 +182,7 @@ export async function fetchTitle( headers: request.headers, body: request.body, }) + status = response.status console.log('[fetchTitle] 收到响应:', { status: response.status, @@ -147,24 +192,93 @@ export async function fetchTitle( if (!response.ok) { const errorText = await response.text().catch(() => 'unknown') + const dataPreview = errorText.slice(0, 500) console.warn('[fetchTitle] 请求失败:', { status: response.status, - error: errorText.slice(0, 500), + error: dataPreview, }) - return null + return { + title: null, + status: response.status, + reason: 'http_non_200', + dataPreview, + } + } + + let data: unknown + try { + data = await response.json() + } catch (error) { + const dataPreview = error instanceof Error ? error.message.slice(0, 500) : 'json_parse_failed' + console.warn('[fetchTitle] 解析 JSON 失败:', { status: response.status, error: dataPreview }) + return { + title: null, + status: response.status, + reason: 'parse_failed', + dataPreview, + } } - const data: unknown = await response.json() + const dataPreview = stringifyPreview(data) console.log('[fetchTitle] 解析响应体:', { provider: adapter.providerType, - dataPreview: JSON.stringify(data).slice(0, 500), + dataPreview, }) - const title = adapter.parseTitleResponse(data) - console.log('[fetchTitle] 解析标题结果:', { title }) - return title + const adapterTitle = adapter.parseTitleResponse(data)?.trim() || null + if (adapterTitle) { + console.log('[fetchTitle] 解析标题结果:', { source: 'adapter', title: adapterTitle }) + return { + title: adapterTitle, + status: response.status, + reason: 'success', + dataPreview, + } + } + + const fallbackTitle = extractTitleFromCommonResponse(data)?.trim() || null + if (fallbackTitle) { + console.log('[fetchTitle] 解析标题结果:', { source: 'generic-fallback', title: fallbackTitle }) + return { + title: fallbackTitle, + status: response.status, + reason: 'success', + dataPreview, + } + } + + const reason: FetchTitleFailureReason = hasKnownTitleShape(data) ? 'empty_content' : 'parse_failed' + console.log('[fetchTitle] 解析标题结果:', { source: 'none', reason, status: response.status }) + return { + title: null, + status: response.status, + reason, + dataPreview, + } } catch (error) { + const dataPreview = error instanceof Error ? error.message.slice(0, 500) : 'unknown_error' console.error('[fetchTitle] 异常:', error) - return null + return { + title: null, + status, + reason: 'parse_failed', + dataPreview, + } } } + +/** + * 执行非流式标题生成请求 + * + * @param request 构建好的 HTTP 请求配置 + * @param adapter 供应商适配器(用于解析响应) + * @returns 提取的标题文本,失败返回 null + */ +export async function fetchTitle( + request: ProviderRequest, + adapter: ProviderAdapter, + fetchFn: typeof globalThis.fetch = fetch, +): Promise { + const result = await fetchTitleWithDiagnostics(request, adapter, fetchFn) + return result.title +} diff --git a/packages/core/src/providers/title-extract.ts b/packages/core/src/providers/title-extract.ts new file mode 100644 index 0000000..26628e8 --- /dev/null +++ b/packages/core/src/providers/title-extract.ts @@ -0,0 +1,130 @@ +/** + * 标题提取辅助函数(Provider-agnostic) + */ + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function readLastNonEmptyLine(value: string): string | null { + const lines = value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + if (lines.length === 0) return null + const last = lines[lines.length - 1] + if (!last) return null + if (last.startsWith('- ')) return last.slice(2).trim() || null + return last +} + +/** + * 从“文本或文本块数组”中提取文本。 + * 兼容 string / [{text}] / [{type:'text', text}] / [{thinking}] 等常见结构。 + */ +export function extractTextFromContentLike(content: unknown): string | null { + const text = readString(content) + if (text) return text + + for (const item of asArray(content)) { + const direct = readString(item) + if (direct) return direct + + const rec = asRecord(item) + if (!rec) continue + + const itemText = readString(rec.text) + if (itemText) return itemText + + const outputText = readString(rec.output_text) + if (outputText) return outputText + + const thinking = readString(rec.thinking) + if (thinking) { + const extracted = readLastNonEmptyLine(thinking) + if (extracted) return extracted + } + + const nestedContent = extractTextFromContentLike(rec.content) + if (nestedContent) return nestedContent + + const nestedParts = extractTextFromContentLike(rec.parts) + if (nestedParts) return nestedParts + } + + return null +} + +/** + * 从常见 provider 响应体结构中提取标题文本。 + * adapter 的 parseTitleResponse 失败时,可作为通用兜底。 + */ +export function extractTitleFromCommonResponse(responseBody: unknown): string | null { + const direct = readString(responseBody) + if (direct) return direct + + const root = asRecord(responseBody) + if (!root) return null + + // OpenAI Responses API + const outputText = readString(root.output_text) + if (outputText) return outputText + + // OpenAI Chat Completions + const firstChoice = asArray(root.choices)[0] + if (firstChoice) { + const choice = asRecord(firstChoice) + if (choice) { + const message = asRecord(choice.message) + if (message) { + const fromMessage = extractTextFromContentLike(message.content) + if (fromMessage) return fromMessage + } + + const fromChoiceText = readString(choice.text) + if (fromChoiceText) return fromChoiceText + + const fromChoiceDelta = asRecord(choice.delta) + if (fromChoiceDelta) { + const fromDelta = readString(fromChoiceDelta.content) + if (fromDelta) return fromDelta + } + } + } + + // Anthropic Messages API (and compatible variants) + const fromContent = extractTextFromContentLike(root.content) + if (fromContent) return fromContent + + // Google Gemini style + const firstCandidate = asArray(root.candidates)[0] + if (firstCandidate) { + const candidate = asRecord(firstCandidate) + if (candidate) { + const candidateContent = asRecord(candidate.content) + if (candidateContent) { + const fromParts = extractTextFromContentLike(candidateContent.parts) + if (fromParts) return fromParts + } + + const fromCandidateText = extractTextFromContentLike(candidate.text) + if (fromCandidateText) return fromCandidateText + } + } + + // Some gateways wrap payload in `data` + const wrappedData = root.data + const fromWrappedData = extractTitleFromCommonResponse(wrappedData) + if (fromWrappedData) return fromWrappedData + + return null +} diff --git a/packages/core/tests/anthropic-title-request.test.ts b/packages/core/tests/anthropic-title-request.test.ts new file mode 100644 index 0000000..8d74a53 --- /dev/null +++ b/packages/core/tests/anthropic-title-request.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test' +import { AnthropicAdapter } from '../src/providers/anthropic-adapter.ts' + +describe('AnthropicAdapter.buildTitleRequest', () => { + it('does not send thinking field for title requests', () => { + const adapter = new AnthropicAdapter() + const request = adapter.buildTitleRequest({ + baseUrl: 'https://example.com/anthropic', + apiKey: 'sk-test', + modelId: 'qianfan-code-latest', + prompt: 'title prompt', + }) + + const body = JSON.parse(request.body) as Record + expect(body.model).toBe('qianfan-code-latest') + expect(body.max_tokens).toBe(50) + expect(body.messages).toEqual([{ role: 'user', content: 'title prompt' }]) + expect('thinking' in body).toBeFalse() + }) +}) + diff --git a/packages/core/tests/fetch-title-with-diagnostics.test.ts b/packages/core/tests/fetch-title-with-diagnostics.test.ts new file mode 100644 index 0000000..d066bd8 --- /dev/null +++ b/packages/core/tests/fetch-title-with-diagnostics.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'bun:test' +import { fetchTitleWithDiagnostics } from '../src/providers/sse-reader.ts' +import type { ProviderAdapter, ProviderRequest, StreamEvent, StreamRequestInput, TitleRequestInput } from '../src/providers/types.ts' + +function createAdapter(parseTitleResponse: (body: unknown) => string | null): ProviderAdapter { + return { + providerType: 'openai', + buildStreamRequest(_input: StreamRequestInput): ProviderRequest { + return { url: 'https://example.com/stream', headers: {}, body: '{}' } + }, + parseSSELine(_jsonLine: string): StreamEvent[] { + return [] + }, + buildTitleRequest(_input: TitleRequestInput): ProviderRequest { + return { url: 'https://example.com/title', headers: {}, body: '{}' } + }, + parseTitleResponse, + } +} + +const request: ProviderRequest = { + url: 'https://example.com/title', + headers: { 'content-type': 'application/json' }, + body: '{"a":1}', +} + +describe('fetchTitleWithDiagnostics', () => { + it('returns success when adapter extracts title', async () => { + const adapter = createAdapter(() => 'Adapter Title') + const result = await fetchTitleWithDiagnostics( + request, + adapter, + async () => new Response(JSON.stringify({ foo: 'bar' }), { status: 200 }), + ) + expect(result.reason).toBe('success') + expect(result.title).toBe('Adapter Title') + expect(result.status).toBe(200) + }) + + it('returns http_non_200 with status and preview', async () => { + const adapter = createAdapter(() => null) + const result = await fetchTitleWithDiagnostics( + request, + adapter, + async () => new Response('bad request', { status: 400 }), + ) + expect(result.reason).toBe('http_non_200') + expect(result.title).toBeNull() + expect(result.status).toBe(400) + expect(result.dataPreview).toContain('bad request') + }) + + it('returns empty_content when payload has known title shape but empty value', async () => { + const adapter = createAdapter(() => null) + const result = await fetchTitleWithDiagnostics( + request, + adapter, + async () => new Response(JSON.stringify({ content: [] }), { status: 200 }), + ) + expect(result.reason).toBe('empty_content') + expect(result.title).toBeNull() + expect(result.status).toBe(200) + }) + + it('returns parse_failed when payload is unknown shape', async () => { + const adapter = createAdapter(() => null) + const result = await fetchTitleWithDiagnostics( + request, + adapter, + async () => new Response(JSON.stringify({ foo: { bar: 1 } }), { status: 200 }), + ) + expect(result.reason).toBe('parse_failed') + expect(result.title).toBeNull() + expect(result.status).toBe(200) + }) +}) + diff --git a/packages/core/tests/title-extract.test.ts b/packages/core/tests/title-extract.test.ts new file mode 100644 index 0000000..c5ff713 --- /dev/null +++ b/packages/core/tests/title-extract.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'bun:test' +import { + extractTextFromContentLike, + extractTitleFromCommonResponse, +} from '../src/providers/title-extract.ts' + +describe('title extraction fallback', () => { + it('extracts from OpenAI chat completion shape', () => { + const body = { + choices: [{ message: { content: 'OpenAI title' } }], + } + expect(extractTitleFromCommonResponse(body)).toBe('OpenAI title') + }) + + it('extracts from OpenAI responses output_text', () => { + const body = { + output_text: 'Responses title', + } + expect(extractTitleFromCommonResponse(body)).toBe('Responses title') + }) + + it('extracts from Anthropic text block', () => { + const body = { + content: [{ type: 'text', text: 'Anthropic title' }], + } + expect(extractTitleFromCommonResponse(body)).toBe('Anthropic title') + }) + + it('extracts from Anthropic thinking block by taking last line', () => { + const body = { + content: [{ type: 'thinking', thinking: 'step1\n- Thinking title' }], + } + expect(extractTitleFromCommonResponse(body)).toBe('Thinking title') + }) + + it('extracts from Google candidates parts', () => { + const body = { + candidates: [{ content: { parts: [{ text: 'Gemini title' }] } }], + } + expect(extractTitleFromCommonResponse(body)).toBe('Gemini title') + }) + + it('extracts wrapped payload under data', () => { + const body = { + data: { + choices: [{ message: { content: 'Wrapped title' } }], + }, + } + expect(extractTitleFromCommonResponse(body)).toBe('Wrapped title') + }) + + it('returns null for malformed response', () => { + expect(extractTitleFromCommonResponse({ foo: { bar: 1 } })).toBeNull() + expect(extractTitleFromCommonResponse(null)).toBeNull() + }) + + it('extracts from content-like mixed arrays', () => { + const content = [ + { type: 'image' }, + { type: 'text', text: 'Text block title' }, + ] + expect(extractTextFromContentLike(content)).toBe('Text block title') + }) +}) diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 8d9e198..10fff88 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -164,6 +164,8 @@ export interface ChatSendInput { * 生成对话标题的输入参数 */ export interface GenerateTitleInput { + /** 对话 ID(用于日志与诊断) */ + conversationId?: string /** 用户消息内容(用于生成标题) */ userMessage: string /** 渠道 ID */