diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index d51b00eb6..298bbd904 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" @@ -106,6 +106,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 +151,6 @@ const showSetting = ref(false) const { mappedStatus, - inputMessage, messages, changeChatMode, abortRequest, @@ -248,6 +248,55 @@ const openAIRobot = () => { // 当前Robot的bubbleRenderers无法做到响应式更新,因此Agent模式的type要与Chat模式不同 const bubbleRenderers = { 'agent-content': AgentRenderer, 'agent-loading': AgentRenderer } +const resolveChatMessageContent = (message: any, context: { messages: any[]; status: string }) => { + const hasAgentContent = message.renderContent?.some((item: any) => { + return item.type === 'agent-content' || item.type === 'agent-loading' + }) + const isAgentMessage = message.metadata?.chatMode === 'agent' || hasAgentContent + + if (!isAgentMessage || message.role !== 'assistant') { + return Array.isArray(message.renderContent) && message.renderContent.length > 0 + ? message.renderContent + : message.content + } + + const isLastMessage = context.messages.at(-1) === message + const isGenerating = Boolean(message.loading) || (isLastMessage && context.status !== 'finished') + 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 + + if (!Array.isArray(message.renderContent) || message.renderContent.length === 0) { + const agentStatus = ['success', 'failed', 'fix'].includes(message.metadata?.agentStatus) + ? message.metadata.agentStatus + : 'failed' + return [ + { + type: 'agent-content', + status: agentStatus, + content: message.content + } + ] + } + + 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 handleFileSelected = async (formData: FormData, updateAttachment: (resourceUrl: string) => void) => { try { const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue index 8206bdcca..d8c971e39 100644 --- a/packages/plugins/robot/src/components/chat/RobotChat.vue +++ b/packages/plugins/robot/src/components/chat/RobotChat.vue @@ -113,7 +113,9 @@ const props = defineProps({ type: Function }, status: { type: String }, - chatMode: { type: String }, + messageContentResolver: { + type: Function + }, allowFiles: { type: Boolean, default: false @@ -169,51 +171,35 @@ const contentRendererMatches = computed(() => [ }, { priority: BubbleRendererMatchPriority.NORMAL, - find: (message: any, content: any) => - !message.loading && message.content && (!content?.type || ['markdown', 'text'].includes(content.type)), + find: (_message: any, content: any) => !content?.type || ['markdown', 'text'].includes(content.type), renderer: MarkdownRenderer }, { priority: BubbleRendererMatchPriority.NORMAL, - find: (message: any) => message?.content?.[0]?.type === 'img' || message?.content?.[0]?.type === 'image', + find: (_message: any, content: any) => ['img', 'image'].includes(content?.type), 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 getTextContent = (content: any) => { + if (typeof content === 'string') { + return content } - - 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 - }) + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === 'string') { + return item + } + if (item?.type === 'text') { + return item.text ?? item.content ?? '' + } + return '' + }) + .filter(Boolean) + .join('\n') + } + return '' } // 处理文件选择事件 @@ -272,21 +258,43 @@ 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) + 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: any) => { + if (item?.type === 'img' || item?.type === 'image') { + return { + type: 'img', + content: item.content || item.url || item.image_url?.url || '' + } } - ] + if (item?.type === 'text') { + return { + type: 'text', + content: item.content ?? item.text ?? '' + } + } + return item + }) + } + + if (Array.isArray(message.content) && message.content.length > 0) { + const textContent = getTextContent( + message.content.map((item: any) => item?.text ?? item?.content ?? item?.image_url?.url ?? '') + ) + if (textContent) { + return textContent + } + } + + const textContent = getTextContent(message.content) + if (textContent) { + return textContent } return message.content @@ -326,28 +334,29 @@ const handleSendMessage = async (content: string) => { } 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) => ({ + userMessage.content = [ + { + type: 'text', + text: messageContent + }, + ...files.map((item) => ({ type: 'image_url', image_url: { url: item.url } })) - .concat({ + ] as any + userMessage.renderContent = [ + { type: 'text', - text: messageContent - }) + content: messageContent + }, + ...files.map((item) => ({ + type: 'img', + content: item.url + })) + ] + } else { userMessage.renderContent = [ { type: 'text', diff --git a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue index 6774d28f4..a448a8356 100644 --- a/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue +++ b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue @@ -29,7 +29,7 @@ hljs.registerLanguage('xml', xml) hljs.registerLanguage('shell', shell) interface MarkdownMessage { - content: string + content: string | string[] | Record[] } const props = defineProps({ @@ -71,7 +71,15 @@ const markdownIt = new MarkdownIt({ }) const renderContent = computed(() => { - return DOMPurify.sanitize(markdownIt.render(props.message.content)) + const content = Array.isArray(props.message.content) + ? props.message.content + .map((item: any) => item?.text ?? item?.content ?? '') + .filter(Boolean) + .join('\n') + : typeof props.message.content === 'string' + ? 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 c5dec73ba..36d244355 100644 --- a/packages/plugins/robot/src/composables/core/useConversation.ts +++ b/packages/plugins/robot/src/composables/core/useConversation.ts @@ -33,6 +33,29 @@ export interface ConversationMetadata { let currentConversationMetadata: ConversationMetadata = {} +const extractMessageText = (content: unknown): string => { + if (typeof content === 'string') { + return content + } + + if (Array.isArray(content)) { + return content + .map((item: any) => { + if (typeof item === 'string') { + return item + } + if (item?.type === 'text') { + return item.text ?? item.content ?? '' + } + return '' + }) + .filter(Boolean) + .join('\n') + } + + return '' +} + const createResponseProvider = ( provider: Pick ): UseMessageOptions['responseProvider'] => { @@ -180,7 +203,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { const saveConversations = () => { conversations.value.forEach((conversation) => { - void saveConversation(conversation) + saveConversation(conversation) }) } @@ -198,7 +221,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 +281,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { } } currentConversationMetadata = conversation.metadata || {} - void saveConversation(conversation) + saveConversation(conversation) return currentId } } @@ -328,7 +351,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/test/composables/core/useConversation.test.ts b/packages/plugins/robot/test/composables/core/useConversation.test.ts new file mode 100644 index 000000000..1521b8dbd --- /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 000000000..4e788a014 --- /dev/null +++ b/packages/plugins/robot/test/utils/chat.utils.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi } from 'vitest' +import { + addSystemPrompt, + 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('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('does not overwrite truthy non-string values unless they are mergeable 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' } + ]) + }) + }) +})