diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index d51b00eb62..237beb9823 100644 --- a/packages/plugins/robot/src/Main.vue +++ b/packages/plugins/robot/src/Main.vue @@ -19,11 +19,11 @@ v-model:show="robotVisible" v-model:input="inputMessage" :status="mappedStatus" - :chat-mode="robotSettingState.chatMode" :prompt-items="promptItems" :bubble-renderers="bubbleRenderers" :allowFiles="isVisualModel && robotSettingState.chatMode === ChatMode.Agent" :show-aborted="robotSettingState.chatMode !== ChatMode.Agent" + :message-content-resolver="resolveChatMessageContent" :beforeSubmit="checkApiKey" :promptClickHandler="promptClickHandler" @fileSelected="handleFileSelected" @@ -93,6 +93,16 @@ import { AgentRenderer } from './components/renderers' import useChat from './composables/useChat' import useModelConfig from './composables/core/useConfig' import { ChatMode } from './types/mode.types' +import { STATUS } from './constants/status' +import { + AgentMessageStatus, + RobotMessageContentType, + RobotMessageRole, + isAgentFinalStatus, + type MessageResolverContext, + type RobotMessage, + type RobotRenderContentItem +} from './types' import apiService from './services/api' const props = defineProps({ @@ -106,6 +116,7 @@ const { robotSettingState, getModelCapabilities, updateThinkingState, getSelecte const robotVisible = ref(false) const fullscreen = ref(false) +const inputMessage = ref('') watch(robotVisible, (visible) => { useLayout().layoutState.toolbars.render = visible ? META_APP.Robot : '' @@ -150,7 +161,6 @@ const showSetting = ref(false) const { mappedStatus, - inputMessage, messages, changeChatMode, abortRequest, @@ -224,9 +234,9 @@ const promptClickHandler = (item: PromptProps & { mode?: 'chat' | 'agent' }) => changeChatMode(item.mode) } messages.value.push({ - role: 'user', + role: RobotMessageRole.User, content: item.description || '', - renderContent: [{ type: 'text', content: item.description }] + renderContent: [{ type: RobotMessageContentType.Text, content: item.description }] }) sendUserMessage() } @@ -245,8 +255,67 @@ const openAIRobot = () => { useLayout().closeSetting(true) } -// 当前Robot的bubbleRenderers无法做到响应式更新,因此Agent模式的type要与Chat模式不同 -const bubbleRenderers = { 'agent-content': AgentRenderer, 'agent-loading': AgentRenderer } +// 当前 Robot 的 bubbleRenderers 无法做到响应式更新,因此 Agent 模式需要独立的内容类型。 +// `agent-content` 表示 Agent 的最终内容片段;`agent-loading` 表示 Agent 处理中间态占位片段。 +const bubbleRenderers = { + [RobotMessageContentType.AgentContent]: AgentRenderer, + [RobotMessageContentType.AgentLoading]: AgentRenderer +} + +const resolveChatMessageContent = (message: RobotMessage, context: MessageResolverContext) => { + const renderContent = Array.isArray(message.renderContent) ? message.renderContent : [] + const hasAgentContent = renderContent.some((item) => { + return item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading + }) + const isAgentMessage = message.metadata?.chatMode === ChatMode.Agent || hasAgentContent + + if (!isAgentMessage || message.role !== RobotMessageRole.Assistant) { + return Array.isArray(message.renderContent) && message.renderContent.length > 0 + ? message.renderContent + : message.content + } + + // context.status 是当前整轮对话请求的运行状态,不是单条渲染片段状态。 + const isLastMessage = context.messages.at(-1) === message + const isGenerating = Boolean(message.loading) || (isLastMessage && context.status !== STATUS.FINISHED) + const resolvedRenderContent = isGenerating + ? renderContent + : renderContent.filter((item) => item.type !== RobotMessageContentType.AgentLoading) + const agentContents = resolvedRenderContent.filter( + (item): item is RobotRenderContentItem => + item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading + ) + // item.status 是单个 agent 片段的业务结果,例如 success/failed/fix/loading。 + const finalStatus = agentContents.findLast((item) => isAgentFinalStatus(item.status))?.status + + if (!Array.isArray(message.renderContent) || message.renderContent.length === 0) { + const agentStatus = isAgentFinalStatus(message.metadata?.agentStatus) + ? message.metadata.agentStatus + : AgentMessageStatus.Failed + return [ + { + type: RobotMessageContentType.AgentContent, + status: agentStatus, + content: message.content + } + ] + } + + return resolvedRenderContent.map((item) => { + if (item.type !== RobotMessageContentType.AgentContent || isGenerating) { + return item + } + + if (!item.status || item.status === AgentMessageStatus.Loading) { + return { + ...item, + status: finalStatus || message.metadata?.agentStatus || AgentMessageStatus.Failed + } + } + + return item + }) +} const handleFileSelected = async (formData: FormData, updateAttachment: (resourceUrl: string) => void) => { try { diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue index 8206bdccae..f69c0a8ea6 100644 --- a/packages/plugins/robot/src/components/chat/RobotChat.vue +++ b/packages/plugins/robot/src/components/chat/RobotChat.vue @@ -11,7 +11,7 @@
-
+
+ }, allowFiles: { type: Boolean, default: false @@ -139,7 +149,7 @@ const selectedAttachments = ref([]) const robotVisible = defineModel('show', { required: true }) const fullscreen = defineModel('fullscreen') const inputMessage = defineModel('input', { required: true }) -const messages = defineModel('messages', { required: true }) +const messages = defineModel('messages', { required: true }) const senderRef = ref | null>(null) watch( @@ -159,63 +169,29 @@ const contentRendererMatches = computed(() => [ }, ...Object.entries(props.bubbleRenderers).map(([type, renderer]) => ({ priority: BubbleRendererMatchPriority.NORMAL, - find: (_message: any, content: any) => content?.type === type, + find: (_message: RobotMessage, content: RobotRenderContentItem) => content?.type === type, renderer })), { priority: BubbleRendererMatchPriority.NORMAL, - find: (message: any, content: any) => content?.type === 'tool' && message.tool_calls?.length, + find: (message: RobotMessage, content: RobotRenderContentItem) => + content?.type === RobotMessageContentType.Tool && Boolean(message.tool_calls?.length), renderer: BubbleRenderers.Tools }, { priority: BubbleRendererMatchPriority.NORMAL, - find: (message: any, content: any) => - !message.loading && message.content && (!content?.type || ['markdown', 'text'].includes(content.type)), + find: (_message: RobotMessage, content: RobotRenderContentItem) => + !content?.type || [RobotMessageContentType.Markdown, RobotMessageContentType.Text].includes(content.type as any), renderer: MarkdownRenderer }, { priority: BubbleRendererMatchPriority.NORMAL, - find: (message: any) => message?.content?.[0]?.type === 'img' || message?.content?.[0]?.type === 'image', + find: (_message: RobotMessage, content: RobotRenderContentItem) => + [RobotMessageContentType.Img, RobotMessageContentType.Image].includes(content?.type as any), renderer: ImgRenderer } ]) -const isAgentMessage = (message: any) => { - const hasAgentContent = message.renderContent?.some((item: any) => { - return item.type === 'agent-content' || item.type === 'agent-loading' - }) - return message.metadata?.chatMode === 'agent' || hasAgentContent -} - -const resolveAgentRenderContent = (message: any) => { - if (!isAgentMessage(message) || message.role !== 'assistant') { - return message.renderContent - } - - const isLastMessage = messages.value.at(-1) === message - const isGenerating = Boolean(message.loading) || (isLastMessage && GeneratingStatus.includes(props.status as any)) - const renderContent = isGenerating - ? message.renderContent - : message.renderContent.filter((item: any) => item.type !== 'agent-loading') - const agentContents = renderContent.filter((item: any) => item.type === 'agent-content') - const finalStatus = agentContents.findLast((item: any) => ['success', 'failed', 'fix'].includes(item.status))?.status - - return renderContent.map((item: any) => { - if (item.type !== 'agent-content' || isGenerating) { - return item - } - - if (!item.status || item.status === 'loading') { - return { - ...item, - status: finalStatus || message.metadata?.agentStatus || 'failed' - } - } - - return item - }) -} - // 处理文件选择事件 const handleSingleFilesSelected = (files: File[] | null, retry = false) => { if (!files?.length) return @@ -271,36 +247,56 @@ const getSvgIcon = (name: string, style?: CSSProperties) => { const aiAvatar = getSvgIcon('AI') const welcomeIcon = getSvgIcon('AI', { fontSize: '44px' }) -const resolveMessageContent = (message: any) => { - if (Array.isArray(message.renderContent) && message.renderContent.length > 0) { - return resolveAgentRenderContent(message) +const resolveMessageContent = (message: RobotMessage) => { + if (props.messageContentResolver) { + return props.messageContentResolver(message, { + messages: messages.value, + status: props.status + }) } - if (isAgentMessage(message) && message.role === 'assistant' && message.content) { - const agentStatus = ['success', 'failed', 'fix'].includes(message.metadata?.agentStatus) - ? message.metadata.agentStatus - : 'failed' - return [ - { - type: 'agent-content', - status: agentStatus, - content: message.content + if (Array.isArray(message.renderContent) && message.renderContent.length > 0) { + return message.renderContent.map((item) => { + if (item?.type === RobotMessageContentType.Img || item?.type === RobotMessageContentType.Image) { + return { + type: RobotMessageContentType.Img, + content: item.content || item.url || item.image_url?.url || '' + } } - ] + if (item?.type === RobotMessageContentType.Text) { + return { + type: RobotMessageContentType.Text, + content: item.content ?? item.text ?? '' + } + } + return item + }) + } + + if (Array.isArray(message.content) && message.content.length > 0) { + const textContent = extractMessageText(message.content) + if (textContent) { + return textContent + } + } + + const textContent = extractMessageText(message.content) + if (textContent) { + return textContent } return message.content } const roleConfigs: Record = { - assistant: { + [RobotMessageRole.Assistant]: { placement: 'start', avatar: aiAvatar }, - user: { + [RobotMessageRole.User]: { placement: 'end' }, - system: { + [RobotMessageRole.System]: { hidden: true } } @@ -320,37 +316,38 @@ const handleSendMessage = async (content: string) => { return } - const userMessage: ChatMessage = { - role: 'user', + const userMessage: RobotMessage = { + role: RobotMessageRole.User, content: messageContent } const files = selectedAttachments.value.filter((item) => item.status === 'success') if (files.length > 0) { - const fileMessages: ChatMessage[] = files.map((file) => ({ - role: 'user', - content: '', - renderContent: [ - { - type: 'img', - content: file.url - } - ] - })) - messages.value.push(...fileMessages) - userMessage.content = files - .map((item) => ({ - type: 'image_url', + userMessage.content = [ + { + type: RobotMessageContentType.Text, + text: messageContent + }, + ...files.map((item) => ({ + type: RobotMessageContentType.ImageUrl, image_url: { url: item.url } })) - .concat({ - type: 'text', - text: messageContent - }) + ] as RobotInputContentPart[] + userMessage.renderContent = [ + { + type: RobotMessageContentType.Text, + content: messageContent + }, + ...files.map((item) => ({ + type: RobotMessageContentType.Img, + content: item.url + })) + ] + } else { userMessage.renderContent = [ { - type: 'text', + type: RobotMessageContentType.Text, content: messageContent } ] diff --git a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue index 6774d28f4f..f2a3d7e012 100644 --- a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue +++ b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue @@ -8,6 +8,7 @@ import DOMPurify from 'dompurify' import MarkdownIt from 'markdown-it' import type { Options } from 'markdown-it' import hljs from 'highlight.js/lib/core' +import { extractMessageText } from '../../utils' import 'highlight.js/styles/github.css' // 按需加载语言 @@ -29,7 +30,7 @@ hljs.registerLanguage('xml', xml) hljs.registerLanguage('shell', shell) interface MarkdownMessage { - content: string + content: unknown } const props = defineProps({ @@ -71,7 +72,15 @@ const markdownIt = new MarkdownIt({ }) const renderContent = computed(() => { - return DOMPurify.sanitize(markdownIt.render(props.message.content)) + let content = '' + + if (typeof props.message.content === 'string') { + content = props.message.content + } else if (Array.isArray(props.message.content)) { + content = extractMessageText(props.message.content) + } + + return DOMPurify.sanitize(markdownIt.render(content)) }) diff --git a/packages/plugins/robot/src/composables/core/useConversation.ts b/packages/plugins/robot/src/composables/core/useConversation.ts index c5dec73ba3..94c6045b60 100644 --- a/packages/plugins/robot/src/composables/core/useConversation.ts +++ b/packages/plugins/robot/src/composables/core/useConversation.ts @@ -12,6 +12,8 @@ import { import type { CompletionChoice } from '@opentiny/tiny-robot-kit' import { STATUS, type MessageState } from '../../constants/status' import type { OpenAICompatibleProvider } from '../../services/OpenAICompatibleProvider' +import { extractMessageText } from '../../utils/chat.utils' +import { RobotMessageRole } from '../../types/chat.types' export interface ConversationAdapterOptions { provider: Pick @@ -100,7 +102,8 @@ const createResponseProvider = ( } const updateMessageMetadata = (currentMessage: ChatMessage, chunk: ChatCompletion, choice?: CompletionChoice) => { - currentMessage.role = choice?.delta?.role || choice?.message?.role || currentMessage.role || 'assistant' + currentMessage.role = + choice?.delta?.role || choice?.message?.role || currentMessage.role || RobotMessageRole.Assistant currentMessage.loading = undefined currentMessage.renderContent ||= [] currentMessage.metadata ||= {} @@ -180,7 +183,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { const saveConversations = () => { conversations.value.forEach((conversation) => { - void saveConversation(conversation) + saveConversation(conversation) }) } @@ -198,7 +201,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { currentConversationMetadata = conversation.metadata } conversation.updatedAt = Date.now() - void saveConversation(conversation) + saveConversation(conversation) } const updateTitle = (conversationId: string, title?: string) => { @@ -258,7 +261,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { } } currentConversationMetadata = conversation.metadata || {} - void saveConversation(conversation) + saveConversation(conversation) return currentId } } @@ -328,7 +331,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { const currentTitle = currentConversation.title if (currentTitle === defaultTitle && currentId) { const messageContent = getActiveEngine()?.messages.value.find((item) => item.role === 'user')?.content - const contentStr = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent) + const contentStr = extractMessageText(messageContent) || JSON.stringify(messageContent) updateTitle(currentId, contentStr.substring(0, 20)) } } diff --git a/packages/plugins/robot/src/types/chat.types.ts b/packages/plugins/robot/src/types/chat.types.ts index f5a1d7f691..dfde3ec246 100644 --- a/packages/plugins/robot/src/types/chat.types.ts +++ b/packages/plugins/robot/src/types/chat.types.ts @@ -1,6 +1,8 @@ import type { BubbleContentItem } from '@opentiny/tiny-robot' import type { ResponseToolCall } from './mcp.types' import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import type { ChatMode } from './mode.types' +import type { STATUS } from '../constants/status' export interface RequestOptions { url?: string @@ -36,18 +38,183 @@ export interface LLMMessage { [prop: string]: unknown } +/** + * 消息角色枚举。 + * 与模型协议中的 role 对齐,用于区分消息发送方。 + */ +export enum RobotMessageRole { + Assistant = 'assistant', + User = 'user', + System = 'system', + Tool = 'tool' +} + +/** + * 气泡内容类型枚举。 + * type 描述的是一条消息中“某一段渲染内容”应该如何展示。 + */ +export enum RobotMessageContentType { + Text = 'text', + Markdown = 'markdown', + Tool = 'tool', + Img = 'img', + Image = 'image', + ImageUrl = 'image_url', + Loading = 'loading', + AgentContent = 'agent-content', + AgentLoading = 'agent-loading', + Reasoning = 'reasoning' +} + +/** + * Agent 渲染片段状态。 + * item.status 描述的是单个 agent 片段的业务执行结果,不等同于会话级请求状态。 + */ +export enum AgentMessageStatus { + Loading = 'loading', + Reasoning = 'reasoning', + Running = 'running', + Success = 'success', + Failed = 'failed', + Fix = 'fix' +} + +export const AgentFinalStatuses = [ + AgentMessageStatus.Success, + AgentMessageStatus.Failed, + AgentMessageStatus.Fix +] as const + +export type AgentFinalStatus = typeof AgentFinalStatuses[number] + +export interface RobotTextContentPart { + type: RobotMessageContentType.Text + text?: string + content?: string +} + +export interface RobotImageUrlContentPart { + type: RobotMessageContentType.ImageUrl + image_url: { + url: string + } +} + +export type RobotInputContentPart = string | RobotTextContentPart | RobotImageUrlContentPart + +export interface BubbleTextContentItem extends BubbleContentItem { + type: RobotMessageContentType.Text | RobotMessageContentType.Markdown + content?: string + text?: string +} + +export interface BubbleImageContentItem extends BubbleContentItem { + type: RobotMessageContentType.Img | RobotMessageContentType.Image + content?: string + url?: string + image_url?: { + url: string + } +} + +export interface BubbleToolContentItem extends BubbleContentItem { + type: RobotMessageContentType.Tool + name?: string + status?: string + content?: unknown + toolCallId?: string + formatPretty?: boolean +} + +export interface BubbleLoadingContentItem extends BubbleContentItem { + type: RobotMessageContentType.Loading + content?: string +} + +export interface BubbleAgentContentItem extends BubbleContentItem { + type: RobotMessageContentType.AgentContent | RobotMessageContentType.AgentLoading + status?: AgentMessageStatus | string + content?: unknown + contentType?: RobotMessageContentType | string +} + +export type RobotRenderContentItem = + | BubbleTextContentItem + | BubbleImageContentItem + | BubbleToolContentItem + | BubbleLoadingContentItem + | BubbleAgentContentItem + | (BubbleContentItem & { + type?: string + content?: unknown + status?: string + text?: string + url?: string + image_url?: { url: string } + toolCallId?: string + contentType?: string + formatPretty?: boolean + name?: string + }) + +export interface RobotMessageMetadata { + chatMode?: ChatMode | string + agentStatus?: AgentMessageStatus | string + createdAt?: number + updatedAt?: number + id?: string + model?: string + [key: string]: unknown +} + +export interface RobotMessageState { + thinking?: boolean + toolsHandled?: boolean + toolCall?: Record + toolCallResults?: Record + [key: string]: unknown +} + +export type RobotMessageContent = string | RobotInputContentPart[] + export type Message = ChatMessage & { - renderContent: BubbleContentItem[] + role: RobotMessageRole | string + content: RobotMessageContent + renderContent: RobotRenderContentItem[] tool_calls: ResponseToolCall[] + metadata?: RobotMessageMetadata + state?: RobotMessageState } -export interface RobotMessage { - role: string - content: string | BubbleContentItem[] - renderContent?: Array +export interface RobotMessage extends Omit, 'role' | 'content' | 'renderContent' | 'tool_calls'> { + role: RobotMessageRole | string + content: RobotMessageContent + renderContent?: RobotRenderContentItem[] + tool_calls?: ResponseToolCall[] + metadata?: RobotMessageMetadata + state?: RobotMessageState + loading?: boolean + aborted?: boolean + reasoning_content?: string + originContent?: string [prop: string]: unknown } +export interface MessageResolverContext { + messages: RobotMessage[] + /** + * context.status 是当前整轮请求的生命周期状态: + * pending/streaming/finished/error/aborted。 + */ + status: STATUS | string +} + +export type MessageContentResolver = (message: RobotMessage, context: MessageResolverContext) => unknown + +export const isAgentFinalStatus = (status: unknown): status is AgentFinalStatus => { + return typeof status === 'string' && AgentFinalStatuses.includes(status as AgentFinalStatus) +} + export interface LLMRequestBody { baseUrl?: string model?: string diff --git a/packages/plugins/robot/src/utils/chat.utils.ts b/packages/plugins/robot/src/utils/chat.utils.ts index 702a2cc343..00d822a854 100644 --- a/packages/plugins/robot/src/utils/chat.utils.ts +++ b/packages/plugins/robot/src/utils/chat.utils.ts @@ -1,6 +1,13 @@ import { toRaw } from 'vue' import type { StreamHandler } from '@opentiny/tiny-robot-kit' -import type { LLMMessage, RobotMessage } from '../types' +import { + RobotMessageContentType, + type LLMMessage, + type RobotInputContentPart, + type RobotMessage, + type RobotMessageContent, + type RobotRenderContentItem +} from '../types' // 格式化LLM输入messages消息 export const formatMessages = (messages: LLMMessage[]) => { @@ -31,6 +38,32 @@ export const serializeError = (err: unknown): string => { } } +export const extractMessageText = (content: RobotMessageContent | RobotRenderContentItem[] | unknown): string => { + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return '' + } + + return content + .map((item) => { + if (typeof item === 'string') { + return item + } + + const typedItem = item as RobotInputContentPart | RobotRenderContentItem + if (typedItem?.type === RobotMessageContentType.Text) { + return typedItem.text ?? typedItem.content ?? '' + } + + return '' + }) + .filter(Boolean) + .join('\n') +} + /** * 合并字符串字段。如果值是对象,则递归合并字符串字段 * @param target 目标对象 diff --git a/packages/plugins/robot/test/composables/core/useConversation.test.ts b/packages/plugins/robot/test/composables/core/useConversation.test.ts new file mode 100644 index 0000000000..1521b8dbde --- /dev/null +++ b/packages/plugins/robot/test/composables/core/useConversation.test.ts @@ -0,0 +1,691 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { markRaw, ref } from 'vue' + +type MockConversation = { + id: string + title: string + createdAt: number + updatedAt: number + metadata?: Record + engine?: { + messages: ReturnType> + sendMessage: ReturnType + send: ReturnType + } +} + +const storageSaveConversation = vi.fn() +const deleteConversation = vi.fn() +const clear = vi.fn() +const updateConversationTitle = vi.fn((conversationId: string, title?: string) => { + const conversation = conversations.value.find((item) => item.id === conversationId) + if (conversation) { + conversation.title = title || conversation.title + } +}) +const saveMessages = vi.fn() +const abortActiveRequest = vi.fn() +const switchConversationKit = vi.fn() +const createConversationKit = vi.fn() + +const activeConversationId = ref('conv-1') +const conversations = ref([]) +const activeConversation = ref(null) + +let lastUseConversationOptions: any = null + +const syncActiveConversation = () => { + activeConversation.value = conversations.value.find((item) => item.id === activeConversationId.value) || null +} + +const updateCurrentEngine = (engine?: MockConversation['engine']) => { + const index = conversations.value.findIndex((item) => item.id === activeConversationId.value) + if (index !== -1) { + conversations.value[index] = { + ...conversations.value[index], + engine + } + } + syncActiveConversation() +} + +const createEngine = (messages: any[] = []) => ({ + messages: ref(messages), + sendMessage: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined) +}) + +const createRawEngine = ( + messages: any[] = [], + overrides: Partial = {} +): MockConversation['engine'] => + markRaw({ + ...createEngine(messages), + ...overrides + }) as MockConversation['engine'] + +const createConversationRecord = ( + overrides: Partial = {}, + messageOverrides: any[] = [] +): MockConversation => ({ + id: overrides.id || 'conv-1', + title: overrides.title || '新会话', + createdAt: overrides.createdAt || 100, + updatedAt: overrides.updatedAt || 100, + metadata: overrides.metadata || {}, + engine: overrides.engine || createRawEngine(messageOverrides), + ...overrides +}) + +vi.mock('@opentiny/tiny-robot-kit', () => ({ + localStorageStrategyFactory: () => ({ + saveConversation: storageSaveConversation + }), + useConversation: (options: any) => { + lastUseConversationOptions = options + return { + conversations, + activeConversationId, + activeConversation, + createConversation: createConversationKit, + switchConversation: switchConversationKit, + deleteConversation, + clear, + updateConversationTitle, + saveMessages, + abortActiveRequest + } + } +})) + +describe('useConversationAdapter', () => { + beforeEach(() => { + vi.clearAllMocks() + lastUseConversationOptions = null + activeConversationId.value = 'conv-1' + conversations.value = [ + createConversationRecord( + { + id: 'conv-1', + title: '新会话', + metadata: { chatMode: 'chat' } + }, + [] + ) + ] + syncActiveConversation() + + createConversationKit.mockImplementation(({ title, metadata }: any) => { + const conversation = createConversationRecord({ + id: `conv-${conversations.value.length + 1}`, + title, + metadata: metadata || {}, + createdAt: 100 + conversations.value.length, + updatedAt: 100 + conversations.value.length + }) + conversations.value.push(conversation) + activeConversationId.value = conversation.id + syncActiveConversation() + return conversation + }) + + switchConversationKit.mockImplementation(async (conversationId: string) => { + activeConversationId.value = conversationId + syncActiveConversation() + return true + }) + }) + + const createAdapter = async () => { + const module = await import('../../../src/composables/core/useConversation') + + return module.useConversationAdapter({ + provider: { + chatStream: vi.fn().mockResolvedValue(undefined) + } as any, + onStreamData: vi.fn(), + onFinishRequest: vi.fn().mockResolvedValue(undefined), + onMessageProcessed: vi.fn().mockResolvedValue(undefined), + statusManager: { + isProcessing: vi.fn(() => false), + setProcessing: vi.fn(), + resetProcessing: vi.fn() + } + }) + } + + describe('conversation state and save helpers', () => { + it('exposes current conversation id and conversation list', async () => { + const adapter = await createAdapter() + + expect(adapter.conversationState.currentId).toBe('conv-1') + expect(adapter.conversationState.conversations).toHaveLength(1) + expect(adapter.conversationState.conversations[0].id).toBe('conv-1') + }) + + it('saves all conversations through the storage strategy', async () => { + conversations.value.push( + createConversationRecord({ + id: 'conv-2', + title: '第二个会话', + metadata: { chatMode: 'agent' } + }) + ) + const adapter = await createAdapter() + + adapter.saveConversations() + + expect(storageSaveConversation).toHaveBeenCalledTimes(2) + expect(storageSaveConversation).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: 'conv-1', + title: '新会话', + metadata: { chatMode: 'chat' } + }) + ) + expect(storageSaveConversation).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: 'conv-2', + title: '第二个会话', + metadata: { chatMode: 'agent' } + }) + ) + }) + + it('does nothing when updateMetadata targets a missing conversation', async () => { + const adapter = await createAdapter() + + adapter.updateMetadata('missing', { chatMode: 'agent' }) + + expect(storageSaveConversation).not.toHaveBeenCalled() + expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'chat' }) + }) + + it('merges metadata and persists the active conversation', async () => { + const adapter = await createAdapter() + + adapter.updateMetadata('conv-1', { chatMode: 'agent', feature: 'vision' }) + + expect(adapter.conversationState.conversations[0].metadata).toEqual({ + chatMode: 'agent', + feature: 'vision' + }) + expect(storageSaveConversation).toHaveBeenCalledTimes(1) + expect(storageSaveConversation).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'conv-1', + metadata: { + chatMode: 'agent', + feature: 'vision' + } + }) + ) + }) + + it('updates the current metadata cache so future assistant chunks inherit the latest mode', async () => { + const onStreamData = vi.fn() + const onFinishRequest = vi.fn().mockResolvedValue(undefined) + const onMessageProcessed = vi.fn().mockResolvedValue(undefined) + const statusManager = { + isProcessing: vi.fn(() => false), + setProcessing: vi.fn(), + resetProcessing: vi.fn() + } + const module = await import('../../../src/composables/core/useConversation') + const adapter = module.useConversationAdapter({ + provider: { + chatStream: vi.fn().mockResolvedValue(undefined) + } as any, + onStreamData, + onFinishRequest, + onMessageProcessed, + statusManager + }) + + adapter.updateMetadata('conv-1', { chatMode: 'agent' }) + + const currentMessage = { + role: '', + renderContent: [], + metadata: {} + } + const chunk = { + created: 123, + id: 'cmpl-1', + model: 'model-a' + } + const choice = { + delta: { + role: 'assistant' + } + } + + lastUseConversationOptions.useMessageOptions.onCompletionChunk( + { + currentMessage, + messages: [currentMessage], + chunk, + choice + }, + () => {} + ) + + expect(currentMessage.metadata.chatMode).toBe('agent') + expect(currentMessage.metadata.createdAt).toBe(123) + expect(currentMessage.metadata.id).toBe('cmpl-1') + expect(currentMessage.metadata.model).toBe('model-a') + expect(onStreamData).toHaveBeenCalledTimes(1) + }) + }) + + describe('message manager', () => { + it('exposes messages from the active engine', async () => { + updateCurrentEngine(createRawEngine([{ role: 'user', content: 'hello' }])) + const adapter = await createAdapter() + + expect(adapter.messageManager.messages.value).toEqual([{ role: 'user', content: 'hello' }]) + }) + + it('delegates sendMessage to the active engine', async () => { + const sendMessage = vi.fn().mockResolvedValue('sent') + updateCurrentEngine( + createRawEngine([], { + messages: ref([]), + sendMessage, + send: vi.fn().mockResolvedValue(undefined) + }) + ) + const adapter = await createAdapter() + + await adapter.messageManager.sendMessage('hello world') + + expect(sendMessage).toHaveBeenCalledWith('hello world') + }) + + it('delegates send to the active engine', async () => { + const send = vi.fn().mockResolvedValue('sent') + updateCurrentEngine( + createRawEngine([], { + messages: ref([]), + sendMessage: vi.fn().mockResolvedValue(undefined), + send + }) + ) + const adapter = await createAdapter() + const message = { role: 'user', content: 'hello' } + + await adapter.messageManager.send(message as any) + + expect(send).toHaveBeenCalledWith(message) + }) + + it('falls back to resolved promises when there is no active engine', async () => { + updateCurrentEngine(undefined) + const adapter = await createAdapter() + + await expect(adapter.messageManager.sendMessage('hello')).resolves.toBeUndefined() + await expect(adapter.messageManager.send({ role: 'user', content: 'msg' } as any)).resolves.toBeUndefined() + }) + + it('delegates abortRequest to the underlying conversation hook', async () => { + const adapter = await createAdapter() + + adapter.messageManager.abortRequest() + + expect(abortActiveRequest).toHaveBeenCalledTimes(1) + }) + }) + + describe('createConversation', () => { + it('reuses the current empty conversation instead of creating a new one', async () => { + const adapter = await createAdapter() + + const result = adapter.createConversation('重命名会话', { chatMode: 'agent' }) + + expect(result).toBe('conv-1') + expect(createConversationKit).not.toHaveBeenCalled() + expect(adapter.conversationState.conversations[0].title).toBe('重命名会话') + expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'agent' }) + expect(storageSaveConversation).toHaveBeenCalledTimes(1) + }) + + it('creates a brand new conversation when the current one already contains user content', async () => { + updateCurrentEngine(createRawEngine([{ role: 'user', content: '已有消息' }])) + const adapter = await createAdapter() + + const result = adapter.createConversation('新的会话', { chatMode: 'agent' }) + + expect(result).toBe('conv-2') + expect(createConversationKit).toHaveBeenCalledWith({ + title: '新的会话', + metadata: { chatMode: 'agent' } + }) + expect(adapter.conversationState.currentId).toBe('conv-2') + expect(adapter.conversationState.conversations).toHaveLength(2) + }) + + it('treats tool calls as non-empty conversation content', async () => { + updateCurrentEngine(createRawEngine([{ role: 'assistant', content: '', tool_calls: [{ id: 'call-1' }] }])) + const adapter = await createAdapter() + + adapter.createConversation('工具会话', { chatMode: 'chat' }) + + expect(createConversationKit).toHaveBeenCalledTimes(1) + }) + + it('treats tool_call_id as non-empty conversation content', async () => { + updateCurrentEngine(createRawEngine([{ role: 'tool', content: '', tool_call_id: 'call-1' }])) + const adapter = await createAdapter() + + adapter.createConversation('工具结果会话', { chatMode: 'chat' }) + + expect(createConversationKit).toHaveBeenCalledTimes(1) + }) + + it('treats renderContent-only messages as non-empty conversation content', async () => { + updateCurrentEngine(createRawEngine([{ role: 'user', content: '', renderContent: [{ type: 'text' }] }])) + const adapter = await createAdapter() + + adapter.createConversation('渲染内容会话', { chatMode: 'chat' }) + + expect(createConversationKit).toHaveBeenCalledTimes(1) + }) + + it('keeps existing metadata when reusing an empty conversation without new metadata', async () => { + conversations.value[0].metadata = { chatMode: 'chat', tag: 'old' } + const adapter = await createAdapter() + + adapter.createConversation('空会话改名') + + expect(adapter.conversationState.conversations[0].metadata).toEqual({ chatMode: 'chat', tag: 'old' }) + expect(storageSaveConversation).toHaveBeenCalledTimes(1) + }) + }) + + describe('switchConversation', () => { + it('returns null when the target conversation does not exist', async () => { + const adapter = await createAdapter() + + const result = await adapter.switchConversation('missing') + + expect(result).toBeNull() + expect(switchConversationKit).not.toHaveBeenCalled() + }) + + it('delegates to the underlying switch function and calls onStart with wrapped apis', async () => { + conversations.value.push( + createConversationRecord({ + id: 'conv-2', + title: '第二个会话', + metadata: { chatMode: 'agent' } + }) + ) + const adapter = await createAdapter() + const onStart = vi.fn() + + const result = await adapter.switchConversation('conv-2', onStart) + + expect(result).toBe(true) + expect(switchConversationKit).toHaveBeenCalledWith('conv-2') + expect(onStart).toHaveBeenCalledTimes(1) + const [state, messages, methods] = onStart.mock.calls[0] + expect(state.currentId).toBe('conv-2') + expect(messages).toEqual([]) + expect(methods.createConversation).toBeTypeOf('function') + expect(methods.switchConversation).toBeTypeOf('function') + expect(methods.updateMetadata).toBeTypeOf('function') + }) + + it('does not call onStart when the underlying switch returns a falsy result', async () => { + conversations.value.push( + createConversationRecord({ + id: 'conv-2', + title: '第二个会话', + metadata: { chatMode: 'agent' } + }) + ) + switchConversationKit.mockResolvedValueOnce(false) + const adapter = await createAdapter() + const onStart = vi.fn() + + const result = await adapter.switchConversation('conv-2', onStart) + + expect(result).toBe(false) + expect(onStart).not.toHaveBeenCalled() + }) + }) + + describe('autoSetTitle', () => { + it('updates the default title using the first user text message', async () => { + updateCurrentEngine( + createRawEngine([ + { role: 'system', content: 'system prompt' }, + { role: 'user', content: '请帮我生成一个表单页面' }, + { role: 'assistant', content: '好的' } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '请帮我生成一个表单页面') + }) + + it('extracts title text from multimodal user content arrays', async () => { + updateCurrentEngine( + createRawEngine([ + { + role: 'user', + content: [ + { type: 'text', text: '对比这两张图片的布局差异并总结' }, + { type: 'image_url', image_url: { url: 'https://example.com/1.png' } }, + { type: 'image_url', image_url: { url: 'https://example.com/2.png' } } + ] + } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '对比这两张图片的布局差异并总结') + }) + + it('joins multiple text fragments from multimodal content before truncation', async () => { + updateCurrentEngine( + createRawEngine([ + { + role: 'user', + content: [ + { type: 'text', text: '第一段需求' }, + { type: 'image_url', image_url: { url: 'https://example.com/1.png' } }, + { type: 'text', text: '第二段补充说明' } + ] + } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '第一段需求\n第二段补充说明') + }) + + it('falls back to JSON when multimodal content does not contain any text part', async () => { + const imageOnlyMessage = [ + { type: 'image_url', image_url: { url: 'https://example.com/1.png' } }, + { type: 'image_url', image_url: { url: 'https://example.com/2.png' } } + ] + updateCurrentEngine( + createRawEngine([ + { + role: 'user', + content: imageOnlyMessage + } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', JSON.stringify(imageOnlyMessage).substring(0, 20)) + }) + + it('truncates long titles to 20 characters', async () => { + updateCurrentEngine( + createRawEngine([ + { + role: 'user', + content: '这是一个非常长非常长非常长非常长的标题文本,用于测试截断逻辑' + } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith( + 'conv-1', + '这是一个非常长非常长非常长非常长的标题文本'.substring(0, 20) + ) + }) + + it('uses the first user message instead of later user messages', async () => { + updateCurrentEngine( + createRawEngine([ + { + role: 'user', + content: '第一次提问标题' + }, + { + role: 'assistant', + content: '回答' + }, + { + role: 'user', + content: '第二次提问标题' + } + ]) + ) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).toHaveBeenCalledWith('conv-1', '第一次提问标题') + }) + + it('does not update title when the conversation title has already been customized', async () => { + conversations.value[0].title = '用户手动标题' + updateCurrentEngine(createRawEngine([{ role: 'user', content: '不会被使用' }])) + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-1') + + expect(updateConversationTitle).not.toHaveBeenCalled() + }) + + it('does not update title for inactive conversations', async () => { + conversations.value.push( + createConversationRecord({ + id: 'conv-2', + title: '新会话', + metadata: { chatMode: 'agent' } + }) + ) + conversations.value[1] = { + ...conversations.value[1], + engine: createRawEngine([{ role: 'user', content: 'inactive conversation title' }]) + } + syncActiveConversation() + const adapter = await createAdapter() + + adapter.autoSetTitle('conv-2') + + expect(updateConversationTitle).not.toHaveBeenCalled() + }) + + it('does not update title when the conversation cannot be found', async () => { + const adapter = await createAdapter() + + adapter.autoSetTitle('missing') + + expect(updateConversationTitle).not.toHaveBeenCalled() + }) + }) + + describe('adapter plugin behavior', () => { + it('marks message state as error when the useMessage plugin reports an error', async () => { + const adapter = await createAdapter() + const error = new Error('stream failed') + + lastUseConversationOptions.useMessageOptions.plugins[0].onError({ error }) + + expect(adapter.messageManager.messageState.status).toBe('error') + expect(adapter.messageManager.messageState.errorMsg).toBe(error) + }) + + it('calls onFinishRequest after the adapter plugin observes a finished response', async () => { + const onFinishRequest = vi.fn().mockResolvedValue(undefined) + const statusManager = { + isProcessing: vi.fn(() => false), + setProcessing: vi.fn(), + resetProcessing: vi.fn() + } + const module = await import('../../../src/composables/core/useConversation') + module.useConversationAdapter({ + provider: { + chatStream: vi.fn().mockResolvedValue(undefined) + } as any, + onStreamData: vi.fn(), + onFinishRequest, + onMessageProcessed: vi.fn().mockResolvedValue(undefined), + statusManager + }) + const messages = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'world' } + ] + + await lastUseConversationOptions.useMessageOptions.plugins[0].onAfterRequest({ + messages, + lastChoice: { + finish_reason: 'stop' + } + }) + + expect(statusManager.setProcessing).toHaveBeenCalledTimes(1) + expect(onFinishRequest).toHaveBeenCalledWith('stop', messages, [messages[0]], expect.any(Object)) + }) + + it('skips onFinishRequest when the status manager is already processing', async () => { + const onFinishRequest = vi.fn().mockResolvedValue(undefined) + const statusManager = { + isProcessing: vi.fn(() => true), + setProcessing: vi.fn(), + resetProcessing: vi.fn() + } + const module = await import('../../../src/composables/core/useConversation') + module.useConversationAdapter({ + provider: { + chatStream: vi.fn().mockResolvedValue(undefined) + } as any, + onStreamData: vi.fn(), + onFinishRequest, + onMessageProcessed: vi.fn().mockResolvedValue(undefined), + statusManager + }) + + await lastUseConversationOptions.useMessageOptions.plugins[0].onAfterRequest({ + messages: [{ role: 'user', content: 'hello' }], + lastChoice: { + finish_reason: 'stop' + } + }) + + expect(statusManager.setProcessing).not.toHaveBeenCalled() + expect(onFinishRequest).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/plugins/robot/test/utils/chat.utils.test.ts b/packages/plugins/robot/test/utils/chat.utils.test.ts new file mode 100644 index 0000000000..9a102ab6bd --- /dev/null +++ b/packages/plugins/robot/test/utils/chat.utils.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it, vi } from 'vitest' +import { + addSystemPrompt, + extractMessageText, + formatMessages, + mergeStringFields, + processSSEStream, + removeLoading, + serializeError +} from '../../src/utils/chat.utils' + +describe('chat utils', () => { + describe('formatMessages', () => { + it('filters out completely empty messages', () => { + const result = formatMessages([ + { role: 'user', content: '' }, + { role: 'assistant', content: 'hello' } + ] as any) + + expect(result).toEqual([{ role: 'assistant', content: 'hello' }]) + }) + + it('keeps messages that only contain tool calls', () => { + const result = formatMessages([ + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'tool-1', type: 'function' }] + } + ] as any) + + expect(result).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'tool-1', type: 'function' }] + } + ]) + }) + + it('keeps messages that only contain tool_call_id', () => { + const result = formatMessages([ + { + role: 'tool', + content: '', + tool_call_id: 'tool-1' + } + ] as any) + + expect(result).toEqual([ + { + role: 'tool', + content: '', + tool_call_id: 'tool-1' + } + ]) + }) + + it('preserves multimodal content arrays', () => { + const content = [ + { type: 'text', text: '请对比图片差异' }, + { type: 'image_url', image_url: { url: 'https://example.com/1.png' } } + ] + + const result = formatMessages([ + { + role: 'user', + content + } + ] as any) + + expect(result).toEqual([ + { + role: 'user', + content + } + ]) + }) + + it('preserves reasoning_content when present', () => { + const result = formatMessages([ + { + role: 'assistant', + content: 'answer', + reasoning_content: 'thoughts' + } + ] as any) + + expect(result).toEqual([ + { + role: 'assistant', + content: 'answer', + reasoning_content: 'thoughts' + } + ]) + }) + + it('keeps tool fields together with normal content', () => { + const result = formatMessages([ + { + role: 'assistant', + content: 'tool output', + tool_calls: [{ id: 'tool-1' }], + reasoning_content: 'internal' + } + ] as any) + + expect(result[0]).toEqual({ + role: 'assistant', + content: 'tool output', + tool_calls: [{ id: 'tool-1' }], + reasoning_content: 'internal' + }) + }) + }) + + describe('serializeError', () => { + it('returns an empty string for undefined and null', () => { + expect(serializeError(undefined)).toBe('') + expect(serializeError(null)).toBe('') + }) + + it('serializes Error instances into structured json strings', () => { + expect(serializeError(new TypeError('boom'))).toBe('{"name":"TypeError","message":"boom"}') + }) + + it('returns plain strings unchanged', () => { + expect(serializeError('plain text')).toBe('plain text') + }) + + it('json-stringifies ordinary objects', () => { + expect(serializeError({ code: 500, message: 'fail' })).toBe('{"code":500,"message":"fail"}') + }) + + it('falls back to String() for non-json-serializable values', () => { + const value = 1n + + expect(serializeError(value)).toBe('1') + }) + }) + + describe('extractMessageText', () => { + it('returns plain string content as-is', () => { + expect(extractMessageText('hello')).toBe('hello') + }) + + it('joins text items from mixed content arrays', () => { + const result = extractMessageText([ + 'line 1', + { type: 'text', text: 'line 2' }, + { type: 'text', content: 'line 3' }, + { type: 'image_url', image_url: { url: 'https://example.com/a.png' } } + ]) + + expect(result).toBe('line 1\nline 2\nline 3') + }) + + it('returns an empty string for unsupported content', () => { + expect(extractMessageText({ foo: 'bar' })).toBe('') + }) + }) + + describe('mergeStringFields', () => { + it('concatenates sibling string fields', () => { + const result = mergeStringFields( + { + content: 'hello', + title: 'foo' + }, + { + content: ' world', + title: ' bar' + } + ) + + expect(result).toEqual({ + content: 'hello world', + title: 'foo bar' + }) + }) + + it('recursively merges nested object fields', () => { + const result = mergeStringFields( + { + delta: { + content: 'hello', + metadata: { + reason: 'a' + } + } + }, + { + delta: { + content: ' world', + metadata: { + reason: 'b' + } + } + } + ) + + expect(result).toEqual({ + delta: { + content: 'hello world', + metadata: { + reason: 'ab' + } + } + }) + }) + + it('copies missing fields from the source object', () => { + const result = mergeStringFields( + { + delta: { + content: 'hello' + } + }, + { + delta: { + role: 'assistant' + }, + finish_reason: 'stop' + } + ) + + expect(result).toEqual({ + delta: { + content: 'hello', + role: 'assistant' + }, + finish_reason: 'stop' + }) + }) + + it('overwrites falsy non-string values and recursively merges nested objects', () => { + const result = mergeStringFields( + { + index: 0, + done: false, + meta: { + step: 'a' + } + }, + { + index: 1, + done: true, + meta: { + step: 'b' + } + } + ) + + expect(result).toEqual({ + index: 1, + done: true, + meta: { + step: 'ab' + } + }) + }) + }) + + describe('processSSEStream', () => { + it('parses standard SSE chunks and forwards them to the handler', () => { + const handler = { + onData: vi.fn(), + onDone: vi.fn(), + onError: vi.fn() + } + const data = [ + 'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}', + '', + 'data: {"choices":[{"delta":{"content":" world"},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '' + ].join('\n\n') + + processSSEStream(data, handler as any) + + expect(handler.onData).toHaveBeenCalledTimes(2) + expect(handler.onDone).toHaveBeenCalledWith('stop') + }) + + it('uses the latest finish reason before DONE', () => { + const handler = { + onData: vi.fn(), + onDone: vi.fn(), + onError: vi.fn() + } + const data = [ + 'data: {"choices":[{"delta":{"content":"a"},"finish_reason":null}]}', + '', + 'data: {"choices":[{"delta":{"content":"b"},"finish_reason":"tool_calls"}]}', + '', + 'data: [DONE]', + '' + ].join('\n\n') + + processSSEStream(data, handler as any) + + expect(handler.onDone).toHaveBeenCalledWith('tool_calls') + }) + + it('ignores blank segments and malformed lines that do not start with data', () => { + const handler = { + onData: vi.fn(), + onDone: vi.fn(), + onError: vi.fn() + } + const data = ['event: ping', '', 'data: [DONE]', ''].join('\n\n') + + processSSEStream(data, handler as any) + + expect(handler.onData).not.toHaveBeenCalled() + expect(handler.onDone).toHaveBeenCalledWith(undefined) + }) + + it('swallows JSON parse errors and continues parsing later chunks', () => { + const handler = { + onData: vi.fn(), + onDone: vi.fn(), + onError: vi.fn() + } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const data = [ + 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}', + '', + 'data: {"choices":', + '', + 'data: {"choices":[{"delta":{"content":"still ok"},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '' + ].join('\n\n') + + processSSEStream(data, handler as any) + + expect(handler.onData).toHaveBeenCalledTimes(2) + expect(handler.onDone).toHaveBeenCalledWith('stop') + expect(consoleError).toHaveBeenCalled() + + consoleError.mockRestore() + }) + }) + + describe('removeLoading', () => { + it('removes the last loading item by default', () => { + const messages = [ + { + renderContent: [ + { type: 'text', content: 'hello' }, + { type: 'loading', content: '' }, + { type: 'agent-loading', content: '' } + ] + } + ] + + removeLoading(messages as any) + + expect(messages[0].renderContent).toEqual([ + { type: 'text', content: 'hello' }, + { type: 'loading', content: '' } + ]) + }) + + it('removes the last loading item that matches the provided name', () => { + const messages = [ + { + renderContent: [ + { type: 'loading', content: 'tool-a' }, + { type: 'loading', content: 'tool-b' }, + { type: 'loading', content: 'tool-a' } + ] + } + ] + + removeLoading(messages as any, 'tool-a') + + expect(messages[0].renderContent).toEqual([ + { type: 'loading', content: 'tool-a' }, + { type: 'loading', content: 'tool-b' } + ]) + }) + + it('does nothing when there is no renderContent', () => { + const messages = [{}] + + expect(() => removeLoading(messages as any)).not.toThrow() + expect(messages).toEqual([{}]) + }) + + it('does nothing when no loading item matches the provided name', () => { + const messages = [ + { + renderContent: [ + { type: 'text', content: 'hello' }, + { type: 'loading', content: 'tool-a' } + ] + } + ] + + removeLoading(messages as any, 'tool-b') + + expect(messages[0].renderContent).toEqual([ + { type: 'text', content: 'hello' }, + { type: 'loading', content: 'tool-a' } + ]) + }) + }) + + describe('addSystemPrompt', () => { + it('adds a system prompt when the message list is empty', () => { + const messages: any[] = [] + + addSystemPrompt(messages, '你是一个助手') + + expect(messages).toEqual([{ role: 'system', content: '你是一个助手' }]) + }) + + it('prepends a system prompt when the first message is not system', () => { + const messages = [{ role: 'user', content: 'hello' }] + + addSystemPrompt(messages as any, '你是一个助手') + + expect(messages).toEqual([ + { role: 'system', content: '你是一个助手' }, + { role: 'user', content: 'hello' } + ]) + }) + + it('updates the existing system prompt when content has changed', () => { + const messages = [ + { role: 'system', content: '旧提示词' }, + { role: 'user', content: 'hello' } + ] + + addSystemPrompt(messages as any, '新提示词') + + expect(messages[0]).toEqual({ role: 'system', content: '新提示词' }) + }) + + it('keeps the existing system prompt when it already matches', () => { + const messages = [ + { role: 'system', content: '固定提示词' }, + { role: 'user', content: 'hello' } + ] + + addSystemPrompt(messages as any, '固定提示词') + + expect(messages).toEqual([ + { role: 'system', content: '固定提示词' }, + { role: 'user', content: 'hello' } + ]) + }) + }) +})