From e28eba01e11a8be0b23ba392aa15d3e1da764f11 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Mon, 9 Mar 2026 19:04:00 +0800 Subject: [PATCH] refactor/channel & ipc (#349) Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan --- AGENTS.md | 6 + README.ja-JP.md | 25 +- README.md | 25 +- README.zh-CN.md | 25 +- electron/api/routes/providers.ts | 42 +- electron/main/ipc-handlers.ts | 115 ++- electron/main/provider-model-sync.ts | 48 + electron/preload/index.ts | 1 + .../providers/provider-runtime-sync.ts | 181 ++-- .../services/providers/provider-service.ts | 51 +- electron/utils/openclaw-auth.ts | 222 ++--- electron/utils/store.ts | 2 - eslint.config.mjs | 4 + refactor.md | 35 + src/App.tsx | 5 +- src/i18n/locales/en/common.json | 7 +- src/i18n/locales/en/settings.json | 13 + src/i18n/locales/ja/common.json | 7 +- src/i18n/locales/ja/settings.json | 13 + src/i18n/locales/zh/common.json | 7 +- src/i18n/locales/zh/settings.json | 13 + src/lib/api-client.ts | 350 ++++++-- src/lib/host-api.ts | 137 +++ src/lib/host-events.ts | 40 + src/lib/telemetry.ts | 85 +- src/pages/Settings/index.tsx | 476 +++++++--- src/stores/chat/helpers.ts | 842 ++++++++++++++++++ src/stores/chat/history-actions.ts | 148 +++ src/stores/chat/internal.ts | 70 ++ src/stores/chat/runtime-actions.ts | 12 + src/stores/chat/runtime-event-actions.ts | 52 ++ src/stores/chat/runtime-event-handlers.ts | 286 ++++++ src/stores/chat/runtime-send-actions.ts | 194 ++++ src/stores/chat/runtime-ui-actions.ts | 16 + src/stores/chat/session-actions.ts | 266 ++++++ src/stores/chat/session-history-actions.ts | 10 + src/stores/chat/store-api.ts | 18 + src/stores/chat/types.ts | 113 +++ src/stores/gateway.ts | 67 +- src/stores/settings.ts | 9 - tests/unit/api-client.test.ts | 84 ++ .../unit/chat-runtime-event-handlers.test.ts | 166 ++++ tests/unit/chat-session-actions.test.ts | 123 +++ tests/unit/gateway-events.test.ts | 41 + tests/unit/host-api.test.ts | 88 ++ tests/unit/host-events.test.ts | 74 ++ tests/unit/provider-model-sync.test.ts | 89 ++ 47 files changed, 4160 insertions(+), 543 deletions(-) create mode 100644 electron/main/provider-model-sync.ts create mode 100644 src/stores/chat/helpers.ts create mode 100644 src/stores/chat/history-actions.ts create mode 100644 src/stores/chat/internal.ts create mode 100644 src/stores/chat/runtime-actions.ts create mode 100644 src/stores/chat/runtime-event-actions.ts create mode 100644 src/stores/chat/runtime-event-handlers.ts create mode 100644 src/stores/chat/runtime-send-actions.ts create mode 100644 src/stores/chat/runtime-ui-actions.ts create mode 100644 src/stores/chat/session-actions.ts create mode 100644 src/stores/chat/session-history-actions.ts create mode 100644 src/stores/chat/store-api.ts create mode 100644 src/stores/chat/types.ts create mode 100644 tests/unit/chat-runtime-event-handlers.test.ts create mode 100644 tests/unit/chat-session-actions.test.ts create mode 100644 tests/unit/gateway-events.test.ts create mode 100644 tests/unit/host-api.test.ts create mode 100644 tests/unit/host-events.test.ts create mode 100644 tests/unit/provider-model-sync.test.ts diff --git a/AGENTS.md b/AGENTS.md index 84d67dcf9..3a97934ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones: - **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed. - **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys. - **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records. +- **Renderer/Main API boundary (important)**: + - Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls. + - Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead. + - Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift. + - Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic. +- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit. diff --git a/README.ja-JP.md b/README.ja-JP.md index a5e50611b..fb6e45fc1 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -184,7 +184,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ ## アーキテクチャ -ClawXは、UIの関心事とAIランタイム操作を分離する**デュアルプロセスアーキテクチャ**を採用しています: +ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します: ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -198,18 +198,29 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル │ │ • 自動アップデートオーケストレーション │ │ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ -│ │ IPC │ +│ │ IPC(権威ある制御プレーン) │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ React レンダラープロセス │ │ │ │ • モダンなコンポーネントベースUI(React 19) │ │ │ │ • Zustandによるステート管理 │ │ -│ │ • リアルタイムWebSocket通信 │ │ +│ │ • 統一 host-api/api-client 呼び出し │ │ │ │ • リッチなMarkdownレンダリング │ │ │ └────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────┘ │ - │ WebSocket (JSON-RPC) + │ Main管理のトランスポート戦略 + │(WS優先、HTTP次点、IPCフォールバック) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Host API と Main プロキシ層 │ +│ │ +│ • hostapi:fetch(Mainプロキシ、CORS回避) │ +│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │ +│ • 統一エラーマッピングとリトライ/バックオフ │ +└──────────────────────────────┬──────────────────────────────────┘ + │ + │ WS / HTTP / IPC フォールバック ▼ ┌─────────────────────────────────────────────────────────────────┐ │ OpenClaw ゲートウェイ │ @@ -224,9 +235,11 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル ### 設計原則 - **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します -- **グレースフルリカバリ**: 指数バックオフ付きの再接続ロジックが、一時的な障害を自動的に処理します +- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません +- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します +- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します - **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します -- **ホットリロード**: 開発モードでは、ゲートウェイを再起動せずにUIの即時更新をサポートします +- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します --- diff --git a/README.md b/README.md index 080e3952c..25f6adc5a 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Notes: ## Architecture -ClawX employs a **dual-process architecture** that separates UI concerns from AI runtime operations: +ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle: ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -201,18 +201,29 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI │ │ • Auto-update orchestration │ │ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ -│ │ IPC │ +│ │ IPC (authoritative control plane) │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ React Renderer Process │ │ │ │ • Modern component-based UI (React 19) │ │ │ │ • State management with Zustand │ │ -│ │ • Real-time WebSocket communication │ │ +│ │ • Unified host-api/api-client calls │ │ │ │ • Rich Markdown rendering │ │ │ └────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────┘ │ - │ WebSocket (JSON-RPC) + │ Main-owned transport strategy + │ (WS first, HTTP then IPC fallback) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Host API & Main Process Proxies │ +│ │ +│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │ +│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │ +│ • Unified error mapping & retry/backoff │ +└──────────────────────────────┬──────────────────────────────────┘ + │ + │ WS / HTTP / IPC fallback ▼ ┌─────────────────────────────────────────────────────────────────┐ │ OpenClaw Gateway │ @@ -227,9 +238,11 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI ### Design Principles - **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation -- **Graceful Recovery**: Built-in reconnection logic with exponential backoff handles transient failures automatically +- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface +- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability +- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically - **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms -- **Hot Reload**: Development mode supports instant UI updates without restarting the gateway +- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues --- diff --git a/README.zh-CN.md b/README.zh-CN.md index e13eb77a0..63c8b7fea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -188,7 +188,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 ## 系统架构 -ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离: +ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理: ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -202,18 +202,29 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离: │ │ • 自动更新编排 │ │ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ -│ │ IPC │ +│ │ IPC(权威控制面) │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ React 渲染进程 │ │ │ │ • 现代组件化 UI(React 19) │ │ │ │ • Zustand 状态管理 │ │ -│ │ • WebSocket 实时通信 │ │ +│ │ • 统一 host-api/api-client 调用 │ │ │ │ • Markdown 富文本渲染 │ │ │ └────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────┘ │ - │ WebSocket (JSON-RPC) + │ 主进程统一传输策略 + │(WS 优先,HTTP 次之,IPC 回退) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Host API 与主进程代理层 │ +│ │ +│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │ +│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │ +│ • 统一错误映射与重试/退避策略 │ +└──────────────────────────────┬──────────────────────────────────┘ + │ + │ WS / HTTP / IPC 回退 ▼ ┌─────────────────────────────────────────────────────────────────┐ │ OpenClaw 网关 │ @@ -228,9 +239,11 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离: ### 设计原则 - **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应 -- **优雅恢复**:内置带指数退避的重连逻辑,自动处理瞬时故障 +- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节 +- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性 +- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障 - **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制 -- **热重载**:开发模式支持即时 UI 更新,无需重启网关 +- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题 --- diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 94538678d..b1c800f87 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -1,15 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { - deleteApiKey, - deleteProvider, - getAllProvidersWithKeyInfo, - getApiKey, - getDefaultProvider, - getProvider, - hasApiKey, - saveProvider, - setDefaultProvider, - storeApiKey, type ProviderConfig, } from '../../utils/secure-storage'; import { @@ -135,19 +125,19 @@ export async function handleProviderRoutes( } if (url.pathname === '/api/providers' && req.method === 'GET') { - sendJson(res, 200, await getAllProvidersWithKeyInfo()); + sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo()); return true; } if (url.pathname === '/api/providers/default' && req.method === 'GET') { - sendJson(res, 200, { providerId: await getDefaultProvider() ?? null }); + sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null }); return true; } if (url.pathname === '/api/providers/default' && req.method === 'PUT') { try { const body = await parseJsonBody<{ providerId: string }>(req); - await setDefaultProvider(body.providerId); + await providerService.setDefaultLegacyProvider(body.providerId); await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { @@ -159,7 +149,7 @@ export async function handleProviderRoutes( if (url.pathname === '/api/providers/validate' && req.method === 'POST') { try { const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req); - const provider = await getProvider(body.providerId); + const provider = await providerService.getLegacyProvider(body.providerId); const providerType = provider?.type || body.providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl; @@ -211,11 +201,11 @@ export async function handleProviderRoutes( try { const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req); const config = body.config; - await saveProvider(config); + await providerService.saveLegacyProvider(config); if (body.apiKey !== undefined) { const trimmedKey = body.apiKey.trim(); if (trimmedKey) { - await storeApiKey(config.id, trimmedKey); + await providerService.setLegacyProviderApiKey(config.id, trimmedKey); await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } } @@ -231,15 +221,15 @@ export async function handleProviderRoutes( const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); if (providerId.endsWith('/api-key')) { const actualId = providerId.slice(0, -('/api-key'.length)); - sendJson(res, 200, { apiKey: await getApiKey(actualId) }); + sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) }); return true; } if (providerId.endsWith('/has-api-key')) { const actualId = providerId.slice(0, -('/has-api-key'.length)); - sendJson(res, 200, { hasKey: await hasApiKey(actualId) }); + sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) }); return true; } - sendJson(res, 200, await getProvider(providerId)); + sendJson(res, 200, await providerService.getLegacyProvider(providerId)); return true; } @@ -247,20 +237,20 @@ export async function handleProviderRoutes( const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); try { const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); - const existing = await getProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); if (!existing) { sendJson(res, 404, { success: false, error: 'Provider not found' }); return true; } const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() }; - await saveProvider(nextConfig); + await providerService.saveLegacyProvider(nextConfig); if (body.apiKey !== undefined) { const trimmedKey = body.apiKey.trim(); if (trimmedKey) { - await storeApiKey(providerId, trimmedKey); + await providerService.setLegacyProviderApiKey(providerId, trimmedKey); await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await syncDeletedProviderApiKeyToRuntime(existing, providerId); } } @@ -275,14 +265,14 @@ export async function handleProviderRoutes( if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') { const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); try { - const existing = await getProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); if (url.searchParams.get('apiKeyOnly') === '1') { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await syncDeletedProviderApiKeyToRuntime(existing, providerId); sendJson(res, 200, { success: true }); return true; } - await deleteProvider(providerId); + await providerService.deleteLegacyProvider(providerId); await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager); sendJson(res, 200, { success: true }); } catch (error) { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 9e84c41a1..344a8bd07 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -50,6 +50,7 @@ import { } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; +import { PORTS } from '../utils/config'; type AppRequest = { id?: string; @@ -79,6 +80,7 @@ export function registerIpcHandlers( ): void { // Unified request protocol (non-breaking: legacy channels remain available) registerUnifiedRequestHandlers(gatewayManager); + registerHostApiProxyHandlers(); // Gateway handlers registerGatewayHandlers(gatewayManager, mainWindow); @@ -135,6 +137,68 @@ export function registerIpcHandlers( registerFileHandlers(); } +type HostApiFetchRequest = { + path: string; + method?: string; + headers?: Record; + body?: unknown; +}; + +function registerHostApiProxyHandlers(): void { + ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { + try { + const path = typeof request?.path === 'string' ? request.path : ''; + if (!path || !path.startsWith('/')) { + throw new Error(`Invalid host API path: ${String(request?.path)}`); + } + + const method = (request.method || 'GET').toUpperCase(); + const headers: Record = { ...(request.headers || {}) }; + let body: BodyInit | undefined; + + if (request.body !== undefined && request.body !== null) { + if (typeof request.body === 'string') { + body = request.body; + } else { + body = JSON.stringify(request.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + } + + const response = await fetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, { + method, + headers, + body, + }); + + const data: { status: number; ok: boolean; json?: unknown; text?: string } = { + status: response.status, + ok: response.ok, + }; + + if (response.status !== 204) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + data.json = await response.json().catch(() => undefined); + } else { + data.text = await response.text().catch(() => ''); + } + } + + return { ok: true, data }; + } catch (error) { + return { + ok: false, + error: { + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }); +} + function mapAppErrorCode(error: unknown): AppResponse['error']['code'] { const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); if (msg.includes('timeout')) return 'TIMEOUT'; @@ -156,6 +220,7 @@ function isProxyKey(key: keyof AppSettings): boolean { } function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { + const providerService = getProviderService(); const handleProxySettingsChange = async () => { const settings = await getAllSettings(); await applyProxySettings(settings); @@ -194,32 +259,32 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } case 'provider': { if (request.action === 'list') { - data = await getAllProvidersWithKeyInfo(); + data = await providerService.listLegacyProvidersWithKeyInfo(); break; } if (request.action === 'get') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.get payload'); - data = await getProvider(providerId); + data = await providerService.getLegacyProvider(providerId); break; } if (request.action === 'getDefault') { - data = await getDefaultProvider(); + data = await providerService.getDefaultLegacyProvider(); break; } if (request.action === 'hasApiKey') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.hasApiKey payload'); - data = await hasApiKey(providerId); + data = await providerService.hasLegacyProviderApiKey(providerId); break; } if (request.action === 'getApiKey') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.getApiKey payload'); - data = await getApiKey(providerId); + data = await providerService.getLegacyProviderApiKey(providerId); break; } if (request.action === 'validateKey') { @@ -234,7 +299,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { throw new Error('Invalid provider.validateKey payload'); } - const provider = await getProvider(providerId); + const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; @@ -251,12 +316,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (!config) throw new Error('Invalid provider.save payload'); try { - await saveProvider(config); + await providerService.saveLegacyProvider(config); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(config.id, trimmedKey); + await providerService.setLegacyProviderApiKey(config.id, trimmedKey); } } @@ -278,8 +343,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (!providerId) throw new Error('Invalid provider.delete payload'); try { - const existing = await getProvider(providerId); - await deleteProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); + await providerService.deleteLegacyProvider(providerId); if (existing?.type) { try { await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); @@ -303,8 +368,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload'); try { - await storeApiKey(providerId, apiKey); - const provider = await getProvider(providerId); + await providerService.setLegacyProviderApiKey(providerId, apiKey); + const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); try { @@ -328,13 +393,13 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey; if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload'); - const existing = await getProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); if (!existing) { data = { success: false, error: 'Provider not found' }; break; } - const previousKey = await getApiKey(providerId); + const previousKey = await providerService.getLegacyProviderApiKey(providerId); const previousOck = getOpenClawProviderKey(existing.type, providerId); try { @@ -344,15 +409,15 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { updatedAt: new Date().toISOString(), }; const ock = getOpenClawProviderKey(nextConfig.type, providerId); - await saveProvider(nextConfig); + await providerService.saveLegacyProvider(nextConfig); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(providerId, trimmedKey); + await providerService.setLegacyProviderApiKey(providerId, trimmedKey); await saveProviderKeyToOpenClaw(ock, trimmedKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(ock); } } @@ -366,12 +431,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { data = { success: true }; } catch (error) { try { - await saveProvider(existing); + await providerService.saveLegacyProvider(existing); if (previousKey) { - await storeApiKey(providerId, previousKey); + await providerService.setLegacyProviderApiKey(providerId, previousKey); await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { @@ -387,8 +452,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.deleteApiKey payload'); try { - await deleteApiKey(providerId); - const provider = await getProvider(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); + const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); try { @@ -410,8 +475,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (!providerId) throw new Error('Invalid provider.setDefault payload'); try { - await setDefaultProvider(providerId); - const provider = await getProvider(providerId); + await providerService.setDefaultLegacyProvider(providerId); + const provider = await providerService.getLegacyProvider(providerId); if (provider) { try { await syncDefaultProviderToRuntime(providerId, gatewayManager); @@ -2422,4 +2487,4 @@ function registerSessionHandlers(): void { return { success: false, error: String(err) }; } }); -} \ No newline at end of file +} diff --git a/electron/main/provider-model-sync.ts b/electron/main/provider-model-sync.ts new file mode 100644 index 000000000..f13dc6425 --- /dev/null +++ b/electron/main/provider-model-sync.ts @@ -0,0 +1,48 @@ +import { getProviderConfig } from '../utils/provider-registry'; +import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys'; +import type { ProviderConfig } from '../utils/secure-storage'; + +export interface AgentProviderUpdatePayload { + providerKey: string; + entry: { + baseUrl: string; + api: string; + apiKey: string | undefined; + models: Array<{ id: string; name: string }>; + }; +} + +export function getModelIdFromRef(modelRef: string | undefined, providerKey: string): string | undefined { + if (!modelRef) return undefined; + if (modelRef.startsWith(`${providerKey}/`)) { + return modelRef.slice(providerKey.length + 1); + } + return modelRef; +} + +export function buildNonOAuthAgentProviderUpdate( + provider: ProviderConfig, + providerId: string, + modelRef: string | undefined +): AgentProviderUpdatePayload | null { + if (provider.type === 'custom' || provider.type === 'ollama' || isOAuthProviderType(provider.type)) { + return null; + } + + const providerKey = getOpenClawProviderKeyForType(provider.type, providerId); + const meta = getProviderConfig(provider.type); + const baseUrl = provider.baseUrl || meta?.baseUrl; + const api = meta?.api; + if (!baseUrl || !api) return null; + + const modelId = getModelIdFromRef(modelRef, providerKey); + return { + providerKey, + entry: { + baseUrl, + api, + apiKey: meta?.apiKeyEnv, + models: modelId ? [{ id: modelId, name: modelId }] : [], + }, + }; +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8ed4ae1bd..c6d443b81 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -22,6 +22,7 @@ const electronAPI = { 'gateway:restart', 'gateway:rpc', 'gateway:httpProxy', + 'hostapi:fetch', 'gateway:health', 'gateway:getControlUiUrl', // OpenClaw diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index d25b1c731..534ec94a3 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -18,6 +18,12 @@ import { logger } from '../../utils/logger'; const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli'; const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`; +type RuntimeProviderSyncContext = { + runtimeProviderKey: string; + meta: ReturnType; + api: string; +}; + export function getOpenClawProviderKey(type: string, providerId: string): string { if (type === 'custom' || type === 'ollama') { const suffix = providerId.replace(/-/g, '').slice(0, 8); @@ -172,63 +178,119 @@ export async function syncAllProviderAuthToRuntime(): Promise { } } -export async function syncSavedProviderToRuntime( +async function syncProviderSecretToRuntime( config: ProviderConfig, + runtimeProviderKey: string, apiKey: string | undefined, - gatewayManager?: GatewayManager, ): Promise { - const ock = await resolveRuntimeProviderKey(config); const secret = await getProviderSecret(config.id); - if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey); } - } else if (secret?.type === 'api_key') { - await saveProviderKeyToOpenClaw(ock, secret.apiKey); - } else if (secret?.type === 'oauth') { - await saveOAuthTokenToOpenClaw(ock, { + return; + } + + if (secret?.type === 'api_key') { + await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey); + return; + } + + if (secret?.type === 'oauth') { + await saveOAuthTokenToOpenClaw(runtimeProviderKey, { access: secret.accessToken, refresh: secret.refreshToken, expires: secret.expiresAt, email: secret.email, projectId: secret.subject, }); - } else if (secret?.type === 'local' && secret.apiKey) { - await saveProviderKeyToOpenClaw(ock, secret.apiKey); - } - - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (!api) { return; } - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); + if (secret?.type === 'local' && secret.apiKey) { + await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey); + } +} - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } +async function resolveRuntimeSyncContext(config: ProviderConfig): Promise { + const runtimeProviderKey = await resolveRuntimeProviderKey(config); + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + if (!api) { + return null; + } + + return { + runtimeProviderKey, + meta, + api, + }; +} + +async function syncRuntimeProviderConfig( + config: ProviderConfig, + context: RuntimeProviderSyncContext, +): Promise { + await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, { + baseUrl: config.baseUrl || context.meta?.baseUrl, + api: context.api, + apiKeyEnv: context.meta?.apiKeyEnv, + headers: context.meta?.headers, + }); +} + +async function syncCustomProviderAgentModel( + config: ProviderConfig, + runtimeProviderKey: string, + apiKey: string | undefined, +): Promise { + if (config.type !== 'custom' && config.type !== 'ollama') { + return; + } + + const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); + if (!resolvedKey || !config.baseUrl) { + return; + } + + const modelId = config.model; + await updateAgentModelProvider(runtimeProviderKey, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); +} + +async function syncProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, +): Promise { + const context = await resolveRuntimeSyncContext(config); + if (!context) { + return null; + } + + await syncProviderSecretToRuntime(config, context.runtimeProviderKey, apiKey); + await syncRuntimeProviderConfig(config, context); + await syncCustomProviderAgentModel(config, context.runtimeProviderKey, apiKey); + return context; +} + +export async function syncSavedProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, + gatewayManager?: GatewayManager, +): Promise { + const context = await syncProviderToRuntime(config, apiKey); + if (!context) { + return; } scheduleGatewayRestart( gatewayManager, - `Scheduling Gateway restart after saving provider "${ock}" config`, + `Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`, ); } @@ -237,54 +299,13 @@ export async function syncUpdatedProviderToRuntime( apiKey: string | undefined, gatewayManager?: GatewayManager, ): Promise { - const ock = await resolveRuntimeProviderKey(config); - const fallbackModels = await getProviderFallbackModelRefs(config); - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - const secret = await getProviderSecret(config.id); - - if (!api) { + const context = await syncProviderToRuntime(config, apiKey); + if (!context) { return; } - if (apiKey !== undefined) { - const trimmedKey = apiKey.trim(); - if (trimmedKey) { - await saveProviderKeyToOpenClaw(ock, trimmedKey); - } - } else if (secret?.type === 'api_key') { - await saveProviderKeyToOpenClaw(ock, secret.apiKey); - } else if (secret?.type === 'oauth') { - await saveOAuthTokenToOpenClaw(ock, { - access: secret.accessToken, - refresh: secret.refreshToken, - expires: secret.expiresAt, - email: secret.email, - projectId: secret.subject, - }); - } else if (secret?.type === 'local' && secret.apiKey) { - await saveProviderKeyToOpenClaw(ock, secret.apiKey); - } - - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } + const ock = context.runtimeProviderKey; + const fallbackModels = await getProviderFallbackModelRefs(config); const defaultProviderId = await getDefaultProvider(); if (defaultProviderId === config.id) { diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index a9c7361d6..1531d3d75 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -20,11 +20,7 @@ import { import { deleteApiKey, deleteProvider, - getAllProviders, - getAllProvidersWithKeyInfo, getApiKey, - getDefaultProvider, - getProvider, hasApiKey, saveProvider, setDefaultProvider, @@ -32,6 +28,14 @@ import { } from '../../utils/secure-storage'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; +function maskApiKey(apiKey: string | null): string | null { + if (!apiKey) return null; + if (apiKey.length > 12) { + return `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`; + } + return '*'.repeat(apiKey.length); +} + export class ProviderService { async listVendors(): Promise { return PROVIDER_DEFINITIONS; @@ -49,7 +53,7 @@ export class ProviderService { async getDefaultAccountId(): Promise { await ensureProviderStoreMigrated(); - return (await getDefaultProvider()) ?? getDefaultProviderAccountId(); + return getDefaultProviderAccountId(); } async createAccount(account: ProviderAccount, apiKey?: string): Promise { @@ -107,31 +111,54 @@ export class ProviderService { } async listLegacyProviders(): Promise { - return getAllProviders(); + await ensureProviderStoreMigrated(); + const accounts = await listProviderAccounts(); + return accounts.map(providerAccountToConfig); } async listLegacyProvidersWithKeyInfo(): Promise { - return getAllProvidersWithKeyInfo(); + const providers = await this.listLegacyProviders(); + const results: ProviderWithKeyInfo[] = []; + for (const provider of providers) { + const apiKey = await getApiKey(provider.id); + results.push({ + ...provider, + hasKey: !!apiKey, + keyMasked: maskApiKey(apiKey), + }); + } + return results; } async getLegacyProvider(providerId: string): Promise { - return getProvider(providerId); + await ensureProviderStoreMigrated(); + const account = await getProviderAccount(providerId); + return account ? providerAccountToConfig(account) : null; } async saveLegacyProvider(config: ProviderConfig): Promise { - await saveProvider(config); + await ensureProviderStoreMigrated(); + const account = providerConfigToAccount(config); + const existing = await getProviderAccount(config.id); + if (existing) { + await this.updateAccount(config.id, account); + return; + } + await this.createAccount(account); } async deleteLegacyProvider(providerId: string): Promise { - return deleteProvider(providerId); + await ensureProviderStoreMigrated(); + await this.deleteAccount(providerId); + return true; } async setDefaultLegacyProvider(providerId: string): Promise { - await setDefaultProvider(providerId); + await this.setDefaultAccount(providerId); } async getDefaultLegacyProvider(): Promise { - return getDefaultProvider(); + return this.getDefaultAccountId(); } async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise { diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index cf4a85721..a296e44c3 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -386,21 +386,14 @@ export async function setOpenClawDefaultModel( const config = await readOpenClawJson(); ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const rawModel = modelOverride || getProviderDefaultModel(provider); - const model = rawModel - ? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`) - : undefined; + const model = normalizeModelRef(provider, modelOverride); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; } - const modelId = model.startsWith(`${provider}/`) - ? model.slice(provider.length + 1) - : model; - const fallbackModelIds = fallbackModels - .filter((fallback) => fallback.startsWith(`${provider}/`)) - .map((fallback) => fallback.slice(provider.length + 1)); + const modelId = extractModelId(provider, model); + const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); // Set the default model for the agents const agents = (config.agents || {}) as Record; @@ -415,51 +408,16 @@ export async function setOpenClawDefaultModel( // Configure models.providers for providers that need explicit registration. const providerCfg = getProviderConfig(provider); if (providerCfg) { - const models = (config.models || {}) as Record; - const providers = (models.providers || {}) as Record; - const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers); - - const existingProvider = - providers[provider] && typeof providers[provider] === 'object' - ? (providers[provider] as Record) - : {}; - - const existingModels = Array.isArray(existingProvider.models) - ? (existingProvider.models as Array>) - : []; - const registryModels = (providerCfg.models ?? []).map((m) => ({ ...m })) as Array>; - - const mergedModels = [...registryModels]; - for (const item of existingModels) { - const id = typeof item?.id === 'string' ? item.id : ''; - if (id && !mergedModels.some((m) => m.id === id)) { - mergedModels.push(item); - } - } - for (const candidateModelId of [modelId, ...fallbackModelIds]) { - if (candidateModelId && !mergedModels.some((m) => m.id === candidateModelId)) { - mergedModels.push({ id: candidateModelId, name: candidateModelId }); - } - } - - const providerEntry: Record = { - ...existingProvider, + upsertOpenClawProviderEntry(config, provider, { baseUrl: providerCfg.baseUrl, api: providerCfg.api, - apiKey: providerCfg.apiKeyEnv, - models: mergedModels, - }; - if (providerCfg.headers && Object.keys(providerCfg.headers).length > 0) { - providerEntry.headers = providerCfg.headers; - } - providers[provider] = providerEntry; + apiKeyEnv: providerCfg.apiKeyEnv, + headers: providerCfg.headers, + modelIds: [modelId, ...fallbackModelIds], + includeRegistryModels: true, + mergeExistingModels: true, + }); console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); - if (removedLegacyMoonshot) { - console.log('Removed legacy models.providers.moonshot alias entry'); - } - - models.providers = providers; - config.models = models; } else { // Built-in provider: remove any stale models.providers entry const models = (config.models || {}) as Record; @@ -489,6 +447,99 @@ interface RuntimeProviderConfigOverride { authHeader?: boolean; } +type ProviderEntryBuildOptions = { + baseUrl: string; + api: string; + apiKeyEnv?: string; + headers?: Record; + authHeader?: boolean; + modelIds?: string[]; + includeRegistryModels?: boolean; + mergeExistingModels?: boolean; +}; + +function normalizeModelRef(provider: string, modelOverride?: string): string | undefined { + const rawModel = modelOverride || getProviderDefaultModel(provider); + if (!rawModel) return undefined; + return rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`; +} + +function extractModelId(provider: string, modelRef: string): string { + return modelRef.startsWith(`${provider}/`) ? modelRef.slice(provider.length + 1) : modelRef; +} + +function extractFallbackModelIds(provider: string, fallbackModels: string[]): string[] { + return fallbackModels + .filter((fallback) => fallback.startsWith(`${provider}/`)) + .map((fallback) => fallback.slice(provider.length + 1)); +} + +function mergeProviderModels( + ...groups: Array>> +): Array> { + const merged: Array> = []; + const seen = new Set(); + + for (const group of groups) { + for (const item of group) { + const id = typeof item?.id === 'string' ? item.id : ''; + if (!id || seen.has(id)) continue; + seen.add(id); + merged.push(item); + } + } + return merged; +} + +function upsertOpenClawProviderEntry( + config: Record, + provider: string, + options: ProviderEntryBuildOptions, +): void { + const models = (config.models || {}) as Record; + const providers = (models.providers || {}) as Record; + const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers); + const existingProvider = ( + providers[provider] && typeof providers[provider] === 'object' + ? (providers[provider] as Record) + : {} + ); + + const existingModels = options.mergeExistingModels && Array.isArray(existingProvider.models) + ? (existingProvider.models as Array>) + : []; + const registryModels = options.includeRegistryModels + ? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array>) + : []; + const runtimeModels = (options.modelIds ?? []).map((id) => ({ id, name: id })); + + const nextProvider: Record = { + ...existingProvider, + baseUrl: options.baseUrl, + api: options.api, + models: mergeProviderModels(registryModels, existingModels, runtimeModels), + }; + if (options.apiKeyEnv) nextProvider.apiKey = options.apiKeyEnv; + if (options.headers && Object.keys(options.headers).length > 0) { + nextProvider.headers = options.headers; + } else { + delete nextProvider.headers; + } + if (options.authHeader !== undefined) { + nextProvider.authHeader = options.authHeader; + } else { + delete nextProvider.authHeader; + } + + providers[provider] = nextProvider; + models.providers = providers; + config.models = models; + + if (removedLegacyMoonshot) { + console.log('Removed legacy models.providers.moonshot alias entry'); + } +} + function removeLegacyMoonshotProviderEntry( _provider: string, _providers: Record @@ -528,26 +579,13 @@ export async function syncProviderConfigToOpenClaw( ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); if (override.baseUrl && override.api) { - const models = (config.models || {}) as Record; - const providers = (models.providers || {}) as Record; - removeLegacyMoonshotProviderEntry(provider, providers); - - const nextModels: Array> = []; - if (modelId) nextModels.push({ id: modelId, name: modelId }); - - const nextProvider: Record = { + upsertOpenClawProviderEntry(config, provider, { baseUrl: override.baseUrl, api: override.api, - models: nextModels, - }; - if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv; - if (override.headers && Object.keys(override.headers).length > 0) { - nextProvider.headers = override.headers; - } - - providers[provider] = nextProvider; - models.providers = providers; - config.models = models; + apiKeyEnv: override.apiKeyEnv, + headers: override.headers, + modelIds: modelId ? [modelId] : [], + }); } // Ensure extension is enabled for oauth providers to prevent gateway wiping config @@ -580,21 +618,14 @@ export async function setOpenClawDefaultModelWithOverride( const config = await readOpenClawJson(); ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const rawModel = modelOverride || getProviderDefaultModel(provider); - const model = rawModel - ? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`) - : undefined; + const model = normalizeModelRef(provider, modelOverride); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; } - const modelId = model.startsWith(`${provider}/`) - ? model.slice(provider.length + 1) - : model; - const fallbackModelIds = fallbackModels - .filter((fallback) => fallback.startsWith(`${provider}/`)) - .map((fallback) => fallback.slice(provider.length + 1)); + const modelId = extractModelId(provider, model); + const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; @@ -606,33 +637,14 @@ export async function setOpenClawDefaultModelWithOverride( config.agents = agents; if (override.baseUrl && override.api) { - const models = (config.models || {}) as Record; - const providers = (models.providers || {}) as Record; - removeLegacyMoonshotProviderEntry(provider, providers); - - const nextModels: Array> = []; - for (const candidateModelId of [modelId, ...fallbackModelIds]) { - if (candidateModelId && !nextModels.some((entry) => entry.id === candidateModelId)) { - nextModels.push({ id: candidateModelId, name: candidateModelId }); - } - } - - const nextProvider: Record = { + upsertOpenClawProviderEntry(config, provider, { baseUrl: override.baseUrl, api: override.api, - models: nextModels, - }; - if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv; - if (override.headers && Object.keys(override.headers).length > 0) { - nextProvider.headers = override.headers; - } - if (override.authHeader !== undefined) { - nextProvider.authHeader = override.authHeader; - } - - providers[provider] = nextProvider; - models.providers = providers; - config.models = models; + apiKeyEnv: override.apiKeyEnv, + headers: override.headers, + authHeader: override.authHeader, + modelIds: [modelId, ...fallbackModelIds], + }); } const gateway = (config.gateway || {}) as Record; diff --git a/electron/utils/store.ts b/electron/utils/store.ts index c3d50055a..a28fb9af5 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -36,7 +36,6 @@ export interface AppSettings { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; - gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; // Update updateChannel: 'stable' | 'beta' | 'dev'; @@ -74,7 +73,6 @@ const defaults: AppSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', - gatewayTransportPreference: 'ws-first', // Update updateChannel: 'stable', diff --git a/eslint.config.mjs b/eslint.config.mjs index 58e9f2a69..03ac439c8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,6 +49,10 @@ export default [ selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']", message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.', }, + { + selector: "CallExpression[callee.name='fetch'] Literal[value=/^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?\\//]", + message: 'Do not call local endpoints directly from renderer. Route through host-api/api-client proxies.', + }, ], }, }, diff --git a/refactor.md b/refactor.md index 6230f0eef..757edee4a 100644 --- a/refactor.md +++ b/refactor.md @@ -141,3 +141,38 @@ This branch captures local refactors focused on frontend UX polish, IPC call con - explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback. - Removed forced uppercase rendering for bucket headers to preserve localized casing. - Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior. + +### 19. `refactor_clawx_1` × `main` merge outcome (main-first baseline) +- Performed a dedicated merge line with `origin/main` as the conflict-resolution baseline for high-risk files. +- Resolved wide conflict surface in gateway/store/page layers by prioritizing compile-safe `main` implementations, then selectively re-applying compatible refactor behavior. +- Merge result focus: + - keep app runnable and type-safe first + - avoid partial hybrid states that mix incompatible host-api/ipc patterns in a single module + - retain low-risk UX/flow improvements only when behavior parity is clear + +### 20. Post-merge compile recovery +- Fixed merge-induced breakages causing `tsc`/build failures: + - malformed blocks in settings/channels/chat/store files + - duplicated variable declarations in `electron/gateway/manager.ts` + - mismatched transport helper usage introduced by partial conflict picks +- Re-aligned broken modules to `origin/main` where necessary to restore a stable build baseline. +- Current status after cleanup: + - `pnpm run typecheck` passes + - app-side Vite/Electron compile succeeds; packaging step may still fail under restricted proxy/network environments (non-code issue) + +### 21. IPC event compatibility fix after merge +- Fixed runtime error `Invalid IPC channel: gateway:channel-status` by restoring preload allowlist compatibility with renderer subscriptions. +- Reconciled `electron/preload/index.ts` event channel whitelist with active gateway event usage: + - `gateway:status-changed` + - `gateway:message` + - `gateway:notification` + - `gateway:channel-status` + - `gateway:chat-message` + - `gateway:exit` + - `gateway:error` + +### 22. Transport strategy consolidation (no user toggle) +- Removed manual transport selection UI from Settings/Developer to reduce operator complexity and avoid invalid combinations. +- Kept transport abstraction in code, but locked runtime policy to a single fixed chain: + - `WS first -> HTTP fallback -> IPC fallback` +- Applied this as bootstrap default in app initialization rather than user preference switching. diff --git a/src/App.tsx b/src/App.tsx index c9d7816dc..63ac0783b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,7 +91,6 @@ function App() { const initSettings = useSettingsStore((state) => state.init); const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); - const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference); const setupComplete = useSettingsStore((state) => state.setupComplete); const initGateway = useGatewayStore((state) => state.init); @@ -152,8 +151,8 @@ function App() { }, [theme]); useEffect(() => { - applyGatewayTransportPreference(gatewayTransportPreference); - }, [gatewayTransportPreference]); + applyGatewayTransportPreference(); + }, []); return ( diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4c4fd1cfd..2522d2d21 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -28,7 +28,10 @@ "back": "Back", "next": "Next", "skip": "Skip", - "restart": "Restart" + "restart": "Restart", + "show": "Show", + "hide": "Hide", + "clear": "Clear" }, "status": { "running": "Running", @@ -49,4 +52,4 @@ "notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.", "warning": "Gateway is not running." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 67c683de8..ce985c691 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -207,6 +207,19 @@ "cliPowershell": "PowerShell command.", "cmdUnavailable": "Command unavailable", "cmdCopied": "CLI command copied", + "wsDiagnostic": "WS Diagnostic Mode", + "wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.", + "wsDiagnosticEnabled": "WS diagnostic mode enabled", + "wsDiagnosticDisabled": "WS diagnostic mode disabled", + "telemetryViewer": "Telemetry Viewer", + "telemetryViewerDesc": "Local-only UX/performance telemetry, latest 200 entries.", + "telemetryAggregated": "Top Events", + "telemetryTotal": "Total", + "telemetryErrors": "Errors", + "telemetrySlow": "Slow (>=800ms)", + "telemetryEmpty": "No telemetry yet", + "telemetryCopied": "Telemetry copied", + "telemetryCleared": "Telemetry cleared", "installCmd": "Install \"openclaw\" Command", "installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)", "installTitle": "Install OpenClaw Command", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 21878ce7e..99d25c990 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -28,7 +28,10 @@ "back": "戻る", "next": "次へ", "skip": "スキップ", - "restart": "再起動" + "restart": "再起動", + "show": "表示", + "hide": "非表示", + "clear": "クリア" }, "status": { "running": "実行中", @@ -49,4 +52,4 @@ "notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。", "warning": "ゲートウェイが停止中です。" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index d3de77893..9d8884daa 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -206,6 +206,19 @@ "cliPowershell": "PowerShell コマンド。", "cmdUnavailable": "コマンドが利用できません", "cmdCopied": "CLI コマンドをコピーしました", + "wsDiagnostic": "WS 診断モード", + "wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。", + "wsDiagnosticEnabled": "WS 診断モードを有効化しました", + "wsDiagnosticDisabled": "WS 診断モードを無効化しました", + "telemetryViewer": "テレメトリビューア", + "telemetryViewerDesc": "ローカル専用の UX/性能テレメトリ(最新 200 件)。", + "telemetryAggregated": "イベント集計", + "telemetryTotal": "合計", + "telemetryErrors": "エラー", + "telemetrySlow": "遅延(>=800ms)", + "telemetryEmpty": "テレメトリはまだありません", + "telemetryCopied": "テレメトリをコピーしました", + "telemetryCleared": "テレメトリをクリアしました", "installCmd": "\"openclaw\" コマンドをインストール", "installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)", "installTitle": "OpenClaw コマンドをインストール", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 33b260e4d..f91a84779 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -28,7 +28,10 @@ "back": "返回", "next": "下一步", "skip": "跳过", - "restart": "重启" + "restart": "重启", + "show": "显示", + "hide": "隐藏", + "clear": "清空" }, "status": { "running": "运行中", @@ -49,4 +52,4 @@ "notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。", "warning": "网关未运行。" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 05f80f5b9..18755cb5f 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -207,6 +207,19 @@ "cliPowershell": "PowerShell 命令。", "cmdUnavailable": "命令不可用", "cmdCopied": "CLI 命令已复制", + "wsDiagnostic": "WS 诊断模式", + "wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。", + "wsDiagnosticEnabled": "已启用 WS 诊断模式", + "wsDiagnosticDisabled": "已关闭 WS 诊断模式", + "telemetryViewer": "埋点查看器", + "telemetryViewerDesc": "仅本地 UX/性能埋点,显示最近 200 条。", + "telemetryAggregated": "事件聚合", + "telemetryTotal": "总数", + "telemetryErrors": "错误", + "telemetrySlow": "慢请求(>=800ms)", + "telemetryEmpty": "暂无埋点", + "telemetryCopied": "埋点已复制", + "telemetryCleared": "埋点已清空", "installCmd": "安装 \"openclaw\" 命令", "installCmdDesc": "安装 ~/.local/bin/openclaw(无需管理员权限)", "installTitle": "安装 OpenClaw 命令", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 5e0ce52bb..e841d4790 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,3 +1,5 @@ +import { trackUiEvent } from './telemetry'; + export type AppErrorCode = | 'TIMEOUT' | 'RATE_LIMIT' @@ -8,12 +10,7 @@ export type AppErrorCode = | 'UNKNOWN'; export type TransportKind = 'ipc' | 'ws' | 'http'; -export type GatewayTransportPreference = - | 'ws-first' - | 'http-first' - | 'ws-only' - | 'http-only' - | 'ipc-only'; +export type GatewayTransportPreference = 'ws-first'; type TransportInvoker = (channel: string, args: unknown[]) => Promise; type TransportRequest = { channel: string; args: unknown[] }; @@ -90,6 +87,7 @@ const UNIFIED_CHANNELS = new Set([ ]); const customInvokers = new Map, TransportInvoker>(); +const GATEWAY_WS_DIAG_FLAG = 'clawx:gateway-ws-diagnostic'; let transportConfig: ApiClientTransportConfig = { enabled: { @@ -136,8 +134,21 @@ type GatewayWsTransportOptions = { websocketFactory?: (url: string) => WebSocket; }; +type GatewayControlUiResponse = { + success?: boolean; + token?: string; +}; + +function normalizeGatewayRpcEnvelope(value: unknown): { success: boolean; result?: unknown; error?: string } { + if (value && typeof value === 'object' && 'success' in (value as Record)) { + return value as { success: boolean; result?: unknown; error?: string }; + } + return { success: true, result: value }; +} + let cachedGatewayPort: { port: number; expiresAt: number } | null = null; const transportBackoffUntil: Partial, number>> = {}; +const SLOW_REQUEST_THRESHOLD_MS = 800; async function resolveGatewayPort(): Promise { const now = Date.now(); @@ -173,11 +184,13 @@ class TransportUnsupportedError extends Error { export class AppError extends Error { code: AppErrorCode; cause?: unknown; + details?: Record; - constructor(code: AppErrorCode, message: string, cause?: unknown) { + constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record) { super(message); this.code = code; this.cause = cause; + this.details = details; } } @@ -198,30 +211,60 @@ function mapUnifiedErrorCode(code?: string): AppErrorCode { } } -function normalizeError(err: unknown): AppError { +function normalizeError(err: unknown, details?: Record): AppError { + if (err instanceof AppError) { + return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) }); + } + const message = err instanceof Error ? err.message : String(err); const lower = message.toLowerCase(); if (lower.includes('timeout')) { - return new AppError('TIMEOUT', message, err); + return new AppError('TIMEOUT', message, err, details); } if (lower.includes('rate limit')) { - return new AppError('RATE_LIMIT', message, err); + return new AppError('RATE_LIMIT', message, err, details); } if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) { - return new AppError('PERMISSION', message, err); + return new AppError('PERMISSION', message, err, details); } if (lower.includes('network') || lower.includes('fetch')) { - return new AppError('NETWORK', message, err); + return new AppError('NETWORK', message, err, details); } if (lower.includes('gateway')) { - return new AppError('GATEWAY', message, err); + return new AppError('GATEWAY', message, err, details); } if (lower.includes('config') || lower.includes('invalid')) { - return new AppError('CONFIG', message, err); + return new AppError('CONFIG', message, err, details); } - return new AppError('UNKNOWN', message, err); + return new AppError('UNKNOWN', message, err, details); +} + +function shouldLogApiRequests(): boolean { + try { + return import.meta.env.DEV || window.localStorage.getItem('clawx:api-log') === '1'; + } catch { + return !!import.meta.env.DEV; + } +} + +function logApiAttempt(entry: { + requestId: string; + channel: string; + transport: TransportKind; + attempt: number; + durationMs: number; + ok: boolean; + error?: unknown; +}): void { + if (!shouldLogApiRequests()) return; + const base = `[api-client] id=${entry.requestId} channel=${entry.channel} transport=${entry.transport} attempt=${entry.attempt} durationMs=${entry.durationMs}`; + if (entry.ok) { + console.info(`${base} result=ok`); + } else { + console.warn(`${base} result=error`, entry.error); + } } function isRuleMatch(matcher: string | RegExp, channel: string): boolean { @@ -263,53 +306,58 @@ export function clearTransportBackoff(kind?: Exclude): voi delete transportBackoffUntil.http; } -function gatewayRulesForPreference(preference: GatewayTransportPreference): TransportRule[] { - switch (preference) { - case 'http-first': - return [ - { matcher: /^gateway:rpc$/, order: ['http', 'ws', 'ipc'] }, - { matcher: /^gateway:/, order: ['ipc'] }, - { matcher: /.*/, order: ['ipc'] }, - ]; - case 'ws-only': - return [ - { matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] }, - { matcher: /^gateway:/, order: ['ipc'] }, - { matcher: /.*/, order: ['ipc'] }, - ]; - case 'http-only': - return [ - { matcher: /^gateway:rpc$/, order: ['http', 'ipc'] }, - { matcher: /^gateway:/, order: ['ipc'] }, - { matcher: /.*/, order: ['ipc'] }, - ]; - case 'ipc-only': - return [ - { matcher: /^gateway:rpc$/, order: ['ipc'] }, - { matcher: /^gateway:/, order: ['ipc'] }, - { matcher: /.*/, order: ['ipc'] }, - ]; - case 'ws-first': - default: - return [ +export function applyGatewayTransportPreference(): void { + const wsDiagnosticEnabled = getGatewayWsDiagnosticEnabled(); + clearTransportBackoff(); + if (wsDiagnosticEnabled) { + configureApiClient({ + enabled: { + ws: true, + http: true, + }, + rules: [ { matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] }, { matcher: /^gateway:/, order: ['ipc'] }, { matcher: /.*/, order: ['ipc'] }, - ]; + ], + }); + return; + } + + // Availability-first default: + // keep IPC as the authoritative runtime path. + configureApiClient({ + enabled: { + ws: false, + http: false, + }, + rules: [ + { matcher: /^gateway:rpc$/, order: ['ipc'] }, + { matcher: /^gateway:/, order: ['ipc'] }, + { matcher: /.*/, order: ['ipc'] }, + ], + }); +} + +export function getGatewayWsDiagnosticEnabled(): boolean { + try { + return window.localStorage.getItem(GATEWAY_WS_DIAG_FLAG) === '1'; + } catch { + return false; } } -export function applyGatewayTransportPreference(preference: GatewayTransportPreference): void { - const enableWs = preference === 'ws-first' || preference === 'http-first' || preference === 'ws-only'; - const enableHttp = preference === 'ws-first' || preference === 'http-first' || preference === 'http-only'; - clearTransportBackoff(); - configureApiClient({ - enabled: { - ws: enableWs, - http: enableHttp, - }, - rules: gatewayRulesForPreference(preference), - }); +export function setGatewayWsDiagnosticEnabled(enabled: boolean): void { + try { + if (enabled) { + window.localStorage.setItem(GATEWAY_WS_DIAG_FLAG, '1'); + } else { + window.localStorage.removeItem(GATEWAY_WS_DIAG_FLAG); + } + } catch { + // ignore localStorage errors + } + applyGatewayTransportPreference(); } function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest { @@ -341,7 +389,7 @@ async function invokeViaIpc(channel: string, args: unknown[]): Promise { if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) { // Fallback to legacy channel handlers. } else { - throw normalizeError(err); + throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' }); } } } @@ -349,7 +397,7 @@ async function invokeViaIpc(channel: string, args: unknown[]): Promise { try { return await window.electron.ipcRenderer.invoke(channel, ...args) as T; } catch (err) { - throw normalizeError(err); + throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' }); } } @@ -568,12 +616,13 @@ export function createGatewayHttpTransportInvoker( : 15000; const response = await invokeViaIpc<{ - success: boolean; - status?: number; ok?: boolean; + data?: unknown; + error?: unknown; + success?: boolean; + status?: number; json?: unknown; text?: string; - error?: string; }>('gateway:httpProxy', [{ path: '/rpc', method: 'POST', @@ -585,8 +634,42 @@ export function createGatewayHttpTransportInvoker( }, }]); + if (response && 'data' in response && typeof response.ok === 'boolean') { + if (!response.ok) { + const errObj = response.error as { message?: string } | string | undefined; + throw new Error( + typeof errObj === 'string' + ? errObj + : (errObj?.message || 'Gateway HTTP proxy failed'), + ); + } + const proxyData = response.data as { status?: number; ok?: boolean; json?: unknown; text?: string } | undefined; + const payload = proxyData?.json as Record | undefined; + if (!payload || typeof payload !== 'object') { + throw new Error(proxyData?.text || `Gateway HTTP returned non-JSON (status=${proxyData?.status ?? 'unknown'})`); + } + if (payload.type === 'res') { + if (payload.ok === false || payload.error) { + throw new Error(String(payload.error ?? 'Gateway HTTP request failed')); + } + return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T; + } + if ('ok' in payload) { + if (!payload.ok) { + throw new Error(String(payload.error ?? 'Gateway HTTP request failed')); + } + return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T; + } + return normalizeGatewayRpcEnvelope(payload) as T; + } + if (!response?.success) { - throw new Error(response?.error || 'Gateway HTTP proxy failed'); + const errObj = response?.error as { message?: string } | string | undefined; + throw new Error( + typeof errObj === 'string' + ? errObj + : (errObj?.message || 'Gateway HTTP proxy failed'), + ); } const payload = response?.json as Record | undefined; @@ -598,16 +681,16 @@ export function createGatewayHttpTransportInvoker( if (payload.ok === false || payload.error) { throw new Error(String(payload.error ?? 'Gateway HTTP request failed')); } - return (payload.payload ?? payload) as T; + return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T; } if ('ok' in payload) { if (!payload.ok) { throw new Error(String(payload.error ?? 'Gateway HTTP request failed')); } - return (payload.data ?? payload) as T; + return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T; } - return payload as T; + return normalizeGatewayRpcEnvelope(payload) as T; }; } @@ -615,7 +698,13 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio const timeoutMs = options.timeoutMs ?? 15000; const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url)); const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl; - const resolveToken = options.tokenResolver ?? (() => invokeViaIpc('settings:get', ['gatewayToken'])); + const resolveToken = options.tokenResolver ?? (async () => { + const controlUi = await invokeViaIpc('gateway:getControlUiUrl', []); + if (controlUi?.success && typeof controlUi.token === 'string' && controlUi.token.trim()) { + return controlUi.token; + } + return await invokeViaIpc('settings:get', [{ key: 'gatewayToken' }]); + }); let socket: WebSocket | null = null; let connectPromise: Promise | null = null; @@ -636,12 +725,36 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio } }; + const formatGatewayError = (errorValue: unknown): string => { + if (errorValue == null) return 'unknown'; + if (typeof errorValue === 'string') return errorValue; + if (typeof errorValue === 'object') { + const asRecord = errorValue as Record; + const message = typeof asRecord.message === 'string' ? asRecord.message : null; + const code = typeof asRecord.code === 'string' || typeof asRecord.code === 'number' + ? String(asRecord.code) + : null; + if (message && code) return `${code}: ${message}`; + if (message) return message; + try { + return JSON.stringify(errorValue); + } catch { + return String(errorValue); + } + } + return String(errorValue); + }; + const sendConnect = async (_challengeNonce: string) => { if (!socket || socket.readyState !== WebSocket.OPEN) { throw new Error('Gateway WS not open during connect handshake'); } const token = await Promise.resolve(resolveToken()); connectRequestId = `connect-${Date.now()}`; + const auth = + typeof token === 'string' && token.trim().length > 0 + ? { token } + : undefined; socket.send(JSON.stringify({ type: 'req', id: connectRequestId, @@ -650,18 +763,18 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio minProtocol: 3, maxProtocol: 3, client: { - id: 'clawx-ui', + id: 'openclaw-control-ui', displayName: 'ClawX UI', - version: '0.1.0', + version: '1.0.0', platform: window.electron?.platform ?? 'unknown', - mode: 'ui', + mode: 'webchat', }, - auth: { - token: token ?? null, - }, - caps: [], + auth, + caps: ['tool-events'], role: 'operator', scopes: ['operator.admin'], + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', + locale: typeof navigator !== 'undefined' ? navigator.language : 'en', }, })); }; @@ -738,7 +851,7 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio const ok = msg.ok !== false && !msg.error; if (!ok) { cleanup(); - reject(new Error(`Gateway WS connect failed: ${String(msg.error ?? 'unknown')}`)); + reject(new Error(`Gateway WS connect failed: ${formatGatewayError(msg.error)}`)); return; } handshakeDone = true; @@ -765,10 +878,10 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio const ok = msg.ok !== false && !msg.error; if (!ok) { - item.reject(new Error(String(msg.error ?? 'Gateway WS request failed'))); + item.reject(new Error(formatGatewayError(msg.error ?? 'Gateway WS request failed'))); return; } - item.resolve(msg.payload ?? msg); + item.resolve(normalizeGatewayRpcEnvelope(msg.payload ?? msg)); } catch { // ignore malformed payload } @@ -838,7 +951,7 @@ export function initializeDefaultTransports(): void { if (defaultTransportsInitialized) return; registerTransportInvoker('ws', createGatewayWsTransportInvoker()); registerTransportInvoker('http', createGatewayHttpTransportInvoker()); - applyGatewayTransportPreference('ws-first'); + applyGatewayTransportPreference(); defaultTransportsInitialized = true; } @@ -864,15 +977,65 @@ export function toUserMessage(error: unknown): string { } export async function invokeApi(channel: string, ...args: unknown[]): Promise { + const requestId = crypto.randomUUID(); const order = resolveTransportOrder(channel); let lastError: unknown; - for (const kind of order) { + for (let i = 0; i < order.length; i += 1) { + const kind = order[i]; + const attempt = i + 1; + const startedAt = Date.now(); try { - return await invokeViaTransport(kind, channel, args); + const value = await invokeViaTransport(kind, channel, args); + const durationMs = Date.now() - startedAt; + logApiAttempt({ + requestId, + channel, + transport: kind, + attempt, + durationMs, + ok: true, + }); + if (durationMs >= SLOW_REQUEST_THRESHOLD_MS || attempt > 1) { + trackUiEvent('api.request', { + requestId, + channel, + transport: kind, + attempt, + durationMs, + fallbackUsed: attempt > 1, + }); + } + return value; } catch (err) { + const durationMs = Date.now() - startedAt; + logApiAttempt({ + requestId, + channel, + transport: kind, + attempt, + durationMs, + ok: false, + error: err, + }); + trackUiEvent('api.request_error', { + requestId, + channel, + transport: kind, + attempt, + durationMs, + message: err instanceof Error ? err.message : String(err), + }); + if (err instanceof TransportUnsupportedError) { markTransportFailure(kind); + trackUiEvent('api.transport_fallback', { + requestId, + channel, + from: kind, + reason: 'unsupported', + nextAttempt: attempt + 1, + }); lastError = err; continue; } @@ -880,13 +1043,38 @@ export async function invokeApi(channel: string, ...args: unknown[]): Promise // For non-IPC transports, fail open to the next transport. if (kind !== 'ipc') { markTransportFailure(kind); + trackUiEvent('api.transport_fallback', { + requestId, + channel, + from: kind, + reason: 'error', + nextAttempt: attempt + 1, + }); continue; } - throw err; + throw normalizeError(err, { + requestId, + channel, + transport: kind, + attempt, + durationMs, + }); } } - throw normalizeError(lastError); + trackUiEvent('api.request_failed', { + requestId, + channel, + attempts: order.length, + message: lastError instanceof Error ? lastError.message : String(lastError), + }); + + throw normalizeError(lastError, { + requestId, + channel, + transport: 'ipc', + attempt: order.length, + }); } export async function invokeIpc(channel: string, ...args: unknown[]): Promise { diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 8c8cf14ed..884e5492f 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -1,6 +1,39 @@ +import { invokeIpc } from '@/lib/api-client'; +import { trackUiEvent } from './telemetry'; + const HOST_API_PORT = 3210; const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; +type HostApiProxyResponse = { + ok?: boolean; + data?: { + status?: number; + ok?: boolean; + json?: unknown; + text?: string; + }; + error?: { message?: string } | string; + // backward compatibility fields + success: boolean; + status?: number; + json?: unknown; + text?: string; +}; + +type HostApiProxyData = { + status?: number; + ok?: boolean; + json?: unknown; + text?: string; +}; + +function headersToRecord(headers?: HeadersInit): Record { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + if (Array.isArray(headers)) return Object.fromEntries(headers); + return { ...headers }; +} + async function parseResponse(response: Response): Promise { if (!response.ok) { let message = `${response.status} ${response.statusText}`; @@ -22,7 +55,104 @@ async function parseResponse(response: Response): Promise { return await response.json() as T; } +function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string { + return typeof error === 'string' + ? error + : (error?.message || 'Host API proxy request failed'); +} + +function parseUnifiedProxyResponse( + response: HostApiProxyResponse, + path: string, + method: string, + startedAt: number, +): T { + if (!response.ok) { + throw new Error(resolveProxyErrorMessage(response.error)); + } + + const data: HostApiProxyData = response.data ?? {}; + trackUiEvent('hostapi.fetch', { + path, + method, + source: 'ipc-proxy', + durationMs: Date.now() - startedAt, + status: data.status ?? 200, + }); + + if (data.status === 204) return undefined as T; + if (data.json !== undefined) return data.json as T; + return data.text as T; +} + +function parseLegacyProxyResponse( + response: HostApiProxyResponse, + path: string, + method: string, + startedAt: number, +): T { + if (!response.success) { + throw new Error(resolveProxyErrorMessage(response.error)); + } + + if (!response.ok) { + const message = response.text + || (typeof response.json === 'object' && response.json != null && 'error' in (response.json as Record) + ? String((response.json as Record).error) + : `HTTP ${response.status ?? 'unknown'}`); + throw new Error(message); + } + + trackUiEvent('hostapi.fetch', { + path, + method, + source: 'ipc-proxy-legacy', + durationMs: Date.now() - startedAt, + status: response.status ?? 200, + }); + + if (response.status === 204) return undefined as T; + if (response.json !== undefined) return response.json as T; + return response.text as T; +} + +function shouldFallbackToBrowser(message: string): boolean { + return message.includes('Invalid IPC channel: hostapi:fetch') + || message.includes('window is not defined'); +} + export async function hostApiFetch(path: string, init?: RequestInit): Promise { + const startedAt = Date.now(); + const method = init?.method || 'GET'; + // In Electron renderer, always proxy through main process to avoid CORS. + try { + const response = await invokeIpc('hostapi:fetch', { + path, + method, + headers: headersToRecord(init?.headers), + body: init?.body ?? null, + }); + + if (typeof response?.ok === 'boolean' && 'data' in response) { + return parseUnifiedProxyResponse(response, path, method, startedAt); + } + + return parseLegacyProxyResponse(response, path, method, startedAt); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + trackUiEvent('hostapi.fetch_error', { + path, + method, + source: 'ipc-proxy', + durationMs: Date.now() - startedAt, + message, + }); + if (!shouldFallbackToBrowser(message)) { + throw error; + } + } + + // Browser-only fallback (non-Electron environments). const response = await fetch(`${HOST_API_BASE}${path}`, { ...init, headers: { @@ -30,6 +160,13 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise ...(init?.headers || {}), }, }); + trackUiEvent('hostapi.fetch', { + path, + method, + source: 'browser-fallback', + durationMs: Date.now() - startedAt, + status: response.status, + }); return parseResponse(response); } diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index 35f9c3a16..200f731b8 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -2,6 +2,21 @@ import { createHostEventSource } from './host-api'; let eventSource: EventSource | null = null; +const HOST_EVENT_TO_IPC_CHANNEL: Record = { + 'gateway:status': 'gateway:status-changed', + 'gateway:error': 'gateway:error', + 'gateway:notification': 'gateway:notification', + 'gateway:chat-message': 'gateway:chat-message', + 'gateway:channel-status': 'gateway:channel-status', + 'gateway:exit': 'gateway:exit', + 'oauth:code': 'oauth:code', + 'oauth:success': 'oauth:success', + 'oauth:error': 'oauth:error', + 'channel:whatsapp-qr': 'channel:whatsapp-qr', + 'channel:whatsapp-success': 'channel:whatsapp-success', + 'channel:whatsapp-error': 'channel:whatsapp-error', +}; + function getEventSource(): EventSource { if (!eventSource) { eventSource = createHostEventSource(); @@ -9,10 +24,35 @@ function getEventSource(): EventSource { return eventSource; } +function allowSseFallback(): boolean { + try { + return window.localStorage.getItem('clawx:allow-sse-fallback') === '1'; + } catch { + return false; + } +} + export function subscribeHostEvent( eventName: string, handler: (payload: T) => void, ): () => void { + const ipc = window.electron?.ipcRenderer; + const ipcChannel = HOST_EVENT_TO_IPC_CHANNEL[eventName]; + if (ipcChannel && ipc?.on && ipc?.off) { + const listener = (payload: unknown) => { + handler(payload as T); + }; + ipc.on(ipcChannel, listener); + return () => { + ipc.off(ipcChannel, listener); + }; + } + + if (!allowSseFallback()) { + console.warn(`[host-events] no IPC mapping for event "${eventName}", SSE fallback disabled`); + return () => {}; + } + const source = getEventSource(); const listener = (event: Event) => { const payload = JSON.parse((event as MessageEvent).data) as T; diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 218963297..8f81cee08 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,6 +1,17 @@ type TelemetryPayload = Record; +export type UiTelemetryEntry = { + id: number; + event: string; + payload: TelemetryPayload; + count: number; + ts: string; +}; const counters = new Map(); +const history: UiTelemetryEntry[] = []; +const listeners = new Set<(entry: UiTelemetryEntry) => void>(); +let nextEntryId = 1; +const MAX_HISTORY = 500; function safeStringify(payload: TelemetryPayload): string { try { @@ -14,10 +25,29 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi const count = (counters.get(event) ?? 0) + 1; counters.set(event, count); - const logPayload = { + const normalizedPayload = { ...payload, + }; + const ts = new Date().toISOString(); + const entry: UiTelemetryEntry = { + id: nextEntryId, + event, + payload: normalizedPayload, count, - ts: new Date().toISOString(), + ts, + }; + nextEntryId += 1; + + history.push(entry); + if (history.length > MAX_HISTORY) { + history.splice(0, history.length - MAX_HISTORY); + } + listeners.forEach((listener) => listener(entry)); + + const logPayload = { + ...normalizedPayload, + count, + ts, }; // Local-only telemetry for UX diagnostics. @@ -27,3 +57,54 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi export function getUiCounter(event: string): number { return counters.get(event) ?? 0; } + +export function trackUiTiming( + event: string, + durationMs: number, + payload: TelemetryPayload = {}, +): void { + trackUiEvent(event, { + ...payload, + durationMs: Math.round(durationMs), + }); +} + +export function startUiTiming( + event: string, + payload: TelemetryPayload = {}, +): (nextPayload?: TelemetryPayload) => number { + const start = typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + + return (nextPayload: TelemetryPayload = {}): number => { + const end = typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + const durationMs = Math.max(0, end - start); + trackUiTiming(event, durationMs, { ...payload, ...nextPayload }); + return durationMs; + }; +} + +export function getUiTelemetrySnapshot(limit = 200): UiTelemetryEntry[] { + if (!Number.isFinite(limit) || limit <= 0) { + return []; + } + if (limit >= history.length) { + return [...history]; + } + return history.slice(-limit); +} + +export function clearUiTelemetry(): void { + counters.clear(); + history.length = 0; +} + +export function subscribeUiTelemetry(listener: (entry: UiTelemetryEntry) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index fe834409e..f842576ff 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -2,7 +2,7 @@ * Settings Page * Application configuration */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Sun, Moon, @@ -30,8 +30,19 @@ import { useGatewayStore } from '@/stores/gateway'; import { useUpdateStore } from '@/stores/update'; import { ProvidersSettings } from '@/components/settings/ProvidersSettings'; import { UpdateSettings } from '@/components/settings/UpdateSettings'; -import { invokeIpc, toUserMessage } from '@/lib/api-client'; -import { trackUiEvent } from '@/lib/telemetry'; +import { + getGatewayWsDiagnosticEnabled, + invokeIpc, + setGatewayWsDiagnosticEnabled, + toUserMessage, +} from '@/lib/api-client'; +import { + clearUiTelemetry, + getUiTelemetrySnapshot, + subscribeUiTelemetry, + trackUiEvent, + type UiTelemetryEntry, +} from '@/lib/telemetry'; import { useTranslation } from 'react-i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; import { hostApiFetch } from '@/lib/host-api'; @@ -41,8 +52,6 @@ type ControlUiInfo = { port: number; }; -type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; - export function Settings() { const { t } = useTranslation('settings'); const { @@ -58,14 +67,12 @@ export function Settings() { proxyHttpsServer, proxyAllServer, proxyBypassRules, - gatewayTransportPreference, setProxyEnabled, setProxyServer, setProxyHttpServer, setProxyHttpsServer, setProxyAllServer, setProxyBypassRules, - setGatewayTransportPreference, autoCheckUpdate, setAutoCheckUpdate, autoDownloadUpdate, @@ -88,14 +95,9 @@ export function Settings() { const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false); const [showAdvancedProxy, setShowAdvancedProxy] = useState(false); const [savingProxy, setSavingProxy] = useState(false); - - const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [ - { value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' }, - { value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' }, - { value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' }, - { value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' }, - { value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' }, - ]; + const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false); + const [showTelemetryViewer, setShowTelemetryViewer] = useState(false); + const [telemetryEntries, setTelemetryEntries] = useState([]); const isWindows = window.electron.platform === 'win32'; const showCliTools = true; @@ -222,6 +224,25 @@ export function Settings() { return () => { unsubscribe?.(); }; }, []); + useEffect(() => { + setWsDiagnosticEnabled(getGatewayWsDiagnosticEnabled()); + }, []); + + useEffect(() => { + if (!devModeUnlocked) return; + setTelemetryEntries(getUiTelemetrySnapshot(200)); + const unsubscribe = subscribeUiTelemetry((entry) => { + setTelemetryEntries((prev) => { + const next = [...prev, entry]; + if (next.length > 200) { + next.splice(0, next.length - 200); + } + return next; + }); + }); + return unsubscribe; + }, [devModeUnlocked]); + useEffect(() => { setProxyEnabledDraft(proxyEnabled); }, [proxyEnabled]); @@ -279,8 +300,99 @@ export function Settings() { } }; + const telemetryStats = useMemo(() => { + let errorCount = 0; + let slowCount = 0; + for (const entry of telemetryEntries) { + if (entry.event.endsWith('_error') || entry.event.includes('request_error')) { + errorCount += 1; + } + const durationMs = typeof entry.payload.durationMs === 'number' + ? entry.payload.durationMs + : Number.NaN; + if (Number.isFinite(durationMs) && durationMs >= 800) { + slowCount += 1; + } + } + return { total: telemetryEntries.length, errorCount, slowCount }; + }, [telemetryEntries]); + + const telemetryByEvent = useMemo(() => { + const map = new Map(); + + for (const entry of telemetryEntries) { + const current = map.get(entry.event) ?? { + event: entry.event, + count: 0, + errorCount: 0, + slowCount: 0, + totalDuration: 0, + timedCount: 0, + lastTs: entry.ts, + }; + + current.count += 1; + current.lastTs = entry.ts; + + if (entry.event.endsWith('_error') || entry.event.includes('request_error')) { + current.errorCount += 1; + } + + const durationMs = typeof entry.payload.durationMs === 'number' + ? entry.payload.durationMs + : Number.NaN; + if (Number.isFinite(durationMs)) { + current.totalDuration += durationMs; + current.timedCount += 1; + if (durationMs >= 800) { + current.slowCount += 1; + } + } + + map.set(entry.event, current); + } + + return [...map.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 12); + }, [telemetryEntries]); + + const handleCopyTelemetry = async () => { + try { + const serialized = telemetryEntries.map((entry) => JSON.stringify(entry)).join('\n'); + await navigator.clipboard.writeText(serialized); + toast.success(t('developer.telemetryCopied')); + } catch (error) { + toast.error(`${t('common:status.error')}: ${String(error)}`); + } + }; + + const handleClearTelemetry = () => { + clearUiTelemetry(); + setTelemetryEntries([]); + toast.success(t('developer.telemetryCleared')); + }; + + const handleWsDiagnosticToggle = (enabled: boolean) => { + setGatewayWsDiagnosticEnabled(enabled); + setWsDiagnosticEnabled(enabled); + toast.success( + enabled + ? t('developer.wsDiagnosticEnabled') + : t('developer.wsDiagnosticDisabled'), + ); + }; + return ( -
+

{t('title')}

@@ -289,7 +401,7 @@ export function Settings() {

{/* Appearance */} - + {t('appearance.title')} {t('appearance.description')} @@ -343,7 +455,7 @@ export function Settings() { {/* AI Providers */} - + @@ -357,7 +469,7 @@ export function Settings() { {/* Gateway */} - + {t('gateway.title')} {t('gateway.description')} @@ -430,34 +542,8 @@ export function Settings() { -
-
-
- -

- {t('gateway.proxyDesc')} -

-
- -
- -
- - setProxyServerDraft(event.target.value)} - placeholder="http://127.0.0.1:7890" - /> -

- {t('gateway.proxyServerHelp')} -

-
- - {devModeUnlocked && ( + {devModeUnlocked ? ( +
+
)}
- )} - -
- - setProxyBypassRulesDraft(event.target.value)} - placeholder=";localhost;127.0.0.1;::1" - /> -

- {t('gateway.proxyBypassHelp')} -

- -
-

- {t('gateway.proxyRestartNote')} -

- + ) : ( +
+ {t('advanced.devModeDesc')}
-
+ )}
{/* Updates */} - + @@ -593,7 +709,7 @@ export function Settings() { {/* Advanced */} - + {t('advanced.title')} {t('advanced.description')} @@ -616,40 +732,12 @@ export function Settings() { {/* Developer */} {devModeUnlocked && ( - + {t('developer.title')} {t('developer.description')} -
-
- -

- {t('advanced.transport.desc')} -

-
-
- {transportOptions.map((option) => ( - - ))} -
-
- - -

@@ -729,12 +817,116 @@ export function Settings() {

)} + + +
+
+
+ +

+ {t('developer.wsDiagnosticDesc')} +

+
+ +
+ +
+
+ +

+ {t('developer.telemetryViewerDesc')} +

+
+ +
+ + {showTelemetryViewer && ( +
+
+ {t('developer.telemetryTotal')}: {telemetryStats.total} + 0 ? 'destructive' : 'secondary'}> + {t('developer.telemetryErrors')}: {telemetryStats.errorCount} + + 0 ? 'secondary' : 'outline'}> + {t('developer.telemetrySlow')}: {telemetryStats.slowCount} + +
+ + +
+
+ +
+ {telemetryByEvent.length > 0 && ( +
+

+ {t('developer.telemetryAggregated')} +

+
+ {telemetryByEvent.map((item) => ( +
+ {item.event} + n={item.count} + + avg={item.timedCount > 0 ? Math.round(item.totalDuration / item.timedCount) : 0}ms + + slow={item.slowCount} + err={item.errorCount} +
+ ))} +
+
+ )} +
+ {telemetryEntries.length === 0 ? ( +
{t('developer.telemetryEmpty')}
+ ) : ( + telemetryEntries + .slice() + .reverse() + .map((entry) => ( +
+
+ {entry.event} + {entry.ts} +
+
+                                {JSON.stringify({ count: entry.count, ...entry.payload }, null, 2)}
+                              
+
+ )) + )} +
+
+
+ )} +
)} {/* About */} - + {t('about.title')} diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts new file mode 100644 index 000000000..b2d9bb83b --- /dev/null +++ b/src/stores/chat/helpers.ts @@ -0,0 +1,842 @@ +import { invokeIpc } from '@/lib/api-client'; +import type { AttachedFileMeta, ChatSession, ContentBlock, RawMessage, ToolStatus } from './types'; + +// Module-level timestamp tracking the last chat event received. +// Used by the safety timeout to avoid false-positive "no response" errors +// during tool-use conversations where streamingMessage is temporarily cleared +// between tool-result finals and the next delta. +let _lastChatEventAt = 0; + +/** Normalize a timestamp to milliseconds. Handles both seconds and ms. */ +function toMs(ts: number): number { + // Timestamps < 1e12 are in seconds (before ~2033); >= 1e12 are milliseconds + return ts < 1e12 ? ts * 1000 : ts; +} + +// Timer for fallback history polling during active sends. +// If no streaming events arrive within a few seconds, we periodically +// poll chat.history to surface intermediate tool-call turns. +let _historyPollTimer: ReturnType | null = null; + +// Timer for delayed error finalization. When the Gateway reports a mid-stream +// error (e.g. "terminated"), it may retry internally and recover. We wait +// before committing the error to give the recovery path a chance. +let _errorRecoveryTimer: ReturnType | null = null; + +function clearErrorRecoveryTimer(): void { + if (_errorRecoveryTimer) { + clearTimeout(_errorRecoveryTimer); + _errorRecoveryTimer = null; + } +} + +function clearHistoryPoll(): void { + if (_historyPollTimer) { + clearTimeout(_historyPollTimer); + _historyPollTimer = null; + } +} + +// ── Local image cache ───────────────────────────────────────── +// The Gateway doesn't store image attachments in session content blocks, +// so we cache them locally keyed by staged file path (which appears in the +// [media attached: ...] reference in the Gateway's user message text). +// Keying by path avoids the race condition of keying by runId (which is only +// available after the RPC returns, but history may load before that). +const IMAGE_CACHE_KEY = 'clawx:image-cache'; +const IMAGE_CACHE_MAX = 100; // max entries to prevent unbounded growth + +function loadImageCache(): Map { + try { + const raw = localStorage.getItem(IMAGE_CACHE_KEY); + if (raw) { + const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>; + return new Map(entries); + } + } catch { /* ignore parse errors */ } + return new Map(); +} + +function saveImageCache(cache: Map): void { + try { + // Evict oldest entries if over limit + const entries = Array.from(cache.entries()); + const trimmed = entries.length > IMAGE_CACHE_MAX + ? entries.slice(entries.length - IMAGE_CACHE_MAX) + : entries; + localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed)); + } catch { /* ignore quota errors */ } +} + +const _imageCache = loadImageCache(); + +function upsertImageCacheEntry(filePath: string, file: Omit): void { + _imageCache.set(filePath, { ...file, filePath }); + saveImageCache(_imageCache); +} + +/** Extract plain text from message content (string or content blocks) */ +function getMessageText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return (content as Array<{ type?: string; text?: string }>) + .filter(b => b.type === 'text' && b.text) + .map(b => b.text!) + .join('\n'); + } + return ''; +} + +/** Extract media file refs from [media attached: () | ...] patterns */ +function extractMediaRefs(text: string): Array<{ filePath: string; mimeType: string }> { + const refs: Array<{ filePath: string; mimeType: string }> = []; + const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g; + let match; + while ((match = regex.exec(text)) !== null) { + refs.push({ filePath: match[1], mimeType: match[2] }); + } + return refs; +} + +/** Map common file extensions to MIME types */ +function mimeFromExtension(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + // Images + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'avif': 'image/avif', + 'svg': 'image/svg+xml', + // Documents + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'md': 'text/markdown', + 'rtf': 'application/rtf', + 'epub': 'application/epub+zip', + // Archives + 'zip': 'application/zip', + 'tar': 'application/x-tar', + 'gz': 'application/gzip', + 'rar': 'application/vnd.rar', + '7z': 'application/x-7z-compressed', + // Audio + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + 'ogg': 'audio/ogg', + 'aac': 'audio/aac', + 'flac': 'audio/flac', + 'm4a': 'audio/mp4', + // Video + 'mp4': 'video/mp4', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', + 'mkv': 'video/x-matroska', + 'webm': 'video/webm', + 'm4v': 'video/mp4', + }; + return map[ext] || 'application/octet-stream'; +} + +/** + * Extract raw file paths from message text. + * Detects absolute paths (Unix: / or ~/, Windows: C:\ etc.) ending with common file extensions. + * Handles both image and non-image files, consistent with channel push message behavior. + */ +function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: string }> { + const refs: Array<{ filePath: string; mimeType: string }> = []; + const seen = new Set(); + const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v'; + // Unix absolute paths (/... or ~/...) — lookbehind rejects mid-token slashes + // (e.g. "path/to/file.mp4", "https://example.com/file.mp4") + const unixRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); + // Windows absolute paths (C:\... D:\...) — lookbehind rejects drive letter glued to a word + const winRegex = new RegExp(`(?]*?\\.(?:${exts}))`, 'gi'); + for (const regex of [unixRegex, winRegex]) { + let match; + while ((match = regex.exec(text)) !== null) { + const p = match[1]; + if (p && !seen.has(p)) { + seen.add(p); + refs.push({ filePath: p, mimeType: mimeFromExtension(p) }); + } + } + } + return refs; +} + +/** + * Extract images from a content array (including nested tool_result content). + * Converts them to AttachedFileMeta entries with preview set to data URL or remote URL. + */ +function extractImagesAsAttachedFiles(content: unknown): AttachedFileMeta[] { + if (!Array.isArray(content)) return []; + const files: AttachedFileMeta[] = []; + + for (const block of content as ContentBlock[]) { + if (block.type === 'image') { + // Path 1: Anthropic source-wrapped format {source: {type, media_type, data}} + if (block.source) { + const src = block.source; + const mimeType = src.media_type || 'image/jpeg'; + + if (src.type === 'base64' && src.data) { + files.push({ + fileName: 'image', + mimeType, + fileSize: 0, + preview: `data:${mimeType};base64,${src.data}`, + }); + } else if (src.type === 'url' && src.url) { + files.push({ + fileName: 'image', + mimeType, + fileSize: 0, + preview: src.url, + }); + } + } + // Path 2: Flat format from Gateway tool results {data, mimeType} + else if (block.data) { + const mimeType = block.mimeType || 'image/jpeg'; + files.push({ + fileName: 'image', + mimeType, + fileSize: 0, + preview: `data:${mimeType};base64,${block.data}`, + }); + } + } + // Recurse into tool_result content blocks + if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) { + files.push(...extractImagesAsAttachedFiles(block.content)); + } + } + return files; +} + +/** + * Build an AttachedFileMeta entry for a file ref, using cache if available. + */ +function makeAttachedFile(ref: { filePath: string; mimeType: string }): AttachedFileMeta { + const cached = _imageCache.get(ref.filePath); + if (cached) return { ...cached, filePath: ref.filePath }; + const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; + return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath }; +} + +/** + * Extract file path from a tool call's arguments by toolCallId. + * Searches common argument names: file_path, filePath, path, file. + */ +function getToolCallFilePath(msg: RawMessage, toolCallId: string): string | undefined { + if (!toolCallId) return undefined; + + // Anthropic/normalized format — toolCall blocks in content array + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id === toolCallId) { + const args = (block.input ?? block.arguments) as Record | undefined; + if (args) { + const fp = args.file_path ?? args.filePath ?? args.path ?? args.file; + if (typeof fp === 'string') return fp; + } + } + } + } + + // OpenAI format — tool_calls array on the message itself + const msgAny = msg as unknown as Record; + const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls as Array>) { + if (tc.id !== toolCallId) continue; + const fn = (tc.function ?? tc) as Record; + let args: Record | undefined; + try { + args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; + } catch { /* ignore */ } + if (args) { + const fp = args.file_path ?? args.filePath ?? args.path ?? args.file; + if (typeof fp === 'string') return fp; + } + } + } + + return undefined; +} + +/** + * Collect all tool call file paths from a message into a Map. + */ +function collectToolCallPaths(msg: RawMessage, paths: Map): void { + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id) { + const args = (block.input ?? block.arguments) as Record | undefined; + if (args) { + const fp = args.file_path ?? args.filePath ?? args.path ?? args.file; + if (typeof fp === 'string') paths.set(block.id, fp); + } + } + } + } + const msgAny = msg as unknown as Record; + const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls; + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls as Array>) { + const id = typeof tc.id === 'string' ? tc.id : ''; + if (!id) continue; + const fn = (tc.function ?? tc) as Record; + let args: Record | undefined; + try { + args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record; + } catch { /* ignore */ } + if (args) { + const fp = args.file_path ?? args.filePath ?? args.path ?? args.file; + if (typeof fp === 'string') paths.set(id, fp); + } + } + } +} + +/** + * Before filtering tool_result messages from history, scan them for any file/image + * content and attach those to the immediately following assistant message. + * This mirrors channel push message behavior where tool outputs surface files to the UI. + * Handles: + * - Image content blocks (base64 / url) + * - [media attached: path (mime) | path] text patterns in tool result output + * - Raw file paths in tool result text + */ +function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] { + const pending: AttachedFileMeta[] = []; + const toolCallPaths = new Map(); + + return messages.map((msg) => { + // Track file paths from assistant tool call arguments for later matching + if (msg.role === 'assistant') { + collectToolCallPaths(msg, toolCallPaths); + } + + if (isToolResultRole(msg.role)) { + // Resolve file path from the matching tool call + const matchedPath = msg.toolCallId ? toolCallPaths.get(msg.toolCallId) : undefined; + + // 1. Image/file content blocks in the structured content array + const imageFiles = extractImagesAsAttachedFiles(msg.content); + if (matchedPath) { + for (const f of imageFiles) { + if (!f.filePath) { + f.filePath = matchedPath; + f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; + } + } + } + pending.push(...imageFiles); + + // 2. [media attached: ...] patterns in tool result text output + const text = getMessageText(msg.content); + if (text) { + const mediaRefs = extractMediaRefs(text); + const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); + for (const ref of mediaRefs) { + pending.push(makeAttachedFile(ref)); + } + // 3. Raw file paths in tool result text (documents, audio, video, etc.) + for (const ref of extractRawFilePaths(text)) { + if (!mediaRefPaths.has(ref.filePath)) { + pending.push(makeAttachedFile(ref)); + } + } + } + + return msg; // will be filtered later + } + + if (msg.role === 'assistant' && pending.length > 0) { + const toAttach = pending.splice(0); + // Deduplicate against files already on the assistant message + const existingPaths = new Set( + (msg._attachedFiles || []).map(f => f.filePath).filter(Boolean), + ); + const newFiles = toAttach.filter(f => !f.filePath || !existingPaths.has(f.filePath)); + if (newFiles.length === 0) return msg; + return { + ...msg, + _attachedFiles: [...(msg._attachedFiles || []), ...newFiles], + }; + } + + return msg; + }); +} + +/** + * Restore _attachedFiles for messages loaded from history. + * Handles: + * 1. [media attached: path (mime) | path] patterns (attachment-button flow) + * 2. Raw image file paths typed in message text (e.g. /Users/.../image.png) + * Uses local cache for previews when available; missing previews are loaded async. + */ +function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { + return messages.map((msg, idx) => { + // Only process user and assistant messages; skip if already enriched + if ((msg.role !== 'user' && msg.role !== 'assistant') || msg._attachedFiles) return msg; + const text = getMessageText(msg.content); + + // Path 1: [media attached: path (mime) | path] — guaranteed format from attachment button + const mediaRefs = extractMediaRefs(text); + const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); + + // Path 2: Raw file paths. + // For assistant messages: scan own text AND the nearest preceding user message text, + // but only for non-tool-only assistant messages (i.e. the final answer turn). + // Tool-only messages (thinking + tool calls) should not show file previews — those + // belong to the final answer message that comes after the tool results. + // User messages never get raw-path previews so the image is not shown twice. + let rawRefs: Array<{ filePath: string; mimeType: string }> = []; + if (msg.role === 'assistant' && !isToolOnlyMessage(msg)) { + // Own text + rawRefs = extractRawFilePaths(text).filter(r => !mediaRefPaths.has(r.filePath)); + + // Nearest preceding user message text (look back up to 5 messages) + const seenPaths = new Set(rawRefs.map(r => r.filePath)); + for (let i = idx - 1; i >= Math.max(0, idx - 5); i--) { + const prev = messages[i]; + if (!prev) break; + if (prev.role === 'user') { + const prevText = getMessageText(prev.content); + for (const ref of extractRawFilePaths(prevText)) { + if (!mediaRefPaths.has(ref.filePath) && !seenPaths.has(ref.filePath)) { + seenPaths.add(ref.filePath); + rawRefs.push(ref); + } + } + break; // only use the nearest user message + } + } + } + + const allRefs = [...mediaRefs, ...rawRefs]; + if (allRefs.length === 0) return msg; + + const files: AttachedFileMeta[] = allRefs.map(ref => { + const cached = _imageCache.get(ref.filePath); + if (cached) return { ...cached, filePath: ref.filePath }; + const fileName = ref.filePath.split(/[\\/]/).pop() || 'file'; + return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath }; + }); + return { ...msg, _attachedFiles: files }; + }); +} + +/** + * Async: load missing previews from disk via IPC for messages that have + * _attachedFiles with null previews. Updates messages in-place and triggers re-render. + * Handles both [media attached: ...] patterns and raw filePath entries. + */ +async function loadMissingPreviews(messages: RawMessage[]): Promise { + // Collect all image paths that need previews + const needPreview: Array<{ filePath: string; mimeType: string }> = []; + const seenPaths = new Set(); + + for (const msg of messages) { + if (!msg._attachedFiles) continue; + + // Path 1: files with explicit filePath field (raw path detection or enriched refs) + for (const file of msg._attachedFiles) { + const fp = file.filePath; + if (!fp || seenPaths.has(fp)) continue; + // Images: need preview. Non-images: need file size (for FileCard display). + const needsLoad = file.mimeType.startsWith('image/') + ? !file.preview + : file.fileSize === 0; + if (needsLoad) { + seenPaths.add(fp); + needPreview.push({ filePath: fp, mimeType: file.mimeType }); + } + } + + // Path 2: [media attached: ...] patterns (legacy — in case filePath wasn't stored) + if (msg.role === 'user') { + const text = getMessageText(msg.content); + const refs = extractMediaRefs(text); + for (let i = 0; i < refs.length; i++) { + const file = msg._attachedFiles[i]; + const ref = refs[i]; + if (!file || !ref || seenPaths.has(ref.filePath)) continue; + const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0; + if (needsLoad) { + seenPaths.add(ref.filePath); + needPreview.push(ref); + } + } + } + } + + if (needPreview.length === 0) return false; + + try { + const thumbnails = await invokeIpc( + 'media:getThumbnails', + needPreview, + ) as Record; + + let updated = false; + for (const msg of messages) { + if (!msg._attachedFiles) continue; + + // Update files that have filePath + for (const file of msg._attachedFiles) { + const fp = file.filePath; + if (!fp) continue; + const thumb = thumbnails[fp]; + if (thumb && (thumb.preview || thumb.fileSize)) { + if (thumb.preview) file.preview = thumb.preview; + if (thumb.fileSize) file.fileSize = thumb.fileSize; + _imageCache.set(fp, { ...file }); + updated = true; + } + } + + // Legacy: update by index for [media attached: ...] refs + if (msg.role === 'user') { + const text = getMessageText(msg.content); + const refs = extractMediaRefs(text); + for (let i = 0; i < refs.length; i++) { + const file = msg._attachedFiles[i]; + const ref = refs[i]; + if (!file || !ref || file.filePath) continue; // skip if already handled via filePath + const thumb = thumbnails[ref.filePath]; + if (thumb && (thumb.preview || thumb.fileSize)) { + if (thumb.preview) file.preview = thumb.preview; + if (thumb.fileSize) file.fileSize = thumb.fileSize; + _imageCache.set(ref.filePath, { ...file }); + updated = true; + } + } + } + } + if (updated) saveImageCache(_imageCache); + return updated; + } catch (err) { + console.warn('[loadMissingPreviews] Failed:', err); + return false; + } +} + +function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null { + const canonical = sessions.find((s) => s.key.startsWith('agent:'))?.key; + if (!canonical) return null; + const parts = canonical.split(':'); + if (parts.length < 2) return null; + return `${parts[0]}:${parts[1]}`; +} + +function isToolOnlyMessage(message: RawMessage | undefined): boolean { + if (!message) return false; + if (isToolResultRole(message.role)) return true; + + const msg = message as unknown as Record; + const content = message.content; + + // Check OpenAI-format tool_calls field (real-time streaming from OpenAI-compatible models) + const toolCalls = msg.tool_calls ?? msg.toolCalls; + const hasOpenAITools = Array.isArray(toolCalls) && toolCalls.length > 0; + + if (!Array.isArray(content)) { + // Content is not an array — check if there's OpenAI-format tool_calls + if (hasOpenAITools) { + // Has tool calls but content might be empty/string — treat as tool-only + // if there's no meaningful text content + const textContent = typeof content === 'string' ? content.trim() : ''; + return textContent.length === 0; + } + return false; + } + + let hasTool = hasOpenAITools; + let hasText = false; + let hasNonToolContent = false; + + for (const block of content as ContentBlock[]) { + if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'toolCall' || block.type === 'toolResult') { + hasTool = true; + continue; + } + if (block.type === 'text' && block.text && block.text.trim()) { + hasText = true; + continue; + } + // Only actual image output disqualifies a tool-only message. + // Thinking blocks are internal reasoning that can accompany tool_use — they + // should NOT prevent the message from being treated as an intermediate tool step. + if (block.type === 'image') { + hasNonToolContent = true; + } + } + + return hasTool && !hasText && !hasNonToolContent; +} + +function isToolResultRole(role: unknown): boolean { + if (!role) return false; + const normalized = String(role).toLowerCase(); + return normalized === 'toolresult' || normalized === 'tool_result'; +} + +function extractTextFromContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const block of content as ContentBlock[]) { + if (block.type === 'text' && block.text) { + parts.push(block.text); + } + } + return parts.join('\n'); +} + +function summarizeToolOutput(text: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) return undefined; + const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length === 0) return undefined; + const summaryLines = lines.slice(0, 2); + let summary = summaryLines.join(' / '); + if (summary.length > 160) { + summary = `${summary.slice(0, 157)}...`; + } + return summary; +} + +function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] { + const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; + if (status === 'error' || status === 'failed') return 'error'; + if (status === 'completed' || status === 'success' || status === 'done') return 'completed'; + return fallback; +} + +function parseDurationMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const parsed = typeof value === 'string' ? Number(value) : NaN; + return Number.isFinite(parsed) ? parsed : undefined; +} + +function extractToolUseUpdates(message: unknown): ToolStatus[] { + if (!message || typeof message !== 'object') return []; + const msg = message as Record; + const updates: ToolStatus[] = []; + + // Path 1: Anthropic/normalized format — tool blocks inside content array + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue; + updates.push({ + id: block.id || block.name, + toolCallId: block.id, + name: block.name, + status: 'running', + updatedAt: Date.now(), + }); + } + } + + // Path 2: OpenAI format — tool_calls array on the message itself + if (updates.length === 0) { + const toolCalls = msg.tool_calls ?? msg.toolCalls; + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls as Array>) { + const fn = (tc.function ?? tc) as Record; + const name = typeof fn.name === 'string' ? fn.name : ''; + if (!name) continue; + const id = typeof tc.id === 'string' ? tc.id : name; + updates.push({ + id, + toolCallId: typeof tc.id === 'string' ? tc.id : undefined, + name, + status: 'running', + updatedAt: Date.now(), + }); + } + } + } + + return updates; +} + +function extractToolResultBlocks(message: unknown, eventState: string): ToolStatus[] { + if (!message || typeof message !== 'object') return []; + const msg = message as Record; + const content = msg.content; + if (!Array.isArray(content)) return []; + + const updates: ToolStatus[] = []; + for (const block of content as ContentBlock[]) { + if (block.type !== 'tool_result' && block.type !== 'toolResult') continue; + const outputText = extractTextFromContent(block.content ?? block.text ?? ''); + const summary = summarizeToolOutput(outputText); + updates.push({ + id: block.id || block.name || 'tool', + toolCallId: block.id, + name: block.name || block.id || 'tool', + status: normalizeToolStatus(undefined, eventState === 'delta' ? 'running' : 'completed'), + summary, + updatedAt: Date.now(), + }); + } + + return updates; +} + +function extractToolResultUpdate(message: unknown, eventState: string): ToolStatus | null { + if (!message || typeof message !== 'object') return null; + const msg = message as Record; + const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; + if (!isToolResultRole(role)) return null; + + const toolName = typeof msg.toolName === 'string' ? msg.toolName : (typeof msg.name === 'string' ? msg.name : ''); + const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined; + const details = (msg.details && typeof msg.details === 'object') ? msg.details as Record : undefined; + const rawStatus = (msg.status ?? details?.status); + const fallback = eventState === 'delta' ? 'running' : 'completed'; + const status = normalizeToolStatus(rawStatus, fallback); + const durationMs = parseDurationMs(details?.durationMs ?? details?.duration ?? (msg as Record).durationMs); + + const outputText = (details && typeof details.aggregated === 'string') + ? details.aggregated + : extractTextFromContent(msg.content); + const summary = summarizeToolOutput(outputText) ?? summarizeToolOutput(String(details?.error ?? msg.error ?? '')); + + const name = toolName || toolCallId || 'tool'; + const id = toolCallId || name; + + return { + id, + toolCallId, + name, + status, + durationMs, + summary, + updatedAt: Date.now(), + }; +} + +function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] { + const order: Record = { running: 0, completed: 1, error: 2 }; + return order[incoming] >= order[existing] ? incoming : existing; +} + +function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] { + if (updates.length === 0) return current; + const next = [...current]; + for (const update of updates) { + const key = update.toolCallId || update.id || update.name; + if (!key) continue; + const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key); + if (index === -1) { + next.push(update); + continue; + } + const existing = next[index]; + next[index] = { + ...existing, + ...update, + name: update.name || existing.name, + status: mergeToolStatus(existing.status, update.status), + durationMs: update.durationMs ?? existing.durationMs, + summary: update.summary ?? existing.summary, + updatedAt: update.updatedAt || existing.updatedAt, + }; + } + return next; +} + +function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] { + const updates: ToolStatus[] = []; + const toolResultUpdate = extractToolResultUpdate(message, eventState); + if (toolResultUpdate) updates.push(toolResultUpdate); + updates.push(...extractToolResultBlocks(message, eventState)); + updates.push(...extractToolUseUpdates(message)); + return updates; +} + +function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { + if (!message) return false; + if (typeof message.content === 'string' && message.content.trim()) return true; + + const content = message.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type === 'text' && block.text && block.text.trim()) return true; + if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true; + if (block.type === 'image') return true; + } + } + + const msg = message as unknown as Record; + if (typeof msg.text === 'string' && msg.text.trim()) return true; + + return false; +} + +function setHistoryPollTimer(timer: ReturnType | null): void { + _historyPollTimer = timer; +} + +function hasErrorRecoveryTimer(): boolean { + return _errorRecoveryTimer != null; +} + +function setErrorRecoveryTimer(timer: ReturnType | null): void { + _errorRecoveryTimer = timer; +} + +function setLastChatEventAt(value: number): void { + _lastChatEventAt = value; +} + +function getLastChatEventAt(): number { + return _lastChatEventAt; +} + +export { + toMs, + clearErrorRecoveryTimer, + clearHistoryPoll, + extractImagesAsAttachedFiles, + getMessageText, + extractMediaRefs, + extractRawFilePaths, + makeAttachedFile, + enrichWithToolResultFiles, + isToolResultRole, + enrichWithCachedImages, + loadMissingPreviews, + upsertImageCacheEntry, + getCanonicalPrefixFromSessions, + getToolCallFilePath, + collectToolUpdates, + upsertToolStatuses, + hasNonToolAssistantContent, + isToolOnlyMessage, + setHistoryPollTimer, + hasErrorRecoveryTimer, + setErrorRecoveryTimer, + setLastChatEventAt, + getLastChatEventAt, +}; diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts new file mode 100644 index 000000000..514e2aeed --- /dev/null +++ b/src/stores/chat/history-actions.ts @@ -0,0 +1,148 @@ +import { invokeIpc } from '@/lib/api-client'; +import { + clearHistoryPoll, + enrichWithCachedImages, + enrichWithToolResultFiles, + getMessageText, + hasNonToolAssistantContent, + isToolResultRole, + loadMissingPreviews, + toMs, +} from './helpers'; +import type { RawMessage } from './types'; +import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; + +export function createHistoryActions( + set: ChatSet, + get: ChatGet, +): Pick { + return { + loadHistory: async (quiet = false) => { + const { currentSessionKey } = get(); + if (!quiet) set({ loading: true, error: null }); + + try { + const result = await invokeIpc( + 'gateway:rpc', + 'chat.history', + { sessionKey: currentSessionKey, limit: 200 } + ) as { success: boolean; result?: Record; error?: string }; + + if (result.success && result.result) { + const data = result.result; + const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; + + // Before filtering: attach images/files from tool_result messages to the next assistant message + const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); + const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role)); + // Restore file attachments for user/assistant messages (from cache + text patterns) + const enrichedMessages = enrichWithCachedImages(filteredMessages); + const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; + + // Preserve the optimistic user message during an active send. + // The Gateway may not include the user's message in chat.history + // until the run completes, causing it to flash out of the UI. + let finalMessages = enrichedMessages; + const userMsgAt = get().lastUserMessageAt; + if (get().sending && userMsgAt) { + const userMsMs = toMs(userMsgAt); + const hasRecentUser = enrichedMessages.some( + (m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000, + ); + if (!hasRecentUser) { + const currentMsgs = get().messages; + const optimistic = [...currentMsgs].reverse().find( + (m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000, + ); + if (optimistic) { + finalMessages = [...enrichedMessages, optimistic]; + } + } + } + + set({ messages: finalMessages, thinkingLevel, loading: false }); + + // Extract first user message text as a session label for display in the toolbar. + // Skip main sessions (key ends with ":main") — they rely on the Gateway-provided + // displayName (e.g. the configured agent name "ClawX") instead. + const isMainSession = currentSessionKey.endsWith(':main'); + if (!isMainSession) { + const firstUserMsg = finalMessages.find((m) => m.role === 'user'); + if (firstUserMsg) { + const labelText = getMessageText(firstUserMsg.content).trim(); + if (labelText) { + const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText; + set((s) => ({ + sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated }, + })); + } + } + } + + // Record last activity time from the last message in history + const lastMsg = finalMessages[finalMessages.length - 1]; + if (lastMsg?.timestamp) { + const lastAt = toMs(lastMsg.timestamp); + set((s) => ({ + sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt }, + })); + } + + // Async: load missing image previews from disk (updates in background) + loadMissingPreviews(finalMessages).then((updated) => { + if (updated) { + // Create new object references so React.memo detects changes. + // loadMissingPreviews mutates AttachedFileMeta in place, so we + // must produce fresh message + file references for each affected msg. + set({ + messages: finalMessages.map(msg => + msg._attachedFiles + ? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) } + : msg + ), + }); + } + }); + const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); + + // If we're sending but haven't received streaming events, check + // whether the loaded history reveals intermediate tool-call activity. + // This surfaces progress via the pendingFinal → ActivityIndicator path. + const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0; + const isAfterUserMsg = (msg: RawMessage): boolean => { + if (!userMsTs || !msg.timestamp) return true; + return toMs(msg.timestamp) >= userMsTs; + }; + + if (isSendingNow && !pendingFinal) { + const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => { + if (msg.role !== 'assistant') return false; + return isAfterUserMsg(msg); + }); + if (hasRecentAssistantActivity) { + set({ pendingFinal: true }); + } + } + + // If pendingFinal, check whether the AI produced a final text response. + if (pendingFinal || get().pendingFinal) { + const recentAssistant = [...filteredMessages].reverse().find((msg) => { + if (msg.role !== 'assistant') return false; + if (!hasNonToolAssistantContent(msg)) return false; + return isAfterUserMsg(msg); + }); + if (recentAssistant) { + clearHistoryPoll(); + set({ sending: false, activeRunId: null, pendingFinal: false }); + } + } + } else { + set({ messages: [], loading: false }); + } + } catch (err) { + console.warn('Failed to load chat history:', err); + set({ messages: [], loading: false }); + } + }, + }; +} diff --git a/src/stores/chat/internal.ts b/src/stores/chat/internal.ts new file mode 100644 index 000000000..f62a57dee --- /dev/null +++ b/src/stores/chat/internal.ts @@ -0,0 +1,70 @@ +import { DEFAULT_SESSION_KEY, type ChatState } from './types'; +import { createRuntimeActions } from './runtime-actions'; +import { createSessionHistoryActions } from './session-history-actions'; +import type { ChatGet, ChatSet } from './store-api'; + +export const initialChatState: Pick< + ChatState, + | 'messages' + | 'loading' + | 'error' + | 'sending' + | 'activeRunId' + | 'streamingText' + | 'streamingMessage' + | 'streamingTools' + | 'pendingFinal' + | 'lastUserMessageAt' + | 'pendingToolImages' + | 'sessions' + | 'currentSessionKey' + | 'sessionLabels' + | 'sessionLastActivity' + | 'showThinking' + | 'thinkingLevel' +> = { + messages: [], + loading: false, + error: null, + + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + + sessions: [], + currentSessionKey: DEFAULT_SESSION_KEY, + sessionLabels: {}, + sessionLastActivity: {}, + + showThinking: true, + thinkingLevel: null, +}; + +export function createChatActions( + set: ChatSet, + get: ChatGet, +): Pick< + ChatState, + | 'loadSessions' + | 'switchSession' + | 'newSession' + | 'deleteSession' + | 'cleanupEmptySession' + | 'loadHistory' + | 'sendMessage' + | 'abortRun' + | 'handleChatEvent' + | 'toggleThinking' + | 'refresh' + | 'clearError' +> { + return { + ...createSessionHistoryActions(set, get), + ...createRuntimeActions(set, get), + }; +} diff --git a/src/stores/chat/runtime-actions.ts b/src/stores/chat/runtime-actions.ts new file mode 100644 index 000000000..1d52026f8 --- /dev/null +++ b/src/stores/chat/runtime-actions.ts @@ -0,0 +1,12 @@ +import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; +import { createRuntimeEventActions } from './runtime-event-actions'; +import { createRuntimeSendActions } from './runtime-send-actions'; +import { createRuntimeUiActions } from './runtime-ui-actions'; + +export function createRuntimeActions(set: ChatSet, get: ChatGet): RuntimeActions { + return { + ...createRuntimeSendActions(set, get), + ...createRuntimeEventActions(set, get), + ...createRuntimeUiActions(set, get), + }; +} diff --git a/src/stores/chat/runtime-event-actions.ts b/src/stores/chat/runtime-event-actions.ts new file mode 100644 index 000000000..beec59755 --- /dev/null +++ b/src/stores/chat/runtime-event-actions.ts @@ -0,0 +1,52 @@ +import { clearHistoryPoll, setLastChatEventAt } from './helpers'; +import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; +import { handleRuntimeEventState } from './runtime-event-handlers'; + +export function createRuntimeEventActions(set: ChatSet, get: ChatGet): Pick { + return { + handleChatEvent: (event: Record) => { + const runId = String(event.runId || ''); + const eventState = String(event.state || ''); + const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null; + const { activeRunId, currentSessionKey } = get(); + + // Only process events for the current session (when sessionKey is present) + if (eventSessionKey != null && eventSessionKey !== currentSessionKey) return; + + // Only process events for the active run (or if no active run set) + if (activeRunId && runId && runId !== activeRunId) return; + + setLastChatEventAt(Date.now()); + + // Defensive: if state is missing but we have a message, try to infer state. + let resolvedState = eventState; + if (!resolvedState && event.message && typeof event.message === 'object') { + const msg = event.message as Record; + const stopReason = msg.stopReason ?? msg.stop_reason; + if (stopReason) { + resolvedState = 'final'; + } else if (msg.role || msg.content) { + resolvedState = 'delta'; + } + } + + // Only pause the history poll when we receive actual streaming data. + // The gateway sends "agent" events with { phase, startedAt } that carry + // no message — these must NOT kill the poll, since the poll is our only + // way to track progress when the gateway doesn't stream intermediate turns. + const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final' + || resolvedState === 'error' || resolvedState === 'aborted'; + if (hasUsefulData) { + clearHistoryPoll(); + // Adopt run started from another client (e.g. console at 127.0.0.1:18789): + // show loading/streaming in the app when this session has an active run. + const { sending } = get(); + if (!sending && runId) { + set({ sending: true, activeRunId: runId, error: null }); + } + } + + handleRuntimeEventState(set, get, event, resolvedState, runId); + }, + }; +} diff --git a/src/stores/chat/runtime-event-handlers.ts b/src/stores/chat/runtime-event-handlers.ts new file mode 100644 index 000000000..df16afa48 --- /dev/null +++ b/src/stores/chat/runtime-event-handlers.ts @@ -0,0 +1,286 @@ +import { + clearErrorRecoveryTimer, + clearHistoryPoll, + collectToolUpdates, + extractImagesAsAttachedFiles, + extractMediaRefs, + extractRawFilePaths, + getMessageText, + getToolCallFilePath, + hasErrorRecoveryTimer, + hasNonToolAssistantContent, + isToolOnlyMessage, + isToolResultRole, + makeAttachedFile, + setErrorRecoveryTimer, + upsertToolStatuses, +} from './helpers'; +import type { AttachedFileMeta, RawMessage } from './types'; +import type { ChatGet, ChatSet } from './store-api'; + +export function handleRuntimeEventState( + set: ChatSet, + get: ChatGet, + event: Record, + resolvedState: string, + runId: string, +): void { + switch (resolvedState) { + case 'started': { + // Run just started (e.g. from console); show loading immediately. + const { sending: currentSending } = get(); + if (!currentSending && runId) { + set({ sending: true, activeRunId: runId, error: null }); + } + break; + } + case 'delta': { + // If we're receiving new deltas, the Gateway has recovered from any + // prior error — cancel the error finalization timer and clear the + // stale error banner so the user sees the live stream again. + if (hasErrorRecoveryTimer()) { + clearErrorRecoveryTimer(); + set({ error: null }); + } + const updates = collectToolUpdates(event.message, resolvedState); + set((s) => ({ + streamingMessage: (() => { + if (event.message && typeof event.message === 'object') { + const msgRole = (event.message as RawMessage).role; + if (isToolResultRole(msgRole)) return s.streamingMessage; + } + return event.message ?? s.streamingMessage; + })(), + streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, + })); + break; + } + case 'final': { + clearErrorRecoveryTimer(); + if (get().error) set({ error: null }); + // Message complete - add to history and clear streaming + const finalMsg = event.message as RawMessage | undefined; + if (finalMsg) { + const updates = collectToolUpdates(finalMsg, resolvedState); + if (isToolResultRole(finalMsg.role)) { + // Resolve file path from the streaming assistant message's matching tool call + const currentStreamForPath = get().streamingMessage as RawMessage | null; + const matchedPath = (currentStreamForPath && finalMsg.toolCallId) + ? getToolCallFilePath(currentStreamForPath, finalMsg.toolCallId) + : undefined; + + // Mirror enrichWithToolResultFiles: collect images + file refs for next assistant msg + const toolFiles: AttachedFileMeta[] = [ + ...extractImagesAsAttachedFiles(finalMsg.content), + ]; + if (matchedPath) { + for (const f of toolFiles) { + if (!f.filePath) { + f.filePath = matchedPath; + f.fileName = matchedPath.split(/[\\/]/).pop() || 'image'; + } + } + } + const text = getMessageText(finalMsg.content); + if (text) { + const mediaRefs = extractMediaRefs(text); + const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath)); + for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref)); + for (const ref of extractRawFilePaths(text)) { + if (!mediaRefPaths.has(ref.filePath)) toolFiles.push(makeAttachedFile(ref)); + } + } + set((s) => { + // Snapshot the current streaming assistant message (thinking + tool_use) into + // messages[] before clearing it. The Gateway does NOT send separate 'final' + // events for intermediate tool-use turns — it only sends deltas and then the + // tool result. Without snapshotting here, the intermediate thinking+tool steps + // would be overwritten by the next turn's deltas and never appear in the UI. + const currentStream = s.streamingMessage as RawMessage | null; + const snapshotMsgs: RawMessage[] = []; + if (currentStream) { + const streamRole = currentStream.role; + if (streamRole === 'assistant' || streamRole === undefined) { + // Use message's own id if available, otherwise derive a stable one from runId + const snapId = currentStream.id + || `${runId || 'run'}-turn-${s.messages.length}`; + if (!s.messages.some(m => m.id === snapId)) { + snapshotMsgs.push({ + ...(currentStream as RawMessage), + role: 'assistant', + id: snapId, + }); + } + } + } + return { + messages: snapshotMsgs.length > 0 ? [...s.messages, ...snapshotMsgs] : s.messages, + streamingText: '', + streamingMessage: null, + pendingFinal: true, + pendingToolImages: toolFiles.length > 0 + ? [...s.pendingToolImages, ...toolFiles] + : s.pendingToolImages, + streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, + }; + }); + break; + } + const toolOnly = isToolOnlyMessage(finalMsg); + const hasOutput = hasNonToolAssistantContent(finalMsg); + const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); + set((s) => { + const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; + const streamingTools = hasOutput ? [] : nextTools; + + // Attach any images collected from preceding tool results + const pendingImgs = s.pendingToolImages; + const msgWithImages: RawMessage = pendingImgs.length > 0 + ? { + ...finalMsg, + role: (finalMsg.role || 'assistant') as RawMessage['role'], + id: msgId, + _attachedFiles: [...(finalMsg._attachedFiles || []), ...pendingImgs], + } + : { ...finalMsg, role: (finalMsg.role || 'assistant') as RawMessage['role'], id: msgId }; + const clearPendingImages = { pendingToolImages: [] as AttachedFileMeta[] }; + + // Check if message already exists (prevent duplicates) + const alreadyExists = s.messages.some(m => m.id === msgId); + if (alreadyExists) { + return toolOnly ? { + streamingText: '', + streamingMessage: null, + pendingFinal: true, + streamingTools, + ...clearPendingImages, + } : { + streamingText: '', + streamingMessage: null, + sending: hasOutput ? false : s.sending, + activeRunId: hasOutput ? null : s.activeRunId, + pendingFinal: hasOutput ? false : true, + streamingTools, + ...clearPendingImages, + }; + } + return toolOnly ? { + messages: [...s.messages, msgWithImages], + streamingText: '', + streamingMessage: null, + pendingFinal: true, + streamingTools, + ...clearPendingImages, + } : { + messages: [...s.messages, msgWithImages], + streamingText: '', + streamingMessage: null, + sending: hasOutput ? false : s.sending, + activeRunId: hasOutput ? null : s.activeRunId, + pendingFinal: hasOutput ? false : true, + streamingTools, + ...clearPendingImages, + }; + }); + // After the final response, quietly reload history to surface all intermediate + // tool-use turns (thinking + tool blocks) from the Gateway's authoritative record. + if (hasOutput && !toolOnly) { + clearHistoryPoll(); + void get().loadHistory(true); + } + } else { + // No message in final event - reload history to get complete data + set({ streamingText: '', streamingMessage: null, pendingFinal: true }); + get().loadHistory(); + } + break; + } + case 'error': { + const errorMsg = String(event.errorMessage || 'An error occurred'); + const wasSending = get().sending; + + // Snapshot the current streaming message into messages[] so partial + // content ("Let me get that written down...") is preserved in the UI + // rather than being silently discarded. + const currentStream = get().streamingMessage as RawMessage | null; + if (currentStream && (currentStream.role === 'assistant' || currentStream.role === undefined)) { + const snapId = (currentStream as RawMessage).id + || `error-snap-${Date.now()}`; + const alreadyExists = get().messages.some(m => m.id === snapId); + if (!alreadyExists) { + set((s) => ({ + messages: [...s.messages, { ...currentStream, role: 'assistant' as const, id: snapId }], + })); + } + } + + set({ + error: errorMsg, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + pendingToolImages: [], + }); + + // Don't immediately give up: the Gateway often retries internally + // after transient API failures (e.g. "terminated"). Keep `sending` + // true for a grace period so that recovery events are processed and + // the agent-phase-completion handler can still trigger loadHistory. + if (wasSending) { + clearErrorRecoveryTimer(); + const ERROR_RECOVERY_GRACE_MS = 15_000; + setErrorRecoveryTimer(setTimeout(() => { + setErrorRecoveryTimer(null); + const state = get(); + if (state.sending && !state.streamingMessage) { + clearHistoryPoll(); + // Grace period expired with no recovery — finalize the error + set({ + sending: false, + activeRunId: null, + lastUserMessageAt: null, + }); + // One final history reload in case the Gateway completed in the + // background and we just missed the event. + state.loadHistory(true); + } + }, ERROR_RECOVERY_GRACE_MS)); + } else { + clearHistoryPoll(); + set({ sending: false, activeRunId: null, lastUserMessageAt: null }); + } + break; + } + case 'aborted': { + clearHistoryPoll(); + clearErrorRecoveryTimer(); + set({ + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + }); + break; + } + default: { + // Unknown or empty state — if we're currently sending and receive an event + // with a message, attempt to process it as streaming data. This handles + // edge cases where the Gateway sends events without a state field. + const { sending } = get(); + if (sending && event.message && typeof event.message === 'object') { + console.warn(`[handleChatEvent] Unknown event state "${resolvedState}", treating message as streaming delta. Event keys:`, Object.keys(event)); + const updates = collectToolUpdates(event.message, 'delta'); + set((s) => ({ + streamingMessage: event.message ?? s.streamingMessage, + streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools, + })); + } + break; + } + } +} diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts new file mode 100644 index 000000000..9f088162f --- /dev/null +++ b/src/stores/chat/runtime-send-actions.ts @@ -0,0 +1,194 @@ +import { invokeIpc } from '@/lib/api-client'; +import { + clearErrorRecoveryTimer, + clearHistoryPoll, + getLastChatEventAt, + setHistoryPollTimer, + setLastChatEventAt, + upsertImageCacheEntry, +} from './helpers'; +import type { RawMessage } from './types'; +import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; + +export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { + return { + sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => { + const trimmed = text.trim(); + if (!trimmed && (!attachments || attachments.length === 0)) return; + + const { currentSessionKey } = get(); + + // Add user message optimistically (with local file metadata for UI display) + const nowMs = Date.now(); + const userMsg: RawMessage = { + role: 'user', + content: trimmed || (attachments?.length ? '(file attached)' : ''), + timestamp: nowMs / 1000, + id: crypto.randomUUID(), + _attachedFiles: attachments?.map(a => ({ + fileName: a.fileName, + mimeType: a.mimeType, + fileSize: a.fileSize, + preview: a.preview, + filePath: a.stagedPath, + })), + }; + set((s) => ({ + messages: [...s.messages, userMsg], + sending: true, + error: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: nowMs, + })); + + // Update session label with first user message text as soon as it's sent + const { sessionLabels, messages } = get(); + const isFirstMessage = !messages.slice(0, -1).some((m) => m.role === 'user'); + if (!currentSessionKey.endsWith(':main') && isFirstMessage && !sessionLabels[currentSessionKey] && trimmed) { + const truncated = trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed; + set((s) => ({ sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated } })); + } + + // Mark this session as most recently active + set((s) => ({ sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: nowMs } })); + + // Start the history poll and safety timeout IMMEDIATELY (before the + // RPC await) because the gateway's chat.send RPC may block until the + // entire agentic conversation finishes — the poll must run in parallel. + setLastChatEventAt(Date.now()); + clearHistoryPoll(); + clearErrorRecoveryTimer(); + + const POLL_START_DELAY = 3_000; + const POLL_INTERVAL = 4_000; + const pollHistory = () => { + const state = get(); + if (!state.sending) { clearHistoryPoll(); return; } + if (state.streamingMessage) { + setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL)); + return; + } + state.loadHistory(true); + setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL)); + }; + setHistoryPollTimer(setTimeout(pollHistory, POLL_START_DELAY)); + + const SAFETY_TIMEOUT_MS = 90_000; + const checkStuck = () => { + const state = get(); + if (!state.sending) return; + if (state.streamingMessage || state.streamingText) return; + if (state.pendingFinal) { + setTimeout(checkStuck, 10_000); + return; + } + if (Date.now() - getLastChatEventAt() < SAFETY_TIMEOUT_MS) { + setTimeout(checkStuck, 10_000); + return; + } + clearHistoryPoll(); + set({ + error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.', + sending: false, + activeRunId: null, + lastUserMessageAt: null, + }); + }; + setTimeout(checkStuck, 30_000); + + try { + const idempotencyKey = crypto.randomUUID(); + const hasMedia = attachments && attachments.length > 0; + if (hasMedia) { + console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath)); + } + + // Cache image attachments BEFORE the IPC call to avoid race condition: + // history may reload (via Gateway event) before the RPC returns. + // Keyed by staged file path which appears in [media attached: ...]. + if (hasMedia && attachments) { + for (const a of attachments) { + upsertImageCacheEntry(a.stagedPath, { + fileName: a.fileName, + mimeType: a.mimeType, + fileSize: a.fileSize, + preview: a.preview, + }); + } + } + + let result: { success: boolean; result?: { runId?: string }; error?: string }; + + // Longer timeout for chat sends to tolerate high-latency networks (avoids connect error) + const CHAT_SEND_TIMEOUT_MS = 120_000; + + if (hasMedia) { + result = await invokeIpc( + 'chat:sendWithMedia', + { + sessionKey: currentSessionKey, + message: trimmed || 'Process the attached file(s).', + deliver: false, + idempotencyKey, + media: attachments.map((a) => ({ + filePath: a.stagedPath, + mimeType: a.mimeType, + fileName: a.fileName, + })), + }, + ) as { success: boolean; result?: { runId?: string }; error?: string }; + } else { + result = await invokeIpc( + 'gateway:rpc', + 'chat.send', + { + sessionKey: currentSessionKey, + message: trimmed, + deliver: false, + idempotencyKey, + }, + CHAT_SEND_TIMEOUT_MS, + ) as { success: boolean; result?: { runId?: string }; error?: string }; + } + + console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`); + + if (!result.success) { + clearHistoryPoll(); + set({ error: result.error || 'Failed to send message', sending: false }); + } else if (result.result?.runId) { + set({ activeRunId: result.result.runId }); + } + } catch (err) { + clearHistoryPoll(); + set({ error: String(err), sending: false }); + } + }, + + // ── Abort active run ── + + abortRun: async () => { + clearHistoryPoll(); + clearErrorRecoveryTimer(); + const { currentSessionKey } = get(); + set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] }); + set({ streamingTools: [] }); + + try { + await invokeIpc( + 'gateway:rpc', + 'chat.abort', + { sessionKey: currentSessionKey }, + ); + } catch (err) { + set({ error: String(err) }); + } + }, + + // ── Handle incoming chat events from Gateway ── + + }; +} diff --git a/src/stores/chat/runtime-ui-actions.ts b/src/stores/chat/runtime-ui-actions.ts new file mode 100644 index 000000000..792b7a67e --- /dev/null +++ b/src/stores/chat/runtime-ui-actions.ts @@ -0,0 +1,16 @@ +import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; + +export function createRuntimeUiActions(set: ChatSet, get: ChatGet): Pick { + return { + toggleThinking: () => set((s) => ({ showThinking: !s.showThinking })), + + // ── Refresh: reload history + sessions ── + + refresh: async () => { + const { loadHistory, loadSessions } = get(); + await Promise.all([loadHistory(), loadSessions()]); + }, + + clearError: () => set({ error: null }), + }; +} diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts new file mode 100644 index 000000000..b2c698e2c --- /dev/null +++ b/src/stores/chat/session-actions.ts @@ -0,0 +1,266 @@ +import { invokeIpc } from '@/lib/api-client'; +import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; +import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types'; +import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; + +export function createSessionActions( + set: ChatSet, + get: ChatGet, +): Pick { + return { + loadSessions: async () => { + try { + const result = await invokeIpc( + 'gateway:rpc', + 'sessions.list', + {} + ) as { success: boolean; result?: Record; error?: string }; + + if (result.success && result.result) { + const data = result.result; + const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; + const sessions: ChatSession[] = rawSessions.map((s: Record) => ({ + key: String(s.key || ''), + label: s.label ? String(s.label) : undefined, + displayName: s.displayName ? String(s.displayName) : undefined, + thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined, + model: s.model ? String(s.model) : undefined, + })).filter((s: ChatSession) => s.key); + + const canonicalBySuffix = new Map(); + for (const session of sessions) { + if (!session.key.startsWith('agent:')) continue; + const parts = session.key.split(':'); + if (parts.length < 3) continue; + const suffix = parts.slice(2).join(':'); + if (suffix && !canonicalBySuffix.has(suffix)) { + canonicalBySuffix.set(suffix, session.key); + } + } + + // Deduplicate: if both short and canonical existed, keep canonical only + const seen = new Set(); + const dedupedSessions = sessions.filter((s) => { + if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false; + if (seen.has(s.key)) return false; + seen.add(s.key); + return true; + }); + + const { currentSessionKey } = get(); + let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY; + if (!nextSessionKey.startsWith('agent:')) { + const canonicalMatch = canonicalBySuffix.get(nextSessionKey); + if (canonicalMatch) { + nextSessionKey = canonicalMatch; + } + } + if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) { + // Current session not found in the backend list + const isNewEmptySession = get().messages.length === 0; + if (!isNewEmptySession) { + nextSessionKey = dedupedSessions[0].key; + } + } + + const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey + ? [ + ...dedupedSessions, + { key: nextSessionKey, displayName: nextSessionKey }, + ] + : dedupedSessions; + + set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey }); + + if (currentSessionKey !== nextSessionKey) { + get().loadHistory(); + } + + // Background: fetch first user message for every non-main session to populate labels upfront. + // Uses a small limit so it's cheap; runs in parallel and doesn't block anything. + const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main')); + if (sessionsToLabel.length > 0) { + void Promise.all( + sessionsToLabel.map(async (session) => { + try { + const r = await invokeIpc( + 'gateway:rpc', + 'chat.history', + { sessionKey: session.key, limit: 1000 }, + ) as { success: boolean; result?: Record }; + if (!r.success || !r.result) return; + const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : []; + const firstUser = msgs.find((m) => m.role === 'user'); + const lastMsg = msgs[msgs.length - 1]; + set((s) => { + const next: Partial = {}; + if (firstUser) { + const labelText = getMessageText(firstUser.content).trim(); + if (labelText) { + const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText; + next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated }; + } + } + if (lastMsg?.timestamp) { + next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) }; + } + return next; + }); + } catch { /* ignore per-session errors */ } + }), + ); + } + } + } catch (err) { + console.warn('Failed to load sessions:', err); + } + }, + + // ── Switch session ── + + switchSession: (key: string) => { + const { currentSessionKey, messages } = get(); + const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; + set((s) => ({ + currentSessionKey: key, + messages: [], + streamingText: '', + streamingMessage: null, + streamingTools: [], + activeRunId: null, + error: null, + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + ...(leavingEmpty ? { + sessions: s.sessions.filter((s) => s.key !== currentSessionKey), + sessionLabels: Object.fromEntries( + Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), + ), + sessionLastActivity: Object.fromEntries( + Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), + ), + } : {}), + })); + get().loadHistory(); + }, + + // ── Delete session ── + // + // NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent) + // RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase. + // Deletion is therefore a local-only UI operation: the session is removed from + // the sidebar list and its labels/activity maps are cleared. The underlying + // JSONL history file on disk is intentionally left intact, consistent with the + // newSession() design that avoids sessions.reset to preserve history. + + deleteSession: async (key: string) => { + // Soft-delete the session's JSONL transcript on disk. + // The main process renames .jsonl → .deleted.jsonl so that + // sessions.list and token-usage queries both skip it automatically. + try { + const result = await invokeIpc('session:delete', key) as { + success: boolean; + error?: string; + }; + if (!result.success) { + console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error); + } + } catch (err) { + console.warn(`[deleteSession] IPC call failed for ${key}:`, err); + } + + const { currentSessionKey, sessions } = get(); + const remaining = sessions.filter((s) => s.key !== key); + + if (currentSessionKey === key) { + // Switched away from deleted session — pick the first remaining or create new + const next = remaining[0]; + set((s) => ({ + sessions: remaining, + sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), + sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), + messages: [], + streamingText: '', + streamingMessage: null, + streamingTools: [], + activeRunId: null, + error: null, + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, + })); + if (next) { + get().loadHistory(); + } + } else { + set((s) => ({ + sessions: remaining, + sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)), + sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)), + })); + } + }, + + // ── New session ── + + newSession: () => { + // Generate a new unique session key and switch to it. + // NOTE: We intentionally do NOT call sessions.reset on the old session. + // sessions.reset archives (renames) the session JSONL file, making old + // conversation history inaccessible when the user switches back to it. + const { currentSessionKey, messages } = get(); + const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; + const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX; + const newKey = `${prefix}:session-${Date.now()}`; + const newSessionEntry: ChatSession = { key: newKey, displayName: newKey }; + set((s) => ({ + currentSessionKey: newKey, + sessions: [ + ...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions), + newSessionEntry, + ], + sessionLabels: leavingEmpty + ? Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey)) + : s.sessionLabels, + sessionLastActivity: leavingEmpty + ? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey)) + : s.sessionLastActivity, + messages: [], + streamingText: '', + streamingMessage: null, + streamingTools: [], + activeRunId: null, + error: null, + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + })); + }, + + // ── Cleanup empty session on navigate away ── + + cleanupEmptySession: () => { + const { currentSessionKey, messages } = get(); + // Only remove non-main sessions that were never used (no messages sent). + // This mirrors the "leavingEmpty" logic in switchSession so that creating + // a new session and immediately navigating away doesn't leave a ghost entry + // in the sidebar. + const isEmptyNonMain = !currentSessionKey.endsWith(':main') && messages.length === 0; + if (!isEmptyNonMain) return; + set((s) => ({ + sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey), + sessionLabels: Object.fromEntries( + Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), + ), + sessionLastActivity: Object.fromEntries( + Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey), + ), + })); + }, + + // ── Load chat history ── + + }; +} diff --git a/src/stores/chat/session-history-actions.ts b/src/stores/chat/session-history-actions.ts new file mode 100644 index 000000000..5a39422de --- /dev/null +++ b/src/stores/chat/session-history-actions.ts @@ -0,0 +1,10 @@ +import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; +import { createHistoryActions } from './history-actions'; +import { createSessionActions } from './session-actions'; + +export function createSessionHistoryActions(set: ChatSet, get: ChatGet): SessionHistoryActions { + return { + ...createSessionActions(set, get), + ...createHistoryActions(set, get), + }; +} diff --git a/src/stores/chat/store-api.ts b/src/stores/chat/store-api.ts new file mode 100644 index 000000000..8451b6882 --- /dev/null +++ b/src/stores/chat/store-api.ts @@ -0,0 +1,18 @@ +import type { ChatState } from './types'; + +export type ChatSet = ( + partial: Partial | ((state: ChatState) => Partial), + replace?: false, +) => void; + +export type ChatGet = () => ChatState; + +export type SessionHistoryActions = Pick< + ChatState, + 'loadSessions' | 'switchSession' | 'newSession' | 'deleteSession' | 'cleanupEmptySession' | 'loadHistory' +>; + +export type RuntimeActions = Pick< + ChatState, + 'sendMessage' | 'abortRun' | 'handleChatEvent' | 'toggleThinking' | 'refresh' | 'clearError' +>; diff --git a/src/stores/chat/types.ts b/src/stores/chat/types.ts new file mode 100644 index 000000000..c7472d13f --- /dev/null +++ b/src/stores/chat/types.ts @@ -0,0 +1,113 @@ +/** Metadata for locally-attached files (not from Gateway) */ +export interface AttachedFileMeta { + fileName: string; + mimeType: string; + fileSize: number; + preview: string | null; + filePath?: string; +} + +/** Raw message from OpenClaw chat.history */ +export interface RawMessage { + role: 'user' | 'assistant' | 'system' | 'toolresult'; + content: unknown; // string | ContentBlock[] + timestamp?: number; + id?: string; + toolCallId?: string; + toolName?: string; + details?: unknown; + isError?: boolean; + /** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */ + _attachedFiles?: AttachedFileMeta[]; +} + +/** Content block inside a message */ +export interface ContentBlock { + type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult'; + text?: string; + thinking?: string; + source?: { type: string; media_type?: string; data?: string; url?: string }; + /** Flat image format from Gateway tool results (no source wrapper) */ + data?: string; + mimeType?: string; + id?: string; + name?: string; + input?: unknown; + arguments?: unknown; + content?: unknown; +} + +/** Session from sessions.list */ +export interface ChatSession { + key: string; + label?: string; + displayName?: string; + thinkingLevel?: string; + model?: string; +} + +export interface ToolStatus { + id?: string; + toolCallId?: string; + name: string; + status: 'running' | 'completed' | 'error'; + durationMs?: number; + summary?: string; + updatedAt: number; +} + +export interface ChatState { + // Messages + messages: RawMessage[]; + loading: boolean; + error: string | null; + + // Streaming + sending: boolean; + activeRunId: string | null; + streamingText: string; + streamingMessage: unknown | null; + streamingTools: ToolStatus[]; + pendingFinal: boolean; + lastUserMessageAt: number | null; + /** Images collected from tool results, attached to the next assistant message */ + pendingToolImages: AttachedFileMeta[]; + + // Sessions + sessions: ChatSession[]; + currentSessionKey: string; + /** First user message text per session key, used as display label */ + sessionLabels: Record; + /** Last message timestamp (ms) per session key, used for sorting */ + sessionLastActivity: Record; + + // Thinking + showThinking: boolean; + thinkingLevel: string | null; + + // Actions + loadSessions: () => Promise; + switchSession: (key: string) => void; + newSession: () => void; + deleteSession: (key: string) => Promise; + cleanupEmptySession: () => void; + loadHistory: (quiet?: boolean) => Promise; + sendMessage: ( + text: string, + attachments?: Array<{ + fileName: string; + mimeType: string; + fileSize: number; + stagedPath: string; + preview: string | null; + }> + ) => Promise; + abortRun: () => Promise; + handleChatEvent: (event: Record) => void; + toggleThinking: () => void; + refresh: () => Promise; + clearError: () => void; +} + +export const DEFAULT_CANONICAL_PREFIX = 'agent:main'; +export const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`; diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 7ac0d21e4..61a472ad2 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -3,12 +3,13 @@ * Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC. */ import { create } from 'zustand'; -import { createHostEventSource, hostApiFetch } from '@/lib/host-api'; +import { hostApiFetch } from '@/lib/host-api'; import { invokeIpc } from '@/lib/api-client'; +import { subscribeHostEvent } from '@/lib/host-events'; import type { GatewayStatus } from '../types/gateway'; let gatewayInitPromise: Promise | null = null; -let gatewayEventSource: EventSource | null = null; +let gatewayEventUnsubscribers: Array<() => void> | null = null; interface GatewayHealth { ok: boolean; @@ -148,37 +149,39 @@ export const useGatewayStore = create((set, get) => ({ const status = await hostApiFetch('/api/gateway/status'); set({ status, isInitialized: true }); - if (!gatewayEventSource) { - gatewayEventSource = createHostEventSource(); - gatewayEventSource.addEventListener('gateway:status', (event) => { - set({ status: JSON.parse((event as MessageEvent).data) as GatewayStatus }); - }); - gatewayEventSource.addEventListener('gateway:error', (event) => { - const payload = JSON.parse((event as MessageEvent).data) as { message?: string }; + if (!gatewayEventUnsubscribers) { + const unsubscribers: Array<() => void> = []; + unsubscribers.push(subscribeHostEvent('gateway:status', (payload) => { + set({ status: payload }); + })); + unsubscribers.push(subscribeHostEvent<{ message?: string }>('gateway:error', (payload) => { set({ lastError: payload.message || 'Gateway error' }); - }); - gatewayEventSource.addEventListener('gateway:notification', (event) => { - handleGatewayNotification(JSON.parse((event as MessageEvent).data) as { - method?: string; - params?: Record; - }); - }); - gatewayEventSource.addEventListener('gateway:chat-message', (event) => { - handleGatewayChatMessage(JSON.parse((event as MessageEvent).data)); - }); - gatewayEventSource.addEventListener('gateway:channel-status', (event) => { - import('./channels') - .then(({ useChannelsStore }) => { - const update = JSON.parse((event as MessageEvent).data) as { channelId?: string; status?: string }; - if (!update.channelId || !update.status) return; - const state = useChannelsStore.getState(); - const channel = state.channels.find((item) => item.type === update.channelId); - if (channel) { - state.updateChannel(channel.id, { status: mapChannelStatus(update.status) }); - } - }) - .catch(() => {}); - }); + })); + unsubscribers.push(subscribeHostEvent<{ method?: string; params?: Record }>( + 'gateway:notification', + (payload) => { + handleGatewayNotification(payload); + }, + )); + unsubscribers.push(subscribeHostEvent('gateway:chat-message', (payload) => { + handleGatewayChatMessage(payload); + })); + unsubscribers.push(subscribeHostEvent<{ channelId?: string; status?: string }>( + 'gateway:channel-status', + (update) => { + import('./channels') + .then(({ useChannelsStore }) => { + if (!update.channelId || !update.status) return; + const state = useChannelsStore.getState(); + const channel = state.channels.find((item) => item.type === update.channelId); + if (channel) { + state.updateChannel(channel.id, { status: mapChannelStatus(update.status) }); + } + }) + .catch(() => {}); + }, + )); + gatewayEventUnsubscribers = unsubscribers; } } catch (error) { console.error('Failed to initialize Gateway:', error); diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 801a676eb..76c53bdf3 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -6,11 +6,9 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; import { hostApiFetch } from '@/lib/host-api'; -import { invokeIpc } from '@/lib/api-client'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; -type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only'; interface SettingsState { // General @@ -28,7 +26,6 @@ interface SettingsState { proxyHttpsServer: string; proxyAllServer: string; proxyBypassRules: string; - gatewayTransportPreference: GatewayTransportPreference; // Update updateChannel: UpdateChannel; @@ -56,7 +53,6 @@ interface SettingsState { setProxyHttpsServer: (value: string) => void; setProxyAllServer: (value: string) => void; setProxyBypassRules: (value: string) => void; - setGatewayTransportPreference: (value: GatewayTransportPreference) => void; setUpdateChannel: (channel: UpdateChannel) => void; setAutoCheckUpdate: (value: boolean) => void; setAutoDownloadUpdate: (value: boolean) => void; @@ -84,7 +80,6 @@ const defaultSettings = { proxyHttpsServer: '', proxyAllServer: '', proxyBypassRules: ';localhost;127.0.0.1;::1', - gatewayTransportPreference: 'ws-first' as GatewayTransportPreference, updateChannel: 'stable' as UpdateChannel, autoCheckUpdate: true, autoDownloadUpdate: false, @@ -142,10 +137,6 @@ export const useSettingsStore = create()( setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }), setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), - setGatewayTransportPreference: (gatewayTransportPreference) => { - set({ gatewayTransportPreference }); - void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {}); - }, setUpdateChannel: (updateChannel) => set({ updateChannel }), setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts index 124711f46..af6e5c5f1 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -8,11 +8,17 @@ import { registerTransportInvoker, unregisterTransportInvoker, clearTransportBackoff, + getApiClientConfig, + applyGatewayTransportPreference, + createGatewayHttpTransportInvoker, + getGatewayWsDiagnosticEnabled, + setGatewayWsDiagnosticEnabled, } from '@/lib/api-client'; describe('api-client', () => { beforeEach(() => { vi.resetAllMocks(); + window.localStorage.removeItem('clawx:gateway-ws-diagnostic'); configureApiClient({ enabled: { ws: false, http: false }, rules: [{ matcher: /.*/, order: ['ipc'] }], @@ -150,4 +156,82 @@ describe('api-client', () => { expect(wsInvoker).toHaveBeenCalledTimes(2); expect(invoke).toHaveBeenCalledTimes(2); }); + + it('defaults transport preference to ipc-only', () => { + applyGatewayTransportPreference(); + const config = getApiClientConfig(); + expect(config.enabled.ws).toBe(false); + expect(config.enabled.http).toBe(false); + expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ipc'] }); + }); + + it('enables ws->http->ipc order when ws diagnostic is on', () => { + setGatewayWsDiagnosticEnabled(true); + expect(getGatewayWsDiagnosticEnabled()).toBe(true); + + const config = getApiClientConfig(); + expect(config.enabled.ws).toBe(true); + expect(config.enabled.http).toBe(true); + expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] }); + }); + + it('parses gateway:httpProxy unified envelope response', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ + ok: true, + data: { + status: 200, + ok: true, + json: { type: 'res', ok: true, payload: { rows: [1, 2] } }, + }, + }); + + const invoker = createGatewayHttpTransportInvoker(); + const result = await invoker<{ success: boolean; result: { rows: number[] } }>( + 'gateway:rpc', + ['chat.history', { sessionKey: 's1' }], + ); + + expect(result.success).toBe(true); + expect(result.result.rows).toEqual([1, 2]); + expect(invoke).toHaveBeenCalledWith( + 'gateway:httpProxy', + expect.objectContaining({ + path: '/rpc', + method: 'POST', + }), + ); + }); + + it('throws meaningful error when gateway:httpProxy unified envelope fails', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ + ok: false, + error: { message: 'proxy unavailable' }, + }); + + const invoker = createGatewayHttpTransportInvoker(); + await expect(invoker('gateway:rpc', ['chat.history', {}])).rejects.toThrow('proxy unavailable'); + }); + + it('normalizes raw gateway:httpProxy payload into ipc-style envelope', async () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ + ok: true, + data: { + status: 200, + ok: true, + json: { channels: [{ id: 'telegram-default' }] }, + }, + }); + + const invoker = createGatewayHttpTransportInvoker(); + const result = await invoker<{ success: boolean; result: { channels: Array<{ id: string }> } }>( + 'gateway:rpc', + ['channels.status', { probe: false }], + ); + + expect(result.success).toBe(true); + expect(result.result.channels[0].id).toBe('telegram-default'); + }); }); diff --git a/tests/unit/chat-runtime-event-handlers.test.ts b/tests/unit/chat-runtime-event-handlers.test.ts new file mode 100644 index 000000000..b1d6f81ff --- /dev/null +++ b/tests/unit/chat-runtime-event-handlers.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const clearErrorRecoveryTimer = vi.fn(); +const clearHistoryPoll = vi.fn(); +const collectToolUpdates = vi.fn(() => []); +const extractImagesAsAttachedFiles = vi.fn(() => []); +const extractMediaRefs = vi.fn(() => []); +const extractRawFilePaths = vi.fn(() => []); +const getMessageText = vi.fn(() => ''); +const getToolCallFilePath = vi.fn(() => undefined); +const hasErrorRecoveryTimer = vi.fn(() => false); +const hasNonToolAssistantContent = vi.fn(() => true); +const isToolOnlyMessage = vi.fn(() => false); +const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult'); +const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }) => ({ + fileName: ref.filePath.split('/').pop() || 'file', + mimeType: ref.mimeType, + fileSize: 0, + preview: null, + filePath: ref.filePath, +})); +const setErrorRecoveryTimer = vi.fn(); +const upsertToolStatuses = vi.fn((_current, updates) => updates); + +vi.mock('@/stores/chat/helpers', () => ({ + clearErrorRecoveryTimer: (...args: unknown[]) => clearErrorRecoveryTimer(...args), + clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args), + collectToolUpdates: (...args: unknown[]) => collectToolUpdates(...args), + extractImagesAsAttachedFiles: (...args: unknown[]) => extractImagesAsAttachedFiles(...args), + extractMediaRefs: (...args: unknown[]) => extractMediaRefs(...args), + extractRawFilePaths: (...args: unknown[]) => extractRawFilePaths(...args), + getMessageText: (...args: unknown[]) => getMessageText(...args), + getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args), + hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args), + hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), + isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args), + isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), + makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args), + setErrorRecoveryTimer: (...args: unknown[]) => setErrorRecoveryTimer(...args), + upsertToolStatuses: (...args: unknown[]) => upsertToolStatuses(...args), +})); + +type ChatLikeState = { + sending: boolean; + activeRunId: string | null; + error: string | null; + streamingMessage: unknown | null; + streamingTools: unknown[]; + messages: Array>; + pendingToolImages: unknown[]; + pendingFinal: boolean; + lastUserMessageAt: number | null; + streamingText: string; + loadHistory: ReturnType; +}; + +function makeHarness(initial?: Partial) { + let state: ChatLikeState = { + sending: false, + activeRunId: null, + error: 'stale error', + streamingMessage: null, + streamingTools: [], + messages: [], + pendingToolImages: [], + pendingFinal: false, + lastUserMessageAt: null, + streamingText: '', + loadHistory: vi.fn(), + ...initial, + }; + + const set = (partial: Partial | ((s: ChatLikeState) => Partial)) => { + const next = typeof partial === 'function' ? partial(state) : partial; + state = { ...state, ...next }; + }; + const get = () => state; + return { set, get, read: () => state }; +} + +describe('chat runtime event handlers', () => { + beforeEach(() => { + vi.resetAllMocks(); + hasErrorRecoveryTimer.mockReturnValue(false); + collectToolUpdates.mockReturnValue([]); + upsertToolStatuses.mockImplementation((_current, updates) => updates); + }); + + it('marks sending on started event', async () => { + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness({ sending: false, activeRunId: null, error: 'err' }); + + handleRuntimeEventState(h.set as never, h.get as never, {}, 'started', 'run-1'); + const next = h.read(); + expect(next.sending).toBe(true); + expect(next.activeRunId).toBe('run-1'); + expect(next.error).toBeNull(); + }); + + it('applies delta event and clears stale error when recovery timer exists', async () => { + hasErrorRecoveryTimer.mockReturnValue(true); + collectToolUpdates.mockReturnValue([{ name: 'tool-a', status: 'running', updatedAt: 1 }]); + + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness({ + error: 'old', + streamingTools: [], + streamingMessage: { role: 'assistant', content: 'old' }, + }); + const event = { message: { role: 'assistant', content: 'delta' } }; + + handleRuntimeEventState(h.set as never, h.get as never, event, 'delta', 'run-2'); + const next = h.read(); + expect(clearErrorRecoveryTimer).toHaveBeenCalledTimes(1); + expect(next.error).toBeNull(); + expect(next.streamingMessage).toEqual(event.message); + expect(next.streamingTools).toEqual([{ name: 'tool-a', status: 'running', updatedAt: 1 }]); + }); + + it('loads history when final event has no message', async () => { + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness(); + + handleRuntimeEventState(h.set as never, h.get as never, {}, 'final', 'run-3'); + const next = h.read(); + expect(next.pendingFinal).toBe(true); + expect(next.streamingMessage).toBeNull(); + expect(h.read().loadHistory).toHaveBeenCalledTimes(1); + }); + + it('handles error event and finalizes immediately when not sending', async () => { + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness({ sending: false, activeRunId: 'r1', lastUserMessageAt: 123 }); + + handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'boom' }, 'error', 'r1'); + const next = h.read(); + expect(clearHistoryPoll).toHaveBeenCalledTimes(1); + expect(next.error).toBe('boom'); + expect(next.sending).toBe(false); + expect(next.activeRunId).toBeNull(); + expect(next.lastUserMessageAt).toBeNull(); + expect(next.streamingTools).toEqual([]); + }); + + it('clears runtime state on aborted event', async () => { + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness({ + sending: true, + activeRunId: 'r2', + streamingText: 'abc', + pendingFinal: true, + lastUserMessageAt: 5, + pendingToolImages: [{ fileName: 'x' }], + }); + + handleRuntimeEventState(h.set as never, h.get as never, {}, 'aborted', 'r2'); + const next = h.read(); + expect(next.sending).toBe(false); + expect(next.activeRunId).toBeNull(); + expect(next.streamingText).toBe(''); + expect(next.pendingFinal).toBe(false); + expect(next.lastUserMessageAt).toBeNull(); + expect(next.pendingToolImages).toEqual([]); + }); +}); + diff --git a/tests/unit/chat-session-actions.test.ts b/tests/unit/chat-session-actions.test.ts new file mode 100644 index 000000000..79a78b59a --- /dev/null +++ b/tests/unit/chat-session-actions.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const invokeIpcMock = vi.fn(); + +vi.mock('@/lib/api-client', () => ({ + invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), +})); + +type ChatLikeState = { + currentSessionKey: string; + sessions: Array<{ key: string; displayName?: string }>; + messages: Array<{ role: string; timestamp?: number; content?: unknown }>; + sessionLabels: Record; + sessionLastActivity: Record; + streamingText: string; + streamingMessage: unknown | null; + streamingTools: unknown[]; + activeRunId: string | null; + error: string | null; + pendingFinal: boolean; + lastUserMessageAt: number | null; + pendingToolImages: unknown[]; + loadHistory: ReturnType; +}; + +function makeHarness(initial?: Partial) { + let state: ChatLikeState = { + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + streamingText: '', + streamingMessage: null, + streamingTools: [], + activeRunId: null, + error: null, + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + loadHistory: vi.fn(), + ...initial, + }; + const set = (partial: Partial | ((s: ChatLikeState) => Partial)) => { + const patch = typeof partial === 'function' ? partial(state) : partial; + state = { ...state, ...patch }; + }; + const get = () => state; + return { set, get, read: () => state }; +} + +describe('chat session actions', () => { + beforeEach(() => { + vi.resetAllMocks(); + invokeIpcMock.mockResolvedValue({ success: true }); + }); + + it('switchSession removes empty non-main leaving session and loads history', async () => { + const { createSessionActions } = await import('@/stores/chat/session-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:foo:session-a', + sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }], + messages: [], + sessionLabels: { 'agent:foo:session-a': 'A' }, + sessionLastActivity: { 'agent:foo:session-a': 1 }, + }); + const actions = createSessionActions(h.set as never, h.get as never); + + actions.switchSession('agent:foo:main'); + const next = h.read(); + expect(next.currentSessionKey).toBe('agent:foo:main'); + expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeUndefined(); + expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined(); + expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined(); + expect(h.read().loadHistory).toHaveBeenCalledTimes(1); + }); + + it('deleteSession updates current session and keeps sidebar consistent', async () => { + const { createSessionActions } = await import('@/stores/chat/session-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:foo:session-a', + sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }], + sessionLabels: { 'agent:foo:session-a': 'A' }, + sessionLastActivity: { 'agent:foo:session-a': 1 }, + messages: [{ role: 'user' }], + }); + const actions = createSessionActions(h.set as never, h.get as never); + + await actions.deleteSession('agent:foo:session-a'); + const next = h.read(); + expect(invokeIpcMock).toHaveBeenCalledWith('session:delete', 'agent:foo:session-a'); + expect(next.currentSessionKey).toBe('agent:foo:main'); + expect(next.sessions.map((s) => s.key)).toEqual(['agent:foo:main']); + expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined(); + expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined(); + expect(h.read().loadHistory).toHaveBeenCalledTimes(1); + }); + + it('newSession creates a canonical session key and clears transient state', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111); + const { createSessionActions } = await import('@/stores/chat/session-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:foo:main', + sessions: [{ key: 'agent:foo:main' }], + messages: [{ role: 'assistant' }], + streamingText: 'streaming', + activeRunId: 'r1', + pendingFinal: true, + }); + const actions = createSessionActions(h.set as never, h.get as never); + + actions.newSession(); + const next = h.read(); + expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111'); + expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true); + expect(next.messages).toEqual([]); + expect(next.streamingText).toBe(''); + expect(next.activeRunId).toBeNull(); + expect(next.pendingFinal).toBe(false); + nowSpy.mockRestore(); + }); +}); + diff --git a/tests/unit/gateway-events.test.ts b/tests/unit/gateway-events.test.ts new file mode 100644 index 000000000..8e10c5c42 --- /dev/null +++ b/tests/unit/gateway-events.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiFetchMock = vi.fn(); +const subscribeHostEventMock = vi.fn(); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/lib/host-events', () => ({ + subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args), +})); + +describe('gateway store event wiring', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('subscribes to host events through subscribeHostEvent on init', async () => { + hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); + + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:status', expect.any(Function)); + expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:error', expect.any(Function)); + expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function)); + expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); + expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); + + handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 }); + expect(useGatewayStore.getState().status.state).toBe('stopped'); + }); +}); diff --git a/tests/unit/host-api.test.ts b/tests/unit/host-api.test.ts new file mode 100644 index 000000000..d60c9900a --- /dev/null +++ b/tests/unit/host-api.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const invokeIpcMock = vi.fn(); + +vi.mock('@/lib/api-client', () => ({ + invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), +})); + +describe('host-api', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('uses IPC proxy and returns unified envelope json', async () => { + invokeIpcMock.mockResolvedValueOnce({ + ok: true, + data: { + status: 200, + ok: true, + json: { success: true }, + }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + const result = await hostApiFetch<{ success: boolean }>('/api/settings'); + + expect(result.success).toBe(true); + expect(invokeIpcMock).toHaveBeenCalledWith( + 'hostapi:fetch', + expect.objectContaining({ path: '/api/settings', method: 'GET' }), + ); + }); + + it('supports legacy proxy envelope response', async () => { + invokeIpcMock.mockResolvedValueOnce({ + success: true, + status: 200, + ok: true, + json: { ok: 1 }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + const result = await hostApiFetch<{ ok: number }>('/api/settings'); + expect(result.ok).toBe(1); + }); + + it('throws proxy error from unified envelope', async () => { + invokeIpcMock.mockResolvedValueOnce({ + ok: false, + error: { message: 'No handler registered for hostapi:fetch' }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + await expect(hostApiFetch('/api/test')).rejects.toThrow('No handler registered for hostapi:fetch'); + }); + + it('throws message from legacy non-ok envelope', async () => { + invokeIpcMock.mockResolvedValueOnce({ + success: true, + ok: false, + status: 401, + json: { error: 'Invalid Authentication' }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication'); + }); + + it('falls back to browser fetch only when IPC channel is unavailable', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ fallback: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch')); + + const { hostApiFetch } = await import('@/lib/host-api'); + const result = await hostApiFetch<{ fallback: boolean }>('/api/test'); + + expect(result.fallback).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:3210/api/test', + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); +}); diff --git a/tests/unit/host-events.test.ts b/tests/unit/host-events.test.ts new file mode 100644 index 000000000..3725c6476 --- /dev/null +++ b/tests/unit/host-events.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const addEventListenerMock = vi.fn(); +const removeEventListenerMock = vi.fn(); +const eventSourceMock = { + addEventListener: addEventListenerMock, + removeEventListener: removeEventListenerMock, +} as unknown as EventSource; + +const createHostEventSourceMock = vi.fn(() => eventSourceMock); + +vi.mock('@/lib/host-api', () => ({ + createHostEventSource: () => createHostEventSourceMock(), +})); + +describe('host-events', () => { + beforeEach(() => { + vi.resetAllMocks(); + window.localStorage.clear(); + }); + + it('subscribes through IPC for mapped host events', async () => { + const onMock = vi.mocked(window.electron.ipcRenderer.on); + const offMock = vi.mocked(window.electron.ipcRenderer.off); + const captured: Array<(...args: unknown[]) => void> = []; + onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => { + captured.push(cb); + return () => {}; + }); + + const { subscribeHostEvent } = await import('@/lib/host-events'); + const handler = vi.fn(); + const unsubscribe = subscribeHostEvent('gateway:status', handler); + + expect(onMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function)); + expect(createHostEventSourceMock).not.toHaveBeenCalled(); + + captured[0]({ state: 'running' }); + expect(handler).toHaveBeenCalledWith({ state: 'running' }); + + unsubscribe(); + expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function)); + }); + + it('does not use SSE fallback by default for unknown events', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { subscribeHostEvent } = await import('@/lib/host-events'); + const unsubscribe = subscribeHostEvent('unknown:event', vi.fn()); + expect(createHostEventSourceMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[host-events] no IPC mapping for event "unknown:event", SSE fallback disabled', + ); + unsubscribe(); + warnSpy.mockRestore(); + }); + + it('uses SSE fallback only when explicitly enabled', async () => { + window.localStorage.setItem('clawx:allow-sse-fallback', '1'); + const { subscribeHostEvent } = await import('@/lib/host-events'); + const handler = vi.fn(); + const unsubscribe = subscribeHostEvent('unknown:event', handler); + + expect(createHostEventSourceMock).toHaveBeenCalledTimes(1); + expect(addEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function)); + + const listener = addEventListenerMock.mock.calls[0][1] as (event: Event) => void; + listener({ data: JSON.stringify({ x: 1 }) } as unknown as Event); + expect(handler).toHaveBeenCalledWith({ x: 1 }); + + unsubscribe(); + expect(removeEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function)); + }); +}); + diff --git a/tests/unit/provider-model-sync.test.ts b/tests/unit/provider-model-sync.test.ts new file mode 100644 index 000000000..bfc47b373 --- /dev/null +++ b/tests/unit/provider-model-sync.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { buildNonOAuthAgentProviderUpdate, getModelIdFromRef } from '@electron/main/provider-model-sync'; +import type { ProviderConfig } from '@electron/utils/secure-storage'; + +function providerConfig(overrides: Partial): ProviderConfig { + return { + id: 'provider-id', + name: 'Provider', + type: 'moonshot', + enabled: true, + createdAt: '2026-03-09T00:00:00.000Z', + updatedAt: '2026-03-09T00:00:00.000Z', + ...overrides, + }; +} + +describe('provider-model-sync', () => { + it('extracts model ID from provider/model refs', () => { + expect(getModelIdFromRef('moonshot/kimi-k2.5', 'moonshot')).toBe('kimi-k2.5'); + expect(getModelIdFromRef('kimi-k2.5', 'moonshot')).toBe('kimi-k2.5'); + expect(getModelIdFromRef(undefined, 'moonshot')).toBeUndefined(); + }); + + it('builds models.json update payload for moonshot default switch', () => { + const payload = buildNonOAuthAgentProviderUpdate( + providerConfig({ type: 'moonshot', id: 'moonshot' }), + 'moonshot', + 'moonshot/kimi-k2.5', + ); + + expect(payload).toEqual({ + providerKey: 'moonshot', + entry: { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + apiKey: 'MOONSHOT_API_KEY', + models: [{ id: 'kimi-k2.5', name: 'kimi-k2.5' }], + }, + }); + }); + + it('prefers provider custom baseUrl and omits models when modelRef is missing', () => { + const payload = buildNonOAuthAgentProviderUpdate( + providerConfig({ + type: 'ark', + id: 'ark', + baseUrl: 'https://custom-ark.example.com/v3', + }), + 'ark', + undefined, + ); + + expect(payload).toEqual({ + providerKey: 'ark', + entry: { + baseUrl: 'https://custom-ark.example.com/v3', + api: 'openai-completions', + apiKey: 'ARK_API_KEY', + models: [], + }, + }); + }); + + it('returns null for oauth and multi-instance providers', () => { + expect( + buildNonOAuthAgentProviderUpdate( + providerConfig({ type: 'qwen-portal', id: 'qwen-portal' }), + 'qwen-portal', + 'qwen-portal/coder-model', + ), + ).toBeNull(); + + expect( + buildNonOAuthAgentProviderUpdate( + providerConfig({ type: 'custom', id: 'custom-123' }), + 'custom-123', + 'custom-123/model', + ), + ).toBeNull(); + + expect( + buildNonOAuthAgentProviderUpdate( + providerConfig({ type: 'ollama', id: 'ollama' }), + 'ollama', + 'ollama/qwen3:latest', + ), + ).toBeNull(); + }); +});