Compare commits

..

2 Commits

Author SHA1 Message Date
MyPrototypeWhat
60d6fbe8f4 feat: refactor web search to provider-specific tools with advanced parameters
- Add ExaSearchTool and TavilySearchTool with provider-specific parameters
- Extend type system for Exa (neural search, date filters) and Tavily (AI answers, search depth)
- Update all providers to support ProviderSpecificParams interface
- Add searchResultAdapters for unified citation conversion
- Remove rawContent from LLM output and storage to reduce token usage
- Support favicon, highlights, answer, images metadata
- Update UI components to handle new tool names
- Preserve existing RAG compression and token cutoff capabilities

Breaking changes: None (backward compatible with existing providers)
2025-10-13 17:53:40 +08:00
MyPrototypeWhat
ff378ca567 feat: enhance web search functionality with abort signal support
- Updated WebSearchTool to accept an abort signal in the execute method.
- Modified various WebSearchProvider classes to include httpOptions for search methods, allowing for abort signal handling.
- Improved WebSearchService to prioritize external abort signals for better request management.
- Enhanced MessageTool to reflect tool status with appropriate UI feedback.
2025-10-09 17:44:42 +08:00
270 changed files with 3554 additions and 9732 deletions

View File

@@ -16,13 +16,10 @@ on:
jobs:
translate:
if: |
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
(github.event_name == 'issues') ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -45,7 +42,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@@ -108,5 +105,3 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

View File

@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {

View File

@@ -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];

Binary file not shown.

View File

@@ -125,113 +125,21 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-beta.1
What's New in v1.6.3
Major Features:
- Agent System: Introducing intelligent Agent capabilities alongside Assistants. Agents can autonomously solve complex problems using Claude Code SDK with tool calling, file operations, and multi-turn reasoning
- Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations
- Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking
- Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management
Agent Features:
- Tool Support: Web search, file operations, bash commands, and custom MCP tools
- Advanced Configuration: Max turns, temperature, token limits
- Permission Control: Configurable tool approval modes (manual, automatic, none)
- Session Persistence: Automatic message saving with optimized streaming and database integration
- Model Selection: API-based model filtering with provider-specific support
UI/UX Improvements:
- Unified assistant/agent tabs with smooth animations
- In-place session name editing
- Virtual list rendering for improved performance
- Session count indicators for active agents
- Enhanced settings popup with tabbed interface
- Webview keyboard shortcut interception for search functionality
API & Infrastructure:
- RESTful API for agent and session management
- Drizzle ORM integration for agent database
- OAuth support for Claude Code authentication
- Express validator for request validation
- Comprehensive error handling with Zod schemas
Model Updates:
- Gemini 2.5 Image Flash support
- Grok 4 Fast with reasoning capabilities
- Qwen3-omni and Qwen3-vl thinking models
- DeepSeek, Claude 4.5, GLM 4.6 support
- GitHub Copilot CLI integration with gpt-5-codex
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
Bug Fixes:
- Fix Swagger UI accessibility issues
- Fix AI SDK error display with syntax highlighting
- Fix webview search shortcut handling
- Fix agent model visibility for CherryIn provider
- Fix session message ordering and persistence
- Fix anthropic model visibility in agent configuration
- Fix knowledge base deletion and web search RAG errors
- Fix migration for missing providers
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
Technical Updates:
- React 19.2.0 upgrade
- Enhanced Claude Code service with streaming support
- Improved message transformation and streaming lifecycle
- Database migration system with automatic schema sync
- Optimized bundle size and dependency management
<!--LANG:zh-CN-->
v1.7.0-beta.1 新特性
核心功能:
- Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题
- Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成
- Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪
- 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验
Agent 功能特性:
- 工具支持网页搜索、文件操作、Bash 命令执行和自定义 MCP 工具
- 高级配置最大轮次、温度、Token 限制
- 权限控制:可配置的工具批准模式(手动、自动、无需批准)
- 会话持久化:自动消息保存,优化的流式传输和数据库集成
- 模型选择:基于 API 的模型过滤,支持特定提供商
界面与交互优化:
- 统一的助手/Agent 标签页,带有流畅动画效果
- 会话名称原地编辑功能
- 虚拟列表渲染,提升性能表现
- 活跃 Agent 的会话计数指示器
- 增强的设置弹窗,采用标签页界面
- Webview 键盘快捷键拦截,支持搜索功能
API 与基础设施:
- RESTful API 用于 Agent 和会话管理
- 集成 Drizzle ORM 管理 Agent 数据库
- Claude Code OAuth 认证支持
- Express validator 请求验证
- 基于 Zod 模式的完善错误处理
模型更新:
- 支持 Gemini 2.5 Image Flash
- Grok 4 Fast 推理能力
- Qwen3-omni 和 Qwen3-vl 思考模型
- DeepSeek、Claude 4.5、GLM 4.6 支持
- GitHub Copilot CLI 集成 gpt-5-codex
问题修复:
- 修复 Swagger UI 无法打开
- 修复 AI SDK 错误显示,添加语法高亮
- 修复 Webview 搜索快捷键处理
- 修复 CherryIn 提供商的 Agent 模型可见性
- 修复会话消息排序和持久化
- 修复 Anthropic 模型在 Agent 配置中的可见性
- 修复知识库删除和网页搜索 RAG 错误
- 修复缺失提供商的迁移问题
技术更新:
- 升级至 React 19.2.0
- 增强 Claude Code 服务流式传输支持
- 改进消息转换和流式生命周期
- 数据库迁移系统,支持自动模式同步
- 优化打包大小和依赖管理
<!--LANG:END-->
- Upgrade to Electron 37.6.0
- Update dependencies across packages

View File

