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 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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') From 255b19d6ee51f1d8aef4fc66afc02e9dd1708378 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 15:14:12 +0800 Subject: [PATCH 06/18] fix(model): resolve doubao provider model inference issue (#11552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the issue where doubao provider could not infer models using the name field. Refactored `isDeepSeekHybridInferenceModel` to use `withModelIdAndNameAsId` utility function to check both model.id and model.name, avoiding duplicate calls in `isReasoningModel`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/renderer/src/config/models/reasoning.ts | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index dab918d5fd..fc3c925db1 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -460,16 +460,19 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => { } export const isDeepSeekHybridInferenceModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - // deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别 - // openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险 - // Matches: "deepseek-v3" followed by ".digit" or "-digit". - // Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence" - // until the end of the string. - // Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha - // Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit) - // TODO: move to utils and add test cases - return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1') + const { idResult, nameResult } = withModelIdAndNameAsId(model, (model) => { + const modelId = getLowerBaseModelName(model.id) + // deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别 + // openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险 + // Matches: "deepseek-v3" followed by ".digit" or "-digit". + // Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence" + // until the end of the string. + // Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha + // Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit) + // TODO: move to utils and add test cases + return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1') + }) + return idResult || nameResult } export const isLingReasoningModel = (model?: Model): boolean => { @@ -523,7 +526,6 @@ export function isReasoningModel(model?: Model): boolean { REASONING_REGEX.test(model.name) || isSupportedThinkingTokenDoubaoModel(model) || isDeepSeekHybridInferenceModel(model) || - isDeepSeekHybridInferenceModel({ ...model, id: model.name }) || false ) } From 444c13e1e31fdedadcbab6b56851ec425865bc74 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 15:35:23 +0800 Subject: [PATCH 07/18] fix: correct trace token usage (#11575) fix: correct ai sdk token usage mapping --- .../src/aiCore/trace/AiSdkSpanAdapter.ts | 4 +- .../trace/__tests__/AiSdkSpanAdapter.test.ts | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/aiCore/trace/__tests__/AiSdkSpanAdapter.test.ts diff --git a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts index f3df504de8..0c0e08a03d 100644 --- a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts +++ b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts @@ -245,8 +245,8 @@ export class AiSdkSpanAdapter { 'gen_ai.usage.output_tokens' ] - const completionTokens = attributes[inputsTokenKeys.find((key) => attributes[key]) || ''] - const promptTokens = attributes[outputTokenKeys.find((key) => attributes[key]) || ''] + const promptTokens = attributes[inputsTokenKeys.find((key) => attributes[key]) || ''] + const completionTokens = attributes[outputTokenKeys.find((key) => attributes[key]) || ''] if (completionTokens !== undefined || promptTokens !== undefined) { const usage: TokenUsage = { diff --git a/src/renderer/src/aiCore/trace/__tests__/AiSdkSpanAdapter.test.ts b/src/renderer/src/aiCore/trace/__tests__/AiSdkSpanAdapter.test.ts new file mode 100644 index 0000000000..4cd6241e64 --- /dev/null +++ b/src/renderer/src/aiCore/trace/__tests__/AiSdkSpanAdapter.test.ts @@ -0,0 +1,53 @@ +import type { Span } from '@opentelemetry/api' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' +import { describe, expect, it, vi } from 'vitest' + +import { AiSdkSpanAdapter } from '../AiSdkSpanAdapter' + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() + }) + } +})) + +describe('AiSdkSpanAdapter', () => { + const createMockSpan = (attributes: Record): Span => { + const span = { + spanContext: () => ({ + traceId: 'trace-id', + spanId: 'span-id' + }), + _attributes: attributes, + _events: [], + name: 'test span', + status: { code: SpanStatusCode.OK }, + kind: SpanKind.CLIENT, + startTime: [0, 0] as [number, number], + endTime: [0, 1] as [number, number], + ended: true, + parentSpanId: '', + links: [] + } + return span as unknown as Span + } + + it('maps prompt and completion usage tokens to the correct fields', () => { + const attributes = { + 'ai.usage.promptTokens': 321, + 'ai.usage.completionTokens': 654 + } + + const span = createMockSpan(attributes) + const result = AiSdkSpanAdapter.convertToSpanEntity({ span }) + + expect(result.usage).toBeDefined() + expect(result.usage?.prompt_tokens).toBe(321) + expect(result.usage?.completion_tokens).toBe(654) + expect(result.usage?.total_tokens).toBe(975) + }) +}) From 50a217a6382f02648c863bc72d9de23de6ba785d Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 15:37:18 +0800 Subject: [PATCH 08/18] fix: separate undefined vs none reasoning effort (#11562) fix(reasoning): handle reasoning effort none case properly separate undefined and none reasoning effort cases clean up redundant model checks and add fallback logging --- .../aiCore/utils/__tests__/reasoning.test.ts | 29 +++++++++++++- src/renderer/src/aiCore/utils/reasoning.ts | 39 +++++++------------ 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts b/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts index 1303e254a9..36253e5c1d 100644 --- a/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts @@ -144,7 +144,7 @@ describe('reasoning utils', () => { expect(result).toEqual({}) }) - it('should disable reasoning for OpenRouter when no reasoning effort set', async () => { + it('should not override reasoning for OpenRouter when reasoning effort undefined', async () => { const { isReasoningModel } = await import('@renderer/config/models') vi.mocked(isReasoningModel).mockReturnValue(true) @@ -161,6 +161,29 @@ describe('reasoning utils', () => { settings: {} } as Assistant + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({}) + }) + + it('should disable reasoning for OpenRouter when reasoning effort explicitly none', async () => { + const { isReasoningModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + + const model: Model = { + id: 'anthropic/claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: SystemProviderIds.openrouter + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'none' + } + } as Assistant + const result = getReasoningEffort(assistant, model) expect(result).toEqual({ reasoning: { enabled: false, exclude: true } }) }) @@ -269,7 +292,9 @@ describe('reasoning utils', () => { const assistant: Assistant = { id: 'test', name: 'Test', - settings: {} + settings: { + reasoning_effort: 'none' + } } as Assistant const result = getReasoningEffort(assistant, model) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 8f0df91e7b..f320a9f5d9 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -16,10 +16,8 @@ import { isGPT5SeriesModel, isGPT51SeriesModel, isGrok4FastReasoningModel, - isGrokReasoningModel, isOpenAIDeepResearchModel, isOpenAIModel, - isOpenAIReasoningModel, isQwenAlwaysThinkModel, isQwenReasoningModel, isReasoningModel, @@ -64,30 +62,22 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } const reasoningEffort = assistant?.settings?.reasoning_effort - // Handle undefined and 'none' reasoningEffort. - // TODO: They should be separated. - if (!reasoningEffort || reasoningEffort === 'none') { + // reasoningEffort is not set, no extra reasoning setting + // Generally, for every model which supports reasoning control, the reasoning effort won't be undefined. + // It's for some reasoning models that don't support reasoning control, such as deepseek reasoner. + if (!reasoningEffort) { + return {} + } + + // Handle 'none' reasoningEffort. It's explicitly off. + if (reasoningEffort === 'none') { // openrouter: use reasoning if (model.provider === SystemProviderIds.openrouter) { - // Don't disable reasoning for Gemini models that support thinking tokens - if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { - return {} - } // 'none' is not an available value for effort for now. // I think they should resolve this issue soon, so I'll just go ahead and use this value. if (isGPT51SeriesModel(model) && reasoningEffort === 'none') { return { reasoning: { effort: 'none' } } } - // Don't disable reasoning for models that require it - if ( - isGrokReasoningModel(model) || - isOpenAIReasoningModel(model) || - isQwenAlwaysThinkModel(model) || - model.id.includes('seed-oss') || - model.id.includes('minimax-m2') - ) { - return {} - } return { reasoning: { enabled: false, exclude: true } } } @@ -101,11 +91,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { enable_thinking: false } } - // claude - if (isSupportedThinkingTokenClaudeModel(model)) { - return {} - } - // gemini if (isSupportedThinkingTokenGeminiModel(model)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { @@ -118,8 +103,10 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } } } + } else { + logger.warn(`Model ${model.id} cannot disable reasoning. Fallback to empty reasoning param.`) + return {} } - return {} } // use thinking, doubao, zhipu, etc. @@ -139,6 +126,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } } + logger.warn(`Model ${model.id} doesn't match any disable reasoning behavior. Fallback to empty reasoning param.`) return {} } @@ -293,6 +281,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } // OpenRouter models, use reasoning + // FIXME: duplicated openrouter handling. remove one if (model.provider === SystemProviderIds.openrouter) { if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) { return { From f5c144404d754323d51720e27b14703aac53a979 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 15:37:45 +0800 Subject: [PATCH 09/18] fix: persist inputbar text using global variable cache to prevent loss on tab switch (#11558) * fix: implement draft persistence using CacheService in Inputbar components * fix: enhance draft persistence in Inputbar components using CacheService * fix: update cache handling for mentioned models in Inputbar component * fix: improve validation of cached models in Inputbar component --------- Co-authored-by: suyao --- .../home/Inputbar/AgentSessionInputbar.tsx | 24 +++++-------- .../src/pages/home/Inputbar/Inputbar.tsx | 34 ++++++++++++++++--- .../home/Inputbar/components/InputbarCore.tsx | 7 ++-- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx index 722697be78..2dbcd04067 100644 --- a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx @@ -9,6 +9,7 @@ import { getModel } from '@renderer/hooks/useModel' import { useSettings } from '@renderer/hooks/useSettings' import { useTextareaResize } from '@renderer/hooks/useTextareaResize' import { useTimer } from '@renderer/hooks/useTimer' +import { CacheService } from '@renderer/services/CacheService' import { pauseTrace } from '@renderer/services/SpanManagerService' import { estimateUserPromptUsage } from '@renderer/services/TokenService' import { useAppDispatch, useAppSelector } from '@renderer/store' @@ -41,19 +42,10 @@ import { getInputbarConfig } from './registry' import { TopicType } from './types' const logger = loggerService.withContext('AgentSessionInputbar') -const agentSessionDraftCache = new Map() -const readDraftFromCache = (key: string): string => { - return agentSessionDraftCache.get(key) ?? '' -} +const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours -const writeDraftToCache = (key: string, value: string) => { - if (!value) { - agentSessionDraftCache.delete(key) - } else { - agentSessionDraftCache.set(key, value) - } -} +const getAgentDraftCacheKey = (agentId: string) => `agent-session-draft-${agentId}` type Props = { agentId: string @@ -170,16 +162,15 @@ const AgentSessionInputbarInner: FC = ({ assistant, agentId, session const scope = TopicType.Session const config = getInputbarConfig(scope) - // Use shared hooks for text and textarea management - const initialDraft = useMemo(() => readDraftFromCache(agentId), [agentId]) - const persistDraft = useCallback((next: string) => writeDraftToCache(agentId, next), [agentId]) + // Use shared hooks for text and textarea management with draft persistence + const draftCacheKey = getAgentDraftCacheKey(agentId) const { text, setText, isEmpty: inputEmpty } = useInputText({ - initialValue: initialDraft, - onChange: persistDraft + initialValue: CacheService.get(draftCacheKey) ?? '', + onChange: (value) => CacheService.set(draftCacheKey, value, DRAFT_CACHE_TTL) }) const { textareaRef, @@ -431,6 +422,7 @@ const AgentSessionInputbarInner: FC = ({ assistant, agentId, session }) ) + // Clear text after successful send (draft is cleared automatically via onChange) setText('') setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500) } catch (error) { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 65777533c5..0624bfed81 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -24,6 +24,7 @@ import { useInputbarToolsState } from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider' import { getDefaultTopic } from '@renderer/services/AssistantService' +import { CacheService } from '@renderer/services/CacheService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' @@ -39,7 +40,7 @@ import { getSendMessageShortcutLabel } from '@renderer/utils/input' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { debounce } from 'lodash' import type { FC } from 'react' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { InputbarCore } from './components/InputbarCore' @@ -51,6 +52,17 @@ import TokenCount from './TokenCount' const logger = loggerService.withContext('Inputbar') +const INPUTBAR_DRAFT_CACHE_KEY = 'inputbar-draft' +const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours + +const getMentionedModelsCacheKey = (assistantId: string) => `inputbar-mentioned-models-${assistantId}` + +const getValidatedCachedModels = (assistantId: string): Model[] => { + const cached = CacheService.get(getMentionedModelsCacheKey(assistantId)) + if (!Array.isArray(cached)) return [] + return cached.filter((model) => model?.id && model?.name) +} + interface Props { assistant: Assistant setActiveTopic: (topic: Topic) => void @@ -80,16 +92,18 @@ const Inputbar: FC = ({ assistant: initialAssistant, setActiveTopic, topi toggleExpanded: () => {} }) + const [initialMentionedModels] = useState(() => getValidatedCachedModels(initialAssistant.id)) + const initialState = useMemo( () => ({ files: [] as FileType[], - mentionedModels: [] as Model[], + mentionedModels: initialMentionedModels, selectedKnowledgeBases: initialAssistant.knowledge_bases ?? [], isExpanded: false, couldAddImageFile: false, extensions: [] as string[] }), - [initialAssistant.knowledge_bases] + [initialMentionedModels, initialAssistant.knowledge_bases] ) return ( @@ -121,7 +135,10 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se const { setFiles, setMentionedModels, setSelectedKnowledgeBases } = useInputbarToolsDispatch() const { setCouldAddImageFile } = useInputbarToolsInternalDispatch() - const { text, setText } = useInputText() + const { text, setText } = useInputText({ + initialValue: CacheService.get(INPUTBAR_DRAFT_CACHE_KEY) ?? '', + onChange: (value) => CacheService.set(INPUTBAR_DRAFT_CACHE_KEY, value, DRAFT_CACHE_TTL) + }) const { textareaRef, resize: resizeTextArea, @@ -190,6 +207,15 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se setCouldAddImageFile(canAddImageFile) }, [canAddImageFile, setCouldAddImageFile]) + const onUnmount = useEffectEvent((id: string) => { + CacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL) + }) + + useEffect(() => { + return () => onUnmount(assistant.id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assistant.id]) + const placeholderText = enableQuickPanelTriggers ? t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) : t('chat.input.placeholder_without_triggers', { diff --git a/src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx b/src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx index f2065ce9a1..e1ae6d1bae 100644 --- a/src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx +++ b/src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx @@ -156,11 +156,8 @@ export const InputbarCore: FC = ({ const setText = useCallback>>( (value) => { - if (typeof value === 'function') { - onTextChange(value(textRef.current)) - } else { - onTextChange(value) - } + const newText = typeof value === 'function' ? value(textRef.current) : value + onTextChange(newText) }, [onTextChange] ) From 706fac898a2c71020607e40d2c7c9f6c3216d52d Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 15:39:09 +0800 Subject: [PATCH 10/18] fix(i18n): clarify image-generation endpoint type as OpenAI-based (#11554) * fix(i18n): remove image-generation translations and clarify endpoint type Update English locale to specify OpenAI for image generation Add comments to clarify image-generation endpoint type relationship * fix(i18n): correct portuguese translations in pt-pt.json --- packages/ai-sdk-provider/src/cherryin-provider.ts | 1 + src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/i18n/translate/de-de.json | 2 +- src/renderer/src/i18n/translate/el-gr.json | 2 +- src/renderer/src/i18n/translate/es-es.json | 2 +- src/renderer/src/i18n/translate/fr-fr.json | 2 +- src/renderer/src/i18n/translate/ja-jp.json | 2 +- src/renderer/src/i18n/translate/pt-pt.json | 10 +++++----- src/renderer/src/i18n/translate/ru-ru.json | 2 +- src/renderer/src/types/index.ts | 1 + 12 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/ai-sdk-provider/src/cherryin-provider.ts b/packages/ai-sdk-provider/src/cherryin-provider.ts index 1f799133d9..d045fdc505 100644 --- a/packages/ai-sdk-provider/src/cherryin-provider.ts +++ b/packages/ai-sdk-provider/src/cherryin-provider.ts @@ -69,6 +69,7 @@ export interface CherryInProviderSettings { headers?: HeadersInput /** * Optional endpoint type to distinguish different endpoint behaviors. + * "image-generation" is also openai endpoint, but specifically for image generation. */ endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank' } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a57c549c7e..008f1721e8 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Image Generation", + "image-generation": "Image Generation (OpenAI)", "jina-rerank": "Jina Rerank", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8f1d81aabd..60b4782739 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "图片生成", + "image-generation": "图像生成 (OpenAI)", "jina-rerank": "Jina 重排序", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index acda928d31..8068b666f0 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "圖片生成", + "image-generation": "圖像生成 (OpenAI)", "jina-rerank": "Jina Rerank", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 94c338ba30..61446bc794 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Bildgenerierung", + "image-generation": "Bilderzeugung (OpenAI)", "jina-rerank": "Jina Reranking", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index b13975d218..39830d9c51 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Δημιουργία Εικόνας", + "image-generation": "Δημιουργία Εικόνων (OpenAI)", "jina-rerank": "Επαναταξινόμηση Jina", "openai": "OpenAI", "openai-response": "Απάντηση OpenAI" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 08b90da9ac..8573824525 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Generación de imágenes", + "image-generation": "Generación de Imágenes (OpenAI)", "jina-rerank": "Reordenamiento Jina", "openai": "OpenAI", "openai-response": "Respuesta de OpenAI" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9a744c2d56..16b2da7596 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Génération d'images", + "image-generation": "Génération d'images (OpenAI)", "jina-rerank": "Reclassement Jina", "openai": "OpenAI", "openai-response": "Réponse OpenAI" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 98c571adb4..03a077a7b8 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "画像生成", + "image-generation": "画像生成 (OpenAI)", "jina-rerank": "Jina Rerank", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index ae993eaf13..e8704d318d 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -16,7 +16,7 @@ "error": { "failed": "Falha ao excluir o agente" }, - "title": "删除代理" + "title": "Excluir Agente" }, "edit": { "title": "Agent Editor" @@ -111,7 +111,7 @@ "label": "Modo de permissão", "options": { "acceptEdits": "Aceitar edições automaticamente", - "bypassPermissions": "忽略检查 de permissão", + "bypassPermissions": "Ignorar verificações de permissão", "default": "Padrão (perguntar antes de continuar)", "plan": "Modo de planejamento (plano sujeito a aprovação)" }, @@ -150,7 +150,7 @@ }, "success": { "install": "Plugin instalado com sucesso", - "uninstall": "插件 desinstalado com sucesso" + "uninstall": "Plugin desinstalado com sucesso" }, "tab": "plug-in", "type": { @@ -1134,7 +1134,7 @@ "duplicate": "Duplicar", "edit": "Editar", "enabled": "Ativado", - "error": "错误", + "error": "Erro", "errors": { "create_message": "Falha ao criar mensagem", "validation": "Falha na verificação" @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Geração de Imagem", + "image-generation": "Geração de Imagens (OpenAI)", "jina-rerank": "Jina Reordenar", "openai": "OpenAI", "openai-response": "Resposta OpenAI" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 931dcb3170..1114ae9544 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1208,7 +1208,7 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "Изображение", + "image-generation": "Генерация изображений (OpenAI)", "jina-rerank": "Jina Rerank", "openai": "OpenAI", "openai-response": "OpenAI-Response" diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index bff57185a7..ad9beb5d5a 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -239,6 +239,7 @@ export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'functio export type ModelTag = Exclude | 'free' +// "image-generation" is also openai endpoint, but specifically for image generation. export type EndpointType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank' export type ModelPricing = { From 03ff6e1ca63b097b6f447057d40d246850a4164f Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 17:31:32 +0800 Subject: [PATCH 11/18] fix: stabilize home scroll behavior (#11576) * feat(dom): extend scrollIntoView with Chromium-specific options Add ChromiumScrollIntoViewOptions interface to support additional scroll container options * refactor(hooks): optimize timer and scroll position hooks - Use useMemo for scrollKey in useScrollPosition to avoid unnecessary recalculations - Refactor useTimer to use useCallback for all functions to prevent unnecessary recreations - Reorganize function order and improve cleanup logic in useTimer * fix: stabilize home scroll behavior * Update src/renderer/src/pages/home/Messages/ChatNavigation.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(utils/dom): add null check for element in scrollIntoView Prevent potential runtime errors by gracefully handling falsy elements with a warning log * fix(hooks): use ref for scroll key to avoid stale closure * fix(useScrollPosition): add cleanup for scroll handler to prevent memory leaks --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/renderer/src/hooks/useScrollPosition.ts | 15 ++- src/renderer/src/hooks/useTimer.ts | 98 ++++++++++--------- .../pages/home/Messages/ChatNavigation.tsx | 4 +- .../src/pages/home/Messages/Message.tsx | 8 +- .../pages/home/Messages/MessageAnchorLine.tsx | 5 +- .../src/pages/home/Messages/MessageGroup.tsx | 5 +- .../pages/home/Messages/MessageOutline.tsx | 5 +- src/renderer/src/utils/dom.ts | 19 +++- 8 files changed, 99 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/hooks/useScrollPosition.ts b/src/renderer/src/hooks/useScrollPosition.ts index acb1bd851b..c0f09300d8 100644 --- a/src/renderer/src/hooks/useScrollPosition.ts +++ b/src/renderer/src/hooks/useScrollPosition.ts @@ -1,5 +1,5 @@ import { throttle } from 'lodash' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTimer } from './useTimer' @@ -12,13 +12,18 @@ import { useTimer } from './useTimer' */ export default function useScrollPosition(key: string, throttleWait?: number) { const containerRef = useRef(null) - const scrollKey = `scroll:${key}` + const scrollKey = useMemo(() => `scroll:${key}`, [key]) + const scrollKeyRef = useRef(scrollKey) const { setTimeoutTimer } = useTimer() + useEffect(() => { + scrollKeyRef.current = scrollKey + }, [scrollKey]) + const handleScroll = throttle(() => { const position = containerRef.current?.scrollTop ?? 0 window.requestAnimationFrame(() => { - window.keyv.set(scrollKey, position) + window.keyv.set(scrollKeyRef.current, position) }) }, throttleWait ?? 100) @@ -28,5 +33,9 @@ export default function useScrollPosition(key: string, throttleWait?: number) { setTimeoutTimer('scrollEffect', scroll, 50) }, [scrollKey, setTimeoutTimer]) + useEffect(() => { + return () => handleScroll.cancel() + }, [handleScroll]) + return { containerRef, handleScroll } } diff --git a/src/renderer/src/hooks/useTimer.ts b/src/renderer/src/hooks/useTimer.ts index af4df045cf..69fa89cdf9 100644 --- a/src/renderer/src/hooks/useTimer.ts +++ b/src/renderer/src/hooks/useTimer.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' /** * 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器 @@ -43,10 +43,38 @@ export const useTimer = () => { const timeoutMapRef = useRef(new Map()) const intervalMapRef = useRef(new Map()) + /** + * 清除指定 key 的 setTimeout 定时器 + * @param key - 定时器标识符 + */ + const clearTimeoutTimer = useCallback((key: string) => { + clearTimeout(timeoutMapRef.current.get(key)) + timeoutMapRef.current.delete(key) + }, []) + + /** + * 清除指定 key 的 setInterval 定时器 + * @param key - 定时器标识符 + */ + const clearIntervalTimer = useCallback((key: string) => { + clearInterval(intervalMapRef.current.get(key)) + intervalMapRef.current.delete(key) + }, []) + + /** + * 清除所有定时器,包括 setTimeout 和 setInterval + */ + const clearAllTimers = useCallback(() => { + timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) + intervalMapRef.current.forEach((timer) => clearInterval(timer)) + timeoutMapRef.current.clear() + intervalMapRef.current.clear() + }, []) + // 组件卸载时自动清理所有定时器 useEffect(() => { return () => clearAllTimers() - }, []) + }, [clearAllTimers]) /** * 设置一个 setTimeout 定时器 @@ -65,12 +93,15 @@ export const useTimer = () => { * cleanup(); * ``` */ - const setTimeoutTimer = (key: string, ...args: Parameters) => { - clearTimeout(timeoutMapRef.current.get(key)) - const timer = setTimeout(...args) - timeoutMapRef.current.set(key, timer) - return () => clearTimeoutTimer(key) - } + const setTimeoutTimer = useCallback( + (key: string, ...args: Parameters) => { + clearTimeout(timeoutMapRef.current.get(key)) + const timer = setTimeout(...args) + timeoutMapRef.current.set(key, timer) + return () => clearTimeoutTimer(key) + }, + [clearTimeoutTimer] + ) /** * 设置一个 setInterval 定时器 @@ -89,56 +120,31 @@ export const useTimer = () => { * cleanup(); * ``` */ - const setIntervalTimer = (key: string, ...args: Parameters) => { - clearInterval(intervalMapRef.current.get(key)) - const timer = setInterval(...args) - intervalMapRef.current.set(key, timer) - return () => clearIntervalTimer(key) - } - - /** - * 清除指定 key 的 setTimeout 定时器 - * @param key - 定时器标识符 - */ - const clearTimeoutTimer = (key: string) => { - clearTimeout(timeoutMapRef.current.get(key)) - timeoutMapRef.current.delete(key) - } - - /** - * 清除指定 key 的 setInterval 定时器 - * @param key - 定时器标识符 - */ - const clearIntervalTimer = (key: string) => { - clearInterval(intervalMapRef.current.get(key)) - intervalMapRef.current.delete(key) - } + const setIntervalTimer = useCallback( + (key: string, ...args: Parameters) => { + clearInterval(intervalMapRef.current.get(key)) + const timer = setInterval(...args) + intervalMapRef.current.set(key, timer) + return () => clearIntervalTimer(key) + }, + [clearIntervalTimer] + ) /** * 清除所有 setTimeout 定时器 */ - const clearAllTimeoutTimers = () => { + const clearAllTimeoutTimers = useCallback(() => { timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) timeoutMapRef.current.clear() - } + }, []) /** * 清除所有 setInterval 定时器 */ - const clearAllIntervalTimers = () => { + const clearAllIntervalTimers = useCallback(() => { intervalMapRef.current.forEach((timer) => clearInterval(timer)) intervalMapRef.current.clear() - } - - /** - * 清除所有定时器,包括 setTimeout 和 setInterval - */ - const clearAllTimers = () => { - timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) - intervalMapRef.current.forEach((timer) => clearInterval(timer)) - timeoutMapRef.current.clear() - intervalMapRef.current.clear() - } + }, []) return { setTimeoutTimer, diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 4aeb275b6a..9f9f92f25c 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -10,6 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import type { RootState } from '@renderer/store' // import { selectCurrentTopicId } from '@renderer/store/newMessage' +import { scrollIntoView } from '@renderer/utils/dom' import { Button, Drawer, Tooltip } from 'antd' import type { FC } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' @@ -118,7 +119,8 @@ const ChatNavigation: FC = ({ containerId }) => { } const scrollToMessage = (element: HTMLElement) => { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + // Use container: 'nearest' to keep scroll within the chat pane (Chromium-only, see #11565, #11567) + scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' }) } const scrollToTop = () => { diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 798559f8d9..ddbcd9cf25 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -15,6 +15,7 @@ import { estimateMessageUsage } from '@renderer/services/TokenService' import type { Assistant, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { classNames, cn } from '@renderer/utils' +import { scrollIntoView } from '@renderer/utils/dom' import { isMessageProcessing } from '@renderer/utils/messageUtils/is' import { Divider } from 'antd' import type { Dispatch, FC, SetStateAction } from 'react' @@ -79,9 +80,10 @@ const MessageItem: FC = ({ useEffect(() => { if (isEditing && messageContainerRef.current) { - messageContainerRef.current.scrollIntoView({ + scrollIntoView(messageContainerRef.current, { behavior: 'smooth', - block: 'center' + block: 'center', + container: 'nearest' }) } }, [isEditing]) @@ -124,7 +126,7 @@ const MessageItem: FC = ({ const messageHighlightHandler = useCallback( (highlight: boolean = true) => { if (messageContainerRef.current) { - messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) + scrollIntoView(messageContainerRef.current, { behavior: 'smooth', block: 'center', container: 'nearest' }) if (highlight) { setTimeoutTimer( 'messageHighlightHandler', diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index d36448913f..ab489dd700 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -12,6 +12,7 @@ import { newMessagesActions } from '@renderer/store/newMessage' // import { updateMessageThunk } from '@renderer/store/thunk/messageThunk' import type { Message } from '@renderer/types/newMessage' import { isEmoji, removeLeadingEmoji } from '@renderer/utils' +import { scrollIntoView } from '@renderer/utils/dom' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { Avatar } from 'antd' import { CircleChevronDown } from 'lucide-react' @@ -119,7 +120,7 @@ const MessageAnchorLine: FC = ({ messages }) => { () => { const messageElement = document.getElementById(`message-${message.id}`) if (messageElement) { - messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) + scrollIntoView(messageElement, { behavior: 'auto', block: 'start', container: 'nearest' }) } }, 100 @@ -141,7 +142,7 @@ const MessageAnchorLine: FC = ({ messages }) => { return } - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' }) }, [setSelectedMessage] ) diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 1e1eca27a1..849e4b1c76 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -10,6 +10,7 @@ import type { MultiModelMessageStyle } from '@renderer/store/settings' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' +import { scrollIntoView } from '@renderer/utils/dom' import { Popover } from 'antd' import type { ComponentProps } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' @@ -73,7 +74,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { () => { const messageElement = document.getElementById(`message-${message.id}`) if (messageElement) { - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' }) } }, 200 @@ -132,7 +133,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { setSelectedMessage(message) } else { // 直接滚动 - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' }) } } } diff --git a/src/renderer/src/pages/home/Messages/MessageOutline.tsx b/src/renderer/src/pages/home/Messages/MessageOutline.tsx index 1327dd4a89..0fb372841f 100644 --- a/src/renderer/src/pages/home/Messages/MessageOutline.tsx +++ b/src/renderer/src/pages/home/Messages/MessageOutline.tsx @@ -3,6 +3,7 @@ import type { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { Message } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' +import { scrollIntoView } from '@renderer/utils/dom' import type { FC } from 'react' import React, { useMemo, useRef } from 'react' import { useSelector } from 'react-redux' @@ -72,10 +73,10 @@ const MessageOutline: FC = ({ message }) => { const parent = messageOutlineContainerRef.current?.parentElement const messageContentContainer = parent?.querySelector('.message-content-container') if (messageContentContainer) { - const headingElement = messageContentContainer.querySelector(`#${id}`) + const headingElement = messageContentContainer.querySelector(`#${id}`) if (headingElement) { const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start' - headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock }) + scrollIntoView(headingElement, { behavior: 'smooth', block: scrollBlock, container: 'nearest' }) } } } diff --git a/src/renderer/src/utils/dom.ts b/src/renderer/src/utils/dom.ts index 6dd09cda5e..15161ea86d 100644 --- a/src/renderer/src/utils/dom.ts +++ b/src/renderer/src/utils/dom.ts @@ -1,3 +1,15 @@ +import { loggerService } from '@logger' + +const logger = loggerService.withContext('utils/dom') + +interface ChromiumScrollIntoViewOptions extends ScrollIntoViewOptions { + /** + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#container + * @see https://github.com/microsoft/TypeScript/issues/62803 + */ + container?: 'all' | 'nearest' +} + /** * Simple wrapper for scrollIntoView with common default options. * Provides a unified interface with sensible defaults. @@ -5,7 +17,12 @@ * @param element - The target element to scroll into view * @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' } */ -export function scrollIntoView(element: HTMLElement, options?: ScrollIntoViewOptions): void { +export function scrollIntoView(element: HTMLElement, options?: ChromiumScrollIntoViewOptions): void { + if (!element) { + logger.warn('[scrollIntoView] Unexpected falsy element. Do nothing as fallback.') + return + } + const defaultOptions: ScrollIntoViewOptions = { behavior: 'smooth', block: 'center', From 3b1155b538da0f26e5aca37cfca0fbd1118bbabb Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 17:51:17 +0800 Subject: [PATCH 12/18] fix: make knowledge base tool always visible regardless of sidebar settings (#11553) * refactor(knowledgeBaseTool): remove unused sidebar icon visibility check * refactor(Inputbar): remove unused knowledge icon visibility logic Simplify knowledge base selection by directly using assistant.knowledge_bases --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 7 +++---- .../src/pages/home/Inputbar/tools/knowledgeBaseTool.tsx | 6 ------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 0624bfed81..0985023ead 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -14,7 +14,6 @@ import { useInputText } from '@renderer/hooks/useInputText' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' -import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { useTextareaResize } from '@renderer/hooks/useTextareaResize' import { useTimer } from '@renderer/hooks/useTimer' import { @@ -150,7 +149,6 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se minHeight: 30 }) - const showKnowledgeIcon = useSidebarIconShow('knowledge') const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(initialAssistant.id) const { sendMessageShortcut, showInputEstimatedTokens, enableQuickPanelTriggers } = useSettings() const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -407,9 +405,10 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se focusTextarea ]) + // TODO: Just use assistant.knowledge_bases as selectedKnowledgeBases. context state is overdesigned. useEffect(() => { - setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : []) - }, [assistant.knowledge_bases, setSelectedKnowledgeBases, showKnowledgeIcon]) + setSelectedKnowledgeBases(assistant.knowledge_bases ?? []) + }, [assistant.knowledge_bases, setSelectedKnowledgeBases]) useEffect(() => { // Disable web search if model doesn't support it diff --git a/src/renderer/src/pages/home/Inputbar/tools/knowledgeBaseTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/knowledgeBaseTool.tsx index 5e3ffcf864..787d98eb34 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/knowledgeBaseTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/knowledgeBaseTool.tsx @@ -1,5 +1,4 @@ import { useAssistant } from '@renderer/hooks/useAssistant' -import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types' import type { KnowledgeBase } from '@renderer/types' import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools' @@ -30,7 +29,6 @@ const knowledgeBaseTool = defineTool({ render: function KnowledgeBaseToolRender(context) { const { assistant, state, actions, quickPanel } = context - const knowledgeSidebarEnabled = useSidebarIconShow('knowledge') const { updateAssistant } = useAssistant(assistant.id) const handleSelect = useCallback( @@ -41,10 +39,6 @@ const knowledgeBaseTool = defineTool({ [updateAssistant, actions] ) - if (!knowledgeSidebarEnabled) { - return null - } - return ( Date: Sun, 30 Nov 2025 17:58:23 +0800 Subject: [PATCH 13/18] fix: improve BashTool command display and enhance ToolTitle layout (#11572) * fix: improve BashTool command display and enhance ToolTitle layout * style(ant.css): fix overflow in collapse header text * fix(i18n): translate toolPendingFallback in multiple languages --------- Co-authored-by: icarus --- src/renderer/src/assets/styles/ant.css | 4 +++ src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../Tools/MessageAgentTools/BashTool.tsx | 24 +++++--------- .../Tools/MessageAgentTools/GenericTools.tsx | 6 ++-- .../Tools/MessageAgentTools/index.tsx | 33 +++++++++++++++++-- 14 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/assets/styles/ant.css b/src/renderer/src/assets/styles/ant.css index 30005ff738..7d651a6a6a 100644 --- a/src/renderer/src/assets/styles/ant.css +++ b/src/renderer/src/assets/styles/ant.css @@ -215,6 +215,10 @@ border-top: none !important; } +.ant-collapse-header-text { + overflow-x: hidden; +} + .ant-slider .ant-slider-handle::after { box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 008f1721e8..782340e011 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -280,6 +280,7 @@ "denied": "Tool request was denied.", "timeout": "Tool request timed out before receiving approval." }, + "toolPendingFallback": "Tool", "waiting": "Waiting for tool permission decision..." }, "type": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 60b4782739..c1874f7fb8 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -280,6 +280,7 @@ "denied": "工具请求已被拒绝。", "timeout": "工具请求在收到批准前超时。" }, + "toolPendingFallback": "工具", "waiting": "等待工具权限决定..." }, "type": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8068b666f0..db81e30006 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -280,6 +280,7 @@ "denied": "工具請求已被拒絕。", "timeout": "工具請求在收到核准前逾時。" }, + "toolPendingFallback": "工具", "waiting": "等待工具權限決定..." }, "type": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 61446bc794..e7314482a3 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -280,6 +280,7 @@ "denied": "Tool-Anfrage wurde abgelehnt.", "timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist." }, + "toolPendingFallback": "Werkzeug", "waiting": "Warten auf Entscheidung über Tool-Berechtigung..." }, "type": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 39830d9c51..bc825ec688 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -280,6 +280,7 @@ "denied": "Το αίτημα για εργαλείο απορρίφθηκε.", "timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση." }, + "toolPendingFallback": "Εργαλείο", "waiting": "Αναμονή για απόφαση άδειας εργαλείου..." }, "type": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 8573824525..2da83ad229 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -280,6 +280,7 @@ "denied": "La solicitud de herramienta fue denegada.", "timeout": "La solicitud de herramienta expiró antes de recibir la aprobación." }, + "toolPendingFallback": "Herramienta", "waiting": "Esperando la decisión de permiso de la herramienta..." }, "type": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 16b2da7596..3f3e9108c5 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -280,6 +280,7 @@ "denied": "La demande d'outil a été refusée.", "timeout": "La demande d'outil a expiré avant d'obtenir l'approbation." }, + "toolPendingFallback": "Outil", "waiting": "En attente de la décision d'autorisation de l'outil..." }, "type": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 03a077a7b8..e2591fb20d 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -280,6 +280,7 @@ "denied": "ツールリクエストは拒否されました。", "timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。" }, + "toolPendingFallback": "ツール", "waiting": "ツールの許可決定を待っています..." }, "type": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index e8704d318d..cae6ebd38d 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -280,6 +280,7 @@ "denied": "Solicitação de ferramenta foi negada.", "timeout": "A solicitação da ferramenta expirou antes de receber aprovação." }, + "toolPendingFallback": "Ferramenta", "waiting": "Aguardando decisão de permissão da ferramenta..." }, "type": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 1114ae9544..fe5ebbcb25 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -280,6 +280,7 @@ "denied": "Запрос на инструмент был отклонён.", "timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения." }, + "toolPendingFallback": "Инструмент", "waiting": "Ожидание решения о разрешении на использование инструмента..." }, "type": { diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx index 7d627c3455..798807d4d6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx @@ -5,8 +5,6 @@ import { Terminal } from 'lucide-react' import { ToolTitle } from './GenericTools' import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types' -const MAX_TAG_LENGTH = 100 - export function BashTool({ input, output @@ -17,12 +15,10 @@ export function BashTool({ // 如果有输出,计算输出行数 const outputLines = output ? output.split('\n').length : 0 - // 处理命令字符串的截断,添加空值检查 + // 处理命令字符串,添加空值检查 const command = input?.command ?? '' - const needsTruncate = command.length > MAX_TAG_LENGTH - const displayCommand = needsTruncate ? `${command.slice(0, MAX_TAG_LENGTH)}...` : command - const tagContent = {displayCommand} + const tagContent = {command} return { key: 'tool', @@ -34,16 +30,12 @@ export function BashTool({ params={input?.description} stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} /> -
- {needsTruncate ? ( - {command}
} - trigger="hover"> - {tagContent} - - ) : ( - tagContent - )} +
+ {command}
} + trigger="hover"> + {tagContent} + ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx index 9eaaf76f2c..2245730ce7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx @@ -18,9 +18,9 @@ export function ToolTitle({ }) { return (
- {icon} - {label && {label}} - {params && {params}} + {icon && {icon}} + {label && {label}} + {params && {params}} {stats && {stats}}
) diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx index 42a1cf403b..e523305277 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx @@ -1,7 +1,10 @@ import { loggerService } from '@logger' +import { useAppSelector } from '@renderer/store' +import { selectPendingPermission } from '@renderer/store/toolPermissions' import type { NormalToolResponse } from '@renderer/types' import type { CollapseProps } from 'antd' -import { Collapse } from 'antd' +import { Collapse, Spin } from 'antd' +import { useTranslation } from 'react-i18next' // 导出所有类型 export * from './types' @@ -83,17 +86,41 @@ function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; in // 统一的组件渲染入口 export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) { const { arguments: args, response, tool, status } = toolResponse - logger.info('Rendering agent tool response', { + logger.debug('Rendering agent tool response', { tool: tool, arguments: args, + status, response }) + const pendingPermission = useAppSelector((state) => + selectPendingPermission(state.toolPermissions, toolResponse.toolCallId) + ) + if (status === 'pending') { - return + if (pendingPermission) { + return + } + return } return ( ) } + +function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) { + const { t } = useTranslation() + const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool') + const detail = description?.trim() || t('agent.toolPermission.executing') + + return ( +
+ +
+ {label} + {detail} +
+
+ ) +} From 2bd680361af1f3d62729eca9093d471e11aafb4c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:58:37 +0800 Subject: [PATCH 14/18] fix: set CLAUDE_CONFIG_DIR to avoid path encoding issues on Windows with non-ASCII usernames (#11550) * Initial plan * fix: set CLAUDE_CONFIG_DIR to avoid path encoding issues on Windows with non-ASCII usernames Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --- src/main/services/agents/services/claudecode/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 53b318c5b2..e5cefadd68 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -1,6 +1,7 @@ // src/main/services/agents/services/claudecode/index.ts import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' +import path from 'node:path' import type { CanUseTool, @@ -121,7 +122,11 @@ class ClaudeCodeService implements AgentServiceInterface { // TODO: support set small model in UI ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId, ELECTRON_RUN_AS_NODE: '1', - ELECTRON_NO_ATTACH_CONSOLE: '1' + ELECTRON_NO_ATTACH_CONSOLE: '1', + // Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues + // on Windows when the username contains non-ASCII characters (e.g., Chinese characters) + // This prevents the SDK from using the user's home directory which may have encoding problems + CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude') } const errorChunks: string[] = [] From d968df4612ff42eb5286a54b898c80b706994066 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 18:34:56 +0800 Subject: [PATCH 15/18] fix(ApiService): properly handle and throw stream errors in API check (#11577) * fix(ApiService): handle stream errors properly in checkApi Ensure stream errors are properly caught and thrown when checking API availability * docs(types): add type safety comment for ResponseError Add FIXME comment highlighting weak type safety in ResponseError type --- src/renderer/src/services/ApiService.ts | 11 +++++------ src/renderer/src/types/newMessage.ts | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 1b2bf0433f..d265d5cb48 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -12,7 +12,7 @@ import type { FetchChatCompletionParams } from '@renderer/types' import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { type Chunk, ChunkType } from '@renderer/types/chunk' -import type { Message } from '@renderer/types/newMessage' +import type { Message, ResponseError } from '@renderer/types/newMessage' import type { SdkModel } from '@renderer/types/sdk' import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils' import { abortCompletion, readyToAbort } from '@renderer/utils/abortController' @@ -476,7 +476,7 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 } else { const abortId = uuid() const signal = readyToAbort(abortId) - let chunkError + let streamError: ResponseError | undefined const params: StreamTextParams = { system: assistant.prompt, prompt: 'hi', @@ -495,19 +495,18 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 callType: 'check', onChunk: (chunk: Chunk) => { if (chunk.type === ChunkType.ERROR) { - chunkError = chunk.error + streamError = chunk.error } else { abortCompletion(abortId) } } } - // Try streaming check try { await ai.completions(model.id, params, config) } catch (e) { - if (!isAbortError(e) && !isAbortError(chunkError)) { - throw e + if (!isAbortError(e) && !isAbortError(streamError)) { + throw streamError ?? e } } } diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index 5ce96e4ec7..ef7179527d 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -234,6 +234,7 @@ export interface Response { error?: ResponseError } +// FIXME: Weak type safety. It may be a specific class instance which inherits Error in runtime. export type ResponseError = Record export interface MessageInputBaseParams { From 0097ca80e260c9b1068cc1594f86e924c510ec47 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 30 Nov 2025 18:39:47 +0800 Subject: [PATCH 16/18] docs: improve CLAUDE.md PR workflow guidelines (#11548) docs: update CLAUDE.md with PR workflow details Add critical section about Pull Request workflow requirements including reading the template, following all sections, never skipping, and proper formatting --- CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88ab1a116f..c96fc0e403 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,15 @@ This file provides guidance to AI coding assistants when working with code in th - **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications. - **Lint, test, and format before completion**: Coding tasks are only complete after running `yarn lint`, `yarn test`, and `yarn format` successfully. - **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`). -- **Follow PR template**: When submitting pull requests, follow the template in `.github/pull_request_template.md` to ensure complete context and documentation. + +## Pull Request Workflow (CRITICAL) + +When creating a Pull Request, you MUST: + +1. **Read the PR template first**: Always read `.github/pull_request_template.md` before creating the PR +2. **Follow ALL template sections**: Structure the `--body` parameter to include every section from the template +3. **Never skip sections**: Include all sections even if marking them as N/A or "None" +4. **Use proper formatting**: Match the template's markdown structure exactly (headings, checkboxes, code blocks) ## Development Commands From b3a58ec321b22551a70532c8f2173564bf137096 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 30 Nov 2025 19:57:44 +0800 Subject: [PATCH 17/18] chore: update release notes for v1.7.1 --- electron-builder.yml | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index d75cd5855d..5e63e7231d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,9 +134,9 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - A New Era of Intelligence with Cherry Studio 1.7.0 + A New Era of Intelligence with Cherry Studio 1.7.1 - Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. + Today we're releasing Cherry Studio 1.7.1 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. 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. @@ -187,9 +187,9 @@ releaseInfo: The Agent Era is here. We can't wait to see what you'll create. - Cherry Studio 1.7.0:开启智能新纪元 + Cherry Studio 1.7.1:开启智能新纪元 - 今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。 + 今天,我们正式发布 Cherry Studio 1.7.1 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。 多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。 diff --git a/package.json b/package.json index 52c57b886f..17be71ee59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0", + "version": "1.7.1", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 08d450971467605886294ba2cf228d5269c39774 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 1 Dec 2025 09:12:05 +0800 Subject: [PATCH 18/18] refactor: replace CacheService with cacheService for consistent caching in Inputbar components Updated the Inputbar and AgentSessionInputbar components to use the new cacheService for managing draft persistence and mentioned models, ensuring consistency across the application. --- .../src/pages/home/Inputbar/AgentSessionInputbar.tsx | 6 +++--- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx index 2dbcd04067..2111822641 100644 --- a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx @@ -1,3 +1,4 @@ +import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel' import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' @@ -9,7 +10,6 @@ import { getModel } from '@renderer/hooks/useModel' import { useSettings } from '@renderer/hooks/useSettings' import { useTextareaResize } from '@renderer/hooks/useTextareaResize' import { useTimer } from '@renderer/hooks/useTimer' -import { CacheService } from '@renderer/services/CacheService' import { pauseTrace } from '@renderer/services/SpanManagerService' import { estimateUserPromptUsage } from '@renderer/services/TokenService' import { useAppDispatch, useAppSelector } from '@renderer/store' @@ -169,8 +169,8 @@ const AgentSessionInputbarInner: FC = ({ assistant, agentId, session setText, isEmpty: inputEmpty } = useInputText({ - initialValue: CacheService.get(draftCacheKey) ?? '', - onChange: (value) => CacheService.set(draftCacheKey, value, DRAFT_CACHE_TTL) + initialValue: cacheService.get(draftCacheKey) ?? '', + onChange: (value) => cacheService.set(draftCacheKey, value, DRAFT_CACHE_TTL) }) const { textareaRef, diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index e029599d05..9cd4077caa 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -1,3 +1,4 @@ +import { cacheService } from '@data/CacheService' import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import { @@ -23,7 +24,6 @@ import { useInputbarToolsState } from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider' import { getDefaultTopic } from '@renderer/services/AssistantService' -import { CacheService } from '@renderer/services/CacheService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' @@ -57,7 +57,7 @@ const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours const getMentionedModelsCacheKey = (assistantId: string) => `inputbar-mentioned-models-${assistantId}` const getValidatedCachedModels = (assistantId: string): Model[] => { - const cached = CacheService.get(getMentionedModelsCacheKey(assistantId)) + const cached = cacheService.get(getMentionedModelsCacheKey(assistantId)) if (!Array.isArray(cached)) return [] return cached.filter((model) => model?.id && model?.name) } @@ -135,8 +135,8 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se const { setCouldAddImageFile } = useInputbarToolsInternalDispatch() const { text, setText } = useInputText({ - initialValue: CacheService.get(INPUTBAR_DRAFT_CACHE_KEY) ?? '', - onChange: (value) => CacheService.set(INPUTBAR_DRAFT_CACHE_KEY, value, DRAFT_CACHE_TTL) + initialValue: cacheService.get(INPUTBAR_DRAFT_CACHE_KEY) ?? '', + onChange: (value) => cacheService.set(INPUTBAR_DRAFT_CACHE_KEY, value, DRAFT_CACHE_TTL) }) const { textareaRef, @@ -208,7 +208,7 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se }, [canAddImageFile, setCouldAddImageFile]) const onUnmount = useEffectEvent((id: string) => { - CacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL) + cacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL) }) useEffect(() => {