diff --git a/CLAUDE.md b/CLAUDE.md index b399fa0..9f50491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,37 @@ ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用): - 配置读写 - Hand 触发 -### 7.2 验证命令 +### 7.2 前端调试优先使用 WebMCP + +ZCLAW 注册了 WebMCP 结构化调试工具(`desktop/src/lib/webmcp-tools.ts`),AI 代理可直接查询应用状态而无需 DOM 截图。 + +**原则:能用 WebMCP 工具完成的调试,优先使用 WebMCP 而非 DevTools MCP(`take_snapshot`/`evaluate_script`),以减少约 67% 的 token 消耗。** + +已注册的 WebMCP 工具: + +| 工具名 | 用途 | +|--------|------| +| `get_zclaw_state` | 综合状态概览(连接、登录、流式、模型) | +| `check_connection` | 连接状态检查 | +| `send_message` | 发送聊天消息 | +| `cancel_stream` | 取消当前流式响应 | +| `get_streaming_state` | 流式响应详细状态 | +| `list_conversations` | 列出最近对话 | +| `get_current_conversation` | 获取当前对话完整消息 | +| `switch_conversation` | 切换到指定对话 | +| `get_token_usage` | Token 用量统计 | +| `get_offline_queue` | 离线消息队列 | +| `get_saas_account` | SaaS 账户和订阅信息 | +| `get_available_models` | 可用 LLM 模型列表 | +| `get_current_agent` | 当前 Agent 详情 | +| `list_agents` | 所有 Agent 列表 | +| `get_console_errors` | 应用日志中的错误 | + +**使用前提**:Chrome 146+ 并启用 `chrome://flags/#enable-webmcp-testing`。仅在开发模式注册。 + +**何时仍需 DevTools MCP**:UI 布局/样式问题、点击交互、截图对比、网络请求检查。 + +### 7.3 验证命令 ```bash # TypeScript 类型检查 @@ -274,7 +304,7 @@ pnpm vitest run pnpm start:dev ```` -### 7.3 人工验证清单 +### 7.4 人工验证清单 - [ ] 能否正常连接后端服务 - [ ] 能否发送消息并获得流式响应 diff --git a/desktop/src/lib/webmcp-tools.ts b/desktop/src/lib/webmcp-tools.ts new file mode 100644 index 0000000..a141051 --- /dev/null +++ b/desktop/src/lib/webmcp-tools.ts @@ -0,0 +1,519 @@ +/** + * WebMCP Tools — Structured debugging tools for AI agents via navigator.modelContext + * + * Chrome 146+ supports the WebMCP DevTrial: websites can register structured tools + * that AI agents call directly without DOM scraping. This reduces token consumption + * by ~67% compared to DevTools MCP snapshot-based debugging. + * + * Only registered in development mode (import.meta.env.DEV). + * Requires chrome://flags/#enable-webmcp-testing enabled. + * + * @see https://developer.chrome.com/docs/ai/webmcp + */ + +import { useConnectionStore } from '../store/connectionStore'; +import { useSaaSStore } from '../store/saasStore'; +import { useStreamStore } from '../store/chat/streamStore'; +import { useConversationStore } from '../store/chat/conversationStore'; +import { useMessageStore } from '../store/chat/messageStore'; +import { useOfflineStore } from '../store/offlineStore'; +import { createLogger } from './logger'; + +const log = createLogger('WebMCP'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ModelContextAPI { + registerTool(tool: { + name: string; + description: string; + inputSchema: Record; + handler: (input: Record) => Promise; + }): void; +} + +declare global { + interface Navigator { + modelContext?: ModelContextAPI; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Safely serialize store state, stripping private/internal fields */ +function safeState>( + state: T, + omitKeys: string[] = [], +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(state)) { + if (key.startsWith('_')) continue; + if (omitKeys.includes(key)) continue; + if (typeof value === 'function') continue; + result[key] = value; + } + return result; +} + +// --------------------------------------------------------------------------- +// Tool definitions +// --------------------------------------------------------------------------- + +/** All registered tool names (for logging) */ +const registeredTools: string[] = []; + +function registerTool( + name: string, + description: string, + inputSchema: Record, + handler: (input: Record) => Promise, +): void { + const ctx = navigator.modelContext; + if (!ctx) return; + + ctx.registerTool({ name, description, inputSchema, handler }); + registeredTools.push(name); +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +function registerConnectionTools(): void { + // --- get_zclaw_state --- + registerTool( + 'get_zclaw_state', + 'Get comprehensive ZCLAW application state: connection mode, SaaS login, streaming, and conversation summary', + { type: 'object', properties: {} }, + async () => { + const conn = useConnectionStore.getState(); + const saas = useSaaSStore.getState(); + const stream = useStreamStore.getState(); + + return safeState({ + connectionState: conn.connectionState, + connectionMode: saas.connectionMode, + saasUrl: saas.saasUrl, + saasReachable: saas.saasReachable, + isLoggedIn: saas.isLoggedIn, + account: saas.account + ? { username: saas.account.username, email: saas.account.email, role: saas.account.role } + : null, + isStreaming: stream.isStreaming, + activeRunId: stream.activeRunId, + chatMode: stream.chatMode, + isLoading: stream.isLoading, + availableModels: saas.availableModels.map((m) => ({ id: m.id, alias: m.alias })), + }); + }, + ); + + // --- check_connection --- + registerTool( + 'check_connection', + 'Check if ZCLAW is connected to backend and report connection details', + { type: 'object', properties: {} }, + async () => { + const conn = useConnectionStore.getState(); + const saas = useSaaSStore.getState(); + const client = conn.client; + + return { + connectionState: conn.connectionState, + connectionMode: saas.connectionMode, + gatewayState: client?.getState?.() ?? 'unknown', + saasUrl: saas.saasUrl, + saasReachable: saas.saasReachable, + isLoggedIn: saas.isLoggedIn, + }; + }, + ); +} + +function registerChatTools(): void { + // --- send_message --- + registerTool( + 'send_message', + 'Send a chat message to the current ZCLAW agent. Returns the message ID and streaming status.', + { + type: 'object', + properties: { + content: { type: 'string', description: 'Message content to send' }, + }, + required: ['content'], + }, + async (input) => { + const content = String(input.content ?? ''); + if (!content.trim()) { + return { error: 'Message content cannot be empty' }; + } + + const stream = useStreamStore.getState(); + if (stream.isStreaming) { + return { error: 'Already streaming a message. Wait for completion.' }; + } + + const conn = useConnectionStore.getState(); + if (conn.connectionState !== 'connected') { + return { error: `Not connected (state: ${conn.connectionState})` }; + } + + // Fire and forget — streamStore handles the rest + stream.sendMessage(content).catch((err: unknown) => { + log.warn('sendMessage failed:', err); + }); + + return { sent: true, content }; + }, + ); + + // --- cancel_stream --- + registerTool( + 'cancel_stream', + 'Cancel the currently active streaming response', + { type: 'object', properties: {} }, + async () => { + const stream = useStreamStore.getState(); + if (!stream.isStreaming) { + return { cancelled: false, reason: 'No active stream' }; + } + stream.cancelStream(); + return { cancelled: true }; + }, + ); + + // --- get_streaming_state --- + registerTool( + 'get_streaming_state', + 'Get detailed streaming state: isStreaming, activeRunId, chatMode, suggestions', + { type: 'object', properties: {} }, + async () => { + const stream = useStreamStore.getState(); + return { + isStreaming: stream.isStreaming, + activeRunId: stream.activeRunId, + chatMode: stream.chatMode, + suggestions: stream.suggestions, + isLoading: stream.isLoading, + }; + }, + ); +} + +function registerConversationTools(): void { + // --- list_conversations --- + registerTool( + 'list_conversations', + 'List recent conversations with metadata (id, title, agent, message count, timestamps)', + { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max conversations to return (default 10)' }, + }, + }, + async (input) => { + const limit = Number(input.limit ?? 10); + const convStore = useConversationStore.getState(); + const conversations = convStore.conversations.slice(0, limit); + + return conversations.map((c) => ({ + id: c.id, + title: c.title ?? '(untitled)', + agentId: c.agentId, + messageCount: c.messages?.length ?? 0, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })); + }, + ); + + // --- get_current_conversation --- + registerTool( + 'get_current_conversation', + 'Get the current active conversation with full message history', + { + type: 'object', + properties: { + includeContent: { type: 'boolean', description: 'Include full message content (default true)' }, + maxMessages: { type: 'number', description: 'Max messages to return (default 50)' }, + }, + }, + async (input) => { + const includeContent = input.includeContent !== false; + const maxMessages = Number(input.maxMessages ?? 50); + const convStore = useConversationStore.getState(); + const conv = convStore.conversations.find((c) => c.id === convStore.currentConversationId); + + if (!conv) { + return { error: 'No active conversation' }; + } + + const messages = (conv.messages ?? []).slice(-maxMessages).map((m) => { + const base: Record = { + id: m.id, + role: m.role, + timestamp: m.timestamp, + streaming: m.streaming, + error: m.error, + }; + if (includeContent) { + base.content = m.content; + if (m.thinkingContent) base.thinkingContent = m.thinkingContent; + if (m.toolSteps) base.toolSteps = m.toolSteps; + } + return base; + }); + + return { + id: conv.id, + title: conv.title, + agentId: conv.agentId, + messageCount: conv.messages?.length ?? 0, + returnedMessages: messages.length, + messages, + }; + }, + ); + + // --- switch_conversation --- + registerTool( + 'switch_conversation', + 'Switch to a different conversation by ID', + { + type: 'object', + properties: { + conversationId: { type: 'string', description: 'Conversation ID to switch to' }, + }, + required: ['conversationId'], + }, + async (input) => { + const conversationId = String(input.conversationId ?? ''); + const convStore = useConversationStore.getState(); + const exists = convStore.conversations.some((c) => c.id === conversationId); + + if (!exists) { + return { error: `Conversation ${conversationId} not found` }; + } + + useConversationStore.setState({ currentConversationId: conversationId }); + return { switched: true, conversationId }; + }, + ); +} + +function registerMemoryTools(): void { + // --- get_token_usage --- + registerTool( + 'get_token_usage', + 'Get token usage statistics for the current session', + { type: 'object', properties: {} }, + async () => { + const msgStore = useMessageStore.getState(); + const totals = msgStore.getTotalTokens(); + return { + totalInputTokens: totals.input, + totalOutputTokens: totals.output, + totalTokens: totals.total, + }; + }, + ); +} + +function registerOfflineTools(): void { + // --- get_offline_queue --- + registerTool( + 'get_offline_queue', + 'Get the current offline message queue (messages waiting to be sent when backend reconnects)', + { type: 'object', properties: {} }, + async () => { + const offline = useOfflineStore.getState(); + return { + isOffline: offline.isOffline, + queueLength: offline.queuedMessages.length, + messages: offline.queuedMessages.map((m) => ({ + id: m.id, + content: m.content.substring(0, 200), + agentId: m.agentId, + timestamp: m.timestamp, + })), + }; + }, + ); +} + +function registerSaaSTools(): void { + // --- get_saas_account --- + registerTool( + 'get_saas_account', + 'Get current SaaS account details and subscription info', + { type: 'object', properties: {} }, + async () => { + const saas = useSaaSStore.getState(); + return { + isLoggedIn: saas.isLoggedIn, + account: saas.account, + connectionMode: saas.connectionMode, + saasUrl: saas.saasUrl, + saasReachable: saas.saasReachable, + subscription: saas.subscription, + billingLoading: saas.billingLoading, + billingError: saas.billingError, + availableTemplates: saas.availableTemplates.map((t) => ({ + id: t.id, + name: t.name, + category: t.category, + })), + assignedTemplate: saas.assignedTemplate + ? { id: saas.assignedTemplate.id, name: saas.assignedTemplate.name } + : null, + }; + }, + ); + + // --- get_available_models --- + registerTool( + 'get_available_models', + 'List available LLM models from the SaaS backend', + { type: 'object', properties: {} }, + async () => { + const saas = useSaaSStore.getState(); + return saas.availableModels.map((m) => ({ + id: m.id, + alias: m.alias, + provider_id: m.provider_id, + context_window: m.context_window, + supports_streaming: m.supports_streaming, + })); + }, + ); +} + +function registerAgentTools(): void { + // --- get_current_agent --- + registerTool( + 'get_current_agent', + 'Get the currently active agent/clone details', + { type: 'object', properties: {} }, + async () => { + const convStore = useConversationStore.getState(); + const agent = convStore.currentAgent; + + if (!agent) { + return { error: 'No agent selected' }; + } + + return { + id: agent.id, + name: agent.name, + icon: agent.icon, + color: agent.color, + lastMessage: agent.lastMessage, + }; + }, + ); + + // --- list_agents --- + registerTool( + 'list_agents', + 'List all available agents/clones', + { type: 'object', properties: {} }, + async () => { + const convStore = useConversationStore.getState(); + return convStore.agents.map((a) => ({ + id: a.id, + name: a.name, + icon: a.icon, + color: a.color, + lastMessage: a.lastMessage, + })); + }, + ); +} + +// --------------------------------------------------------------------------- +// Console error capture tool +// --------------------------------------------------------------------------- + +function registerConsoleTools(): void { + // --- get_console_errors --- + registerTool( + 'get_console_errors', + 'Get recent console errors captured by the application logger', + { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max errors to return (default 20)' }, + }, + }, + async (input) => { + const limit = Number(input.limit ?? 20); + + // Collect errors from recent console output + // The structured logger writes to window.__zclaw_logs__ if available + const logs = (window as unknown as Record).__zclaw_logs__ ?? []; + const errors = logs + .filter((entry): entry is Record => { + const e = entry as Record; + return e.level === 'error' || e.level === 'warn'; + }) + .slice(-limit); + + return { + totalCaptured: logs.length, + errorCount: errors.length, + errors: errors.map((e) => ({ + level: e.level, + message: e.message, + args: e.args, + timestamp: e.timestamp, + })), + }; + }, + ); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +let _initialized = false; + +/** + * Initialize WebMCP tools (dev mode only). + * Call once from app entry point. + */ +export function initWebMCPTools(): void { + if (_initialized) return; + if (!import.meta.env.DEV) return; + if (!navigator.modelContext) { + log.debug('WebMCP not available (enable chrome://flags/#enable-webmcp-testing)'); + return; + } + + log.info('Registering WebMCP debugging tools...'); + + try { + registerConnectionTools(); + registerChatTools(); + registerConversationTools(); + registerMemoryTools(); + registerOfflineTools(); + registerSaaSTools(); + registerAgentTools(); + registerConsoleTools(); + + log.info(`Registered ${registeredTools.length} WebMCP tools: ${registeredTools.join(', ')}`); + _initialized = true; + } catch (err) { + log.warn('Failed to register WebMCP tools:', err); + } +} + +/** Get list of registered tool names (for diagnostics) */ +export function getRegisteredWebMCPTools(): string[] { + return [...registeredTools]; +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index efb28c9..2b6493f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -4,6 +4,7 @@ import App from './App'; import './index.css'; import { ToastProvider } from './components/ui/Toast'; import { GlobalErrorBoundary } from './components/ui/ErrorBoundary'; +import { initWebMCPTools } from './lib/webmcp-tools'; // Global error handler for uncaught errors const handleGlobalError = (error: Error, errorInfo: React.ErrorInfo) => { @@ -25,6 +26,9 @@ const handleGlobalReset = () => { sessionStorage.clear(); }; +// Initialize WebMCP debugging tools (dev mode only, Chrome 146+) +initWebMCPTools(); + ReactDOM.createRoot(document.getElementById('root')!).render(