diff --git a/next.config.ts b/next.config.ts index 1cd1d965f1..ad5a22e678 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,7 +42,6 @@ const nextConfig: NextConfig = { // so we need to disable it // refs: https://github.com/lobehub/lobe-chat/pull/7430 serverMinification: false, - turbopackFileSystemCacheForDev: true, webVitalsAttribution: ['CLS', 'LCP'], webpackBuildWorker: true, webpackMemoryOptimizations: true, diff --git a/package.json b/package.json index d5bbc2d34b..04f69f4b6d 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "@emotion/react": "^11.14.0", "@fal-ai/client": "^1.7.2", "@formkit/auto-animate": "^0.9.0", - "@google/genai": "^1.29.1", + "@google/genai": "^1.30.0", "@huggingface/inference": "^4.13.3", "@icons-pack/react-simple-icons": "^13.8.0", "@khmyznikov/pwa-install": "0.3.9", diff --git a/packages/context-engine/src/processors/ToolCall.ts b/packages/context-engine/src/processors/ToolCall.ts index 831f2da2b5..54d266e24f 100644 --- a/packages/context-engine/src/processors/ToolCall.ts +++ b/packages/context-engine/src/processors/ToolCall.ts @@ -130,6 +130,7 @@ export class ToolCallProcessor extends BaseProcessor { : `${tool.identifier}.${tool.apiName}`, }, id: tool.id, + thoughtSignature: tool.thoughtSignature, type: 'function', }), ); diff --git a/packages/context-engine/src/processors/__tests__/ToolCall.test.ts b/packages/context-engine/src/processors/__tests__/ToolCall.test.ts index dc78c4cf2c..cdf87ac51c 100644 --- a/packages/context-engine/src/processors/__tests__/ToolCall.test.ts +++ b/packages/context-engine/src/processors/__tests__/ToolCall.test.ts @@ -72,6 +72,65 @@ describe('ToolCallProcessor', () => { ]); }); + it('should pass through thoughtSignature when present', async () => { + const processor = new ToolCallProcessor(defaultConfig); + const context = createContext([ + { + content: '', + id: 'msg1', + role: 'assistant', + tools: [ + { + apiName: 'search', + arguments: '{"query":"test"}', + id: 'call_1', + identifier: 'web', + thoughtSignature: 'Let me search for this information', + type: 'builtin', + }, + ], + }, + ]); + + const result = await processor.process(context); + + expect(result.messages[0].tool_calls).toEqual([ + { + function: { + arguments: '{"query":"test"}', + name: 'web.search', + }, + id: 'call_1', + thoughtSignature: 'Let me search for this information', + type: 'function', + }, + ]); + }); + + it('should handle missing thoughtSignature', async () => { + const processor = new ToolCallProcessor(defaultConfig); + const context = createContext([ + { + content: '', + id: 'msg1', + role: 'assistant', + tools: [ + { + apiName: 'search', + arguments: '{"query":"test"}', + id: 'call_1', + identifier: 'web', + type: 'builtin', + }, + ], + }, + ]); + + const result = await processor.process(context); + + expect(result.messages[0].tool_calls[0].thoughtSignature).toBeUndefined(); + }); + it('should use custom genToolCallingName function', async () => { const genToolCallingName = vi.fn( (identifier, apiName, type) => `custom_${identifier}_${apiName}_${type}`, diff --git a/packages/context-engine/src/tools/ToolNameResolver.ts b/packages/context-engine/src/tools/ToolNameResolver.ts index 5bd3fb2b1d..684a017800 100644 --- a/packages/context-engine/src/tools/ToolNameResolver.ts +++ b/packages/context-engine/src/tools/ToolNameResolver.ts @@ -82,6 +82,7 @@ export class ToolNameResolver { arguments: toolCall.function.arguments, id: toolCall.id, identifier, + thoughtSignature: toolCall.thoughtSignature, type: (type ?? 'default') as any, }; diff --git a/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts b/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts index 6c2132e037..e4615d37e0 100644 --- a/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts +++ b/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts @@ -455,6 +455,63 @@ describe('ToolNameResolver', () => { }); }); + describe('resolve - thoughtSignature', () => { + it('should pass through thoughtSignature when present', () => { + const toolCalls = [ + { + function: { + arguments: '{"query": "test"}', + name: 'test-plugin____myAction____builtin', + }, + id: 'call_1', + thoughtSignature: 'thinking about this...', + type: 'function', + }, + ]; + + const manifests = { + 'test-plugin': { + api: [{ description: 'My action', name: 'myAction', parameters: {} }], + identifier: 'test-plugin', + meta: {}, + type: 'builtin' as const, + }, + }; + + const result = resolver.resolve(toolCalls, manifests); + + expect(result).toHaveLength(1); + expect(result[0].thoughtSignature).toBe('thinking about this...'); + }); + + it('should handle missing thoughtSignature', () => { + const toolCalls = [ + { + function: { + arguments: '{"query": "test"}', + name: 'test-plugin____myAction____builtin', + }, + id: 'call_1', + type: 'function', + }, + ]; + + const manifests = { + 'test-plugin': { + api: [{ description: 'My action', name: 'myAction', parameters: {} }], + identifier: 'test-plugin', + meta: {}, + type: 'builtin' as const, + }, + }; + + const result = resolver.resolve(toolCalls, manifests); + + expect(result).toHaveLength(1); + expect(result[0].thoughtSignature).toBeUndefined(); + }); + }); + describe('resolve - edge cases', () => { it('should filter out invalid tool calls with missing apiName', () => { const toolCalls = [ diff --git a/packages/context-engine/src/types.ts b/packages/context-engine/src/types.ts index 818d940202..5c43a525da 100644 --- a/packages/context-engine/src/types.ts +++ b/packages/context-engine/src/types.ts @@ -30,6 +30,7 @@ export interface MessageToolCall { name: string; }; id: string; + thoughtSignature?: string; type: 'function'; } export interface Message { diff --git a/packages/fetch-sse/src/fetchSSE.ts b/packages/fetch-sse/src/fetchSSE.ts index a3e3bf50c0..fc8f8a837a 100644 --- a/packages/fetch-sse/src/fetchSSE.ts +++ b/packages/fetch-sse/src/fetchSSE.ts @@ -17,7 +17,7 @@ import { nanoid } from '@lobechat/utils/uuid'; import { getMessageError } from './parseError'; -type SSEFinishType = 'done' | 'error' | 'abort'; +type SSEFinishType = 'done' | 'error' | 'abort' | string; export type OnFinishHandler = ( text: string, @@ -48,6 +48,10 @@ export interface MessageTextChunk { text: string; type: 'text'; } +export interface MessageStopChunk { + reason: string; + type: 'stop'; +} export interface MessageBase64ImageChunk { id: string; @@ -86,7 +90,8 @@ export interface FetchSSEOptions { | MessageGroundingChunk | MessageUsageChunk | MessageBase64ImageChunk - | MessageSpeedChunk, + | MessageSpeedChunk + | MessageStopChunk, ) => void; responseAnimation?: ResponseAnimation; } @@ -387,6 +392,11 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio break; } + case 'stop': { + options.onMessageHandle?.({ reason: data, type: 'stop' }); + break; + } + case 'reasoning': { if (textSmoothing) { thinkingController.pushToQueue(data); diff --git a/packages/model-runtime/src/core/contextBuilders/google.test.ts b/packages/model-runtime/src/core/contextBuilders/google.test.ts index 145d800990..2e9e1fa4b2 100644 --- a/packages/model-runtime/src/core/contextBuilders/google.test.ts +++ b/packages/model-runtime/src/core/contextBuilders/google.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types'; import { parseDataUri } from '../../utils/uriParser'; import { + GEMINI_MAGIC_THOUGHT_SIGNATURE, buildGoogleMessage, buildGoogleMessages, buildGooglePart, @@ -232,6 +233,415 @@ describe('google contextBuilders', () => { }); }); + it('should correctly convert function call message with thoughtSignature', async () => { + const message = { + role: 'assistant', + tool_calls: [ + { + function: { + arguments: JSON.stringify({ + language: ['JSON'], + path: 'package.json', + query: '"version":', + repo: 'lobehub/lobe-chat', + }), + name: 'grep____searchGitHub____mcp', + }, + id: 'grep____searchGitHub____mcp_0_6RnOMTF0', + thoughtSignature: + 'EsUHCsIHAdHtim9/MrjP+pnhM8DVkvulyfWQVf+isXQxEAbF32gbflE1hl6Te80qtp77Ywn8opB2uhQOIH/l6SStsj3+XRy1U1DTeKtqZxDBoLP2rNK6pi3/nk0ZOQIc8f6rxB70G/zOhk7d/1XQFqhmw5H+yDVRQjGD1cNPY5ctWGxQLAIk/HMWNovUJzz2c81jGWoXu7k2vtpuur2hcAL+J79BEVUTfvU3mSiXqJFTClmFPB6Fe79i0y3TwM2XdIBxzPgVgf8B+Pnv1S6YDxHNSm46jTlXKcSw30r3ixs5xEOzerbOUW5WG9BGukw/YQVvHiuoGLIALRa2Ig7dlOMH8+o+f0mKJtyYj8yF6wyBMol+G4mhSHvQSKJLj/Z5kFHvDZKeVUEOZed6vZivYLrVezjQPXgLHJMOmbp6QrZGxqW45QxDKY5X5F8giIOM8VgsUYhDQUBown+3vvwkIBA24icDsOwdhJ/roe9GabbGfxpkSzARIFh7rSI01cRKbh6cEaVFXf2WQftPeD7dBseQLiCdUYoy4ytECrjTpknrWnVUG6Ly4SKW6uN/IJXpm9JT9GgnGLIddFtEQzm9sIKWNpGEz6++lZpiCFS6LsYSnTP3vPj/7oSABRmwWywxA8EmLh+sv+jiK5aMjFi1sTuJ0Ujsvza3/SHZKewNi9WKQUDOa9Mqtjs2YGDnJxto4l5GMUzI5vhf6/+/A5eHALfVabaFP97v8FEPrXQU94dognwx4EnNqy/KWmGIlYZYqIfjaSAy7Z74viwl+oTtL9gyyBDc/FrQvXfyrYIq8N0pkLKAEh33fa/+YVocLL1LKI9rb2bg/RRr+Ee4NyIQKhIdEJaEh74d1COd/4r06J92ThkfVo5PEVTSsr8tBKiJ5wSmX9vyhbLWzxmXoq1xfGrs8kg7NMW53XEWGlQrIVOQmUtjjjBQKj6b4rBTAO6EKk63cGFbkSPohifiUBPHbxUUPy/hf0tQpeOo3jA01AuCFLOIZ5IYJ+Rm5+aZTU3Panv+Q7Yl1w5t5swhbNZfg7MlU/sxwLijLuWDDNfw+2Zw/aa3VDPgVw6Nv2vKkHi4tUU0XlgfiQgQYUMPxpGRV837uUxvZFNep2QUlAMog5h4sMYJWIAX1kK1pzsyR/KxuCn6nUq4ovWNBQHLC4aW2ZcGgW/6CbF81F1cewUz+vWNMMkJrL0d9celGEbFuY0Q709UipaDbCg49twlnLV9XUwqC5wYTFBiJbynBDqiZAvXn2YOxNIs8CCzuu2GSCQDo09ksJy5g/o=', + type: 'function', + }, + ], + } as OpenAIChatMessage; + + const converted = await buildGoogleMessage(message); + + expect(converted).toEqual({ + parts: [ + { + functionCall: { + args: { + language: ['JSON'], + path: 'package.json', + query: '"version":', + repo: 'lobehub/lobe-chat', + }, + name: 'grep____searchGitHub____mcp', + }, + thoughtSignature: + 'EsUHCsIHAdHtim9/MrjP+pnhM8DVkvulyfWQVf+isXQxEAbF32gbflE1hl6Te80qtp77Ywn8opB2uhQOIH/l6SStsj3+XRy1U1DTeKtqZxDBoLP2rNK6pi3/nk0ZOQIc8f6rxB70G/zOhk7d/1XQFqhmw5H+yDVRQjGD1cNPY5ctWGxQLAIk/HMWNovUJzz2c81jGWoXu7k2vtpuur2hcAL+J79BEVUTfvU3mSiXqJFTClmFPB6Fe79i0y3TwM2XdIBxzPgVgf8B+Pnv1S6YDxHNSm46jTlXKcSw30r3ixs5xEOzerbOUW5WG9BGukw/YQVvHiuoGLIALRa2Ig7dlOMH8+o+f0mKJtyYj8yF6wyBMol+G4mhSHvQSKJLj/Z5kFHvDZKeVUEOZed6vZivYLrVezjQPXgLHJMOmbp6QrZGxqW45QxDKY5X5F8giIOM8VgsUYhDQUBown+3vvwkIBA24icDsOwdhJ/roe9GabbGfxpkSzARIFh7rSI01cRKbh6cEaVFXf2WQftPeD7dBseQLiCdUYoy4ytECrjTpknrWnVUG6Ly4SKW6uN/IJXpm9JT9GgnGLIddFtEQzm9sIKWNpGEz6++lZpiCFS6LsYSnTP3vPj/7oSABRmwWywxA8EmLh+sv+jiK5aMjFi1sTuJ0Ujsvza3/SHZKewNi9WKQUDOa9Mqtjs2YGDnJxto4l5GMUzI5vhf6/+/A5eHALfVabaFP97v8FEPrXQU94dognwx4EnNqy/KWmGIlYZYqIfjaSAy7Z74viwl+oTtL9gyyBDc/FrQvXfyrYIq8N0pkLKAEh33fa/+YVocLL1LKI9rb2bg/RRr+Ee4NyIQKhIdEJaEh74d1COd/4r06J92ThkfVo5PEVTSsr8tBKiJ5wSmX9vyhbLWzxmXoq1xfGrs8kg7NMW53XEWGlQrIVOQmUtjjjBQKj6b4rBTAO6EKk63cGFbkSPohifiUBPHbxUUPy/hf0tQpeOo3jA01AuCFLOIZ5IYJ+Rm5+aZTU3Panv+Q7Yl1w5t5swhbNZfg7MlU/sxwLijLuWDDNfw+2Zw/aa3VDPgVw6Nv2vKkHi4tUU0XlgfiQgQYUMPxpGRV837uUxvZFNep2QUlAMog5h4sMYJWIAX1kK1pzsyR/KxuCn6nUq4ovWNBQHLC4aW2ZcGgW/6CbF81F1cewUz+vWNMMkJrL0d9celGEbFuY0Q709UipaDbCg49twlnLV9XUwqC5wYTFBiJbynBDqiZAvXn2YOxNIs8CCzuu2GSCQDo09ksJy5g/o=', + }, + ], + role: 'model', + }); + }); + + describe('should correctly convert function call message without thoughtSignature', () => { + it('should add magic signature when last message is tool message', async () => { + const messages: OpenAIChatMessage[] = [ + { + content: 'Web Browsing plugin available', + role: 'system', + }, + { + content: '杭州天气如何', + role: 'user', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"杭州天气","searchEngines":["google"]}', + name: 'lobe-web-browsing____search____builtin', + }, + id: 'call_001', + type: 'function', + }, + ], + }, + { + content: 'Tool execution was aborted by user.', + name: 'lobe-web-browsing____search____builtin', + role: 'tool', + tool_call_id: 'call_001', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"杭州 天气","searchEngines":["bing"]}', + name: 'lobe-web-browsing____search____builtin', + }, + id: 'call_002', + type: 'function', + }, + ], + }, + { + content: 'no result', + name: 'lobe-web-browsing____search____builtin', + role: 'tool', + tool_call_id: 'call_002', + }, + ]; + + const contents = await buildGoogleMessages(messages); + + expect(contents).toEqual([ + { + parts: [{ text: 'Web Browsing plugin available' }], + role: 'user', + }, + { parts: [{ text: '杭州天气如何' }], role: 'user' }, + { + parts: [ + { + functionCall: { + args: { query: '杭州天气', searchEngines: ['google'] }, + name: 'lobe-web-browsing____search____builtin', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'lobe-web-browsing____search____builtin', + response: { result: 'Tool execution was aborted by user.' }, + }, + }, + ], + role: 'user', + }, + { + parts: [ + { + functionCall: { + args: { query: '杭州 天气', searchEngines: ['bing'] }, + name: 'lobe-web-browsing____search____builtin', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'lobe-web-browsing____search____builtin', + response: { result: 'no result' }, + }, + }, + ], + role: 'user', + }, + ]); + }); + + it('should NOT add magic signature when thoughtSignature already exists', async () => { + const existingSignature = 'existing_signature_from_model'; + const messages: OpenAIChatMessage[] = [ + { + content: '杭州天气如何', + role: 'user', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"杭州天气","searchEngines":["google"]}', + name: 'lobe-web-browsing____search____builtin', + }, + id: 'call_001', + thoughtSignature: existingSignature, + type: 'function', + }, + ], + }, + { + content: 'Tool result', + name: 'lobe-web-browsing____search____builtin', + role: 'tool', + tool_call_id: 'call_001', + }, + ]; + + const contents = await buildGoogleMessages(messages); + + expect(contents).toEqual([ + { + parts: [{ text: '杭州天气如何' }], + role: 'user', + }, + { + parts: [ + { + functionCall: { + args: { query: '杭州天气', searchEngines: ['google'] }, + name: 'lobe-web-browsing____search____builtin', + }, + // Should keep existing thoughtSignature, not add magic signature + thoughtSignature: existingSignature, + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'lobe-web-browsing____search____builtin', + response: { result: 'Tool result' }, + }, + }, + ], + role: 'user', + }, + ]); + }); + + it('should add magic signature only after last user message in multi-turn scenario', async () => { + const messages: OpenAIChatMessage[] = [ + { + content: 'First question', + role: 'user', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"first"}', + name: 'search', + }, + id: 'call_001', + type: 'function', + }, + ], + }, + { + content: 'First result', + name: 'search', + role: 'tool', + tool_call_id: 'call_001', + }, + { + content: 'Second question', + role: 'user', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"second"}', + name: 'search', + }, + id: 'call_002', + type: 'function', + }, + ], + }, + { + content: 'Second result', + name: 'search', + role: 'tool', + tool_call_id: 'call_002', + }, + ]; + + const contents = await buildGoogleMessages(messages); + + expect(contents).toEqual([ + { + parts: [{ text: 'First question' }], + role: 'user', + }, + { + parts: [ + { + functionCall: { + args: { query: 'first' }, + name: 'search', + }, + // No magic signature for this one (before last user message) + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'search', + response: { result: 'First result' }, + }, + }, + ], + role: 'user', + }, + { + parts: [{ text: 'Second question' }], + role: 'user', + }, + { + parts: [ + { + functionCall: { + args: { query: 'second' }, + name: 'search', + }, + // Magic signature added (after last user message) + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'search', + response: { result: 'Second result' }, + }, + }, + ], + role: 'user', + }, + ]); + }); + + it('should NOT add magic signature when last message is user text message', async () => { + const messages: OpenAIChatMessage[] = [ + { + content: 'Web Browsing plugin available', + role: 'system', + }, + { + content: '杭州天气如何', + role: 'user', + }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: '{"query":"杭州天气","searchEngines":["google"]}', + name: 'lobe-web-browsing____search____builtin', + }, + id: 'call_001', + type: 'function', + }, + ], + }, + { + content: 'Tool execution was aborted by user.', + name: 'lobe-web-browsing____search____builtin', + role: 'tool', + tool_call_id: 'call_001', + }, + { + content: 'Please try again', + role: 'user', + }, + ]; + + const contents = await buildGoogleMessages(messages); + + expect(contents).toEqual([ + { + parts: [{ text: 'Web Browsing plugin available' }], + role: 'user', + }, + { + parts: [{ text: '杭州天气如何' }], + role: 'user', + }, + { + parts: [ + { + functionCall: { + args: { query: '杭州天气', searchEngines: ['google'] }, + name: 'lobe-web-browsing____search____builtin', + }, + // No thoughtSignature should be added when last message is user text + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'lobe-web-browsing____search____builtin', + response: { result: 'Tool execution was aborted by user.' }, + }, + }, + ], + role: 'user', + }, + { + parts: [{ text: 'Please try again' }], + role: 'user', + }, + ]); + }); + }); + it('should correctly handle empty content', async () => { const message: OpenAIChatMessage = { content: '' as any, // explicitly set as empty string @@ -361,6 +771,7 @@ describe('google contextBuilders', () => { args: { location: 'London', unit: 'celsius' }, name: 'get_current_weather', }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, }, ], role: 'model', @@ -410,6 +821,74 @@ describe('google contextBuilders', () => { { parts: [{ text: 'Hi' }], role: 'model' }, ]); }); + + it('should correctly convert full conversation with thoughtSignature', async () => { + const messages: OpenAIChatMessage[] = [ + { content: 'system prompt', role: 'system' }, + { content: 'LobeChat 最新版本', role: 'user' }, + { + content: '', + role: 'assistant', + tool_calls: [ + { + function: { + arguments: JSON.stringify({ + language: ['JSON'], + path: 'package.json', + query: '"version":', + repo: 'lobehub/lobe-chat', + }), + name: 'grep____searchGitHub____mcp', + }, + id: 'grep____searchGitHub____mcp_0_6RnOMTF0', + thoughtSignature: 'test-signature', + type: 'function', + }, + ], + }, + { + content: '', + name: 'grep____searchGitHub____mcp', + role: 'tool', + tool_call_id: 'grep____searchGitHub____mcp_0_6RnOMTF0', + }, + ]; + + const contents = await buildGoogleMessages(messages); + + expect(contents).toEqual([ + { parts: [{ text: 'system prompt' }], role: 'user' }, + { parts: [{ text: 'LobeChat 最新版本' }], role: 'user' }, + { + parts: [ + { + functionCall: { + args: { + language: ['JSON'], + path: 'package.json', + query: '"version":', + repo: 'lobehub/lobe-chat', + }, + name: 'grep____searchGitHub____mcp', + }, + thoughtSignature: 'test-signature', + }, + ], + role: 'model', + }, + { + parts: [ + { + functionResponse: { + name: 'grep____searchGitHub____mcp', + response: { result: '' }, + }, + }, + ], + role: 'user', + }, + ]); + }); }); describe('buildGoogleTool', () => { diff --git a/packages/model-runtime/src/core/contextBuilders/google.ts b/packages/model-runtime/src/core/contextBuilders/google.ts index 9e885baa0f..18c61e4539 100644 --- a/packages/model-runtime/src/core/contextBuilders/google.ts +++ b/packages/model-runtime/src/core/contextBuilders/google.ts @@ -11,6 +11,12 @@ import { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '. import { safeParseJSON } from '../../utils/safeParseJSON'; import { parseDataUri } from '../../utils/uriParser'; +/** + * Magic thoughtSignature + * @see https://ai.google.dev/gemini-api/docs/thought-signatures#model-behavior:~:text=context_engineering_is_the_way_to_go + */ +export const GEMINI_MAGIC_THOUGHT_SIGNATURE = 'context_engineering_is_the_way_to_go'; + /** * Convert OpenAI content part to Google Part format */ @@ -95,6 +101,7 @@ export const buildGoogleMessage = async ( args: safeParseJSON(tool.function.arguments)!, name: tool.function.name, }, + thoughtSignature: tool.thoughtSignature, })), role: 'model', }; @@ -155,7 +162,43 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis const contents = await Promise.all(pools); // Filter out empty messages: contents.parts must not be empty. - return contents.filter((content: Content) => content.parts && content.parts.length > 0); + const filteredContents = contents.filter( + (content: Content) => content.parts && content.parts.length > 0, + ); + + // Check if the last message is a tool message + const lastMessage = messages.at(-1); + const shouldAddMagicSignature = lastMessage?.role === 'tool'; + + if (shouldAddMagicSignature) { + // Find the last user message index in filtered contents + let lastUserIndex = -1; + for (let i = filteredContents.length - 1; i >= 0; i--) { + if (filteredContents[i].role === 'user') { + // Skip if it's a functionResponse (tool result) + const hasFunctionResponse = filteredContents[i].parts?.some((p) => p.functionResponse); + if (!hasFunctionResponse) { + lastUserIndex = i; + break; + } + } + } + + // Add magic signature to all function calls after last user message that don't have thoughtSignature + for (let i = lastUserIndex + 1; i < filteredContents.length; i++) { + const content = filteredContents[i]; + if (content.role === 'model' && content.parts) { + for (const part of content.parts) { + if (part.functionCall && !part.thoughtSignature) { + // Only add magic signature if thoughtSignature doesn't exist + part.thoughtSignature = GEMINI_MAGIC_THOUGHT_SIGNATURE; + } + } + } + } + } + + return filteredContents; }; /** diff --git a/packages/model-runtime/src/core/streams/google/google-ai.test.ts b/packages/model-runtime/src/core/streams/google/google-ai.test.ts index 41c14c7561..aea8dd54cc 100644 --- a/packages/model-runtime/src/core/streams/google/google-ai.test.ts +++ b/packages/model-runtime/src/core/streams/google/google-ai.test.ts @@ -4,938 +4,1239 @@ import { describe, expect, it, vi } from 'vitest'; import * as uuidModule from '../../../utils/uuid'; import { GoogleGenerativeAIStream, LOBE_ERROR_KEY } from './index'; +/** + * Helper function to decode stream chunks into string array + */ +async function decodeStreamChunks(stream: ReadableStream): Promise { + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of stream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + return chunks; +} + describe('GoogleGenerativeAIStream', () => { - it('should transform Google Generative AI stream to protocol stream', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1').mockReturnValueOnce('abcd1234'); + describe('Basic functionality', () => { + it('should transform Google Generative AI stream to protocol stream', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1').mockReturnValueOnce('abcd1234'); - const mockGenerateContentResponse = (text: string, functionCalls?: any[]) => - ({ - text: text, - functionCalls: functionCalls, - }) as unknown as GenerateContentResponse; + const mockGoogleStream = new ReadableStream({ + start(controller) { + // Text chunk + controller.enqueue({ + text: 'Hello', + candidates: [{ content: { parts: [{ text: 'Hello' }], role: 'model' }, index: 0 }], + } as unknown as GenerateContentResponse); - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue(mockGenerateContentResponse('Hello')); + // Function call chunk + controller.enqueue({ + text: '', + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'testFunction', + args: { arg1: 'value1' }, + }, + }, + ], + role: 'model', + }, + index: 0, + }, + ], + } as unknown as GenerateContentResponse); - controller.enqueue( - mockGenerateContentResponse('', [{ name: 'testFunction', args: { arg1: 'value1' } }]), - ); + // Final chunk with finishReason and usageMetadata to mark terminal event + controller.enqueue({ + text: ' world!', + candidates: [ + { + content: { parts: [{ text: ' world!' }], role: 'model' }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 1, + totalTokenCount: 1, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1 }], + }, + modelVersion: 'gemini-test', + } as unknown as GenerateContentResponse); - // final chunk should include finishReason and usageMetadata to mark terminal event - controller.enqueue({ - text: ' world!', - candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], + controller.close(); + }, + }); + + const onStartMock = vi.fn(); + const onTextMock = vi.fn(); + const onToolCallMock = vi.fn(); + const onCompletionMock = vi.fn(); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream, { + callbacks: { + onStart: onStartMock, + onText: onTextMock, + onToolsCalling: onToolCallMock, + onCompletion: onCompletionMock, + }, + }); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + // text + 'id: chat_1\n', + 'event: text\n', + `data: "Hello"\n\n`, + + // tool call + 'id: chat_1\n', + 'event: tool_calls\n', + `data: [{"function":{"arguments":"{\\"arg1\\":\\"value1\\"}","name":"testFunction"},"id":"testFunction_0_abcd1234","index":0,"type":"function"}]\n\n`, + + // text + 'id: chat_1\n', + 'event: text\n', + `data: " world!"\n\n`, + // stop + 'id: chat_1\n', + 'event: stop\n', + `data: "STOP"\n\n`, + // usage + 'id: chat_1\n', + 'event: usage\n', + `data: {"inputTextTokens":1,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":1,"totalOutputTokens":0,"totalTokens":1}\n\n`, + ]); + + expect(onStartMock).toHaveBeenCalledTimes(1); + expect(onTextMock).toHaveBeenNthCalledWith(1, 'Hello'); + expect(onTextMock).toHaveBeenNthCalledWith(2, ' world!'); + expect(onToolCallMock).toHaveBeenCalledTimes(1); + expect(onCompletionMock).toHaveBeenCalledTimes(1); + }); + + it('should handle empty stream', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('E5M9dFKw'); + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], + usageMetadata: { + promptTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + promptTokensDetails: [ + { modality: 'TEXT', tokenCount: 0 }, + { modality: 'IMAGE', tokenCount: 0 }, + ], + }, + modelVersion: 'gemini-test', + } as unknown as GenerateContentResponse); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + 'id: chat_E5M9dFKw\n', + 'event: stop\n', + `data: "STOP"\n\n`, + 'id: chat_E5M9dFKw\n', + 'event: usage\n', + `data: {"inputCachedTokens":0,"inputImageTokens":0,"inputTextTokens":0,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":0,"totalOutputTokens":0,"totalTokens":0}\n\n`, + ]); + }); + + it('should return undefined data without text', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + candidates: [ + { + content: { parts: [{ text: '234' }], role: 'model' }, + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + text: '234', + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 3, + totalTokenCount: 122, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + thoughtsTokenCount: 100, + }, + modelVersion: 'gemini-2.5-flash-preview-04-17', + }, + { + text: '', + candidates: [ + { + content: { parts: [{ text: '' }], role: 'model' }, + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 3, + totalTokenCount: 122, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 3 }], + thoughtsTokenCount: 100, + }, + modelVersion: 'gemini-2.5-flash-preview-04-17', + }, + { + text: '567890\n', + candidates: [ + { + content: { parts: [{ text: '567890\n' }], role: 'model' }, + finishReason: 'STOP', + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 11, + totalTokenCount: 131, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], + thoughtsTokenCount: 100, + }, + modelVersion: 'gemini-2.5-flash-preview-04-17', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: text', + 'data: "234"\n', + + 'id: chat_1', + 'event: text', + 'data: ""\n', + + 'id: chat_1', + 'event: text', + `data: "567890\\n"\n`, + // stop + 'id: chat_1', + 'event: stop', + `data: "STOP"\n`, + // usage + 'id: chat_1', + 'event: usage', + `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`, + ].map((i) => i + '\n'), + ); + }); + }); + + describe('Reasoning and Thought', () => { + it('should handle thought candidate part', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + candidates: [ + { + content: { + parts: [{ text: '**Understanding the Conditional Logic**\n\n', thought: true }], + role: 'model', + }, + index: 0, + }, + ], + text: '**Understanding the Conditional Logic**\n\n', + usageMetadata: { + promptTokenCount: 38, + candidatesTokenCount: 7, + totalTokenCount: 301, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], + thoughtsTokenCount: 256, + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + { + candidates: [ + { + content: { + parts: [{ text: '**Finalizing Interpretation**\n\n', thought: true }], + role: 'model', + }, + index: 0, + }, + ], + text: '**Finalizing Interpretation**\n\n', + usageMetadata: { + promptTokenCount: 38, + candidatesTokenCount: 13, + totalTokenCount: 355, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], + thoughtsTokenCount: 304, + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + { + candidates: [ + { + content: { + parts: [{ text: '简单来说,' }], + role: 'model', + }, + index: 0, + }, + ], + text: '简单来说,', + usageMetadata: { + promptTokenCount: 38, + candidatesTokenCount: 16, + totalTokenCount: 358, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], + thoughtsTokenCount: 304, + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + { + candidates: [ + { + content: { parts: [{ text: '文本内容。' }], role: 'model' }, + finishReason: 'STOP', + index: 0, + }, + ], + text: '文本内容。', + usageMetadata: { + promptTokenCount: 38, + candidatesTokenCount: 19, + totalTokenCount: 361, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], + thoughtsTokenCount: 304, + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: reasoning', + 'data: "**Understanding the Conditional Logic**\\n\\n"\n', + + 'id: chat_1', + 'event: reasoning', + `data: "**Finalizing Interpretation**\\n\\n"\n`, + + 'id: chat_1', + 'event: text', + `data: "简单来说,"\n`, + + 'id: chat_1', + 'event: text', + `data: "文本内容。"\n`, + // stop + 'id: chat_1', + 'event: stop', + `data: "STOP"\n`, + // usage + 'id: chat_1', + 'event: usage', + `data: {"inputTextTokens":38,"outputImageTokens":0,"outputReasoningTokens":304,"outputTextTokens":19,"totalInputTokens":38,"totalOutputTokens":323,"totalTokens":361}\n`, + ].map((i) => i + '\n'), + ); + }); + + it('should handle stop with content and thought', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + candidates: [ + { + content: { parts: [{ text: '234' }], role: 'model' }, + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + text: '234', + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 3, + totalTokenCount: 122, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + thoughtsTokenCount: 100, + }, + modelVersion: 'gemini-2.5-flash-preview-04-17', + }, + { + text: '567890\n', + candidates: [ + { + content: { parts: [{ text: '567890\n' }], role: 'model' }, + finishReason: 'STOP', + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 11, + totalTokenCount: 131, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], + thoughtsTokenCount: 100, + }, + modelVersion: 'gemini-2.5-flash-preview-04-17', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: text', + 'data: "234"\n', + + 'id: chat_1', + 'event: text', + `data: "567890\\n"\n`, + // stop + 'id: chat_1', + 'event: stop', + `data: "STOP"\n`, + // usage + 'id: chat_1', + 'event: usage', + `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`, + ].map((i) => i + '\n'), + ); + }); + }); + + describe('Usage and Token counting', () => { + it('should handle token count', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = { + candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], + usageMetadata: { + promptTokenCount: 266, + totalTokenCount: 266, + promptTokensDetails: [ + { modality: 'TEXT', tokenCount: 8 }, + { modality: 'IMAGE', tokenCount: 258 }, + ], + }, + modelVersion: 'gemini-2.0-flash-exp', + }; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + // stop + 'id: chat_1\n', + 'event: stop\n', + `data: "STOP"\n\n`, + // usage + 'id: chat_1\n', + 'event: usage\n', + `data: {"inputImageTokens":258,"inputTextTokens":8,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`, + ]); + }); + + it('should handle token count with cached token count', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = { + candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], + usageMetadata: { + promptTokenCount: 15725, + candidatesTokenCount: 1053, + totalTokenCount: 16778, + cachedContentTokenCount: 14286, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 15725 }], + cacheTokensDetails: [{ modality: 'TEXT', tokenCount: 14286 }], + }, + modelVersion: 'gemini-2.0-flash-exp', + }; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + // stop + 'id: chat_1\n', + 'event: stop\n', + `data: "STOP"\n\n`, + // usage + 'id: chat_1\n', + 'event: usage\n', + `data: {"inputCacheMissTokens":1439,"inputCachedTokens":14286,"inputTextTokens":15725,"outputImageTokens":0,"outputTextTokens":1053,"totalInputTokens":15725,"totalOutputTokens":1053,"totalTokens":16778}\n\n`, + ]); + }); + + it('should handle stop with content', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + candidates: [ + { + content: { parts: [{ text: '234' }], role: 'model' }, + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + text: '234', + usageMetadata: { + promptTokenCount: 20, + totalTokenCount: 20, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }], + }, + modelVersion: 'gemini-2.0-flash-exp-image-generation', + }, + { + text: '567890\n', + candidates: [ + { + content: { parts: [{ text: '567890\n' }], role: 'model' }, + finishReason: 'STOP', + safetyRatings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, + ], + }, + ], + usageMetadata: { + promptTokenCount: 19, + candidatesTokenCount: 11, + totalTokenCount: 30, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], + candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], + }, + modelVersion: 'gemini-2.0-flash-exp-image-generation', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: text', + 'data: "234"\n', + + 'id: chat_1', + 'event: text', + `data: "567890\\n"\n`, + // stop + 'id: chat_1', + 'event: stop', + `data: "STOP"\n`, + // usage + 'id: chat_1', + 'event: usage', + `data: {"inputTextTokens":19,"outputImageTokens":0,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`, + ].map((i) => i + '\n'), + ); + }); + }); + + describe('Special content types', () => { + it('should handle image', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgoAA' } }], + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 6, + totalTokenCount: 6, + promptTokensDetails: [ + { modality: 'TEXT', tokenCount: 6 }, + { modality: 'IMAGE', tokenCount: 0 }, + ], + }, + modelVersion: 'gemini-2.0-flash-exp', + }; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + // image + 'id: chat_1\n', + 'event: base64_image\n', + `data: "data:image/png;base64,iVBORw0KGgoAA"\n\n`, + // stop + 'id: chat_1\n', + 'event: stop\n', + `data: "STOP"\n\n`, + // usage + 'id: chat_1\n', + 'event: usage\n', + `data: {"inputImageTokens":0,"inputTextTokens":6,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":6,"totalOutputTokens":0,"totalTokens":6}\n\n`, + ]); + }); + + it('should handle groundingMetadata', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + text: '123', + candidates: [ + { + content: { + parts: [ + { + text: '123', + }, + ], + role: 'model', + }, + index: 0, + groundingMetadata: {}, + }, + ], + usageMetadata: { + promptTokenCount: 9, + candidatesTokenCount: 18, + totalTokenCount: 27, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 9, + }, + ], + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + { + text: '45678', + candidates: [ + { + content: { + parts: [ + { + text: '45678', + }, + ], + role: 'model', + }, + finishReason: 'STOP', + index: 0, + groundingMetadata: { + searchEntryPoint: { + renderedContent: 'content\n', + }, + groundingChunks: [ + { + web: { + uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXG1234545', + title: 'npmjs.com', + }, + }, + { + web: { + uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXE9288334', + title: 'google.dev', + }, + }, + ], + groundingSupports: [ + { + segment: { + startIndex: 63, + endIndex: 67, + text: '1。', + }, + groundingChunkIndices: [0], + confidenceScores: [1], + }, + { + segment: { + startIndex: 69, + endIndex: 187, + text: 'SDK。', + }, + groundingChunkIndices: [1], + confidenceScores: [1], + }, + ], + webSearchQueries: ['sdk latest version'], + }, + }, + ], + usageMetadata: { + promptTokenCount: 9, + candidatesTokenCount: 122, + totalTokenCount: 131, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 9, + }, + ], + }, + modelVersion: 'models/gemini-2.5-flash-preview-04-17', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: text', + 'data: "123"\n', + + 'id: chat_1', + 'event: text', + 'data: "45678"\n', + + 'id: chat_1', + 'event: grounding', + `data: {\"citations\":[{\"favicon\":\"npmjs.com\",\"title\":\"npmjs.com\",\"url\":\"https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXG1234545\"},{\"favicon\":\"google.dev\",\"title\":\"google.dev\",\"url\":\"https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXE9288334\"}],\"searchQueries\":[\"sdk latest version\"]}\n`, + // stop + 'id: chat_1', + 'event: stop', + `data: "STOP"\n`, + // usage + 'id: chat_1', + 'event: usage', + `data: {"inputTextTokens":9,"outputImageTokens":0,"outputTextTokens":122,"totalInputTokens":9,"totalOutputTokens":122,"totalTokens":131}\n`, + ].map((i) => i + '\n'), + ); + }); + }); + + describe('Tool calls', () => { + it('should handle tool calls with thoughtSignature', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1').mockReturnValueOnce('abcd1234'); + + const data = [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'grep____searchGitHub____mcp', + args: { + query: '"version":', + repo: 'lobehub/lobe-chat', + path: 'package.json', + }, + }, + thoughtSignature: '123', + }, + ], + role: 'model', + }, + index: 0, + }, + ], + modelVersion: 'gemini-3-pro-preview', + responseId: 'UVcdaZ26ILac_uMP9ZOeiQ0', + usageMetadata: { + promptTokenCount: 1171, + candidatesTokenCount: 41, + totalTokenCount: 1408, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1171 }], + thoughtsTokenCount: 196, + }, + }, + { + candidates: [ + { + content: { parts: [{ text: '' }], role: 'model' }, + finishReason: 'STOP', + index: 0, + }, + ], + modelVersion: 'gemini-3-pro-preview', + responseId: 'UVcdaZ26ILac_uMP9ZOeiQ0', + usageMetadata: { + promptTokenCount: 1171, + candidatesTokenCount: 41, + totalTokenCount: 1408, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1171 }], + thoughtsTokenCount: 196, + }, + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: tool_calls', + 'data: [{"function":{"arguments":"{\\"query\\":\\"\\\\\\\"version\\\\\\":\\",\\"repo\\":\\"lobehub/lobe-chat\\",\\"path\\":\\"package.json\\"}","name":"grep____searchGitHub____mcp"},"id":"grep____searchGitHub____mcp_0_abcd1234","index":0,"thoughtSignature":"123","type":"function"}]\n', + + 'id: chat_1', + 'event: stop', + 'data: "STOP"\n', + + 'id: chat_1', + 'event: usage', + 'data: {"inputTextTokens":1171,"outputImageTokens":0,"outputReasoningTokens":196,"outputTextTokens":41,"totalInputTokens":1171,"totalOutputTokens":237,"totalTokens":1408}\n', + ].map((i) => i + '\n'), + ); + }); + + it('should handle parallel tool calls', async () => { + vi.spyOn(uuidModule, 'nanoid') + .mockReturnValueOnce('1') + .mockReturnValueOnce('abcd1234') + .mockReturnValueOnce('efgh5678'); + + const data = [ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'get_current_temperature', + args: { + location: 'Paris', + }, + }, + thoughtSignature: 'ErEDCq4DAdHtim...', + }, + ], + role: 'model', + }, + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 72, + candidatesTokenCount: 18, + totalTokenCount: 167, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 72, + }, + ], + thoughtsTokenCount: 77, + }, + modelVersion: 'gemini-3-pro-preview', + responseId: 'UDcdaZviO4jojMcPycPDkQY', + }, + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'get_current_temperature', + args: { + location: 'London', + }, + }, + }, + ], + role: 'model', + }, + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 72, + candidatesTokenCount: 36, + totalTokenCount: 185, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 72, + }, + ], + thoughtsTokenCount: 77, + }, + modelVersion: 'gemini-3-pro-preview', + responseId: 'UDcdaZviO4jojMcPycPDkQY', + }, + { + candidates: [ + { + content: { + parts: [ + { + text: '', + }, + ], + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 72, + candidatesTokenCount: 36, + totalTokenCount: 185, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 72, + }, + ], + thoughtsTokenCount: 77, + }, + modelVersion: 'gemini-3-pro-preview', + responseId: 'UDcdaZviO4jojMcPycPDkQY', + }, + ]; + + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: tool_calls', + 'data: [{"function":{"arguments":"{\\"location\\":\\"Paris\\"}","name":"get_current_temperature"},"id":"get_current_temperature_0_abcd1234","index":0,"thoughtSignature":"ErEDCq4DAdHtim...","type":"function"}]\n', + + 'id: chat_1', + 'event: tool_calls', + 'data: [{"function":{"arguments":"{\\"location\\":\\"London\\"}","name":"get_current_temperature"},"id":"get_current_temperature_0_efgh5678","index":0,"type":"function"}]\n', + + 'id: chat_1', + 'event: stop', + 'data: "STOP"\n', + + 'id: chat_1', + 'event: usage', + 'data: {"inputTextTokens":72,"outputImageTokens":0,"outputReasoningTokens":77,"outputTextTokens":36,"totalInputTokens":72,"totalOutputTokens":113,"totalTokens":185}\n', + ].map((i) => i + '\n'), + ); + }); + + it('should handle thoughtSignature with empty text', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + + const data = [ + { + candidates: [ + { + content: { + parts: [ + { + text: '你好!很高兴为你服务。请问有什么我可以帮你的吗?\n\n无论是回答问题、协助写作、翻译,还是随便聊聊,我都随时待命!', + }, + ], + role: 'model', + }, + index: 0, + }, + ], usageMetadata: { promptTokenCount: 1, - totalTokenCount: 1, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 1 }], - }, - modelVersion: 'gemini-test', - } as unknown as GenerateContentResponse); - - controller.close(); - }, - }); - - const onStartMock = vi.fn(); - const onTextMock = vi.fn(); - const onToolCallMock = vi.fn(); - const onCompletionMock = vi.fn(); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream, { - callbacks: { - onStart: onStartMock, - onText: onTextMock, - onToolsCalling: onToolCallMock, - onCompletion: onCompletionMock, - }, - }); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - // text - 'id: chat_1\n', - 'event: text\n', - `data: "Hello"\n\n`, - - // tool call - 'id: chat_1\n', - 'event: tool_calls\n', - `data: [{"function":{"arguments":"{\\"arg1\\":\\"value1\\"}","name":"testFunction"},"id":"testFunction_0_abcd1234","index":0,"type":"function"}]\n\n`, - - // text - 'id: chat_1\n', - 'event: text\n', - `data: " world!"\n\n`, - // stop - 'id: chat_1\n', - 'event: stop\n', - `data: "STOP"\n\n`, - // usage - 'id: chat_1\n', - 'event: usage\n', - `data: {"inputTextTokens":1,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":1,"totalOutputTokens":0,"totalTokens":1}\n\n`, - ]); - - expect(onStartMock).toHaveBeenCalledTimes(1); - expect(onTextMock).toHaveBeenNthCalledWith(1, 'Hello'); - expect(onTextMock).toHaveBeenNthCalledWith(2, ' world!'); - expect(onToolCallMock).toHaveBeenCalledTimes(1); - expect(onCompletionMock).toHaveBeenCalledTimes(1); - }); - - it('should handle empty stream', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('E5M9dFKw'); - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue({ - candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], - usageMetadata: { - promptTokenCount: 0, - cachedContentTokenCount: 0, - totalTokenCount: 0, + candidatesTokenCount: 35, + totalTokenCount: 712, promptTokensDetails: [ - { modality: 'TEXT', tokenCount: 0 }, - { modality: 'IMAGE', tokenCount: 0 }, - ], - }, - modelVersion: 'gemini-test', - } as unknown as GenerateContentResponse); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - 'id: chat_E5M9dFKw\n', - 'event: stop\n', - `data: "STOP"\n\n`, - 'id: chat_E5M9dFKw\n', - 'event: usage\n', - `data: {"inputCachedTokens":0,"inputImageTokens":0,"inputTextTokens":0,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":0,"totalOutputTokens":0,"totalTokens":0}\n\n`, - ]); - }); - - it('should handle image', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = { - candidates: [ - { - content: { - parts: [{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgoAA' } }], - role: 'model', - }, - finishReason: 'STOP', - index: 0, - }, - ], - usageMetadata: { - promptTokenCount: 6, - totalTokenCount: 6, - promptTokensDetails: [ - { modality: 'TEXT', tokenCount: 6 }, - { modality: 'IMAGE', tokenCount: 0 }, - ], - }, - modelVersion: 'gemini-2.0-flash-exp', - }; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue(data); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - // image - 'id: chat_1\n', - 'event: base64_image\n', - `data: "data:image/png;base64,iVBORw0KGgoAA"\n\n`, - // stop - 'id: chat_1\n', - 'event: stop\n', - `data: "STOP"\n\n`, - // usage - 'id: chat_1\n', - 'event: usage\n', - `data: {"inputImageTokens":0,"inputTextTokens":6,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":6,"totalOutputTokens":0,"totalTokens":6}\n\n`, - ]); - }); - - it('should handle token count', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = { - candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], - usageMetadata: { - promptTokenCount: 266, - totalTokenCount: 266, - promptTokensDetails: [ - { modality: 'TEXT', tokenCount: 8 }, - { modality: 'IMAGE', tokenCount: 258 }, - ], - }, - modelVersion: 'gemini-2.0-flash-exp', - }; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue(data); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - // stop - 'id: chat_1\n', - 'event: stop\n', - `data: "STOP"\n\n`, - // usage - 'id: chat_1\n', - 'event: usage\n', - `data: {"inputImageTokens":258,"inputTextTokens":8,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`, - ]); - }); - - it('should handle token count with cached token count', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = { - candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }], - usageMetadata: { - promptTokenCount: 15725, - candidatesTokenCount: 1053, - totalTokenCount: 16778, - cachedContentTokenCount: 14286, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 15725 }], - cacheTokensDetails: [{ modality: 'TEXT', tokenCount: 14286 }], - }, - modelVersion: 'gemini-2.0-flash-exp', - }; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue(data); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - // stop - 'id: chat_1\n', - 'event: stop\n', - `data: "STOP"\n\n`, - // usage - 'id: chat_1\n', - 'event: usage\n', - `data: {"inputCacheMissTokens":1439,"inputCachedTokens":14286,"inputTextTokens":15725,"outputImageTokens":0,"outputTextTokens":1053,"totalInputTokens":15725,"totalOutputTokens":1053,"totalTokens":16778}\n\n`, - ]); - }); - - it('should handle stop with content', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = [ - { - candidates: [ - { - content: { parts: [{ text: '234' }], role: 'model' }, - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - text: '234', - usageMetadata: { - promptTokenCount: 20, - totalTokenCount: 20, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }], - }, - modelVersion: 'gemini-2.0-flash-exp-image-generation', - }, - { - text: '567890\n', - candidates: [ - { - content: { parts: [{ text: '567890\n' }], role: 'model' }, - finishReason: 'STOP', - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 11, - totalTokenCount: 30, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], - }, - modelVersion: 'gemini-2.0-flash-exp-image-generation', - }, - ]; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - data.forEach((item) => { - controller.enqueue(item); - }); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual( - [ - 'id: chat_1', - 'event: text', - 'data: "234"\n', - - 'id: chat_1', - 'event: text', - `data: "567890\\n"\n`, - // stop - 'id: chat_1', - 'event: stop', - `data: "STOP"\n`, - // usage - 'id: chat_1', - 'event: usage', - `data: {"inputTextTokens":19,"outputImageTokens":0,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`, - ].map((i) => i + '\n'), - ); - }); - - it('should handle stop with content and thought', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = [ - { - candidates: [ - { - content: { parts: [{ text: '234' }], role: 'model' }, - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - text: '234', - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 3, - totalTokenCount: 122, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - thoughtsTokenCount: 100, - }, - modelVersion: 'gemini-2.5-flash-preview-04-17', - }, - { - text: '567890\n', - candidates: [ - { - content: { parts: [{ text: '567890\n' }], role: 'model' }, - finishReason: 'STOP', - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 11, - totalTokenCount: 131, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], - thoughtsTokenCount: 100, - }, - modelVersion: 'gemini-2.5-flash-preview-04-17', - }, - ]; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - data.forEach((item) => { - controller.enqueue(item); - }); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual( - [ - 'id: chat_1', - 'event: text', - 'data: "234"\n', - - 'id: chat_1', - 'event: text', - `data: "567890\\n"\n`, - // stop - 'id: chat_1', - 'event: stop', - `data: "STOP"\n`, - // usage - 'id: chat_1', - 'event: usage', - `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`, - ].map((i) => i + '\n'), - ); - }); - - it('should handle thought candidate part', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = [ - { - candidates: [ - { - content: { - parts: [{ text: '**Understanding the Conditional Logic**\n\n', thought: true }], - role: 'model', - }, - index: 0, - }, - ], - text: '**Understanding the Conditional Logic**\n\n', - usageMetadata: { - promptTokenCount: 38, - candidatesTokenCount: 7, - totalTokenCount: 301, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], - thoughtsTokenCount: 256, - }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - { - candidates: [ - { - content: { - parts: [{ text: '**Finalizing Interpretation**\n\n', thought: true }], - role: 'model', - }, - index: 0, - }, - ], - text: '**Finalizing Interpretation**\n\n', - usageMetadata: { - promptTokenCount: 38, - candidatesTokenCount: 13, - totalTokenCount: 355, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], - thoughtsTokenCount: 304, - }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - { - candidates: [ - { - content: { - parts: [{ text: '简单来说,' }], - role: 'model', - }, - index: 0, - }, - ], - text: '简单来说,', - usageMetadata: { - promptTokenCount: 38, - candidatesTokenCount: 16, - totalTokenCount: 358, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], - thoughtsTokenCount: 304, - }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - { - candidates: [ - { - content: { parts: [{ text: '文本内容。' }], role: 'model' }, - finishReason: 'STOP', - index: 0, - }, - ], - text: '文本内容。', - usageMetadata: { - promptTokenCount: 38, - candidatesTokenCount: 19, - totalTokenCount: 361, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 38 }], - thoughtsTokenCount: 304, - }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - ]; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - data.forEach((item) => { - controller.enqueue(item); - }); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual( - [ - 'id: chat_1', - 'event: reasoning', - 'data: "**Understanding the Conditional Logic**\\n\\n"\n', - - 'id: chat_1', - 'event: reasoning', - `data: "**Finalizing Interpretation**\\n\\n"\n`, - - 'id: chat_1', - 'event: text', - `data: "简单来说,"\n`, - - 'id: chat_1', - 'event: text', - `data: "文本内容。"\n`, - // stop - 'id: chat_1', - 'event: stop', - `data: "STOP"\n`, - // usage - 'id: chat_1', - 'event: usage', - `data: {"inputTextTokens":38,"outputImageTokens":0,"outputReasoningTokens":304,"outputTextTokens":19,"totalInputTokens":38,"totalOutputTokens":323,"totalTokens":361}\n`, - ].map((i) => i + '\n'), - ); - }); - - it('should return undefined data without text', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = [ - { - candidates: [ - { - content: { parts: [{ text: '234' }], role: 'model' }, - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - text: '234', - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 3, - totalTokenCount: 122, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - thoughtsTokenCount: 100, - }, - modelVersion: 'gemini-2.5-flash-preview-04-17', - }, - { - text: '', - candidates: [ - { - content: { parts: [{ text: '' }], role: 'model' }, - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 3, - totalTokenCount: 122, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 3 }], - thoughtsTokenCount: 100, - }, - modelVersion: 'gemini-2.5-flash-preview-04-17', - }, - { - text: '567890\n', - candidates: [ - { - content: { parts: [{ text: '567890\n' }], role: 'model' }, - finishReason: 'STOP', - safetyRatings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, - { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, - ], - }, - ], - usageMetadata: { - promptTokenCount: 19, - candidatesTokenCount: 11, - totalTokenCount: 131, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], - candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], - thoughtsTokenCount: 100, - }, - modelVersion: 'gemini-2.5-flash-preview-04-17', - }, - ]; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - data.forEach((item) => { - controller.enqueue(item); - }); - - controller.close(); - }, - }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual( - [ - 'id: chat_1', - 'event: text', - 'data: "234"\n', - - 'id: chat_1', - 'event: text', - 'data: ""\n', - - 'id: chat_1', - 'event: text', - `data: "567890\\n"\n`, - // stop - 'id: chat_1', - 'event: stop', - `data: "STOP"\n`, - // usage - 'id: chat_1', - 'event: usage', - `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`, - ].map((i) => i + '\n'), - ); - }); - - it('should handle groundingMetadata', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - - const data = [ - { - text: '123', - candidates: [ - { - content: { - parts: [ - { - text: '123', - }, - ], - role: 'model', - }, - index: 0, - groundingMetadata: {}, - }, - ], - usageMetadata: { - promptTokenCount: 9, - candidatesTokenCount: 18, - totalTokenCount: 27, - promptTokensDetails: [ - { - modality: 'TEXT', - tokenCount: 9, - }, - ], - }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - { - text: '45678', - candidates: [ - { - content: { - parts: [ - { - text: '45678', - }, - ], - role: 'model', - }, - finishReason: 'STOP', - index: 0, - groundingMetadata: { - searchEntryPoint: { - renderedContent: 'content\n', + { + modality: 'TEXT', + tokenCount: 1, }, - groundingChunks: [ - { - web: { - uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXG1234545', - title: 'npmjs.com', - }, - }, - { - web: { - uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXE9288334', - title: 'google.dev', - }, - }, - ], - groundingSupports: [ - { - segment: { - startIndex: 63, - endIndex: 67, - text: '1。', - }, - groundingChunkIndices: [0], - confidenceScores: [1], - }, - { - segment: { - startIndex: 69, - endIndex: 187, - text: 'SDK。', - }, - groundingChunkIndices: [1], - confidenceScores: [1], - }, - ], - webSearchQueries: ['sdk latest version'], - }, + ], + thoughtsTokenCount: 676, }, - ], - usageMetadata: { - promptTokenCount: 9, - candidatesTokenCount: 122, - totalTokenCount: 131, - promptTokensDetails: [ + modelVersion: 'gemini-3-pro-preview', + responseId: 'lTcdaf_1FrONjMcP24Sz6QQ', + }, + { + candidates: [ { - modality: 'TEXT', - tokenCount: 9, + content: { + parts: [ + { + text: '', + thoughtSignature: 'Ep8YCpwYAdHtim...', + }, + ], + role: 'model', + }, + finishReason: 'STOP', + index: 0, }, ], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 35, + totalTokenCount: 712, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 1, + }, + ], + thoughtsTokenCount: 676, + }, + modelVersion: 'gemini-3-pro-preview', + responseId: 'lTcdaf_1FrONjMcP24Sz6QQ', }, - modelVersion: 'models/gemini-2.5-flash-preview-04-17', - }, - ]; + ]; - const mockGoogleStream = new ReadableStream({ - start(controller) { - data.forEach((item) => { - controller.enqueue(item); - }); + const mockGoogleStream = new ReadableStream({ + start(controller) { + data.forEach((item) => { + controller.enqueue(item); + }); - controller.close(); - }, + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual( + [ + 'id: chat_1', + 'event: text', + 'data: "你好!很高兴为你服务。请问有什么我可以帮你的吗?\\n\\n无论是回答问题、协助写作、翻译,还是随便聊聊,我都随时待命!"\n', + + 'id: chat_1', + 'event: stop', + 'data: "STOP"\n', + + 'id: chat_1', + 'event: usage', + 'data: {"inputTextTokens":1,"outputImageTokens":0,"outputReasoningTokens":676,"outputTextTokens":35,"totalInputTokens":1,"totalOutputTokens":711,"totalTokens":712}\n', + ].map((i) => i + '\n'), + ); }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual( - [ - 'id: chat_1', - 'event: text', - 'data: "123"\n', - - 'id: chat_1', - 'event: text', - 'data: "45678"\n', - - 'id: chat_1', - 'event: grounding', - `data: {\"citations\":[{\"favicon\":\"npmjs.com\",\"title\":\"npmjs.com\",\"url\":\"https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXG1234545\"},{\"favicon\":\"google.dev\",\"title\":\"google.dev\",\"url\":\"https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbF9wXE9288334\"}],\"searchQueries\":[\"sdk latest version\"]}\n`, - // stop - 'id: chat_1', - 'event: stop', - `data: "STOP"\n`, - // usage - 'id: chat_1', - 'event: usage', - `data: {"inputTextTokens":9,"outputImageTokens":0,"outputTextTokens":122,"totalInputTokens":9,"totalOutputTokens":122,"totalTokens":131}\n`, - ].map((i) => i + '\n'), - ); }); - it('should handle promptFeedback with blockReason (PROHIBITED_CONTENT)', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + describe('Error handling', () => { + it('should handle promptFeedback with blockReason (PROHIBITED_CONTENT)', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - const data = { - promptFeedback: { - blockReason: 'PROHIBITED_CONTENT', - }, - usageMetadata: { - promptTokenCount: 4438, - totalTokenCount: 4438, - promptTokensDetails: [{ modality: 'TEXT', tokenCount: 4438 }], - }, - modelVersion: 'gemini-2.5-pro', - responseId: 'THOUaKaNOeiGz7IPjL_VgQc', - }; + const data = { + promptFeedback: { + blockReason: 'PROHIBITED_CONTENT', + }, + usageMetadata: { + promptTokenCount: 4438, + totalTokenCount: 4438, + promptTokensDetails: [{ modality: 'TEXT', tokenCount: 4438 }], + }, + modelVersion: 'gemini-2.5-pro', + responseId: 'THOUaKaNOeiGz7IPjL_VgQc', + }; - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue(data); - controller.close(); - }, + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + controller.close(); + }, + }); + + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + + const chunks = await decodeStreamChunks(protocolStream); + + expect(chunks).toEqual([ + 'id: chat_1\n', + 'event: error\n', + `data: {"body":{"context":{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}},"message":"Your request may contain prohibited content. Please adjust your request to comply with the usage guidelines.","provider":"google"},"type":"ProviderBizError"}\n\n`, + ]); }); - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); + it('should pass through injected lobe error marker', async () => { + vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); - const decoder = new TextDecoder(); - const chunks = []; + const errorPayload = { message: 'internal error', code: 123 }; - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } + const mockGoogleStream = new ReadableStream({ + start(controller) { + controller.enqueue({ [LOBE_ERROR_KEY]: errorPayload }); + controller.close(); + }, + }); - expect(chunks).toEqual([ - 'id: chat_1\n', - 'event: error\n', - `data: {"body":{"context":{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}},"message":"Your request may contain prohibited content. Please adjust your request to comply with the usage guidelines.","provider":"google"},"type":"ProviderBizError"}\n\n`, - ]); - }); + const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - it('should pass through injected lobe error marker', async () => { - vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); + const chunks = await decodeStreamChunks(protocolStream); - const errorPayload = { message: 'internal error', code: 123 }; - - const mockGoogleStream = new ReadableStream({ - start(controller) { - controller.enqueue({ [LOBE_ERROR_KEY]: errorPayload }); - controller.close(); - }, + expect(chunks).toEqual([ + 'id: chat_1\n', + 'event: error\n', + `data: ${JSON.stringify(errorPayload)}\n\n`, + ]); }); - - const protocolStream = GoogleGenerativeAIStream(mockGoogleStream); - - const decoder = new TextDecoder(); - const chunks = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - 'id: chat_1\n', - 'event: error\n', - `data: ${JSON.stringify(errorPayload)}\n\n`, - ]); }); }); diff --git a/packages/model-runtime/src/core/streams/google/index.ts b/packages/model-runtime/src/core/streams/google/index.ts index 0243f3c657..b02b6bd278 100644 --- a/packages/model-runtime/src/core/streams/google/index.ts +++ b/packages/model-runtime/src/core/streams/google/index.ts @@ -1,4 +1,4 @@ -import { GenerateContentResponse } from '@google/genai'; +import { GenerateContentResponse, Part } from '@google/genai'; import { GroundingSearch } from '@lobechat/types'; import { ChatStreamCallbacks } from '../../../types'; @@ -74,19 +74,27 @@ const transformGoogleGenerativeAIStream = ( } } - const functionCalls = chunk.functionCalls; + // Parse function calls from candidate.content.parts + const functionCalls = + candidate?.content?.parts + ?.filter((part: any) => part.functionCall) + .map((part: Part) => ({ + ...part.functionCall, + thoughtSignature: part.thoughtSignature, + })) || []; - if (functionCalls) { + if (functionCalls.length > 0) { return [ { data: functionCalls.map( - (value, index): StreamToolCallChunkData => ({ + (value, index: number): StreamToolCallChunkData => ({ function: { arguments: JSON.stringify(value.args), name: value.name, }, id: generateToolCallId(index, value.name), index: index, + thoughtSignature: value.thoughtSignature, type: 'function', }), ), @@ -97,7 +105,13 @@ const transformGoogleGenerativeAIStream = ( ]; } - const text = chunk.text; + // Parse text from candidate.content.parts + // Filter out thought content (thought: true) and thoughtSignature + const text = + candidate?.content?.parts + ?.filter((part: any) => part.text && !part.thought && !part.thoughtSignature) + .map((part: any) => part.text) + .join('') || ''; if (candidate) { // 首先检查是否为 reasoning 内容 (thought: true) diff --git a/packages/model-runtime/src/core/streams/protocol.ts b/packages/model-runtime/src/core/streams/protocol.ts index aa6705d181..01fe864d5a 100644 --- a/packages/model-runtime/src/core/streams/protocol.ts +++ b/packages/model-runtime/src/core/streams/protocol.ts @@ -98,6 +98,7 @@ export interface StreamToolCallChunkData { }; id?: string; index: number; + thoughtSignature?: string; type: 'function' | string; } diff --git a/packages/model-runtime/src/providers/google/index.test.ts b/packages/model-runtime/src/providers/google/index.test.ts index d0a88e213b..8566dac3e3 100644 --- a/packages/model-runtime/src/providers/google/index.test.ts +++ b/packages/model-runtime/src/providers/google/index.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { GenerateContentResponse, Tool } from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; import OpenAI from 'openai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/model-runtime/src/providers/google/index.ts b/packages/model-runtime/src/providers/google/index.ts index e4ffae69e1..c6db404943 100644 --- a/packages/model-runtime/src/providers/google/index.ts +++ b/packages/model-runtime/src/providers/google/index.ts @@ -267,19 +267,21 @@ export class LobeGoogleAI implements LobeRuntimeAI { const inputStartAt = Date.now(); - const geminiStreamResponse = await this.client.models.generateContentStream({ - config, - contents, - model, - }); - - const googleStream = this.createEnhancedStream(geminiStreamResponse, controller.signal); - const [prod, useForDebug] = googleStream.tee(); - + const finalPayload = { config, contents, model }; const key = this.isVertexAi ? 'DEBUG_VERTEX_AI_CHAT_COMPLETION' : 'DEBUG_GOOGLE_CHAT_COMPLETION'; + if (process.env[key] === '1') { + console.log('[requestPayload]'); + console.log(JSON.stringify(finalPayload), '\n'); + } + + const geminiStreamResponse = await this.client.models.generateContentStream(finalPayload); + + const googleStream = this.createEnhancedStream(geminiStreamResponse, controller.signal); + const [prod, useForDebug] = googleStream.tee(); + if (process.env[key] === '1') { debugStream(useForDebug).catch(); } diff --git a/packages/model-runtime/src/types/toolsCalling.ts b/packages/model-runtime/src/types/toolsCalling.ts index d5a711908a..9198f7b62b 100644 --- a/packages/model-runtime/src/types/toolsCalling.ts +++ b/packages/model-runtime/src/types/toolsCalling.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; import type { PartialDeep } from 'type-fest'; +import { z } from 'zod'; /** * The function that the model called. @@ -30,6 +30,7 @@ export interface MessageToolCall { */ id: string; + thoughtSignature?: string; /** * The type of the tool. Currently, only `function` is supported. */ @@ -42,6 +43,7 @@ export const MessageToolCallSchema = z.object({ name: z.string(), }), id: z.string(), + thoughtSignature: z.string().optional(), type: z.string(), }); diff --git a/packages/types/src/message/common/tools.ts b/packages/types/src/message/common/tools.ts index e55fcc2a2f..6b36bb4648 100644 --- a/packages/types/src/message/common/tools.ts +++ b/packages/types/src/message/common/tools.ts @@ -30,6 +30,7 @@ export interface ChatToolPayload { identifier: string; intervention?: ToolIntervention; result_msg_id?: string; + thoughtSignature?: string; type: LobeToolRenderType; } @@ -84,6 +85,7 @@ export interface MessageToolCall { */ id: string; + thoughtSignature?: string; /** * The type of the tool. Currently, only `function` is supported. */ @@ -108,6 +110,7 @@ export const ChatToolPayloadSchema = z.object({ identifier: z.string(), intervention: ToolInterventionSchema.optional(), result_msg_id: z.string().optional(), + thoughtSignature: z.string().optional(), type: z.string(), }); diff --git a/src/features/Conversation/Messages/Group/Error/index.tsx b/src/features/Conversation/Messages/Group/Error/index.tsx index a89542a849..57585908d1 100644 --- a/src/features/Conversation/Messages/Group/Error/index.tsx +++ b/src/features/Conversation/Messages/Group/Error/index.tsx @@ -15,12 +15,13 @@ export interface ErrorContentProps { const ErrorContent = memo(({ error, id }) => { const { t } = useTranslation('common'); - const errorProps = useErrorContent(error); const [deleteMessage] = useChatStore((s) => [s.deleteDBMessage]); const message = ; - if (!error?.message) { + const errorProps = useErrorContent(error); + + if (!errorProps?.message) { if (!message) return null; return {message}; } diff --git a/src/features/Conversation/Messages/Group/GroupItem.tsx b/src/features/Conversation/Messages/Group/GroupItem.tsx index 35458670be..a9f80220aa 100644 --- a/src/features/Conversation/Messages/Group/GroupItem.tsx +++ b/src/features/Conversation/Messages/Group/GroupItem.tsx @@ -30,10 +30,10 @@ const GroupItem = memo( }); }} > - + ) : ( - + ); }, isEqual, diff --git a/src/store/chat/slices/aiChat/actions/streamingExecutor.ts b/src/store/chat/slices/aiChat/actions/streamingExecutor.ts index 3160b7faab..7b61598215 100644 --- a/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +++ b/src/store/chat/slices/aiChat/actions/streamingExecutor.ts @@ -76,7 +76,7 @@ export interface StreamingExecutorAction { tool_calls?: MessageToolCall[]; content: string; traceId?: string; - finishType?: 'done' | 'error' | 'abort'; + finishType?: string; usage?: ModelUsage; }>; /** @@ -283,13 +283,13 @@ export const streamingExecutor: StateCreator< let thinkingStartAt: number; let duration: number | undefined; let reasoningOperationId: string | undefined; - let finishType: 'done' | 'error' | 'abort' | undefined; + let finishType: string | undefined; // to upload image const uploadTasks: Map> = new Map(); // Throttle tool_calls updates to prevent excessive re-renders (max once per 300ms) const throttledUpdateToolCalls = throttle( - (toolCalls: any[]) => { + (toolCalls: MessageToolCall[]) => { internal_dispatchMessage( { id: messageId, @@ -366,7 +366,6 @@ export const streamingExecutor: StateCreator< throttledUpdateToolCalls.flush(); internal_toggleToolCallingStreaming(messageId, undefined); - tools = get().internal_transformToolCalls(parsedToolCalls); tool_calls = toolCalls; parsedToolCalls = parsedToolCalls.map((item) => ({ @@ -377,6 +376,8 @@ export const streamingExecutor: StateCreator< }, })); + tools = get().internal_transformToolCalls(parsedToolCalls); + isFunctionCall = true; } @@ -395,7 +396,7 @@ export const streamingExecutor: StateCreator< messageId, content, { - toolCalls: parsedToolCalls, + tools, reasoning: !!reasoning ? { ...reasoning, duration: duration && !isNaN(duration) ? duration : undefined } : undefined, diff --git a/src/store/chat/slices/message/actions/optimisticUpdate.ts b/src/store/chat/slices/message/actions/optimisticUpdate.ts index b8d44f8386..b953026460 100644 --- a/src/store/chat/slices/message/actions/optimisticUpdate.ts +++ b/src/store/chat/slices/message/actions/optimisticUpdate.ts @@ -3,11 +3,11 @@ import { ChatImageItem, ChatMessageError, ChatMessagePluginError, + ChatToolPayload, CreateMessageParams, GroundingSearch, MessageMetadata, MessagePluginItem, - MessageToolCall, ModelReasoning, UIChatMessage, UpdateMessageRAGParams, @@ -69,7 +69,7 @@ export interface MessageOptimisticUpdateAction { provider?: string; reasoning?: ModelReasoning; search?: GroundingSearch; - toolCalls?: MessageToolCall[]; + tools?: ChatToolPayload[]; }, context?: OptimisticUpdateContext, ) => Promise; @@ -204,22 +204,17 @@ export const messageOptimisticUpdate: StateCreator< }, optimisticUpdateMessageContent: async (id, content, extra, context) => { - const { - internal_dispatchMessage, - refreshMessages, - internal_transformToolCalls, - replaceMessages, - } = get(); + const { internal_dispatchMessage, refreshMessages, replaceMessages } = get(); // Due to the async update method and refresh need about 100ms // we need to update the message content at the frontend to avoid the update flick // refs: https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171 - if (extra?.toolCalls) { + if (extra?.tools) { internal_dispatchMessage( { id, type: 'updateMessage', - value: { tools: internal_transformToolCalls(extra?.toolCalls) }, + value: { tools: extra?.tools }, }, context, ); @@ -246,7 +241,7 @@ export const messageOptimisticUpdate: StateCreator< provider: extra?.provider, reasoning: extra?.reasoning, search: extra?.search, - tools: extra?.toolCalls ? internal_transformToolCalls(extra?.toolCalls) : undefined, + tools: extra?.tools, }, { sessionId, topicId }, ); diff --git a/src/store/chat/slices/plugin/actions/internals.ts b/src/store/chat/slices/plugin/actions/internals.ts index 76e28463d0..61bb590b52 100644 --- a/src/store/chat/slices/plugin/actions/internals.ts +++ b/src/store/chat/slices/plugin/actions/internals.ts @@ -1,6 +1,6 @@ /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */ import { ToolNameResolver } from '@lobechat/context-engine'; -import { MessageToolCall, ToolsCallingContext } from '@lobechat/types'; +import { ChatToolPayload, MessageToolCall, ToolsCallingContext } from '@lobechat/types'; import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk'; import { StateCreator } from 'zustand/vanilla'; @@ -19,7 +19,7 @@ export interface PluginInternalsAction { /** * Transform tool calls from runtime format to storage format */ - internal_transformToolCalls: (toolCalls: MessageToolCall[]) => any[]; + internal_transformToolCalls: (toolCalls: MessageToolCall[]) => ChatToolPayload[]; /** * Construct tools calling context for plugin invocation