diff --git a/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch b/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch new file mode 100644 index 0000000000..cebfdd00a5 --- /dev/null +++ b/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch @@ -0,0 +1,44 @@ +diff --git a/dist/index.js b/dist/index.js +index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "message") { +- controller.enqueue({ +- type: "text-end", +- id: value.item.id +- }); ++ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start ++ if (currentTextId) { ++ controller.enqueue({ ++ type: "text-end", ++ id: currentTextId ++ }); ++ } + currentTextId = null; + } else if (isResponseOutputItemDoneReasoningChunk(value)) { + const activeReasoningPart = activeReasoning[value.item.id]; +diff --git a/dist/index.mjs b/dist/index.mjs +index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "message") { +- controller.enqueue({ +- type: "text-end", +- id: value.item.id +- }); ++ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start ++ if (currentTextId) { ++ controller.enqueue({ ++ type: "text-end", ++ id: currentTextId ++ }); ++ } + currentTextId = null; + } else if (isResponseOutputItemDoneReasoningChunk(value)) { + const activeReasoningPart = activeReasoning[value.item.id]; diff --git a/electron-builder.yml b/electron-builder.yml index ad953f8fb8..f75cc0b99c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -129,112 +129,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-beta.1 + What's New in v1.7.0-beta.2 - 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 + New Features: + - Session Settings: Manage session-specific settings and model configurations independently + - Notes Full-Text Search: Search across all notes with match highlighting + - Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) + - Intel OV OCR: Hardware-accelerated OCR using Intel NPU + - Auto-start API Server: Automatically starts when agents exist - 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 + Improvements: + - Agent model selection now requires explicit user choice + - Added Mistral AI provider support + - Added NewAPI generic provider support + - Improved navbar layout consistency across different modes + - Enhanced chat component responsiveness + - Better code block display on small screens + - Updated OVMS to 2025.3 official release + - Added Greek language support 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 - - 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 + - Fixed GitHub Copilot gpt-5-codex streaming issues + - Fixed assistant creation failures + - Fixed translate auto-copy functionality + - Fixed miniapps external link opening + - Fixed message layout and overflow issues + - Fixed API key parsing to preserve spaces + - Fixed agent display in different navbar layouts - v1.7.0-beta.1 新特性 + v1.7.0-beta.2 新特性 - 核心功能: - - Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题 - - Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成 - - Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪 - - 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验 + 新功能: + - 会话设置:独立管理会话特定的设置和模型配置 + - 笔记全文搜索:跨所有笔记搜索并高亮匹配内容 + - 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区) + - Intel OV OCR:使用 Intel NPU 的硬件加速 OCR + - 自动启动 API 服务器:当存在 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 + 改进: + - Agent 模型选择现在需要用户显式选择 + - 添加 Mistral AI 提供商支持 + - 添加 NewAPI 通用提供商支持 + - 改进不同模式下的导航栏布局一致性 + - 增强聊天组件响应式设计 + - 优化小屏幕代码块显示 + - 更新 OVMS 至 2025.3 正式版 + - 添加希腊语支持 问题修复: - - 修复 Swagger UI 无法打开 - - 修复 AI SDK 错误显示,添加语法高亮 - - 修复 Webview 搜索快捷键处理 - - 修复 CherryIn 提供商的 Agent 模型可见性 - - 修复会话消息排序和持久化 - - 修复 Anthropic 模型在 Agent 配置中的可见性 - - 修复知识库删除和网页搜索 RAG 错误 - - 修复缺失提供商的迁移问题 - - 技术更新: - - 升级至 React 19.2.0 - - 增强 Claude Code 服务流式传输支持 - - 改进消息转换和流式生命周期 - - 数据库迁移系统,支持自动模式同步 - - 优化打包大小和依赖管理 + - 修复 GitHub Copilot gpt-5-codex 流式传输问题 + - 修复助手创建失败 + - 修复翻译自动复制功能 + - 修复小程序外部链接打开 + - 修复消息布局和溢出问题 + - 修复 API 密钥解析以保留空格 + - 修复不同导航栏布局中的 Agent 显示 diff --git a/package.json b/package.json index feb82d5112..c4edb928f5 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", - "@opeoginni/github-copilot-openai-compatible": "0.1.18", + "@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", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index c91f5f9333..b5b04c340d 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -369,6 +369,7 @@ export enum IpcChannel { // OCR OCR_ocr = 'ocr:ocr', + OCR_ListProviders = 'ocr:list-providers', // OVMS Ovms_AddModel = 'ovms:add-model', diff --git a/resources/scripts/install-ovms.js b/resources/scripts/install-ovms.js index 57710e43f6..e4a5cf0444 100644 --- a/resources/scripts/install-ovms.js +++ b/resources/scripts/install-ovms.js @@ -5,105 +5,171 @@ const { execSync } = require('child_process') const { downloadWithPowerShell } = require('./download') // Base URL for downloading OVMS binaries -const OVMS_PKG_NAME = 'ovms250911.zip' -const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`] +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' /** - * Downloads and extracts the OVMS binary for the specified platform + * 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 */ -async function downloadOvmsBinary() { - // Create output directory structure - OVMS goes into its own subdirectory + +/** + * Clean old OVMS installation if it exists + */ +function cleanOldOvmsInstallation() { + console.log('Cleaning the existing OVMS installation...') 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)) { - fs.rmSync(csOvmsDir, { recursive: true }) - } - - const tempdir = os.tmpdir() - const tempFilename = path.join(tempdir, 'ovms.zip') - - // Try each URL until one succeeds - let downloadSuccess = false - let lastError = null - - 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 + fs.rmSync(csOvmsDir, { recursive: true }) } 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...`) - } + console.warn(`Failed to clean up old OVMS installation: ${error.message}`) + return 110 } } - // Check if any download succeeded - if (!downloadSuccess) { - console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`) + 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 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) return 103 } - try { - console.log(`Extracting to ${csDir}...`) + // 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 { // Use tar.exe to extract the ZIP file - console.log(`Extracting OVMS to ${csDir}...`) - execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' }) - console.log(`OVMS extracted to ${csDir}`) + console.log(`Extracting OVMS Base 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: ${error.message}`) - 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 - } - + fs.unlinkSync(tempFilename) 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 } @@ -158,7 +224,27 @@ async function installOvms() { return 102 } - return await downloadOvmsBinary() + // 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 } // Run the installation diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts index 0cba77aaa3..3cb81f4124 100644 --- a/src/main/apiServer/server.ts +++ b/src/main/apiServer/server.ts @@ -1,7 +1,8 @@ import { createServer } from 'node:http' +import { loggerService } from '@logger' + import { agentService } from '../services/agents' -import { loggerService } from '../services/LoggerService' import { app } from './app' import { config } from './config' @@ -15,11 +16,17 @@ export class ApiServer { private server: ReturnType | null = null async start(): Promise { - if (this.server) { + if (this.server && this.server.listening) { logger.warn('Server already running') return } + // Clean up any failed server instance + if (this.server && !this.server.listening) { + logger.warn('Cleaning up failed server instance') + this.server = null + } + // Load config const { port, host } = await config.load() @@ -39,7 +46,11 @@ export class ApiServer { resolve() }) - this.server!.on('error', reject) + this.server!.on('error', (error) => { + // Clean up the server instance if listen fails + this.server = null + reject(error) + }) }) } diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index a06ead610b..a32d6d37dc 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -2,7 +2,12 @@ import { isEmpty } from 'lodash' import type { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { loggerService } from '../../services/LoggerService' -import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils' +import { + getAvailableProviders, + getProviderAnthropicModelChecker, + listAllAvailableModels, + transformModelToOpenAI +} from '../utils' const logger = loggerService.withContext('ModelsService') @@ -10,10 +15,6 @@ const logger = loggerService.withContext('ModelsService') export type ModelsFilter = ApiModelsFilter -const isAnthropicProvider = (provider: { type: string; anthropicApiHost?: string }) => { - return provider.type === 'anthropic' || !isEmpty(provider.anthropicApiHost?.trim()) -} - export class ModelsService { async getModels(filter: ModelsFilter): Promise { try { @@ -22,7 +23,7 @@ export class ModelsService { let providers = await getAvailableProviders() if (filter.providerType === 'anthropic') { - providers = providers.filter(isAnthropicProvider) + providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim())) } const models = await listAllAvailableModels(providers) @@ -31,22 +32,18 @@ export class ModelsService { for (const model of models) { const provider = providers.find((p) => p.id === model.provider) - logger.debug(`Processing model ${model.id} from provider ${model.provider}`, { - isAnthropicModel: provider?.isAnthropicModel - }) - if ( - !provider || - (filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(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')) { + logger.debug(`Processing model ${model.id}`) + if (!provider) { + logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`) continue } - if (filter.supportAnthropic && model.endpoint_type !== 'anthropic' && !isAnthropicProvider(provider)) { - 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 + } } const openAIModel = transformModelToOpenAI(model, provider) diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index b0ca1e13bd..525ffef57f 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,7 +1,7 @@ import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import { reduxService } from '@main/services/ReduxService' -import type { ApiModel, EndpointType, Model, Provider } from '@types' +import type { ApiModel, Model, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') @@ -114,7 +114,6 @@ export async function validateModelId(model: string): Promise<{ error?: ModelValidationError provider?: Provider modelId?: string - modelEndpointType?: EndpointType }> { try { if (!model || typeof model !== 'string') { @@ -167,8 +166,7 @@ export async function validateModelId(model: string): Promise<{ } // Check if model exists in provider - const modelInProvider = provider.models?.find((m) => m.id === modelId) - const modelExists = !!modelInProvider + const modelExists = provider.models?.some((m) => m.id === modelId) if (!modelExists) { const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none' return { @@ -181,13 +179,10 @@ export async function validateModelId(model: string): Promise<{ } } - const modelEndpointType = modelInProvider?.endpoint_type - return { valid: true, provider, - modelId, - modelEndpointType + modelId } } catch (error: any) { logger.error('Error validating model ID', { error, model }) @@ -284,3 +279,16 @@ 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 + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 9d8d19f57b..699074e279 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -238,11 +238,26 @@ if (!app.requestSingleInstanceLock()) { logger.error('Failed to initialize Agent service:', error) } - // Start API server if enabled + // Start API server if enabled or if agents exist try { const config = await apiServerService.getCurrentConfig() logger.info('API server config:', config) - if (config.enabled) { + + // 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) { await apiServerService.start() } } catch (error: any) { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2f68d5dd04..62b1c57527 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -875,6 +875,7 @@ 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) => diff --git a/src/main/mcpServers/didi-mcp.ts b/src/main/mcpServers/didi-mcp.ts new file mode 100644 index 0000000000..905fc4ff81 --- /dev/null +++ b/src/main/mcpServers/didi-mcp.ts @@ -0,0 +1,473 @@ +/** + * 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 { + 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 diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 17631225a5..2323701e49 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -4,6 +4,7 @@ import type { BuiltinMCPServerName } from '@types' import { 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,6 +44,10 @@ 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}`) } diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index 1b60cc6643..fb2049de74 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -60,15 +60,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => { if (!isFindShortcut && !isEscape && !isEnter) { return } - // Prevent default to override the guest page's native find dialog - // and keep shortcuts routed to our custom search overlay - event.preventDefault() 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, diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 9a7c95e11e..aa9b57860c 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -9,7 +9,6 @@ import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' -import { isEmpty } from 'lodash' import type { GetAgentSessionResponse } from '../..' import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' @@ -62,20 +61,11 @@ class ClaudeCodeService implements AgentServiceInterface { }) return aiStream } - - const validateModelInfo: (m: typeof modelInfo) => boolean = (m) => { - const { provider, modelEndpointType } = m - if (!provider) return false - if (isEmpty(provider.apiKey?.trim())) return false - - const isAnthropicType = provider.type === 'anthropic' - const isAnthropicEndpoint = modelEndpointType === 'anthropic' - const hasValidApiHost = !isEmpty(provider.anthropicApiHost?.trim()) - - return !(!isAnthropicType && !isAnthropicEndpoint && !hasValidApiHost) - } - - if (!modelInfo.provider || !validateModelInfo(modelInfo)) { + if ( + (modelInfo.provider?.type !== 'anthropic' && + (modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) || + modelInfo.provider.apiKey === '' + ) { logger.error('Anthropic provider configuration is missing', { modelInfo }) diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 0c268db9c0..80cd547671 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -3,6 +3,7 @@ import { isLinux } from '@main/constant' import type { OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' import { BuiltinOcrProviderIds } from '@types' +import { ovOcrService } from './builtin/OvOcrService' import { ppocrService } from './builtin/PpocrService' import { systemOcrService } from './builtin/SystemOcrService' import { tesseractService } from './builtin/TesseractService' @@ -23,6 +24,10 @@ export class OcrService { this.registry.delete(providerId) } + public listProviderIds(): string[] { + return Array.from(this.registry.keys()) + } + public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise { const handler = this.registry.get(provider.id) if (!handler) { @@ -40,3 +45,5 @@ 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)) diff --git a/src/main/services/ocr/builtin/OvOcrService.ts b/src/main/services/ocr/builtin/OvOcrService.ts new file mode 100644 index 0000000000..6e0eee1c37 --- /dev/null +++ b/src/main/services/ocr/builtin/OvOcrService.ts @@ -0,0 +1,129 @@ +import { loggerService } from '@logger' +import { isWin } from '@main/constant' +import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { isImageFileMetadata } 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 { + 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 { + const imgDir = this.getImgDir() + const targetFilePath = path.join(imgDir, targetFileName) + await fs.promises.copyFile(sourceFilePath, targetFilePath) + } + + private async runOcrBatch(): Promise { + 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 { + 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 => { + 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() diff --git a/src/preload/index.ts b/src/preload/index.ts index 1085047b49..1fa5ee0339 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -477,7 +477,8 @@ const api = { }, ocr: { ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => - ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) + ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider), + listProviders: (): Promise => ipcRenderer.invoke(IpcChannel.OCR_ListProviders) }, cherryai: { generateSignature: (params: { method: string; path: string; query: string; body: Record }) => diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 7452af6296..cc8a93d54c 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -11,7 +11,7 @@ import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConv import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@types' import { WebSearchSource } from '@types' -import type { TextStreamPart, ToolSet } from 'ai' +import { AISDKError, type TextStreamPart, type ToolSet } from 'ai' import { ToolCallChunkHandler } from './handleToolCallChunk' @@ -358,11 +358,14 @@ 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 instanceof AISDKError + ? chunk.error + : new ProviderSpecificError({ + message: formatErrorMessage(chunk.error), + provider: 'unknown', + cause: chunk.error + }) }) break diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index a210b956d0..4872f02fbc 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -84,10 +84,8 @@ export default class ModernAiProvider { throw new Error('Model is required for completions. Please use constructor with model parameter.') } - // 确保配置存在 - if (!this.config) { - this.config = providerToAiSdkConfig(this.actualProvider, this.model) - } + // 每次请求时重新生成配置以确保API key轮换生效 + this.config = providerToAiSdkConfig(this.actualProvider, this.model) // 准备特殊配置 await prepareSpecialProviderConfig(this.actualProvider, this.config) diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 5a2131a1f7..0707e9bd40 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -72,13 +72,19 @@ 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() - this.apiKey = this.getApiKey() + } + + /** + * 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() } /** diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 6a1de3215e..e0b60eea9b 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants' import { isClaudeReasoningModel, isOpenAIReasoningModel, @@ -166,7 +167,8 @@ export abstract class OpenAIBaseClient< baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders(), - ...this.provider.extra_headers + ...this.provider.extra_headers, + ...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {}) } }) as TSdkInstance } diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index 2a19729d7e..9942ffa405 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -55,6 +55,14 @@ 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 diff --git a/src/renderer/src/api/agent.ts b/src/renderer/src/api/agent.ts index fb212e8bd4..ed75477ccc 100644 --- a/src/renderer/src/api/agent.ts +++ b/src/renderer/src/api/agent.ts @@ -6,6 +6,7 @@ import type { ApiModelsResponse, CreateAgentRequest, CreateAgentResponse, + CreateAgentSessionResponse, CreateSessionForm, CreateSessionRequest, GetAgentResponse, @@ -22,6 +23,7 @@ import { AgentServerErrorSchema, ApiModelsResponseSchema, CreateAgentResponseSchema, + CreateAgentSessionResponseSchema, GetAgentResponseSchema, GetAgentSessionResponseSchema, ListAgentSessionsResponseSchema, @@ -174,12 +176,12 @@ export class AgentApiClient { } } - public async createSession(agentId: string, session: CreateSessionForm): Promise { + public async createSession(agentId: string, session: CreateSessionForm): Promise { const url = this.getSessionPaths(agentId).base try { const payload = session satisfies CreateSessionRequest const response = await this.axios.post(url, payload) - const data = GetAgentSessionResponseSchema.parse(response.data) + const data = CreateAgentSessionResponseSchema.parse(response.data) return data } catch (error) { throw processError(error, 'Failed to add session.') diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index e5c4038f15..cc978b3f8c 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -358,7 +358,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` * 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡, * 二是 代码块内容过少时 toolbar 会和 title 重叠。 */ - min-width: 45ch; + min-width: 35ch; .code-toolbar { background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index a73db2b30c..b502c6e9da 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -3,7 +3,7 @@ import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' import type { Extension } from '@uiw/react-codemirror' import { keymap } from '@uiw/react-codemirror' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { getNormalizedExtension } from './utils' @@ -204,3 +204,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) { }) }, [onHeightChange]) } + +interface UseScrollToLineOptions { + highlight?: boolean +} + +export function useScrollToLine(editorViewRef: React.MutableRefObject) { + 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] + ) +} diff --git a/src/renderer/src/components/CodeEditor/types.ts b/src/renderer/src/components/CodeEditor/types.ts index 7346fd115f..f30056b108 100644 --- a/src/renderer/src/components/CodeEditor/types.ts +++ b/src/renderer/src/components/CodeEditor/types.ts @@ -4,6 +4,7 @@ export type CodeMirrorTheme = 'light' | 'dark' | 'none' | Extension export interface CodeEditorHandles { save?: () => void + scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void } export interface CodeEditorProps { diff --git a/src/renderer/src/components/HighlightText.tsx b/src/renderer/src/components/HighlightText.tsx new file mode 100644 index 0000000000..d24b9c607c --- /dev/null +++ b/src/renderer/src/components/HighlightText.tsx @@ -0,0 +1,49 @@ +import type { FC } from 'react' +import { memo, useMemo } from 'react' + +interface HighlightTextProps { + text: string + keyword: string + caseSensitive?: boolean + className?: string +} + +/** + * Text highlighting component that marks keyword matches + */ +const HighlightText: FC = ({ text, keyword, caseSensitive = false, className }) => { + const highlightedText = useMemo(() => { + if (!keyword || !text) { + return {text} + } + + // 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 {part} + } + return {part} + })} + + ) + }, [text, keyword, caseSensitive]) + + const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography' + + return {highlightedText} +} + +export default memo(HighlightText) diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 45263534fc..e291a1ecb3 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -26,6 +26,7 @@ const WebviewContainer = memo( }) => { const webviewRef = useRef(null) const [enableSpellCheck] = usePreference('app.spell_check.enabled') + const [minappsOpenLinkExternal] = usePreference('feature.minapp.open_link_external') const setRef = (appid: string) => { onSetRefCallback(appid, null) @@ -76,6 +77,8 @@ 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) } } @@ -104,6 +107,22 @@ 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%', diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index de38b2216a..4d92180b4e 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -1,7 +1,6 @@ import type { SelectedItemProps } from '@heroui/react' import { Button, - cn, Form, Input, Modal, @@ -17,7 +16,7 @@ import { import { loggerService } from '@logger' import type { Selection } from '@react-types/shared' import ClaudeIcon from '@renderer/assets/images/models/claude.png' -import { getModelLogo } from '@renderer/config/models' +import { agentModelFilter, 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' @@ -33,7 +32,7 @@ import type { } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types' import { AlertTriangleIcon } from 'lucide-react' -import type { ChangeEvent, FormEvent, ReactNode } from 'react' +import type { ChangeEvent, FormEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -58,50 +57,37 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ name: existing?.name ?? 'Claude Code', description: existing?.description, instructions: existing?.instructions, - model: existing?.model ?? 'claude-4-sonnet', + model: existing?.model ?? '', accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [], allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [], mcps: existing?.mcps ? [...existing.mcps] : [], configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {}) }) -interface BaseProps { +type Props = { agent?: AgentWithTools -} - -interface TriggerProps extends BaseProps { - trigger: { content: ReactNode; className?: string } - isOpen?: never - onClose?: never -} - -interface StateProps extends BaseProps { - trigger?: never isOpen: boolean onClose: () => void } -type Props = TriggerProps | StateProps - /** * Modal component for creating or editing an agent. * * Either trigger or isOpen and onClose is given. * @param agent - Optional agent entity for editing mode. - * @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal. * @param isOpen - Optional controlled modal open state. From useDisclosure. * @param onClose - Optional callback when modal closes. From useDisclosure. * @returns Modal component for agent creation/editing */ -export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => { - const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) +export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _onClose }) => { + const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) const { t } = useTranslation() const loadingRef = useRef(false) // const { setTimeoutTimer } = useTimer() const { addAgent } = useAgents() const { updateAgent } = useUpdateAgent() // hard-coded. We only support anthropic for now. - const { models } = useApiModels({ supportAnthropic: true }) + const { models } = useApiModels({ providerType: 'anthropic' }) const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) @@ -246,14 +232,23 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o const modelOptions = useMemo(() => { // mocked data. not final version - 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[] + 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[] }, [models]) const onModelChange = useCallback((e: ChangeEvent) => { @@ -351,23 +346,6 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o return ( - {/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single - encapsulated component. This is because the Modal component needs to bind the onOpen - event handler to the Button for proper focus management. - - Or just use external isOpen/onOpen/onClose to control modal state. - */} - - {trigger && ( -
{ - e.stopPropagation() - onOpen() - }} - className={cn('w-full', trigger.className)}> - {trigger.content} -
- )} = ({ 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 diff --git a/src/renderer/src/components/RichEditor/constants.ts b/src/renderer/src/components/RichEditor/constants.ts new file mode 100644 index 0000000000..08b0ba7cf2 --- /dev/null +++ b/src/renderer/src/components/RichEditor/constants.ts @@ -0,0 +1,2 @@ +// Attribute used to store the original source line number in markdown editors +export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line' diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx index 31850f47e1..ee91bc253d 100644 --- a/src/renderer/src/components/RichEditor/index.tsx +++ b/src/renderer/src/components/RichEditor/index.tsx @@ -1,6 +1,7 @@ import { Tooltip } from '@cherrystudio/ui' 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 { t } from 'i18next' @@ -29,6 +30,156 @@ 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 + + // 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 = '', @@ -372,6 +523,22 @@ 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, diff --git a/src/renderer/src/components/RichEditor/types.ts b/src/renderer/src/components/RichEditor/types.ts index 9616400004..8dcadbb664 100644 --- a/src/renderer/src/components/RichEditor/types.ts +++ b/src/renderer/src/components/RichEditor/types.ts @@ -111,6 +111,8 @@ 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 diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts index 1ece36fb00..576162fac7 100644 --- a/src/renderer/src/components/RichEditor/useRichEditor.ts +++ b/src/renderer/src/components/RichEditor/useRichEditor.ts @@ -2,6 +2,7 @@ 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 { @@ -11,6 +12,7 @@ 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' @@ -36,6 +38,31 @@ 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 @@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor // TipTap editor extensions const extensions = useMemo( () => [ + SourceLineAttribute, StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 0a1d2db69d..456cdaf323 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -67,14 +67,14 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>` flex-direction: row; min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'}; max-height: var(--navbar-height); - margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0}; + margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0}; padding-left: ${({ $isFullScreen }) => isMac ? ($isFullScreen ? 'var(--sidebar-width)' : 'env(titlebar-area-x)') : 0}; -webkit-app-region: drag; ` const NavbarLeftContainer = styled.div` - min-width: var(--assistants-width); + /* min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'}; */ padding: 0 10px; display: flex; flex-direction: row; diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 8a910b06b2..38af10c63f 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -430,6 +430,12 @@ export const SYSTEM_MODELS: Record = } ], anthropic: [ + { + id: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + name: 'Claude Haiku 4.5', + group: 'Claude 4.5' + }, { id: 'claude-sonnet-4-5-20250929', provider: 'anthropic', diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 8d306e7d8e..a9e9123c08 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -335,7 +335,8 @@ export function isClaudeReasoningModel(model?: Model): boolean { modelId.includes('claude-3-7-sonnet') || modelId.includes('claude-3.7-sonnet') || modelId.includes('claude-sonnet-4') || - modelId.includes('claude-opus-4') + modelId.includes('claude-opus-4') || + modelId.includes('claude-haiku-4') ) } @@ -493,8 +494,9 @@ export const THINKING_TOKEN_MAP: Record = 'qwen3-(?!max).*$': { min: 1024, max: 38_912 }, // Claude models - 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 }, - 'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 } + 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 }, + 'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 }, + 'claude-opus-4-1.*$': { min: 1024, max: 32_000 } } export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => { diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 25d4075341..0d9566303f 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -1,3 +1,4 @@ +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding' import type { Model } from '@renderer/types' import { getLowerBaseModelName } from '@renderer/utils' import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '@shared/config/prompts' @@ -5,7 +6,7 @@ import type OpenAI from 'openai' import { getWebSearchTools } from '../tools' import { isOpenAIReasoningModel } from './reasoning' -import { isGenerateImageModel, isVisionModel } from './vision' +import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision' import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch' export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i @@ -246,3 +247,7 @@ 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) +} diff --git a/src/renderer/src/config/models/vision.ts b/src/renderer/src/config/models/vision.ts index b9dfac5311..98ccf8dfb9 100644 --- a/src/renderer/src/config/models/vision.ts +++ b/src/renderer/src/config/models/vision.ts @@ -15,6 +15,7 @@ const visionAllowedModels = [ 'gemini-(flash|pro|flash-lite)-latest', 'gemini-exp', 'claude-3', + 'claude-haiku-4', 'claude-sonnet-4', 'claude-opus-4', 'vision', diff --git a/src/renderer/src/config/models/websearch.ts b/src/renderer/src/config/models/websearch.ts index 4addaff00b..5a8d678bbc 100644 --- a/src/renderer/src/config/models/websearch.ts +++ b/src/renderer/src/config/models/websearch.ts @@ -7,7 +7,7 @@ import { isAnthropicModel } from './utils' import { isPureGenerateImageModel, isTextToImageModel } from './vision' export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( - `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`, + `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`, 'i' ) diff --git a/src/renderer/src/config/ocr.ts b/src/renderer/src/config/ocr.ts index 9de0795256..2f5141c0f0 100644 --- a/src/renderer/src/config/ocr.ts +++ b/src/renderer/src/config/ocr.ts @@ -1,6 +1,7 @@ import type { BuiltinOcrProvider, BuiltinOcrProviderId, + OcrOvProvider, OcrPpocrProvider, OcrProviderCapability, OcrSystemProvider, @@ -50,10 +51,23 @@ 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 + paddleocr: ppocrOcr, + ovocr: ovOcr } as const satisfies Record export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index ebc16502a0..aba30d80c8 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -55,7 +55,7 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png' import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' -import type { AtLeast, Model, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types' +import type { AtLeast, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types' import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types' import { TOKENFLUX_HOST } from './constant' @@ -80,6 +80,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://open.cherryin.net', + anthropicApiHost: 'https://open.cherryin.net', models: [], isSystem: true, enabled: true @@ -101,7 +102,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = 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 diff --git a/src/renderer/src/hooks/agents/types.ts b/src/renderer/src/hooks/agents/types.ts new file mode 100644 index 0000000000..9cf5769f57 --- /dev/null +++ b/src/renderer/src/hooks/agents/types.ts @@ -0,0 +1,4 @@ +export type UpdateAgentBaseOptions = { + /** Whether to show success toast after updating. Defaults to true. */ + showSuccessToast?: boolean +} diff --git a/src/renderer/src/hooks/agents/useActiveAgent.ts b/src/renderer/src/hooks/agents/useActiveAgent.ts new file mode 100644 index 0000000000..f522ac7f28 --- /dev/null +++ b/src/renderer/src/hooks/agents/useActiveAgent.ts @@ -0,0 +1,8 @@ +import { useRuntime } from '../useRuntime' +import { useAgent } from './useAgent' + +export const useActiveAgent = () => { + const { chat } = useRuntime() + const { activeAgentId } = chat + return useAgent(activeAgentId) +} diff --git a/src/renderer/src/hooks/agents/useActiveSession.ts b/src/renderer/src/hooks/agents/useActiveSession.ts new file mode 100644 index 0000000000..dd744b6a50 --- /dev/null +++ b/src/renderer/src/hooks/agents/useActiveSession.ts @@ -0,0 +1,9 @@ +import { useRuntime } from '../useRuntime' +import { useSession } from './useSession' + +export const useActiveSession = () => { + const { chat } = useRuntime() + const { activeSessionIdMap, activeAgentId } = chat + const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null + return useSession(activeAgentId, activeSessionId) +} diff --git a/src/renderer/src/hooks/agents/useAgent.ts b/src/renderer/src/hooks/agents/useAgent.ts index a6755a4293..e1c682046f 100644 --- a/src/renderer/src/hooks/agents/useAgent.ts +++ b/src/renderer/src/hooks/agents/useAgent.ts @@ -11,8 +11,8 @@ export const useAgent = (id: string | null) => { const key = id ? client.agentPaths.withId(id) : null const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { - if (!id || id === 'fake') { - return null + if (!id) { + throw new Error(t('agent.get.error.null_id')) } if (!apiServerConfig.enabled) { throw new Error(t('apiServer.messages.notEnabled')) diff --git a/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts b/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts index 23161b4223..98609f5b29 100644 --- a/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts +++ b/src/renderer/src/hooks/agents/useAgentSessionInitializer.ts @@ -17,7 +17,7 @@ export const useAgentSessionInitializer = () => { const dispatch = useAppDispatch() const client = useAgentClient() const { chat } = useRuntime() - const { activeAgentId, activeSessionId } = chat + const { activeAgentId, activeSessionIdMap } = chat /** * Initialize session for the given agent by loading its sessions @@ -25,11 +25,11 @@ export const useAgentSessionInitializer = () => { */ const initializeAgentSession = useCallback( async (agentId: string) => { - if (!agentId || agentId === 'fake') return + if (!agentId) return try { // Check if this agent already has an active session - const currentSessionId = activeSessionId[agentId] + const currentSessionId = activeSessionIdMap[agentId] if (currentSessionId) { // Session already exists, just switch to session view dispatch(setActiveTopicOrSessionAction('session')) @@ -58,21 +58,21 @@ export const useAgentSessionInitializer = () => { dispatch(setActiveTopicOrSessionAction('session')) } }, - [client, dispatch, activeSessionId] + [client, dispatch, activeSessionIdMap] ) /** * Auto-initialize when activeAgentId changes */ useEffect(() => { - if (activeAgentId && activeAgentId !== 'fake') { + if (activeAgentId) { // Check if we need to initialize this agent's session - const hasActiveSession = activeSessionId[activeAgentId] + const hasActiveSession = activeSessionIdMap[activeAgentId] if (!hasActiveSession) { initializeAgentSession(activeAgentId) } } - }, [activeAgentId, activeSessionId, initializeAgentSession]) + }, [activeAgentId, activeSessionIdMap, initializeAgentSession]) return { initializeAgentSession diff --git a/src/renderer/src/hooks/agents/useAgents.ts b/src/renderer/src/hooks/agents/useAgents.ts index 858376bdc6..93ddf4a50e 100644 --- a/src/renderer/src/hooks/agents/useAgents.ts +++ b/src/renderer/src/hooks/agents/useAgents.ts @@ -26,7 +26,8 @@ export const useAgents = () => { const key = client.agentPaths.base const { apiServerConfig, apiServerRunning } = useApiServer() const fetcher = useCallback(async () => { - if (!apiServerConfig.enabled) { + // API server will start on startup if enabled OR there are agents + if (!apiServerConfig.enabled && !apiServerRunning) { throw new Error(t('apiServer.messages.notEnabled')) } if (!apiServerRunning) { diff --git a/src/renderer/src/hooks/agents/useSession.ts b/src/renderer/src/hooks/agents/useSession.ts index 25b900bf9b..1c6ebd608f 100644 --- a/src/renderer/src/hooks/agents/useSession.ts +++ b/src/renderer/src/hooks/agents/useSession.ts @@ -1,21 +1,24 @@ import { useAppDispatch } from '@renderer/store' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' -import type { UpdateSessionForm } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' -import { useCallback, useEffect, useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { useAgentClient } from './useAgentClient' +import { useUpdateSession } from './useUpdateSession' -export const useSession = (agentId: string, sessionId: string) => { +export const useSession = (agentId: string | null, sessionId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const key = client.getSessionPaths(agentId).withId(sessionId) + const key = agentId && sessionId ? client.getSessionPaths(agentId).withId(sessionId) : null const dispatch = useAppDispatch() - const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId]) + const sessionTopicId = useMemo(() => (sessionId ? buildAgentSessionTopicId(sessionId) : null), [sessionId]) + const { updateSession } = useUpdateSession(agentId) const fetcher = async () => { + if (!agentId) throw new Error(t('agent.get.error.null_id')) + if (!sessionId) throw new Error(t('agent.session.get.error.null_id')) const data = await client.getSession(agentId, sessionId) return data } @@ -24,26 +27,13 @@ export const useSession = (agentId: string, sessionId: string) => { // Use loadTopicMessagesThunk to load messages (with caching mechanism) // This ensures messages are preserved when switching between sessions/tabs useEffect(() => { - if (sessionId) { + if (sessionTopicId) { // loadTopicMessagesThunk will check if messages already exist in Redux // and skip loading if they do (unless forceReload is true) dispatch(loadTopicMessagesThunk(sessionTopicId)) } }, [dispatch, sessionId, sessionTopicId]) - const updateSession = useCallback( - async (form: UpdateSessionForm) => { - if (!agentId) return - try { - const result = await client.updateSession(agentId, form) - mutate(result) - } catch (error) { - window.toast.error(t('agent.session.update.error.failed')) - } - }, - [agentId, client, mutate, t] - ) - return { session: data, error, diff --git a/src/renderer/src/hooks/agents/useSessions.ts b/src/renderer/src/hooks/agents/useSessions.ts index 87aa77e063..a9977ba2e3 100644 --- a/src/renderer/src/hooks/agents/useSessions.ts +++ b/src/renderer/src/hooks/agents/useSessions.ts @@ -1,4 +1,4 @@ -import type { CreateSessionForm } from '@renderer/types' +import type { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -6,46 +6,50 @@ import useSWR from 'swr' import { useAgentClient } from './useAgentClient' -export const useSessions = (agentId: string) => { +export const useSessions = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const key = client.getSessionPaths(agentId).base + const key = agentId ? client.getSessionPaths(agentId).base : null const fetcher = async () => { + if (!agentId) throw new Error('No active agent.') const data = await client.listSessions(agentId) return data.data } const { data, error, isLoading, mutate } = useSWR(key, fetcher) const createSession = useCallback( - async (form: CreateSessionForm) => { + async (form: CreateSessionForm): Promise => { + if (!agentId) return null try { const result = await client.createSession(agentId, form) await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false }) return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) - return undefined + return null } }, [agentId, client, mutate, t] ) - // TODO: including messages field const getSession = useCallback( - async (id: string) => { + async (id: string): Promise => { + if (!agentId) return null try { const result = await client.getSession(agentId, id) mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session))) + return result } catch (error) { window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed'))) + return null } }, [agentId, client, mutate, t] ) const deleteSession = useCallback( - async (id: string) => { + async (id: string): Promise => { if (!agentId) return false try { await client.deleteSession(agentId, id) diff --git a/src/renderer/src/hooks/agents/useUpdateAgent.ts b/src/renderer/src/hooks/agents/useUpdateAgent.ts index c036e228b8..6cc697b7e0 100644 --- a/src/renderer/src/hooks/agents/useUpdateAgent.ts +++ b/src/renderer/src/hooks/agents/useUpdateAgent.ts @@ -4,20 +4,16 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' +import type { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' -export type UpdateAgentOptions = { - /** Whether to show success toast after updating. Defaults to true. */ - showSuccessToast?: boolean -} - export const useUpdateAgent = () => { const { t } = useTranslation() const client = useAgentClient() const listKey = client.agentPaths.base const updateAgent = useCallback( - async (form: UpdateAgentForm, options?: UpdateAgentOptions) => { + async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => { try { const itemKey = client.agentPaths.withId(form.id) // may change to optimistic update @@ -35,7 +31,7 @@ export const useUpdateAgent = () => { ) const updateModel = useCallback( - async (agentId: string, modelId: string, options?: UpdateAgentOptions) => { + async (agentId: string, modelId: string, options?: UpdateAgentBaseOptions) => { updateAgent({ id: agentId, model: modelId }, options) }, [updateAgent] diff --git a/src/renderer/src/hooks/agents/useUpdateSession.ts b/src/renderer/src/hooks/agents/useUpdateSession.ts index cbbd26e043..233fb717ef 100644 --- a/src/renderer/src/hooks/agents/useUpdateSession.ts +++ b/src/renderer/src/hooks/agents/useUpdateSession.ts @@ -1,19 +1,21 @@ import type { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types' -import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { getErrorMessage } from '@renderer/utils/error' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { mutate } from 'swr' +import type { UpdateAgentBaseOptions } from './types' import { useAgentClient } from './useAgentClient' -export const useUpdateSession = (agentId: string) => { +export const useUpdateSession = (agentId: string | null) => { const { t } = useTranslation() const client = useAgentClient() - const paths = client.getSessionPaths(agentId) - const listKey = paths.base const updateSession = useCallback( - async (form: UpdateSessionForm) => { + async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => { + if (!agentId) return + const paths = client.getSessionPaths(agentId) + const listKey = paths.base const sessionId = form.id try { const itemKey = paths.withId(sessionId) @@ -24,13 +26,29 @@ export const useUpdateSession = (agentId: string) => { (prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? [] ) mutate(itemKey, result) - window.toast.success(t('common.update_success')) + if (options?.showSuccessToast ?? true) { + window.toast.success(t('common.update_success')) + } } catch (error) { - window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed'))) + window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) }) } }, - [agentId, client, listKey, paths, t] + [agentId, client, t] ) - return updateSession + const updateModel = useCallback( + async (sessionId: string, modelId: string, options?: UpdateAgentBaseOptions) => { + if (!agentId) return + return updateSession( + { + id: sessionId, + model: modelId + }, + options + ) + }, + [agentId, updateSession] + ) + + return { updateSession, updateModel } } diff --git a/src/renderer/src/hooks/useOcrProvider.tsx b/src/renderer/src/hooks/useOcrProvider.tsx index 01ecab885a..34f99c5e50 100644 --- a/src/renderer/src/hooks/useOcrProvider.tsx +++ b/src/renderer/src/hooks/useOcrProvider.tsx @@ -1,5 +1,6 @@ import { Avatar } from '@cherrystudio/ui' import { loggerService } from '@logger' +import IntelLogo from '@renderer/assets/images/providers/intel.png' import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png' import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' @@ -77,6 +78,8 @@ export const useOcrProviders = () => { return case 'paddleocr': return + case 'ovocr': + return } } return diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index ec5f0be5cc..4eb310d80a 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -324,7 +324,8 @@ const builtInMcpDescriptionKeyMap: Record = { [BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch', [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', - [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python' + [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', + [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { @@ -334,12 +335,14 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { const builtinOcrProviderKeyMap = { system: 'ocr.builtin.system', tesseract: '', - paddleocr: '' + paddleocr: '', + ovocr: '' } as const satisfies Record export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => { if (key === 'tesseract') return 'Tesseract' else if (key == 'paddleocr') return 'PaddleOCR' + else if (key == 'ovocr') return 'Intel OV(NPU) OCR' else return getLabel(builtinOcrProviderKeyMap, key) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 288f0964b0..9dbdd32dae 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Failed to get the agent." + "failed": "Failed to get the agent.", + "null_id": "Agent ID is null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Failed to get the session" + "failed": "Failed to get the session", + "null_id": "Session ID is null" } }, "label_one": "Session", @@ -1957,6 +1959,14 @@ "rename": "Rename", "rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}", "save": "Save to Notes", + "search": { + "both": "Name+Content", + "content": "Content", + "found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})", + "more_matches": "more matches", + "searching": "Searching...", + "show_less": "Show less" + }, "settings": { "data": { "apply": "Apply", @@ -2041,6 +2051,7 @@ "provider": { "cannot_remove_builtin": "Cannot delete built-in provider", "existing": "The provider already exists", + "get_providers": "Failed to get available providers", "not_found": "OCR provider does not exist", "update_failed": "Failed to update configuration" }, @@ -2102,8 +2113,10 @@ "install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU", "install_code_102": "Only supports Windows", "install_code_103": "Download OVMS runtime failed", - "install_code_104": "Uncompress OVMS runtime failed", - "install_code_105": "Clean OVMS runtime failed", + "install_code_104": "Failed to install OVMS runtime", + "install_code_105": "Failed to create ovdnd.exe", + "install_code_106": "Failed to create run.bat", + "install_code_110": "Failed to clean old OVMS runtime", "run": "Run OVMS failed:", "stop": "Stop OVMS failed:" }, @@ -3574,6 +3587,7 @@ "builtinServers": "Builtin Servers", "builtinServersDescriptions": { "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", + "didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "fetch": "MCP server for retrieving URL web content", "filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.", @@ -4079,7 +4093,7 @@ "api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.", "api_key": { "label": "API Key", - "tip": "Multiple keys separated by commas or spaces" + "tip": "Use commas to separate multiple keys" }, "api_version": "API Version", "aws-bedrock": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 3e6bac4708..9b146596fc 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "获取智能体失败" + "failed": "获取智能体失败", + "null_id": "智能体 ID 为空。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "获取会话失败" + "failed": "获取会话失败", + "null_id": "会话 ID 为空" } }, "label_one": "会话", @@ -1957,6 +1959,14 @@ "rename": "重命名", "rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}", "save": "保存到笔记", + "search": { + "both": "名称+内容", + "content": "内容", + "found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})", + "more_matches": "个匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "应用", @@ -2041,6 +2051,7 @@ "provider": { "cannot_remove_builtin": "不能删除内置提供商", "existing": "提供商已存在", + "get_providers": "获取可用提供商失败", "not_found": "OCR 提供商不存在", "update_failed": "更新配置失败" }, @@ -2077,7 +2088,7 @@ "description": "

1. 下载 OV 模型.

2. 在 'Manager' 中添加模型.

仅支持 Windows!

OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .

请参考 Intel OVMS 指南

", "download": { "button": "下载", - "error": "选择失败", + "error": "下载失败", "model_id": { "label": "模型 ID", "model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头", @@ -2102,8 +2113,10 @@ "install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU", "install_code_102": "仅支持 Windows", "install_code_103": "下载 OVMS runtime 失败", - "install_code_104": "解压 OVMS runtime 失败", - "install_code_105": "清理 OVMS runtime 失败", + "install_code_104": "安装 OVMS runtime 失败", + "install_code_105": "创建 ovdnd.exe 失败", + "install_code_106": "创建 run.bat 失败", + "install_code_110": "清理旧 OVMS runtime 失败", "run": "运行 OVMS 失败:", "stop": "停止 OVMS 失败:" }, @@ -3574,6 +3587,7 @@ "builtinServers": "内置服务器", "builtinServersDescriptions": { "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", + "didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "fetch": "用于获取 URL 网页内容的 MCP 服务器", "filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录", @@ -4079,7 +4093,7 @@ "api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。", "api_key": { "label": "API 密钥", - "tip": "多个密钥使用逗号或空格分隔" + "tip": "多个密钥使用逗号分隔" }, "api_version": "API 版本", "aws-bedrock": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b87e5e3256..57dc3480b5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "無法取得代理程式。" + "failed": "無法取得代理程式。", + "null_id": "代理程式 ID 為空。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "無法取得工作階段" + "failed": "無法取得工作階段", + "null_id": "工作階段 ID 為空" } }, "label_one": "會議", @@ -1957,6 +1959,14 @@ "rename": "重命名", "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", "save": "儲存到筆記", + "search": { + "both": "名稱+內容", + "content": "內容", + "found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})", + "more_matches": "個匹配", + "searching": "搜索中...", + "show_less": "收起" + }, "settings": { "data": { "apply": "應用", @@ -2040,8 +2050,9 @@ "error": { "provider": { "cannot_remove_builtin": "不能刪除內建提供者", - "existing": "提供商已存在", - "not_found": "OCR 提供商不存在", + "existing": "提供者已存在", + "get_providers": "取得可用提供者失敗", + "not_found": "OCR 提供者不存在", "update_failed": "更新配置失敗" }, "unknown": "OCR過程發生錯誤" @@ -2077,7 +2088,7 @@ "description": "

