From 01104b95bfd645ccd634d69a0f060873edd57fb5 Mon Sep 17 00:00:00 2001 From: lichunn <269031597@qq.com> Date: Fri, 26 Jun 2026 15:38:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix=EF=BC=9A=E8=B0=83=E6=95=B4RobotChat?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=92=8C=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/plugins/robot/src/Main.vue | 53 ++++++- .../robot/src/components/chat/RobotChat.vue | 139 ++++++++++-------- .../components/renderers/MarkdownRenderer.vue | 12 +- .../src/composables/core/useConversation.ts | 31 +++- 4 files changed, 162 insertions(+), 73 deletions(-) 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)) } } From 3b65f7d8a84b0d58b46f80e6765e38b634be393a Mon Sep 17 00:00:00 2001 From: lichunn <269031597@qq.com> Date: Fri, 26 Jun 2026 15:39:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composables/core/useConversation.test.ts | 691 ++++++++++++++++++ .../robot/test/utils/chat.utils.test.ts | 436 +++++++++++ 2 files changed, 1127 insertions(+) create mode 100644 packages/plugins/robot/test/composables/core/useConversation.test.ts create mode 100644 packages/plugins/robot/test/utils/chat.utils.test.ts 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' } + ]) + }) + }) +}) From 6d31656f59fa1b209fd33a2f457aeeaf8c005392 Mon Sep 17 00:00:00 2001 From: lichunn <269031597@qq.com> Date: Mon, 29 Jun 2026 19:27:36 +0800 Subject: [PATCH 3/3] fix:fix review --- packages/plugins/robot/src/Main.vue | 64 ++++--- .../robot/src/components/chat/RobotChat.vue | 88 ++++----- .../components/renderers/MarkdownRenderer.vue | 19 +- .../src/composables/core/useConversation.ts | 28 +-- .../plugins/robot/src/types/chat.types.ts | 177 +++++++++++++++++- .../plugins/robot/src/utils/chat.utils.ts | 35 +++- .../robot/test/utils/chat.utils.test.ts | 24 ++- 7 files changed, 323 insertions(+), 112 deletions(-) diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index 298bbd904..237beb982 100644 --- a/packages/plugins/robot/src/Main.vue +++ b/packages/plugins/robot/src/Main.vue @@ -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({ @@ -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,51 +255,61 @@ 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: any, context: { messages: any[]; status: string }) => { - const hasAgentContent = message.renderContent?.some((item: any) => { - return item.type === 'agent-content' || item.type === 'agent-loading' +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 === 'agent' || hasAgentContent + const isAgentMessage = message.metadata?.chatMode === ChatMode.Agent || hasAgentContent - if (!isAgentMessage || message.role !== 'assistant') { + 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 !== '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 + 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 = ['success', 'failed', 'fix'].includes(message.metadata?.agentStatus) + const agentStatus = isAgentFinalStatus(message.metadata?.agentStatus) ? message.metadata.agentStatus - : 'failed' + : AgentMessageStatus.Failed return [ { - type: 'agent-content', + type: RobotMessageContentType.AgentContent, status: agentStatus, content: message.content } ] } - return renderContent.map((item: any) => { - if (item.type !== 'agent-content' || isGenerating) { + return resolvedRenderContent.map((item) => { + if (item.type !== RobotMessageContentType.AgentContent || isGenerating) { return item } - if (!item.status || item.status === 'loading') { + if (!item.status || item.status === AgentMessageStatus.Loading) { return { ...item, - status: finalStatus || message.metadata?.agentStatus || 'failed' + status: finalStatus || message.metadata?.agentStatus || AgentMessageStatus.Failed } } diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue index d8c971e39..f69c0a8ea 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, @@ -141,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( @@ -161,47 +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) => !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, content: any) => ['img', 'image'].includes(content?.type), + find: (_message: RobotMessage, content: RobotRenderContentItem) => + [RobotMessageContentType.Img, RobotMessageContentType.Image].includes(content?.type as any), renderer: ImgRenderer } ]) -const getTextContent = (content: any) => { - if (typeof content === 'string') { - return content - } - 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 '' -} - // 处理文件选择事件 const handleSingleFilesSelected = (files: File[] | null, retry = false) => { if (!files?.length) return @@ -257,7 +247,7 @@ const getSvgIcon = (name: string, style?: CSSProperties) => { const aiAvatar = getSvgIcon('AI') const welcomeIcon = getSvgIcon('AI', { fontSize: '44px' }) -const resolveMessageContent = (message: any) => { +const resolveMessageContent = (message: RobotMessage) => { if (props.messageContentResolver) { return props.messageContentResolver(message, { messages: messages.value, @@ -266,16 +256,16 @@ const resolveMessageContent = (message: any) => { } if (Array.isArray(message.renderContent) && message.renderContent.length > 0) { - return message.renderContent.map((item: any) => { - if (item?.type === 'img' || item?.type === 'image') { + return message.renderContent.map((item) => { + if (item?.type === RobotMessageContentType.Img || item?.type === RobotMessageContentType.Image) { return { - type: 'img', + type: RobotMessageContentType.Img, content: item.content || item.url || item.image_url?.url || '' } } - if (item?.type === 'text') { + if (item?.type === RobotMessageContentType.Text) { return { - type: 'text', + type: RobotMessageContentType.Text, content: item.content ?? item.text ?? '' } } @@ -284,15 +274,13 @@ const resolveMessageContent = (message: any) => { } 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 ?? '') - ) + const textContent = extractMessageText(message.content) if (textContent) { return textContent } } - const textContent = getTextContent(message.content) + const textContent = extractMessageText(message.content) if (textContent) { return textContent } @@ -301,14 +289,14 @@ const resolveMessageContent = (message: any) => { } const roleConfigs: Record = { - assistant: { + [RobotMessageRole.Assistant]: { placement: 'start', avatar: aiAvatar }, - user: { + [RobotMessageRole.User]: { placement: 'end' }, - system: { + [RobotMessageRole.System]: { hidden: true } } @@ -328,38 +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) { userMessage.content = [ { - type: 'text', + type: RobotMessageContentType.Text, text: messageContent }, ...files.map((item) => ({ - type: 'image_url', + type: RobotMessageContentType.ImageUrl, image_url: { url: item.url } })) - ] as any + ] as RobotInputContentPart[] userMessage.renderContent = [ { - type: 'text', + type: RobotMessageContentType.Text, content: messageContent }, ...files.map((item) => ({ - type: 'img', + 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 a448a8356..f2a3d7e01 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 | string[] | Record[] + content: unknown } const props = defineProps({ @@ -71,14 +72,14 @@ const markdownIt = new MarkdownIt({ }) const renderContent = computed(() => { - 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 - : '' + 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 36d244355..94c6045b6 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 @@ -33,29 +35,6 @@ 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'] => { @@ -123,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 ||= {} diff --git a/packages/plugins/robot/src/types/chat.types.ts b/packages/plugins/robot/src/types/chat.types.ts index f5a1d7f69..dfde3ec24 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 702a2cc34..00d822a85 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/utils/chat.utils.test.ts b/packages/plugins/robot/test/utils/chat.utils.test.ts index 4e788a014..9a102ab6b 100644 --- a/packages/plugins/robot/test/utils/chat.utils.test.ts +++ b/packages/plugins/robot/test/utils/chat.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { addSystemPrompt, + extractMessageText, formatMessages, mergeStringFields, processSSEStream, @@ -138,6 +139,27 @@ describe('chat utils', () => { }) }) + 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( @@ -211,7 +233,7 @@ describe('chat utils', () => { }) }) - it('does not overwrite truthy non-string values unless they are mergeable objects', () => { + it('overwrites falsy non-string values and recursively merges nested objects', () => { const result = mergeStringFields( { index: 0,