diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 3200140f77..2fd3cf1749 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -44,4 +44,4 @@ jobs: run: yarn build:check - name: Lint Check - run: yarn lint + run: yarn test:lint diff --git a/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch b/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch index d4381aa11c..e9ca84e6cd 100644 --- a/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch +++ b/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch @@ -65,11 +65,44 @@ index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01 await packager.info.emitArtifactBuildCompleted({ file: installerPath, updateInfo, +diff --git a/out/util/yarn.js b/out/util/yarn.js +index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644 +--- a/out/util/yarn.js ++++ b/out/util/yarn.js +@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) { + arch, + platform, + buildFromSource, ++ ignoreModules: config.excludeReBuildModules || undefined, + projectRootPath: projectDir, + mode: config.nativeRebuilder || "sequential", + disablePreGypCopy: true, diff --git a/scheme.json b/scheme.json -index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644 +index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644 --- a/scheme.json +++ b/scheme.json -@@ -1975,6 +1975,13 @@ +@@ -1825,6 +1825,20 @@ + "string" + ] + }, ++ "excludeReBuildModules": { ++ "anyOf": [ ++ { ++ "items": { ++ "type": "string" ++ }, ++ "type": "array" ++ }, ++ { ++ "type": "null" ++ } ++ ], ++ "description": "The modules to exclude from the rebuild." ++ }, + "executableArgs": { + "anyOf": [ + { +@@ -1975,6 +1989,13 @@ ], "description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing." }, @@ -83,7 +116,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "packageCategory": { "description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place", "type": [ -@@ -2327,6 +2334,13 @@ +@@ -2327,6 +2348,13 @@ "MacConfiguration": { "additionalProperties": false, "properties": { @@ -97,7 +130,28 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "additionalArguments": { "anyOf": [ { -@@ -2737,7 +2751,7 @@ +@@ -2527,6 +2555,20 @@ + "string" + ] + }, ++ "excludeReBuildModules": { ++ "anyOf": [ ++ { ++ "items": { ++ "type": "string" ++ }, ++ "type": "array" ++ }, ++ { ++ "type": "null" ++ } ++ ], ++ "description": "The modules to exclude from the rebuild." ++ }, + "executableName": { + "description": "The executable name. Defaults to `productName`.", + "type": [ +@@ -2737,7 +2779,7 @@ "type": "boolean" }, "minimumSystemVersion": { @@ -106,7 +160,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "type": [ "null", "string" -@@ -2959,6 +2973,13 @@ +@@ -2959,6 +3001,13 @@ "MasConfiguration": { "additionalProperties": false, "properties": { @@ -120,7 +174,28 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "additionalArguments": { "anyOf": [ { -@@ -3369,7 +3390,7 @@ +@@ -3159,6 +3208,20 @@ + "string" + ] + }, ++ "excludeReBuildModules": { ++ "anyOf": [ ++ { ++ "items": { ++ "type": "string" ++ }, ++ "type": "array" ++ }, ++ { ++ "type": "null" ++ } ++ ], ++ "description": "The modules to exclude from the rebuild." ++ }, + "executableName": { + "description": "The executable name. Defaults to `productName`.", + "type": [ +@@ -3369,7 +3432,7 @@ "type": "boolean" }, "minimumSystemVersion": { @@ -129,7 +204,28 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "type": [ "null", "string" -@@ -6507,6 +6528,13 @@ +@@ -6381,6 +6444,20 @@ + "string" + ] + }, ++ "excludeReBuildModules": { ++ "anyOf": [ ++ { ++ "items": { ++ "type": "string" ++ }, ++ "type": "array" ++ }, ++ { ++ "type": "null" ++ } ++ ], ++ "description": "The modules to exclude from the rebuild." ++ }, + "executableName": { + "description": "The executable name. Defaults to `productName`.", + "type": [ +@@ -6507,6 +6584,13 @@ "string" ] }, @@ -143,7 +239,28 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43eb "protocols": { "anyOf": [ { -@@ -7376,6 +7404,13 @@ +@@ -7153,6 +7237,20 @@ + "string" + ] + }, ++ "excludeReBuildModules": { ++ "anyOf": [ ++ { ++ "items": { ++ "type": "string" ++ }, ++ "type": "array" ++ }, ++ { ++ "type": "null" ++ } ++ ], ++ "description": "The modules to exclude from the rebuild." ++ }, + "executableName": { + "description": "The executable name. Defaults to `productName`.", + "type": [ +@@ -7376,6 +7474,13 @@ ], "description": "MAS (Mac Application Store) development options (`mas-dev` target)." }, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7f4a4e3a66..9b22ffc33b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -19,7 +19,13 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['@libsql/client', 'bufferutil', 'utf-8-validate'] + external: ['@libsql/client', 'bufferutil', 'utf-8-validate'], + output: { + // 彻底禁用代码分割 - 返回 null 强制单文件打包 + manualChunks: undefined, + // 内联所有动态导入,这是关键配置 + inlineDynamicImports: true + } }, sourcemap: process.env.NODE_ENV === 'development' }, diff --git a/scripts/after-pack.js b/scripts/after-pack.js index 073120e584..a764642308 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -36,6 +36,11 @@ exports.default = async function (context) { keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) } } + + if (platform === 'windows') { + fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true }) + fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true }) + } } /** diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index f956a0573f..c129e19115 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -21,10 +21,13 @@ export default abstract class BaseReranker { return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' } - let baseURL = this.base?.rerankBaseURL?.endsWith('/') - ? this.base.rerankBaseURL.slice(0, -1) - : this.base.rerankBaseURL - // 必须携带/v1,否则会404 + let baseURL = this.base.rerankBaseURL + + if (baseURL && baseURL.endsWith('/')) { + // `/` 结尾强制使用rerankBaseURL + return `${baseURL}rerank` + } + if (baseURL && !baseURL.endsWith('/v1')) { baseURL = `${baseURL}/v1` } diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 772c885a01..203a0d5f1c 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,11 +1,12 @@ import { isWin } from '@main/constant' import { locales } from '@main/utils/locales' -import { IpcChannel } from '@shared/IpcChannel' import { FeedUrl } from '@shared/config/constant' +import { IpcChannel } from '@shared/IpcChannel' import { UpdateInfo } from 'builder-util-runtime' import { app, BrowserWindow, dialog } from 'electron' import logger from 'electron-log' -import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater' +import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater' +import path from 'path' import icon from '../../../build/icon.png?asset' import { configManager } from './ConfigManager' @@ -56,6 +57,10 @@ export default class AppUpdater { logger.info('下载完成', releaseInfo) }) + if (isWin) { + ;(autoUpdater as NsisUpdater).installDirectory = path.dirname(app.getPath('exe')) + } + this.autoUpdater = autoUpdater } diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index ffbda737c6..29cf86399a 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -453,7 +453,7 @@ export class AnthropicAPIClient extends BaseApiClient< }) if (this.useSystemPromptForTools) { - systemPrompt = await buildSystemPrompt(systemPrompt, mcpTools) + systemPrompt = await buildSystemPrompt(systemPrompt, mcpTools, assistant) } const systemMessage: TextBlockParam | undefined = systemPrompt diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index bc848df7f9..1a67696e33 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -452,7 +452,7 @@ export class GeminiAPIClient extends BaseApiClient< }) if (this.useSystemPromptForTools) { - systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools) + systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant) } let messageContents: Content diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 07359c837f..f9c4372c7b 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -420,7 +420,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< }) if (this.useSystemPromptForTools) { - systemMessage.content = await buildSystemPrompt(systemMessage.content || '', mcpTools) + systemMessage.content = await buildSystemPrompt(systemMessage.content || '', mcpTools, assistant) } // 3. 处理用户消息 diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 0fdd65f709..a0f4d8077d 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -290,7 +290,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< }) if (this.useSystemPromptForTools) { - systemMessageInput.text = await buildSystemPrompt(systemMessageInput.text || '', mcpTools) + systemMessageInput.text = await buildSystemPrompt(systemMessageInput.text || '', mcpTools, assistant) } systemMessageContent.push(systemMessageInput) systemMessage.content = systemMessageContent diff --git a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts index 560a9e0aac..324382b918 100644 --- a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts @@ -97,11 +97,21 @@ export const ImageGenerationMiddleware: CompletionsMiddleware = ) } - const b64_json_array = response.data?.map((item) => `data:image/png;base64,${item.b64_json}`) || [] + let imageType: 'url' | 'base64' = 'base64' + const imageList = + response.data?.reduce((acc: string[], image) => { + if (image.url) { + acc.push(image.url) + imageType = 'url' + } else if (image.b64_json) { + acc.push(`data:image/png;base64,${image.b64_json}`) + } + return acc + }, []) || [] enqueue({ type: ChunkType.IMAGE_COMPLETE, - image: { type: 'base64', images: b64_json_array } + image: { type: imageType, images: imageList } }) const usage = (response as any).usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 1c9b8a21f2..9974f19596 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -112,21 +112,50 @@ ul { } .bubble { + background-color: var(--chat-background); + #chat-main { + background-color: var(--chat-background); + } + #messages { + background-color: var(--chat-background); + } + #inputbar { + margin: -5px 15px 15px 15px; + background: var(--color-background); + } .system-prompt { background-color: var(--chat-background-assistant); } .message-content-container { margin: 5px 0; border-radius: 8px; + padding: 0.5rem 1rem; } + + .block-wrapper { + display: flow-root; + } + + .block-wrapper:last-child > *:last-child { + margin-bottom: 0; + } + + .message-content-container > *:last-child { + margin-bottom: 0; + } + .message-thought-container { margin-top: 8px; } + .message-user { - .message-content-container { - margin: 5px 0; - border-radius: 8px 0 8px 8px; - padding: 10px 15px 0 15px; + color: var(--chat-text-user); + .message-content-container-user .anticon { + color: var(--chat-text-user) !important; + } + + .markdown { + color: var(--chat-text-user); } } .group-grid-container.horizontal, @@ -147,6 +176,12 @@ ul { code { color: var(--color-text); } + .markdown { + display: flow-root; + *:last-child { + margin-bottom: 0; + } + } } .lucide { diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 033de18ab4..2c6d6655f5 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -334,6 +334,7 @@ mjx-container { .cm-gutters { line-height: 1.6; + border-right: none; } .cm-content { diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx index 0928df8d68..d461b2899c 100644 --- a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -22,6 +22,7 @@ const MermaidPreview: React.FC = ({ children, setTools }) => { const diagramId = useRef(`mermaid-${nanoid(6)}`).current const [error, setError] = useState(null) const [isRendering, setIsRendering] = useState(false) + const [isVisible, setIsVisible] = useState(true) // 使用通用图像工具 const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, { @@ -75,10 +76,55 @@ const MermaidPreview: React.FC = ({ children, setTools }) => { [renderMermaid] ) + /** + * 监听可见性变化,用于触发重新渲染。 + * 这是为了解决 `MessageGroup` 组件的 `fold` 布局中被 `display: none` 隐藏的图标无法正确渲染的问题。 + * 监听时向上遍历到第一个有 `fold` className 的父节点为止(也就是目前的 `MessageWrapper`)。 + * FIXME: 将来 mermaid-js 修复此问题后可以移除这里的相关逻辑。 + */ + useEffect(() => { + if (!mermaidRef.current) return + + const checkVisibility = () => { + const element = mermaidRef.current + if (!element) return + + const currentlyVisible = element.offsetParent !== null + setIsVisible(currentlyVisible) + } + + // 初始检查 + checkVisibility() + + const observer = new MutationObserver(() => { + checkVisibility() + }) + + let targetElement = mermaidRef.current.parentElement + while (targetElement) { + observer.observe(targetElement, { + attributes: true, + attributeFilter: ['class', 'style'] + }) + + if (targetElement.className?.includes('fold')) { + break + } + + targetElement = targetElement.parentElement + } + + return () => { + observer.disconnect() + } + }, []) + // 触发渲染 useEffect(() => { if (isLoadingMermaid) return + if (mermaidRef.current?.offsetParent === null) return + if (children) { setIsRendering(true) debouncedRender(children) @@ -90,7 +136,7 @@ const MermaidPreview: React.FC = ({ children, setTools }) => { return () => { debouncedRender.cancel() } - }, [children, isLoadingMermaid, debouncedRender]) + }, [children, isLoadingMermaid, debouncedRender, isVisible]) const isLoading = isLoadingMermaid || isRendering diff --git a/src/renderer/src/components/__tests__/MermaidPreview.test.tsx b/src/renderer/src/components/__tests__/MermaidPreview.test.tsx new file mode 100644 index 0000000000..3f76fc5eb8 --- /dev/null +++ b/src/renderer/src/components/__tests__/MermaidPreview.test.tsx @@ -0,0 +1,221 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +import MermaidPreview from '../CodeBlockView/MermaidPreview' + +const mocks = vi.hoisted(() => ({ + useMermaid: vi.fn(), + usePreviewToolHandlers: vi.fn(), + usePreviewTools: vi.fn() +})) + +// Mock hooks +vi.mock('@renderer/hooks/useMermaid', () => ({ + useMermaid: () => mocks.useMermaid() +})) + +vi.mock('@renderer/components/CodeToolbar', () => ({ + usePreviewToolHandlers: () => mocks.usePreviewToolHandlers(), + usePreviewTools: () => mocks.usePreviewTools() +})) + +// Mock nanoid +vi.mock('@reduxjs/toolkit', () => ({ + nanoid: () => 'test-id-123456' +})) + +// Mock lodash debounce +vi.mock('lodash', async () => { + const actual = await import('lodash') + return { + ...actual, + debounce: vi.fn((fn) => { + const debounced = (...args: any[]) => fn(...args) + debounced.cancel = vi.fn() + return debounced + }) + } +}) + +// Mock antd components +vi.mock('antd', () => ({ + Flex: ({ children, vertical, ...props }: any) => ( +
+ {children} +
+ ), + Spin: ({ children, spinning, indicator }: any) => ( +
+ {spinning && indicator} + {children} +
+ ) +})) + +describe('MermaidPreview', () => { + const mockMermaid = { + parse: vi.fn(), + render: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + + mocks.useMermaid.mockReturnValue({ + mermaid: mockMermaid, + isLoading: false, + error: null + }) + + mocks.usePreviewToolHandlers.mockReturnValue({ + handleZoom: vi.fn(), + handleCopyImage: vi.fn(), + handleDownload: vi.fn() + }) + + mocks.usePreviewTools.mockReturnValue({}) + + mockMermaid.parse.mockResolvedValue(true) + mockMermaid.render.mockResolvedValue({ + svg: 'test diagram' + }) + + // Mock MutationObserver + global.MutationObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + takeRecords: vi.fn() + })) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('visibility detection', () => { + it('should not render mermaid when element has display: none', async () => { + const mermaidCode = 'graph TD\nA-->B' + + const { container } = render({mermaidCode}) + + // Mock offsetParent to be null (simulating display: none) + const mermaidElement = container.querySelector('.mermaid') + if (mermaidElement) { + Object.defineProperty(mermaidElement, 'offsetParent', { + get: () => null, + configurable: true + }) + } + + // Re-render to trigger the effect + render({mermaidCode}) + + // Should not call mermaid render when offsetParent is null + expect(mockMermaid.render).not.toHaveBeenCalled() + + const svgElement = mermaidElement?.querySelector('svg.flowchart') + expect(svgElement).not.toBeInTheDocument() + }) + + it('should setup MutationObserver to monitor parent elements', () => { + const mermaidCode = 'graph TD\nA-->B' + + render({mermaidCode}) + + expect(global.MutationObserver).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should observe parent elements up to fold className', () => { + const mermaidCode = 'graph TD\nA-->B' + + // Create a DOM structure that simulates MessageGroup fold layout + const foldContainer = document.createElement('div') + foldContainer.className = 'fold selected' + + const messageWrapper = document.createElement('div') + messageWrapper.className = 'message-wrapper' + + const codeBlock = document.createElement('div') + codeBlock.className = 'code-block' + + foldContainer.appendChild(messageWrapper) + messageWrapper.appendChild(codeBlock) + document.body.appendChild(foldContainer) + + render({mermaidCode}, { + container: codeBlock + }) + + const observerInstance = (global.MutationObserver as Mock).mock.results[0]?.value + expect(observerInstance.observe).toHaveBeenCalled() + + // Cleanup + document.body.removeChild(foldContainer) + }) + + it('should trigger re-render when visibility changes from hidden to visible', async () => { + const mermaidCode = 'graph TD\nA-->B' + + const { container, rerender } = render({mermaidCode}) + + const mermaidElement = container.querySelector('.mermaid') + + // Initially hidden (offsetParent is null) + Object.defineProperty(mermaidElement, 'offsetParent', { + get: () => null, + configurable: true + }) + + // Clear previous calls + mockMermaid.render.mockClear() + + // Re-render with hidden state + rerender({mermaidCode}) + + // Should not render when hidden + expect(mockMermaid.render).not.toHaveBeenCalled() + + // Now make it visible + Object.defineProperty(mermaidElement, 'offsetParent', { + get: () => document.body, + configurable: true + }) + + // Simulate MutationObserver callback + const observerCallback = (global.MutationObserver as Mock).mock.calls[0][0] + act(() => { + observerCallback([]) + }) + + // Re-render to trigger visibility change effect + rerender({mermaidCode}) + + await waitFor(() => { + expect(mockMermaid.render).toHaveBeenCalledWith('mermaid-test-id-123456', mermaidCode, expect.any(Object)) + + const svgElement = mermaidElement?.querySelector('svg.flowchart') + expect(svgElement).toBeInTheDocument() + expect(svgElement).toHaveClass('flowchart') + }) + }) + + it('should handle mermaid loading state', () => { + mocks.useMermaid.mockReturnValue({ + mermaid: mockMermaid, + isLoading: true, + error: null + }) + + const mermaidCode = 'graph TD\nA-->B' + + render({mermaidCode}) + + // Should not render when mermaid is loading + expect(mockMermaid.render).not.toHaveBeenCalled() + + // Should show loading state + expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'true') + }) + }) +}) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 78f4ff3d0b..3c849cc854 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -145,6 +145,7 @@ import YoudaoLogo from '@renderer/assets/images/providers/netease-youdao.svg' import NomicLogo from '@renderer/assets/images/providers/nomic.png' import { getProviderByModel } from '@renderer/services/AssistantService' import { Model } from '@renderer/types' +import { getBaseModelName } from '@renderer/utils' import OpenAI from 'openai' import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts' @@ -2484,9 +2485,10 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { return false } + const baseName = getBaseModelName(model.id, '/').toLowerCase() + return ( - model.id.toLowerCase().startsWith('qwen3') || - model.id.toLowerCase().startsWith('qwen/qwen3') || + baseName.startsWith('qwen3') || [ 'qwen-plus-latest', 'qwen-plus-0428', @@ -2494,7 +2496,7 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { 'qwen-turbo-latest', 'qwen-turbo-0428', 'qwen-turbo-2025-04-28' - ].includes(model.id.toLowerCase()) + ].includes(baseName) ) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index fa5586db37..4f4a099577 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Enter prompt", "add.prompt.variables.tip": { "title": "Available variables", - "content": "{{date}}:\tDate\n{{time}}:\tTime\n{{datetime}}:\tDate and time\n{{system}}:\tOperating system\n{{arch}}:\tCPU architecture\n{{language}}:\tLanguage\n{{model_name}}:\tModel name" + "content": "{{date}}:\tDate\n{{time}}:\tTime\n{{datetime}}:\tDate and time\n{{system}}:\tOperating system\n{{arch}}:\tCPU architecture\n{{language}}:\tLanguage\n{{model_name}}:\tModel name\n{{username}}:\tUsername" }, "add.title": "Create Agent", "import": { @@ -1966,6 +1966,7 @@ }, "actions": { "title": "Actions", + "custom": "Custom Action", "reset": { "button": "Reset", "tooltip": "Reset to default actions. Custom actions will not be deleted.", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9ae56ed03f..b43378189a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "プロンプトを入力", "add.prompt.variables.tip": { "title": "利用可能な変数", - "content": "{{date}}:\t日付\n{{time}}:\t時間\n{{datetime}}:\t日付と時間\n{{system}}:\tオペレーティングシステム\n{{arch}}:\tCPUアーキテクチャ\n{{language}}:\t言語\n{{model_name}}:\tモデル名" + "content": "{{date}}:\t日付\n{{time}}:\t時間\n{{datetime}}:\t日付と時間\n{{system}}:\tオペレーティングシステム\n{{arch}}:\tCPUアーキテクチャ\n{{language}}:\t言語\n{{model_name}}:\tモデル名\n{{username}}:\tユーザー名" }, "add.title": "エージェントを作成", "import": { @@ -1966,6 +1966,7 @@ }, "actions": { "title": "機能設定", + "custom": "カスタム機能", "reset": { "button": "リセット", "tooltip": "デフォルト機能にリセット(カスタム機能は保持)", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 52289a23bd..9183c1cb9c 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Введите промпт", "add.prompt.variables.tip": { "title": "Доступные переменные", - "content": "{{date}}:\tДата\n{{time}}:\tВремя\n{{datetime}}:\tДата и время\n{{system}}:\tОперационная система\n{{arch}}:\tАрхитектура процессора\n{{language}}:\tЯзык\n{{model_name}}:\tНазвание модели" + "content": "{{date}}:\tДата\n{{time}}:\tВремя\n{{datetime}}:\tДата и время\n{{system}}:\tОперационная система\n{{arch}}:\tАрхитектура процессора\n{{language}}:\tЯзык\n{{model_name}}:\tНазвание модели\n{{username}}:\tИмя пользователя" }, "add.title": "Создать агента", "delete.popup.content": "Вы уверены, что хотите удалить этого агента?", @@ -1966,6 +1966,7 @@ }, "actions": { "title": "Действия", + "custom": "Пользовательское действие", "reset": { "button": "Сбросить", "tooltip": "Сбросить стандартные действия. Пользовательские останутся.", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c484932b43..8563925d3c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "输入提示词", "add.prompt.variables.tip": { "title": "可用的变量", - "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称" + "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名" }, "add.title": "创建智能体", "import": { @@ -1931,7 +1931,7 @@ "selected": "划词", "selected_note": "划词后立即显示工具栏", "ctrlkey": "Ctrl 键", - "ctrlkey_note": "划词后,再 按住 Ctrl键,才显示工具栏", + "ctrlkey_note": "划词后,再 长按 Ctrl键,才显示工具栏", "shortcut": "快捷键", "shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。", "shortcut_link": "前往快捷键设置" @@ -1966,6 +1966,7 @@ }, "actions": { "title": "功能", + "custom": "自定义功能", "reset": { "button": "重置", "tooltip": "重置为默认功能,自定义功能不会被删除", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 43b327d811..d00ba2625f 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "輸入提示詞", "add.prompt.variables.tip": { "title": "可用的變數", - "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱" + "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱" }, "add.title": "建立智慧代理人", "import": { @@ -1965,6 +1965,7 @@ }, "actions": { "title": "功能", + "custom": "自訂功能", "reset": { "button": "重設", "tooltip": "重設為預設功能,自訂功能不會被刪除", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 5812ee4678..63d41a3c4a 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Εισαγάγετε φράση προκαλέσεως", "add.prompt.variables.tip": { "title": "Διαθέσιμες μεταβλητές", - "content": "{{date}}:\tΗμερομηνία\n{{time}}:\tΏρα\n{{datetime}}:\tΗμερομηνία και ώρα\n{{system}}:\tΛειτουργικό σύστημα\n{{arch}}:\tΑρχιτεκτονική CPU\n{{language}}:\tΓλώσσα\n{{model_name}}:\tΌνομα μοντέλου" + "content": "{{date}}:\tΗμερομηνία\n{{time}}:\tΏρα\n{{datetime}}:\tΗμερομηνία και ώρα\n{{system}}:\tΛειτουργικό σύστημα\n{{arch}}:\tΑρχιτεκτονική CPU\n{{language}}:\tΓλώσσα\n{{model_name}}:\tΌνομα μοντέλου\n{{username}}:\tΌνομα χρήστη" }, "add.title": "Δημιουργία νέου ειδικού", "delete.popup.content": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον ειδικό;", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ffdb1043cc..9c1fc85026 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Ingrese la palabra clave", "add.prompt.variables.tip": { "title": "Variables disponibles", - "content": "{{date}}:\tFecha\n{{time}}:\tHora\n{{datetime}}:\tFecha y hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitectura de CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNombre del modelo" + "content": "{{date}}:\tFecha\n{{time}}:\tHora\n{{datetime}}:\tFecha y hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitectura de CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNombre del modelo\n{{username}}:\tNombre de usuario" }, "add.title": "Crear agente inteligente", "delete.popup.content": "¿Está seguro de que desea eliminar este agente inteligente?", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 1b878d83f3..fdf5ba60ab 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Entrer le mot-clé", "add.prompt.variables.tip": { "title": "Variables disponibles", - "content": "{{date}}:\tDate\n{{time}}:\tHeure\n{{datetime}}:\tDate et heure\n{{system}}:\tSystème d'exploitation\n{{arch}}:\tArchitecture du processeur\n{{language}}:\tLangue\n{{model_name}}:\tNom du modèle" + "content": "{{date}}:\tDate\n{{time}}:\tHeure\n{{datetime}}:\tDate et heure\n{{system}}:\tSystème d'exploitation\n{{arch}}:\tArchitecture du processeur\n{{language}}:\tLangue\n{{model_name}}:\tNom du modèle\n{{username}}:\tNom d'utilisateur" }, "add.title": "Créer un agent intelligent", "delete.popup.content": "Êtes-vous sûr de vouloir supprimer cet agent intelligent ?", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index d15c22528a..9dd700d466 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "Digite o Prompt", "add.prompt.variables.tip": { "title": "Variáveis disponíveis", - "content": "{{date}}:\tData\n{{time}}:\tHora\n{{datetime}}:\tData e hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitetura da CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNome do modelo" + "content": "{{date}}:\tData\n{{time}}:\tHora\n{{datetime}}:\tData e hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitetura da CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNome do modelo\n{{username}}:\tNome de utilizador" }, "add.title": "Criar Agente Inteligente", "delete.popup.content": "Tem certeza de que deseja excluir este agente inteligente?", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index c8ebcac5c5..cf227e517d 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -191,16 +191,16 @@ const Inputbar: FC = () => { ) } - if (topic.prompt) { - assistant.prompt = assistant.prompt ? `${assistant.prompt}\n${topic.prompt}` : topic.prompt - } + const assistantWithTopicPrompt = topic.prompt + ? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` } + : assistant baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage) const { message, blocks } = getUserMessage(baseUserMessage) currentMessageId.current = message.id - dispatch(_sendMessage(message, blocks, assistant, topic.id)) + dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id)) // Clear input setText('') @@ -309,7 +309,7 @@ const Inputbar: FC = () => { }, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef]) const handleKeyDown = (event: React.KeyboardEvent) => { - const isEnterPressed = event.keyCode == 13 + const isEnterPressed = event.key === 'Enter' // 按下Tab键,自动选中${xxx} if (event.key === 'Tab' && inputFocus) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx index 046c8395ad..8cecea1ad8 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx @@ -1,6 +1,6 @@ -import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import ImageViewer from '@renderer/components/ImageViewer' -import { type ImageMessageBlock } from '@renderer/types/newMessage' +import { type ImageMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage' +import { Skeleton } from 'antd' import React from 'react' import styled from 'styled-components' @@ -9,23 +9,26 @@ interface Props { } const ImageBlock: React.FC = ({ block }) => { - if (block.status !== 'success') return - const images = block.metadata?.generateImageResponse?.images?.length - ? block.metadata?.generateImageResponse?.images - : block?.file?.path - ? [`file://${block?.file?.path}`] - : [] - return ( - - {images.map((src, index) => ( - - ))} - - ) + if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.PROCESSING) + return + if (block.status === MessageBlockStatus.SUCCESS) { + const images = block.metadata?.generateImageResponse?.images?.length + ? block.metadata?.generateImageResponse?.images + : block?.file?.path + ? [`file://${block?.file?.path}`] + : [] + return ( + + {images.map((src, index) => ( + + ))} + + ) + } else return null } const Container = styled.div` display: flex; @@ -33,5 +36,4 @@ const Container = styled.div` gap: 10px; margin-top: 8px; ` - export default React.memo(ImageBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index a7c1960291..b469f03264 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -42,6 +42,7 @@ const blockWrapperVariants = { const AnimatedBlockWrapper: React.FC = ({ children, enableAnimation }) => { return ( @@ -85,7 +86,7 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { const groupKey = block.map((imageBlock) => imageBlock.id).join('-') return ( - + {block.map((imageBlock) => ( ))} @@ -161,17 +162,16 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { export default React.memo(MessageBlockRenderer) -const ImageBlockGroup = styled.div<{ $columns: number }>` +const ImageBlockGroup = styled.div` display: grid; - grid-template-columns: repeat(${({ $columns }) => Math.min(3, $columns)}, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; - width: 100%; max-width: 960px; - > * { + /* > * { min-width: 200px; - } + } */ @media (min-width: 1536px) { - grid-template-columns: repeat(4, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); max-width: 1280px; > * { min-width: 250px; diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 5e1b57205c..ee48f8bff3 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -81,14 +81,17 @@ const MessageItem: FC = ({ const handleEditResend = useCallback( async (blocks: MessageBlock[]) => { + const assistantWithTopicPrompt = topic.prompt + ? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` } + : assistant try { - await resendUserMessageWithEdit(message, blocks, assistant) + await resendUserMessageWithEdit(message, blocks, assistantWithTopicPrompt) stopEditing() } catch (error) { console.error('Failed to resend message:', error) } }, - [message, resendUserMessageWithEdit, assistant, stopEditing] + [message, resendUserMessageWithEdit, assistant, stopEditing, topic.prompt] ) const handleEditCancel = useCallback(() => { diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index cada57a7e3..ee5c189e5d 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -40,7 +40,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const model = assistant.model || assistant.defaultModel const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize } = useSettings() + const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut } = useSettings() const { t } = useTranslation() const textareaRef = useRef(null) const attachmentButtonRef = useRef(null) @@ -137,9 +137,8 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) } } - const handleClick = async (withResend?: boolean) => { - if (isProcessing) return - setIsProcessing(true) + // 处理编辑区块并上传文件 + const processEditedBlocks = async () => { const updatedBlocks = [...editedBlocks] if (files && files.length) { const uploadedFiles = await FileManager.uploadFiles(files) @@ -153,10 +152,48 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) } }) } - if (withResend) { - onResend(updatedBlocks) - } else { - onSave(updatedBlocks) + return updatedBlocks + } + + const handleSave = async () => { + if (isProcessing) return + setIsProcessing(true) + const updatedBlocks = await processEditedBlocks() + onSave(updatedBlocks) + } + + const handleResend = async () => { + if (isProcessing) return + setIsProcessing(true) + const updatedBlocks = await processEditedBlocks() + onResend(updatedBlocks) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (message.role !== 'user') { + return + } + + const isEnterPressed = event.key === 'Enter' + + if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') { + handleResend() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) { + handleResend() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) { + handleResend() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) { + handleResend() + return event.preventDefault() } } @@ -175,6 +212,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) handleTextChange(block.id, e.target.value) resizeTextArea() }} + onKeyDown={handleKeyDown} autoFocus contextMenu="true" spellCheck={false} @@ -240,13 +278,13 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) - handleClick()}> + {message.role === 'user' && ( - handleClick(true)}> + diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index e27193912d..0e7ab8e25c 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -121,10 +121,13 @@ const MessageMenubar: FC = (props) => { const handleResendUserMessage = useCallback( async (messageUpdate?: Message) => { if (!loading) { - await resendMessage(messageUpdate ?? message, assistant) + const assistantWithTopicPrompt = topic.prompt + ? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` } + : assistant + await resendMessage(messageUpdate ?? message, assistantWithTopicPrompt) } }, - [assistant, loading, message, resendMessage] + [assistant, loading, message, resendMessage, topic.prompt] ) const { startEditing } = useMessageEditing() @@ -319,8 +322,12 @@ const MessageMenubar: FC = (props) => { // const _message = resetAssistantMessage(message, selectedModel) // editMessage(message.id, { ..._message }) // REMOVED + const assistantWithTopicPrompt = topic.prompt + ? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` } + : assistant + // Call the function from the hook - regenerateAssistantMessage(message, assistant) + regenerateAssistantMessage(message, assistantWithTopicPrompt) } const onMentionModel = async (e: React.MouseEvent) => { @@ -397,7 +404,8 @@ const MessageMenubar: FC = (props) => { menu={{ style: { maxHeight: 250, - overflowY: 'auto' + overflowY: 'auto', + backgroundClip: 'border-box' }, items: [ ...TranslateLanguageOptions.map((item) => ({ diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 2a95d3090e..51bdd59e58 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -199,7 +199,6 @@ const Topics: FC = ({ style }) => { if (summaryText) { const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false } updateTopic(updatedTopic) - topic.id === activeTopic.id && setActiveTopic(updatedTopic) } else { window.message?.error(t('message.error.fetchTopicName')) } @@ -223,7 +222,6 @@ const Topics: FC = ({ style }) => { if (name && topic?.name !== name) { const updatedTopic = { ...topic, name, isNameManuallyEdited: true } updateTopic(updatedTopic) - topic.id === activeTopic.id && setActiveTopic(updatedTopic) } } }, diff --git a/src/renderer/src/pages/mcp-servers/SyncServersPopup.tsx b/src/renderer/src/pages/mcp-servers/SyncServersPopup.tsx index f25a1c8735..ee86520144 100644 --- a/src/renderer/src/pages/mcp-servers/SyncServersPopup.tsx +++ b/src/renderer/src/pages/mcp-servers/SyncServersPopup.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components' import { getModelScopeToken, saveModelScopeToken, syncModelScopeServers } from './modelscopeSyncUtils' import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './providers/tokenflux' +import { getTokenLanYunToken, LANYUN_KEY_HOST, saveTokenLanYunToken, syncTokenLanYunServers } from './providers/lanyun' // Provider configuration interface interface ProviderConfig { @@ -45,6 +46,17 @@ const providers: ProviderConfig[] = [ getToken: getTokenFluxToken, saveToken: saveTokenFluxToken, syncServers: syncTokenFluxServers + }, + { + key: 'lanyun', + name: '蓝耘科技', + description: '蓝耘科技云平台 MCP 服务', + discoverUrl: 'https://mcp.lanyun.net', + apiKeyUrl: LANYUN_KEY_HOST, + tokenFieldName: 'tokenLanyunToken', + getToken: getTokenLanYunToken, + saveToken: saveTokenLanYunToken, + syncServers: syncTokenLanYunServers } ] diff --git a/src/renderer/src/pages/mcp-servers/providers/lanyun.ts b/src/renderer/src/pages/mcp-servers/providers/lanyun.ts new file mode 100644 index 0000000000..d8aac2fa9c --- /dev/null +++ b/src/renderer/src/pages/mcp-servers/providers/lanyun.ts @@ -0,0 +1,178 @@ +import type { MCPServer } from '@renderer/types' +import i18next from 'i18next' + +// Token storage constants and utilities +const TOKEN_STORAGE_KEY = 'tokenLanyunToken' +export const TOKENLANYUN_HOST = 'https://mcp.lanyun.net' +export const LANYUN_MCP_HOST = TOKENLANYUN_HOST + '/mcp/manager/selectListByApiKey' +export const LANYUN_KEY_HOST = TOKENLANYUN_HOST + '/#/manage/apiKey' + +export const saveTokenLanYunToken = (token: string): void => { + localStorage.setItem(TOKEN_STORAGE_KEY, token) +} + +export const getTokenLanYunToken = (): string | null => { + return localStorage.getItem(TOKEN_STORAGE_KEY) +} + +export const clearTokenLanYunToken = (): void => { + localStorage.removeItem(TOKEN_STORAGE_KEY) +} + +export const hasTokenLanYunToken = (): boolean => { + return !!getTokenLanYunToken() +} + +interface TokenLanYunServer { + id: string + /** + * locales 字段用于存储多语言信息。 + * 其中 key(lang)为语言代码(如 'zh', 'en'), + * value 为该语言下的 name 和 description。 + * 例如: + * { + * "zh": { name: "文档处理工具", description: "..." }, + * "en": { name: "Document Processor", description: "..." } + * } + */ + locales?: { + [lang: string]: { + description?: string + name?: string + } + } + chineseName?: string + description?: string + operationalUrls?: { url: string }[] + tags?: string[] + logoUrl?: string +} + +interface TokenLanYunSyncResult { + success: boolean + message: string + addedServers: MCPServer[] + errorDetails?: string +} + +// Function to fetch and process TokenLanYun servers +export const syncTokenLanYunServers = async ( + token: string, + existingServers: MCPServer[] +): Promise => { + const t = i18next.t + + try { + const response = await fetch(LANYUN_MCP_HOST, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + clearTokenLanYunToken() + return { + success: false, + message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), + addedServers: [] + } + } + + // Handle server errors + if (response.status === 500 || !response.ok) { + return { + success: false, + message: t('settings.mcp.sync.error'), + addedServers: [], + errorDetails: `Status: ${response.status}` + } + } + + // Process successful response + const data = await response.json() + if (data.code === 401) { + return { + success: false, + message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), + addedServers: [], + errorDetails: `Status: ${response.status}` + } + } + if (data.code === 500) { + return { + success: false, + message: t('settings.mcp.sync.error'), + addedServers: [], + errorDetails: `Status: ${response.status}` + } + } + + const servers: TokenLanYunServer[] = data.data || [] + + if (servers.length === 0) { + return { + success: true, + message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), + addedServers: [] + } + } + + // Transform Token servers to MCP servers format + const addedServers: MCPServer[] = [] + console.log('TokenLanYun servers:', servers) + for (const server of servers) { + try { + if (!server.operationalUrls?.[0]?.url) continue + + // If any existing server id contains '@lanyun', clear them before adding new ones + // if (existingServers.some((s) => s.id.startsWith('@lanyun'))) { + // for (let i = existingServers.length - 1; i >= 0; i--) { + // if (existingServers[i].id.startsWith('@lanyun')) { + // existingServers.splice(i, 1) + // } + // } + // } + // Skip if server already exists after clearing + if (existingServers.some((s) => s.id === `@lanyun/${server.id}`)) continue + + const mcpServer: MCPServer = { + id: `@lanyun/${server.id}`, + name: + server.chineseName || server.locales?.zh?.name || server.locales?.en?.name || `LanYun Server ${server.id}`, + description: server.description || '', + type: 'sse', + baseUrl: server.operationalUrls[0].url, + command: '', + args: [], + env: {}, + isActive: true, + provider: '蓝耘科技', + providerUrl: server.operationalUrls[0].url, + logoUrl: server.logoUrl || '', + tags: server.tags ?? (server.chineseName ? [server.chineseName] : []) + } + + addedServers.push(mcpServer) + } catch (err) { + console.error('Error processing LanYun server:', err) + } + } + + return { + success: true, + message: t('settings.mcp.sync.success', { count: addedServers.length }), + addedServers + } + } catch (error) { + console.error('TokenLanyun sync error:', error) + return { + success: false, + message: t('settings.mcp.sync.error'), + addedServers: [], + errorDetails: String(error) + } + } +} diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index c57ebedce5..316bb5a999 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -1,4 +1,5 @@ import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import AiProvider from '@renderer/aiCore' import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg' import { NavbarCenter, NavbarMain, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' @@ -11,7 +12,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import AiProvider from '@renderer/aiCore' import FileManager from '@renderer/services/FileManager' import { translateText } from '@renderer/services/TranslateService' import { useAppDispatch } from '@renderer/store' diff --git a/src/renderer/src/pages/paintings/SiliconPage.tsx b/src/renderer/src/pages/paintings/SiliconPage.tsx index e9bd959bde..a519e63cc7 100644 --- a/src/renderer/src/pages/paintings/SiliconPage.tsx +++ b/src/renderer/src/pages/paintings/SiliconPage.tsx @@ -1,4 +1,5 @@ import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import AiProvider from '@renderer/aiCore' import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg' import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg' import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg' @@ -16,7 +17,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import AiProvider from '@renderer/aiCore' import { getProviderByModel } from '@renderer/services/AssistantService' import FileManager from '@renderer/services/FileManager' import { translateText } from '@renderer/services/TranslateService' diff --git a/src/renderer/src/pages/settings/ModelSettings/TopicNamingModalPopup.tsx b/src/renderer/src/pages/settings/ModelSettings/TopicNamingModalPopup.tsx index 54f22f3820..3e28bd3239 100644 --- a/src/renderer/src/pages/settings/ModelSettings/TopicNamingModalPopup.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/TopicNamingModalPopup.tsx @@ -1,8 +1,9 @@ +import { QuestionCircleOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' import { setEnableTopicNaming, setTopicNamingPrompt } from '@renderer/store/settings' -import { Button, Input, Modal, Switch } from 'antd' +import { Button, Divider, Flex, Input, Modal, Popover, Switch } from 'antd' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,6 +37,8 @@ const PopupContainer: React.FC = ({ resolve }) => { TopicNamingModalPopup.hide = onCancel + const promptVarsContent =
{t('agents.add.prompt.variables.tip.content')}
+ return ( = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" footer={null} - width={500} centered> +
{t('settings.models.enable_topic_naming')}
dispatch(setEnableTopicNaming(v))} />
-
-
{t('settings.models.topic_naming_prompt')}
+ +
+ +
{t('settings.models.topic_naming_prompt')}
+ + + +
- ) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index d9becd6952..02763079d4 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -416,7 +416,10 @@ export async function fetchTranslate({ content, assistant, onResponse }: FetchTr export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) { const prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title') const model = getTopNamingModel() || assistant.model || getDefaultModel() - const userMessages = takeRight(messages, 5) + const userMessages = takeRight(messages, 5).map((message) => ({ + ...message, + content: getMainTextContent(message) + })) const provider = getProviderByModel(model) diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index 4cb4755382..746c8999cb 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -137,7 +137,7 @@ export interface ImageCompleteChunk { /** * The image content of the chunk */ - image?: { type: 'base64'; images: string[] } + image?: { type: 'url' | 'base64'; images: string[] } } export interface ThinkingDeltaChunk { diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index de555aa53f..cbde058d24 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -7,9 +7,9 @@ import { convertMathFormula, findCitationInChildren, getCodeBlockId, + markdownToPlainText, removeTrailingDoubleSpaces, - updateCodeBlock, - markdownToPlainText + updateCodeBlock } from '../markdown' describe('markdown', () => { diff --git a/src/renderer/src/utils/__tests__/naming.test.ts b/src/renderer/src/utils/__tests__/naming.test.ts index 4a76334bfe..1d4560a3ab 100644 --- a/src/renderer/src/utils/__tests__/naming.test.ts +++ b/src/renderer/src/utils/__tests__/naming.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { firstLetter, generateColorFromChar, + getBaseModelName, getBriefInfo, getDefaultGroupName, getFirstCharacter, @@ -157,6 +158,38 @@ describe('naming', () => { }) }) + describe('getBaseModelName', () => { + it('should extract base model name with single delimiter', () => { + expect(getBaseModelName('DeepSeek/DeepSeek-R1')).toBe('DeepSeek-R1') + expect(getBaseModelName('openai/gpt-4.1')).toBe('gpt-4.1') + expect(getBaseModelName('anthropic/claude-3.5-sonnet')).toBe('claude-3.5-sonnet') + }) + + it('should extract base model name with multiple levels', () => { + expect(getBaseModelName('Pro/deepseek-ai/DeepSeek-R1')).toBe('DeepSeek-R1') + expect(getBaseModelName('org/team/group/model')).toBe('model') + }) + + it('should return original id if no delimiter found', () => { + expect(getBaseModelName('deepseek-r1')).toBe('deepseek-r1') + expect(getBaseModelName('deepseek-r1:free')).toBe('deepseek-r1:free') + }) + + it('should handle edge cases', () => { + // 验证空字符串的情况 + expect(getBaseModelName('')).toBe('') + // 验证以分隔符结尾的字符串 + expect(getBaseModelName('model/')).toBe('') + expect(getBaseModelName('model/name/')).toBe('') + // 验证以分隔符开头的字符串 + expect(getBaseModelName('/model')).toBe('model') + expect(getBaseModelName('/path/to/model')).toBe('model') + // 验证连续分隔符的情况 + expect(getBaseModelName('model//name')).toBe('name') + expect(getBaseModelName('model///name')).toBe('name') + }) + }) + describe('generateColorFromChar', () => { it('should generate a valid hex color code', () => { // 验证生成有效的十六进制颜色代码 diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index c3c6229064..e4881ed062 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,8 +1,8 @@ import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' +import removeMarkdown from 'remove-markdown' import { unified } from 'unified' import { visit } from 'unist-util-visit' -import removeMarkdown from 'remove-markdown' /** * 更彻底的查找方法,递归搜索所有子元素 diff --git a/src/renderer/src/utils/naming.ts b/src/renderer/src/utils/naming.ts index f475fa7421..df178104de 100644 --- a/src/renderer/src/utils/naming.ts +++ b/src/renderer/src/utils/naming.ts @@ -46,6 +46,20 @@ export const getDefaultGroupName = (id: string, provider?: string): string => { return str } +/** + * 从模型 ID 中提取基础名称。 + * 例如: + * - 'deepseek/deepseek-r1' => 'deepseek-r1' + * - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1' + * @param {string} id 模型 ID + * @param {string} [delimiter='/'] 分隔符,默认为 '/' + * @returns {string} 基础名称 + */ +export const getBaseModelName = (id: string, delimiter: string = '/'): string => { + const parts = id.split(delimiter) + return parts[parts.length - 1] +} + /** * 用于获取 avatar 名字的辅助函数,会取出字符串的第一个字符,支持表情符号。 * @param {string} str 输入字符串 diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 072635e430..7ae0b7327f 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -1,5 +1,6 @@ import store from '@renderer/store' -import { MCPTool } from '@renderer/types' +import { Assistant, MCPTool } from '@renderer/types' + export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. @@ -147,7 +148,11 @@ ${availableTools} ` } -export const buildSystemPrompt = async (userSystemPrompt: string, tools?: MCPTool[]): Promise => { +export const buildSystemPrompt = async ( + userSystemPrompt: string, + tools?: MCPTool[], + assistant?: Assistant +): Promise => { if (typeof userSystemPrompt === 'string') { const now = new Date() if (userSystemPrompt.includes('{{date}}')) { @@ -197,13 +202,22 @@ export const buildSystemPrompt = async (userSystemPrompt: string, tools?: MCPToo if (userSystemPrompt.includes('{{model_name}}')) { try { - const modelName = store.getState().llm.defaultModel.name - userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, modelName) + userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, assistant?.model?.name || 'Unknown Model') } catch (error) { console.error('Failed to get model name:', error) userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, 'Unknown Model') } } + + if (userSystemPrompt.includes('{{username}}')) { + try { + const username = store.getState().settings.userName || 'Unknown Username' + userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, username) + } catch (error) { + console.error('Failed to get username:', error) + userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, 'Unknown Username') + } + } } if (tools && tools.length > 0) { diff --git a/yarn.lock b/yarn.lock index f79b066618..97e2864d2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6030,7 +6030,7 @@ __metadata: "app-builder-lib@patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch": version: 26.0.15 - resolution: "app-builder-lib@patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch::version=26.0.15&hash=b02ae9" + resolution: "app-builder-lib@patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch::version=26.0.15&hash=1f4887" dependencies: "@develar/schema-utils": "npm:~2.6.5" "@electron/asar": "npm:3.4.1" @@ -6068,7 +6068,7 @@ __metadata: peerDependencies: dmg-builder: 26.0.15 electron-builder-squirrel-windows: 26.0.15 - checksum: 10c0/616072842c01f9f65283c95bf5642106c32bc3c6679672955f57b48bae9c28de10e18f2005d0e6e46cb2cb560dda3869ebf1412d3db50b7872c5f660581ad6db + checksum: 10c0/5de2bd593b21e464585ffa3424e053d41f8569b14ba2a00f29f84cb0b83347a7da3653587f9ef8b5d2f6d1e5bfc4081956b9d72f180d65960db49b5ac84b73d4 languageName: node linkType: hard