From 42ba395c6a9a4527c9452ebe9a282b86a645493f Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 14:01:17 +0800 Subject: [PATCH 01/18] refactor: align StreamableHttpMCPServer and mcp-workspace with ShopAgent pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamableHttpMCPServer: use node: prefixes, parseMcpUrl/endpoint instead of separate host/port, rename sessions→httpSessions to avoid BaseMCPServer conflict, remove SESSION_ID_HEADER_NAME constant, accept url option - PromptXMCPServer: pass url string instead of port+host+corsEnabled - mcp-workspace/http-server: align startup logs (3-line format) and add summarizeArgs to tool call logging - mcp-workspace: add stdio-server.ts reusing existing tools/service layer - mcp-workspace/bin: support --transport stdio|http flag and WORKSPACE_MCP_TRANSPORT env var Co-Authored-By: Claude Sonnet 4.6 --- .../src/servers/PromptXMCPServer.ts | 4 +- .../src/servers/StreamableHttpMCPServer.ts | 734 +++++++----------- packages/mcp-workspace/package.json | 39 + packages/mcp-workspace/src/bin/mcp-server.ts | 59 ++ packages/mcp-workspace/src/http-server.ts | 268 +++++++ packages/mcp-workspace/src/index.ts | 23 + packages/mcp-workspace/src/service/index.ts | 8 + .../src/service/workspace.service.ts | 218 ++++++ packages/mcp-workspace/src/stdio-server.ts | 45 ++ packages/mcp-workspace/src/tools/index.ts | 1 + packages/mcp-workspace/src/tools/workspace.ts | 197 +++++ packages/mcp-workspace/tsconfig.json | 13 + packages/mcp-workspace/tsup.config.ts | 16 + pnpm-lock.yaml | 19 + 14 files changed, 1193 insertions(+), 451 deletions(-) create mode 100644 packages/mcp-workspace/package.json create mode 100644 packages/mcp-workspace/src/bin/mcp-server.ts create mode 100644 packages/mcp-workspace/src/http-server.ts create mode 100644 packages/mcp-workspace/src/index.ts create mode 100644 packages/mcp-workspace/src/service/index.ts create mode 100644 packages/mcp-workspace/src/service/workspace.service.ts create mode 100644 packages/mcp-workspace/src/stdio-server.ts create mode 100644 packages/mcp-workspace/src/tools/index.ts create mode 100644 packages/mcp-workspace/src/tools/workspace.ts create mode 100644 packages/mcp-workspace/tsconfig.json create mode 100644 packages/mcp-workspace/tsup.config.ts 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-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..2f8621e6 --- /dev/null +++ b/packages/mcp-workspace/src/tools/workspace.ts @@ -0,0 +1,197 @@ +/** + * 工作区 MCP 工具定义 + * + * 提供 AI 可调用的工具,用于操作用户绑定的本地工作区文件夹。 + */ + +import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +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(); +} + +function ok(data: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, data }, null, 2) }], + }; +} + +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/pnpm-lock.yaml b/pnpm-lock.yaml index 6f7b88fb..d6fbefe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,6 +422,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': From 538aac46ad869b4778ef05dc62d3097be8342cbd Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 14:28:03 +0800 Subject: [PATCH 02/18] feat(desktop): add workspace sidebar panel with file explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceService (Electron main): fs-based folder management, dir listing, file read/write/delete, persisted to userData/workspace-folders.json - IPC: 10 workspace:* handlers via ipcMain.handle, exposed via contextBridge - useWorkspace hook: manages folders, expanded paths, dir cache via Electron IPC - WorkspacePanel: resizable right sidebar shell (360–600px, drag handle) - WorkspacePanelHeader: tab bar with badge support - FileTree: recursive tree with file-type icons, drag-to-input, delete on hover - WorkspaceExplorerPanel: folder list, file tree, text/image preview - WorkspaceExplorerAdapter: bridges useWorkspace hook to pure UI panel - Studio.tsx: FolderOpen toggle button + WorkspacePanel integrated on right side Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/main/index.ts | 38 ++ .../src/main/services/WorkspaceService.ts | 108 +++++ apps/desktop/src/preload/index.ts | 41 +- .../agentx-ui/components/studio/Studio.tsx | 75 ++- .../components/workspace/FileTree.tsx | 296 ++++++++++++ .../workspace/WorkspaceExplorerAdapter.tsx | 54 +++ .../workspace/WorkspaceExplorerPanel.tsx | 441 ++++++++++++++++++ .../components/workspace/WorkspacePanel.tsx | 104 +++++ .../workspace/WorkspacePanelHeader.tsx | 67 +++ .../components/workspace/explorerTypes.ts | 30 ++ .../agentx-ui/components/workspace/types.ts | 27 ++ apps/desktop/src/view/hooks/useWorkspace.ts | 110 +++++ 12 files changed, 1374 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/main/services/WorkspaceService.ts create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/FileTree.tsx create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerAdapter.tsx create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspaceExplorerPanel.tsx create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanel.tsx create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/WorkspacePanelHeader.tsx create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/explorerTypes.ts create mode 100644 apps/desktop/src/view/components/agentx-ui/components/workspace/types.ts create mode 100644 apps/desktop/src/view/hooks/useWorkspace.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0bbd365c..6bde3427 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -16,6 +16,7 @@ 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 { workspaceService } from '~/main/services/WorkspaceService' import * as logger from '@promptx/logger' import * as path from 'node:path' import * as fs from 'node:fs' @@ -72,6 +73,7 @@ class PromptXDesktopApp { this.setupShellIPC() this.setupAgentXIPC() this.setupWebAccessIPC() + this.setupWorkspaceIPC() // Setup infrastructure logger.info('Setting up infrastructure...') @@ -724,6 +726,42 @@ 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)) + } + private setupUpdateIPC(): void { // 检查更新 ipcMain.handle('check-for-updates', async () => { diff --git a/apps/desktop/src/main/services/WorkspaceService.ts b/apps/desktop/src/main/services/WorkspaceService.ts new file mode 100644 index 00000000..5e960ef6 --- /dev/null +++ b/apps/desktop/src/main/services/WorkspaceService.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { app } from 'electron' +import { randomUUID } from 'node:crypto' + +export interface WorkspaceFolder { + id: string + name: string + path: string +} + +interface WorkspaceFoldersConfig { + folders: WorkspaceFolder[] +} + +export class WorkspaceService { + private configPath: string + + constructor() { + this.configPath = path.join(app.getPath('userData'), 'workspace-folders.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 } + 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.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/preload/index.ts b/apps/desktop/src/preload/index.ts index 8a6d004d..5fc22a86 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,19 @@ 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 info platform: string } @@ -176,6 +202,19 @@ 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 info platform: process.platform, } as ElectronAPI) 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..383f85b0 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(false); + 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/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, + }; +} From 004f7ed80b71e9c1270061af22fda4e548c0dc13 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 14:37:44 +0800 Subject: [PATCH 03/18] feat(mcp-workspace): add built-in MCP server configuration tools Add mcp-config.service.ts managing ~/.promptx/mcp-servers.json with two built-in (non-removable) servers: - promptx: HTTP http://127.0.0.1:5276/mcp (reads port from ~/.promptx/config.json) - mcp-office: stdio node .../mcp-office/dist/index.js (auto-detected) New tools exposed via workspace MCP: - list_mcp_servers: returns built-in + user-defined servers - add_mcp_server: adds custom stdio or http/sse server - remove_mcp_server: removes user-defined server (builtin protected) - update_mcp_server: updates user-defined server fields Co-Authored-By: Claude Sonnet 4.6 --- packages/mcp-workspace/src/index.ts | 5 + packages/mcp-workspace/src/service/index.ts | 8 + .../src/service/mcp-config.service.ts | 193 ++++++++++++++++++ packages/mcp-workspace/src/tools/workspace.ts | 133 ++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 packages/mcp-workspace/src/service/mcp-config.service.ts diff --git a/packages/mcp-workspace/src/index.ts b/packages/mcp-workspace/src/index.ts index c5839c4b..66df8b05 100644 --- a/packages/mcp-workspace/src/index.ts +++ b/packages/mcp-workspace/src/index.ts @@ -20,4 +20,9 @@ export { writeWorkspaceFile, createWorkspaceDirectory, deleteWorkspaceItem, + listMcpServers, + addMcpServer, + removeMcpServer, + updateMcpServer, + type McpServerConfig, } from './service/index.js'; diff --git a/packages/mcp-workspace/src/service/index.ts b/packages/mcp-workspace/src/service/index.ts index f0a20ee8..3f320c88 100644 --- a/packages/mcp-workspace/src/service/index.ts +++ b/packages/mcp-workspace/src/service/index.ts @@ -6,3 +6,11 @@ export { createWorkspaceDirectory, deleteWorkspaceItem, } from './workspace.service.js'; + +export { + listMcpServers, + addMcpServer, + removeMcpServer, + updateMcpServer, + type McpServerConfig, +} from './mcp-config.service.js'; diff --git a/packages/mcp-workspace/src/service/mcp-config.service.ts b/packages/mcp-workspace/src/service/mcp-config.service.ts new file mode 100644 index 00000000..218b0778 --- /dev/null +++ b/packages/mcp-workspace/src/service/mcp-config.service.ts @@ -0,0 +1,193 @@ +/** + * MCP 服务器配置管理服务 + * + * 管理 ~/.promptx/mcp-servers.json,并合并内置服务器。 + * 内置服务器 (builtin: true) 不可删除/修改。 + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { createLogger } from '@promptx/logger'; + +const logger = createLogger(); + +const CONFIG_PATH = join(homedir(), '.promptx', 'mcp-servers.json'); +const PROMPTX_CONFIG_PATH = join(homedir(), '.promptx', 'config.json'); + +export interface McpServerConfig { + name: string; + description?: string; + builtin?: boolean; + enabled: boolean; + // stdio 类型 + command?: string; + args?: string[]; + env?: Record; + // http/sse 类型 + type?: 'http' | 'sse'; + url?: string; +} + +interface McpConfigFile { + servers: McpServerConfig[]; +} + +// ── 内置服务器 ──────────────────────────────────────────────────────────────── + +function getPromptXUrl(): string { + try { + if (existsSync(PROMPTX_CONFIG_PATH)) { + const raw = readFileSync(PROMPTX_CONFIG_PATH, 'utf-8'); + const cfg = JSON.parse(raw) as { host?: string; port?: number }; + const host = cfg.host || '127.0.0.1'; + const port = cfg.port || 5276; + return `http://${host}:${port}/mcp`; + } + } catch { + // ignore + } + return 'http://127.0.0.1:5276/mcp'; +} + +function getMcpOfficePath(): string | null { + // 1. 环境变量优先 + if (process.env.MCP_OFFICE_PATH) return process.env.MCP_OFFICE_PATH; + + try { + // 2. 相对于本文件在 monorepo 中的位置 + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + // dist/service/ → ../../ → dist/ → ../../../ → packages/mcp-workspace/ + // packages/mcp-office/dist/index.js + const candidates = [ + join(__dirname, '../../../mcp-office/dist/index.js'), // monorepo dist + join(__dirname, '../../../../mcp-office/dist/index.js'), // one level deeper + join(process.cwd(), 'packages/mcp-office/dist/index.js'), // cwd fallback + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + } catch { + // ignore + } + return null; +} + +function getBuiltinServers(): McpServerConfig[] { + const servers: McpServerConfig[] = [ + { + name: 'promptx', + description: 'PromptX MCP Server (Roles, Tools, Memory)', + builtin: true, + enabled: true, + type: 'http', + url: getPromptXUrl(), + }, + ]; + + const officePath = getMcpOfficePath(); + if (officePath) { + servers.push({ + name: 'mcp-office', + description: 'Office document reader (Word, Excel, PDF)', + builtin: true, + enabled: true, + command: 'node', + args: [officePath], + }); + } + + return servers; +} + +// ── 持久化 ──────────────────────────────────────────────────────────────────── + +function loadConfig(): McpConfigFile { + try { + if (existsSync(CONFIG_PATH)) { + const raw = readFileSync(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw) as McpConfigFile; + } + } catch { + logger.warn('Failed to load mcp-servers.json'); + } + return { servers: [] }; +} + +async function saveConfig(cfg: McpConfigFile): Promise { + await mkdir(dirname(CONFIG_PATH), { recursive: true }); + await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8'); +} + +// ── 公开 API ────────────────────────────────────────────────────────────────── + +export function listMcpServers(): McpServerConfig[] { + const builtins = getBuiltinServers(); + const userCfg = loadConfig(); + // 用户服务器放在内置之后,过滤掉与内置同名的 + const builtinNames = new Set(builtins.map(s => s.name)); + const userServers = userCfg.servers.filter(s => !builtinNames.has(s.name)); + return [...builtins, ...userServers]; +} + +export async function addMcpServer(config: Omit): Promise { + const builtins = getBuiltinServers(); + if (builtins.some(s => s.name === config.name)) { + throw new Error(`内置服务器 "${config.name}" 已存在,不可覆盖`); + } + + const cfg = loadConfig(); + if (cfg.servers.some(s => s.name === config.name)) { + throw new Error(`服务器 "${config.name}" 已存在`); + } + + const entry: McpServerConfig = { ...config, builtin: false }; + cfg.servers.push(entry); + await saveConfig(cfg); + + logger.info(`[addMcpServer] Added: ${config.name}`); + return entry; +} + +export async function removeMcpServer(name: string): Promise { + const builtins = getBuiltinServers(); + if (builtins.some(s => s.name === name)) { + throw new Error(`内置服务器 "${name}" 不可删除`); + } + + const cfg = loadConfig(); + const before = cfg.servers.length; + cfg.servers = cfg.servers.filter(s => s.name !== name); + + if (cfg.servers.length === before) { + throw new Error(`服务器 "${name}" 不存在`); + } + + await saveConfig(cfg); + logger.info(`[removeMcpServer] Removed: ${name}`); +} + +export async function updateMcpServer( + name: string, + updates: Partial> +): Promise { + const builtins = getBuiltinServers(); + if (builtins.some(s => s.name === name)) { + throw new Error(`内置服务器 "${name}" 不可修改`); + } + + const cfg = loadConfig(); + const idx = cfg.servers.findIndex(s => s.name === name); + if (idx === -1) { + throw new Error(`服务器 "${name}" 不存在`); + } + + cfg.servers[idx] = { ...cfg.servers[idx]!, ...updates, name, builtin: false }; + await saveConfig(cfg); + + logger.info(`[updateMcpServer] Updated: ${name}`); + return cfg.servers[idx]!; +} diff --git a/packages/mcp-workspace/src/tools/workspace.ts b/packages/mcp-workspace/src/tools/workspace.ts index 2f8621e6..c7e3d75a 100644 --- a/packages/mcp-workspace/src/tools/workspace.ts +++ b/packages/mcp-workspace/src/tools/workspace.ts @@ -13,6 +13,12 @@ import { createWorkspaceDirectory, deleteWorkspaceItem, } from '../service/workspace.service.js'; +import { + listMcpServers, + addMcpServer, + removeMcpServer, + updateMcpServer, +} from '../service/mcp-config.service.js'; import { createLogger } from '@promptx/logger'; const logger = createLogger(); @@ -121,6 +127,92 @@ export const WORKSPACE_TOOLS: Tool[] = [ required: ['path'], }, }, + + // ── MCP 服务器配置 ────────────────────────────────────────────────────────── + { + name: 'list_mcp_servers', + description: `列出所有已配置的 MCP 服务器(包括内置服务器和用户自定义服务器)。 + +内置服务器(builtin: true)不可删除或修改。 +返回每个服务器的名称、类型、连接信息、启用状态和描述。`, + inputSchema: { + type: 'object' as const, + properties: {}, + }, + }, + { + name: 'add_mcp_server', + description: `添加一个新的 MCP 服务器配置。 + +支持两种传输类型: +- stdio: 需要 command(命令)和可选的 args(参数列表)、env(环境变量) +- http/sse: 需要 type("http" 或 "sse")和 url + +不可添加与内置服务器同名的配置。`, + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: '服务器唯一名称(英文,不含空格)' }, + description: { type: 'string', description: '服务器描述(可选)' }, + enabled: { type: 'boolean', description: '是否启用,默认 true' }, + command: { type: 'string', description: 'stdio 模式:可执行命令,如 "node"、"npx"' }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'stdio 模式:命令参数列表', + }, + env: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'stdio 模式:追加的环境变量', + }, + type: { type: 'string', enum: ['http', 'sse'], description: 'http/sse 模式:传输类型' }, + url: { type: 'string', description: 'http/sse 模式:服务器 URL' }, + }, + required: ['name'], + }, + }, + { + name: 'remove_mcp_server', + description: `删除一个用户自定义的 MCP 服务器配置。 + +内置服务器不可删除。`, + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: '要删除的服务器名称' }, + }, + required: ['name'], + }, + }, + { + name: 'update_mcp_server', + description: `更新一个用户自定义 MCP 服务器的配置。 + +内置服务器不可修改。只传入需要更新的字段。`, + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: '要更新的服务器名称' }, + description: { type: 'string', description: '新描述' }, + enabled: { type: 'boolean', description: '是否启用' }, + command: { type: 'string', description: 'stdio 模式:命令' }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'stdio 模式:参数列表', + }, + env: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'stdio 模式:环境变量', + }, + type: { type: 'string', enum: ['http', 'sse'], description: 'http/sse 模式:类型' }, + url: { type: 'string', description: 'http/sse 模式:URL' }, + }, + required: ['name'], + }, + }, ]; export async function handleWorkspaceTool( @@ -165,6 +257,47 @@ export async function handleWorkspaceTool( return ok({ path, message: '已删除' }); } + // ── MCP 服务器配置 ──────────────────────────────────────────────────── + case 'list_mcp_servers': { + const servers = listMcpServers(); + return ok(servers); + } + + case 'add_mcp_server': { + const name = requireString(args, 'name'); + const entry = await addMcpServer({ + name, + description: args.description as string | undefined, + enabled: args.enabled !== false, + command: args.command as string | undefined, + args: Array.isArray(args.args) ? args.args as string[] : undefined, + env: args.env as Record | undefined, + type: args.type as 'http' | 'sse' | undefined, + url: args.url as string | undefined, + }); + return ok({ message: `服务器 "${name}" 已添加`, server: entry }); + } + + case 'remove_mcp_server': { + const name = requireString(args, 'name'); + await removeMcpServer(name); + return ok({ message: `服务器 "${name}" 已删除` }); + } + + case 'update_mcp_server': { + const name = requireString(args, 'name'); + const updated = await updateMcpServer(name, { + description: args.description as string | undefined, + enabled: args.enabled as boolean | undefined, + command: args.command as string | undefined, + args: Array.isArray(args.args) ? args.args as string[] : undefined, + env: args.env as Record | undefined, + type: args.type as 'http' | 'sse' | undefined, + url: args.url as string | undefined, + }); + return ok({ message: `服务器 "${name}" 已更新`, server: updated }); + } + default: return err(`Unknown tool: ${name}`); } From 49287b1086df87003bb81fc76c51e7fc54cfd9db Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 16:44:14 +0800 Subject: [PATCH 04/18] feat(desktop): add mcp-workspace as built-in MCP server Add mcp-workspace (http://127.0.0.1:18062/mcp) as a third built-in server in both getMcpServers() (settings UI display) and the AgentX runtime config builder, alongside promptx and mcp-office. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/main/services/AgentXService.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/main/services/AgentXService.ts b/apps/desktop/src/main/services/AgentXService.ts index ef030750..22e1b5c0 100644 --- a/apps/desktop/src/main/services/AgentXService.ts +++ b/apps/desktop/src/main/services/AgentXService.ts @@ -201,6 +201,12 @@ export class AgentXService { } } + // Add built-in mcp-workspace server + mcpServers['mcp-workspace'] = { + type: 'http', + url: 'http://127.0.0.1:18062/mcp', + } + // Add user-configured MCP servers if (this.config.mcpServers) { for (const server of this.config.mcpServers) { @@ -455,6 +461,16 @@ export class AgentXService { }) } + // 添加内置的 mcp-workspace 服务器 + servers.push({ + name: 'mcp-workspace', + type: 'http', + url: 'http://127.0.0.1:18062/mcp', + enabled: true, + builtin: true, + description: 'Workspace file explorer (Browse, read, write local files)', + }) + // 添加用户配置的服务器 if (this.config.mcpServers) { servers.push(...this.config.mcpServers) From 4e93377685612546c561c8c778a9f400cd6ca484 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 18:51:45 +0800 Subject: [PATCH 05/18] fix: sync workspace config path and suppress agentxjs verbose logs - WorkspaceService: save to ~/.promptx/workspaces.json to align with mcp-workspace reader; add added_at field; auto-create directory - AgentXService: set agentxjs log level to warn Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/main/services/AgentXService.ts | 4 +++- apps/desktop/src/main/services/WorkspaceService.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/AgentXService.ts b/apps/desktop/src/main/services/AgentXService.ts index 22e1b5c0..275eaa72 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 diff --git a/apps/desktop/src/main/services/WorkspaceService.ts b/apps/desktop/src/main/services/WorkspaceService.ts index 5e960ef6..d975a460 100644 --- a/apps/desktop/src/main/services/WorkspaceService.ts +++ b/apps/desktop/src/main/services/WorkspaceService.ts @@ -1,12 +1,13 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' -import { app } from 'electron' +import { homedir } from 'node:os' import { randomUUID } from 'node:crypto' export interface WorkspaceFolder { id: string name: string path: string + added_at: string } interface WorkspaceFoldersConfig { @@ -17,7 +18,7 @@ export class WorkspaceService { private configPath: string constructor() { - this.configPath = path.join(app.getPath('userData'), 'workspace-folders.json') + this.configPath = path.join(homedir(), '.promptx', 'workspaces.json') } async getFolders(): Promise { @@ -32,7 +33,7 @@ export class WorkspaceService { async addFolder(folderPath: string, name: string): Promise { const folders = await this.getFolders() - const folder: WorkspaceFolder = { id: randomUUID(), name, path: folderPath } + const folder: WorkspaceFolder = { id: randomUUID(), name, path: folderPath, added_at: new Date().toISOString() } folders.push(folder) await this.saveFolders(folders) return folder @@ -93,6 +94,7 @@ export class WorkspaceService { } 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') } } From afb3021196bc3361b8713bf98a3a3568276352d4 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 20:07:09 +0800 Subject: [PATCH 06/18] feat: start mcp-workspace as stdio process like mcp-office Replace HTTP transport with stdio for mcp-workspace, matching the mcp-office pattern. Add getMcpWorkspacePath() to resolve the binary with dev/prod/node_modules fallbacks. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/services/AgentXService.ts | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/services/AgentXService.ts b/apps/desktop/src/main/services/AgentXService.ts index 275eaa72..8717f811 100644 --- a/apps/desktop/src/main/services/AgentXService.ts +++ b/apps/desktop/src/main/services/AgentXService.ts @@ -203,10 +203,18 @@ export class AgentXService { } } - // Add built-in mcp-workspace server - mcpServers['mcp-workspace'] = { - type: 'http', - url: 'http://127.0.0.1:18062/mcp', + // 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 @@ -409,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 } @@ -464,14 +499,17 @@ export class AgentXService { } // 添加内置的 mcp-workspace 服务器 - servers.push({ - name: 'mcp-workspace', - type: 'http', - url: 'http://127.0.0.1:18062/mcp', - enabled: true, - builtin: true, - description: 'Workspace file explorer (Browse, read, write local files)', - }) + 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) { From c13f3087902aa8d707e48869e5e590fcb7009be6 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 21:49:47 +0800 Subject: [PATCH 07/18] refactor(mcp-workspace): remove mcp-config service, extract ok/err utils - Remove mcp-config.service.ts and related MCP server management tools - Move ok/err response helpers to utils/index.ts - Clean up service/index.ts and public index.ts exports Co-Authored-By: Claude Sonnet 4.6 --- .../components/container/AgentList.tsx | 77 ++++--- packages/mcp-workspace/src/index.ts | 5 - packages/mcp-workspace/src/service/index.ts | 8 - .../src/service/mcp-config.service.ts | 193 ------------------ packages/mcp-workspace/src/tools/workspace.ts | 146 +------------ packages/mcp-workspace/src/utils/index.ts | 13 ++ 6 files changed, 58 insertions(+), 384 deletions(-) delete mode 100644 packages/mcp-workspace/src/service/mcp-config.service.ts create mode 100644 packages/mcp-workspace/src/utils/index.ts 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/packages/mcp-workspace/src/index.ts b/packages/mcp-workspace/src/index.ts index 66df8b05..c5839c4b 100644 --- a/packages/mcp-workspace/src/index.ts +++ b/packages/mcp-workspace/src/index.ts @@ -20,9 +20,4 @@ export { writeWorkspaceFile, createWorkspaceDirectory, deleteWorkspaceItem, - listMcpServers, - addMcpServer, - removeMcpServer, - updateMcpServer, - type McpServerConfig, } from './service/index.js'; diff --git a/packages/mcp-workspace/src/service/index.ts b/packages/mcp-workspace/src/service/index.ts index 3f320c88..f0a20ee8 100644 --- a/packages/mcp-workspace/src/service/index.ts +++ b/packages/mcp-workspace/src/service/index.ts @@ -6,11 +6,3 @@ export { createWorkspaceDirectory, deleteWorkspaceItem, } from './workspace.service.js'; - -export { - listMcpServers, - addMcpServer, - removeMcpServer, - updateMcpServer, - type McpServerConfig, -} from './mcp-config.service.js'; diff --git a/packages/mcp-workspace/src/service/mcp-config.service.ts b/packages/mcp-workspace/src/service/mcp-config.service.ts deleted file mode 100644 index 218b0778..00000000 --- a/packages/mcp-workspace/src/service/mcp-config.service.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * MCP 服务器配置管理服务 - * - * 管理 ~/.promptx/mcp-servers.json,并合并内置服务器。 - * 内置服务器 (builtin: true) 不可删除/修改。 - */ - -import { readFileSync, existsSync } from 'node:fs'; -import { writeFile, mkdir } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { homedir } from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { createLogger } from '@promptx/logger'; - -const logger = createLogger(); - -const CONFIG_PATH = join(homedir(), '.promptx', 'mcp-servers.json'); -const PROMPTX_CONFIG_PATH = join(homedir(), '.promptx', 'config.json'); - -export interface McpServerConfig { - name: string; - description?: string; - builtin?: boolean; - enabled: boolean; - // stdio 类型 - command?: string; - args?: string[]; - env?: Record; - // http/sse 类型 - type?: 'http' | 'sse'; - url?: string; -} - -interface McpConfigFile { - servers: McpServerConfig[]; -} - -// ── 内置服务器 ──────────────────────────────────────────────────────────────── - -function getPromptXUrl(): string { - try { - if (existsSync(PROMPTX_CONFIG_PATH)) { - const raw = readFileSync(PROMPTX_CONFIG_PATH, 'utf-8'); - const cfg = JSON.parse(raw) as { host?: string; port?: number }; - const host = cfg.host || '127.0.0.1'; - const port = cfg.port || 5276; - return `http://${host}:${port}/mcp`; - } - } catch { - // ignore - } - return 'http://127.0.0.1:5276/mcp'; -} - -function getMcpOfficePath(): string | null { - // 1. 环境变量优先 - if (process.env.MCP_OFFICE_PATH) return process.env.MCP_OFFICE_PATH; - - try { - // 2. 相对于本文件在 monorepo 中的位置 - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - // dist/service/ → ../../ → dist/ → ../../../ → packages/mcp-workspace/ - // packages/mcp-office/dist/index.js - const candidates = [ - join(__dirname, '../../../mcp-office/dist/index.js'), // monorepo dist - join(__dirname, '../../../../mcp-office/dist/index.js'), // one level deeper - join(process.cwd(), 'packages/mcp-office/dist/index.js'), // cwd fallback - ]; - for (const p of candidates) { - if (existsSync(p)) return p; - } - } catch { - // ignore - } - return null; -} - -function getBuiltinServers(): McpServerConfig[] { - const servers: McpServerConfig[] = [ - { - name: 'promptx', - description: 'PromptX MCP Server (Roles, Tools, Memory)', - builtin: true, - enabled: true, - type: 'http', - url: getPromptXUrl(), - }, - ]; - - const officePath = getMcpOfficePath(); - if (officePath) { - servers.push({ - name: 'mcp-office', - description: 'Office document reader (Word, Excel, PDF)', - builtin: true, - enabled: true, - command: 'node', - args: [officePath], - }); - } - - return servers; -} - -// ── 持久化 ──────────────────────────────────────────────────────────────────── - -function loadConfig(): McpConfigFile { - try { - if (existsSync(CONFIG_PATH)) { - const raw = readFileSync(CONFIG_PATH, 'utf-8'); - return JSON.parse(raw) as McpConfigFile; - } - } catch { - logger.warn('Failed to load mcp-servers.json'); - } - return { servers: [] }; -} - -async function saveConfig(cfg: McpConfigFile): Promise { - await mkdir(dirname(CONFIG_PATH), { recursive: true }); - await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8'); -} - -// ── 公开 API ────────────────────────────────────────────────────────────────── - -export function listMcpServers(): McpServerConfig[] { - const builtins = getBuiltinServers(); - const userCfg = loadConfig(); - // 用户服务器放在内置之后,过滤掉与内置同名的 - const builtinNames = new Set(builtins.map(s => s.name)); - const userServers = userCfg.servers.filter(s => !builtinNames.has(s.name)); - return [...builtins, ...userServers]; -} - -export async function addMcpServer(config: Omit): Promise { - const builtins = getBuiltinServers(); - if (builtins.some(s => s.name === config.name)) { - throw new Error(`内置服务器 "${config.name}" 已存在,不可覆盖`); - } - - const cfg = loadConfig(); - if (cfg.servers.some(s => s.name === config.name)) { - throw new Error(`服务器 "${config.name}" 已存在`); - } - - const entry: McpServerConfig = { ...config, builtin: false }; - cfg.servers.push(entry); - await saveConfig(cfg); - - logger.info(`[addMcpServer] Added: ${config.name}`); - return entry; -} - -export async function removeMcpServer(name: string): Promise { - const builtins = getBuiltinServers(); - if (builtins.some(s => s.name === name)) { - throw new Error(`内置服务器 "${name}" 不可删除`); - } - - const cfg = loadConfig(); - const before = cfg.servers.length; - cfg.servers = cfg.servers.filter(s => s.name !== name); - - if (cfg.servers.length === before) { - throw new Error(`服务器 "${name}" 不存在`); - } - - await saveConfig(cfg); - logger.info(`[removeMcpServer] Removed: ${name}`); -} - -export async function updateMcpServer( - name: string, - updates: Partial> -): Promise { - const builtins = getBuiltinServers(); - if (builtins.some(s => s.name === name)) { - throw new Error(`内置服务器 "${name}" 不可修改`); - } - - const cfg = loadConfig(); - const idx = cfg.servers.findIndex(s => s.name === name); - if (idx === -1) { - throw new Error(`服务器 "${name}" 不存在`); - } - - cfg.servers[idx] = { ...cfg.servers[idx]!, ...updates, name, builtin: false }; - await saveConfig(cfg); - - logger.info(`[updateMcpServer] Updated: ${name}`); - return cfg.servers[idx]!; -} diff --git a/packages/mcp-workspace/src/tools/workspace.ts b/packages/mcp-workspace/src/tools/workspace.ts index c7e3d75a..d7443eb6 100644 --- a/packages/mcp-workspace/src/tools/workspace.ts +++ b/packages/mcp-workspace/src/tools/workspace.ts @@ -5,6 +5,7 @@ */ import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { ok, err } from '../utils/index'; import { listWorkspaces, listDirectory, @@ -13,12 +14,6 @@ import { createWorkspaceDirectory, deleteWorkspaceItem, } from '../service/workspace.service.js'; -import { - listMcpServers, - addMcpServer, - removeMcpServer, - updateMcpServer, -} from '../service/mcp-config.service.js'; import { createLogger } from '@promptx/logger'; const logger = createLogger(); @@ -127,92 +122,6 @@ export const WORKSPACE_TOOLS: Tool[] = [ required: ['path'], }, }, - - // ── MCP 服务器配置 ────────────────────────────────────────────────────────── - { - name: 'list_mcp_servers', - description: `列出所有已配置的 MCP 服务器(包括内置服务器和用户自定义服务器)。 - -内置服务器(builtin: true)不可删除或修改。 -返回每个服务器的名称、类型、连接信息、启用状态和描述。`, - inputSchema: { - type: 'object' as const, - properties: {}, - }, - }, - { - name: 'add_mcp_server', - description: `添加一个新的 MCP 服务器配置。 - -支持两种传输类型: -- stdio: 需要 command(命令)和可选的 args(参数列表)、env(环境变量) -- http/sse: 需要 type("http" 或 "sse")和 url - -不可添加与内置服务器同名的配置。`, - inputSchema: { - type: 'object' as const, - properties: { - name: { type: 'string', description: '服务器唯一名称(英文,不含空格)' }, - description: { type: 'string', description: '服务器描述(可选)' }, - enabled: { type: 'boolean', description: '是否启用,默认 true' }, - command: { type: 'string', description: 'stdio 模式:可执行命令,如 "node"、"npx"' }, - args: { - type: 'array', - items: { type: 'string' }, - description: 'stdio 模式:命令参数列表', - }, - env: { - type: 'object', - additionalProperties: { type: 'string' }, - description: 'stdio 模式:追加的环境变量', - }, - type: { type: 'string', enum: ['http', 'sse'], description: 'http/sse 模式:传输类型' }, - url: { type: 'string', description: 'http/sse 模式:服务器 URL' }, - }, - required: ['name'], - }, - }, - { - name: 'remove_mcp_server', - description: `删除一个用户自定义的 MCP 服务器配置。 - -内置服务器不可删除。`, - inputSchema: { - type: 'object' as const, - properties: { - name: { type: 'string', description: '要删除的服务器名称' }, - }, - required: ['name'], - }, - }, - { - name: 'update_mcp_server', - description: `更新一个用户自定义 MCP 服务器的配置。 - -内置服务器不可修改。只传入需要更新的字段。`, - inputSchema: { - type: 'object' as const, - properties: { - name: { type: 'string', description: '要更新的服务器名称' }, - description: { type: 'string', description: '新描述' }, - enabled: { type: 'boolean', description: '是否启用' }, - command: { type: 'string', description: 'stdio 模式:命令' }, - args: { - type: 'array', - items: { type: 'string' }, - description: 'stdio 模式:参数列表', - }, - env: { - type: 'object', - additionalProperties: { type: 'string' }, - description: 'stdio 模式:环境变量', - }, - type: { type: 'string', enum: ['http', 'sse'], description: 'http/sse 模式:类型' }, - url: { type: 'string', description: 'http/sse 模式:URL' }, - }, - required: ['name'], - }, - }, ]; export async function handleWorkspaceTool( @@ -257,47 +166,6 @@ export async function handleWorkspaceTool( return ok({ path, message: '已删除' }); } - // ── MCP 服务器配置 ──────────────────────────────────────────────────── - case 'list_mcp_servers': { - const servers = listMcpServers(); - return ok(servers); - } - - case 'add_mcp_server': { - const name = requireString(args, 'name'); - const entry = await addMcpServer({ - name, - description: args.description as string | undefined, - enabled: args.enabled !== false, - command: args.command as string | undefined, - args: Array.isArray(args.args) ? args.args as string[] : undefined, - env: args.env as Record | undefined, - type: args.type as 'http' | 'sse' | undefined, - url: args.url as string | undefined, - }); - return ok({ message: `服务器 "${name}" 已添加`, server: entry }); - } - - case 'remove_mcp_server': { - const name = requireString(args, 'name'); - await removeMcpServer(name); - return ok({ message: `服务器 "${name}" 已删除` }); - } - - case 'update_mcp_server': { - const name = requireString(args, 'name'); - const updated = await updateMcpServer(name, { - description: args.description as string | undefined, - enabled: args.enabled as boolean | undefined, - command: args.command as string | undefined, - args: Array.isArray(args.args) ? args.args as string[] : undefined, - env: args.env as Record | undefined, - type: args.type as 'http' | 'sse' | undefined, - url: args.url as string | undefined, - }); - return ok({ message: `服务器 "${name}" 已更新`, server: updated }); - } - default: return err(`Unknown tool: ${name}`); } @@ -316,15 +184,3 @@ function requireString(args: Record, key: string): string { return val.trim(); } -function ok(data: unknown): CallToolResult { - return { - content: [{ type: 'text', text: JSON.stringify({ success: true, data }, null, 2) }], - }; -} - -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/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, + }; +} From 69c2751e3fd136e1e5fc7a31bc0a8ace7be361ff Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 21:56:18 +0800 Subject: [PATCH 08/18] feat: drag files from workspace panel to chat input Listen to ws-file-drag-* custom events in Chat; show drop overlay on drag start; pass dropped path to InputPane via droppedWorkspacePaths prop which calls addFilesFromPaths to attach the file. Co-Authored-By: Claude Sonnet 4.6 --- .../agentx-ui/components/container/Chat.tsx | 33 ++++++++++++++++++- .../agentx-ui/components/pane/InputPane.tsx | 22 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) 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) && (
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 */ From e0e6cd599a07cc219a8e981e8087b5592e984bc0 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 22:22:44 +0800 Subject: [PATCH 09/18] feat: auto-expand workspace folders on load When loadFolders() completes, automatically expand all root folders and fetch their directory contents so the file tree is visible by default without manual clicking. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/view/hooks/useWorkspace.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/view/hooks/useWorkspace.ts b/apps/desktop/src/view/hooks/useWorkspace.ts index 0425f9df..b51e209b 100644 --- a/apps/desktop/src/view/hooks/useWorkspace.ts +++ b/apps/desktop/src/view/hooks/useWorkspace.ts @@ -27,6 +27,19 @@ export function useWorkspace() { try { const result = await window.electronAPI.workspace.getFolders(); setFolders(result); + // Auto-expand all workspace root folders and load their contents + if (result.length > 0) { + setExpandedPaths(prev => { + const next = { ...prev }; + for (const f of result) next[f.path] = true; + return next; + }); + await Promise.allSettled(result.map(f => + window.electronAPI.workspace.listDir(f.path).then(entries => + setDirCache(prev => ({ ...prev, [f.path]: entries })) + ) + )); + } } finally { setIsLoading(false); } From 660c314a75fa3a6ca8fa4da22e9750cb3cc705d2 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 22:29:35 +0800 Subject: [PATCH 10/18] feat: open workspace panel by default Change workspacePanelOpen initial state from false to true so the workspace sidebar is visible on startup. Co-Authored-By: Claude Sonnet 4.6 --- .../src/view/components/agentx-ui/components/studio/Studio.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 383f85b0..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 @@ -111,7 +111,7 @@ 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(false); + const [workspacePanelOpen, setWorkspacePanelOpen] = React.useState(true); const [workspaceActiveTab, setWorkspaceActiveTab] = React.useState("explorer"); // Toast state From 95320f492a9417a14723f9621b237ef557e6cf10 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 22:36:04 +0800 Subject: [PATCH 11/18] feat: show Git installation prompt on Windows welcome page Add system:checkGit IPC handler to detect Git on Windows. Display a dismissible banner on WelcomePage when Git is not installed, with a link to download Git for Windows. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/main/index.ts | 11 +++++++ apps/desktop/src/preload/index.ts | 8 +++++ .../components/container/WelcomePage.tsx | 30 +++++++++++++++++-- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6bde3427..6e1d1b9a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -760,6 +760,17 @@ class PromptXDesktopApp { 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') + execSync('git --version', { stdio: 'ignore', timeout: 3000 }) + return { installed: true } + } catch { + return { installed: false } + } + }) } private setupUpdateIPC(): void { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 5fc22a86..d96e7713 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -141,6 +141,10 @@ interface ElectronAPI { createDir: (dirPath: string) => Promise deleteItem: (itemPath: string) => Promise } + // System API + system: { + checkGit: () => Promise<{ installed: boolean }> + } // System info platform: string } @@ -215,6 +219,10 @@ contextBridge.exposeInMainWorld('electronAPI', { 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/WelcomePage.tsx b/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx index 6da768c1..6a5d996d 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx @@ -9,7 +9,7 @@ */ import * as React from "react"; -import { Send, Hammer, Sparkles, Bot, Wrench, Users, GitBranch } from "lucide-react"; +import { Send, Hammer, Sparkles, Bot, Wrench, Users, GitBranch, AlertCircle, ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/components/agentx-ui/utils"; import logo from "../../../../../../assets/icons/icon.png"; @@ -76,8 +76,9 @@ export function WelcomePage({ const { t } = useTranslation(); const [inputValue, setInputValue] = React.useState(""); const [enableV2, setEnableV2] = React.useState(false); + const [gitInstalled, setGitInstalled] = React.useState(true); - // Load V2 config on mount + // Load V2 config and check Git on mount React.useEffect(() => { 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 && ( +
+ +
+

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

+
+ +
+ )} + {/* Main content - centered */}
{/* Logo */} From da814ffc0ae27108bf5613783bd938dd6fe3755d Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 22:41:23 +0800 Subject: [PATCH 12/18] fix: improve Git detection on Windows with fallback paths Try common Git installation paths (Program Files, Program Files x86) if git command is not in PATH, to avoid false negatives when Git is installed but not added to system PATH. Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/main/index.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6e1d1b9a..c1613ae3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -765,8 +765,26 @@ class PromptXDesktopApp { if (process.platform !== 'win32') return { installed: true } try { const { execSync } = await import('node:child_process') - execSync('git --version', { stdio: 'ignore', timeout: 3000 }) - return { installed: true } + // Try git command first + try { + execSync('git --version', { stdio: 'ignore', 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`, { stdio: 'ignore', timeout: 3000 }) + return { installed: true } + } catch { + // Continue to next path + } + } + return { installed: false } + } } catch { return { installed: false } } From e660f156c42b57c0cb377adee1136fab3692dace Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Mon, 23 Mar 2026 23:26:58 +0800 Subject: [PATCH 13/18] fix: revert workspace auto-expansion and fix Git warning display - Remove auto-expand and content loading from workspace folders on load - Only show Git warning on Windows platform (check platform in JSX) Co-Authored-By: Claude Sonnet 4.5 --- .../agentx-ui/components/container/WelcomePage.tsx | 2 +- apps/desktop/src/view/hooks/useWorkspace.ts | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx b/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx index 6a5d996d..d8c8dab0 100644 --- a/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx +++ b/apps/desktop/src/view/components/agentx-ui/components/container/WelcomePage.tsx @@ -171,7 +171,7 @@ export function WelcomePage({ return (
{/* Git warning banner - only on Windows when Git not installed */} - {!gitInstalled && ( + {!gitInstalled && window.electronAPI?.platform === 'win32' && (
diff --git a/apps/desktop/src/view/hooks/useWorkspace.ts b/apps/desktop/src/view/hooks/useWorkspace.ts index b51e209b..0425f9df 100644 --- a/apps/desktop/src/view/hooks/useWorkspace.ts +++ b/apps/desktop/src/view/hooks/useWorkspace.ts @@ -27,19 +27,6 @@ export function useWorkspace() { try { const result = await window.electronAPI.workspace.getFolders(); setFolders(result); - // Auto-expand all workspace root folders and load their contents - if (result.length > 0) { - setExpandedPaths(prev => { - const next = { ...prev }; - for (const f of result) next[f.path] = true; - return next; - }); - await Promise.allSettled(result.map(f => - window.electronAPI.workspace.listDir(f.path).then(entries => - setDirCache(prev => ({ ...prev, [f.path]: entries })) - ) - )); - } } finally { setIsLoading(false); } From 6fa6f1f1375bdca161ec39705df5e11b9e1e7615 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Tue, 24 Mar 2026 15:13:04 +0800 Subject: [PATCH 14/18] refactor: split action tool into 4 domain-specific tools to reduce LLM call failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split monolithic action tool (32 ops, 18 params) into: - action: role activation/creation (activate, born, identity) - lifecycle: goal & task management (want, plan, todo, finish, achieve, abandon, focus) - learning: cognitive cycle (reflect, realize, master, forget, synthesize, skill) - organization: org, position & personnel management (16 ops) - Add V1 role guard to new tools with friendly error messages - Fix census.list parser misclassifying members as positions (removed flawed isRole heuristic) - Handle '─── unaffiliated ───' section in census output - Fix organization tree not showing in roles page when roles aren't in resource scan - Update recall/remember V2 error messages to reference correct tool names - Update dayu role knowledge docs to reflect new tool structure - Add Git check debug logging for Windows Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 18 +- .../components/RoleTreeListPanel.tsx | 85 ++--- .../src/view/pages/roles-window/index.tsx | 26 +- packages/core/src/rolex/RolexBridge.js | 56 ++-- packages/mcp-server/src/tools/action.ts | 292 +++--------------- packages/mcp-server/src/tools/index.ts | 21 +- packages/mcp-server/src/tools/learning.ts | 151 +++++++++ packages/mcp-server/src/tools/lifecycle.ts | 122 ++++++++ packages/mcp-server/src/tools/organization.ts | 145 +++++++++ packages/mcp-server/src/tools/recall.ts | 22 +- packages/mcp-server/src/tools/remember.ts | 6 +- .../execution/migration-workflow.execution.md | 30 +- .../organization-workflow.execution.md | 10 +- .../dayu/knowledge/rolex-api.knowledge.md | 48 ++- 14 files changed, 657 insertions(+), 375 deletions(-) create mode 100644 packages/mcp-server/src/tools/learning.ts create mode 100644 packages/mcp-server/src/tools/lifecycle.ts create mode 100644 packages/mcp-server/src/tools/organization.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c1613ae3..a5dfcb3f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -762,14 +762,17 @@ class PromptXDesktopApp { workspaceService.deleteItem(itemPath)) ipcMain.handle('system:checkGit', async () => { + console.log('[checkGit] platform:', process.platform) if (process.platform !== 'win32') return { installed: true } try { const { execSync } = await import('node:child_process') // Try git command first try { - execSync('git --version', { stdio: 'ignore', timeout: 3000 }) + const gitVersion = execSync('git --version', { encoding: 'utf-8', timeout: 3000 }).trim() + console.log('[checkGit] git --version succeeded:', gitVersion) return { installed: true } - } catch { + } catch (e: any) { + console.log('[checkGit] git --version failed:', e?.message || e) // Try common Git installation paths on Windows const commonPaths = [ 'C:\\Program Files\\Git\\cmd\\git.exe', @@ -777,15 +780,18 @@ class PromptXDesktopApp { ] for (const gitPath of commonPaths) { try { - execSync(`"${gitPath}" --version`, { stdio: 'ignore', timeout: 3000 }) + const ver = execSync(`"${gitPath}" --version`, { encoding: 'utf-8', timeout: 3000 }).trim() + console.log('[checkGit] fallback path succeeded:', gitPath, ver) return { installed: true } - } catch { - // Continue to next path + } catch (e2: any) { + console.log('[checkGit] fallback path failed:', gitPath, e2?.message || e2) } } + console.log('[checkGit] all checks failed, returning installed: false') return { installed: false } } - } catch { + } catch (e: any) { + console.log('[checkGit] outer catch:', e?.message || e) return { installed: false } } }) 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/packages/core/src/rolex/RolexBridge.js b/packages/core/src/rolex/RolexBridge.js index 893c6965..c0fc4be3 100644 --- a/packages/core/src/rolex/RolexBridge.js +++ b/packages/core/src/rolex/RolexBridge.js @@ -364,6 +364,10 @@ class RolexBridge { await this.ensureInitialized() const textOutput = await this.rolex.direct('!census.list') + console.log('[RolexBridge] census.list raw output:') + console.log(textOutput) + console.log('[RolexBridge] census.list raw output END') + // 解析文本输出为结构化数据 return this._parseCensusOutput(textOutput) } @@ -394,6 +398,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 +413,28 @@ class RolexBridge { }) } } - // 检测缩进行(角色或职位) + // 检测缩进行(角色/个体成员) else if (line.startsWith(' ') && currentOrg) { const match = trimmed.match(/^([^\s—]+)(?:\s*\([^)]+\))?\s*—\s*(.+)$/) + console.log('[_parseCensus] indented line:', JSON.stringify(trimmed), 'match:', match ? `name=${match[1]}, desc=${match[2]}` : 'NO MATCH') 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 +442,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/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/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:角色名/组织名/职位名(根据操作不同含义不同) From f33a4ec481972bbcb645ba5e989385463a710060 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Tue, 24 Mar 2026 15:45:40 +0800 Subject: [PATCH 15/18] fix: deduplicate resources, conditional Git warning, cleanup logs - Deduplicate items in ResourcesPage loadResources - V2 roles overwrite V1 entries in DiscoverCommand - Git warning banner now checks installation status - Remove debug logs from RolexBridge and checkGit - Add DeepSeek preset to AgentX profiles config Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 19 ++--- .../src/view/pages/resources-window/index.tsx | 15 +++- .../components/AgentXProfilesConfig.tsx | 7 ++ .../src/view/pages/settings-window/index.tsx | 83 ++++++++++++------- .../src/pouch/commands/DiscoverCommand.js | 7 +- packages/core/src/rolex/RolexBridge.js | 5 -- 6 files changed, 82 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a5dfcb3f..09b8d0ea 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -762,17 +762,13 @@ class PromptXDesktopApp { workspaceService.deleteItem(itemPath)) ipcMain.handle('system:checkGit', async () => { - console.log('[checkGit] platform:', process.platform) if (process.platform !== 'win32') return { installed: true } try { const { execSync } = await import('node:child_process') - // Try git command first try { - const gitVersion = execSync('git --version', { encoding: 'utf-8', timeout: 3000 }).trim() - console.log('[checkGit] git --version succeeded:', gitVersion) + execSync('git --version', { encoding: 'utf-8', timeout: 3000 }) return { installed: true } - } catch (e: any) { - console.log('[checkGit] git --version failed:', e?.message || e) + } catch { // Try common Git installation paths on Windows const commonPaths = [ 'C:\\Program Files\\Git\\cmd\\git.exe', @@ -780,18 +776,15 @@ class PromptXDesktopApp { ] for (const gitPath of commonPaths) { try { - const ver = execSync(`"${gitPath}" --version`, { encoding: 'utf-8', timeout: 3000 }).trim() - console.log('[checkGit] fallback path succeeded:', gitPath, ver) + execSync(`"${gitPath}" --version`, { encoding: 'utf-8', timeout: 3000 }) return { installed: true } - } catch (e2: any) { - console.log('[checkGit] fallback path failed:', gitPath, e2?.message || e2) + } catch { + // continue } } - console.log('[checkGit] all checks failed, returning installed: false') return { installed: false } } - } catch (e: any) { - console.log('[checkGit] outer catch:', e?.message || e) + } catch { return { installed: false } } }) 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/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/index.tsx b/apps/desktop/src/view/pages/settings-window/index.tsx index 85fd278f..eae9e21a 100644 --- a/apps/desktop/src/view/pages/settings-window/index.tsx +++ b/apps/desktop/src/view/pages/settings-window/index.tsx @@ -24,6 +24,56 @@ import { WebAccessConfig } from "./components/WebAccessConfig" 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 +362,8 @@ function SettingsWindow() { - {/* Windows Git requirement warning */} - {window.electronAPI?.platform === "win32" && ( - - )} + {/* Windows Git requirement warning - only when Git not detected */} + {/* MCP 配置 */} 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 c0fc4be3..379cbf52 100644 --- a/packages/core/src/rolex/RolexBridge.js +++ b/packages/core/src/rolex/RolexBridge.js @@ -364,10 +364,6 @@ class RolexBridge { await this.ensureInitialized() const textOutput = await this.rolex.direct('!census.list') - console.log('[RolexBridge] census.list raw output:') - console.log(textOutput) - console.log('[RolexBridge] census.list raw output END') - // 解析文本输出为结构化数据 return this._parseCensusOutput(textOutput) } @@ -416,7 +412,6 @@ class RolexBridge { // 检测缩进行(角色/个体成员) else if (line.startsWith(' ') && currentOrg) { const match = trimmed.match(/^([^\s—]+)(?:\s*\([^)]+\))?\s*—\s*(.+)$/) - console.log('[_parseCensus] indented line:', JSON.stringify(trimmed), 'match:', match ? `name=${match[1]}, desc=${match[2]}` : 'NO MATCH') if (match) { const name = match[1].trim() const description = match[2].trim() From 4545adc3ce2585fae865b6e6245e148cf2ce8448 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Tue, 24 Mar 2026 18:13:58 +0800 Subject: [PATCH 16/18] feat: add Feishu bot integration with WebSocket long-connection - Add Feishu backend service (FeishuBot, FeishuBridge, FeishuSessionManager, FeishuManager) using @larksuiteoapi/node-sdk - Add Feishu config UI with start/stop toggle and status indicator - Add IPC handlers for feishu config, start, stop, status, remove - Bridge Feishu messages to AgentX with text and image support - Auto-restore Feishu connection on app startup - Add WeChat config component (hidden, for future use) - Add i18n keys for Feishu and WeChat in zh-CN and en Co-Authored-By: Claude Opus 4.6 --- apps/desktop/package.json | 1 + apps/desktop/src/i18n/locales/en.json | 47 ++++ apps/desktop/src/i18n/locales/zh-CN.json | 47 ++++ apps/desktop/src/main/index.ts | 60 +++++ .../src/main/services/feishu/FeishuBot.ts | 214 +++++++++++++++ .../src/main/services/feishu/FeishuBridge.ts | 121 +++++++++ .../src/main/services/feishu/FeishuManager.ts | 163 ++++++++++++ .../services/feishu/FeishuSessionManager.ts | 68 +++++ .../desktop/src/main/services/feishu/index.ts | 4 + .../components/FeishuConfig.tsx | 249 ++++++++++++++++++ .../components/WechatConfig.tsx | 73 +++++ .../src/view/pages/settings-window/index.tsx | 4 + pnpm-lock.yaml | 125 ++++++++- 13 files changed, 1175 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/services/feishu/FeishuBot.ts create mode 100644 apps/desktop/src/main/services/feishu/FeishuBridge.ts create mode 100644 apps/desktop/src/main/services/feishu/FeishuManager.ts create mode 100644 apps/desktop/src/main/services/feishu/FeishuSessionManager.ts create mode 100644 apps/desktop/src/main/services/feishu/index.ts create mode 100644 apps/desktop/src/view/pages/settings-window/components/FeishuConfig.tsx create mode 100644 apps/desktop/src/view/pages/settings-window/components/WechatConfig.tsx 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..11c27b23 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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 4f30b7f7..1327b4f2 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": "配置服务器主机、端口和调试设置", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 09b8d0ea..bea82d77 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -16,6 +16,7 @@ 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' @@ -33,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) @@ -73,6 +75,7 @@ class PromptXDesktopApp { this.setupShellIPC() this.setupAgentXIPC() this.setupWebAccessIPC() + this.setupFeishuIPC() this.setupWorkspaceIPC() // Setup infrastructure @@ -694,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() 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/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 eae9e21a..aab52d59 100644 --- a/apps/desktop/src/view/pages/settings-window/index.tsx +++ b/apps/desktop/src/view/pages/settings-window/index.tsx @@ -21,6 +21,8 @@ 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" @@ -376,6 +378,8 @@ function SettingsWindow() { {/* 远程访问 */} + + {/* */}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6fbefe1..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 @@ -1512,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==} @@ -1698,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'} @@ -1958,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==} @@ -3177,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: @@ -5279,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. @@ -5295,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==} @@ -5314,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==} @@ -6313,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==} @@ -9231,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 @@ -9554,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': {} @@ -10809,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 @@ -13053,6 +13153,8 @@ snapshots: lodash.escaperegexp@4.1.2: {} + lodash.identity@3.0.0: {} + lodash.isequal@4.5.0: {} lodash.isplainobject@4.0.6: {} @@ -13063,6 +13165,8 @@ snapshots: lodash.mergewith@4.6.2: {} + lodash.pickby@4.6.0: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} @@ -13078,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: @@ -14353,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) From 45a6e01d54b4472355e7cb5089b1768cac653791 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Tue, 24 Mar 2026 18:21:59 +0800 Subject: [PATCH 17/18] feat: add v2.3.0 release notification Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/i18n/locales/en.json | 4 ++++ apps/desktop/src/i18n/locales/zh-CN.json | 4 ++++ .../view/components/notifications/notificationService.ts | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/apps/desktop/src/i18n/locales/en.json b/apps/desktop/src/i18n/locales/en.json index 11c27b23..351bc768 100644 --- a/apps/desktop/src/i18n/locales/en.json +++ b/apps/desktop/src/i18n/locales/en.json @@ -861,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 1327b4f2..67c20857 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -858,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/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", From f121f2f5db3e75e303b231d1191bba4d35fa7af1 Mon Sep 17 00:00:00 2001 From: xierfloat <2053619887@qq.com> Date: Tue, 24 Mar 2026 18:25:07 +0800 Subject: [PATCH 18/18] docs: update v2.3.0 changeset with full release notes Co-Authored-By: Claude Opus 4.6 --- .changeset/red-kings-hear.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/red-kings-hear.md 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 检测与路径问题 +- 清理调试日志输出