diff --git a/.github/issue-checker.yml b/.github/issue-checker.yml deleted file mode 100644 index 483e9d966f..0000000000 --- a/.github/issue-checker.yml +++ /dev/null @@ -1,252 +0,0 @@ -default-mode: - add: - remove: [pull_request_target, issues] - -labels: - # 跳过一个 label - # 去掉一个 label - - # skips and removes - - name: skip all - content: - regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?' - - name: remove all - content: - regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?' - - - name: skip kind/bug - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)' - - name: remove kind/bug - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)' - - - name: skip kind/enhancement - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)' - - name: remove kind/enhancement - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)' - - - name: skip kind/question - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)' - - name: remove kind/question - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)' - - - name: skip area/Connectivity - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)' - - name: remove area/Connectivity - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)' - - - name: skip area/UI/UX - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)' - - name: remove area/UI/UX - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)' - - - name: skip kind/documentation - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)' - - name: remove kind/documentation - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)' - - - name: skip client:linux - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)' - - name: remove client:linux - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)' - - - name: skip client:mac - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)' - - name: remove client:mac - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)' - - - name: skip client:win - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)' - - name: remove client:win - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)' - - - name: skip sig/Assistant - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)' - - name: remove sig/Assistant - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)' - - - name: skip sig/Data - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)' - - name: remove sig/Data - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)' - - - name: skip sig/MCP - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)' - - name: remove sig/MCP - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)' - - - name: skip sig/RAG - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)' - - name: remove sig/RAG - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)' - - - name: skip lgtm - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)' - - name: remove lgtm - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)' - - - name: skip License - content: - regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)' - - name: remove License - content: - regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)' - - # `Dev Team` - - name: Dev Team - mode: - add: [pull_request_target, issues] - author_association: - - COLLABORATOR - - # Area labels - - name: area/Connectivity - content: area/Connectivity - regexes: '代理|[Pp]roxy' - skip-if: - - skip all - - skip area/Connectivity - remove-if: - - remove all - - remove area/Connectivity - - - name: area/UI/UX - content: area/UI/UX - regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]' - skip-if: - - skip all - - skip area/UI/UX - remove-if: - - remove all - - remove area/UI/UX - - # Kind labels - - name: kind/documentation - content: kind/documentation - regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme' - skip-if: - - skip all - - skip kind/documentation - remove-if: - - remove all - - remove kind/documentation - - # Client labels - - name: client:linux - content: client:linux - regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)' - skip-if: - - skip all - - skip client:linux - remove-if: - - remove all - - remove client:linux - - - name: client:mac - content: client:mac - regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)' - skip-if: - - skip all - - skip client:mac - remove-if: - - remove all - - remove client:mac - - - name: client:win - content: client:win - regexes: '(?:[Ww]in|[Ww]indows)' - skip-if: - - skip all - - skip client:win - remove-if: - - remove all - - remove client:win - - # SIG labels - - name: sig/Assistant - content: sig/Assistant - regexes: '快捷助手|[Aa]ssistant' - skip-if: - - skip all - - skip sig/Assistant - remove-if: - - remove all - - remove sig/Assistant - - - name: sig/Data - content: sig/Data - regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源' - skip-if: - - skip all - - skip sig/Data - remove-if: - - remove all - - remove sig/Data - - - name: sig/MCP - content: sig/MCP - regexes: '[Mm][Cc][Pp]' - skip-if: - - skip all - - skip sig/MCP - remove-if: - - remove all - - remove sig/MCP - - - name: sig/RAG - content: sig/RAG - regexes: '知识库|[Rr][Aa][Gg]' - skip-if: - - skip all - - skip sig/RAG - remove-if: - - remove all - - remove sig/RAG - - # Other labels - - name: lgtm - content: lgtm - regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)' - skip-if: - - skip all - - skip lgtm - remove-if: - - remove all - - remove lgtm - - - name: License - content: License - regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)' - skip-if: - - skip all - - skip License - remove-if: - - remove all - - remove License diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml index 033ab4bfa0..99cad5602b 100644 --- a/.github/workflows/delete-branch.yml +++ b/.github/workflows/delete-branch.yml @@ -13,6 +13,7 @@ jobs: steps: - name: Delete merged branch uses: actions/github-script@v8 + continue-on-error: true with: script: | github.rest.git.deleteRef({ diff --git a/.github/workflows/issue-checker.yml b/.github/workflows/issue-checker.yml deleted file mode 100644 index 45da0f6b50..0000000000 --- a/.github/workflows/issue-checker.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: 'Issue Checker' - -on: - issues: - types: [opened, edited] - pull_request_target: - types: [opened, edited] - issue_comment: - types: [created, edited] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - triage: - runs-on: ubuntu-latest - steps: - - uses: MaaAssistantArknights/issue-checker@v1.14 - with: - repo-token: '${{ secrets.GITHUB_TOKEN }}' - configuration-path: .github/issue-checker.yml - not-before: 2022-08-05T00:00:00Z - include-title: 1 diff --git a/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch b/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch deleted file mode 100644 index cebfdd00a5..0000000000 --- a/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch +++ /dev/null @@ -1,44 +0,0 @@ -diff --git a/dist/index.js b/dist/index.js -index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class { - } - }); - } else if (value.item.type === "message") { -- controller.enqueue({ -- type: "text-end", -- id: value.item.id -- }); -+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start -+ if (currentTextId) { -+ controller.enqueue({ -+ type: "text-end", -+ id: currentTextId -+ }); -+ } - currentTextId = null; - } else if (isResponseOutputItemDoneReasoningChunk(value)) { - const activeReasoningPart = activeReasoning[value.item.id]; -diff --git a/dist/index.mjs b/dist/index.mjs -index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class { - } - }); - } else if (value.item.type === "message") { -- controller.enqueue({ -- type: "text-end", -- id: value.item.id -- }); -+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start -+ if (currentTextId) { -+ controller.enqueue({ -+ type: "text-end", -+ id: currentTextId -+ }); -+ } - currentTextId = null; - } else if (isResponseOutputItemDoneReasoningChunk(value)) { - const activeReasoningPart = activeReasoning[value.item.id]; diff --git a/package.json b/package.json index 1316591751..5f415e2068 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", - "@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch", + "@opeoginni/github-copilot-openai-compatible": "0.1.19", "@playwright/test": "^1.52.0", "@radix-ui/react-context-menu": "^2.2.16", "@reduxjs/toolkit": "^2.2.5", diff --git a/src/main/index.ts b/src/main/index.ts index 699074e279..4412c28af2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,6 @@ import process from 'node:process' import { registerIpc } from './ipc' import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' -import { configManager } from './services/ConfigManager' import mcpService from './services/MCPService' import { nodeTraceService } from './services/NodeTraceService' import { @@ -42,12 +41,12 @@ const logger = loggerService.withContext('MainEntry') /** * Disable hardware acceleration if setting is enabled */ -//FIXME should not use configManager, use usePreference instead +//FIXME should not use preferenceService before initialization //TODO 我们需要调整配置管理的加载位置,以保证其在 preferenceService 初始化之前被调用 -const disableHardwareAcceleration = configManager.getDisableHardwareAcceleration() -if (disableHardwareAcceleration) { - app.disableHardwareAcceleration() -} +// const disableHardwareAcceleration = preferenceService.get('app.disable_hardware_acceleration') +// if (disableHardwareAcceleration) { +// app.disableHardwareAcceleration() +// } /** * Disable chromium's window animations diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f2b71267b4..31d1ea2a1a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -169,7 +169,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { windows.forEach((window) => { window.webContents.session.setSpellCheckerLanguages(languages) }) - configManager.set('spellCheckLanguages', languages) + preferenceService.set('app.spell_check.languages', languages) }) // launch on boot @@ -264,12 +264,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) - ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { - configManager.set(key, value, isNotify) + ipcMain.handle(IpcChannel.Config_Set, (_, key: string) => { + // Legacy config handler - will be deprecated + logger.warn(`Legacy Config_Set called for key: ${key}`) }) ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => { - return configManager.get(key) + // Legacy config handler - will be deprecated + logger.warn(`Legacy Config_Get called for key: ${key}`) + return undefined }) // // theme @@ -280,7 +283,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => { const windows = BrowserWindow.getAllWindows() handleZoomFactor(windows, delta, reset) - return configManager.getZoomFactor() + return preferenceService.get('app.zoom_factor') }) // clear cache diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index a9fcab82e5..d63d7ed60c 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,9 +1,8 @@ import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isWin } from '@main/constant' -import { configManager } from '@main/services/ConfigManager' import { getIpCountry } from '@main/utils/ipService' -import { generateUserAgent } from '@main/utils/systemInfo' +import { generateUserAgent, getClientId } from '@main/utils/systemInfo' import { FeedUrl } from '@shared/config/constant' import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' @@ -39,7 +38,7 @@ export default class AppUpdater { autoUpdater.requestHeaders = { ...autoUpdater.requestHeaders, 'User-Agent': generateUserAgent(), - 'X-Client-Id': configManager.getClientId() + 'X-Client-Id': getClientId() } autoUpdater.on('error', (error) => { diff --git a/src/main/services/NodeTraceService.ts b/src/main/services/NodeTraceService.ts index 70fd92a28b..ec74baee90 100644 --- a/src/main/services/NodeTraceService.ts +++ b/src/main/services/NodeTraceService.ts @@ -1,3 +1,4 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isDev } from '@main/constant' import { CacheBatchSpanProcessor, FunctionSpanExporter } from '@mcp-trace/trace-core' @@ -7,7 +8,6 @@ import { context, trace } from '@opentelemetry/api' import { BrowserWindow, ipcMain } from 'electron' import * as path from 'path' -import { ConfigKeys, configManager } from './ConfigManager' import { spanCacheService } from './SpanCacheService' export const TRACER_NAME = 'CherryStudio' @@ -91,8 +91,13 @@ export function openTraceWindow(topicId: string, traceId: string, autoOpen = tru } else { traceWin.loadFile(path.join(__dirname, '../renderer/traceWindow.html')) } + let unsubscribeLanguage: (() => void) | null = null + traceWin.on('closed', () => { - configManager.unsubscribe(ConfigKeys.Language, setLanguageCallback) + if (unsubscribeLanguage) { + unsubscribeLanguage() + unsubscribeLanguage = null + } try { traceWin?.destroy() } finally { @@ -106,13 +111,15 @@ export function openTraceWindow(topicId: string, traceId: string, autoOpen = tru topicId, modelName }) - traceWin!.webContents.send('set-language', { lang: configManager.get(ConfigKeys.Language) }) - configManager.subscribe(ConfigKeys.Language, setLanguageCallback) + traceWin!.webContents.send('set-language', { lang: preferenceService.get('app.language') }) + unsubscribeLanguage = preferenceService.subscribeChange('app.language', setLanguageCallback) }) } -const setLanguageCallback = (lang: string) => { - traceWin!.webContents.send('set-language', { lang }) +const setLanguageCallback = (lang: string | null) => { + if (lang) { + traceWin?.webContents.send('set-language', { lang }) + } } export const setTraceWindowTitle = (title: string) => { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 6c4ed12db0..ceb977a136 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -14,7 +14,6 @@ import { join } from 'path' import icon from '../../../build/icon.png?asset' import { titleBarOverlayDark, titleBarOverlayLight } from '../config' -import { configManager } from './ConfigManager' import { contextMenu } from './ContextMenu' import { initSessionUserAgent } from './WebviewService' @@ -87,7 +86,7 @@ export class WindowService { webSecurity: false, webviewTag: true, allowRunningInsecureContent: true, - zoomFactor: configManager.getZoomFactor(), + zoomFactor: preferenceService.get('app.zoom_factor'), backgroundThrottling: false } }) @@ -120,10 +119,10 @@ export class WindowService { } private setupSpellCheck(mainWindow: BrowserWindow) { - const enableSpellCheck = configManager.get('enableSpellCheck', false) + const enableSpellCheck = preferenceService.get('app.spell_check.enabled') if (enableSpellCheck) { try { - const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[] + const spellCheckLanguages = preferenceService.get('app.spell_check.languages') spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages) } catch (error) { logger.error('Failed to set spell check languages:', error as Error) @@ -175,7 +174,7 @@ export class WindowService { private setupWindowEvents(mainWindow: BrowserWindow) { mainWindow.once('ready-to-show', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) // show window only when laucn to tray not set const isLaunchToTray = preferenceService.get('app.tray.on_launch') @@ -204,14 +203,14 @@ export class WindowService { // and resize ipc // mainWindow.on('will-resize', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize()) }) // set the zoom factor again when the window is going to restore // minimize and restore will cause zoom reset mainWindow.on('restore', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) }) // ARCH: as `will-resize` is only for Win & Mac, @@ -219,7 +218,7 @@ export class WindowService { // but `resize` will fliker the ui if (isLinux) { mainWindow.on('resize', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize()) }) } diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index 2611cf608c..80a9b252f3 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -40,7 +40,8 @@ vi.mock('@main/utils/locales', () => ({ })) vi.mock('@main/utils/systemInfo', () => ({ - generateUserAgent: vi.fn(() => 'test-user-agent') + generateUserAgent: vi.fn(() => 'test-user-agent'), + getClientId: vi.fn(() => 'test-client-id') })) vi.mock('electron', () => ({ diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index f6f6d2c40e..74a4f63850 100644 --- a/src/main/utils/__tests__/file.test.ts +++ b/src/main/utils/__tests__/file.test.ts @@ -264,7 +264,7 @@ describe('file', () => { const buffer = iconv.encode(content, 'GB18030') // 模拟文件读取和编码检测 - vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) + vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string) vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') const result = await readTextFileWithAutoEncoding(mockFilePath) @@ -276,7 +276,7 @@ describe('file', () => { const buffer = iconv.encode(content, 'UTF-8') // 模拟文件读取 - vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) + vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string) vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') const result = await readTextFileWithAutoEncoding(mockFilePath) diff --git a/src/main/utils/systemInfo.ts b/src/main/utils/systemInfo.ts index 84db4efed7..427501726a 100644 --- a/src/main/utils/systemInfo.ts +++ b/src/main/utils/systemInfo.ts @@ -1,6 +1,8 @@ +import { preferenceService } from '@data/PreferenceService' import { app } from 'electron' import macosRelease from 'macos-release' import os from 'os' +import { v4 as uuidv4 } from 'uuid' /** * System information interface @@ -90,3 +92,19 @@ export function generateUserAgent(): string { return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36` } + +/** + * Get or generate a unique client ID + * @returns {string} Client ID + */ +export function getClientId(): string { + let clientId = preferenceService.get('app.user.id') + + // If it's the placeholder value, generate a new UUID + if (!clientId || clientId.length === 0) { + clientId = uuidv4() + preferenceService.set('app.user.id', clientId) + } + + return clientId +} diff --git a/src/main/utils/zoom.ts b/src/main/utils/zoom.ts index d75b31724e..969859a7f9 100644 --- a/src/main/utils/zoom.ts +++ b/src/main/utils/zoom.ts @@ -1,13 +1,12 @@ +import { preferenceService } from '@data/PreferenceService' import type { BrowserWindow } from 'electron' -import { configManager } from '../services/ConfigManager' - export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) { if (reset) { wins.forEach((win) => { win.webContents.setZoomFactor(1) }) - configManager.setZoomFactor(1) + preferenceService.set('app.zoom_factor', 1) return } @@ -15,12 +14,12 @@ export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: bo return } - const currentZoom = configManager.getZoomFactor() + const currentZoom = preferenceService.get('app.zoom_factor') const newZoom = Number((currentZoom + delta).toFixed(1)) if (newZoom >= 0.5 && newZoom <= 2.0) { wins.forEach((win) => { win.webContents.setZoomFactor(newZoom) }) - configManager.setZoomFactor(newZoom) + preferenceService.set('app.zoom_factor', newZoom) } } diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index ab850fb6ca..12ad483121 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -6,6 +6,7 @@ import type { LanguageModelMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { noThinkMiddleware } from './noThinkMiddleware' +import { toolChoiceMiddleware } from './toolChoiceMiddleware' const logger = loggerService.withContext('AiSdkMiddlewareBuilder') @@ -32,6 +33,8 @@ export interface AiSdkMiddlewareConfig { uiMessages?: Message[] // 内置搜索配置 webSearchPluginConfig?: WebSearchPluginConfig + // 知识库识别开关,默认开启 + knowledgeRecognition?: 'off' | 'on' } /** @@ -122,6 +125,15 @@ export class AiSdkMiddlewareBuilder { export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageModelMiddleware[] { const builder = new AiSdkMiddlewareBuilder() + // 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库) + if (config.knowledgeRecognition === 'off') { + builder.add({ + name: 'force-knowledge-first', + middleware: toolChoiceMiddleware('builtin_knowledge_search') + }) + logger.debug('Added toolChoice middleware to force knowledge base search on first round') + } + // 1. 根据provider添加特定中间件 if (config.provider) { addProviderSpecificMiddlewares(builder, config) diff --git a/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts new file mode 100644 index 0000000000..7bb00aff55 --- /dev/null +++ b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts @@ -0,0 +1,45 @@ +import { loggerService } from '@logger' +import type { LanguageModelMiddleware } from 'ai' + +const logger = loggerService.withContext('toolChoiceMiddleware') + +/** + * Tool Choice Middleware + * Controls tool selection strategy across multiple rounds of tool calls: + * - First round: Forces the model to call a specific tool (e.g., knowledge base search) + * - Subsequent rounds: Allows the model to automatically choose any available tool + * + * This ensures knowledge base is consulted first while still enabling MCP tools + * and other capabilities in follow-up interactions. + * + * @param forceFirstToolName - The tool name to force on the first round + * @returns LanguageModelMiddleware + */ +export function toolChoiceMiddleware(forceFirstToolName: string): LanguageModelMiddleware { + let toolCallRound = 0 + + return { + middlewareVersion: 'v2', + + transformParams: async ({ params }) => { + toolCallRound++ + + const transformedParams = { ...params } + + if (toolCallRound === 1) { + // First round: force the specified tool + logger.debug(`Round ${toolCallRound}: Forcing tool choice to '${forceFirstToolName}'`) + transformedParams.toolChoice = { + type: 'tool', + toolName: forceFirstToolName + } + } else { + // Subsequent rounds: allow automatic tool selection + logger.debug(`Round ${toolCallRound}: Using automatic tool choice`) + transformedParams.toolChoice = { type: 'auto' } + } + + return transformedParams + } + } +} diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index bc4c3208c6..23e5deff61 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -5,6 +5,7 @@ import { GEMINI_FLASH_MODEL_REGEX, getThinkModelType, isDeepSeekHybridInferenceModel, + isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isGrok4FastReasoningModel, isGrokReasoningModel, @@ -171,6 +172,10 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // Doubao 思考模式支持 if (isSupportedThinkingTokenDoubaoModel(model)) { + if (isDoubaoSeedAfter251015(model)) { + return { reasoningEffort } + } + // Comment below this line seems weird. reasoning is high instead of null/undefined. Who wrote this? // reasoningEffort 为空,默认开启 enabled if (reasoningEffort === 'high') { return { thinking: { type: 'enabled' } } @@ -227,12 +232,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] if (supportedOptions.includes(reasoningEffort)) { return { - reasoning_effort: reasoningEffort + reasoningEffort } } else { // 如果不支持,fallback到第一个支持的值 return { - reasoning_effort: supportedOptions[0] + reasoningEffort: supportedOptions[0] } } } diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000000..5ac0ae1273 --- /dev/null +++ b/src/renderer/src/components/ConfirmDialog.tsx @@ -0,0 +1,45 @@ +import { Button } from '@heroui/react' +import { CheckIcon, XIcon } from 'lucide-react' +import type { FC } from 'react' +import { createPortal } from 'react-dom' + +interface Props { + x: number + y: number + message: string + onConfirm: () => void + onCancel: () => void +} + +const ConfirmDialog: FC = ({ x, y, message, onConfirm, onCancel }) => { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + <> +
+
+
+
{message}
+
+ + +
+
+
+ , + document.body + ) +} + +export default ConfirmDialog diff --git a/src/renderer/src/components/Tags/CustomTag.tsx b/src/renderer/src/components/Tags/CustomTag.tsx index 512130348b..cd35237821 100644 --- a/src/renderer/src/components/Tags/CustomTag.tsx +++ b/src/renderer/src/components/Tags/CustomTag.tsx @@ -14,6 +14,7 @@ export interface CustomTagProps { closable?: boolean onClose?: () => void onClick?: MouseEventHandler + onContextMenu?: MouseEventHandler disabled?: boolean inactive?: boolean } @@ -28,6 +29,7 @@ const CustomTag: FC = ({ closable = false, onClose, onClick, + onContextMenu, disabled, inactive }) => { @@ -40,6 +42,7 @@ const CustomTag: FC = ({ $closable={closable} $clickable={!disabled && !!onClick} onClick={disabled ? undefined : onClick} + onContextMenu={disabled ? undefined : onContextMenu} style={{ ...(disabled && { cursor: 'not-allowed' }), ...style @@ -57,7 +60,7 @@ const CustomTag: FC = ({ )} ), - [actualColor, children, closable, disabled, icon, onClick, onClose, size, style] + [actualColor, children, closable, disabled, icon, onClick, onClose, onContextMenu, size, style] ) return tooltip ? ( diff --git a/src/renderer/src/config/__test__/reasoning.test.ts b/src/renderer/src/config/__test__/reasoning.test.ts new file mode 100644 index 0000000000..96fad861f3 --- /dev/null +++ b/src/renderer/src/config/__test__/reasoning.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' + +import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel } from '../models/reasoning' + +// FIXME: Idk why it's imported. Maybe circular dependency somewhere +vi.mock('@renderer/services/AssistantService.ts', () => ({ + getDefaultAssistant: () => { + return { + id: 'default', + name: 'default', + emoji: '😀', + prompt: '', + topics: [], + messages: [], + type: 'assistant', + regularPhrases: [], + settings: {} + } + } +})) + +describe('Doubao Models', () => { + describe('isDoubaoThinkingAutoModel', () => { + it('should return false for invalid models', () => { + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-251015', + name: 'doubao-seed-1-6-251015', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-lite-251015', + name: 'doubao-seed-1-6-lite-251015', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-thinking-250715', + name: 'doubao-seed-1-6-thinking-250715', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-flash', + name: 'doubao-seed-1-6-flash', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-thinking', + name: 'doubao-seed-1-6-thinking', + provider: '', + group: '' + }) + ).toBe(false) + }) + + it('should return true for valid models', () => { + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-250615', + name: 'doubao-seed-1-6-250615', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'Doubao-Seed-1.6', + name: 'Doubao-Seed-1.6', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-1-5-thinking-pro-m', + name: 'doubao-1-5-thinking-pro-m', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1.6-lite', + name: 'doubao-seed-1.6-lite', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-1-5-thinking-pro-m-12345', + name: 'doubao-1-5-thinking-pro-m-12345', + provider: '', + group: '' + }) + ).toBe(true) + }) + }) + + describe('isDoubaoSeedAfter251015', () => { + it('should return true for models matching the pattern', () => { + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-251015', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-lite-251015', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return false for models not matching the pattern', () => { + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-250615', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'Doubao-Seed-1.6', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-1-5-thinking-pro-m', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-lite-251016', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index a9e9123c08..65b32fb47e 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -31,6 +31,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { qwen_thinking: ['low', 'medium', 'high'] as const, doubao: ['auto', 'high'] as const, doubao_no_auto: ['high'] as const, + doubao_after_251015: ['minimal', 'low', 'medium', 'high'] as const, hunyuan: ['auto'] as const, zhipu: ['auto'] as const, perplexity: ['low', 'medium', 'high'] as const, @@ -51,6 +52,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking, doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const, doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const, + doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015, hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const, zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const, perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity, @@ -85,6 +87,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => { } else if (isSupportedThinkingTokenDoubaoModel(model)) { if (isDoubaoThinkingAutoModel(model)) { thinkingModelType = 'doubao' + } else if (isDoubaoSeedAfter251015(model)) { + thinkingModelType = 'doubao_after_251015' } else { thinkingModelType = 'doubao_no_auto' } @@ -308,14 +312,21 @@ export const DOUBAO_THINKING_MODEL_REGEX = /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i // 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx +// Auto thinking is no longer supported after version 251015, see https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-6 export const DOUBAO_THINKING_AUTO_MODEL_REGEX = - /doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i + /doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-lite)?(?!-251015)(?:-\d+)?$/i export function isDoubaoThinkingAutoModel(model: Model): boolean { const modelId = getLowerBaseModelName(model.id) return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name) } +export function isDoubaoSeedAfter251015(model: Model): boolean { + const pattern = new RegExp(/doubao-seed-1-6-(?:lite-)?251015/i) + const result = pattern.test(model.id) + return result +} + export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index 68d967615c..978cb5cb3b 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -14,6 +14,7 @@ export function usePaintings() { const aihubmix_image_upscale = useAppSelector((state) => state.paintings.aihubmix_image_upscale) const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate) const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit) + const ovms_paintings = useAppSelector((state) => state.paintings.ovms_paintings) const dispatch = useAppDispatch() return { @@ -27,6 +28,7 @@ export function usePaintings() { aihubmix_image_upscale, openai_image_generate, openai_image_edit, + ovms_paintings, addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => { dispatch(addPainting({ namespace, painting })) return painting diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index df349b2880..b6f41328f3 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -538,6 +538,7 @@ "context": "Clear Context {{Command}}" }, "new_topic": "New Topic {{Command}}", + "paste_text_file_confirm": "Paste into input bar?", "pause": "Pause", "placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools", "placeholder_without_triggers": "Type your message here, press {{key}} to send", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 01011f3ba3..fb9cfb9968 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -538,6 +538,7 @@ "context": "清除上下文 {{Command}}" }, "new_topic": "新话题 {{Command}}", + "paste_text_file_confirm": "粘贴到输入框?", "pause": "暂停", "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 5a68e67c45..67752e6ba2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -538,6 +538,7 @@ "context": "清除上下文 {{Command}}" }, "new_topic": "新話題 {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "暫停", "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 06e0637d56..c33807d7c8 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -538,6 +538,7 @@ "context": "Καθαρισμός ενδιάμεσων {{Command}}" }, "new_topic": "Νέο θέμα {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Παύση", "placeholder": "Εισάγετε μήνυμα εδώ...", "placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 73a70089c6..741011884a 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -538,6 +538,7 @@ "context": "Limpiar contexto {{Command}}" }, "new_topic": "Nuevo tema {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Pausar", "placeholder": "Escribe aquí tu mensaje...", "placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 87dcd00451..8eedfb3ebc 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -538,6 +538,7 @@ "context": "Effacer le contexte {{Command}}" }, "new_topic": "Nouveau sujet {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Pause", "placeholder": "Entrez votre message ici...", "placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index f06e46712a..3909d5f2b9 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -538,6 +538,7 @@ "context": "コンテキストをクリア {{Command}}" }, "new_topic": "新しいトピック {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "一時停止", "placeholder": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index af05e2d78b..3d65aa320f 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -538,6 +538,7 @@ "context": "Limpar contexto {{Command}}" }, "new_topic": "Novo tópico {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Pausar", "placeholder": "Digite sua mensagem aqui...", "placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 75b73a270a..f665ba6f62 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -538,6 +538,7 @@ "context": "Очистить контекст {{Command}}" }, "new_topic": "Новый топик {{Command}}", + "paste_text_file_confirm": "[to be translated]:粘贴到输入框?", "pause": "Остановить", "placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...", "placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx index ec3d123632..195949a13c 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx @@ -12,8 +12,8 @@ import { GlobalOutlined, LinkOutlined } from '@ant-design/icons' -import { ColFlex } from '@cherrystudio/ui' -import { Tooltip } from '@cherrystudio/ui' +import { ColFlex, Tooltip } from '@cherrystudio/ui' +import ConfirmDialog from '@renderer/components/ConfirmDialog' import CustomTag from '@renderer/components/Tags/CustomTag' import { useAttachment } from '@renderer/hooks/useAttachment' import FileManager from '@renderer/services/FileManager' @@ -21,13 +21,15 @@ import type { FileMetadata } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Image } from 'antd' import { isEmpty } from 'lodash' -import type { FC } from 'react' +import type { FC, MouseEvent } from 'react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface Props { files: FileMetadata[] setFiles: (files: FileMetadata[]) => void + onAttachmentContextMenu?: (file: FileMetadata, event: MouseEvent) => void } const MAX_FILENAME_DISPLAY_LENGTH = 20 @@ -133,24 +135,91 @@ export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => { ) } -const AttachmentPreview: FC = ({ files, setFiles }) => { +const AttachmentPreview: FC = ({ files, setFiles, onAttachmentContextMenu }) => { + const { t } = useTranslation() + const [contextMenu, setContextMenu] = useState<{ + file: FileMetadata + x: number + y: number + } | null>(null) + + const handleContextMenu = async (file: FileMetadata, event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + // 获取被点击元素的位置 + const target = event.currentTarget as HTMLElement + const rect = target.getBoundingClientRect() + + // 计算对话框位置:附件标签的中心位置 + const x = rect.left + rect.width / 2 + const y = rect.top + + try { + const isText = await window.api.file.isTextFile(file.path) + if (!isText) { + setContextMenu(null) + return + } + + setContextMenu({ + file, + x, + y + }) + } catch (error) { + setContextMenu(null) + } + } + + const handleConfirm = () => { + if (contextMenu && onAttachmentContextMenu) { + // Create a synthetic mouse event for the callback + const syntheticEvent = { + preventDefault: () => {}, + stopPropagation: () => {} + } as MouseEvent + onAttachmentContextMenu(contextMenu.file, syntheticEvent) + } + setContextMenu(null) + } + + const handleCancel = () => { + setContextMenu(null) + } + if (isEmpty(files)) { return null } return ( - - {files.map((file) => ( - setFiles(files.filter((f) => f.id !== file.id))}> - - - ))} - + <> + + {files.map((file) => ( + setFiles(files.filter((f) => f.id !== file.id))} + onContextMenu={(event) => { + void handleContextMenu(file, event) + }}> + + + ))} + + + {contextMenu && ( + + )} + ) } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index f381dbe4ea..ed59d6dcb6 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -296,6 +296,53 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) + const appendTxtContentToInput = useCallback( + async (file: FileType, event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + try { + const targetPath = file.path + const content = await window.api.file.readExternal(targetPath, true) + try { + await navigator.clipboard.writeText(content) + } catch (clipboardError) { + logger.warn('Failed to copy txt attachment content to clipboard:', clipboardError as Error) + } + + setText((prev) => { + if (!prev) { + return content + } + + const needsSeparator = !prev.endsWith('\n') + return needsSeparator ? `${prev}\n${content}` : prev + content + }) + + setFiles((prev) => prev.filter((currentFile) => currentFile.id !== file.id)) + + setTimeoutTimer( + 'appendTxtAttachment', + () => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + const end = textArea.value.length + textArea.focus() + textArea.setSelectionRange(end, end) + } + + resizeTextArea(true) + }, + 0 + ) + } catch (error) { + logger.warn('Failed to append txt attachment content:', error as Error) + window.toast.error(t('chat.input.file_error')) + } + }, + [resizeTextArea, setTimeoutTimer, t] + ) + const handleKeyDown = (event: React.KeyboardEvent) => { // 按下Tab键,自动选中${xxx} if (event.key === 'Tab' && inputFocus) { @@ -834,7 +881,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = id="inputbar" className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')} ref={containerRef}> - {files.length > 0 && } + {files.length > 0 && ( + + )} {selectedKnowledgeBases.length > 0 && ( = ({ agent, isActive, onDelete, onPress }) = + {isActive && ( + + {sessions.length} + + )} + {!isActive && } - {isActive && ( - - {sessions.length} - - )} - {!isActive && ( - - - - )} @@ -111,29 +107,27 @@ export const AgentNameWrapper: React.FC> = export const MenuButton: React.FC> = ({ className, ...props }) => (
) -export const BotIcon: React.FC> = ({ className, ...props }) => ( -
-) +export const BotIcon: React.FC> = ({ ...props }) => { + const { t } = useTranslation() + return ( + + + + + + ) +} export const SessionCount: React.FC> = ({ className, ...props }) => (
) diff --git a/src/renderer/src/pages/paintings/OvmsPage.tsx b/src/renderer/src/pages/paintings/OvmsPage.tsx new file mode 100644 index 0000000000..612a964cb9 --- /dev/null +++ b/src/renderer/src/pages/paintings/OvmsPage.tsx @@ -0,0 +1,690 @@ +import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' +import { useCache } from '@data/hooks/useCache' +import { loggerService } from '@logger' +import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' +import Scrollbar from '@renderer/components/Scrollbar' +import { isMac } from '@renderer/config/constant' +import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' +import { usePaintings } from '@renderer/hooks/usePaintings' +import { useAllProviders } from '@renderer/hooks/useProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { getProviderLabel } from '@renderer/i18n/label' +import FileManager from '@renderer/services/FileManager' +import { translateText } from '@renderer/services/TranslateService' +import type { FileMetadata, OvmsPainting } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { Avatar, Input, InputNumber, Select, Slider } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import { Info } from 'lucide-react' +import type { FC } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation, useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +import SendMessageButton from '../home/Inputbar/SendMessageButton' +import { SettingHelpLink, SettingTitle } from '../settings' +import Artboard from './components/Artboard' +import PaintingsList from './components/PaintingsList' +import { + type ConfigItem, + createDefaultOvmsPainting, + createOvmsConfig, + DEFAULT_OVMS_PAINTING, + getOvmsModels, + OVMS_MODELS +} from './config/ovmsConfig' + +const logger = loggerService.withContext('OvmsPage') + +const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => { + const { addPainting, removePainting, updatePainting, ovms_paintings } = usePaintings() + const ovmsPaintings = useMemo(() => ovms_paintings || [], [ovms_paintings]) + const [painting, setPainting] = useState(ovmsPaintings[0] || DEFAULT_OVMS_PAINTING) + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(null) + const [spaceClickCount, setSpaceClickCount] = useState(0) + const [isTranslating, setIsTranslating] = useState(false) + const [availableModels, setAvailableModels] = useState>([]) + const [ovmsConfig, setOvmsConfig] = useState([]) + + const { t } = useTranslation() + const providers = useAllProviders() + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + if (provider) { + return { + label: getProviderLabel(provider.id), + value: provider.id + } + } else { + return { + label: 'Unknown Provider', + value: undefined + } + } + }) + const [generating, setGenerating] = useCache('chat.generating') + + const navigate = useNavigate() + const location = useLocation() + const { autoTranslateWithSpace } = useSettings() + const spaceClickTimer = useRef(null) + const ovmsProvider = providers.find((p) => p.id === 'ovms')! + + const getNewPainting = useCallback(() => { + if (availableModels.length > 0) { + return createDefaultOvmsPainting(availableModels) + } + return { + ...DEFAULT_OVMS_PAINTING, + id: uuid() + } + }, [availableModels]) + + const textareaRef = useRef(null) + + // Load available models on component mount + useEffect(() => { + const loadModels = () => { + try { + // Get OVMS provider to access its models + const ovmsProvider = providers.find((p) => p.id === 'ovms') + const providerModels = ovmsProvider?.models || [] + + // Filter and format models for image generation + const filteredModels = getOvmsModels(providerModels) + setAvailableModels(filteredModels) + setOvmsConfig(createOvmsConfig(filteredModels)) + + // Update painting if it doesn't have a valid model + if (filteredModels.length > 0 && !filteredModels.some((m) => m.value === painting.model)) { + const defaultPainting = createDefaultOvmsPainting(filteredModels) + setPainting(defaultPainting) + } + } catch (error) { + logger.error(`Failed to load OVMS models: ${error}`) + // Use default config if loading fails + setOvmsConfig(createOvmsConfig()) + } + } + + loadModels() + }, [providers, painting.model]) // Re-run when providers change + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting('ovms_paintings', updatedPainting) + } + + const handleError = (error: unknown) => { + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: getErrorMessage(error), + centered: true + }) + } + } + + const downloadImages = async (urls: string[]) => { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + if (!url?.trim()) { + logger.error('Image URL is empty, possibly due to prohibited prompt') + window.toast.warning(t('message.empty_url')) + return null + } + return await window.api.file.download(url) + } catch (error) { + logger.error(`Failed to download image: ${error}`) + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.toast.warning(t('message.empty_url')) + } + return null + } + }) + ) + + return downloadedFiles.filter((file): file is FileMetadata => file !== null) + } + + const onGenerate = async () => { + if (painting.files.length > 0) { + const confirmed = await window.modal.confirm({ + content: t('paintings.regenerate.confirm'), + centered: true + }) + + if (!confirmed) return + await FileManager.deleteFiles(painting.files) + } + + const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' + updatePaintingState({ prompt }) + + if (!painting.model || !painting.prompt) { + return + } + + const controller = new AbortController() + setAbortController(controller) + setIsLoading(true) + setGenerating(true) + + try { + // Prepare request body for OVMS + const requestBody = { + model: painting.model, + prompt: painting.prompt, + size: painting.size || '512x512', + num_inference_steps: painting.num_inference_steps || 4, + rng_seed: painting.rng_seed || 0 + } + + logger.info('OVMS API request:', requestBody) + + const response = await fetch(`${ovmsProvider.apiHost}images/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody), + signal: controller.signal + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } })) + logger.error('OVMS API error:', errorData) + throw new Error(errorData.error?.message || 'Image generation failed') + } + + const data = await response.json() + logger.info('OVMS API response:', data) + + // Handle base64 encoded images + if (data.data && data.data.length > 0) { + const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json) + + if (base64s.length > 0) { + const validFiles = await Promise.all( + base64s.map(async (base64) => { + return await window.api.file.saveBase64Image(base64) + }) + ) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) }) + } + + // Handle URL-based images if available + const urls = data.data.filter((item) => item.url).map((item) => item.url) + + if (urls.length > 0) { + const validFiles = await downloadImages(urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + } + } catch (error: unknown) { + handleError(error) + } finally { + setIsLoading(false) + setGenerating(false) + setAbortController(null) + } + } + + const handleRetry = async (painting: OvmsPainting) => { + setIsLoading(true) + try { + const validFiles = await downloadImages(painting.urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: painting.urls }) + } catch (error) { + handleError(error) + } finally { + setIsLoading(false) + } + } + + const onCancel = () => { + abortController?.abort() + } + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % painting.files.length) + } + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length) + } + + const handleAddPainting = () => { + const newPainting = addPainting('ovms_paintings', getNewPainting()) + updatePainting('ovms_paintings', newPainting) + setPainting(newPainting) + return newPainting + } + + const onDeletePainting = (paintingToDelete: OvmsPainting) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = ovmsPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(ovmsPaintings[currentIndex - 1]) + } else if (ovmsPaintings.length > 1) { + setPainting(ovmsPaintings[1]) + } + } + + removePainting('ovms_paintings', paintingToDelete) + } + + const translate = async () => { + if (isTranslating) { + return + } + + if (!painting.prompt) { + return + } + + try { + setIsTranslating(true) + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) + updatePaintingState({ prompt: translatedText }) + } catch (error) { + logger.error('Translation failed:', error as Error) + } finally { + setIsTranslating(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (autoTranslateWithSpace && event.key === ' ') { + setSpaceClickCount((prev) => prev + 1) + + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + + spaceClickTimer.current = setTimeout(() => { + setSpaceClickCount(0) + }, 200) + + if (spaceClickCount === 2) { + setSpaceClickCount(0) + setIsTranslating(true) + translate() + } + } + } + + const handleProviderChange = (providerId: string) => { + const routeName = location.pathname.split('/').pop() + if (providerId !== routeName) { + navigate('../' + providerId, { replace: true }) + } + } + + // Handle random seed generation + const handleRandomSeed = () => { + const randomSeed = Math.floor(Math.random() * 2147483647) + updatePaintingState({ rng_seed: randomSeed }) + return randomSeed + } + + // Render configuration form + const renderConfigForm = (item: ConfigItem) => { + switch (item.type) { + case 'select': { + const isDisabled = typeof item.disabled === 'function' ? item.disabled(item, painting) : item.disabled + const selectOptions = + typeof item.options === 'function' + ? item.options(item, painting).map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + : item.options?.map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + + return ( + updatePaintingState({ [item.key!]: e.target.value })} + suffix={ + item.key === 'rng_seed' ? ( + + ) : ( + item.suffix + ) + } + /> + ) + case 'inputNumber': + return ( + updatePaintingState({ [item.key!]: v })} + /> + ) + case 'textarea': + return ( +