diff --git a/packages/model-runtime/src/core/streams/qwen.test.ts b/packages/model-runtime/src/core/streams/qwen.test.ts index 85d98d09f3..923282cf66 100644 --- a/packages/model-runtime/src/core/streams/qwen.test.ts +++ b/packages/model-runtime/src/core/streams/qwen.test.ts @@ -479,6 +479,146 @@ describe('QwenAIStream', () => { `data: [{"function":{"arguments":"","name":"get_weather"},"id":"call_123","index":0,"type":"function"}]\n\n`, ]); }); + + it('should handle mixed text content followed by streaming tool calls (DeepSeek style)', async () => { + // This test simulates the stream pattern from DeepSeek models via Qwen API + // where text content is streamed first, followed by incremental tool call chunks + const mockOpenAIStream = new ReadableStream({ + start(controller) { + // Text content chunks with role in first chunk + controller.enqueue({ + choices: [ + { + delta: { content: '看来', role: 'assistant' }, + finish_reason: null, + index: 0, + }, + ], + id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075', + model: 'deepseek-v3', + object: 'chat.completion.chunk', + created: 1767574524, + }); + controller.enqueue({ + choices: [ + { + delta: { content: '我的' }, + finish_reason: null, + index: 0, + }, + ], + id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075', + model: 'deepseek-v3', + object: 'chat.completion.chunk', + created: 1767574524, + }); + controller.enqueue({ + choices: [ + { + delta: { content: '函数调用格式有误。' }, + finish_reason: null, + index: 0, + }, + ], + id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075', + model: 'deepseek-v3', + object: 'chat.completion.chunk', + created: 1767574524, + }); + + // First tool call chunk with id, name, and partial arguments + controller.enqueue({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_ff00c42325d74b979990cb', + type: 'function', + function: { + name: 'modelscope-time____get_current_time____mcp', + arguments: '{"', + }, + index: 0, + }, + ], + }, + finish_reason: null, + index: 0, + }, + ], + id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075', + model: 'deepseek-v3', + object: 'chat.completion.chunk', + created: 1767574524, + }); + + // Subsequent tool call chunk with only incremental arguments (no id) + controller.enqueue({ + choices: [ + { + delta: { + tool_calls: [ + { + type: 'function', + function: { + arguments: 'timezone":"America/New_York"}', + }, + index: 0, + }, + ], + }, + finish_reason: null, + index: 0, + }, + ], + id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075', + model: 'deepseek-v3', + object: 'chat.completion.chunk', + created: 1767574524, + }); + + controller.close(); + }, + }); + + const onTextMock = vi.fn(); + const onToolCallMock = vi.fn(); + + const protocolStream = QwenAIStream(mockOpenAIStream, { + callbacks: { + onText: onTextMock, + onToolsCalling: onToolCallMock, + }, + }); + + const decoder = new TextDecoder(); + const chunks = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + // Verify complete chunks array + expect(chunks).toEqual([ + 'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n', + 'event: text\n', + 'data: "看来"\n\n', + 'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n', + 'event: text\n', + 'data: "我的"\n\n', + 'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n', + 'event: text\n', + 'data: "函数调用格式有误。"\n\n', + 'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n', + 'event: tool_calls\n', + 'data: [{"function":{"arguments":"{\\"","name":"modelscope-time____get_current_time____mcp"},"id":"call_ff00c42325d74b979990cb","index":0,"type":"function"}]\n\n', + 'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n', + 'event: tool_calls\n', + 'data: [{"function":{"arguments":"timezone\\":\\"America/New_York\\"}","name":null},"id":"call_ff00c42325d74b979990cb","index":0,"type":"function"}]\n\n', + ]); + }); }); describe('transformQwenStream', () => { diff --git a/packages/model-runtime/src/core/streams/qwen.ts b/packages/model-runtime/src/core/streams/qwen.ts index 62c892d0d5..733e5f4543 100644 --- a/packages/model-runtime/src/core/streams/qwen.ts +++ b/packages/model-runtime/src/core/streams/qwen.ts @@ -70,8 +70,18 @@ export const transformQwenStream = ( if (item.delta?.tool_calls) { return { - data: item.delta.tool_calls.map( - (value, index): StreamToolCallChunkData => ({ + data: item.delta.tool_calls.map((value, index): StreamToolCallChunkData => { + // Store first tool call's info in streamContext for subsequent chunks + // (similar pattern to OpenAI stream handling) + if (streamContext && !streamContext.tool && value.id && value.function?.name) { + streamContext.tool = { + id: value.id, + index: typeof value.index !== 'undefined' ? value.index : index, + name: value.function.name, + }; + } + + return { // Qwen models may send tool_calls in two separate chunks: // 1. First chunk: {id, name} without arguments // 2. Second chunk: {id, arguments} without name @@ -81,11 +91,13 @@ export const transformQwenStream = ( arguments: value.function?.arguments ?? '', name: value.function?.name ?? null, }, - id: value.id || generateToolCallId(index, value.function?.name), + // For incremental chunks without id, use the stored tool id from streamContext + id: + value.id || streamContext?.tool?.id || generateToolCallId(index, value.function?.name), index: typeof value.index !== 'undefined' ? value.index : index, type: value.type || 'function', - }), - ), + }; + }), id: chunk.id, type: 'tool_calls', } as StreamProtocolToolCallChunk; diff --git a/src/app/[variants]/(main)/chat/features/Conversation/Header/index.tsx b/src/app/[variants]/(main)/chat/features/Conversation/Header/index.tsx index de874baa3b..13c17a4679 100644 --- a/src/app/[variants]/(main)/chat/features/Conversation/Header/index.tsx +++ b/src/app/[variants]/(main)/chat/features/Conversation/Header/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { isDesktop } from '@lobechat/const'; import { Flexbox } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { memo } from 'react'; @@ -22,7 +23,7 @@ const Header = memo(() => { } right={ - + {isDesktop && }