Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2
This commit is contained in:
+98
-42
@@ -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-->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type UpdateAgentBaseOptions = {
|
||||
/** Whether to show success toast after updating. Defaults to true. */
|
||||
showSuccessToast?: boolean
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "提供者网址",
|
||||
|
||||
@@ -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 провайдера",
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 168,
|
||||
version: 169,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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()
|
||||
// 在这里定义额外的校验逻辑
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user