@@ -2,7 +2,6 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
@@ -16,8 +15,7 @@ export default defineConfig([
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports,
'import-zod': importZod
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
@@ -27,7 +25,6 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-sora.3",
"version": "1.7.0-alpha.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -83,7 +83,6 @@
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@@ -93,7 +92,6 @@
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0"
},
@@ -101,10 +99,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -126,7 +124,6 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "6.3.0-fork.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -155,7 +152,6 @@
"@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",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -223,7 +219,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.68",
"ai": "^5.0.59",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -260,11 +256,11 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
@@ -297,15 +293,16 @@
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"oxlint": "^1.22.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.15.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"playwright": "^1.52.0",
"proxy-agent": "^6.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
@@ -337,6 +334,7 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swagger-ui-express": "^5.0.1",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
@@ -372,19 +370,17 @@
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch"
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12",
"@ai-sdk/xai": "^2.0.26",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
import { InferToolInput, InferToolOutput } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -15,13 +15,6 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
/**
* 插件初始化时接收的完整配置对象
*
@@ -66,7 +59,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropic: InferToolOutput<AnthropicWebSearchTool>
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
// OpenAI 工具 - 基于实际输出
// TODO: 上游定义不规范,是unknown
@@ -89,8 +82,8 @@ export type WebSearchToolOutputSchema = {
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<AnthropicWebSearchTool>
openai: InferToolInput<OpenAIWebSearchTool>
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
}

View File

@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { customProvider, Provider } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
/**
* 基础 Provider IDs

View File

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -53,7 +53,6 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -235,6 +234,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -317,7 +317,6 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config',
// Anthropic OAuth
@@ -337,7 +336,6 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',

View File

@@ -22,12 +22,3 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

View File

@@ -5,171 +5,105 @@ const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL =
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
/**
* error code:
* 101: Unsupported CPU (not Intel Ultra)
* 102: Unsupported platform (not Windows)
* 103: Download failed
* 104: Installation failed
* 105: Failed to create ovdnd.exe
* 106: Failed to create run.bat
* 110: Cleanup of old installation failed
* Downloads and extracts the OVMS binary for the specified platform
*/
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) {
try {
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
fs.rmSync(csOvmsDir, { recursive: true })
}
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
try {
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
// Try each URL until one succeeds
let downloadSuccess = false
let lastError = null
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
} catch (error) {
console.error(`Download OVMS Base failed: ${error.message}`)
fs.unlinkSync(tempFilename)
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
try {
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
} catch (error) {
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
lastError = error
// Clean up failed download file if it exists
if (fs.existsSync(tempFilename)) {
try {
fs.unlinkSync(tempFilename)
} catch (cleanupError) {
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
}
}
// Continue to next URL if this one failed
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
console.log(`Trying next URL...`)
}
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 103
}
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try {
console.log(`Extracting to ${csDir}...`)
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
console.log(`Extracting OVMS to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
fs.unlinkSync(tempFilename)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if ovmsDir is empty and remove it if so
try {
const ovmsDir = path.join(csDir, 'ovms')
const files = fs.readdirSync(ovmsDir)
if (files.length === 0) {
fs.rmSync(ovmsDir, { recursive: true })
console.log(`Removed empty directory: ${ovmsDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
return 105
}
return 104
}
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
// copy ovms.exe to ovdnd.exe
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
console.log('Copied ovms.exe to ovdnd.exe')
} catch (error) {
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
return 105
}
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
// del %USERPROFILE%\.cherrystudio\ovms_log.log
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
fs.appendFileSync(runBatPath, '\r\n')
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
fs.appendFileSync(
runBatPath,
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
)
console.log(`Created run.bat at: ${runBatPath}`)
} catch (error) {
console.error(`Error creating run.bat: ${error.message}`)
return 106
}
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
console.log(`Created config file: ${configJsonPath}`)
return 0
}
/**
* Install OVMS Extra package
*/
async function installOvmsExtra() {
// Download the extra package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
try {
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
} catch (error) {
console.error(`Download OVMS Extra failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the extra package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS Extra: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
try {
const files = fs.readdirSync(patchDir)
files.forEach((file) => {
const srcPath = path.join(patchDir, file)
const destPath = path.join(csOvmsBinDir, file)
fs.copyFileSync(srcPath, destPath)
console.log(`Applied patch file: ${file}`)
})
} catch (error) {
console.error(`Error applying OVMS patch: ${error.message}`)
}
return 0
}
@@ -224,27 +158,7 @@ async function installOvms() {
return 102
}
// Clean old installation if it exists
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
return await downloadOvmsBinary()
}
// Run the installation

View File

@@ -2,9 +2,9 @@
* 该脚本用于少量自动翻译所有baseLocale以外的文本。待翻译文案必须以[to be translated]开头
*
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import * as fs from 'fs'
import OpenAI from 'openai'
import * as path from 'path'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')

View File

@@ -4,9 +4,9 @@
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import fs from 'fs'
import OpenAI from 'openai'
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }

View File

@@ -1,5 +1,5 @@
import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources'
import express, { Request, Response } from 'express'
import { ChatCompletionCreateParams } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import {

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources'
import { Provider } from '@types'
import OpenAI from 'openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import { ModelValidationError, validateModelId } from '../utils'

View File

@@ -1,13 +1,6 @@
import { isEmpty } from 'lodash'
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
import { loggerService } from '../../services/LoggerService'
import {
getAvailableProviders,
getProviderAnthropicModelChecker,
listAllAvailableModels,
transformModelToOpenAI
} from '../utils'
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
const logger = loggerService.withContext('ModelsService')
@@ -23,7 +16,9 @@ export class ModelsService {
let providers = await getAvailableProviders()
if (filter.providerType === 'anthropic') {
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim()))
providers = providers.filter(
(p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '')
)
}
const models = await listAllAvailableModels(providers)
@@ -32,18 +27,18 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
logger.debug(`Processing model ${model.id} from provider ${model.provider}`, {
isAnthropicModel: provider?.isAnthropicModel
})
if (
!provider ||
(filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model))
) {
continue
}
if (filter.providerType === 'anthropic') {
const checker = getProviderAnthropicModelChecker(provider.id)
if (!checker(model)) {
logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`)
continue
}
// Special case: For "aihubmix", it should be covered by above condition, but just in case
if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) {
continue
}
const openAIModel = transformModelToOpenAI(model, provider)

View File

@@ -279,16 +279,3 @@ export function validateProvider(provider: Provider): boolean {
return false
}
}
export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => {
switch (providerId) {
case 'cherryin':
case 'new-api':
return (m: Model) => m.endpoint_type === 'anthropic'
case 'aihubmix':
return (m: Model) => m.id.includes('claude')
default:
// allow all models when checker not configured
return () => true
}
}

View File

