From 5167c927bed4c13411b084990a74d2a2203d4854 Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 28 Nov 2025 13:56:46 +0800 Subject: [PATCH 1/8] fix: preserve openrouter reasoning with web search (#11505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(options): implement deep merging for provider options Add deep merge functionality to preserve nested properties when combining provider options. The new implementation handles object merging recursively while maintaining type safety. * refactor(tsconfig): reorganize include paths in tsconfig files Clean up and reorder include paths for better maintainability and consistency between tsconfig.node.json and tsconfig.web.json * test: add aiCore test configuration and script Add new test configuration for aiCore package and corresponding test script in package.json to enable running tests specifically for the aiCore module. * fix: format * fix(aiCore): resolve test failures and update test infrastructure - Add vitest setup file with global mocks for @cherrystudio/ai-sdk-provider - Fix context assertions: use 'model' instead of 'modelId' in plugin tests - Fix error handling tests: update expected error messages to match actual behavior - Fix streamText tests: use 'maxOutputTokens' instead of 'maxTokens' - Fix schemas test: update expected provider list to match actual implementation - Fix mock-responses: use AI SDK v5 format (inputTokens/outputTokens) - Update vi.mock to use importOriginal for preserving jsonSchema export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): add alias mock for @cherrystudio/ai-sdk-provider in tests The vi.mock in setup file doesn't work for source code imports. Use vitest resolve.alias to mock the external package properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): disable unused-vars warnings in mock file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): use import.meta.url for ESM compatibility in vitest config __dirname is not available in ESM modules, use fileURLToPath instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): use absolute paths in vitest config for workspace compatibility - Use path.resolve for setupFiles and all alias paths - Extend aiCore vitest.config.ts from root workspace config - Change aiCore test environment to 'node' instead of 'jsdom' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * docs(factory): improve mergeProviderOptions documentation Add detailed explanation of merge behavior with examples * test(factory): add tests for mergeProviderOptions behavior Add test cases to verify mergeProviderOptions correctly handles primitive values, arrays, and nested objects during merging * refactor(tests): clean up mock responses test fixtures Remove unused mock streaming chunks and error responses to simplify test fixtures Update warning details structure in mock complete responses * docs(test): clarify comment in generateImage test Update comment to use consistent 'model id' terminology instead of 'modelId' * test(factory): verify array replacement in mergeProviderOptions --------- Co-authored-by: suyao Co-authored-by: Claude --- package.json | 1 + .../src/__tests__/fixtures/mock-responses.ts | 119 ++---------------- .../src/__tests__/mocks/ai-sdk-provider.ts | 35 ++++++ packages/aiCore/src/__tests__/setup.ts | 9 ++ .../core/options/__tests__/factory.test.ts | 109 ++++++++++++++++ packages/aiCore/src/core/options/factory.ts | 60 ++++++++- .../core/providers/__tests__/schemas.test.ts | 9 +- .../runtime/__tests__/generateImage.test.ts | 31 +++-- .../runtime/__tests__/generateText.test.ts | 15 ++- .../core/runtime/__tests__/streamText.test.ts | 22 ++-- packages/aiCore/vitest.config.ts | 12 +- tsconfig.node.json | 12 +- tsconfig.web.json | 14 +-- vitest.config.ts | 12 ++ 14 files changed, 308 insertions(+), 152 deletions(-) create mode 100644 packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts create mode 100644 packages/aiCore/src/__tests__/setup.ts create mode 100644 packages/aiCore/src/core/options/__tests__/factory.test.ts diff --git a/package.json b/package.json index de89b4514c..304785117e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test": "vitest run --silent", "test:main": "vitest run --project main", "test:renderer": "vitest run --project renderer", + "test:aicore": "vitest run --project aiCore", "test:update": "yarn test:renderer --update", "test:coverage": "vitest run --coverage --silent", "test:ui": "vitest --ui", diff --git a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts index 9855cfb36c..388a4f7fd5 100644 --- a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts +++ b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts @@ -3,12 +3,13 @@ * Provides realistic mock responses for all provider types */ -import { jsonSchema, type ModelMessage, type Tool } from 'ai' +import type { ModelMessage, Tool } from 'ai' +import { jsonSchema } from 'ai' /** * Standard test messages for all scenarios */ -export const testMessages = { +export const testMessages: Record = { simple: [{ role: 'user' as const, content: 'Hello, how are you?' }], conversation: [ @@ -45,7 +46,7 @@ export const testMessages = { { role: 'assistant' as const, content: '15 * 23 = 345' }, { role: 'user' as const, content: 'Now divide that by 5' } ] -} satisfies Record +} /** * Standard test tools for tool calling scenarios @@ -138,68 +139,17 @@ export const testTools: Record = { } } -/** - * Mock streaming chunks for different providers - */ -export const mockStreamingChunks = { - text: [ - { type: 'text-delta' as const, textDelta: 'Hello' }, - { type: 'text-delta' as const, textDelta: ', ' }, - { type: 'text-delta' as const, textDelta: 'this ' }, - { type: 'text-delta' as const, textDelta: 'is ' }, - { type: 'text-delta' as const, textDelta: 'a ' }, - { type: 'text-delta' as const, textDelta: 'test.' } - ], - - withToolCall: [ - { type: 'text-delta' as const, textDelta: 'Let me check the weather for you.' }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: '{"location":' - }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: ' "San Francisco, CA"}' - }, - { - type: 'tool-call' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - args: { location: 'San Francisco, CA' } - } - ], - - withFinish: [ - { type: 'text-delta' as const, textDelta: 'Complete response.' }, - { - type: 'finish' as const, - finishReason: 'stop' as const, - usage: { - promptTokens: 10, - completionTokens: 5, - totalTokens: 15 - } - } - ] -} - /** * Mock complete responses for non-streaming scenarios + * Note: AI SDK v5 uses inputTokens/outputTokens instead of promptTokens/completionTokens */ export const mockCompleteResponses = { simple: { text: 'This is a simple response.', finishReason: 'stop' as const, usage: { - promptTokens: 15, - completionTokens: 8, + inputTokens: 15, + outputTokens: 8, totalTokens: 23 } }, @@ -215,8 +165,8 @@ export const mockCompleteResponses = { ], finishReason: 'tool-calls' as const, usage: { - promptTokens: 25, - completionTokens: 12, + inputTokens: 25, + outputTokens: 12, totalTokens: 37 } }, @@ -225,14 +175,15 @@ export const mockCompleteResponses = { text: 'Response with warnings.', finishReason: 'stop' as const, usage: { - promptTokens: 10, - completionTokens: 5, + inputTokens: 10, + outputTokens: 5, totalTokens: 15 }, warnings: [ { type: 'unsupported-setting' as const, - message: 'Temperature parameter not supported for this model' + setting: 'temperature', + details: 'Temperature parameter not supported for this model' } ] } @@ -285,47 +236,3 @@ export const mockImageResponses = { warnings: [] } } - -/** - * Mock error responses - */ -export const mockErrors = { - invalidApiKey: { - name: 'APIError', - message: 'Invalid API key provided', - statusCode: 401 - }, - - rateLimitExceeded: { - name: 'RateLimitError', - message: 'Rate limit exceeded. Please try again later.', - statusCode: 429, - headers: { - 'retry-after': '60' - } - }, - - modelNotFound: { - name: 'ModelNotFoundError', - message: 'The requested model was not found', - statusCode: 404 - }, - - contextLengthExceeded: { - name: 'ContextLengthError', - message: "This model's maximum context length is 4096 tokens", - statusCode: 400 - }, - - timeout: { - name: 'TimeoutError', - message: 'Request timed out after 30000ms', - code: 'ETIMEDOUT' - }, - - networkError: { - name: 'NetworkError', - message: 'Network connection failed', - code: 'ECONNREFUSED' - } -} diff --git a/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts new file mode 100644 index 0000000000..57dcdd0fd1 --- /dev/null +++ b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts @@ -0,0 +1,35 @@ +/** + * Mock for @cherrystudio/ai-sdk-provider + * This mock is used in tests to avoid importing the actual package + */ + +export type CherryInProviderSettings = { + apiKey?: string + baseURL?: string +} + +// oxlint-disable-next-line no-unused-vars +export const createCherryIn = (_options?: CherryInProviderSettings) => ({ + // oxlint-disable-next-line no-unused-vars + languageModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + chat: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin-chat', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + textEmbeddingModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-embedding-model' + }) +}) diff --git a/packages/aiCore/src/__tests__/setup.ts b/packages/aiCore/src/__tests__/setup.ts new file mode 100644 index 0000000000..1e35458ad6 --- /dev/null +++ b/packages/aiCore/src/__tests__/setup.ts @@ -0,0 +1,9 @@ +/** + * Vitest Setup File + * Global test configuration and mocks for @cherrystudio/ai-core package + */ + +// Mock Vite SSR helper to avoid Node environment errors +;(globalThis as any).__vite_ssr_exportName__ = (_name: string, value: any) => value + +// Note: @cherrystudio/ai-sdk-provider is mocked via alias in vitest.config.ts diff --git a/packages/aiCore/src/core/options/__tests__/factory.test.ts b/packages/aiCore/src/core/options/__tests__/factory.test.ts new file mode 100644 index 0000000000..86f8017818 --- /dev/null +++ b/packages/aiCore/src/core/options/__tests__/factory.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { createOpenAIOptions, createOpenRouterOptions, mergeProviderOptions } from '../factory' + +describe('mergeProviderOptions', () => { + it('deep merges provider options for the same provider', () => { + const reasoningOptions = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'medium' + } + }) + const webSearchOptions = createOpenRouterOptions({ + plugins: [{ id: 'web', max_results: 5 }] + }) + + const merged = mergeProviderOptions(reasoningOptions, webSearchOptions) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, + effort: 'medium' + }, + plugins: [{ id: 'web', max_results: 5 }] + }) + }) + + it('preserves options from other providers while merging', () => { + const openRouter = createOpenRouterOptions({ + reasoning: { enabled: true } + }) + const openAI = createOpenAIOptions({ + reasoningEffort: 'low' + }) + const merged = mergeProviderOptions(openRouter, openAI) + + expect(merged.openrouter).toEqual({ reasoning: { enabled: true } }) + expect(merged.openai).toEqual({ reasoningEffort: 'low' }) + }) + + it('overwrites primitive values with later values', () => { + const first = createOpenAIOptions({ + reasoningEffort: 'low', + user: 'user-123' + }) + const second = createOpenAIOptions({ + reasoningEffort: 'high', + maxToolCalls: 5 + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openai).toEqual({ + reasoningEffort: 'high', // overwritten by second + user: 'user-123', // preserved from first + maxToolCalls: 5 // added from second + }) + }) + + it('overwrites arrays with later values instead of merging', () => { + const first = createOpenRouterOptions({ + models: ['gpt-4', 'gpt-3.5-turbo'] + }) + const second = createOpenRouterOptions({ + models: ['claude-3-opus', 'claude-3-sonnet'] + }) + + const merged = mergeProviderOptions(first, second) + + // Array is completely replaced, not merged + expect(merged.openrouter?.models).toEqual(['claude-3-opus', 'claude-3-sonnet']) + }) + + it('deeply merges nested objects while overwriting primitives', () => { + const first = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'low' + }, + user: 'user-123' + }) + const second = createOpenRouterOptions({ + reasoning: { + effort: 'high', + max_tokens: 500 + }, + user: 'user-456' + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, // preserved from first + effort: 'high', // overwritten by second + max_tokens: 500 // added from second + }, + user: 'user-456' // overwritten by second + }) + }) + + it('replaces arrays instead of merging them', () => { + const first = createOpenRouterOptions({ plugins: [{ id: 'old' }] }) + const second = createOpenRouterOptions({ plugins: [{ id: 'new' }] }) + const merged = mergeProviderOptions(first, second) + // @ts-expect-error type-check for openrouter options is skipped. see function signature of createOpenRouterOptions + expect(merged.openrouter?.plugins).toEqual([{ id: 'new' }]) + }) +}) diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index ecd53e6330..1e493b2337 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -26,13 +26,65 @@ export function createGenericProviderOptions( return { [provider]: options } as Record> } +type PlainObject = Record + +const isPlainObject = (value: unknown): value is PlainObject => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function deepMergeObjects(target: T, source: PlainObject): T { + const result: PlainObject = { ...target } + Object.entries(source).forEach(([key, value]) => { + if (isPlainObject(value) && isPlainObject(result[key])) { + result[key] = deepMergeObjects(result[key], value) + } else { + result[key] = value + } + }) + return result as T +} + /** - * 合并多个供应商的options - * @param optionsMap 包含多个供应商选项的对象 - * @returns 合并后的TypedProviderOptions + * Deep-merge multiple provider-specific options. + * Nested objects are recursively merged; primitive values are overwritten. + * + * When the same key appears in multiple options: + * - If both values are plain objects: they are deeply merged (recursive merge) + * - If values are primitives/arrays: the later value overwrites the earlier one + * + * @example + * mergeProviderOptions( + * { openrouter: { reasoning: { enabled: true, effort: 'low' }, user: 'user-123' } }, + * { openrouter: { reasoning: { effort: 'high', max_tokens: 500 }, models: ['gpt-4'] } } + * ) + * // Result: { + * // openrouter: { + * // reasoning: { enabled: true, effort: 'high', max_tokens: 500 }, + * // user: 'user-123', + * // models: ['gpt-4'] + * // } + * // } + * + * @param optionsMap Objects containing options for multiple providers + * @returns Fully merged TypedProviderOptions */ export function mergeProviderOptions(...optionsMap: Partial[]): TypedProviderOptions { - return Object.assign({}, ...optionsMap) + return optionsMap.reduce((acc, options) => { + if (!options) { + return acc + } + Object.entries(options).forEach(([providerId, providerOptions]) => { + if (!providerOptions) { + return + } + if (acc[providerId]) { + acc[providerId] = deepMergeObjects(acc[providerId] as PlainObject, providerOptions as PlainObject) + } else { + acc[providerId] = providerOptions as any + } + }) + return acc + }, {} as TypedProviderOptions) } /** diff --git a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts index 82b390ba05..02fe21889a 100644 --- a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts +++ b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts @@ -19,15 +19,20 @@ describe('Provider Schemas', () => { expect(Array.isArray(baseProviders)).toBe(true) expect(baseProviders.length).toBeGreaterThan(0) + // These are the actual base providers defined in schemas.ts const expectedIds = [ 'openai', - 'openai-responses', + 'openai-chat', 'openai-compatible', 'anthropic', 'google', 'xai', 'azure', - 'deepseek' + 'azure-responses', + 'deepseek', + 'openrouter', + 'cherryin', + 'cherryin-chat' ] const actualIds = baseProviders.map((p) => p.id) expectedIds.forEach((id) => { diff --git a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts index 217319aacc..56ab87dbcc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts @@ -232,11 +232,13 @@ describe('RuntimeExecutor.generateImage', () => { expect(pluginCallOrder).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd']) + // transformParams receives params without model (model is handled separately) + // and context with core fields + dynamic fields (requestId, startTime, etc.) expect(testPlugin.transformParams).toHaveBeenCalledWith( - { prompt: 'A test image' }, + expect.objectContaining({ prompt: 'A test image' }), expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -273,11 +275,12 @@ describe('RuntimeExecutor.generateImage', () => { await executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' }) + // resolveModel receives model id and context with core fields expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith( 'dall-e-3', expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -339,12 +342,11 @@ describe('RuntimeExecutor.generateImage', () => { .generateImage({ model: 'invalid-model', prompt: 'A test image' }) .catch((error) => error) - expect(thrownError).toBeInstanceOf(ImageGenerationError) - expect(thrownError.message).toContain('Failed to generate image:') + // Error is thrown from pluginEngine directly as ImageModelResolutionError + expect(thrownError).toBeInstanceOf(ImageModelResolutionError) + expect(thrownError.message).toContain('Failed to resolve image model: invalid-model') expect(thrownError.providerId).toBe('openai') expect(thrownError.modelId).toBe('invalid-model') - expect(thrownError.cause).toBeInstanceOf(ImageModelResolutionError) - expect(thrownError.cause.message).toContain('Failed to resolve image model: invalid-model') }) it('should handle ImageModelResolutionError without provider', async () => { @@ -362,8 +364,9 @@ describe('RuntimeExecutor.generateImage', () => { const apiError = new Error('API request failed') vi.mocked(aiGenerateImage).mockRejectedValue(apiError) + // Error propagates directly from pluginEngine without wrapping await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'API request failed' ) }) @@ -376,8 +379,9 @@ describe('RuntimeExecutor.generateImage', () => { vi.mocked(aiGenerateImage).mockRejectedValue(noImageError) vi.mocked(NoImageGeneratedError.isInstance).mockReturnValue(true) + // Error propagates directly from pluginEngine await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'No image generated' ) }) @@ -398,15 +402,17 @@ describe('RuntimeExecutor.generateImage', () => { [errorPlugin] ) + // Error propagates directly from pluginEngine await expect(executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'Generation failed' ) + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) }) @@ -419,9 +425,10 @@ describe('RuntimeExecutor.generateImage', () => { const abortController = new AbortController() setTimeout(() => abortController.abort(), 10) + // Error propagates directly from pluginEngine await expect( executor.generateImage({ model: 'dall-e-3', prompt: 'A test image', abortSignal: abortController.signal }) - ).rejects.toThrow('Failed to generate image:') + ).rejects.toThrow('Operation was aborted') }) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts index 9a0f204159..cb1d1d671a 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts @@ -17,10 +17,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - generateText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + generateText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -409,11 +413,12 @@ describe('RuntimeExecutor.generateText', () => { }) ).rejects.toThrow('Generation failed') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts index eae04783bb..49253594cc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts @@ -11,10 +11,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - streamText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + streamText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -153,7 +157,7 @@ describe('RuntimeExecutor.streamText', () => { describe('Max Tokens Parameter', () => { const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000] - it.each(maxTokensValues)('should support maxTokens=%s', async (maxTokens) => { + it.each(maxTokensValues)('should support maxOutputTokens=%s', async (maxOutputTokens) => { const mockStream = { textStream: (async function* () { yield 'Response' @@ -168,12 +172,13 @@ describe('RuntimeExecutor.streamText', () => { await executor.streamText({ model: 'gpt-4', messages: testMessages.simple, - maxOutputTokens: maxTokens + maxOutputTokens }) + // Parameters are passed through without transformation expect(streamText).toHaveBeenCalledWith( expect.objectContaining({ - maxTokens + maxOutputTokens }) ) }) @@ -513,11 +518,12 @@ describe('RuntimeExecutor.streamText', () => { }) ).rejects.toThrow('Stream error') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/vitest.config.ts b/packages/aiCore/vitest.config.ts index 0cc6b51df4..2f520ea967 100644 --- a/packages/aiCore/vitest.config.ts +++ b/packages/aiCore/vitest.config.ts @@ -1,12 +1,20 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ test: { - globals: true + globals: true, + setupFiles: [path.resolve(__dirname, './src/__tests__/setup.ts')] }, resolve: { alias: { - '@': './src' + '@': path.resolve(__dirname, './src'), + // Mock external packages that may not be available in test environment + '@cherrystudio/ai-sdk-provider': path.resolve(__dirname, './src/__tests__/mocks/ai-sdk-provider.ts') } }, esbuild: { diff --git a/tsconfig.node.json b/tsconfig.node.json index 83c3f2b461..6953fa7b37 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,14 +2,14 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "electron.vite.config.*", - "src/main/**/*", - "src/preload/**/*", - "src/main/env.d.ts", - "src/renderer/src/types/*", - "packages/shared/**/*", "scripts", + "src/main/**/*", + "src/main/env.d.ts", + "src/preload/**/*", + "src/renderer/src/services/traceApi.ts", + "src/renderer/src/types/*", "packages/mcp-trace/**/*", - "src/renderer/src/services/traceApi.ts" + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 2d91fe0260..b09020a20d 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,16 +1,16 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": [ - "src/renderer/src/**/*", - "src/preload/*.d.ts", "local/src/renderer/**/*", - "packages/shared/**/*", - "tests/__mocks__/**/*", - "packages/mcp-trace/**/*", - "packages/aiCore/src/**/*", + "src/renderer/src/**/*", "src/main/integration/cherryai/index.js", + "src/preload/*.d.ts", + "tests/__mocks__/**/*", + "packages/aiCore/src/**/*", + "packages/ai-sdk-provider/**/*", "packages/extension-table-plus/**/*", - "packages/ai-sdk-provider/**/*" + "packages/mcp-trace/**/*", + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/vitest.config.ts b/vitest.config.ts index b4440a2461..a245f7a416 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,18 @@ export default defineConfig({ environment: 'node', include: ['scripts/**/*.{test,spec}.{ts,tsx}', 'scripts/**/__tests__/**/*.{test,spec}.{ts,tsx}'] } + }, + // aiCore 包单元测试配置 + { + extends: 'packages/aiCore/vitest.config.ts', + test: { + name: 'aiCore', + environment: 'node', + include: [ + 'packages/aiCore/**/*.{test,spec}.{ts,tsx}', + 'packages/aiCore/**/__tests__/**/*.{test,spec}.{ts,tsx}' + ] + } } ], // 全局共享配置 From 1b926178f1d192fe107867db9d60b17d4bb7fada Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 28 Nov 2025 14:44:45 +0800 Subject: [PATCH 2/8] chore: update @openrouter/ai-sdk-provider to version 1.2.8 in package.json and yarn.lock --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 304785117e..515217ad22 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", - "@openrouter/ai-sdk-provider": "^1.2.5", + "@openrouter/ai-sdk-provider": "^1.2.8", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", diff --git a/yarn.lock b/yarn.lock index 7f7ed62da7..3bd2fc9278 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5044,15 +5044,15 @@ __metadata: languageName: node linkType: hard -"@openrouter/ai-sdk-provider@npm:^1.2.5": - version: 1.2.5 - resolution: "@openrouter/ai-sdk-provider@npm:1.2.5" +"@openrouter/ai-sdk-provider@npm:^1.2.8": + version: 1.2.8 + resolution: "@openrouter/ai-sdk-provider@npm:1.2.8" dependencies: "@openrouter/sdk": "npm:^0.1.8" peerDependencies: ai: ^5.0.0 zod: ^3.24.1 || ^v4 - checksum: 10c0/f422f767ff8fcba2bb2fca32e5e2df163abae3c754f98416830654c5135db3aed5d4f941bfa0005109d202053a2e6a4a6b997940eb154ac964c87dd85dbe82e1 + checksum: 10c0/a1508d8d538f601f0b7f5f96da32ddbd3c156742a20b427742963d8ac2cee26ce857ad7c64df743efce632b1602b19c81dcd03ebc24ae5a371211a65ead1c181 languageName: node linkType: hard @@ -10050,7 +10050,7 @@ __metadata: "@mozilla/readability": "npm:^0.6.0" "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch" "@notionhq/client": "npm:^2.2.15" - "@openrouter/ai-sdk-provider": "npm:^1.2.5" + "@openrouter/ai-sdk-provider": "npm:^1.2.8" "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/core": "npm:2.0.0" "@opentelemetry/exporter-trace-otlp-http": "npm:^0.200.0" From 4620b71aee8031ea6a8813c4fcf5b26376289c70 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 28 Nov 2025 15:13:23 +0800 Subject: [PATCH 3/8] chore: update release notes for v1.7.0 --- electron-builder.yml | 140 +++++++++++++++++++++++++++++-------------- package.json | 2 +- 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 823c147a05..d75cd5855d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,56 +134,108 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-rc.3 + A New Era of Intelligence with Cherry Studio 1.7.0 - ✨ New Features: - - Provider: Added Silicon provider support for Anthropic API compatibility - - Provider: AIHubMix support for nano banana + Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. - 🐛 Bug Fixes: - - i18n: Clean up translation tags and untranslated strings - - Provider: Fixed Silicon provider code list - - Provider: Fixed Poe API reasoning parameters for GPT-5 and reasoning models - - Provider: Fixed duplicate /v1 in Anthropic API endpoints - - Provider: Fixed Azure provider handling in AI SDK integration - - Models: Added Claude Opus 4.5 pattern to THINKING_TOKEN_MAP - - Models: Improved Gemini reasoning and message handling - - Models: Fixed custom parameters for Gemini models - - Models: Fixed qwen-mt-flash text delta support - - Models: Fixed Groq verbosity setting - - UI: Fixed quota display and quota tips - - UI: Fixed web search button condition - - Settings: Fixed updateAssistantPreset reducer to properly update preset - - Settings: Respect enableMaxTokens setting when maxTokens is not configured - - SDK: Fixed header merging logic in AI SDK + For years, AI assistants have been reactive — waiting for your commands, responding to your questions. With Agent, we're changing that. Now, AI can truly work alongside you: understanding complex goals, breaking them into steps, and executing them independently. - ⚡ Improvements: - - SDK: Upgraded @anthropic-ai/claude-agent-sdk to 0.1.53 + This is what we've been building toward. And it's just the beginning. + + 🤖 Meet Agent + Imagine having a brilliant colleague who never sleeps. Give Agent a goal — write a report, analyze data, refactor code — and watch it work. It reasons through problems, breaks them into steps, calls the right tools, and adapts when things change. + + - **Think → Plan → Act**: From goal to execution, fully autonomous + - **Deep Reasoning**: Multi-turn thinking that solves real problems + - **Tool Mastery**: File operations, web search, code execution, and more + - **Skill Plugins**: Extend with custom commands and capabilities + - **You Stay in Control**: Real-time approval for sensitive actions + - **Full Visibility**: Every thought, every decision, fully transparent + + 🌐 Expanding Ecosystem + - **New Providers**: HuggingFace, Mistral, CherryIN, AI Gateway, Intel OVMS, Didi MCP + - **New Models**: Claude 4.5 Haiku, DeepSeek v3.2, GLM-4.6, Doubao, Ling series + - **MCP Integration**: Alibaba Cloud, ModelScope, Higress, MCP.so, TokenFlux and more + + 📚 Smarter Knowledge Base + - **OpenMinerU**: Self-hosted document processing + - **Full-Text Search**: Find anything instantly across your notes + - **Enhanced Tool Selection**: Smarter configuration for better AI assistance + + 📝 Notes, Reimagined + - Full-text search with highlighted results + - AI-powered smart rename + - Export as image + - Auto-wrap for tables + + 🖼️ Image & OCR + - Intel OVMS painting capabilities + - Intel OpenVINO NPU-accelerated OCR + + 🌍 Now in 10+ Languages + - Added German support + - Enhanced internationalization + + ⚡ Faster & More Polished + - Electron 38 upgrade + - New MCP management interface + - Dozens of UI refinements + + ❤️ Fully Open Source + Commercial restrictions removed. Cherry Studio now follows standard AGPL v3 — free for teams of any size. + + The Agent Era is here. We can't wait to see what you'll create. - v1.7.0-rc.3 更新内容 + Cherry Studio 1.7.0:开启智能新纪元 - ✨ 新功能: - - 提供商:新增 Silicon 提供商对 Anthropic API 的兼容性支持 - - 提供商:AIHubMix 支持 nano banana + 今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。 - 🐛 问题修复: - - 国际化:清理翻译标签和未翻译字符串 - - 提供商:修复 Silicon 提供商代码列表 - - 提供商:修复 Poe API 对 GPT-5 和推理模型的推理参数 - - 提供商:修复 Anthropic API 端点重复 /v1 问题 - - 提供商:修复 Azure 提供商在 AI SDK 集成中的处理 - - 模型:Claude Opus 4.5 添加到 THINKING_TOKEN_MAP - - 模型:改进 Gemini 推理和消息处理 - - 模型:修复 Gemini 模型自定义参数 - - 模型:修复 qwen-mt-flash text delta 支持 - - 模型:修复 Groq verbosity 设置 - - 界面:修复配额显示和配额提示 - - 界面:修复 Web 搜索按钮条件 - - 设置:修复 updateAssistantPreset reducer 正确更新 preset - - 设置:尊重 enableMaxTokens 设置 - - SDK:修复 AI SDK 中 header 合并逻辑 + 多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。 - ⚡ 改进: - - SDK:升级 @anthropic-ai/claude-agent-sdk 到 0.1.53 + 这是我们一直在构建的未来。而这,仅仅是开始。 + + 🤖 认识 Agent + 想象一位永不疲倦的得力伙伴。给 Agent 一个目标——撰写报告、分析数据、重构代码——然后看它工作。它会推理问题、拆解步骤、调用工具,并在情况变化时灵活应对。 + + - **思考 → 规划 → 行动**:从目标到执行,全程自主 + - **深度推理**:多轮思考,解决真实问题 + - **工具大师**:文件操作、网络搜索、代码执行,样样精通 + - **技能插件**:自定义命令,无限扩展 + - **你掌控全局**:敏感操作,实时审批 + - **完全透明**:每一步思考,每一个决策,清晰可见 + + 🌐 生态持续壮大 + - **新增服务商**:Hugging Face、Mistral、Perplexity、SophNet、AI Gateway、Cerebras AI + - **新增模型**:Gemini 3、Gemini 3 Pro(支持图像预览)、GPT-5.1、Claude Opus 4.5 + - **MCP 集成**:百炼、魔搭、Higress、MCP.so、TokenFlux 等平台 + + 📚 更智能的知识库 + - **OpenMinerU**:本地自部署文档处理 + - **全文搜索**:笔记内容一搜即达 + - **增强工具选择**:更智能的配置,更好的 AI 协助 + + 📝 笔记,焕然一新 + - 全文搜索,结果高亮 + - AI 智能重命名 + - 导出为图片 + - 表格自动换行 + + 🖼️ 图像与 OCR + - Intel OVMS 绘图能力 + - Intel OpenVINO NPU 加速 OCR + + 🌍 支持 10+ 种语言 + - 新增德语支持 + - 全面增强国际化 + + ⚡ 更快、更精致 + - 升级 Electron 38 + - 新的 MCP 管理界面 + - 数十处 UI 细节打磨 + + ❤️ 完全开源 + 商用限制已移除。Cherry Studio 现遵循标准 AGPL v3 协议——任意规模团队均可自由使用。 + + Agent 纪元已至。期待你的创造。 diff --git a/package.json b/package.json index 515217ad22..52c57b886f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-rc.3", + "version": "1.7.0", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 13ac5d564a55f0fc98f1aa94600a2863ffd1d176 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 28 Nov 2025 20:46:52 +0800 Subject: [PATCH 4/8] fix: match tool-call chunk with tool id (#11533) --- src/renderer/src/aiCore/chunk/handleToolCallChunk.ts | 3 ++- src/renderer/src/utils/mcp-tools.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index 32c7e534e3..b5acbb690b 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -212,8 +212,9 @@ export class ToolCallChunkHandler { description: toolName, type: 'builtin' } as BaseTool - } else if ((mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool)) { + } else if ((mcpTool = this.mcpTools.find((t) => t.id === toolName) as MCPTool)) { // 如果是客户端执行的 MCP 工具,沿用现有逻辑 + // toolName is mcpTool.id (registered with id as key in convertMcpToolsToAiSdkTools) logger.info(`[ToolCallChunkHandler] Handling client-side MCP tool: ${toolName}`) // mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool // if (!mcpTool) { diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index 49628628d4..364b22e651 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -90,7 +90,8 @@ export function openAIToolsToMcpTool( return undefined } const tools = mcpTools.filter((mcpTool) => { - return mcpTool.id === toolName || mcpTool.name === toolName + // toolName is mcpTool.id (registered with id as function name) + return mcpTool.id === toolName }) if (tools.length > 1) { logger.warn(`Multiple MCP Tools found for tool call: ${toolName}`) From 284d0f99e14918639b99ff1b39e4fe100aaf3523 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sat, 29 Nov 2025 14:33:10 +0800 Subject: [PATCH 5/8] fix(anthropic): comment out CONTEXT_100M_HEADER to handle via user preferences (#11545) See #11540 and #11397 for context on moving this to assistant settings --- src/renderer/src/aiCore/prepareParams/header.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/prepareParams/header.ts b/src/renderer/src/aiCore/prepareParams/header.ts index 615f07db35..480f13314e 100644 --- a/src/renderer/src/aiCore/prepareParams/header.ts +++ b/src/renderer/src/aiCore/prepareParams/header.ts @@ -7,7 +7,7 @@ import { isAwsBedrockProvider, isVertexProvider } from '@renderer/utils/provider // https://docs.claude.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking const INTERLEAVED_THINKING_HEADER = 'interleaved-thinking-2025-05-14' // https://docs.claude.com/en/docs/build-with-claude/context-windows#1m-token-context-window -const CONTEXT_100M_HEADER = 'context-1m-2025-08-07' +// const CONTEXT_100M_HEADER = 'context-1m-2025-08-07' // https://docs.cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/web-search const WEBSEARCH_HEADER = 'web-search-2025-03-05' @@ -25,7 +25,9 @@ export function addAnthropicHeaders(assistant: Assistant, model: Model): string[ if (isVertexProvider(provider) && assistant.enableWebSearch) { anthropicHeaders.push(WEBSEARCH_HEADER) } - anthropicHeaders.push(CONTEXT_100M_HEADER) + // We may add it by user preference in assistant.settings instead of always adding it. + // See #11540, #11397 + // anthropicHeaders.push(CONTEXT_100M_HEADER) } return anthropicHeaders } From c23e88ecd1c073c7f4d0f0de4fc4cba99432437a Mon Sep 17 00:00:00 2001 From: Phantom Date: Sat, 29 Nov 2025 14:37:26 +0800 Subject: [PATCH 6/8] fix: handle Gemini API version correctly for Cloudflare Gateway URLs (#11543) * refactor(api): extract version regex into constant for reuse Move the version matching regex pattern into a module-level constant to improve code reuse and maintainability. The functionality remains unchanged. * refactor(api): rename regex constant and use dynamic regex construction Use a string pattern for version regex to allow dynamic construction and improve maintainability. Rename constant to better reflect its purpose. * feat(api): add getLastApiVersion utility function Implement a utility function to extract the last API version segment from URLs. This is useful for handling cases where multiple version segments exist in the path and we need to determine the most specific version being used. Add comprehensive test cases covering various URL patterns and edge cases. * feat(api): add utility to remove trailing API version from URLs Add withoutTrailingApiVersion function to clean up URLs by removing version segments at the end of paths. This helps standardize API endpoint URLs when version is not needed. * refactor(api): rename isSupportedAPIVerion to supportApiVersion for clarity * fix(gemini): handle api version dynamically for non-vertex providers Use getLastApiVersion utility to determine the latest API version for non-vertex providers instead of hardcoding to v1beta * feat(api): add function to extract trailing API version from URL Add getTrailingApiVersion utility function to specifically extract API version segments that appear at the end of URLs. This complements existing version-related utilities and helps handle cases where we only care about the final version in the path. * refactor(gemini): use getTrailingApiVersion instead of getLastApiVersion The function name was changed to better reflect its purpose of extracting the trailing API version from the URL. The logic was also simplified and made more explicit. * refactor(api): remove unused getLastApiVersion function The function was removed as it was no longer needed, simplifying the API version handling to only use trailing version detection. The trailing version regex was extracted to a constant for reuse. --- .../legacy/clients/gemini/GeminiAPIClient.ts | 12 +++ src/renderer/src/utils/__tests__/api.test.ts | 90 ++++++++++++++++++- src/renderer/src/utils/api.ts | 74 +++++++++++++-- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index 27e659c1af..9c930a33ec 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -46,6 +46,7 @@ import type { GeminiSdkRawOutput, GeminiSdkToolCall } from '@renderer/types/sdk' +import { getTrailingApiVersion, withoutTrailingApiVersion } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { geminiFunctionCallToMcpTool, @@ -163,6 +164,10 @@ export class GeminiAPIClient extends BaseApiClient< return models } + override getBaseURL(): string { + return withoutTrailingApiVersion(super.getBaseURL()) + } + override async getSdkInstance() { if (this.sdkInstance) { return this.sdkInstance @@ -188,6 +193,13 @@ export class GeminiAPIClient extends BaseApiClient< if (this.provider.isVertex) { return 'v1' } + + // Extract trailing API version from the URL + const trailingVersion = getTrailingApiVersion(this.provider.apiHost || '') + if (trailingVersion) { + return trailingVersion + } + return 'v1beta' } diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index e854445fc5..5b9d0f64f6 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -7,11 +7,13 @@ import { formatApiKeys, formatAzureOpenAIApiHost, formatVertexApiHost, + getTrailingApiVersion, hasAPIVersion, maskApiKey, routeToEndpoint, splitApiKeyString, - validateApiHost + validateApiHost, + withoutTrailingApiVersion } from '../api' vi.mock('@renderer/store', () => { @@ -316,4 +318,90 @@ describe('api', () => { ) }) }) + + describe('getTrailingApiVersion', () => { + it('extracts trailing API version from URL', () => { + expect(getTrailingApiVersion('https://api.example.com/v1')).toBe('v1') + expect(getTrailingApiVersion('https://api.example.com/v2')).toBe('v2') + }) + + it('extracts trailing API version with alpha/beta suffix', () => { + expect(getTrailingApiVersion('https://api.example.com/v2alpha')).toBe('v2alpha') + expect(getTrailingApiVersion('https://api.example.com/v3beta')).toBe('v3beta') + }) + + it('extracts trailing API version with trailing slash', () => { + expect(getTrailingApiVersion('https://api.example.com/v1/')).toBe('v1') + expect(getTrailingApiVersion('https://api.example.com/v2beta/')).toBe('v2beta') + }) + + it('returns undefined when API version is in the middle of path', () => { + expect(getTrailingApiVersion('https://api.example.com/v1/chat')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v1/completions')).toBeUndefined() + }) + + it('returns undefined when no trailing version exists', () => { + expect(getTrailingApiVersion('https://api.example.com')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/api')).toBeUndefined() + }) + + it('extracts trailing version from complex URLs', () => { + expect(getTrailingApiVersion('https://api.example.com/service/v1')).toBe('v1') + expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/google-ai-studio/v1beta')).toBe('v1beta') + }) + + it('only extracts the trailing version when multiple versions exist', () => { + expect(getTrailingApiVersion('https://api.example.com/v1/service/v2')).toBe('v2') + expect( + getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxxxxx/google-ai-studio/google-ai-studio/v1beta') + ).toBe('v1beta') + }) + + it('returns undefined for empty string', () => { + expect(getTrailingApiVersion('')).toBeUndefined() + }) + }) + + describe('withoutTrailingApiVersion', () => { + it('removes trailing API version from URL', () => { + expect(withoutTrailingApiVersion('https://api.example.com/v1')).toBe('https://api.example.com') + expect(withoutTrailingApiVersion('https://api.example.com/v2')).toBe('https://api.example.com') + }) + + it('removes trailing API version with alpha/beta suffix', () => { + expect(withoutTrailingApiVersion('https://api.example.com/v2alpha')).toBe('https://api.example.com') + expect(withoutTrailingApiVersion('https://api.example.com/v3beta')).toBe('https://api.example.com') + }) + + it('removes trailing API version with trailing slash', () => { + expect(withoutTrailingApiVersion('https://api.example.com/v1/')).toBe('https://api.example.com') + expect(withoutTrailingApiVersion('https://api.example.com/v2beta/')).toBe('https://api.example.com') + }) + + it('does not remove API version in the middle of path', () => { + expect(withoutTrailingApiVersion('https://api.example.com/v1/chat')).toBe('https://api.example.com/v1/chat') + expect(withoutTrailingApiVersion('https://api.example.com/v1/completions')).toBe( + 'https://api.example.com/v1/completions' + ) + }) + + it('returns URL unchanged when no trailing version exists', () => { + expect(withoutTrailingApiVersion('https://api.example.com')).toBe('https://api.example.com') + expect(withoutTrailingApiVersion('https://api.example.com/api')).toBe('https://api.example.com/api') + }) + + it('handles complex URLs with version at the end', () => { + expect(withoutTrailingApiVersion('https://api.example.com/service/v1')).toBe('https://api.example.com/service') + }) + + it('handles URLs with multiple versions but only removes the trailing one', () => { + expect(withoutTrailingApiVersion('https://api.example.com/v1/service/v2')).toBe( + 'https://api.example.com/v1/service' + ) + }) + + it('returns empty string unchanged', () => { + expect(withoutTrailingApiVersion('')).toBe('') + }) + }) }) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 845187eb80..72d44c5c25 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -12,6 +12,19 @@ export function formatApiKeys(value: string): string { return value.replaceAll(',', ',').replaceAll('\n', ',') } +/** + * Matches a version segment in a path that starts with `/v` and optionally + * continues with `alpha` or `beta`. The segment may be followed by `/` or the end + * of the string (useful for cases like `/v3alpha/resources`). + */ +const VERSION_REGEX_PATTERN = '\\/v\\d+(?:alpha|beta)?(?=\\/|$)' + +/** + * Matches an API version at the end of a URL (with optional trailing slash). + * Used to detect and extract versions only from the trailing position. + */ +const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i + /** * 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等), * @@ -21,16 +34,14 @@ export function formatApiKeys(value: string): string { export function hasAPIVersion(host?: string): boolean { if (!host) return false - // 匹配路径中以 `/v` 开头并可选跟随 `alpha` 或 `beta` 的版本段, - // 该段后面可以跟 `/` 或字符串结束(用于匹配诸如 `/v3alpha/resources` 的情况)。 - const versionRegex = /\/v\d+(?:alpha|beta)?(?=\/|$)/i + const regex = new RegExp(VERSION_REGEX_PATTERN, 'i') try { const url = new URL(host) - return versionRegex.test(url.pathname) + return regex.test(url.pathname) } catch { // 若无法作为完整 URL 解析,则当作路径直接检测 - return versionRegex.test(host) + return regex.test(host) } } @@ -55,7 +66,7 @@ export function withoutTrailingSlash(url: T): T { * Formats an API host URL by normalizing it and optionally appending an API version. * * @param host - The API host URL to format. Leading/trailing whitespace will be trimmed and trailing slashes removed. - * @param isSupportedAPIVerion - Whether the API version is supported. Defaults to `true`. + * @param supportApiVersion - Whether the API version is supported. Defaults to `true`. * @param apiVersion - The API version to append if needed. Defaults to `'v1'`. * * @returns The formatted API host URL. If the host is empty after normalization, returns an empty string. @@ -67,13 +78,13 @@ export function withoutTrailingSlash(url: T): T { * formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#' * formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2' */ -export function formatApiHost(host?: string, isSupportedAPIVerion: boolean = true, apiVersion: string = 'v1'): string { +export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string { const normalizedHost = withoutTrailingSlash(trim(host)) if (!normalizedHost) { return '' } - if (normalizedHost.endsWith('#') || !isSupportedAPIVerion || hasAPIVersion(normalizedHost)) { + if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) { return normalizedHost } return `${normalizedHost}/${apiVersion}` @@ -213,3 +224,50 @@ export function splitApiKeyString(keyStr: string): string[] { .map((k) => k.replace(/\\,/g, ',')) .filter((k) => k) } + +/** + * Extracts the trailing API version segment from a URL path. + * + * This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL. + * Only versions at the end of the path are extracted, not versions in the middle. + * The returned version string does not include leading or trailing slashes. + * + * @param {string} url - The URL string to parse. + * @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found. + * + * @example + * getTrailingApiVersion('https://api.example.com/v1') // 'v1' + * getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta' + * getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end) + * getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta' + * getTrailingApiVersion('https://api.example.com') // undefined + */ +export function getTrailingApiVersion(url: string): string | undefined { + const match = url.match(TRAILING_VERSION_REGEX) + + if (match) { + // Extract version without leading slash and trailing slash + return match[0].replace(/^\//, '').replace(/\/$/, '') + } + + return undefined +} + +/** + * Removes the trailing API version segment from a URL path. + * + * This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL. + * Only versions at the end of the path are removed, not versions in the middle. + * + * @param {string} url - The URL string to process. + * @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found. + * + * @example + * withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com' + * withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com' + * withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change) + * withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com' + */ +export function withoutTrailingApiVersion(url: string): string { + return url.replace(TRAILING_VERSION_REGEX, '') +} From 876f59d650d2b2387a4ae1564e31d27b53b172d0 Mon Sep 17 00:00:00 2001 From: xerxesliu Date: Sat, 29 Nov 2025 14:40:49 +0800 Subject: [PATCH 7/8] fix: resolve copy image failure for JPEG format pictures (#11529) - Convert all image formats to PNG before writing to clipboard to ensure compatibility - Refactor handleCopyImage to unify image source handling (Base64, File, URL) - Add convertImageToPng utility function using canvas API for robust conversion - Remove fallback logic that attempted to write unsupported JPEG format --- src/renderer/src/components/ImageViewer.tsx | 32 ++++++------- src/renderer/src/utils/image.ts | 51 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx index 757a694419..51df8c95c3 100644 --- a/src/renderer/src/components/ImageViewer.tsx +++ b/src/renderer/src/components/ImageViewer.tsx @@ -10,6 +10,7 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import { download } from '@renderer/utils/download' +import { convertImageToPng } from '@renderer/utils/image' import type { ImageProps as AntImageProps } from 'antd' import { Dropdown, Image as AntImage, Space } from 'antd' import { Base64 } from 'js-base64' @@ -33,39 +34,38 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => { // 复制图片到剪贴板 const handleCopyImage = async (src: string) => { try { + let blob: Blob + if (src.startsWith('data:')) { // 处理 base64 格式的图片 const match = src.match(/^data:(image\/\w+);base64,(.+)$/) if (!match) throw new Error('Invalid base64 image format') const mimeType = match[1] const byteArray = Base64.toUint8Array(match[2]) - const blob = new Blob([byteArray], { type: mimeType }) - await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })]) + blob = new Blob([byteArray], { type: mimeType }) } else if (src.startsWith('file://')) { // 处理本地文件路径 const bytes = await window.api.fs.read(src) const mimeType = mime.getType(src) || 'application/octet-stream' - const blob = new Blob([bytes], { type: mimeType }) - await navigator.clipboard.write([ - new ClipboardItem({ - [mimeType]: blob - }) - ]) + blob = new Blob([bytes], { type: mimeType }) } else { // 处理 URL 格式的图片 const response = await fetch(src) - const blob = await response.blob() - - await navigator.clipboard.write([ - new ClipboardItem({ - [blob.type]: blob - }) - ]) + blob = await response.blob() } + // 统一转换为 PNG 以确保兼容性(剪贴板 API 不支持 JPEG) + const pngBlob = await convertImageToPng(blob) + + const item = new ClipboardItem({ + 'image/png': pngBlob + }) + await navigator.clipboard.write([item]) + window.toast.success(t('message.copy.success')) } catch (error) { - logger.error('Failed to copy image:', error as Error) + const err = error as Error + logger.error(`Failed to copy image: ${err.message}`, { stack: err.stack }) window.toast.error(t('message.copy.failed')) } } diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index a42f372f3c..3d4824549a 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -566,3 +566,54 @@ export const makeSvgSizeAdaptive = (element: Element): Element => { return element } + +/** + * 将图片 Blob 转换为 PNG 格式的 Blob + * @param blob 原始图片 Blob + * @returns Promise 转换后的 PNG Blob + */ +export const convertImageToPng = async (blob: Blob): Promise => { + if (blob.type === 'image/png') { + return blob + } + + return new Promise((resolve, reject) => { + const img = new Image() + const url = URL.createObjectURL(blob) + + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d') + + if (!ctx) { + URL.revokeObjectURL(url) + reject(new Error('Failed to get canvas context')) + return + } + + ctx.drawImage(img, 0, 0) + canvas.toBlob((pngBlob) => { + URL.revokeObjectURL(url) + if (pngBlob) { + resolve(pngBlob) + } else { + reject(new Error('Failed to convert image to png')) + } + }, 'image/png') + } catch (error) { + URL.revokeObjectURL(url) + reject(error) + } + } + + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('Failed to load image for conversion')) + } + + img.src = url + }) +} From f1f4831157260ccb420a017969dfe3a1439f4ae1 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sat, 29 Nov 2025 20:29:47 +0800 Subject: [PATCH 8/8] fix: prevent NaN thinking timers (#11556) * fix: prevent NaN thinking timers * test: cover thinking timer fallback and cleanup --- .../home/Messages/Blocks/ThinkingBlock.tsx | 14 +++--- .../Blocks/__tests__/ThinkingBlock.test.tsx | 14 ++++++ .../src/windows/mini/home/HomeWindow.tsx | 25 +++++++++- .../action/components/ActionUtils.ts | 23 ++++++++- .../components/__tests__/ActionUtils.test.ts | 48 +++++++++++++++++++ 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 109562f7d5..32afabb370 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -102,10 +102,12 @@ const ThinkingBlock: React.FC = ({ block }) => { ) } +const normalizeThinkingTime = (value?: number) => (typeof value === 'number' && Number.isFinite(value) ? value : 0) + const ThinkingTimeSeconds = memo( ({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => { const { t } = useTranslation() - const [displayTime, setDisplayTime] = useState(blockThinkingTime) + const [displayTime, setDisplayTime] = useState(normalizeThinkingTime(blockThinkingTime)) const timer = useRef(null) @@ -121,7 +123,7 @@ const ThinkingTimeSeconds = memo( clearInterval(timer.current) timer.current = null } - setDisplayTime(blockThinkingTime) + setDisplayTime(normalizeThinkingTime(blockThinkingTime)) } return () => { @@ -132,10 +134,10 @@ const ThinkingTimeSeconds = memo( } }, [isThinking, blockThinkingTime]) - const thinkingTimeSeconds = useMemo( - () => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1), - [displayTime] - ) + const thinkingTimeSeconds = useMemo(() => { + const safeTime = normalizeThinkingTime(displayTime) + return ((safeTime < 1000 ? 100 : safeTime) / 1000).toFixed(1) + }, [displayTime]) return isThinking ? t('chat.thinking', { diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx index d573408225..7c4bdf13cb 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx @@ -255,6 +255,20 @@ describe('ThinkingBlock', () => { unmount() }) }) + + it('should clamp invalid thinking times to a safe default', () => { + const testCases = [undefined, Number.NaN, Number.POSITIVE_INFINITY] + + testCases.forEach((thinking_millsec) => { + const block = createThinkingBlock({ + thinking_millsec: thinking_millsec as any, + status: MessageBlockStatus.SUCCESS + }) + const { unmount } = renderThinkingBlock(block) + expect(getThinkingTimeText()).toHaveTextContent('0.1s') + unmount() + }) + }) }) describe('collapse behavior', () => { diff --git a/src/renderer/src/windows/mini/home/HomeWindow.tsx b/src/renderer/src/windows/mini/home/HomeWindow.tsx index a3da9d9a0b..23787066e8 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -254,6 +254,17 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { let blockId: string | null = null let thinkingBlockId: string | null = null + let thinkingStartTime: number | null = null + + const resolveThinkingDuration = (duration?: number) => { + if (typeof duration === 'number' && Number.isFinite(duration)) { + return duration + } + if (thinkingStartTime !== null) { + return Math.max(0, performance.now() - thinkingStartTime) + } + return 0 + } setIsLoading(true) setIsOutputted(false) @@ -291,6 +302,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { case ChunkType.THINKING_START: { setIsOutputted(true) + thinkingStartTime = performance.now() if (thinkingBlockId) { store.dispatch( updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } }) @@ -315,9 +327,13 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { { setIsOutputted(true) if (thinkingBlockId) { + if (thinkingStartTime === null) { + thinkingStartTime = performance.now() + } + const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec) throttledBlockUpdate(thinkingBlockId, { content: chunk.text, - thinking_millsec: chunk.thinking_millsec + thinking_millsec: thinkingDuration }) } } @@ -325,14 +341,17 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { case ChunkType.THINKING_COMPLETE: { if (thinkingBlockId) { + const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec) cancelThrottledBlockUpdate(thinkingBlockId) store.dispatch( updateOneBlock({ id: thinkingBlockId, - changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec } + changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: thinkingDuration } }) ) } + thinkingStartTime = null + thinkingBlockId = null } break case ChunkType.TEXT_START: @@ -404,6 +423,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { if (!isAborted) { throw new Error(chunk.error.message) } + thinkingStartTime = null + thinkingBlockId = null } //fall through case ChunkType.BLOCK_COMPLETE: diff --git a/src/renderer/src/windows/selection/action/components/ActionUtils.ts b/src/renderer/src/windows/selection/action/components/ActionUtils.ts index 12f3881fe2..baa6ab07fe 100644 --- a/src/renderer/src/windows/selection/action/components/ActionUtils.ts +++ b/src/renderer/src/windows/selection/action/components/ActionUtils.ts @@ -41,8 +41,19 @@ export const processMessages = async ( let textBlockId: string | null = null let thinkingBlockId: string | null = null + let thinkingStartTime: number | null = null let textBlockContent: string = '' + const resolveThinkingDuration = (duration?: number) => { + if (typeof duration === 'number' && Number.isFinite(duration)) { + return duration + } + if (thinkingStartTime !== null) { + return Math.max(0, performance.now() - thinkingStartTime) + } + return 0 + } + const assistantMessage = getAssistantMessage({ assistant, topic @@ -79,6 +90,7 @@ export const processMessages = async ( switch (chunk.type) { case ChunkType.THINKING_START: { + thinkingStartTime = performance.now() if (thinkingBlockId) { store.dispatch( updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } }) @@ -102,9 +114,13 @@ export const processMessages = async ( case ChunkType.THINKING_DELTA: { if (thinkingBlockId) { + if (thinkingStartTime === null) { + thinkingStartTime = performance.now() + } + const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec) throttledBlockUpdate(thinkingBlockId, { content: chunk.text, - thinking_millsec: chunk.thinking_millsec + thinking_millsec: thinkingDuration }) } onStream() @@ -113,6 +129,7 @@ export const processMessages = async ( case ChunkType.THINKING_COMPLETE: { if (thinkingBlockId) { + const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec) cancelThrottledBlockUpdate(thinkingBlockId) store.dispatch( updateOneBlock({ @@ -120,12 +137,13 @@ export const processMessages = async ( changes: { content: chunk.text, status: MessageBlockStatus.SUCCESS, - thinking_millsec: chunk.thinking_millsec + thinking_millsec: thinkingDuration } }) ) thinkingBlockId = null } + thinkingStartTime = null } break case ChunkType.TEXT_START: @@ -190,6 +208,7 @@ export const processMessages = async ( case ChunkType.ERROR: { const blockId = textBlockId || thinkingBlockId + thinkingStartTime = null if (blockId) { store.dispatch( updateOneBlock({ diff --git a/src/renderer/src/windows/selection/action/components/__tests__/ActionUtils.test.ts b/src/renderer/src/windows/selection/action/components/__tests__/ActionUtils.test.ts index 5e02b813ca..d97290e756 100644 --- a/src/renderer/src/windows/selection/action/components/__tests__/ActionUtils.test.ts +++ b/src/renderer/src/windows/selection/action/components/__tests__/ActionUtils.test.ts @@ -284,6 +284,54 @@ describe('processMessages', () => { }) }) + describe('thinking timer fallback', () => { + it('should use local timer when thinking_millsec is missing', async () => { + const nowValues = [1000, 1500, 2000] + let nowIndex = 0 + const performanceSpy = vi.spyOn(performance, 'now').mockImplementation(() => { + const value = nowValues[Math.min(nowIndex, nowValues.length - 1)] + nowIndex += 1 + return value + }) + + const mockChunks = [ + { type: ChunkType.THINKING_START }, + { type: ChunkType.THINKING_DELTA, text: 'Thinking...' }, + { type: ChunkType.THINKING_COMPLETE, text: 'Done thinking' }, + { type: ChunkType.TEXT_START }, + { type: ChunkType.TEXT_COMPLETE, text: 'Final answer' }, + { type: ChunkType.BLOCK_COMPLETE } + ] + + vi.mocked(fetchChatCompletion).mockImplementation(async ({ onChunkReceived }: any) => { + for (const chunk of mockChunks) { + await onChunkReceived(chunk) + } + }) + + await processMessages( + mockAssistant, + mockTopic, + 'test prompt', + mockSetAskId, + mockOnStream, + mockOnFinish, + mockOnError + ) + + const thinkingDeltaCall = vi.mocked(throttledBlockUpdate).mock.calls.find(([id]) => id === 'thinking-block-1') + const deltaPayload = thinkingDeltaCall?.[1] as { thinking_millsec?: number } | undefined + expect(deltaPayload?.thinking_millsec).toBe(500) + + const thinkingCompleteUpdate = vi + .mocked(updateOneBlock) + .mock.calls.find(([payload]) => (payload as any)?.changes?.thinking_millsec !== undefined) + expect((thinkingCompleteUpdate?.[0] as any)?.changes?.thinking_millsec).toBe(1000) + + performanceSpy.mockRestore() + }) + }) + describe('stream with exceptions', () => { it('should handle error chunks properly', async () => { const mockError = new Error('Stream processing error')