diff --git a/.changeset/red-kings-hear.md b/.changeset/red-kings-hear.md new file mode 100644 index 00000000..b6af6094 --- /dev/null +++ b/.changeset/red-kings-hear.md @@ -0,0 +1,29 @@ +--- +"@promptx/mcp-workspace": minor +"@promptx/mcp-server": minor +"@promptx/resource": minor +"@promptx/core": minor +"@promptx/desktop": minor +--- + +## v2.3.0 + +### 新功能 + +- **飞书接入**:支持通过飞书机器人与 PromptX 交互,使用 WebSocket 长连接模式无需公网 IP,实现类似 OpenClaw 的多平台接入能力 +- **工作区功能**:新增工作区侧边栏,支持项目文件浏览、拖拽文件到对话输入、文件读写管理 +- **DeepSeek 预配置**:AgentX 配置新增 DeepSeek 预设,开箱即用 +- **Windows Git 检测**:首页添加 Git 安装状态检测与引导提示 +- **MCP Workspace 服务**:新增内置 MCP 工作区服务,支持文件操作和配置管理 + +### 优化 + +- **RoleX 全面优化**:修复组织操作相关的 bug,拆分 action 工具为 4 个领域工具以减少 LLM 调用失败 +- **资源去重**:修复资源页面重复 key 警告,V2 角色正确覆盖 V1 同名角色 +- **通知中心**:新增 v2.3.0 版本更新通知 + +### 修复 + +- 修复工作区文件夹自动展开导致的性能问题 +- 修复 Windows 平台 Git 检测与路径问题 +- 清理调试日志输出 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ba23ad6b..1b1d585e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,6 +41,7 @@ "@agentxjs/queue": "1.9.0", "@agentxjs/runtime": "workspace:*", "@agentxjs/ui": "1.9.0", + "@larksuiteoapi/node-sdk": "^1.59.0", "@promptx/config": "workspace:*", "@promptx/core": "workspace:*", "@promptx/mcp-office": "workspace:*", diff --git a/apps/desktop/src/i18n/locales/en.json b/apps/desktop/src/i18n/locales/en.json index 9805240b..351bc768 100644 --- a/apps/desktop/src/i18n/locales/en.json +++ b/apps/desktop/src/i18n/locales/en.json @@ -99,6 +99,53 @@ "copyUrl": "URL copied", "qrHint": "Scan to access on phone or other devices" }, + "feishu": { + "title": "Feishu (Lark)", + "description": "Connect Feishu bot to interact with PromptX via Feishu messages", + "appId": { + "label": "App ID", + "placeholder": "cli_xxxxxxxxxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "Enter Feishu app secret" + }, + "encryptKey": { + "label": "Encrypt Key (Optional)", + "placeholder": "Event verification key" + }, + "save": "Save Config", + "saving": "Saving...", + "saveSuccess": "Feishu config saved", + "saveFailed": "Failed to save Feishu config", + "testConnection": "Test Connection", + "comingSoon": "Feishu integration coming soon", + "connected": "Connected", + "disconnected": "Disconnected", + "configRequired": "Please fill in App ID and App Secret first", + "startSuccess": "Feishu bot started", + "startFailed": "Failed to start Feishu bot", + "stopSuccess": "Feishu bot stopped", + "stopFailed": "Failed to stop Feishu bot", + "remove": "Disconnect & Remove", + "removeSuccess": "Feishu config removed", + "removeFailed": "Failed to remove Feishu config", + "guide": "Create an app on Feishu Open Platform to get credentials", + "guideLink": "Feishu Open Platform" + }, + "wechat": { + "title": "WeChat", + "description": "Connect personal WeChat to interact with PromptX via WeChat messages", + "installTitle": "1. Install Plugin", + "installDesc": "First install the openclaw WeChat plugin", + "installCmd": "npx -y @tencent-weixin/openclaw-weixin-cli install", + "loginTitle": "2. QR Code Login", + "loginDesc": "Run the command below and scan the QR code with WeChat", + "loginCmd": "openclaw channels login --channel openclaw-weixin", + "startBtn": "Start WeChat Integration", + "comingSoon": "WeChat integration coming soon", + "copied": "Command copied" + }, "server": { "title": "Server Configuration", "description": "Configure server host, port and debug settings", @@ -814,6 +861,10 @@ "rolexUpgrade": { "title": "RoleX (V2) Architecture Upgrade", "content": "Existing V2 roles need to be upgraded. Please activate Nuwa and ask her to upgrade and migrate your roles to continue using them." + }, + "updateV230": { + "title": "v2.3.0 Update Released", + "content": "🚀 Comprehensive RoleX improvements with organization operation bug fixes\n\n⚡ Added DeepSeek preset configuration, ready to use out of the box\n\n📂 New workspace feature with project file management\n\n💬 Feishu (Lark) integration for multi-platform access, similar to OpenClaw" } } } diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 4f30b7f7..67c20857 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -99,6 +99,53 @@ "copyUrl": "链接已复制", "qrHint": "扫码在手机或其他设备上访问" }, + "feishu": { + "title": "飞书接入", + "description": "连接飞书机器人,通过飞书消息与 PromptX 交互", + "appId": { + "label": "App ID", + "placeholder": "cli_xxxxxxxxxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "输入飞书应用的 App Secret" + }, + "encryptKey": { + "label": "Encrypt Key(可选)", + "placeholder": "事件验证密钥" + }, + "save": "保存配置", + "saving": "保存中...", + "saveSuccess": "飞书配置已保存", + "saveFailed": "保存飞书配置失败", + "testConnection": "测试连接", + "comingSoon": "飞书接入功能即将上线", + "connected": "已连接", + "disconnected": "未连接", + "configRequired": "请先填写 App ID 和 App Secret", + "startSuccess": "飞书机器人已启动", + "startFailed": "启动飞书机器人失败", + "stopSuccess": "飞书机器人已停止", + "stopFailed": "停止飞书机器人失败", + "remove": "断开并删除配置", + "removeSuccess": "飞书配置已删除", + "removeFailed": "删除飞书配置失败", + "guide": "前往飞书开放平台创建应用并获取凭证", + "guideLink": "飞书开放平台" + }, + "wechat": { + "title": "微信接入", + "description": "连接个人微信,通过微信消息与 PromptX 交互", + "installTitle": "1. 安装插件", + "installDesc": "首先需要安装 openclaw 微信插件", + "installCmd": "npx -y @tencent-weixin/openclaw-weixin-cli install", + "loginTitle": "2. 扫码登录", + "loginDesc": "运行以下命令,使用微信扫码授权登录", + "loginCmd": "openclaw channels login --channel openclaw-weixin", + "startBtn": "启动微信接入", + "comingSoon": "微信接入功能即将上线", + "copied": "命令已复制" + }, "server": { "title": "服务器配置", "description": "配置服务器主机、端口和调试设置", @@ -811,6 +858,10 @@ "rolexUpgrade": { "title": "RoleX(V2)架构升级", "content": "原有 V2 角色需要升级。请激活女娲,让女娲进行升级与迁移才能继续使用。" + }, + "updateV230": { + "title": "v2.3.0 版本更新", + "content": "🚀 全面优化 RoleX 功能,修复组织操作相关的 bug\n\n⚡ 新增 DeepSeek 预配置,开箱即用\n\n📂 新增工作区功能,支持项目文件管理\n\n💬 支持连接飞书,实现类似 OpenClaw 的多平台接入能力" } } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0bbd365c..bea82d77 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -16,6 +16,8 @@ import { AutoStartWindow } from '~/main/windows/AutoStartWindow' import { CognitionWindow } from '~/main/windows/CognitionWindow' import { agentXService } from '~/main/services/AgentXService' import { webAccessService } from '~/main/services/WebAccessService' +import { FeishuManager } from '~/main/services/feishu' +import { workspaceService } from '~/main/services/WorkspaceService' import * as logger from '@promptx/logger' import * as path from 'node:path' import * as fs from 'node:fs' @@ -32,6 +34,7 @@ class PromptXDesktopApp { private updateManager: UpdateManager | null = null private autoStartService: AutoStartService | null = null private autoStartWindow: AutoStartWindow | null = null + private feishuManager: FeishuManager | null = null async initialize(): Promise { // Capture console output to log file (covers @agentxjs/common runtime logs) @@ -72,6 +75,8 @@ class PromptXDesktopApp { this.setupShellIPC() this.setupAgentXIPC() this.setupWebAccessIPC() + this.setupFeishuIPC() + this.setupWorkspaceIPC() // Setup infrastructure logger.info('Setting up infrastructure...') @@ -692,6 +697,63 @@ class PromptXDesktopApp { }) } + private setupFeishuIPC(): void { + const dataDir = app.getPath('userData') + this.feishuManager = new FeishuManager(dataDir, agentXService.getPort()) + + ipcMain.handle('feishu:getConfig', async () => { + const saved = this.feishuManager!.loadConfig() + if (saved?.feishu) { + return saved.feishu + } + return null + }) + + ipcMain.handle('feishu:saveConfig', async (_, config: any) => { + try { + this.feishuManager!.saveConfig(config, { name: 'PromptX' }) + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + + ipcMain.handle('feishu:start', async (_, feishuConfig: any, roleConfig?: any) => { + try { + const role = roleConfig || { name: 'PromptX' } + await this.feishuManager!.start(feishuConfig, role) + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + + ipcMain.handle('feishu:stop', async () => { + try { + await this.feishuManager!.stop() + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + + ipcMain.handle('feishu:status', async () => { + return this.feishuManager!.getStatus() + }) + + ipcMain.handle('feishu:remove', async () => { + try { + await this.feishuManager!.remove() + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + + // 尝试恢复已保存的飞书连接 + this.feishuManager.restore().catch(() => {}) + } + private setupWebAccessIPC(): void { ipcMain.handle('webAccess:getStatus', () => { const last = webAccessService.getLastStatus() @@ -724,6 +786,70 @@ class PromptXDesktopApp { }) } + private setupWorkspaceIPC(): void { + ipcMain.handle('workspace:getFolders', async () => workspaceService.getFolders()) + + ipcMain.handle('workspace:addFolder', async (_, folderPath: string, name: string) => + workspaceService.addFolder(folderPath, name)) + + ipcMain.handle('workspace:removeFolder', async (_, id: string) => + workspaceService.removeFolder(id)) + + ipcMain.handle('workspace:pickFolder', async () => { + const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }) + if (result.canceled || !result.filePaths[0]) return null + const folderPath = result.filePaths[0] + const name = folderPath.split(/[/\\]/).filter(Boolean).pop() || 'workspace' + return { path: folderPath, name } + }) + + ipcMain.handle('workspace:listDir', async (_, dirPath: string) => + workspaceService.listDir(dirPath)) + + ipcMain.handle('workspace:readFile', async (_, filePath: string) => + workspaceService.readFile(filePath)) + + ipcMain.handle('workspace:readFileBase64', async (_, filePath: string) => + workspaceService.readFileBase64(filePath)) + + ipcMain.handle('workspace:writeFile', async (_, filePath: string, content: string) => + workspaceService.writeFile(filePath, content)) + + ipcMain.handle('workspace:createDir', async (_, dirPath: string) => + workspaceService.createDir(dirPath)) + + ipcMain.handle('workspace:deleteItem', async (_, itemPath: string) => + workspaceService.deleteItem(itemPath)) + + ipcMain.handle('system:checkGit', async () => { + if (process.platform !== 'win32') return { installed: true } + try { + const { execSync } = await import('node:child_process') + try { + execSync('git --version', { encoding: 'utf-8', timeout: 3000 }) + return { installed: true } + } catch { + // Try common Git installation paths on Windows + const commonPaths = [ + 'C:\\Program Files\\Git\\cmd\\git.exe', + 'C:\\Program Files (x86)\\Git\\cmd\\git.exe', + ] + for (const gitPath of commonPaths) { + try { + execSync(`"${gitPath}" --version`, { encoding: 'utf-8', timeout: 3000 }) + return { installed: true } + } catch { + // continue + } + } + return { installed: false } + } + } catch { + return { installed: false } + } + }) + } + private setupUpdateIPC(): void { // 检查更新 ipcMain.handle('check-for-updates', async () => { diff --git a/apps/desktop/src/main/services/AgentXService.ts b/apps/desktop/src/main/services/AgentXService.ts index ef030750..8717f811 100644 --- a/apps/desktop/src/main/services/AgentXService.ts +++ b/apps/desktop/src/main/services/AgentXService.ts @@ -1,4 +1,4 @@ -import { createAgentX, type AgentX, type Unsubscribe } from 'agentxjs' +import { createAgentX, LoggerFactoryImpl, type AgentX, type Unsubscribe } from 'agentxjs' import * as logger from '@promptx/logger' import * as path from 'node:path' import * as fs from 'node:fs' @@ -157,6 +157,8 @@ export class AgentXService { } async start(): Promise { + LoggerFactoryImpl.configure({ defaultLevel: 'warn' }) + if (this.isRunning) { logger.info('AgentX service is already running') return @@ -201,6 +203,20 @@ export class AgentXService { } } + // Add built-in mcp-workspace server (stdio) + const mcpWorkspacePath = this.getMcpWorkspacePath() + if (mcpWorkspacePath) { + const mcpCommand = process.env.PROMPTX_MAC_HELPER_PATH || process.execPath + mcpServers['mcp-workspace'] = { + command: mcpCommand, + args: [mcpWorkspacePath, '--transport', 'stdio'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + }, + } + } + // Add user-configured MCP servers if (this.config.mcpServers) { for (const server of this.config.mcpServers) { @@ -401,6 +417,33 @@ export class AgentXService { } } + /** + * Get the path to mcp-workspace server (mcp-server.js entry) + */ + private getMcpWorkspacePath(): string { + const devPath = path.join(__dirname, '../../../../packages/mcp-workspace/dist/mcp-server.js') + const prodPath = path.join(process.resourcesPath || '', 'mcp-workspace/mcp-server.js') + + if (fs.existsSync(devPath)) { + return devPath + } + if (fs.existsSync(prodPath)) { + return prodPath + } + + const nodeModulesPath = path.join(__dirname, '../../../node_modules/@promptx/mcp-workspace/dist/mcp-server.js') + if (fs.existsSync(nodeModulesPath)) { + return nodeModulesPath + } + + try { + return require.resolve('@promptx/mcp-workspace/mcp-server') + } catch { + logger.warn('MCP Workspace server not found, workspace file access will not be available') + return '' + } + } + getPort(): number { return this.port } @@ -455,6 +498,19 @@ export class AgentXService { }) } + // 添加内置的 mcp-workspace 服务器 + const mcpWorkspacePath = this.getMcpWorkspacePath() + if (mcpWorkspacePath) { + servers.push({ + name: 'mcp-workspace', + command: 'node', + args: [mcpWorkspacePath, '--transport', 'stdio'], + enabled: true, + builtin: true, + description: 'Workspace file explorer (Browse, read, write local files)', + }) + } + // 添加用户配置的服务器 if (this.config.mcpServers) { servers.push(...this.config.mcpServers) diff --git a/apps/desktop/src/main/services/WorkspaceService.ts b/apps/desktop/src/main/services/WorkspaceService.ts new file mode 100644 index 00000000..d975a460 --- /dev/null +++ b/apps/desktop/src/main/services/WorkspaceService.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { homedir } from 'node:os' +import { randomUUID } from 'node:crypto' + +export interface WorkspaceFolder { + id: string + name: string + path: string + added_at: string +} + +interface WorkspaceFoldersConfig { + folders: WorkspaceFolder[] +} + +export class WorkspaceService { + private configPath: string + + constructor() { + this.configPath = path.join(homedir(), '.promptx', 'workspaces.json') + } + + async getFolders(): Promise { + try { + const raw = await fs.readFile(this.configPath, 'utf-8') + const cfg: WorkspaceFoldersConfig = JSON.parse(raw) + return cfg.folders || [] + } catch { + return [] + } + } + + async addFolder(folderPath: string, name: string): Promise { + const folders = await this.getFolders() + const folder: WorkspaceFolder = { id: randomUUID(), name, path: folderPath, added_at: new Date().toISOString() } + folders.push(folder) + await this.saveFolders(folders) + return folder + } + + async removeFolder(id: string): Promise { + const folders = await this.getFolders() + await this.saveFolders(folders.filter(f => f.id !== id)) + } + + async listDir(dirPath: string): Promise { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const IGNORE = new Set(['node_modules', '.git', '.DS_Store', 'Thumbs.db', 'dist', '.next', '__pycache__']) + const result: DirEntry[] = [] + for (const e of entries) { + if (IGNORE.has(e.name)) continue + try { + const stat = await fs.stat(path.join(dirPath, e.name)) + result.push({ + name: e.name, + path: path.join(dirPath, e.name), + is_dir: e.isDirectory(), + size: stat.isFile() ? stat.size : 0, + modified: stat.mtime.toISOString(), + }) + } catch { /* skip inaccessible */ } + } + result.sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + return result + } + + async readFile(filePath: string): Promise { + const MAX = 512 * 1024 // 512KB + const buf = await fs.readFile(filePath) + if (buf.length > MAX) return buf.subarray(0, MAX).toString('utf-8') + '\n\n[文件已截断]' + return buf.toString('utf-8') + } + + async readFileBase64(filePath: string): Promise { + const buf = await fs.readFile(filePath) + return buf.toString('base64') + } + + async writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, content, 'utf-8') + } + + async createDir(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }) + } + + async deleteItem(itemPath: string): Promise { + await fs.rm(itemPath, { recursive: true, force: true }) + } + + private async saveFolders(folders: WorkspaceFolder[]): Promise { + await fs.mkdir(path.dirname(this.configPath), { recursive: true }) + await fs.writeFile(this.configPath, JSON.stringify({ folders }, null, 2), 'utf-8') + } +} + +export interface DirEntry { + name: string + path: string + is_dir: boolean + size: number + modified: string | null +} + +export const workspaceService = new WorkspaceService() diff --git a/apps/desktop/src/main/services/feishu/FeishuBot.ts b/apps/desktop/src/main/services/feishu/FeishuBot.ts new file mode 100644 index 00000000..7c7432e1 --- /dev/null +++ b/apps/desktop/src/main/services/feishu/FeishuBot.ts @@ -0,0 +1,214 @@ +/** + * 飞书 WebSocket Bot + * + * 使用飞书官方长连接模式,无需公网 IP。 + * 依赖 @larksuiteoapi/node-sdk 提供的 ws 客户端。 + */ + +import * as logger from '@promptx/logger' + +let larkModule: any = null +async function getLark() { + if (larkModule) return larkModule + try { + larkModule = await import('@larksuiteoapi/node-sdk') + } catch { + larkModule = null + } + return larkModule +} + +export interface FeishuConfig { + appId: string + appSecret: string + encryptKey?: string +} + +export interface FeishuInboundMessage { + messageId: string + chatId: string + senderId: string + content: string | { type: 'image'; data: string; mediaType: string } + chatType: string +} + +export class FeishuBot { + private config: FeishuConfig + private client: any = null + private wsClient: any = null + private running = false + private onMessage: ((msg: FeishuInboundMessage) => void) | null = null + + constructor(config: FeishuConfig) { + this.config = config + } + + async start(onMessage: (msg: FeishuInboundMessage) => void) { + const lark = await getLark() + if (!lark) { + throw new Error('@larksuiteoapi/node-sdk 未安装') + } + logger.info('[FeishuBot] lark module keys:', Object.keys(lark)) + logger.info('[FeishuBot] lark.Client:', typeof lark.Client) + logger.info('[FeishuBot] lark.default:', typeof lark.default) + logger.info('[FeishuBot] lark.EventDispatcher:', typeof lark.EventDispatcher) + logger.info('[FeishuBot] lark.WSClient:', typeof lark.WSClient) + + // Handle CJS/ESM interop — exports may be on .default + const sdk = lark.Client ? lark : lark.default || lark + + if (!sdk.Client || !sdk.EventDispatcher || !sdk.WSClient) { + throw new Error('@larksuiteoapi/node-sdk 模块结构异常,无法找到 Client/EventDispatcher/WSClient') + } + + if (!this.config.appId || !this.config.appSecret) { + throw new Error('飞书 appId 和 appSecret 不能为空') + } + + this.onMessage = onMessage + this.running = true + + this.client = new sdk.Client({ + appId: this.config.appId, + appSecret: this.config.appSecret, + loggerLevel: sdk.LoggerLevel?.error ?? 4, + }) + + const eventDispatcher = new sdk.EventDispatcher({ + encryptKey: this.config.encryptKey || '', + }).register({ + 'im.message.receive_v1': (data: any) => this.handleIncoming(data), + }) + + logger.info('[FeishuBot] EventDispatcher created and registered') + + this.wsClient = new sdk.WSClient({ + appId: this.config.appId, + appSecret: this.config.appSecret, + loggerLevel: sdk.LoggerLevel?.error ?? 4, + }) + + logger.info('[FeishuBot] WSClient created, starting...') + this.wsClient.start({ eventDispatcher }).catch((err: any) => { + if (this.running) { + logger.error('[FeishuBot] WebSocket error:', err.message) + } + }) + + logger.info('[FeishuBot] Started, appId:', this.config.appId) + } + + async stop() { + this.running = false + try { + this.wsClient?.close?.({ force: true }) + } catch { /* ignore */ } + this.client = null + this.wsClient = null + logger.info('[FeishuBot] Stopped') + } + + async sendText(chatId: string, text: string) { + if (!this.client) return + const receiveIdType = chatId.startsWith('oc_') ? 'chat_id' : 'open_id' + try { + const res = await this.client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text }), + }, + }) + if (res.code !== 0) { + logger.warn('[FeishuBot] Send failed:', res.code, res.msg) + } + } catch (err: any) { + logger.error('[FeishuBot] sendText error:', err.message) + } + } + + async addReaction(messageId: string, emojiType = 'THUMBSUP') { + if (!this.client) return + try { + await this.client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { reaction_type: { emoji_type: emojiType } }, + }) + } catch { /* ignore */ } + } + + private async handleIncoming(data: any) { + logger.info('[FeishuBot] handleIncoming called, raw data keys:', Object.keys(data || {})) + logger.info('[FeishuBot] handleIncoming data:', JSON.stringify(data, null, 2).slice(0, 1000)) + + if (!this.running || !this.onMessage) { + logger.warn('[FeishuBot] handleIncoming skipped: running=', this.running, 'onMessage=', !!this.onMessage) + return + } + + const msg = data.message + const sender = data.sender + logger.info('[FeishuBot] sender:', JSON.stringify(sender)) + logger.info('[FeishuBot] message:', JSON.stringify(msg).slice(0, 500)) + + if (sender?.sender_type === 'bot') { + logger.info('[FeishuBot] Skipping bot message') + return + } + + const senderId = sender?.sender_id?.open_id ?? 'unknown' + const chatId = msg.chat_id + const msgType = msg.message_type + logger.info(`[FeishuBot] chatId=${chatId}, msgType=${msgType}, senderId=${senderId}`) + + let content: string | { type: 'image'; data: string; mediaType: string } + try { + const parsed = JSON.parse(msg.content || '{}') + logger.info('[FeishuBot] parsed content:', JSON.stringify(parsed)) + if (msgType === 'text') { + const text = parsed.text || '' + if (!text.trim()) { + logger.info('[FeishuBot] Skipping empty text') + return + } + content = text + } else if (msgType === 'image') { + const fileKey = parsed.image_key + if (!fileKey || !this.client) return + try { + const res = await this.client.im.messageResource.get({ + params: { type: 'image' }, + path: { message_id: msg.message_id, file_key: fileKey }, + }) + const stream = res.getReadableStream() + const chunks: Buffer[] = [] + await new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', resolve) + stream.on('error', reject) + }) + const base64 = Buffer.concat(chunks).toString('base64') + content = { type: 'image', data: base64, mediaType: 'image/jpeg' } + } catch (err: any) { + logger.warn(`[FeishuBot] Failed to download image ${fileKey}:`, err.message) + return + } + } else { + return + } + } catch (parseErr) { + logger.error('[FeishuBot] Failed to parse message content:', String(parseErr)) + return + } + + logger.info(`[FeishuBot] Dispatching to onMessage: chatId=${chatId}, contentType=${typeof content}`) + this.onMessage({ + messageId: msg.message_id, + chatId, + senderId, + content, + chatType: msg.chat_type, + }) + } +} diff --git a/apps/desktop/src/main/services/feishu/FeishuBridge.ts b/apps/desktop/src/main/services/feishu/FeishuBridge.ts new file mode 100644 index 00000000..0a729e5f --- /dev/null +++ b/apps/desktop/src/main/services/feishu/FeishuBridge.ts @@ -0,0 +1,121 @@ +/** + * 飞书 ↔ agentx 消息桥接 + * + * 收到飞书消息 → 调用 agentx message_send_request + * 用 text_delta 累积回复文本 + * conversation_end 时把完整回复发回飞书 + */ + +import * as logger from '@promptx/logger' +import type { AgentX } from 'agentxjs' +import type { FeishuBot, FeishuInboundMessage } from './FeishuBot' +import type { FeishuSessionManager, RoleConfig } from './FeishuSessionManager' + +export class FeishuBridge { + private agentx: AgentX + private bot: FeishuBot + private sessionManager: FeishuSessionManager + private roleConfig: RoleConfig + private pendingReply = new Map() + private unsubscribes: Array<() => void> = [] + + constructor(agentx: AgentX, bot: FeishuBot, sessionManager: FeishuSessionManager, roleConfig: RoleConfig) { + this.agentx = agentx + this.bot = bot + this.sessionManager = sessionManager + this.roleConfig = roleConfig + this.setupListeners() + } + + async handleFeishuMessage(msg: FeishuInboundMessage) { + const preview = typeof msg.content === 'string' ? msg.content.slice(0, 50) : '[image]' + logger.info(`[FeishuBridge] ← Feishu [${msg.chatId}]: ${preview}`) + + let agentxContent: any + if (typeof msg.content === 'object' && msg.content.type === 'image') { + agentxContent = [ + { type: 'image', data: msg.content.data, mediaType: msg.content.mediaType }, + ] + } else { + agentxContent = msg.content + } + + try { + logger.info(`[FeishuBridge] Getting/creating session for chatId=${msg.chatId}`) + const imageId = await this.sessionManager.getOrCreate( + msg.chatId, + this.agentx, + this.roleConfig, + ) + logger.info(`[FeishuBridge] Session ready, imageId=${imageId}`) + + logger.info(`[FeishuBridge] Sending image_run_request, imageId=${imageId}`) + await this.agentx.request('image_run_request' as any, { imageId }).catch((e: any) => { + logger.warn(`[FeishuBridge] image_run_request failed (may be already running):`, e?.message) + }) + + logger.info(`[FeishuBridge] Sending message_send_request, imageId=${imageId}, contentType=${typeof agentxContent}`) + await this.agentx.request('message_send_request' as any, { + imageId, + content: agentxContent, + }) + logger.info(`[FeishuBridge] message_send_request completed`) + + await this.bot.addReaction(msg.messageId, 'THUMBSUP').catch(() => {}) + } catch (err: any) { + logger.error('[FeishuBridge] Failed to forward message:', err.message, err.stack) + } + } + + destroy() { + this.pendingReply.clear() + for (const unsub of this.unsubscribes) { + try { unsub() } catch { /* ignore */ } + } + this.unsubscribes = [] + } + + private setupListeners() { + const unsubDelta = this.agentx.on('text_delta' as any, (e: any) => { + const imageId = e.context?.imageId + if (!imageId) return + const text = e.data?.text + if (!text) return + const existing = this.pendingReply.get(imageId) ?? '' + this.pendingReply.set(imageId, existing + text) + if (!existing) { + logger.info(`[FeishuBridge] First text_delta for imageId=${imageId}: "${text.slice(0, 50)}"`) + } + }) + if (unsubDelta) this.unsubscribes.push(unsubDelta as any) + + const unsubEnd = this.agentx.on('conversation_end' as any, async (e: any) => { + const imageId = e.context?.imageId + logger.info(`[FeishuBridge] conversation_end event, imageId=${imageId}, event keys=${Object.keys(e || {})}`) + if (!imageId) return + + const chatId = this.sessionManager.getChatId(imageId) + logger.info(`[FeishuBridge] conversation_end: chatId=${chatId} for imageId=${imageId}`) + if (!chatId) return + + const text = this.pendingReply.get(imageId) + this.pendingReply.delete(imageId) + + if (!text) { + logger.warn(`[FeishuBridge] conversation_end but no reply for imageId=${imageId}`) + return + } + + logger.info(`[FeishuBridge] → Feishu [${chatId}]: (${text.length} chars) ${text.slice(0, 100)}...`) + try { + await this.bot.sendText(chatId, text) + logger.info(`[FeishuBridge] sendText completed for chatId=${chatId}`) + } catch (err: any) { + logger.error(`[FeishuBridge] sendText failed:`, err.message) + } + }) + if (unsubEnd) this.unsubscribes.push(unsubEnd as any) + + logger.info('[FeishuBridge] Listeners registered') + } +} diff --git a/apps/desktop/src/main/services/feishu/FeishuManager.ts b/apps/desktop/src/main/services/feishu/FeishuManager.ts new file mode 100644 index 00000000..f6b6647c --- /dev/null +++ b/apps/desktop/src/main/services/feishu/FeishuManager.ts @@ -0,0 +1,163 @@ +/** + * 飞书模块入口 + * + * 管理飞书 Bot 实例生命周期。 + * 配置持久化在 {dataDir}/feishu-config.json。 + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as logger from '@promptx/logger' +import { createAgentX, type AgentX } from 'agentxjs' +import { FeishuBot, type FeishuConfig } from './FeishuBot' +import { FeishuBridge } from './FeishuBridge' +import { FeishuSessionManager, type RoleConfig } from './FeishuSessionManager' + +export interface FeishuSavedConfig { + feishu: FeishuConfig + role: RoleConfig +} + +export interface FeishuStatus { + connected: boolean + appId?: string + error?: string +} + +export class FeishuManager { + private configFile: string + private agentxPort: number + private bot: FeishuBot | null = null + private bridge: FeishuBridge | null = null + private sessionManager: FeishuSessionManager | null = null + private remoteAgentx: AgentX | null = null + private _connected = false + private _error: string | null = null + private _appId: string | null = null + + constructor(dataDir: string, agentxPort: number = 5200) { + this.configFile = path.join(dataDir, 'feishu-config.json') + this.agentxPort = agentxPort + } + + /** + * 启动飞书 Bot + */ + async start(feishuConfig: FeishuConfig, roleConfig: RoleConfig): Promise { + await this.stop() + + // 连接到本地 AgentX WebSocket 服务 + this.remoteAgentx = await createAgentX({ + serverUrl: `ws://127.0.0.1:${this.agentxPort}`, + }) + + this.bot = new FeishuBot(feishuConfig) + this.sessionManager = new FeishuSessionManager() + this.bridge = new FeishuBridge(this.remoteAgentx, this.bot, this.sessionManager, roleConfig) + + await this.bot.start((msg) => this.bridge!.handleFeishuMessage(msg)) + + this._connected = true + this._error = null + this._appId = feishuConfig.appId + + // 持久化配置 + this.saveConfig(feishuConfig, roleConfig) + + logger.info('[FeishuManager] Started') + } + + /** + * 停止飞书 Bot + */ + async stop(): Promise { + if (this.bridge) { + this.bridge.destroy() + this.bridge = null + } + if (this.sessionManager) { + this.sessionManager.clear() + this.sessionManager = null + } + if (this.bot) { + await this.bot.stop() + this.bot = null + } + if (this.remoteAgentx) { + try { + await (this.remoteAgentx as any).close?.() + } catch { /* ignore */ } + this.remoteAgentx = null + } + this._connected = false + this._error = null + logger.info('[FeishuManager] Stopped') + } + + /** + * 停止并删除配置 + */ + async remove(): Promise { + await this.stop() + this._appId = null + try { + if (fs.existsSync(this.configFile)) { + fs.unlinkSync(this.configFile) + } + } catch (err: any) { + logger.warn('[FeishuManager] Failed to remove config:', err.message) + } + } + + /** + * 启动时恢复已保存的连接 + */ + async restore(): Promise { + const saved = this.loadConfig() + if (!saved?.feishu?.appId) return + try { + await this.start(saved.feishu, saved.role) + logger.info('[FeishuManager] Restored connection') + } catch (err: any) { + this._error = err.message + logger.warn('[FeishuManager] Failed to restore:', err.message) + } + } + + getStatus(): FeishuStatus { + return { + connected: this._connected, + appId: this._appId || undefined, + error: this._error || undefined, + } + } + + isConnected(): boolean { + return this._connected + } + + // ---------- 配置持久化 ---------- + + loadConfig(): FeishuSavedConfig | null { + try { + logger.info(`[FeishuManager] loadConfig from: ${this.configFile}`) + if (fs.existsSync(this.configFile)) { + const raw = fs.readFileSync(this.configFile, 'utf-8') + const data = JSON.parse(raw) + logger.info(`[FeishuManager] loadConfig success, appId=${data?.feishu?.appId || 'N/A'}`) + return data + } + logger.info('[FeishuManager] Config file does not exist') + } catch (err: any) { + logger.error('[FeishuManager] Failed to load config:', err.message) + } + return null + } + + saveConfig(feishuConfig: FeishuConfig, roleConfig: RoleConfig): void { + logger.info(`[FeishuManager] saveConfig to: ${this.configFile}, appId=${feishuConfig?.appId}`) + const data = { feishu: feishuConfig, role: roleConfig } + fs.writeFileSync(this.configFile, JSON.stringify(data, null, 2), 'utf-8') + logger.info(`[FeishuManager] saveConfig success`) + } +} diff --git a/apps/desktop/src/main/services/feishu/FeishuSessionManager.ts b/apps/desktop/src/main/services/feishu/FeishuSessionManager.ts new file mode 100644 index 00000000..5e4ffd02 --- /dev/null +++ b/apps/desktop/src/main/services/feishu/FeishuSessionManager.ts @@ -0,0 +1,68 @@ +/** + * 飞书会话管理 + * + * 维护 飞书 chat_id ↔ agentx imageId 的双向映射。 + * 每个飞书群/私聊对应一个 agentx 对话(image)。 + */ + +import * as logger from '@promptx/logger' +import type { AgentX } from 'agentxjs' + +export interface RoleConfig { + name: string + systemPrompt?: string + mcpServers?: Record + disallowedTools?: string[] + tools?: unknown[] +} + +export class FeishuSessionManager { + private chatToImage = new Map() + private imageToChat = new Map() + + async getOrCreate(chatId: string, agentx: AgentX, roleConfig: RoleConfig): Promise { + const existing = this.chatToImage.get(chatId) + if (existing) return existing + + logger.info(`[FeishuSession] Creating conversation for chatId=${chatId}, role=${roleConfig.name}`) + + const imageConfig: Record = { + name: `${roleConfig.name}_feishu_${Date.now()}`, + description: `飞书接入 - ${roleConfig.name}`, + } + if (roleConfig.systemPrompt) imageConfig.systemPrompt = roleConfig.systemPrompt + if (roleConfig.mcpServers) imageConfig.mcpServers = roleConfig.mcpServers + if (roleConfig.disallowedTools?.length) imageConfig.disallowedTools = roleConfig.disallowedTools + if (roleConfig.tools) imageConfig.tools = roleConfig.tools + + const containerId = `feishu_promptx` + logger.info(`[FeishuSession] Calling image_create_request, containerId=${containerId}, config=`, JSON.stringify(imageConfig)) + const result = await agentx.request('image_create_request' as any, { containerId, config: imageConfig }) as any + logger.info(`[FeishuSession] image_create_request result:`, JSON.stringify(result).slice(0, 500)) + const imageId = result?.data?.record?.imageId + + if (!imageId) { + logger.error('[FeishuSession] No imageId in result:', JSON.stringify(result)) + throw new Error('创建 agentx 对话失败') + } + + logger.info(`[FeishuSession] Calling image_run_request, imageId=${imageId}`) + await agentx.request('image_run_request' as any, { imageId }) + + this.chatToImage.set(chatId, imageId) + this.imageToChat.set(imageId, chatId) + + logger.info(`[FeishuSession] Mapped chatId=${chatId} → imageId=${imageId}`) + return imageId + } + + getChatId(imageId: string): string | undefined { + return this.imageToChat.get(imageId) + } + + clear() { + this.chatToImage.clear() + this.imageToChat.clear() + logger.info('[FeishuSession] All sessions cleared') + } +} diff --git a/apps/desktop/src/main/services/feishu/index.ts b/apps/desktop/src/main/services/feishu/index.ts new file mode 100644 index 00000000..0a2d8b4a --- /dev/null +++ b/apps/desktop/src/main/services/feishu/index.ts @@ -0,0 +1,4 @@ +export { FeishuManager } from './FeishuManager' +export type { FeishuConfig } from './FeishuBot' +export type { RoleConfig } from './FeishuSessionManager' +export type { FeishuSavedConfig, FeishuStatus } from './FeishuManager' diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8a6d004d..d96e7713 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -40,12 +40,25 @@ interface AgentXConfig { } interface OpenDialogOptions { - title?: string defaultPath?: string filters?: { name: string; extensions: string[] }[] properties?: ('openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles')[] } +interface WorkspaceFolder { + id: string + name: string + path: string +} + +interface DirEntry { + name: string + path: string + is_dir: boolean + size: number + modified: string | null +} + interface OpenDialogResult { canceled: boolean filePaths: string[] @@ -115,6 +128,23 @@ interface ElectronAPI { shell: { openExternal: (url: string) => Promise } + // Workspace API + workspace: { + getFolders: () => Promise + addFolder: (path: string, name: string) => Promise + removeFolder: (id: string) => Promise + pickFolder: () => Promise<{ path: string; name: string } | null> + listDir: (dirPath: string) => Promise + readFile: (filePath: string) => Promise + readFileBase64: (filePath: string) => Promise + writeFile: (filePath: string, content: string) => Promise + createDir: (dirPath: string) => Promise + deleteItem: (itemPath: string) => Promise + } + // System API + system: { + checkGit: () => Promise<{ installed: boolean }> + } // System info platform: string } @@ -176,6 +206,23 @@ contextBridge.exposeInMainWorld('electronAPI', { shell: { openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), }, + // Workspace API + workspace: { + getFolders: () => ipcRenderer.invoke('workspace:getFolders'), + addFolder: (path: string, name: string) => ipcRenderer.invoke('workspace:addFolder', path, name), + removeFolder: (id: string) => ipcRenderer.invoke('workspace:removeFolder', id), + pickFolder: () => ipcRenderer.invoke('workspace:pickFolder'), + listDir: (dirPath: string) => ipcRenderer.invoke('workspace:listDir', dirPath), + readFile: (filePath: string) => ipcRenderer.invoke('workspace:readFile', filePath), + readFileBase64: (filePath: string) => ipcRenderer.invoke('workspace:readFileBase64', filePath), + writeFile: (filePath: string, content: string) => ipcRenderer.invoke('workspace:writeFile', filePath, content), + createDir: (dirPath: string) => ipcRenderer.invoke('workspace:createDir', dirPath), + deleteItem: (itemPath: string) => ipcRenderer.invoke('workspace:deleteItem', itemPath), + }, + // System API + system: { + checkGit: () => ipcRenderer.invoke('system:checkGit'), + }, // System info platform: process.platform, } as ElectronAPI) diff --git a/apps/desktop/src/view/components/agentx-ui/components/container/AgentList.tsx b/apps/desktop/src/view/components/agentx-ui/components/container/AgentList.tsx index cd4e931c..6715e344 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/container/AgentList.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/container/AgentList.tsx @@ -143,52 +143,63 @@ export function AgentList({ // First message cache for each image const [firstMessages, setFirstMessages] = React.useState>({}); + const firstMessagesCacheRef = React.useRef>({}); + const fetchingRef = React.useRef>(new Set()); // Filter out ... tags from text const filterFilePathTags = (text: string): string => { return text.replace(/[^<]*<\/file>\s*/g, '').trim(); }; - // Fetch first message for each image + // Fetch first message for a single image + const fetchFirstMessage = React.useCallback(async (imageId: string) => { + if (!agentx || firstMessagesCacheRef.current[imageId] || fetchingRef.current.has(imageId)) return; + fetchingRef.current.add(imageId); + try { + const response = await agentx.request("image_messages_request", { imageId }); + const messages = response.data?.messages || []; + const firstUserMsg = messages.find((m: any) => m.role === "user"); + if (firstUserMsg?.content) { + let textContent = Array.isArray(firstUserMsg.content) + ? firstUserMsg.content.find((c: any) => c.type === "text")?.text || "" + : typeof firstUserMsg.content === "string" ? firstUserMsg.content : ""; + textContent = filterFilePathTags(textContent); + if (textContent) { + const preview = textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""); + firstMessagesCacheRef.current[imageId] = preview; + setFirstMessages(prev => ({ ...prev, [imageId]: preview })); + } + } + } catch { + // Ignore errors + } finally { + fetchingRef.current.delete(imageId); + } + }, [agentx]); + + // Lazy-load first messages in batches (concurrency = 3) after initial render React.useEffect(() => { if (!agentx || images.length === 0) return; - const fetchFirstMessages = async () => { - const newFirstMessages: Record = {}; + let cancelled = false; + const BATCH_SIZE = 3; - for (const img of images) { - // Skip if already cached - if (firstMessages[img.imageId]) { - newFirstMessages[img.imageId] = firstMessages[img.imageId]; - continue; - } - - try { - const response = await agentx.request("image_messages_request", { imageId: img.imageId }); - const messages = response.data?.messages || []; - // Find first user message - const firstUserMsg = messages.find((m: any) => m.role === "user"); - if (firstUserMsg?.content) { - // Extract text content - let textContent = Array.isArray(firstUserMsg.content) - ? firstUserMsg.content.find((c: any) => c.type === "text")?.text || "" - : typeof firstUserMsg.content === "string" ? firstUserMsg.content : ""; - // Filter out file path tags - textContent = filterFilePathTags(textContent); - if (textContent) { - newFirstMessages[img.imageId] = textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""); - } - } - } catch (error) { - // Ignore errors, just don't show first message - } + const loadInBatches = async () => { + const uncached = images.filter(img => !firstMessagesCacheRef.current[img.imageId]); + for (let i = 0; i < uncached.length; i += BATCH_SIZE) { + if (cancelled) break; + const batch = uncached.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(img => fetchFirstMessage(img.imageId))); } - - setFirstMessages(prev => ({ ...prev, ...newFirstMessages })); }; - fetchFirstMessages(); - }, [agentx, images]); + // Defer to avoid blocking initial render + const timer = setTimeout(loadInBatches, 100); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [agentx, images, fetchFirstMessage]); // Map images to ListPaneItem[] const items: ListPaneItem[] = React.useMemo(() => { diff --git a/apps/desktop/src/view/components/agentx-ui/components/container/Chat.tsx b/apps/desktop/src/view/components/agentx-ui/components/container/Chat.tsx index 17b746bb..7e689951 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/container/Chat.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/container/Chat.tsx @@ -169,6 +169,31 @@ export function Chat({ const [droppedFiles, setDroppedFiles] = React.useState(undefined); const dragCounterRef = React.useRef(0); + // Workspace panel file drag state + const [wsIsDragging, setWsIsDragging] = React.useState(false); + const [droppedWorkspacePaths, setDroppedWorkspacePaths] = React.useState(); + + // Listen to workspace file drag custom events from FileTree + React.useEffect(() => { + const onWsDragStart = () => setWsIsDragging(true); + const onWsMouseUp = () => setWsIsDragging(false); + const onWsDrop = (e: Event) => { + const detail = (e as CustomEvent).detail as { path: string; name: string; isImage: boolean }; + if (detail?.path) { + setDroppedWorkspacePaths([detail.path]); + } + }; + + document.addEventListener("ws-file-drag-start", onWsDragStart); + document.addEventListener("mouseup", onWsMouseUp); + document.addEventListener("ws-file-drag-drop", onWsDrop); + return () => { + document.removeEventListener("ws-file-drag-start", onWsDragStart); + document.removeEventListener("mouseup", onWsMouseUp); + document.removeEventListener("ws-file-drag-drop", onWsDrop); + }; + }, []); + // Handle drag events for full-area drop zone const handleDragEnter = React.useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -210,6 +235,10 @@ export function Chat({ setDroppedFiles(undefined); }, []); + const handleDroppedWorkspacePathsProcessed = React.useCallback(() => { + setDroppedWorkspacePaths(undefined); + }, []); + // Toolbar items const toolbarItems: ToolBarItem[] = React.useMemo( () => [ @@ -274,11 +303,13 @@ export function Chat({ onToolbarItemClick={handleToolbarClick} droppedFiles={droppedFiles} onDroppedFilesProcessed={handleDroppedFilesProcessed} + droppedWorkspacePaths={droppedWorkspacePaths} + onDroppedWorkspacePathsProcessed={handleDroppedWorkspacePathsProcessed} /> {/* Full-area drop overlay - dark mask style */} - {isDragging && ( + {(isDragging || wsIsDragging) && (
{ window.electronAPI?.invoke("server-config:get").then((config: any) => { if (config?.enableV2) { @@ -86,6 +87,12 @@ export function WelcomePage({ }).catch(() => { // Ignore errors, default to false }); + + window.electronAPI?.system?.checkGit().then((result: { installed: boolean }) => { + setGitInstalled(result.installed); + }).catch(() => { + setGitInstalled(true); // Assume installed on error + }); }, []); const tagline = t("agentxUI.welcome.tagline"); @@ -163,6 +170,25 @@ export function WelcomePage({ return (
+ {/* Git warning banner - only on Windows when Git not installed */} + {!gitInstalled && window.electronAPI?.platform === 'win32' && ( +
+ +
+

+ AgentX 在 Windows 上需要安装 Git。 +

+
+ +
+ )} + {/* Main content - centered */}
{/* Logo */} diff --git a/apps/desktop/src/view/components/agentx-ui/components/pane/InputPane.tsx b/apps/desktop/src/view/components/agentx-ui/components/pane/InputPane.tsx index 42e24ba0..e6280873 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/pane/InputPane.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/pane/InputPane.tsx @@ -135,6 +135,14 @@ export interface InputPaneProps { * Callback when dropped files have been processed */ onDroppedFilesProcessed?: () => void; + /** + * Workspace file paths dropped from workspace panel + */ + droppedWorkspacePaths?: string[]; + /** + * Callback when dropped workspace paths have been processed + */ + onDroppedWorkspacePathsProcessed?: () => void; } /** @@ -188,6 +196,8 @@ export const InputPane: React.ForwardRefExoticComponent< acceptAllFileTypes = true, droppedFiles, onDroppedFilesProcessed, + droppedWorkspacePaths, + onDroppedWorkspacePathsProcessed, }, ref ) => { @@ -518,6 +528,18 @@ export const InputPane: React.ForwardRefExoticComponent< } }, [droppedFiles, onDroppedFilesProcessed]); + // Stable ref for addFilesFromPaths + const addFilesFromPathsRef = React.useRef(addFilesFromPaths); + addFilesFromPathsRef.current = addFilesFromPaths; + + // Process workspace file paths dragged from workspace panel + React.useEffect(() => { + if (droppedWorkspacePaths && droppedWorkspacePaths.length > 0) { + addFilesFromPathsRef.current(droppedWorkspacePaths); + onDroppedWorkspacePathsProcessed?.(); + } + }, [droppedWorkspacePaths, onDroppedWorkspacePathsProcessed]); + /** * Handle emoji select */ diff --git a/apps/desktop/src/view/components/agentx-ui/components/studio/Studio.tsx b/apps/desktop/src/view/components/agentx-ui/components/studio/Studio.tsx index 14cbb37c..cb6d5dc5 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/studio/Studio.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/studio/Studio.tsx @@ -37,7 +37,7 @@ import * as React from "react"; import type { AgentX } from "agentxjs"; -import { ChevronsRight } from "lucide-react"; +import { ChevronsRight, FolderOpen } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AgentList } from "@/components/agentx-ui/components/container/AgentList"; import { Chat } from "@/components/agentx-ui/components/container/Chat"; @@ -45,6 +45,9 @@ import { WelcomePage } from "@/components/agentx-ui/components/container/Welcome import { ToastContainer, useToast } from "@/components/agentx-ui/components/element/Toast"; import { useImages } from "@/components/agentx-ui/hooks"; import { cn } from "@/components/agentx-ui/utils"; +import { WorkspacePanel } from "@/components/agentx-ui/components/workspace/WorkspacePanel"; +import { WorkspaceExplorerAdapter } from "@/components/agentx-ui/components/workspace/WorkspaceExplorerAdapter"; +import type { WorkspacePanelPlugin } from "@/components/agentx-ui/components/workspace/types"; export interface StudioProps { /** @@ -108,6 +111,8 @@ export function Studio({ const [visitedImages, setVisitedImages] = React.useState>(new Map()); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); const [refreshTrigger, setRefreshTrigger] = React.useState(0); + const [workspacePanelOpen, setWorkspacePanelOpen] = React.useState(true); + const [workspaceActiveTab, setWorkspaceActiveTab] = React.useState("explorer"); // Toast state const { toasts, showToast, dismissToast } = useToast(); @@ -225,6 +230,16 @@ export function Studio({ }; }, [agentx, showToast]); + const workspacePlugins = React.useMemo(() => [ + { + id: "explorer", + label: "文件", + icon: , + order: 1, + component: WorkspaceExplorerAdapter, + } + ], []); + return (
{/* Sidebar - AgentList or Collapsed Button */} @@ -263,23 +278,51 @@ export function Studio({ )} {/* Main area - WelcomePage or Chat */} -
- {!currentImageId && } - {Array.from(visitedImages.entries()).map(([imageId, imageName]) => ( -
- { pendingMessagesRef.current.delete(imageId); }} - /> -
- ))} +
+ {/* Toolbar with workspace toggle */} +
+ +
+ {/* Original main content */} +
+ {!currentImageId && } + {Array.from(visitedImages.entries()).map(([imageId, imageName]) => ( +
+ { pendingMessagesRef.current.delete(imageId); }} + /> +
+ ))} +
+ {/* Workspace panel on the right */} + setWorkspacePanelOpen(false)} + plugins={workspacePlugins} + activeTabId={workspaceActiveTab} + onTabChange={setWorkspaceActiveTab} + /> + {/* Toast notifications */}
diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/FileTree.tsx b/apps/desktop/src/view/components/agentx-ui/components/workspace/FileTree.tsx new file mode 100644 index 00000000..e8c5b24c --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/FileTree.tsx @@ -0,0 +1,296 @@ +import * as React from "react"; +import { useState, useCallback, useRef } from "react"; +import { + ChevronRight, + ChevronDown, + Folder, + FolderOpen, + FileText, + FileCode, + FileImage, + File, + Trash2, + Loader2, +} from "lucide-react"; +import { cn } from "@/components/agentx-ui/utils"; +import type { DirEntryItem } from "./explorerTypes"; + +export interface WsFileDragPayload { + path: string; + name: string; + isImage: boolean; +} + +const IMAGE_EXTS = new Set([ + "jpg", "jpeg", "png", "gif", "bmp", "webp", "ico", "svg", +]); + +const DRAG_THRESHOLD = 5; + +interface FileTreeNodeProps { + entry: DirEntryItem; + depth: number; + isExpanded: boolean; + isSelected: boolean; + children?: DirEntryItem[]; + childrenLoading: boolean; + onToggle: (path: string) => void; + onSelect: (path: string) => void; + onLoadChildren: (path: string) => void; + onDelete?: (path: string) => void; + expandedPaths: Record; + dirCache: Record; +} + +const FILE_ICON_MAP: Record = { + ts: , + tsx: , + js: , + jsx: , + py: , + rs: , + json: , + md: , + txt: , + png: , + jpg: , + jpeg: , + svg: , + css: , + scss: , + html: , + vue: , +}; + +function getFileIcon(name: string): React.ReactNode { + const ext = name.split(".").pop()?.toLowerCase() || ""; + return FILE_ICON_MAP[ext] || ; +} + +function formatSize(bytes: number): string { + if (bytes === 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const FileTreeNode = React.memo(function FileTreeNode({ + entry, + depth, + isExpanded, + isSelected, + childrenLoading, + onToggle, + onSelect, + onLoadChildren, + onDelete, + expandedPaths, + dirCache, +}: FileTreeNodeProps) { + const [hovering, setHovering] = useState(false); + const dragStateRef = useRef<{ + startX: number; + startY: number; + active: boolean; + payload: WsFileDragPayload; + } | null>(null); + + const handleClick = useCallback(() => { + if (dragStateRef.current?.active) return; + if (entry.is_dir) { + onToggle(entry.path); + if (!isExpanded && !dirCache[entry.path]) { + onLoadChildren(entry.path); + } + } else { + onSelect(entry.path); + } + }, [entry.path, entry.is_dir, isExpanded, dirCache, onToggle, onSelect, onLoadChildren]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (entry.is_dir || e.button !== 0) return; + + const ext = entry.name.split(".").pop()?.toLowerCase() || ""; + const isImage = IMAGE_EXTS.has(ext); + dragStateRef.current = { + startX: e.clientX, + startY: e.clientY, + active: false, + payload: { path: entry.path, name: entry.name, isImage }, + }; + + const onMove = (ev: MouseEvent) => { + const state = dragStateRef.current; + if (!state) return; + const dx = ev.clientX - state.startX; + const dy = ev.clientY - state.startY; + if (!state.active && Math.sqrt(dx * dx + dy * dy) >= DRAG_THRESHOLD) { + state.active = true; + document.dispatchEvent(new CustomEvent("ws-file-drag-start", { detail: state.payload })); + } + if (state.active) { + document.dispatchEvent(new CustomEvent("ws-file-drag-move", { detail: { x: ev.clientX, y: ev.clientY } })); + } + }; + + const onUp = (ev: MouseEvent) => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + const state = dragStateRef.current; + if (state?.active) { + document.dispatchEvent(new CustomEvent("ws-file-drag-drop", { + detail: { ...state.payload, x: ev.clientX, y: ev.clientY }, + })); + } + dragStateRef.current = null; + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [entry.path, entry.name, entry.is_dir]); + + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(entry.path); + }, + [entry.path, onDelete] + ); + + const children = dirCache[entry.path] || []; + + return ( + <> +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + > + {entry.is_dir ? ( + <> + + {childrenLoading ? ( + + ) : isExpanded ? ( + + ) : ( + + )} + + + {isExpanded ? ( + + ) : ( + + )} + + + ) : ( + <> + + {getFileIcon(entry.name)} + + )} + {entry.name} + {!entry.is_dir && entry.size > 0 && ( + + {formatSize(entry.size)} + + )} + {hovering && onDelete && ( + + )} +
+ + {entry.is_dir && isExpanded && ( +
+ {children.map((child) => ( + + ))} + {children.length === 0 && !childrenLoading && ( +
+ 空目录 +
+ )} +
+ )} + + ); +}); + +interface FileTreeProps { + entries: DirEntryItem[]; + expandedPaths: Record; + selectedPath: string | null; + dirCache: Record; + loadingPaths: Set; + onToggle: (path: string) => void; + onSelect: (path: string) => void; + onLoadChildren: (path: string) => void; + onDelete?: (path: string) => void; + rootDepth?: number; +} + +export function FileTree({ + entries, + expandedPaths, + selectedPath, + dirCache, + loadingPaths, + onToggle, + onSelect, + onLoadChildren, + onDelete, + rootDepth = 0, +}: FileTreeProps) { + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerAdapter.tsx b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerAdapter.tsx new file mode 100644 index 00000000..d91e3971 --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerAdapter.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { WorkspaceExplorerPanel } from "./WorkspaceExplorerPanel"; +import type { WorkspacePanelContentProps } from "./types"; +import type { DirEntryItem } from "./explorerTypes"; + +export function WorkspaceExplorerAdapter({ isActive }: WorkspacePanelContentProps) { + const { + folders, expandedPaths, selectedPath, isLoading, dirCache, + toggleExpanded, setSelectedPath, loadFolders, pickAndAddFolder, + removeFolder, listDir, readFile, readFileBase64, writeFile, deleteItem, + restoreExpandedDirs, + } = useWorkspace(); + + React.useEffect(() => { + if (isActive) { + loadFolders(); + restoreExpandedDirs(); + } + }, [isActive]); + + const handleAddFolder = React.useCallback(async () => { + await pickAndAddFolder(); + }, [pickAndAddFolder]); + + const handleLoadDir = React.useCallback(async (path: string): Promise => { + return await listDir(path); + }, [listDir]); + + return ( + { + const sep = dirPath.includes('/') ? '/' : '\\'; + await writeFile(dirPath.replace(/[/\\]+$/, '') + sep + name, content); + await listDir(dirPath); + }} + onDeleteItem={async (path) => { + await deleteItem(path); + }} + /> + ); +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerPanel.tsx b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerPanel.tsx new file mode 100644 index 00000000..86a1a527 --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerPanel.tsx @@ -0,0 +1,441 @@ +import * as React from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; +import { + FolderPlus, + FolderMinus, + X, + RefreshCw, + FilePlus, + Eye, + Folder, + File, + Loader2, + AlertTriangle, +} from "lucide-react"; +import { cn } from "@/components/agentx-ui/utils"; +import { FileTree } from "./FileTree"; +import type { WorkspaceExplorerPanelProps } from "./explorerTypes"; + +/** + * WorkspaceExplorerPanel — 纯 UI 组件 + * + * 提供:工作区文件夹列表、文件树浏览、文件预览 + * 不包含业务逻辑(Electron IPC 等在 adapter 层处理) + */ +export function WorkspaceExplorerPanel({ + folders, + expandedPaths, + selectedPath, + isLoading, + dirCache, + onAddFolder, + onRemoveFolder, + onToggleExpanded, + onSelectPath, + onLoadDir, + onReadFile, + onReadFileBase64, + onCreateFile, + onDeleteItem, +}: WorkspaceExplorerPanelProps) { + const [previewContent, setPreviewContent] = useState(null); + const [previewPath, setPreviewPath] = useState(null); + const [previewType, setPreviewType] = useState("text"); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [loadingPaths, setLoadingPaths] = useState>(new Set()); + const [showNewFileInput, setShowNewFileInput] = useState(null); + const [newFileName, setNewFileName] = useState(""); + + const blobUrlRef = useRef(null); + const previewCacheRef = useRef>(new Map()); + + const revokePreviousBlobUrl = useCallback(() => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }, []); + + // Cleanup blob URLs on unmount + React.useEffect(() => { + return () => { + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + previewCacheRef.current.clear(); + }; + }, []); + + const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp", "ico"]); + const OTHER_BINARY_EXTS = new Set([ + "mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", + "mp3", "wav", "flac", "aac", "ogg", "wma", + "zip", "rar", "7z", "tar", "gz", "bz2", "xz", + "exe", "dll", "so", "dylib", "bin", + "woff", "woff2", "ttf", "otf", "eot", + "db", "sqlite", "sqlite3", + "psd", "ai", "sketch", "fig", + "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "svg", + ]); + + type FileCategory = "text" | "image" | "binary"; + + const getFileCategory = useCallback((filePath: string): FileCategory => { + const ext = filePath.split(".").pop()?.toLowerCase() || ""; + if (IMAGE_EXTS.has(ext)) return "image"; + if (OTHER_BINARY_EXTS.has(ext)) return "binary"; + return "text"; + }, []); + + const handleLoadChildren = useCallback( + async (path: string) => { + setLoadingPaths((prev) => new Set(prev).add(path)); + try { + await onLoadDir(path); + } finally { + setLoadingPaths((prev) => { + const next = new Set(prev); + next.delete(path); + return next; + }); + } + }, + [onLoadDir] + ); + + const handleSelect = useCallback( + async (path: string) => { + onSelectPath(path); + const category = getFileCategory(path); + setPreviewType(category); + setPreviewPath(path); + setPreviewError(null); + + const cached = previewCacheRef.current.get(path); + if (cached) { + setPreviewContent(cached.content); + setPreviewType(cached.type); + setPreviewLoading(false); + return; + } + + revokePreviousBlobUrl(); + setPreviewLoading(true); + + try { + if (category === "image") { + const base64 = await onReadFileBase64(path); + const res = await fetch(`data:application/octet-stream;base64,${base64}`); + const buffer = await res.arrayBuffer(); + const ext = path.split(".").pop()?.toLowerCase() || "png"; + const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "gif" ? "image/gif" + : ext === "webp" ? "image/webp" + : ext === "bmp" ? "image/bmp" + : ext === "ico" ? "image/x-icon" + : "image/png"; + const blob = new Blob([buffer], { type: mime }); + const url = URL.createObjectURL(blob); + blobUrlRef.current = url; + setPreviewContent(url); + } else if (category === "text") { + const content = await onReadFile(path); + setPreviewContent(content); + previewCacheRef.current.set(path, { type: category, content }); + } else { + setPreviewContent(null); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setPreviewError(msg); + setPreviewContent(null); + } finally { + setPreviewLoading(false); + } + }, + [onSelectPath, onReadFile, onReadFileBase64, getFileCategory, revokePreviousBlobUrl] + ); + + const invalidateCache = useCallback((path: string) => { + previewCacheRef.current.delete(path); + }, []); + + const handleDelete = useCallback( + async (path: string) => { + try { + await onDeleteItem(path); + invalidateCache(path); + if (previewPath === path) { + setPreviewContent(null); + setPreviewPath(null); + } + const parentPath = path.replace(/[/\\][^/\\]+$/, ""); + if (parentPath) { + await onLoadDir(parentPath); + } + } catch (e) { + console.error("[WorkspaceExplorer] Delete failed:", e); + } + }, + [onDeleteItem, onLoadDir, previewPath, invalidateCache] + ); + + const handleCreateFile = useCallback( + async (dirPath: string) => { + if (!newFileName.trim()) return; + try { + await onCreateFile(dirPath, newFileName.trim(), ""); + setShowNewFileInput(null); + setNewFileName(""); + await onLoadDir(dirPath); + } catch (e) { + console.error("[WorkspaceExplorer] Create file failed:", e); + } + }, + [newFileName, onCreateFile, onLoadDir] + ); + + const closePreview = useCallback(() => { + revokePreviousBlobUrl(); + setPreviewContent(null); + setPreviewPath(null); + setPreviewError(null); + onSelectPath(null); + }, [onSelectPath, revokePreviousBlobUrl]); + + const previewFileName = useMemo( + () => previewPath?.split(/[/\\]/).pop() || "", + [previewPath] + ); + + // 空状态 + if (folders.length === 0) { + return ( +
+
+
+ +
+

+ 添加工作区文件夹 +

+

+ 关联本地文件夹,AI 可以读取内容、生成文件,成为你的智能助手 +

+ +
+
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+ + 工作区 ({folders.length}) + +
+ +
+
+ + {/* 文件树 + 预览 分区 */} +
+ {/* 文件树区域 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + folders.map((folder) => ( +
+ {/* 文件夹标题 */} +
+ +
+ + + +
+
+ + {/* 新建文件输入 */} + {showNewFileInput === folder.path && ( +
+ setNewFileName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleCreateFile(folder.path); } + if (e.key === "Escape") { e.preventDefault(); setShowNewFileInput(null); } + }} + placeholder="文件名..." + className="flex-1 text-xs px-2 py-1 rounded border border-border bg-background outline-none focus:ring-1 focus:ring-primary" + /> + + +
+ )} + + {/* 文件树 */} + {expandedPaths[folder.path] && ( + + )} +
+ )) + )} +
+ + {/* 文件预览区域 */} + {previewPath !== null && ( +
+
+
+ + {previewFileName} +
+ +
+
+ {previewLoading ? ( +
+ +
+ ) : previewError ? ( +
+ +

无法预览

+

{previewError}

+
+ ) : previewType === "image" && previewContent ? ( +
+ {previewFileName} +
+ ) : previewType === "text" && previewContent !== null ? ( +
+                  {previewContent || "(空文件)"}
+                
+ ) : ( +
+ +

{previewFileName}

+

+ 该文件类型暂不支持预览 +

+
+ )} +
+
+ )} +
+
+ ); +} + +function ChevronRightIcon() { + return ( + + + + ); +} + +function ChevronDownIcon() { + return ( + + + + ); +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanel.tsx b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanel.tsx new file mode 100644 index 00000000..58b6e2b6 --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; +import { GripVertical } from "lucide-react"; +import { cn } from "@/components/agentx-ui/utils"; +import { WorkspacePanelHeader } from "./WorkspacePanelHeader"; +import type { WorkspacePanelProps } from "./types"; + +const DEFAULT_WIDTH = 420; +const MIN_WIDTH = 360; +const MAX_WIDTH = 600; + +/** + * WorkspacePanel — 通用右侧面板外壳 + * + * 提供:拖拽宽度调整、tab 栏切换、打开/关闭 + * 不包含任何业务逻辑。 + */ +export function WorkspacePanel({ + isOpen, + onClose, + plugins, + activeTabId, + onTabChange, + defaultWidth = DEFAULT_WIDTH, + minWidth = MIN_WIDTH, + maxWidth = MAX_WIDTH, +}: WorkspacePanelProps) { + const [panelWidth, setPanelWidth] = React.useState(defaultWidth); + const isDraggingRef = React.useRef(false); + const startXRef = React.useRef(0); + const startWidthRef = React.useRef(defaultWidth); + + // 拖拽事件 + const handleDragStart = React.useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + startXRef.current = e.clientX; + startWidthRef.current = panelWidth; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [panelWidth]); + + React.useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current) return; + const delta = startXRef.current - e.clientX; + const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + delta)); + setPanelWidth(newWidth); + }; + const handleMouseUp = () => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [minWidth, maxWidth]); + + if (!isOpen) return null; + + const visiblePlugins = plugins.filter((p) => p.visible !== false); + const activePlugin = visiblePlugins.find((p) => p.id === activeTabId); + + return ( +
+ {/* 左侧拖拽手柄 */} +
+
+ +
+
+ + {/* Tab 栏 */} + + + {/* 面板内容 */} +
+ {activePlugin && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanelHeader.tsx b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanelHeader.tsx new file mode 100644 index 00000000..dc4b7a67 --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanelHeader.tsx @@ -0,0 +1,67 @@ +import { X } from "lucide-react"; +import { cn } from "@/components/agentx-ui/utils"; +import type { WorkspacePanelPlugin } from "./types"; + +interface WorkspacePanelHeaderProps { + plugins: WorkspacePanelPlugin[]; + activeTabId: string; + onTabChange: (tabId: string) => void; + onClose: () => void; +} + +export function WorkspacePanelHeader({ + plugins, + activeTabId, + onTabChange, + onClose, +}: WorkspacePanelHeaderProps) { + return ( +
+
+ {plugins.map((plugin) => { + const isActive = plugin.id === activeTabId; + return ( + + ); + })} +
+ +
+ +
+
+ ); +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/explorerTypes.ts b/apps/desktop/src/view/components/agentx-ui/components/workspace/explorerTypes.ts new file mode 100644 index 00000000..56e6562a --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/explorerTypes.ts @@ -0,0 +1,30 @@ +export interface WorkspaceFolderItem { + id: string; + path: string; + name: string; +} + +export interface DirEntryItem { + name: string; + path: string; + is_dir: boolean; + size: number; + modified: string | null; +} + +export interface WorkspaceExplorerPanelProps { + folders: WorkspaceFolderItem[]; + expandedPaths: Record; + selectedPath: string | null; + isLoading: boolean; + dirCache: Record; + onAddFolder: () => void; + onRemoveFolder: (id: string) => void; + onToggleExpanded: (path: string) => void; + onSelectPath: (path: string | null) => void; + onLoadDir: (path: string) => Promise; + onReadFile: (path: string) => Promise; + onReadFileBase64: (path: string) => Promise; + onCreateFile: (dirPath: string, name: string, content: string) => Promise; + onDeleteItem: (path: string) => Promise; +} diff --git a/apps/desktop/src/view/components/agentx-ui/components/workspace/types.ts b/apps/desktop/src/view/components/agentx-ui/components/workspace/types.ts new file mode 100644 index 00000000..fbf85afe --- /dev/null +++ b/apps/desktop/src/view/components/agentx-ui/components/workspace/types.ts @@ -0,0 +1,27 @@ +import type { ReactNode, ComponentType } from "react"; + +export interface WorkspacePanelContentProps { + isActive: boolean; + onClose: () => void; +} + +export interface WorkspacePanelPlugin { + id: string; + label: string; + icon: ReactNode; + badge?: number; + order: number; + component: ComponentType; + visible?: boolean; +} + +export interface WorkspacePanelProps { + isOpen: boolean; + onClose: () => void; + plugins: WorkspacePanelPlugin[]; + activeTabId: string; + onTabChange: (tabId: string) => void; + defaultWidth?: number; + minWidth?: number; + maxWidth?: number; +} diff --git a/apps/desktop/src/view/components/notifications/notificationService.ts b/apps/desktop/src/view/components/notifications/notificationService.ts index 1f540b52..987e8e76 100644 --- a/apps/desktop/src/view/components/notifications/notificationService.ts +++ b/apps/desktop/src/view/components/notifications/notificationService.ts @@ -5,6 +5,14 @@ const SHOWN_KEY = "promptx_notifications_shown" // 默认通知数据 const defaultNotifications: Notification[] = [ + { + id: "update-v2.3.0", + title: "notifications.updateV230.title", + content: "notifications.updateV230.content", + type: "success", + timestamp: Date.now(), + read: false, + }, { id: "update-v2.2.1", title: "notifications.updateV221.title", diff --git a/apps/desktop/src/view/hooks/useWorkspace.ts b/apps/desktop/src/view/hooks/useWorkspace.ts new file mode 100644 index 00000000..0425f9df --- /dev/null +++ b/apps/desktop/src/view/hooks/useWorkspace.ts @@ -0,0 +1,110 @@ +import { useState, useCallback, useRef } from "react"; + +export interface WorkspaceFolder { + id: string; + name: string; + path: string; +} + +export interface DirEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + modified: string | null; +} + +export function useWorkspace() { + const [folders, setFolders] = useState([]); + const [expandedPaths, setExpandedPaths] = useState>({}); + const [selectedPath, setSelectedPath] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [dirCache, setDirCache] = useState>({}); + const restoringRef = useRef(false); + + const loadFolders = useCallback(async () => { + setIsLoading(true); + try { + const result = await window.electronAPI.workspace.getFolders(); + setFolders(result); + } finally { + setIsLoading(false); + } + }, []); + + const pickAndAddFolder = useCallback(async (): Promise => { + const picked = await window.electronAPI.workspace.pickFolder(); + if (!picked) return null; + const folder = await window.electronAPI.workspace.addFolder(picked.path, picked.name); + setFolders(prev => [...prev, folder]); + return folder; + }, []); + + const removeFolder = useCallback(async (id: string) => { + await window.electronAPI.workspace.removeFolder(id); + setFolders(prev => { + const folder = prev.find(f => f.id === id); + if (folder) { + setDirCache(prevCache => { + const next = { ...prevCache }; + for (const key of Object.keys(next)) { + if (key.startsWith(folder.path)) delete next[key]; + } + return next; + }); + } + return prev.filter(f => f.id !== id); + }); + }, []); + + const listDir = useCallback(async (dirPath: string): Promise => { + const entries = await window.electronAPI.workspace.listDir(dirPath); + setDirCache(prev => ({ ...prev, [dirPath]: entries })); + return entries; + }, []); + + const toggleExpanded = useCallback((path: string) => { + setExpandedPaths(prev => ({ ...prev, [path]: !prev[path] })); + }, []); + + const restoreExpandedDirs = useCallback(async () => { + if (restoringRef.current) return; + restoringRef.current = true; + try { + const paths = Object.keys(expandedPaths).filter(p => expandedPaths[p]); + await Promise.allSettled(paths.map(p => listDir(p))); + } finally { + restoringRef.current = false; + } + }, [expandedPaths, listDir]); + + const readFile = useCallback(async (filePath: string): Promise => { + return window.electronAPI.workspace.readFile(filePath); + }, []); + + const readFileBase64 = useCallback(async (filePath: string): Promise => { + return window.electronAPI.workspace.readFileBase64(filePath); + }, []); + + const writeFile = useCallback(async (filePath: string, content: string) => { + await window.electronAPI.workspace.writeFile(filePath, content); + }, []); + + const deleteItem = useCallback(async (itemPath: string) => { + await window.electronAPI.workspace.deleteItem(itemPath); + setDirCache(prev => { + const next = { ...prev }; + for (const key of Object.keys(next)) { + if (key.startsWith(itemPath) || itemPath.startsWith(key)) delete next[key]; + } + return next; + }); + }, []); + + return { + folders, expandedPaths, selectedPath, isLoading, dirCache, + toggleExpanded, setSelectedPath, loadFolders, pickAndAddFolder, + removeFolder, listDir, readFile, readFileBase64, writeFile, deleteItem, + restoreExpandedDirs, + }; +} diff --git a/apps/desktop/src/view/pages/resources-window/index.tsx b/apps/desktop/src/view/pages/resources-window/index.tsx index f69a43e6..e98f0c2b 100644 --- a/apps/desktop/src/view/pages/resources-window/index.tsx +++ b/apps/desktop/src/view/pages/resources-window/index.tsx @@ -81,14 +81,21 @@ export default function ResourcesPage() { if (result?.success) { const { grouped } = result.data || {} const flat: ResourceItem[] = [] + const seen = new Set() Object.keys(grouped || {}).forEach((source) => { const group = grouped[source] || {} - ;(group.roles || []).forEach((role: any) => + ;(group.roles || []).forEach((role: any) => { + const key = `role-${source}-${role.id || role.name}` + if (seen.has(key)) return + seen.add(key) flat.push({ id: role.id || role.name, name: role.name, description: role.description, type: "role", source }) - ) - ;(group.tools || []).forEach((tool: any) => + }) + ;(group.tools || []).forEach((tool: any) => { + const key = `tool-${source}-${tool.id || tool.name}` + if (seen.has(key)) return + seen.add(key) flat.push({ id: tool.id || tool.name, name: tool.name, description: tool.description, type: "tool", source }) - ) + }) }) setItems(flat) } else { diff --git a/apps/desktop/src/view/pages/roles-window/components/RoleTreeListPanel.tsx b/apps/desktop/src/view/pages/roles-window/components/RoleTreeListPanel.tsx index 2169bd28..a7f9c206 100644 --- a/apps/desktop/src/view/pages/roles-window/components/RoleTreeListPanel.tsx +++ b/apps/desktop/src/view/pages/roles-window/components/RoleTreeListPanel.tsx @@ -214,44 +214,55 @@ export default function RoleTreeListPanel({
) : versionFilter === "v2" ? ( <> - {/* V2 角色:显示组织树状结构 */} - {Array.from(orgMap.entries()).map(([orgName, orgRoles]) => { - const orgInfo = getOrgInfo(orgName) - const isExpanded = expandedOrgs.has(orgName) - return ( -
-
- - {isExpanded && ( -
- {orgRoles.map(role => renderRole(role))} -
- )} -
- ) - })} + +
+
+ {orgName} + + {displayRoles.length} + +
+ {orgInfo?.charter && ( +

+ {orgInfo.charter} +

+ )} +
+ + {isExpanded && ( +
+ {displayRoles.map(role => renderRole(role))} +
+ )} +
+ ) + }) + })()} {/* 无组织的 V2 角色 */} {rolesWithoutOrg.length > 0 && ( diff --git a/apps/desktop/src/view/pages/roles-window/index.tsx b/apps/desktop/src/view/pages/roles-window/index.tsx index 27972e20..5d90305a 100644 --- a/apps/desktop/src/view/pages/roles-window/index.tsx +++ b/apps/desktop/src/view/pages/roles-window/index.tsx @@ -99,13 +99,27 @@ export default function RolesPage() { }) } - // 设置组织列表 + // 设置组织列表(包含所有组织,即使其下没有匹配到已扫描的角色) if (directory?.organizations) { - setOrganizations(directory.organizations.map((org: any) => ({ - name: org.name, - charter: org.charter, - roles: flat.filter(r => r.org === org.name) - }))) + setOrganizations(directory.organizations.map((org: any) => { + const matchedRoles = flat.filter(r => r.org === org.name) + // 如果没有匹配到的角色,用 directory 中的成员信息构造占位角色 + const orgRoles = matchedRoles.length > 0 ? matchedRoles : (org.members || []).map((m: any) => ({ + id: m.name, + name: m.name, + description: m.position || '', + type: "role" as const, + source: "user", + version: "v2" as const, + org: org.name, + position: m.position, + })) + return { + name: org.name, + charter: org.charter, + roles: orgRoles, + } + })) } } } catch (e) { diff --git a/apps/desktop/src/view/pages/settings-window/components/AgentXProfilesConfig.tsx b/apps/desktop/src/view/pages/settings-window/components/AgentXProfilesConfig.tsx index 78604ff8..f383d8ff 100644 --- a/apps/desktop/src/view/pages/settings-window/components/AgentXProfilesConfig.tsx +++ b/apps/desktop/src/view/pages/settings-window/components/AgentXProfilesConfig.tsx @@ -72,6 +72,13 @@ const PRESETS = [ baseUrl: "https://openrouter.ai/api ", model: "claude-opus-4-6" }, + { + id: "deepseek", + name: "DeepSeek", + nameZh: "DeepSeek", + baseUrl: "https://api.deepseek.com/anthropic", + model: "deepseek-chat" + }, { id: "custom", name: "Custom", diff --git a/apps/desktop/src/view/pages/settings-window/components/FeishuConfig.tsx b/apps/desktop/src/view/pages/settings-window/components/FeishuConfig.tsx new file mode 100644 index 00000000..3abd03bc --- /dev/null +++ b/apps/desktop/src/view/pages/settings-window/components/FeishuConfig.tsx @@ -0,0 +1,249 @@ +import { useState, useEffect, useCallback } from "react" +import { useTranslation } from "react-i18next" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Loader2, ExternalLink, Circle } from "lucide-react" +import { toast } from "sonner" + +interface FeishuConfigData { + appId: string + appSecret: string + encryptKey: string +} + +interface FeishuStatus { + connected: boolean + appId?: string + error?: string +} + +const EMPTY_CONFIG: FeishuConfigData = { + appId: "", + appSecret: "", + encryptKey: "" +} + +export function FeishuConfig() { + const { t } = useTranslation() + const [config, setConfig] = useState(EMPTY_CONFIG) + const [status, setStatus] = useState({ connected: false }) + const [isSaving, setIsSaving] = useState(false) + const [isToggling, setIsToggling] = useState(false) + + const loadStatus = useCallback(async () => { + try { + const s = await window.electronAPI?.invoke("feishu:status") + if (s) setStatus(s) + } catch { + // ignore + } + }, []) + + const loadConfig = useCallback(async () => { + try { + console.log("[FeishuConfig] loadConfig calling feishu:getConfig") + const saved = await window.electronAPI?.invoke("feishu:getConfig") + console.log("[FeishuConfig] loadConfig result:", saved) + if (saved) { + setConfig({ + appId: saved.appId || "", + appSecret: saved.appSecret || "", + encryptKey: saved.encryptKey || "" + }) + } + } catch (e) { + console.error("[FeishuConfig] loadConfig error:", e) + } + }, []) + + useEffect(() => { + loadConfig() + loadStatus() + }, [loadConfig, loadStatus]) + + const handleSave = async () => { + if (!config.appId || !config.appSecret) { + toast.error(t("settings.feishu.saveFailed")) + return + } + setIsSaving(true) + try { + console.log("[FeishuConfig] saving config:", config) + const result = await window.electronAPI?.invoke("feishu:saveConfig", config) + console.log("[FeishuConfig] save result:", result) + if (result?.success === false) { + toast.error(result.error || t("settings.feishu.saveFailed")) + } else { + toast.success(t("settings.feishu.saveSuccess")) + } + } catch (e) { + console.error("[FeishuConfig] save error:", e) + toast.error(t("settings.feishu.saveFailed")) + } finally { + setIsSaving(false) + } + } + + const handleToggle = async (checked: boolean) => { + if (checked && (!config.appId || !config.appSecret)) { + toast.error(t("settings.feishu.configRequired")) + return + } + + setIsToggling(true) + try { + if (checked) { + // Save config first, then start + await window.electronAPI?.invoke("feishu:saveConfig", config) + const result = await window.electronAPI?.invoke("feishu:start", config, { name: "PromptX" }) + if (result?.success === false) { + toast.error(result.error || t("settings.feishu.startFailed")) + } else { + toast.success(t("settings.feishu.startSuccess")) + } + } else { + const result = await window.electronAPI?.invoke("feishu:stop") + if (result?.success === false) { + toast.error(result.error || t("settings.feishu.stopFailed")) + } else { + toast.success(t("settings.feishu.stopSuccess")) + } + } + await loadStatus() + } catch (e) { + toast.error(String(e)) + } finally { + setIsToggling(false) + } + } + + const handleRemove = async () => { + try { + await window.electronAPI?.invoke("feishu:remove") + setConfig(EMPTY_CONFIG) + setStatus({ connected: false }) + toast.success(t("settings.feishu.removeSuccess")) + } catch { + toast.error(t("settings.feishu.removeFailed")) + } + } + + const openFeishuPlatform = async () => { + const url = "https://open.feishu.cn/" + try { + if (window.electronAPI?.shell?.openExternal) { + await window.electronAPI.shell.openExternal(url) + } else { + window.open(url, "_blank") + } + } catch { + window.open(url, "_blank") + } + } + + const connected = status.connected + + return ( + + +
+
+ + {t("settings.feishu.title")} + + + {connected ? t("settings.feishu.connected") : t("settings.feishu.disconnected")} + + + {t("settings.feishu.description")} +
+
+ {isToggling ? ( + + ) : ( + + )} +
+
+
+ +
+ + setConfig(prev => ({ ...prev, appId: e.target.value }))} + disabled={connected} + /> +
+ +
+ + setConfig(prev => ({ ...prev, appSecret: e.target.value }))} + disabled={connected} + /> +
+ +
+ + setConfig(prev => ({ ...prev, encryptKey: e.target.value }))} + disabled={connected} + /> +
+ + {status.error && ( +

{status.error}

+ )} + +
+ + {connected && ( + + )} +
+ +