1. 下載 OV 模型。

2. 在 'Manager' 中新增模型。

僅支援 Windows!

OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。

請參考 Intel OVMS 指南

", "download": { "button": "下載", - "error": "選擇失敗", + "error": "下載失敗", "model_id": { "label": "模型 ID", "model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭", @@ -2102,8 +2113,10 @@ "install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU", "install_code_102": "僅支援 Windows", "install_code_103": "下載 OVMS runtime 失敗", - "install_code_104": "解壓 OVMS runtime 失敗", - "install_code_105": "清理 OVMS runtime 失敗", + "install_code_104": "安裝 OVMS runtime 失敗", + "install_code_105": "創建 ovdnd.exe 失敗", + "install_code_106": "創建 run.bat 失敗", + "install_code_110": "清理舊 OVMS runtime 失敗", "run": "執行 OVMS 失敗:", "stop": "停止 OVMS 失敗:" }, @@ -3574,6 +3587,7 @@ "builtinServers": "內置伺服器", "builtinServersDescriptions": { "brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數", + "didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數", "dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key", "fetch": "用於獲取 URL 網頁內容的 MCP 伺服器", "filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄", @@ -4079,7 +4093,7 @@ "api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。", "api_key": { "label": "API 金鑰", - "tip": "多個金鑰使用逗號或空格分隔" + "tip": "多個金鑰使用逗號分隔" }, "api_version": "API 版本", "aws-bedrock": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index a39c429d1c..259d64c910 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Αποτυχία λήψης του πράκτορα." + "failed": "Αποτυχία λήψης του πράκτορα.", + "null_id": "Το ID του πράκτορα είναι null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Αποτυχία λήψης της συνεδρίας" + "failed": "Αποτυχία λήψης της συνεδρίας", + "null_id": "Το ID της συνεδρίας είναι null" } }, "label_one": "Συνεδρία", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου", "existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη", + "get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων", "not_found": "Ο πάροχος OCR δεν υπάρχει", "update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης" }, @@ -3574,6 +3577,7 @@ "builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServersDescriptions": { "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", + "didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", "filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 219f809187..8b9b863e04 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "No se pudo obtener el agente." + "failed": "No se pudo obtener el agente.", + "null_id": "El ID del agente es nulo." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Error al obtener la sesión" + "failed": "Error al obtener la sesión", + "null_id": "El ID de sesión es nulo" } }, "label_one": "Sesión", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "No se puede eliminar el proveedor integrado", "existing": "El proveedor ya existe", + "get_providers": "Error al obtener proveedores disponibles", "not_found": "El proveedor de OCR no existe", "update_failed": "Actualización de la configuración fallida" }, @@ -3574,6 +3577,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", + "didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL", "filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 72899c3c13..f40ffc8163 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Échec de l'obtention de l'agent." + "failed": "Échec de l'obtention de l'agent.", + "null_id": "L'ID de l'agent est nul." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Échec de l'obtention de la session" + "failed": "Échec de l'obtention de la session", + "null_id": "L'ID de session est nul" } }, "label_one": "Session", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré", "existing": "Le fournisseur existe déjà", + "get_providers": "Échec de l'obtention des fournisseurs disponibles", "not_found": "Le fournisseur OCR n'existe pas", "update_failed": "Échec de la mise à jour de la configuration" }, @@ -3574,6 +3577,7 @@ "builtinServers": "Serveurs intégrés", "builtinServersDescriptions": { "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", + "didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", "filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 5c7e77400c..03c5218c31 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "エージェントの取得に失敗しました。" + "failed": "エージェントの取得に失敗しました。", + "null_id": "エージェント ID が null です。" } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "セッションの取得に失敗しました" + "failed": "セッションの取得に失敗しました", + "null_id": "セッション ID が null です" } }, "label_one": "セッション", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "組み込みプロバイダーは削除できません", "existing": "プロバイダーはすでに存在します", + "get_providers": "利用可能なプロバイダーの取得に失敗しました", "not_found": "OCRプロバイダーが存在しません", "update_failed": "更新構成に失敗しました" }, @@ -3574,6 +3577,7 @@ "builtinServers": "組み込みサーバー", "builtinServersDescriptions": { "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", + "didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", "filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index f10aea90df..4ea6d25f56 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Falha ao obter o agente." + "failed": "Falha ao obter o agente.", + "null_id": "O ID do agente é nulo." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Falha ao obter a sessão" + "failed": "Falha ao obter a sessão", + "null_id": "O ID da sessão é nulo" } }, "label_one": "Sessão", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "Não é possível excluir o provedor integrado", "existing": "O provedor já existe", + "get_providers": "Falha ao obter provedores disponíveis", "not_found": "O provedor OCR não existe", "update_failed": "Falha ao atualizar a configuração" }, @@ -3574,6 +3577,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", + "didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "fetch": "servidor MCP para obter o conteúdo da página web do URL", "filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index bf4533a4ac..8f751d8d9d 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -22,7 +22,8 @@ }, "get": { "error": { - "failed": "Не удалось получить агента." + "failed": "Не удалось получить агента.", + "null_id": "ID агента равен null." } }, "list": { @@ -73,7 +74,8 @@ }, "get": { "error": { - "failed": "Не удалось получить сеанс" + "failed": "Не удалось получить сеанс", + "null_id": "ID сессии равен null" } }, "label_one": "Сессия", @@ -2041,6 +2043,7 @@ "provider": { "cannot_remove_builtin": "Не удается удалить встроенного поставщика", "existing": "Поставщик уже существует", + "get_providers": "Не удалось получить доступных поставщиков", "not_found": "Поставщик OCR отсутствует", "update_failed": "Обновление конфигурации не удалось" }, @@ -3574,6 +3577,7 @@ "builtinServers": "Встроенные серверы", "builtinServersDescriptions": { "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", + "didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL", "filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ", diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 98cfd7d84f..0d1e37d0b4 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -54,7 +54,8 @@ const Chat: FC = (props) => { const { isTopNavbar } = useNavbarPosition() const chatMaxWidth = useChatMaxWidth() const { chat } = useRuntime() - const { activeTopicOrSession, activeAgentId, activeSessionId } = chat + const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat + const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) @@ -143,16 +144,13 @@ const Chat: FC = (props) => { firstUpdateOrNoFirstUpdateHandler() } - const mainHeight = isTopNavbar - ? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)' - : 'calc(100vh - var(--navbar-height))' + const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))' const SessionMessages = useMemo(() => { if (activeAgentId === null) { return () =>
Active Agent ID is invalid.
} - const sessionId = activeSessionId[activeAgentId] - if (!sessionId) { + if (!activeSessionId) { return () =>
Active Session ID is invalid.
} if (!apiServerEnabled) { @@ -162,18 +160,17 @@ const Chat: FC = (props) => {
) } - return () => + return () => }, [activeAgentId, activeSessionId, apiServerEnabled, t]) const SessionInputBar = useMemo(() => { if (activeAgentId === null) { return () =>
Active Agent ID is invalid.
} - const sessionId = activeSessionId[activeAgentId] - if (!sessionId) { + if (!activeSessionId) { return () =>
Active Session ID is invalid.
} - return () => + return () => }, [activeAgentId, activeSessionId]) // TODO: more info @@ -197,66 +194,78 @@ const Chat: FC = (props) => {
) }, []) + return ( - {isTopNavbar && ( - - )} -
- - {activeTopicOrSession === 'topic' && ( - <> - - } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> - {messageNavigation === 'buttons' && } - - - )} - {activeTopicOrSession === 'session' && !activeAgentId && } - {activeTopicOrSession === 'session' && activeAgentId && !activeSessionId[activeAgentId] && ( - - )} - {activeTopicOrSession === 'session' && activeAgentId && activeSessionId[activeAgentId] && ( - <> - - - - )} - {isMultiSelectMode && } - -
+ +
+ + +
+ {activeTopicOrSession === 'topic' && ( + <> + + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> + {messageNavigation === 'buttons' && } + + + )} + {activeTopicOrSession === 'session' && !activeAgentId && } + {activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && } + {activeTopicOrSession === 'session' && activeAgentId && activeSessionId && ( + <> + + + + )} + {isMultiSelectMode && } +
+
+
+
{topicPosition === 'right' && showTopics && ( + style={{ + position: 'absolute', + right: 0, + top: isTopNavbar ? 0 : 'calc(var(--navbar-height) + 1px)', + width: 'var(--assistants-width)', + height: '100%', + zIndex: 10 + }}> = (props) => { export const useChatMaxWidth = () => { const [showTopics] = usePreference('topic.tab.show') const [topicPosition] = usePreference('topic.position') - const { isLeftNavbar } = useNavbarPosition() + const { isLeftNavbar, isTopNavbar } = useNavbarPosition() const { showAssistants } = useShowAssistants() const showRightTopics = showTopics && topicPosition === 'right' const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' + const minusBorderWidth = isTopNavbar ? (showTopics ? '- 12px' : '- 6px') : '' const sidebarWidth = isLeftNavbar ? '- var(--sidebar-width)' : '' - return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth})` + return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth} ${minusBorderWidth})` } const Container = styled.div` diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx index dd67c311ad..47120e7020 100644 --- a/src/renderer/src/pages/home/ChatNavbar.tsx +++ b/src/renderer/src/pages/home/ChatNavbar.tsx @@ -1,34 +1,22 @@ -import { RowFlex } from '@cherrystudio/ui' -import { Tooltip } from '@cherrystudio/ui' +import { RowFlex, Tooltip } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' -import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react' import { NavbarHeader } from '@renderer/components/app/Navbar' -import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import SearchPopup from '@renderer/components/Popups/SearchPopup' -import { permissionModeCards } from '@renderer/constants/permissionModes' -import { useAgent } from '@renderer/hooks/agents/useAgent' -import { useSession } from '@renderer/hooks/agents/useSession' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useAssistant } from '@renderer/hooks/useAssistant' import { modelGenerating } from '@renderer/hooks/useModel' -import { useRuntime } from '@renderer/hooks/useRuntime' +import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import type { ApiModel, Assistant, PermissionMode, Topic } from '@renderer/types' -import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import type { Assistant, Topic } from '@renderer/types' import { t } from 'i18next' import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' -import type { FC, ReactNode } from 'react' -import React, { useCallback } from 'react' +import type { FC } from 'react' import styled from 'styled-components' -import { AgentSettingsPopup } from '../settings/AgentSettings' -import { AgentLabel } from '../settings/AgentSettings/shared' import AssistantsDrawer from './components/AssistantsDrawer' -import SelectAgentModelButton from './components/SelectAgentModelButton' -import SelectModelButton from './components/SelectModelButton' +import ChatNavbarContent from './components/ChatNavbarContent' import UpdateAppButton from './components/UpdateAppButton' interface Props { @@ -47,11 +35,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showTopics, toggleShowTopics } = useShowTopics() - const { chat } = useRuntime() - const { activeTopicOrSession, activeAgentId } = chat - const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null - const { agent } = useAgent(activeAgentId) - const { updateModel } = useUpdateAgent() + const { isTopNavbar } = useNavbarPosition() useShortcut('toggle_show_assistants', toggleShowAssistants) @@ -81,25 +65,25 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo }) } - const handleUpdateModel = useCallback( - async (model: ApiModel) => { - if (!agent) return - return updateModel(agent.id, model.id, { showSuccessToast: false }) - }, - [agent, updateModel] - ) + // const handleUpdateModel = useCallback( + // async (model: ApiModel) => { + // if (!activeSession || !activeAgent) return + // return updateModel(activeSession.id, model.id, { showSuccessToast: false }) + // }, + // [activeAgent, activeSession, updateModel] + // ) return ( - -
- {showAssistants && ( + +
+ {isTopNavbar && showAssistants && ( )} - {!showAssistants && ( + {isTopNavbar && !showAssistants && ( toggleShowAssistants()} style={{ marginRight: 8 }}> @@ -107,71 +91,44 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - {!showAssistants && ( + {!showAssistants && isTopNavbar && ( - + )} - {activeTopicOrSession === 'topic' && } - {activeTopicOrSession === 'session' && agent && ( - - - AgentSettingsPopup.show({ agentId: agent.id })} - classNames={{ - base: 'self-stretch', - item: 'h-full' - }}> - - - - - - - - {activeAgentId && sessionId && ( - - - - )} - - - )} +
- - - - - - - - SearchPopup.show()}> - - - - {topicPosition === 'right' && !showTopics && ( + {isTopNavbar && } + {isTopNavbar && ( + + + + + + )} + {isTopNavbar && ( + + SearchPopup.show()}> + + + + )} + {isTopNavbar && topicPosition === 'right' && !showTopics && ( )} - {topicPosition === 'right' && showTopics && ( + {isTopNavbar && topicPosition === 'right' && showTopics && ( @@ -183,74 +140,6 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo ) } -const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => { - const { agent } = useAgent(agentId) - const { session } = useSession(agentId, sessionId) - if (!session || !agent) { - return null - } - - const firstAccessiblePath = session.accessible_paths?.[0] - const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode - const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode) - const permissionModeLabel = permissionModeCard - ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback) - : permissionMode - - const infoItems: ReactNode[] = [] - - const InfoTag = ({ - text, - className, - onClick - }: { - text: string - className?: string - classNames?: {} - onClick?: (e: React.MouseEvent) => void - }) => ( -
- {text} -
- ) - - // infoItems.push() - - if (firstAccessiblePath) { - infoItems.push( - { - window.api.file - .openPath(firstAccessiblePath) - .catch((e) => - window.toast.error( - formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath })) - ) - ) - }} - /> - ) - } - - infoItems.push() - - if (infoItems.length === 0) { - return null - } - - return
{infoItems}
-} - export const NavbarIcon = styled.div` -webkit-app-region: none; border-radius: 8px; diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index c6e93789d6..4a6415e429 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -121,7 +121,7 @@ const HomePage: FC = () => { type: 'chat' }) } else if (activeTopicOrSession === 'topic') { - dispatch(setActiveAgentId('fake')) + dispatch(setActiveAgentId(null)) } }, [activeTopicOrSession, dispatch, setActiveAssistant]) @@ -134,6 +134,7 @@ const HomePage: FC = () => { setActiveTopic={setActiveTopic} setActiveAssistant={setActiveAssistant} position="left" + activeTopicOrSession={activeTopicOrSession} /> )} diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index c3b626b1e1..3d0a94faad 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -339,29 +339,18 @@ const GroupContainer = styled.div` const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>` width: 100%; display: grid; + overflow-y: visible; gap: 16px; &.horizontal { padding-bottom: 4px; grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr)); - overflow-y: hidden; overflow-x: auto; - &::-webkit-scrollbar { - height: 6px; - } - &::-webkit-scrollbar-thumb { - background: var(--color-scrollbar-thumb); - border-radius: var(--scrollbar-thumb-radius); - } - &::-webkit-scrollbar-thumb:hover { - background: var(--color-scrollbar-thumb-hover); - } } &.fold, &.vertical { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 8px; - overflow: hidden; } &.grid { grid-template-columns: repeat( @@ -369,15 +358,11 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number } minmax(0, 1fr) ); grid-template-rows: auto; - overflow-y: auto; - overflow-x: hidden; } &.multi-select-mode { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 10px; - overflow-y: auto; - overflow-x: hidden; .grid { height: auto; } @@ -403,7 +388,7 @@ interface MessageWrapperProps { const MessageWrapper = styled.div` &.horizontal { padding: 1px; - /* overflow-y: auto; */ + overflow-y: auto; .message { height: 100%; border: 0.5px solid var(--color-border); @@ -425,7 +410,7 @@ const MessageWrapper = styled.div` &.grid { display: block; height: 300px; - overflow: hidden; + overflow-y: hidden; border: 0.5px solid var(--color-border); border-radius: 10px; cursor: pointer; diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 669836ba04..fc3e1a76a1 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,10 +1,8 @@ -import { RowFlex } from '@cherrystudio/ui' -import { Tooltip } from '@cherrystudio/ui' +import { RowFlex, Tooltip } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' -import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' +import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import SearchPopup from '@renderer/components/Popups/SearchPopup' -import { isLinux, isMac, isWin } from '@renderer/config/constant' -import { useAssistant } from '@renderer/hooks/useAssistant' +import { isLinux, isWin } from '@renderer/config/constant' import { modelGenerating } from '@renderer/hooks/useModel' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' @@ -17,7 +15,6 @@ import type { FC } from 'react' import styled from 'styled-components' import AssistantsDrawer from './components/AssistantsDrawer' -import SelectModelButton from './components/SelectModelButton' import UpdateAppButton from './components/UpdateAppButton' interface Props { activeAssistant: Assistant @@ -25,13 +22,19 @@ interface Props { setActiveTopic: (topic: Topic) => void setActiveAssistant: (assistant: Assistant) => void position: 'left' | 'right' + activeTopicOrSession?: 'topic' | 'session' } -const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => { +const HeaderNavbar: FC = ({ + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic, + activeTopicOrSession +}) => { const [narrowMode, setNarrowMode] = usePreference('chat.narrow_mode') const [topicPosition] = usePreference('topic.position') - const { assistant } = useAssistant(activeAssistant.id) const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showTopics, toggleShowTopics } = useShowTopics() @@ -89,7 +92,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo justifyContent: 'flex-start', borderRight: 'none', paddingLeft: 0, - paddingRight: 10, + paddingRight: 0, minWidth: 'auto' }}> @@ -111,15 +114,14 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - - - + diff --git a/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx b/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx deleted file mode 100644 index d1f588ff1f..0000000000 --- a/src/renderer/src/pages/home/Tabs/AgentSettingsTab.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button, Divider } from '@heroui/react' -import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' -import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings' -import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' -import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings' -import type { GetAgentResponse } from '@renderer/types/agent' -import type { FC } from 'react' -import { useTranslation } from 'react-i18next' - -interface Props { - agent: GetAgentResponse | undefined | null - update: ReturnType['updateAgent'] -} - -const AgentSettingsTab: FC = ({ agent, update }) => { - const { t } = useTranslation() - - const onMoreSetting = () => { - if (agent?.id) { - AgentSettingsPopup.show({ agentId: agent.id! }) - } - } - - if (!agent) { - return null - } - - return ( -
- - - - -
- ) -} - -export default AgentSettingsTab diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 75195c5e88..ea8a8bd3bf 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -13,7 +13,7 @@ import { getErrorMessage } from '@renderer/utils' import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes' import type { Assistant } from '@types' import type { FC } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -82,12 +82,6 @@ const AssistantsTab: FC = (props) => { updateAssistants }) - useEffect(() => { - if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServerConfig.enabled) { - setActiveAgentId(agents[0].id) - } - }, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServerConfig.enabled]) - const onDeleteAssistant = useCallback( (assistant: Assistant) => { const remaining = assistants.filter((a) => a.id !== assistant.id) @@ -109,7 +103,7 @@ const AssistantsTab: FC = (props) => { return ( - {!apiServerConfig.enabled && !iknow[ALERT_KEY] && ( + {!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && ( = (props) => { {apiServerConfig.enabled && !apiServerRunning && ( )} - {apiServerConfig.enabled && apiServerRunning && agentsError && ( + {apiServerRunning && agentsError && ( = (props) => { const Container = styled(Scrollbar)` display: flex; flex-direction: column; - padding: 10px; + padding: 12px 10px; ` export default AssistantsTab diff --git a/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx new file mode 100644 index 0000000000..346c3abb1d --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/SessionSettingsTab.tsx @@ -0,0 +1,43 @@ +import { Button, Divider } from '@heroui/react' +import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' +import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings' +import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings' +import EssentialSettings from '@renderer/pages/settings/AgentSettings/EssentialSettings' +import type { GetAgentSessionResponse } from '@renderer/types' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + session: GetAgentSessionResponse | undefined | null + update: ReturnType['updateSession'] +} + +const SessionSettingsTab: FC = ({ session, update }) => { + const { t } = useTranslation() + + const onMoreSetting = () => { + if (session?.id) { + SessionSettingsPopup.show({ + agentId: session.agent_id, + sessionId: session.id + }) + } + } + + if (!session) { + return null + } + + return ( +
+ + + + +
+ ) +} + +export default SessionSettingsTab diff --git a/src/renderer/src/pages/home/Tabs/SessionsTab.tsx b/src/renderer/src/pages/home/Tabs/SessionsTab.tsx index c236a8fc0a..e9950a5c60 100644 --- a/src/renderer/src/pages/home/Tabs/SessionsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SessionsTab.tsx @@ -14,7 +14,6 @@ const SessionsTab: FC = () => { const { activeAgentId } = chat const { t } = useTranslation() const { apiServer } = useSettings() - const { topicPosition, navbarPosition } = useSettings() if (!apiServer.enabled) { return ( @@ -34,15 +33,7 @@ const SessionsTab: FC = () => { return ( - + diff --git a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx index 45065b0b0d..6fbef9cbca 100644 --- a/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AgentItem.tsx @@ -44,9 +44,16 @@ const AgentItem: FC = ({ agent, isActive, onDelete, onPress }) = - - {isActive ? {sessions.length} : } - + {isActive && ( + + {sessions.length} + + )} + {!isActive && ( + + + + )}
@@ -110,6 +117,16 @@ export const MenuButton: React.FC> = ({ cla /> ) +export const BotIcon: React.FC> = ({ className, ...props }) => ( +
+) + export const SessionCount: React.FC> = ({ className, ...props }) => (
= () => { - const { agents, deleteAgent, isLoading, error } = useAgents() - const { t } = useTranslation() - const { chat } = useRuntime() - const { activeAgentId } = chat - const { initializeAgentSession } = useAgentSessionInitializer() - - const dispatch = useAppDispatch() - - const setActiveAgentId = useCallback( - async (id: string) => { - dispatch(setActiveAgentIdAction(id)) - // Initialize the session for this agent - await initializeAgentSession(id) - }, - [dispatch, initializeAgentSession] - ) - - useEffect(() => { - if (!isLoading && agents.length > 0 && !activeAgentId) { - setActiveAgentId(agents[0].id) - } - }, [isLoading, agents, activeAgentId, setActiveAgentId]) - - return ( - <> - {isLoading && } - {error && } - {!isLoading && - !error && - agents.map((agent) => ( - deleteAgent(agent.id)} - onPress={() => { - setActiveAgentId(agent.id) - }} - /> - ))} - e.continuePropagation()} - startContent={} - className="w-full justify-start bg-transparent text-foreground-500 hover:bg-[var(--color-list-item)]"> - {t('agent.add.title')} - - ) - }} - /> - - ) -} diff --git a/src/renderer/src/pages/home/Tabs/components/Assistants.tsx b/src/renderer/src/pages/home/Tabs/components/Assistants.tsx deleted file mode 100644 index 70d9bea799..0000000000 --- a/src/renderer/src/pages/home/Tabs/components/Assistants.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { DownOutlined, RightOutlined } from '@ant-design/icons' -import { Tooltip } from '@cherrystudio/ui' -import { Button } from '@heroui/react' -import { DraggableList } from '@renderer/components/DraggableList' -import { useAssistants } from '@renderer/hooks/useAssistant' -import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' -import { useAssistantsTabSortType } from '@renderer/hooks/useStore' -import { useTags } from '@renderer/hooks/useTags' -import type { Assistant } from '@renderer/types' -import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes' -import { Plus } from 'lucide-react' -import { type FC, useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import AssistantItem from './AssistantItem' -import { SectionName } from './SectionName' - -interface AssistantsProps { - activeAssistant: Assistant - setActiveAssistant: (assistant: Assistant) => void - onCreateAssistant: () => void - onCreateDefaultAssistant: () => void -} - -const Assistants: FC = ({ - activeAssistant, - setActiveAssistant, - onCreateAssistant, - onCreateDefaultAssistant -}) => { - const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants() - const [dragging, setDragging] = useState(false) - const { addAssistantPreset } = useAssistantPresets() - const { t } = useTranslation() - const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags() - const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType() - - const onDelete = useCallback( - (assistant: Assistant) => { - const remaining = assistants.filter((a) => a.id !== assistant.id) - if (assistant.id === activeAssistant?.id) { - const newActive = remaining[remaining.length - 1] - newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant() - } - removeAssistant(assistant.id) - }, - [activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant] - ) - - const handleSortByChange = useCallback( - (sortType: AssistantTabSortType) => { - setAssistantsTabSortType(sortType) - }, - [setAssistantsTabSortType] - ) - - const handleGroupReorder = useCallback( - (tag: string, newGroupList: Assistant[]) => { - let insertIndex = 0 - const newGlobal = assistants.map((a) => { - const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')] - if (tags.includes(tag)) { - const replaced = newGroupList[insertIndex] - insertIndex += 1 - return replaced - } - return a - }) - updateAssistants(newGlobal) - }, - [assistants, t, updateAssistants] - ) - - const renderAddAssistantButton = useMemo(() => { - return ( - - ) - }, [onCreateAssistant, t]) - - if (assistantsTabSortType === 'tags') { - return ( - <> - -
- {getGroupedAssistants.map((group) => ( - - {group.tag !== t('assistants.tags.untagged') && ( - toggleTagCollapse(group.tag)}> - - - {collapsedTags[group.tag] ? ( - - ) : ( - - )} - {group.tag} - - - - - )} - {!collapsedTags[group.tag] && ( -
- handleGroupReorder(group.tag, newList)} - onDragStart={() => setDragging(true)} - onDragEnd={() => setDragging(false)}> - {(assistant) => ( - - )} - -
- )} -
- ))} - {renderAddAssistantButton} -
- - ) - } - - return ( -
- - setDragging(true)} - onDragEnd={() => setDragging(false)}> - {(assistant) => ( - - )} - - {!dragging && renderAddAssistantButton} -
-
- ) -} - -// 样式组件 - -const TagsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -` - -const GroupTitle = styled.div` - color: var(--color-text-2); - font-size: 12px; - font-weight: 500; - cursor: pointer; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - height: 24px; - margin: 5px 0; -` - -const GroupTitleName = styled.div` - max-width: 50%; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - box-sizing: border-box; - padding: 0 4px; - color: var(--color-text); - font-size: 13px; - line-height: 24px; - margin-right: 5px; - display: flex; -` - -const GroupTitleDivider = styled.div` - flex: 1; - border-top: 1px solid var(--color-border); -` - -export default Assistants diff --git a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index 324fb23493..3d45c73002 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -1,40 +1,46 @@ -import { Button, cn, Input, Tooltip } from '@heroui/react' +import { Tooltip } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { isMac } from '@renderer/config/constant' import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' import { useRuntime } from '@renderer/hooks/useRuntime' -import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings' import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared' import { useAppDispatch, useAppSelector } from '@renderer/store' import { newMessagesActions } from '@renderer/store/newMessage' import type { AgentSessionEntity } from '@renderer/types' -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@renderer/ui/context-menu' +import { classNames } from '@renderer/utils' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' -import { XIcon } from 'lucide-react' +import { MenuIcon, XIcon } from 'lucide-react' import React, { type FC, memo, startTransition, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' - // const logger = loggerService.withContext('AgentItem') interface SessionItemProps { session: AgentSessionEntity // use external agentId as SSOT, instead of session.agent_id agentId: string - isDisabled?: boolean - isLoading?: boolean onDelete: () => void onPress: () => void } -const SessionItem: FC = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => { +const SessionItem: FC = ({ session, agentId, onDelete, onPress }) => { const { t } = useTranslation() const { chat } = useRuntime() - const updateSession = useUpdateSession(agentId) - const activeSessionId = chat.activeSessionId[agentId] + const { updateSession } = useUpdateSession(agentId) + const activeSessionId = chat.activeSessionIdMap[agentId] const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false) const { setTimeoutTimer } = useTimer() const dispatch = useAppDispatch() @@ -50,16 +56,16 @@ const SessionItem: FC = ({ session, agentId, isDisabled, isLoa const DeleteButton = () => { return ( -
+ {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} +
+ }> + { e.stopPropagation() if (isConfirmingDeletion || e.ctrlKey || e.metaKey) { @@ -78,17 +84,11 @@ const SessionItem: FC = ({ session, agentId, isDisabled, isLoa } }}> {isConfirmingDeletion ? ( - + ) : ( - + )} -
+ ) } @@ -106,44 +106,44 @@ const SessionItem: FC = ({ session, agentId, isDisabled, isLoa } }, [activeSessionId, dispatch, isFulfilled, session.id, sessionTopicId]) + const [topicPosition, setTopicPosition] = usePreference('topic.position') + const singlealone = topicPosition === 'right' + return ( <> - startEdit(session.name ?? '')} - className="group"> - - {isPending && !isActive && } - {isFulfilled && !isActive && } - {isEditing && ( - + {isPending && !isActive && } + {isFulfilled && !isActive && } + + {isEditing ? ( + ) => handleValueChange(e.target.value)} onKeyDown={handleKeyDown} - onClick={(e) => e.stopPropagation()} - classNames={{ - base: 'h-full', - mainWrapper: 'h-full', - inputWrapper: 'h-full min-h-0 px-1.5', - input: isSaving ? 'brightness-50' : undefined - }} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + style={{ opacity: isSaving ? 0.5 : 1 }} /> - )} - {!isEditing && ( -
- + ) : ( + <> + + + -
+ )} -
-
+ +
= ({ session, agentId, isDisabled, isLoa {t('common.edit')} + + + + {t('settings.topic.position.label')} + + + setTopicPosition('left')}> + {t('settings.topic.position.left')} + + setTopicPosition('right')}> + {t('settings.topic.position.right')} + + + = ({ session, agentId, isDisabled, isLoa ) } -const ButtonContainer: React.FC & { isActive?: boolean }> = ({ - isActive, - className, - children, - ...props -}) => { - const { topicPosition } = useSettings() - const activeBg = topicPosition === 'left' ? 'bg-[var(--color-list-item)]' : 'bg-foreground-100' - return ( - - ) -} +const SessionListItem = styled.div` + padding: 7px 12px; + border-radius: var(--list-item-border-radius); + font-size: 13px; + display: flex; + flex-direction: column; + justify-content: space-between; + cursor: pointer; + width: calc(var(--assistants-width) - 20px); + margin-bottom: 8px; -const SessionLabelContainer: React.FC> = ({ className, ...props }) => ( -
-) + .menu { + opacity: 0; + color: var(--color-text-3); + } + + &:hover { + background-color: var(--color-list-item-hover); + transition: background-color 0.1s; + + .menu { + opacity: 1; + } + } + + &.active { + background-color: var(--color-list-item); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + .menu { + opacity: 1; + + &:hover { + color: var(--color-text-2); + } + } + } + + &.singlealone { + border-radius: 0 !important; + &:hover { + background-color: var(--color-background-soft); + } + &.active { + border-left: 2px solid var(--color-primary); + box-shadow: none; + } + } +` + +const SessionNameContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + height: 20px; + justify-content: space-between; +` + +const SessionName = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 13px; + position: relative; +` + +const SessionEditInput = styled.input` + background: var(--color-background); + border: none; + color: var(--color-text-1); + font-size: 13px; + font-family: inherit; + padding: 2px 6px; + width: 100%; + outline: none; + padding: 0; +` + +const MenuButton = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + min-width: 20px; + min-height: 20px; + .anticon { + font-size: 12px; + } +` const PendingIndicator = styled.div.attrs({ className: 'animation-pulse' diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 7b2c949f16..9302e1b2a3 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -12,7 +12,7 @@ import { } from '@renderer/store/runtime' import type { CreateSessionForm } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' -import { AnimatePresence, motion } from 'framer-motion' +import { motion } from 'framer-motion' import { memo, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -30,7 +30,7 @@ const Sessions: React.FC = ({ agentId }) => { const { agent } = useAgent(agentId) const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId) const { chat } = useRuntime() - const { activeSessionId, sessionWaiting } = chat + const { activeSessionIdMap } = chat const dispatch = useAppDispatch() const setActiveSessionId = useCallback( @@ -75,24 +75,24 @@ const Sessions: React.FC = ({ agentId }) => { [agentId, deleteSession, dispatch, sessions, t] ) - const currentActiveSessionId = activeSessionId[agentId] + const activeSessionId = activeSessionIdMap[agentId] useEffect(() => { - if (!isLoading && sessions.length > 0 && !currentActiveSessionId) { + if (!isLoading && sessions.length > 0 && !activeSessionId) { setActiveSessionId(agentId, sessions[0].id) } - }, [isLoading, sessions, currentActiveSessionId, agentId, setActiveSessionId]) + }, [isLoading, sessions, activeSessionId, agentId, setActiveSessionId]) useEffect(() => { - if (currentActiveSessionId) { + if (activeSessionId) { dispatch( newMessagesActions.setTopicFulfilled({ - topicId: buildAgentSessionTopicId(currentActiveSessionId), + topicId: buildAgentSessionTopicId(activeSessionId), fulfilled: false }) ) } - }, [currentActiveSessionId, dispatch]) + }, [activeSessionId, dispatch]) if (isLoading) { return ( @@ -109,45 +109,30 @@ const Sessions: React.FC = ({ agentId }) => { if (error) return return ( - - - - {t('agent.session.add.title')} - - - - {/* h-9 */} - 9 * 4} - scrollerStyle={{ - // FIXME: This component only supports CSSProperties - overflowX: 'hidden' - }} - autoHideScrollbar> - {(session) => ( - - handleDeleteSession(session.id)} - onPress={() => setActiveSessionId(agentId, session.id)} - /> - - )} - - - +
+ + {t('agent.session.add.title')} + + {/* h-9 */} + 9 * 4} + scrollerStyle={{ + // FIXME: This component only supports CSSProperties + overflowX: 'hidden' + }} + autoHideScrollbar> + {(session) => ( + handleDeleteSession(session.id)} + onPress={() => setActiveSessionId(agentId, session.id)} + /> + )} + +
) } diff --git a/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx index 363536e8f7..3dd29e32f3 100644 --- a/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/UnifiedAddButton.tsx @@ -1,4 +1,4 @@ -import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { Button, Popover, PopoverContent, PopoverTrigger, useDisclosure } from '@heroui/react' import { AgentModal } from '@renderer/components/Popups/agent/AgentModal' import { Bot, MessageSquare } from 'lucide-react' import type { FC } from 'react' @@ -14,7 +14,7 @@ interface UnifiedAddButtonProps { const UnifiedAddButton: FC = ({ onCreateAssistant }) => { const { t } = useTranslation() const [isPopoverOpen, setIsPopoverOpen] = useState(false) - const [isAgentModalOpen, setIsAgentModalOpen] = useState(false) + const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure() const handleAddAssistant = () => { setIsPopoverOpen(false) @@ -23,7 +23,7 @@ const UnifiedAddButton: FC = ({ onCreateAssistant }) => { const handleAddAgent = () => { setIsPopoverOpen(false) - setIsAgentModalOpen(true) + onAgentModalOpen() } return ( @@ -54,7 +54,7 @@ const UnifiedAddButton: FC = ({ onCreateAssistant }) => { - setIsAgentModalOpen(false)} /> +
) } diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 6a0f8d420a..fd1b74cfee 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -1,7 +1,8 @@ import { usePreference } from '@data/hooks/usePreference' +import { Alert, Skeleton } from '@heroui/react' import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup' -import { useAgent } from '@renderer/hooks/agents/useAgent' -import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -9,14 +10,14 @@ import { useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { Assistant, Topic } from '@renderer/types' import type { Tab } from '@renderer/types/chat' -import { classNames, uuid } from '@renderer/utils' +import { classNames, getErrorMessage, uuid } from '@renderer/utils' import type { FC } from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import AgentSettingsTab from './AgentSettingsTab' import Assistants from './AssistantsTab' +import SessionSettingsTab from './SessionSettingsTab' import Settings from './SettingsTab' import Topics from './TopicsTab' @@ -49,8 +50,8 @@ const HomeTabs: FC = ({ const { t } = useTranslation() const { chat } = useRuntime() const { activeTopicOrSession, activeAgentId } = chat - const { agent } = useAgent(activeAgentId) - const { updateAgent } = useUpdateAgent() + const { session, isLoading: isSessionLoading, error: sessionError } = useActiveSession() + const { updateSession } = useUpdateSession(activeAgentId) const isSessionView = activeTopicOrSession === 'session' const isTopicView = activeTopicOrSession === 'topic' @@ -127,7 +128,7 @@ const HomeTabs: FC = ({ )} - {position === 'right' && topicPosition === 'right' && isTopicView && ( + {position === 'right' && topicPosition === 'right' && ( setTab('topic')}> {t('common.topics')} @@ -156,7 +157,20 @@ const HomeTabs: FC = ({ /> )} {tab === 'settings' && isTopicView && } - {tab === 'settings' && isSessionView && } + {tab === 'settings' && isSessionView && !sessionError && ( + + + + )} + {tab === 'settings' && isSessionView && sessionError && ( +
+ +
+ )} ) @@ -177,10 +191,7 @@ const Container = styled.div` background-color: var(--color-background); } [navbar-position='top'] & { - height: calc(100vh - var(--navbar-height) - 12px); - &.right { - height: calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px); - } + height: calc(100vh - var(--navbar-height)); } overflow: hidden; .collapsed { diff --git a/src/renderer/src/pages/home/components/ChatNavbarContent.tsx b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx new file mode 100644 index 0000000000..16f68430ac --- /dev/null +++ b/src/renderer/src/pages/home/components/ChatNavbarContent.tsx @@ -0,0 +1,148 @@ +import { BreadcrumbItem, Breadcrumbs, cn } from '@heroui/react' +import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' +import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent' +import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' +import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' +import { useRuntime } from '@renderer/hooks/useRuntime' +import type { AgentEntity, AgentSessionEntity, ApiModel, Assistant } from '@renderer/types' +import { formatErrorMessageWithPrefix } from '@renderer/utils/error' +import { t } from 'i18next' +import { Folder } from 'lucide-react' +import type { FC, ReactNode } from 'react' +import { useCallback } from 'react' + +import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings' +import { AgentLabel, SessionLabel } from '../../settings/AgentSettings/shared' +import SelectAgentBaseModelButton from './SelectAgentBaseModelButton' +import SelectModelButton from './SelectModelButton' + +interface Props { + assistant: Assistant +} + +const ChatNavbarContent: FC = ({ assistant }) => { + const { chat } = useRuntime() + const { activeTopicOrSession } = chat + const { agent: activeAgent } = useActiveAgent() + const { session: activeSession } = useActiveSession() + const { updateModel } = useUpdateSession(activeAgent?.id ?? null) + + const handleUpdateModel = useCallback( + async (model: ApiModel) => { + if (!activeAgent || !activeSession) return + return updateModel(activeSession.id, model.id, { showSuccessToast: false }) + }, + [activeAgent, activeSession, updateModel] + ) + + return ( + <> + {activeTopicOrSession === 'topic' && } + {activeTopicOrSession === 'session' && activeAgent && ( + + + AgentSettingsPopup.show({ agentId: activeAgent.id })} + classNames={{ base: 'self-stretch', item: 'h-full' }}> + + + {activeSession && ( + + SessionSettingsPopup.show({ + agentId: activeAgent.id, + sessionId: activeSession.id + }) + } + classNames={{ base: 'self-stretch', item: 'h-full' }}> + + + )} + {activeSession && ( + + + + )} + {activeAgent && activeSession && ( + + + + )} + + + )} + + ) +} + +const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity }> = ({ agent, session }) => { + if (!session || !agent) { + return null + } + + const firstAccessiblePath = session.accessible_paths?.[0] + // const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode + // const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode) + // const permissionModeLabel = permissionModeCard + // ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback) + // : permissionMode + + const infoItems: ReactNode[] = [] + + const InfoTag = ({ + text, + className, + onClick + }: { + text: string + className?: string + classNames?: {} + onClick?: (e: React.MouseEvent) => void + }) => ( +
+ + {text} +
+ ) + + // infoItems.push() + + if (firstAccessiblePath) { + infoItems.push( + { + window.api.file + .openPath(firstAccessiblePath) + .catch((e) => + window.toast.error( + formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath })) + ) + ) + }} + /> + ) + } + + // infoItems.push() + + if (infoItems.length === 0) { + return null + } + + return
{infoItems}
+} + +export default ChatNavbarContent diff --git a/src/renderer/src/pages/home/components/SelectAgentModelButton.tsx b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx similarity index 72% rename from src/renderer/src/pages/home/components/SelectAgentModelButton.tsx rename to src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx index acca217e7f..1d46335e1b 100644 --- a/src/renderer/src/pages/home/components/SelectAgentModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectAgentBaseModelButton.tsx @@ -1,10 +1,10 @@ import { Button } from '@heroui/react' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup' -import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' +import { agentModelFilter } from '@renderer/config/models' import { useApiModel } from '@renderer/hooks/agents/useModel' import { getProviderNameById } from '@renderer/services/ProviderService' -import type { AgentBaseWithId, ApiModel, Model } from '@renderer/types' +import type { AgentBaseWithId, ApiModel } from '@renderer/types' import { isAgentEntity } from '@renderer/types' import { getModelFilterByAgentType } from '@renderer/utils/agentSession' import { apiModelAdapter } from '@renderer/utils/model' @@ -13,22 +13,21 @@ import type { FC } from 'react' import { useTranslation } from 'react-i18next' interface Props { - agent: AgentBaseWithId + agentBase: AgentBaseWithId onSelect: (model: ApiModel) => Promise isDisabled?: boolean } -const SelectAgentModelButton: FC = ({ agent, onSelect, isDisabled }) => { +const SelectAgentBaseModelButton: FC = ({ agentBase: agent, onSelect, isDisabled }) => { const { t } = useTranslation() const model = useApiModel({ id: agent?.model }) const apiFilter = isAgentEntity(agent) ? getModelFilterByAgentType(agent.type) : undefined - const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model) if (!agent) return null const onSelectModel = async () => { - const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter }) + const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter: agentModelFilter }) if (selectedModel && selectedModel.id !== agent.model) { onSelect(selectedModel) } @@ -40,12 +39,12 @@ const SelectAgentModelButton: FC = ({ agent, onSelect, isDisabled }) => { diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index fd9785c20c..bc2fa87b4a 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -138,15 +138,22 @@ const TranslatePage: FC = () => { // ) // 控制复制行为 + const copy = useCallback( + async (text: string) => { + await navigator.clipboard.writeText(text) + setCopied(true) + }, + [setCopied] + ) + const onCopy = useCallback(async () => { try { - await navigator.clipboard.writeText(translatedContent) - setCopied(true) + await copy(translatedContent) } catch (error) { logger.error('Failed to copy text to clipboard:', error as Error) window.toast.error(t('common.copy_failed')) } - }, [setCopied, t, translatedContent]) + }, [copy, t, translatedContent]) /** * 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常 @@ -187,7 +194,7 @@ const TranslatePage: FC = () => { setTimeoutTimer( 'auto-copy', async () => { - await onCopy() + await copy(translated) }, 100 ) @@ -204,7 +211,7 @@ const TranslatePage: FC = () => { window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e)) } }, - [autoCopy, onCopy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] + [autoCopy, copy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] ) // 控制翻译按钮是否可用 diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index af44d4430b..96c8cc2e5a 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -36,7 +36,7 @@ export const DEFAULT_ASSISTANT_SETTINGS: AssistantSettings = { maxTokens: 0, streamOutput: true, topP: 1, - enableTopP: true, + enableTopP: false, toolUseMode: 'prompt', customParameters: [] } @@ -175,7 +175,7 @@ export const getAssistantSettings = (assistant: Assistant): AssistantSettings => temperature: assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE, enableTemperature: assistant?.settings?.enableTemperature ?? true, topP: assistant?.settings?.topP ?? 1, - enableTopP: assistant?.settings?.enableTopP ?? true, + enableTopP: assistant?.settings?.enableTopP ?? false, enableMaxTokens: assistant?.settings?.enableMaxTokens ?? false, maxTokens: getAssistantMaxTokens(), streamOutput: assistant?.settings?.streamOutput ?? true, diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 498beed049..ce5895fc36 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -24,6 +24,7 @@ export const EVENT_NAMES = { COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE', EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE', LOCATE_MESSAGE: 'LOCATE_MESSAGE', + LOCATE_NOTE_LINE: 'LOCATE_NOTE_LINE', ADD_NEW_TOPIC: 'ADD_NEW_TOPIC', RESEND_MESSAGE: 'RESEND_MESSAGE', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', diff --git a/src/renderer/src/services/NotesSearchService.ts b/src/renderer/src/services/NotesSearchService.ts new file mode 100644 index 0000000000..fa9c3e3186 --- /dev/null +++ b/src/renderer/src/services/NotesSearchService.ts @@ -0,0 +1,262 @@ +import { loggerService } from '@logger' +import type { NotesTreeNode } from '@renderer/types/note' + +const logger = loggerService.withContext('NotesSearchService') + +/** + * Search match result + */ +export interface SearchMatch { + lineNumber: number + lineContent: string + matchStart: number + matchEnd: number + context: string +} + +/** + * Search result with match information + */ +export interface SearchResult extends NotesTreeNode { + matchType: 'filename' | 'content' | 'both' + matches?: SearchMatch[] + score: number +} + +/** + * Search options + */ +export interface SearchOptions { + caseSensitive?: boolean + useRegex?: boolean + maxFileSize?: number + maxMatchesPerFile?: number + contextLength?: number +} + +/** + * Escape regex special characters + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Calculate relevance score + * - Filename match has higher priority + * - More matches increase score + * - More recent updates increase score + */ +export function calculateRelevanceScore(node: NotesTreeNode, keyword: string, matches: SearchMatch[]): number { + let score = 0 + + // Exact filename match (highest weight) + if (node.name.toLowerCase() === keyword.toLowerCase()) { + score += 200 + } + // Filename contains match (high weight) + else if (node.name.toLowerCase().includes(keyword.toLowerCase())) { + score += 100 + } + + // Content match count + score += Math.min(matches.length * 2, 50) + + // Recent updates boost score + const daysSinceUpdate = (Date.now() - new Date(node.updatedAt).getTime()) / (1000 * 60 * 60 * 24) + score += Math.max(0, 10 - daysSinceUpdate) + + return score +} + +/** + * Search file content for keyword matches + */ +export async function searchFileContent( + node: NotesTreeNode, + keyword: string, + options: SearchOptions = {} +): Promise { + const { + caseSensitive = false, + useRegex = false, + maxFileSize = 10 * 1024 * 1024, // 10MB + maxMatchesPerFile = 50, + contextLength = 50 + } = options + + try { + if (node.type !== 'file') { + return null + } + + const content = await window.api.file.readExternal(node.externalPath) + + if (!content) { + return null + } + + if (content.length > maxFileSize) { + logger.warn(`File too large to search: ${node.externalPath} (${content.length} bytes)`) + return null + } + + const flags = caseSensitive ? 'g' : 'gi' + const pattern = useRegex ? new RegExp(keyword, flags) : new RegExp(escapeRegex(keyword), flags) + + const lines = content.split('\n') + const matches: SearchMatch[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + pattern.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = pattern.exec(line)) !== null) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length + + // Keep context short: only 2 chars before match, more after + const beforeMatch = Math.min(2, matchStart) + const contextStart = matchStart - beforeMatch + const contextEnd = Math.min(line.length, matchEnd + contextLength) + + // Add ellipsis if context doesn't start at line beginning + const prefix = contextStart > 0 ? '...' : '' + const contextText = prefix + line.substring(contextStart, contextEnd) + + matches.push({ + lineNumber: i + 1, + lineContent: line, + matchStart: beforeMatch + prefix.length, + matchEnd: matchEnd - matchStart + beforeMatch + prefix.length, + context: contextText + }) + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length >= maxMatchesPerFile) { + break + } + } + + if (matches.length === 0) { + return null + } + + const score = calculateRelevanceScore(node, keyword, matches) + + return { + ...node, + matchType: 'content', + matches, + score + } + } catch (error) { + logger.error(`Failed to search file content for ${node.externalPath}:`, error as Error) + return null + } +} + +/** + * Check if filename matches keyword + */ +export function matchFileName(node: NotesTreeNode, keyword: string, caseSensitive = false): boolean { + const name = caseSensitive ? node.name : node.name.toLowerCase() + const key = caseSensitive ? keyword : keyword.toLowerCase() + return name.includes(key) +} + +/** + * Flatten tree to extract file nodes + */ +export function flattenTreeToFiles(nodes: NotesTreeNode[]): NotesTreeNode[] { + const result: NotesTreeNode[] = [] + + function traverse(nodes: NotesTreeNode[]) { + for (const node of nodes) { + if (node.type === 'file') { + result.push(node) + } + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(nodes) + return result +} + +/** + * Search all files concurrently + */ +export async function searchAllFiles( + nodes: NotesTreeNode[], + keyword: string, + options: SearchOptions = {}, + signal?: AbortSignal +): Promise { + const startTime = performance.now() + const CONCURRENCY = 5 + const results: SearchResult[] = [] + + const fileNodes = flattenTreeToFiles(nodes) + + logger.debug( + `Starting full-text search: keyword="${keyword}", totalFiles=${fileNodes.length}, options=${JSON.stringify(options)}` + ) + + const queue = [...fileNodes] + + const worker = async () => { + while (queue.length > 0) { + if (signal?.aborted) { + break + } + + const node = queue.shift() + if (!node) break + + const nameMatch = matchFileName(node, keyword, options.caseSensitive) + const contentResult = await searchFileContent(node, keyword, options) + + if (nameMatch && contentResult) { + results.push({ + ...contentResult, + matchType: 'both', + score: contentResult.score + 100 + }) + } else if (nameMatch) { + results.push({ + ...node, + matchType: 'filename', + matches: [], + score: 100 + }) + } else if (contentResult) { + results.push(contentResult) + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fileNodes.length) }, () => worker())) + + const sortedResults = results.sort((a, b) => b.score - a.score) + + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + + logger.debug( + `Full-text search completed: keyword="${keyword}", duration=${duration}ms, ` + + `totalFiles=${fileNodes.length}, resultsFound=${sortedResults.length}, ` + + `filenameMatches=${sortedResults.filter((r) => r.matchType === 'filename').length}, ` + + `contentMatches=${sortedResults.filter((r) => r.matchType === 'content').length}, ` + + `bothMatches=${sortedResults.filter((r) => r.matchType === 'both').length}` + ) + + return sortedResults +} diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index e3db7c8197..0cd84879cd 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -194,8 +194,7 @@ const assistantsSlice = createSlice({ }) }, addAssistantPreset: (state, action: PayloadAction) => { - // @ts-ignore ts-2589 false positive - state.agents.push(action.payload) + state.presets.push(action.payload) }, removeAssistantPreset: (state, action: PayloadAction<{ id: string }>) => { state.presets = state.presets.filter((c) => c.id !== action.payload.id) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 151075799f..30d5dfe309 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -69,7 +69,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 162, + version: 163, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 2bae82c147..eb659399be 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -144,6 +144,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ type: 'inMemory', isActive: false, provider: 'CherryAI' + }, + { + id: nanoid(), + name: '@cherry/didi-mcp', + reference: 'https://mcp.didichuxing.com/', + type: 'inMemory', + isActive: false, + env: { + DIDI_API_KEY: 'YOUR_DIDI_API_KEY' + }, + shouldConfig: true, + provider: 'CherryAI' } ] as const diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 726d184669..61d7e5be5e 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -243,7 +243,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined } } // 3. Handle Knowledge Base References - if (block.knowledge && block.knowledge.length > 0) { + if (block.knowledge && Array.isArray(block.knowledge) && block.knowledge.length > 0) { formattedCitations.push( ...block.knowledge.map((result, index) => { const filePattern = /\[(.*?)]\(http:\/\/file\/(.*?)\)/ @@ -271,7 +271,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined ) } - if (block.memories && block.memories.length > 0) { + if (block.memories && Array.isArray(block.memories) && block.memories.length > 0) { // 5. Handle Memory References formattedCitations.push( ...block.memories.map((memory, index) => ({ diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 08ba587736..0a63f09672 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2671,6 +2671,20 @@ const migrateConfig = { logger.error('migrate 162 error', error as Error) return state } + }, + '163': (state: RootState) => { + try { + addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr) + state.llm.providers.forEach((provider) => { + if (provider.id === 'cherryin') { + provider.anthropicApiHost = 'https://open.cherryin.net' + } + }) + return state + } catch (error) { + logger.error('migrate 163 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 568b8eea67..6ea0de9d73 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -16,7 +16,7 @@ export interface ChatState { activeAgentId: string | null /** UI state. Map agent id to active session id. * null represents no active session */ - activeSessionId: Record + activeSessionIdMap: Record /** meanwhile active Assistants or Agents */ activeTopicOrSession: 'topic' | 'session' /** topic ids that are currently being renamed */ @@ -95,7 +95,7 @@ const initialState: RuntimeState = { activeTopic: null, activeAgentId: null, activeTopicOrSession: 'topic', - activeSessionId: {}, + activeSessionIdMap: {}, renamingTopics: [], newlyRenamedTopics: [], sessionWaiting: {} @@ -168,7 +168,7 @@ const runtimeSlice = createSlice({ }, setActiveSessionIdAction: (state, action: PayloadAction<{ agentId: string; sessionId: string | null }>) => { const { agentId, sessionId } = action.payload - state.chat.activeSessionId[agentId] = sessionId + state.chat.activeSessionIdMap[agentId] = sessionId }, setActiveTopicOrSessionAction: (state, action: PayloadAction<'topic' | 'session'>) => { state.chat.activeTopicOrSession = action.payload diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index d3171e16d4..2cb2fe4367 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -266,8 +266,12 @@ export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({ slash_commands: z.array(SlashCommandSchema).optional() // Array of slash commands to trigger the agent }) +export const CreateAgentSessionResponseSchema = GetAgentSessionResponseSchema + export type GetAgentSessionResponse = z.infer +export type CreateAgentSessionResponse = GetAgentSessionResponse + export const ListAgentSessionsResponseSchema = z.object({ data: z.array(AgentSessionEntitySchema), total: z.int(), diff --git a/src/renderer/src/types/apiModels.ts b/src/renderer/src/types/apiModels.ts index ee0d63d850..565eb9241a 100644 --- a/src/renderer/src/types/apiModels.ts +++ b/src/renderer/src/types/apiModels.ts @@ -6,7 +6,6 @@ import { ProviderTypeSchema } from './provider' // Request schema for /v1/models export const ApiModelsFilterSchema = z.object({ providerType: ProviderTypeSchema.optional(), - supportAnthropic: z.coerce.boolean().optional(), offset: z.coerce.number().min(0).default(0).optional(), limit: z.coerce.number().min(1).default(20).optional() }) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 83024d1aa6..5a0e5fffbb 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -686,7 +686,8 @@ export const BuiltinMCPServerNames = { fetch: '@cherry/fetch', filesystem: '@cherry/filesystem', difyKnowledge: '@cherry/dify-knowledge', - python: '@cherry/python' + python: '@cherry/python', + didiMCP: '@cherry/didi-mcp' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames] diff --git a/src/renderer/src/types/ocr.ts b/src/renderer/src/types/ocr.ts index a1b7315081..66e0f9616f 100644 --- a/src/renderer/src/types/ocr.ts +++ b/src/renderer/src/types/ocr.ts @@ -6,7 +6,8 @@ import { isImageFileMetadata } from '.' export const BuiltinOcrProviderIds = { tesseract: 'tesseract', system: 'system', - paddleocr: 'paddleocr' + paddleocr: 'paddleocr', + ovocr: 'ovocr' } as const export type BuiltinOcrProviderId = keyof typeof BuiltinOcrProviderIds @@ -189,3 +190,19 @@ export type OcrPpocrProvider = { export const isOcrPpocrProvider = (p: OcrProvider): p is OcrPpocrProvider => { return p.id === BuiltinOcrProviderIds.paddleocr } + +// OV OCR Types +export type OcrOvConfig = OcrProviderBaseConfig & { + langs?: TranslateLanguageCode[] +} + +export type OcrOvProvider = { + id: 'ovocr' + config: OcrOvConfig +} & ImageOcrProvider & + // PdfOcrProvider & + BuiltinOcrProvider + +export const isOcrOVProvider = (p: OcrProvider): p is OcrOvProvider => { + return p.id === BuiltinOcrProviderIds.ovocr +} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts index 44396d76ca..25a330b114 100644 --- a/src/renderer/src/utils/__tests__/markdownConverter.test.ts +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -1,7 +1,18 @@ +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import { describe, expect, it } from 'vitest' import { htmlToMarkdown, markdownToHtml } from '../markdownConverter' +/** + * Strip markdown line number attributes for testing HTML structure + */ + +const LINE_NUMBER_REGEX = new RegExp(`\\s*${MARKDOWN_SOURCE_LINE_ATTR.replace(/-/g, '\\-')}="\\d+"`, 'g') + +function stripLineNumbers(html: string): string { + return html.replace(LINE_NUMBER_REGEX, '') +} + describe('markdownConverter', () => { describe('htmlToMarkdown', () => { it('should convert HTML to Markdown', () => { @@ -104,13 +115,13 @@ describe('markdownConverter', () => { describe('markdownToHtml', () => { it('should convert
to
', () => { const markdown = 'Text with
\nindentation
\nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

Text with
\nindentation
\nand without indentation

\n') }) it('should handle indentation in blockquotes', () => { const markdown = '> Quote line 1\n> Quote line 2 with indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // This should preserve indentation within the blockquote expect(result).toContain('Quote line 1') expect(result).toContain('Quote line 2 with indentation') @@ -118,7 +129,7 @@ describe('markdownConverter', () => { it('should preserve indentation in nested lists', () => { const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) // Should create proper nested list structure expect(result).toContain('
    ') expect(result).toContain('
  • ') @@ -126,13 +137,13 @@ describe('markdownConverter', () => { it('should handle poetry or formatted text with indentation', () => { const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you

    \n') }) it('should preserve indentation after line breaks with multiple paragraphs', () => { const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

    First paragraph

    \n
    with indentation\n\nSecond paragraph\n

    with different indentation

    \n' ) @@ -140,14 +151,14 @@ describe('markdownConverter', () => { it('should handle zero-width indentation (just line break)', () => { const markdown = 'Hello\n\nWorld' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Hello

    \n

    World

    \n') }) it('should preserve indentation in mixed content', () => { const markdown = 'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '

    Normal text\nIndented continuation

    \n
      \n
    • List item\nList continuation
    • \n
    \n
    \n

    Quote\nIndented quote

    \n
    \n' ) @@ -155,19 +166,19 @@ describe('markdownConverter', () => { it('should convert Markdown to HTML', () => { const markdown = '# Hello World' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

    Hello World

    ') }) it('should convert math block syntax to HTML', () => { const markdown = '$$a+b+c$$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('
    ') }) it('should convert math inline syntax to HTML', () => { const markdown = '$a+b+c$' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('') }) @@ -181,7 +192,7 @@ describe('markdownConverter', () => { \\nabla \\cdot \\vec{\\mathbf{B}} & = 0 \\end{array}$$` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain( '
    ' ) @@ -189,7 +200,7 @@ describe('markdownConverter', () => { it('should convert task list syntax to proper HTML', () => { const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('data-type="taskItem"') expect(result).toContain('data-checked="false"') @@ -202,7 +213,7 @@ describe('markdownConverter', () => { it('should convert mixed task list with checked and unchecked items', () => { const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('First task') expect(result).toContain('Second task') @@ -213,14 +224,14 @@ describe('markdownConverter', () => { it('should NOT convert standalone task syntax to task list', () => { const markdown = '[x] abcd' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('

    [x] abcd

    ') expect(result).not.toContain('data-type="taskList"') }) it('should handle regular list items alongside task lists', () => { const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toContain('data-type="taskList"') expect(result).toContain('Regular item') expect(result).toContain('Task item') @@ -241,25 +252,25 @@ describe('markdownConverter', () => { const markdown = `# 🌠 Screenshot ![](https://example.com/image.png)` - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    🌠 Screenshot

    \n

    \n') }) it('should handle heading and paragraph', () => { const markdown = '# Hello\n\nHello' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Hello

    \n

    Hello

    \n') }) it('should convert code block to HTML', () => { const markdown = '```\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('
    console.log("Hello, world!");\n
    ') }) it('should convert code block with language to HTML', () => { const markdown = '```javascript\nconsole.log("Hello, world!");\n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
    console.log("Hello, world!");\n
    ' ) @@ -267,7 +278,7 @@ describe('markdownConverter', () => { it('should convert table to HTML', () => { const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    f
    f
    f
    \n' ) @@ -275,7 +286,7 @@ describe('markdownConverter', () => { it('should escape XML-like tags in code blocks', () => { const markdown = '```jsx\nconst component = <>
    content
    \n```' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
    const component = <><div>content</div></>\n
    ' ) @@ -283,13 +294,13 @@ describe('markdownConverter', () => { it('should escape XML-like tags in inline code', () => { const markdown = 'Use `<>` for fragments' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Use <> for fragments

    \n') }) it('shoud convert XML-like tags in paragraph', () => { const markdown = '' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    \n') }) }) @@ -297,7 +308,7 @@ describe('markdownConverter', () => { describe('Task List with Labels', () => { it('should wrap task items with labels when label option is true', () => { const markdown = '- [ ] abcd\n\n- [x] efgh' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe( '
      \n
    • \n

      \n
    • \n
    • \n

      \n
    • \n
    \n' ) @@ -317,7 +328,7 @@ describe('markdownConverter', () => { const originalHtml = '
    ' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe( '
      \n
    • \n
    \n' @@ -328,7 +339,7 @@ describe('markdownConverter', () => { const originalHtml = '
      \n
    • \n

      123

      \n
    • \n
    • \n

      \n
    • \n
    \n' const markdown = htmlToMarkdown(originalHtml) - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) expect(html).toBe(originalHtml) }) @@ -383,7 +394,7 @@ describe('markdownConverter', () => { describe('markdown image', () => { it('should convert markdown image to HTML img tag', () => { const markdown = '![foo](train.jpg)' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    foo

    \n') }) it('should convert markdown image with file:// protocol to HTML img tag', () => { @@ -420,7 +431,7 @@ describe('markdownConverter', () => { it('should handle hardbreak with backslash followed by indented text', () => { const markdown = 'Text with \\\n indentation \\\nand without indentation' - const result = markdownToHtml(markdown) + const result = stripLineNumbers(markdownToHtml(markdown)) expect(result).toBe('

    Text with
    \nindentation
    \nand without indentation

    \n') }) @@ -454,7 +465,7 @@ describe('markdownConverter', () => { it('should preserve custom XML tags mixed with regular markdown', () => { const markdown = '# Heading\n\nWidget content\n\n**Bold text**' - const html = markdownToHtml(markdown) + const html = stripLineNumbers(markdownToHtml(markdown)) const backToMarkdown = htmlToMarkdown(html) expect(html).toContain('

    Heading

    ') @@ -470,7 +481,7 @@ describe('markdownConverter', () => { it('should not add unwanted line breaks during simple text typing', () => { const html = '

    Hello world

    ' const markdown = htmlToMarkdown(html) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('Hello world') expect(backToHtml).toBe('

    Hello world

    \n') @@ -479,7 +490,7 @@ describe('markdownConverter', () => { it('should preserve simple paragraph structure during round-trip conversion', () => { const originalHtml = '

    This is a simple paragraph being typed

    ' const markdown = htmlToMarkdown(originalHtml) - const backToHtml = markdownToHtml(markdown) + const backToHtml = stripLineNumbers(markdownToHtml(markdown)) expect(markdown).toBe('This is a simple paragraph being typed') expect(backToHtml).toBe('

    This is a simple paragraph being typed

    \n') }) @@ -520,4 +531,24 @@ cssclasses: expect(backToMarkdown).toBe(markdown) }) }) + + describe('should have markdown line number injected in HTML', () => { + it('should inject line numbers into paragraphs', () => { + const markdown = 'First paragraph\n\nSecond paragraph\n\nThird paragraph' + const result = markdownToHtml(markdown) + expect(result).toContain(`

    First paragraph

    `) + expect(result).toContain(`

    Second paragraph

    `) + expect(result).toContain(`

    Third paragraph

    `) + }) + + it('should inject line numbers into mixed content', () => { + const markdown = 'Text\n\n- List\n\n> Quote' + const result = markdownToHtml(markdown) + expect(result).toContain(`

    Text

    `) + expect(result).toContain(`
      `) + expect(result).toContain(`
    • List
    • `) + expect(result).toContain(`
      `) + expect(result).toContain(`

      Quote

      `) + }) + }) }) diff --git a/src/renderer/src/utils/agentSession.ts b/src/renderer/src/utils/agentSession.ts index bf72f40552..69cccfcc11 100644 --- a/src/renderer/src/utils/agentSession.ts +++ b/src/renderer/src/utils/agentSession.ts @@ -18,7 +18,7 @@ export const getModelFilterByAgentType = (type: AgentType): ApiModelsFilter => { switch (type) { case 'claude-code': return { - supportAnthropic: true + providerType: 'anthropic' } default: return {} diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 62d0db5623..550de8c7dd 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -5,7 +5,7 @@ * @returns {string} 格式化后的 API key 字符串。 */ export function formatApiKeys(value: string): string { - return value.replaceAll(',', ',').replaceAll(' ', ',').replaceAll('\n', ',') + return value.replaceAll(',', ',').replaceAll('\n', ',') } /** diff --git a/src/renderer/src/utils/markdownConverter.ts b/src/renderer/src/utils/markdownConverter.ts index 4c225ee3bb..ac55d16ed4 100644 --- a/src/renderer/src/utils/markdownConverter.ts +++ b/src/renderer/src/utils/markdownConverter.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants' import type { TurndownPlugin } from '@truto/turndown-plugin-gfm' import he from 'he' import htmlTags, { type HtmlTags } from 'html-tags' @@ -85,12 +86,57 @@ const md = new MarkdownIt({ typographer: false // Enable smartypants and other sweet transforms }) +// Helper function to inject line number data attribute +function injectLineNumber(token: any, openTag: string): string { + if (token.map && token.map.length >= 2) { + const startLine = token.map[0] + 1 // Convert to 1-based line number + // Insert data attribute before the first closing > + // Handle both self-closing tags (e.g.,
      ) and opening tags (e.g.,

      ) + const result = openTag.replace(/(\s*\/?>)/, ` ${MARKDOWN_SOURCE_LINE_ATTR}="${startLine}"$1`) + logger.debug('injectLineNumber', { openTag, result, startLine, hasMap: !!token.map }) + return result + } + return openTag +} + +// Store the original renderer +const defaultRender = md.renderer.render.bind(md.renderer) + +// Override the main render method to inject line numbers +md.renderer.render = function (tokens, options, env) { + return defaultRender(tokens, options, env) +} + +// Override default rendering rules to add line numbers +const defaultBlockRules = [ + 'paragraph_open', + 'heading_open', + 'blockquote_open', + 'bullet_list_open', + 'ordered_list_open', + 'list_item_open', + 'table_open', + 'hr' +] + +defaultBlockRules.forEach((ruleName) => { + const original = md.renderer.rules[ruleName] + md.renderer.rules[ruleName] = function (tokens, idx, options, env, self) { + const token = tokens[idx] + let result = original ? original(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options) + result = injectLineNumber(token, result) + return result + } +}) + // Override the code_block and code_inline renderers to properly escape HTML entities md.renderer.rules.code_block = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `

      ${escapedContent}
      ` + let html = `
      ${escapedContent}
      ` + html = injectLineNumber(token, html) + return html } md.renderer.rules.code_inline = function (tokens, idx) { @@ -103,7 +149,9 @@ md.renderer.rules.fence = function (tokens, idx) { const token = tokens[idx] const langName = token.info ? ` class="language-${token.info.trim()}"` : '' const escapedContent = he.encode(token.content, { useNamedReferences: false }) - return `
      ${escapedContent}
      ` + let html = `
      ${escapedContent}
      ` + html = injectLineNumber(token, html) + return html } // Custom task list plugin for markdown-it @@ -305,8 +353,11 @@ function yamlFrontMatterPlugin(md: MarkdownIt) { // Renderer: output YAML front matter as special HTML element md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' - return `
      ${content}
      ` + const token = tokens[idx] + const content = token?.content ?? '' + let html = `
      ${content}
      ` + html = injectLineNumber(token, html) + return html } } @@ -408,9 +459,12 @@ function tipTapKatexPlugin(md: MarkdownIt) { // 2) Renderer: output TipTap-friendly container md.renderer.rules.math_block = (tokens: Array<{ content?: string }>, idx: number): string => { - const content = tokens[idx]?.content ?? '' + const token = tokens[idx] + const content = token?.content ?? '' const latexEscaped = he.encode(content, { useNamedReferences: true }) - return `
      ` + let html = `
      ` + html = injectLineNumber(token, html) + return html } // 3) Inline parser: recognize $...$ on a single line as inline math diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index 5d57547f69..00c5493343 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -26,7 +26,7 @@ export const oauthWithSiliconFlow = async (setKey) => { } export const oauthWithAihubmix = async (setKey) => { - const authUrl = ` https://aihubmix.com/token?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh` + const authUrl = ` https://console.aihubmix.com/token?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh` const popup = window.open( authUrl, diff --git a/yarn.lock b/yarn.lock index e39f6d64cf..5c25d1542b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6689,6 +6689,18 @@ __metadata: languageName: node linkType: hard +"@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": + version: 0.1.18 + resolution: "@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::version=0.1.18&hash=1cf9d0" + dependencies: + "@ai-sdk/openai": "npm:^2.0.42" + "@ai-sdk/openai-compatible": "npm:^1.0.19" + "@ai-sdk/provider": "npm:^2.1.0-beta.4" + "@ai-sdk/provider-utils": "npm:^3.0.10" + checksum: 10c0/cfffc031d2742068d20baed0e0ade6e9182c29ee7a425fa64262c04023ae75220b8b944ad2c9554255681e325fa1a70ec5e1f961b5f7370c871e70cbaeac0e79 + languageName: node + linkType: hard + "@oxc-project/runtime@npm:0.71.0": version: 0.71.0 resolution: "@oxc-project/runtime@npm:0.71.0" @@ -12963,7 +12975,7 @@ __metadata: "@opentelemetry/sdk-trace-base": "npm:^2.0.0" "@opentelemetry/sdk-trace-node": "npm:^2.0.0" "@opentelemetry/sdk-trace-web": "npm:^2.0.0" - "@opeoginni/github-copilot-openai-compatible": "npm:0.1.18" + "@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": "npm:^1.52.0" "@radix-ui/react-context-menu": "npm:^2.2.16" "@reduxjs/toolkit": "npm:^2.2.5"