Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex
2025-10-31 18:29:44 +08:00
69 changed files with 3571 additions and 1518 deletions
+98 -42
View File
@@ -134,60 +134,116 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-beta.2
What's New in v1.7.0-beta.3
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
- Enhanced Tool Permission System: Real-time tool approval interface with improved UX
- Plugin Management System: Support for Claude Agent plugins (agents, commands, skills)
- Skill Tool: Add skill execution capabilities for agents
- Mobile App Data Restore: Support restoring data to mobile applications
- OpenMinerU Preprocessor: Knowledge base now supports open-source MinerU for document processing
- HuggingFace Provider: Added HuggingFace as AI provider
- Claude Haiku 4.5: Support for the latest Claude Haiku 4.5 model
- Ling Series Models: Added support for Ling-1T and related models
- Intel OVMS Painting: New painting provider using Intel OpenVINO Model Server
- Automatic Update Checks: Implement periodic update checking with configurable intervals
- HuggingChat Mini App: New mini app for HuggingChat integration
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
- Agent Creation: New agents are now automatically activated upon creation
- Lazy Loading: Optimize page load performance with route lazy loading
- UI Enhancements: Improved agent item styling and layout consistency
- Navigation: Better navbar layout for fullscreen mode on macOS
- Settings Tab: Enhanced context slider consistency
- Backup Manager: Unified footer layout for local and S3 backup managers
- Menu System: Enhanced application menu with improved help section
- Proxy Rules: Comprehensive proxy bypass rule matching
- German Language: Added German language support
- MCP Confirmation: Added confirmation modal when activating protocol-installed MCP servers
- Translation: Enhanced translation script with concurrency and validation
- Electron & Vite: Updated to Electron 38 and Vite 4.0.1
Claude Code Tool Improvements:
- GlobTool: Now counts lines instead of files in output for better clarity
- ReadTool: Automatically removes system reminder tags from output
- TodoWriteTool: Improved rendering behavior
- Environment Variables: Updated model-related environment variable names
Bug Fixes:
- 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
- Fixed session model not being used when sending messages
- Fixed tool approval UI and shared workspace plugin inconsistencies
- Fixed API server readiness notification to renderer
- Fixed grouped items not respecting saved tag order
- Fixed assistant/agent activation when creating new ones
- Fixed Dashscope Anthropic API host and migrated old configs
- Fixed Qwen3 thinking mode control for Ollama
- Fixed disappeared MCP button
- Fixed create assistant causing blank screen
- Fixed up-down button visibility in some cases
- Fixed hooks preventing save on composing enter key
- Fixed Azure GPT-image-1 and OpenRouter Gemini-image
- Fixed Silicon reasoning issues
- Fixed topic branch incomplete copy with two-pass ID mapping
- Fixed deep research model search context restrictions
- Fixed model capability checking logic
- Fixed reranker API error response capture
- Fixed right-click paste file content into inputbar
- Fixed minimax-m2 support in aiCore
<!--LANG:zh-CN-->
v1.7.0-beta.2 新特性
v1.7.0-beta.3 新特性
新功能:
- 会话设置:独立管理会话特定的设置和模型配置
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
- Intel OV OCR:使用 Intel NPU 的硬件加速 OCR
- 自动启动 API 服务器:当存在 Agent 时自动启动
- 增强工具权限系统:实时工具审批界面,改进用户体验
- 插件管理系统:支持 Claude Agent 插件(agents、commands、skills
- 技能工具:为 Agent 添加技能执行能力
- 移动应用数据恢复:支持将数据恢复到移动应用程序
- OpenMinerU 预处理器:知识库现支持使用开源 MinerU 处理文档
- HuggingFace 提供商:添加 HuggingFace 作为 AI 提供商
- Claude Haiku 4.5:支持最新的 Claude Haiku 4.5 模型
- Ling 系列模型:添加 Ling-1T 及相关模型支持
- Intel OVMS 绘图:使用 Intel OpenVINO 模型服务器的新绘图提供商
- 自动更新检查:实现可配置间隔的定期更新检查
- HuggingChat 小程序:新增 HuggingChat 集成小程序
改进:
- Agent 模型选择现在需要用户显式选择
- 添加 Mistral AI 提供商支持
- 添加 NewAPI 通用提供商支持
- 改进不同模式下的导航栏布局一致性
- 增强聊天组件响应式设计
- 优化小屏幕代码块显示
- 更新 OVMS 至 2025.3 正式版
- 添加希腊语支持
- Agent 创建:新创建的 Agent 现在会自动激活
- 懒加载:通过路由懒加载优化页面加载性能
- UI 增强:改进 Agent 项目样式和布局一致性
- 导航:改进 macOS 全屏模式下的导航栏布局
- 设置选项卡:增强上下文滑块一致性
- 备份管理器:统一本地和 S3 备份管理器的页脚布局
- 菜单系统:增强应用菜单,改进帮助部分
- 代理规则:全面的代理绕过规则匹配
- 德语支持:添加德语语言支持
- MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框
- 翻译:增强翻译脚本的并发和验证功能
- Electron & Vite:更新至 Electron 38 和 Vite 4.0.1
Claude Code 工具改进:
- GlobTool:现在计算行数而不是文件数,提供更清晰的输出
- ReadTool:自动从输出中移除系统提醒标签
- TodoWriteTool:改进渲染行为
- 环境变量:更新模型相关的环境变量名称
问题修复:
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
- 修复助手创建失败
- 修复翻译自动复制功能
- 修复小程序外部链接打开
- 修复消息布局和溢出问题
- 修复 API 密钥解析以保留空格
- 修复不同导航栏布局中的 Agent 显示
- 修复发送消息时未使用会话模型
- 修复工具审批 UI 和共享工作区插件不一致
- 修复 API 服务器就绪通知到渲染器
- 修复分组项目不遵守已保存标签顺序
- 修复创建新的助手/Agent 时的激活问题
- 修复 Dashscope Anthropic API 主机并迁移旧配置
- 修复 Ollama 的 Qwen3 思考模式控制
- 修复 MCP 按钮消失
- 修复创建助手导致空白屏幕
- 修复某些情况下上下按钮可见性
- 修复钩子在输入法输入时阻止保存
- 修复 Azure GPT-image-1 和 OpenRouter Gemini-image
- 修复 Silicon 推理问题
- 修复主题分支不完整复制,采用两阶段 ID 映射
- 修复深度研究模型搜索上下文限制
- 修复模型能力检查逻辑
- 修复 reranker API 错误响应捕获
- 修复右键粘贴文件内容到输入栏
- 修复 aiCore 中的 minimax-m2 支持
<!--LANG:END-->
+2
View File
@@ -95,8 +95,10 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"qrcode.react": "^4.2.0",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
+9 -1
View File
@@ -354,6 +354,7 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status',
ApiServer_Ready = 'api-server:ready',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config',
@@ -395,5 +396,12 @@ export enum IpcChannel {
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content',
// WebSocket
WebSocket_Start = 'webSocket:start',
WebSocket_Stop = 'webSocket:stop',
WebSocket_Status = 'webSocket:status',
WebSocket_SendFile = 'webSocket:send-file',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
}
+9
View File
@@ -1,8 +1,10 @@
import { createServer } from 'node:http'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { agentService } from '../services/agents'
import { windowService } from '../services/WindowService'
import { app } from './app'
import { config } from './config'
@@ -43,6 +45,13 @@ export class ApiServer {
return new Promise((resolve, reject) => {
this.server!.listen(port, host, () => {
logger.info('API server started', { host, port })
// Notify renderer that API server is ready
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.ApiServer_Ready)
}
resolve()
})
+9 -1
View File
@@ -30,6 +30,7 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC
import fontList from 'font-list'
import { agentMessageRepository } from './services/agents/database'
import { PluginService } from './services/agents/plugins/PluginService'
import { apiServerService } from './services/ApiServerService'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
@@ -50,7 +51,6 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { PluginService } from './services/PluginService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -72,6 +72,7 @@ import {
} from './services/SpanCacheService'
import storeSyncService from './services/StoreSyncService'
import VertexAIService from './services/VertexAIService'
import WebSocketService from './services/WebSocketService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
@@ -1021,6 +1022,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
// WebSocket
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start)
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop)
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
// Preference handlers
PreferenceService.registerIpcHandler()
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
import { loggerService } from '@logger'
import * as fs from 'fs'
import { networkInterfaces } from 'os'
import * as path from 'path'
import type { Socket } from 'socket.io'
import { Server } from 'socket.io'
import { windowService } from './WindowService'
const logger = loggerService.withContext('WebSocketService')
class WebSocketService {
private io: Server | null = null
private isStarted = false
private port = 7017
private connectedClients = new Set<string>()
private getLocalIpAddress(): string | undefined {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ interface: string; address: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
interface: name,
address: iface.address,
priority
})
}
}
}
if (candidates.length === 0) {
logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1')
return '127.0.0.1'
}
// 按优先级排序,选择优先级最高的
candidates.sort((a, b) => a.priority - b.priority)
const best = candidates[0]
logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`)
return best.address
}
public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => {
if (this.isStarted && this.io) {
return { success: true, port: this.port }
}
try {
this.io = new Server(this.port, {
cors: {
origin: '*',
methods: ['GET', 'POST']
},
transports: ['websocket', 'polling'],
allowEIO3: true,
pingTimeout: 60000,
pingInterval: 25000
})
this.io.on('connection', (socket: Socket) => {
this.connectedClients.add(socket.id)
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.error('Main window is null, cannot send connection event')
} else {
mainWindow.webContents.send('websocket-client-connected', {
connected: true,
clientId: socket.id
})
logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`)
}
socket.on('message', (data) => {
logger.info('Received message from mobile:', data)
mainWindow?.webContents.send('websocket-message-received', data)
socket.emit('message_received', { success: true })
})
socket.on('disconnect', () => {
logger.info(`Client disconnected: ${socket.id}`)
this.connectedClients.delete(socket.id)
if (this.connectedClients.size === 0) {
mainWindow?.webContents.send('websocket-client-connected', {
connected: false,
clientId: socket.id
})
}
})
})
// Engine 层面的事件监听
this.io.engine.on('connection_error', (err) => {
logger.error('Engine connection error:', err)
})
this.io.engine.on('connection', (rawSocket) => {
const remoteAddr = rawSocket.request.connection.remoteAddress
logger.info(`[Engine] Raw connection from: ${remoteAddr}`)
logger.info(`[Engine] Transport: ${rawSocket.transport.name}`)
rawSocket.on('packet', (packet: { type: string; data?: any }) => {
logger.info(
`[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`,
packet.data ? { data: packet.data } : {}
)
})
rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => {
logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`)
})
rawSocket.on('close', (reason: string) => {
logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`)
})
rawSocket.on('error', (error: Error) => {
logger.error(`[Engine] Connection error from ${remoteAddr}:`, error)
})
})
// Socket.IO 握手失败监听
this.io.on('connection_error', (err) => {
logger.error('[Socket.IO] Connection error during handshake:', err)
})
this.isStarted = true
logger.info(`WebSocket server started on port ${this.port}`)
return { success: true, port: this.port }
} catch (error) {
logger.error('Failed to start WebSocket server:', error as Error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
public stop = async (): Promise<{ success: boolean }> => {
if (!this.isStarted || !this.io) {
return { success: true }
}
try {
await new Promise<void>((resolve) => {
this.io!.close(() => {
resolve()
})
})
this.io = null
this.isStarted = false
this.connectedClients.clear()
logger.info('WebSocket server stopped')
return { success: true }
} catch (error) {
logger.error('Failed to stop WebSocket server:', error as Error)
return { success: false }
}
}
public getStatus = async (): Promise<{
isRunning: boolean
port?: number
ip?: string
clientConnected: boolean
}> => {
return {
isRunning: this.isStarted,
port: this.isStarted ? this.port : undefined,
ip: this.isStarted ? this.getLocalIpAddress() : undefined,
clientConnected: this.connectedClients.size > 0
}
}
public getAllCandidates = async (): Promise<
Array<{
host: string
interface: string
priority: number
}>
> => {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ host: string; interface: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
host: iface.address,
interface: name,
priority
})
logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`)
}
}
}
// 按优先级排序返回
candidates.sort((a, b) => a.priority - b.priority)
logger.info(
`Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}`
)
return candidates
}
public sendFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string
): Promise<{ success: boolean; error?: string }> => {
if (!this.isStarted || !this.io) {
const errorMsg = 'WebSocket server is not running.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
if (this.connectedClients.size === 0) {
const errorMsg = 'No client connected.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
const mainWindow = windowService.getMainWindow()
return new Promise((resolve, reject) => {
const stats = fs.statSync(filePath)
const totalSize = stats.size
const filename = path.basename(filePath)
const stream = fs.createReadStream(filePath)
let bytesSent = 0
const startTime = Date.now()
logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`)
// 向客户端发送文件开始的信号,包含文件名和总大小
this.io!.emit('zip-file-start', { filename, totalSize })
stream.on('data', (chunk) => {
bytesSent += chunk.length
const progress = (bytesSent / totalSize) * 100
// 向客户端发送文件块
this.io!.emit('zip-file-chunk', chunk)
// 向渲染进程发送进度更新
mainWindow?.webContents.send('file-send-progress', { progress })
// 每10%记录一次进度
if (Math.floor(progress) % 10 === 0) {
const elapsed = (Date.now() - startTime) / 1000
const speed = elapsed > 0 ? bytesSent / elapsed : 0
logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`)
}
})
stream.on('end', () => {
const totalTime = (Date.now() - startTime) / 1000
const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0
logger.info(
`File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)`
)
// 确保发送100%的进度
mainWindow?.webContents.send('file-send-progress', { progress: 100 })
// 向客户端发送文件结束的信号
this.io!.emit('zip-file-end')
resolve({ success: true })
})
stream.on('error', (error) => {
logger.error(`File transfer failed: ${filename}`, error)
reject({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
})
})
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}
export default new WebSocketService()
@@ -0,0 +1,426 @@
import { loggerService } from '@logger'
import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types'
import { CachedPluginsDataSchema } from '@types'
import * as fs from 'fs'
import * as path from 'path'
const logger = loggerService.withContext('PluginCacheStore')
interface PluginCacheStoreDeps {
allowedExtensions: string[]
getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills'
getClaudeBasePath: (workdir: string) => string
getClaudePluginDirectory: (workdir: string, type: PluginType) => string
getPluginsBasePath: () => string
}
export class PluginCacheStore {
constructor(private readonly deps: PluginCacheStoreDeps) {}
async listAvailableFilePlugins(type: 'agent' | 'command'): Promise<PluginMetadata[]> {
const basePath = this.deps.getPluginsBasePath()
const directory = path.join(basePath, this.deps.getPluginDirectoryName(type))
try {
await fs.promises.access(directory, fs.constants.R_OK)
} catch (error) {
logger.warn(`Plugin directory not accessible: ${directory}`, {
error: error instanceof Error ? error.message : String(error)
})
return []
}
const plugins: PluginMetadata[] = []
const categories = await fs.promises.readdir(directory, { withFileTypes: true })
for (const categoryEntry of categories) {
if (!categoryEntry.isDirectory()) {
continue
}
const category = categoryEntry.name
const categoryPath = path.join(directory, category)
const files = await fs.promises.readdir(categoryPath, { withFileTypes: true })
for (const file of files) {
if (!file.isFile()) {
continue
}
const ext = path.extname(file.name).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
continue
}
try {
const filePath = path.join(categoryPath, file.name)
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name)
const metadata = await parsePluginMetadata(filePath, sourcePath, category, type)
plugins.push(metadata)
} catch (error) {
logger.warn(`Failed to parse plugin: ${file.name}`, {
category,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
return plugins
}
async listAvailableSkills(): Promise<PluginMetadata[]> {
const basePath = this.deps.getPluginsBasePath()
const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill'))
const skills: PluginMetadata[] = []
try {
await fs.promises.access(skillsPath)
} catch {
logger.warn('Skills directory not found', { skillsPath })
return []
}
try {
const skillDirectories = await findAllSkillDirectories(skillsPath, basePath)
logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath })
for (const { folderPath, sourcePath } of skillDirectories) {
try {
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
skills.push(metadata)
} catch (error) {
logger.warn(`Failed to parse skill folder: ${sourcePath}`, {
folderPath,
error: error instanceof Error ? error.message : String(error)
})
}
}
} catch (error) {
logger.error('Failed to scan skill directory', {
skillsPath,
error: error instanceof Error ? error.message : String(error)
})
}
return skills
}
async readSourceContent(sourcePath: string): Promise<string> {
const absolutePath = this.resolveSourcePath(sourcePath)
try {
await fs.promises.access(absolutePath, fs.constants.R_OK)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: sourcePath
} as PluginError
}
try {
return await fs.promises.readFile(absolutePath, 'utf-8')
} catch (error) {
throw {
type: 'READ_FAILED',
path: sourcePath,
reason: error instanceof Error ? error.message : String(error)
} as PluginError
}
}
resolveSourcePath(sourcePath: string): string {
const normalized = path.normalize(sourcePath)
if (normalized.includes('..')) {
throw {
type: 'PATH_TRAVERSAL',
message: 'Path traversal detected',
path: sourcePath
} as PluginError
}
const basePath = this.deps.getPluginsBasePath()
const absolutePath = path.join(basePath, normalized)
const resolvedPath = path.resolve(absolutePath)
if (!resolvedPath.startsWith(path.resolve(basePath))) {
throw {
type: 'PATH_TRAVERSAL',
message: 'Path outside plugins directory',
path: sourcePath
} as PluginError
}
return resolvedPath
}
async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise<void> {
let stats: fs.Stats
try {
stats = await fs.promises.stat(sourceAbsolutePath)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: sourceAbsolutePath
} as PluginError
}
if (!stats.isDirectory()) {
throw {
type: 'INVALID_METADATA',
reason: 'Skill source is not a directory',
path: sourcePath
} as PluginError
}
}
async validatePluginFile(filePath: string, maxFileSize: number): Promise<void> {
let stats: fs.Stats
try {
stats = await fs.promises.stat(filePath)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: filePath
} as PluginError
}
if (stats.size > maxFileSize) {
throw {
type: 'FILE_TOO_LARGE',
size: stats.size,
max: maxFileSize
} as PluginError
}
const ext = path.extname(filePath).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
throw {
type: 'INVALID_FILE_TYPE',
extension: ext
} as PluginError
}
try {
const basePath = this.deps.getPluginsBasePath()
const relativeSourcePath = path.relative(basePath, filePath)
const segments = relativeSourcePath.split(path.sep)
const rootDir = segments[0]
const agentDir = this.deps.getPluginDirectoryName('agent')
const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command'
const category = path.basename(path.dirname(filePath))
await parsePluginMetadata(filePath, relativeSourcePath, category, type)
} catch (error) {
throw {
type: 'INVALID_METADATA',
reason: 'Failed to parse frontmatter',
path: filePath
} as PluginError
}
}
async listInstalled(workdir: string): Promise<InstalledPlugin[]> {
const claudePath = this.deps.getClaudeBasePath(workdir)
const cacheData = await this.readCacheFile(claudePath)
if (cacheData) {
logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir })
return cacheData.plugins
}
logger.info('Cache read failed, rebuilding from filesystem', { workdir })
return await this.rebuild(workdir)
}
async upsert(workdir: string, plugin: InstalledPlugin): Promise<void> {
const claudePath = this.deps.getClaudeBasePath(workdir)
let cacheData = await this.readCacheFile(claudePath)
let plugins = cacheData?.plugins
if (!plugins) {
plugins = await this.rebuild(workdir)
cacheData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
}
const updatedPlugin: InstalledPlugin = {
...plugin,
metadata: {
...plugin.metadata,
installedAt: plugin.metadata.installedAt ?? Date.now()
}
}
const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type)
if (index >= 0) {
plugins[index] = updatedPlugin
} else {
plugins.push(updatedPlugin)
}
const data: CachedPluginsData = {
version: cacheData?.version ?? 1,
lastUpdated: Date.now(),
plugins
}
await fs.promises.mkdir(claudePath, { recursive: true })
await this.writeCacheFile(claudePath, data)
}
async remove(workdir: string, filename: string, type: PluginType): Promise<void> {
const claudePath = this.deps.getClaudeBasePath(workdir)
let cacheData = await this.readCacheFile(claudePath)
let plugins = cacheData?.plugins
if (!plugins) {
plugins = await this.rebuild(workdir)
cacheData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
}
const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type))
const data: CachedPluginsData = {
version: cacheData?.version ?? 1,
lastUpdated: Date.now(),
plugins: filtered
}
await fs.promises.mkdir(claudePath, { recursive: true })
await this.writeCacheFile(claudePath, data)
}
async rebuild(workdir: string): Promise<InstalledPlugin[]> {
logger.info('Rebuilding plugin cache from filesystem', { workdir })
const claudePath = this.deps.getClaudeBasePath(workdir)
try {
await fs.promises.access(claudePath, fs.constants.R_OK)
} catch {
logger.warn('.claude directory not found, returning empty plugin list', { claudePath })
return []
}
const plugins: InstalledPlugin[] = []
await Promise.all([
this.collectFilePlugins(workdir, 'agent', plugins),
this.collectFilePlugins(workdir, 'command', plugins),
this.collectSkillPlugins(workdir, plugins)
])
try {
const cacheData: CachedPluginsData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
await this.writeCacheFile(claudePath, cacheData)
logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir })
} catch (error) {
logger.error('Failed to write cache file after rebuild', {
error: error instanceof Error ? error.message : String(error)
})
}
return plugins
}
private async collectFilePlugins(
workdir: string,
type: Exclude<PluginType, 'skill'>,
plugins: InstalledPlugin[]
): Promise<void> {
const directory = this.deps.getClaudePluginDirectory(workdir, type)
try {
await fs.promises.access(directory, fs.constants.R_OK)
} catch {
logger.debug(`${type} directory not found or not accessible`, { directory })
return
}
const files = await fs.promises.readdir(directory, { withFileTypes: true })
for (const file of files) {
if (!file.isFile()) {
continue
}
const ext = path.extname(file.name).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
continue
}
try {
const filePath = path.join(directory, file.name)
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name)
const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type)
plugins.push({ filename: file.name, type, metadata })
} catch (error) {
logger.warn(`Failed to parse ${type} plugin: ${file.name}`, {
error: error instanceof Error ? error.message : String(error)
})
}
}
}
private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise<void> {
const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill')
const claudePath = this.deps.getClaudeBasePath(workdir)
try {
await fs.promises.access(skillsPath, fs.constants.R_OK)
} catch {
logger.debug('Skills directory not found or not accessible', { skillsPath })
return
}
const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath)
for (const { folderPath, sourcePath } of skillDirectories) {
try {
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
plugins.push({ filename: metadata.filename, type: 'skill', metadata })
} catch (error) {
logger.warn(`Failed to parse skill plugin: ${sourcePath}`, {
error: error instanceof Error ? error.message : String(error)
})
}
}
}
private async readCacheFile(claudePath: string): Promise<CachedPluginsData | null> {
const cachePath = path.join(claudePath, 'plugins.json')
try {
const content = await fs.promises.readFile(cachePath, 'utf-8')
const data = JSON.parse(content)
return CachedPluginsDataSchema.parse(data)
} catch (err) {
logger.warn(`Failed to read cache file at ${cachePath}`, {
error: err instanceof Error ? err.message : String(err)
})
return null
}
}
private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise<void> {
const cachePath = path.join(claudePath, 'plugins.json')
const tempPath = `${cachePath}.tmp`
const content = JSON.stringify(data, null, 2)
await fs.promises.writeFile(tempPath, content, 'utf-8')
await fs.promises.rename(tempPath, cachePath)
}
}
@@ -0,0 +1,149 @@
import { loggerService } from '@logger'
import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations'
import type { PluginError } from '@types'
import * as crypto from 'crypto'
import * as fs from 'fs'
const logger = loggerService.withContext('PluginInstaller')
export class PluginInstaller {
async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
const tempPath = `${destPath}.tmp`
let fileCopied = false
try {
await fs.promises.copyFile(sourceAbsolutePath, tempPath)
fileCopied = true
logger.debug('File copied to temp location', { agentId, tempPath })
await fs.promises.rename(tempPath, destPath)
logger.debug('File moved to final location', { agentId, destPath })
} catch (error) {
if (fileCopied) {
await this.safeUnlink(tempPath, 'temp file')
}
throw this.toPluginError('install', error)
}
}
async uninstallFilePlugin(
agentId: string,
filename: string,
type: 'agent' | 'command',
filePath: string
): Promise<void> {
try {
await fs.promises.unlink(filePath)
logger.debug('Plugin file deleted', { agentId, filename, type, filePath })
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw this.toPluginError('uninstall', error)
}
logger.warn('Plugin file already deleted', { agentId, filename, type, filePath })
}
}
async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise<string> {
try {
await fs.promises.access(filePath, fs.constants.W_OK)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: filePath
} as PluginError
}
try {
await fs.promises.writeFile(filePath, content, 'utf8')
logger.debug('Plugin content written successfully', {
agentId,
filePath,
size: Buffer.byteLength(content, 'utf8')
})
} catch (error) {
throw {
type: 'WRITE_FAILED',
path: filePath,
reason: error instanceof Error ? error.message : String(error)
} as PluginError
}
return crypto.createHash('sha256').update(content).digest('hex')
}
async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
const logContext = logger.withContext('installSkill')
let folderCopied = false
const tempPath = `${destPath}.tmp`
try {
try {
await fs.promises.access(destPath)
await deleteDirectoryRecursive(destPath)
logContext.info('Removed existing skill folder', { agentId, destPath })
} catch {
// No existing folder
}
await copyDirectoryRecursive(sourceAbsolutePath, tempPath)
folderCopied = true
logContext.info('Skill folder copied to temp location', { agentId, tempPath })
await fs.promises.rename(tempPath, destPath)
logContext.info('Skill folder moved to final location', { agentId, destPath })
} catch (error) {
if (folderCopied) {
await this.safeRemoveDirectory(tempPath, 'temp folder')
}
throw this.toPluginError('install-skill', error)
}
}
async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise<void> {
const logContext = logger.withContext('uninstallSkill')
try {
await deleteDirectoryRecursive(skillPath)
logContext.info('Skill folder deleted', { agentId, folderName, skillPath })
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw this.toPluginError('uninstall-skill', error)
}
logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath })
}
}
private toPluginError(operation: string, error: unknown): PluginError {
return {
type: 'TRANSACTION_FAILED',
operation,
reason: error instanceof Error ? error.message : String(error)
}
}
private async safeUnlink(targetPath: string, label: string): Promise<void> {
try {
await fs.promises.unlink(targetPath)
logger.debug(`Rolled back ${label}`, { targetPath })
} catch (unlinkError) {
logger.error(`Failed to rollback ${label}`, {
targetPath,
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
})
}
}
private async safeRemoveDirectory(targetPath: string, label: string): Promise<void> {
try {
await deleteDirectoryRecursive(targetPath)
logger.info(`Rolled back ${label}`, { targetPath })
} catch (unlinkError) {
logger.error(`Failed to rollback ${label}`, {
targetPath,
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
})
}
}
}
@@ -0,0 +1,614 @@
import { loggerService } from '@logger'
import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
import type {
GetAgentResponse,
InstalledPlugin,
InstallPluginOptions,
ListAvailablePluginsResult,
PluginError,
PluginMetadata,
PluginType,
UninstallPluginOptions
} from '@types'
import { app } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import { AgentService } from '../services/AgentService'
import { PluginCacheStore } from './PluginCacheStore'
import { PluginInstaller } from './PluginInstaller'
const logger = loggerService.withContext('PluginService')
interface PluginServiceConfig {
maxFileSize: number // bytes
cacheTimeout: number // milliseconds
}
/**
* PluginService manages agent and command plugins from resources directory.
*
* Features:
* - Singleton pattern for consistent state management
* - Caching of available plugins for performance
* - Security validation (path traversal, file size, extensions)
* - Transactional install/uninstall operations
* - Integration with AgentService for metadata persistence
*/
export class PluginService {
private static instance: PluginService | null = null
private availablePluginsCache: ListAvailablePluginsResult | null = null
private cacheTimestamp = 0
private config: PluginServiceConfig
private readonly cacheStore: PluginCacheStore
private readonly installer: PluginInstaller
private readonly agentService: AgentService
private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown']
private constructor(config?: Partial<PluginServiceConfig>) {
this.config = {
maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default
cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default
}
this.agentService = AgentService.getInstance()
this.cacheStore = new PluginCacheStore({
allowedExtensions: this.ALLOWED_EXTENSIONS,
getPluginDirectoryName: this.getPluginDirectoryName.bind(this),
getClaudeBasePath: this.getClaudeBasePath.bind(this),
getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this),
getPluginsBasePath: this.getPluginsBasePath.bind(this)
})
this.installer = new PluginInstaller()
logger.info('PluginService initialized', {
maxFileSize: this.config.maxFileSize,
cacheTimeout: this.config.cacheTimeout
})
}
/**
* Get singleton instance
*/
static getInstance(config?: Partial<PluginServiceConfig>): PluginService {
if (!PluginService.instance) {
PluginService.instance = new PluginService(config)
}
return PluginService.instance
}
/**
* List all available plugins from resources directory (with caching)
*/
async listAvailable(): Promise<ListAvailablePluginsResult> {
const now = Date.now()
// Return cached data if still valid
if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) {
logger.debug('Returning cached plugin list', {
cacheAge: now - this.cacheTimestamp
})
return this.availablePluginsCache
}
logger.info('Scanning available plugins')
// Scan all plugin types
const [agents, commands, skills] = await Promise.all([
this.cacheStore.listAvailableFilePlugins('agent'),
this.cacheStore.listAvailableFilePlugins('command'),
this.cacheStore.listAvailableSkills()
])
const result: ListAvailablePluginsResult = {
agents,
commands,
skills, // NEW: include skills
total: agents.length + commands.length + skills.length
}
// Update cache
this.availablePluginsCache = result
this.cacheTimestamp = now
logger.info('Available plugins scanned', {
agentsCount: agents.length,
commandsCount: commands.length,
skillsCount: skills.length,
total: result.total
})
return result
}
/**
* Install plugin with validation and transactional safety
*/
async install(options: InstallPluginOptions): Promise<PluginMetadata> {
logger.info('Installing plugin', options)
const context = await this.prepareInstallContext(options)
if (options.type === 'skill') {
return await this.installSkillPlugin(options, context)
}
return await this.installFilePlugin(options, context)
}
private async prepareInstallContext(options: InstallPluginOptions): Promise<{
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}> {
const agent = await this.getAgentOrThrow(options.agentId)
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
await this.validateWorkdir(agent, workdir)
const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath)
return { agent, workdir, sourceAbsolutePath }
}
private async installSkillPlugin(
options: InstallPluginOptions,
context: {
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}
): Promise<PluginMetadata> {
const { agent, workdir, sourceAbsolutePath } = context
await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath)
const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills')
const sanitizedFolderName = this.sanitizeFolderName(metadata.filename)
await this.ensureClaudeDirectory(workdir, 'skill')
const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
metadata.filename = sanitizedFolderName
await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath)
const installedAt = Date.now()
const metadataWithInstall: PluginMetadata = {
...metadata,
filename: sanitizedFolderName,
installedAt,
updatedAt: metadata.updatedAt ?? installedAt,
type: 'skill'
}
const installedPlugin: InstalledPlugin = {
filename: sanitizedFolderName,
type: 'skill',
metadata: metadataWithInstall
}
await this.cacheStore.upsert(workdir, installedPlugin)
this.upsertAgentPlugin(agent, installedPlugin)
logger.info('Skill installed successfully', {
agentId: options.agentId,
sourcePath: options.sourcePath,
folderName: sanitizedFolderName
})
return metadataWithInstall
}
private async installFilePlugin(
options: InstallPluginOptions,
context: {
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}
): Promise<PluginMetadata> {
const { agent, workdir, sourceAbsolutePath } = context
if (options.type === 'skill') {
throw {
type: 'INVALID_FILE_TYPE',
extension: options.type
} as PluginError
}
const filePluginType: 'agent' | 'command' = options.type
await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize)
const category = path.basename(path.dirname(options.sourcePath))
const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType)
const sanitizedFilename = this.sanitizeFilename(metadata.filename)
metadata.filename = sanitizedFilename
await this.ensureClaudeDirectory(workdir, filePluginType)
const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename)
await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath)
const installedAt = Date.now()
const metadataWithInstall: PluginMetadata = {
...metadata,
filename: sanitizedFilename,
installedAt,
updatedAt: metadata.updatedAt ?? installedAt,
type: filePluginType
}
const installedPlugin: InstalledPlugin = {
filename: sanitizedFilename,
type: filePluginType,
metadata: metadataWithInstall
}
await this.cacheStore.upsert(workdir, installedPlugin)
this.upsertAgentPlugin(agent, installedPlugin)
logger.info('Plugin installed successfully', {
agentId: options.agentId,
filename: sanitizedFilename,
type: filePluginType
})
return metadataWithInstall
}
/**
* Uninstall plugin with cleanup
*/
async uninstall(options: UninstallPluginOptions): Promise<void> {
logger.info('Uninstalling plugin', options)
const agent = await this.getAgentOrThrow(options.agentId)
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
await this.validateWorkdir(agent, workdir)
if (options.type === 'skill') {
const sanitizedFolderName = this.sanitizeFolderName(options.filename)
const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath)
await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill')
this.removeAgentPlugin(agent, sanitizedFolderName, 'skill')
logger.info('Skill uninstalled successfully', {
agentId: options.agentId,
folderName: sanitizedFolderName
})
return
}
const sanitizedFilename = this.sanitizeFilename(options.filename)
const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename)
await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath)
await this.cacheStore.remove(workdir, sanitizedFilename, options.type)
this.removeAgentPlugin(agent, sanitizedFilename, options.type)
logger.info('Plugin uninstalled successfully', {
agentId: options.agentId,
filename: sanitizedFilename,
type: options.type
})
}
/**
* List installed plugins for an agent (from database + filesystem validation)
*/
async listInstalled(agentId: string): Promise<InstalledPlugin[]> {
logger.debug('Listing installed plugins', { agentId })
const agent = await this.getAgentOrThrow(agentId)
const workdir = agent.accessible_paths?.[0]
if (!workdir) {
logger.warn('Agent has no accessible paths', { agentId })
return []
}
const plugins = await this.listInstalledFromCache(workdir)
logger.debug('Listed installed plugins from cache', {
agentId,
count: plugins.length
})
return plugins
}
/**
* Invalidate plugin cache (for development/testing)
*/
invalidateCache(): void {
this.availablePluginsCache = null
this.cacheTimestamp = 0
logger.info('Plugin cache invalidated')
}
// ============================================================================
// Cache File Management (for installed plugins)
// ============================================================================
/**
* Read cache file from .claude/plugins.json
* Returns null if cache doesn't exist or is invalid
*/
/**
* List installed plugins from cache file
* Falls back to filesystem scan if cache is missing or corrupt
*/
async listInstalledFromCache(workdir: string): Promise<InstalledPlugin[]> {
logger.debug('Listing installed plugins from cache', { workdir })
return await this.cacheStore.listInstalled(workdir)
}
/**
* Read plugin content from source (resources directory)
*/
async readContent(sourcePath: string): Promise<string> {
logger.info('Reading plugin content', { sourcePath })
const content = await this.cacheStore.readSourceContent(sourcePath)
logger.debug('Plugin content read successfully', {
sourcePath,
size: content.length
})
return content
}
/**
* Write plugin content to installed plugin (in agent's .claude directory)
* Note: Only works for file-based plugins (agents/commands), not skills
*/
async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise<void> {
logger.info('Writing plugin content', { agentId, filename, type })
const agent = await this.getAgentOrThrow(agentId)
const workdir = this.getWorkdirOrThrow(agent, agentId)
await this.validateWorkdir(agent, workdir)
// Check if plugin is installed
let installedPlugins = agent.installed_plugins ?? []
if (installedPlugins.length === 0) {
installedPlugins = await this.cacheStore.listInstalled(workdir)
agent.installed_plugins = installedPlugins
}
const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type)
if (!installedPlugin) {
throw {
type: 'PLUGIN_NOT_INSTALLED',
filename,
agentId
} as PluginError
}
if (type === 'skill') {
throw {
type: 'INVALID_FILE_TYPE',
extension: type
} as PluginError
}
const filePluginType = type as 'agent' | 'command'
const filePath = this.getClaudePluginPath(workdir, filePluginType, filename)
const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content)
const updatedMetadata: PluginMetadata = {
...installedPlugin.metadata,
contentHash: newContentHash,
size: Buffer.byteLength(content, 'utf8'),
updatedAt: Date.now(),
filename,
type: filePluginType
}
const updatedPlugin: InstalledPlugin = {
filename,
type: filePluginType,
metadata: updatedMetadata
}
await this.cacheStore.upsert(workdir, updatedPlugin)
this.upsertAgentPlugin(agent, updatedPlugin)
logger.info('Plugin content updated successfully', {
agentId,
filename,
type: filePluginType,
newContentHash
})
}
// ============================================================================
// Private Helper Methods
// ============================================================================
/**
* Resolve plugin type to directory name under .claude
*/
private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' {
if (type === 'agent') {
return 'agents'
}
if (type === 'command') {
return 'commands'
}
return 'skills'
}
/**
* Get the base .claude directory for a workdir
*/
private getClaudeBasePath(workdir: string): string {
return path.join(workdir, '.claude')
}
/**
* Get the directory for a specific plugin type inside .claude
*/
private getClaudePluginDirectory(workdir: string, type: PluginType): string {
return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type))
}
/**
* Get the absolute path for a plugin file/folder inside .claude
*/
private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string {
return path.join(this.getClaudePluginDirectory(workdir, type), filename)
}
/**
* Get absolute path to plugins directory (handles packaged vs dev)
*/
private getPluginsBasePath(): string {
// Use the utility function which handles both dev and production correctly
if (app.isPackaged) {
return path.join(process.resourcesPath, 'claude-code-plugins')
}
return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins')
}
/**
* Validate source path to prevent path traversal attacks
*/
private async getAgentOrThrow(agentId: string): Promise<GetAgentResponse> {
const agent = await this.agentService.getAgent(agentId)
if (!agent) {
throw {
type: 'INVALID_WORKDIR',
agentId,
workdir: '',
message: 'Agent not found'
} as PluginError
}
return agent
}
private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string {
const workdir = agent.accessible_paths?.[0]
if (!workdir) {
throw {
type: 'INVALID_WORKDIR',
agentId,
workdir: '',
message: 'Agent has no accessible paths'
} as PluginError
}
return workdir
}
/**
* Validate workdir against agent's accessible paths
*/
private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise<void> {
// Verify workdir is in agent's accessible_paths
if (!agent.accessible_paths?.includes(workdir)) {
throw {
type: 'INVALID_WORKDIR',
workdir,
agentId: agent.id,
message: 'Workdir not in agent accessible paths'
} as PluginError
}
// Verify workdir exists and is accessible
try {
await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK)
} catch (error) {
throw {
type: 'WORKDIR_NOT_FOUND',
workdir,
message: 'Workdir does not exist or is not accessible'
} as PluginError
}
}
private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void {
const existing = agent.installed_plugins ?? []
const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type))
agent.installed_plugins = [...filtered, plugin]
}
private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void {
if (!agent.installed_plugins) {
agent.installed_plugins = []
return
}
agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type))
}
/**
* Sanitize filename to remove unsafe characters (for agents/commands)
*/
private sanitizeFilename(filename: string): string {
// Remove path separators
let sanitized = filename.replace(/[/\\]/g, '_')
// Remove null bytes using String method to avoid control-regex lint error
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
// Limit to safe characters (alphanumeric, dash, underscore, dot)
sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_')
// Ensure .md extension
if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) {
sanitized += '.md'
}
return sanitized
}
/**
* Sanitize folder name for skills (different rules than file names)
* NO dots allowed to avoid confusion with file extensions
*/
private sanitizeFolderName(folderName: string): string {
// Remove path separators
let sanitized = folderName.replace(/[/\\]/g, '_')
// Remove null bytes using String method to avoid control-regex lint error
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
// Limit to safe characters (alphanumeric, dash, underscore)
// NOTE: No dots allowed to avoid confusion with file extensions
sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_')
// Validate no extension was provided
if (folderName.includes('.')) {
logger.warn('Skill folder name contained dots, sanitized', {
original: folderName,
sanitized
})
}
return sanitized
}
/**
* Ensure .claude subdirectory exists for the given plugin type
*/
private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise<void> {
const typeDir = this.getClaudePluginDirectory(workdir, type)
try {
await fs.promises.mkdir(typeDir, { recursive: true })
logger.debug('Ensured directory exists', { typeDir })
} catch (error) {
logger.error('Failed to create directory', {
typeDir,
error: error instanceof Error ? error.message : String(error)
})
throw {
type: 'PERMISSION_DENIED',
path: typeDir
} as PluginError
}
}
}
export const pluginService = PluginService.getInstance()
@@ -1,5 +1,7 @@
import path from 'node:path'
import { loggerService } from '@logger'
import { pluginService } from '@main/services/agents/plugins/PluginService'
import { getDataPath } from '@main/utils'
import type {
AgentEntity,
@@ -17,6 +19,8 @@ import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
import type { AgentModelField } from '../errors'
const logger = loggerService.withContext('AgentService')
export class AgentService extends BaseService {
private static instance: AgentService | null = null
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
@@ -92,6 +96,24 @@ export class AgentService extends BaseService {
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
agent.tools = await this.listMcpTools(agent.type, agent.mcps)
// Load installed_plugins from cache file instead of database
const workdir = agent.accessible_paths?.[0]
if (workdir) {
try {
agent.installed_plugins = await pluginService.listInstalledFromCache(workdir)
} catch (error) {
// Log error but don't fail the request
logger.warn(`Failed to load installed plugins for agent ${id}`, {
workdir,
error: error instanceof Error ? error.message : String(error)
})
agent.installed_plugins = []
}
} else {
agent.installed_plugins = []
}
return agent
}
@@ -106,7 +106,10 @@ class ClaudeCodeService implements AgentServiceInterface {
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
ANTHROPIC_MODEL: modelInfo.modelId,
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId,
ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId,
// TODO: support set small model in UI
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId,
ELECTRON_RUN_AS_NODE: '1',
ELECTRON_NO_ATTACH_CONSOLE: '1'
}
@@ -73,6 +73,15 @@ const emptyUsage: LanguageModelUsage = {
*/
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
/**
* Filters out command-* tags from text content to prevent internal command
* messages from appearing in the user-facing UI.
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
*/
const filterCommandTags = (text: string): string => {
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
}
/**
* Extracts provider metadata from the raw Claude message so we can surface it
* on every emitted stream part for observability and debugging purposes.
@@ -270,12 +279,17 @@ function handleUserMessage(
const chunks: AgentStreamPart[] = []
const providerMetadata = sdkMessageToProviderMetadata(message)
const content = message.message.content
const isSynthetic = message.isSynthetic ?? false
if (typeof content === 'string') {
if (!content) {
return chunks
}
const filteredContent = filterCommandTags(content)
if (!filteredContent) {
return chunks
}
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
@@ -285,7 +299,7 @@ function handleUserMessage(
chunks.push({
type: 'text-delta',
id,
text: content,
text: filteredContent,
providerMetadata
})
chunks.push({
@@ -323,24 +337,30 @@ function handleUserMessage(
providerExecuted: true
})
}
} else if (block.type === 'text') {
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
id,
providerMetadata
})
chunks.push({
type: 'text-delta',
id,
text: (block as { text: string }).text,
providerMetadata
})
chunks.push({
type: 'text-end',
id,
providerMetadata
})
} else if (block.type === 'text' && !isSynthetic) {
const rawText = (block as { text: string }).text
const filteredText = filterCommandTags(rawText)
// Only push text chunks if there's content after filtering
if (filteredText) {
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
id,
providerMetadata
})
chunks.push({
type: 'text-delta',
id,
text: filteredText,
providerMetadata
})
chunks.push({
type: 'text-end',
id,
providerMetadata
})
}
} else {
logger.warn('Unhandled user content block', { type: (block as any).type })
}
+10 -3
View File
@@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl')
function installMCPServer(server: MCPServer) {
const mainWindow = windowService.getMainWindow()
const now = Date.now()
if (!server.id) {
server.id = nanoid()
const payload: MCPServer = {
...server,
id: server.id ?? nanoid(),
installSource: 'protocol',
isTrusted: false,
isActive: false,
trustedAt: undefined,
installedAt: server.installedAt ?? now
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server)
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload)
}
}
+17 -1
View File
@@ -567,7 +567,16 @@ const api = {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop),
onReady: (callback: () => void): (() => void) => {
const listener = () => {
callback()
}
ipcRenderer.on(IpcChannel.ApiServer_Ready, listener)
return () => {
ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener)
}
}
},
claudeCodePlugin: {
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
@@ -583,6 +592,13 @@ const api = {
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
},
webSocket: {
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
}
}
@@ -0,0 +1,591 @@
import { Button } from '@heroui/button'
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
import { Progress } from '@heroui/progress'
import { Spinner } from '@heroui/spinner'
import { loggerService } from '@logger'
import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TopView } from '../TopView'
const logger = loggerService.withContext('ExportToPhoneLanPopup')
interface Props {
resolve: (data: any) => void
}
type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error'
type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error'
const LoadingQRCode: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spinner />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')}
</span>
</div>
)
}
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<QRCodeSVG
marginSize={2}
value={qrCodeValue}
level="Q"
size={160}
imageSettings={{
src: '/src/assets/images/logo.png',
width: 40,
height: 40,
excavate: true
}}
/>
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.scan_qr')}
</span>
</div>
)
}
const ConnectingAnimation: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-warning)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)'
}}>
<Spinner size="lg" color="warning" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')}
</span>
</div>
</div>
)
}
const ConnectedDisplay: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-success)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-success)'
}}>
<span style={{ fontSize: '48px' }}>📱</span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
{t('settings.data.export_to_phone.lan.connected')}
</span>
</div>
</div>
)
}
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
const { t } = useTranslation()
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
padding: '20px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)'
}}>
<span style={{ fontSize: '48px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.connection_failed')}
</span>
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
</div>
)
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [isOpen, setIsOpen] = useState(true)
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
const [qrCodeValue, setQrCodeValue] = useState('')
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
const { t } = useTranslation()
// 派生状态
const isConnected = connectionPhase === 'connected'
const canSend = isConnected && selectedFolderPath && transferPhase === 'idle'
const isSending = transferPhase === 'preparing' || transferPhase === 'sending'
// 状态文本映射
const connectionStatusText = useMemo(() => {
const statusMap = {
initializing: t('settings.data.export_to_phone.lan.status.initializing'),
waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'),
connecting: t('settings.data.export_to_phone.lan.status.connecting'),
connected: t('settings.data.export_to_phone.lan.status.connected'),
disconnected: t('settings.data.export_to_phone.lan.status.disconnected'),
error: t('settings.data.export_to_phone.lan.status.error')
}
return statusMap[connectionPhase]
}, [connectionPhase, t])
const transferStatusText = useMemo(() => {
const statusMap = {
idle: '',
preparing: t('settings.data.export_to_phone.lan.status.preparing'),
sending: t('settings.data.export_to_phone.lan.status.sending'),
completed: t('settings.data.export_to_phone.lan.status.completed'),
error: t('settings.data.export_to_phone.lan.status.error')
}
return statusMap[transferPhase]
}, [transferPhase, t])
// 状态样式映射
const connectionStatusStyles = useMemo(() => {
const styleMap = {
initializing: {
bg: 'var(--color-background-mute)',
border: 'var(--color-border-mute)'
},
waiting_qr_scan: {
bg: 'var(--color-primary-mute)',
border: 'var(--color-primary-soft)'
},
connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' },
connected: {
bg: 'var(--color-status-success)',
border: 'var(--color-status-success)'
},
disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' },
error: { bg: 'var(--color-error)', border: 'var(--color-error)' }
}
return styleMap[connectionPhase]
}, [connectionPhase])
const initWebSocket = useCallback(async () => {
try {
setConnectionPhase('initializing')
await window.api.webSocket.start()
const { port, ip } = await window.api.webSocket.status()
if (ip && port) {
const candidates = await window.api.webSocket.getAllCandidates()
const connectionInfo = {
type: 'cherry-studio-app',
candidates,
selectedHost: ip,
port,
timestamp: Date.now()
}
setQrCodeValue(JSON.stringify(connectionInfo))
setConnectionPhase('waiting_qr_scan')
logger.info(`QR code generated: ${ip}:${port} with ${candidates.length} IP candidates`)
} else {
setError(t('settings.data.export_to_phone.lan.error.no_ip'))
setConnectionPhase('error')
}
} catch (error) {
setError(
`${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}`
)
setConnectionPhase('error')
logger.error('Failed to initialize WebSocket:', error as Error)
}
}, [t])
const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => {
logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`)
if (data.connected) {
setConnectionPhase('connected')
setError(null)
} else {
setConnectionPhase('disconnected')
}
}, [])
const handleMessageReceived = useCallback((_event: any, data: any) => {
logger.info(`Received message from mobile: ${JSON.stringify(data)}`)
}, [])
const handleSendProgress = useCallback(
(_event: any, data: { progress: number }) => {
const progress = data.progress
setSendProgress(progress)
if (transferPhase === 'preparing' && progress > 0) {
setTransferPhase('sending')
}
if (progress >= 100) {
setTransferPhase('completed')
// 启动 3 秒倒计时自动关闭
setAutoCloseCountdown(3)
}
},
[transferPhase]
)
const handleSelectZip = useCallback(async () => {
const result = await window.api.file.select()
if (result) {
setSelectedFolderPath(result[0].path)
}
}, [])
const handleSendZip = useCallback(async () => {
if (!selectedFolderPath) {
setError(t('settings.data.export_to_phone.lan.error.no_file'))
return
}
setTransferPhase('preparing')
setError(null)
setSendProgress(0)
try {
logger.info(`Starting file transfer: ${selectedFolderPath}`)
await window.api.webSocket.sendFile(selectedFolderPath)
} catch (error) {
setError(
`${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}`
)
setTransferPhase('error')
logger.error('Failed to send file:', error as Error)
}
}, [selectedFolderPath, t])
// 尝试关闭弹窗 - 如果正在传输则显示确认
const handleCancel = useCallback(() => {
if (isSending) {
setShowCloseConfirm(true)
} else {
setIsOpen(false)
}
}, [isSending])
// 确认强制关闭
const handleForceClose = useCallback(() => {
logger.info('Force closing popup during transfer')
setIsOpen(false)
}, [])
// 取消关闭确认
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false)
}, [])
// 清理并关闭
const handleClose = useCallback(async () => {
try {
// 主动断开 WebSocket 连接
if (isConnected || connectionPhase !== 'disconnected') {
logger.info('Closing popup, stopping WebSocket')
await window.api.webSocket.stop()
}
} catch (error) {
logger.error('Failed to stop WebSocket on close:', error as Error)
}
resolve({})
}, [resolve, isConnected, connectionPhase])
useEffect(() => {
initWebSocket()
const removeClientConnectedListener = window.electron.ipcRenderer.on(
'websocket-client-connected',
handleClientConnected
)
const removeMessageReceivedListener = window.electron.ipcRenderer.on(
'websocket-message-received',
handleMessageReceived
)
const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress)
return () => {
removeClientConnectedListener()
removeMessageReceivedListener()
removeSendProgressListener()
window.api.webSocket.stop()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 自动关闭倒计时
useEffect(() => {
if (autoCloseCountdown === null) return
if (autoCloseCountdown <= 0) {
logger.debug('Auto-closing popup after transfer completion')
setIsOpen(false)
return
}
const timer = setTimeout(() => {
setAutoCloseCountdown(autoCloseCountdown - 1)
}, 1000)
return () => clearTimeout(timer)
}, [autoCloseCountdown])
// 状态指示器组件
const StatusIndicator = useCallback(
() => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '8px',
backgroundColor: connectionStatusStyles.bg,
border: `1px solid ${connectionStatusStyles.border}`
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div>
),
[connectionStatusStyles, connectionStatusText]
)
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
const QRCodeDisplay = useCallback(() => {
switch (connectionPhase) {
case 'waiting_qr_scan':
case 'disconnected':
return <ScanQRCode qrCodeValue={qrCodeValue} />
case 'initializing':
return <LoadingQRCode />
case 'connecting':
return <ConnectingAnimation />
case 'connected':
return <ConnectedDisplay />
case 'error':
return <ErrorQRCode error={error} />
default:
return null
}
}, [connectionPhase, qrCodeValue, error])
// 传输进度组件
const TransferProgress = useCallback(() => {
if (!isSending && transferPhase !== 'completed') return null
return (
<div style={{ paddingTop: '8px' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
border: `1px solid var(--color-border)`,
borderRadius: '8px',
backgroundColor: 'var(--color-background-mute)'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '14px',
fontWeight: '500'
}}>
<span style={{ color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.transfer_progress')}
</span>
<span
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
</span>
</div>
<Progress
value={Math.round(sendProgress)}
size="md"
color={transferPhase === 'completed' ? 'success' : 'primary'}
showValueLabel={false}
aria-label="Send progress"
/>
</div>
</div>
)
}, [isSending, transferPhase, sendProgress, t])
const AutoCloseCountdown = useCallback(() => {
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
return (
<div
style={{
fontSize: '12px',
color: 'var(--color-text-2)',
textAlign: 'center',
paddingTop: '4px'
}}>
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
</div>
)
}, [transferPhase, autoCloseCountdown, t])
// 错误显示组件
const ErrorDisplay = useCallback(() => {
if (!error || transferPhase !== 'error') return null
return (
<div
style={{
padding: '12px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)',
textAlign: 'center'
}}>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}> {error}</span>
</div>
)
}, [error, transferPhase])
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
handleCancel()
}
}}
isDismissable={false}
isKeyboardDismissDisabled={false}
placement="center"
onClose={handleClose}>
<ModalContent>
{() => (
<>
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
<ModalBody>
<SettingRow>
<StatusIndicator />
</SettingRow>
<SettingRow>
<div>{t('settings.data.export_to_phone.lan.content')}</div>
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')}
</Button>
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button>
</div>
</SettingRow>
<SettingHelpText
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
</SettingHelpText>
<TransferProgress />
<AutoCloseCountdown />
<ErrorDisplay />
</ModalBody>
{showCloseConfirm && (
<ModalFooter>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '12px',
padding: '8px',
borderRadius: '8px',
backgroundColor: 'var(--color-status-warning)',
border: '1px solid var(--color-status-warning)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
{t('settings.data.export_to_phone.lan.confirm_close_title')}
</span>
</div>
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
{t('settings.data.export_to_phone.lan.confirm_close_message')}
</span>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
{t('common.cancel')}
</Button>
<Button size="sm" color="danger" onPress={handleForceClose}>
{t('settings.data.export_to_phone.lan.force_close')}
</Button>
</div>
</div>
</ModalFooter>
)}
</>
)}
</ModalContent>
</Modal>
)
}
const TopViewKey = 'ExportToPhoneLanPopup'
export default class ExportToPhoneLanPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -68,6 +68,7 @@ type Props = {
agent?: AgentWithTools
isOpen: boolean
onClose: () => void
afterSubmit?: (a: AgentEntity) => void
}
/**
@@ -79,7 +80,7 @@ type Props = {
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose }) => {
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => {
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
@@ -302,8 +303,13 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies UpdateAgentForm
updateAgent(updatePayload)
logger.debug('Updated agent', updatePayload)
const result = await updateAgent(updatePayload)
if (result) {
logger.debug('Updated agent', result)
afterSubmit?.(result)
} else {
logger.error('Update failed.')
}
} else {
const newAgent = {
type: form.type,
@@ -316,12 +322,13 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies AddAgentForm
const result = await addAgent(newAgent)
if (!result.success) {
loadingRef.current = false
throw result.error
}
afterSubmit?.(result.data)
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
@@ -330,16 +337,17 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
[
form.type,
form.model,
form.accessible_paths,
form.name,
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
form.configuration,
agent,
onClose,
t,
updateAgent,
afterSubmit,
addAgent
]
)
-4
View File
@@ -1,4 +0,0 @@
export type UpdateAgentBaseOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
+5 -1
View File
@@ -25,6 +25,10 @@ export const useAgents = () => {
const client = useAgentClient()
const key = client.agentPaths.base
const { apiServerConfig, apiServerRunning } = useApiServer()
// Disable SWR fetching when server is not running by setting key to null
const swrKey = apiServerRunning ? key : null
const fetcher = useCallback(async () => {
// API server will start on startup if enabled OR there are agents
if (!apiServerConfig.enabled && !apiServerRunning) {
@@ -37,7 +41,7 @@ export const useAgents = () => {
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat
const dispatch = useAppDispatch()
@@ -1,10 +1,10 @@
import type { ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
import type { AgentEntity, ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
import type { UpdateAgentBaseOptions, UpdateAgentFunction } from '@renderer/types/agent'
import { formatErrorMessageWithPrefix } 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 useUpdateAgent = () => {
@@ -12,8 +12,8 @@ export const useUpdateAgent = () => {
const client = useAgentClient()
const listKey = client.agentPaths.base
const updateAgent = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => {
const updateAgent: UpdateAgentFunction = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions): Promise<AgentEntity | undefined> => {
try {
const itemKey = client.agentPaths.withId(form.id)
// may change to optimistic update
@@ -23,8 +23,10 @@ export const useUpdateAgent = () => {
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
return undefined
}
},
[client, listKey, t]
@@ -1,18 +1,18 @@
import type { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import type { AgentSessionEntity, ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import type { UpdateAgentBaseOptions, UpdateAgentSessionFunction } from '@renderer/types/agent'
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 | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const updateSession = useCallback(
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => {
const updateSession: UpdateAgentSessionFunction = useCallback(
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions): Promise<AgentSessionEntity | undefined> => {
if (!agentId) return
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
@@ -29,8 +29,10 @@ export const useUpdateSession = (agentId: string | null) => {
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
return result
} catch (error) {
window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) })
return undefined
}
},
[agentId, client, t]
+12 -2
View File
@@ -14,8 +14,8 @@ export const useApiServer = () => {
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Optimistic initial state.
const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled)
// Initial state - no longer optimistic, wait for actual status
const [apiServerRunning, setApiServerRunning] = useState(false)
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerEnabled = useCallback(
@@ -99,6 +99,16 @@ export const useApiServer = () => {
checkApiServerStatus()
}, [checkApiServerStatus])
// Listen for API server ready event
useEffect(() => {
const cleanup = window.api.apiServer.onReady(() => {
logger.info('API server ready event received, checking status')
checkApiServerStatus()
})
return cleanup
}, [checkApiServerStatus])
return {
apiServerConfig,
apiServerRunning,
+21 -4
View File
@@ -80,17 +80,34 @@ export function useAppInit() {
useEffect(() => {
savedAvatar?.value && cacheService.set('avatar', savedAvatar.value)
}, [savedAvatar, dispatch])
}, [savedAvatar])
useEffect(() => {
const checkForUpdates = async () => {
const { isPackaged } = await window.api.getAppInfo()
if (!isPackaged || !autoCheckUpdate) {
return
}
const { updateInfo } = await window.api.checkForUpdate()
updateAppUpdateState({ info: updateInfo })
}
// Initial check with delay
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && autoCheckUpdate) {
await delay(2)
const { updateInfo } = await window.api.checkForUpdate()
updateAppUpdateState({ info: updateInfo })
await checkForUpdates()
}
})
// Set up 4-hour interval check
const FOUR_HOURS = 4 * 60 * 60 * 1000
const intervalId = setInterval(checkForUpdates, FOUR_HOURS)
return () => clearInterval(intervalId)
}, [autoCheckUpdate, updateAppUpdateState])
useEffect(() => {
@@ -135,7 +152,7 @@ export function useAppInit() {
cacheService.set('filesPath', info.filesPath)
cacheService.set('resourcesPath', info.resourcesPath)
})
}, [dispatch])
}, [])
useEffect(() => {
KnowledgeQueue.checkAllBases()
@@ -0,0 +1,57 @@
import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning'
import {
ensureServerTrusted as ensureServerTrustedCore,
getCommandPreview
} from '@renderer/pages/settings/MCPSettings/utils'
import type { MCPServer } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMCPServers } from './useMCPServers'
/**
* Hook for handling MCP server trust verification
* Binds UI (modal dialog) to the core trust verification logic
*/
export const useMCPServerTrust = () => {
const { updateMCPServer } = useMCPServers()
const { t } = useTranslation()
/**
* Request user confirmation to trust a server
* Shows a warning modal with server command preview
*/
const requestConfirm = useCallback(
async (server: MCPServer): Promise<boolean> => {
const commandPreview = getCommandPreview(server)
return modalConfirm({
title: t('settings.mcp.protocolInstallWarning.title'),
content: (
<ProtocolInstallWarningContent
message={t('settings.mcp.protocolInstallWarning.message')}
commandLabel={t('settings.mcp.protocolInstallWarning.command')}
commandPreview={commandPreview}
/>
),
okText: t('settings.mcp.protocolInstallWarning.run'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true }
})
},
[t]
)
/**
* Ensures a server is trusted before proceeding
* Combines core logic with UI confirmation
*/
const ensureServerTrusted = useCallback(
async (server: MCPServer): Promise<MCPServer | null> => {
return ensureServerTrustedCore(server, requestConfirm, updateMCPServer)
},
[requestConfirm, updateMCPServer]
)
return { ensureServerTrusted }
}
+2 -2
View File
@@ -11,12 +11,12 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import { findMainTextBlocks } from '@renderer/utils/messageUtils/find'
import { find, isEmpty } from 'lodash'
import { useEffect, useState } from 'react'
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'
import { useAssistant } from './useAssistant'
let _activeTopic: Topic
let _setActiveTopic: (topic: Topic) => void
let _setActiveTopic: Dispatch<SetStateAction<Topic>>
// const logger = loggerService.withContext('useTopic')
+47
View File
@@ -1047,6 +1047,7 @@
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"completed": "Completed",
"confirm": "Confirm",
"copied": "Copied",
"copy": "Copy",
@@ -3038,6 +3039,46 @@
"title": "Export Menu Settings",
"yuque": "Export to Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Select backup file"
},
"content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
"lan": {
"auto_close_tip": "Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "Confirm Close",
"connected": "Connected",
"connection_failed": "Connection failed",
"content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
"error": {
"init_failed": "Initialization failed",
"no_file": "No file selected",
"no_ip": "Unable to get IP address",
"send_failed": "Failed to send file"
},
"force_close": "Force Close",
"generating_qr": "Generating QR code...",
"noZipSelected": "No compressed file selected",
"scan_qr": "Please scan QR code with your phone",
"selectZip": "Select a compressed file",
"sendZip": "Begin data recovery",
"status": {
"completed": "Transfer completed",
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected",
"error": "Connection error",
"initializing": "Initializing connection...",
"preparing": "Preparing transfer...",
"sending": "Transferring {{progress}}%",
"waiting_qr_scan": "Please scan QR code to connect"
},
"title": "LAN transmission",
"transfer_progress": "Transfer progress"
},
"title": "Export to phone"
},
"hour_interval_one": "{{count}} hour",
"hour_interval_other": "{{count}} hours",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "No prompts available",
"requiredField": "Required Field"
},
"protocolInstallWarning": {
"command": "Startup command",
"message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.",
"run": "Run",
"title": "Run external MCP?"
},
"provider": "Provider",
"providerPlaceholder": "Provider name",
"providerUrl": "Provider URL",
+47
View File
@@ -1047,6 +1047,7 @@
"clear": "清除",
"close": "关闭",
"collapse": "折叠",
"completed": "完成",
"confirm": "确认",
"copied": "已复制",
"copy": "复制",
@@ -3038,6 +3039,46 @@
"title": "导出菜单设置",
"yuque": "导出到语雀"
},
"export_to_phone": {
"confirm": {
"button": "选择备份文件"
},
"content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "{{seconds}} 秒后自动关闭...",
"confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?",
"confirm_close_title": "确认关闭",
"connected": "连接成功",
"connection_failed": "连接失败",
"content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "初始化失败",
"no_file": "未选择文件",
"no_ip": "无法获取 IP 地址",
"send_failed": "发送文件失败"
},
"force_close": "强制关闭",
"generating_qr": "正在生成二维码...",
"noZipSelected": "未选择压缩文件",
"scan_qr": "请使用手机扫码连接",
"selectZip": "选择压缩文件",
"sendZip": "开始恢复数据",
"status": {
"completed": "传输完成",
"connected": "连接成功",
"connecting": "正在连接中...",
"disconnected": "连接已断开",
"error": "连接出错",
"initializing": "正在初始化连接...",
"preparing": "准备传输中...",
"sending": "传输中 {{progress}}%",
"waiting_qr_scan": "请扫描二维码连接"
},
"title": "局域网传输",
"transfer_progress": "传输进度"
},
"title": "导出至手机"
},
"hour_interval_one": "{{count}} 小时",
"hour_interval_other": "{{count}} 小时",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "无可用提示",
"requiredField": "必填字段"
},
"protocolInstallWarning": {
"command": "启动命令",
"message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。",
"run": "运行",
"title": "运行外部 MCP"
},
"provider": "提供者",
"providerPlaceholder": "提供者名称",
"providerUrl": "提供者网址",
+47
View File
@@ -1047,6 +1047,7 @@
"clear": "清除",
"close": "關閉",
"collapse": "折疊",
"completed": "[to be translated]:Completed",
"confirm": "確認",
"copied": "已複製",
"copy": "複製",
@@ -3038,6 +3039,46 @@
"title": "匯出選單設定",
"yuque": "匯出到語雀"
},
"export_to_phone": {
"confirm": {
"button": "選擇備份檔案"
},
"content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "未選取壓縮檔案",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "選擇壓縮檔案",
"sendZip": "開始恢復資料",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "區域網路傳輸",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "匯出手機"
},
"hour_interval_one": "{{count}} 小時",
"hour_interval_other": "{{count}} 小時",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "無可用提示",
"requiredField": "必填欄位"
},
"protocolInstallWarning": {
"command": "啟動命令",
"message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。",
"run": "執行",
"title": "執行外部 MCP"
},
"provider": "提供者",
"providerPlaceholder": "提供者名稱",
"providerUrl": "提供者網址",
@@ -1047,6 +1047,7 @@
"clear": "Löschen",
"close": "Schließen",
"collapse": "Einklappen",
"completed": "[to be translated]:Completed",
"confirm": "Bestätigen",
"copied": "Kopiert",
"copy": "Kopieren",
@@ -3038,6 +3039,46 @@
"title": "Export-Menü-Einstellungen",
"yuque": "Nach Yuque exportieren"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:Select backup file"
},
"content": "[to be translated]:Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:No compressed file selected",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:Select a compressed file",
"sendZip": "[to be translated]:Begin data recovery",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:LAN transmission",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:Export to phone"
},
"hour_interval_one": "{{count}} Stunde",
"hour_interval_other": "{{count}} Stunden",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "Keine Prompts verfügbar",
"requiredField": "Pflichtfeld"
},
"protocolInstallWarning": {
"command": "Startbefehl",
"message": "Dieses MCP wurde über ein Protokoll aus einer externen Quelle installiert. Das Ausführen unbekannter Tools kann Ihren Computer schädigen.",
"run": "Laufen",
"title": "Externes MCP ausführen?"
},
"provider": "Anbieter",
"providerPlaceholder": "Anbietername",
"providerUrl": "Anbieter-Website",
@@ -1047,6 +1047,7 @@
"clear": "Καθαρισμός",
"close": "Κλείσιμο",
"collapse": "Σύμπτυξη",
"completed": "[to be translated]:Completed",
"confirm": "Επιβεβαίωση",
"copied": "Αντιγράφηκε",
"copy": "Αντιγραφή",
@@ -3038,6 +3039,46 @@
"title": "Εξαγωγή ρυθμίσεων μενού",
"yuque": "Εξαγωγή στο Yuque"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} ώρα",
"hour_interval_other": "{{count}} ώρες",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις",
"requiredField": "Υποχρεωτικό πεδίο"
},
"protocolInstallWarning": {
"command": "Εντολή εκκίνησης",
"message": "Αυτό το MCP εγκαταστάθηκε από εξωτερική πηγή μέσω πρωτοκόλλου. Η εκτέλεση άγνωστων εργαλείων ενδέχεται να βλάψει τον υπολογιστή σας.",
"run": "Τρέξε",
"title": "Εκτέλεση εξωτερικού MCP;"
},
"provider": "Πάροχος",
"providerPlaceholder": "Όνομα παρόχου",
"providerUrl": "URL Παρόχου",
@@ -1047,6 +1047,7 @@
"clear": "Limpiar",
"close": "Cerrar",
"collapse": "Colapsar",
"completed": "[to be translated]:Completed",
"confirm": "Confirmar",
"copied": "Copiado",
"copy": "Copiar",
@@ -3038,6 +3039,46 @@
"title": "Exportar configuración del menú",
"yuque": "Exportar a Yuque"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} hora",
"hour_interval_other": "{{count}} horas",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "No hay indicaciones disponibles",
"requiredField": "Campo obligatorio"
},
"protocolInstallWarning": {
"command": "Comando de inicio",
"message": "Este MCP fue instalado desde una fuente externa a través del protocolo. Ejecutar herramientas desconocidas puede dañar tu computadora.",
"run": "Correr",
"title": "¿Ejecutar MCP externo?"
},
"provider": "Proveedor",
"providerPlaceholder": "Nombre del proveedor",
"providerUrl": "URL del proveedor",
@@ -1047,6 +1047,7 @@
"clear": "Effacer",
"close": "Fermer",
"collapse": "Réduire",
"completed": "[to be translated]:Completed",
"confirm": "Confirmer",
"copied": "Copié",
"copy": "Copier",
@@ -3038,6 +3039,46 @@
"title": "Exporter les paramètres du menu",
"yuque": "Exporter vers Yuque"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} heure",
"hour_interval_other": "{{count}} heures",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "Aucune invite disponible",
"requiredField": "Champ obligatoire"
},
"protocolInstallWarning": {
"command": "Commande de démarrage",
"message": "Ce MCP a été installé depuis une source externe via le protocole. L'exécution d'outils inconnus peut endommager votre ordinateur.",
"run": "Courir",
"title": "Exécuter un MCP externe ?"
},
"provider": "Поставщик",
"providerPlaceholder": "Название поставщика",
"providerUrl": "Адрес поставщика",
@@ -1047,6 +1047,7 @@
"clear": "クリア",
"close": "閉じる",
"collapse": "折りたたむ",
"completed": "[to be translated]:Completed",
"confirm": "確認",
"copied": "コピーされました",
"copy": "コピー",
@@ -3038,6 +3039,46 @@
"title": "エクスポートメニュー設定",
"yuque": "語雀にエクスポート"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} 時間",
"hour_interval_other": "{{count}} 時間",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "利用可能なプロンプトはありません",
"requiredField": "必須フィールド"
},
"protocolInstallWarning": {
"command": "起動コマンド",
"message": "このMCPは外部ソースからプロトコル経由でインストールされました。不明なツールを実行すると、コンピューターに危害を及ぼす可能性があります。",
"run": "走る",
"title": "外部のMCPを実行しますか?"
},
"provider": "プロバイダー",
"providerPlaceholder": "プロバイダー名",
"providerUrl": "プロバイダーURL",
@@ -1047,6 +1047,7 @@
"clear": "Limpar",
"close": "Fechar",
"collapse": "Recolher",
"completed": "[to be translated]:Completed",
"confirm": "Confirmar",
"copied": "Copiado",
"copy": "Copiar",
@@ -3038,6 +3039,46 @@
"title": "Exportar Configurações do Menu",
"yuque": "Exportar para Yuque"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} hora",
"hour_interval_other": "{{count}} horas",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "Nenhuma dica disponível",
"requiredField": "Campo obrigatório"
},
"protocolInstallWarning": {
"command": "Comando de inicialização",
"message": "Este MCP foi instalado a partir de uma fonte externa via protocolo. Executar ferramentas desconhecidas pode prejudicar seu computador.",
"run": "Correr",
"title": "Executar MCP externo?"
},
"provider": "Fornecedor",
"providerPlaceholder": "Nome do Fornecedor",
"providerUrl": "URL do Fornecedor",
@@ -1047,6 +1047,7 @@
"clear": "Очистить",
"close": "Закрыть",
"collapse": "Свернуть",
"completed": "[to be translated]:Completed",
"confirm": "Подтверждение",
"copied": "Скопировано",
"copy": "Копировать",
@@ -3038,6 +3039,46 @@
"title": "Настройки меню экспорта",
"yuque": "Экспорт в Yuque"
},
"export_to_phone": {
"confirm": {
"button": "[to be translated]:选择备份文件"
},
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "[to be translated]:Confirm Close",
"connected": "[to be translated]:Connected",
"connection_failed": "[to be translated]:Connection failed",
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "[to be translated]:Initialization failed",
"no_file": "[to be translated]:No file selected",
"no_ip": "[to be translated]:Unable to get IP address",
"send_failed": "[to be translated]:Failed to send file"
},
"force_close": "[to be translated]:Force Close",
"generating_qr": "[to be translated]:Generating QR code...",
"noZipSelected": "[to be translated]:未选择压缩文件",
"scan_qr": "[to be translated]:Please scan QR code with your phone",
"selectZip": "[to be translated]:选择压缩文件",
"sendZip": "[to be translated]:开始恢复数据",
"status": {
"completed": "[to be translated]:Transfer completed",
"connected": "[to be translated]:Connected",
"connecting": "[to be translated]:Connecting...",
"disconnected": "[to be translated]:Disconnected",
"error": "[to be translated]:Connection error",
"initializing": "[to be translated]:Initializing connection...",
"preparing": "[to be translated]:Preparing transfer...",
"sending": "[to be translated]:Transferring {{progress}}%",
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
},
"title": "[to be translated]:局域网传输",
"transfer_progress": "[to be translated]:Transfer progress"
},
"title": "[to be translated]:导出至手机"
},
"hour_interval_one": "{{count}} час",
"hour_interval_other": "{{count}} часов",
"joplin": {
@@ -3784,6 +3825,12 @@
"noPromptsAvailable": "Нет доступных подсказок",
"requiredField": "Обязательное поле"
},
"protocolInstallWarning": {
"command": "Команда запуска",
"message": "Этот MCP был установлен из внешнего источника через протокол. Запуск неизвестных инструментов может повредить ваш компьютер.",
"run": "Беги",
"title": "Запускать внешний MCP?"
},
"provider": "Провайдер",
"providerPlaceholder": "Имя провайдера",
"providerUrl": "URL провайдера",
+10 -41
View File
@@ -5,7 +5,6 @@ import { useAssistants } from '@renderer/hooks/useAssistant'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import NavigationService from '@renderer/services/NavigationService'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
@@ -35,8 +34,10 @@ const HomePage: FC = () => {
const location = useLocation()
const state = location.state
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
const [activeAssistant, _setActiveAssistant] = useState<Assistant>(
state?.assistant || _activeAssistant || assistants[0]
)
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', state?.topic)
const [showAssistants] = usePreference('assistant.tab.show')
const [showTopics] = usePreference('topic.tab.show')
const [topicPosition] = usePreference('topic.position')
@@ -47,16 +48,20 @@ const HomePage: FC = () => {
_activeAssistant = activeAssistant
const setActiveAssistant = useCallback(
// TODO: allow to set it as null.
(newAssistant: Assistant) => {
if (newAssistant.id === activeAssistant.id) return
if (newAssistant.id === activeAssistant?.id) return
startTransition(() => {
_setActiveAssistant(newAssistant)
if (newAssistant.id !== 'fake') {
dispatch(setActiveAgentId(null))
}
// 同步更新 active topic,避免不必要的重新渲染
const newTopic = newAssistant.topics[0]
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
})
},
[_setActiveTopic, activeAssistant]
[_setActiveTopic, activeAssistant?.id, dispatch]
)
const setActiveTopic = useCallback(
@@ -80,19 +85,6 @@ const HomePage: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state])
useEffect(() => {
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
const newAssistant = assistants.find((a) => a.id === assistantId)
if (newAssistant) {
setActiveAssistant(newAssistant)
}
})
return () => {
unsubscribe()
}
}, [assistants, setActiveAssistant])
useEffect(() => {
const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics
window.api.window.setMinimumSize(canMinimize ? SECOND_MIN_WINDOW_WIDTH : MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
@@ -102,29 +94,6 @@ const HomePage: FC = () => {
}
}, [showAssistants, showTopics, topicPosition])
useEffect(() => {
if (activeTopicOrSession === 'session') {
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
} else if (activeTopicOrSession === 'topic') {
dispatch(setActiveAgentId(null))
}
}, [activeTopicOrSession, dispatch, setActiveAssistant])
return (
<Container id="home-page">
{isLeftNavbar && (
@@ -0,0 +1,16 @@
import { AccordionItem } from '@heroui/react'
import { PencilRuler } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { SkillToolInput, SkillToolOutput } from './types'
export function SkillTool({ input, output }: { input: SkillToolInput; output?: SkillToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Skill Tool"
title={<ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input.command} />}>
{output}
</AccordionItem>
)
}
@@ -17,6 +17,7 @@ import { MultiEditTool } from './MultiEditTool'
import { NotebookEditTool } from './NotebookEditTool'
import { ReadTool } from './ReadTool'
import { SearchTool } from './SearchTool'
import { SkillTool } from './SkillTool'
import { TaskTool } from './TaskTool'
import { TodoWriteTool } from './TodoWriteTool'
import type { ToolInput, ToolOutput } from './types'
@@ -25,6 +26,7 @@ import { UnknownToolRenderer } from './UnknownToolRenderer'
import { WebFetchTool } from './WebFetchTool'
import { WebSearchTool } from './WebSearchTool'
import { WriteTool } from './WriteTool'
const logger = loggerService.withContext('MessageAgentTools')
// 创建工具渲染器映射,这样就实现了完全的类型安全
@@ -43,7 +45,8 @@ export const toolRenderers = {
[AgentToolsType.MultiEdit]: MultiEditTool,
[AgentToolsType.BashOutput]: BashOutputTool,
[AgentToolsType.NotebookEdit]: NotebookEditTool,
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool,
[AgentToolsType.Skill]: SkillTool
} as const
// 类型守卫函数
@@ -1,4 +1,5 @@
export enum AgentToolsType {
Skill = 'Skill',
Read = 'Read',
Task = 'Task',
Bash = 'Bash',
@@ -22,6 +23,15 @@ export type TextOutput = {
}
// Read 工具的类型定义
export interface SkillToolInput {
/**
* The skill to use
*/
command: string
}
export type SkillToolOutput = string
export interface ReadToolInput {
/**
* The absolute path to the file to read
@@ -2,6 +2,7 @@ import type { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { MessageAgentTools } from './MessageAgentTools'
import { AgentToolsType } from './MessageAgentTools/types'
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
import { MessageMemorySearchToolTitle } from './MessageMemorySearch'
import { MessageWebSearchToolTitle } from './MessageWebSearch'
@@ -9,27 +10,12 @@ import { MessageWebSearchToolTitle } from './MessageWebSearch'
interface Props {
block: ToolMessageBlock
}
const prefix = 'builtin_'
const agentPrefix = 'mcp__'
const agentTools = [
'Read',
'Task',
'Bash',
'Search',
'Glob',
'TodoWrite',
'WebSearch',
'Grep',
'Write',
'WebFetch',
'Edit',
'MultiEdit',
'BashOutput',
'NotebookEdit',
'ExitPlanMode'
]
const isAgentTool = (toolName: string) => {
if (agentTools.includes(toolName) || toolName.startsWith(agentPrefix)) {
const builtinToolsPrefix = 'builtin_'
const agentMcpToolsPrefix = 'mcp__'
const agentTools = Object.values(AgentToolsType)
const isAgentTool = (toolName: AgentToolsType) => {
if (agentTools.includes(toolName) || toolName.startsWith(agentMcpToolsPrefix)) {
return true
}
return false
@@ -38,8 +24,8 @@ const isAgentTool = (toolName: string) => {
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
if (toolName.startsWith(builtinToolsPrefix)) {
toolName = toolName.slice(builtinToolsPrefix.length)
switch (toolName) {
case 'web_search':
case 'web_search_preview':
@@ -51,7 +37,7 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
default:
return null
}
} else if (isAgentTool(toolName)) {
} else if (isAgentTool(toolName as AgentToolsType)) {
return <MessageAgentTools toolResponse={toolResponse} />
}
return null
@@ -1,5 +1,5 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
import { Button, Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
@@ -54,7 +54,6 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
const isSubmittingAllow = request?.status === 'submitting-allow'
const isSubmittingDeny = request?.status === 'submitting-deny'
const isSubmitting = isSubmittingAllow || isSubmittingDeny
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
const handleDecision = useCallback(
async (
@@ -147,37 +146,16 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
{t('agent.toolPermission.button.cancel')}
</Button>
{hasSuggestions ? (
<ButtonGroup className="h-8">
<Button
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.runWithOptions')}
className="h-8 rounded-l-none"
color="success"
isDisabled={isSubmitting || isExpired}
isIconOnly
variant="solid"></Button>
</ButtonGroup>
) : (
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
)}
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
<Button
aria-label={
@@ -11,7 +11,7 @@ import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { getErrorMessage } from '@renderer/utils'
import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes'
import type { Assistant } from '@types'
import type { Assistant, Topic } from '@types'
import type { FC } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -38,7 +38,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServerConfig, apiServerRunning } = useApiServer()
const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer()
const apiServerEnabled = apiServerConfig.enabled
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
@@ -101,6 +101,30 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
[setAssistantsTabSortType]
)
const handleAgentPress = useCallback(
(agentId: string) => {
setActiveAgentId(agentId)
// TODO: should allow it to be null
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
},
[setActiveAgentId, setActiveAssistant]
)
return (
<Container className="assistants-tab" ref={containerRef}>
{!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && (
@@ -115,8 +139,8 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
/>
)}
{agentsLoading && <Spinner />}
{apiServerConfig.enabled && !apiServerRunning && (
{(agentsLoading || apiServerLoading) && <Spinner />}
{apiServerConfig.enabled && !apiServerLoading && !apiServerRunning && (
<Alert color="danger" title={t('agent.server.error.not_running')} isClosable className="mb-2" />
)}
{apiServerRunning && agentsError && (
@@ -128,7 +152,11 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
/>
)}
<UnifiedAddButton onCreateAssistant={onCreateAssistant} />
<UnifiedAddButton
onCreateAssistant={onCreateAssistant}
setActiveAssistant={setActiveAssistant}
setActiveAgentId={setActiveAgentId}
/>
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups
@@ -164,7 +192,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={setActiveAgentId}
onAgentPress={handleAgentPress}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
@@ -1,20 +1,26 @@
import { Button, Popover, PopoverContent, PopoverTrigger, useDisclosure } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { AgentEntity, Assistant, Topic } from '@renderer/types'
import { Bot, MessageSquare } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
interface UnifiedAddButtonProps {
onCreateAssistant: () => void
setActiveAssistant: (a: Assistant) => void
setActiveAgentId: (id: string) => void
}
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setActiveAssistant, setActiveAgentId }) => {
const { t } = useTranslation()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure()
const dispatch = useAppDispatch()
const handleAddAssistant = () => {
setIsPopoverOpen(false)
@@ -26,6 +32,31 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
onAgentModalOpen()
}
const afterCreate = useCallback(
(a: AgentEntity) => {
// TODO: should allow it to be null
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
setActiveAgentId(a.id)
dispatch(setActiveTopicOrSessionAction('session'))
},
[dispatch, setActiveAgentId, setActiveAssistant]
)
return (
<div className="mb-1">
<Popover
@@ -54,7 +85,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
</PopoverContent>
</Popover>
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} />
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} afterSubmit={afterCreate} />
</div>
)
}
@@ -1,4 +1,6 @@
import { useAppDispatch } from '@renderer/store'
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import type { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
@@ -21,6 +23,13 @@ export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
// Selector to get tagsOrder from Redux store
const selectTagsOrder = useMemo(
() => createSelector([(state: RootState) => state.assistants], (assistants) => assistants.tagsOrder ?? []),
[]
)
const savedTagsOrder = useAppSelector(selectTagsOrder)
// Group unified items by tags
const groupedUnifiedItems = useMemo(() => {
const groups = new Map<string, UnifiedItem[]>()
@@ -45,16 +54,30 @@ export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => {
}
})
// Sort groups: untagged first, then tagged groups
// Sort groups: untagged first, then by savedTagsOrder
const untaggedKey = t('assistants.tags.untagged')
const sortedGroups = Array.from(groups.entries()).sort(([tagA], [tagB]) => {
if (tagA === untaggedKey) return -1
if (tagB === untaggedKey) return 1
if (savedTagsOrder.length > 0) {
const indexA = savedTagsOrder.indexOf(tagA)
const indexB = savedTagsOrder.indexOf(tagB)
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB
}
if (indexA !== -1) return -1
if (indexB !== -1) return 1
}
return 0
})
return sortedGroups.map(([tag, items]) => ({ tag, items }))
}, [unifiedItems, t])
}, [unifiedItems, t, savedTagsOrder])
const handleUnifiedGroupReorder = useCallback(
(tag: string, newGroupList: UnifiedItem[]) => {
+10 -1
View File
@@ -8,6 +8,8 @@ import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import type { Tab } from '@renderer/types/chat'
import { classNames, getErrorMessage, uuid } from '@renderer/utils'
@@ -52,6 +54,7 @@ const HomeTabs: FC<Props> = ({
const { activeTopicOrSession, activeAgentId } = chat
const { session, isLoading: isSessionLoading, error: sessionError } = useActiveSession()
const { updateSession } = useUpdateSession(activeAgentId)
const dispatch = useAppDispatch()
const isSessionView = activeTopicOrSession === 'session'
const isTopicView = activeTopicOrSession === 'topic'
@@ -71,13 +74,19 @@ const HomeTabs: FC<Props> = ({
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
assistant && setActiveAssistant(assistant)
if (assistant) {
setActiveAssistant(assistant)
dispatch(setActiveAgentId(null))
dispatch(setActiveTopicOrSessionAction('topic'))
}
}
const onCreateDefaultAssistant = () => {
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
dispatch(setActiveAgentId(null))
dispatch(setActiveTopicOrSessionAction('topic'))
}
useEffect(() => {
@@ -63,7 +63,12 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
)}
{activeSession && (
<BreadcrumbItem>
<SelectAgentBaseModelButton agentBase={activeSession} onSelect={handleUpdateModel} />
<SelectAgentBaseModelButton
agentBase={activeSession}
onSelect={async (model) => {
await handleUpdateModel(model)
}}
/>
</BreadcrumbItem>
)}
{activeAgent && activeSession && (
@@ -1,20 +1,20 @@
import { Button, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import type { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Plus } from 'lucide-react'
import React, { useCallback } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface AccessibleDirsSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
update: UpdateAgentFunctionUnion
}
const logger = loggerService.withContext('AccessibleDirsSetting')
export const AccessibleDirsSetting: React.FC<AccessibleDirsSettingProps> = ({ base, update }) => {
export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingProps) => {
const { t } = useTranslation()
const updateAccessiblePaths = useCallback(
@@ -1,5 +1,5 @@
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import type { AgentEntity, UpdateAgentForm } from '@renderer/types'
import type { AgentEntity, UpdateAgentForm, UpdateAgentFunction } from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -8,7 +8,7 @@ import { SettingsItem, SettingsTitle } from './shared'
export interface AvatarSettingsProps {
agent: AgentEntity
update: (form: UpdateAgentForm) => Promise<void>
update: UpdateAgentFunction
}
// const logger = loggerService.withContext('AvatarSetting')
@@ -1,16 +1,16 @@
import { Textarea } from '@heroui/react'
import type { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import React, { useCallback, useState } from 'react'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface DescriptionSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
update: UpdateAgentFunctionUnion
}
export const DescriptionSetting: React.FC<DescriptionSettingProps> = ({ base, update }) => {
export const DescriptionSetting = ({ base, update }: DescriptionSettingProps) => {
const { t } = useTranslation()
const [description, setDescription] = useState<string | undefined>(base?.description?.trim())
@@ -47,7 +47,9 @@ const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, show
</div>
</SettingsItem>
)}
{isAgent && <AvatarSetting agent={agentBase} update={update} />}
{isAgent && (
<AvatarSetting agent={agentBase} update={update as ReturnType<typeof useUpdateAgent>['updateAgent']} />
)}
<NameSetting base={agentBase} update={update} />
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
<AccessibleDirsSetting base={agentBase} update={update} />
@@ -1,16 +1,16 @@
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
import type { AgentBaseWithId, ApiModel, UpdateAgentBaseForm } from '@renderer/types'
import type { AgentBaseWithId, ApiModel, UpdateAgentFunctionUnion } from '@renderer/types'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface ModelSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
update: UpdateAgentFunctionUnion
isDisabled?: boolean
}
export const ModelSetting: React.FC<ModelSettingProps> = ({ base, update, isDisabled }) => {
export const ModelSetting = ({ base, update, isDisabled }: ModelSettingProps) => {
const { t } = useTranslation()
const updateModel = async (model: ApiModel) => {
@@ -23,7 +23,13 @@ export const ModelSetting: React.FC<ModelSettingProps> = ({ base, update, isDisa
return (
<SettingsItem inline>
<SettingsTitle id="model">{t('common.model')}</SettingsTitle>
<SelectAgentBaseModelButton agentBase={base} onSelect={updateModel} isDisabled={isDisabled} />
<SelectAgentBaseModelButton
agentBase={base}
onSelect={async (model) => {
await updateModel(model)
}}
isDisabled={isDisabled}
/>
</SettingsItem>
)
}
@@ -1,5 +1,5 @@
import { Input } from '@heroui/react'
import type { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -7,10 +7,10 @@ import { SettingsItem, SettingsTitle } from './shared'
export interface NameSettingsProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
update: UpdateAgentFunctionUnion
}
export const NameSetting: React.FC<NameSettingsProps> = ({ base, update }) => {
export const NameSetting = ({ base, update }: NameSettingsProps) => {
const { t } = useTranslation()
const [name, setName] = useState<string | undefined>(base?.name?.trim())
const updateName = async (name: UpdateAgentBaseForm['name']) => {
@@ -1,6 +1,6 @@
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentFunctionUnion } from '@renderer/types/agent'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -11,7 +11,7 @@ import { SettingsContainer } from './shared'
interface PluginSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse
update: (partial: UpdateAgentBaseForm) => Promise<void>
update: UpdateAgentFunctionUnion
}
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
@@ -9,8 +9,8 @@ import type {
PermissionMode,
Tool,
UpdateAgentBaseForm,
UpdateAgentForm,
UpdateSessionForm
UpdateAgentFunction,
UpdateAgentSessionFunction
} from '@renderer/types'
import { AgentConfigurationSchema } from '@renderer/types'
import { Modal } from 'antd'
@@ -24,11 +24,11 @@ import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
type AgentToolingSettingsProps =
| {
agentBase: GetAgentResponse | undefined | null
update: (form: UpdateAgentForm) => Promise<void> | void
update: UpdateAgentFunction
}
| {
agentBase: GetAgentSessionResponse | undefined | null
update: (form: UpdateSessionForm) => Promise<void> | void
update: UpdateAgentSessionFunction
}
type AgentConfigurationState = AgentConfiguration & Record<string, unknown>
@@ -3,6 +3,7 @@ import {
CloudSyncOutlined,
FileSearchOutlined,
LoadingOutlined,
WifiOutlined,
YuqueOutlined
} from '@ant-design/icons'
import { RowFlex } from '@cherrystudio/ui'
@@ -13,6 +14,7 @@ import DividerWithText from '@renderer/components/DividerWithText'
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
import ListItem from '@renderer/components/ListItem'
import BackupPopup from '@renderer/components/Popups/BackupPopup'
import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup'
import RestorePopup from '@renderer/components/Popups/RestorePopup'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles'
@@ -293,8 +295,12 @@ const DataSettings: FC = () => {
onValueChange={(checked) => {
shouldCopyData = checked
}}
className="mr-2"
/>
size="sm">
<span style={{ fontWeight: 'normal', fontSize: '14px' }}>
{t('settings.data.app_data.copy_data_option')}
</span>
</Switch>
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
{t('settings.data.app_data.copy_data_option')}
</MigrationPathLabel>
@@ -616,11 +622,21 @@ const DataSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch isSelected={skipBackupFile} onValueChange={onSkipBackupFilesChange} />
<Switch isSelected={skipBackupFile} onValueChange={onSkipBackupFilesChange} size="sm" />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-[5px]">
<Button variant="ghost" size="sm" onClick={ExportToPhoneLanPopup.show}>
<WifiOutlined />
{t('settings.data.export_to_phone.lan.title')}
</Button>
</RowFlex>
</SettingRow>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>
@@ -635,7 +651,9 @@ const DataSettings: FC = () => {
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
<RowFlex className="ml-2 gap-[5px]">
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
<Button variant="ghost" size="sm" onClick={handleSelectAppDataPath}>
{t('settings.data.app_data.select')}
</Button>
</RowFlex>
</PathRow>
</SettingRow>
@@ -648,7 +666,7 @@ const DataSettings: FC = () => {
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
<RowFlex className="ml-2 gap-[5px]">
<Button onClick={() => handleOpenPath(appInfo?.logsPath)}>
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.logsPath)}>
{t('settings.data.app_logs.button')}
</Button>
</RowFlex>
@@ -658,7 +676,9 @@ const DataSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.data.app_knowledge.label')}</SettingRowTitle>
<RowFlex className="items-center gap-[5px]">
<Button onClick={handleRemoveAllFiles}>{t('settings.data.app_knowledge.button.delete')}</Button>
<Button variant="ghost" size="sm" onClick={handleRemoveAllFiles}>
{t('settings.data.app_knowledge.button.delete')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
@@ -668,14 +688,16 @@ const DataSettings: FC = () => {
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
</SettingRowTitle>
<RowFlex className="gap-[5px]">
<Button onClick={handleClearCache}>{t('settings.data.clear_cache.button')}</Button>
<Button variant="ghost" size="sm" onClick={handleClearCache}>
{t('settings.data.clear_cache.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
<RowFlex className="gap-[5px]">
<Button onClick={reset} variant="destructive">
<Button variant="destructive" size="sm" onClick={reset}>
{t('settings.general.reset.title')}
</Button>
</RowFlex>
@@ -145,6 +145,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
// Process DXT file
try {
const installTimestamp = Date.now()
const result = await window.api.mcp.uploadDxt(dxtFile)
if (!result.success) {
@@ -195,7 +196,11 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
logoUrl: manifest.icon ? `${extractDir}/${manifest.icon}` : undefined,
provider: manifest.author?.name,
providerUrl: manifest.homepage || manifest.repository?.url,
tags: manifest.keywords
tags: manifest.keywords,
installSource: 'manual',
isTrusted: true,
installedAt: installTimestamp,
trustedAt: installTimestamp
}
onSuccess(newServer)
@@ -260,12 +265,17 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
}
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
const installTimestamp = Date.now()
const newServer: MCPServer = {
id: nanoid(),
...serverToAdd,
name: serverToAdd.name || t('settings.mcp.newServer'),
baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '',
isActive: false // 初始狀態為非啟用
isActive: false, // 初始狀態為非啟用
installSource: 'manual',
isTrusted: true,
installedAt: installTimestamp,
trustedAt: installTimestamp
}
onSuccess(newServer)
@@ -6,6 +6,7 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust'
import type { MCPServer } from '@renderer/types'
import { formatMcpError } from '@renderer/utils/error'
import { matchKeywordsInString } from '@renderer/utils/match'
@@ -30,6 +31,7 @@ const logger = loggerService.withContext('McpServersList')
const McpServersList: FC = () => {
const { mcpServers, addMCPServer, deleteMCPServer, updateMcpServers, updateMCPServer } = useMCPServers()
const { ensureServerTrusted } = useMCPServerTrust()
const { t } = useTranslation()
const navigate = useNavigate()
const [isAddModalVisible, setIsAddModalVisible] = useState(false)
@@ -158,30 +160,37 @@ const McpServersList: FC = () => {
)
const handleToggleActive = async (server: MCPServer, active: boolean) => {
setLoadingServerIds((prev) => new Set(prev).add(server.id))
const oldActiveState = server.isActive
logger.silly('toggle activate', { serverId: server.id, active })
let serverForUpdate = server
if (active) {
const trustedServer = await ensureServerTrusted(server)
if (!trustedServer) {
return
}
serverForUpdate = trustedServer
}
setLoadingServerIds((prev) => new Set(prev).add(serverForUpdate.id))
const oldActiveState = serverForUpdate.isActive
logger.silly('toggle activate', { serverId: serverForUpdate.id, active })
try {
if (active) {
// Fetch version when server is activated
await fetchServerVersion({ ...server, isActive: active })
await fetchServerVersion({ ...serverForUpdate, isActive: active })
} else {
await window.api.mcp.stopServer(server)
// Clear version when server is deactivated
setServerVersions((prev) => ({ ...prev, [server.id]: null }))
await window.api.mcp.stopServer(serverForUpdate)
setServerVersions((prev) => ({ ...prev, [serverForUpdate.id]: null }))
}
updateMCPServer({ ...server, isActive: active })
updateMCPServer({ ...serverForUpdate, isActive: active })
} catch (error: any) {
window.modal.error({
title: t('settings.mcp.startError'),
content: formatMcpError(error),
centered: true
})
updateMCPServer({ ...server, isActive: oldActiveState })
updateMCPServer({ ...serverForUpdate, isActive: oldActiveState })
} finally {
setLoadingServerIds((prev) => {
const next = new Set(prev)
next.delete(server.id)
next.delete(serverForUpdate.id)
return next
})
}
@@ -5,6 +5,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js'
import { DeleteIcon } from '@renderer/components/Icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust'
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
import type { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
import { formatMcpError } from '@renderer/utils/error'
@@ -84,6 +85,7 @@ const McpSettings: React.FC = () => {
const decodedServerId = serverId ? decodeURIComponent(serverId) : ''
const server = useMCPServer(decodedServerId).server as MCPServer
const { deleteMCPServer, updateMCPServer } = useMCPServers()
const { ensureServerTrusted } = useMCPServerTrust()
const [serverType, setServerType] = useState<MCPServer['type']>('stdio')
const [form] = Form.useForm<MCPFormValues>()
const [loading, setLoading] = useState(false)
@@ -269,6 +271,7 @@ const McpSettings: React.FC = () => {
// set basic fields
const mcpServer: MCPServer = {
...server,
id: server.id,
name: values.name,
type: values.serverType || server.type,
@@ -404,34 +407,43 @@ const McpSettings: React.FC = () => {
}
await form.validateFields()
setLoadingServer(server.id)
const oldActiveState = server.isActive
let serverForUpdate = server
if (active) {
const trustedServer = await ensureServerTrusted(server)
if (!trustedServer) {
return
}
serverForUpdate = trustedServer
}
setLoadingServer(serverForUpdate.id)
const oldActiveState = serverForUpdate.isActive
try {
if (active) {
const localTools = await window.api.mcp.listTools(server)
const localTools = await window.api.mcp.listTools(serverForUpdate)
setTools(localTools)
const localPrompts = await window.api.mcp.listPrompts(server)
const localPrompts = await window.api.mcp.listPrompts(serverForUpdate)
setPrompts(localPrompts)
const localResources = await window.api.mcp.listResources(server)
const localResources = await window.api.mcp.listResources(serverForUpdate)
setResources(localResources)
const version = await window.api.mcp.getServerVersion(server)
const version = await window.api.mcp.getServerVersion(serverForUpdate)
setServerVersion(version)
} else {
await window.api.mcp.stopServer(server)
await window.api.mcp.stopServer(serverForUpdate)
setServerVersion(null)
}
updateMCPServer({ ...server, isActive: active })
updateMCPServer({ ...serverForUpdate, isActive: active })
} catch (error: any) {
window.modal.error({
title: t('settings.mcp.startError'),
content: formatMcpError(error as McpError),
centered: true
})
updateMCPServer({ ...server, isActive: oldActiveState })
updateMCPServer({ ...serverForUpdate, isActive: oldActiveState })
} finally {
setLoadingServer(null)
}
@@ -0,0 +1,33 @@
import React from 'react'
interface ProtocolInstallWarningContentProps {
message: string
commandLabel: string
commandPreview: string
}
/**
* Warning content component for protocol-installed MCP servers
* Displays a security warning and the command that will be executed
*/
const ProtocolInstallWarningContent: React.FC<ProtocolInstallWarningContentProps> = ({
message,
commandLabel,
commandPreview
}) => {
return (
<div className="space-y-3 text-left">
<p>{message}</p>
{commandPreview && (
<div className="space-y-1">
<div className="font-semibold">{commandLabel}</div>
<pre className="whitespace-pre-wrap break-all rounded-md bg-[var(--color-fill-secondary)] p-2">
{commandPreview}
</pre>
</div>
)}
</div>
)
}
export default ProtocolInstallWarningContent
@@ -1,51 +1,58 @@
/**
* MCP Settings
*/
import { loggerService } from '@logger'
import type { MCPServer } from '@renderer/types'
export const MCPRoutes = {
// 管理类页面
servers: '/settings/mcp/servers',
npxSearch: '/settings/mcp/npx-search',
mcpInstall: '/settings/mcp/mcp-install',
// 发现类页面
builtin: '/settings/mcp/builtin',
marketplaces: '/settings/mcp/marketplaces',
// 服务商页面
modelscope: '/settings/mcp/modelscope',
tokenflux: '/settings/mcp/tokenflux',
lanyun: '/settings/mcp/lanyun',
'302ai': '/settings/mcp/302ai',
bailian: '/settings/mcp/bailian'
} as const
const logger = loggerService.withContext('MCPSettings/utils')
/**
* MCP
* @param page
* @returns
* Get command preview string from MCP server configuration
* @param server - The MCP server to extract command from
* @returns Formatted command string with arguments
*/
export function getMCPRoute(page: keyof typeof MCPRoutes): string {
return MCPRoutes[page]
export const getCommandPreview = (server: MCPServer): string => {
return [server.command, ...(server.args ?? [])]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
}
/**
* MCP
* @param providerKey
* @returns
* Ensures a server is trusted before proceeding (pure logic, no UI)
* @param currentServer - The server to verify trust for
* @param requestConfirm - Callback to request user confirmation
* @param updateServer - Callback to update server state
* @returns The trusted server if confirmed, or null if user declined
*/
export function getMCPProviderRoute(providerKey: string): string {
return `/settings/mcp/${providerKey}`
}
export async function ensureServerTrusted(
currentServer: MCPServer,
requestConfirm: (server: MCPServer) => Promise<boolean>,
updateServer: (server: MCPServer) => void
): Promise<MCPServer | null> {
const isProtocolInstall = currentServer.installSource === 'protocol'
/**
* MCP
* @param serverId ID
* @returns
*/
export function getMCPServerSettingsRoute(serverId: string): string {
return `/settings/mcp/settings/${serverId}`
}
logger.silly('ensureServerTrusted', {
serverId: currentServer.id,
installSource: currentServer.installSource,
isTrusted: currentServer.isTrusted
})
// 类型定义
export type MCPPage = keyof typeof MCPRoutes
// Early return if no trust verification needed
if (!isProtocolInstall || currentServer.isTrusted) {
return currentServer
}
// Request user confirmation via callback
const confirmed = await requestConfirm(currentServer)
if (!confirmed) {
return null
}
// Update server with trust information
const trustedServer = {
...currentServer,
installSource: 'protocol' as const,
isTrusted: true,
trustedAt: Date.now()
}
updateServer(trustedServer)
return trustedServer
}
@@ -18,7 +18,6 @@ export const EVENT_NAMES = {
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
SWITCH_ASSISTANT: 'SWITCH_ASSISTANT',
NEW_CONTEXT: 'NEW_CONTEXT',
NEW_BRANCH: 'NEW_BRANCH',
COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE',
+1 -1
View File
@@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 168,
version: 169,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},
+27 -9
View File
@@ -79,7 +79,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
command: 'npx',
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@@ -91,14 +93,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
MEMORY_FILE_PATH: 'YOUR_MEMORY_FILE_PATH'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.sequentialThinking,
type: 'inMemory',
isActive: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@@ -109,14 +115,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
BRAVE_API_KEY: 'YOUR_API_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.fetch,
type: 'inMemory',
isActive: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@@ -125,7 +135,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'],
shouldConfig: true,
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@@ -136,14 +148,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
DIFY_KEY: 'YOUR_DIFY_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
name: BuiltinMCPServerNames.python,
type: 'inMemory',
isActive: false,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
},
{
id: nanoid(),
@@ -155,7 +171,9 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
DIDI_API_KEY: 'YOUR_DIDI_API_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
provider: 'CherryAI',
installSource: 'builtin',
isTrusted: true
}
] as const
+18 -1
View File
@@ -32,7 +32,7 @@ import type {
TranslateLanguageCode,
WebSearchProvider
} from '@renderer/types'
import { isSystemProvider, SystemProviderIds } from '@renderer/types'
import { isBuiltinMCPServer, isSystemProvider, SystemProviderIds } from '@renderer/types'
import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils'
import { defaultByPassRules } from '@shared/config/constant'
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
@@ -2771,6 +2771,23 @@ const migrateConfig = {
logger.error('migrate 168 error', error as Error)
return state
}
},
'169': (state: RootState) => {
try {
if (state?.mcp?.servers) {
state.mcp.servers = state.mcp.servers.map((server) => {
const inferredSource = isBuiltinMCPServer(server) ? 'builtin' : 'unknown'
return {
...server,
installSource: inferredSource
}
})
}
return state
} catch (error) {
logger.error('migrate 169 error', error as Error)
return state
}
}
}
+23 -26
View File
@@ -8,7 +8,7 @@ import type { ModelMessage, TextStreamPart } from 'ai'
import * as z from 'zod'
import type { Message, MessageBlock } from './newMessage'
import { PluginMetadataSchema } from './plugin'
import { InstalledPluginSchema, PluginMetadataSchema } from './plugin'
// ------------------ Core enums and helper types ------------------
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
@@ -58,30 +58,7 @@ export const AgentConfigurationSchema = z
// https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors
permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default'
max_turns: z.number().optional().default(100), // Maximum number of interaction turns, default to 100
// Plugin metadata
installed_plugins: z
.array(
z.object({
sourcePath: z.string(), // Full source path for re-install/updates
filename: z.string(), // Destination filename (unique)
type: z.enum(['agent', 'command', 'skill']),
name: z.string(),
description: z.string().optional(),
allowed_tools: z.array(z.string()).optional(),
tools: z.array(z.string()).optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
version: z.string().optional(),
author: z.string().optional(),
contentHash: z.string(), // Detect file modifications
installedAt: z.number(), // Track installation time
updatedAt: z.number().optional() // Track updates
})
)
.optional()
.default([])
max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100
})
.loose()
@@ -250,6 +227,25 @@ export type SessionForm = CreateSessionForm | UpdateSessionForm
export type UpdateAgentBaseForm = Partial<AgentBase> & { id: string }
// --------------------- Components & Hooks ----------------------
export type UpdateAgentBaseOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
export type UpdateAgentFunction = (
form: UpdateAgentForm,
options?: UpdateAgentBaseOptions
) => Promise<AgentEntity | undefined>
export type UpdateAgentSessionFunction = (
form: UpdateSessionForm,
options?: UpdateAgentBaseOptions
) => Promise<AgentSessionEntity | undefined>
export type UpdateAgentFunctionUnion = UpdateAgentFunction | UpdateAgentSessionFunction
// ------------------ API data transfer objects ------------------
export interface CreateAgentRequest extends AgentBase {
type: AgentType
@@ -264,7 +260,8 @@ export interface UpdateAgentRequest extends Partial<AgentBase> {}
export type ReplaceAgentRequest = AgentBase
export const GetAgentResponseSchema = AgentEntitySchema.extend({
tools: z.array(ToolSchema).optional() // All tools available to the agent (including built-in and custom)
tools: z.array(ToolSchema).optional(), // All tools available to the agent (including built-in and custom)
installed_plugins: z.array(InstalledPluginSchema).optional() // Plugins loaded from .claude/plugins.json cache
})
export type GetAgentResponse = z.infer<typeof GetAgentResponseSchema>
+9 -1
View File
@@ -11,7 +11,7 @@ import type { StreamTextParams } from './aiCoreTypes'
import type { Chunk } from './chunk'
import type { FileMetadata } from './file'
import type { KnowledgeBase, KnowledgeReference } from './knowledge'
import type { MCPConfigSample, McpServerType } from './mcp'
import type { MCPConfigSample, MCPServerInstallSource, McpServerType } from './mcp'
import type { Message } from './newMessage'
import type { BaseTool, MCPTool } from './tool'
@@ -682,6 +682,14 @@ export interface MCPServer {
shouldConfig?: boolean
/** 用于标记服务器是否运行中 */
isActive: boolean
/** 标记 MCP 安装来源,例如 builtin/manual/protocol */
installSource?: MCPServerInstallSource
/** 指示用户是否已信任该 MCP */
isTrusted?: boolean
/** 首次标记为信任的时间戳 */
trustedAt?: number
/** 安装时间戳 */
installedAt?: number
}
export type BuiltinMCPServer = MCPServer & {
+8 -1
View File
@@ -27,6 +27,9 @@ export const McpServerTypeSchema = z
})
.pipe(z.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])) // 大多数情况下默认使用 stdio
export const MCPServerInstallSourceSchema = z.enum(['builtin', 'manual', 'protocol', 'unknown']).default('unknown')
export type MCPServerInstallSource = z.infer<typeof MCPServerInstallSourceSchema>
/**
* MCP
* FIXME: 为了兼容性
@@ -168,7 +171,11 @@ export const McpServerConfigSchema = z
*
*
*/
isActive: z.boolean().optional().describe('Whether the server is active')
isActive: z.boolean().optional().describe('Whether the server is active'),
installSource: MCPServerInstallSourceSchema.optional().describe('Where the MCP server was installed from'),
isTrusted: z.boolean().optional().describe('Whether the MCP server has been trusted by user'),
trustedAt: z.number().optional().describe('Timestamp when the server was trusted'),
installedAt: z.number().optional().describe('Timestamp when the server was installed')
})
.strict()
// 在这里定义额外的校验逻辑
+9
View File
@@ -43,6 +43,15 @@ export const InstalledPluginSchema = z.object({
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>
// Cache file schema for .claude/plugins.json
export const CachedPluginsDataSchema = z.object({
version: z.number().default(1),
lastUpdated: z.number(), // Unix timestamp in milliseconds
plugins: z.array(InstalledPluginSchema)
})
export type CachedPluginsData = z.infer<typeof CachedPluginsDataSchema>
// Error handling types
export type PluginError =
| { type: 'PATH_TRAVERSAL'; message: string; path: string }
+148 -4
View File
@@ -14064,6 +14064,13 @@ __metadata:
languageName: node
linkType: hard
"@socket.io/component-emitter@npm:~3.1.0":
version: 3.1.2
resolution: "@socket.io/component-emitter@npm:3.1.2"
checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7
languageName: node
linkType: hard
"@standard-schema/spec@npm:^1.0.0":
version: 1.0.0
resolution: "@standard-schema/spec@npm:1.0.0"
@@ -15286,7 +15293,7 @@ __metadata:
languageName: node
linkType: hard
"@types/cors@npm:^2.8.19":
"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19":
version: 2.8.19
resolution: "@types/cors@npm:2.8.19"
dependencies:
@@ -15859,6 +15866,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:>=10.0.0":
version: 24.3.1
resolution: "@types/node@npm:24.3.1"
dependencies:
undici-types: "npm:~7.10.0"
checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.19.86
resolution: "@types/node@npm:18.19.86"
@@ -17396,6 +17412,7 @@ __metadata:
pdf-parse: "npm:^1.1.1"
playwright: "npm:^1.55.1"
proxy-agent: "npm:^6.5.0"
qrcode.react: "npm:^4.2.0"
react: "npm:^19.2.0"
react-dom: "npm:^19.2.0"
react-error-boundary: "npm:^6.0.0"
@@ -17427,6 +17444,7 @@ __metadata:
selection-hook: "npm:^1.0.12"
sharp: "npm:^0.34.3"
shiki: "npm:^3.12.0"
socket.io: "npm:^4.8.1"
strict-url-sanitise: "npm:^0.0.1"
string-width: "npm:^7.2.0"
striptags: "npm:^3.2.0"
@@ -17502,6 +17520,16 @@ __metadata:
languageName: node
linkType: hard
"accepts@npm:~1.3.4":
version: 1.3.8
resolution: "accepts@npm:1.3.8"
dependencies:
mime-types: "npm:~2.1.34"
negotiator: "npm:0.6.3"
checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362
languageName: node
linkType: hard
"acorn-jsx@npm:^5.3.2":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@@ -18281,6 +18309,13 @@ __metadata:
languageName: node
linkType: hard
"base64id@npm:2.0.0, base64id@npm:~2.0.0":
version: 2.0.0
resolution: "base64id@npm:2.0.0"
checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453
languageName: node
linkType: hard
"basic-ftp@npm:^5.0.2":
version: 5.0.5
resolution: "basic-ftp@npm:5.0.5"
@@ -19597,7 +19632,7 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:^0.7.1":
"cookie@npm:^0.7.1, cookie@npm:~0.7.2":
version: 0.7.2
resolution: "cookie@npm:0.7.2"
checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2
@@ -19627,7 +19662,7 @@ __metadata:
languageName: node
linkType: hard
"cors@npm:^2.8.5":
"cors@npm:^2.8.5, cors@npm:~2.8.5":
version: 2.8.5
resolution: "cors@npm:2.8.5"
dependencies:
@@ -20302,6 +20337,18 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4":
version: 4.3.7
resolution: "debug@npm:4.3.7"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b
languageName: node
linkType: hard
"decamelize@npm:1.2.0":
version: 1.2.0
resolution: "decamelize@npm:1.2.0"
@@ -21241,6 +21288,30 @@ __metadata:
languageName: node
linkType: hard
"engine.io-parser@npm:~5.2.1":
version: 5.2.3
resolution: "engine.io-parser@npm:5.2.3"
checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536
languageName: node
linkType: hard
"engine.io@npm:~6.6.0":
version: 6.6.4
resolution: "engine.io@npm:6.6.4"
dependencies:
"@types/cors": "npm:^2.8.12"
"@types/node": "npm:>=10.0.0"
accepts: "npm:~1.3.4"
base64id: "npm:2.0.0"
cookie: "npm:~0.7.2"
cors: "npm:~2.8.5"
debug: "npm:~4.3.1"
engine.io-parser: "npm:~5.2.1"
ws: "npm:~8.17.1"
checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab
languageName: node
linkType: hard
"enhanced-resolve@npm:^5.18.3":
version: 5.18.3
resolution: "enhanced-resolve@npm:5.18.3"
@@ -26622,7 +26693,7 @@ __metadata:
languageName: node
linkType: hard
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35":
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
@@ -27086,6 +27157,13 @@ __metadata:
languageName: node
linkType: hard
"negotiator@npm:0.6.3":
version: 0.6.3
resolution: "negotiator@npm:0.6.3"
checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2
languageName: node
linkType: hard
"negotiator@npm:^0.6.3":
version: 0.6.4
resolution: "negotiator@npm:0.6.4"
@@ -28809,6 +28887,15 @@ __metadata:
languageName: node
linkType: hard
"qrcode.react@npm:^4.2.0":
version: 4.2.0
resolution: "qrcode.react@npm:4.2.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248
languageName: node
linkType: hard
"qs@npm:^6.14.0":
version: 6.14.0
resolution: "qs@npm:6.14.0"
@@ -31289,6 +31376,41 @@ __metadata:
languageName: node
linkType: hard
"socket.io-adapter@npm:~2.5.2":
version: 2.5.5
resolution: "socket.io-adapter@npm:2.5.5"
dependencies:
debug: "npm:~4.3.4"
ws: "npm:~8.17.1"
checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e
languageName: node
linkType: hard
"socket.io-parser@npm:~4.2.4":
version: 4.2.4
resolution: "socket.io-parser@npm:4.2.4"
dependencies:
"@socket.io/component-emitter": "npm:~3.1.0"
debug: "npm:~4.3.1"
checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48
languageName: node
linkType: hard
"socket.io@npm:^4.8.1":
version: 4.8.1
resolution: "socket.io@npm:4.8.1"
dependencies:
accepts: "npm:~1.3.4"
base64id: "npm:~2.0.0"
cors: "npm:~2.8.5"
debug: "npm:~4.3.2"
engine.io: "npm:~6.6.0"
socket.io-adapter: "npm:~2.5.2"
socket.io-parser: "npm:~4.2.4"
checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9
languageName: node
linkType: hard
"socks-proxy-agent@npm:^7.0.0":
version: 7.0.0
resolution: "socks-proxy-agent@npm:7.0.0"
@@ -32939,6 +33061,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~7.10.0":
version: 7.10.0
resolution: "undici-types@npm:7.10.0"
checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635
languageName: node
linkType: hard
"undici@npm:6.21.2":
version: 6.21.2
resolution: "undici@npm:6.21.2"
@@ -33999,6 +34128,21 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:~8.17.1":
version: 8.17.1
resolution: "ws@npm:8.17.1"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe
languageName: node
linkType: hard
"xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz":
version: 0.20.2
resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"