+ {t("settings.feishu.guide")}{" "} + +

+
+
+ ) +} diff --git a/apps/desktop/src/view/pages/settings-window/components/WechatConfig.tsx b/apps/desktop/src/view/pages/settings-window/components/WechatConfig.tsx new file mode 100644 index 00000000..9611958c --- /dev/null +++ b/apps/desktop/src/view/pages/settings-window/components/WechatConfig.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from "react-i18next" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Copy, Terminal } from "lucide-react" +import { toast } from "sonner" + +function CommandBlock({ label, description, command, onCopy }: { + label: string + description: string + command: string + onCopy: () => void +}) { + return ( +
+
+

{label}

+

{description}

+
+
+
+ + {command} +
+ +
+
+ ) +} + +export function WechatConfig() { + const { t } = useTranslation() + + const copyCommand = (cmd: string) => { + navigator.clipboard.writeText(cmd) + toast.success(t("settings.wechat.copied")) + } + + const handleStart = () => { + toast.info(t("settings.wechat.comingSoon")) + } + + return ( + + + {t("settings.wechat.title")} + {t("settings.wechat.description")} + + + copyCommand(t("settings.wechat.installCmd"))} + /> + + copyCommand(t("settings.wechat.loginCmd"))} + /> + +
+ +
+
+
+ ) +} diff --git a/apps/desktop/src/view/pages/settings-window/index.tsx b/apps/desktop/src/view/pages/settings-window/index.tsx index 85fd278f..aab52d59 100644 --- a/apps/desktop/src/view/pages/settings-window/index.tsx +++ b/apps/desktop/src/view/pages/settings-window/index.tsx @@ -21,9 +21,61 @@ import { LanguageSelector } from "./components/LanguageSelector" import { MCPConfig } from "./components/MCPConfig" import { SkillsConfig } from "./components/SkillsConfig" import { WebAccessConfig } from "./components/WebAccessConfig" +import { FeishuConfig } from "./components/FeishuConfig" +// import { WechatConfig } from "./components/WechatConfig" import { AgentXProfilesConfig } from "./components/AgentXProfilesConfig" import { Loader2, Settings, Bot, RefreshCw, Wifi, AlertTriangle } from "lucide-react" +function GitWarningBanner() { + const { t } = useTranslation() + const [gitInstalled, setGitInstalled] = useState(null) + + useEffect(() => { + if (window.electronAPI?.platform !== "win32") { + setGitInstalled(true) + return + } + window.electronAPI?.system?.checkGit().then((result: { installed: boolean }) => { + setGitInstalled(result.installed) + }).catch(() => { + setGitInstalled(true) + }) + }, []) + + if (gitInstalled !== false) return null + + return ( + + ) +} + interface ServerConfig { host: string port: number @@ -312,37 +364,8 @@ function SettingsWindow() { - {/* Windows Git requirement warning */} - {window.electronAPI?.platform === "win32" && ( - - )} + {/* Windows Git requirement warning - only when Git not detected */} + {/* MCP 配置 */} @@ -355,6 +378,8 @@ function SettingsWindow() { {/* 远程访问 */} + + {/* */}
diff --git a/packages/core/src/pouch/commands/DiscoverCommand.js b/packages/core/src/pouch/commands/DiscoverCommand.js index 86a782b1..d099101e 100644 --- a/packages/core/src/pouch/commands/DiscoverCommand.js +++ b/packages/core/src/pouch/commands/DiscoverCommand.js @@ -236,7 +236,12 @@ class DiscoverCommand extends BasePouchCommand { const bridge = getRolexBridge() const v2Roles = await bridge.listV2Roles() v2Roles.forEach(role => { - registry[`v2:${role.id}`] = role + // 如果 V1 registry 中已存在同名角色,用 V2 版本覆盖(避免重复) + if (registry[role.id]) { + registry[role.id] = { ...role, version: 'v2' } + } else { + registry[`v2:${role.id}`] = role + } }) if (v2Roles.length > 0) { logger.info(`[DiscoverCommand] Found ${v2Roles.length} V2 roles from RoleX`) diff --git a/packages/core/src/rolex/RolexBridge.js b/packages/core/src/rolex/RolexBridge.js index 893c6965..379cbf52 100644 --- a/packages/core/src/rolex/RolexBridge.js +++ b/packages/core/src/rolex/RolexBridge.js @@ -394,6 +394,11 @@ class RolexBridge { // 检测组织行(没有缩进,可能包含括号) if (!line.startsWith(' ')) { + // 跳过 ─── unaffiliated ─── 等分隔行,将其下的角色视为无组织 + if (trimmed.includes('unaffiliated') || /^[─—-]{3,}/.test(trimmed)) { + currentOrg = '__unaffiliated__' + continue + } // 这是一个组织名称 currentOrg = trimmed if (!result.organizations.find(o => o.name === currentOrg)) { @@ -404,34 +409,27 @@ class RolexBridge { }) } } - // 检测缩进行(角色或职位) + // 检测缩进行(角色/个体成员) else if (line.startsWith(' ') && currentOrg) { const match = trimmed.match(/^([^\s—]+)(?:\s*\([^)]+\))?\s*—\s*(.+)$/) if (match) { const name = match[1].trim() const description = match[2].trim() - // 判断是角色还是职位 - // 如果描述包含多个逗号分隔的职位,或者包含 "manager" 等关键词,则是角色 - // 否则是职位定义 - const isRole = description.includes(',') || - description.includes('manager') || - description.includes('individual') || - description.includes('organization') || - description.includes('position') - - if (isRole) { - // 这是一个角色 - const positions = description.split(',').map(p => p.trim()) - - // 添加到 roles 列表 - result.roles.push({ - name: name, - org: currentOrg, - position: positions[0] - }) - - // 添加到组织的成员列表 + // census.list 缩进行全部是个体(成员),不是职位定义 + // description 是该成员所任职的职位列表(逗号分隔) + const positions = description.split(',').map(p => p.trim()) + + // 添加到 roles 列表(unaffiliated 的角色 org 为空) + const isUnaffiliated = currentOrg === '__unaffiliated__' + result.roles.push({ + name: name, + org: isUnaffiliated ? undefined : currentOrg, + position: positions[0] + }) + + // 添加到组织的成员列表(unaffiliated 不添加) + if (!isUnaffiliated) { const org = result.organizations.find(o => o.name === currentOrg) if (org) { org.members.push({ @@ -439,15 +437,6 @@ class RolexBridge { position: positions[0] }) } - } else { - // 这是一个职位定义 - const org = result.organizations.find(o => o.name === currentOrg) - if (org) { - org.positions.push({ - name: name, - description: description - }) - } } } } diff --git a/packages/mcp-server/src/servers/PromptXMCPServer.ts b/packages/mcp-server/src/servers/PromptXMCPServer.ts index 4d5799e4..a840c97b 100644 --- a/packages/mcp-server/src/servers/PromptXMCPServer.ts +++ b/packages/mcp-server/src/servers/PromptXMCPServer.ts @@ -52,9 +52,7 @@ export class PromptXMCPServer { this.server = new StreamableHttpMCPServer({ name: options.name || 'promptx-mcp-server', version: options.version || process.env.npm_package_version || '1.0.0', - port: options.port || 5203, - host: options.host || 'localhost', - corsEnabled: options.corsEnabled || false + url: `http://${options.host || 'localhost'}:${options.port || 5203}/mcp`, }); } diff --git a/packages/mcp-server/src/servers/StreamableHttpMCPServer.ts b/packages/mcp-server/src/servers/StreamableHttpMCPServer.ts index df9a5420..0ff43ff3 100644 --- a/packages/mcp-server/src/servers/StreamableHttpMCPServer.ts +++ b/packages/mcp-server/src/servers/StreamableHttpMCPServer.ts @@ -1,60 +1,72 @@ -import express, { Express, Request, Response } from 'express'; -import { Server as HttpServer } from 'http'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - InitializeRequestSchema, - LoggingMessageNotification, - JSONRPCNotification, - JSONRPCError, - Notification, +import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, - ReadResourceRequestSchema + ReadResourceRequestSchema, + isInitializeRequest, + type CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; -import type { Resource, Tool, Prompt } from '@modelcontextprotocol/sdk/types.js'; +import type { Resource } from '@modelcontextprotocol/sdk/types.js'; import { BaseMCPServer } from '~/servers/BaseMCPServer.js'; -import type { MCPServerOptions, ToolWithHandler } from '~/interfaces/MCPServer.js'; +import type { MCPServerOptions } from '~/interfaces/MCPServer.js'; import { WorkerpoolAdapter } from '~/workers/index.js'; import type { ToolWorkerPool } from '~/interfaces/ToolWorkerPool.js'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { randomUUID } from 'crypto'; import packageJson from '../../package.json' assert { type: 'json' }; -const SESSION_ID_HEADER_NAME = "mcp-session-id"; +interface ParsedMcpUrl { + host: string; + port: number; + path: string; + fullUrl: string; +} + +function parseMcpUrl(rawUrl: string): ParsedMcpUrl { + const parsed = new URL(rawUrl); + const host = parsed.hostname || '127.0.0.1'; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === 'https:' ? 443 : 80; + const mcpPath = (parsed.pathname || '/mcp').replace(/\/$/, '') || '/mcp'; + return { host, port, path: mcpPath, fullUrl: `${parsed.protocol}//${host}:${port}${mcpPath}` }; +} + +type SessionEntry = { + server: Server; + transport: StreamableHTTPServerTransport; +}; /** * HTTP流式MCP服务器实现 - * - * 使用 MCP SDK 的 StreamableHTTPServerTransport 处理所有协议细节 - * 支持HTTP JSON-RPC和SSE(Server-Sent Events) + * + * 基于 raw node:http + StreamableHTTPServerTransport + * 参考 ShopAgent workspace-mcp 的简洁模式 */ export class StreamableHttpMCPServer extends BaseMCPServer { - private app?: Express; - private httpServer?: HttpServer; - private port: number; - private host: string; - private corsEnabled: boolean; + private httpServer?: ReturnType; + private endpoint: ParsedMcpUrl; private workerPool: ToolWorkerPool; - - // 支持多个并发连接 - 每个session独立的Server和Transport实例 - private servers: Map = new Map(); - private transports: Map = new Map(); - + + // HTTP Session管理 - 每个session独立的Server和Transport + private httpSessions = new Map(); + constructor(options: MCPServerOptions & { + url?: string; port?: number; host?: string; - corsEnabled?: boolean; }) { super(options); - this.port = options.port || 8080; - this.host = options.host || '127.0.0.1'; // 使用 IPv4 避免 IPv6 问题 - this.corsEnabled = options.corsEnabled || false; - + const url = options.url || + `http://${options.host || '127.0.0.1'}:${options.port || 8080}/mcp`; + this.endpoint = parseMcpUrl(url); + // 初始化 worker pool this.workerPool = new WorkerpoolAdapter({ minWorkers: 2, @@ -62,83 +74,51 @@ export class StreamableHttpMCPServer extends BaseMCPServer { workerTimeout: 30000 }); } - + /** * 连接HTTP传输层 */ protected async connectTransport(): Promise { this.logger.info('Starting HTTP server...'); - + // 初始化 worker pool await this.workerPool.initialize(); this.logger.info('Worker pool initialized'); - - // 创建Express应用 - this.app = express(); - - // 设置Express应用 - 完全仿照官方 - this.setupExpress(); - + + // 创建HTTP服务器 + this.httpServer = createServer(async (req, res) => { + try { + await this.handleHttpRequest(req, res); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`HTTP request failed: ${message}`); + if (!res.headersSent) { + this.sendJsonRpcError(res, 500, -32603, 'Internal server error'); + } + } + }); + // 启动HTTP服务器 await new Promise((resolve, reject) => { - this.httpServer = this.app!.listen(this.port, this.host, () => { - this.logger.info(`HTTP server listening on http://${this.host}:${this.port}/mcp`); + this.httpServer!.once('error', reject); + this.httpServer!.listen(this.endpoint.port, this.endpoint.host, () => { + this.httpServer!.off('error', reject); resolve(); }); - - this.httpServer.on('error', reject); }); + + this.logger.info(`HTTP server listening on ${this.endpoint.fullUrl}`); } - + /** - * 获取或创建session对应的Server实例 - * - * 形式化规约: - * 前置条件:sessionId ≠ null ∧ sessionId ≠ "" - * 后置条件:返回的Server是sessionId唯一对应的 - * 不变式:servers.get(sessionId) 存在 ⟺ transports.get(sessionId) 存在 + * 构建协议服务器实例 */ - private getOrCreateServer(sessionId: string): Server { - // 断言:sessionId必须有效 - if (!sessionId) { - throw new Error('SessionId cannot be null or empty'); - } - - // 如果已存在,直接返回 - if (this.servers.has(sessionId)) { - return this.servers.get(sessionId)!; - } - - // 创建新的Server实例(注意:不监听端口) + private buildProtocolServer(): Server { const server = new Server( - { - name: this.options.name, - version: this.options.version - }, - { - capabilities: { - tools: {}, - resources: {}, - prompts: {} - } - } + { name: this.options.name, version: this.options.version }, + { capabilities: { tools: {}, resources: {}, prompts: {} } } ); - - // 为这个Server注册处理器(独立副本) - this.setupServerHandlers(server); - - // 保存Server实例 - this.servers.set(sessionId, server); - this.logger.info(`Created new Server instance for session: ${sessionId}`); - - return server; - } - - /** - * 为Server实例设置请求处理器 - * 注意:这些处理器是每个Server独立的 - */ - private setupServerHandlers(server: Server): void { + // 工具列表请求 server.setRequestHandler(ListToolsRequestSchema, async () => { this.logger.debug('Handling list tools request'); @@ -146,13 +126,14 @@ export class StreamableHttpMCPServer extends BaseMCPServer { tools: Array.from(this.tools.values()).map(({ handler, ...tool }) => tool) }; }); - + // 工具调用请求 - server.setRequestHandler(CallToolRequestSchema, async (request) => { - this.logger.debug(`Handling tool call: ${request.params.name}`); - return this.executeTool(request.params.name, request.params.arguments); + server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + const { name, arguments: args } = request.params; + this.logger.info(`Tool call: ${name} ${this.summarizeArgs(args || {})}`); + return this.executeTool(name, args); }); - + // 资源列表请求 server.setRequestHandler(ListResourcesRequestSchema, async () => { this.logger.debug('Handling list resources request'); @@ -160,7 +141,7 @@ export class StreamableHttpMCPServer extends BaseMCPServer { resources: Array.from(this.resources.values()) }; }); - + // 读取资源请求 server.setRequestHandler(ReadResourceRequestSchema, async (request) => { this.logger.debug(`Handling read resource: ${request.params.uri}`); @@ -170,7 +151,7 @@ export class StreamableHttpMCPServer extends BaseMCPServer { } return this.readResource(resource); }); - + // 提示词列表请求 server.setRequestHandler(ListPromptsRequestSchema, async () => { this.logger.debug('Handling list prompts request'); @@ -178,7 +159,7 @@ export class StreamableHttpMCPServer extends BaseMCPServer { prompts: Array.from(this.prompts.values()) }; }); - + // 获取提示词请求 server.setRequestHandler(GetPromptRequestSchema, async (request) => { this.logger.debug(`Handling get prompt: ${request.params.name}`); @@ -188,335 +169,216 @@ export class StreamableHttpMCPServer extends BaseMCPServer { } return { prompt }; }); + + return server; } - - /** - * 设置中间件和路由 - 完全仿照官方实现 - */ - private setupExpress(): void { - if (!this.app) return; - - // 仿照官方:只有基础的 JSON 解析 - this.app.use(express.json()); - - // 仿照官方:使用 Router - const router = express.Router(); - - // 健康检查端点 - 在其他路由之前定义 - router.get('/health', (req, res) => { - this.handleHealthCheck(req, res); - }); - - // 仿照官方:路由定义 - router.post('/mcp', async (req, res) => { - await this.handlePostRequest(req, res); - }); - - router.get('/mcp', async (req, res) => { - await this.handleGetRequest(req, res); - }); - - // 仿照官方:挂载路由 - this.app.use('/', router); - } - - /** - * 处理健康检查请求 - * - * 形式化保证: - * - 无副作用(幂等性) - * - O(1)时间复杂度 - * - 始终返回有效JSON - */ - private handleHealthCheck(req: Request, res: Response): void { - const healthStatus = { - status: 'ok', - timestamp: new Date().toISOString(), - service: 'mcp-server', - uptime: process.uptime(), - version: this.getVersion(), - transport: 'http', - sessions: this.servers.size, // 显示当前活跃的session数量 - servers: this.servers.size, // 独立Server实例数量 - transports: this.transports.size // Transport实例数量 - }; - - res.status(200).json(healthStatus); - } - - /** - * 获取服务版本信息 - */ - private getVersion(): string { - return packageJson.version || 'unknown'; - } - + /** - * 处理 GET 请求(SSE) - * 使用独立的Server实例处理SSE连接 + * 处理HTTP请求 */ - private async handleGetRequest(req: Request, res: Response): Promise { - const sessionId = req.headers[SESSION_ID_HEADER_NAME] as string | undefined; - - if (!sessionId) { - res.status(400).json( - this.createErrorResponse('Bad Request: session ID required for SSE.') - ); + private async handleHttpRequest( + req: IncomingMessage, + res: ServerResponse + ): Promise { + const method = (req.method || '').toUpperCase(); + const requestUrl = new URL( + req.url || '/', + `http://${req.headers.host || `${this.endpoint.host}:${this.endpoint.port}`}` + ); + + this.applyCorsHeaders(res); + + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); return; } - - // 确保session存在(获取或创建Server和Transport) - if (!this.transports.has(sessionId)) { - this.logger.info(`Session ${sessionId} not found for SSE, creating...`); - - // 获取或创建Server - const server = this.getOrCreateServer(sessionId); - - // 创建Transport - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId - }); - - // 连接 - await server.connect(transport); - this.transports.set(sessionId, transport); + + if (requestUrl.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + service: 'mcp-server', + version: packageJson.version, + sessions: this.httpSessions.size, + uptime: process.uptime(), + })); + return; } - - this.logger.info(`Establishing SSE stream for session ${sessionId}`); - - // 设置 SSE 必需的响应头 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲 - - // 启动心跳机制 - 每 20 秒发送一次 - const heartbeatInterval = setInterval(() => { - try { - // SSE 心跳格式:注释行 - res.write(':heartbeat\n\n'); - this.logger.info(`Sent SSE heartbeat for session ${sessionId}`); - } catch (error) { - this.logger.error(`Failed to send heartbeat for session ${sessionId}: ${error}`); - clearInterval(heartbeatInterval); - } - }, 20000); // 20 秒间隔 - - // 监听连接关闭事件 - req.on('close', () => { - this.logger.info(`SSE connection closed for session ${sessionId}`); - clearInterval(heartbeatInterval); - // 注意:暂时不清理session,因为客户端可能重连 - }); - - const transport = this.transports.get(sessionId)!; - await transport.handleRequest(req, res); - await this.streamMessages(transport); - - return; - } - - /** - * 发送 SSE 流消息 - 完全复制官方实现 - */ - private async streamMessages(transport: StreamableHTTPServerTransport): Promise { - try { - // 基于 LoggingMessageNotificationSchema 触发客户端的 setNotificationHandler - const message = { - method: 'notifications/message', - params: { level: 'info', data: 'SSE Connection established' } - }; - - this.sendNotification(transport, message); - - let messageCount = 0; - - const interval = setInterval(async () => { - messageCount++; - - const data = `Message ${messageCount} at ${new Date().toISOString()}`; - - const message = { - method: 'notifications/message', - params: { level: 'info', data: data } - }; - - try { - this.sendNotification(transport, message); - - if (messageCount === 2) { - clearInterval(interval); - - const message = { - method: 'notifications/message', - params: { level: 'info', data: 'Streaming complete!' } - }; - - this.sendNotification(transport, message); - } - } catch (error) { - this.logger.error(`Error sending message: ${error}`); - clearInterval(interval); - } - }, 1000); - } catch (error) { - this.logger.error(`Error sending message: ${error}`); + + if (requestUrl.pathname !== this.endpoint.path) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; } - } - - /** - * 发送通知 - 完全复制官方实现 - */ - private async sendNotification( - transport: StreamableHTTPServerTransport, - notification: any - ): Promise { - const rpcNotification = { - ...notification, - jsonrpc: '2.0' - }; - await transport.send(rpcNotification); - } - - /** - * 处理 POST 请求(JSON-RPC) - * 使用独立的Server实例处理每个session - */ - private async handlePostRequest(req: Request, res: Response): Promise { - const sessionId = req.headers[SESSION_ID_HEADER_NAME] as string | undefined; - - this.logger.info('=== POST Request ==='); - this.logger.info(`Headers: ${JSON.stringify(req.headers, null, 2)}`); - this.logger.info(`Body: ${JSON.stringify(req.body, null, 2)}`); - this.logger.info(`Session ID: ${sessionId}`); - - try { - // 处理已有session的请求 - if (sessionId && this.transports.has(sessionId)) { - this.logger.info(`Reusing existing Server and Transport for session: ${sessionId}`); - const transport = this.transports.get(sessionId)!; - await transport.handleRequest(req, res, req.body); + + if (method === 'POST') { + let body: unknown; + try { + body = await this.parseRequestBody(req); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Invalid JSON body'; + const statusCode = message.includes('too large') ? 413 : 400; + this.sendJsonRpcError(res, statusCode, -32700, message); return; } - - // 处理initialize请求(创建新session) - if (!sessionId && this.isInitializeRequest(req.body)) { - this.logger.info('Creating new session for initialize request'); - - // 生成新的session ID - const newSessionId = randomUUID(); - - // 获取或创建该session的Server - const server = this.getOrCreateServer(newSessionId); - - // 创建新的Transport - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => newSessionId - }); - - // 连接Server和Transport - await server.connect(transport); - - // 保存Transport(Server已在getOrCreateServer中保存) - this.transports.set(newSessionId, transport); - - // 处理请求 - await transport.handleRequest(req, res, req.body); - - this.logger.info(`New session created: ${newSessionId}`); + + const sessionId = this.getSessionId(req); + + // 已有session,复用 + if (sessionId && this.httpSessions.has(sessionId)) { + const entry = this.httpSessions.get(sessionId)!; + await entry.transport.handleRequest(req, res, body); return; } - - // 处理带session ID但Transport不存在的情况(可能是服务器重启) - if (sessionId && !this.transports.has(sessionId)) { - this.logger.info(`Session ${sessionId} not found, recreating...`); - - // 获取或创建Server - const server = this.getOrCreateServer(sessionId); - - // 创建新的Transport + + // 新session(initialize请求) + if (!sessionId && isInitializeRequest(body)) { + const server = this.buildProtocolServer(); const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + this.httpSessions.set(sid, { server, transport }); + this.logger.info(`Session initialized: ${sid}`); + }, }); - - // 连接 + + let closed = false; + transport.onclose = () => { + if (closed) return; + closed = true; + const sid = transport.sessionId; + if (sid && this.httpSessions.delete(sid)) { + this.logger.info(`Session closed: ${sid}`); + } + void server.close().catch(() => { /* ignore */ }); + }; + await server.connect(transport); - this.transports.set(sessionId, transport); - - // 处理请求 - await transport.handleRequest(req, res, req.body); + await transport.handleRequest(req, res, body); return; } - + // 无效请求 - this.logger.info('Invalid request - no session ID and not initialize request'); - this.logger.info(`isInitializeRequest result: ${this.isInitializeRequest(req.body)}`); - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: invalid session ID or method.' - }, - id: randomUUID() - }); - - } catch (error) { - this.logger.error(`Error handling MCP request: ${error}`); - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error.' - }, - id: randomUUID() - }); + this.sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided'); + return; + } + + if (method === 'GET' || method === 'DELETE') { + const sessionId = this.getSessionId(req); + if (!sessionId || !this.httpSessions.has(sessionId)) { + this.sendJsonRpcError(res, 400, -32000, 'Invalid or missing session ID'); + return; + } + const entry = this.httpSessions.get(sessionId)!; + await entry.transport.handleRequest(req, res); + return; } + + res.writeHead(405, { Allow: 'GET, POST, DELETE' }); + res.end(); } - + + /** + * 获取session ID + */ + private getSessionId(req: IncomingMessage): string | undefined { + const value = req.headers['mcp-session-id']; + if (Array.isArray(value)) return value[0]; + if (typeof value === 'string' && value.trim()) return value; + return undefined; + } + /** - * 检查是否是 initialize 请求 - 完全复制官方实现 + * 解析请求体 */ - private isInitializeRequest(body: any): boolean { - const isInitial = (data: any) => { - const result = InitializeRequestSchema.safeParse(data); - return result.success; - }; - if (Array.isArray(body)) { - return body.some((request) => isInitial(request)); + private async parseRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + const maxBytes = 2 * 1024 * 1024; // 2MB + + for await (const chunk of req) { + const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + totalBytes += data.length; + if (totalBytes > maxBytes) { + throw new Error(`Request body too large (> ${maxBytes} bytes)`); + } + chunks.push(data); + } + + if (chunks.length === 0) return undefined; + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return undefined; + + try { + return JSON.parse(raw); + } catch { + throw new Error('Invalid JSON body'); } - return isInitial(body); } - + /** - * 创建错误响应 - 完全复制官方实现 + * 参数摘要(截断长字段) */ - private createErrorResponse(message: string): any { - return { - jsonrpc: '2.0', - error: { - code: -32000, - message: message - }, - id: randomUUID() - }; + private summarizeArgs(args: Record): string { + const summary: Record = { ...args }; + if (typeof summary.content === 'string') { + const s = summary.content as string; + summary.content = s.length > 80 ? `${s.slice(0, 80)}... (${s.length} chars)` : s; + } + if (typeof summary.path === 'string') { + const p = summary.path as string; + summary.path = p.length > 120 ? `...${p.slice(-100)}` : p; + } + return JSON.stringify(summary); } - + + /** + * 发送JSON-RPC错误响应 + */ + private sendJsonRpcError( + res: ServerResponse, + statusCode: number, + code: number, + message: string + ): void { + this.applyCorsHeaders(res); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null })); + } + + /** + * 应用CORS头 + */ + private applyCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); + } + /** * 断开HTTP传输层 */ protected async disconnectTransport(): Promise { this.logger.info('Stopping HTTP server...'); - - // 关闭所有 transports 和 servers - for (const [sessionId, transport] of this.transports.entries()) { - this.logger.info(`Closing transport for session: ${sessionId}`); - await transport.close(); + + // 关闭所有sessions + for (const [sid, entry] of this.httpSessions.entries()) { + this.logger.info(`Closing session: ${sid}`); + try { + await entry.transport.close(); + } catch { + /* ignore */ + } + try { + await entry.server.close(); + } catch { + /* ignore */ + } + this.httpSessions.delete(sid); } - - // 清理所有servers(不需要显式关闭,因为它们不监听端口) - this.servers.clear(); - this.transports.clear(); - + // 关闭HTTP服务器 if (this.httpServer) { await new Promise((resolve) => { @@ -525,49 +387,26 @@ export class StreamableHttpMCPServer extends BaseMCPServer { resolve(); }); }); - this.httpServer = undefined; } - + // 终止 worker pool await this.workerPool.terminate(); this.logger.info('Worker pool terminated'); - - this.app = undefined; } - - /** - * 清理特定session的资源 - * 可以在session超时或客户端断开时调用 - */ - private async cleanupSession(sessionId: string): Promise { - this.logger.info(`Cleaning up session: ${sessionId}`); - - // 关闭Transport - const transport = this.transports.get(sessionId); - if (transport) { - await transport.close(); - this.transports.delete(sessionId); - } - - // 移除Server(垃圾回收会处理) - this.servers.delete(sessionId); - - this.logger.info(`Session cleaned up: ${sessionId}`); - } - + /** * 读取资源内容 */ protected async readResource(resource: Resource): Promise { try { const uri = new URL(resource.uri); - + if (uri.protocol === 'file:') { const filePath = uri.pathname; const resolvedPath = path.resolve(filePath); const content = await fs.readFile(resolvedPath, 'utf-8'); - + return { contents: [ { @@ -578,10 +417,9 @@ export class StreamableHttpMCPServer extends BaseMCPServer { ] }; } else if (uri.protocol === 'http:' || uri.protocol === 'https:') { - // 支持HTTP资源 const response = await fetch(resource.uri); const content = await response.text(); - + return { contents: [ { @@ -594,12 +432,13 @@ export class StreamableHttpMCPServer extends BaseMCPServer { } else { throw new Error(`Unsupported resource protocol: ${uri.protocol}`); } - } catch (error: any) { - this.logger.error(`Failed to read resource: ${resource.uri} - ${error}`); - throw new Error(`Failed to read resource: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to read resource: ${resource.uri} - ${message}`); + throw new Error(`Failed to read resource: ${message}`); } } - + /** * 重写 executeTool 方法,使用 WorkerPool 执行所有工具 */ @@ -608,43 +447,42 @@ export class StreamableHttpMCPServer extends BaseMCPServer { this.logger.warn(`Attempted to execute tool '${name}' while server is not running`); throw new Error('Server is not running'); } - + const tool = this.tools.get(name); if (!tool) { this.logger.error(`Tool not found: ${name}. Available tools: ${Array.from(this.tools.keys()).join(', ')}`); throw new Error(`Tool not found: ${name}`); } - + const startTime = Date.now(); - + this.logger.info(`[TOOL_EXEC_START] Tool: ${name} (via WorkerPool)`); this.logger.debug(`[TOOL_ARGS] ${name}: ${JSON.stringify(args)}`); - + try { - // 所有工具都通过 WorkerPool 执行 const result = await this.workerPool.execute(tool, args); - + const responseTime = Date.now() - startTime; this.logger.info(`[TOOL_EXEC_SUCCESS] Tool: ${name}, Time: ${responseTime}ms`); - + // 更新指标 this.metrics.requestCount++; - this.metrics.avgResponseTime = - (this.metrics.avgResponseTime * (this.metrics.requestCount - 1) + responseTime) / + this.metrics.avgResponseTime = + (this.metrics.avgResponseTime * (this.metrics.requestCount - 1) + responseTime) / this.metrics.requestCount; - + return result; - - } catch (error: any) { + + } catch (error: unknown) { const responseTime = Date.now() - startTime; - this.logger.error(`[TOOL_EXEC_ERROR] Tool: ${name}, Time: ${responseTime}ms, Error: ${error.message}`); - + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`[TOOL_EXEC_ERROR] Tool: ${name}, Time: ${responseTime}ms, Error: ${message}`); + // 更新错误计数 this.metrics.errorCount++; - this.lastError = error; - - // 重新抛出错误 + this.lastError = error instanceof Error ? error : new Error(String(error)); + throw error; } } -} \ No newline at end of file +} diff --git a/packages/mcp-server/src/tools/action.ts b/packages/mcp-server/src/tools/action.ts index 397b4c82..ff14c25b 100644 --- a/packages/mcp-server/src/tools/action.ts +++ b/packages/mcp-server/src/tools/action.ts @@ -3,166 +3,16 @@ import { MCPOutputAdapter } from '~/utils/MCPOutputAdapter.js'; const outputAdapter = new MCPOutputAdapter(); -const V2_DESCRIPTION_SECTION = ` -**V2 Roles (RoleX)**: Full lifecycle management (born → want → plan → todo → synthesize). - -On activate, version is auto-detected: V2 takes priority, falls back to V1 if not found. -Use \`version\` parameter to force a specific version: \`"v1"\` for DPML, \`"v2"\` for RoleX.`; - -const V2_EXAMPLES = ` -**V2 create role:** -\`\`\`json -{ "operation": "born", "role": "_", "name": "my-dev", "source": "Feature: ..." } -\`\`\` - -**V2 activate role:** -\`\`\`json -{ "operation": "activate", "role": "my-dev" } -\`\`\` - -**V2 create goal:** -\`\`\`json -{ "operation": "want", "role": "_", "name": "build-api", "source": "Feature: ..." } -\`\`\` - -**V2 check focus:** -\`\`\`json -{ "operation": "focus", "role": "_" } -\`\`\` - -**V2 finish task / achieve goal:** -\`\`\`json -// finish 操作会创建 encounter 节点,ID 格式为 {task-id}-finished -{ "operation": "finish", "role": "_", "name": "task-id", "encounter": "遇到的问题和经历..." } -{ "operation": "achieve", "role": "_", "experience": "learned..." } -\`\`\` - -**V2 complete learning cycle (want → plan → reflect → realize → master → synthesize):** -\`\`\`json -// 完整认知循环流程(基于实际测试验证): - -// 1. 创建目标 -{ "operation": "want", "role": "_", "name": "improve-process", "source": "Feature: 改进流程\\n 作为产品经理..." } - -// 2. 制定计划(必须传入 id 参数!) -{ "operation": "plan", "role": "_", "source": "Feature: 分析问题\\n Scenario: 调研...", "id": "analysis-plan" } - -// 3. 反思 - 创建经验(可跳过 encounter,直接创建) -{ - "operation": "reflect", - "role": "_", - "encounters": [], // 空数组 = 直接创建 experience,无需预定义 encounter - "experience": "Feature: 需求变更管理经验\\n 在项目管理中发现...\\n\\n Scenario: 问题表现\\n Then 需求反复修改导致延误\\n And 团队理解不一致产生返工", - "id": "exp-1" // 自定义 ID,用于后续引用 -} - -// 4. 领悟 - 提炼原则(必须基于已存在的 experience) -{ - "operation": "realize", - "role": "_", - "experiences": ["exp-1"], // 必须是已存在的 experience ID 数组(复数!) - "principle": "Feature: 需求变更管理原则\\n Scenario: 预防原则\\n Then 预防胜于控制\\n And 充分的需求调研", - "id": "principle-1" -} - -// 5. 沉淀 - 创建标准流程 -{ - "operation": "master", - "role": "_", - "procedure": "Feature: 需求变更管理SOP\\n Background:\\n Given 需求变更是常态\\n\\n Scenario: 变更申请阶段\\n When 收到变更请求\\n Then 记录变更内容\\n And 评估影响范围", - "id": "sop-1" -} - -// 6. 传授 - 向其他角色传授知识 -{ - "operation": "synthesize", - "role": "开发工程师", // 目标角色(接收知识的角色) - "name": "需求变更管理", - "source": "Feature: 需求变更管理 - 开发视角\\n Scenario: 配合要点\\n Then 及时反馈技术可行性", - "type": "knowledge" -} - -// 7. 遗忘 - 清理过时知识(可选) -{ "operation": "forget", "role": "_", "nodeId": "outdated-knowledge-id" } -\`\`\` - -**V2 learning cycle - 关键要点:** -\`\`\` -✅ Gherkin 格式必填: experience/principle/procedure/source 都必须使用 Gherkin 格式 -✅ Feature 开头: 必须以 "Feature: 标题" 开头,包含描述 -✅ Scenario 结构: 使用 Scenario/Background 定义场景,内部使用 Then/And/Given/When -✅ 空数组可用: reflect 时 encounters: [] 可直接创建 experience,无需预定义 encounter -✅ ID 数组必填: realize 的 experiences 必须是已存在的 experience ID 数组(复数) -✅ 角色注意: synthesize 的 role 是目标角色(接收知识的角色),不是当前角色 - -🚨 CRITICAL - plan 操作必须传入 id 参数: - plan 操作如果不传入 id 参数,focused_plan_id 不会被设置, - 导致后续 todo 操作失败并报错 "No focused plan. Call plan first." - - ❌ 错误: { "operation": "plan", "role": "_", "source": "..." } - ✅ 正确: { "operation": "plan", "role": "_", "source": "...", "id": "my-plan" } -\`\`\` - -**V2 alternative: 基于任务完成的认知循环:** -\`\`\`json -// 如果想基于实际任务经历: -// 1. 完成任务 → 自动创建 encounter (ID: {task-id}-finished) -{ "operation": "finish", "role": "_", "name": "task-1", "encounter": "遇到的问题..." } - -// 2. 反思 encounter → 创建 experience -{ "operation": "reflect", "role": "_", "encounters": ["task-1-finished"], "experience": "Feature: ...", "id": "exp-1" } - -// 3-6. 后续步骤同上 -\`\`\` - -**V2 synthesize (teach knowledge to a role):** -\`\`\`json -// synthesize 直接指定目标角色,无需先 activate -{ "operation": "synthesize", "role": "target-role", "name": "domain-knowledge", "source": "Feature: ...", "type": "knowledge" } -\`\`\` - -**Organization: view directory:** -\`\`\`json -{ "operation": "directory", "role": "_" } -\`\`\` - -**Organization: found org & hire role:** -\`\`\`json -{ "operation": "found", "role": "_", "name": "my-team", "source": "Feature: ..." } -{ "operation": "hire", "role": "_", "name": "my-dev", "org": "my-team" } -\`\`\` - -**Organization: establish position & appoint:** -\`\`\`json -// ⚠️ 关键:职位名必须是"角色名+岗位"格式,appoint 的 position 必须与 establish 的 name 完全一致 -{ "operation": "establish", "role": "_", "name": "技术负责人岗位", "source": "Feature: ...", "org": "my-team" } -{ "operation": "appoint", "role": "_", "name": "my-dev", "position": "技术负责人岗位", "org": "my-team" } -{ "operation": "charge", "role": "_", "position": "技术负责人岗位", "content": "Feature: ..." } -{ "operation": "require", "role": "_", "position": "lead", "skill": "leadership" } -{ "operation": "abolish", "role": "_", "position": "lead" } -\`\`\` - -**Individual lifecycle:** -\`\`\`json -{ "operation": "retire", "role": "_", "individual": "my-dev" } -{ "operation": "rehire", "role": "_", "individual": "my-dev" } -{ "operation": "die", "role": "_", "individual": "my-dev" } -{ "operation": "train", "role": "_", "individual": "my-dev", "skillId": "coding", "content": "Feature: ..." } -\`\`\` - -**Organization management:** -\`\`\`json -{ "operation": "charter", "role": "_", "org": "my-team", "content": "Feature: ..." } -{ "operation": "dissolve", "role": "_", "org": "my-team" } -\`\`\` -`; - export function createActionTool(enableV2: boolean): ToolWithHandler { - const description = `Role activation${enableV2 ? ' & lifecycle management' : ''} - load role knowledge, memory and capabilities + const description = `Role activation & creation - load role knowledge, memory and capabilities ## Core Features -**V1 Roles (DPML)**: Load role config (persona, principles, knowledge), display memory network.${enableV2 ? V2_DESCRIPTION_SECTION : ''} +**V1 Roles (DPML)**: Load role config (persona, principles, knowledge), display memory network.${enableV2 ? ` +**V2 Roles (RoleX)**: Create and activate V2 roles with full lifecycle support. + +On activate, version is auto-detected: V2 takes priority, falls back to V1 if not found. +Use \`version\` parameter to force a specific version: \`"v1"\` for DPML, \`"v2"\` for RoleX.` : ''} ## Cognitive Cycle @@ -179,25 +29,39 @@ export function createActionTool(enableV2: boolean): ToolWithHandler { | nuwa | 女娲 | AI role creation | | sean | Sean | Product decisions | | writer | Writer | Professional writing | - | dayu | 大禹 | Role migration & org management | > System roles require exact ID match. Use \`discover\` to list all available roles. ## Examples -**V1 activate role:** +**Activate a role (V1 or V2 auto-detect):** \`\`\`json { "role": "luban" } \`\`\` -${enableV2 ? V2_EXAMPLES : ''} +${enableV2 ? ` +**Create a V2 role:** +\`\`\`json +{ "operation": "born", "role": "_", "name": "my-dev", "source": "Feature: Developer\\n As a developer..." } +\`\`\` + +**Get role identity:** +\`\`\`json +{ "operation": "identity", "role": "my-dev" } +\`\`\` + +**Force V1 activation:** +\`\`\`json +{ "role": "nuwa", "version": "v1" } +\`\`\` +` : ''} ## On-Demand Resource Loading (V1 Roles) By default, only **personality** (persona + thought patterns) is loaded to save context. Use \`roleResources\` to load additional sections **before** you need them: -- **Before executing tools or tasks** → load \`principle\` first to get workflow, methodology and execution standards -- **When facing unfamiliar professional questions** → load \`knowledge\` first to get domain expertise +- **Before executing tools or tasks** → load \`principle\` first +- **When facing unfamiliar professional questions** → load \`knowledge\` first - **When you need full role capabilities at once** → load \`all\` \`\`\`json @@ -205,27 +69,22 @@ Use \`roleResources\` to load additional sections **before** you need them: { "role": "nuwa", "roleResources": "knowledge" } { "role": "nuwa", "roleResources": "all" } \`\`\` +${enableV2 ? ` +## Related Tools +After activating a V2 role, use these tools for further operations: +- **lifecycle**: Goal & task management (want → plan → todo → finish → achieve) +- **learning**: Cognitive cycle (reflect → realize → master → synthesize) +- **organization**: Org, position & personnel management +` : ''} ## Guidelines - Choose the right role for the task; suggest switching when out of scope - Act as the activated role, maintain its professional traits - Use \`discover\` first when a role is not found`; - const v2Operations = [ - 'born', 'identity', 'want', 'plan', 'todo', 'finish', 'achieve', 'abandon', 'focus', 'synthesize', - 'found', 'establish', 'hire', 'fire', 'appoint', 'dismiss', 'directory', - // 学习循环 - 'reflect', 'realize', 'master', 'forget', 'skill', - // 个体生命周期 - 'retire', 'die', 'rehire', 'train', - // 组织管理 - 'charter', 'dissolve', - // 职位管理 - 'charge', 'require', 'abolish' - ]; const operationEnum = enableV2 - ? ['activate', ...v2Operations] + ? ['activate', 'born', 'identity'] : ['activate']; return { @@ -238,7 +97,7 @@ Use \`roleResources\` to load additional sections **before** you need them: type: 'string', enum: operationEnum, description: enableV2 - ? 'Operation type. Default: activate. V2 lifecycle: born, identity, want, plan, todo, finish, achieve, abandon, focus, synthesize. Learning: reflect, realize, master, forget, skill. Organization: found, charter, dissolve, hire, fire. Position: establish, charge, require, appoint, dismiss, abolish. Individual: retire, die, rehire, train. Query: directory' + ? 'Operation: activate (default), born (create V2 role), identity (view role info)' : 'Operation type. Default: activate.' }, role: { @@ -248,86 +107,16 @@ Use \`roleResources\` to load additional sections **before** you need them: roleResources: { type: 'string', enum: ['all', 'personality', 'principle', 'knowledge'], - description: 'Resources to load for V1 roles (DPML): all(全部加载), personality(角色性格), principle(角色原则), knowledge(角色知识)' + description: 'Resources to load for V1 roles: all, personality, principle, knowledge' }, ...(enableV2 ? { name: { type: 'string', - description: 'Name parameter for born(role name), want(goal name), todo(task name), focus(focus item), synthesize(knowledge name), finish(task name)' + description: 'Role name for born operation' }, source: { type: 'string', - description: 'Gherkin source text for born/want/todo/synthesize/plan/establish operations' - }, - type: { - type: 'string', - description: 'Synthesize type: knowledge, experience, or voice. For synthesize operation, the role parameter specifies the target role to teach (no need to activate first).' - }, - experience: { - type: 'string', - description: 'Experience text (Gherkin Feature format) for reflect operation, or reflection text for achieve/abandon operations' - }, - testable: { - type: 'boolean', - description: 'Testable flag for want/todo operations' - }, - org: { - type: 'string', - description: 'Organization name for found/establish/hire/fire/appoint/dismiss' - }, - parent: { - type: 'string', - description: 'Parent organization name for found (nested orgs)' - }, - position: { - type: 'string', - description: 'Position name for appoint/charge/require/abolish' - }, - encounters: { - type: 'array', - items: { type: 'string' }, - description: 'Array of encounter node IDs for reflect operation. Must be existing encounter IDs (usually created by finish operation), or pass empty array [] to create experience directly without consuming encounters.' - }, - experiences: { - type: 'array', - items: { type: 'string' }, - description: 'Array of experience node IDs for realize operation. Must be existing experience IDs created by reflect operation. This parameter is REQUIRED for realize.' - }, - principle: { - type: 'string', - description: 'Gherkin source for principle in realize operation' - }, - procedure: { - type: 'string', - description: 'Gherkin source for procedure in master operation' - }, - nodeId: { - type: 'string', - description: 'Node ID for forget operation' - }, - locator: { - type: 'string', - description: 'Resource locator for skill operation (e.g., npm:@scope/package)' - }, - individual: { - type: 'string', - description: 'Individual ID for retire/die/rehire/train operations' - }, - skillId: { - type: 'string', - description: 'Skill ID for train/require operations' - }, - content: { - type: 'string', - description: 'Content for train/charter/charge operations' - }, - id: { - type: 'string', - description: 'Optional ID for plan/reflect/realize/master operations. IMPORTANT: plan operation REQUIRES id parameter to set focused_plan_id, otherwise todo will fail.' - }, - skill: { - type: 'string', - description: 'Skill name for require operation' + description: 'Gherkin source text for born operation' }, version: { type: 'string', @@ -338,7 +127,7 @@ Use \`roleResources\` to load additional sections **before** you need them: }, required: ['role'] }, - handler: async (args: { role: string; operation?: string; roleResources?: string; name?: string; source?: string; type?: string; experience?: string; testable?: boolean; org?: string; parent?: string; position?: string; version?: string }) => { + handler: async (args: { role: string; operation?: string; roleResources?: string; name?: string; source?: string; version?: string }) => { const operation = args.operation || 'activate'; // V2 disabled: always use V1 @@ -346,8 +135,8 @@ Use \`roleResources\` to load additional sections **before** you need them: return activateV1(args); } - // 非 activate 操作 → 直接走 RoleX V2 路径 - if (operation !== 'activate') { + // born / identity → 直接走 RoleX V2 路径 + if (operation === 'born' || operation === 'identity') { const core = await import('@promptx/core'); const coreExports = core.default || core; const { RolexActionDispatcher } = (coreExports as any).rolex; @@ -410,4 +199,3 @@ async function activateV1(args: { role: string; roleResources?: string }) { // 向后兼容导出(默认启用 V2) export const actionTool: ToolWithHandler = createActionTool(true); - diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index 32fd1122..5e6b1973 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -11,6 +11,11 @@ export { recallTool } from './recall.js'; export { rememberTool } from './remember.js'; export { toolxTool } from './toolx.js'; +// V2 拆分工具 +export { lifecycleTool, createLifecycleTool } from './lifecycle.js'; +export { learningTool, createLearningTool } from './learning.js'; +export { organizationTool, createOrganizationTool } from './organization.js'; + import { createDiscoverTool } from './welcome.js'; import { createActionTool } from './action.js'; import { projectTool } from './project.js'; @@ -18,13 +23,16 @@ import { projectTool } from './project.js'; import { recallTool } from './recall.js'; import { rememberTool } from './remember.js'; import { toolxTool } from './toolx.js'; +import { createLifecycleTool } from './lifecycle.js'; +import { createLearningTool } from './learning.js'; +import { createOrganizationTool } from './organization.js'; import type { ToolWithHandler } from '~/interfaces/MCPServer.js'; /** * 根据 enableV2 标志创建工具列表(工具描述和行为随之变化) */ export function createAllTools(enableV2: boolean): ToolWithHandler[] { - return [ + const tools: ToolWithHandler[] = [ createDiscoverTool(enableV2), createActionTool(enableV2), projectTool, @@ -33,6 +41,17 @@ export function createAllTools(enableV2: boolean): ToolWithHandler[] { rememberTool, toolxTool ]; + + // V2 拆分工具:仅在 enableV2 时注册 + if (enableV2) { + tools.push( + createLifecycleTool(enableV2), + createLearningTool(enableV2), + createOrganizationTool(enableV2) + ); + } + + return tools; } /** diff --git a/packages/mcp-server/src/tools/learning.ts b/packages/mcp-server/src/tools/learning.ts new file mode 100644 index 00000000..860463b5 --- /dev/null +++ b/packages/mcp-server/src/tools/learning.ts @@ -0,0 +1,151 @@ +import type { ToolWithHandler } from '~/interfaces/MCPServer.js'; +import { MCPOutputAdapter } from '~/utils/MCPOutputAdapter.js'; + +const outputAdapter = new MCPOutputAdapter(); + +export function createLearningTool(enableV2: boolean): ToolWithHandler { + const description = `V2 role cognitive learning cycle - reflect, distill principles, and teach knowledge + +## Operations + +| Operation | Required Params | Description | +|-----------|----------------|-------------| +| reflect | encounters, experience, id | Create experience from encounters (pass encounters:[] to create directly) | +| realize | experiences, principle, id | Distill principles from experiences | +| master | procedure, id | Create standard procedures from principles | +| forget | nodeId | Remove outdated knowledge | +| synthesize | role, name, source, type | Teach knowledge to another role | +| skill | locator | Load a skill resource | + +## Learning Cycle + +\`\`\` +reflect (create experience) → realize (distill principle) → master (create procedure) → synthesize (teach to others) +\`\`\` + +## Key Rules + +- All \`experience\`, \`principle\`, \`procedure\`, and \`source\` MUST use Gherkin Feature format +- \`reflect\`: pass \`encounters: []\` to create experience directly without consuming encounters +- \`realize\`: \`experiences\` must be an array of existing experience IDs +- \`synthesize\`: \`role\` is the **target** role (who receives knowledge), not the current role + +## Examples + +\`\`\`json +{ "operation": "reflect", "role": "_", "encounters": [], "experience": "Feature: API Design Experience\\n Scenario: Problem\\n Then learned to use pagination", "id": "exp-1" } +{ "operation": "realize", "role": "_", "experiences": ["exp-1"], "principle": "Feature: API Principle\\n Scenario: Always paginate\\n Then use cursor-based pagination", "id": "p-1" } +{ "operation": "master", "role": "_", "procedure": "Feature: API SOP\\n Scenario: New endpoint\\n When creating endpoint\\n Then add pagination\\n And add rate limiting", "id": "sop-1" } +{ "operation": "synthesize", "role": "backend-dev", "name": "api-knowledge", "source": "Feature: API Best Practices...", "type": "knowledge" } +{ "operation": "forget", "role": "_", "nodeId": "outdated-id" } +\`\`\` + +## Prerequisites + +A V2 role must be activated first via the \`action\` tool before using learning operations (except synthesize).`; + + return { + name: 'learning', + description, + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['reflect', 'realize', 'master', 'forget', 'synthesize', 'skill'], + description: 'Learning operation to perform' + }, + role: { + type: 'string', + description: 'Active role ID ("_" for current role), or target role ID for synthesize' + }, + name: { + type: 'string', + description: 'Knowledge name for synthesize operation' + }, + source: { + type: 'string', + description: 'Gherkin source text for synthesize operation' + }, + type: { + type: 'string', + description: 'Synthesize type: "knowledge", "experience", or "voice"' + }, + id: { + type: 'string', + description: 'Custom ID for the created node (reflect/realize/master)' + }, + encounters: { + type: 'array', + items: { type: 'string' }, + description: 'Encounter IDs for reflect. Pass [] to create experience directly' + }, + experiences: { + type: 'array', + items: { type: 'string' }, + description: 'Experience IDs for realize. Must be existing experience IDs' + }, + experience: { + type: 'string', + description: 'Gherkin Feature text for reflect operation' + }, + principle: { + type: 'string', + description: 'Gherkin Feature text for realize operation' + }, + procedure: { + type: 'string', + description: 'Gherkin Feature text for master operation' + }, + nodeId: { + type: 'string', + description: 'Node ID to remove for forget operation' + }, + locator: { + type: 'string', + description: 'Resource locator for skill (e.g., npm:@scope/package)' + } + }, + required: ['role', 'operation'] + }, + handler: async (args: Record) => { + const operation = args.operation; + const core = await import('@promptx/core'); + const coreExports = core.default || core; + const { RolexActionDispatcher } = (coreExports as any).rolex; + const dispatcher = new RolexActionDispatcher(); + + // 检查角色是否为 V1(不支持 learning 操作) + // synthesize 的 role 是目标角色,不做检查 + if (args.role && args.role !== '_' && operation !== 'synthesize') { + try { + const isV2 = await dispatcher.isV2Role(args.role); + if (!isV2) { + return outputAdapter.convertToMCPFormat({ + type: 'error', + content: `❌ V1 角色 "${args.role}" 不支持 learning 工具 + +learning 工具仅支持 V2 角色(RoleX)。V1 角色(DPML)请使用 recall/remember 工具管理知识。 + +**V1 角色知识管理**: +• \`recall\` - 检索角色记忆 +• \`remember\` - 保存新知识 + +**如需使用 learning 工具**,请先创建 V2 角色: +\`\`\`json +{ "operation": "born", "role": "_", "name": "my-role", "source": "Feature: ..." } +\`\`\`` + }); + } + } catch (e) { + console.warn('[learning] V2 role check failed, continuing:', e); + } + } + + const result = await dispatcher.dispatch(operation, args); + return outputAdapter.convertToMCPFormat(result); + } + }; +} + +export const learningTool: ToolWithHandler = createLearningTool(true); diff --git a/packages/mcp-server/src/tools/lifecycle.ts b/packages/mcp-server/src/tools/lifecycle.ts new file mode 100644 index 00000000..bb1e3be0 --- /dev/null +++ b/packages/mcp-server/src/tools/lifecycle.ts @@ -0,0 +1,122 @@ +import type { ToolWithHandler } from '~/interfaces/MCPServer.js'; +import { MCPOutputAdapter } from '~/utils/MCPOutputAdapter.js'; + +const outputAdapter = new MCPOutputAdapter(); + +export function createLifecycleTool(enableV2: boolean): ToolWithHandler { + const description = `V2 role goal & task lifecycle management + +## Operations + +| Operation | Required Params | Description | +|-----------|----------------|-------------| +| want | name, source | Create a goal for the active role | +| plan | source, **id** | Create a plan under the current goal. **id is REQUIRED** or todo will fail | +| todo | name, source | Create a task under the current plan | +| finish | name | Complete a task (creates an encounter node with ID: {name}-finished) | +| achieve | experience | Achieve the current goal with a reflection | +| abandon | experience | Abandon the current goal with a reason | +| focus | name | Switch focus to a specific goal/plan/task | + +## Workflow + +\`\`\` +want (create goal) → plan (create plan, MUST pass id) → todo (create tasks) → finish (complete tasks) → achieve (complete goal) +\`\`\` + +## Examples + +\`\`\`json +{ "operation": "want", "role": "_", "name": "build-api", "source": "Feature: Build REST API\\n As a developer..." } +{ "operation": "plan", "role": "_", "source": "Feature: API Design\\n Scenario: endpoints...", "id": "api-plan" } +{ "operation": "todo", "role": "_", "name": "implement-auth", "source": "Feature: Auth endpoint..." } +{ "operation": "finish", "role": "_", "name": "implement-auth", "encounter": "Encountered CORS issues..." } +{ "operation": "achieve", "role": "_", "experience": "learned REST best practices..." } +{ "operation": "focus", "role": "_", "name": "api-plan" } +\`\`\` + +## Prerequisites + +A V2 role must be activated first via the \`action\` tool before using lifecycle operations.`; + + return { + name: 'lifecycle', + description, + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['want', 'plan', 'todo', 'finish', 'achieve', 'abandon', 'focus'], + description: 'Lifecycle operation to perform' + }, + role: { + type: 'string', + description: 'Active role ID, or "_" to use the currently active role' + }, + name: { + type: 'string', + description: 'Name of the goal (want), task (todo/finish), or focus target (focus)' + }, + source: { + type: 'string', + description: 'Gherkin Feature source text for want/plan/todo' + }, + id: { + type: 'string', + description: 'Plan ID. REQUIRED for plan operation to set focused_plan_id' + }, + testable: { + type: 'boolean', + description: 'Whether the goal/task is testable (for want/todo)' + }, + experience: { + type: 'string', + description: 'Reflection text for achieve/abandon operations' + }, + encounter: { + type: 'string', + description: 'Encounter description for finish operation' + } + }, + required: ['role', 'operation'] + }, + handler: async (args: Record) => { + const operation = args.operation; + const core = await import('@promptx/core'); + const coreExports = core.default || core; + const { RolexActionDispatcher } = (coreExports as any).rolex; + const dispatcher = new RolexActionDispatcher(); + + // 检查角色是否为 V1(不支持 lifecycle 操作) + if (args.role && args.role !== '_') { + try { + const isV2 = await dispatcher.isV2Role(args.role); + if (!isV2) { + return outputAdapter.convertToMCPFormat({ + type: 'error', + content: `❌ V1 角色 "${args.role}" 不支持 lifecycle 工具 + +lifecycle 工具仅支持 V2 角色(RoleX)。V1 角色(DPML)不支持目标与任务管理。 + +**解决方案**: +1. 先使用 action 工具创建一个 V2 角色: +\`\`\`json +{ "operation": "born", "role": "_", "name": "my-role", "source": "Feature: ..." } +\`\`\` +2. 然后激活该 V2 角色后再使用 lifecycle 工具` + }); + } + } catch (e) { + // 检查失败时继续执行,让 dispatcher 自行处理 + console.warn('[lifecycle] V2 role check failed, continuing:', e); + } + } + + const result = await dispatcher.dispatch(operation, args); + return outputAdapter.convertToMCPFormat(result); + } + }; +} + +export const lifecycleTool: ToolWithHandler = createLifecycleTool(true); diff --git a/packages/mcp-server/src/tools/organization.ts b/packages/mcp-server/src/tools/organization.ts new file mode 100644 index 00000000..0397e13e --- /dev/null +++ b/packages/mcp-server/src/tools/organization.ts @@ -0,0 +1,145 @@ +import type { ToolWithHandler } from '~/interfaces/MCPServer.js'; +import { MCPOutputAdapter } from '~/utils/MCPOutputAdapter.js'; + +const outputAdapter = new MCPOutputAdapter(); + +export function createOrganizationTool(enableV2: boolean): ToolWithHandler { + const description = `V2 organization, position, and individual management + +## Organization Operations + +| Operation | Required Params | Description | +|-----------|----------------|-------------| +| found | name, source | Create a new organization | +| charter | org, content | Set organization charter | +| dissolve | org | Dissolve an organization | +| directory | (none) | View organization directory | + +## Position Operations + +| Operation | Required Params | Description | +|-----------|----------------|-------------| +| establish | name, source, org | Create a position in an organization | +| charge | position, content | Assign responsibilities to a position | +| require | position, skill | Add skill requirement to a position | +| abolish | position | Remove a position | + +## Personnel Operations + +| Operation | Required Params | Description | +|-----------|----------------|-------------| +| hire | name, org | Hire a role into an organization | +| fire | name, org | Remove a role from an organization | +| appoint | name, position, org | Appoint a role to a position | +| dismiss | name, org | Dismiss a role from a position | +| retire | individual | Retire an individual | +| rehire | individual | Rehire a retired individual | +| die | individual | Permanently remove an individual | +| train | individual, skillId, content | Train an individual with a skill | + +## Examples + +\`\`\`json +{ "operation": "found", "role": "_", "name": "dev-team", "source": "Feature: Dev Team\\n Build products..." } +{ "operation": "hire", "role": "_", "name": "my-dev", "org": "dev-team" } +{ "operation": "establish", "role": "_", "name": "tech-lead", "source": "Feature: Tech Lead...", "org": "dev-team" } +{ "operation": "appoint", "role": "_", "name": "my-dev", "position": "tech-lead", "org": "dev-team" } +{ "operation": "directory", "role": "_" } +\`\`\``; + + return { + name: 'organization', + description, + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + enum: [ + 'found', 'charter', 'dissolve', 'directory', + 'establish', 'charge', 'require', 'abolish', + 'hire', 'fire', 'appoint', 'dismiss', + 'retire', 'rehire', 'die', 'train' + ], + description: 'Organization/position/personnel operation to perform' + }, + role: { + type: 'string', + description: 'Active role ID, or "_" to use the currently active role' + }, + name: { + type: 'string', + description: 'Name of the organization (found), position (establish), or individual (hire/fire/appoint/dismiss)' + }, + source: { + type: 'string', + description: 'Gherkin source text for found/establish operations' + }, + org: { + type: 'string', + description: 'Target organization name' + }, + parent: { + type: 'string', + description: 'Parent organization name for nested orgs (found)' + }, + position: { + type: 'string', + description: 'Position name for appoint/charge/require/abolish' + }, + individual: { + type: 'string', + description: 'Individual ID for retire/die/rehire/train' + }, + skillId: { + type: 'string', + description: 'Skill ID for train operation' + }, + skill: { + type: 'string', + description: 'Skill name for require operation' + }, + content: { + type: 'string', + description: 'Content for charter/charge/train operations' + } + }, + required: ['role', 'operation'] + }, + handler: async (args: Record) => { + const operation = args.operation; + const core = await import('@promptx/core'); + const coreExports = core.default || core; + const { RolexActionDispatcher } = (coreExports as any).rolex; + const dispatcher = new RolexActionDispatcher(); + + // organization 操作大部分不需要 _requireActiveRole, + // 但仍然检查非 "_" 的角色是否为 V1,给出友好提示 + if (args.role && args.role !== '_') { + try { + const isV2 = await dispatcher.isV2Role(args.role); + if (!isV2) { + return outputAdapter.convertToMCPFormat({ + type: 'error', + content: `❌ V1 角色 "${args.role}" 不支持 organization 工具 + +organization 工具仅支持 V2 角色(RoleX)。 + +**如需使用 organization 工具**,请先创建 V2 角色: +\`\`\`json +{ "operation": "born", "role": "_", "name": "my-role", "source": "Feature: ..." } +\`\`\`` + }); + } + } catch (e) { + console.warn('[organization] V2 role check failed, continuing:', e); + } + } + + const result = await dispatcher.dispatch(operation, args); + return outputAdapter.convertToMCPFormat(result); + } + }; +} + +export const organizationTool: ToolWithHandler = createOrganizationTool(true); diff --git a/packages/mcp-server/src/tools/recall.ts b/packages/mcp-server/src/tools/recall.ts index ceb88e6a..38acc819 100644 --- a/packages/mcp-server/src/tools/recall.ts +++ b/packages/mcp-server/src/tools/recall.ts @@ -93,33 +93,29 @@ Step 3: Answer using recalled context type: 'error', content: `❌ V2 角色 "${args.role}" 不支持 recall 工具 -V2 角色(RoleX)使用数据库存储和认知循环系统,请使用 action 工具查询角色知识: +V2 角色(RoleX)使用数据库存储和认知循环系统,请使用以下工具: -🔍 **查询角色知识**: +🔍 **查询角色知识**(action 工具): • identity - 查看角色完整身份和知识体系 + +📋 **目标与任务管理**(lifecycle 工具): • focus - 查看当前进行中的目标和任务 -🧠 **自我沉淀(学习循环)**: +🧠 **自我沉淀**(learning 工具): • reflect - 反思遇到的问题,创建经验 • realize - 总结领悟的原则 • master - 沉淀为标准操作流程(SOP) • synthesize - 向其他角色传授知识 • forget - 遗忘过时的知识 -**示例 - 查看角色知识**: +**示例 - 查看角色知识**(action 工具): \`\`\`json -{ - "operation": "identity", - "role": "${args.role}" -} +{ "operation": "identity", "role": "${args.role}" } \`\`\` -**示例 - 查看当前进度**: +**示例 - 查看当前进度**(lifecycle 工具): \`\`\`json -{ - "operation": "focus", - "role": "${args.role}" -} +{ "operation": "focus", "role": "${args.role}" } \`\`\` 当前 recall 工具仅支持 V1 角色(DPML 格式)。` diff --git a/packages/mcp-server/src/tools/remember.ts b/packages/mcp-server/src/tools/remember.ts index 9461d021..d40a517e 100644 --- a/packages/mcp-server/src/tools/remember.ts +++ b/packages/mcp-server/src/tools/remember.ts @@ -131,16 +131,16 @@ Strip content to minimum essential words. For each word ask: does removing it ch type: 'error', content: `❌ V2 角色 "${args.role}" 不支持 remember 工具 -V2 角色(RoleX)使用数据库存储和认知循环系统,请使用 action 工具的自我沉淀操作: +V2 角色(RoleX)使用数据库存储和认知循环系统,请使用 learning 工具: -🧠 **自我沉淀(学习循环)**: +🧠 **自我沉淀(learning 工具)**: • reflect - 反思遇到的问题,创建经验 • realize - 总结领悟的原则 • master - 沉淀为标准操作流程(SOP) • synthesize - 向其他角色传授知识 • forget - 遗忘过时的知识 -**示例**: +**示例**(learning 工具): \`\`\`json { "operation": "reflect", diff --git a/packages/mcp-workspace/package.json b/packages/mcp-workspace/package.json new file mode 100644 index 00000000..f04de3d5 --- /dev/null +++ b/packages/mcp-workspace/package.json @@ -0,0 +1,39 @@ +{ + "name": "@promptx/mcp-workspace", + "version": "2.2.1", + "description": "MCP server for managing workspace files and directories", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "bin": { + "mcp-workspace": "./dist/mcp-server.js" + }, + "files": [ + "dist", + "package.json", + "README.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rimraf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@promptx/logger": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "tsup": "^8.5.0", + "typescript": "^5.9.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mcp-workspace/src/bin/mcp-server.ts b/packages/mcp-workspace/src/bin/mcp-server.ts new file mode 100644 index 00000000..25e6f00b --- /dev/null +++ b/packages/mcp-workspace/src/bin/mcp-server.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +/** + * Workspace MCP CLI 入口 + * + * 用法: + * mcp-workspace # 默认启动 HTTP 服务 + * mcp-workspace --transport stdio # stdio 模式 + * mcp-workspace --url http://host:port/mcp # 指定 HTTP URL + * + * 环境变量: + * WORKSPACE_MCP_URL - 指定 MCP 服务 URL (默认: http://127.0.0.1:18062/mcp) + * WORKSPACE_MCP_TRANSPORT - 指定传输模式 stdio|http (默认: http) + */ + +import { startHttpServer } from '../http-server.js'; +import { startStdioServer } from '../stdio-server.js'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); + +const DEFAULT_MCP_URL = 'http://127.0.0.1:18062/mcp'; + +function parseArgs(): { mcpUrl: string; transport: 'http' | 'stdio' } { + const args = process.argv.slice(2); + let mcpUrl = process.env.WORKSPACE_MCP_URL || DEFAULT_MCP_URL; + let transport: 'http' | 'stdio' = + (process.env.WORKSPACE_MCP_TRANSPORT as 'http' | 'stdio') || 'http'; + + for (let i = 0; i < args.length; i++) { + if (args[i]!.startsWith('--url=')) { + mcpUrl = args[i]!.slice('--url='.length); + } else if (args[i] === '--url' && args[i + 1]) { + mcpUrl = args[++i]!; + } else if (args[i]!.startsWith('--transport=')) { + transport = args[i]!.slice('--transport='.length) as 'http' | 'stdio'; + } else if (args[i] === '--transport' && args[i + 1]) { + transport = args[++i]! as 'http' | 'stdio'; + } + } + + return { mcpUrl, transport }; +} + +async function main() { + const { mcpUrl, transport } = parseArgs(); + logger.info('Workspace MCP starting...'); + + if (transport === 'stdio') { + await startStdioServer(); + } else { + await startHttpServer({ mcpUrl }); + } +} + +main().catch((err) => { + logger.error('Fatal:', err); + process.exit(1); +}); diff --git a/packages/mcp-workspace/src/http-server.ts b/packages/mcp-workspace/src/http-server.ts new file mode 100644 index 00000000..c1203911 --- /dev/null +++ b/packages/mcp-workspace/src/http-server.ts @@ -0,0 +1,268 @@ +/** + * Workspace MCP HTTP 服务器 + * + * 基于 Streamable HTTP 协议提供 MCP 服务 + */ + +import { randomUUID } from 'node:crypto'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + isInitializeRequest, + type CallToolRequest, +} from '@modelcontextprotocol/sdk/types.js'; + +import { WORKSPACE_TOOLS, handleWorkspaceTool } from './tools/index.js'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); +const MCP_VERSION = '1.0.0'; + +type SessionEntry = { + server: McpProtocolServer; + transport: StreamableHTTPServerTransport; +}; + +interface ParsedMcpUrl { + host: string; + port: number; + path: string; + fullUrl: string; +} + +export interface HttpServerConfig { + mcpUrl: string; +} + +export async function startHttpServer(config: HttpServerConfig): Promise { + const endpoint = parseMcpUrl(config.mcpUrl); + const sessions = new Map(); + + const httpServer = createServer(async (req, res) => { + try { + await handleHttpRequest(req, res, endpoint, sessions); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`HTTP request failed: ${message}`); + if (!res.headersSent) { + sendJsonRpcError(res, 500, -32603, 'Internal server error'); + } + } + }); + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(endpoint.port, endpoint.host, () => { + httpServer.off('error', reject); + resolve(); + }); + }); + + logger.info(`Starting v${MCP_VERSION} (http)...`); + logger.info(`URL: ${endpoint.fullUrl}`); + logger.info('Ready'); + + const shutdown = async () => { + logger.info('Shutting down...'); + for (const [sid, entry] of sessions.entries()) { + try { await entry.transport.close(); } catch { /* ignore */ } + try { await entry.server.close(); } catch { /* ignore */ } + sessions.delete(sid); + } + await new Promise((resolve) => httpServer.close(() => resolve())); + process.exit(0); + }; + + process.once('SIGINT', () => { void shutdown(); }); + process.once('SIGTERM', () => { void shutdown(); }); +} + +function buildProtocolServer(): McpProtocolServer { + const server = new McpProtocolServer( + { name: 'workspace-mcp', version: MCP_VERSION }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: WORKSPACE_TOOLS, + })); + + server.setRequestHandler( + CallToolRequestSchema, + async (request: CallToolRequest) => { + const { name, arguments: args } = request.params; + logger.info(`Tool call: ${name} ${summarizeArgs(args || {})}`); + return handleWorkspaceTool(name, args || {}); + } + ); + + return server; +} + +async function handleHttpRequest( + req: IncomingMessage, + res: ServerResponse, + endpoint: ParsedMcpUrl, + sessions: Map +): Promise { + const method = (req.method || '').toUpperCase(); + const requestUrl = new URL( + req.url || '/', + `http://${req.headers.host || `${endpoint.host}:${endpoint.port}`}` + ); + + applyCorsHeaders(res); + + if (method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (requestUrl.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + service: 'workspace-mcp', + version: MCP_VERSION, + sessions: sessions.size, + })); + return; + } + + if (requestUrl.pathname !== endpoint.path) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + + if (method === 'POST') { + let body: unknown; + try { + body = await parseRequestBody(req); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Invalid JSON body'; + const statusCode = message.includes('too large') ? 413 : 400; + sendJsonRpcError(res, statusCode, -32700, message); + return; + } + + const sessionId = getSessionId(req); + + if (sessionId && sessions.has(sessionId)) { + const entry = sessions.get(sessionId)!; + await entry.transport.handleRequest(req, res, body); + return; + } + + if (!sessionId && isInitializeRequest(body)) { + const server = buildProtocolServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + sessions.set(sid, { server, transport }); + logger.info(`Session initialized: ${sid}`); + }, + }); + + let closed = false; + transport.onclose = () => { + if (closed) return; + closed = true; + const sid = transport.sessionId; + if (sid && sessions.delete(sid)) { + logger.info(`Session closed: ${sid}`); + } + void server.close().catch(() => { /* ignore */ }); + }; + + await server.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + + sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided'); + return; + } + + if (method === 'GET' || method === 'DELETE') { + const sessionId = getSessionId(req); + if (!sessionId || !sessions.has(sessionId)) { + sendJsonRpcError(res, 400, -32000, 'Invalid or missing session ID'); + return; + } + const entry = sessions.get(sessionId)!; + await entry.transport.handleRequest(req, res); + return; + } + + res.writeHead(405, { Allow: 'GET, POST, DELETE' }); + res.end(); +} + +function parseMcpUrl(rawUrl: string): ParsedMcpUrl { + const parsed = new URL(rawUrl); + const host = parsed.hostname || '127.0.0.1'; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === 'https:' ? 443 : 80; + const path = (parsed.pathname || '/mcp').replace(/\/$/, '') || '/mcp'; + return { host, port, path, fullUrl: `${parsed.protocol}//${host}:${port}${path}` }; +} + +function getSessionId(req: IncomingMessage): string | undefined { + const value = req.headers['mcp-session-id']; + if (Array.isArray(value)) return value[0]; + if (typeof value === 'string' && value.trim()) return value; + return undefined; +} + +async function parseRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + const maxBytes = 2 * 1024 * 1024; + + for await (const chunk of req) { + const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + totalBytes += data.length; + if (totalBytes > maxBytes) throw new Error(`Request body too large (> ${maxBytes} bytes)`); + chunks.push(data); + } + + if (chunks.length === 0) return undefined; + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return undefined; + + try { return JSON.parse(raw); } + catch { throw new Error('Invalid JSON body'); } +} + +function summarizeArgs(args: Record): string { + const summary: Record = { ...args }; + if (typeof summary.content === 'string') { + const s = summary.content as string; + summary.content = s.length > 80 ? `${s.slice(0, 80)}... (${s.length} chars)` : s; + } + if (typeof summary.path === 'string') { + const p = summary.path as string; + summary.path = p.length > 120 ? `...${p.slice(-100)}` : p; + } + return JSON.stringify(summary); +} + +function sendJsonRpcError(res: ServerResponse, statusCode: number, code: number, message: string): void { + applyCorsHeaders(res); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null })); +} + +function applyCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); +} diff --git a/packages/mcp-workspace/src/index.ts b/packages/mcp-workspace/src/index.ts new file mode 100644 index 00000000..c5839c4b --- /dev/null +++ b/packages/mcp-workspace/src/index.ts @@ -0,0 +1,23 @@ +/** + * Workspace MCP - 工作区文件操作 MCP 服务器 + * + * 提供 AI 安全访问用户本地工作区文件的能力 + * + * ## 功能 + * - list_workspaces: 获取工作区列表 + * - list_workspace_directory: 列出目录内容 + * - read_workspace_file: 读取文件内容 + * - write_workspace_file: 写入文件 + * - create_workspace_directory: 创建目录 + * - delete_workspace_item: 删除文件/目录 + */ + +export { WORKSPACE_TOOLS, handleWorkspaceTool } from './tools/index.js'; +export { + listWorkspaces, + listDirectory, + readWorkspaceFile, + writeWorkspaceFile, + createWorkspaceDirectory, + deleteWorkspaceItem, +} from './service/index.js'; diff --git a/packages/mcp-workspace/src/service/index.ts b/packages/mcp-workspace/src/service/index.ts new file mode 100644 index 00000000..f0a20ee8 --- /dev/null +++ b/packages/mcp-workspace/src/service/index.ts @@ -0,0 +1,8 @@ +export { + listWorkspaces, + listDirectory, + readWorkspaceFile, + writeWorkspaceFile, + createWorkspaceDirectory, + deleteWorkspaceItem, +} from './workspace.service.js'; diff --git a/packages/mcp-workspace/src/service/workspace.service.ts b/packages/mcp-workspace/src/service/workspace.service.ts new file mode 100644 index 00000000..5ee1e148 --- /dev/null +++ b/packages/mcp-workspace/src/service/workspace.service.ts @@ -0,0 +1,218 @@ +/** + * 工作区文件操作服务 + * + * 直接操作本地文件系统,读取 ~/.promptx/workspaces.json 获取工作区配置。 + * 所有路径操作都会校验是否在已绑定的工作区范围内。 + */ + +import { readFileSync, existsSync, createReadStream } from 'node:fs'; +import { readdir, stat, rm, unlink, mkdir, writeFile } from 'node:fs/promises'; +import { join, basename, resolve, normalize } from 'node:path'; +import { homedir } from 'node:os'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); + +interface WorkspaceFolder { + id: string; + path: string; + name: string; + added_at: string; +} + +interface WorkspaceConfig { + folders: WorkspaceFolder[]; +} + +interface DirEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + modified: string | null; +} + +const CONFIG_PATH = join(homedir(), '.promptx', 'workspaces.json'); + +const HIDDEN_DIRS = new Set([ + 'node_modules', '__pycache__', 'target', '.git', '.svn', + '.hg', '.DS_Store', 'Thumbs.db', '.idea', '.vscode', +]); + +const BINARY_EXTS = new Set([ + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'svg', + 'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', + 'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', + 'exe', 'dll', 'so', 'dylib', 'bin', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'woff', 'woff2', 'ttf', 'otf', 'eot', + 'db', 'sqlite', 'sqlite3', + 'psd', 'ai', 'sketch', 'fig', +]); + +const MAX_READ_BYTES = 512 * 1024; +const MAX_LINES = 5_000; +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +function loadConfig(): WorkspaceConfig { + try { + if (existsSync(CONFIG_PATH)) { + const raw = readFileSync(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw) as WorkspaceConfig; + } + } catch { + logger.warn('Failed to load workspace config'); + } + return { folders: [] }; +} + +function assertWithinWorkspace(filePath: string, config: WorkspaceConfig): void { + const normalized = normalize(resolve(filePath)); + const isWithin = config.folders.some(f => normalized.startsWith(normalize(resolve(f.path)))); + if (!isWithin) { + throw new Error(`路径不在任何工作区内: ${filePath}`); + } +} + +function getExt(name: string): string { + const dot = name.lastIndexOf('.'); + return dot === -1 ? '' : name.slice(dot + 1).toLowerCase(); +} + +function isBinaryExt(name: string): boolean { + return BINARY_EXTS.has(getExt(name)); +} + +function formatDate(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +export async function listWorkspaces(): Promise { + const config = loadConfig(); + logger.info(`[listWorkspaces] ${config.folders.length} folders`); + return config.folders; +} + +export async function listDirectory(dirPath: string): Promise { + const config = loadConfig(); + assertWithinWorkspace(dirPath, config); + + const entries = await readdir(dirPath, { withFileTypes: true }); + const results: DirEntry[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.') || HIDDEN_DIRS.has(entry.name)) continue; + + const fullPath = join(dirPath, entry.name); + try { + const s = await stat(fullPath); + results.push({ + name: entry.name, + path: fullPath, + is_dir: entry.isDirectory(), + size: s.size, + modified: formatDate(s.mtime), + }); + } catch { + // skip inaccessible entries + } + } + + results.sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + logger.info(`[listDirectory] ${dirPath} → ${results.length} entries`); + return results; +} + +export async function readWorkspaceFile(filePath: string): Promise { + const config = loadConfig(); + assertWithinWorkspace(filePath, config); + + const s = await stat(filePath); + if (!s.isFile()) throw new Error(`不是文件: ${filePath}`); + + if (s.size === 0) { + logger.info(`[readWorkspaceFile] ${filePath} (empty file)`); + return '(空文件)'; + } + + if (isBinaryExt(basename(filePath))) { + throw new Error('二进制文件无法作为文本读取,请使用其他方式处理'); + } + + const fileSize = s.size; + if (fileSize > MAX_FILE_SIZE) { + throw new Error(`文件过大 (${(fileSize / 1024 / 1024).toFixed(1)}MB),超过 ${MAX_FILE_SIZE / 1024 / 1024}MB 限制`); + } + + const readSize = Math.min(fileSize, MAX_READ_BYTES); + const buffer = Buffer.alloc(readSize); + + await new Promise((resolve, reject) => { + let offset = 0; + const stream = createReadStream(filePath, { start: 0, end: readSize - 1 }); + stream.on('data', (chunk: Buffer | string) => { + const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + buf.copy(buffer, offset); + offset += buf.length; + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const text = buffer.toString('utf-8'); + const lines = text.split('\n'); + const wasTruncatedBySize = fileSize > MAX_READ_BYTES; + const wasTruncatedByLines = lines.length > MAX_LINES; + const displayLines = wasTruncatedByLines ? lines.slice(0, MAX_LINES) : lines; + let result = displayLines.join('\n'); + + if (wasTruncatedBySize || wasTruncatedByLines) { + result += `\n\n─── 文件已截断(原始大小 ${(fileSize / 1024 / 1024).toFixed(1)}MB,显示前 ${displayLines.length} 行)───`; + } + + logger.info(`[readWorkspaceFile] ${filePath} (${(readSize / 1024).toFixed(0)}KB read)`); + return result; +} + +export async function writeWorkspaceFile(filePath: string, content: string): Promise { + const config = loadConfig(); + assertWithinWorkspace(filePath, config); + + const dir = join(filePath, '..'); + await mkdir(dir, { recursive: true }); + await writeFile(filePath, content, 'utf-8'); + + logger.info(`[writeWorkspaceFile] ${filePath} (${content.length} bytes)`); +} + +export async function createWorkspaceDirectory(dirPath: string): Promise { + const config = loadConfig(); + assertWithinWorkspace(dirPath, config); + + await mkdir(dirPath, { recursive: true }); + logger.info(`[createWorkspaceDirectory] ${dirPath}`); +} + +export async function deleteWorkspaceItem(itemPath: string): Promise { + const config = loadConfig(); + assertWithinWorkspace(itemPath, config); + + if (config.folders.some(f => normalize(resolve(f.path)) === normalize(resolve(itemPath)))) { + throw new Error('不能删除工作区根目录,请使用移除工作区功能'); + } + + const s = await stat(itemPath); + if (s.isDirectory()) { + await rm(itemPath, { recursive: true, force: true }); + } else { + await unlink(itemPath); + } + + logger.info(`[deleteWorkspaceItem] ${itemPath}`); +} diff --git a/packages/mcp-workspace/src/stdio-server.ts b/packages/mcp-workspace/src/stdio-server.ts new file mode 100644 index 00000000..a4c9ea2e --- /dev/null +++ b/packages/mcp-workspace/src/stdio-server.ts @@ -0,0 +1,45 @@ +/** + * Workspace MCP Stdio 服务器 + * + * 基于标准输入输出的 MCP 服务,复用现有 tools/service 层。 + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolRequest, +} from '@modelcontextprotocol/sdk/types.js'; + +import { WORKSPACE_TOOLS, handleWorkspaceTool } from './tools/index.js'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); +const MCP_VERSION = '1.0.0'; + +export async function startStdioServer(): Promise { + const server = new Server( + { name: 'workspace-mcp', version: MCP_VERSION }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: WORKSPACE_TOOLS, + })); + + server.setRequestHandler( + CallToolRequestSchema, + async (request: CallToolRequest) => { + const { name, arguments: args } = request.params; + logger.info(`Tool call: ${name}`); + return handleWorkspaceTool(name, args || {}); + } + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + logger.info(`Starting v${MCP_VERSION} (stdio)...`); + logger.info('Ready'); +} diff --git a/packages/mcp-workspace/src/tools/index.ts b/packages/mcp-workspace/src/tools/index.ts new file mode 100644 index 00000000..612501c7 --- /dev/null +++ b/packages/mcp-workspace/src/tools/index.ts @@ -0,0 +1 @@ +export { WORKSPACE_TOOLS, handleWorkspaceTool } from './workspace.js'; diff --git a/packages/mcp-workspace/src/tools/workspace.ts b/packages/mcp-workspace/src/tools/workspace.ts new file mode 100644 index 00000000..d7443eb6 --- /dev/null +++ b/packages/mcp-workspace/src/tools/workspace.ts @@ -0,0 +1,186 @@ +/** + * 工作区 MCP 工具定义 + * + * 提供 AI 可调用的工具,用于操作用户绑定的本地工作区文件夹。 + */ + +import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { ok, err } from '../utils/index'; +import { + listWorkspaces, + listDirectory, + readWorkspaceFile, + writeWorkspaceFile, + createWorkspaceDirectory, + deleteWorkspaceItem, +} from '../service/workspace.service.js'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); + +export const WORKSPACE_TOOLS: Tool[] = [ + { + name: 'list_workspaces', + description: `获取用户绑定的工作区文件夹列表。 + +返回每个工作区的 id、名称和绝对路径。 +调用此工具后,可以使用 list_workspace_directory 浏览具体目录。`, + inputSchema: { + type: 'object' as const, + properties: {}, + }, + }, + { + name: 'list_workspace_directory', + description: `列出工作区中某个目录的内容。 + +返回文件和子目录列表(名称、绝对路径、大小、修改时间)。 +自动跳过隐藏文件和 node_modules 等常见忽略目录。 +路径必须在已绑定的工作区范围内。`, + inputSchema: { + type: 'object' as const, + properties: { + path: { + type: 'string', + description: '目录的绝对路径', + }, + }, + required: ['path'], + }, + }, + { + name: 'read_workspace_file', + description: `读取工作区中某个文件的文本内容。 + +支持 UTF-8 编码的文本文件。二进制文件(图片、压缩包等)不支持。 +大文件自动截断:最多读取前 512KB / 5000 行。 +路径必须在已绑定的工作区范围内。`, + inputSchema: { + type: 'object' as const, + properties: { + path: { + type: 'string', + description: '文件的绝对路径', + }, + }, + required: ['path'], + }, + }, + { + name: 'write_workspace_file', + description: `在工作区中创建或覆盖写入文件。 + +如果父目录不存在会自动递归创建。 +路径必须在已绑定的工作区范围内。 +⚠️ 会覆盖已有文件内容,请谨慎使用。`, + inputSchema: { + type: 'object' as const, + properties: { + path: { + type: 'string', + description: '文件的绝对路径', + }, + content: { + type: 'string', + description: '要写入的文件内容', + }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'create_workspace_directory', + description: `在工作区中创建目录(支持递归创建)。 + +路径必须在已绑定的工作区范围内。`, + inputSchema: { + type: 'object' as const, + properties: { + path: { + type: 'string', + description: '要创建的目录绝对路径', + }, + }, + required: ['path'], + }, + }, + { + name: 'delete_workspace_item', + description: `删除工作区中的文件或目录。 + +目录会被递归删除。不能删除工作区根目录。 +路径必须在已绑定的工作区范围内。 +⚠️ 此操作不可逆,请确认后再调用。`, + inputSchema: { + type: 'object' as const, + properties: { + path: { + type: 'string', + description: '要删除的文件或目录绝对路径', + }, + }, + required: ['path'], + }, + }, +]; + +export async function handleWorkspaceTool( + name: string, + args: Record +): Promise { + try { + switch (name) { + case 'list_workspaces': { + const folders = await listWorkspaces(); + return ok(folders); + } + + case 'list_workspace_directory': { + const path = requireString(args, 'path'); + const entries = await listDirectory(path); + return ok(entries); + } + + case 'read_workspace_file': { + const path = requireString(args, 'path'); + const content = await readWorkspaceFile(path); + return ok({ path, content }); + } + + case 'write_workspace_file': { + const path = requireString(args, 'path'); + const content = requireString(args, 'content'); + await writeWorkspaceFile(path, content); + return ok({ path, message: '文件已写入', bytes: content.length }); + } + + case 'create_workspace_directory': { + const path = requireString(args, 'path'); + await createWorkspaceDirectory(path); + return ok({ path, message: '目录已创建' }); + } + + case 'delete_workspace_item': { + const path = requireString(args, 'path'); + await deleteWorkspaceItem(path); + return ok({ path, message: '已删除' }); + } + + default: + return err(`Unknown tool: ${name}`); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[${name}] 执行失败: ${message}`); + return err(message); + } +} + +function requireString(args: Record, key: string): string { + const val = args[key]; + if (typeof val !== 'string' || !val.trim()) { + throw new Error(`参数 ${key} 必填且必须是非空字符串`); + } + return val.trim(); +} + diff --git a/packages/mcp-workspace/src/utils/index.ts b/packages/mcp-workspace/src/utils/index.ts new file mode 100644 index 00000000..f4e301ab --- /dev/null +++ b/packages/mcp-workspace/src/utils/index.ts @@ -0,0 +1,13 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +export function ok(data: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, data }, null, 2) }], + }; +} + +export function err(message: string): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify({ success: false, error: message }, null, 2) }], + isError: true, + }; +} diff --git a/packages/mcp-workspace/tsconfig.json b/packages/mcp-workspace/tsconfig.json new file mode 100644 index 00000000..66ed84f4 --- /dev/null +++ b/packages/mcp-workspace/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "baseUrl": "./src", + "paths": { + "~/*": ["./*"] + } + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/mcp-workspace/tsup.config.ts b/packages/mcp-workspace/tsup.config.ts new file mode 100644 index 00000000..e44a3bdf --- /dev/null +++ b/packages/mcp-workspace/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'mcp-server': 'src/bin/mcp-server.ts', + }, + format: ['esm'], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + target: 'node18', + outDir: 'dist', + external: ['@promptx/logger', '@modelcontextprotocol/sdk'], +}); diff --git a/packages/resource/resources/role/dayu/execution/migration-workflow.execution.md b/packages/resource/resources/role/dayu/execution/migration-workflow.execution.md index 056a984b..68da4b35 100644 --- a/packages/resource/resources/role/dayu/execution/migration-workflow.execution.md +++ b/packages/resource/resources/role/dayu/execution/migration-workflow.execution.md @@ -3,7 +3,7 @@ ## V1→V2 迁移工作流 ### Step 1: 读取V1角色 - - 通过 action 激活 V1 角色(version: "v1"),或由用户提供角色内容 + - 通过 action 工具激活 V1 角色(version: "v1"),或由用户提供角色内容 - 加载全部资源:roleResources: "all" - 记录 personality、principle、knowledge 三层内容 @@ -15,30 +15,32 @@ - 向用户展示映射方案,确认后继续 ### Step 3: 创建V2角色 - - born:用整合后的 persona 描述创建角色 - - synthesize type=voice:迁移有独立价值的 thought(传入 targetRole 参数) - - synthesize type=knowledge:迁移专有知识(传入 targetRole 参数) - - synthesize type=experience:迁移关键执行经验(传入 targetRole 参数) - - ⚠️ 关键:synthesize 必须传入 targetRole 参数(角色名),无需先 activate + - action 工具 born:用整合后的 persona 描述创建角色 + - learning 工具 synthesize type=voice:迁移有独立价值的 thought(传入 role 参数指定目标角色) + - learning 工具 synthesize type=knowledge:迁移专有知识(传入 role 参数指定目标角色) + - learning 工具 synthesize type=experience:迁移关键执行经验(传入 role 参数指定目标角色) + - ⚠️ 关键:synthesize 的 role 参数是目标角色名(接收知识的角色),无需先 activate ### Step 4: 组织安排(可选) - - 如果角色属于某个团队 → hire 到组织 - - 如果角色有明确职责 → establish 职位(职位名必须是"角色名+岗位"格式)+ appoint(position 参数必须与 establish 的 name 完全一致) + - 使用 organization 工具: + - hire(name, org):角色加入组织 + - establish(name, source, org):创建职位(职位名必须是"角色名+岗位"格式) + - appoint(name, position, org):任命到职位(position 必须与 establish 的 name 完全一致) ### Step 5: 验证 - - identity 查看角色完整身份,确认所有 feature 已写入 + - action 工具 identity 查看角色完整身份,确认所有 feature 已写入 - 与 V1 原始内容对比,确认核心特质保留 - - 如有缺失,补充 synthesize + - 如有缺失,补充 learning 工具 synthesize - - synthesize 必须传入 targetRole 参数(角色名),无需先 activate + - learning 工具 synthesize 的 role 参数是目标角色名(接收知识的角色),无需先 activate - IF V1角色有大量thought THEN 整合为精炼的persona,不要逐个迁移 - IF knowledge是通用知识 THEN 不迁移(AI已具备) - IF execution是标准流程 THEN 映射为duty;IF是领域知识 THEN 映射为knowledge - 迁移前必须向用户确认映射方案 - - ⚠️ 职位命名规范:establish 创建职位时,name 必须是"角色名+岗位"格式(如"产品经理岗位") - - ⚠️ appoint 任命时,position 参数必须与 establish 的 name 完全一致 - - 验证方式:用 directory 检查 members 列表,而不是只看命令返回值 + - ⚠️ 职位命名规范:organization 工具 establish 创建职位时,name 必须是"角色名+岗位"格式(如"产品经理岗位") + - ⚠️ organization 工具 appoint 任命时,position 参数必须与 establish 的 name 完全一致 + - 验证方式:用 organization 工具 directory 检查 members 列表,而不是只看命令返回值 diff --git a/packages/resource/resources/role/dayu/execution/organization-workflow.execution.md b/packages/resource/resources/role/dayu/execution/organization-workflow.execution.md index 5275b59c..006f93cb 100644 --- a/packages/resource/resources/role/dayu/execution/organization-workflow.execution.md +++ b/packages/resource/resources/role/dayu/execution/organization-workflow.execution.md @@ -1,6 +1,6 @@ - ## 组织管理操作指南 + ## 组织管理操作指南(使用 organization 工具) ### 查看现状 - directory:查看所有组织、角色、职位的全局视图 @@ -21,12 +21,20 @@ - fire(name, org):角色离开组织 - appoint(name, position, org):角色承担职位 - dismiss(name, org):角色卸任职位 + + ### 清理与解散 + - dissolve(org):解散组织(⚠️ 不会自动清理成员) + - retire(individual):退休角色 + - die(individual):永久删除角色 + - ⚠️ 正确的解散流程:dismiss → fire → abolish → dissolve → die/retire + - 所有组织操作使用 organization 工具 - 先 found 组织,再 establish 职位,再 hire + appoint - hire 是前提,appoint 是进阶——先成为成员,再承担职责 - 小团队不需要职位,hire 即可 - directory 是诊断工具,操作前后都应查看 + - dissolve 不会级联清理成员,需要提前手动 dismiss → fire diff --git a/packages/resource/resources/role/dayu/knowledge/rolex-api.knowledge.md b/packages/resource/resources/role/dayu/knowledge/rolex-api.knowledge.md index 816b097d..b58dbeef 100644 --- a/packages/resource/resources/role/dayu/knowledge/rolex-api.knowledge.md +++ b/packages/resource/resources/role/dayu/knowledge/rolex-api.knowledge.md @@ -1,26 +1,52 @@ - ## 组织操作 API 速查 + ## 工具与操作 API 速查 - ### 角色生命周期 - | 操作 | 必需参数 | 可选参数 | 前置条件 | 说明 | - |---|---|---|---|---| - | born | name, source | - | 无 | 创建角色 | - | activate | role | version | 无 | 激活角色(设为当前活跃角色) | - | synthesize | name, source, type | targetRole | 无 | 教授知识/经验/声音 | - | identity | - | role | 无 | 查看角色身份 | + ### action 工具(角色管理) + | 操作 | 必需参数 | 可选参数 | 说明 | + |---|---|---|---| + | activate | role | version, roleResources | 激活角色(设为当前活跃角色) | + | born | name, source | - | 创建 V2 角色 | + | identity | role | - | 查看角色身份 | + + > ⚠️ 关键:born 只创建角色,不会自动激活。 + + ### lifecycle 工具(目标与任务) + | 操作 | 必需参数 | 可选参数 | 说明 | + |---|---|---|---| + | want | name, source | testable | 创建目标 | + | plan | source, **id** | - | 创建计划(id 必填!) | + | todo | name, source | testable | 创建任务 | + | finish | name | encounter | 完成任务 | + | achieve | experience | - | 达成目标 | + | abandon | experience | - | 放弃目标 | + | focus | name | - | 切换焦点 | + + ### learning 工具(知识管理) + | 操作 | 必需参数 | 可选参数 | 说明 | + |---|---|---|---| + | synthesize | name, source, type | role(目标角色) | 教授知识/经验/声音 | + | reflect | encounters, experience, id | - | 反思创建经验 | + | realize | experiences, principle, id | - | 提炼原则 | + | master | procedure, id | - | 沉淀 SOP | + | forget | nodeId | - | 遗忘过时知识 | - > ⚠️ 关键:born 只创建角色,不会自动激活。synthesize 可以传入 targetRole 参数指定目标角色,无需先 activate。如果不传 targetRole,则使用当前活跃角色(需要先 activate)。 + > ⚠️ synthesize 可传入 role 参数指定目标角色(接收知识的角色),无需先 activate。 - ### 组织操作 + ### organization 工具(组织管理) | 操作 | 必需参数 | 可选参数 | 说明 | |---|---|---|---| | found | name | source, parent | 创建组织 | - | establish | name, source, org | - | 在组织中创建职位。⚠️ name 必须是"角色名+岗位"格式(如"产品经理岗位") | + | establish | name, source, org | - | 在组织中创建职位。⚠️ name 必须是"角色名+岗位"格式 | | hire | name, org | - | 雇佣角色到组织 | | fire | name, org | - | 从组织解雇角色 | | appoint | name, position, org | - | 任命角色到职位。⚠️ position 必须与 establish 的 name 完全一致 | | dismiss | name, org | - | 免除角色职位 | | directory | - | - | 查看全局目录 | + | charter | org, content | - | 设置组织章程 | + | dissolve | org | - | 解散组织 | + | retire | individual | - | 退休角色 | + | die | individual | - | 永久删除角色 | + | train | individual, skillId, content | - | 训练角色技能 | ### 参数说明 - name:角色名/组织名/职位名(根据操作不同含义不同) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f7b88fb..7fc0c6a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@agentxjs/ui': specifier: 1.9.0 version: 1.9.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(emoji-mart@5.6.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@larksuiteoapi/node-sdk': + specifier: ^1.59.0 + version: 1.59.0 '@promptx/config': specifier: workspace:* version: link:../../packages/config @@ -422,6 +425,25 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/mcp-workspace: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.27.1(zod@4.3.6) + '@promptx/logger': + specifier: workspace:* + version: link:../logger + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.15 + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages/resource: dependencies: '@modelcontextprotocol/server-filesystem': @@ -1493,7 +1515,7 @@ packages: resolution: {integrity: sha512-6TZqxHJtGv8SMDlr81KOhmAcZIjNkPZS7g748YDJnkwr5lvNZv5NnkjE6y94Co93g0l8xptV7hw9yL6nYBKU7w==} '@issuexjs/node@0.2.0': - resolution: {integrity: sha512-dfa1KCcewe9HJaQbrNTP8wdTPETmA+6oqeZalT0xTwaFMpk/aGqnKkxvVk93v9X4wdqIskK6VUkgTKn0uTqxeg==, tarball: https://registry.npmjs.org/@issuexjs/node/-/node-0.2.0.tgz} + resolution: {integrity: sha512-dfa1KCcewe9HJaQbrNTP8wdTPETmA+6oqeZalT0xTwaFMpk/aGqnKkxvVk93v9X4wdqIskK6VUkgTKn0uTqxeg==} '@jimp/bmp@0.16.13': resolution: {integrity: sha512-9edAxu7N2FX7vzkdl5Jo1BbACfycUtBQX+XBMcHA2bk62P8R0otgkHg798frgAk/WxQIzwxqOH6wMiCwrlAzdQ==} @@ -1679,6 +1701,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@larksuiteoapi/node-sdk@1.59.0': + resolution: {integrity: sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -1939,6 +1964,36 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3158,6 +3213,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + babel-plugin-polyfill-corejs2@0.4.15: resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==} peerDependencies: @@ -5260,6 +5318,9 @@ packages: lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. @@ -5276,6 +5337,9 @@ packages: lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -5295,6 +5359,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6294,10 +6361,17 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + prst-shape-transform@1.0.5-beta.0: resolution: {integrity: sha512-AsFdub+qDdqwEnF6CVOkbrVab4un/Ag1uc5uLTTBGlVCjan8wrQN1oNTtQC0+8PBs8DHGY11hiUNO2E9mC2k0w==} @@ -9212,6 +9286,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@larksuiteoapi/node-sdk@1.59.0': + dependencies: + axios: 1.13.6 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.5.4 + qs: 6.15.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -9535,6 +9623,29 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10790,6 +10901,14 @@ snapshots: aws4@1.13.2: {} + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0): dependencies: '@babel/compat-data': 7.29.0 @@ -13034,6 +13153,8 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.identity@3.0.0: {} + lodash.isequal@4.5.0: {} lodash.isplainobject@4.0.6: {} @@ -13044,6 +13165,8 @@ snapshots: lodash.mergewith@4.6.2: {} + lodash.pickby@4.6.0: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} @@ -13059,6 +13182,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -14334,11 +14459,28 @@ snapshots: proto-list@1.2.4: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.15 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + prst-shape-transform@1.0.5-beta.0(@babel/core@7.29.0): dependencies: '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0)