@@ -30,7 +30,6 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -109,7 +108,6 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
@@ -159,26 +157,11 @@ if (!app.requestSingleInstanceLock()) {
logger.error('Failed to initialize Agent service:', error)
}
// Start API server if enabled or if agents exist
// Start API server if enabled
try {
const config = await apiServerService.getCurrentConfig()
logger.info('API server config:', config)
// Check if there are any agents
let shouldStart = config.enabled
if (!shouldStart) {
try {
const { total } = await agentService.listAgents({ limit: 1 })
if (total > 0) {
shouldStart = true
logger.info(`Detected ${total} agent(s), auto-starting API server`)
}
} catch (error: any) {
logger.warn('Failed to check agent count:', error)
}
}
if (shouldStart) {
if (config.enabled) {
await apiServerService.start()
}
} catch (error: any) {

View File

@@ -142,7 +142,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -786,6 +786,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
@@ -875,7 +876,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>

View File

@@ -1,473 +0,0 @@
/**
* DiDi MCP Server Implementation
*
* Based on official DiDi MCP API capabilities.
* API Documentation: https://mcp.didichuxing.com/api?tap=api
*
* Provides ride-hailing services including map search, price estimation,
* order management, and driver tracking.
*
* Note: Only available in Mainland China.
*/
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const logger = loggerService.withContext('DiDiMCPServer')
export class DiDiMcpServer {
private _server: Server
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
private apiKey: string
constructor(apiKey?: string) {
this._server = new Server(
{
name: 'didi-mcp-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
// Get API key from parameter or environment variables
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
if (!this.apiKey) {
logger.warn('DIDI_API_KEY environment variable is not set')
}
this.setupRequestHandlers()
}
get server(): Server {
return this._server
}
private setupRequestHandlers() {
// List available tools
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'maps_textsearch',
description: 'Search for POI locations based on keywords and city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'Query city'
},
keywords: {
type: 'string',
description: 'Search keywords'
},
location: {
type: 'string',
description: 'Location coordinates, format: longitude,latitude'
}
},
required: ['keywords', 'city']
}
},
{
name: 'taxi_cancel_order',
description: 'Cancel a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation or query results'
},
reason: {
type: 'string',
description:
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
}
},
required: ['order_id']
}
},
{
name: 'taxi_create_order',
description: 'Create taxi order directly via API without opening any app interface',
inputSchema: {
type: 'object',
properties: {
caller_car_phone: {
type: 'string',
description: 'Caller phone number (optional)'
},
estimate_trace_id: {
type: 'string',
description: 'Estimation trace ID from estimation results'
},
product_category: {
type: 'string',
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
}
},
required: ['product_category', 'estimate_trace_id']
}
},
{
name: 'taxi_estimate',
description: 'Get available ride-hailing vehicle types and fare estimates',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
from_name: {
type: 'string',
description: 'Departure location name'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
},
to_name: {
type: 'string',
description: 'Destination name'
}
},
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
}
},
{
name: 'taxi_generate_ride_app_link',
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
product_category: {
type: 'string',
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
}
},
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
}
},
{
name: 'taxi_get_driver_location',
description: 'Get real-time driver location for a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Taxi order ID'
}
},
required: ['order_id']
}
},
{
name: 'taxi_query_order',
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
}
}
}
}
]
}
})
// Handle tool calls
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'maps_textsearch':
return await this.handleMapsTextSearch(args)
case 'taxi_cancel_order':
return await this.handleTaxiCancelOrder(args)
case 'taxi_create_order':
return await this.handleTaxiCreateOrder(args)
case 'taxi_estimate':
return await this.handleTaxiEstimate(args)
case 'taxi_generate_ride_app_link':
return await this.handleTaxiGenerateRideAppLink(args)
case 'taxi_get_driver_location':
return await this.handleTaxiGetDriverLocation(args)
case 'taxi_query_order':
return await this.handleTaxiQueryOrder(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
logger.error(`Error calling tool ${name}:`, error as Error)
throw error
}
})
}
private async handleMapsTextSearch(args: any) {
const { city, keywords, location } = args
const params = {
name: 'maps_textsearch',
arguments: {
keywords,
city,
...(location && { location })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Maps text search error:', error as Error)
throw error
}
}
private async handleTaxiCancelOrder(args: any) {
const { order_id, reason } = args
const params = {
name: 'taxi_cancel_order',
arguments: {
order_id,
...(reason && { reason })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi cancel order error:', error as Error)
throw error
}
}
private async handleTaxiCreateOrder(args: any) {
const { caller_car_phone, estimate_trace_id, product_category } = args
const params = {
name: 'taxi_create_order',
arguments: {
product_category,
estimate_trace_id,
...(caller_car_phone && { caller_car_phone })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi create order error:', error as Error)
throw error
}
}
private async handleTaxiEstimate(args: any) {
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
const params = {
name: 'taxi_estimate',
arguments: {
from_lng,
from_lat,
from_name,
to_lng,
to_lat,
to_name
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi estimate error:', error as Error)
throw error
}
}
private async handleTaxiGenerateRideAppLink(args: any) {
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
const params = {
name: 'taxi_generate_ride_app_link',
arguments: {
from_lng,
from_lat,
to_lng,
to_lat,
...(product_category && { product_category })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi generate ride app link error:', error as Error)
throw error
}
}
private async handleTaxiGetDriverLocation(args: any) {
const { order_id } = args
const params = {
name: 'taxi_get_driver_location',
arguments: {
order_id
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi get driver location error:', error as Error)
throw error
}
}
private async handleTaxiQueryOrder(args: any) {
const { order_id } = args
const params = {
name: 'taxi_query_order',
arguments: {
...(order_id && { order_id })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi query order error:', error as Error)
throw error
}
}
private async makeRequest(method: string, params: any): Promise<any> {
const requestData = {
jsonrpc: '2.0',
method: method,
id: Date.now(),
...(Object.keys(params).length > 0 && { params })
}
// API key is passed as URL parameter
const url = `${this.baseUrl}?key=${this.apiKey}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
}
return data.result
}
}
export default DiDiMcpServer

View File

@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import * as z from 'zod'
import { z } from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')

View File

@@ -3,7 +3,6 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search'
import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
@@ -43,10 +42,6 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.didiMCP: {
const apiKey = envs.DIDI_API_KEY
return new DiDiMcpServer(apiKey).server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import * as z from 'zod'
import { z } from 'zod'
export const RequestPayloadSchema = z.object({
url: z.url(),

View File

@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import * as z from 'zod'
import { z } from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')

View File

@@ -1,11 +1,5 @@
import { IpcChannel } from '@shared/IpcChannel'
import {
ApiServerConfig,
GetApiServerStatusResult,
RestartApiServerStatusResult,
StartApiServerStatusResult,
StopApiServerStatusResult
} from '@types'
import { ApiServerConfig } from '@types'
import { ipcMain } from 'electron'
import { apiServer } from '../apiServer'
@@ -58,7 +52,7 @@ export class ApiServerService {
registerIpcHandlers(): void {
// API Server
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
try {
await this.start()
return { success: true }
@@ -67,7 +61,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
try {
await this.stop()
return { success: true }
@@ -76,7 +70,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
try {
await this.restart()
return { success: true }
@@ -85,7 +79,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
try {
const config = await this.getCurrentConfig()
return {

View File

@@ -1,15 +1,17 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, net } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -24,6 +26,7 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -63,6 +66,7 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -243,9 +247,37 @@ export default class AppUpdater {
}
}
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}
/**
@@ -317,9 +349,38 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,16 +147,11 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = this.getDbPath(id)
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -249,8 +244,7 @@ class KnowledgeService {
dimensions
})
try {
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

@@ -1,5 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -37,66 +36,3 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

View File

@@ -274,4 +274,46 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -4,7 +4,7 @@ import {
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import * as z from 'zod'
import { z } from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation

View File

@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService'
@@ -23,10 +22,6 @@ export class OcrService {
this.registry.delete(providerId)
}
public listProviderIds(): string[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
@@ -44,5 +39,3 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))

View File

@@ -1,128 +0,0 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { promisify } from 'util'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
super()
}
public isAvailable(): boolean {
return (
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
)
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
private getImgDir(): string {
return path.join(this.getOvOcrPath(), 'img')
}
private getOutputDir(): string {
return path.join(this.getOvOcrPath(), 'output')
}
private async clearDirectory(dirPath: string): Promise<void> {
if (fs.existsSync(dirPath)) {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await this.clearDirectory(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} else {
// If the directory does not exist, create it
await fs.promises.mkdir(dirPath, { recursive: true })
}
}
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
const imgDir = this.getImgDir()
const targetFilePath = path.join(imgDir, targetFileName)
await fs.promises.copyFile(sourceFilePath, targetFilePath)
}
private async runOcrBatch(): Promise<void> {
const ovOcrPath = this.getOvOcrPath()
try {
// Execute run.bat in the ov-ocr directory
await execAsync(`"${PATH_BAT_FILE}"`, {
cwd: ovOcrPath,
timeout: 60000 // 60 second timeout
})
} catch (error) {
logger.error(`Error running ovocr batch: ${error}`)
throw new Error(`Failed to run OCR batch: ${error}`)
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
try {
// 1. Clear img directory and output directory
await this.clearDirectory(this.getImgDir())
await this.clearDirectory(this.getOutputDir())
// 2. Copy file to img directory
const fileName = path.basename(filePath)
await this.copyFileToImgDir(filePath, fileName)
logger.info(`File copied to img directory: ${fileName}`)
// 3. Run run.bat
logger.info('Running OV OCR batch process...')
await this.runOcrBatch()
// 4. Check that output/[basename].txt file exists
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
if (!fs.existsSync(outputFilePath)) {
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
}
// 5. Read output/[basename].txt file content
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
// 6. Return result
return { text: ocrText }
} catch (error) {
logger.error(`Error during OV OCR process: ${error}`)
throw error
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()

View File

@@ -1,7 +1,7 @@
import { loadOcrImage } from '@main/utils/ocr'
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { net } from 'electron'
import * as z from 'zod'
import { z } from 'zod'
import { OcrBaseService } from './OcrBaseService'

View File

@@ -1,8 +1,8 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import * as fs from 'fs'
import OpenAI from 'openai'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -12,7 +12,6 @@ import {
FileListResponse,
FileMetadata,
FileUploadResponse,
GetApiServerStatusResult,
KnowledgeBaseParams,
KnowledgeItem,
KnowledgeSearchResult,
@@ -23,11 +22,8 @@ import {
OcrProvider,
OcrResult,
Provider,
RestartApiServerStatusResult,
S3Config,
Shortcut,
StartApiServerStatusResult,
StopApiServerStatusResult,
SupportedOcrFile,
ThemeMode,
WebDavConfig
@@ -55,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -227,7 +223,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
add: ({
base,
item,
@@ -394,16 +390,7 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -480,8 +467,7 @@ const api = {
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
@@ -501,12 +487,6 @@ const api = {
ipcRenderer.removeListener(channel, listener)
}
}
},
apiServer: {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
}
}

View File

@@ -20,7 +20,6 @@ import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
import { VideoPage } from './pages/video/VideoPage'
const Router: FC = () => {
const { navbarPosition } = useNavbarPosition()
@@ -41,7 +40,6 @@ const Router: FC = () => {
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
<Route path="/video" element={<VideoPage />} />
</Routes>
</ErrorBoundary>
)

View File

@@ -6,8 +6,6 @@
import { loggerService } from '@logger'
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai'
@@ -357,11 +355,7 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
error: chunk.error as Record<string, any>
})
break

View File

@@ -12,23 +12,8 @@ import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type {
Assistant,
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -98,8 +83,10 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
}
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
// 确保配置存在
if (!this.config) {
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -513,34 +500,6 @@ export default class ModernAiProvider {
return images
}
/**
* We manually implement this method before aisdk supports it well
*/
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
return this.legacyProvider.createVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
return this.legacyProvider.retrieveVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
return this.legacyProvider.retrieveVideoContent(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
return this.legacyProvider.deleteVideo(params)
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
}

View File

@@ -70,19 +70,13 @@ export abstract class BaseApiClient<
{
public provider: Provider
protected host: string
protected apiKey: string
protected sdkInstance?: TSdkInstance
constructor(provider: Provider) {
this.provider = provider
this.host = this.getBaseURL()
}
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
this.apiKey = this.getApiKey()
}
/**

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,9 +1,3 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import {
ChatCompletionContentPart,
ChatCompletionContentPartRefusal,
ChatCompletionTool
} from '@cherrystudio/openai/resources'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -84,6 +78,8 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { t } from 'i18next'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
import { GenericChunk } from '../../middleware/schemas'
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'

View File

@@ -1,6 +1,4 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import {
isClaudeReasoningModel,
isOpenAIReasoningModel,
@@ -26,6 +24,7 @@ import {
ReasoningEffortOptionalParams
} from '@renderer/types/sdk'
import { formatApiHost } from '@renderer/utils/api'
import OpenAI, { AzureOpenAI } from 'openai'
import { BaseApiClient } from '../BaseApiClient'
@@ -168,7 +167,8 @@ export abstract class OpenAIBaseClient<
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
}
}) as TSdkInstance
}

View File

@@ -1,5 +1,3 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types'
@@ -36,12 +34,6 @@ import {
OpenAIResponseSdkTool,
OpenAIResponseSdkToolCall
} from '@renderer/types/sdk'
import {
CreateVideoParams,
DeleteVideoParams,
RetrieveVideoContentParams,
RetrieveVideoParams
} from '@renderer/types/video'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
isSupportedToolUse,
@@ -53,6 +45,8 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { MB } from '@shared/config/constant'
import { t } from 'i18next'
import { isEmpty } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
import { OpenAIAPIClient } from './OpenAIApiClient'
@@ -158,26 +152,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return await sdk.responses.create(payload, options)
}
public async createVideo(params: CreateVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.create(params.params, params.options)
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.retrieve(params.videoId, params.options)
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<Response> {
const sdk = await this.getSdkInstance()
return sdk.videos.downloadContent(params.videoId, params.query, params.options)
}
public async deleteVideo(params: DeleteVideoParams): Promise<OpenAI.Videos.VideoDeleteResponse> {
const sdk = await this.getSdkInstance()
return sdk.videos.delete(params.videoId, params.options)
}
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
if (file.size > 32 * MB) return undefined
try {
@@ -369,14 +343,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
switch (message.type) {
case 'function_call_output':
if (typeof message.output === 'string') {
sum += estimateTextTokens(message.output)
} else {
sum += message.output
.filter((item) => item.type === 'input_text')
.map((item) => estimateTextTokens(item.text))
.reduce((prev, cur) => prev + cur, 0)
}
sum += estimateTextTokens(message.output)
break
case 'function_call':
sum += estimateTextTokens(message.arguments)

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { objectKeys, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,5 +1,4 @@
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from '@cherrystudio/openai'
import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types'
import { Provider } from '@renderer/types'
import {
@@ -14,6 +13,7 @@ import {
SdkTool,
SdkToolCall
} from '@renderer/types/sdk'
import OpenAI from 'openai'
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
import { CompletionsContext } from '../middleware/types'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -5,22 +5,8 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type {
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
@@ -193,54 +179,6 @@ export default class AiProvider {
return this.apiClient.generateImage(params)
}
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.createVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.retrieveVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const response = await this.apiClient.retrieveVideoContent(params)
return {
type: 'openai',
response
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const result = await this.apiClient.deleteVideo(params)
return {
type: 'openai',
result
}
} else {
throw new Error('Video deletion is not supported by this provider')
}
}
public getBaseURL(): string {
return this.apiClient.getBaseURL()
}

View File

@@ -1,10 +1,10 @@
import OpenAI from '@cherrystudio/openai'
import { toFile } from '@cherrystudio/openai/uploads'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import FileManager from '@renderer/services/FileManager'
import { ChunkType } from '@renderer/types/chunk'
import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import OpenAI from 'openai'
import { toFile } from 'openai/uploads'
import { BaseApiClient } from '../../clients/BaseApiClient'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'

View File

@@ -4,8 +4,6 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
/**
@@ -188,14 +186,6 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 其他provider的通用处理
break
}
// OVMS+MCP's specific middleware
if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) {
builder.add({
name: 'no-think',
middleware: noThinkMiddleware()
})
}
}
/**

View File

@@ -1,52 +0,0 @@
import { loggerService } from '@logger'
import { LanguageModelMiddleware } from 'ai'
const logger = loggerService.withContext('noThinkMiddleware')
/**
* No Think Middleware
* Automatically appends ' /no_think' string to the end of user messages for the provider
* This prevents the model from generating unnecessary thinking process and returns results directly
* @returns LanguageModelMiddleware
*/
export function noThinkMiddleware(): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
const lastContent = message.content[message.content.length - 1]
// If the last content is text type, append ' /no_think'
if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') {
// Avoid duplicate additions
if (!lastContent.text.endsWith('/no_think')) {
logger.debug('Adding /no_think to user message')
return {
...message,
content: [
...message.content.slice(0, -1),
{
...lastContent,
text: lastContent.text + ' /no_think'
}
]
}
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@@ -24,8 +24,10 @@ import { generateText } from 'ai'
import { isEmpty } from 'lodash'
import { MemoryProcessor } from '../../services/MemoryProcessor'
import { exaSearchTool } from '../tools/ExaSearchTool'
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
import { memorySearchTool } from '../tools/MemorySearchTool'
import { tavilySearchTool } from '../tools/TavilySearchTool'
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
const logger = loggerService.withContext('SearchOrchestrationPlugin')
@@ -316,13 +318,28 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
if (needsSearch) {
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
// logger.info('🌐 Adding web search tool with pre-extracted keywords')
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
// 根据 Provider ID 动态选择工具
switch (assistant.webSearchProviderId) {
case 'exa':
logger.info('🌐 Adding Exa search tool (provider-specific)')
// Exa 工具直接接受单个查询字符串,使用第一个问题或合并所有问题
params.tools['builtin_exa_search'] = exaSearchTool(context.requestId)
break
case 'tavily':
logger.info('🌐 Adding Tavily search tool (provider-specific)')
// Tavily 工具直接接受单个查询字符串
params.tools['builtin_tavily_search'] = tavilySearchTool(context.requestId)
break
default:
logger.info('🌐 Adding web search tool with pre-extracted keywords')
// 其他 Provider 使用通用的 WebSearchTool
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
break
}
}
}

View File

@@ -3,7 +3,6 @@
* 处理文件内容提取、文件格式转换、文件上传等逻辑
*/
import type OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { FileMetadata, Message, Model } from '@renderer/types'
@@ -11,6 +10,7 @@ import { FileTypes } from '@renderer/types'
import { FileMessageBlock } from '@renderer/types/newMessage'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import type { FilePart, TextPart } from 'ai'
import type OpenAI from 'openai'
import { getAiSdkProviderId } from '../provider/factory'
import { getFileSizeLimit, supportsImageInput, supportsLargeFileUpload, supportsPdfInput } from './modelCapabilities'

View File

@@ -23,7 +23,6 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -160,14 +159,14 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(20),
stopWhen: stepCountIs(10),
maxRetries: 0
}
if (tools) {
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
params.system = assistant.prompt
}
logger.debug('params', params)
return {

View File

@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({ copilot: { defaultHeaders: {} } })
}
}))
import type { Model, Provider } from '@renderer/types'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
return {
get: (key: string) => store.get(key),
set: (key: string, value: string) => {
store.set(key, value)
}
}
}
const createCopilotProvider = (): Provider => ({
id: 'copilot',
type: 'openai',
name: 'GitHub Copilot',
apiKey: 'test-key',
apiHost: 'https://api.githubcopilot.com',
models: [],
isSystem: true
})
const createModel = (id: string, name = id): Model => ({
id,
name,
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
})
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
})
it('configures gpt-5-codex with the Copilot provider', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
})
it('uses the Copilot provider for other models and keeps headers', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})

View File

@@ -1,25 +0,0 @@
import type { Model } from '@renderer/types'
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
export const COPILOT_DEFAULT_HEADERS = {
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
'User-Agent': COPILOT_USER_AGENT,
'Editor-Version': COPILOT_EDITOR_VERSION,
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
'editor-version': COPILOT_EDITOR_VERSION,
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
'copilot-vision-request': 'true'
} as const
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
export function isCopilotResponsesModel(model: Model): boolean {
const normalizedId = model.id?.trim().toLowerCase()
const normalizedName = model.name?.trim().toLowerCase()
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
}

View File

@@ -28,8 +28,7 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
gemini: 'google', // Google Gemini -> google
'azure-openai': 'azure', // Azure OpenAI -> azure
'openai-response': 'openai', // OpenAI Responses -> openai
grok: 'xai', // Grok -> xai
copilot: 'github-copilot-openai-compatible'
grok: 'xai' // Grok -> xai
}
/**

View File

@@ -21,7 +21,6 @@ import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
@@ -63,14 +62,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider)
}
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider)
}
@@ -111,9 +109,6 @@ function formatProviderApiHost(provider: Provider): Provider {
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
@@ -156,26 +151,6 @@ export function providerToAiSdkConfig(
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
headers: {
...COPILOT_DEFAULT_HEADERS,
...storedHeaders,
...actualProvider.extra_headers
},
name: actualProvider.id,
includeUsage: true
})
return {
providerId: 'github-copilot-openai-compatible',
options
}
}
// 处理OpenAI模式
const extraOptions: any = {}
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
@@ -197,6 +172,15 @@ export function providerToAiSdkConfig(
}
}
}
// copilot
if (actualProvider.id === 'copilot') {
extraOptions.headers = {
...extraOptions.headers,
'editor-version': 'vscode/1.97.2',
'copilot-vision-request': 'true'
}
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
@@ -245,6 +229,7 @@ export function providerToAiSdkConfig(
}
}
// 如果AI SDK支持该provider使用原生配置
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@@ -292,17 +277,9 @@ export async function prepareSpecialProviderConfig(
) {
switch (provider.id) {
case 'copilot': {
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
const headers = {
...COPILOT_DEFAULT_HEADERS,
...defaultHeaders
}
const { token } = await window.api.copilot.getToken(headers)
const defaultHeaders = store.getState().copilot.defaultHeaders
const { token } = await window.api.copilot.getToken(defaultHeaders)
config.options.apiKey = token
config.options.headers = {
...headers,
...config.options.headers
}
break
}
case 'cherryai': {

View File

@@ -32,14 +32,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
supportsImageGeneration: true,
aliases: ['vertexai-anthropic']
},
{
id: 'github-copilot-openai-compatible',
name: 'GitHub Copilot OpenAI Compatible',
import: () => import('@opeoginni/github-copilot-openai-compatible'),
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
supportsImageGeneration: false,
aliases: ['copilot', 'github-copilot']
},
{
id: 'bedrock',
name: 'Amazon Bedrock',
@@ -55,14 +47,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createPerplexity',
supportsImageGeneration: false,
aliases: ['perplexity']
},
{
id: 'mistral',
name: 'Mistral',
import: () => import('@ai-sdk/mistral'),
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
}
] as const

View File

@@ -0,0 +1,166 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('ExaSearchTool')
/**
* Exa 专用搜索工具 - 暴露 Exa 的高级搜索能力给 LLM
* 支持 Neural Search、Category Filtering、Date Range 等功能
*/
export const exaSearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('exa')
if (!webSearchProvider) {
throw new Error('Exa provider not found or not configured')
}
return tool({
name: 'builtin_exa_search',
description: `Advanced AI-powered search using Exa.ai with neural understanding and filtering capabilities.
Key Features:
- Neural Search: AI-powered semantic search that understands intent
- Search Type: Choose between neural (AI), keyword (traditional), or auto mode
- Category Filter: Focus on specific content types (company, research paper, news, etc.)
- Date Range: Filter by publication date
- Auto-prompt: Let Exa optimize your query automatically
Best for: Research, finding specific types of content, semantic search, and understanding complex queries.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
numResults: z.number().min(1).max(20).optional().describe('Number of results to return (1-20, default: 5)'),
type: z
.enum(['neural', 'keyword', 'auto', 'fast'])
.optional()
.describe(
'Search type: neural (embeddings-based), keyword (Google-like SERP), auto (default, intelligently combines both), or fast (streamlined versions)'
),
category: z
.string()
.optional()
.describe(
'Filter by content category: company, research paper, news, github, tweet, movie, song, personal site, pdf, etc.'
),
startPublishedDate: z
.string()
.optional()
.describe('Start date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endPublishedDate: z
.string()
.optional()
.describe('End date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
startCrawlDate: z
.string()
.optional()
.describe('Start date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endCrawlDate: z
.string()
.optional()
.describe('End date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
useAutoprompt: z.boolean().optional().describe('Let Exa optimize your query automatically (recommended: true)')
}),
execute: async (params, { abortSignal }) => {
// 构建 provider 特定参数(排除 query 和 numResults这些由系统控制
const providerParams: ProviderSpecificParams = {
exa: {
type: params.type,
category: params.category,
startPublishedDate: params.startPublishedDate,
endPublishedDate: params.endPublishedDate,
startCrawlDate: params.startCrawlDate,
endCrawlDate: params.endCrawlDate,
useAutoprompt: params.useAutoprompt
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Exa search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Exa AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Exa 特有的元数据
if ('favicon' in result && result.favicon) {
citation.favicon = result.favicon
}
if ('author' in result && result.author) {
citation.author = result.author
}
if ('publishedDate' in result && result.publishedDate) {
citation.publishedDate = result.publishedDate
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
if ('highlights' in result && result.highlights) {
citation.highlights = result.highlights
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Exa search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Exa AI Search: Neural search with semantic understanding and rich metadata (author, publish date, highlights).'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type ExaSearchToolOutput = InferToolOutput<ReturnType<typeof exaSearchTool>>
export type ExaSearchToolInput = InferToolInput<ReturnType<typeof exaSearchTool>>

View File

@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { isEmpty } from 'lodash'
import * as z from 'zod'
import { z } from 'zod'
/**
* 知识库搜索工具

View File

@@ -1,7 +1,7 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
import { MemoryProcessor } from '../../services/MemoryProcessor'

View File

@@ -0,0 +1,161 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('TavilySearchTool')
/**
* Tavily 专用搜索工具 - 暴露 Tavily 的高级搜索能力给 LLM
* 支持 AI-powered answers、Search depth control、Topic filtering 等功能
*/
export const tavilySearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('tavily')
if (!webSearchProvider) {
throw new Error('Tavily provider not found or not configured')
}
return tool({
name: 'builtin_tavily_search',
description: `AI-powered search using Tavily with direct answers and comprehensive content extraction.
Key Features:
- Direct AI Answer: Get a concise, factual answer extracted from search results
- Search Depth: Choose between basic (fast) or advanced (comprehensive) search
- Topic Focus: Filter by general, news, or finance topics
- Full Content: Access complete webpage content, not just snippets
- Rich Media: Optionally include relevant images from search results
Best for: Quick factual answers, news monitoring, financial research, and comprehensive content analysis.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
maxResults: z
.number()
.min(1)
.max(20)
.optional()
.describe('Maximum number of results to return (1-20, default: 5)'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe('Topic filter: general (default), news (latest news), or finance (financial/market data)'),
searchDepth: z
.enum(['basic', 'advanced'])
.optional()
.describe('Search depth: basic (faster, top results) or advanced (slower, more comprehensive)'),
includeAnswer: z
.boolean()
.optional()
.describe('Include AI-generated direct answer extracted from results (default: true)'),
includeRawContent: z
.boolean()
.optional()
.describe('Include full webpage content instead of just snippets (default: true)'),
includeImages: z.boolean().optional().describe('Include relevant images from search results (default: false)')
}),
execute: async (params, { abortSignal }) => {
try {
// 构建 provider 特定参数
const providerParams: ProviderSpecificParams = {
tavily: {
topic: params.topic,
searchDepth: params.searchDepth,
includeAnswer: params.includeAnswer,
includeRawContent: params.includeRawContent,
includeImages: params.includeImages
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Tavily search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('Tavily search aborted')
throw error
}
logger.error('Tavily search failed:', error as Error)
throw new Error(`Tavily search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Tavily AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Tavily 特有的元数据
if ('answer' in result && result.answer) {
citation.answer = result.answer // Tavily 的直接答案
}
if ('images' in result && result.images && result.images.length > 0) {
citation.images = result.images // Tavily 的图片
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Tavily search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Tavily AI Search: AI-powered with direct answers, full content extraction, and optional image results.'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type TavilySearchToolOutput = InferToolOutput<ReturnType<typeof tavilySearchTool>>
export type TavilySearchToolInput = InferToolInput<ReturnType<typeof tavilySearchTool>>

View File

@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
/**
* 使用预提取关键词的网络搜索工具
@@ -40,7 +40,7 @@ You can use this tool as-is to search with the prepared queries, or provide addi
.describe('Optional additional context, keywords, or specific focus to enhance the search')
}),
execute: async ({ additionalContext }) => {
execute: async ({ additionalContext }, { abortSignal }) => {
let finalQueries = [...extractedKeywords.question]
if (additionalContext?.trim()) {
@@ -67,7 +67,15 @@ You can use this tool as-is to search with the prepared queries, or provide addi
links: extractedKeywords.links
}
}
searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
// abortSignal?.addEventListener('abort', () => {
// console.log('tool_call_abortSignal', abortSignal?.aborted)
// })
searchResults = await WebSearchService.processWebsearch(
webSearchProvider!,
extractResults,
requestId,
abortSignal
)
return searchResults
},

View File

@@ -6,7 +6,6 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -53,12 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -106,7 +100,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -149,16 +142,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -429,13 +412,6 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -443,11 +419,6 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

View File

@@ -9,8 +9,6 @@ import {
CreateAgentRequest,
CreateAgentResponse,
CreateAgentResponseSchema,
CreateAgentSessionResponse,
CreateAgentSessionResponseSchema,
CreateSessionForm,
CreateSessionRequest,
GetAgentResponse,
@@ -173,12 +171,12 @@ export class AgentApiClient {
}
}
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateAgentSessionResponse> {
public async createSession(agentId: string, session: CreateSessionForm): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).base
try {
const payload = session satisfies CreateSessionRequest
const response = await this.axios.post(url, payload)
const data = CreateAgentSessionResponseSchema.parse(response.data)
const data = GetAgentSessionResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to add session.')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
*/
min-width: 35ch;
min-width: 45ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};

View File

@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
@@ -203,80 +203,3 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
})
}, [onHeightChange])
}
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
const domAtPos = view.domAtPos(position)
let node: Node | null = domAtPos.node
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
while (node) {
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
return node
}
node = node.parentElement
}
return null
}, [])
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
if (previousHighlight) {
previousHighlight.classList.remove('animation-locate-highlight')
}
element.classList.add('animation-locate-highlight')
const handleAnimationEnd = () => {
element.classList.remove('animation-locate-highlight')
element.removeEventListener('animationend', handleAnimationEnd)
}
element.addEventListener('animationend', handleAnimationEnd)
}, [])
return useCallback(
(lineNumber: number, options?: UseScrollToLineOptions) => {
const view = editorViewRef.current
if (!view) return
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
const lineElement = findLineElement(view, targetLine.from)
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
if (options?.highlight) {
requestAnimationFrame(() => highlightLine(view, lineElement))
}
return
}
view.dispatch({
effects: EditorView.scrollIntoView(targetLine.from, {
y: 'start'
})
})
if (!options?.highlight) {
return
}
setTimeout(() => {
const fallbackElement = findLineElement(view, targetLine.from)
if (fallbackElement) {
highlightLine(view, fallbackElement)
}
}, 200)
},
[editorViewRef, findLineElement, highlightLine]
)
}

View File

@@ -5,14 +5,13 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
}
export interface CodeEditorProps {
@@ -182,11 +181,8 @@ const CodeEditor = ({
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({
save: handleSave,
scrollToLine
save: handleSave
}))
return (

View File

@@ -1,48 +0,0 @@
import { FC, memo, useMemo } from 'react'
interface HighlightTextProps {
text: string
keyword: string
caseSensitive?: boolean
className?: string
}
/**
* Text highlighting component that marks keyword matches
*/
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// Escape regex special characters
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(`(${escapedKeyword})`, flags)
// Split text by keyword matches
const parts = text.split(regex)
return (
<>
{parts.map((part, index) => {
// Check if part matches keyword
const isMatch = regex.test(part)
regex.lastIndex = 0 // Reset regex state
if (isMatch) {
return <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)

View File

@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}

View File

@@ -25,7 +25,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
const { enableSpellCheck } = useSettings()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -76,8 +76,6 @@ const WebviewContainer = memo(
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
// Set link opening behavior for this webview
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
}
@@ -106,22 +104,6 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
try {
const webviewId = webviewRef.current.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView may not be ready yet, settings will be applied in dom-ready event
logger.debug(`WebView ${appid} not ready for settings update`)
}
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
const WebviewStyle: React.CSSProperties = {
width: '100%',
height: '100%',

View File

@@ -10,7 +10,6 @@ interface ShowParams {
providerId: string
title?: string
showHealthCheck?: boolean
providerType?: 'llm' | 'webSearch' | 'preprocess'
}
interface Props extends ShowParams {
@@ -20,7 +19,7 @@ interface Props extends ShowParams {
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
@@ -33,20 +32,14 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
}
const ListComponent = useMemo(() => {
const type =
providerType ||
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
switch (type) {
case 'webSearch':
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'preprocess':
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'llm':
default:
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
if (isWebSearchProviderId(providerId)) {
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
}, [providerId, showHealthCheck, providerType])
if (isPreprocessProviderId(providerId)) {
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
return (
<Modal

View File

@@ -17,7 +17,7 @@ import {
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { agentModelFilter, getModelLogo } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
@@ -245,23 +245,14 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? [])
.filter((m) =>
agentModelFilter({
id: m.id,
provider: m.provider || '',
name: m.name,
group: ''
})
)
.map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
return (models ?? []).map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {

View File

@@ -98,7 +98,7 @@ export const SessionModal: React.FC<Props> = ({
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession } = useSessions(agentId)
const { updateSession } = useUpdateSession(agentId)
const updateSession = useUpdateSession(agentId)
const { agent } = useAgent(agentId)
const isEditing = (session?: AgentSessionEntity) => session !== undefined

View File

@@ -1,2 +0,0 @@
// Attribute used to store the original source line number in markdown editors
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'

View File

@@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd'
@@ -30,156 +29,6 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor')
/**
* Find element by line number with fallback strategies:
* 1. Exact line + content match
* 2. Exact line match
* 3. Closest line <= target
*/
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
if (allElements.length === 0) {
logger.warn('No elements with data-source-line attribute found')
return null
}
const exactMatches = editorDom.querySelectorAll(
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
) as NodeListOf<HTMLElement>
// Strategy 1: Exact line + content match
if (exactMatches.length > 1 && lineContent) {
for (const match of Array.from(exactMatches)) {
if (match.textContent?.includes(lineContent)) {
return match
}
}
}
// Strategy 2: Exact line match
if (exactMatches.length > 0) {
return exactMatches[0]
}
// Strategy 3: Closest line <= target
let closestElement: HTMLElement | null = null
let closestLine = 0
for (const el of allElements) {
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
if (sourceLine <= lineNumber && sourceLine > closestLine) {
closestLine = sourceLine
closestElement = el
}
}
return closestElement
}
/**
* Create fixed-position highlight overlay at element location
* with boundary detection to prevent overflow and toolbar overlap
*/
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
try {
// Remove previous overlay
const previousOverlay = document.body.querySelector('.highlight-overlay')
if (previousOverlay) {
previousOverlay.remove()
}
const editorWrapper = container.closest('.rich-editor-wrapper')
// Create overlay at element position
const rect = element.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'highlight-overlay animation-locate-highlight'
overlay.style.position = 'fixed'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
overlay.style.pointerEvents = 'none'
overlay.style.zIndex = '9999'
overlay.style.borderRadius = '4px'
document.body.appendChild(overlay)
// Update overlay position and visibility on scroll
const updatePosition = () => {
const newRect = element.getBoundingClientRect()
const newContainerRect = container.getBoundingClientRect()
// Update position
overlay.style.left = `${newRect.left}px`
overlay.style.top = `${newRect.top}px`
overlay.style.width = `${newRect.width}px`
overlay.style.height = `${newRect.height}px`
// Get current toolbar bottom (it might change)
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
// Check if overlay is within visible bounds
const overlayTop = newRect.top
const overlayBottom = newRect.bottom
const visibleTop = currentToolbarBottom // Don't overlap toolbar
const visibleBottom = newContainerRect.bottom
// Hide overlay if any part is outside the visible container area
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
overlay.style.opacity = '0'
overlay.style.visibility = 'hidden'
} else {
overlay.style.opacity = '1'
overlay.style.visibility = 'visible'
}
}
container.addEventListener('scroll', updatePosition)
// Auto-remove after animation
const handleAnimationEnd = () => {
overlay.remove()
container.removeEventListener('scroll', updatePosition)
overlay.removeEventListener('animationend', handleAnimationEnd)
}
overlay.addEventListener('animationend', handleAnimationEnd)
} catch (error) {
logger.error('Failed to create highlight overlay:', error as Error)
}
}
/**
* Scroll to element and show highlight after scroll completes
*/
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
let scrollTimeout: NodeJS.Timeout
const handleScroll = () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
container.removeEventListener('scroll', handleScroll)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}, 150)
}
container.addEventListener('scroll', handleScroll)
// Fallback: if element already in view (no scroll happens)
setTimeout(() => {
const initialScrollTop = container.scrollTop
setTimeout(() => {
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
container.removeEventListener('scroll', handleScroll)
clearTimeout(scrollTimeout)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}
}, 200)
}, 50)
}
const RichEditor = ({
ref,
initialContent = '',
@@ -523,22 +372,6 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value
}
},
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,

View File

@@ -111,8 +111,6 @@ export interface RichEditorRef {
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void

View File

@@ -2,7 +2,6 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
@@ -12,7 +11,6 @@ import {
markdownToPreviewText
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention'
@@ -38,31 +36,6 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor')
// Create extension to preserve data-source-line attribute
const SourceLineAttribute = Extension.create({
name: 'sourceLineAttribute',
addGlobalAttributes() {
return [
{
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
attributes: {
dataSourceLine: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
return value
},
renderHTML: (attributes) => {
if (!attributes.dataSourceLine) return {}
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
}
}
}
}
]
}
})
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
@@ -223,7 +196,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
SourceLineAttribute,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]

View File

@@ -32,7 +32,6 @@ import {
Sparkle,
Sun,
Terminal,
Video,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -107,8 +106,6 @@ const getTabIcon = (
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
case 'video':
return <Video size={14} />
default:
return null
}

View File

@@ -23,8 +23,7 @@ export const ToastPortal = () => {
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
// `w-auto` would not overwrite default style, which set the width to a fixed value and causes text overflow.
base: 'hero-toast w-auto! max-w-[50vw]'
base: 'hero-toast'
}
}}
/>,

View File

@@ -1,6 +1,5 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import { RequireSome } from '@renderer/types'
import { t } from 'i18next'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
@@ -55,7 +54,7 @@ export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast({ title: t('common.loading'), ...args })
return addToast(args)
}
export const getToastUtilities = () =>

View File

@@ -1,101 +0,0 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@@ -74,7 +74,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
`
const NavbarLeftContainer = styled.div`
min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'};
min-width: var(--assistants-width);
padding: 0 10px;
display: flex;
flex-direction: row;

View File

@@ -26,8 +26,7 @@ import {
Palette,
Settings,
Sparkle,
Sun,
Video
Sun
} from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -140,8 +139,7 @@ const MainMenus: FC = () => {
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />,
video: <Video size={18} className="icon" />
code_tools: <Code size={18} className="icon" />
}
const pathMap = {
@@ -153,8 +151,7 @@ const MainMenus: FC = () => {
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes',
video: '/video'
notes: '/notes'
}
return sidebarIcons.visible.map((icon) => {

View File

@@ -22,6 +22,8 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
@@ -30,13 +32,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -44,6 +46,7 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -147,9 +150,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
},
{
id: 'stepfun',
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
bodered: true
},
{
@@ -260,6 +263,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://www.tiangong.cn/',
bodered: true
},
{
id: 'hugging-chat',
name: 'HuggingChat',
logo: HuggingChatLogo,
url: 'https://huggingface.co/chat/',
bodered: true
},
{
id: 'Felo',
name: 'Felo',
@@ -287,6 +297,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://bot.n.cn/',
bodered: true
},
{
id: 'nm-search',
name: i18n.t('minapps.nami-ai-search'),
logo: NamiAiSearchLogo,
url: 'https://www.n.cn/',
bodered: true
},
{
id: 'thinkany',
name: 'ThinkAny',
@@ -297,6 +314,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,7 +24,6 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -44,7 +43,6 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -68,8 +66,6 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -146,46 +142,19 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
return true
}
@@ -296,11 +265,7 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
return modelId.startsWith('qwen3') && modelId.includes('thinking')
}
// Doubao 支持思考模式的模型正则
@@ -364,10 +329,7 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -481,8 +443,6 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },

View File

@@ -1,12 +1,11 @@
import OpenAI from '@cherrystudio/openai'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding'
import { Model } from '@renderer/types'
import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts'
import { getWebSearchTools } from '../tools'
import { isOpenAIReasoningModel } from './reasoning'
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
import { isGenerateImageModel, isVisionModel } from './vision'
import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch'
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
@@ -247,7 +246,3 @@ export const isOpenAIOpenWeightModel = (model: Model) => {
// zhipu 视觉推理模型用这组 special token 标记推理结果
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const
export const agentModelFilter = (model: Model): boolean => {
return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
}

View File

@@ -1,149 +0,0 @@
import { SystemProviderId, Video } from '@renderer/types'
// Hard-encoded for now. We may implement a function to filter video generation model from provider.models.
export const videoModelsMap = {
openai: ['sora-2', 'sora-2-pro'] as const
} as const satisfies Partial<Record<SystemProviderId, string[]>>
// Mock data for testing
export const mockVideos: Video[] = [
{
id: '1',
type: 'openai',
status: 'downloaded',
prompt: 'A beautiful sunset over the ocean with waves crashing',
thumbnail: 'https://picsum.photos/200/200?random=1',
fileId: 'file-001',
providerId: 'openai',
name: 'video-001',
metadata: {
id: 'video-001',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2',
progress: 100,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'completed'
}
},
{
id: '2',
type: 'openai',
status: 'in_progress',
prompt: 'A cat playing with a ball of yarn in slow motion',
progress: 65,
providerId: 'openai',
name: 'video-002',
metadata: {
id: 'video-002',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 65,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'in_progress'
}
},
{
id: '3',
type: 'openai',
status: 'queued',
prompt: 'Time-lapse of flowers blooming in a garden',
providerId: 'openai',
name: 'video-003',
metadata: {
id: 'video-003',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '12',
size: '1280x720',
status: 'queued'
}
},
{
id: '4',
type: 'openai',
prompt: 'Birds flying in formation against blue sky',
status: 'downloading',
progress: 80,
thumbnail: 'https://picsum.photos/200/200?random=4',
providerId: 'openai',
name: 'video-004',
metadata: {
id: 'video-004',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'completed'
}
},
{
id: '5',
type: 'openai',
status: 'failed',
error: { code: '400', message: 'Video generation failed' },
prompt: 'Mountain landscape with snow peaks and forest',
providerId: 'openai',
name: 'video-005',
metadata: {
id: 'video-005',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: { code: '400', message: 'Video generation failed' },
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'failed'
}
},
{
id: '6',
type: 'openai',
status: 'completed',
thumbnail: 'https://picsum.photos/200/200?random=6',
prompt: 'City street at night with neon lights reflecting on wet pavement',
providerId: 'openai',
name: 'video-006',
metadata: {
id: 'video-006',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '12',
size: '1024x1792',
status: 'completed'
}
}
]

View File

@@ -24,7 +24,7 @@ const visionAllowedModels = [
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qwen3-omni',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -82,14 +82,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -107,7 +107,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -1,7 +1,6 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
@@ -51,23 +50,10 @@ const ppocrOcr: OcrPpocrProvider = {
}
} as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr,
ovocr: ovOcr
paddleocr: ppocrOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)

View File

@@ -58,6 +58,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import {
AtLeast,
isSystemProvider,
Model,
OpenAIServiceTiers,
Provider,
ProviderType,
@@ -81,17 +82,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
anthropicApiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
},
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -109,6 +109,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
apiKey: '',
apiHost: 'https://aihubmix.com',
anthropicApiHost: 'https://aihubmix.com/anthropic',
isAnthropicModel: (m: Model) => m.id.includes('claude'),
models: SYSTEM_MODELS.aihubmix,
isSystem: true,
enabled: false
@@ -288,7 +289,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': {
id: 'new-api',
name: 'New API',
type: 'new-api',
type: 'openai',
apiKey: '',
apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000',
@@ -741,17 +742,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
ph8: {
api: {
url: 'https://ph8.co'
@@ -1431,5 +1432,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
}
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
return ['new-api', 'cherryin'].includes(provider.id)
}

Some files were not shown because too many files have changed in this diff Show More