diff --git a/AGENTS.md b/AGENTS.md index 7f6b2115..56d18edd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones: - **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries. - **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly. - **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright. -- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `src/i18n/locales//.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table. +- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales//.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table. - **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. diff --git a/README.ja-JP.md b/README.ja-JP.md index 73aaf294..6894b842 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -240,18 +240,17 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を │ └──────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬─────────────────────────────────────┘ │ - │ Main管理のトランスポート戦略 - │(WS優先、HTTP次点、IPCフォールバック) + │ 型付き IPC リクエスト ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ Host API と Main プロキシ層 │ +│ Main Host Services と Gateway Manager │ │ │ -│ • hostapi:fetch(Mainプロキシ、CORS回避) │ -│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │ -│ • 統一エラーマッピングとリトライ/バックオフ │ +│ • host:invoke 型付きサービスディスパッチ │ +│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │ +│ • Main が Gateway WebSocket とプロセス監視を所有 │ └──────────────────────────────┬──────────────────────────────────┘ │ - │ WS / HTTP / IPC フォールバック + │ Main 所有 WebSocket ▼ ┌─────────────────────────────────────────────────────────────────┐ │ OpenClaw ゲートウェイ │ @@ -266,10 +265,11 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を - **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します - **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません -- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します +- **Mainによるトランスポート制御**: Gateway WebSocket は Electron Main のみが所有し、Renderer は型付き IPC で Main と通信します +- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します - **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します - **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します -- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します +- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません ### プロセスモデルと Gateway トラブルシューティング @@ -317,9 +317,7 @@ AI を開発ワークフローに統合できます。エージェントを使 ```ClawX/ ├── electron/ # Electron メインプロセス -│ ├── api/ # メイン側 API ルーターとハンドラー -│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール -│ ├── services/ # Provider/Secrets/ランタイムサービス +│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス │ │ ├── providers/ # provider/account モデル同期ロジック │ │ └── secrets/ # OS キーチェーンと秘密情報管理 │ ├── shared/ # 共通 Provider スキーマ/定数 diff --git a/README.md b/README.md index 25a1fe4f..59c5f0c6 100644 --- a/README.md +++ b/README.md @@ -244,18 +244,17 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The │ └────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬───────────────────────────────────┘ │ - │ Main-owned transport strategy - │ (WS first, HTTP then IPC fallback) + │ Typed IPC requests ▼ ┌──────────────────────────────────────────────────────────────────┐ -│ Host API & Main Process Proxies │ +│ Main Host Services & Gateway Manager │ │ │ -│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │ -│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │ -│ • Unified error mapping & retry/backoff │ +│ • host:invoke typed service dispatcher │ +│ • Settings, files, sessions, skills, providers, diagnostics │ +│ • Main-owned Gateway WebSocket and process supervision │ └──────────────────────────────┬───────────────────────────────────┘ │ - │ WS / HTTP / IPC fallback + │ Main-owned WebSocket ▼ ┌──────────────────────────────────────────────────────────────────┐ │ OpenClaw Gateway │ @@ -270,10 +269,11 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The - **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation - **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 +- **Main-Process Transport Ownership**: Electron Main owns the Gateway WebSocket; the renderer talks to Main over typed IPC +- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes - **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 -- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues +- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly ### Process Model & Gateway Troubleshooting @@ -321,9 +321,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro ```ClawX/ ├── electron/ # Electron Main Process -│ ├── api/ # Main-side API router and handlers -│ │ └── routes/ # RPC/HTTP proxy route modules -│ ├── services/ # Provider, secrets and runtime services +│ ├── services/ # Typed host APIs, provider, secrets and runtime services │ │ ├── providers/ # Provider/account model sync logic │ │ └── secrets/ # OS keychain and secret storage │ ├── shared/ # Shared provider schemas/constants diff --git a/README.zh-CN.md b/README.zh-CN.md index 2de31d3e..d681ee5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -244,18 +244,17 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 │ └────────────────────────────────────────────────────────────┘ │ └──────────────────────────────┬───────────────────────────────────┘ │ - │ 主进程统一传输策略 - │(WS 优先,HTTP 次之,IPC 回退) + │ 类型化 IPC 请求 ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ Host API 与主进程代理层 │ +│ 主进程 Host Services 与 Gateway Manager │ │ │ -│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │ -│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │ -│ • 统一错误映射与重试/退避策略 │ +│ • host:invoke 类型化服务分发 │ +│ • 设置、文件、会话、技能、供应商、诊断服务 │ +│ • 主进程持有 Gateway WebSocket 并负责进程监控 │ └──────────────────────────────┬──────────────────────────────────┘ │ - │ WS / HTTP / IPC 回退 + │ 主进程持有 WebSocket ▼ ┌─────────────────────────────────────────────────────────────────┐ │ OpenClaw 网关 │ @@ -270,10 +269,11 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 - **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应 - **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节 -- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性 +- **主进程掌控传输策略**:Gateway WebSocket 只由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main +- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route - **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障 - **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制 -- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题 +- **CORS 安全**:渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点 ### 进程模型与 Gateway 排障 @@ -321,9 +321,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 ```ClawX/ ├── electron/ # Electron 主进程 -│ ├── api/ # 主进程 API 路由与处理器 -│ │ └── routes/ # RPC/HTTP 代理路由模块 -│ ├── services/ # Provider、Secrets 与运行时服务 +│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务 │ │ ├── providers/ # Provider/account 模型同步逻辑 │ │ └── secrets/ # 系统钥匙串与密钥存储 │ ├── shared/ # 共享 Provider schema/常量 diff --git a/electron/api/context.ts b/electron/api/context.ts deleted file mode 100644 index 0cdc726a..00000000 --- a/electron/api/context.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { BrowserWindow } from 'electron'; -import type { GatewayManager } from '../gateway/manager'; -import type { ClawHubService } from '../gateway/clawhub'; -import type { HostEventBus } from './event-bus'; - -export interface HostApiContext { - gatewayManager: GatewayManager; - clawHubService: ClawHubService; - eventBus: HostEventBus; - mainWindow: BrowserWindow | null; -} diff --git a/electron/api/event-bus.ts b/electron/api/event-bus.ts deleted file mode 100644 index c7e442b1..00000000 --- a/electron/api/event-bus.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ServerResponse } from 'http'; - -type EventPayload = unknown; - -export class HostEventBus { - private readonly clients = new Set(); - - addSseClient(res: ServerResponse): void { - this.clients.add(res); - res.on('close', () => { - this.clients.delete(res); - }); - } - - emit(eventName: string, payload: EventPayload): void { - const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`; - for (const client of this.clients) { - try { - client.write(message); - } catch { - this.clients.delete(client); - } - } - } - - closeAll(): void { - for (const client of this.clients) { - try { - client.end(); - } catch { - // Ignore individual client close failures. - } - } - this.clients.clear(); - } -} diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts deleted file mode 100644 index f48fe035..00000000 --- a/electron/api/route-utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { PORTS } from '../utils/config'; - -/** - * Allowed CORS origins — only the Electron renderer (Vite dev or production) - * and the OpenClaw Gateway are permitted to make cross-origin requests. - */ -const ALLOWED_ORIGINS = new Set([ - `http://127.0.0.1:${PORTS.CLAWX_DEV}`, - `http://localhost:${PORTS.CLAWX_DEV}`, - `http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`, - `http://localhost:${PORTS.OPENCLAW_GATEWAY}`, -]); - -export async function parseJsonBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const raw = Buffer.concat(chunks).toString('utf8').trim(); - if (!raw) { - return {} as T; - } - return JSON.parse(raw) as T; -} - -/** - * Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type. - * This prevents "simple request" CSRF where the browser skips the preflight - * when Content-Type is text/plain or application/x-www-form-urlencoded. - */ -export function requireJsonContentType(req: IncomingMessage): boolean { - if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') { - return true; - } - // Requests without a body (content-length 0 or absent) are safe — CSRF - // "simple request" attacks rely on sending a crafted body. - const contentLength = req.headers['content-length']; - if (contentLength === '0' || contentLength === undefined) { - return true; - } - const ct = req.headers['content-type'] || ''; - return ct.includes('application/json'); -} - -export function setCorsHeaders(res: ServerResponse, origin?: string): void { - // Only reflect the Origin header back if it is in the allow-list. - // Omitting the header for unknown origins causes the browser to block - // the response — this is the intended behavior for untrusted callers. - if (origin && ALLOWED_ORIGINS.has(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Vary', 'Origin'); - } - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); -} - -export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -export function sendNoContent(res: ServerResponse): void { - res.statusCode = 204; - res.end(); -} - -export function sendText(res: ServerResponse, statusCode: number, text: string): void { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end(text); -} diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts deleted file mode 100644 index 3ee65f78..00000000 --- a/electron/api/routes/agents.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { - assignChannelToAgent, - clearChannelBinding, - createAgent, - deleteAgentConfig, - listAgentsSnapshot, - removeAgentWorkspaceDirectory, - resolveAccountIdForAgent, - updateAgentModel, - updateAgentName, -} from '../../utils/agent-config'; -import { deleteChannelAccountConfig } from '../../utils/channel-config'; -import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { ensureClawXContext } from '../../utils/openclaw-workspace'; - -function scheduleGatewayReload(ctx: HostApiContext, reason: string): void { - if (ctx.gatewayManager.getStatus().state !== 'stopped') { - ctx.gatewayManager.debouncedReload(); - return; - } - void reason; -} - -import { exec } from 'child_process'; -import { promisify } from 'util'; -const execAsync = promisify(exec); - -/** - * Force a full Gateway process restart after agent deletion. - * - * A SIGUSR1 in-process reload is NOT sufficient here: channel plugins - * (e.g. Feishu) maintain long-lived WebSocket connections to external - * services and do not disconnect accounts that were removed from the - * config during an in-process reload. The only reliable way to drop - * stale bot connections is to kill the Gateway process entirely and - * spawn a fresh one that reads the updated openclaw.json from scratch. - */ -export async function restartGatewayForAgentDeletion(ctx: HostApiContext): Promise { - try { - // Capture the PID of the running Gateway BEFORE stop() clears it. - const status = ctx.gatewayManager.getStatus(); - const pid = status.pid; - const port = status.port; - console.log('[agents] Triggering Gateway restart (kill+respawn) after agent deletion', { pid, port }); - - // Force-kill the Gateway process by PID. The manager's stop() only - // kills "owned" processes; if the manager connected to an already- - // running Gateway (ownsProcess=false), stop() simply closes the WS - // and the old process stays alive with its stale channel connections. - if (pid) { - try { - if (process.platform === 'win32') { - await execAsync(`taskkill /F /PID ${pid} /T`); - } else { - process.kill(pid, 'SIGTERM'); - // Give it a moment to die - await new Promise((resolve) => setTimeout(resolve, 500)); - try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* already dead */ } - } - } catch { - // process already gone – that's fine - } - } else if (port) { - // If we don't know the PID (e.g. connected to an orphaned Gateway from - // a previous pnpm dev run), forcefully kill whatever is on the port. - try { - if (process.platform === 'darwin' || process.platform === 'linux') { - // MUST use -sTCP:LISTEN. Otherwise lsof returns the client process (ClawX itself) - // that has an ESTABLISHED WebSocket connection to the port, causing us to kill ourselves. - const { stdout } = await execAsync(`lsof -t -i :${port} -sTCP:LISTEN`); - const pids = stdout.trim().split('\n').filter(Boolean); - for (const p of pids) { - try { process.kill(parseInt(p, 10), 'SIGTERM'); } catch { /* ignore */ } - } - await new Promise((resolve) => setTimeout(resolve, 500)); - for (const p of pids) { - try { process.kill(parseInt(p, 10), 'SIGKILL'); } catch { /* ignore */ } - } - } else if (process.platform === 'win32') { - // Find PID listening on the port - const { stdout } = await execAsync(`netstat -ano | findstr :${port}`); - const lines = stdout.trim().split('\n'); - const pids = new Set(); - for (const line of lines) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 5 && parts[1].endsWith(`:${port}`) && parts[3] === 'LISTENING') { - pids.add(parts[4]); - } - } - for (const p of pids) { - try { await execAsync(`taskkill /F /PID ${p} /T`); } catch { /* ignore */ } - } - } - } catch { - // Port might not be bound or command failed; ignore - } - } - - await ctx.gatewayManager.restart(); - console.log('[agents] Gateway restart completed after agent deletion'); - } catch (err) { - console.warn('[agents] Gateway restart after agent deletion failed:', err); - } -} - -export async function handleAgentRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/agents' && req.method === 'GET') { - sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) }); - return true; - } - - if (url.pathname === '/api/agents' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ name: string; inheritWorkspace?: boolean }>(req); - const snapshot = await createAgent(body.name, { inheritWorkspace: body.inheritWorkspace }); - // Sync provider API keys to the new agent's auth-profiles.json so the - // embedded runner can authenticate with LLM providers when messages - // arrive via channel bots (e.g. Feishu). Without this, the copied - // auth-profiles.json may contain a stale key → 401 from the LLM. - syncAllProviderAuthToRuntime().catch((err) => { - console.warn('[agents] Failed to sync provider auth after agent creation:', err); - }); - scheduleGatewayReload(ctx, 'create-agent'); - // Ensure newly provisioned workspaces get ClawX context merge/cleanup - // even when gateway status events do not fire (e.g. in-process reload). - void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => { - console.warn('[agents] Failed to ensure ClawX context after agent creation:', err); - }); - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') { - const suffix = url.pathname.slice('/api/agents/'.length); - const parts = suffix.split('/').filter(Boolean); - - if (parts.length === 1) { - try { - const body = await parseJsonBody<{ name: string }>(req); - const agentId = decodeURIComponent(parts[0]); - const snapshot = await updateAgentName(agentId, body.name); - scheduleGatewayReload(ctx, 'update-agent'); - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (parts.length === 2 && parts[1] === 'model') { - try { - const body = await parseJsonBody<{ modelRef?: string | null }>(req); - const agentId = decodeURIComponent(parts[0]); - const snapshot = await updateAgentModel(agentId, body.modelRef ?? null); - try { - await syncAllProviderAuthToRuntime(); - // Ensure this agent's runtime model registry reflects the new model override. - await syncAgentModelOverrideToRuntime(agentId); - } catch (syncError) { - console.warn('[agents] Failed to sync runtime after updating agent model:', syncError); - } - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (parts.length === 3 && parts[1] === 'channels') { - try { - const agentId = decodeURIComponent(parts[0]); - const channelType = decodeURIComponent(parts[2]); - const snapshot = await assignChannelToAgent(agentId, channelType); - scheduleGatewayReload(ctx, 'assign-channel'); - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - } - - if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') { - const suffix = url.pathname.slice('/api/agents/'.length); - const parts = suffix.split('/').filter(Boolean); - - if (parts.length === 1) { - try { - const agentId = decodeURIComponent(parts[0]); - const { snapshot, removedEntry } = await deleteAgentConfig(agentId); - // Await reload synchronously BEFORE responding to the client. - // This ensures the Feishu plugin has disconnected the deleted bot - // before the UI shows "delete success" and the user tries chatting. - await restartGatewayForAgentDeletion(ctx); - // Delete workspace after reload so the new config is already live. - await removeAgentWorkspaceDirectory(removedEntry).catch((err) => { - console.warn('[agents] Failed to remove workspace after agent deletion:', err); - }); - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (parts.length === 3 && parts[1] === 'channels') { - try { - const agentId = decodeURIComponent(parts[0]); - const channelType = decodeURIComponent(parts[2]); - const ownerId = agentId.trim().toLowerCase(); - const snapshotBefore = await listAgentsSnapshot(); - const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners) - .filter(([channelAccountKey, owner]) => { - if (owner !== ownerId) return false; - return channelAccountKey.startsWith(`${channelType}:`); - }) - .map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1)); - // Backward compatibility for legacy agentId->accountId mapping. - if (ownedAccountIds.length === 0) { - const legacyAccountId = resolveAccountIdForAgent(agentId); - if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) { - ownedAccountIds.push(legacyAccountId); - } - } - - for (const accountId of ownedAccountIds) { - await deleteChannelAccountConfig(channelType, accountId); - await clearChannelBinding(channelType, accountId); - } - const snapshot = await listAgentsSnapshot(); - scheduleGatewayReload(ctx, 'remove-agent-channel'); - sendJson(res, 200, { success: true, ...snapshot }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - } - - return false; -} diff --git a/electron/api/routes/app.ts b/electron/api/routes/app.ts deleted file mode 100644 index c1573c00..00000000 --- a/electron/api/routes/app.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor'; - -export async function handleAppRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/events' && req.method === 'GET') { - // CORS headers are already set by the server middleware. - res.writeHead(200, { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - }); - res.write(': connected\n\n'); - ctx.eventBus.addSseClient(res); - // Send a current-state snapshot immediately so renderer subscribers do not - // miss lifecycle transitions that happened before the SSE connection opened. - res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`); - return true; - } - - if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') { - const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req); - const mode = body.mode === 'fix' ? 'fix' : 'diagnose'; - sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor()); - return true; - } - - // OPTIONS is handled by the server middleware; no route-level handler needed. - - return false; -} diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts deleted file mode 100644 index caa1e014..00000000 --- a/electron/api/routes/cron.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { join } from 'node:path'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { getOpenClawConfigDir } from '../../utils/paths'; -import { resolveAccountIdFromSessionHistory } from '../../utils/session-util'; -import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias'; -import { resolveAgentIdFromChannel } from '../../utils/agent-config'; - -/** - * Find agentId from session history by delivery "to" address. - * Efficiently searches only agent session directories for matching deliveryContext.to. - */ -interface GatewayCronJob { - id: string; - name: string; - description?: string; - enabled: boolean; - createdAtMs: number; - updatedAtMs: number; - schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string }; - payload: { kind: string; message?: string; text?: string }; - delivery?: { mode: string; channel?: string; to?: string; accountId?: string }; - sessionTarget?: string; - state: { - nextRunAtMs?: number; - runningAtMs?: number; - lastRunAtMs?: number; - lastStatus?: string; - lastError?: string; - lastDurationMs?: number; - }; -} - -interface CronRunLogEntry { - jobId?: string; - action?: string; - status?: string; - error?: string; - summary?: string; - sessionId?: string; - sessionKey?: string; - ts?: number; - runAtMs?: number; - durationMs?: number; - model?: string; - provider?: string; -} - -interface CronSessionKeyParts { - agentId: string; - jobId: string; - runSessionId?: string; -} - -interface CronSessionFallbackMessage { - id: string; - role: 'assistant' | 'system'; - content: string; - timestamp: number; - isError?: boolean; -} - -function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null { - if (!sessionKey.startsWith('agent:')) return null; - const parts = sessionKey.split(':'); - if (parts.length < 4 || parts[2] !== 'cron') return null; - - const agentId = parts[1] || 'main'; - const jobId = parts[3]; - if (!jobId) return null; - - if (parts.length === 4) { - return { agentId, jobId }; - } - - if (parts.length === 6 && parts[4] === 'run' && parts[5]) { - return { agentId, jobId, runSessionId: parts[5] }; - } - - return null; -} - -function normalizeTimestampMs(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return value < 1e12 ? value * 1000 : value; - } - if (typeof value === 'string' && value.trim()) { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; -} - -function formatDuration(durationMs: number | undefined): string | null { - if (!durationMs || !Number.isFinite(durationMs)) return null; - if (durationMs < 1000) return `${Math.round(durationMs)}ms`; - if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`; - return `${Math.round(durationMs / 1000)}s`; -} - -function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null { - const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs); - if (!timestamp) return null; - - const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : ''; - const summary = typeof entry.summary === 'string' ? entry.summary.trim() : ''; - const error = typeof entry.error === 'string' ? entry.error.trim() : ''; - let content = summary || error; - - if (!content) { - content = status === 'error' - ? 'Scheduled task failed.' - : 'Scheduled task completed.'; - } - - if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) { - content = `Run failed: ${content}`; - } - - const meta: string[] = []; - const duration = formatDuration(entry.durationMs); - if (duration) meta.push(`Duration: ${duration}`); - if (entry.provider && entry.model) { - meta.push(`Model: ${entry.provider}/${entry.model}`); - } else if (entry.model) { - meta.push(`Model: ${entry.model}`); - } - if (meta.length > 0) { - content = `${content}\n\n${meta.join(' | ')}`; - } - - return { - id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`, - role: status === 'error' ? 'system' : 'assistant', - content, - timestamp, - ...(status === 'error' ? { isError: true } : {}), - }; -} - -async function readCronRunLog(jobId: string): Promise { - const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`); - const raw = await readFile(logPath, 'utf8').catch(() => ''); - if (!raw.trim()) return []; - - const entries: CronRunLogEntry[] = []; - for (const line of raw.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed) as CronRunLogEntry; - if (!entry || entry.jobId !== jobId) continue; - if (entry.action && entry.action !== 'finished') continue; - entries.push(entry); - } catch { - // Ignore malformed log lines so one bad entry does not hide the rest. - } - } - return entries; -} - -async function readSessionStoreEntry( - agentId: string, - sessionKey: string, -): Promise | undefined> { - const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json'); - const raw = await readFile(storePath, 'utf8').catch(() => ''); - if (!raw.trim()) return undefined; - - try { - const store = JSON.parse(raw) as Record; - const directEntry = store[sessionKey]; - if (directEntry && typeof directEntry === 'object') { - return directEntry as Record; - } - - const sessions = (store as { sessions?: unknown }).sessions; - if (Array.isArray(sessions)) { - const arrayEntry = sessions.find((entry) => { - if (!entry || typeof entry !== 'object') return false; - const record = entry as Record; - return record.key === sessionKey || record.sessionKey === sessionKey; - }); - if (arrayEntry && typeof arrayEntry === 'object') { - return arrayEntry as Record; - } - } - } catch { - return undefined; - } - - return undefined; -} - -export function buildCronSessionFallbackMessages(params: { - sessionKey: string; - job?: Pick; - runs: CronRunLogEntry[]; - sessionEntry?: { label?: string; updatedAt?: number }; - limit?: number; -}): CronSessionFallbackMessage[] { - const parsed = parseCronSessionKey(params.sessionKey); - if (!parsed) return []; - - const matchingRuns = params.runs - .filter((entry) => { - if (!parsed.runSessionId) return true; - return entry.sessionId === parsed.runSessionId - || entry.sessionKey === `${params.sessionKey}`; - }) - .sort((a, b) => { - const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0; - const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0; - return left - right; - }); - - const messages: CronSessionFallbackMessage[] = []; - const prompt = params.job?.payload?.message || params.job?.payload?.text || ''; - const taskName = params.job?.name?.trim() - || params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim() - || ''; - const firstRelevantTimestamp = matchingRuns.length > 0 - ? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts)) - : (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt); - - if (taskName || prompt) { - const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task']; - if (prompt) lines.push(`Prompt: ${prompt}`); - messages.push({ - id: `cron-meta-${parsed.jobId}`, - role: 'system', - content: lines.join('\n'), - timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1), - }); - } - - matchingRuns.forEach((entry, index) => { - const message = buildCronRunMessage(entry, index); - if (message) messages.push(message); - }); - - if (matchingRuns.length === 0) { - const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs); - if (runningAt) { - messages.push({ - id: `cron-running-${parsed.jobId}`, - role: 'system', - content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.', - timestamp: runningAt, - }); - } else if (messages.length === 0) { - messages.push({ - id: `cron-empty-${parsed.jobId}`, - role: 'system', - content: 'No chat transcript is available for this scheduled task yet.', - timestamp: params.sessionEntry?.updatedAt ?? Date.now(), - }); - } - } - - const limit = typeof params.limit === 'number' && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : messages.length; - return messages.slice(-limit); -} - -type JsonRecord = Record; -type GatewayCronDelivery = NonNullable; - -function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null { - // Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS). - // No per-channel backend blocks are needed. - return null; -} - -function normalizeCronDelivery( - rawDelivery: unknown, - fallbackMode: GatewayCronDelivery['mode'] = 'none', -): GatewayCronDelivery { - if (!rawDelivery || typeof rawDelivery !== 'object') { - return { mode: fallbackMode }; - } - - const delivery = rawDelivery as JsonRecord; - const mode = typeof delivery.mode === 'string' && delivery.mode.trim() - ? delivery.mode.trim() - : fallbackMode; - const channel = typeof delivery.channel === 'string' && delivery.channel.trim() - ? toOpenClawChannelType(delivery.channel.trim()) - : undefined; - const to = typeof delivery.to === 'string' && delivery.to.trim() - ? delivery.to.trim() - : undefined; - const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim() - ? delivery.accountId.trim() - : undefined; - - if (mode === 'announce' && !channel) { - return { mode: 'none' }; - } - - return { - mode, - ...(channel ? { channel } : {}), - ...(to ? { to } : {}), - ...(accountId ? { accountId } : {}), - }; -} - -function normalizeCronDeliveryPatch(rawDelivery: unknown): Record { - if (!rawDelivery || typeof rawDelivery !== 'object') { - return {}; - } - - const delivery = rawDelivery as JsonRecord; - const patch: Record = {}; - if ('mode' in delivery) { - patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() - ? delivery.mode.trim() - : 'none'; - } - if ('channel' in delivery) { - patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim() - ? toOpenClawChannelType(delivery.channel.trim()) - : ''; - } - if ('to' in delivery) { - patch.to = typeof delivery.to === 'string' ? delivery.to : ''; - } - if ('accountId' in delivery) { - patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : ''; - } - return patch; -} - -function buildCronUpdatePatch(input: Record): Record { - const patch = { ...input }; - - if (typeof patch.schedule === 'string') { - patch.schedule = { kind: 'cron', expr: patch.schedule }; - } - - if (typeof patch.message === 'string') { - patch.payload = { kind: 'agentTurn', message: patch.message }; - delete patch.message; - } - - if ('delivery' in patch) { - patch.delivery = normalizeCronDeliveryPatch(patch.delivery); - } - - if ('agentId' in patch) { - const agentId = typeof patch.agentId === 'string' && patch.agentId.trim() - ? patch.agentId.trim() - : 'main'; - patch.agentId = agentId; - // Keep sessionTarget as isolated when agentId changes - } - - return patch; -} - -function transformCronJob(job: GatewayCronJob) { - const message = job.payload?.message || job.payload?.text || ''; - const gatewayDelivery = normalizeCronDelivery(job.delivery); - const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined; - const delivery = channelType - ? { ...gatewayDelivery, channel: channelType } - : gatewayDelivery; - const target = channelType - ? { - channelType, - channelId: delivery.accountId || gatewayDelivery.channel, - channelName: channelType, - recipient: delivery.to, - } - : undefined; - const lastRun = job.state?.lastRunAtMs - ? { - time: new Date(job.state.lastRunAtMs).toISOString(), - success: job.state.lastStatus === 'ok', - error: job.state.lastError, - duration: job.state.lastDurationMs, - } - : undefined; - const nextRun = job.state?.nextRunAtMs - ? new Date(job.state.nextRunAtMs).toISOString() - : undefined; - - // Parse agentId from the job's agentId field - const agentId = (job as unknown as { agentId?: string }).agentId || 'main'; - - return { - id: job.id, - name: job.name, - message, - schedule: job.schedule, - delivery, - target, - enabled: job.enabled, - createdAt: new Date(job.createdAtMs).toISOString(), - updatedAt: new Date(job.updatedAtMs).toISOString(), - lastRun, - nextRun, - agentId, - }; -} - -export async function handleCronRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/cron/session-history' && req.method === 'GET') { - const sessionKey = url.searchParams.get('sessionKey')?.trim() || ''; - const parsedSession = parseCronSessionKey(sessionKey); - if (!parsedSession) { - sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` }); - return true; - } - - const rawLimit = Number(url.searchParams.get('limit') || '200'); - const limit = Number.isFinite(rawLimit) - ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) - : 200; - - try { - const [jobsResult, runs, sessionEntry] = await Promise.all([ - ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000) - .catch(() => ({ jobs: [] as GatewayCronJob[] })), - readCronRunLog(parsedSession.jobId), - readSessionStoreEntry(parsedSession.agentId, sessionKey), - ]); - - const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? []; - const job = jobs.find((item) => item.id === parsedSession.jobId); - const messages = buildCronSessionFallbackMessages({ - sessionKey, - job, - runs, - sessionEntry: sessionEntry ? { - label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined, - updatedAt: normalizeTimestampMs(sessionEntry.updatedAt), - } : undefined, - limit, - }); - - sendJson(res, 200, { messages }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/cron/jobs' && req.method === 'GET') { - try { - let jobs: GatewayCronJob[] = []; - let usedFallback = false; - - try { - // 8s timeout — fail fast when Gateway is busy with AI tasks. - const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000); - const data = result as { jobs?: GatewayCronJob[] }; - jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []); - - // DEBUG: log name and agentId for each job - console.debug('Fetched cron jobs from Gateway:'); - for (const job of jobs) { - const jobAgentId = (job as unknown as { agentId?: string }).agentId; - const deliveryInfo = job.delivery ? `delivery={mode:${job.delivery.mode}, channel:${job.delivery.channel || '(none)'}, accountId:${job.delivery.accountId || '(none)'}, to:${job.delivery.to || '(none)'}}` : 'delivery=(none)'; - console.debug(` - name: "${job.name}", agentId: "${jobAgentId || '(undefined)'}", ${deliveryInfo}, sessionTarget: "${job.sessionTarget || '(none)'}", payload.kind: "${job.payload?.kind || '(none)'}"`); - } - } catch { - // Fallback: read cron.json directly when Gateway RPC fails/times out. - try { - const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json'); - const raw = await readFile(cronJsonPath, 'utf-8'); - const parsed = JSON.parse(raw); - const fileJobs = Array.isArray(parsed) ? parsed : (parsed?.jobs ?? []); - jobs = fileJobs as GatewayCronJob[]; - usedFallback = true; - } catch { - // No fallback data available either - } - } - - // Run repair in background — don't block the response. - if (!usedFallback && jobs.length > 0) { - // Repair 1: delivery channel missing - const jobsToRepairDelivery = jobs.filter((job) => { - const isIsolatedAgent = - (job.sessionTarget === 'isolated' || !job.sessionTarget) && - job.payload?.kind === 'agentTurn'; - return ( - isIsolatedAgent && - job.delivery?.mode === 'announce' && - !job.delivery?.channel - ); - }); - if (jobsToRepairDelivery.length > 0) { - // Fire-and-forget: repair in background - void (async () => { - for (const job of jobsToRepairDelivery) { - try { - await ctx.gatewayManager.rpc('cron.update', { - id: job.id, - patch: { delivery: { mode: 'none' } }, - }); - } catch { - // ignore per-job repair failure - } - } - })(); - // Optimistically fix the response data - for (const job of jobsToRepairDelivery) { - job.delivery = { mode: 'none' }; - if (job.state?.lastError?.includes('Channel is required')) { - job.state.lastError = undefined; - job.state.lastStatus = 'ok'; - } - } - } - - // Repair 2: agentId is undefined for jobs with announce delivery - // Only repair undefined -> inferred agent, NOT main -> inferred agent - const jobsToRepairAgent = jobs.filter((job) => { - const jobAgentId = (job as unknown as { agentId?: string }).agentId; - return ( - (job.sessionTarget === 'isolated' || !job.sessionTarget) && - job.payload?.kind === 'agentTurn' && - job.delivery?.mode === 'announce' && - job.delivery?.channel && - jobAgentId === undefined // Only repair when agentId is completely undefined - ); - }); - if (jobsToRepairAgent.length > 0) { - console.debug(`Found ${jobsToRepairAgent.length} jobs needing agent repair:`); - for (const job of jobsToRepairAgent) { - console.debug(` - Job "${job.name}" (id: ${job.id}): current agentId="${(job as unknown as { agentId?: string }).agentId || '(undefined)'}", channel="${job.delivery?.channel}", accountId="${job.delivery?.accountId || '(none)'}"`); - } - // Fire-and-forget: repair in background - void (async () => { - for (const job of jobsToRepairAgent) { - try { - const channel = toOpenClawChannelType(job.delivery!.channel!); - const accountId = job.delivery!.accountId; - const toAddress = job.delivery!.to; - - // Try 1: resolve from channel + accountId binding - let correctAgentId = await resolveAgentIdFromChannel(channel, accountId); - - // If no accountId, try to resolve it from session history using "to" address, then get agentId - let resolvedAccountId: string | null = null; - if (!correctAgentId && !accountId && toAddress) { - console.debug(`No binding found for channel="${channel}", accountId="${accountId || '(none)'}", trying session history for to="${toAddress}"`); - resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel); - if (resolvedAccountId) { - console.debug(`Resolved accountId="${resolvedAccountId}" from session history, now resolving agentId`); - correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId); - } - } - - if (correctAgentId) { - console.debug(`Repairing job "${job.name}": agentId "${(job as unknown as { agentId?: string }).agentId || '(undefined)'}" -> "${correctAgentId}"`); - // When accountId was resolved via to address, include it in the patch - const patch: Record = { agentId: correctAgentId }; - if (resolvedAccountId && !accountId) { - patch.delivery = { accountId: resolvedAccountId }; - } - await ctx.gatewayManager.rpc('cron.update', { id: job.id, patch }); - // Update the local job object so response reflects correct agentId - (job as unknown as { agentId: string }).agentId = correctAgentId; - if (resolvedAccountId && !accountId && job.delivery) { - job.delivery.accountId = resolvedAccountId; - } - } else { - console.warn(`Could not resolve agent for job "${job.name}": channel="${channel}", accountId="${accountId || '(none)'}", to="${toAddress || '(none)'}"`); - } - } catch (error) { - console.error(`Failed to repair agent for job "${job.name}":`, error); - } - } - })(); - } - } - - sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }))); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/cron/jobs' && req.method === 'POST') { - try { - const input = await parseJsonBody<{ - name: string; - message: string; - schedule: string; - delivery?: GatewayCronDelivery; - enabled?: boolean; - agentId?: string; - }>(req); - const agentId = typeof input.agentId === 'string' && input.agentId.trim() - ? input.agentId.trim() - : 'main'; - // DEBUG: log the input and resolved agentId - console.debug(`Creating cron job: name="${input.name}", input.agentId="${input.agentId || '(not provided)'}", resolved agentId="${agentId}"`); - const delivery = normalizeCronDelivery(input.delivery); - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel); - if (delivery.mode === 'announce' && unsupportedDeliveryError) { - sendJson(res, 400, { success: false, error: unsupportedDeliveryError }); - return true; - } - const result = await ctx.gatewayManager.rpc('cron.add', { - name: input.name, - schedule: { kind: 'cron', expr: input.schedule }, - payload: { kind: 'agentTurn', message: input.message }, - enabled: input.enabled ?? true, - wakeMode: 'next-heartbeat', - sessionTarget: 'isolated', - agentId, - delivery, - }); - sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') { - try { - const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length)); - const input = await parseJsonBody>(req); - const patch = buildCronUpdatePatch(input); - const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' - ? patch.delivery as Record - : undefined; - const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() - ? deliveryPatch.channel.trim() - : undefined; - const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() - ? deliveryPatch.mode.trim() - : undefined; - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); - if (unsupportedDeliveryError && deliveryMode !== 'none') { - sendJson(res, 400, { success: false, error: unsupportedDeliveryError }); - return true; - } - const result = await ctx.gatewayManager.rpc('cron.update', { id, patch }); - sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') { - try { - const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length)); - sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id })); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/cron/toggle' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ id: string; enabled: boolean }>(req); - sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } })); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/cron/trigger' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ id: string }>(req); - sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' })); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts deleted file mode 100644 index b4a9ed79..00000000 --- a/electron/api/routes/files.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { dialog, nativeImage } from 'electron'; -import crypto from 'node:crypto'; -import { basename, extname, join } from 'node:path'; -import { homedir } from 'node:os'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; - -const EXT_MIME_MAP: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.mp4': 'video/mp4', - '.webm': 'video/webm', - '.mov': 'video/quicktime', - '.avi': 'video/x-msvideo', - '.mkv': 'video/x-matroska', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.ogg': 'audio/ogg', - '.flac': 'audio/flac', - '.pdf': 'application/pdf', - '.zip': 'application/zip', - '.gz': 'application/gzip', - '.tar': 'application/x-tar', - '.7z': 'application/x-7z-compressed', - '.rar': 'application/vnd.rar', - '.json': 'application/json', - '.xml': 'application/xml', - '.csv': 'text/csv', - '.txt': 'text/plain', - '.md': 'text/markdown', - '.html': 'text/html', - '.css': 'text/css', - '.js': 'text/javascript', - '.ts': 'text/typescript', - '.py': 'text/x-python', -}; - -function getMimeType(ext: string): string { - return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; -} - -function mimeToExt(mimeType: string): string { - for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { - if (mime === mimeType) return ext; - } - return ''; -} - -const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); -const DIRECTORY_MIME_TYPE = 'application/x-directory'; - -async function generateImagePreview(filePath: string, mimeType: string): Promise { - try { - const { readFile } = await import('node:fs/promises'); - if (mimeType === 'image/svg+xml') { - const buf = await readFile(filePath); - return `data:${mimeType};base64,${buf.toString('base64')}`; - } - - const img = nativeImage.createFromPath(filePath); - if (img.isEmpty()) return null; - const size = img.getSize(); - const maxDim = 512; - if (size.width > maxDim || size.height > maxDim) { - const resized = size.width >= size.height - ? img.resize({ width: maxDim }) - : img.resize({ height: maxDim }); - return `data:image/png;base64,${resized.toPNG().toString('base64')}`; - } - const buf = await readFile(filePath); - return `data:${mimeType};base64,${buf.toString('base64')}`; - } catch { - return null; - } -} - -/** - * Resolve a Gateway-emitted outgoing-media URL to the original file on disk. - * Mirror of `electron/main/ipc-handlers.ts::resolveOutgoingMediaUrl` — kept - * in sync so the host-api HTTP path serves the same data as the IPC path. - */ -async function resolveOutgoingMediaUrl( - gatewayUrl: string, -): Promise<{ path: string; mimeType: string } | null> { - try { - const m = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//); - if (!m) return null; - const attachmentId = decodeURIComponent(m[1]); - if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null; - const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`); - const fsP = await import('node:fs/promises'); - const raw = await fsP.readFile(recordPath, 'utf8'); - const record = JSON.parse(raw) as { - original?: { path?: string; contentType?: string }; - }; - const original = record?.original; - if (!original?.path) return null; - return { - path: original.path, - mimeType: typeof original.contentType === 'string' && original.contentType - ? original.contentType - : 'application/octet-stream', - }; - } catch { - return null; - } -} - -export async function handleFileRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ filePaths: string[] }>(req); - const fsP = await import('node:fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); - const results = []; - for (const filePath of body.filePaths) { - const id = crypto.randomUUID(); - const fileName = basename(filePath); - const sourceStat = await fsP.stat(filePath); - if (sourceStat.isDirectory()) { - results.push({ - id, - fileName, - mimeType: DIRECTORY_MIME_TYPE, - fileSize: 0, - stagedPath: filePath, - preview: null, - }); - continue; - } - - const ext = extname(filePath); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); - await fsP.copyFile(filePath, stagedPath); - const s = await fsP.stat(stagedPath); - const mimeType = getMimeType(ext); - const preview = mimeType.startsWith('image/') - ? await generateImagePreview(stagedPath, mimeType) - : null; - results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); - } - sendJson(res, 200, results); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req); - const fsP = await import('node:fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); - const id = crypto.randomUUID(); - const ext = extname(body.fileName) || mimeToExt(body.mimeType); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); - const buffer = Buffer.from(body.base64, 'base64'); - await fsP.writeFile(stagedPath, buffer); - const mimeType = body.mimeType || getMimeType(ext); - const preview = mimeType.startsWith('image/') - ? await generateImagePreview(stagedPath, mimeType) - : null; - sendJson(res, 200, { - id, - fileName: body.fileName, - mimeType, - fileSize: buffer.length, - stagedPath, - preview, - }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - paths: Array<{ filePath?: string; gatewayUrl?: string; mimeType: string }>; - }>(req); - const fsP = await import('node:fs/promises'); - const results: Record = {}; - for (const entry of body.paths) { - if (entry.filePath) { - try { - const s = await fsP.stat(entry.filePath); - const preview = entry.mimeType.startsWith('image/') - ? await generateImagePreview(entry.filePath, entry.mimeType) - : null; - results[entry.filePath] = { preview, fileSize: s.size }; - } catch { - results[entry.filePath] = { preview: null, fileSize: 0 }; - } - continue; - } - if (entry.gatewayUrl) { - const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl); - if (!resolved) { - results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; - continue; - } - try { - const s = await fsP.stat(resolved.path); - const preview = resolved.mimeType.startsWith('image/') - ? await generateImagePreview(resolved.path, resolved.mimeType) - : null; - results[entry.gatewayUrl] = { preview, fileSize: s.size }; - } catch { - results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; - } - } - } - sendJson(res, 200, results); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/files/save-image' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - base64?: string; - mimeType?: string; - filePath?: string; - defaultFileName: string; - }>(req); - const ext = body.defaultFileName.includes('.') - ? body.defaultFileName.split('.').pop()! - : (body.mimeType?.split('/')[1] || 'png'); - const result = await dialog.showSaveDialog({ - defaultPath: join(homedir(), 'Downloads', body.defaultFileName), - filters: [ - { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, - { name: 'All Files', extensions: ['*'] }, - ], - }); - if (result.canceled || !result.filePath) { - sendJson(res, 200, { success: false }); - return true; - } - const fsP = await import('node:fs/promises'); - if (body.filePath) { - await fsP.copyFile(body.filePath, result.filePath); - } else if (body.base64) { - await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64')); - } else { - sendJson(res, 400, { success: false, error: 'No image data provided' }); - return true; - } - sendJson(res, 200, { success: true, savedPath: result.filePath }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts deleted file mode 100644 index d457ea6d..00000000 --- a/electron/api/routes/gateway.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { PORTS } from '../../utils/config'; -import { scheduleControlUiDeviceAutoApproval } from '../../utils/control-ui-device-pairing'; -import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui'; -import { getSetting } from '../../utils/store'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; - -async function runGatewayRpc(ctx: HostApiContext, method: string, params?: unknown, timeoutMs?: number): Promise { - return await ctx.gatewayManager.rpc(method, params, timeoutMs); -} - -export async function handleGatewayRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') { - const status = ctx.gatewayManager.getStatus(); - const token = await getSetting('gatewayToken'); - const port = status.port || PORTS.OPENCLAW_GATEWAY; - sendJson(res, 200, { - wsUrl: `ws://127.0.0.1:${port}/ws`, - token, - port, - }); - return true; - } - - if (url.pathname === '/api/gateway/status' && req.method === 'GET') { - sendJson(res, 200, ctx.gatewayManager.getStatus()); - return true; - } - - if (url.pathname === '/api/gateway/health' && req.method === 'GET') { - const health = await ctx.gatewayManager.checkHealth({ - probe: url.searchParams.get('probe') === '1' || url.searchParams.get('probe') === 'true', - }); - sendJson(res, 200, health); - return true; - } - - if (url.pathname === '/api/gateway/start' && req.method === 'POST') { - try { - await ctx.gatewayManager.start(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/gateway/stop' && req.method === 'POST') { - try { - await ctx.gatewayManager.stop(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/gateway/restart' && req.method === 'POST') { - try { - await ctx.gatewayManager.restart(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') { - try { - const status = ctx.gatewayManager.getStatus(); - const token = await getSetting('gatewayToken'); - const port = status.port || PORTS.OPENCLAW_GATEWAY; - const view = url.searchParams.get('view') === 'dreams' ? 'dreams' : undefined; - const urlValue = buildOpenClawControlUiUrl(port, token, { view }); - scheduleControlUiDeviceAutoApproval(ctx.gatewayManager); - sendJson(res, 200, { success: true, url: urlValue, token, port }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/chat/sessions' && req.method === 'GET') { - try { - const result = await runGatewayRpc>(ctx, 'sessions.list', { - includeDerivedTitles: true, - includeLastMessage: true, - }); - sendJson(res, 200, { success: true, result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/chat/history' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - sessionKey: string; - limit?: number; - maxChars?: number; - timeoutMs?: number; - }>(req); - const params: Record = { - sessionKey: body.sessionKey, - ...(typeof body.limit === 'number' ? { limit: body.limit } : {}), - ...(typeof body.maxChars === 'number' ? { maxChars: body.maxChars } : {}), - }; - const result = await runGatewayRpc>( - ctx, - 'chat.history', - params, - typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined, - ); - sendJson(res, 200, { success: true, result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/chat/send' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - sessionKey: string; - message: string; - deliver?: boolean; - idempotencyKey: string; - }>(req); - const result = await runGatewayRpc<{ runId?: string }>( - ctx, - 'chat.send', - { - sessionKey: body.sessionKey, - message: body.message, - deliver: body.deliver ?? false, - idempotencyKey: body.idempotencyKey, - }, - 120000, - ); - sendJson(res, 200, { success: true, result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/chat/abort' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ sessionKey: string }>(req); - const result = await runGatewayRpc>(ctx, 'chat.abort', { sessionKey: body.sessionKey }); - sendJson(res, 200, { success: true, result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - sessionKey: string; - message: string; - deliver?: boolean; - idempotencyKey: string; - media?: Array<{ filePath: string; mimeType: string; fileName: string }>; - }>(req); - const VISION_MIME_TYPES = new Set([ - 'image/png', 'image/jpeg', 'image/bmp', 'image/webp', - ]); - const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = []; - const fileReferences: string[] = []; - if (body.media && body.media.length > 0) { - const fsP = await import('node:fs/promises'); - for (const m of body.media) { - fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`); - if (VISION_MIME_TYPES.has(m.mimeType)) { - const fileBuffer = await fsP.readFile(m.filePath); - imageAttachments.push({ - content: fileBuffer.toString('base64'), - mimeType: m.mimeType, - fileName: m.fileName, - }); - } - } - } - - const message = fileReferences.length > 0 - ? [body.message, ...fileReferences].filter(Boolean).join('\n') - : body.message; - const rpcParams: Record = { - sessionKey: body.sessionKey, - message, - deliver: body.deliver ?? false, - idempotencyKey: body.idempotencyKey, - }; - if (imageAttachments.length > 0) { - rpcParams.attachments = imageAttachments; - } - const result = await runGatewayRpc(ctx, 'chat.send', rpcParams, 120000); - sendJson(res, 200, { success: true, result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/logs.ts b/electron/api/routes/logs.ts deleted file mode 100644 index 6cbc039c..00000000 --- a/electron/api/routes/logs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { logger } from '../../utils/logger'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; - -export async function handleLogRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/logs' && req.method === 'GET') { - const tailLines = Number(url.searchParams.get('tailLines') || '100'); - sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) }); - return true; - } - - if (url.pathname === '/api/logs/dir' && req.method === 'GET') { - sendJson(res, 200, { dir: logger.getLogDir() }); - return true; - } - - if (url.pathname === '/api/logs/files' && req.method === 'GET') { - sendJson(res, 200, { files: await logger.listLogFiles() }); - return true; - } - - return false; -} diff --git a/electron/api/routes/media.ts b/electron/api/routes/media.ts deleted file mode 100644 index 255460d4..00000000 --- a/electron/api/routes/media.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import type { HostApiContext } from '../context'; -import { - CLAWX_OPENAI_IMAGE_DEFAULT_MODEL, - CLAWX_OPENAI_IMAGE_PROVIDER_KEY, -} from '../../utils/openclaw-image-relay-constants'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { - applyOpenAiImageRelaySettings, - getImageGenerationSettingsSnapshot, - listImageGenerationProvidersFromRuntime, - runImageGenerationTest, - setImageGenerationConfig, - type ImageGenerationModelConfig, -} from '../../utils/openclaw-image-generation'; - -export async function handleMediaRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/media/image-generation' && req.method === 'GET') { - try { - sendJson(res, 200, { success: true, ...(await getImageGenerationSettingsSnapshot()) }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/media/image-generation' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ - timeoutMs?: number | null; - openAiRelayEnabled?: boolean; - openAiRelayBaseUrl?: string | null; - openAiRelayModel?: string | null; - openAiRelayApiKey?: string; - }>(req); - - const current = await getImageGenerationSettingsSnapshot(); - const normalizeRelayModel = (value: unknown): string => { - const raw = typeof value === 'string' && value.trim() - ? value.trim() - : (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL); - const slash = raw.indexOf('/'); - return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL; - }; - const relayModel = normalizeRelayModel(body.openAiRelayModel); - let nextPrimary = current.config.primary; - if (body.openAiRelayEnabled === true) { - nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`; - } else if (body.openAiRelayEnabled === false) { - nextPrimary = null; - } - const next: ImageGenerationModelConfig = { - primary: nextPrimary, - fallbacks: [], - timeoutMs: body.timeoutMs !== undefined - ? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null) - : current.config.timeoutMs, - }; - - if (typeof body.openAiRelayEnabled === 'boolean') { - await applyOpenAiImageRelaySettings({ - enabled: body.openAiRelayEnabled, - baseUrl: body.openAiRelayBaseUrl, - apiKey: body.openAiRelayApiKey, - model: relayModel, - }); - } - - const config = await setImageGenerationConfig(next); - sendJson(res, 200, { - success: true, - config, - ...(await getImageGenerationSettingsSnapshot()), - }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/media/image-generation/providers' && req.method === 'GET') { - try { - const providers = await listImageGenerationProvidersFromRuntime(); - sendJson(res, 200, { success: true, providers }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/media/image-generation/test' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - agentId?: string; - prompt?: string; - model?: string; - }>(req); - const result = await runImageGenerationTest(body); - sendJson(res, result.success ? 200 : 500, { success: result.success, ...result }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts deleted file mode 100644 index 99c30749..00000000 --- a/electron/api/routes/providers.ts +++ /dev/null @@ -1,455 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { - type ProviderConfig, -} from '../../utils/secure-storage'; -import { - getProviderConfig, -} from '../../utils/provider-registry'; -import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth'; -import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { - syncDefaultProviderToRuntime, - syncDeletedProviderApiKeyToRuntime, - syncDeletedProviderToRuntime, - syncProviderApiKeyToRuntime, - syncSavedProviderToRuntime, - syncUpdatedProviderToRuntime, -} from '../../services/providers/provider-runtime-sync'; -import { validateApiKeyWithProvider } from '../../services/providers/provider-validation'; -import { getProviderService } from '../../services/providers/provider-service'; -import { providerAccountToConfig } from '../../services/providers/provider-store'; -import type { ProviderAccount } from '../../shared/providers/types'; -import { logger } from '../../utils/logger'; - -const legacyProviderRoutesWarned = new Set(); - -function hasObjectChanges>( - existing: T, - patch: Partial | undefined, -): boolean { - if (!patch) return false; - const keys = Object.keys(patch) as Array; - if (keys.length === 0) return false; - return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key])); -} - -export async function handleProviderRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - const providerService = getProviderService(); - const logLegacyProviderRoute = (route: string): void => { - if (legacyProviderRoutesWarned.has(route)) return; - legacyProviderRoutesWarned.add(route); - logger.warn( - `[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`, - ); - }; - - if (url.pathname === '/api/provider-vendors' && req.method === 'GET') { - sendJson(res, 200, await providerService.listVendors()); - return true; - } - - if (url.pathname === '/api/provider-accounts' && req.method === 'GET') { - sendJson(res, 200, await providerService.listAccounts()); - return true; - } - - if (url.pathname === '/api/provider-accounts' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req); - const account = await providerService.createAccount(body.account, body.apiKey); - await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager); - sendJson(res, 200, { success: true, account }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') { - sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null }); - return true; - } - - if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ accountId: string }>(req); - const currentDefault = await providerService.getDefaultAccountId(); - if (currentDefault === body.accountId) { - sendJson(res, 200, { success: true, noChange: true }); - return true; - } - await providerService.setDefaultAccount(body.accountId); - await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - // ── New account-based companion endpoints ───────────────────────── - // Exposed alongside the existing /api/provider-accounts surface so the - // renderer (and any future external client) can drop the legacy - // /api/providers paths without losing functionality. Specific paths - // must be matched BEFORE the generic /api/provider-accounts/:id rule - // below to avoid being captured as account ids. - - if (url.pathname === '/api/provider-accounts/key-info' && req.method === 'GET') { - sendJson(res, 200, await providerService.listAccountsKeyInfo()); - return true; - } - - if (url.pathname === '/api/provider-accounts/validate' && req.method === 'POST') { - try { - // Accept legacy `providerId` as a fallback so external clients that - // migrate by URL alone (without renaming their request body) continue - // to work. The renderer always sends all three fields; older callers - // may send only `providerId`. - const body = await parseJsonBody<{ - accountId?: string; - vendorId?: string; - providerId?: string; - apiKey: string; - options?: { baseUrl?: string; apiProtocol?: string }; - }>(req); - const accountId = body.accountId || body.vendorId || body.providerId || ''; - const account = accountId ? await providerService.getAccount(accountId) : null; - const providerType = account?.vendorId || body.vendorId || body.providerId || accountId; - const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; - const resolvedBaseUrl = body.options?.baseUrl || account?.baseUrl || registryBaseUrl; - const resolvedProtocol = body.options?.apiProtocol || account?.apiProtocol; - sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { - baseUrl: resolvedBaseUrl, - apiProtocol: resolvedProtocol, - })); - } catch (error) { - sendJson(res, 500, { valid: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/provider-accounts/oauth/start' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - provider: OAuthProviderType | BrowserOAuthProviderType; - region?: 'global' | 'cn'; - accountId?: string; - label?: string; - }>(req); - if (body.provider === 'openai') { - await browserOAuthManager.startFlow(body.provider, { - accountId: body.accountId, - label: body.label, - }); - } else { - await deviceOAuthManager.startFlow(body.provider, body.region, { - accountId: body.accountId, - label: body.label, - }); - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/provider-accounts/oauth/cancel' && req.method === 'POST') { - try { - await deviceOAuthManager.stopFlow(); - await browserOAuthManager.stopFlow(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/provider-accounts/oauth/submit' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ code: string }>(req); - const accepted = browserOAuthManager.submitManualCode(body.code || ''); - if (!accepted) { - sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' }); - return true; - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') { - const remainder = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); - if (remainder.endsWith('/api-key')) { - const accountId = remainder.slice(0, -'/api-key'.length); - sendJson(res, 200, { apiKey: await providerService.getAccountApiKey(accountId) }); - return true; - } - if (remainder.endsWith('/has-api-key')) { - const accountId = remainder.slice(0, -'/has-api-key'.length); - sendJson(res, 200, { hasKey: await providerService.hasAccountApiKey(accountId) }); - return true; - } - sendJson(res, 200, await providerService.getAccount(remainder)); - return true; - } - - if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') { - const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); - try { - const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); - const existing = await providerService.getAccount(accountId); - if (!existing) { - sendJson(res, 404, { success: false, error: 'Provider account not found' }); - return true; - } - const hasPatchChanges = hasObjectChanges(existing as unknown as Record, body.updates); - if (!hasPatchChanges && body.apiKey === undefined) { - sendJson(res, 200, { success: true, noChange: true, account: existing }); - return true; - } - const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey); - await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager); - sendJson(res, 200, { success: true, account: nextAccount }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') { - const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); - try { - const existing = await providerService.getAccount(accountId); - const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai' - ? 'openai-codex' - : undefined; - if (url.searchParams.get('apiKeyOnly') === '1') { - await providerService._deleteProviderApiKeyInternal(accountId); - await syncDeletedProviderApiKeyToRuntime( - existing ? providerAccountToConfig(existing) : null, - accountId, - runtimeProviderKey, - ); - sendJson(res, 200, { success: true }); - return true; - } - await providerService.deleteAccount(accountId); - await syncDeletedProviderToRuntime( - existing ? providerAccountToConfig(existing) : null, - accountId, - ctx.gatewayManager, - runtimeProviderKey, - ); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers' && req.method === 'GET') { - logLegacyProviderRoute('GET /api/providers'); - sendJson(res, 200, await providerService._listProvidersWithKeyInfoInternal()); - return true; - } - - if (url.pathname === '/api/providers/default' && req.method === 'GET') { - logLegacyProviderRoute('GET /api/providers/default'); - sendJson(res, 200, { providerId: await providerService._getDefaultProviderInternal() ?? null }); - return true; - } - - if (url.pathname === '/api/providers/default' && req.method === 'PUT') { - logLegacyProviderRoute('PUT /api/providers/default'); - try { - const body = await parseJsonBody<{ providerId: string }>(req); - const currentDefault = await providerService._getDefaultProviderInternal(); - if (currentDefault === body.providerId) { - sendJson(res, 200, { success: true, noChange: true }); - return true; - } - await providerService._setDefaultProviderInternal(body.providerId); - await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers/validate' && req.method === 'POST') { - logLegacyProviderRoute('POST /api/providers/validate'); - try { - const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req); - const provider = await providerService._getProviderInternal(body.providerId); - const providerType = provider?.type || body.providerId; - const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; - const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl; - const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol; - sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol })); - } catch (error) { - sendJson(res, 500, { valid: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') { - logLegacyProviderRoute('POST /api/providers/oauth/start'); - try { - const body = await parseJsonBody<{ - provider: OAuthProviderType | BrowserOAuthProviderType; - region?: 'global' | 'cn'; - accountId?: string; - label?: string; - }>(req); - if (body.provider === 'openai') { - await browserOAuthManager.startFlow(body.provider, { - accountId: body.accountId, - label: body.label, - }); - } else { - await deviceOAuthManager.startFlow(body.provider, body.region, { - accountId: body.accountId, - label: body.label, - }); - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') { - logLegacyProviderRoute('POST /api/providers/oauth/cancel'); - try { - await deviceOAuthManager.stopFlow(); - await browserOAuthManager.stopFlow(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') { - logLegacyProviderRoute('POST /api/providers/oauth/submit'); - try { - const body = await parseJsonBody<{ code: string }>(req); - const accepted = browserOAuthManager.submitManualCode(body.code || ''); - if (!accepted) { - sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' }); - return true; - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/providers' && req.method === 'POST') { - logLegacyProviderRoute('POST /api/providers'); - try { - const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req); - const config = body.config; - await providerService._saveProviderInternal(config); - if (body.apiKey !== undefined) { - const trimmedKey = body.apiKey.trim(); - if (trimmedKey) { - await providerService._setProviderApiKeyInternal(config.id, trimmedKey); - await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); - } - } - await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') { - logLegacyProviderRoute('GET /api/providers/:id'); - 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 providerService._getProviderApiKeyInternal(actualId) }); - return true; - } - if (providerId.endsWith('/has-api-key')) { - const actualId = providerId.slice(0, -('/has-api-key'.length)); - sendJson(res, 200, { hasKey: await providerService._hasProviderApiKeyInternal(actualId) }); - return true; - } - sendJson(res, 200, await providerService._getProviderInternal(providerId)); - return true; - } - - if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') { - logLegacyProviderRoute('PUT /api/providers/:id'); - const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); - try { - const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); - const existing = await providerService._getProviderInternal(providerId); - if (!existing) { - sendJson(res, 404, { success: false, error: 'Provider not found' }); - return true; - } - const hasPatchChanges = hasObjectChanges(existing as unknown as Record, body.updates); - if (!hasPatchChanges && body.apiKey === undefined) { - sendJson(res, 200, { success: true, noChange: true }); - return true; - } - const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() }; - await providerService._saveProviderInternal(nextConfig); - if (body.apiKey !== undefined) { - const trimmedKey = body.apiKey.trim(); - if (trimmedKey) { - await providerService._setProviderApiKeyInternal(providerId, trimmedKey); - await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); - } else { - await providerService._deleteProviderApiKeyInternal(providerId); - await syncDeletedProviderApiKeyToRuntime(existing, providerId); - } - } - await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') { - logLegacyProviderRoute('DELETE /api/providers/:id'); - const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); - try { - const existing = await providerService._getProviderInternal(providerId); - if (url.searchParams.get('apiKeyOnly') === '1') { - await providerService._deleteProviderApiKeyInternal(providerId); - await syncDeletedProviderApiKeyToRuntime(existing, providerId); - sendJson(res, 200, { success: true }); - return true; - } - await providerService._deleteProviderInternal(providerId); - await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/settings.ts b/electron/api/routes/settings.ts deleted file mode 100644 index c3e948e7..00000000 --- a/electron/api/routes/settings.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { applyProxySettings } from '../../main/proxy'; -import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup'; -import { syncProxyConfigToOpenClaw } from '../../utils/openclaw-proxy'; -import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; - -async function handleProxySettingsChange(ctx: HostApiContext): Promise { - const settings = await getAllSettings(); - await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false }); - await applyProxySettings(settings); - if (ctx.gatewayManager.getStatus().state === 'running') { - await ctx.gatewayManager.restart(); - } -} - -function patchTouchesProxy(patch: Partial): boolean { - return Object.keys(patch).some((key) => ( - key === 'proxyEnabled' || - key === 'proxyServer' || - key === 'proxyHttpServer' || - key === 'proxyHttpsServer' || - key === 'proxyAllServer' || - key === 'proxyBypassRules' - )); -} - -function patchTouchesLaunchAtStartup(patch: Partial): boolean { - return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup'); -} - -export async function handleSettingsRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/settings' && req.method === 'GET') { - sendJson(res, 200, await getAllSettings()); - return true; - } - - if (url.pathname === '/api/settings' && req.method === 'PUT') { - try { - const patch = await parseJsonBody>(req); - const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; - for (const [key, value] of entries) { - await setSetting(key, value); - } - if (patchTouchesProxy(patch)) { - await handleProxySettingsChange(ctx); - } - if (patchTouchesLaunchAtStartup(patch)) { - await syncLaunchAtStartupSettingFromStore(); - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') { - const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings; - try { - sendJson(res, 200, { value: await getSetting(key) }); - } catch (error) { - sendJson(res, 404, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') { - const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings; - try { - const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req); - await setSetting(key, body.value); - if ( - key === 'proxyEnabled' || - key === 'proxyServer' || - key === 'proxyHttpServer' || - key === 'proxyHttpsServer' || - key === 'proxyAllServer' || - key === 'proxyBypassRules' - ) { - await handleProxySettingsChange(ctx); - } - if (key === 'launchAtStartup') { - await syncLaunchAtStartupSettingFromStore(); - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/settings/reset' && req.method === 'POST') { - try { - await resetSettings(); - await handleProxySettingsChange(ctx); - await syncLaunchAtStartupSettingFromStore(); - sendJson(res, 200, { success: true, settings: await getAllSettings() }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts deleted file mode 100644 index dd193fe5..00000000 --- a/electron/api/routes/skills.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { getAllSkillConfigs, updateSkillConfig, updateSkillConfigs } from '../../utils/skill-config'; -import { collectQuickAccessSkills, filterEnabledQuickAccessSkills, type QuickAccessRuntimeSkillStatus } from '../../utils/skill-quick-access'; -import { listLocalSkills } from '../../services/skills/local-skill-service'; -import type { MarketplaceInstallParams, MarketplaceSearchParams, MarketplaceUninstallParams } from '../../gateway/clawhub'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; - -async function handleMarketplaceCapability(res: ServerResponse, ctx: HostApiContext): Promise { - sendJson(res, 200, { - success: true, - capability: await ctx.clawHubService.getMarketplaceCapability(), - }); -} - -async function handleMarketplaceSearch(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { - const body = await parseJsonBody(req); - sendJson(res, 200, { - success: true, - results: await ctx.clawHubService.search(body), - }); -} - -async function handleMarketplaceInstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { - const body = await parseJsonBody(req); - await ctx.clawHubService.install(body); - sendJson(res, 200, { success: true }); -} - -async function handleMarketplaceUninstall(req: IncomingMessage, res: ServerResponse, ctx: HostApiContext): Promise { - const body = await parseJsonBody(req); - await ctx.clawHubService.uninstall(body); - sendJson(res, 200, { success: true }); -} - -async function handleMarketplaceList(res: ServerResponse, ctx: HostApiContext): Promise { - sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() }); -} - -export async function handleSkillRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/skills/configs' && req.method === 'GET') { - sendJson(res, 200, await getAllSkillConfigs()); - return true; - } - - if (url.pathname === '/api/skills/config' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ - skillKey: string; - enabled?: boolean; - apiKey?: string; - env?: Record; - }>(req); - sendJson(res, 200, await updateSkillConfig(body.skillKey, { - enabled: body.enabled, - apiKey: body.apiKey, - env: body.env, - })); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/configs' && req.method === 'PATCH') { - try { - const body = await parseJsonBody<{ - updates?: Array<{ - skillKey: string; - enabled?: boolean; - apiKey?: string; - env?: Record; - }>; - }>(req); - sendJson(res, 200, await updateSkillConfigs(body.updates || [])); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/local' && req.method === 'GET') { - try { - sendJson(res, 200, { - success: true, - skills: await listLocalSkills(), - }); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/quick-access' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ - workspace?: string; - }>(req); - const [scannedSkills, configs] = await Promise.all([ - collectQuickAccessSkills({ - workspace: body.workspace, - }), - getAllSkillConfigs(), - ]); - let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined; - if (ctx.gatewayManager.getStatus().state === 'running') { - try { - const runtimeStatus = await ctx.gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status'); - runtimeSkills = runtimeStatus.skills || []; - } catch { - runtimeSkills = undefined; - } - } - sendJson(res, 200, { - success: true, - skills: filterEnabledQuickAccessSkills(scannedSkills, runtimeSkills, configs), - }); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/marketplace/capability' && req.method === 'GET') { - try { - await handleMarketplaceCapability(res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/marketplace/search' && req.method === 'POST') { - try { - await handleMarketplaceSearch(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/marketplace/install' && req.method === 'POST') { - try { - await handleMarketplaceInstall(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/marketplace/uninstall' && req.method === 'POST') { - try { - await handleMarketplaceUninstall(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/skills/marketplace/list' && req.method === 'GET') { - try { - await handleMarketplaceList(res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') { - try { - await handleMarketplaceCapability(res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/search' && req.method === 'POST') { - try { - await handleMarketplaceSearch(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/install' && req.method === 'POST') { - try { - await handleMarketplaceInstall(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') { - try { - await handleMarketplaceUninstall(req, res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/list' && req.method === 'GET') { - try { - await handleMarketplaceList(res, ctx); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req); - await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req); - await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/api/routes/usage.ts b/electron/api/routes/usage.ts deleted file mode 100644 index 9c97da75..00000000 --- a/electron/api/routes/usage.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { getRecentTokenUsageHistory } from '../../utils/token-usage'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; - -export async function handleUsageRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') { - const rawLimit = url.searchParams.get('limit'); - let limit: number | undefined; - if (rawLimit != null && rawLimit.trim() !== '') { - const parsedLimit = Number(rawLimit); - if (Number.isFinite(parsedLimit)) { - limit = Math.max(Math.floor(parsedLimit), 1); - } - } - sendJson(res, 200, await getRecentTokenUsageHistory(limit)); - return true; - } - - return false; -} diff --git a/electron/api/server.ts b/electron/api/server.ts deleted file mode 100644 index ebde397b..00000000 --- a/electron/api/server.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { getPort } from '../utils/config'; -import { logger } from '../utils/logger'; -import { extensionRegistry } from '../extensions/registry'; -import type { HostApiContext } from './context'; -import { handleAppRoutes } from './routes/app'; -import { handleGatewayRoutes } from './routes/gateway'; -import { handleSettingsRoutes } from './routes/settings'; -import { handleProviderRoutes } from './routes/providers'; -import { handleAgentRoutes } from './routes/agents'; -import { handleChannelRoutes } from './routes/channels'; -import { handleLogRoutes } from './routes/logs'; -import { handleUsageRoutes } from './routes/usage'; -import { handleSkillRoutes } from './routes/skills'; -import { handleFileRoutes } from './routes/files'; -import { handleSessionRoutes } from './routes/sessions'; -import { handleCronRoutes } from './routes/cron'; -import { handleDiagnosticsRoutes } from './routes/diagnostics'; -import { handleMediaRoutes } from './routes/media'; -import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils'; - -type RouteHandler = ( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -) => Promise; - -const coreRouteHandlers: RouteHandler[] = [ - handleAppRoutes, - handleGatewayRoutes, - handleSettingsRoutes, - handleProviderRoutes, - handleAgentRoutes, - handleChannelRoutes, - handleSkillRoutes, - handleFileRoutes, - handleSessionRoutes, - handleCronRoutes, - handleDiagnosticsRoutes, - handleLogRoutes, - handleUsageRoutes, - handleMediaRoutes, -]; - -function buildRouteHandlers(): RouteHandler[] { - const extensionHandlers = extensionRegistry.getRouteHandlers(); - return [...coreRouteHandlers, ...extensionHandlers]; -} - -/** - * Per-session secret token used to authenticate Host API requests. - * Generated once at server start and shared with the renderer via IPC. - * This prevents cross-origin attackers from reading sensitive data even - * if they can reach 127.0.0.1:13210 (the CORS wildcard alone is not - * sufficient because browsers attach the Origin header but not a secret). - */ -let hostApiToken: string = ''; - -/** Retrieve the current Host API auth token (for use by IPC proxy). */ -export function getHostApiToken(): string { - return hostApiToken; -} - -export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HOST_API')): Server { - // Generate a cryptographically random token for this session. - hostApiToken = randomBytes(32).toString('hex'); - - const server = createServer(async (req, res) => { - try { - const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`); - // ── CORS headers ───────────────────────────────────────── - // Set origin-aware CORS headers early so every response - // (including error responses) carries them consistently. - const origin = req.headers.origin; - setCorsHeaders(res, origin); - - // CORS preflight — respond before auth so browsers can negotiate. - if (req.method === 'OPTIONS') { - res.statusCode = 204; - res.end(); - return; - } - - // ── Auth gate ────────────────────────────────────────────── - // Every non-preflight request must carry a valid Bearer token. - // Accept via Authorization header (preferred) or ?token= query - // parameter (for EventSource which cannot set custom headers). - const authHeader = req.headers.authorization || ''; - const bearerToken = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : (requestUrl.searchParams.get('token') || ''); - if (bearerToken !== hostApiToken) { - sendJson(res, 401, { success: false, error: 'Unauthorized' }); - return; - } - - // ── Content-Type gate (anti-CSRF) ────────────────────────── - // Mutation requests must use application/json to force a CORS - // preflight, preventing "simple request" CSRF attacks. - if (!requireJsonContentType(req)) { - sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' }); - return; - } - - const routeHandlers = buildRouteHandlers(); - for (const handler of routeHandlers) { - if (await handler(req, res, requestUrl, ctx)) { - return; - } - } - sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` }); - } catch (error) { - logger.error('Host API request failed:', error); - sendJson(res, 500, { success: false, error: String(error) }); - } - }); - - server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EACCES' || error.code === 'EADDRINUSE') { - logger.error( - `Host API server failed to bind port ${port}: ${error.message}. ` + - 'On Windows this is often caused by Hyper-V reserving the port range. ' + - `Set CLAWX_PORT_CLAWX_HOST_API env var to override the default port.`, - ); - } else { - logger.error('Host API server error:', error); - } - }); - - server.listen(port, '127.0.0.1', () => { - logger.info(`Host API server listening on http://127.0.0.1:${port}`); - }); - - return server; -} diff --git a/electron/extensions/builtin/diagnostics.ts b/electron/extensions/builtin/diagnostics.ts index 82a62dff..c17ef9af 100644 --- a/electron/extensions/builtin/diagnostics.ts +++ b/electron/extensions/builtin/diagnostics.ts @@ -1,22 +1,22 @@ +import { createDiagnosticsApi } from '../../services/diagnostics-api'; import type { Extension, ExtensionContext, - HostApiRouteExtension, - RouteHandler, + HostApiProviderExtension, } from '../types'; -class DiagnosticsExtension implements HostApiRouteExtension { +class DiagnosticsExtension implements HostApiProviderExtension { readonly id = 'builtin/diagnostics'; setup(_ctx: ExtensionContext): void { - // Diagnostics routes are stateless; no setup needed. + // Diagnostics are exposed through host IPC contributions. } - getRouteHandler(): RouteHandler { - return async (req, res, url, ctx) => { - const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics'); - return handleDiagnosticsRoutes(req, res, url, ctx); - }; + getHostApiContributions(ctx: ExtensionContext) { + return [{ + module: 'diagnostics', + actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }), + }]; } } diff --git a/electron/extensions/builtin/index.ts b/electron/extensions/builtin/index.ts index 8a81abdf..d1caa02a 100644 --- a/electron/extensions/builtin/index.ts +++ b/electron/extensions/builtin/index.ts @@ -1,6 +1,8 @@ import { registerBuiltinExtension } from '../loader'; +import { createClawHubMarketplaceExtension } from './clawhub-marketplace'; import { createDiagnosticsExtension } from './diagnostics'; export function registerAllBuiltinExtensions(): void { + registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension); registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension); } diff --git a/electron/extensions/index.ts b/electron/extensions/index.ts index d34d3a96..795e1593 100644 --- a/electron/extensions/index.ts +++ b/electron/extensions/index.ts @@ -3,15 +3,14 @@ export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader'; export type { Extension, ExtensionContext, - HostApiRouteExtension, + HostApiProviderExtension, MarketplaceProviderExtension, MarketplaceCapability, AuthProviderExtension, AuthStatus, - RouteHandler, } from './types'; export { - isHostApiRouteExtension, + isHostApiProviderExtension, isMarketplaceProviderExtension, isAuthProviderExtension, } from './types'; diff --git a/electron/extensions/registry.ts b/electron/extensions/registry.ts index 05b39978..5d6bfe0f 100644 --- a/electron/extensions/registry.ts +++ b/electron/extensions/registry.ts @@ -2,24 +2,24 @@ import { logger } from '../utils/logger'; import type { Extension, ExtensionContext, - HostApiRouteExtension, MarketplaceProviderExtension, - RouteHandler, } from './types'; import { - isHostApiRouteExtension, + isHostApiProviderExtension, isMarketplaceProviderExtension, } from './types'; class ExtensionRegistry { private extensions = new Map(); private ctx: ExtensionContext | null = null; + private hostApiUnregisters = new Map void>(); async initialize(ctx: ExtensionContext): Promise { this.ctx = ctx; for (const ext of this.extensions.values()) { try { await ext.setup(ctx); + this.registerHostApiContributions(ext, ctx); logger.info(`[extensions] Extension "${ext.id}" initialized`); } catch (err) { logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err); @@ -36,9 +36,15 @@ class ExtensionRegistry { logger.debug(`[extensions] Registered extension "${extension.id}"`); if (this.ctx) { - void Promise.resolve(extension.setup(this.ctx)).catch((err) => { - logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err); - }); + void Promise.resolve(extension.setup(this.ctx)) + .then(() => { + if (this.ctx) { + this.registerHostApiContributions(extension, this.ctx); + } + }) + .catch((err) => { + logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err); + }); } } @@ -50,12 +56,6 @@ class ExtensionRegistry { return [...this.extensions.values()]; } - getRouteHandlers(): RouteHandler[] { - return this.getAll() - .filter(isHostApiRouteExtension) - .map((ext: HostApiRouteExtension) => ext.getRouteHandler()); - } - getMarketplaceProvider(): MarketplaceProviderExtension | undefined { return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined; } @@ -63,6 +63,8 @@ class ExtensionRegistry { async teardownAll(): Promise { for (const ext of this.extensions.values()) { try { + this.hostApiUnregisters.get(ext.id)?.(); + this.hostApiUnregisters.delete(ext.id); await ext.teardown?.(); } catch (err) { logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err); @@ -71,6 +73,21 @@ class ExtensionRegistry { this.extensions.clear(); this.ctx = null; } + + private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void { + this.hostApiUnregisters.get(ext.id)?.(); + this.hostApiUnregisters.delete(ext.id); + + if (!isHostApiProviderExtension(ext)) { + return; + } + + const contributions = ext.getHostApiContributions(ctx); + if (contributions.length === 0) { + return; + } + this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions)); + } } export const extensionRegistry = new ExtensionRegistry(); diff --git a/electron/extensions/types.ts b/electron/extensions/types.ts index 01bd5f84..3505a7aa 100644 --- a/electron/extensions/types.ts +++ b/electron/extensions/types.ts @@ -1,8 +1,6 @@ -import type { IncomingMessage, ServerResponse } from 'http'; import type { BrowserWindow } from 'electron'; import type { GatewayManager } from '../gateway/manager'; -import type { HostEventBus } from '../api/event-bus'; -import type { HostApiContext } from '../api/context'; +import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract'; import type { MarketplaceSearchParams, MarketplaceInstallParams, @@ -12,17 +10,10 @@ import type { ClawHubSkillResult, } from '../gateway/clawhub'; -export type RouteHandler = ( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -) => Promise; - export interface ExtensionContext { gatewayManager: GatewayManager; - eventBus: HostEventBus; getMainWindow: () => BrowserWindow | null; + hostApi: HostApiContributionRegistrar; } export interface Extension { @@ -31,10 +22,6 @@ export interface Extension { teardown?(): void | Promise; } -export interface HostApiRouteExtension extends Extension { - getRouteHandler(): RouteHandler; -} - export interface MarketplaceCapability { mode: string; canSearch: boolean; @@ -48,6 +35,10 @@ export interface MarketplaceProviderExtension extends Extension { install(params: MarketplaceInstallParams): Promise; } +export interface HostApiProviderExtension extends Extension { + getHostApiContributions(ctx: ExtensionContext): HostApiContribution[]; +} + export type LegacyMarketplaceSearchParams = ClawHubSearchParams; export type LegacyMarketplaceInstallParams = ClawHubInstallParams; export type LegacyMarketplaceSkillResult = ClawHubSkillResult; @@ -63,14 +54,15 @@ export interface AuthProviderExtension extends Extension { onStartup?(mainWindow: BrowserWindow): Promise; } -export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension { - return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function'; -} - export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension { return 'getCapability' in ext && 'search' in ext && 'install' in ext; } +export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension { + return 'getHostApiContributions' in ext + && typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function'; +} + export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension { return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function'; } diff --git a/electron/gateway/capability-monitor.ts b/electron/gateway/capability-monitor.ts index 0d112ac2..d9bcb73b 100644 --- a/electron/gateway/capability-monitor.ts +++ b/electron/gateway/capability-monitor.ts @@ -3,6 +3,7 @@ import type { GatewayHealthSummary, GatewayStatus, } from './manager'; +import type { GatewayRuntimePayload } from '@shared/types/gateway'; export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory'; @@ -11,7 +12,7 @@ export interface GatewayCapabilityProbe { checkedAt?: number; durationMs?: number; error?: string; - payload?: unknown; + payload?: GatewayRuntimePayload; } export interface GatewayCoreProbe { @@ -41,7 +42,7 @@ function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function capabilityFromPayload(payload: unknown, checkedAt = Date.now()): GatewayCapabilityProbe { +function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe { return { state: 'healthy', checkedAt, @@ -67,15 +68,15 @@ export class GatewayCapabilityMonitor { private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY; private lastCoreProbe: GatewayCoreProbe | undefined; - recordOpenClawHealth(payload: unknown): void { + recordOpenClawHealth(payload: GatewayRuntimePayload): void { this.openclawHealth = capabilityFromPayload(payload); } - recordOpenClawStatus(payload: unknown): void { + recordOpenClawStatus(payload: GatewayRuntimePayload): void { this.openclawStatus = capabilityFromPayload(payload); } - recordPresence(payload: unknown): void { + recordPresence(payload: GatewayRuntimePayload): void { this.presence = capabilityFromPayload(payload); } @@ -83,7 +84,7 @@ export class GatewayCapabilityMonitor { this.lastCoreProbe = probe; } - recordCapabilitySuccess(name: GatewayCapabilityName, payload: unknown, durationMs?: number): void { + recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void { const probe: GatewayCapabilityProbe = { state: 'healthy', checkedAt: Date.now(), diff --git a/electron/gateway/chat-runtime-events.ts b/electron/gateway/chat-runtime-events.ts index 4d883963..03cc6cb3 100644 --- a/electron/gateway/chat-runtime-events.ts +++ b/electron/gateway/chat-runtime-events.ts @@ -12,10 +12,17 @@ function readNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function withBase( - type: ChatRuntimeEvent['type'], +type ChatRuntimeEventType = ChatRuntimeEvent['type']; +type ChatRuntimeEventFor = Extract; +type ChatRuntimeEventBaseFor = Pick< + ChatRuntimeEventFor, + 'type' | 'runId' | 'sessionKey' | 'seq' | 'ts' +>; + +function withBase( + type: T, payload: Record, -): Pick | null { +): ChatRuntimeEventBaseFor | null { const runId = readString(payload.runId); if (!runId) return null; return { @@ -24,7 +31,7 @@ function withBase( sessionKey: readString(payload.sessionKey), seq: readNumber(payload.seq), ts: readNumber(payload.ts), - }; + } as ChatRuntimeEventBaseFor; } export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null { diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 0a9a99f8..be1011a4 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -183,7 +183,7 @@ export class ClawHubService { baseDir: skillDir, }; })); - return items.filter((item): item is ClawHubInstalledSkillResult => item !== null); + return items.filter((item): item is NonNullable => item !== null); } catch (error) { console.error('ClawHub list error:', error); return []; diff --git a/electron/gateway/event-dispatch.ts b/electron/gateway/event-dispatch.ts index 379c828f..f652543a 100644 --- a/electron/gateway/event-dispatch.ts +++ b/electron/gateway/event-dispatch.ts @@ -1,6 +1,11 @@ import { GatewayEventType, type JsonRpcNotification } from './protocol'; import { logger } from '../utils/logger'; import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events'; +import type { + GatewayChannelStatusEvent, + GatewayChatMessageEvent, + GatewayRuntimePayload, +} from '@shared/host-events/contract'; type GatewayEventEmitter = { emit: (event: string, payload: unknown) => boolean; @@ -27,17 +32,17 @@ export function dispatchProtocolEvent( } case 'channel.status': case 'channel.status_changed': - emitter.emit('channel:status', payload as { channelId: string; status: string }); + emitter.emit('channel:status', payload as GatewayChannelStatusEvent); break; case 'gateway.ready': case 'ready': emitter.emit('gateway:ready', payload); break; case 'health': - emitter.emit('gateway:health', payload); + emitter.emit('gateway:health', payload as GatewayRuntimePayload); break; case 'presence': - emitter.emit('gateway:presence', payload); + emitter.emit('gateway:presence', payload as GatewayRuntimePayload); break; default: emitter.emit('notification', { method: event, params: payload }); @@ -57,10 +62,10 @@ export function dispatchJsonRpcNotification( } switch (notification.method) { case GatewayEventType.CHANNEL_STATUS_CHANGED: - emitter.emit('channel:status', notification.params as { channelId: string; status: string }); + emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent); break; case GatewayEventType.MESSAGE_RECEIVED: - emitter.emit('chat:message', notification.params as { message: unknown }); + emitter.emit('chat:message', notification.params as GatewayChatMessageEvent); break; case GatewayEventType.ERROR: { const errorData = notification.params as { message?: string }; diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index af23f07a..6d58c739 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -56,6 +56,17 @@ import { type GatewayCapabilityName, type GatewayCapabilitySnapshot, } from './capability-monitor'; +import { + isGatewayWsTraceEnabled, + redactGatewayFrameForTrace, + summarizeGatewayFrameForTrace, +} from './ws-trace'; +import type { + GatewayChannelStatusEvent, + GatewayChatMessageEvent, + GatewayRuntimePayload, +} from '@shared/host-events/contract'; +import type { ChatRuntimeEvent } from '@shared/chat-runtime-events'; export interface GatewayStatus { state: GatewayLifecycleState; @@ -134,11 +145,11 @@ export interface GatewayManagerEvents { notification: (notification: JsonRpcNotification) => void; exit: (code: number | null) => void; error: (error: Error) => void; - 'gateway:health': (data: unknown) => void; - 'gateway:presence': (data: unknown) => void; - 'channel:status': (data: { channelId: string; status: string }) => void; - 'chat:message': (data: { message: unknown }) => void; - 'chat:runtime-event': (data: unknown) => void; + 'gateway:health': (data: GatewayRuntimePayload) => void; + 'gateway:presence': (data: GatewayRuntimePayload) => void; + 'channel:status': (data: GatewayChannelStatusEvent) => void; + 'chat:message': (data: GatewayChatMessageEvent) => void; + 'chat:runtime-event': (data: ChatRuntimeEvent) => void; } /** @@ -346,7 +357,6 @@ export class GatewayManager extends EventEmitter { try { await runGatewayStartupSequence({ port: this.status.port, - ownedPid: this.process?.pid, shouldWaitForPortFree: process.platform === 'win32', hasOwnedProcess: () => this.process?.pid != null && this.ownsProcess, resetStartupStderrLines: () => { @@ -885,6 +895,12 @@ export class GatewayManager extends EventEmitter { }; try { + if (isGatewayWsTraceEnabled()) { + logger.debug('[gateway-ws-trace] send', { + summary: summarizeGatewayFrameForTrace(request), + frame: redactGatewayFrameForTrace(request), + }); + } this.ws.send(JSON.stringify(request)); } catch (error) { rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`)); @@ -900,7 +916,11 @@ export class GatewayManager extends EventEmitter { } const capability = classifyCapabilityMethod(method); if (capability) { - this.capabilityMonitor.recordCapabilitySuccess(capability, result, Date.now() - startedAt); + this.capabilityMonitor.recordCapabilitySuccess( + capability, + result as GatewayRuntimePayload, + Date.now() - startedAt, + ); } return result; }).catch((error) => { @@ -965,10 +985,10 @@ export class GatewayManager extends EventEmitter { ]); if (healthResult.status === 'fulfilled') { - this.capabilityMonitor.recordOpenClawHealth(healthResult.value); + this.capabilityMonitor.recordOpenClawHealth(healthResult.value as GatewayRuntimePayload); } if (statusResult.status === 'fulfilled') { - this.capabilityMonitor.recordOpenClawStatus(statusResult.value); + this.capabilityMonitor.recordOpenClawStatus(statusResult.value as GatewayRuntimePayload); } } @@ -1144,6 +1164,12 @@ export class GatewayManager extends EventEmitter { private handleMessage(message: unknown): void { this.connectionMonitor.markAlive('message'); this.recordGatewayAlive(); + if (isGatewayWsTraceEnabled()) { + logger.debug('[gateway-ws-trace] recv', { + summary: summarizeGatewayFrameForTrace(message), + frame: redactGatewayFrameForTrace(message), + }); + } if (typeof message !== 'object' || message === null) { logger.debug('Received non-object Gateway message'); diff --git a/electron/gateway/process-launcher.ts b/electron/gateway/process-launcher.ts index 021fbdfe..27698d56 100644 --- a/electron/gateway/process-launcher.ts +++ b/electron/gateway/process-launcher.ts @@ -176,10 +176,11 @@ export async function launchGatewayProcess(options: { reject(error); }; - child.on('error', (error) => { + child.on('error', (error: unknown) => { + const normalizedError = error instanceof Error ? error : new Error(String(error)); logger.error('Gateway process spawn error:', error); - options.onError(error); - rejectOnce(error); + options.onError(normalizedError); + rejectOnce(normalizedError); }); child.on('exit', (code: number) => { diff --git a/electron/gateway/ws-client.ts b/electron/gateway/ws-client.ts index 254f7eba..0e52b3fb 100644 --- a/electron/gateway/ws-client.ts +++ b/electron/gateway/ws-client.ts @@ -7,6 +7,11 @@ import { signDevicePayload, } from '../utils/device-identity'; import { logger } from '../utils/logger'; +import { + isGatewayWsTraceEnabled, + redactGatewayFrameForTrace, + summarizeGatewayFrameForTrace, +} from './ws-trace'; export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000; export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000; @@ -245,6 +250,12 @@ export async function connectGatewaySocket(options: { }); connectId = connectPayload.connectId; + if (isGatewayWsTraceEnabled()) { + logger.debug('[gateway-ws-trace] send', { + summary: summarizeGatewayFrameForTrace(connectPayload.frame), + frame: redactGatewayFrameForTrace(connectPayload.frame), + }); + } ws.send(JSON.stringify(connectPayload.frame)); const requestTimeout = setTimeout(() => { @@ -286,6 +297,12 @@ export async function connectGatewaySocket(options: { ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); + if (isGatewayWsTraceEnabled()) { + logger.debug('[gateway-ws-trace] recv', { + summary: summarizeGatewayFrameForTrace(message), + frame: redactGatewayFrameForTrace(message), + }); + } if ( !challengeReceived && typeof message === 'object' && message !== null && diff --git a/electron/gateway/ws-trace.ts b/electron/gateway/ws-trace.ts new file mode 100644 index 00000000..d30fe8de --- /dev/null +++ b/electron/gateway/ws-trace.ts @@ -0,0 +1,51 @@ +const SECRET_KEYS = new Set([ + 'token', + 'authorization', + 'apikey', + 'api_key', + 'signature', + 'cookie', + 'set-cookie', + 'accesstoken', + 'refreshtoken', +]); + +export function isGatewayWsTraceEnabled(): boolean { + return process.env.CLAWX_GATEWAY_WS_TRACE === '1'; +} + +export function redactGatewayFrameForTrace(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactGatewayFrameForTrace(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + + const result: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + const normalizedKey = key.toLowerCase(); + result[key] = SECRET_KEYS.has(normalizedKey) + ? '[redacted]' + : redactGatewayFrameForTrace(item); + } + return result; +} + +export function summarizeGatewayFrameForTrace(value: unknown): string { + if (!value || typeof value !== 'object') return typeof value; + const frame = value as Record; + if (frame.type === 'req') { + return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`; + } + if (frame.type === 'res') { + return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`; + } + if (frame.type === 'event') { + return `event ${String(frame.event ?? '-')}`; + } + if (typeof frame.method === 'string') { + return `jsonrpc method=${frame.method}`; + } + return 'unknown gateway frame'; +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 1fe590b9..5271e1fd 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -3,10 +3,10 @@ * Manages window creation, system tray, and IPC handlers */ import { app, BrowserWindow, nativeImage, session, shell } from 'electron'; -import type { Server } from 'node:http'; import { join } from 'path'; import { GatewayManager } from '../gateway/manager'; import { registerIpcHandlers } from './ipc-handlers'; +import { HostApiRegistry } from './ipc/host-invoke'; import { createTray } from './tray'; import { createMenu } from './menu'; import { registerZoomShortcuts } from './zoom-shortcuts'; @@ -47,8 +47,6 @@ import { createSignalQuitHandler } from './signal-quit'; import { acquireProcessInstanceFileLock } from './process-instance-lock'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config'; -import { startHostApiServer } from '../api/server'; -import { HostEventBus } from '../api/event-bus'; import { deviceOAuthManager } from '../utils/device-oauth'; import { browserOAuthManager } from '../utils/browser-oauth'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; @@ -88,7 +86,8 @@ app.disableHardwareAcceleration(); // on X11 it supplements the StartupWMClass matching. // Must be called before app.whenReady() / before any window is created. if (process.platform === 'linux') { - app.setDesktopName('clawx.desktop'); + const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void }; + linuxApp.setDesktopName?.('clawx.desktop'); } // Prevent multiple instances of the app from running simultaneously. @@ -133,11 +132,16 @@ const gotTheLock = gotElectronLock && gotFileLock; let mainWindow: BrowserWindow | null = null; let gatewayManager!: GatewayManager; let clawHubService!: ClawHubService; -let hostEventBus!: HostEventBus; -let hostApiServer: Server | null = null; +const hostApiRegistry = new HostApiRegistry(); const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); +function sendMainWindowEvent(channel: string, payload: unknown): void { + const win = mainWindow; + if (!win || win.isDestroyed()) return; + win.webContents.send(channel, payload); +} + /** * Resolve the icons directory path (works in both dev and packaged mode) */ @@ -322,7 +326,7 @@ async function initialize(): Promise { } // Set application menu - createMenu(); + await createMenu(); // Create the main window const window = createMainWindow(); @@ -356,20 +360,17 @@ async function initialize(): Promise { ); // Register IPC handlers - registerIpcHandlers(gatewayManager, clawHubService, window); - - hostApiServer = startHostApiServer({ - gatewayManager, - clawHubService, - eventBus: hostEventBus, - mainWindow: window, - }); + registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry); // Initialize extension system await extensionRegistry.initialize({ gatewayManager, - eventBus: hostEventBus, getMainWindow: () => mainWindow, + hostApi: { + register: (extensionId, contributions) => ( + hostApiRegistry.registerExtensionContributions(extensionId, contributions) + ), + }, }); // Wire marketplace provider to ClawHubService if an extension provides one @@ -441,7 +442,7 @@ async function initialize(): Promise { // Bridge gateway and host-side events before any auto-start logic runs, so // renderer subscribers observe the full startup lifecycle. gatewayManager.on('status', (status: { state: string }) => { - hostEventBus.emit('gateway:status', status); + sendMainWindowEvent('gateway:status-changed', status); if (status.state === 'running' && !isE2EMode) { void ensureClawXContext().catch((error) => { logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error); @@ -450,79 +451,71 @@ async function initialize(): Promise { }); gatewayManager.on('error', (error) => { - hostEventBus.emit('gateway:error', { message: error.message }); + sendMainWindowEvent('gateway:error', { message: error.message }); }); gatewayManager.on('notification', (notification) => { - hostEventBus.emit('gateway:notification', notification); + sendMainWindowEvent('gateway:notification', notification); }); gatewayManager.on('gateway:health', (data) => { - hostEventBus.emit('gateway:health', data); + sendMainWindowEvent('gateway:health-changed', data); }); gatewayManager.on('gateway:presence', (data) => { - hostEventBus.emit('gateway:presence', data); + sendMainWindowEvent('gateway:presence-changed', data); }); gatewayManager.on('chat:message', (data) => { - hostEventBus.emit('gateway:chat-message', data); + sendMainWindowEvent('gateway:chat-message', data); }); gatewayManager.on('chat:runtime-event', (data) => { - hostEventBus.emit('chat:runtime-event', data); + sendMainWindowEvent('chat:runtime-event', data); }); gatewayManager.on('channel:status', (data) => { - hostEventBus.emit('gateway:channel-status', data); + sendMainWindowEvent('gateway:channel-status', data); }); gatewayManager.on('exit', (code) => { - hostEventBus.emit('gateway:exit', { code }); + sendMainWindowEvent('gateway:exit', { code }); }); deviceOAuthManager.on('oauth:code', (payload) => { - hostEventBus.emit('oauth:code', payload); - }); - - deviceOAuthManager.on('oauth:start', (payload) => { - hostEventBus.emit('oauth:start', payload); + sendMainWindowEvent('oauth:code', payload); }); deviceOAuthManager.on('oauth:success', (payload) => { - hostEventBus.emit('oauth:success', { ...payload, success: true }); + sendMainWindowEvent('oauth:success', { ...payload, success: true }); }); deviceOAuthManager.on('oauth:error', (error) => { - hostEventBus.emit('oauth:error', error); - }); - - browserOAuthManager.on('oauth:start', (payload) => { - hostEventBus.emit('oauth:start', payload); + sendMainWindowEvent('oauth:error', error); }); browserOAuthManager.on('oauth:code', (payload) => { - hostEventBus.emit('oauth:code', payload); + sendMainWindowEvent('oauth:code', payload); }); browserOAuthManager.on('oauth:success', (payload) => { - hostEventBus.emit('oauth:success', { ...payload, success: true }); + sendMainWindowEvent('oauth:success', { ...payload, success: true }); }); browserOAuthManager.on('oauth:error', (error) => { - hostEventBus.emit('oauth:error', error); + sendMainWindowEvent('oauth:error', error); }); whatsAppLoginManager.on('qr', (data) => { - hostEventBus.emit('channel:whatsapp-qr', data); + sendMainWindowEvent('channel:whatsapp-qr', data); }); whatsAppLoginManager.on('success', (data) => { - hostEventBus.emit('channel:whatsapp-success', data); + sendMainWindowEvent('channel:whatsapp-success', data); }); whatsAppLoginManager.on('error', (error) => { - hostEventBus.emit('channel:whatsapp-error', error); + sendMainWindowEvent('channel:whatsapp-error', error); }); // Start Gateway automatically (this seeds missing bootstrap files with full templates) @@ -588,7 +581,6 @@ if (gotTheLock) { gatewayManager = new GatewayManager(); clawHubService = new ClawHubService(); - hostEventBus = new HostEventBus(); // Register builtin extensions and load manifest registerAllBuiltinExtensions(); @@ -652,8 +644,6 @@ if (gotTheLock) { return; } - hostEventBus.closeAll(); - hostApiServer?.close(); void extensionRegistry.teardownAll(); const stopPromise = gatewayManager.stop().catch((err) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index da714230..b211e37b 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -2,18 +2,17 @@ * IPC Handlers * Registers all IPC handlers for main-renderer communication */ -import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, extname, basename, resolve, sep, relative } from 'node:path'; -import crypto from 'node:crypto'; import { syncMacTrafficLightPosition } from './traffic-light-layout'; import { GatewayManager } from '../gateway/manager'; -import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; +import { ClawHubService } from '../gateway/clawhub'; import { type ProviderConfig, } from '../utils/secure-storage'; -import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir, expandPath } from '../utils/paths'; +import { getOpenClawStatus, getOpenClawSkillsDir, ensureDir, expandPath } from '../utils/paths'; import { getOpenClawCliCommand } from '../utils/openclaw-cli'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; import { @@ -21,41 +20,15 @@ import { removeProviderFromOpenClaw, } from '../utils/openclaw-auth'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; -import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing'; -import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui'; import { logger } from '../utils/logger'; import { resolveAgentIdFromChannel } from '../utils/agent-config'; import { resolveAccountIdFromSessionHistory } from '../utils/session-util'; -import { - removeSessionEntry, - resolveSessionTranscriptPath, - sweepSessionArtefacts, -} from '../utils/session-files'; -import { - saveChannelConfig, - getChannelConfig, - getChannelFormValues, - deleteChannelConfig, - listConfiguredChannels, - setChannelEnabled, - validateChannelConfig, - validateChannelCredentials, -} from '../utils/channel-config'; -import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias'; -import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; -import { - ensureDingTalkPluginInstalled, - ensureFeishuPluginInstalled, - ensureWeComPluginInstalled, -} from '../utils/plugin-install'; -import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; -import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; -import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; +import { deviceOAuthManager } from '../utils/device-oauth'; +import { browserOAuthManager } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; import { getProviderService } from '../services/providers/provider-service'; import { @@ -70,7 +43,27 @@ import { import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure'; -import { registerHostApiProxyHandlers } from './ipc/host-api-proxy'; +import { HostApiRegistry, registerHostInvokeHandler } from './ipc/host-invoke'; +import { createAppApi } from '../services/app-api'; +import { createOpenClawApi } from '../services/openclaw-api'; +import { createShellApi } from '../services/shell-api'; +import { createDialogApi } from '../services/dialog-api'; +import { createWindowApi } from '../services/window-api'; +import { createUpdatesApi } from '../services/updates-api'; +import { createUvApi } from '../services/uv-api'; +import { createGatewayApi } from '../services/gateway-api'; +import { createLogsApi } from '../services/logs-api'; +import { createSettingsApi } from '../services/settings-api'; +import { createChannelsApi } from '../services/channels-api'; +import { createAgentsApi } from '../services/agents-api'; +import { createChatApi } from '../services/chat-api'; +import { createCronApi } from '../services/cron-api'; +import { createFilesApi } from '../services/files-api'; +import { createMediaApi } from '../services/media-api'; +import { createProvidersApi } from '../services/providers-api'; +import { createSessionsApi } from '../services/sessions-api'; +import { createSkillsApi } from '../services/skills-api'; +import { createUsageApi } from '../services/usage-api'; import { isLaunchAtStartupKey, isProxyKey, @@ -78,6 +71,7 @@ import { type AppRequest, type AppResponse, } from './ipc/request-helpers'; +import { createMenu } from './menu'; const gatewayRpcBackpressure = new GatewayRpcBackpressure(); @@ -87,22 +81,20 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure(); export function registerIpcHandlers( gatewayManager: GatewayManager, clawHubService: ClawHubService, - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + hostApiRegistry: HostApiRegistry, ): void { // Unified request protocol (non-breaking: legacy channels remain available) registerUnifiedRequestHandlers(gatewayManager); - // Host API proxy handlers - registerHostApiProxyHandlers(); + // Typed host invoke handlers (new renderer facade; legacy channels remain available) + registerTypedHostHandlers(gatewayManager, clawHubService, mainWindow, hostApiRegistry); // Gateway handlers - registerGatewayHandlers(gatewayManager, mainWindow); - - // ClawHub handlers - registerClawHubHandlers(clawHubService); + registerGatewayHandlers(gatewayManager); // OpenClaw handlers - registerOpenClawHandlers(gatewayManager); + registerOpenClawHandlers(); // Provider handlers registerProviderHandlers(gatewayManager); @@ -113,27 +105,15 @@ export function registerIpcHandlers( // Dialog handlers registerDialogHandlers(); - // Session handlers - registerSessionHandlers(); - // App handlers registerAppHandlers(); // Settings handlers registerSettingsHandlers(gatewayManager); - // UV handlers - registerUvHandlers(); - - // Log handlers (for UI to read gateway/app logs) - registerLogHandlers(); - // Usage handlers registerUsageHandlers(); - // Skill config handlers (direct file access, no Gateway RPC) - registerSkillConfigHandlers(); - // Cron task handlers (proxy to Gateway RPC) registerCronHandlers(gatewayManager); @@ -143,16 +123,41 @@ export function registerIpcHandlers( // WhatsApp handlers registerWhatsAppHandlers(mainWindow); - // Device OAuth handlers (Code Plan) - registerDeviceOAuthHandlers(mainWindow); - - // File staging handlers (upload/send separation) - registerFileHandlers(); - // File preview handlers (sandboxed read/write/list for inline viewer) registerFilePreviewHandlers(); } +function registerTypedHostHandlers( + gatewayManager: GatewayManager, + clawHubService: ClawHubService, + mainWindow: BrowserWindow, + hostApiRegistry: HostApiRegistry, +): void { + hostApiRegistry.registerCoreServices({ + app: createAppApi(), + openclaw: createOpenClawApi(), + shell: createShellApi(), + dialog: createDialogApi(), + window: createWindowApi(mainWindow), + updates: createUpdatesApi(appUpdater), + uv: createUvApi(), + settings: createSettingsApi(gatewayManager), + gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure), + logs: createLogsApi(), + channels: createChannelsApi({ gatewayManager, mainWindow }), + agents: createAgentsApi({ gatewayManager }), + providers: createProvidersApi({ gatewayManager, mainWindow }), + files: createFilesApi(), + media: createMediaApi(), + sessions: createSessionsApi(), + chat: createChatApi({ gatewayManager }), + cron: createCronApi({ gatewayManager }), + skills: createSkillsApi({ clawHubService, gatewayManager }), + usage: createUsageApi(), + }); + registerHostInvokeHandler(hostApiRegistry); +} + function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); const handleProxySettingsChange = async () => { @@ -501,108 +506,6 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { }, }; } - case 'cron': { - if (request.action === 'list') { - const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); - const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; - data = jobs.map(transformCronJob); - break; - } - if (request.action === 'create') { - type CronCreateInput = { - name: string; - message: string; - schedule: string; - delivery?: { mode: string; channel?: string; to?: string }; - enabled?: boolean; - }; - const payload = request.payload as - | { input?: CronCreateInput } - | [CronCreateInput] - | CronCreateInput - | undefined; - let input: CronCreateInput | undefined; - if (Array.isArray(payload)) { - input = payload[0]; - } else if (payload && typeof payload === 'object' && 'input' in payload) { - input = payload.input; - } else { - input = payload as CronCreateInput | undefined; - } - if (!input) throw new Error('Invalid cron.create payload'); - const gatewayInput = { - name: input.name, - schedule: { kind: 'cron', expr: input.schedule }, - payload: { kind: 'agentTurn', message: input.message }, - enabled: input.enabled ?? true, - wakeMode: 'next-heartbeat', - sessionTarget: 'isolated', - delivery: normalizeCronDelivery(input.delivery), - }; - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel); - if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) { - throw new Error(unsupportedDeliveryError); - } - const created = await gatewayManager.rpc('cron.add', gatewayInput); - data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created; - break; - } - if (request.action === 'update') { - const payload = request.payload as - | { id?: string; input?: Record } - | [string, Record] - | undefined; - const id = Array.isArray(payload) ? payload[0] : payload?.id; - const input = Array.isArray(payload) ? payload[1] : payload?.input; - if (!id || !input) throw new Error('Invalid cron.update payload'); - const patch = buildCronUpdatePatch(input); - const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' - ? patch.delivery as Record - : undefined; - const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() - ? deliveryPatch.channel.trim() - : undefined; - const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() - ? deliveryPatch.mode.trim() - : undefined; - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); - if (unsupportedDeliveryError && deliveryMode !== 'none') { - throw new Error(unsupportedDeliveryError); - } - data = await gatewayManager.rpc('cron.update', { id, patch }); - break; - } - if (request.action === 'delete') { - const payload = request.payload as { id?: string } | string | undefined; - const id = typeof payload === 'string' ? payload : payload?.id; - if (!id) throw new Error('Invalid cron.delete payload'); - data = await gatewayManager.rpc('cron.remove', { id }); - break; - } - if (request.action === 'toggle') { - const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined; - const id = Array.isArray(payload) ? payload[0] : payload?.id; - const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled; - if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload'); - data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); - break; - } - if (request.action === 'trigger') { - const payload = request.payload as { id?: string } | string | undefined; - const id = typeof payload === 'string' ? payload : payload?.id; - if (!id) throw new Error('Invalid cron.trigger payload'); - data = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); - break; - } - return { - id: request.id, - ok: false, - error: { - code: 'UNSUPPORTED', - message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, - }, - }; - } case 'usage': { if (request.action === 'recentTokenHistory') { const payload = request.payload as { limit?: number } | number | undefined; @@ -649,6 +552,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (isLaunchAtStartupKey(key)) { await syncLaunchAtStartupSettingFromStore(); } + if (key === 'language') { + await createMenu(typeof value === 'string' ? value : undefined); + } data = { success: true }; break; } @@ -664,6 +570,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (entries.some(([key]) => isLaunchAtStartupKey(key))) { await syncLaunchAtStartupSettingFromStore(); } + if (entries.some(([key]) => key === 'language')) { + await createMenu(typeof patch.language === 'string' ? patch.language : undefined); + } data = { success: true }; break; } @@ -672,6 +581,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const settings = await getAllSettings(); await handleProxySettingsChange(); await syncLaunchAtStartupSettingFromStore(); + await createMenu(settings.language); data = { success: true, settings }; break; } @@ -710,342 +620,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } /** - * Skill config IPC handlers - * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) - */ -function registerSkillConfigHandlers(): void { - // Update skill config (apiKey and env) - ipcMain.handle('skill:updateConfig', async (_, params: { - skillKey: string; - apiKey?: string; - env?: Record; - }) => { - return await updateSkillConfig(params.skillKey, { - apiKey: params.apiKey, - env: params.env, - }); - }); - - // Get skill config - ipcMain.handle('skill:getConfig', async (_, skillKey: string) => { - return await getSkillConfig(skillKey); - }); - - // Get all skill configs - ipcMain.handle('skill:getAllConfigs', async () => { - return await getAllSkillConfigs(); - }); -} - -/** - * Gateway CronJob type (as returned by cron.list RPC) - */ -interface GatewayCronJob { - id: string; - name: string; - description?: string; - enabled: boolean; - createdAtMs: number; - updatedAtMs: number; - schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string }; - payload: { kind: string; message?: string; text?: string }; - delivery?: { mode: string; channel?: string; to?: string; accountId?: string }; - sessionTarget?: string; - state: { - nextRunAtMs?: number; - lastRunAtMs?: number; - lastStatus?: string; - lastError?: string; - lastDurationMs?: number; - }; -} - -type GatewayCronDelivery = NonNullable; - -function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null { - // Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS). - // No per-channel backend blocks are needed. - return null; -} - -function normalizeCronDelivery( - rawDelivery: unknown, - fallbackMode: GatewayCronDelivery['mode'] = 'none', -): GatewayCronDelivery { - if (!rawDelivery || typeof rawDelivery !== 'object') { - return { mode: fallbackMode }; - } - - const delivery = rawDelivery as Record; - const mode = typeof delivery.mode === 'string' && delivery.mode.trim() - ? delivery.mode.trim() - : fallbackMode; - const channel = typeof delivery.channel === 'string' && delivery.channel.trim() - ? toOpenClawChannelType(delivery.channel.trim()) - : undefined; - const to = typeof delivery.to === 'string' && delivery.to.trim() - ? delivery.to.trim() - : undefined; - const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim() - ? delivery.accountId.trim() - : undefined; - - if (mode === 'announce' && !channel) { - return { mode: 'none' }; - } - - return { - mode, - ...(channel ? { channel } : {}), - ...(to ? { to } : {}), - ...(accountId ? { accountId } : {}), - }; -} - -function normalizeCronDeliveryPatch(rawDelivery: unknown): Record { - if (!rawDelivery || typeof rawDelivery !== 'object') { - return {}; - } - - const delivery = rawDelivery as Record; - const patch: Record = {}; - if ('mode' in delivery) { - patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() - ? delivery.mode.trim() - : 'none'; - } - if ('channel' in delivery) { - patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim() - ? toOpenClawChannelType(delivery.channel.trim()) - : ''; - } - if ('to' in delivery) { - patch.to = typeof delivery.to === 'string' ? delivery.to : ''; - } - if ('accountId' in delivery) { - patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : ''; - } - return patch; -} - -function buildCronUpdatePatch(input: Record): Record { - const patch = { ...input }; - - if (typeof patch.schedule === 'string') { - patch.schedule = { kind: 'cron', expr: patch.schedule }; - } - - if (typeof patch.message === 'string') { - patch.payload = { kind: 'agentTurn', message: patch.message }; - delete patch.message; - } - - if ('delivery' in patch) { - patch.delivery = normalizeCronDeliveryPatch(patch.delivery); - } - - return patch; -} - -/** - * Transform a Gateway CronJob to the frontend CronJob format - */ -function transformCronJob(job: GatewayCronJob) { - // Extract message from payload - const message = job.payload?.message || job.payload?.text || ''; - const gatewayDelivery = normalizeCronDelivery(job.delivery); - const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined; - const delivery = channelType - ? { ...gatewayDelivery, channel: channelType } - : gatewayDelivery; - - // Build target from delivery info — only if a delivery channel is specified - const target = channelType - ? { channelType, channelId: delivery.accountId || gatewayDelivery.channel, channelName: channelType, recipient: delivery.to } - : undefined; - - // Build lastRun from state - const lastRun = job.state?.lastRunAtMs - ? { - time: new Date(job.state.lastRunAtMs).toISOString(), - success: job.state.lastStatus === 'ok', - error: job.state.lastError, - duration: job.state.lastDurationMs, - } - : undefined; - - // Build nextRun from state - const nextRun = job.state?.nextRunAtMs - ? new Date(job.state.nextRunAtMs).toISOString() - : undefined; - - return { - id: job.id, - name: job.name, - message, - schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it - delivery, - target, - enabled: job.enabled, - createdAt: new Date(job.createdAtMs).toISOString(), - updatedAt: new Date(job.updatedAtMs).toISOString(), - lastRun, - nextRun, - }; -} - -/** - * Cron task IPC handlers - * Proxies cron operations to the Gateway RPC service. - * The frontend works with plain cron expression strings, but the Gateway - * expects CronSchedule objects ({ kind: "cron", expr: "..." }). - * These handlers bridge the two formats. + * Cron maintenance */ function registerCronHandlers(gatewayManager: GatewayManager): void { - // List all cron jobs — transforms Gateway CronJob format to frontend CronJob format - ipcMain.handle('cron:list', async () => { - try { - const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); - const jobs = Array.isArray(result) ? result : (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; - - // Auto-repair legacy UI-created jobs that were saved without - // delivery: { mode: 'none' }. The Gateway auto-normalizes them - // to delivery: { mode: 'announce' } which then fails with - // "Channel is required" when no external channels are configured. - for (const job of jobs) { - const isIsolatedAgent = - (job.sessionTarget === 'isolated' || !job.sessionTarget) && - job.payload?.kind === 'agentTurn'; - const needsRepair = - isIsolatedAgent && - job.delivery?.mode === 'announce' && - !job.delivery?.channel; - - if (needsRepair) { - try { - await gatewayManager.rpc('cron.update', { - id: job.id, - patch: { delivery: { mode: 'none' } }, - }); - job.delivery = { mode: 'none' }; - // Clear stale channel-resolution error from the last run - if (job.state?.lastError?.includes('Channel is required')) { - job.state.lastError = undefined; - job.state.lastStatus = 'ok'; - } - } catch (e) { - console.warn(`Failed to auto-repair cron job ${job.id}:`, e); - } - } - } - - // Transform Gateway format to frontend format - return jobs.map(transformCronJob); - } catch (error) { - console.error('Failed to list cron jobs:', error); - throw error; - } - }); - - // Create a new cron job - // UI-created tasks have no delivery target — results go to the ClawX chat page. - // Tasks created via external channels (Feishu, Discord, etc.) are handled - // directly by the OpenClaw Gateway and do not pass through this IPC handler. - ipcMain.handle('cron:create', async (_, input: { - name: string; - message: string; - schedule: string; - delivery?: GatewayCronDelivery; - enabled?: boolean; - }) => { - try { - const gatewayInput = { - name: input.name, - schedule: { kind: 'cron', expr: input.schedule }, - payload: { kind: 'agentTurn', message: input.message }, - enabled: input.enabled ?? true, - wakeMode: 'next-heartbeat', - sessionTarget: 'isolated', - // UI-created jobs deliver results via ClawX WebSocket chat events, - // not external messaging channels. Setting mode='none' prevents - // the Gateway from attempting channel delivery (which would fail - // with "Channel is required" when no channels are configured). - delivery: normalizeCronDelivery(input.delivery), - }; - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel); - if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) { - throw new Error(unsupportedDeliveryError); - } - const result = await gatewayManager.rpc('cron.add', gatewayInput); - // Transform the returned job to frontend format - if (result && typeof result === 'object') { - return transformCronJob(result as GatewayCronJob); - } - return result; - } catch (error) { - console.error('Failed to create cron job:', error); - throw error; - } - }); - - // Update an existing cron job - ipcMain.handle('cron:update', async (_, id: string, input: Record) => { - try { - const patch = buildCronUpdatePatch(input); - const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' - ? patch.delivery as Record - : undefined; - const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() - ? deliveryPatch.channel.trim() - : undefined; - const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() - ? deliveryPatch.mode.trim() - : undefined; - const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); - if (unsupportedDeliveryError && deliveryMode !== 'none') { - throw new Error(unsupportedDeliveryError); - } - const result = await gatewayManager.rpc('cron.update', { id, patch }); - return result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result; - } catch (error) { - console.error('Failed to update cron job:', error); - throw error; - } - }); - - // Delete a cron job - ipcMain.handle('cron:delete', async (_, id: string) => { - try { - const result = await gatewayManager.rpc('cron.remove', { id }); - return result; - } catch (error) { - console.error('Failed to delete cron job:', error); - throw error; - } - }); - - // Toggle a cron job enabled/disabled - ipcMain.handle('cron:toggle', async (_, id: string, enabled: boolean) => { - try { - const result = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); - return result; - } catch (error) { - console.error('Failed to toggle cron job:', error); - throw error; - } - }); - - // Trigger a cron job manually - ipcMain.handle('cron:trigger', async (_, id: string) => { - try { - const result = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); - return result; - } catch (error) { - console.error('Failed to trigger cron job:', error); - throw error; - } - }); - // Periodic cron job repair: checks for jobs with undefined agentId and repairs them // This handles cases where cron jobs were created via openclaw CLI without specifying agent const CRON_AGENT_REPAIR_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -1106,118 +683,15 @@ function registerCronHandlers(gatewayManager: GatewayManager): void { }, CRON_AGENT_REPAIR_INTERVAL_MS); } -/** - * UV-related IPC handlers - */ -function registerUvHandlers(): void { - // Check if uv is installed - ipcMain.handle('uv:check', async () => { - return await checkUvInstalled(); - }); - - // Install uv and setup managed Python - ipcMain.handle('uv:install-all', async () => { - try { - const isInstalled = await checkUvInstalled(); - if (!isInstalled) { - await installUv(); - } - // Always run python setup to ensure it exists in uv's cache - await setupManagedPython(); - return { success: true }; - } catch (error) { - console.error('Failed to setup uv/python:', error); - return { success: false, error: String(error) }; - } - }); -} - -/** - * Log-related IPC handlers - * Allows the renderer to read application logs for diagnostics - */ -function registerLogHandlers(): void { - // Get recent logs from memory ring buffer - ipcMain.handle('log:getRecent', async (_, count?: number) => { - return logger.getRecentLogs(count); - }); - - // Read log file content (last N lines) - ipcMain.handle('log:readFile', async (_, tailLines?: number) => { - return await logger.readLogFile(tailLines); - }); - - // Get log file path (so user can open in file explorer) - ipcMain.handle('log:getFilePath', async () => { - return logger.getLogFilePath(); - }); - - // Get log directory path - ipcMain.handle('log:getDir', async () => { - return logger.getLogDir(); - }); - - // List all log files - ipcMain.handle('log:listFiles', async () => { - return await logger.listLogFiles(); - }); -} - /** * Gateway-related IPC handlers */ -function registerGatewayHandlers( - gatewayManager: GatewayManager, - mainWindow: BrowserWindow -): void { - type GatewayHttpProxyRequest = { - path?: string; - method?: string; - headers?: Record; - body?: unknown; - timeoutMs?: number; - }; - +function registerGatewayHandlers(gatewayManager: GatewayManager): void { // Get Gateway status ipcMain.handle('gateway:status', () => { return gatewayManager.getStatus(); }); - // Check if Gateway is connected - ipcMain.handle('gateway:isConnected', () => { - return gatewayManager.isConnected(); - }); - - // Start Gateway - ipcMain.handle('gateway:start', async () => { - try { - await gatewayManager.start(); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - // Stop Gateway - ipcMain.handle('gateway:stop', async () => { - try { - await gatewayManager.stop(); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - // Restart Gateway - ipcMain.handle('gateway:restart', async () => { - try { - await gatewayManager.restart(); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - // Gateway RPC call ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => { try { @@ -1234,281 +708,16 @@ function registerGatewayHandlers( } }); - // Gateway HTTP proxy - // Renderer must not call gateway HTTP directly (CORS); all HTTP traffic - // should go through this main-process proxy. - ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => { - try { - const status = gatewayManager.getStatus(); - const port = status.port || 18789; - const path = request?.path && request.path.startsWith('/') ? request.path : '/'; - const method = (request?.method || 'GET').toUpperCase(); - const timeoutMs = - typeof request?.timeoutMs === 'number' && request.timeoutMs > 0 - ? request.timeoutMs - : 15000; - - const token = await getSetting('gatewayToken'); - const headers: Record = { - ...(request?.headers ?? {}), - }; - if (!headers.Authorization && !headers.authorization && token) { - headers.Authorization = `Bearer ${token}`; - } - - let body: string | undefined; - if (request?.body !== undefined && request?.body !== null) { - body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); - if (!headers['Content-Type'] && !headers['content-type']) { - headers['Content-Type'] = 'application/json'; - } - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const response = await (async () => { - try { - return await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, { - method, - headers, - body, - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - } - })(); - - const contentType = (response.headers.get('content-type') || '').toLowerCase(); - if (contentType.includes('application/json')) { - const json = await response.json(); - return { - success: true, - status: response.status, - ok: response.ok, - json, - }; - } - - const text = await response.text(); - return { - success: true, - status: response.status, - ok: response.ok, - text, - }; - } catch (error) { - return { - success: false, - error: String(error), - }; - } - }); - - // Chat send with media — reads staged files from disk and builds attachments. - // Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments. - // All other files are referenced by path in the message text so the model - // can access them via tools (the same format channels use). - const VISION_MIME_TYPES = new Set([ - 'image/png', 'image/jpeg', 'image/bmp', 'image/webp', - ]); - - ipcMain.handle('chat:sendWithMedia', async (_, params: { - sessionKey: string; - message: string; - deliver?: boolean; - idempotencyKey: string; - media?: Array<{ filePath: string; mimeType: string; fileName: string }>; - }) => { - try { - let message = params.message; - // The Gateway processes image attachments through TWO parallel paths: - // Path A: `attachments` param → parsed via `parseMessageWithAttachments` → - // injected as inline vision content when the model supports images. - // Format: { content: base64, mimeType: string, fileName?: string } - // Path B: `[media attached: ...]` in message text → Gateway's native image - // detection (`detectAndLoadPromptImages`) reads the file from disk and - // injects it as inline vision content. Also works for history messages. - // We use BOTH paths for maximum reliability. - const imageAttachments: Array> = []; - const fileReferences: string[] = []; - - if (params.media && params.media.length > 0) { - const fsP = await import('fs/promises'); - for (const m of params.media) { - const exists = await fsP.access(m.filePath).then(() => true, () => false); - logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`); - - // Always add file path reference so the model can access it via tools - fileReferences.push( - `[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`, - ); - - if (VISION_MIME_TYPES.has(m.mimeType)) { - // Send as base64 attachment in the format the Gateway expects: - // { content: base64String, mimeType: string, fileName?: string } - // The Gateway normalizer looks for `a.content` (NOT `a.source.data`). - const fileBuffer = await fsP.readFile(m.filePath); - const base64Data = fileBuffer.toString('base64'); - logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); - imageAttachments.push({ - content: base64Data, - mimeType: m.mimeType, - fileName: m.fileName, - }); - } - } - } - - // Append file references to message text so the model knows about them - if (fileReferences.length > 0) { - const refs = fileReferences.join('\n'); - message = message ? `${message}\n\n${refs}` : refs; - } - - const rpcParams: Record = { - sessionKey: params.sessionKey, - message, - deliver: params.deliver ?? false, - idempotencyKey: params.idempotencyKey, - }; - - if (imageAttachments.length > 0) { - rpcParams.attachments = imageAttachments; - } - - logger.info(`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`); - - // Longer timeout for chat sends to tolerate high-latency networks (avoids connect error) - const timeoutMs = 120000; - const result = await gatewayManager.rpc('chat.send', rpcParams, timeoutMs); - logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); - return { success: true, result }; - } catch (error) { - logger.error(`[chat:sendWithMedia] Error: ${String(error)}`); - return { success: false, error: String(error) }; - } - }); - - // Get the Control UI URL with token for embedding - ipcMain.handle('gateway:getControlUiUrl', async () => { - try { - const status = gatewayManager.getStatus(); - const token = await getSetting('gatewayToken'); - const port = status.port || 18789; - const url = buildOpenClawControlUiUrl(port, token); - scheduleControlUiDeviceAutoApproval(gatewayManager); - return { success: true, url, port, token }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); - - // Health check - ipcMain.handle('gateway:health', async () => { - try { - const health = await gatewayManager.checkHealth(); - return { success: true, ...health }; - } catch (error) { - return { success: false, ok: false, error: String(error) }; - } - }); - - // Forward Gateway events to renderer - gatewayManager.on('status', (status) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:status-changed', status); - } - }); - - gatewayManager.on('message', (message) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:message', message); - } - }); - - gatewayManager.on('notification', (notification) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:notification', notification); - } - }); - - gatewayManager.on('gateway:health', (data) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:health-changed', data); - } - }); - - gatewayManager.on('gateway:presence', (data) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:presence-changed', data); - } - }); - - gatewayManager.on('channel:status', (data) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:channel-status', data); - } - }); - - gatewayManager.on('chat:message', (data) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:chat-message', data); - } - }); - - gatewayManager.on('chat:runtime-event', (data) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('chat:runtime-event', data); - } - }); - - gatewayManager.on('exit', (code) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:exit', code); - } - }); - - gatewayManager.on('error', (error) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('gateway:error', error.message); - } - }); + // Gateway events are bridged once in main/index.ts through the typed + // hostEvents surface. Keeping listener forwarding here would double-deliver + // streaming/runtime events to the renderer. } /** * OpenClaw-related IPC handlers * For checking package status and channel configuration */ -function registerOpenClawHandlers(gatewayManager: GatewayManager): void { - // Plugin-based channels require a full Gateway process restart to properly - // initialize / tear-down plugin connections. SIGUSR1 in-process reload is - // not sufficient for channel plugins (see restartGatewayForAgentDeletion). - const forceRestartChannels = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']); - - const scheduleGatewayChannelRestart = (reason: string): void => { - if (gatewayManager.getStatus().state !== 'stopped') { - logger.info(`Scheduling Gateway restart after ${reason}`); - gatewayManager.debouncedRestart(150); - } else { - logger.info(`Gateway is stopped; skip immediate restart after ${reason}`); - } - }; - - const scheduleGatewayChannelSaveRefresh = (channelType: string, reason: string): void => { - if (gatewayManager.getStatus().state === 'stopped') { - logger.info(`Gateway is stopped; skip immediate refresh after ${reason}`); - return; - } - if (forceRestartChannels.has(channelType)) { - logger.info(`Scheduling Gateway restart after ${reason}`); - gatewayManager.debouncedRestart(150); - return; - } - logger.info(`Scheduling Gateway reload after ${reason}`); - gatewayManager.debouncedReload(150); - }; - +function registerOpenClawHandlers(): void { // Get OpenClaw package status ipcMain.handle('openclaw:status', () => { const status = getOpenClawStatus(); @@ -1516,22 +725,6 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { return status; }); - // Check if OpenClaw is ready (package present) - ipcMain.handle('openclaw:isReady', () => { - const status = getOpenClawStatus(); - return status.packageExists; - }); - - // Get the resolved OpenClaw directory path (for diagnostics) - ipcMain.handle('openclaw:getDir', () => { - return getOpenClawDir(); - }); - - // Get the OpenClaw config directory (~/.openclaw) - ipcMain.handle('openclaw:getConfigDir', () => { - return getOpenClawConfigDir(); - }); - // Get the OpenClaw skills directory (~/.openclaw/skills) ipcMain.handle('openclaw:getSkillsDir', () => { const dir = getOpenClawSkillsDir(); @@ -1554,182 +747,12 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { return { success: false, error: String(error) }; } }); - - - // ==================== Channel Configuration Handlers ==================== - - // Save channel configuration - ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record) => { - try { - logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) }); - if (channelType === 'dingtalk') { - const installResult = await ensureDingTalkPluginInstalled(); - if (!installResult.installed) { - return { - success: false, - error: installResult.warning || 'DingTalk plugin install failed', - }; - } - await saveChannelConfig(channelType, config); - scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); - return { - success: true, - pluginInstalled: installResult.installed, - warning: installResult.warning, - }; - } - if (channelType === 'wecom') { - const installResult = await ensureWeComPluginInstalled(); - if (!installResult.installed) { - return { - success: false, - error: installResult.warning || 'WeCom plugin install failed', - }; - } - await saveChannelConfig(channelType, config); - scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); - return { - success: true, - pluginInstalled: installResult.installed, - warning: installResult.warning, - }; - } - // QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed - if (channelType === 'feishu') { - const installResult = await ensureFeishuPluginInstalled(); - if (!installResult.installed) { - return { - success: false, - error: installResult.warning || 'Feishu plugin install failed', - }; - } - await saveChannelConfig(channelType, config); - scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); - return { - success: true, - pluginInstalled: installResult.installed, - warning: installResult.warning, - }; - } - await saveChannelConfig(channelType, config); - scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); - return { success: true }; - } catch (error) { - console.error('Failed to save channel config:', error); - return { success: false, error: String(error) }; - } - }); - - // Get channel configuration - ipcMain.handle('channel:getConfig', async (_, channelType: string) => { - try { - const config = await getChannelConfig(channelType); - return { success: true, config }; - } catch (error) { - console.error('Failed to get channel config:', error); - return { success: false, error: String(error) }; - } - }); - - // Get channel form values (reverse-transformed for UI pre-fill) - ipcMain.handle('channel:getFormValues', async (_, channelType: string) => { - try { - const values = await getChannelFormValues(channelType); - return { success: true, values }; - } catch (error) { - console.error('Failed to get channel form values:', error); - return { success: false, error: String(error) }; - } - }); - - // Delete channel configuration - ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { - try { - await deleteChannelConfig(channelType); - scheduleGatewayChannelRestart(`channel:deleteConfig (${channelType})`); - return { success: true }; - } catch (error) { - console.error('Failed to delete channel config:', error); - return { success: false, error: String(error) }; - } - }); - - // List configured channels - ipcMain.handle('channel:listConfigured', async () => { - try { - const channels = await listConfiguredChannels(); - return { success: true, channels }; - } catch (error) { - console.error('Failed to list channels:', error); - return { success: false, error: String(error) }; - } - }); - - // Enable or disable a channel - ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { - try { - await setChannelEnabled(channelType, enabled); - scheduleGatewayChannelRestart(`channel:setEnabled (${channelType}, enabled=${enabled})`); - return { success: true }; - } catch (error) { - console.error('Failed to set channel enabled:', error); - return { success: false, error: String(error) }; - } - }); - - // Validate channel configuration - ipcMain.handle('channel:validate', async (_, channelType: string) => { - try { - const result = await validateChannelConfig(channelType); - return { success: true, ...result }; - } catch (error) { - console.error('Failed to validate channel:', error); - return { success: false, valid: false, errors: [String(error)], warnings: [] }; - } - }); - - // Validate channel credentials by calling actual service APIs (before saving) - ipcMain.handle('channel:validateCredentials', async (_, channelType: string, config: Record) => { - try { - const result = await validateChannelCredentials(channelType, config); - return { success: true, ...result }; - } catch (error) { - console.error('Failed to validate channel credentials:', error); - return { success: false, valid: false, errors: [String(error)], warnings: [] }; - } - }); } /** * WhatsApp Login Handlers */ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { - // Request WhatsApp QR code - ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => { - try { - logger.info('channel:requestWhatsAppQr', { accountId }); - await whatsAppLoginManager.start(accountId); - return { success: true }; - } catch (error) { - logger.error('channel:requestWhatsAppQr failed', error); - return { success: false, error: String(error) }; - } - }); - - // Cancel WhatsApp login - ipcMain.handle('channel:cancelWhatsAppQr', async () => { - try { - await whatsAppLoginManager.stop(); - return { success: true }; - } catch (error) { - logger.error('channel:cancelWhatsAppQr failed', error); - return { success: false, error: String(error) }; - } - }); - - // Check WhatsApp status (is it active?) - // ipcMain.handle('channel:checkWhatsAppStatus', ...) - // Forward events to renderer whatsAppLoginManager.on('qr', (data) => { if (!mainWindow.isDestroyed()) { @@ -1752,50 +775,6 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { }); } -/** - * Device OAuth Handlers (Code Plan) - */ -function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { - deviceOAuthManager.setWindow(mainWindow); - browserOAuthManager.setWindow(mainWindow); - - // Request Provider OAuth initialization - ipcMain.handle( - 'provider:requestOAuth', - async ( - _, - provider: OAuthProviderType | BrowserOAuthProviderType, - region?: 'global' | 'cn', - options?: { accountId?: string; label?: string }, - ) => { - try { - logger.info(`provider:requestOAuth for ${provider}`); - if (provider === 'openai') { - await browserOAuthManager.startFlow(provider, options); - } else { - await deviceOAuthManager.startFlow(provider, region, options); - } - return { success: true }; - } catch (error) { - logger.error('provider:requestOAuth failed', error); - return { success: false, error: String(error) }; - } - }, - ); - - // Cancel Provider OAuth - ipcMain.handle('provider:cancelOAuth', async () => { - try { - await deviceOAuthManager.stopFlow(); - await browserOAuthManager.stopFlow(); - return { success: true }; - } catch (error) { - logger.error('provider:cancelOAuth failed', error); - return { success: false, error: String(error) }; - } - }); -} - /** * Provider-related IPC handlers */ @@ -1828,19 +807,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { return await providerService.listLegacyProvidersWithKeyInfo(); }); - // New provider-service endpoints used by the account-based refactor. - ipcMain.handle('provider:listVendors', async () => { - return await providerService.listVendors(); - }); - - ipcMain.handle('provider:listAccounts', async () => { - return await providerService.listAccounts(); - }); - - ipcMain.handle('provider:getAccount', async (_, accountId: string) => { - return await providerService.getAccount(accountId); - }); - // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { logLegacyProviderChannel('provider:get'); @@ -2117,61 +1083,6 @@ function registerShellHandlers(): void { }); } -/** - * ClawHub-related IPC handlers - */ -function registerClawHubHandlers(clawHubService: ClawHubService): void { - // Search skills - ipcMain.handle('clawhub:search', async (_, params: ClawHubSearchParams) => { - try { - const results = await clawHubService.search(params); - return { success: true, results }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }); - - // Install skill - ipcMain.handle('clawhub:install', async (_, params: ClawHubInstallParams) => { - try { - await clawHubService.install(params); - return { success: true }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }); - - // Uninstall skill - ipcMain.handle('clawhub:uninstall', async (_, params: ClawHubUninstallParams) => { - try { - await clawHubService.uninstall(params); - return { success: true }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }); - - // List installed skills - ipcMain.handle('clawhub:list', async () => { - try { - const results = await clawHubService.listInstalled(); - return { success: true, results }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - }); - - // Open skill readme - ipcMain.handle('clawhub:openSkillReadme', async (_, slug: string) => { - try { - await clawHubService.openSkillReadme(slug); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); -} - /** * Dialog-related IPC handlers */ @@ -2182,12 +1093,6 @@ function registerDialogHandlers(): void { return result; }); - // Show save dialog - ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => { - const result = await dialog.showSaveDialog(options); - return result; - }); - // Show message box ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => { const result = await dialog.showMessageBox(options); @@ -2209,26 +1114,11 @@ function registerAppHandlers(): void { return app.getName(); }); - // Get app path - ipcMain.handle('app:getPath', (_, name: Parameters[0]) => { - return app.getPath(name); - }); - // Get platform ipcMain.handle('app:platform', () => { return process.platform; }); - // Quit app - ipcMain.handle('app:quit', () => { - app.quit(); - }); - - // Relaunch app - ipcMain.handle('app:relaunch', () => { - app.relaunch(); - app.quit(); - }); } function registerSettingsHandlers(gatewayManager: GatewayManager): void { @@ -2265,6 +1155,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { if (key === 'launchAtStartup') { await syncLaunchAtStartupSettingFromStore(); } + if (key === 'language') { + await createMenu(typeof value === 'string' ? value : undefined); + } return { success: true }; }); @@ -2288,6 +1181,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { if (entries.some(([key]) => key === 'launchAtStartup')) { await syncLaunchAtStartupSettingFromStore(); } + if (entries.some(([key]) => key === 'language')) { + await createMenu(typeof patch.language === 'string' ? patch.language : undefined); + } return { success: true }; }); @@ -2297,6 +1193,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { const settings = await getAllSettings(); await handleProxySettingsChange(); await syncLaunchAtStartupSettingFromStore(); + await createMenu(settings.language); return { success: true, settings }; }); } @@ -2389,415 +1286,7 @@ function getMimeType(ext: string): string { return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; } -function mimeToExt(mimeType: string): string { - for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { - if (mime === mimeType) return ext; - } - return ''; -} - const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); -const DIRECTORY_MIME_TYPE = 'application/x-directory'; - -/** - * Generate a preview data URL for image files. - * Resizes large images while preserving aspect ratio (only constrain the - * longer side so the image is never squished). The frontend handles - * square cropping via CSS object-fit: cover. - */ -async function generateImagePreview(filePath: string, mimeType: string): Promise { - try { - const { readFile: readFileAsync } = await import('fs/promises'); - if (mimeType === 'image/svg+xml') { - const buf = await readFileAsync(filePath); - return `data:${mimeType};base64,${buf.toString('base64')}`; - } - - const img = nativeImage.createFromPath(filePath); - if (img.isEmpty()) return null; - const size = img.getSize(); - const maxDim = 512; // keep enough resolution for crisp display on Retina - // Only resize if larger than threshold — specify ONE dimension to keep ratio - if (size.width > maxDim || size.height > maxDim) { - const resized = size.width >= size.height - ? img.resize({ width: maxDim }) // landscape / square → constrain width - : img.resize({ height: maxDim }); // portrait → constrain height - return `data:image/png;base64,${resized.toPNG().toString('base64')}`; - } - // Small image — use original (async read to avoid blocking) - const buf = await readFileAsync(filePath); - return `data:${mimeType};base64,${buf.toString('base64')}`; - } catch { - return null; - } -} - -/** - * File staging IPC handlers - * Stage files to ~/.openclaw/media/outbound/ for gateway access - */ -function registerFileHandlers(): void { - // Stage files from real disk paths (used with dialog:open) - ipcMain.handle('file:stage', async (_, filePaths: string[]) => { - const fsP = await import('fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); - - const results = []; - for (const filePath of filePaths) { - const id = crypto.randomUUID(); - const fileName = basename(filePath); - const sourceStat = await fsP.stat(filePath); - if (sourceStat.isDirectory()) { - results.push({ - id, - fileName, - mimeType: DIRECTORY_MIME_TYPE, - fileSize: 0, - stagedPath: filePath, - preview: null, - }); - continue; - } - - const ext = extname(filePath); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); - await fsP.copyFile(filePath, stagedPath); - - const s = await fsP.stat(stagedPath); - const mimeType = getMimeType(ext); - let preview: string | null = null; - if (mimeType.startsWith('image/')) { - preview = await generateImagePreview(stagedPath, mimeType); - } - - results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); - } - return results; - }); - - // Stage file from buffer (used for clipboard paste / drag-drop) - ipcMain.handle('file:stageBuffer', async (_, payload: { - base64: string; - fileName: string; - mimeType: string; - }) => { - const fsP = await import('fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); - - const id = crypto.randomUUID(); - const ext = extname(payload.fileName) || mimeToExt(payload.mimeType); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); - const buffer = Buffer.from(payload.base64, 'base64'); - await fsP.writeFile(stagedPath, buffer); - - const mimeType = payload.mimeType || getMimeType(ext); - const fileSize = buffer.length; - - // Generate preview for images - let preview: string | null = null; - if (mimeType.startsWith('image/')) { - preview = await generateImagePreview(stagedPath, mimeType); - } - - return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview }; - }); - - // Load thumbnails for file paths on disk (used to restore previews in history) - // Save an image to a user-chosen location (base64 data URI or existing file path) - ipcMain.handle('media:saveImage', async (_, params: { - base64?: string; - mimeType?: string; - filePath?: string; - defaultFileName: string; - }) => { - try { - const ext = params.defaultFileName.includes('.') - ? params.defaultFileName.split('.').pop()! - : (params.mimeType?.split('/')[1] || 'png'); - const result = await dialog.showSaveDialog({ - defaultPath: join(homedir(), 'Downloads', params.defaultFileName), - filters: [ - { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, - { name: 'All Files', extensions: ['*'] }, - ], - }); - if (result.canceled || !result.filePath) return { success: false }; - - const fsP = await import('fs/promises'); - if (params.filePath) { - try { - await fsP.access(params.filePath); - await fsP.copyFile(params.filePath, result.filePath); - } catch { - return { success: false, error: 'Source file not found' }; - } - } else if (params.base64) { - const buffer = Buffer.from(params.base64, 'base64'); - await fsP.writeFile(result.filePath, buffer); - } else { - return { success: false, error: 'No image data provided' }; - } - return { success: true, savedPath: result.filePath }; - } catch (err) { - return { success: false, error: String(err) }; - } - }); - - ipcMain.handle('media:getThumbnails', async ( - _, - paths: Array<{ filePath?: string; gatewayUrl?: string; mimeType: string }>, - ) => { - const fsP = await import('fs/promises'); - const results: Record = {}; - for (const entry of paths) { - // Local on-disk file (the original code path). - if (entry.filePath) { - try { - const s = await fsP.stat(entry.filePath); - let preview: string | null = null; - if (entry.mimeType.startsWith('image/')) { - preview = await generateImagePreview(entry.filePath, entry.mimeType); - } - results[entry.filePath] = { preview, fileSize: s.size }; - } catch { - results[entry.filePath] = { preview: null, fileSize: 0 }; - } - continue; - } - // Gateway-injected outgoing media URL. The renderer cannot reach the - // Gateway HTTP server directly (CORS / env drift), so we resolve it - // here against OpenClaw's local outgoing media records and load the - // original file off disk. The URL shape is fixed by OpenClaw: - // /api/chat/media/outgoing///full - if (entry.gatewayUrl) { - const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl); - if (!resolved) { - results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; - continue; - } - try { - const s = await fsP.stat(resolved.path); - let preview: string | null = null; - if (resolved.mimeType.startsWith('image/')) { - preview = await generateImagePreview(resolved.path, resolved.mimeType); - } - results[entry.gatewayUrl] = { preview, fileSize: s.size }; - } catch { - results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; - } - } - } - return results; - }); -} - -/** - * Resolve a Gateway-emitted outgoing-media URL to the original file on disk. - * - * OpenClaw's runtime stages every assistant `MEDIA:/path` artifact under - * `~/.openclaw/media/outgoing/`: - * - `originals/.` — the source bytes copied verbatim - * - `records/.json` — `{ original: { path, contentType, ... }, ... }` - * - * The Gateway then injects an `assistant-media` content block with - * `url:'/api/chat/media/outgoing///full'`. - * We only need the `` segment to look up the record. - */ -async function resolveOutgoingMediaUrl( - gatewayUrl: string, -): Promise<{ path: string; mimeType: string } | null> { - try { - const m = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//); - if (!m) return null; - const attachmentId = decodeURIComponent(m[1]); - if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null; - const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`); - const fsP = await import('fs/promises'); - const raw = await fsP.readFile(recordPath, 'utf8'); - const record = JSON.parse(raw) as { - original?: { path?: string; contentType?: string }; - }; - const original = record?.original; - if (!original?.path) return null; - return { - path: original.path, - mimeType: typeof original.contentType === 'string' && original.contentType - ? original.contentType - : 'application/octet-stream', - }; - } catch { - return null; - } -} - -/** - * Session IPC handlers - * - * Performs a HARD delete of a session's JSONL transcript on disk. - * sessionKey format: "agent::" — e.g. "agent:main:session-1234567890". - * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl - * (where is typically a UUID resolved via sessions.json). - * - * For each deleted session we unlink every file that belongs to its on-disk id: - * - .jsonl — the live transcript - * - .deleted.jsonl — leftovers from earlier soft-delete releases - * - .jsonl.reset.* — historical snapshots produced by sessions.reset - * - .trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar - * - .trajectory-path.json — pointer to the runtime trajectory; if it - * points outside the sessions/ folder - * (OPENCLAW_TRAJECTORY_DIR override) the - * referenced file is unlinked too. - * - * The session entry is also removed from sessions.json so sessions.list stops - * surfacing it. Token-usage history reported by the Dashboard reads the same - * transcripts, so deleted conversations stop contributing to the chart. - * - * Path resolution and the sibling sweep are shared with the HTTP mirror at - * `electron/api/routes/sessions.ts` via `electron/utils/session-files.ts`, - * so both surfaces unlink the same set of files for a given session id. - */ -const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; - -function registerSessionHandlers(): void { - ipcMain.handle('session:delete', async (_, sessionKey: string) => { - try { - if (!sessionKey || !sessionKey.startsWith('agent:')) { - return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; - } - - const parts = sessionKey.split(':'); - if (parts.length < 3) { - return { success: false, error: `sessionKey has too few parts: ${sessionKey}` }; - } - - const agentId = parts[1]; - // Defence-in-depth: agentId becomes a path segment under - // ~/.openclaw/agents/. Reject anything that could escape that root - // (".." segments, slashes, NULs, etc.) before touching the FS. - if (!SAFE_AGENT_ID.test(agentId)) { - return { success: false, error: `Invalid agentId: ${agentId}` }; - } - - const openclawConfigDir = getOpenClawConfigDir(); - const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions'); - const sessionsJsonPath = join(sessionsDir, 'sessions.json'); - - logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`); - logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`); - - const fsP = await import('fs/promises'); - - // ── Step 1: read sessions.json to find the UUID file for this sessionKey ── - let sessionsJson: Record = {}; - try { - const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); - sessionsJson = JSON.parse(raw) as Record; - } catch (e) { - logger.warn(`[session:delete] Could not read sessions.json: ${String(e)}`); - return { success: false, error: `Could not read sessions.json: ${String(e)}` }; - } - - const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey); - if (!resolution.ok) { - if (resolution.failure.kind === 'not-found') { - const rawVal = sessionsJson[sessionKey]; - logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`); - return { success: false, error: `Cannot resolve file for session: ${sessionKey}` }; - } - logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`); - return { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` }; - } - - const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution; - logger.info(`[session:delete] file: ${resolvedSrcPath}`); - - // ── Step 2: hard-delete the JSONL transcript and its siblings ── - const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId); - for (const removedPath of sweep.removed) { - logger.info(`[session:delete] Unlinked ${removedPath}`); - } - for (const { path: failedPath, error } of sweep.errors) { - logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`); - } - logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`); - - // ── Step 3: remove the entry from sessions.json ── - try { - // Re-read to avoid race conditions - const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); - const json2 = JSON.parse(raw2) as Record; - removeSessionEntry(json2, sessionKey); - await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); - logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`); - } catch (e) { - logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`); - // Non-fatal — transcript files were already unlinked. - } - - return { success: true }; - } catch (err) { - logger.error(`[session:delete] Unexpected error for ${sessionKey}:`, err); - return { success: false, error: String(err) }; - } - }); - - ipcMain.handle('session:rename', async (_, sessionKey: string, label: string) => { - try { - if (!sessionKey || !sessionKey.startsWith('agent:')) { - return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; - } - if (!label || typeof label !== 'string' || !label.trim()) { - return { success: false, error: 'Label cannot be empty' }; - } - - const parts = sessionKey.split(':'); - if (parts.length < 3) { - return { success: false, error: `Malformed sessionKey: ${sessionKey}` }; - } - const agentId = parts[1]; - if (!SAFE_AGENT_ID.test(agentId)) { - return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` }; - } - - const sessionsJsonPath = join( - getOpenClawConfigDir(), - 'agents', - agentId, - 'sessions', - 'sessions.json', - ); - - const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); - const json = JSON.parse(raw) as Record; - - // Update label in sessions.json — supports both object-keyed and array formats - let found = false; - if (json[sessionKey] && typeof json[sessionKey] === 'object') { - (json[sessionKey] as Record).label = label.trim(); - found = true; - } - if (Array.isArray(json.sessions)) { - for (const entry of json.sessions as Array>) { - if (entry.key === sessionKey || entry.sessionKey === sessionKey) { - entry.label = label.trim(); - found = true; - } - } - } - - if (!found) { - return { success: false, error: `Session not found in sessions.json: ${sessionKey}` }; - } - - await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8'); - logger.info(`[session:rename] key=${sessionKey} label=${label.trim()}`); - return { success: true }; - } catch (err) { - logger.error(`[session:rename] Unexpected error for ${sessionKey}:`, err); - return { success: false, error: String(err) }; - } - }); -} // ── File preview (sandboxed) ────────────────────────────────────────── // diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts deleted file mode 100644 index 09f606e7..00000000 --- a/electron/main/ipc/host-api-proxy.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ipcMain } from 'electron'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import { getPort } from '../../utils/config'; -import { getHostApiToken } from '../../api/server'; - -type HostApiFetchRequest = { - path: string; - method?: string; - headers?: Record; - body?: unknown; -}; - -export function registerHostApiProxyHandlers(): void { - const hostApiPort = getPort('CLAWX_HOST_API'); - - // Expose the per-session auth token to the renderer so the browser-fallback - // path in host-api.ts can authenticate against the Host API server. - ipcMain.handle('hostapi:token', () => getHostApiToken()); - - 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 || {}) }; - // Inject the per-session auth token so the Host API server accepts this request. - headers['Authorization'] = `Bearer ${getHostApiToken()}`; - let body: string | undefined; - - if (request.body !== undefined && request.body !== null) { - if (typeof request.body === 'string') { - body = request.body; - } else { - body = JSON.stringify(request.body); - } - // Ensure Content-Type is set for requests with a body so the - // server's anti-CSRF Content-Type gate does not reject them. - if (!headers['Content-Type'] && !headers['content-type']) { - headers['Content-Type'] = 'application/json'; - } - } - - const response = await proxyAwareFetch(`http://127.0.0.1:${hostApiPort}${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), - }, - }; - } - }); -} diff --git a/electron/main/ipc/host-contract.ts b/electron/main/ipc/host-contract.ts new file mode 100644 index 00000000..bf625356 --- /dev/null +++ b/electron/main/ipc/host-contract.ts @@ -0,0 +1,52 @@ +import type { HostApiContract } from '@shared/host-api/contract'; + +export type HostRequest = { + id: string; + module: string; + action: string; + payload?: unknown; +}; + +export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL'; + +export type HostResponse = + | { id?: string; ok: true; data: T } + | { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } }; + +export type RuntimeHostAction = (payload?: unknown) => Promise | unknown; +type MaybePromise = T | Promise; + +type HostServiceFunction = TFunction extends (...args: infer Args) => infer Result + ? (...args: Args) => MaybePromise> + : never; + +type HostServiceModule = { + [A in keyof TModule]: HostServiceFunction; +}; + +export type HostServiceRegistry = { + [M in keyof HostApiContract]?: Partial>; +}; +export type CompleteHostServiceRegistry = { + [M in keyof HostApiContract]: HostServiceModule; +}; + +export type HostApiContribution = { + module: string; + actions: Record; +}; + +export type HostApiContributionRegistrar = { + register: (extensionId: string, contributions: HostApiContribution[]) => () => void; +}; + +export function isHostRequest(value: unknown): value is HostRequest { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return typeof record.id === 'string' + && record.id.length > 0 + && typeof record.module === 'string' + && record.module.length > 0 + && typeof record.action === 'string' + && record.action.length > 0; +} diff --git a/electron/main/ipc/host-invoke.ts b/electron/main/ipc/host-invoke.ts new file mode 100644 index 00000000..d8f970a3 --- /dev/null +++ b/electron/main/ipc/host-invoke.ts @@ -0,0 +1,134 @@ +import { ipcMain } from 'electron'; +import { + type HostApiContribution, + type HostResponse, + type HostServiceRegistry, + type RuntimeHostAction, + isHostRequest, +} from './host-contract'; + +type RegisteredHostAction = { + action: RuntimeHostAction; + ownerId: string; +}; + +function assertValidContributionKey(kind: 'module' | 'action', value: string): void { + if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) { + throw new Error(`Invalid host API ${kind}: ${value}`); + } +} + +export class HostApiRegistry { + private modules = new Map>(); + + registerCoreServices(services: HostServiceRegistry): void { + for (const [moduleName, actions] of Object.entries(services)) { + if (!actions || typeof actions !== 'object') continue; + for (const [actionName, action] of Object.entries(actions)) { + if (typeof action !== 'function') continue; + this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core'); + } + } + } + + registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void { + const registered: Array<{ module: string; action: string }> = []; + + for (const contribution of contributions) { + assertValidContributionKey('module', contribution.module); + for (const [actionName, action] of Object.entries(contribution.actions)) { + assertValidContributionKey('action', actionName); + this.registerAction(contribution.module, actionName, action, extensionId); + registered.push({ module: contribution.module, action: actionName }); + } + } + + return () => { + for (const { module, action } of registered) { + const moduleActions = this.modules.get(module); + const registeredAction = moduleActions?.get(action); + if (registeredAction?.ownerId === extensionId) { + moduleActions?.delete(action); + } + if (moduleActions?.size === 0) { + this.modules.delete(module); + } + } + }; + } + + resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined { + return this.modules.get(moduleName)?.get(actionName)?.action; + } + + private registerAction( + moduleName: string, + actionName: string, + action: RuntimeHostAction, + ownerId: string, + ): void { + const moduleActions = this.modules.get(moduleName) ?? new Map(); + if (moduleActions.has(actionName)) { + throw new Error(`Host API action already registered: ${moduleName}.${actionName}`); + } + moduleActions.set(actionName, { action, ownerId }); + this.modules.set(moduleName, moduleActions); + } +} + +function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry { + if (registryOrServices instanceof HostApiRegistry) { + return registryOrServices; + } + const registry = new HostApiRegistry(); + registry.registerCoreServices(registryOrServices); + return registry; +} + +export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) { + const registry = toHostApiRegistry(registryOrServices); + return async function dispatchHostRequest(request: unknown): Promise { + const requestId = request && typeof request === 'object' + ? String((request as Record).id ?? '') + : undefined; + + if (!isHostRequest(request)) { + return { + id: requestId, + ok: false, + error: { code: 'VALIDATION', message: 'Invalid host request format' }, + }; + } + + const action = registry.resolve(request.module, request.action); + if (typeof action !== 'function') { + return { + id: request.id, + ok: false, + error: { + code: 'UNSUPPORTED', + message: `Unsupported host request: ${request.module}.${request.action}`, + }, + }; + } + + try { + const data = await action(request.payload); + return { id: request.id, ok: true, data }; + } catch (error) { + return { + id: request.id, + ok: false, + error: { + code: 'INTERNAL', + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }; +} + +export function registerHostInvokeHandler(registry: HostApiRegistry): void { + const dispatch = createHostInvokeDispatcher(registry); + ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request)); +} diff --git a/electron/main/menu.ts b/electron/main/menu.ts index f2edb3d5..4ff27ba2 100644 --- a/electron/main/menu.ts +++ b/electron/main/menu.ts @@ -3,12 +3,33 @@ * Creates the native application menu for macOS/Windows/Linux */ import { Menu, app, shell, BrowserWindow } from 'electron'; +import { MENU_LABELS } from '@shared/i18n/resources'; +import { resolveSupportedLanguage, type LanguageCode } from '@shared/language'; +import { getSetting } from '../utils/store'; + +function applyAppName(label: string): string { + return label.replaceAll('{{appName}}', app.name); +} + +async function resolveMenuLanguage(language?: string): Promise { + if (language) return resolveSupportedLanguage(language); + try { + return resolveSupportedLanguage(await getSetting('language')); + } catch { + return resolveSupportedLanguage(app.getLocale()); + } +} + +function getMenuTargetWindow(): BrowserWindow | null { + return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null; +} /** * Create application menu */ -export function createMenu(): void { +export async function createMenu(language?: string): Promise { const isMac = process.platform === 'darwin'; + const labels = MENU_LABELS[await resolveMenuLanguage(language)]; const template: Electron.MenuItemConstructorOptions[] = [ // App menu (macOS only) @@ -17,24 +38,24 @@ export function createMenu(): void { { label: app.name, submenu: [ - { role: 'about' as const }, + { role: 'about' as const, label: applyAppName(labels.app.about) }, { type: 'separator' as const }, { - label: 'Preferences...', + label: labels.app.preferences, accelerator: 'Cmd+,', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/settings'); }, }, { type: 'separator' as const }, - { role: 'services' as const }, + { role: 'services' as const, label: labels.app.services }, { type: 'separator' as const }, - { role: 'hide' as const }, - { role: 'hideOthers' as const }, - { role: 'unhide' as const }, + { role: 'hide' as const, label: applyAppName(labels.app.hide) }, + { role: 'hideOthers' as const, label: labels.app.hideOthers }, + { role: 'unhide' as const, label: labels.app.unhide }, { type: 'separator' as const }, - { role: 'quit' as const }, + { role: 'quit' as const, label: applyAppName(labels.app.quit) }, ], }, ] @@ -42,110 +63,113 @@ export function createMenu(): void { // File menu { - label: 'File', + label: labels.file.label, submenu: [ { - label: 'New Chat', + id: 'new-chat', + label: labels.file.newChat, accelerator: 'CmdOrCtrl+N', click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/chat'); + const win = getMenuTargetWindow(); + win?.webContents.send('new-chat'); }, }, { type: 'separator' }, - isMac ? { role: 'close' } : { role: 'quit' }, + isMac + ? { role: 'close', label: labels.file.close } + : { role: 'quit', label: applyAppName(labels.app.quit) }, ], }, // Edit menu { - label: 'Edit', + label: labels.edit.label, submenu: [ - { role: 'undo' }, - { role: 'redo' }, + { role: 'undo', label: labels.edit.undo }, + { role: 'redo', label: labels.edit.redo }, { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, + { role: 'cut', label: labels.edit.cut }, + { role: 'copy', label: labels.edit.copy }, + { role: 'paste', label: labels.edit.paste }, ...(isMac ? [ - { role: 'pasteAndMatchStyle' as const }, - { role: 'delete' as const }, - { role: 'selectAll' as const }, + { role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle }, + { role: 'delete' as const, label: labels.edit.delete }, + { role: 'selectAll' as const, label: labels.edit.selectAll }, ] : [ - { role: 'delete' as const }, + { role: 'delete' as const, label: labels.edit.delete }, { type: 'separator' as const }, - { role: 'selectAll' as const }, + { role: 'selectAll' as const, label: labels.edit.selectAll }, ]), ], }, // View menu { - label: 'View', + label: labels.view.label, submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, + { role: 'reload', label: labels.view.reload }, + { role: 'forceReload', label: labels.view.forceReload }, + { role: 'toggleDevTools', label: labels.view.toggleDevTools }, { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, + { role: 'resetZoom', label: labels.view.resetZoom }, + { role: 'zoomIn', label: labels.view.zoomIn }, + { role: 'zoomOut', label: labels.view.zoomOut }, { type: 'separator' }, - { role: 'togglefullscreen' }, + { role: 'togglefullscreen', label: labels.view.toggleFullscreen }, ], }, // Navigate menu { - label: 'Navigate', + label: labels.navigate.label, submenu: [ { - label: 'Dashboard', + label: labels.navigate.dashboard, accelerator: 'CmdOrCtrl+1', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/'); }, }, { - label: 'Chat', + label: labels.navigate.chat, accelerator: 'CmdOrCtrl+2', click: () => { - const win = BrowserWindow.getFocusedWindow(); - win?.webContents.send('navigate', '/chat'); + const win = getMenuTargetWindow(); + win?.webContents.send('navigate', '/'); }, }, { - label: 'Channels', + label: labels.navigate.channels, accelerator: 'CmdOrCtrl+3', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/channels'); }, }, { - label: 'Skills', + label: labels.navigate.skills, accelerator: 'CmdOrCtrl+4', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/skills'); }, }, { - label: 'Cron Tasks', + label: labels.navigate.cronTasks, accelerator: 'CmdOrCtrl+5', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/cron'); }, }, { - label: 'Settings', + label: labels.navigate.settings, accelerator: isMac ? 'Cmd+,' : 'Ctrl+,', click: () => { - const win = BrowserWindow.getFocusedWindow(); + const win = getMenuTargetWindow(); win?.webContents.send('navigate', '/settings'); }, }, @@ -154,40 +178,41 @@ export function createMenu(): void { // Window menu { - label: 'Window', + label: labels.window.label, submenu: [ - { role: 'minimize' }, - { role: 'zoom' }, + { role: 'minimize', label: labels.window.minimize }, + { role: 'zoom', label: labels.window.zoom }, ...(isMac ? [ { type: 'separator' as const }, - { role: 'front' as const }, + { role: 'front' as const, label: labels.window.front }, { type: 'separator' as const }, - { role: 'window' as const }, + { role: 'window' as const, label: labels.window.label }, ] - : [{ role: 'close' as const }]), + : [{ role: 'close' as const, label: labels.window.close }]), ], }, // Help menu { role: 'help', + label: labels.help.label, submenu: [ { - label: 'Documentation', + label: labels.help.documentation, click: async () => { await shell.openExternal('https://claw-x.com'); }, }, { - label: 'Report Issue', + label: labels.help.reportIssue, click: async () => { await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues'); }, }, { type: 'separator' }, { - label: 'OpenClaw Documentation', + label: labels.help.openClawDocumentation, click: async () => { await shell.openExternal('https://docs.openclaw.ai'); }, diff --git a/electron/main/proxy.ts b/electron/main/proxy.ts index f8982b13..dc0f7db4 100644 --- a/electron/main/proxy.ts +++ b/electron/main/proxy.ts @@ -4,7 +4,14 @@ import { buildElectronProxyConfig } from '../utils/proxy'; import { logger } from '../utils/logger'; export async function applyProxySettings( - partialSettings?: Pick + partialSettings?: Pick, ): Promise { const settings = partialSettings ?? await getAllSettings(); const config = buildElectronProxyConfig(settings); diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 1a19a58e..bf6e1cad 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -230,7 +230,7 @@ export class AppUpdater extends EventEmitter { * Start a countdown that auto-installs the downloaded update. * Sends `update:auto-install-countdown` events to the renderer each second. */ - private startAutoInstallCountdown(): void { + startAutoInstallCountdown(): void { this.clearAutoInstallTimer(); this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS; this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown }); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 052548b7..b82802d3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,6 +3,19 @@ * Exposes safe APIs to the renderer process via contextBridge */ import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import type { HostRequest } from '@shared/host-api/types'; +import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract'; + +const validStaticEventChannels: Set = new Set( + Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)), +); +const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i; + +function isValidEventChannel(channel: string): boolean { + return validStaticEventChannels.has(channel) + || DYNAMIC_CHANNEL_EVENT_RE.test(channel) + || channel.startsWith('ext:'); +} /** * IPC renderer methods exposed to the renderer process @@ -16,34 +29,19 @@ const electronAPI = { const validChannels = [ // Gateway 'gateway:status', - 'gateway:isConnected', - 'gateway:start', - 'gateway:stop', - 'gateway:restart', - 'gateway:rpc', - 'gateway:httpProxy', - 'hostapi:fetch', - 'hostapi:token', - 'gateway:health', - 'gateway:getControlUiUrl', // OpenClaw 'openclaw:status', - 'openclaw:isReady', // Shell 'shell:openExternal', 'shell:showItemInFolder', 'shell:openPath', // Dialog 'dialog:open', - 'dialog:save', 'dialog:message', // App 'app:version', 'app:name', - 'app:getPath', 'app:platform', - 'app:quit', - 'app:relaunch', 'app:request', // Window controls 'window:minimize', @@ -84,51 +82,6 @@ const electronAPI = { 'provider:setDefault', 'provider:getDefault', 'provider:validateKey', - 'provider:requestOAuth', - 'provider:cancelOAuth', - // Cron - 'cron:list', - 'cron:create', - 'cron:update', - 'cron:delete', - 'cron:toggle', - 'cron:trigger', - // Channel Config - 'channel:saveConfig', - 'channel:getConfig', - 'channel:getFormValues', - 'channel:deleteConfig', - 'channel:listConfigured', - 'channel:setEnabled', - 'channel:validate', - 'channel:validateCredentials', - // WhatsApp - 'channel:requestWhatsAppQr', - 'channel:cancelWhatsAppQr', - // ClawHub - 'clawhub:search', - 'clawhub:install', - 'clawhub:uninstall', - 'clawhub:list', - 'clawhub:openSkillReadme', - // UV - 'uv:check', - 'uv:install-all', - // Skill config (direct file access) - 'skill:updateConfig', - 'skill:getConfig', - 'skill:getAllConfigs', - // Logs - 'log:getRecent', - 'log:readFile', - 'log:getFilePath', - 'log:getDir', - 'log:listFiles', - // File staging & media - 'file:stage', - 'file:stageBuffer', - 'media:getThumbnails', - 'media:saveImage', // File preview (sandboxed read/write/list/tree) 'file:readText', 'file:readBinary', @@ -136,14 +89,7 @@ const electronAPI = { 'file:stat', 'file:listDir', 'file:listTree', - // Chat send with media (reads staged files in main process) - 'chat:sendWithMedia', - // Session management - 'session:delete', - 'session:rename', // OpenClaw extras - 'openclaw:getDir', - 'openclaw:getConfigDir', 'openclaw:getSkillsDir', 'openclaw:getCliCommand', ]; @@ -159,40 +105,7 @@ const electronAPI = { * Listen for events from main process */ on: (channel: string, callback: (...args: unknown[]) => void) => { - const validChannels = [ - 'gateway:status-changed', - 'gateway:message', - 'gateway:notification', - 'gateway:health-changed', - 'gateway:presence-changed', - 'gateway:channel-status', - 'gateway:chat-message', - 'chat:runtime-event', - 'channel:whatsapp-qr', - 'channel:whatsapp-success', - 'channel:whatsapp-error', - 'channel:wechat-qr', - 'channel:wechat-success', - 'channel:wechat-error', - 'gateway:exit', - 'gateway:error', - 'navigate', - 'update:status-changed', - 'update:checking', - 'update:available', - 'update:not-available', - 'update:progress', - 'update:downloaded', - 'update:error', - 'update:auto-install-countdown', - 'cron:updated', - 'oauth:code', - 'oauth:success', - 'oauth:error', - 'openclaw:cli-installed', - ]; - - if (validChannels.includes(channel) || channel.startsWith('ext:')) { + if (isValidEventChannel(channel)) { const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => { callback(...args); }; @@ -211,38 +124,7 @@ const electronAPI = { * Listen for a single event from main process */ once: (channel: string, callback: (...args: unknown[]) => void) => { - const validChannels = [ - 'gateway:status-changed', - 'gateway:message', - 'gateway:notification', - 'gateway:health-changed', - 'gateway:presence-changed', - 'gateway:channel-status', - 'gateway:chat-message', - 'chat:runtime-event', - 'channel:whatsapp-qr', - 'channel:whatsapp-success', - 'channel:whatsapp-error', - 'channel:wechat-qr', - 'channel:wechat-success', - 'channel:wechat-error', - 'gateway:exit', - 'gateway:error', - 'navigate', - 'update:status-changed', - 'update:checking', - 'update:available', - 'update:not-available', - 'update:progress', - 'update:downloaded', - 'update:error', - 'update:auto-install-countdown', - 'oauth:code', - 'oauth:success', - 'oauth:error', - ]; - - if (validChannels.includes(channel) || channel.startsWith('ext:')) { + if (isValidEventChannel(channel)) { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); return; } @@ -286,8 +168,13 @@ const electronAPI = { isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL, }; +const clawxAPI = { + hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request), +}; + // Expose the API to the renderer process contextBridge.exposeInMainWorld('electron', electronAPI); +contextBridge.exposeInMainWorld('clawx', clawxAPI); // Type declarations for the renderer process export type ElectronAPI = typeof electronAPI; diff --git a/electron/services/agents-api.ts b/electron/services/agents-api.ts new file mode 100644 index 00000000..3407f85c --- /dev/null +++ b/electron/services/agents-api.ts @@ -0,0 +1,125 @@ +import type { GatewayManager } from '../gateway/manager'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { + assignChannelToAgent, + clearChannelBinding, + createAgent, + deleteAgentConfig, + listAgentsSnapshot, + removeAgentWorkspaceDirectory, + resolveAccountIdForAgent, + updateAgentModel, + updateAgentName, +} from '../utils/agent-config'; +import { deleteChannelAccountConfig } from '../utils/channel-config'; +import { ensureClawXContext } from '../utils/openclaw-workspace'; +import { isRecord } from './payload-utils'; +import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from './providers/provider-runtime-sync'; + +type AgentsApiContext = { + gatewayManager: GatewayManager; +}; + +function requireString(payload: unknown, key: string): string { + if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) { + throw new Error(`${key} is required`); + } + return payload[key].trim(); +} + +function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void { + if (ctx.gatewayManager.getStatus().state !== 'stopped') { + ctx.gatewayManager.debouncedReload(); + return; + } + void reason; +} + +async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise { + try { + await ctx.gatewayManager.restart(); + console.log('[agents] Gateway restart completed after agent deletion'); + } catch (err) { + console.warn('[agents] Gateway restart after agent deletion failed:', err); + } +} + +export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] { + return { + list: async () => ({ success: true, ...(await listAgentsSnapshot()) }), + create: async (payload) => { + const name = requireString(payload, 'name'); + const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined; + const snapshot = await createAgent(name, { inheritWorkspace }); + syncAllProviderAuthToRuntime().catch((err) => { + console.warn('[agents] Failed to sync provider auth after agent creation:', err); + }); + scheduleGatewayReload(ctx, 'create-agent'); + void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => { + console.warn('[agents] Failed to ensure ClawX context after agent creation:', err); + }); + return { success: true, ...snapshot }; + }, + update: async (payload) => { + const agentId = requireString(payload, 'id'); + const name = requireString(payload, 'name'); + const snapshot = await updateAgentName(agentId, name); + scheduleGatewayReload(ctx, 'update-agent'); + return { success: true, ...snapshot }; + }, + updateModel: async (payload) => { + const agentId = requireString(payload, 'id'); + const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null; + const snapshot = await updateAgentModel(agentId, modelRef); + try { + await syncAllProviderAuthToRuntime(); + await syncAgentModelOverrideToRuntime(agentId); + } catch (syncError) { + console.warn('[agents] Failed to sync runtime after updating agent model:', syncError); + } + return { success: true, ...snapshot }; + }, + delete: async (payload) => { + const agentId = requireString(payload, 'id'); + const { snapshot, removedEntry } = await deleteAgentConfig(agentId); + await restartGatewayForAgentDeletion(ctx); + await removeAgentWorkspaceDirectory(removedEntry).catch((err) => { + console.warn('[agents] Failed to remove workspace after agent deletion:', err); + }); + return { success: true, ...snapshot }; + }, + assignChannel: async (payload) => { + const agentId = requireString(payload, 'id'); + const channelType = requireString(payload, 'channelType'); + const snapshot = await assignChannelToAgent(agentId, channelType); + scheduleGatewayReload(ctx, 'assign-channel'); + return { success: true, ...snapshot }; + }, + removeChannel: async (payload) => { + const agentId = requireString(payload, 'id'); + const channelType = requireString(payload, 'channelType'); + const ownerId = agentId.trim().toLowerCase(); + const snapshotBefore = await listAgentsSnapshot(); + const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners) + .filter(([channelAccountKey, owner]) => { + if (owner !== ownerId) return false; + return channelAccountKey.startsWith(`${channelType}:`); + }) + .map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1)); + if (ownedAccountIds.length === 0) { + const legacyAccountId = resolveAccountIdForAgent(agentId); + if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) { + ownedAccountIds.push(legacyAccountId); + } + } + + for (const accountId of ownedAccountIds) { + await deleteChannelAccountConfig(channelType, accountId); + await clearChannelBinding(channelType, accountId); + } + const snapshot = await listAgentsSnapshot(); + scheduleGatewayReload(ctx, 'remove-agent-channel'); + return { success: true, ...snapshot }; + }, + }; +} diff --git a/electron/services/app-api.ts b/electron/services/app-api.ts new file mode 100644 index 00000000..82a95f8b --- /dev/null +++ b/electron/services/app-api.ts @@ -0,0 +1,16 @@ +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor'; +import { isRecord } from './payload-utils'; + +type OpenClawDoctorPayload = { + mode?: unknown; +}; + +export function createAppApi(): CompleteHostServiceRegistry['app'] { + return { + openClawDoctor: async (payload) => { + const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {}; + return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor(); + }, + }; +} diff --git a/electron/api/routes/channels.ts b/electron/services/channels-api.ts similarity index 65% rename from electron/api/routes/channels.ts rename to electron/services/channels-api.ts index c9fe1d5f..bf1baa30 100644 --- a/electron/api/routes/channels.ts +++ b/electron/services/channels-api.ts @@ -1,11 +1,12 @@ import { readFile, readdir } from 'node:fs/promises'; -import { extractSessionRecords } from '../../utils/session-util'; -import type { IncomingMessage, ServerResponse } from 'http'; import { join } from 'node:path'; +import type { BrowserWindow } from 'electron'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { extractSessionRecords } from '../utils/session-util'; import { + cleanupDanglingWeChatPluginState, deleteChannelAccountConfig, deleteChannelConfig, - cleanupDanglingWeChatPluginState, getChannelFormValues, listConfiguredChannelAccountsFromConfig, listConfiguredChannels, @@ -16,14 +17,14 @@ import { setChannelEnabled, validateChannelConfig, validateChannelCredentials, -} from '../../utils/channel-config'; +} from '../utils/channel-config'; import { assignChannelAccountToAgent, clearAllBindingsForChannel, clearChannelBinding, listAgentsSnapshot, listAgentsSnapshotFromConfig, -} from '../../utils/agent-config'; +} from '../utils/agent-config'; import { ensureDiscordPluginInstalled, ensureDingTalkPluginInstalled, @@ -32,14 +33,14 @@ import { ensureWeChatPluginInstalled, ensureWeComPluginInstalled, ensureWhatsAppPluginInstalled, -} from '../../utils/plugin-install'; +} from '../utils/plugin-install'; import { computeChannelRuntimeStatus, pickChannelRuntimeStatus, type ChannelConnectionStatus, type ChannelRuntimeAccountSnapshot, type GatewayHealthState, -} from '../../utils/channel-status'; +} from '../utils/channel-status'; import { OPENCLAW_WECHAT_CHANNEL_TYPE, UI_WECHAT_CHANNEL_TYPE, @@ -47,16 +48,16 @@ import { isCanonicalOpenClawAccountId, toOpenClawChannelType, toUiChannelType, -} from '../../utils/channel-alias'; -import { getOpenClawConfigDir } from '../../utils/paths'; +} from '../utils/channel-alias'; +import { getOpenClawConfigDir } from '../utils/paths'; import { cancelWeChatLoginSession, saveWeChatAccountState, startWeChatLoginSession, waitForWeChatLoginSession, -} from '../../utils/wechat-login'; -import { whatsAppLoginManager } from '../../utils/whatsapp-login'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; +} from '../utils/wechat-login'; +import { whatsAppLoginManager } from '../utils/whatsapp-login'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -68,322 +69,48 @@ import { listSlackDirectoryPeersFromConfig, normalizeSlackMessagingTarget, normalizeWhatsAppMessagingTarget, -} from '../../utils/openclaw-sdk'; -import { logger } from '../../utils/logger'; -import { buildGatewayHealthSummary } from '../../utils/gateway-health'; -import type { GatewayHealthSummary } from '../../gateway/manager'; - -// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports -// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function listWhatsAppDirectoryGroupsFromConfig(_params: any): Promise { return []; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function listWhatsAppDirectoryPeersFromConfig(_params: any): Promise { return []; } -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; +} from '../utils/openclaw-sdk'; +import { buildGatewayHealthSummary } from '../utils/gateway-health'; +import { logger } from '../utils/logger'; +import type { GatewayManager, GatewayHealthSummary } from '../gateway/manager'; +import { isRecord } from './payload-utils'; const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000; const activeQrLogins = new Map(); -interface WebLoginStartResult { - qrcodeUrl?: string; - message?: string; - sessionKey?: string; +async function listWhatsAppDirectoryGroupsFromConfig(_params: unknown): Promise { return []; } +async function listWhatsAppDirectoryPeersFromConfig(_params: unknown): Promise { return []; } + +type ChannelsApiContext = { + gatewayManager: GatewayManager; + mainWindow?: BrowserWindow; +}; + +type JsonRecord = Record; +type MaybePromise = T | Promise; +type DirectoryEntry = { + kind: 'user' | 'group' | 'channel'; + id: string; + name?: string; + handle?: string; +}; + +interface ChannelTargetOptionView { + value: string; + label: string; + kind: 'user' | 'group' | 'channel'; } -function resolveStoredChannelType(channelType: string): string { - return toOpenClawChannelType(channelType); -} - -function buildQrLoginKey(channelType: string, accountId?: string): string { - return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`; -} - -async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise { - const config = await readOpenClawConfig(); - const configuredAccounts = listConfiguredChannelAccountsFromConfig(config) ?? {}; - const storedChannelType = resolveStoredChannelType(channelType); - const knownAccountIds = configuredAccounts[storedChannelType]?.accountIds ?? []; - return knownAccountIds.includes(accountId); -} - -async function validateCanonicalAccountId( - channelType: string, - accountId: string | undefined, - options?: { allowLegacyConfiguredId?: boolean; required?: boolean }, -): Promise { - if (!accountId) { - return options?.required ? 'accountId is required' : null; - } - const trimmed = accountId.trim(); - if (!trimmed) return 'accountId cannot be empty'; - if (isCanonicalOpenClawAccountId(trimmed)) { - return null; - } - if (options?.allowLegacyConfiguredId && await isLegacyConfiguredAccountId(channelType, trimmed)) { - return null; - } - // Backward compatibility note: - // existing legacy IDs can still be edited/bound if they already exist in config. - // new account IDs must be canonical to match OpenClaw runtime routing behavior. - return 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'; -} - -async function validateAccountIdOrReply( - res: ServerResponse, - channelType: string, - accountId: string | undefined, - options?: { required?: boolean }, -): Promise { - const error = await validateCanonicalAccountId(channelType, accountId, { - allowLegacyConfiguredId: true, - required: options?.required, - }); - if (!error) { - return true; - } - sendJson(res, 400, { success: false, error }); - return false; -} - -function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string { - const loginKey = buildQrLoginKey(channelType, accountId); - activeQrLogins.set(loginKey, sessionKey); - return loginKey; -} - -function isActiveQrLogin(loginKey: string, sessionKey: string): boolean { - return activeQrLogins.get(loginKey) === sessionKey; -} - -function clearActiveQrLogin(channelType: string, accountId?: string): void { - activeQrLogins.delete(buildQrLoginKey(channelType, accountId)); -} - -function emitChannelEvent( - ctx: HostApiContext, - channelType: string, - event: 'qr' | 'success' | 'error', - payload: unknown, -): void { - const eventName = buildQrChannelEventName(channelType, event); - ctx.eventBus.emit(eventName, payload); - if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) { - ctx.mainWindow.webContents.send(eventName, payload); - } -} - -async function startWeChatQrLogin(ctx: HostApiContext, accountId?: string): Promise { - void ctx; - return await startWeChatLoginSession({ - ...(accountId ? { accountId } : {}), - force: true, - }); -} - -async function awaitWeChatQrLogin( - ctx: HostApiContext, - sessionKey: string, - loginKey: string, -): Promise { - try { - const result = await waitForWeChatLoginSession({ - sessionKey, - timeoutMs: WECHAT_QR_TIMEOUT_MS, - onQrRefresh: async ({ qrcodeUrl }) => { - if (!isActiveQrLogin(loginKey, sessionKey)) { - return; - } - emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', { - qr: qrcodeUrl, - raw: qrcodeUrl, - sessionKey, - }); - }, - }); - - if (!isActiveQrLogin(loginKey, sessionKey)) { - return; - } - - if (!result.connected || !result.accountId || !result.botToken) { - emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', result.message || 'WeChat login did not complete'); - return; - } - - const normalizedAccountId = await saveWeChatAccountState(result.accountId, { - token: result.botToken, - baseUrl: result.baseUrl, - userId: result.userId, - }); - await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId); - await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId); - scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`); - - if (!isActiveQrLogin(loginKey, sessionKey)) { - return; - } - - emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', { - accountId: normalizedAccountId, - rawAccountId: result.accountId, - message: result.message, - }); - } catch (error) { - if (!isActiveQrLogin(loginKey, sessionKey)) { - return; - } - emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', String(error)); - } finally { - if (isActiveQrLogin(loginKey, sessionKey)) { - activeQrLogins.delete(loginKey); - } - await cancelWeChatLoginSession(sessionKey); - } -} - -function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void { - if (ctx.gatewayManager.getStatus().state === 'stopped') { - return; - } - ctx.gatewayManager.debouncedRestart(); - void reason; -} - -// Plugin-based channels require a full Gateway process restart to properly -// initialize / tear-down plugin connections. SIGUSR1 in-process reload is -// not sufficient for channel plugins (see restartGatewayForAgentDeletion). -// OpenClaw 3.23+ does not reliably support in-process channel reload for any -// channel type. All channel config saves must trigger a full Gateway process -// restart to ensure the channel adapter properly initializes with the new config. -const FORCE_RESTART_CHANNELS = new Set([ - 'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE, - 'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost', -]); - -function scheduleGatewayChannelSaveRefresh( - ctx: HostApiContext, - channelType: string, - reason: string, -): void { - const storedChannelType = resolveStoredChannelType(channelType); - if (ctx.gatewayManager.getStatus().state === 'stopped') { - return; - } - if (FORCE_RESTART_CHANNELS.has(storedChannelType)) { - ctx.gatewayManager.debouncedRestart(150); - void reason; - return; - } - ctx.gatewayManager.debouncedReload(150); - void reason; -} - -function toComparableConfig(input: Record): Record { - const next: Record = {}; - for (const [key, value] of Object.entries(input)) { - if (value === undefined || value === null) continue; - if (typeof value === 'string') { - next[key] = value.trim(); - continue; - } - if (typeof value === 'number' || typeof value === 'boolean') { - next[key] = String(value); - } - } - return next; -} - -function isSameConfigValues( - existing: Record | undefined, - incoming: Record, -): boolean { - if (!existing) return false; - const next = toComparableConfig(incoming); - const keys = new Set([...Object.keys(existing), ...Object.keys(next)]); - if (keys.size === 0) return false; - for (const key of keys) { - if ((existing[key] ?? '') !== (next[key] ?? '')) { - return false; - } - } - return true; -} - -async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise { - const storedChannelType = resolveStoredChannelType(channelType); - // Multi-agent safety: only bind when the caller explicitly scopes the account. - // Global channel saves (no accountId) must not override routing to "main". - if (!accountId) return; - const agents = await listAgentsSnapshot(); - if (!agents.agents || agents.agents.length === 0) return; - - // Keep backward compatibility for the legacy default account. - if (accountId === 'default') { - if (agents.agents.some((entry) => entry.id === 'main')) { - await assignChannelAccountToAgent('main', storedChannelType, 'default'); - } - return; - } - - // Legacy compatibility: if accountId matches an existing agentId, keep auto-binding. - if (agents.agents.some((entry) => entry.id === accountId)) { - await migrateLegacyChannelWideBinding(storedChannelType); - await assignChannelAccountToAgent(accountId, storedChannelType, accountId); - return; - } - - await migrateLegacyChannelWideBinding(storedChannelType); -} - -async function migrateLegacyChannelWideBinding(channelType: string): Promise { - const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default'); - const legacyOwner = await readChannelBindingOwner(channelType); - if (!legacyOwner) { - return; - } - - const agents = await listAgentsSnapshot(); - const validAgentIds = new Set(agents.agents.map((agent) => agent.id)); - const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner) - ? explicitDefaultOwner - : (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null); - - if (defaultOwner) { - await assignChannelAccountToAgent(defaultOwner, channelType, 'default'); - } - - // Remove the legacy channel-wide fallback so newly added non-default - // accounts do not silently inherit default-agent routing. - await clearChannelBinding(channelType); -} - -async function readChannelBindingOwner(channelType: string, accountId?: string): Promise { - const config = await readOpenClawConfig(); - const bindings = Array.isArray((config as { bindings?: unknown }).bindings) - ? (config as { bindings: unknown[] }).bindings - : []; - - for (const binding of bindings) { - if (!binding || typeof binding !== 'object') continue; - const candidate = binding as { - agentId?: unknown; - match?: { channel?: unknown; accountId?: unknown } | unknown; - }; - if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue; - if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue; - const match = candidate.match as { channel?: unknown; accountId?: unknown }; - if (match.channel !== channelType) continue; - const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : ''; - if ((accountId?.trim() || '') !== bindingAccountId) continue; - return candidate.agentId; - } - - return null; +interface QQBotKnownUserRecord { + openid?: string; + type?: 'c2c' | 'group'; + nickname?: string; + groupOpenid?: string; + accountId?: string; + lastSeenAt?: number; } interface GatewayChannelStatusPayload { - channelOrder?: string[]; channels?: Record; channelAccounts?: Record>; channelDefaultAccountId?: Record; } @@ -426,14 +147,59 @@ interface ChannelAccountsView { accounts: ChannelAccountView[]; } -export function getChannelStatusDiagnostics(): { - lastChannelsStatusOkAt?: number; - lastChannelsStatusFailureAt?: number; -} { - return { - lastChannelsStatusOkAt, - lastChannelsStatusFailureAt, - }; +let lastChannelsStatusOkAt: number | undefined; +let lastChannelsStatusFailureAt: number | undefined; +const CHANNEL_TARGET_CACHE_TTL_MS = 60_000; +const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true'; +const channelTargetCache = new Map(); + +const FORCE_RESTART_CHANNELS = new Set([ + 'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE, + 'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost', +]); + +function requireString(payload: unknown, key: string): string { + if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) { + throw new Error(`${key} is required`); + } + return payload[key].trim(); +} + +function optionalString(payload: unknown, key: string): string | undefined { + if (!isRecord(payload) || typeof payload[key] !== 'string') return undefined; + return payload[key].trim() || undefined; +} + +function resolveStoredChannelType(channelType: string): string { + return toOpenClawChannelType(channelType); +} + +function buildQrLoginKey(channelType: string, accountId?: string): string { + return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`; +} + +async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise { + const config = await readOpenClawConfig(); + const configuredAccounts = listConfiguredChannelAccountsFromConfig(config) ?? {}; + const storedChannelType = resolveStoredChannelType(channelType); + const knownAccountIds = configuredAccounts[storedChannelType]?.accountIds ?? []; + return knownAccountIds.includes(accountId); +} + +async function validateCanonicalAccountId( + channelType: string, + accountId: string | undefined, + options?: { allowLegacyConfiguredId?: boolean; required?: boolean }, +): Promise { + if (!accountId) { + if (options?.required) throw new Error('accountId is required'); + return; + } + const trimmed = accountId.trim(); + if (!trimmed) throw new Error('accountId cannot be empty'); + if (isCanonicalOpenClawAccountId(trimmed)) return; + if (options?.allowLegacyConfiguredId && await isLegacyConfiguredAccountId(channelType, trimmed)) return; + throw new Error('Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'); } function gatewayHealthStateForChannels( @@ -442,10 +208,7 @@ function gatewayHealthStateForChannels( return gatewayHealthState === 'healthy' ? undefined : gatewayHealthState; } -function overlayStatusReason( - gatewayHealth: GatewayHealthSummary, - fallbackReason: string, -): string { +function overlayStatusReason(gatewayHealth: GatewayHealthSummary, fallbackReason: string): string { return gatewayHealth.reasons[0] || fallbackReason; } @@ -476,52 +239,23 @@ function shouldIncludeRuntimeAccountId( configuredAccountIds: Set, runtimeAccount: { configured?: boolean }, ): boolean { - if (configuredAccountIds.has(accountId)) { - return true; - } - // Defensive filtering: channels.status can occasionally expose transient - // runtime rows for stale sessions. Only include runtime-only accounts when - // gateway marks them as configured. + if (configuredAccountIds.has(accountId)) return true; return runtimeAccount.configured === true; } -interface ChannelTargetOptionView { - value: string; - label: string; - kind: 'user' | 'group' | 'channel'; +export function getChannelStatusDiagnostics(): { + lastChannelsStatusOkAt?: number; + lastChannelsStatusFailureAt?: number; +} { + return { lastChannelsStatusOkAt, lastChannelsStatusFailureAt }; } -interface QQBotKnownUserRecord { - openid?: string; - type?: 'c2c' | 'group'; - nickname?: string; - groupOpenid?: string; - accountId?: string; - lastSeenAt?: number; - interactionCount?: number; -} - -type JsonRecord = Record; -type DirectoryEntry = { - kind: 'user' | 'group' | 'channel'; - id: string; - name?: string; - handle?: string; -}; - -const CHANNEL_TARGET_CACHE_TTL_MS = 60_000; -const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true'; -const channelTargetCache = new Map(); -let lastChannelsStatusOkAt: number | undefined; -let lastChannelsStatusFailureAt: number | undefined; - export async function buildChannelAccountsView( - ctx: HostApiContext, + ctx: ChannelsApiContext, options?: { probe?: boolean; skipRuntime?: boolean }, ): Promise<{ channels: ChannelAccountsView[]; gatewayHealth: GatewayHealthSummary }> { const startedAt = Date.now(); const skipRuntime = options?.skipRuntime === true; - // Read config once and share across all sub-calls (was 5 readFile calls before). const openClawConfig = await readOpenClawConfig(); const [configuredChannels, configuredAccounts, agentsSnapshot] = await Promise.all([ @@ -533,10 +267,7 @@ export async function buildChannelAccountsView( let gatewayStatus: GatewayChannelStatusPayload | null = null; if (!skipRuntime) { try { - // probe=false uses cached runtime state (lighter); probe=true forces - // adapter-level connectivity checks for faster post-restart convergence. const probe = options?.probe === true; - // 8s timeout — fail fast when Gateway is busy with AI tasks. const rpcStartedAt = Date.now(); gatewayStatus = await ctx.gatewayManager.rpc( 'channels.status', @@ -569,7 +300,6 @@ export async function buildChannelAccountsView( }); const gatewayHealthState = gatewayHealthStateForChannels(gatewayHealth.state); const effectiveGatewayHealthState = skipRuntime ? undefined : gatewayHealthState; - const channelTypes = new Set([ ...configuredChannels, ...Object.keys(configuredAccounts), @@ -599,17 +329,11 @@ export async function buildChannelAccountsView( ?? fallbackDefault; const runtimeAccounts = gatewayStatus?.channelAccounts?.[rawChannelType] ?? []; const hasRuntimeConfigured = runtimeAccounts.some((account) => account.configured === true); - if (!hasLocalConfig && !hasRuntimeConfigured) { - continue; - } + if (!hasLocalConfig && !hasRuntimeConfigured) continue; const runtimeAccountIds = runtimeAccounts.reduce((acc, account) => { const accountId = typeof account.accountId === 'string' ? account.accountId.trim() : ''; - if (!accountId) { - return acc; - } - if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) { - return acc; - } + if (!accountId) return acc; + if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) return acc; acc.push(accountId); return acc; }, []); @@ -671,7 +395,7 @@ export async function buildChannelAccountsView( ? 'channels_status_timeout' : groupStatus === 'degraded' && effectiveGatewayHealthState ? overlayStatusReason(gatewayHealth, 'gateway_degraded') - : undefined, + : undefined, accounts, }); } @@ -707,18 +431,12 @@ function buildDirectoryTargetOptions( return results; } -function mergeChannelAccountConfig( - config: JsonRecord, - channelType: string, - accountId?: string, -): JsonRecord { +function mergeChannelAccountConfig(config: JsonRecord, channelType: string, accountId?: string): JsonRecord { const channels = (config.channels && typeof config.channels === 'object') ? config.channels as Record : undefined; const channelSection = channels?.[channelType]; - if (!channelSection || typeof channelSection !== 'object') { - return {}; - } + if (!channelSection || typeof channelSection !== 'object') return {}; const section = channelSection as JsonRecord; const resolvedAccountId = accountId?.trim() @@ -856,19 +574,13 @@ async function listSessionDerivedTargetOptions(params: { || readNonEmptyString(session.channel) || readNonEmptyString(origin?.provider) || readNonEmptyString(origin?.surface); - if (!sessionChannelType || resolveStoredChannelType(sessionChannelType) !== storedChannelType) { - continue; - } + if (!sessionChannelType || resolveStoredChannelType(sessionChannelType) !== storedChannelType) continue; const sessionAccountId = readNonEmptyString(deliveryContext?.accountId) || readNonEmptyString(session.lastAccountId) || readNonEmptyString(origin?.accountId); - if (params.accountId && sessionAccountId && sessionAccountId !== params.accountId) { - continue; - } - if (params.accountId && !sessionAccountId) { - continue; - } + if (params.accountId && sessionAccountId && sessionAccountId !== params.accountId) continue; + if (params.accountId && !sessionAccountId) continue; const value = readNonEmptyString(deliveryContext?.to) || readNonEmptyString(session.lastTo) @@ -880,9 +592,7 @@ async function listSessionDerivedTargetOptions(params: { || readNonEmptyString(origin?.label) || value; const label = buildChannelTargetLabel(labelBase, value); - if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) { - continue; - } + if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue; seen.add(value); candidates.push({ @@ -911,14 +621,9 @@ async function listWeComReqIdTargetOptions(accountId?: string, query?: string): const seen = new Set(); for (const file of files) { - if (!file.isFile() || !file.name.startsWith('reqid-map-') || !file.name.endsWith('.json')) { - continue; - } - + if (!file.isFile() || !file.name.startsWith('reqid-map-') || !file.name.endsWith('.json')) continue; const resolvedAccountId = file.name.slice('reqid-map-'.length, -'.json'.length); - if (accountId && resolvedAccountId !== accountId) { - continue; - } + if (accountId && resolvedAccountId !== accountId) continue; const raw = await readFile(join(wecomDir, file.name), 'utf8').catch(() => ''); if (!raw.trim()) continue; @@ -935,9 +640,7 @@ async function listWeComReqIdTargetOptions(accountId?: string, query?: string): if (!trimmedChatId) continue; const value = `wecom:${trimmedChatId}`; const label = buildChannelTargetLabel('WeCom chat', value); - if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) { - continue; - } + if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue; if (seen.has(value)) continue; seen.add(value); options.push({ value, label, kind: 'channel' }); @@ -952,9 +655,7 @@ async function fetchFeishuTargetOptions(accountId?: string, query?: string): Pro const accountConfig = mergeChannelAccountConfig(config, 'feishu', accountId); const appId = typeof accountConfig.appId === 'string' ? accountConfig.appId.trim() : ''; const appSecret = typeof accountConfig.appSecret === 'string' ? accountConfig.appSecret.trim() : ''; - if (!appId || !appSecret) { - return []; - } + if (!appId || !appSecret) return []; const q = query?.trim().toLowerCase() || ''; const configuredTargets: ChannelTargetOptionView[] = []; @@ -989,32 +690,21 @@ async function fetchFeishuTargetOptions(accountId?: string, query?: string): Pro const origin = resolveFeishuApiOrigin(accountConfig.domain); const tokenResponse = await proxyAwareFetch(`${origin}/open-apis/auth/v3/tenant_access_token/internal`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - app_id: appId, - app_secret: appSecret, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), }); const tokenPayload = await tokenResponse.json() as { code?: number; - msg?: string; tenant_access_token?: string; }; if (!tokenResponse.ok || tokenPayload.code !== 0 || !tokenPayload.tenant_access_token) { return configuredTargets; } - const headers = { - Authorization: `Bearer ${tokenPayload.tenant_access_token}`, - }; - + const headers = { Authorization: `Bearer ${tokenPayload.tenant_access_token}` }; const liveTargets: ChannelTargetOptionView[] = []; try { - const appResponse = await proxyAwareFetch(`${origin}/open-apis/application/v6/applications/${appId}?lang=zh_cn`, { - headers, - }); + const appResponse = await proxyAwareFetch(`${origin}/open-apis/application/v6/applications/${appId}?lang=zh_cn`, { headers }); const appPayload = await appResponse.json() as { code?: number; data?: { app?: JsonRecord } & JsonRecord; @@ -1119,22 +809,6 @@ async function listQQBotKnownTargetOptions(accountId?: string, query?: string): return options; } -async function listWeComTargetOptions(accountId?: string, query?: string): Promise { - const [reqIdTargets, sessionTargets] = await Promise.all([ - listWeComReqIdTargetOptions(accountId, query), - listSessionDerivedTargetOptions({ channelType: 'wecom', accountId, query }), - ]); - return mergeTargetOptions(sessionTargets, reqIdTargets); -} - -async function listDingTalkTargetOptions(accountId?: string, query?: string): Promise { - return await listSessionDerivedTargetOptions({ channelType: 'dingtalk', accountId, query }); -} - -async function listWeChatTargetOptions(accountId?: string, query?: string): Promise { - return await listSessionDerivedTargetOptions({ channelType: OPENCLAW_WECHAT_CHANNEL_TYPE, accountId, query }); -} - async function listConfigDirectoryTargetOptions(params: { channelType: 'discord' | 'telegram' | 'slack' | 'whatsapp'; accountId?: string; @@ -1153,42 +827,28 @@ async function listConfigDirectoryTargetOptions(params: { listDiscordDirectoryPeersFromConfig(commonParams), listDiscordDirectoryGroupsFromConfig(commonParams), ]); - return buildDirectoryTargetOptions( - [...users, ...groups] as DirectoryEntry[], - normalizeDiscordMessagingTarget, - ); + return buildDirectoryTargetOptions([...users, ...groups] as DirectoryEntry[], normalizeDiscordMessagingTarget); } - if (params.channelType === 'telegram') { const [users, groups] = await Promise.all([ listTelegramDirectoryPeersFromConfig(commonParams), listTelegramDirectoryGroupsFromConfig(commonParams), ]); - return buildDirectoryTargetOptions( - [...users, ...groups] as DirectoryEntry[], - normalizeTelegramMessagingTarget, - ); + return buildDirectoryTargetOptions([...users, ...groups] as DirectoryEntry[], normalizeTelegramMessagingTarget); } - if (params.channelType === 'slack') { const [users, groups] = await Promise.all([ listSlackDirectoryPeersFromConfig(commonParams), listSlackDirectoryGroupsFromConfig(commonParams), ]); - return buildDirectoryTargetOptions( - [...users, ...groups] as DirectoryEntry[], - normalizeSlackMessagingTarget, - ); + return buildDirectoryTargetOptions([...users, ...groups] as DirectoryEntry[], normalizeSlackMessagingTarget); } const [users, groups] = await Promise.all([ listWhatsAppDirectoryPeersFromConfig(commonParams), listWhatsAppDirectoryGroupsFromConfig(commonParams), ]); - return buildDirectoryTargetOptions( - [...users, ...groups] as DirectoryEntry[], - normalizeWhatsAppMessagingTarget, - ); + return buildDirectoryTargetOptions([...users, ...groups] as DirectoryEntry[], normalizeWhatsAppMessagingTarget); } async function listChannelTargetOptions(params: { @@ -1200,12 +860,8 @@ async function listChannelTargetOptions(params: { const cacheKey = buildChannelTargetCacheKey(params); if (CHANNEL_TARGET_CACHE_ENABLED) { const cached = channelTargetCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.targets; - } - if (cached) { - channelTargetCache.delete(cacheKey); - } + if (cached && cached.expiresAt > Date.now()) return cached.targets; + if (cached) channelTargetCache.delete(cacheKey); } const targets = await (async (): Promise => { @@ -1224,13 +880,21 @@ async function listChannelTargetOptions(params: { return mergeTargetOptions(knownTargets, sessionTargets); } if (storedChannelType === 'wecom') { - return await listWeComTargetOptions(params.accountId, params.query); + const [reqIdTargets, sessionTargets] = await Promise.all([ + listWeComReqIdTargetOptions(params.accountId, params.query), + listSessionDerivedTargetOptions({ channelType: 'wecom', accountId: params.accountId, query: params.query }), + ]); + return mergeTargetOptions(sessionTargets, reqIdTargets); } if (storedChannelType === 'dingtalk') { - return await listDingTalkTargetOptions(params.accountId, params.query); + return await listSessionDerivedTargetOptions({ channelType: 'dingtalk', accountId: params.accountId, query: params.query }); } if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) { - return await listWeChatTargetOptions(params.accountId, params.query); + return await listSessionDerivedTargetOptions({ + channelType: OPENCLAW_WECHAT_CHANNEL_TYPE, + accountId: params.accountId, + query: params.query, + }); } if ( storedChannelType === 'discord' @@ -1260,302 +924,287 @@ async function listChannelTargetOptions(params: { return targets; } -export async function handleChannelRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/channels/configured' && req.method === 'GET') { - const channels = await listConfiguredChannels(); - sendJson(res, 200, { success: true, channels: Array.from(new Set(channels.map((channel) => toUiChannelType(channel)))) }); - return true; +async function readChannelBindingOwner(channelType: string, accountId?: string): Promise { + const config = await readOpenClawConfig(); + const bindings = Array.isArray((config as { bindings?: unknown }).bindings) + ? (config as { bindings: unknown[] }).bindings + : []; + for (const binding of bindings) { + if (!binding || typeof binding !== 'object') continue; + const candidate = binding as { + agentId?: unknown; + match?: { channel?: unknown; accountId?: unknown } | unknown; + }; + if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue; + if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue; + const match = candidate.match as { channel?: unknown; accountId?: unknown }; + if (match.channel !== channelType) continue; + const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : ''; + if ((accountId?.trim() || '') !== bindingAccountId) continue; + return candidate.agentId; + } + return null; +} + +async function migrateLegacyChannelWideBinding(channelType: string): Promise { + const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default'); + const legacyOwner = await readChannelBindingOwner(channelType); + if (!legacyOwner) return; + + const agents = await listAgentsSnapshot(); + const validAgentIds = new Set(agents.agents.map((agent) => agent.id)); + const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner) + ? explicitDefaultOwner + : (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null); + + if (defaultOwner) { + await assignChannelAccountToAgent(defaultOwner, channelType, 'default'); + } + await clearChannelBinding(channelType); +} + +async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise { + const storedChannelType = resolveStoredChannelType(channelType); + if (!accountId) return; + const agents = await listAgentsSnapshot(); + if (!agents.agents || agents.agents.length === 0) return; + + if (accountId === 'default') { + if (agents.agents.some((entry) => entry.id === 'main')) { + await assignChannelAccountToAgent('main', storedChannelType, 'default'); + } + return; } - if (url.pathname === '/api/channels/accounts' && req.method === 'GET') { - try { - const mode = url.searchParams.get('mode') === 'config' ? 'config' : 'runtime'; - const probe = mode !== 'config' && url.searchParams.get('probe') === '1'; + if (agents.agents.some((entry) => entry.id === accountId)) { + await migrateLegacyChannelWideBinding(storedChannelType); + await assignChannelAccountToAgent(accountId, storedChannelType, accountId); + return; + } + + await migrateLegacyChannelWideBinding(storedChannelType); +} + +function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void { + if (ctx.gatewayManager.getStatus().state === 'stopped') return; + ctx.gatewayManager.debouncedRestart(); + void reason; +} + +function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void { + const storedChannelType = resolveStoredChannelType(channelType); + if (ctx.gatewayManager.getStatus().state === 'stopped') return; + if (FORCE_RESTART_CHANNELS.has(storedChannelType)) { + ctx.gatewayManager.debouncedRestart(150); + void reason; + return; + } + ctx.gatewayManager.debouncedReload(150); + void reason; +} + +function toComparableConfig(input: Record): Record { + const next: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue; + if (typeof value === 'string') { + next[key] = value.trim(); + continue; + } + if (typeof value === 'number' || typeof value === 'boolean') { + next[key] = String(value); + } + } + return next; +} + +function isSameConfigValues( + existing: Record | undefined, + incoming: Record, +): boolean { + if (!existing) return false; + const next = toComparableConfig(incoming); + const keys = new Set([...Object.keys(existing), ...Object.keys(next)]); + if (keys.size === 0) return false; + for (const key of keys) { + if ((existing[key] ?? '') !== (next[key] ?? '')) return false; + } + return true; +} + +function emitChannelEvent( + ctx: ChannelsApiContext, + channelType: string, + event: 'qr' | 'success' | 'error', + payload: unknown, +): void { + const eventName = buildQrChannelEventName(channelType, event); + if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) { + ctx.mainWindow.webContents.send(eventName, payload); + } +} + +async function awaitWeChatQrLogin( + ctx: ChannelsApiContext, + sessionKey: string, + loginKey: string, +): Promise { + try { + const result = await waitForWeChatLoginSession({ + sessionKey, + timeoutMs: WECHAT_QR_TIMEOUT_MS, + onQrRefresh: async ({ qrcodeUrl }) => { + if (activeQrLogins.get(loginKey) !== sessionKey) return; + emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', { qr: qrcodeUrl, raw: qrcodeUrl, sessionKey }); + }, + }); + + if (activeQrLogins.get(loginKey) !== sessionKey) return; + if (!result.connected || !result.accountId || !result.botToken) { + emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', result.message || 'WeChat login did not complete'); + return; + } + + const normalizedAccountId = await saveWeChatAccountState(result.accountId, { + token: result.botToken, + baseUrl: result.baseUrl, + userId: result.userId, + }); + await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId); + await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId); + scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`); + + if (activeQrLogins.get(loginKey) !== sessionKey) return; + emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', { + accountId: normalizedAccountId, + rawAccountId: result.accountId, + message: result.message, + }); + } catch (error) { + if (activeQrLogins.get(loginKey) !== sessionKey) return; + emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', String(error)); + } finally { + if (activeQrLogins.get(loginKey) === sessionKey) activeQrLogins.delete(loginKey); + await cancelWeChatLoginSession(sessionKey); + } +} + +async function ensureChannelPluginInstalled(storedChannelType: string): Promise { + const installers: Record MaybePromise<{ installed: boolean; warning?: string }>> = { + dingtalk: ensureDingTalkPluginInstalled, + wecom: ensureWeComPluginInstalled, + discord: ensureDiscordPluginInstalled, + qqbot: ensureQQBotPluginInstalled, + whatsapp: ensureWhatsAppPluginInstalled, + feishu: ensureFeishuPluginInstalled, + [OPENCLAW_WECHAT_CHANNEL_TYPE]: ensureWeChatPluginInstalled, + }; + const install = installers[storedChannelType]; + if (!install) return; + const result = await install(); + if (!result.installed) { + throw new Error(result.warning || `${toUiChannelType(storedChannelType)} plugin install failed`); + } +} + +export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceRegistry['channels'] { + return { + configured: async () => { + const channels = await listConfiguredChannels(); + return { success: true, channels: Array.from(new Set(channels.map((channel) => toUiChannelType(channel)))) }; + }, + accounts: async (payload) => { + const mode = isRecord(payload) && (payload.mode === 'config' || payload.configOnly === true) ? 'config' : 'runtime'; + const probe = mode !== 'config' && isRecord(payload) && payload.probe === true; logger.info(`[channels.accounts] request mode=${mode} probe=${probe ? '1' : '0'}`); const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, { probe, skipRuntime: mode === 'config', }); - sendJson(res, 200, { success: true, channels, gatewayHealth }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/targets' && req.method === 'GET') { - try { - const channelType = url.searchParams.get('channelType')?.trim() || ''; - const accountId = url.searchParams.get('accountId')?.trim() || undefined; - const query = url.searchParams.get('query')?.trim() || undefined; - if (!channelType) { - sendJson(res, 400, { success: false, error: 'channelType is required' }); - return true; - } - + return { success: true, channels, gatewayHealth }; + }, + targets: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); + const query = optionalString(payload, 'query'); const targets = await listChannelTargetOptions({ channelType, accountId, query }); - sendJson(res, 200, { success: true, channelType, accountId, targets }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); - const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); - if (!validAccountId) { - return true; - } - await setChannelDefaultAccount(body.channelType, body.accountId); - scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/binding' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req); - const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId, { required: true }); - if (!validAccountId) { - return true; - } + return { success: true, channelType, accountId, targets }; + }, + setDefaultAccount: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = requireString(payload, 'accountId'); + await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); + await setChannelDefaultAccount(channelType, accountId); + scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`); + return { success: true }; + }, + bindingSave: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = requireString(payload, 'accountId'); + const agentId = requireString(payload, 'agentId'); + await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true, required: true }); const agents = await listAgentsSnapshot(); - if (!agents.agents.some((entry) => entry.id === body.agentId)) { - throw new Error(`Agent "${body.agentId}" not found`); + if (!agents.agents.some((entry) => entry.id === agentId)) { + throw new Error(`Agent "${agentId}" not found`); } - const storedChannelType = resolveStoredChannelType(body.channelType); - if (body.accountId !== 'default') { + const storedChannelType = resolveStoredChannelType(channelType); + if (accountId !== 'default') { await migrateLegacyChannelWideBinding(storedChannelType); } - await assignChannelAccountToAgent(body.agentId, storedChannelType, body.accountId); - scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') { - try { - const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); - const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); - if (!validAccountId) { - return true; - } - await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId); - scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ channelType: string }>(req); - sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) }); - } catch (error) { - sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); - } - return true; - } - - if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ channelType: string; config: Record }>(req); - sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) }); - } catch (error) { - sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); - } - return true; - } - - if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ accountId: string }>(req); - await whatsAppLoginManager.start(body.accountId); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') { - try { - await whatsAppLoginManager.stop(); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/wechat/start' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ accountId?: string }>(req); - const requestedAccountId = body.accountId?.trim() || undefined; - - const installResult = await ensureWeChatPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' }); - return true; - } - - await cleanupDanglingWeChatPluginState(); - const startResult = await startWeChatQrLogin(ctx, requestedAccountId); - if (!startResult.qrcodeUrl || !startResult.sessionKey) { - throw new Error(startResult.message || 'Failed to generate WeChat QR code'); - } - - const loginKey = setActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, startResult.sessionKey, requestedAccountId); - emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', { - qr: startResult.qrcodeUrl, - raw: startResult.qrcodeUrl, - sessionKey: startResult.sessionKey, - }); - void awaitWeChatQrLogin(ctx, startResult.sessionKey, loginKey); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/wechat/cancel' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ accountId?: string }>(req); - const accountId = body.accountId?.trim() || undefined; - const loginKey = buildQrLoginKey(UI_WECHAT_CHANNEL_TYPE, accountId); - const sessionKey = activeQrLogins.get(loginKey); - clearActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, accountId); - if (sessionKey) { - await cancelWeChatLoginSession(sessionKey); - } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/config' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ channelType: string; config: Record; accountId?: string }>(req); - const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); - if (!validAccountId) { - return true; - } - const storedChannelType = resolveStoredChannelType(body.channelType); - if (storedChannelType === 'dingtalk') { - const installResult = await ensureDingTalkPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' }); - return true; - } - } - if (storedChannelType === 'wecom') { - const installResult = await ensureWeComPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' }); - return true; - } - } - if (storedChannelType === 'discord') { - const installResult = await ensureDiscordPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'Discord plugin install failed' }); - return true; - } - } - if (storedChannelType === 'qqbot') { - const installResult = await ensureQQBotPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'QQBot plugin install failed' }); - return true; - } - } - if (storedChannelType === 'whatsapp') { - const installResult = await ensureWhatsAppPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'WhatsApp plugin install failed' }); - return true; - } - } - // QQBot is installed as an official external channel plugin for this OpenClaw version. - if (storedChannelType === 'feishu') { - const installResult = await ensureFeishuPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' }); - return true; - } - } - if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) { - const installResult = await ensureWeChatPluginInstalled(); - if (!installResult.installed) { - sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' }); - return true; - } - } - const existingValues = await getChannelFormValues(body.channelType, body.accountId); - if (isSameConfigValues(existingValues, body.config)) { - await ensureScopedChannelBinding(body.channelType, body.accountId); + await assignChannelAccountToAgent(agentId, storedChannelType, accountId); + scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`); + return { success: true }; + }, + bindingDelete: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); + await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); + await clearChannelBinding(resolveStoredChannelType(channelType), accountId); + scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`); + return { success: true }; + }, + validateConfig: async (payload) => { + const channelType = requireString(payload, 'channelType'); + return { success: true, ...(await validateChannelConfig(channelType)) }; + }, + validateCredentials: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const config = isRecord(payload) && isRecord(payload.config) ? payload.config as Record : {}; + return { success: true, ...(await validateChannelCredentials(channelType, config)) }; + }, + saveConfig: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const config = isRecord(payload) && isRecord(payload.config) ? payload.config : {}; + const accountId = optionalString(payload, 'accountId'); + await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); + const storedChannelType = resolveStoredChannelType(channelType); + await ensureChannelPluginInstalled(storedChannelType); + const existingValues = await getChannelFormValues(channelType, accountId); + if (isSameConfigValues(existingValues, config)) { + await ensureScopedChannelBinding(channelType, accountId); scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`); - sendJson(res, 200, { success: true, noChange: true }); - return true; + return { success: true, noChange: true }; } - await saveChannelConfig(body.channelType, body.config, body.accountId); - await ensureScopedChannelBinding(body.channelType, body.accountId); + await saveChannelConfig(channelType, config, accountId); + await ensureScopedChannelBinding(channelType, accountId); scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') { - try { - const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req); - await setChannelEnabled(body.channelType, body.enabled); - scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(body.channelType)}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') { - try { - const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); - const accountId = url.searchParams.get('accountId') || undefined; - sendJson(res, 200, { - success: true, - values: await getChannelFormValues(channelType, accountId), - }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') { - try { - const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); - const accountId = url.searchParams.get('accountId') || undefined; + return { success: true }; + }, + setEnabled: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const enabled = isRecord(payload) && payload.enabled === true; + await setChannelEnabled(channelType, enabled); + scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`); + return { success: true }; + }, + formValues: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); + return { success: true, values: await getChannelFormValues(channelType, accountId) }; + }, + deleteConfig: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); const storedChannelType = resolveStoredChannelType(channelType); if (accountId) { await deleteChannelAccountConfig(channelType, accountId); @@ -1566,13 +1215,53 @@ export async function handleChannelRoutes( await clearAllBindingsForChannel(storedChannelType); scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`); } - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - void ctx; - return false; + return { success: true }; + }, + startLogin: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); + const storedChannelType = resolveStoredChannelType(channelType); + if (storedChannelType === 'whatsapp') { + await whatsAppLoginManager.start(accountId ?? 'default'); + return { success: true }; + } + if (storedChannelType !== OPENCLAW_WECHAT_CHANNEL_TYPE) { + throw new Error(`Unsupported login channel: ${channelType}`); + } + await ensureChannelPluginInstalled(storedChannelType); + await cleanupDanglingWeChatPluginState(); + const startResult = await startWeChatLoginSession({ + ...(accountId ? { accountId } : {}), + force: true, + }); + if (!startResult.qrcodeUrl || !startResult.sessionKey) { + throw new Error(startResult.message || 'Failed to generate WeChat QR code'); + } + const loginKey = buildQrLoginKey(UI_WECHAT_CHANNEL_TYPE, accountId); + activeQrLogins.set(loginKey, startResult.sessionKey); + emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', { + qr: startResult.qrcodeUrl, + raw: startResult.qrcodeUrl, + sessionKey: startResult.sessionKey, + }); + void awaitWeChatQrLogin(ctx, startResult.sessionKey, loginKey); + return { success: true }; + }, + cancelLogin: async (payload) => { + const channelType = requireString(payload, 'channelType'); + const accountId = optionalString(payload, 'accountId'); + const storedChannelType = resolveStoredChannelType(channelType); + if (storedChannelType === 'whatsapp') { + await whatsAppLoginManager.stop(); + return { success: true }; + } + if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) { + const loginKey = buildQrLoginKey(UI_WECHAT_CHANNEL_TYPE, accountId); + const sessionKey = activeQrLogins.get(loginKey); + activeQrLogins.delete(loginKey); + if (sessionKey) await cancelWeChatLoginSession(sessionKey); + } + return { success: true }; + }, + }; } diff --git a/electron/services/chat-api.ts b/electron/services/chat-api.ts new file mode 100644 index 00000000..0ed57f9b --- /dev/null +++ b/electron/services/chat-api.ts @@ -0,0 +1,112 @@ +import type { GatewayManager } from '../gateway/manager'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { logger } from '../utils/logger'; +import { isRecord } from './payload-utils'; + +const VISION_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/bmp', + 'image/webp', +]); + +type ChatSendWithMediaPayload = { + sessionKey?: unknown; + message?: unknown; + deliver?: unknown; + idempotencyKey?: unknown; + media?: unknown; +}; + +type MediaPayload = { + filePath?: unknown; + mimeType?: unknown; + fileName?: unknown; +}; + +function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> { + if (!Array.isArray(media)) return []; + return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => { + if (!isRecord(entry)) return []; + const item = entry as MediaPayload; + if (typeof item.filePath !== 'string' || !item.filePath) return []; + return [{ + filePath: item.filePath, + mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream', + fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file', + }]; + }); +} + +export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] { + return { + sendWithMedia: async (payload) => { + const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {}; + const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : ''; + const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : ''; + if (!sessionKey || !idempotencyKey) { + return { success: false, error: 'Invalid chat send payload' }; + } + + try { + let message = typeof body.message === 'string' ? body.message : ''; + const imageAttachments: Array> = []; + const fileReferences: string[] = []; + const media = normalizeMedia(body.media); + + if (media.length > 0) { + const fsP = await import('node:fs/promises'); + for (const item of media) { + const exists = await fsP.access(item.filePath).then(() => true, () => false); + logger.info( + `[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`, + ); + + fileReferences.push( + `[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`, + ); + + if (VISION_MIME_TYPES.has(item.mimeType)) { + const fileBuffer = await fsP.readFile(item.filePath); + const base64Data = fileBuffer.toString('base64'); + logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); + imageAttachments.push({ + content: base64Data, + mimeType: item.mimeType, + fileName: item.fileName, + }); + } + } + } + + if (fileReferences.length > 0) { + const refs = fileReferences.join('\n'); + message = message ? `${message}\n\n${refs}` : refs; + } + + const rpcParams: Record = { + sessionKey, + message, + deliver: body.deliver ?? false, + idempotencyKey, + }; + if (imageAttachments.length > 0) { + rpcParams.attachments = imageAttachments; + } + + logger.info( + `[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`, + ); + const result = await gatewayManager.rpc('chat.send', rpcParams, 120000); + logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); + const response = isRecord(result) && typeof result.runId === 'string' + ? { runId: result.runId } + : undefined; + return { success: true, ...(response ? { result: response } : {}) }; + } catch (error) { + logger.error(`[chat:sendWithMedia] Error: ${String(error)}`); + return { success: false, error: String(error) }; + } + }, + }; +} diff --git a/electron/services/cron-api.ts b/electron/services/cron-api.ts new file mode 100644 index 00000000..ab5cd04a --- /dev/null +++ b/electron/services/cron-api.ts @@ -0,0 +1,563 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron'; +import type { GatewayManager } from '../gateway/manager'; +import { getOpenClawConfigDir } from '../utils/paths'; +import { resolveAgentIdFromChannel } from '../utils/agent-config'; +import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias'; +import { resolveAccountIdFromSessionHistory } from '../utils/session-util'; +import { isRecord } from './payload-utils'; + +interface GatewayCronJob { + id: string; + name: string; + description?: string; + enabled: boolean; + createdAtMs: number; + updatedAtMs: number; + schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string }; + payload: { kind: string; message?: string; text?: string }; + delivery?: { mode: string; channel?: string; to?: string; accountId?: string }; + sessionTarget?: string; + state: { + nextRunAtMs?: number; + runningAtMs?: number; + lastRunAtMs?: number; + lastStatus?: string; + lastError?: string; + lastDurationMs?: number; + }; +} + +interface CronRunLogEntry { + jobId?: string; + action?: string; + status?: string; + error?: string; + summary?: string; + sessionId?: string; + sessionKey?: string; + ts?: number; + runAtMs?: number; + durationMs?: number; + model?: string; + provider?: string; +} + +interface CronSessionKeyParts { + agentId: string; + jobId: string; + runSessionId?: string; +} + +interface CronSessionFallbackMessage { + id: string; + role: 'assistant' | 'system'; + content: string; + timestamp: number; + isError?: boolean; +} + +type JsonRecord = Record; + +function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null { + if (!sessionKey.startsWith('agent:')) return null; + const parts = sessionKey.split(':'); + if (parts.length < 4 || parts[2] !== 'cron') return null; + const agentId = parts[1] || 'main'; + const jobId = parts[3]; + if (!jobId) return null; + if (parts.length === 4) return { agentId, jobId }; + if (parts.length === 6 && parts[4] === 'run' && parts[5]) { + return { agentId, jobId, runSessionId: parts[5] }; + } + return null; +} + +function normalizeTimestampMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function formatDuration(durationMs: number | undefined): string | null { + if (!durationMs || !Number.isFinite(durationMs)) return null; + if (durationMs < 1000) return `${Math.round(durationMs)}ms`; + if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`; + return `${Math.round(durationMs / 1000)}s`; +} + +function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null { + const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs); + if (!timestamp) return null; + + const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : ''; + const summary = typeof entry.summary === 'string' ? entry.summary.trim() : ''; + const error = typeof entry.error === 'string' ? entry.error.trim() : ''; + let content = summary || error; + if (!content) { + content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.'; + } + if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) { + content = `Run failed: ${content}`; + } + + const meta: string[] = []; + const duration = formatDuration(entry.durationMs); + if (duration) meta.push(`Duration: ${duration}`); + if (entry.provider && entry.model) meta.push(`Model: ${entry.provider}/${entry.model}`); + else if (entry.model) meta.push(`Model: ${entry.model}`); + if (meta.length > 0) content = `${content}\n\n${meta.join(' | ')}`; + + return { + id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`, + role: status === 'error' ? 'system' : 'assistant', + content, + timestamp, + ...(status === 'error' ? { isError: true } : {}), + }; +} + +async function readCronRunLog(jobId: string): Promise { + const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`); + const raw = await readFile(logPath, 'utf8').catch(() => ''); + if (!raw.trim()) return []; + + const entries: CronRunLogEntry[] = []; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed) as CronRunLogEntry; + if (!entry || entry.jobId !== jobId) continue; + if (entry.action && entry.action !== 'finished') continue; + entries.push(entry); + } catch { + // Ignore malformed log lines. + } + } + return entries; +} + +async function readSessionStoreEntry( + agentId: string, + sessionKey: string, +): Promise | undefined> { + const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json'); + const raw = await readFile(storePath, 'utf8').catch(() => ''); + if (!raw.trim()) return undefined; + + try { + const store = JSON.parse(raw) as Record; + const directEntry = store[sessionKey]; + if (directEntry && typeof directEntry === 'object') return directEntry as Record; + + const sessions = (store as { sessions?: unknown }).sessions; + if (Array.isArray(sessions)) { + const arrayEntry = sessions.find((entry) => { + if (!entry || typeof entry !== 'object') return false; + const record = entry as Record; + return record.key === sessionKey || record.sessionKey === sessionKey; + }); + if (arrayEntry && typeof arrayEntry === 'object') return arrayEntry as Record; + } + } catch { + return undefined; + } + return undefined; +} + +function buildCronSessionFallbackMessages(params: { + sessionKey: string; + job?: Pick; + runs: CronRunLogEntry[]; + sessionEntry?: { label?: string; updatedAt?: number }; + limit?: number; +}): CronSessionFallbackMessage[] { + const parsed = parseCronSessionKey(params.sessionKey); + if (!parsed) return []; + + const matchingRuns = params.runs + .filter((entry) => { + if (!parsed.runSessionId) return true; + return entry.sessionId === parsed.runSessionId || entry.sessionKey === `${params.sessionKey}`; + }) + .sort((a, b) => { + const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0; + const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0; + return left - right; + }); + + const messages: CronSessionFallbackMessage[] = []; + const prompt = params.job?.payload?.message || params.job?.payload?.text || ''; + const taskName = params.job?.name?.trim() + || params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim() + || ''; + const firstRelevantTimestamp = matchingRuns.length > 0 + ? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts)) + : (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt); + + if (taskName || prompt) { + const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task']; + if (prompt) lines.push(`Prompt: ${prompt}`); + messages.push({ + id: `cron-meta-${parsed.jobId}`, + role: 'system', + content: lines.join('\n'), + timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1), + }); + } + + matchingRuns.forEach((entry, index) => { + const message = buildCronRunMessage(entry, index); + if (message) messages.push(message); + }); + + if (matchingRuns.length === 0) { + const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs); + if (runningAt) { + messages.push({ + id: `cron-running-${parsed.jobId}`, + role: 'system', + content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.', + timestamp: runningAt, + }); + } else if (messages.length === 0) { + messages.push({ + id: `cron-empty-${parsed.jobId}`, + role: 'system', + content: 'No chat transcript is available for this scheduled task yet.', + timestamp: params.sessionEntry?.updatedAt ?? Date.now(), + }); + } + } + + const limit = typeof params.limit === 'number' && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : messages.length; + return messages.slice(-limit); +} + +function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null { + return null; +} + +function normalizeCronDelivery( + rawDelivery: unknown, + fallbackMode: CronJobDelivery['mode'] = 'none', +): CronJobDelivery { + if (!rawDelivery || typeof rawDelivery !== 'object') return { mode: fallbackMode }; + + const delivery = rawDelivery as JsonRecord; + const mode = delivery.mode === 'announce' ? 'announce' : fallbackMode; + const channel = typeof delivery.channel === 'string' && delivery.channel.trim() + ? toOpenClawChannelType(delivery.channel.trim()) + : undefined; + const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined; + const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim() + ? delivery.accountId.trim() + : undefined; + + if (mode === 'announce' && !channel) return { mode: 'none' }; + return { + mode, + ...(channel ? { channel } : {}), + ...(to ? { to } : {}), + ...(accountId ? { accountId } : {}), + }; +} + +function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['schedule'] { + if (schedule.kind === 'at' && typeof schedule.at === 'string') { + return { kind: 'at', at: schedule.at }; + } + if (schedule.kind === 'every' && typeof schedule.everyMs === 'number') { + return { + kind: 'every', + everyMs: schedule.everyMs, + ...(typeof (schedule as CronSchedule & { anchorMs?: unknown }).anchorMs === 'number' + ? { anchorMs: (schedule as CronSchedule & { anchorMs: number }).anchorMs } + : {}), + }; + } + if (schedule.kind === 'cron' && typeof schedule.expr === 'string') { + return { kind: 'cron', expr: schedule.expr, ...(schedule.tz ? { tz: schedule.tz } : {}) }; + } + return typeof schedule.expr === 'string' ? schedule.expr : ''; +} + +function normalizeCronDeliveryPatch(rawDelivery: unknown): Record { + if (!rawDelivery || typeof rawDelivery !== 'object') return {}; + + const delivery = rawDelivery as JsonRecord; + const patch: Record = {}; + if ('mode' in delivery) { + patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none'; + } + if ('channel' in delivery) { + patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim() + ? toOpenClawChannelType(delivery.channel.trim()) + : ''; + } + if ('to' in delivery) patch.to = typeof delivery.to === 'string' ? delivery.to : ''; + if ('accountId' in delivery) patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : ''; + return patch; +} + +function buildCronUpdatePatch(input: Record): Record { + const patch = { ...input }; + if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule }; + if (typeof patch.message === 'string') { + patch.payload = { kind: 'agentTurn', message: patch.message }; + delete patch.message; + } + if ('delivery' in patch) patch.delivery = normalizeCronDeliveryPatch(patch.delivery); + if ('agentId' in patch) { + patch.agentId = typeof patch.agentId === 'string' && patch.agentId.trim() ? patch.agentId.trim() : 'main'; + } + return patch; +} + +function transformCronJob(job: GatewayCronJob): CronJob { + const message = job.payload?.message || job.payload?.text || ''; + const gatewayDelivery = normalizeCronDelivery(job.delivery); + const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined; + const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery; + const target = channelType + ? { + channelType, + channelId: delivery.accountId || gatewayDelivery.channel || channelType, + channelName: channelType, + recipient: delivery.to, + } + : undefined; + const lastRun = job.state?.lastRunAtMs + ? { + time: new Date(job.state.lastRunAtMs).toISOString(), + success: job.state.lastStatus === 'ok', + error: job.state.lastError, + duration: job.state.lastDurationMs, + } + : undefined; + const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined; + const agentId = (job as unknown as { agentId?: string }).agentId || 'main'; + + return { + id: job.id, + name: job.name, + message, + schedule: normalizeCronSchedule(job.schedule), + delivery, + target, + enabled: job.enabled, + createdAt: new Date(job.createdAtMs).toISOString(), + updatedAt: new Date(job.updatedAtMs).toISOString(), + lastRun, + nextRun, + agentId, + }; +} + +async function listCronJobs(gatewayManager: GatewayManager): Promise { + let jobs: GatewayCronJob[] = []; + let usedFallback = false; + + try { + const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000); + const data = result as { jobs?: GatewayCronJob[] }; + jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []); + } catch { + try { + const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json'); + const raw = await readFile(cronJsonPath, 'utf-8'); + const parsed = JSON.parse(raw); + jobs = (Array.isArray(parsed) ? parsed : (parsed?.jobs ?? [])) as GatewayCronJob[]; + usedFallback = true; + } catch { + // No fallback data available. + } + } + + if (!usedFallback && jobs.length > 0) { + repairCronJobsInBackground(gatewayManager, jobs); + } + + return jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })); +} + +function repairCronJobsInBackground(gatewayManager: GatewayManager, jobs: GatewayCronJob[]): void { + const jobsToRepairDelivery = jobs.filter((job) => { + const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget) + && job.payload?.kind === 'agentTurn'; + return isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel; + }); + + if (jobsToRepairDelivery.length > 0) { + void (async () => { + for (const job of jobsToRepairDelivery) { + try { + await gatewayManager.rpc('cron.update', { + id: job.id, + patch: { delivery: { mode: 'none' } }, + }); + } catch { + // ignore per-job repair failure + } + } + })(); + for (const job of jobsToRepairDelivery) { + job.delivery = { mode: 'none' }; + if (job.state?.lastError?.includes('Channel is required')) { + job.state.lastError = undefined; + job.state.lastStatus = 'ok'; + } + } + } + + const jobsToRepairAgent = jobs.filter((job) => { + const jobAgentId = (job as unknown as { agentId?: string }).agentId; + return ( + (job.sessionTarget === 'isolated' || !job.sessionTarget) + && job.payload?.kind === 'agentTurn' + && job.delivery?.mode === 'announce' + && job.delivery?.channel + && jobAgentId === undefined + ); + }); + + if (jobsToRepairAgent.length > 0) { + void (async () => { + for (const job of jobsToRepairAgent) { + try { + const channel = toOpenClawChannelType(job.delivery!.channel!); + const accountId = job.delivery!.accountId; + const toAddress = job.delivery!.to; + let correctAgentId = await resolveAgentIdFromChannel(channel, accountId); + let resolvedAccountId: string | null = null; + if (!correctAgentId && !accountId && toAddress) { + resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel); + if (resolvedAccountId) { + correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId); + } + } + if (correctAgentId) { + const patch: Record = { agentId: correctAgentId }; + if (resolvedAccountId && !accountId) patch.delivery = { accountId: resolvedAccountId }; + await gatewayManager.rpc('cron.update', { id: job.id, patch }); + (job as unknown as { agentId: string }).agentId = correctAgentId; + if (resolvedAccountId && !accountId && job.delivery) job.delivery.accountId = resolvedAccountId; + } + } catch { + // ignore per-job repair failure + } + } + })(); + } +} + +function getId(payload: unknown): string { + const body = isRecord(payload) ? payload : {}; + const id = body.id; + if (typeof id !== 'string' || !id.trim()) throw new Error('id is required'); + return id.trim(); +} + +export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] { + return { + list: async () => listCronJobs(gatewayManager), + create: async (payload) => { + const input = payload; + const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main'; + const delivery = normalizeCronDelivery(input.delivery); + const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel); + if (delivery.mode === 'announce' && unsupportedDeliveryError) { + throw new Error(unsupportedDeliveryError); + } + const result = await gatewayManager.rpc('cron.add', { + name: input.name, + schedule: { kind: 'cron', expr: input.schedule }, + payload: { kind: 'agentTurn', message: input.message }, + enabled: typeof input.enabled === 'boolean' ? input.enabled : true, + wakeMode: 'next-heartbeat', + sessionTarget: 'isolated', + agentId, + delivery, + }); + if (!result || typeof result !== 'object') { + throw new Error('Cron create returned an invalid job'); + } + return transformCronJob(result as GatewayCronJob); + }, + update: async (payload) => { + const body = payload; + const id = getId(body); + const input = isRecord(body.input) ? body.input : {}; + const patch = buildCronUpdatePatch(input); + delete patch.id; + delete patch.input; + const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' + ? patch.delivery as Record + : undefined; + const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() + ? deliveryPatch.channel.trim() + : undefined; + const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() + ? deliveryPatch.mode.trim() + : undefined; + const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); + if (unsupportedDeliveryError && deliveryMode !== 'none') { + throw new Error(unsupportedDeliveryError); + } + const result = await gatewayManager.rpc('cron.update', { id, patch }); + if (!result || typeof result !== 'object') { + throw new Error('Cron update returned an invalid job'); + } + return transformCronJob(result as GatewayCronJob); + }, + delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }), + toggle: async (payload) => { + const body = payload; + return gatewayManager.rpc('cron.update', { + id: getId(body), + patch: { enabled: body.enabled === true }, + }); + }, + trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }), + sessionHistory: async (payload) => { + const body = payload; + const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : ''; + const parsedSession = parseCronSessionKey(sessionKey); + if (!parsedSession) return { success: false, error: `Invalid cron sessionKey: ${sessionKey}` }; + + const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200); + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200; + const [jobsResult, runs, sessionEntry] = await Promise.all([ + gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000) + .catch(() => ({ jobs: [] as GatewayCronJob[] })), + readCronRunLog(parsedSession.jobId), + readSessionStoreEntry(parsedSession.agentId, sessionKey), + ]); + const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? []; + const job = jobs.find((item) => item.id === parsedSession.jobId); + return { + messages: buildCronSessionFallbackMessages({ + sessionKey, + job, + runs, + sessionEntry: sessionEntry ? { + label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined, + updatedAt: normalizeTimestampMs(sessionEntry.updatedAt), + } : undefined, + limit, + }), + }; + }, + deliveryTargets: async () => ({ success: true, targets: [] }), + }; +} diff --git a/electron/api/routes/diagnostics.ts b/electron/services/diagnostics-api.ts similarity index 75% rename from electron/api/routes/diagnostics.ts rename to electron/services/diagnostics-api.ts index 3cbf4cac..c3ea1f5f 100644 --- a/electron/api/routes/diagnostics.ts +++ b/electron/services/diagnostics-api.ts @@ -1,15 +1,18 @@ import { open } from 'node:fs/promises'; import { join } from 'node:path'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { logger } from '../../utils/logger'; -import { getOpenClawConfigDir } from '../../utils/paths'; -import { buildGatewayHealthSummary } from '../../utils/gateway-health'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; -import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { GatewayManager } from '../gateway/manager'; +import { logger } from '../utils/logger'; +import { getOpenClawConfigDir } from '../utils/paths'; +import { buildGatewayHealthSummary } from '../utils/gateway-health'; +import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api'; const DEFAULT_TAIL_LINES = 200; +type DiagnosticsApiContext = { + gatewayManager: GatewayManager; +}; + async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise { const safeTailLines = Math.max(1, Math.floor(tailLines)); try { @@ -42,17 +45,9 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi } } -export async function handleDiagnosticsRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -): Promise { - if ( - (url.pathname === '/api/diagnostics/gateway-snapshot' || url.pathname === '/api/gateway/diagnostics') - && req.method === 'GET' - ) { - try { +export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] { + return { + gatewaySnapshot: async () => { const { channels } = await buildChannelAccountsView(ctx, { probe: false }); const diagnostics = ctx.gatewayManager.getDiagnostics?.() ?? { consecutiveHeartbeatMisses: 0, @@ -74,7 +69,7 @@ export async function handleDiagnosticsRoutes( : undefined, }; const openClawDir = getOpenClawConfigDir(); - sendJson(res, 200, { + return { capturedAt: Date.now(), platform: process.platform, gateway, @@ -82,12 +77,7 @@ export async function handleDiagnosticsRoutes( clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES), gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')), gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')), - }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; + }; + }, + }; } diff --git a/electron/services/dialog-api.ts b/electron/services/dialog-api.ts new file mode 100644 index 00000000..c1f9c831 --- /dev/null +++ b/electron/services/dialog-api.ts @@ -0,0 +1,9 @@ +import { dialog, type MessageBoxOptions, type OpenDialogOptions } from 'electron'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; + +export function createDialogApi(): CompleteHostServiceRegistry['dialog'] { + return { + open: (payload) => dialog.showOpenDialog(payload as OpenDialogOptions), + message: (payload) => dialog.showMessageBox(payload as MessageBoxOptions), + }; +} diff --git a/electron/services/files-api.ts b/electron/services/files-api.ts new file mode 100644 index 00000000..0e834579 --- /dev/null +++ b/electron/services/files-api.ts @@ -0,0 +1,485 @@ +import { app, nativeImage } from 'electron'; +import crypto from 'node:crypto'; +import { homedir } from 'node:os'; +import { basename, extname, join, relative, resolve, sep } from 'node:path'; +import type { + FilePreviewTreeNode, + FilePreviewTreeOptions, + FileReadBinaryOptions, +} from '@shared/host-api/contract'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { expandPath } from '../utils/paths'; +import { isRecord } from './payload-utils'; + +const EXT_MIME_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.mkv': 'video/x-matroska', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.gz': 'application/gzip', + '.tar': 'application/x-tar', + '.7z': 'application/x-7z-compressed', + '.rar': 'application/vnd.rar', + '.json': 'application/json', + '.xml': 'application/xml', + '.csv': 'text/csv', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.ts': 'text/typescript', + '.py': 'text/x-python', + '.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', +}; + +const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); +const DIRECTORY_MIME_TYPE = 'application/x-directory'; +const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024; +const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024; +const FILE_PREVIEW_TREE_MAX_DEPTH = 6; +const FILE_PREVIEW_TREE_MAX_NODES = 5000; +const FILE_PREVIEW_DIR_BLACKLIST = new Set([ + 'node_modules', + '.venv', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', +]); + +type StagePathsPayload = { + filePaths?: unknown; +}; + +type StageBufferPayload = { + base64?: unknown; + fileName?: unknown; + mimeType?: unknown; +}; + +type PathPayload = { + path?: unknown; + content?: unknown; + opts?: unknown; +}; + +type ResolvedSandboxedPath = { + realPath: string; + readOnly: boolean; +}; + +function getMimeType(ext: string): string { + return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; +} + +function mimeToExt(mimeType: string): string { + for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { + if (mime === mimeType) return ext; + } + return ''; +} + +async function generateImagePreview(filePath: string, mimeType: string): Promise { + try { + const img = nativeImage.createFromPath(filePath); + if (img.isEmpty()) return null; + const size = img.getSize(); + const maxDim = 512; + if (size.width > maxDim || size.height > maxDim) { + const resized = size.width >= size.height + ? img.resize({ width: maxDim }) + : img.resize({ height: maxDim }); + return `data:image/png;base64,${resized.toPNG().toString('base64')}`; + } + const { readFile } = await import('node:fs/promises'); + const buf = await readFile(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } catch { + return null; + } +} + +function requirePath(payload: unknown): string { + const path = isRecord(payload) ? payload.path : payload; + if (typeof path !== 'string' || !path.trim()) { + throw new Error('Invalid file path'); + } + return path; +} + +function isPathInside(child: string, parent: string): boolean { + const c = resolve(child); + const p = resolve(parent); + if (process.platform === 'win32') { + const cl = c.toLowerCase(); + const pl = p.toLowerCase(); + return cl === pl || cl.startsWith(pl + sep); + } + return c === p || c.startsWith(p + sep); +} + +function getFilePreviewWriteRoots(): string[] { + const roots: string[] = []; + roots.push(resolve(join(homedir(), '.openclaw'))); + try { + roots.push(resolve(app.getPath('userData'))); + } catch { + // ignore + } + roots.push(resolve(OUTBOUND_DIR)); + return roots; +} + +async function resolveSandboxedPath( + input: string, + mode: 'read' | 'write' = 'read', +): Promise { + if (!input.trim()) { + throw new Error('outsideSandbox'); + } + const expanded = expandPath(input); + const fsP = await import('node:fs/promises'); + let real: string; + try { + real = await fsP.realpath(expanded); + } catch { + real = resolve(expanded); + } + const writeRoots = getFilePreviewWriteRoots(); + if (writeRoots.some((root) => isPathInside(real, root))) { + return { realPath: real, readOnly: false }; + } + if (mode === 'write') { + throw new Error('readOnlyRoot'); + } + return { realPath: real, readOnly: true }; +} + +function looksLikeBinary(buf: Buffer): boolean { + const limit = Math.min(buf.length, 8192); + for (let i = 0; i < limit; i += 1) { + if (buf[i] === 0) return true; + } + return false; +} + +function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean { + if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true; + if (!includeHidden && name.startsWith('.')) return true; + return false; +} + +function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean { + if (!includeHidden && name.startsWith('.')) return true; + return false; +} + +function getTreeOptions(opts: unknown): FilePreviewTreeOptions { + return isRecord(opts) ? opts as FilePreviewTreeOptions : {}; +} + +function getBinaryOptions(opts: unknown): FileReadBinaryOptions { + return isRecord(opts) ? opts as FileReadBinaryOptions : {}; +} + +export function createFilesApi(): CompleteHostServiceRegistry['files'] { + return { + stagePaths: async (payload) => { + const body = isRecord(payload) ? payload as StagePathsPayload : {}; + const filePaths = Array.isArray(body.filePaths) + ? body.filePaths.filter((value): value is string => typeof value === 'string') + : []; + const fsP = await import('node:fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + + const results = []; + for (const filePath of filePaths) { + const id = crypto.randomUUID(); + const fileName = basename(filePath); + const sourceStat = await fsP.stat(filePath); + if (sourceStat.isDirectory()) { + results.push({ + id, + fileName, + mimeType: DIRECTORY_MIME_TYPE, + fileSize: 0, + stagedPath: filePath, + preview: null, + }); + continue; + } + + const ext = extname(filePath); + const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); + await fsP.copyFile(filePath, stagedPath); + const s = await fsP.stat(stagedPath); + const mimeType = getMimeType(ext); + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(stagedPath, mimeType) + : null; + results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); + } + return results; + }, + stageBuffer: async (payload) => { + const body = isRecord(payload) ? payload as StageBufferPayload : {}; + if (typeof body.base64 !== 'string' || typeof body.fileName !== 'string') { + throw new Error('Invalid staged buffer payload'); + } + const fsP = await import('node:fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + + const id = crypto.randomUUID(); + const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : ''; + const ext = extname(body.fileName) || mimeToExt(payloadMimeType); + const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); + const buffer = Buffer.from(body.base64, 'base64'); + await fsP.writeFile(stagedPath, buffer); + + const mimeType = payloadMimeType || getMimeType(ext); + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(stagedPath, mimeType) + : null; + return { + id, + fileName: body.fileName, + mimeType, + fileSize: buffer.length, + stagedPath, + preview, + }; + }, + readText: async (payload) => { + try { + const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read'); + const fsP = await import('node:fs/promises'); + const stat = await fsP.stat(real); + if (!stat.isFile()) return { ok: false, error: 'notFound' }; + if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) return { ok: false, error: 'tooLarge', size: stat.size }; + const buf = await fsP.readFile(real); + if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: stat.size }; + return { + ok: true, + content: buf.toString('utf8'), + mimeType: getMimeType(extname(real)), + size: stat.size, + readOnly, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message.includes('ENOENT')) return { ok: false, error: 'notFound' }; + return { ok: false, error: message }; + } + }, + readBinary: async (payload) => { + try { + const body = isRecord(payload) ? payload as PathPayload : {}; + const opts = getBinaryOptions(body.opts); + const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read'); + const fsP = await import('node:fs/promises'); + const stat = await fsP.stat(real); + if (!stat.isFile()) return { ok: false, error: 'notFound' }; + const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : undefined; + const cap = Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES)); + if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size }; + const buf = await fsP.readFile(real); + const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + return { + ok: true, + data: view, + mimeType: getMimeType(extname(real)), + size: stat.size, + readOnly, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message.includes('ENOENT')) return { ok: false, error: 'notFound' }; + return { ok: false, error: message }; + } + }, + writeText: async (payload) => { + try { + const body = isRecord(payload) ? payload as PathPayload : {}; + if (typeof body.content !== 'string') return { ok: false, error: 'invalidContent' }; + if (Buffer.byteLength(body.content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) { + return { ok: false, error: 'tooLarge' }; + } + const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'write'); + const fsP = await import('node:fs/promises'); + let stat; + try { + stat = await fsP.stat(real); + } catch { + return { ok: false, error: 'notFound' }; + } + if (!stat.isFile()) return { ok: false, error: 'notFound' }; + await fsP.writeFile(real, body.content, 'utf8'); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message === 'readOnlyRoot') return { ok: false, error: 'readOnlyRoot' }; + return { ok: false, error: message }; + } + }, + stat: async (payload) => { + try { + const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read'); + const fsP = await import('node:fs/promises'); + const stat = await fsP.stat(real); + return { + ok: true, + size: stat.size, + mtime: stat.mtimeMs, + isFile: stat.isFile(), + isDir: stat.isDirectory(), + readOnly, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message.includes('ENOENT')) return { ok: false, error: 'notFound' }; + return { ok: false, error: message }; + } + }, + listDir: async (payload) => { + try { + const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read'); + const fsP = await import('node:fs/promises'); + const dirents = await fsP.readdir(real, { withFileTypes: true }); + const entries = await Promise.all(dirents.map(async (entry) => { + const abs = join(real, entry.name); + let size = 0; + try { + if (entry.isFile()) size = (await fsP.stat(abs)).size; + } catch { + // non-fatal + } + return { + name: entry.name, + path: abs, + isDir: entry.isDirectory(), + size, + }; + })); + return { ok: true, entries }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message.includes('ENOENT')) return { ok: false, error: 'notFound' }; + return { ok: false, error: message }; + } + }, + listTree: async (payload) => { + try { + const body = isRecord(payload) ? payload as PathPayload : {}; + const opts = getTreeOptions(body.opts); + const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read'); + const fsP = await import('node:fs/promises'); + const stat = await fsP.stat(real); + if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' }; + const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12)); + const maxNodes = Math.max(1, Math.min(opts.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000)); + const includeHidden = !!opts.includeHidden; + + let nodeCount = 0; + let truncated = false; + + const walk = async (absDir: string, depth: number): Promise => { + if (depth > maxDepth || truncated) return undefined; + let dirents; + try { + dirents = await fsP.readdir(absDir, { withFileTypes: true }); + } catch { + return []; + } + const children: FilePreviewTreeNode[] = []; + for (const entry of dirents) { + if (truncated) break; + const isDir = entry.isDirectory(); + const isFile = entry.isFile(); + if (!isDir && !isFile) continue; + if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue; + if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue; + if (nodeCount >= maxNodes) { + truncated = true; + break; + } + nodeCount += 1; + const abs = join(absDir, entry.name); + const node: FilePreviewTreeNode = { + name: entry.name, + relPath: relative(real, abs).split(sep).join('/'), + absPath: abs, + isDir, + }; + if (isFile) { + try { + const fstat = await fsP.stat(abs); + node.size = fstat.size; + node.mtime = fstat.mtimeMs; + } catch { + // non-fatal + } + } else { + try { + node.mtime = (await fsP.stat(abs)).mtimeMs; + } catch { + // non-fatal + } + node.children = await walk(abs, depth + 1) ?? []; + } + children.push(node); + } + children.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return children; + }; + + const root: FilePreviewTreeNode = { + name: basename(real) || real, + relPath: '', + absPath: real, + isDir: true, + mtime: stat.mtimeMs, + children: (await walk(real, 1)) ?? [], + }; + return { ok: true, root, truncated }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' }; + if (message.includes('ENOENT')) return { ok: false, error: 'notFound' }; + return { ok: false, error: message }; + } + }, + }; +} diff --git a/electron/services/gateway-api.ts b/electron/services/gateway-api.ts new file mode 100644 index 00000000..dafffbf4 --- /dev/null +++ b/electron/services/gateway-api.ts @@ -0,0 +1,79 @@ +import type { GatewayManager } from '../gateway/manager'; +import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { PORTS } from '../utils/config'; +import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing'; +import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui'; +import { getSetting } from '../utils/store'; +import { isRecord } from './payload-utils'; + +type HealthPayload = { + probe?: unknown; +}; + +type ControlUiPayload = { + view?: unknown; +}; + +type RpcPayload = { + method?: unknown; + params?: unknown; + timeoutMs?: unknown; +}; + +function parseTimeoutMs(timeoutMs: unknown): number | undefined { + if (timeoutMs === undefined) return undefined; + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('Invalid gateway RPC timeout'); + } + return timeoutMs; +} + +export function createGatewayApi( + gatewayManager: GatewayManager, + gatewayRpcBackpressure: GatewayRpcBackpressure, +): CompleteHostServiceRegistry['gateway'] { + return { + status: () => gatewayManager.getStatus(), + start: async () => { + await gatewayManager.start(); + return { success: true }; + }, + stop: async () => { + await gatewayManager.stop(); + return { success: true }; + }, + restart: async () => { + await gatewayManager.restart(); + return { success: true }; + }, + health: async (payload) => { + const body = isRecord(payload) ? payload as HealthPayload : {}; + return gatewayManager.checkHealth({ probe: body.probe === true }); + }, + controlUi: async (payload) => { + const body = isRecord(payload) ? payload as ControlUiPayload : {}; + const status = gatewayManager.getStatus(); + const token = await getSetting('gatewayToken'); + const port = status.port || PORTS.OPENCLAW_GATEWAY; + const view = body.view === 'dreams' ? 'dreams' : undefined; + const url = buildOpenClawControlUiUrl(port, token, { view }); + scheduleControlUiDeviceAutoApproval(gatewayManager); + return { success: true, url, token, port }; + }, + rpc: async (payload) => { + const body = isRecord(payload) ? payload as RpcPayload : {}; + const method = typeof body.method === 'string' ? body.method.trim() : ''; + if (!method) { + throw new Error('Invalid gateway RPC method'); + } + const timeoutMs = parseTimeoutMs(body.timeoutMs); + return gatewayRpcBackpressure.run( + method, + body.params, + timeoutMs, + (rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs), + ); + }, + }; +} diff --git a/electron/services/logs-api.ts b/electron/services/logs-api.ts new file mode 100644 index 00000000..aa2db5b1 --- /dev/null +++ b/electron/services/logs-api.ts @@ -0,0 +1,87 @@ +import { readFile } from 'node:fs/promises'; +import { extname, relative, resolve, sep } from 'node:path'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { logger } from '../utils/logger'; +import { isRecord } from './payload-utils'; + +type RecentPayload = { + tailLines?: unknown; +}; + +type ReadFilePayload = RecentPayload & { + path?: unknown; +}; + +type MemoryPayload = { + count?: unknown; +}; + +function safePositiveInteger(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return Math.max(1, Math.floor(value)); +} + +function isPathInside(parentDir: string, childPath: string): boolean { + const relativePath = relative(parentDir, childPath); + return relativePath.length > 0 + && !relativePath.startsWith('..') + && !relativePath.includes(`..${sep}`); +} + +async function validateLogFilePath(path: unknown): Promise { + if (typeof path !== 'string' || path.length === 0) { + throw new Error('Invalid log file path'); + } + + const resolvedPath = resolve(path); + const files = await logger.listLogFiles(); + if (files.some((file) => resolve(file.path) === resolvedPath)) { + return resolvedPath; + } + + const logDir = logger.getLogDir(); + if (!logDir) { + throw new Error('Invalid log file path'); + } + + const resolvedLogDir = resolve(logDir); + if (!isPathInside(resolvedLogDir, resolvedPath) || extname(resolvedPath) !== '.log') { + throw new Error('Invalid log file path'); + } + return resolvedPath; +} + +async function readLogFileTail(path: string, tailLines: number): Promise { + const content = await readFile(path, 'utf8'); + const lines = content.split('\n'); + const hasTrailingNewline = lines.at(-1) === ''; + if (hasTrailingNewline) { + lines.pop(); + } + if (lines.length <= tailLines) return content; + const tail = lines.slice(-tailLines).join('\n'); + return hasTrailingNewline ? `${tail}\n` : tail; +} + +export function createLogsApi(): CompleteHostServiceRegistry['logs'] { + return { + recent: async (payload) => { + const body = isRecord(payload) ? payload as RecentPayload : {}; + return { content: await logger.readLogFile(safePositiveInteger(body.tailLines, 100)) }; + }, + memory: (payload) => { + const body = isRecord(payload) ? payload as MemoryPayload : {}; + return logger.getRecentLogs( + body.count === undefined ? undefined : safePositiveInteger(body.count, 100), + ); + }, + dir: () => ({ dir: logger.getLogDir() }), + filePath: () => ({ path: logger.getLogFilePath() }), + listFiles: async () => ({ files: await logger.listLogFiles() }), + readFile: async (payload) => { + const body = isRecord(payload) ? payload as ReadFilePayload : {}; + const path = await validateLogFilePath(body.path); + return { content: await readLogFileTail(path, safePositiveInteger(body.tailLines, 200)) }; + }, + }; +} diff --git a/electron/services/media-api.ts b/electron/services/media-api.ts new file mode 100644 index 00000000..7ffd6fef --- /dev/null +++ b/electron/services/media-api.ts @@ -0,0 +1,221 @@ +import { dialog, nativeImage } from 'electron'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { + CLAWX_OPENAI_IMAGE_DEFAULT_MODEL, + CLAWX_OPENAI_IMAGE_PROVIDER_KEY, +} from '../utils/openclaw-image-relay-constants'; +import { + applyOpenAiImageRelaySettings, + getImageGenerationSettingsSnapshot, + listImageGenerationProvidersFromRuntime, + runImageGenerationTest, + setImageGenerationConfig, + type ImageGenerationModelConfig, +} from '../utils/openclaw-image-generation'; +import { isRecord } from './payload-utils'; + +type ThumbnailEntry = { + filePath?: unknown; + gatewayUrl?: unknown; + mimeType?: unknown; +}; + +type SaveImagePayload = { + base64?: unknown; + mimeType?: unknown; + filePath?: unknown; + defaultFileName?: unknown; +}; + +type ImageGenerationSettingsPayload = { + timeoutMs?: unknown; + openAiRelayEnabled?: unknown; + openAiRelayBaseUrl?: unknown; + openAiRelayModel?: unknown; + openAiRelayApiKey?: unknown; +}; + +async function generateImagePreview(filePath: string, mimeType: string): Promise { + try { + const { readFile } = await import('node:fs/promises'); + if (mimeType === 'image/svg+xml') { + const buf = await readFile(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } + + const img = nativeImage.createFromPath(filePath); + if (img.isEmpty()) return null; + const size = img.getSize(); + const maxDim = 512; + if (size.width > maxDim || size.height > maxDim) { + const resized = size.width >= size.height + ? img.resize({ width: maxDim }) + : img.resize({ height: maxDim }); + return `data:image/png;base64,${resized.toPNG().toString('base64')}`; + } + const buf = await readFile(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } catch { + return null; + } +} + +async function resolveOutgoingMediaUrl( + gatewayUrl: string, +): Promise<{ path: string; mimeType: string } | null> { + try { + const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//); + if (!match) return null; + const attachmentId = decodeURIComponent(match[1]); + if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null; + const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`); + const fsP = await import('node:fs/promises'); + const raw = await fsP.readFile(recordPath, 'utf8'); + const record = JSON.parse(raw) as { + original?: { path?: string; contentType?: string }; + }; + const original = record?.original; + if (!original?.path) return null; + return { + path: original.path, + mimeType: typeof original.contentType === 'string' && original.contentType + ? original.contentType + : 'application/octet-stream', + }; + } catch { + return null; + } +} + +function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] { + const value = isRecord(payload) ? payload.paths : payload; + return Array.isArray(value) ? value as ThumbnailEntry[] : []; +} + +export function createMediaApi(): CompleteHostServiceRegistry['media'] { + return { + thumbnails: async (payload) => { + const entries = normalizeThumbnailEntries(payload); + const fsP = await import('node:fs/promises'); + const results: Record = {}; + for (const entry of entries) { + const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : 'application/octet-stream'; + if (typeof entry.filePath === 'string' && entry.filePath) { + try { + const stat = await fsP.stat(entry.filePath); + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(entry.filePath, mimeType) + : null; + results[entry.filePath] = { preview, fileSize: stat.size }; + } catch { + results[entry.filePath] = { preview: null, fileSize: 0 }; + } + continue; + } + + if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) { + const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl); + if (!resolved) { + results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; + continue; + } + try { + const stat = await fsP.stat(resolved.path); + const preview = resolved.mimeType.startsWith('image/') + ? await generateImagePreview(resolved.path, resolved.mimeType) + : null; + results[entry.gatewayUrl] = { preview, fileSize: stat.size }; + } catch { + results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; + } + } + } + return results; + }, + saveImage: async (payload) => { + const body = isRecord(payload) ? payload as SaveImagePayload : {}; + const defaultFileName = typeof body.defaultFileName === 'string' && body.defaultFileName + ? body.defaultFileName + : 'image.png'; + const mimeType = typeof body.mimeType === 'string' ? body.mimeType : undefined; + const ext = defaultFileName.includes('.') + ? defaultFileName.split('.').pop()! + : (mimeType?.split('/')[1] || 'png'); + const result = await dialog.showSaveDialog({ + defaultPath: join(homedir(), 'Downloads', defaultFileName), + filters: [ + { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePath) return { success: false }; + + const fsP = await import('node:fs/promises'); + if (typeof body.filePath === 'string' && body.filePath) { + try { + await fsP.access(body.filePath); + await fsP.copyFile(body.filePath, result.filePath); + } catch { + return { success: false, error: 'Source file not found' }; + } + } else if (typeof body.base64 === 'string' && body.base64) { + await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64')); + } else { + return { success: false, error: 'No image data provided' }; + } + return { success: true, savedPath: result.filePath }; + }, + imageGenerationSettings: async () => ({ + success: true, + ...(await getImageGenerationSettingsSnapshot()), + }), + saveImageGenerationSettings: async (payload) => { + const body = isRecord(payload) ? payload as ImageGenerationSettingsPayload : {}; + const current = await getImageGenerationSettingsSnapshot(); + const normalizeRelayModel = (value: unknown): string => { + const raw = typeof value === 'string' && value.trim() + ? value.trim() + : (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL); + const slash = raw.indexOf('/'); + return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL; + }; + const relayModel = normalizeRelayModel(body.openAiRelayModel); + let nextPrimary = current.config.primary; + if (body.openAiRelayEnabled === true) { + nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`; + } else if (body.openAiRelayEnabled === false) { + nextPrimary = null; + } + const next: ImageGenerationModelConfig = { + primary: nextPrimary, + fallbacks: [], + timeoutMs: body.timeoutMs !== undefined + ? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null) + : current.config.timeoutMs, + }; + + if (typeof body.openAiRelayEnabled === 'boolean') { + await applyOpenAiImageRelaySettings({ + enabled: body.openAiRelayEnabled, + baseUrl: typeof body.openAiRelayBaseUrl === 'string' ? body.openAiRelayBaseUrl : null, + apiKey: typeof body.openAiRelayApiKey === 'string' ? body.openAiRelayApiKey : undefined, + model: relayModel, + }); + } + + const config = await setImageGenerationConfig(next); + return { + success: true, + ...(await getImageGenerationSettingsSnapshot()), + config, + }; + }, + imageGenerationProviders: async () => ({ + success: true, + providers: await listImageGenerationProvidersFromRuntime(), + }), + testImageGeneration: async (payload) => runImageGenerationTest(isRecord(payload) ? payload : {}), + }; +} diff --git a/electron/services/openclaw-api.ts b/electron/services/openclaw-api.ts new file mode 100644 index 00000000..ca65b53f --- /dev/null +++ b/electron/services/openclaw-api.ts @@ -0,0 +1,25 @@ +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { getOpenClawCliCommand } from '../utils/openclaw-cli'; +import { ensureDir, getOpenClawSkillsDir, getOpenClawStatus } from '../utils/paths'; +import { existsSync } from 'node:fs'; + +export function createOpenClawApi(): CompleteHostServiceRegistry['openclaw'] { + return { + status: () => getOpenClawStatus(), + getSkillsDir: () => { + const dir = getOpenClawSkillsDir(); + ensureDir(dir); + return dir; + }, + getCliCommand: () => { + const status = getOpenClawStatus(); + if (!status.packageExists) { + return { success: false, error: `OpenClaw package not found at: ${status.dir}` }; + } + if (!existsSync(status.entryPath)) { + return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` }; + } + return { success: true, command: getOpenClawCliCommand() }; + }, + }; +} diff --git a/electron/services/payload-utils.ts b/electron/services/payload-utils.ts new file mode 100644 index 00000000..7bf361f9 --- /dev/null +++ b/electron/services/payload-utils.ts @@ -0,0 +1,5 @@ +export type UnknownRecord = Record; + +export function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/electron/services/providers-api.ts b/electron/services/providers-api.ts new file mode 100644 index 00000000..a839b92a --- /dev/null +++ b/electron/services/providers-api.ts @@ -0,0 +1,477 @@ +import type { BrowserWindow } from 'electron'; +import type { HostApiContract } from '@shared/host-api/contract'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { GatewayManager } from '../gateway/manager'; +import type { ProviderConfig } from '../utils/secure-storage'; +import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; +import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth'; +import { removeProviderFromOpenClaw, saveProviderKeyToOpenClaw } from '../utils/openclaw-auth'; +import { getProviderConfig } from '../utils/provider-registry'; +import { logger } from '../utils/logger'; +import { getProviderService } from './providers/provider-service'; +import { providerAccountToConfig } from './providers/provider-store'; +import { + getOpenClawProviderKey, + syncDefaultProviderToRuntime, + syncDeletedProviderApiKeyToRuntime, + syncDeletedProviderToRuntime, + syncProviderApiKeyToRuntime, + syncSavedProviderToRuntime, + syncUpdatedProviderToRuntime, +} from './providers/provider-runtime-sync'; +import { validateApiKeyWithProvider } from './providers/provider-validation'; +import type { ProviderAccount } from '../shared/providers/types'; +import { isRecord } from './payload-utils'; + +type ProvidersApiContext = { + gatewayManager: GatewayManager; + mainWindow: BrowserWindow; +}; + +type ProviderPayload = + Parameters[0]; + +type ValidationOptions = { + baseUrl?: string; + apiProtocol?: string; +}; + +function hasObjectChanges>( + existing: T, + patch: Partial | undefined, +): boolean { + if (!patch) return false; + const keys = Object.keys(patch) as Array; + if (keys.length === 0) return false; + return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key])); +} + +function payloadString(payload: unknown, key: string): string | undefined { + if (typeof payload === 'string') return payload; + if (!isRecord(payload)) return undefined; + const value = payload[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function requireString(payload: unknown, key: string, action: string): string { + const value = payloadString(payload, key); + if (!value) { + throw new Error(`Invalid providers.${action} payload`); + } + return value; +} + +function getPayloadRecord(payload: unknown, action: string): Record { + if (!isRecord(payload)) { + throw new Error(`Invalid providers.${action} payload`); + } + return payload; +} + +function getProviderId(payload: unknown, action: string): string { + if (Array.isArray(payload)) { + const [providerId] = payload; + if (typeof providerId === 'string' && providerId.trim()) return providerId.trim(); + } + return requireString(payload, 'providerId', action); +} + +function getAccountId(payload: unknown, action: string): string { + return requireString(payload, 'accountId', action); +} + +function getApiKeyPayload(payload: unknown, action: string): { providerId: string; apiKey: string } { + if (Array.isArray(payload)) { + const [providerId, apiKey] = payload; + if (typeof providerId === 'string' && providerId.trim() && typeof apiKey === 'string') { + return { providerId: providerId.trim(), apiKey }; + } + } + const record = getPayloadRecord(payload, action); + const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : ''; + if (!providerId || typeof record.apiKey !== 'string') { + throw new Error(`Invalid providers.${action} payload`); + } + return { providerId, apiKey: record.apiKey }; +} + +function getProviderUpdatePayload(payload: unknown): { + providerId: string; + updates: Partial; + apiKey?: string; +} { + if (Array.isArray(payload)) { + const [providerId, updates, apiKey] = payload; + if (typeof providerId === 'string' && providerId.trim() && isRecord(updates)) { + return { providerId: providerId.trim(), updates: updates as Partial, apiKey: typeof apiKey === 'string' ? apiKey : undefined }; + } + } + const record = getPayloadRecord(payload, 'updateWithKey'); + const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : ''; + if (!providerId || !isRecord(record.updates)) { + throw new Error('Invalid providers.updateWithKey payload'); + } + return { + providerId, + updates: record.updates as Partial, + apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined, + }; +} + +function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: string } { + if (Array.isArray(payload)) { + const [config, apiKey] = payload; + if (isRecord(config)) { + return { config: config as unknown as ProviderConfig, apiKey: typeof apiKey === 'string' ? apiKey : undefined }; + } + } + const record = getPayloadRecord(payload, 'save'); + if (!isRecord(record.config)) { + throw new Error('Invalid providers.save payload'); + } + return { + config: record.config as unknown as ProviderConfig, + apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined, + }; +} + +async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> { + try { + const body = getPayloadRecord(payload, 'validateKey'); + const accountId = typeof body.accountId === 'string' && body.accountId.trim() + ? body.accountId.trim() + : undefined; + const vendorId = typeof body.vendorId === 'string' && body.vendorId.trim() + ? body.vendorId.trim() + : undefined; + const providerId = typeof body.providerId === 'string' && body.providerId.trim() + ? body.providerId.trim() + : undefined; + const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined; + if (!apiKey) { + return { valid: false, error: 'Invalid providers.validateKey payload' }; + } + + const providerService = getProviderService(); + const lookupId = accountId || vendorId || providerId || ''; + const account = lookupId ? await providerService.getAccount(lookupId) : null; + const legacyProvider = !account && providerId ? await providerService._getProviderInternal(providerId) : null; + const providerType = account?.vendorId || legacyProvider?.type || vendorId || providerId || lookupId; + if (!providerType) { + return { valid: false, error: 'Invalid providers.validateKey payload' }; + } + + const options = isRecord(body.options) ? body.options as ValidationOptions : undefined; + const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; + const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl; + const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol; + return await validateApiKeyWithProvider(providerType, apiKey, { + baseUrl: resolvedBaseUrl, + apiProtocol: resolvedProtocol, + }); + } catch (error) { + return { valid: false, error: String(error) }; + } +} + +async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const { config, apiKey } = getSavePayload(payload); + try { + await providerService._saveProviderInternal(config); + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await providerService._setProviderApiKeyInternal(config.id, trimmedKey); + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); + } + } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const providerId = getProviderId(payload, 'delete'); + try { + const existing = await providerService._getProviderInternal(providerId); + await providerService._deleteProviderInternal(providerId); + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) { + const providerService = getProviderService(); + const { providerId, apiKey } = getApiKeyPayload(payload, 'setApiKey'); + try { + await providerService._setProviderApiKeyInternal(providerId, apiKey); + const provider = await providerService._getProviderInternal(providerId); + const providerType = provider?.type || providerId; + await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const { providerId, updates, apiKey } = getProviderUpdatePayload(payload); + const existing = await providerService._getProviderInternal(providerId); + if (!existing) { + return { success: false, error: 'Provider not found' }; + } + + const previousKey = await providerService._getProviderApiKeyInternal(providerId); + const previousOck = getOpenClawProviderKey(existing.type, providerId); + + try { + const nextConfig: ProviderConfig = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + const ock = getOpenClawProviderKey(nextConfig.type, providerId); + await providerService._saveProviderInternal(nextConfig); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await providerService._setProviderApiKeyInternal(providerId, trimmedKey); + await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); + } else { + await providerService._deleteProviderApiKeyInternal(providerId); + await removeProviderFromOpenClaw(ock); + } + } + + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); + return { success: true }; + } catch (error) { + try { + await providerService._saveProviderInternal(existing); + if (previousKey) { + await providerService._setProviderApiKeyInternal(providerId, previousKey); + await saveProviderKeyToOpenClaw(previousOck, previousKey); + } else { + await providerService._deleteProviderApiKeyInternal(providerId); + await removeProviderFromOpenClaw(previousOck); + } + } catch (rollbackError) { + logger.warn('Failed to rollback provider updateWithKey:', rollbackError); + } + return { success: false, error: String(error) }; + } +} + +async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) { + const providerService = getProviderService(); + const providerId = getProviderId(payload, 'deleteApiKey'); + try { + await providerService._deleteProviderApiKeyInternal(providerId); + const provider = await providerService._getProviderInternal(providerId); + await syncDeletedProviderApiKeyToRuntime(provider, providerId); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const providerId = getProviderId(payload, 'setDefault'); + try { + await providerService._setDefaultProviderInternal(providerId); + await syncDefaultProviderToRuntime(providerId, gatewayManager); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const body = getPayloadRecord(payload, 'createAccount'); + if (!isRecord(body.account)) { + throw new Error('Invalid providers.createAccount payload'); + } + const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined; + try { + const account = await providerService.createAccount(body.account as unknown as ProviderAccount, apiKey); + await syncSavedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager); + return { success: true, account }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const body = getPayloadRecord(payload, 'updateAccount'); + const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : ''; + const updates = isRecord(body.updates) ? body.updates as Partial : undefined; + const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined; + if (!accountId || !updates) { + throw new Error('Invalid providers.updateAccount payload'); + } + try { + const existing = await providerService.getAccount(accountId); + if (!existing) { + return { success: false, error: 'Provider account not found' }; + } + const hasPatchChanges = hasObjectChanges(existing as unknown as Record, updates as Record); + if (!hasPatchChanges && apiKey === undefined) { + return { success: true, noChange: true, account: existing }; + } + const account = await providerService.updateAccount(accountId, updates, apiKey); + await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager); + return { success: true, account }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function deleteAccount( + payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean }, + gatewayManager?: GatewayManager, +) { + const providerService = getProviderService(); + const body = getPayloadRecord(payload, 'deleteAccount'); + const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : ''; + const apiKeyOnly = body.apiKeyOnly === true; + if (!accountId) { + throw new Error('Invalid providers.deleteAccount payload'); + } + try { + const existing = await providerService.getAccount(accountId); + const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai' + ? 'openai-codex' + : undefined; + if (apiKeyOnly) { + await providerService._deleteProviderApiKeyInternal(accountId); + await syncDeletedProviderApiKeyToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + runtimeProviderKey, + ); + return { success: true }; + } + await providerService.deleteAccount(accountId); + await syncDeletedProviderToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + gatewayManager, + runtimeProviderKey, + ); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) { + const providerService = getProviderService(); + const accountId = getAccountId(payload, 'setDefaultAccount'); + try { + const currentDefault = await providerService.getDefaultAccountId(); + if (currentDefault === accountId) { + return { success: true, noChange: true }; + } + await providerService.setDefaultAccount(accountId); + await syncDefaultProviderToRuntime(accountId, gatewayManager); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +async function requestOAuth(payload: ProviderPayload<'requestOAuth'>) { + const body = getPayloadRecord(payload, 'requestOAuth'); + const provider = typeof body.provider === 'string' ? body.provider : undefined; + if (!provider) { + return { success: false, error: 'Invalid providers.requestOAuth payload' }; + } + const region = body.region === 'global' || body.region === 'cn' ? body.region : undefined; + const options = { + accountId: typeof body.accountId === 'string' ? body.accountId : undefined, + label: typeof body.label === 'string' ? body.label : undefined, + }; + try { + if (provider === 'openai') { + await browserOAuthManager.startFlow(provider as BrowserOAuthProviderType, options); + } else { + await deviceOAuthManager.startFlow(provider as OAuthProviderType, region, options); + } + return { success: true }; + } catch (error) { + logger.error('providers.requestOAuth failed', error); + return { success: false, error: String(error) }; + } +} + +async function cancelOAuth() { + try { + await deviceOAuthManager.stopFlow(); + await browserOAuthManager.stopFlow(); + return { success: true }; + } catch (error) { + logger.error('providers.cancelOAuth failed', error); + return { success: false, error: String(error) }; + } +} + +async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) { + const body = getPayloadRecord(payload, 'submitOAuth'); + const code = typeof body.code === 'string' ? body.code : ''; + try { + const accepted = browserOAuthManager.submitManualCode(code); + if (!accepted) { + return { success: false, error: 'No active manual OAuth input pending' }; + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } +} + +export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] { + const providerService = getProviderService(); + deviceOAuthManager.setWindow(ctx.mainWindow); + browserOAuthManager.setWindow(ctx.mainWindow); + + return { + list: async () => providerService._listProvidersWithKeyInfoInternal(), + get: async (payload) => providerService._getProviderInternal(getProviderId(payload, 'get')), + getDefault: async () => providerService._getDefaultProviderInternal(), + hasApiKey: async (payload) => providerService._hasProviderApiKeyInternal(getProviderId(payload, 'hasApiKey')), + getApiKey: async (payload) => providerService._getProviderApiKeyInternal(getProviderId(payload, 'getApiKey')), + validateKey, + save: async (payload) => saveProvider(payload, ctx.gatewayManager), + delete: async (payload) => deleteProvider(payload, ctx.gatewayManager), + setApiKey: setProviderApiKey, + updateWithKey: async (payload) => updateProviderWithKey(payload, ctx.gatewayManager), + deleteApiKey: deleteProviderApiKey, + setDefault: async (payload) => setDefaultProvider(payload, ctx.gatewayManager), + accounts: async () => providerService.listAccounts(), + vendors: async () => providerService.listVendors(), + accountKeyInfo: async () => providerService.listAccountsKeyInfo(), + getDefaultAccount: async () => ({ accountId: await providerService.getDefaultAccountId() ?? null }), + getAccount: async (payload) => providerService.getAccount(getAccountId(payload, 'getAccount')), + getAccountApiKey: async (payload) => providerService.getAccountApiKey(getAccountId(payload, 'getAccountApiKey')), + hasAccountApiKey: async (payload) => providerService.hasAccountApiKey(getAccountId(payload, 'hasAccountApiKey')), + createAccount: async (payload) => createAccount(payload, ctx.gatewayManager), + updateAccount: async (payload) => updateAccount(payload, ctx.gatewayManager), + deleteAccount: async (payload) => deleteAccount(payload, ctx.gatewayManager), + deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx.gatewayManager), + setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx.gatewayManager), + requestOAuth, + cancelOAuth, + submitOAuth, + }; +} diff --git a/electron/api/routes/sessions.ts b/electron/services/sessions-api.ts similarity index 54% rename from electron/api/routes/sessions.ts rename to electron/services/sessions-api.ts index 65bbd2e3..d0242bce 100644 --- a/electron/api/routes/sessions.ts +++ b/electron/services/sessions-api.ts @@ -1,15 +1,15 @@ -import type { IncomingMessage, ServerResponse } from 'http'; import { openSync, closeSync, fstatSync, readSync } from 'node:fs'; import { join } from 'node:path'; -import { getOpenClawConfigDir } from '../../utils/paths'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { RawMessage } from '@shared/chat/types'; +import { getOpenClawConfigDir } from '../utils/paths'; +import { logger } from '../utils/logger'; import { removeSessionEntry, resolveSessionTranscriptPath, sweepSessionArtefacts, -} from '../../utils/session-files'; -import { logger } from '../../utils/logger'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; +} from '../utils/session-files'; +import { isRecord } from './payload-utils'; const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024; @@ -22,17 +22,24 @@ type SessionSummary = { lastTimestamp: number | null; }; -type TranscriptMessage = { - role?: unknown; - content?: unknown; - timestamp?: unknown; -}; +type TranscriptMessage = RawMessage; type ParsedTranscriptLine = { type?: string; message?: TranscriptMessage; }; +type SessionPayload = { + id?: unknown; + sessionKey?: unknown; + label?: unknown; + title?: unknown; + agentId?: unknown; + sessionId?: unknown; + limit?: unknown; + sessionKeys?: unknown; +}; + function extractMessageText(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; @@ -185,6 +192,21 @@ function parseSessionKey(sessionKey: string): { agentId: string; suffix: string return { agentId, suffix }; } +function getSessionKey(payload: unknown): string { + const body = isRecord(payload) ? payload as SessionPayload : {}; + const value = body.sessionKey ?? body.id ?? payload; + if (typeof value !== 'string' || !value.startsWith('agent:')) { + throw new Error(`Invalid sessionKey: ${String(value)}`); + } + return value; +} + +function getLimit(payload: unknown, fallback = 200): number { + const value = isRecord(payload) ? (payload as SessionPayload).limit : undefined; + const limitRaw = typeof value === 'number' ? value : fallback; + return Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : fallback; +} + async function readSessionsJson(agentId: string): Promise> { const fsP = await import('node:fs/promises'); const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json'); @@ -263,7 +285,7 @@ async function loadSessionSummary(sessionKey: string): Promise { } } -async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise { +async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise { const parsed = parseSessionKey(sessionKey); if (!parsed) return null; @@ -279,194 +301,167 @@ async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Pr } } -export async function handleSessionRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/sessions/summaries' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ sessionKeys?: string[] }>(req); +async function deleteSession(sessionKey: string): Promise<{ success: boolean; error?: string }> { + if (!sessionKey || !sessionKey.startsWith('agent:')) { + return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; + } + const parts = sessionKey.split(':'); + if (parts.length < 3) { + return { success: false, error: `sessionKey has too few parts: ${sessionKey}` }; + } + const agentId = parts[1]; + if (!SAFE_SESSION_SEGMENT.test(agentId)) { + return { success: false, error: `Invalid agentId: ${agentId}` }; + } + + const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions'); + const sessionsJsonPath = join(sessionsDir, 'sessions.json'); + logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`); + logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`); + + const fsP = await import('node:fs/promises'); + let sessionsJson: Record; + try { + const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); + sessionsJson = JSON.parse(raw) as Record; + } catch (error) { + logger.warn(`[session:delete] Could not read sessions.json: ${String(error)}`); + return { success: false, error: `Could not read sessions.json: ${String(error)}` }; + } + + const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey); + if (!resolution.ok) { + if (resolution.failure.kind === 'not-found') { + logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(sessionsJson[sessionKey])}`); + return { success: false, error: `Cannot resolve file for session: ${sessionKey}` }; + } + logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`); + return { + success: false, + error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}`, + }; + } + + const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution; + logger.info(`[session:delete] file: ${resolvedSrcPath}`); + const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId); + for (const removedPath of sweep.removed) { + logger.info(`[session:delete] Unlinked ${removedPath}`); + } + for (const { path: failedPath, error } of sweep.errors) { + logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`); + } + logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`); + + try { + const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); + const json2 = JSON.parse(raw2) as Record; + removeSessionEntry(json2, sessionKey); + await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); + logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`); + } catch (error) { + logger.warn(`[session:delete] Could not update sessions.json: ${String(error)}`); + } + + return { success: true }; +} + +async function renameSession(sessionKey: string, label: string): Promise<{ success: boolean; error?: string }> { + if (!sessionKey || !sessionKey.startsWith('agent:')) { + return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; + } + if (!label || typeof label !== 'string' || !label.trim()) { + return { success: false, error: 'Label cannot be empty' }; + } + const parts = sessionKey.split(':'); + if (parts.length < 3) { + return { success: false, error: `Malformed sessionKey: ${sessionKey}` }; + } + const agentId = parts[1]; + if (!SAFE_SESSION_SEGMENT.test(agentId)) { + return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` }; + } + + const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json'); + const fsP = await import('node:fs/promises'); + const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); + const json = JSON.parse(raw) as Record; + const trimmedLabel = label.trim(); + + let found = false; + if (json[sessionKey] && typeof json[sessionKey] === 'object') { + (json[sessionKey] as Record).label = trimmedLabel; + found = true; + } + if (Array.isArray(json.sessions)) { + for (const entry of json.sessions as Array>) { + if (entry.key === sessionKey || entry.sessionKey === sessionKey) { + entry.label = trimmedLabel; + found = true; + } + } + } + + if (!found) { + return { success: false, error: `Session not found in sessions.json: ${sessionKey}` }; + } + + await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8'); + logger.info(`[session:rename] key=${sessionKey} label=${trimmedLabel}`); + return { success: true }; +} + +export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] { + return { + delete: async (payload) => deleteSession(getSessionKey(payload)), + rename: async (payload) => { + const body = isRecord(payload) ? payload as SessionPayload : {}; + const sessionKey = getSessionKey(payload); + const label = body.label ?? body.title; + if (typeof label !== 'string') { + throw new Error('Label cannot be empty'); + } + return renameSession(sessionKey, label); + }, + summaries: async (payload) => { + const body = isRecord(payload) ? payload as SessionPayload : {}; const sessionKeys = Array.isArray(body.sessionKeys) ? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:')) : []; - if (sessionKeys.length === 0) { - sendJson(res, 200, { success: true, summaries: [] }); - return true; + if (sessionKeys.length === 0) return { success: true, summaries: [] }; + return { + success: true, + summaries: await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey))), + }; + }, + history: async (payload) => { + const body = isRecord(payload) ? payload as SessionPayload : {}; + const limit = getLimit(payload); + + if (typeof body.sessionKey === 'string' && body.sessionKey.trim()) { + const messages = await loadSessionTranscriptByKey(body.sessionKey.trim(), limit); + if (!messages) return { success: false, error: 'Transcript not found' }; + return { success: true, messages }; } - const summaries = await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey))); - sendJson(res, 200, { success: true, summaries }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - if (url.pathname === '/api/sessions/transcript' && req.method === 'GET') { - try { - const sessionKey = url.searchParams.get('sessionKey')?.trim() || ''; - const limitRaw = Number(url.searchParams.get('limit') ?? '200'); - const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : 200; - - if (sessionKey) { - const messages = await loadSessionTranscriptByKey(sessionKey, limit); - if (!messages) { - sendJson(res, 404, { success: false, error: 'Transcript not found' }); - return true; - } - sendJson(res, 200, { success: true, messages }); - return true; - } - - const agentId = url.searchParams.get('agentId')?.trim() || ''; - const sessionId = url.searchParams.get('sessionId')?.trim() || ''; + const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : ''; + const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''; if (!agentId || !sessionId) { - sendJson(res, 400, { success: false, error: 'agentId and sessionId are required' }); - return true; + return { success: false, error: 'agentId and sessionId are required' }; } if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) { - sendJson(res, 400, { success: false, error: 'Invalid transcript identifier' }); - return true; + return { success: false, error: 'Invalid transcript identifier' }; } - const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`); - const messages = readRecentTranscriptMessages(transcriptPath, limit); - - sendJson(res, 200, { success: true, messages }); - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { - sendJson(res, 404, { success: false, error: 'Transcript not found' }); - } else { - sendJson(res, 500, { success: false, error: 'Failed to load transcript' }); - } - } - return true; - } - - // POST /api/sessions/delete — HTTP mirror of the `session:delete` IPC. - // Both surfaces share electron/utils/session-files.ts so they sweep the - // same set of artefacts: the live transcript, legacy `.deleted.jsonl`, - // `.jsonl.reset.*` snapshots, the trajectory sidecar pair - // (`.trajectory.jsonl` + `.trajectory-path.json`) and — when the - // pointer points outside sessions/ (the OPENCLAW_TRAJECTORY_DIR case) — - // the off-disk runtime trajectory it references. - if (url.pathname === '/api/sessions/delete' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ sessionKey: string }>(req); - const sessionKey = body.sessionKey; - if (!sessionKey || !sessionKey.startsWith('agent:')) { - sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` }); - return true; - } - const parts = sessionKey.split(':'); - if (parts.length < 3) { - sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` }); - return true; - } - const agentId = parts[1]; - // Defence-in-depth: agentId becomes a path segment under - // ~/.openclaw/agents/. The sibling /api/sessions/transcript route - // applies the same check to its sessionId; mirror it here so a - // malformed key can never steer the unlink loop into another folder. - if (!SAFE_SESSION_SEGMENT.test(agentId)) { - sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` }); - return true; - } - const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions'); - const sessionsJsonPath = join(sessionsDir, 'sessions.json'); - const fsP = await import('node:fs/promises'); - const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); - const sessionsJson = JSON.parse(raw) as Record; - - const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey); - if (!resolution.ok) { - if (resolution.failure.kind === 'not-found') { - sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` }); - } else { - logger.warn(`[api/sessions/delete] Refusing out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`); - sendJson(res, 400, { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` }); + try { + const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`); + return { success: true, messages: readRecentTranscriptMessages(transcriptPath, limit) }; + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + return { success: false, error: 'Transcript not found' }; } - return true; + return { success: false, error: 'Failed to load transcript' }; } - - const sweep = await sweepSessionArtefacts(resolution.sessionsDirAbs, resolution.baseId); - for (const { path: failedPath, error } of sweep.errors) { - logger.warn(`[api/sessions/delete] Failed to unlink ${failedPath}: ${String(error)}`); - } - - const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); - const json2 = JSON.parse(raw2) as Record; - removeSessionEntry(json2, sessionKey); - await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - // POST /api/sessions/rename — update session label in sessions.json. - if (url.pathname === '/api/sessions/rename' && req.method === 'POST') { - try { - const body = await parseJsonBody<{ sessionKey: string; label: string }>(req); - const { sessionKey, label } = body; - if (!sessionKey || !sessionKey.startsWith('agent:')) { - sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` }); - return true; - } - if (!label || typeof label !== 'string' || !label.trim()) { - sendJson(res, 400, { success: false, error: 'Label cannot be empty' }); - return true; - } - const parts = sessionKey.split(':'); - if (parts.length < 3) { - sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` }); - return true; - } - const agentId = parts[1]; - if (!SAFE_SESSION_SEGMENT.test(agentId)) { - sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` }); - return true; - } - const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions'); - const sessionsJsonPath = join(sessionsDir, 'sessions.json'); - const fsP = await import('node:fs/promises'); - const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); - const sessionsJson = JSON.parse(raw) as Record; - - const trimmedLabel = label.trim(); - let found = false; - - // Object-keyed format - if (sessionsJson[sessionKey] && typeof sessionsJson[sessionKey] === 'object') { - (sessionsJson[sessionKey] as Record).label = trimmedLabel; - found = true; - } - // Array format - if (Array.isArray(sessionsJson.sessions)) { - for (const entry of sessionsJson.sessions as Array>) { - if (entry.key === sessionKey || entry.sessionKey === sessionKey) { - entry.label = trimmedLabel; - found = true; - } - } - } - - if (!found) { - sendJson(res, 404, { success: false, error: `Session not found: ${sessionKey}` }); - return true; - } - - await fsP.writeFile(sessionsJsonPath, JSON.stringify(sessionsJson, null, 2), 'utf8'); - logger.info(`[api/sessions/rename] key=${sessionKey} label=${trimmedLabel}`); - sendJson(res, 200, { success: true }); - } catch (error) { - sendJson(res, 500, { success: false, error: String(error) }); - } - return true; - } - - return false; + }, + }; } diff --git a/electron/services/settings-api.ts b/electron/services/settings-api.ts new file mode 100644 index 00000000..de39de6b --- /dev/null +++ b/electron/services/settings-api.ts @@ -0,0 +1,133 @@ +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { GatewayManager } from '../gateway/manager'; +import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup'; +import { createMenu } from '../main/menu'; +import { applyProxySettings } from '../main/proxy'; +import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; +import { + type AppSettings, + getAllSettings, + getSetting, + resetSettings, + setSetting, +} from '../utils/store'; +import { isRecord } from './payload-utils'; + +type KeyPayload = { + key?: unknown; +}; + +type SetPayload = KeyPayload & { + value?: unknown; +}; + +type SetManyPayload = { + patch?: unknown; +}; + +const PROXY_SETTING_KEYS = new Set([ + 'proxyEnabled', + 'proxyServer', + 'proxyHttpServer', + 'proxyHttpsServer', + 'proxyAllServer', + 'proxyBypassRules', +]); + +async function validateSettingKey(key: unknown): Promise { + if (typeof key !== 'string' || key.length === 0) return false; + const settings = await getAllSettings(); + return Object.prototype.hasOwnProperty.call(settings, key); +} + +async function requireSettingKey(payload: unknown): Promise { + const key = (payload as KeyPayload | undefined)?.key; + if (!await validateSettingKey(key)) { + throw new Error('Invalid settings key'); + } + return key as keyof AppSettings; +} + +async function requireSettingsPatch(payload: unknown): Promise> { + const patch = (payload as SetManyPayload | undefined)?.patch; + if (!isRecord(patch)) { + throw new Error('Invalid settings patch'); + } + const entries = Object.entries(patch); + for (const [key] of entries) { + if (!await validateSettingKey(key)) { + throw new Error('Invalid settings key'); + } + } + return Object.fromEntries(entries) as Partial; +} + +function patchTouchesProxy(patch: Partial): boolean { + return Object.keys(patch).some((key) => PROXY_SETTING_KEYS.has(key as keyof AppSettings)); +} + +function patchTouchesLaunchAtStartup(patch: Partial): boolean { + return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup'); +} + +function patchTouchesLanguage(patch: Partial): boolean { + return Object.prototype.hasOwnProperty.call(patch, 'language'); +} + +async function handleProxySettingsChange(gatewayManager: GatewayManager): Promise { + const settings = await getAllSettings(); + await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false }); + await applyProxySettings(settings); + if (gatewayManager.getStatus().state === 'running') { + await gatewayManager.restart(); + } +} + +async function runSettingsSideEffects( + gatewayManager: GatewayManager, + patch: Partial, +): Promise { + if (patchTouchesProxy(patch)) { + await handleProxySettingsChange(gatewayManager); + } + if (patchTouchesLaunchAtStartup(patch)) { + await syncLaunchAtStartupSettingFromStore(); + } + if (patchTouchesLanguage(patch)) { + await createMenu(typeof patch.language === 'string' ? patch.language : undefined); + } +} + +export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] { + return { + getAll: () => getAllSettings(), + get: async (payload) => { + const key = await requireSettingKey(payload); + return getSetting(key as never); + }, + set: async (payload) => { + const body = payload as SetPayload | undefined; + const key = await requireSettingKey(body); + await setSetting(key as never, body?.value as never); + await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial); + return { success: true }; + }, + setMany: async (payload) => { + const patch = await requireSettingsPatch(payload); + const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; + for (const [key, value] of entries) { + await setSetting(key, value as never); + } + await runSettingsSideEffects(gatewayManager, patch); + return { success: true }; + }, + reset: async () => { + await resetSettings(); + await handleProxySettingsChange(gatewayManager); + await syncLaunchAtStartupSettingFromStore(); + const settings = await getAllSettings(); + await createMenu(settings.language); + return { success: true, settings }; + }, + }; +} diff --git a/electron/services/shell-api.ts b/electron/services/shell-api.ts new file mode 100644 index 00000000..5c486edb --- /dev/null +++ b/electron/services/shell-api.ts @@ -0,0 +1,38 @@ +import { shell } from 'electron'; +import { homedir } from 'node:os'; +import { join, sep } from 'node:path'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; + +function expandShellPath(input: string): string { + if (input === '~') return homedir(); + if (input.startsWith(`~${sep}`) || input.startsWith('~/') || input.startsWith('~\\')) { + return join(homedir(), input.slice(2)); + } + return input; +} + +function requirePath(path: unknown): string { + if (typeof path !== 'string' || !path.trim()) { + throw new Error('path is required'); + } + return path; +} + +function requireUrl(url: unknown): string { + if (typeof url !== 'string' || !url.trim()) { + throw new Error('url is required'); + } + return url; +} + +export function createShellApi(): CompleteHostServiceRegistry['shell'] { + return { + openExternal: async (payload) => { + await shell.openExternal(requireUrl(payload.url)); + }, + showItemInFolder: (payload) => { + shell.showItemInFolder(expandShellPath(requirePath(payload.path))); + }, + openPath: (payload) => shell.openPath(expandShellPath(requirePath(payload.path))), + }; +} diff --git a/electron/services/skills-api.ts b/electron/services/skills-api.ts new file mode 100644 index 00000000..8ea14a44 --- /dev/null +++ b/electron/services/skills-api.ts @@ -0,0 +1,192 @@ +import type { GatewayManager } from '../gateway/manager'; +import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config'; +import { + collectQuickAccessSkills, + filterEnabledQuickAccessSkills, + type QuickAccessRuntimeSkillStatus, +} from '../utils/skill-quick-access'; +import { listLocalSkills } from './skills/local-skill-service'; +import { isRecord } from './payload-utils'; + +type SkillConfigPayload = { + skillKey?: unknown; + enabled?: unknown; + apiKey?: unknown; + env?: unknown; +}; + +type SkillConfigsPayload = { + updates?: unknown; +}; + +type NormalizedSkillConfigUpdate = { + skillKey: string; + enabled?: boolean; + apiKey?: string; + env?: Record; +}; + +type QuickAccessPayload = { + workspace?: unknown; +}; + +type SkillOpenPayload = { + slug?: unknown; + skillKey?: unknown; + baseDir?: unknown; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getSkillKey(payload: unknown): string { + const body = isRecord(payload) ? payload as SkillConfigPayload : {}; + if (typeof body.skillKey !== 'string' || !body.skillKey.trim()) { + throw new Error('skillKey is required'); + } + return body.skillKey.trim(); +} + +function getEnv(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ); +} + +function getConfigUpdate(payload: unknown): NormalizedSkillConfigUpdate { + const body = isRecord(payload) ? payload as SkillConfigPayload : {}; + return { + skillKey: getSkillKey(payload), + enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined, + apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined, + env: getEnv(body.env), + }; +} + +function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] { + const body = isRecord(payload) ? payload as SkillConfigsPayload : {}; + if (!Array.isArray(body.updates)) return []; + return body.updates.flatMap((entry) => { + if (!isRecord(entry)) return []; + const skillKey = typeof entry.skillKey === 'string' ? entry.skillKey.trim() : ''; + if (!skillKey) return []; + return [{ + skillKey, + enabled: typeof entry.enabled === 'boolean' ? entry.enabled : undefined, + apiKey: typeof entry.apiKey === 'string' ? entry.apiKey : undefined, + env: getEnv(entry.env), + }]; + }); +} + +export function createSkillsApi({ + clawHubService, + gatewayManager, +}: { + clawHubService: ClawHubService; + gatewayManager: GatewayManager; +}): CompleteHostServiceRegistry['skills'] { + return { + local: async () => ({ success: true, skills: await listLocalSkills() }), + configs: async () => getAllSkillConfigs(), + allConfigs: async () => getAllSkillConfigs(), + getConfig: async (payload) => { + const config = await getSkillConfig(getSkillKey(payload)); + return config ? { ...config } : undefined; + }, + updateConfig: async (payload) => { + const { skillKey, ...updates } = getConfigUpdate(payload); + return updateSkillConfig(skillKey, updates); + }, + updateConfigs: async (payload) => updateSkillConfigs(getConfigUpdates(payload)), + status: async () => gatewayManager.rpc('skills.status'), + update: async (payload) => gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {}), + quickAccess: async (payload) => { + const body = isRecord(payload) ? payload as QuickAccessPayload : {}; + const [scannedSkills, configs] = await Promise.all([ + collectQuickAccessSkills({ + workspace: typeof body.workspace === 'string' ? body.workspace : undefined, + }), + getAllSkillConfigs(), + ]); + let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined; + if (gatewayManager.getStatus().state === 'running') { + try { + const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status'); + runtimeSkills = runtimeStatus.skills || []; + } catch { + runtimeSkills = undefined; + } + } + return { + success: true, + skills: filterEnabledQuickAccessSkills(scannedSkills, runtimeSkills, configs), + }; + }, + clawhubCapability: async () => { + try { + return { success: true, capability: await clawHubService.getMarketplaceCapability() }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubList: async () => { + try { + return { success: true, results: await clawHubService.listInstalled() }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubSearch: async (payload) => { + try { + return { success: true, results: await clawHubService.search((isRecord(payload) ? payload : {}) as ClawHubSearchParams) }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubInstall: async (payload) => { + try { + await clawHubService.install((isRecord(payload) ? payload : {}) as ClawHubInstallParams); + return { success: true }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubUninstall: async (payload) => { + try { + await clawHubService.uninstall((isRecord(payload) ? payload : {}) as ClawHubUninstallParams); + return { success: true }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubOpenSkillReadme: async (payload) => { + try { + const body = isRecord(payload) ? payload as SkillOpenPayload : {}; + const skillKey = typeof body.skillKey === 'string' ? body.skillKey : ''; + const slug = typeof body.slug === 'string' ? body.slug : undefined; + const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined; + await clawHubService.openSkillReadme(skillKey || slug || '', slug, baseDir); + return { success: true }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + clawhubOpenSkillPath: async (payload) => { + try { + const body = isRecord(payload) ? payload as SkillOpenPayload : {}; + const skillKey = typeof body.skillKey === 'string' ? body.skillKey : ''; + const slug = typeof body.slug === 'string' ? body.slug : undefined; + const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined; + await clawHubService.openSkillPath(skillKey || slug || '', slug, baseDir); + return { success: true }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + }, + }; +} diff --git a/electron/services/skills/local-skill-service.ts b/electron/services/skills/local-skill-service.ts index d1da7eab..c625b227 100644 --- a/electron/services/skills/local-skill-service.ts +++ b/electron/services/skills/local-skill-service.ts @@ -25,7 +25,7 @@ export interface LocalSkillRecord { icon?: string; version?: string; author?: string; - config?: SkillConfigUpdates; + config?: Record; isCore?: boolean; isBundled?: boolean; source?: string; @@ -268,7 +268,8 @@ async function inspectSkillDir( ]); const skillKey = parsedManifest.id || manifestMeta?.slug || originMeta?.slug || fallbackId; - const config = configs[skillKey] || {}; + const rawConfig = configs[skillKey] || {}; + const config: Record = { ...rawConfig }; const version = manifestMeta?.version || parsedManifest.version || originMeta?.installedVersion; const source = descriptor.source; const isBundled = source === 'openclaw-bundled' || Boolean(preinstalledMeta); @@ -287,7 +288,7 @@ async function inspectSkillDir( slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId, name: parsedManifest.name, description: parsedManifest.description, - enabled: config.enabled !== false, + enabled: rawConfig.enabled !== false, icon: parsedManifest.icon || (isBundled ? '🧩' : '📦'), version, author: manifestMeta?.author || parsedManifest.author, diff --git a/electron/services/updates-api.ts b/electron/services/updates-api.ts new file mode 100644 index 00000000..34c35a23 --- /dev/null +++ b/electron/services/updates-api.ts @@ -0,0 +1,75 @@ +import type { + UpdateInfoSnapshot, + UpdateProgressSnapshot, + UpdateStatusSnapshot, +} from '@shared/host-api/contract'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { AppUpdater, UpdateStatus } from '../main/updater'; + +function normalizeInfo(info: UpdateStatus['info']): UpdateInfoSnapshot | undefined { + if (!info) return undefined; + return { + version: info.version, + releaseDate: info.releaseDate, + releaseNotes: typeof info.releaseNotes === 'string' || info.releaseNotes == null ? info.releaseNotes : String(info.releaseNotes), + }; +} + +function normalizeProgress(progress: UpdateStatus['progress']): UpdateProgressSnapshot | undefined { + if (!progress) return undefined; + return { + total: progress.total, + delta: progress.delta, + transferred: progress.transferred, + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + }; +} + +function normalizeStatus(status: UpdateStatus): UpdateStatusSnapshot { + return { + status: status.status, + info: normalizeInfo(status.info), + progress: normalizeProgress(status.progress), + error: status.error, + }; +} + +export function createUpdatesApi(updater: AppUpdater): CompleteHostServiceRegistry['updates'] { + return { + status: () => normalizeStatus(updater.getStatus()), + version: () => updater.getCurrentVersion(), + check: async () => { + try { + await updater.checkForUpdates(); + return { success: true, status: normalizeStatus(updater.getStatus()) }; + } catch (error) { + return { success: false, error: String(error), status: normalizeStatus(updater.getStatus()) }; + } + }, + download: async () => { + try { + await updater.downloadUpdate(); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + install: () => { + updater.quitAndInstall(); + return { success: true }; + }, + setChannel: (payload) => { + updater.setChannel(payload.channel); + return { success: true }; + }, + setAutoDownload: (payload) => { + updater.setAutoDownload(payload.enable); + return { success: true }; + }, + cancelAutoInstall: () => { + updater.cancelAutoInstall(); + return { success: true }; + }, + }; +} diff --git a/electron/services/usage-api.ts b/electron/services/usage-api.ts new file mode 100644 index 00000000..ff3a444d --- /dev/null +++ b/electron/services/usage-api.ts @@ -0,0 +1,27 @@ +import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { isRecord } from './payload-utils'; + +type RecentTokenHistoryPayload = { + limit?: unknown; +}; + +function getSafeLimit(payload: unknown): number | undefined { + const value = isRecord(payload) ? (payload as RecentTokenHistoryPayload).limit : payload; + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.max(Math.floor(value), 1); + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.max(Math.floor(parsed), 1); + } + } + return undefined; +} + +export function createUsageApi(): CompleteHostServiceRegistry['usage'] { + return { + recentTokenHistory: async (payload) => getRecentTokenUsageHistory(getSafeLimit(payload)), + }; +} diff --git a/electron/services/uv-api.ts b/electron/services/uv-api.ts new file mode 100644 index 00000000..571c3ff1 --- /dev/null +++ b/electron/services/uv-api.ts @@ -0,0 +1,20 @@ +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; + +export function createUvApi(): CompleteHostServiceRegistry['uv'] { + return { + installAll: async () => { + try { + const isInstalled = await checkUvInstalled(); + if (!isInstalled) { + await installUv(); + } + await setupManagedPython(); + return { success: true }; + } catch (error) { + console.error('Failed to setup uv/python:', error); + return { success: false, error: String(error) }; + } + }, + }; +} diff --git a/electron/services/window-api.ts b/electron/services/window-api.ts new file mode 100644 index 00000000..ccc04388 --- /dev/null +++ b/electron/services/window-api.ts @@ -0,0 +1,25 @@ +import type { BrowserWindow } from 'electron'; +import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import { syncMacTrafficLightPosition } from '../main/traffic-light-layout'; + +export function createWindowApi(mainWindow: BrowserWindow): CompleteHostServiceRegistry['window'] { + return { + syncTrafficLightPosition: (payload) => { + syncMacTrafficLightPosition(mainWindow, payload.sidebarCollapsed); + }, + minimize: () => { + mainWindow.minimize(); + }, + maximize: () => { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + }, + close: () => { + mainWindow.close(); + }, + isMaximized: () => mainWindow.isMaximized(), + }; +} diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 20c77b24..29ce0d2e 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -5,6 +5,7 @@ export const PROVIDER_TYPES = [ 'openrouter', 'ark', 'moonshot', + 'moonshot-global', 'siliconflow', 'deepseek', 'minimax-portal', @@ -21,6 +22,7 @@ export const BUILTIN_PROVIDER_TYPES = [ 'openrouter', 'ark', 'moonshot', + 'moonshot-global', 'siliconflow', 'deepseek', 'minimax-portal', @@ -76,16 +78,7 @@ export function assertValidApiProtocol( } } -/** - * UI-selectable subset of api protocols offered to users when configuring - * custom or Ollama providers in Settings. Tightly scoped on purpose so the - * UI dropdown stays simple; built-in providers use the broader - * {@link OpenClawApiProtocol} via {@link ProviderBackendConfig.api}. - */ -export type ProviderProtocol = - | 'openai-completions' - | 'openai-responses' - | 'anthropic-messages'; +export type ProviderProtocol = OpenClawApiProtocol; export type ProviderAuthMode = | 'api_key' diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index de2087e8..0e97c82c 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -21,7 +21,6 @@ const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex'; const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5'; class BrowserOAuthManager extends EventEmitter { - private activeProvider: BrowserOAuthProviderType | null = null; private activeAccountId: string | null = null; private activeLabel: string | null = null; private active = false; @@ -42,7 +41,6 @@ class BrowserOAuthManager extends EventEmitter { } this.active = true; - this.activeProvider = provider; this.activeAccountId = options?.accountId || provider; this.activeLabel = options?.label || null; this.emit('oauth:start', { provider, accountId: this.activeAccountId }); @@ -90,7 +88,6 @@ class BrowserOAuthManager extends EventEmitter { logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error); this.emitError(error instanceof Error ? error.message : String(error)); this.active = false; - this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; this.pendingManualCodeResolve = null; @@ -100,7 +97,6 @@ class BrowserOAuthManager extends EventEmitter { async stopFlow(): Promise { this.active = false; - this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; if (this.pendingManualCodeReject) { @@ -129,7 +125,6 @@ class BrowserOAuthManager extends EventEmitter { const accountId = this.activeAccountId || providerType; const accountLabel = this.activeLabel; this.active = false; - this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; this.pendingManualCodeResolve = null; diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 86bbbb27..4c13333e 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -754,7 +754,7 @@ function migrateLegacyChannelConfigToAccounts( const legacyPayload = getLegacyChannelPayload(channelSection); const legacyKeys = Object.keys(legacyPayload); const existingAccounts = getChannelAccountsMap(channelSection); - const hasAccounts = Boolean(existingAccounts) && Object.keys(existingAccounts).length > 0; + const hasAccounts = existingAccounts ? Object.keys(existingAccounts).length > 0 : false; if (legacyKeys.length === 0) { if (hasAccounts && typeof channelSection.defaultAccount !== 'string') { @@ -1360,11 +1360,14 @@ export async function setChannelEnabled(channelType: string, enabled: boolean): if (enabled) { ensurePluginRegistration(currentConfig, resolvedChannelType); } else { - if (!currentConfig.plugins) currentConfig.plugins = {}; - if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {}; - if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {}; + const plugins = currentConfig.plugins ?? (currentConfig.plugins = {}); + const entries = plugins.entries ?? (plugins.entries = {}); + entries[resolvedChannelType] ??= {}; } - currentConfig.plugins.entries[resolvedChannelType].enabled = enabled; + const entries = currentConfig.plugins?.entries; + const pluginEntry = entries?.[resolvedChannelType]; + if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`); + pluginEntry.enabled = enabled; syncBuiltinChannelsWithPluginAllowlist(currentConfig); await writeOpenClawConfig(currentConfig); console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`); diff --git a/electron/utils/device-identity.ts b/electron/utils/device-identity.ts index de1a2c0a..2cf6cb5e 100644 --- a/electron/utils/device-identity.ts +++ b/electron/utils/device-identity.ts @@ -60,16 +60,16 @@ async function fileExists(p: string): Promise { /** Generate a new Ed25519 identity (async key generation). */ async function generateIdentity(): Promise { - const { publicKey, privateKey } = await new Promise( + const { publicKey, privateKey } = await new Promise<{ publicKey: crypto.KeyObject; privateKey: crypto.KeyObject }>( (resolve, reject) => { - crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => { + crypto.generateKeyPair('ed25519', {}, (err, publicKey, privateKey) => { if (err) reject(err); else resolve({ publicKey, privateKey }); }); }, ); - const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString(); - const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString(); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); return { deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, diff --git a/electron/utils/openai-codex-oauth.ts b/electron/utils/openai-codex-oauth.ts index 615b41a8..3e96642a 100644 --- a/electron/utils/openai-codex-oauth.ts +++ b/electron/utils/openai-codex-oauth.ts @@ -28,6 +28,7 @@ export interface OpenAICodexOAuthCredentials { refresh: string; expires: number; accountId: string; + email?: string; } interface OpenAICodexAuthorizationFlow { @@ -132,6 +133,12 @@ function getAccountIdFromAccessToken(accessToken: string): string | null { return null; } +function getEmailFromAccessToken(accessToken: string): string | undefined { + const payload = decodeJwtPayload(accessToken); + const email = payload?.email; + return typeof email === 'string' && email.trim() ? email.trim() : undefined; +} + async function createAuthorizationFlow(): Promise { const { verifier, challenge } = createPkce(); const state = createState(); @@ -301,6 +308,7 @@ export async function loginOpenAICodexOAuth(options: { refresh: token.refresh, expires: token.expires, accountId, + email: getEmailFromAccessToken(token.access), }; } finally { server?.close(); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 7e7cbac0..4bda62d2 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -708,7 +708,7 @@ async function discoverInstalledExtensionPluginIds(): Promise> { const ids = new Set(); const extensionRoot = join(homedir(), '.openclaw', 'extensions'); - let entries: Awaited>; + let entries: Array<{ isDirectory: () => boolean; name: string }>; try { entries = await readdir(extensionRoot, { withFileTypes: true }); } catch { @@ -1601,9 +1601,12 @@ function isPlainRecord(value: unknown): value is Record { } function removeLegacyMoonshotKimiSearchConfig(config: Record): boolean { - const tools = isPlainRecord(config.tools) ? config.tools : null; - const web = tools && isPlainRecord(tools.web) ? tools.web : null; - const search = web && isPlainRecord(web.search) ? web.search : null; + if (!isPlainRecord(config.tools) || !isPlainRecord(config.tools.web) || !isPlainRecord(config.tools.web.search)) { + return false; + } + const tools = config.tools as Record; + const web = tools.web as Record; + const search = web.search as Record; if (!search || !('kimi' in search)) return false; delete search.kimi; @@ -2677,9 +2680,9 @@ export async function sanitizeOpenClawConfig(): Promise { } } - const installs = isPlainRecord(pluginsObj.installs) ? pluginsObj.installs as Record : null; - const acpxInstall = installs && isPlainRecord(installs.acpx) ? installs.acpx as Record : null; - if (acpxInstall) { + if (isPlainRecord(pluginsObj.installs) && isPlainRecord(pluginsObj.installs.acpx)) { + const installs = pluginsObj.installs; + const acpxInstall = installs.acpx as Record; const currentBundledAcpxDir = join(getOpenClawResolvedDir(), 'dist', 'extensions', 'acpx').replace(/\\/g, '/'); const sourcePath = typeof acpxInstall.sourcePath === 'string' ? acpxInstall.sourcePath : ''; const installPath = typeof acpxInstall.installPath === 'string' ? acpxInstall.installPath : ''; diff --git a/electron/utils/openclaw-doctor.ts b/electron/utils/openclaw-doctor.ts index a5bd638b..9c4c00f1 100644 --- a/electron/utils/openclaw-doctor.ts +++ b/electron/utils/openclaw-doctor.ts @@ -164,7 +164,7 @@ async function runDoctorCommandWithArgs( stderrTruncated = next.truncated; }); - child.on('error', (error) => { + child.on('error', (error: unknown) => { clearTimeout(timeout); logger.error('Failed to spawn OpenClaw doctor process:', error); finish({ diff --git a/electron/utils/openclaw-image-generation.ts b/electron/utils/openclaw-image-generation.ts index 694fd061..267d4818 100644 --- a/electron/utils/openclaw-image-generation.ts +++ b/electron/utils/openclaw-image-generation.ts @@ -90,6 +90,13 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function getAgentsDefaults(config: unknown): Record | undefined { + if (!isRecord(config) || !isRecord(config.agents) || !isRecord(config.agents.defaults)) { + return undefined; + } + return config.agents.defaults; +} + function normalizeModelRef(raw: unknown): string | null { if (typeof raw === 'string' && raw.trim()) { return raw.trim(); @@ -181,13 +188,11 @@ export async function isImageProviderAuthenticated( export async function readImageGenerationConfig(): Promise { const config = await readOpenClawConfig(); - const defaults = config.agents?.defaults; - if (!defaults || typeof defaults !== 'object') { + const defaults = getAgentsDefaults(config); + if (!defaults) { return { primary: null, fallbacks: [], timeoutMs: null }; } - return parseImageGenerationModelConfig( - (defaults as Record).imageGenerationModel, - ); + return parseImageGenerationModelConfig(defaults.imageGenerationModel); } export async function setImageGenerationConfig( @@ -314,12 +319,8 @@ export async function getImageGenerationSettingsSnapshot(): Promise).mediaGenerationAutoProviderFallback === false - ); + const defaults = getAgentsDefaults(openclawConfig); + const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false; const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null; const relayState = readOpenAiCompatibleImageRelayState(openclawConfig as Record); diff --git a/electron/utils/proxy-fetch.ts b/electron/utils/proxy-fetch.ts index cd9c63e4..fddb8ae1 100644 --- a/electron/utils/proxy-fetch.ts +++ b/electron/utils/proxy-fetch.ts @@ -11,7 +11,7 @@ export async function proxyAwareFetch( if (process.versions.electron) { try { const { net } = await import('electron'); - return await net.fetch(input, init); + return await net.fetch(input instanceof URL ? input.toString() : input, init); } catch { // Fall through to the global fetch. } diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index fc2b6fa5..0a9fa398 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -5,7 +5,9 @@ * account-based provider storage and a dedicated secret-store abstraction. */ -import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry'; +import { BUILTIN_PROVIDER_TYPES } from './provider-registry'; +import type { ProviderConfig } from '../shared/providers/types'; +export type { ProviderConfig } from '../shared/providers/types'; import { getActiveOpenClawProviders } from './openclaw-auth'; import { deleteProviderAccount, @@ -25,24 +27,6 @@ import { } from '../services/secrets/secret-store'; import { getOpenClawProviderKeyForType } from './provider-keys'; -/** - * Provider configuration - */ -export interface ProviderConfig { - id: string; - name: string; - type: ProviderType; - baseUrl?: string; - apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; - headers?: Record; - model?: string; - fallbackModels?: string[]; - fallbackProviderIds?: string[]; - enabled: boolean; - createdAt: string; - updatedAt: string; -} - // ==================== API Key Storage ==================== /** diff --git a/electron/utils/store.ts b/electron/utils/store.ts index 4d8bb8a9..38184f78 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto'; import { app } from 'electron'; -import { resolveSupportedLanguage } from '../../shared/language'; +import { resolveSupportedLanguage } from '@shared/language'; // Lazy-load electron-store (ESM module) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/harness/specs/rules/api-client-transport-policy.md b/harness/specs/rules/api-client-transport-policy.md index 959ae5df..a0e48ded 100644 --- a/harness/specs/rules/api-client-transport-policy.md +++ b/harness/specs/rules/api-client-transport-policy.md @@ -8,8 +8,8 @@ requiredTests: - tests/unit/api-client.test.ts --- -Gateway RPC transport is IPC-only by default. Renderer code must not enable WebSocket or HTTP transport unless `src/lib/api-client.ts` explicitly gates it behind `clawx:gateway-ws-diagnostic`. +Gateway RPC transport is IPC-only. Renderer code must not enable WebSocket or HTTP transports to OpenClaw Gateway. -When diagnostics are enabled, the allowed order is `WS -> HTTP -> IPC`; otherwise the allowed order is `IPC`. +The renderer must call Main through typed host-api or legacy IPC wrappers only; Main owns the Gateway WebSocket. -Failed non-IPC transports must use backoff before retry. `gateway:httpProxy` remains a Main-owned proxy path and must not become direct renderer Gateway HTTP access. +Gateway frame diagnostics belong in Main-process logging, not renderer direct Gateway connections. diff --git a/harness/specs/rules/host-api-fallback-policy.md b/harness/specs/rules/host-api-fallback-policy.md index ceb9f923..9039d873 100644 --- a/harness/specs/rules/host-api-fallback-policy.md +++ b/harness/specs/rules/host-api-fallback-policy.md @@ -1,15 +1,16 @@ --- id: host-api-fallback-policy -title: Host API Fallback Policy +title: Host API Typed IPC Policy type: ai-coding-rule appliesTo: - gateway-backend-communication requiredTests: - - tests/unit/host-api.test.ts + - tests/unit/host-api-facade.test.ts + - tests/unit/host-invoke.test.ts --- -Renderer Host API requests must use `hostapi:fetch` IPC proxy by default. +Renderer Host API requests must use the typed `hostApi..()` facade and `host:invoke` bridge. -Browser fallback to `http://127.0.0.1:13210` is allowed only inside `src/lib/host-api.ts`, and only when `clawx:allow-localhost-fallback` is explicitly enabled. +The local Host API HTTP server and browser fallback to `http://127.0.0.1:13210` are removed and must not be reintroduced. -The Host API token must be obtained through `hostapi:token`; pages and components must not construct Host API localhost requests directly. +Pages and components must not call `window.electron.ipcRenderer.invoke(...)` directly for backend data; expose typed host-api/api-client methods instead. diff --git a/harness/specs/rules/host-events-fallback-policy.md b/harness/specs/rules/host-events-fallback-policy.md index a415e54f..beebfbbe 100644 --- a/harness/specs/rules/host-events-fallback-policy.md +++ b/harness/specs/rules/host-events-fallback-policy.md @@ -10,6 +10,6 @@ requiredTests: Host event subscriptions must use IPC mappings by default. -Unknown host events must not fall back to SSE unless `clawx:allow-sse-fallback` is explicitly enabled inside `src/lib/host-events.ts`. +Unknown host events must not fall back to SSE/EventSource. New user-visible gateway, channel, OAuth, or QR events should be added to the host event IPC mapping instead of relying on EventSource fallback. diff --git a/harness/specs/scenarios/gateway-backend-communication.md b/harness/specs/scenarios/gateway-backend-communication.md index e25511be..3470d7a1 100644 --- a/harness/specs/scenarios/gateway-backend-communication.md +++ b/harness/specs/scenarios/gateway-backend-communication.md @@ -8,8 +8,8 @@ ownedPaths: - src/stores/gateway.ts - src/stores/chat.ts - src/stores/chat/** - - electron/api/** - electron/main/ipc/** + - electron/services/** - electron/gateway/** - electron/preload/** - electron/utils/** @@ -41,19 +41,19 @@ forbiddenPatterns: - fetch("http://127.0.0.1:18789 in src/** - fetch('http://localhost:18789 in src/** - fetch("http://localhost:18789 in src/** - - clawx:allow-localhost-fallback outside src/lib/host-api.ts and tests - - clawx:allow-sse-fallback outside src/lib/host-events.ts and tests - - clawx:gateway-ws-diagnostic outside src/lib/api-client.ts and tests + - new WebSocket('ws://127.0.0.1:18789 in src/** + - new WebSocket("ws://127.0.0.1:18789 in src/** + - new WebSocket('ws://localhost:18789 in src/** + - new WebSocket("ws://localhost:18789 in src/** --- Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services. Allowed flow: -Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main host route or IPC handler -> gateway proxy / OpenClaw Gateway -> runtime result -> store/UI. +Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI. Renderer code must not own transport selection, direct IPC channels, direct Gateway HTTP calls, retry policy, or protocol fallback. -Explicit local fallback flags are narrow exceptions: -`clawx:allow-localhost-fallback` belongs to Host API browser fallback only, `clawx:allow-sse-fallback` belongs to host event SSE fallback only, and `clawx:gateway-ws-diagnostic` belongs to API client transport diagnostics only. +Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging. Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark. diff --git a/harness/specs/scenarios/plugin-lifecycle-management.md b/harness/specs/scenarios/plugin-lifecycle-management.md index ae354314..c18cd95c 100644 --- a/harness/specs/scenarios/plugin-lifecycle-management.md +++ b/harness/specs/scenarios/plugin-lifecycle-management.md @@ -8,7 +8,7 @@ ownedPaths: - electron/utils/channel-config.ts - electron/utils/plugin-install.ts - electron/gateway/skills-symlink-cleanup.ts - - electron/api/routes/skills.ts + - electron/services/skills-api.ts - src/stores/skills.ts - resources/skills/** - tests/unit/openclaw-auth.test.ts diff --git a/harness/specs/tasks/decouple-skills-page-from-gateway.md b/harness/specs/tasks/decouple-skills-page-from-gateway.md index 063e3da8..c30f009a 100644 --- a/harness/specs/tasks/decouple-skills-page-from-gateway.md +++ b/harness/specs/tasks/decouple-skills-page-from-gateway.md @@ -26,10 +26,10 @@ touchedAreas: - src/pages/Skills/index.tsx - src/stores/skills.ts - src/types/skill.ts - - src/i18n/locales/en/skills.json - - src/i18n/locales/zh/skills.json - - src/i18n/locales/ja/skills.json - - src/i18n/locales/ru/skills.json + - shared/i18n/locales/en/skills.json + - shared/i18n/locales/zh/skills.json + - shared/i18n/locales/ja/skills.json + - shared/i18n/locales/ru/skills.json - scripts/agent-browser/skills-local-first-smoke.sh - scripts/bundle-openclaw.mjs - tests/e2e/skills-gateway-readiness.spec.ts diff --git a/harness/specs/tasks/fix-chat-history-gateway-timeout.md b/harness/specs/tasks/fix-chat-history-gateway-timeout.md index 9b9789f7..e8d89985 100644 --- a/harness/specs/tasks/fix-chat-history-gateway-timeout.md +++ b/harness/specs/tasks/fix-chat-history-gateway-timeout.md @@ -6,7 +6,7 @@ taskType: runtime-bridge intent: Reduce startup chat.history contention so foreground history loads do not time out behind sidebar background hydration. touchedAreas: - harness/specs/tasks/fix-chat-history-gateway-timeout.md - - electron/api/routes/sessions.ts + - electron/services/sessions-api.ts - electron/gateway/rpc-backpressure.ts - electron/main/ipc-handlers.ts - src/components/layout/Sidebar.tsx @@ -26,7 +26,7 @@ touchedAreas: - tests/unit/gateway-rpc-backpressure.test.ts - tests/unit/history-startup-retry.test.ts - tests/unit/session-label-fetch.test.ts - - tests/unit/session-summaries-route.test.ts + - tests/unit/host-services.test.ts expectedUserBehavior: - Foreground chat history loading is prioritized during gateway startup and restart. - Sidebar/session label hydration does not compete with the first visible history load. @@ -48,7 +48,7 @@ requiredTests: - tests/unit/chat-store-session-label-fetch.test.ts - tests/unit/gateway-rpc-backpressure.test.ts - tests/unit/session-label-fetch.test.ts - - tests/unit/session-summaries-route.test.ts + - tests/unit/host-services.test.ts acceptance: - Renderer does not add direct IPC calls. - Renderer does not fetch Gateway HTTP directly. diff --git a/harness/specs/tasks/hard-delete-session-jsonl.md b/harness/specs/tasks/hard-delete-session-jsonl.md index e07d11b4..a5eb2318 100644 --- a/harness/specs/tasks/hard-delete-session-jsonl.md +++ b/harness/specs/tasks/hard-delete-session-jsonl.md @@ -6,11 +6,11 @@ taskType: runtime-bridge intent: Remove the on-disk session transcript (and its sibling artefacts) when the user deletes a conversation, instead of soft-deleting it via rename. touchedAreas: - electron/main/ipc-handlers.ts - - electron/api/routes/sessions.ts + - electron/services/sessions-api.ts - electron/utils/session-files.ts - src/stores/chat/session-actions.ts - src/stores/chat.ts - - tests/unit/session-delete-route.test.ts + - tests/unit/host-services.test.ts - harness/specs/tasks/hard-delete-session-jsonl.md - AGENTS.md expectedUserBehavior: @@ -25,12 +25,11 @@ requiredProfiles: - fast - comms requiredTests: - - tests/unit/session-delete-route.test.ts + - tests/unit/host-services.test.ts - tests/unit/chat-session-actions.test.ts acceptance: - Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls. - - IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape. - - Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift. + - Typed host session deletion and the legacy session:delete IPC handler unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift. - The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry. - Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run. - agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json). @@ -49,9 +48,8 @@ that rename with a true `unlink` plus a sibling sweep that also removes `.deleted.jsonl` (legacy soft-delete leftovers) and `.jsonl.reset.*` (reset snapshots produced by `sessions.reset`). -Both backends (the IPC handler used by the refactored chat store and the -HTTP route used by the legacy chat store) share the same disk contract via -`electron/utils/session-files.ts`, which centralises: +Both Main surfaces (the typed host session service and the legacy IPC handler) +share the same disk contract via `electron/utils/session-files.ts`, which centralises: - sessions.json entry resolution across the three observed shapes, - cross-platform absolute-path detection (POSIX, Windows `C:\...` and diff --git a/harness/specs/tasks/image-generation-settings.md b/harness/specs/tasks/image-generation-settings.md index 35473b76..5ddf11e0 100644 --- a/harness/specs/tasks/image-generation-settings.md +++ b/harness/specs/tasks/image-generation-settings.md @@ -17,8 +17,7 @@ touchedAreas: - scripts/bundle-openclaw.mjs - scripts/patch-openclaw-image-b64-json.mjs - package.json - - electron/api/routes/media.ts - - electron/api/server.ts + - electron/services/media-api.ts - electron/utils/store.ts - electron/services/providers/provider-runtime-sync.ts - src/lib/image-generation.ts @@ -27,8 +26,8 @@ touchedAreas: - src/components/settings/ImageGenerationSettings.tsx - src/pages/ImageGeneration/index.tsx - src/pages/Models/index.tsx - - src/i18n/locales/*/common.json - - src/i18n/locales/*/dashboard.json + - shared/i18n/locales/*/common.json + - shared/i18n/locales/*/dashboard.json - tests/unit/openclaw-image-generation.test.ts - tests/unit/openclaw-auth.test.ts - tests/e2e/image-generation-settings.spec.ts @@ -53,8 +52,8 @@ requiredTests: - tests/unit/openclaw-image-generation.test.ts - tests/e2e/image-generation-settings.spec.ts acceptance: - - Renderer uses hostApiFetch only (src/lib/image-generation.ts); no direct Gateway HTTP or ipcRenderer from pages. - - GET/PUT /api/media/image-generation and POST /api/media/image-generation/test are handled in Main process. + - Renderer uses typed hostApi media methods only (src/lib/image-generation.ts); no direct Gateway HTTP or ipcRenderer from pages. + - Image generation settings and test actions are handled in Main process services. - Unit tests cover model ref parsing, config read/write, custom endpoint model mapping, private-network endpoint opt-in, and the independent image endpoint not mutating `models.providers.openai`. - E2E verifies the Image Generation page is hidden until developer mode is enabled, is not embedded in Models, and exposes the custom endpoint controls. docs: diff --git a/harness/specs/tasks/plugin-validation.md b/harness/specs/tasks/plugin-validation.md index 7fd823f9..47081300 100644 --- a/harness/specs/tasks/plugin-validation.md +++ b/harness/specs/tasks/plugin-validation.md @@ -9,7 +9,7 @@ touchedAreas: - electron/utils/openclaw-auth.ts - electron/utils/channel-config.ts - electron/utils/plugin-install.ts - - electron/api/routes/skills.ts + - electron/services/skills-api.ts - tests/unit/openclaw-auth.test.ts - tests/unit/channel-config.test.ts - tests/unit/plugin-install.test.ts diff --git a/harness/specs/tasks/prune-host-api-covered-legacy-ipc.md b/harness/specs/tasks/prune-host-api-covered-legacy-ipc.md new file mode 100644 index 00000000..bb205e5d --- /dev/null +++ b/harness/specs/tasks/prune-host-api-covered-legacy-ipc.md @@ -0,0 +1,63 @@ +--- +id: prune-host-api-covered-legacy-ipc +title: Prune hostApi-covered legacy direct IPC handlers +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Remove legacy direct IPC channels that are already covered by host:invoke/hostApi and are no longer invoked by renderer code. +touchedAreas: + - README.md + - README.zh-CN.md + - README.ja-JP.md + - docs/superpowers/** + - harness/** + - electron/** + - src/** + - tests/** + - electron/main/ipc-handlers.ts + - electron/preload/index.ts + - src/lib/host-api.ts + - shared/host-api/contract.ts + - tests/unit/host-api-facade.test.ts + - harness/specs/tasks/prune-host-api-covered-legacy-ipc.md +expectedUserBehavior: + - No visible UI or runtime behavior changes. + - Renderer code continues to use hostApi for logs, skills, ClawHub, channel configuration, provider account helpers, and provider OAuth. + - Remaining direct IPC channels are those still intentionally used by renderer code or compatibility tests. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - host-api-fallback-policy + - docs-sync +requiredTests: + - tests/unit/host-api-facade.test.ts + - pnpm run typecheck + - pnpm test + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - HostApi-covered legacy direct IPC channels are not registered in electron/main/ipc-handlers.ts. + - HostApi-covered legacy direct IPC channels are not exposed through the preload invoke allowlist. + - Legacy Cron actions are not routed through app:request now that Cron uses hostApi.cron. + - Direct IPC channels with no renderer invoke callers are removed from Main and preload. + - Direct IPC channels still used by renderer code remain available. + - Gateway event forwarding and provider/channel OAuth event forwarding continue to work. +docs: + required: false +--- + +## Scope + +Prune direct IPC handlers whose implementation has an equivalent typed host +service and whose old channel is no longer invoked by renderer source code. +Keep event forwarding and still-used direct IPC channels intact. + +## Out of scope + +- Removing `app:request` or the old provider CRUD fallback in the same change. +- Removing direct IPC channels still used by Setup, file preview, shell/dialog, + update, window controls, or tests that intentionally cover legacy fallback. +- Reworking Gateway event subscription channels. diff --git a/harness/specs/tasks/remove-host-api-server-and-renderer-gateway-transports.md b/harness/specs/tasks/remove-host-api-server-and-renderer-gateway-transports.md new file mode 100644 index 00000000..12d86f46 --- /dev/null +++ b/harness/specs/tasks/remove-host-api-server-and-renderer-gateway-transports.md @@ -0,0 +1,74 @@ +--- +id: remove-host-api-server-and-renderer-gateway-transports +title: Remove local Host API server and renderer Gateway transports +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Collapse backend communication to typed Electron IPC in the renderer, with OpenClaw Gateway WebSocket ownership kept in Electron Main. +touchedAreas: + - README.md + - README.zh-CN.md + - README.ja-JP.md + - docs/superpowers/** + - harness/** + - src/** + - tests/** + - electron/api/** + - electron/extensions/** + - src/lib/host-api.ts + - src/lib/host-api-client.ts + - src/lib/api-client.ts + - src/lib/host-events.ts + - src/stores/gateway.ts + - src/stores/chat.ts + - src/pages/Settings/index.tsx + - electron/main/index.ts + - electron/main/ipc-handlers.ts + - electron/main/ipc/** + - electron/preload/** + - electron/services/** + - electron/gateway/** + - harness/specs/scenarios/gateway-backend-communication.md + - harness/specs/rules/host-api-fallback-policy.md + - harness/specs/rules/api-client-transport-policy.md + - tests/unit/host-api-facade.test.ts + - tests/unit/host-invoke.test.ts + - tests/unit/host-events.test.ts + - tests/unit/api-client.test.ts + - tests/unit/gateway-ws-trace.test.ts +expectedUserBehavior: + - Settings, channels, agents, providers, skills, cron, chat, sessions, files, media, usage, and diagnostics continue to work through typed hostApi calls. + - Renderer no longer starts or contacts a local Host API HTTP server. + - Renderer no longer opens a direct WebSocket or HTTP proxy transport to OpenClaw Gateway. + - Gateway RPC and Gateway events continue through the Main-owned Gateway manager connection. + - Gateway WebSocket frame diagnostics are available from Main-process logs when CLAWX_GATEWAY_WS_TRACE=1 is set. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - host-api-fallback-policy + - host-events-fallback-policy + - docs-sync +requiredTests: + - tests/unit/host-api-facade.test.ts + - tests/unit/host-invoke.test.ts + - tests/unit/host-events.test.ts + - tests/unit/api-client.test.ts + - tests/unit/gateway-ws-trace.test.ts + - pnpm run typecheck + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - No production source references the legacy Host API fetch/token IPC names, Gateway HTTP proxy channel, local Host API server startup, Host event bus, or renderer Gateway WS diagnostic toggles. + - src/lib/api-client.ts is IPC-only and does not construct WebSocket or HTTP Gateway transports. + - src/lib/host-api.ts exposes typed hostApi facade methods and does not export a path-based fetch helper. + - src/lib/host-events.ts subscribes through typed IPC events and does not fall back to SSE/EventSource. + - electron/api and local Host API route tests are removed. + - README.md, README.zh-CN.md, and README.ja-JP.md describe typed IPC and Main-owned Gateway WebSocket ownership. +docs: + required: true +--- + +Use this spec when removing or auditing legacy renderer/Main/backend communication paths. diff --git a/harness/specs/tasks/tighten-host-api-contract-types.md b/harness/specs/tasks/tighten-host-api-contract-types.md new file mode 100644 index 00000000..987b0d03 --- /dev/null +++ b/harness/specs/tasks/tighten-host-api-contract-types.md @@ -0,0 +1,71 @@ +--- +id: tighten-host-api-contract-types +title: Tighten host API contract types across renderer and Main +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Replace ad-hoc unknown-based host invoke typing with a function-shaped HostApiContract shared by the renderer facade, preload bridge, and Main host service registry. +touchedAreas: + - README.md + - README.zh-CN.md + - README.ja-JP.md + - docs/superpowers/** + - harness/** + - electron/** + - src/** + - tests/** + - harness/specs/tasks/tighten-host-api-contract-types.md + - shared/host-api/contract.ts + - src/lib/host-api-client.ts + - shared/host-api/types.ts + - src/lib/host-api.ts + - src/types/electron.d.ts + - electron/preload/index.ts + - electron/main/ipc/host-contract.ts + - electron/main/ipc/host-invoke.ts + - electron/services/** + - electron/services/payload-utils.ts + - tests/unit/host-api-facade.test.ts + - tests/unit/host-invoke.test.ts +expectedUserBehavior: + - No visible UI or runtime behavior changes. + - Renderer pages and stores continue to call backend operations through hostApi..(). + - Unsupported or malformed host:invoke requests still return validation or unsupported errors from Main. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - host-api-fallback-policy + - docs-sync +requiredTests: + - tests/unit/host-api-facade.test.ts + - tests/unit/host-invoke.test.ts + - pnpm run typecheck + - pnpm test + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - HostApiContract is expressed as module/action function signatures, not { input, output } descriptors. + - invokeHost infers payload and result types from HostApiContract instead of accepting string/string/unknown plus a caller-supplied output generic. + - src/lib/host-api.ts facade methods expose typed inputs for normal hostApi calls; gateway.rpc keeps a generic result escape hatch for dynamic Gateway RPCs. + - Electron Main host service registration is constrained by the same HostApiContract. + - Host service handlers inherit payload parameter types from HostApiContract instead of annotating payload as unknown. + - Shared payload shape checks live in electron/services/payload-utils.ts instead of being redefined in each service file. + - Runtime host request validation continues to treat untrusted IPC input as unknown at the dispatcher boundary. +docs: + required: false +--- + +## Scope + +This task is a type-safety refactor for the existing typed IPC bridge created by +`remove-host-api-server-and-renderer-gateway-transports`. It does not add a new +backend route, change transport selection, or alter user-visible flows. + +## Out of scope + +- Reworking legacy direct IPC channels that still exist outside host:invoke. +- Adding runtime schema validation for every payload. +- Removing dynamic typing from Gateway RPC method-specific params/results. diff --git a/harness/specs/tasks/tighten-host-events-contract-types.md b/harness/specs/tasks/tighten-host-events-contract-types.md new file mode 100644 index 00000000..a89338ac --- /dev/null +++ b/harness/specs/tasks/tighten-host-events-contract-types.md @@ -0,0 +1,64 @@ +--- +id: tighten-host-events-contract-types +title: Tighten host event contract types +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Move Main-to-renderer host event payload typing into a shared contract so renderer subscribers no longer provide ad-hoc generic payload types. +touchedAreas: + - AGENTS.md + - README.md + - README.zh-CN.md + - README.ja-JP.md + - docs/superpowers/** + - package.json + - tsconfig.json + - tsconfig.node.json + - tsconfig.web.json + - vite.config.ts + - vitest.config.ts + - electron/** + - src/** + - tests/** + - harness/** + - shared/** + - shared/host-events/** + - shared/types/** + - src/lib/host-events.ts + - src/stores/gateway.ts + - src/components/channels/** + - src/components/settings/** + - electron/preload/** + - electron/main/ipc-handlers.ts + - electron/gateway/** + - electron/utils/** + - tests/unit/host-events.test.ts + - tests/unit/gateway-events.test.ts +expectedUserBehavior: + - No visible UI or runtime behavior changes. + - Renderer stores and components continue to receive Gateway, OAuth, and QR channel events through hostEvents. + - Gateway status, chat notifications, channel status refreshes, OAuth login progress, and QR login feedback still update the UI as before. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - host-events-fallback-policy + - docs-sync +requiredTests: + - tests/unit/host-events.test.ts + - tests/unit/gateway-events.test.ts + - pnpm run typecheck + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - src/lib/host-events.ts derives subscriber payload types from a shared host-events contract. + - Renderer call sites do not pass generic payload types to hostEvents subscribers. + - Known Gateway, OAuth, and QR channel event payloads use concrete shared types instead of caller-side unknown casts where the full chain determines the shape. + - Host events still subscribe through IPC and do not reintroduce SSE/EventSource fallback. +docs: + required: false +--- + +Use this spec when changing Main-to-renderer event subscription typing or event payload contracts. diff --git a/harness/src/rules.mjs b/harness/src/rules.mjs index 809e03af..75319ed9 100644 --- a/harness/src/rules.mjs +++ b/harness/src/rules.mjs @@ -4,19 +4,18 @@ import { ROOT, pathMatchesAny, toArray } from './specs.mjs'; const DIRECT_IPC_PATTERN = /window\.electron\.ipcRenderer\.invoke\s*\(/; const DIRECT_GATEWAY_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):18789/; +const DIRECT_GATEWAY_WS_PATTERN = /new\s+WebSocket\s*\(\s*['"`]ws:\/\/(?:127\.0\.0\.1|localhost):18789|ws:\/\/(?:127\.0\.0\.1|localhost):18789/; const HOST_API_LOCAL_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):13210|HOST_API_BASE\s*=\s*`?http:\/\/127\.0\.0\.1:\$\{HOST_API_PORT\}`?/; -const LOCALHOST_FALLBACK_FLAG = 'clawx:allow-localhost-fallback'; -const SSE_FALLBACK_FLAG = 'clawx:allow-sse-fallback'; -const WS_DIAGNOSTIC_FLAG = 'clawx:gateway-ws-diagnostic'; const GATEWAY_READY_MUTATION_PATTERN = /gatewayReady\s*[:=]\s*(?:true|false)|setStatus\s*\([^)]*gatewayReady|setState\s*\([^)]*gatewayReady/s; const COMMUNICATION_PATHS = [ 'src/lib/api-client.ts', 'src/lib/host-api.ts', + 'src/lib/host-api-client.ts', 'src/stores/gateway.ts', 'src/stores/chat.ts', 'src/stores/chat/**', - 'electron/api/**', 'electron/main/ipc/**', + 'electron/services/**', 'electron/gateway/**', 'electron/preload/**', 'electron/utils/**', @@ -55,21 +54,12 @@ export async function scanBackendCommunicationBoundary(files) { failures.push(`${file}: renderer must not fetch Gateway HTTP directly`); } - const isTest = file.startsWith('tests/'); - if (!isTest && text.includes(LOCALHOST_FALLBACK_FLAG) && file !== 'src/lib/host-api.ts') { - failures.push(`${file}: ${LOCALHOST_FALLBACK_FLAG} is only allowed in src/lib/host-api.ts`); + if (DIRECT_GATEWAY_WS_PATTERN.test(text)) { + failures.push(`${file}: renderer must not open Gateway WebSocket connections directly`); } - if (!isTest && HOST_API_LOCAL_HTTP_PATTERN.test(text) && file !== 'src/lib/host-api.ts') { - failures.push(`${file}: direct Host API localhost fallback is only allowed in src/lib/host-api.ts`); - } - - if (!isTest && text.includes(SSE_FALLBACK_FLAG) && file !== 'src/lib/host-events.ts') { - failures.push(`${file}: ${SSE_FALLBACK_FLAG} is only allowed in src/lib/host-events.ts`); - } - - if (!isTest && text.includes(WS_DIAGNOSTIC_FLAG) && file !== 'src/lib/api-client.ts') { - failures.push(`${file}: ${WS_DIAGNOSTIC_FLAG} is only allowed in src/lib/api-client.ts`); + if (HOST_API_LOCAL_HTTP_PATTERN.test(text)) { + failures.push(`${file}: renderer must not use the removed Host API localhost server`); } const isPageOrComponentFile = file.startsWith('src/pages/') || file.startsWith('src/components/'); diff --git a/package.json b/package.json index 3305bd29..cb53ebf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.4.8", + "version": "0.4.9-alpha.0", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", @@ -42,7 +42,9 @@ "bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs", "lint": "eslint . --fix", "lint:check": "eslint .", - "typecheck": "tsc --noEmit", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", + "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", "test": "vitest run", "test:e2e": "pnpm run build:vite && playwright test", "test:e2e:headed": "pnpm run build:vite && playwright test --headed", diff --git a/shared/chat/types.ts b/shared/chat/types.ts new file mode 100644 index 00000000..32078358 --- /dev/null +++ b/shared/chat/types.ts @@ -0,0 +1,169 @@ +import type { ChatRuntimeEvent } from '../chat-runtime-events'; + +/** Metadata for locally-attached files (not from Gateway) */ +export interface AttachedFileMeta { + fileName: string; + mimeType: string; + fileSize: number; + preview: string | null; + previewStatus?: 'unavailable'; + filePath?: string; + source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media'; + /** + * For Gateway-injected outgoing media (assistant-media). The Gateway emits + * an `image` content block with a relative URL like + * `/api/chat/media/outgoing///full`. The renderer + * cannot reach Gateway HTTP directly (CORS / env drift), so this URL is + * resolved through the Main-process proxy in `media:getThumbnails`, which + * looks up `~/.openclaw/media/outgoing/records/.json` and + * loads the original file off disk. + */ + gatewayUrl?: 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; + stopReason?: string; + stop_reason?: string; + errorMessage?: string; + error_message?: string; + /** 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; + /** + * Flat URL on an `image` block. Gateway-injected assistant-media messages + * use this shape: `{ type:'image', url:'/api/chat/media/outgoing/...', mimeType, width, height, alt, openUrl }`. + * Neither nested `source.url` nor flat `data` is set in that case; the + * renderer must read `block.url` directly to surface the artifact. + */ + url?: string; + /** Optional companion of `url` — points at a higher-resolution variant. */ + openUrl?: string; + /** Pixel width of the original image, used for layout hints. */ + width?: number; + /** Pixel height of the original image, used for layout hints. */ + height?: number; + /** Human-readable filename / alt text emitted by the Gateway. */ + alt?: string; + id?: string; + name?: string; + input?: unknown; + arguments?: unknown; + content?: unknown; +} + +/** Session from sessions.list */ +export interface ChatSession { + key: string; + label?: string; + displayName?: string; + derivedTitle?: string; + lastMessagePreview?: string; + thinkingLevel?: string; + model?: string; + updatedAt?: number; + status?: string; + hasActiveRun?: boolean; +} + +export interface ToolStatus { + id?: string; + toolCallId?: string; + name: string; + status: 'running' | 'completed' | 'error'; + durationMs?: number; + summary?: string; + updatedAt: number; +} + +export interface ChatRuntimeRunState { + runId: string; + sessionKey?: string; + status: 'running' | 'completed' | 'error' | 'aborted'; + startedAt?: number; + endedAt?: number; + assistantText: string; + thinkingText: string; + events: ChatRuntimeEvent[]; +} + +export interface ChatState { + // Messages + messages: RawMessage[]; + loading: boolean; + loadingMoreHistory: boolean; + hasMoreHistory: boolean; + error: string | null; + runError: 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[]; + runtimeRuns: Record; + + // Sessions + sessions: ChatSession[]; + currentSessionKey: string; + currentAgentId: 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 + thinkingLevel: string | null; + + // Actions + loadSessions: () => Promise; + switchSession: (key: string) => void; + newSession: () => void; + deleteSession: (key: string) => Promise; + renameSession: (key: string, label: string) => Promise; + cleanupEmptySession: () => void; + loadHistory: (quiet?: boolean) => Promise; + loadMoreHistory: () => Promise; + sendMessage: ( + text: string, + attachments?: Array<{ + fileName: string; + mimeType: string; + fileSize: number; + stagedPath: string; + preview: string | null; + }>, + targetAgentId?: string | null, + ) => Promise; + abortRun: () => Promise; + handleChatEvent: (event: Record) => void; + handleRuntimeEvent: (event: ChatRuntimeEvent) => void; + refresh: () => Promise; + clearError: () => void; +} + +export const DEFAULT_CANONICAL_PREFIX = 'agent:main'; +export const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`; diff --git a/shared/host-api/contract.ts b/shared/host-api/contract.ts new file mode 100644 index 00000000..4fedd358 --- /dev/null +++ b/shared/host-api/contract.ts @@ -0,0 +1,874 @@ +import type { RawMessage } from '../chat/types'; +import type { AgentsSnapshot } from '../types/agent'; +import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; +import type { GatewayHealth, GatewayStatus } from '../types/gateway'; +import type { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill'; + +export type JsonRecord = Record; +export type HostSuccess = { success: boolean; error?: string }; +export type OptionalHostSuccess = { success?: boolean; error?: string }; + +export type OpenClawDoctorMode = 'diagnose' | 'fix'; +export type OpenClawDoctorResult = HostSuccess & { + mode: OpenClawDoctorMode; + exitCode: number | null; + stdout: string; + stderr: string; + command: string; + cwd: string; + durationMs: number; + timedOut?: boolean; +}; +export type OpenClawDoctorPayload = { mode: OpenClawDoctorMode }; + +export type OpenClawStatusResult = { + packageExists: boolean; + isBuilt: boolean; + entryPath: string; + dir: string; + version?: string; +}; +export type OpenClawCliCommandResult = HostSuccess & { command?: string }; + +export type ShellPathPayload = { path: string }; +export type ShellOpenExternalPayload = { url: string }; +export type DialogOpenPayload = { + title?: string; + defaultPath?: string; + buttonLabel?: string; + filters?: Array<{ name: string; extensions: string[] }>; + properties?: Array< + | 'openFile' + | 'openDirectory' + | 'multiSelections' + | 'showHiddenFiles' + | 'createDirectory' + | 'promptToCreate' + | 'noResolveAliases' + | 'treatPackageAsDirectory' + | 'dontAddToRecent' + >; + message?: string; + securityScopedBookmarks?: boolean; +}; +export type DialogOpenResult = { + canceled: boolean; + filePaths: string[]; + bookmarks?: string[]; +}; +export type DialogMessagePayload = { + message: string; + type?: 'none' | 'info' | 'error' | 'question' | 'warning'; + buttons?: string[]; + defaultId?: number; + cancelId?: number; + detail?: string; + checkboxLabel?: string; + checkboxChecked?: boolean; + noLink?: boolean; + title?: string; +}; +export type DialogMessageResult = { + response: number; + checkboxChecked?: boolean; +}; +export type WindowSyncTrafficLightPayload = { sidebarCollapsed: boolean }; +export type UpdateChannel = 'stable' | 'beta' | 'dev'; +export type UpdateInfoSnapshot = { + version: string; + releaseDate?: string; + releaseNotes?: string | null; +}; +export type UpdateProgressSnapshot = { + total: number; + delta: number; + transferred: number; + percent: number; + bytesPerSecond: number; +}; +export type UpdateStatusSnapshot = { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: UpdateInfoSnapshot; + progress?: UpdateProgressSnapshot; + error?: string; +}; +export type UpdateCheckResult = HostSuccess & { status?: UpdateStatusSnapshot }; +export type UpdateSetChannelPayload = { channel: UpdateChannel }; +export type UpdateSetAutoDownloadPayload = { enable: boolean }; + +export type SettingsSnapshot = Partial<{ + theme: 'light' | 'dark' | 'system'; + language: string; + startMinimized: boolean; + launchAtStartup: boolean; + telemetryEnabled: boolean; + gatewayAutoStart: boolean; + gatewayPort: number; + proxyEnabled: boolean; + proxyServer: string; + proxyHttpServer: string; + proxyHttpsServer: string; + proxyAllServer: string; + proxyBypassRules: string; + updateChannel: 'stable' | 'beta' | 'dev'; + autoCheckUpdate: boolean; + sidebarCollapsed: boolean; + sidebarWidth: number; + devModeUnlocked: boolean; + setupComplete: boolean; +}>; +export type SettingsKey = keyof SettingsSnapshot & string; +export type SettingsValue = SettingsSnapshot[SettingsKey]; +export type SettingsGetPayload = { key: SettingsKey }; +export type SettingsSetPayload = { key: SettingsKey; value: SettingsValue }; +export type SettingsSetManyPayload = { patch: Partial }; +export type SettingsResetResult = HostSuccess & { settings: SettingsSnapshot }; + +export type GatewayControlUiPayload = { view?: 'dreams' }; +export type GatewayControlUiResult = HostSuccess & { + url?: string; + token?: string; + port?: number; +}; +export type GatewayHealthPayload = { probe?: boolean }; +export type GatewayRpcPayload = { + method: string; + params?: unknown; + timeoutMs?: number; +}; + +export type LogContentResult = { content: string }; +export type LogDirResult = { dir: string | null }; +export type LogFilePathResult = { path: string | null }; +export type LogRecentPayload = { tailLines?: number }; +export type LogMemoryPayload = { count?: number }; +export type LogReadFilePayload = { path: string; tailLines?: number }; +export type LogFileEntry = { + path: string; + name?: string; + size?: number; + mtime?: number; +}; +export type LogFilesResult = { files: LogFileEntry[] }; + +export type GatewayHealthSummary = { + state: 'healthy' | 'degraded' | 'unresponsive'; + reasons: string[]; + consecutiveHeartbeatMisses: number; + lastAliveAt?: number; + lastRpcSuccessAt?: number; + lastRpcFailureAt?: number; + lastRpcFailureMethod?: string; + lastChannelsStatusOkAt?: number; + lastChannelsStatusFailureAt?: number; +}; + +export type ChannelRuntimeStatus = 'connected' | 'connecting' | 'degraded' | 'disconnected' | 'error'; +export type ChannelAccountItem = { + accountId: string; + name: string; + configured: boolean; + status: ChannelRuntimeStatus; + statusReason?: string; + lastError?: string; + isDefault: boolean; + agentId?: string; +}; +export type ChannelGroupItem = { + channelType: string; + defaultAccountId: string; + status: ChannelRuntimeStatus; + statusReason?: string; + accounts: ChannelAccountItem[]; +}; +export type ChannelTargetOption = { + value: string; + label: string; + kind: 'user' | 'group' | 'channel'; +}; +export type ChannelAccountsPayload = { + mode?: 'config' | 'runtime'; + configOnly?: boolean; + probe?: boolean; +}; +export type ChannelAccountsResult = HostSuccess & { + channels?: ChannelGroupItem[]; + gatewayHealth?: GatewayHealthSummary; +}; +export type ChannelTargetsPayload = { + channelType: string; + accountId?: string; + query?: string; +}; +export type ChannelTargetsResult = HostSuccess & { + channelType?: string; + accountId?: string; + targets?: ChannelTargetOption[]; +}; +export type ChannelTypePayload = { channelType: string }; +export type ChannelAccountPayload = ChannelTypePayload & { accountId?: string }; +export type ChannelRequiredAccountPayload = ChannelTypePayload & { accountId: string }; +export type ChannelBindingSavePayload = ChannelRequiredAccountPayload & { agentId: string }; +export type ChannelBindingDeletePayload = ChannelAccountPayload; +export type ChannelSetEnabledPayload = ChannelTypePayload & { enabled: boolean }; +export type ChannelFormValuesResult = HostSuccess & { + values?: Record; +}; +export type ChannelCredentialValidationPayload = ChannelTypePayload & { + config: Record; +}; +export type ChannelCredentialValidationResult = HostSuccess & { + valid: boolean; + errors?: string[]; + warnings?: string[]; + details?: { + botUsername?: string; + guildName?: string; + channelName?: string; + }; +}; +export type ChannelSaveConfigPayload = ChannelTypePayload & { + config: Record; + accountId?: string; +}; +export type ChannelSaveConfigResult = HostSuccess & { + noChange?: boolean; + warning?: string; +}; +export type ChannelConfiguredResult = HostSuccess & { channels?: Array }; + +export type AgentSnapshotResult = AgentsSnapshot & OptionalHostSuccess; +export type AgentCreatePayload = { name: string; inheritWorkspace?: boolean }; +export type AgentUpdatePayload = { id: string; name: string }; +export type AgentUpdateModelPayload = { id: string; modelRef: string | null }; +export type AgentIdPayload = { id: string }; +export type AgentChannelPayload = { id: string; channelType: string }; + +export type DiagnosticsGatewaySnapshotResult = JsonRecord; + +export type ProviderType = + | 'anthropic' + | 'openai' + | 'google' + | 'openrouter' + | 'ark' + | 'moonshot' + | 'moonshot-global' + | 'siliconflow' + | 'deepseek' + | 'minimax-portal' + | 'minimax-portal-cn' + | 'modelstudio' + | 'ollama' + | 'custom'; +export type ProviderAuthMode = 'api_key' | 'oauth_device' | 'oauth_browser' | 'local'; +export type ProviderVendorCategory = 'official' | 'compatible' | 'local' | 'custom'; +export type ProviderProtocol = + | 'openai-completions' + | 'openai-responses' + | 'openai-codex-responses' + | 'anthropic-messages' + | 'google-generative-ai' + | 'github-copilot' + | 'bedrock-converse-stream' + | 'ollama' + | 'azure-openai-responses'; +export type ProviderConfig = { + id: string; + name: string; + type: ProviderType; + baseUrl?: string; + apiProtocol?: ProviderProtocol; + headers?: Record; + model?: string; + fallbackModels?: string[]; + fallbackProviderIds?: string[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +}; +export type ProviderWithKeyInfo = ProviderConfig & { + hasKey: boolean; + keyMasked: string | null; +}; +export type ProviderVendorInfo = { + id: ProviderType; + name: string; + icon: string; + placeholder: string; + model?: string; + requiresApiKey: boolean; + defaultBaseUrl?: string; + showBaseUrl?: boolean; + showModelId?: boolean; + showModelIdInDevModeOnly?: boolean; + modelIdPlaceholder?: string; + defaultModelId?: string; + isOAuth?: boolean; + supportsApiKey?: boolean; + apiKeyUrl?: string; + docsUrl?: string; + docsUrlZh?: string; + codePlanPresetBaseUrl?: string; + codePlanPresetModelId?: string; + codePlanDocsUrl?: string; + hidden?: boolean; + hideOAuthUi?: boolean; + category: ProviderVendorCategory; + envVar?: string; + supportedAuthModes: ProviderAuthMode[]; + defaultAuthMode: ProviderAuthMode; + supportsMultipleAccounts: boolean; +}; +export type ProviderAccount = { + id: string; + vendorId: ProviderType; + label: string; + authMode: ProviderAuthMode; + baseUrl?: string; + apiProtocol?: ProviderProtocol; + headers?: Record; + model?: string; + fallbackModels?: string[]; + fallbackAccountIds?: string[]; + enabled: boolean; + isDefault: boolean; + metadata?: { + region?: string; + email?: string; + resourceUrl?: string; + customModels?: string[]; + }; + createdAt: string; + updatedAt: string; +}; +export type ProviderAccountKeyInfo = { + accountId: string; + hasKey: boolean; + keyMasked: string | null; +}; +export type ProviderDefaultAccountResult = { accountId: string | null }; +export type ProviderValidationOptions = { + baseUrl?: string; + apiProtocol?: string; +}; +export type ProviderValidationPayload = { + accountId?: string; + vendorId?: string; + providerId?: string; + apiKey: string; + options?: ProviderValidationOptions; +}; +export type ProviderValidationResult = { valid: boolean; error?: string }; +export type ProviderIdPayload = { providerId: string }; +export type ProviderApiKeyPayload = ProviderIdPayload & { apiKey: string }; +export type ProviderSavePayload = { config: ProviderConfig; apiKey?: string }; +export type ProviderUpdateWithKeyPayload = { + providerId: string; + updates: Partial; + apiKey?: string; +}; +export type ProviderAccountIdPayload = { accountId: string }; +export type ProviderCreateAccountPayload = { account: ProviderAccount; apiKey?: string }; +export type ProviderUpdateAccountPayload = { + accountId: string; + updates: Partial; + apiKey?: string; +}; +export type ProviderOAuthRequestPayload = { + provider: string; + region?: 'global' | 'cn'; + accountId?: string; + label?: string; +}; +export type ProviderOAuthSubmitPayload = { code: string }; + +export type StagedFileResult = { + id: string; + fileName: string; + mimeType: string; + fileSize: number; + stagedPath: string; + preview: string | null; + filePath?: string; +}; +export type StagePathsPayload = { filePaths: string[] }; +export type StageBufferPayload = { base64: string; fileName: string; mimeType?: string }; +export type FilePathPayload = { path: string }; +export type FileReadBinaryOptions = { maxBytes?: number }; +export type FilePreviewTreeOptions = { + maxDepth?: number; + maxNodes?: number; + includeHidden?: boolean; +}; +export type FileReadBinaryPayload = FilePathPayload & { opts?: FileReadBinaryOptions }; +export type FileWriteTextPayload = FilePathPayload & { content: string }; +export type FileListTreePayload = FilePathPayload & { opts?: FilePreviewTreeOptions }; +export type FilePreviewError = + | 'outsideSandbox' + | 'readOnlyRoot' + | 'tooLarge' + | 'binary' + | 'notFound' + | 'notDirectory' + | 'invalidContent' + | (string & {}); +export type ReadTextFileResult = { + ok: boolean; + content?: string; + mimeType?: string; + size?: number; + readOnly?: boolean; + error?: FilePreviewError; +}; +export type ReadBinaryFileResult = { + ok: boolean; + data?: Uint8Array; + mimeType?: string; + size?: number; + readOnly?: boolean; + error?: FilePreviewError; +}; +export type WriteTextFileResult = { + ok: boolean; + error?: FilePreviewError; +}; +export type StatFileResult = { + ok: boolean; + size?: number; + mtime?: number; + isFile?: boolean; + isDir?: boolean; + readOnly?: boolean; + error?: FilePreviewError; +}; +export type FileListDirEntry = { + name: string; + path: string; + isDir: boolean; + size: number; +}; +export type FileListDirResult = { + ok: boolean; + entries?: FileListDirEntry[]; + error?: FilePreviewError; +}; +export type FilePreviewTreeNode = { + name: string; + relPath: string; + absPath: string; + isDir: boolean; + size?: number; + mtime?: number; + children?: FilePreviewTreeNode[]; +}; +export type FileListTreeResult = { + ok: boolean; + root?: FilePreviewTreeNode; + truncated?: boolean; + error?: FilePreviewError; +}; + +export type MediaThumbnailEntry = { + filePath?: string; + gatewayUrl?: string; + mimeType?: string; +}; +export type MediaThumbnailsPayload = { paths: MediaThumbnailEntry[] }; +export type MediaThumbnailResult = Record; +export type SaveImagePayload = { + base64?: string; + mimeType?: string; + filePath?: string; + defaultFileName?: string; +}; +export type ImageGenerationModelConfig = { + primary: string | null; + fallbacks: string[]; + timeoutMs: number | null; +}; +export type ImageGenerationAgentAuthRow = { + id: string; + name: string; + isDefault: boolean; + provider: string | null; + configured: boolean; +}; +export type OpenAiImageRelayConfig = { + enabled: boolean; + baseUrl: string; + model: string; + providerKey?: string; + apiKeyConfigured: boolean; +}; +export type ImageGenerationSettingsSnapshot = { + config: ImageGenerationModelConfig; + autoProviderFallback: boolean; + defaultAgentId: string; + agents: ImageGenerationAgentAuthRow[]; + openAiRelay: OpenAiImageRelayConfig; +}; +export type ImageGenerationProviderRow = { + id: string; + label: string; + defaultModel: string; + configured: boolean; + available: boolean; + selected: boolean; + models: string[]; +}; +export type ImageGenerationSettingsPayload = { + primary?: string | null; + fallbacks?: string[]; + timeoutMs?: number | null; + openAiRelayEnabled?: boolean; + openAiRelayBaseUrl?: string | null; + openAiRelayModel?: string | null; + openAiRelayApiKey?: string; +}; +export type ImageGenerationSettingsResult = OptionalHostSuccess & ImageGenerationSettingsSnapshot; +export type ImageGenerationProvidersResult = OptionalHostSuccess & { + providers?: ImageGenerationProviderRow[]; +}; +export type ImageGenerationTestPayload = { + agentId?: string; + prompt?: string; + model?: string; +}; +export type ImageGenerationTestResult = { + success: boolean; + agentId: string; + command: string; + durationMs: number; + error?: string; + stdout?: string; + stderr?: string; + result?: unknown; +}; + +export type SessionHistoryPayload = { + sessionKey?: string; + agentId?: string; + sessionId?: string; + limit?: number; +}; +export type SessionHistoryResult = OptionalHostSuccess & { + messages?: RawMessage[]; +}; +export type SessionSummariesPayload = { sessionKeys?: string[]; limit?: number }; +export type SessionLabelSummary = { + sessionKey: string; + firstUserText: string | null; + lastTimestamp: number | null; +}; +export type SessionSummariesResult = HostSuccess & { + summaries?: SessionLabelSummary[]; +}; +export type SessionDeletePayload = { id: string }; +export type SessionRenamePayload = { id: string; title: string }; + +export type ChatMediaItem = { filePath: string; mimeType?: string; fileName?: string }; +export type ChatSendWithMediaPayload = { + sessionKey: string; + message?: string; + deliver?: boolean; + idempotencyKey: string; + media?: ChatMediaItem[]; +}; +export type ChatSendWithMediaResult = HostSuccess & { + result?: { runId?: string }; +}; + +export type CronUpdatePayload = { id: string; input: CronJobUpdateInput }; +export type CronIdPayload = { id: string }; +export type CronTogglePayload = CronIdPayload & { enabled: boolean }; +export type CronSessionHistoryPayload = { sessionKey: string; limit?: number }; +export type CronSessionHistoryResult = { + messages?: RawMessage[]; +}; + +export type SkillsStatusResult = { + skills?: { + skillKey: string; + slug?: string; + name?: string; + description?: string; + disabled?: boolean; + emoji?: string; + version?: string; + author?: string; + config?: Record; + bundled?: boolean; + always?: boolean; + source?: string; + baseDir?: string; + filePath?: string; + }[]; +}; +export type LocalSkillsResult = HostSuccess & { skills?: Skill[] }; +export type SkillConfigsResult = Record }>; +export type SkillKeyPayload = { skillKey: string }; +export type SkillUpdateConfigPayload = SkillKeyPayload & { + enabled?: boolean; + apiKey?: string; + env?: Record; +}; +export type SkillUpdateConfigsPayload = { updates: SkillUpdateConfigPayload[] }; +export type SkillUpdatePayload = SkillKeyPayload & { enabled?: boolean }; +export type SkillQuickAccessPayload = { workspace?: string }; +export type ClawHubInstalledSkill = { + slug: string; + version?: string; + source?: string; + baseDir?: string; +}; +export type ClawHubCapabilityResult = HostSuccess & { capability?: JsonRecord }; +export type ClawHubListResult = HostSuccess & { + results?: ClawHubInstalledSkill[]; +}; +export type ClawHubSearchPayload = { query?: string }; +export type ClawHubSearchResult = HostSuccess & { + results?: MarketplaceSkill[]; +}; +export type ClawHubInstallPayload = { slug: string; version?: string }; +export type ClawHubUninstallPayload = { slug: string }; +export type ClawHubOpenPayload = { + skillKey?: string; + slug?: string; + baseDir?: string; +}; + +export type UsageHistoryEntry = { + timestamp: string; + sessionId: string; + agentId: string; + model?: string; + provider?: string; + content?: string; + usageStatus?: 'available' | 'missing' | 'error'; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + costUsd?: number; +}; +export type UsageHistoryPayload = { limit?: number }; + +export type DeliveryChannelAccount = { + accountId: string; + name: string; + isDefault: boolean; +}; +export type DeliveryChannelGroup = { + channelType: string; + defaultAccountId: string; + accounts: DeliveryChannelAccount[]; +}; +export type DeliveryTargetsResult = HostSuccess & { targets: DeliveryChannelGroup[] }; + +export type HostApiContract = { + app: { + openClawDoctor: (payload: OpenClawDoctorPayload) => Omit; + }; + openclaw: { + status: () => OpenClawStatusResult; + getSkillsDir: () => string; + getCliCommand: () => OpenClawCliCommandResult; + }; + shell: { + openExternal: (payload: ShellOpenExternalPayload) => void; + showItemInFolder: (payload: ShellPathPayload) => void; + openPath: (payload: ShellPathPayload) => string; + }; + dialog: { + open: (payload: DialogOpenPayload) => DialogOpenResult; + message: (payload: DialogMessagePayload) => DialogMessageResult; + }; + window: { + syncTrafficLightPosition: (payload: WindowSyncTrafficLightPayload) => void; + minimize: () => void; + maximize: () => void; + close: () => void; + isMaximized: () => boolean; + }; + updates: { + status: () => UpdateStatusSnapshot; + version: () => string; + check: () => UpdateCheckResult; + download: () => HostSuccess; + install: () => HostSuccess; + setChannel: (payload: UpdateSetChannelPayload) => HostSuccess; + setAutoDownload: (payload: UpdateSetAutoDownloadPayload) => HostSuccess; + cancelAutoInstall: () => HostSuccess; + }; + uv: { + installAll: () => HostSuccess; + }; + settings: { + getAll: () => SettingsSnapshot; + get: (payload: SettingsGetPayload) => SettingsValue; + set: (payload: SettingsSetPayload) => HostSuccess; + setMany: (payload: SettingsSetManyPayload) => HostSuccess; + reset: () => SettingsResetResult; + }; + gateway: { + status: () => GatewayStatus; + start: () => HostSuccess; + stop: () => HostSuccess; + restart: () => HostSuccess; + health: (payload?: GatewayHealthPayload) => GatewayHealth; + controlUi: (payload?: GatewayControlUiPayload) => GatewayControlUiResult; + rpc: (payload: GatewayRpcPayload) => unknown; + }; + logs: { + recent: (payload?: LogRecentPayload) => LogContentResult; + memory: (payload?: LogMemoryPayload) => string[]; + dir: () => LogDirResult; + filePath: () => LogFilePathResult; + listFiles: () => LogFilesResult; + readFile: (payload: LogReadFilePayload) => LogContentResult; + }; + channels: { + configured: () => ChannelConfiguredResult; + accounts: (payload?: ChannelAccountsPayload) => ChannelAccountsResult; + targets: (payload: ChannelTargetsPayload) => ChannelTargetsResult; + setDefaultAccount: (payload: ChannelRequiredAccountPayload) => HostSuccess; + bindingSave: (payload: ChannelBindingSavePayload) => HostSuccess; + bindingDelete: (payload: ChannelBindingDeletePayload) => HostSuccess; + validateConfig: (payload: ChannelTypePayload) => HostSuccess; + validateCredentials: (payload: ChannelCredentialValidationPayload) => ChannelCredentialValidationResult; + saveConfig: (payload: ChannelSaveConfigPayload) => ChannelSaveConfigResult; + setEnabled: (payload: ChannelSetEnabledPayload) => HostSuccess; + formValues: (payload: ChannelAccountPayload) => ChannelFormValuesResult; + deleteConfig: (payload: ChannelAccountPayload) => HostSuccess; + startLogin: (payload: ChannelAccountPayload) => HostSuccess; + cancelLogin: (payload: ChannelAccountPayload) => HostSuccess; + }; + agents: { + list: () => AgentSnapshotResult; + create: (payload: AgentCreatePayload) => AgentSnapshotResult; + update: (payload: AgentUpdatePayload) => AgentSnapshotResult; + updateModel: (payload: AgentUpdateModelPayload) => AgentSnapshotResult; + delete: (payload: AgentIdPayload) => AgentSnapshotResult; + assignChannel: (payload: AgentChannelPayload) => AgentSnapshotResult; + removeChannel: (payload: AgentChannelPayload) => AgentSnapshotResult; + }; + diagnostics: { + gatewaySnapshot: () => DiagnosticsGatewaySnapshotResult; + }; + providers: { + list: () => ProviderWithKeyInfo[]; + get: (payload: ProviderIdPayload) => ProviderConfig | null; + getDefault: () => string | undefined; + hasApiKey: (payload: ProviderIdPayload) => boolean; + getApiKey: (payload: ProviderIdPayload) => string | null; + validateKey: (payload: ProviderValidationPayload) => ProviderValidationResult; + save: (payload: ProviderSavePayload) => HostSuccess; + delete: (payload: ProviderIdPayload) => HostSuccess; + setApiKey: (payload: ProviderApiKeyPayload) => HostSuccess; + updateWithKey: (payload: ProviderUpdateWithKeyPayload) => HostSuccess; + deleteApiKey: (payload: ProviderIdPayload) => HostSuccess; + setDefault: (payload: ProviderIdPayload) => HostSuccess; + accounts: () => ProviderAccount[]; + vendors: () => ProviderVendorInfo[]; + accountKeyInfo: () => ProviderAccountKeyInfo[]; + getDefaultAccount: () => ProviderDefaultAccountResult; + getAccount: (payload: ProviderAccountIdPayload) => ProviderAccount | null; + getAccountApiKey: (payload: ProviderAccountIdPayload) => string | null; + hasAccountApiKey: (payload: ProviderAccountIdPayload) => boolean; + createAccount: (payload: ProviderCreateAccountPayload) => HostSuccess; + updateAccount: (payload: ProviderUpdateAccountPayload) => HostSuccess; + deleteAccount: (payload: ProviderAccountIdPayload) => HostSuccess; + deleteAccountApiKey: (payload: ProviderAccountIdPayload) => HostSuccess; + setDefaultAccount: (payload: ProviderAccountIdPayload) => HostSuccess; + requestOAuth: (payload: ProviderOAuthRequestPayload) => HostSuccess; + cancelOAuth: () => HostSuccess; + submitOAuth: (payload: ProviderOAuthSubmitPayload) => HostSuccess; + }; + files: { + stagePaths: (payload: StagePathsPayload) => StagedFileResult[]; + stageBuffer: (payload: StageBufferPayload) => StagedFileResult; + readText: (payload: FilePathPayload) => ReadTextFileResult; + readBinary: (payload: FileReadBinaryPayload) => ReadBinaryFileResult; + writeText: (payload: FileWriteTextPayload) => WriteTextFileResult; + stat: (payload: FilePathPayload) => StatFileResult; + listDir: (payload: FilePathPayload) => FileListDirResult; + listTree: (payload: FileListTreePayload) => FileListTreeResult; + }; + media: { + thumbnails: (payload: MediaThumbnailsPayload) => MediaThumbnailResult; + saveImage: (payload: SaveImagePayload) => JsonRecord; + imageGenerationSettings: () => ImageGenerationSettingsResult; + saveImageGenerationSettings: (payload: ImageGenerationSettingsPayload) => ImageGenerationSettingsResult; + imageGenerationProviders: () => ImageGenerationProvidersResult; + testImageGeneration: (payload: ImageGenerationTestPayload) => ImageGenerationTestResult; + }; + sessions: { + delete: (payload: SessionDeletePayload) => HostSuccess; + rename: (payload: SessionRenamePayload) => HostSuccess; + summaries: (payload?: SessionSummariesPayload) => SessionSummariesResult; + history: (payload: SessionHistoryPayload) => SessionHistoryResult; + }; + chat: { + sendWithMedia: (payload: ChatSendWithMediaPayload) => ChatSendWithMediaResult; + }; + cron: { + list: () => CronJob[]; + create: (payload: CronJobCreateInput) => CronJob; + update: (payload: CronUpdatePayload) => CronJob; + delete: (payload: CronIdPayload) => HostSuccess; + toggle: (payload: CronTogglePayload) => HostSuccess; + trigger: (payload: CronIdPayload) => HostSuccess; + sessionHistory: (payload: CronSessionHistoryPayload) => CronSessionHistoryResult; + deliveryTargets: () => DeliveryTargetsResult; + }; + skills: { + local: () => LocalSkillsResult; + configs: () => SkillConfigsResult; + allConfigs: () => SkillConfigsResult; + getConfig: (payload: SkillKeyPayload) => JsonRecord | undefined; + updateConfig: (payload: SkillUpdateConfigPayload) => HostSuccess; + updateConfigs: (payload: SkillUpdateConfigsPayload) => HostSuccess; + status: () => SkillsStatusResult; + update: (payload: SkillUpdatePayload) => HostSuccess; + quickAccess: (payload: SkillQuickAccessPayload) => HostSuccess & { skills?: QuickAccessSkill[] }; + clawhubCapability: () => ClawHubCapabilityResult; + clawhubList: () => ClawHubListResult; + clawhubSearch: (payload: ClawHubSearchPayload) => ClawHubSearchResult; + clawhubInstall: (payload: ClawHubInstallPayload) => HostSuccess; + clawhubUninstall: (payload: ClawHubUninstallPayload) => HostSuccess; + clawhubOpenSkillReadme: (payload: ClawHubOpenPayload) => HostSuccess; + clawhubOpenSkillPath: (payload: ClawHubOpenPayload) => HostSuccess; + }; + usage: { + recentTokenHistory: (payload?: UsageHistoryPayload) => UsageHistoryEntry[]; + }; +}; + +export type HostApiModule = keyof HostApiContract & string; +export type HostApiAction = keyof HostApiContract[M] & string; +export type HostApiFunction< + M extends HostApiModule, + A extends HostApiAction, +> = HostApiContract[M][A] extends (...args: infer Args) => infer Result + ? (...args: Args) => Result + : never; +export type HostApiPayload< + M extends HostApiModule, + A extends HostApiAction, +> = Parameters> extends [] + ? undefined + : Parameters>[0]; +export type HostApiResult< + M extends HostApiModule, + A extends HostApiAction, +> = Awaited>>; +export type HostApiPayloadArgs< + M extends HostApiModule, + A extends HostApiAction, +> = Parameters> extends [] + ? [] + : undefined extends HostApiPayload + ? [payload?: HostApiPayload] + : [payload: HostApiPayload]; diff --git a/shared/host-api/types.ts b/shared/host-api/types.ts new file mode 100644 index 00000000..bf8759a4 --- /dev/null +++ b/shared/host-api/types.ts @@ -0,0 +1,26 @@ +import type { + HostApiAction, + HostApiModule, + HostApiPayload, +} from './contract'; + +export type HostRequest = { + id: string; + module: string; + action: string; + payload?: unknown; +}; + +export type TypedHostRequest< + M extends HostApiModule, + A extends HostApiAction, +> = { + id: string; + module: M; + action: A; + payload?: HostApiPayload; +}; + +export type HostResponse = + | { id?: string; ok: true; data: T } + | { id?: string; ok: false; error?: { code?: string; message?: string; details?: unknown } }; diff --git a/shared/host-events/contract.ts b/shared/host-events/contract.ts new file mode 100644 index 00000000..cade479e --- /dev/null +++ b/shared/host-events/contract.ts @@ -0,0 +1,151 @@ +import type { UpdateStatusSnapshot } from '../host-api/contract'; +import type { ChatRuntimeEvent } from '../chat-runtime-events'; +import type { + GatewayNotification, + GatewayRuntimePayload, + GatewayRuntimeRecord, + GatewayStatus, +} from '../types/gateway'; +export type { GatewayRuntimePayload } from '../types/gateway'; + +export type JsonRecord = Record; + +export type GatewayErrorEvent = string | { message?: string }; +export type GatewayChatMessageEvent = GatewayRuntimeRecord & { + message?: GatewayRuntimePayload; + runId?: GatewayRuntimePayload; +}; +export type GatewayChannelStatusEvent = { + channelId: string; + status: string; +}; +export type GatewayExitEvent = number | null | { code: number | null }; + +export type OAuthCodeEvent = + | { + provider: string; + mode: 'manual'; + authorizationUrl: string; + message?: string; + } + | { + provider: string; + mode?: 'device'; + verificationUri: string; + userCode: string; + expiresIn: number; + }; +export type OAuthSuccessEvent = { + provider: string; + accountId: string; + success?: boolean; +}; +export type OAuthErrorEvent = { + message: string; +}; + +export type ChannelQrEvent = { + qr?: string; + raw?: string; + sessionKey?: string; +}; +export type ChannelSuccessEvent = { + accountId?: string; + rawAccountId?: string; + message?: string; +}; +export type ChannelErrorEvent = string | { message?: string }; + +export type UpdateAutoInstallCountdownEvent = { + seconds: number; + cancelled?: boolean; +}; + +export type HostEventContract = { + gateway: { + statusChanged: (payload: GatewayStatus) => void; + message: (payload: unknown) => void; + notification: (payload: GatewayNotification) => void; + healthChanged: (payload: GatewayRuntimePayload) => void; + presenceChanged: (payload: GatewayRuntimePayload) => void; + chatMessage: (payload: GatewayChatMessageEvent) => void; + channelStatus: (payload: GatewayChannelStatusEvent) => void; + exit: (payload: GatewayExitEvent) => void; + error: (payload: GatewayErrorEvent) => void; + }; + chat: { + runtimeEvent: (payload: ChatRuntimeEvent) => void; + }; + oauth: { + code: (payload: OAuthCodeEvent) => void; + success: (payload: OAuthSuccessEvent) => void; + error: (payload: OAuthErrorEvent) => void; + }; + channel: { + qr: (payload: ChannelQrEvent) => void; + success: (payload: ChannelSuccessEvent) => void; + error: (payload: ChannelErrorEvent) => void; + }; + updates: { + statusChanged: (payload: UpdateStatusSnapshot) => void; + autoInstallCountdown: (payload: UpdateAutoInstallCountdownEvent) => void; + }; + app: { + navigate: (path: string) => void; + newChat: () => void; + openClawCliInstalled: (installedPath: string) => void; + }; +}; + +export type HostEventModule = keyof HostEventContract; +export type HostEventName = keyof HostEventContract[M] & string; +export type HostEventHandler< + M extends HostEventModule, + E extends HostEventName, +> = HostEventContract[M][E]; +export type HostEventArgs< + M extends HostEventModule, + E extends HostEventName, +> = HostEventHandler extends (...args: infer Args) => void ? Args : never; + +export const HOST_EVENT_CHANNELS = { + gateway: { + statusChanged: 'gateway:status-changed', + message: 'gateway:message', + notification: 'gateway:notification', + healthChanged: 'gateway:health-changed', + presenceChanged: 'gateway:presence-changed', + chatMessage: 'gateway:chat-message', + channelStatus: 'gateway:channel-status', + exit: 'gateway:exit', + error: 'gateway:error', + }, + chat: { + runtimeEvent: 'chat:runtime-event', + }, + oauth: { + code: 'oauth:code', + success: 'oauth:success', + error: 'oauth:error', + }, + updates: { + statusChanged: 'update:status-changed', + autoInstallCountdown: 'update:auto-install-countdown', + }, + app: { + navigate: 'navigate', + newChat: 'new-chat', + openClawCliInstalled: 'openclaw:cli-installed', + }, +} as const satisfies { + [M in Exclude]: { + [E in HostEventName]: string; + }; +}; + +export function buildHostChannelEventName( + channel: string, + event: HostEventName<'channel'>, +): string { + return `channel:${channel}-${event}`; +} diff --git a/src/i18n/locales/en/agents.json b/shared/i18n/locales/en/agents.json similarity index 100% rename from src/i18n/locales/en/agents.json rename to shared/i18n/locales/en/agents.json diff --git a/src/i18n/locales/en/channels.json b/shared/i18n/locales/en/channels.json similarity index 100% rename from src/i18n/locales/en/channels.json rename to shared/i18n/locales/en/channels.json diff --git a/src/i18n/locales/en/chat.json b/shared/i18n/locales/en/chat.json similarity index 100% rename from src/i18n/locales/en/chat.json rename to shared/i18n/locales/en/chat.json diff --git a/src/i18n/locales/en/common.json b/shared/i18n/locales/en/common.json similarity index 97% rename from src/i18n/locales/en/common.json rename to shared/i18n/locales/en/common.json index 03734e00..6ffaf583 100644 --- a/src/i18n/locales/en/common.json +++ b/shared/i18n/locales/en/common.json @@ -61,6 +61,7 @@ "gateway": { "notRunning": "Gateway Not Running", "notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.", + "restarting": "Gateway restarting", "warning": "Gateway is not running." } } diff --git a/src/i18n/locales/en/cron.json b/shared/i18n/locales/en/cron.json similarity index 100% rename from src/i18n/locales/en/cron.json rename to shared/i18n/locales/en/cron.json diff --git a/src/i18n/locales/en/dashboard.json b/shared/i18n/locales/en/dashboard.json similarity index 100% rename from src/i18n/locales/en/dashboard.json rename to shared/i18n/locales/en/dashboard.json diff --git a/src/i18n/locales/en/dreams.json b/shared/i18n/locales/en/dreams.json similarity index 100% rename from src/i18n/locales/en/dreams.json rename to shared/i18n/locales/en/dreams.json diff --git a/shared/i18n/locales/en/menu.json b/shared/i18n/locales/en/menu.json new file mode 100644 index 00000000..fc326efa --- /dev/null +++ b/shared/i18n/locales/en/menu.json @@ -0,0 +1,59 @@ +{ + "app": { + "about": "About {{appName}}", + "preferences": "Preferences...", + "services": "Services", + "hide": "Hide {{appName}}", + "hideOthers": "Hide Others", + "unhide": "Show All", + "quit": "Quit {{appName}}" + }, + "file": { + "label": "File", + "newChat": "New Chat", + "close": "Close" + }, + "edit": { + "label": "Edit", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "pasteAndMatchStyle": "Paste and Match Style", + "delete": "Delete", + "selectAll": "Select All" + }, + "view": { + "label": "View", + "reload": "Reload", + "forceReload": "Force Reload", + "toggleDevTools": "Toggle Developer Tools", + "resetZoom": "Actual Size", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "toggleFullscreen": "Toggle Full Screen" + }, + "navigate": { + "label": "Navigate", + "dashboard": "Dashboard", + "chat": "Chat", + "channels": "Channels", + "skills": "Skills", + "cronTasks": "Cron Tasks", + "settings": "Settings" + }, + "window": { + "label": "Window", + "minimize": "Minimize", + "zoom": "Zoom", + "front": "Bring All to Front", + "close": "Close" + }, + "help": { + "label": "Help", + "documentation": "Documentation", + "reportIssue": "Report Issue", + "openClawDocumentation": "OpenClaw Documentation" + } +} diff --git a/src/i18n/locales/en/settings.json b/shared/i18n/locales/en/settings.json similarity index 92% rename from src/i18n/locales/en/settings.json rename to shared/i18n/locales/en/settings.json index 670534fb..6359c6cf 100644 --- a/src/i18n/locales/en/settings.json +++ b/shared/i18n/locales/en/settings.json @@ -9,6 +9,7 @@ "dark": "Dark", "system": "System", "language": "Language", + "menuLanguageUpdated": "Menu language updated", "launchAtStartup": "Launch at system startup", "launchAtStartupDesc": "Automatically launch ClawX when you log in" }, @@ -196,25 +197,6 @@ "advanced": { "title": "Advanced", "description": "Power-user options", - "transport": { - "label": "Gateway Transport Preference", - "desc": "Choose how renderer requests gateway RPC: WebSocket, HTTP proxy, or IPC fallback.", - "saved": "Gateway transport preference saved", - "options": { - "wsFirst": "WS First", - "httpFirst": "HTTP First", - "wsOnly": "WS Only", - "httpOnly": "HTTP Only", - "ipcOnly": "IPC Only" - }, - "descriptions": { - "wsFirst": "WS -> HTTP -> IPC", - "httpFirst": "HTTP -> WS -> IPC", - "wsOnly": "WS -> IPC", - "httpOnly": "HTTP -> IPC", - "ipcOnly": "IPC only" - } - }, "devMode": "Developer Mode", "devModeDesc": "Show developer tools and shortcuts", "telemetry": "Anonymous Usage Data", @@ -259,10 +241,6 @@ "doctorStdout": "Stdout", "doctorStderr": "Stderr", "doctorOutputEmpty": "(empty)", - "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", diff --git a/src/i18n/locales/en/setup.json b/shared/i18n/locales/en/setup.json similarity index 100% rename from src/i18n/locales/en/setup.json rename to shared/i18n/locales/en/setup.json diff --git a/src/i18n/locales/en/skills.json b/shared/i18n/locales/en/skills.json similarity index 100% rename from src/i18n/locales/en/skills.json rename to shared/i18n/locales/en/skills.json diff --git a/src/i18n/locales/ja/agents.json b/shared/i18n/locales/ja/agents.json similarity index 100% rename from src/i18n/locales/ja/agents.json rename to shared/i18n/locales/ja/agents.json diff --git a/src/i18n/locales/ja/channels.json b/shared/i18n/locales/ja/channels.json similarity index 100% rename from src/i18n/locales/ja/channels.json rename to shared/i18n/locales/ja/channels.json diff --git a/src/i18n/locales/ja/chat.json b/shared/i18n/locales/ja/chat.json similarity index 100% rename from src/i18n/locales/ja/chat.json rename to shared/i18n/locales/ja/chat.json diff --git a/src/i18n/locales/ja/common.json b/shared/i18n/locales/ja/common.json similarity index 97% rename from src/i18n/locales/ja/common.json rename to shared/i18n/locales/ja/common.json index be5ccb14..f9202549 100644 --- a/src/i18n/locales/ja/common.json +++ b/shared/i18n/locales/ja/common.json @@ -61,6 +61,7 @@ "gateway": { "notRunning": "ゲートウェイが停止中", "notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。", + "restarting": "ゲートウェイを再起動中", "warning": "ゲートウェイが停止中です。" } } diff --git a/src/i18n/locales/ja/cron.json b/shared/i18n/locales/ja/cron.json similarity index 100% rename from src/i18n/locales/ja/cron.json rename to shared/i18n/locales/ja/cron.json diff --git a/src/i18n/locales/ja/dashboard.json b/shared/i18n/locales/ja/dashboard.json similarity index 100% rename from src/i18n/locales/ja/dashboard.json rename to shared/i18n/locales/ja/dashboard.json diff --git a/src/i18n/locales/ja/dreams.json b/shared/i18n/locales/ja/dreams.json similarity index 100% rename from src/i18n/locales/ja/dreams.json rename to shared/i18n/locales/ja/dreams.json diff --git a/shared/i18n/locales/ja/menu.json b/shared/i18n/locales/ja/menu.json new file mode 100644 index 00000000..93b7c234 --- /dev/null +++ b/shared/i18n/locales/ja/menu.json @@ -0,0 +1,59 @@ +{ + "app": { + "about": "{{appName}} について", + "preferences": "環境設定...", + "services": "サービス", + "hide": "{{appName}} を隠す", + "hideOthers": "ほかを隠す", + "unhide": "すべてを表示", + "quit": "{{appName}} を終了" + }, + "file": { + "label": "ファイル", + "newChat": "新しいチャット", + "close": "閉じる" + }, + "edit": { + "label": "編集", + "undo": "取り消す", + "redo": "やり直す", + "cut": "カット", + "copy": "コピー", + "paste": "ペースト", + "pasteAndMatchStyle": "ペーストしてスタイルを合わせる", + "delete": "削除", + "selectAll": "すべてを選択" + }, + "view": { + "label": "表示", + "reload": "再読み込み", + "forceReload": "強制再読み込み", + "toggleDevTools": "開発者ツールを切り替え", + "resetZoom": "実際のサイズ", + "zoomIn": "拡大", + "zoomOut": "縮小", + "toggleFullscreen": "フルスクリーンを切り替え" + }, + "navigate": { + "label": "移動", + "dashboard": "ダッシュボード", + "chat": "チャット", + "channels": "チャンネル", + "skills": "スキル", + "cronTasks": "定期タスク", + "settings": "設定" + }, + "window": { + "label": "ウィンドウ", + "minimize": "最小化", + "zoom": "ズーム", + "front": "すべてを手前に移動", + "close": "閉じる" + }, + "help": { + "label": "ヘルプ", + "documentation": "ドキュメント", + "reportIssue": "問題を報告", + "openClawDocumentation": "OpenClaw ドキュメント" + } +} diff --git a/src/i18n/locales/ja/settings.json b/shared/i18n/locales/ja/settings.json similarity index 93% rename from src/i18n/locales/ja/settings.json rename to shared/i18n/locales/ja/settings.json index 89393373..6fb8e90d 100644 --- a/src/i18n/locales/ja/settings.json +++ b/shared/i18n/locales/ja/settings.json @@ -9,6 +9,7 @@ "dark": "ダーク", "system": "システム", "language": "言語", + "menuLanguageUpdated": "メニューの言語を更新しました", "launchAtStartup": "システム起動時に自動起動", "launchAtStartupDesc": "ログイン時に ClawX を自動的に起動します" }, @@ -196,25 +197,6 @@ "advanced": { "title": "詳細設定", "description": "上級ユーザー向けオプション", - "transport": { - "label": "Gateway 転送優先度", - "desc": "レンダラープロセスから Gateway RPC を呼ぶ際の優先プロトコルを選択します。", - "saved": "Gateway 転送優先度を保存しました", - "options": { - "wsFirst": "WS 優先", - "httpFirst": "HTTP 優先", - "wsOnly": "WS のみ", - "httpOnly": "HTTP のみ", - "ipcOnly": "IPC のみ" - }, - "descriptions": { - "wsFirst": "WS -> HTTP -> IPC", - "httpFirst": "HTTP -> WS -> IPC", - "wsOnly": "WS -> IPC", - "httpOnly": "HTTP -> IPC", - "ipcOnly": "IPC のみ" - } - }, "devMode": "開発者モード", "devModeDesc": "開発者ツールとショートカットを表示", "telemetry": "匿名利用データ", @@ -259,10 +241,6 @@ "doctorStdout": "標準出力", "doctorStderr": "標準エラー", "doctorOutputEmpty": "(空)", - "wsDiagnostic": "WS 診断モード", - "wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。", - "wsDiagnosticEnabled": "WS 診断モードを有効化しました", - "wsDiagnosticDisabled": "WS 診断モードを無効化しました", "telemetryViewer": "テレメトリビューア", "telemetryViewerDesc": "ローカル専用の UX/性能テレメトリ(最新 200 件)。", "telemetryAggregated": "イベント集計", diff --git a/src/i18n/locales/ja/setup.json b/shared/i18n/locales/ja/setup.json similarity index 100% rename from src/i18n/locales/ja/setup.json rename to shared/i18n/locales/ja/setup.json diff --git a/src/i18n/locales/ja/skills.json b/shared/i18n/locales/ja/skills.json similarity index 100% rename from src/i18n/locales/ja/skills.json rename to shared/i18n/locales/ja/skills.json diff --git a/src/i18n/locales/ru/agents.json b/shared/i18n/locales/ru/agents.json similarity index 100% rename from src/i18n/locales/ru/agents.json rename to shared/i18n/locales/ru/agents.json diff --git a/src/i18n/locales/ru/channels.json b/shared/i18n/locales/ru/channels.json similarity index 100% rename from src/i18n/locales/ru/channels.json rename to shared/i18n/locales/ru/channels.json diff --git a/src/i18n/locales/ru/chat.json b/shared/i18n/locales/ru/chat.json similarity index 100% rename from src/i18n/locales/ru/chat.json rename to shared/i18n/locales/ru/chat.json diff --git a/src/i18n/locales/ru/common.json b/shared/i18n/locales/ru/common.json similarity index 97% rename from src/i18n/locales/ru/common.json rename to shared/i18n/locales/ru/common.json index 2a1c1b53..ed112846 100644 --- a/src/i18n/locales/ru/common.json +++ b/shared/i18n/locales/ru/common.json @@ -61,6 +61,7 @@ "gateway": { "notRunning": "Шлюз не запущен", "notRunningDesc": "Для использования этой функции требуется запущенный шлюз OpenClaw. Он запустится автоматически, или вы можете запустить его в Настройках.", + "restarting": "Шлюз перезапускается", "warning": "Шлюз не запущен." } } diff --git a/src/i18n/locales/ru/cron.json b/shared/i18n/locales/ru/cron.json similarity index 100% rename from src/i18n/locales/ru/cron.json rename to shared/i18n/locales/ru/cron.json diff --git a/src/i18n/locales/ru/dashboard.json b/shared/i18n/locales/ru/dashboard.json similarity index 100% rename from src/i18n/locales/ru/dashboard.json rename to shared/i18n/locales/ru/dashboard.json diff --git a/src/i18n/locales/ru/dreams.json b/shared/i18n/locales/ru/dreams.json similarity index 100% rename from src/i18n/locales/ru/dreams.json rename to shared/i18n/locales/ru/dreams.json diff --git a/shared/i18n/locales/ru/menu.json b/shared/i18n/locales/ru/menu.json new file mode 100644 index 00000000..677383f0 --- /dev/null +++ b/shared/i18n/locales/ru/menu.json @@ -0,0 +1,59 @@ +{ + "app": { + "about": "О {{appName}}", + "preferences": "Настройки...", + "services": "Службы", + "hide": "Скрыть {{appName}}", + "hideOthers": "Скрыть остальные", + "unhide": "Показать все", + "quit": "Выйти из {{appName}}" + }, + "file": { + "label": "Файл", + "newChat": "Новый чат", + "close": "Закрыть" + }, + "edit": { + "label": "Правка", + "undo": "Отменить", + "redo": "Повторить", + "cut": "Вырезать", + "copy": "Копировать", + "paste": "Вставить", + "pasteAndMatchStyle": "Вставить с сохранением стиля", + "delete": "Удалить", + "selectAll": "Выбрать все" + }, + "view": { + "label": "Вид", + "reload": "Перезагрузить", + "forceReload": "Принудительно перезагрузить", + "toggleDevTools": "Переключить инструменты разработчика", + "resetZoom": "Фактический размер", + "zoomIn": "Увеличить", + "zoomOut": "Уменьшить", + "toggleFullscreen": "Переключить полноэкранный режим" + }, + "navigate": { + "label": "Навигация", + "dashboard": "Панель", + "chat": "Чат", + "channels": "Каналы", + "skills": "Навыки", + "cronTasks": "Задачи Cron", + "settings": "Настройки" + }, + "window": { + "label": "Окно", + "minimize": "Свернуть", + "zoom": "Масштаб", + "front": "На передний план", + "close": "Закрыть" + }, + "help": { + "label": "Справка", + "documentation": "Документация", + "reportIssue": "Сообщить о проблеме", + "openClawDocumentation": "Документация OpenClaw" + } +} diff --git a/src/i18n/locales/ru/settings.json b/shared/i18n/locales/ru/settings.json similarity index 93% rename from src/i18n/locales/ru/settings.json rename to shared/i18n/locales/ru/settings.json index 1519e327..ea8e93be 100644 --- a/src/i18n/locales/ru/settings.json +++ b/shared/i18n/locales/ru/settings.json @@ -9,6 +9,7 @@ "dark": "Тёмная", "system": "Системная", "language": "Язык", + "menuLanguageUpdated": "Язык меню обновлён", "launchAtStartup": "Запуск при старте системы", "launchAtStartupDesc": "Автоматически запускать ClawX при входе в систему" }, @@ -196,25 +197,6 @@ "advanced": { "title": "Дополнительные", "description": "Опции для продвинутых пользователей", - "transport": { - "label": "Предпочтение транспорта шлюза", - "desc": "Выберите, как рендерер запрашивает RPC шлюза: WebSocket, HTTP-прокси или IPC-резерв.", - "saved": "Предпочтение транспорта шлюза сохранено", - "options": { - "wsFirst": "WS сначала", - "httpFirst": "HTTP сначала", - "wsOnly": "Только WS", - "httpOnly": "Только HTTP", - "ipcOnly": "Только IPC" - }, - "descriptions": { - "wsFirst": "WS -> HTTP -> IPC", - "httpFirst": "HTTP -> WS -> IPC", - "wsOnly": "WS -> IPC", - "httpOnly": "HTTP -> IPC", - "ipcOnly": "Только IPC" - } - }, "devMode": "Режим разработчика", "devModeDesc": "Показывать инструменты и ярлыки разработчика", "telemetry": "Анонимные данные об использовании", @@ -259,10 +241,6 @@ "doctorStdout": "Стандартный вывод", "doctorStderr": "Стандартная ошибка", "doctorOutputEmpty": "(пусто)", - "wsDiagnostic": "Диагностический режим WS", - "wsDiagnosticDesc": "Временно включить цепочку WS/HTTP-резервов для отладки RPC шлюза.", - "wsDiagnosticEnabled": "Диагностический режим WS включён", - "wsDiagnosticDisabled": "Диагностический режим WS отключён", "telemetryViewer": "Просмотр телеметрии", "telemetryViewerDesc": "Локальная телеметрия UX/производительности, последние 200 записей.", "telemetryAggregated": "Топ событий", diff --git a/src/i18n/locales/ru/setup.json b/shared/i18n/locales/ru/setup.json similarity index 100% rename from src/i18n/locales/ru/setup.json rename to shared/i18n/locales/ru/setup.json diff --git a/src/i18n/locales/ru/skills.json b/shared/i18n/locales/ru/skills.json similarity index 100% rename from src/i18n/locales/ru/skills.json rename to shared/i18n/locales/ru/skills.json diff --git a/src/i18n/locales/zh/agents.json b/shared/i18n/locales/zh/agents.json similarity index 100% rename from src/i18n/locales/zh/agents.json rename to shared/i18n/locales/zh/agents.json diff --git a/src/i18n/locales/zh/channels.json b/shared/i18n/locales/zh/channels.json similarity index 100% rename from src/i18n/locales/zh/channels.json rename to shared/i18n/locales/zh/channels.json diff --git a/src/i18n/locales/zh/chat.json b/shared/i18n/locales/zh/chat.json similarity index 100% rename from src/i18n/locales/zh/chat.json rename to shared/i18n/locales/zh/chat.json diff --git a/src/i18n/locales/zh/common.json b/shared/i18n/locales/zh/common.json similarity index 97% rename from src/i18n/locales/zh/common.json rename to shared/i18n/locales/zh/common.json index 7ad3807f..c55e5ab8 100644 --- a/src/i18n/locales/zh/common.json +++ b/shared/i18n/locales/zh/common.json @@ -61,6 +61,7 @@ "gateway": { "notRunning": "网关未运行", "notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。", + "restarting": "Gateway 重启中", "warning": "网关未运行。" } } diff --git a/src/i18n/locales/zh/cron.json b/shared/i18n/locales/zh/cron.json similarity index 100% rename from src/i18n/locales/zh/cron.json rename to shared/i18n/locales/zh/cron.json diff --git a/src/i18n/locales/zh/dashboard.json b/shared/i18n/locales/zh/dashboard.json similarity index 100% rename from src/i18n/locales/zh/dashboard.json rename to shared/i18n/locales/zh/dashboard.json diff --git a/src/i18n/locales/zh/dreams.json b/shared/i18n/locales/zh/dreams.json similarity index 100% rename from src/i18n/locales/zh/dreams.json rename to shared/i18n/locales/zh/dreams.json diff --git a/shared/i18n/locales/zh/menu.json b/shared/i18n/locales/zh/menu.json new file mode 100644 index 00000000..f5eb5fb0 --- /dev/null +++ b/shared/i18n/locales/zh/menu.json @@ -0,0 +1,59 @@ +{ + "app": { + "about": "关于 {{appName}}", + "preferences": "偏好设置...", + "services": "服务", + "hide": "隐藏 {{appName}}", + "hideOthers": "隐藏其他", + "unhide": "全部显示", + "quit": "退出 {{appName}}" + }, + "file": { + "label": "文件", + "newChat": "新对话", + "close": "关闭" + }, + "edit": { + "label": "编辑", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "pasteAndMatchStyle": "粘贴并匹配样式", + "delete": "删除", + "selectAll": "全选" + }, + "view": { + "label": "显示", + "reload": "重新加载", + "forceReload": "强制重新加载", + "toggleDevTools": "切换开发者工具", + "resetZoom": "实际大小", + "zoomIn": "放大", + "zoomOut": "缩小", + "toggleFullscreen": "切换全屏" + }, + "navigate": { + "label": "导航", + "dashboard": "仪表盘", + "chat": "聊天", + "channels": "频道", + "skills": "技能", + "cronTasks": "定时任务", + "settings": "设置" + }, + "window": { + "label": "窗口", + "minimize": "最小化", + "zoom": "缩放", + "front": "全部置于前台", + "close": "关闭" + }, + "help": { + "label": "帮助", + "documentation": "文档", + "reportIssue": "报告问题", + "openClawDocumentation": "OpenClaw 文档" + } +} diff --git a/src/i18n/locales/zh/settings.json b/shared/i18n/locales/zh/settings.json similarity index 92% rename from src/i18n/locales/zh/settings.json rename to shared/i18n/locales/zh/settings.json index 1ffc84b1..fbcb1064 100644 --- a/src/i18n/locales/zh/settings.json +++ b/shared/i18n/locales/zh/settings.json @@ -9,6 +9,7 @@ "dark": "深色", "system": "跟随系统", "language": "语言", + "menuLanguageUpdated": "菜单语言已更新", "launchAtStartup": "开机自动启动", "launchAtStartupDesc": "登录系统后自动启动 ClawX" }, @@ -196,25 +197,6 @@ "advanced": { "title": "高级", "description": "高级选项", - "transport": { - "label": "网关传输策略", - "desc": "选择渲染进程访问网关 RPC 的优先协议:WebSocket、HTTP 代理或 IPC 回退。", - "saved": "网关传输策略已保存", - "options": { - "wsFirst": "WS 优先", - "httpFirst": "HTTP 优先", - "wsOnly": "仅 WS", - "httpOnly": "仅 HTTP", - "ipcOnly": "仅 IPC" - }, - "descriptions": { - "wsFirst": "WS -> HTTP -> IPC", - "httpFirst": "HTTP -> WS -> IPC", - "wsOnly": "WS -> IPC", - "httpOnly": "HTTP -> IPC", - "ipcOnly": "仅 IPC" - } - }, "devMode": "开发者模式", "devModeDesc": "显示开发者工具和快捷方式", "telemetry": "匿名使用数据", @@ -259,10 +241,6 @@ "doctorStdout": "标准输出", "doctorStderr": "标准错误", "doctorOutputEmpty": "(空)", - "wsDiagnostic": "WS 诊断模式", - "wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。", - "wsDiagnosticEnabled": "已启用 WS 诊断模式", - "wsDiagnosticDisabled": "已关闭 WS 诊断模式", "telemetryViewer": "埋点查看器", "telemetryViewerDesc": "仅本地 UX/性能埋点,显示最近 200 条。", "telemetryAggregated": "事件聚合", diff --git a/src/i18n/locales/zh/setup.json b/shared/i18n/locales/zh/setup.json similarity index 100% rename from src/i18n/locales/zh/setup.json rename to shared/i18n/locales/zh/setup.json diff --git a/src/i18n/locales/zh/skills.json b/shared/i18n/locales/zh/skills.json similarity index 100% rename from src/i18n/locales/zh/skills.json rename to shared/i18n/locales/zh/skills.json diff --git a/shared/i18n/resources.ts b/shared/i18n/resources.ts new file mode 100644 index 00000000..187e0f8d --- /dev/null +++ b/shared/i18n/resources.ts @@ -0,0 +1,131 @@ +import type { LanguageCode } from '../language'; + +// EN +import enCommon from './locales/en/common.json'; +import enSettings from './locales/en/settings.json'; +import enDashboard from './locales/en/dashboard.json'; +import enChat from './locales/en/chat.json'; +import enChannels from './locales/en/channels.json'; +import enAgents from './locales/en/agents.json'; +import enSkills from './locales/en/skills.json'; +import enCron from './locales/en/cron.json'; +import enDreams from './locales/en/dreams.json'; +import enSetup from './locales/en/setup.json'; +import enMenu from './locales/en/menu.json'; + +// ZH +import zhCommon from './locales/zh/common.json'; +import zhSettings from './locales/zh/settings.json'; +import zhDashboard from './locales/zh/dashboard.json'; +import zhChat from './locales/zh/chat.json'; +import zhChannels from './locales/zh/channels.json'; +import zhAgents from './locales/zh/agents.json'; +import zhSkills from './locales/zh/skills.json'; +import zhCron from './locales/zh/cron.json'; +import zhDreams from './locales/zh/dreams.json'; +import zhSetup from './locales/zh/setup.json'; +import zhMenu from './locales/zh/menu.json'; + +// JA +import jaCommon from './locales/ja/common.json'; +import jaSettings from './locales/ja/settings.json'; +import jaDashboard from './locales/ja/dashboard.json'; +import jaChat from './locales/ja/chat.json'; +import jaChannels from './locales/ja/channels.json'; +import jaAgents from './locales/ja/agents.json'; +import jaSkills from './locales/ja/skills.json'; +import jaCron from './locales/ja/cron.json'; +import jaDreams from './locales/ja/dreams.json'; +import jaSetup from './locales/ja/setup.json'; +import jaMenu from './locales/ja/menu.json'; + +// RU +import ruCommon from './locales/ru/common.json'; +import ruSettings from './locales/ru/settings.json'; +import ruDashboard from './locales/ru/dashboard.json'; +import ruChat from './locales/ru/chat.json'; +import ruChannels from './locales/ru/channels.json'; +import ruAgents from './locales/ru/agents.json'; +import ruSkills from './locales/ru/skills.json'; +import ruCron from './locales/ru/cron.json'; +import ruDreams from './locales/ru/dreams.json'; +import ruSetup from './locales/ru/setup.json'; +import ruMenu from './locales/ru/menu.json'; + +export const I18N_NAMESPACES = [ + 'common', + 'settings', + 'dashboard', + 'chat', + 'channels', + 'agents', + 'skills', + 'cron', + 'dreams', + 'setup', + 'menu', +] as const; + +export const I18N_RESOURCES = { + en: { + common: enCommon, + settings: enSettings, + dashboard: enDashboard, + chat: enChat, + channels: enChannels, + agents: enAgents, + skills: enSkills, + cron: enCron, + dreams: enDreams, + setup: enSetup, + menu: enMenu, + }, + zh: { + common: zhCommon, + settings: zhSettings, + dashboard: zhDashboard, + chat: zhChat, + channels: zhChannels, + agents: zhAgents, + skills: zhSkills, + cron: zhCron, + dreams: zhDreams, + setup: zhSetup, + menu: zhMenu, + }, + ja: { + common: jaCommon, + settings: jaSettings, + dashboard: jaDashboard, + chat: jaChat, + channels: jaChannels, + agents: jaAgents, + skills: jaSkills, + cron: jaCron, + dreams: jaDreams, + setup: jaSetup, + menu: jaMenu, + }, + ru: { + common: ruCommon, + settings: ruSettings, + dashboard: ruDashboard, + chat: ruChat, + channels: ruChannels, + agents: ruAgents, + skills: ruSkills, + cron: ruCron, + dreams: ruDreams, + setup: ruSetup, + menu: ruMenu, + }, +} as const; + +export type MenuLabels = typeof enMenu; + +export const MENU_LABELS: Record = { + en: enMenu, + zh: zhMenu, + ja: jaMenu, + ru: ruMenu, +}; diff --git a/shared/types/agent.ts b/shared/types/agent.ts new file mode 100644 index 00000000..b286c799 --- /dev/null +++ b/shared/types/agent.ts @@ -0,0 +1,22 @@ +export interface AgentSummary { + id: string; + name: string; + isDefault: boolean; + modelDisplay: string; + modelRef?: string | null; + overrideModelRef?: string | null; + inheritedModel: boolean; + workspace: string; + agentDir: string; + mainSessionKey: string; + channelTypes: string[]; +} + +export interface AgentsSnapshot { + agents: AgentSummary[]; + defaultAgentId: string; + defaultModelRef?: string | null; + configuredChannelTypes: string[]; + channelOwners: Record; + channelAccountOwners: Record; +} diff --git a/shared/types/channel.ts b/shared/types/channel.ts new file mode 100644 index 00000000..aff73225 --- /dev/null +++ b/shared/types/channel.ts @@ -0,0 +1,568 @@ +/** + * Channel Type Definitions + * Types for messaging channels (WhatsApp, Telegram, etc.) + */ + +/** + * Supported channel types + */ +export type ChannelType = + | 'whatsapp' + | 'wechat' + | 'dingtalk' + | 'telegram' + | 'discord' + | 'signal' + | 'feishu' + | 'wecom' + | 'imessage' + | 'matrix' + | 'line' + | 'msteams' + | 'googlechat' + | 'mattermost' + | 'qqbot'; + +/** + * Channel connection status + */ +export type ChannelStatus = 'connected' | 'disconnected' | 'connecting' | 'degraded' | 'error'; + +/** + * Channel connection type + */ +export type ChannelConnectionType = 'token' | 'qr' | 'oauth' | 'webhook'; + +/** + * Channel data structure + */ +export interface Channel { + id: string; + type: ChannelType; + name: string; + status: ChannelStatus; + accountId?: string; + lastActivity?: string; + error?: string; + avatar?: string; + metadata?: Record; +} + +/** + * Channel configuration field definition + */ +export interface ChannelConfigField { + key: string; + label: string; + type: 'text' | 'password' | 'select'; + placeholder?: string; + required?: boolean; + envVar?: string; + description?: string; + options?: { value: string; label: string }[]; +} + +/** + * Channel metadata with configuration info + */ +export interface ChannelMeta { + id: ChannelType; + name: string; + icon: string; + description: string; + connectionType: ChannelConnectionType; + docsUrl: string; + configFields: ChannelConfigField[]; + instructions: string[]; + isPlugin?: boolean; +} + +/** + * Channel icons mapping + */ +export const CHANNEL_ICONS: Record = { + whatsapp: '📱', + wechat: '💬', + dingtalk: '💬', + telegram: '✈️', + discord: '🎮', + signal: '🔒', + feishu: '🐦', + wecom: '💼', + imessage: '💬', + matrix: '🔗', + line: '🟢', + msteams: '👔', + googlechat: '💭', + mattermost: '💠', + qqbot: '🐧', +}; + +/** + * Channel display names + */ +export const CHANNEL_NAMES: Record = { + whatsapp: 'WhatsApp', + wechat: 'WeChat', + dingtalk: 'DingTalk', + telegram: 'Telegram', + discord: 'Discord', + signal: 'Signal', + feishu: 'Feishu / Lark', + wecom: 'WeCom', + imessage: 'iMessage', + matrix: 'Matrix', + line: 'LINE', + msteams: 'Microsoft Teams', + googlechat: 'Google Chat', + mattermost: 'Mattermost', + qqbot: 'QQ Bot', +}; + +/** + * Channel metadata with configuration information + */ +export const CHANNEL_META: Record = { + qqbot: { + id: 'qqbot', + name: 'QQ Bot', + icon: '🐧', + description: 'channels:meta.qqbot.description', + connectionType: 'token', + docsUrl: 'channels:meta.qqbot.docsUrl', + configFields: [ + { + key: 'appId', + label: 'channels:meta.qqbot.fields.appId.label', + type: 'text', + placeholder: 'channels:meta.qqbot.fields.appId.placeholder', + required: true, + }, + { + key: 'clientSecret', + label: 'channels:meta.qqbot.fields.clientSecret.label', + type: 'password', + placeholder: 'channels:meta.qqbot.fields.clientSecret.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.qqbot.instructions.0', + 'channels:meta.qqbot.instructions.1', + 'channels:meta.qqbot.instructions.2', + ], + }, + dingtalk: { + id: 'dingtalk', + name: 'DingTalk', + icon: '💬', + description: 'channels:meta.dingtalk.description', + connectionType: 'token', + docsUrl: 'channels:meta.dingtalk.docsUrl', + configFields: [ + { + key: 'clientId', + label: 'channels:meta.dingtalk.fields.clientId.label', + type: 'text', + placeholder: 'channels:meta.dingtalk.fields.clientId.placeholder', + required: true, + }, + { + key: 'clientSecret', + label: 'channels:meta.dingtalk.fields.clientSecret.label', + type: 'password', + placeholder: 'channels:meta.dingtalk.fields.clientSecret.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.dingtalk.instructions.0', + 'channels:meta.dingtalk.instructions.1', + 'channels:meta.dingtalk.instructions.2', + ], + isPlugin: true, + }, + wecom: { + id: 'wecom', + name: 'WeCom', + icon: '💼', + description: 'channels:meta.wecom.description', + connectionType: 'token', + docsUrl: 'channels:meta.wecom.docsUrl', + configFields: [ + { + key: 'botId', + label: 'channels:meta.wecom.fields.botId.label', + type: 'text', + placeholder: 'channels:meta.wecom.fields.botId.placeholder', + required: true, + }, + { + key: 'secret', + label: 'channels:meta.wecom.fields.secret.label', + type: 'password', + placeholder: 'channels:meta.wecom.fields.secret.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.wecom.instructions.0', + 'channels:meta.wecom.instructions.1', + 'channels:meta.wecom.instructions.2', + ], + isPlugin: true, + }, + telegram: { + id: 'telegram', + name: 'Telegram', + icon: '✈️', + description: 'channels:meta.telegram.description', + connectionType: 'token', + docsUrl: 'channels:meta.telegram.docsUrl', + configFields: [ + { + key: 'botToken', + label: 'channels:meta.telegram.fields.botToken.label', + type: 'password', + placeholder: 'channels:meta.telegram.fields.botToken.placeholder', + required: true, + envVar: 'TELEGRAM_BOT_TOKEN', + }, + { + key: 'allowedUsers', + label: 'channels:meta.telegram.fields.allowedUsers.label', + type: 'text', + placeholder: 'channels:meta.telegram.fields.allowedUsers.placeholder', + description: 'channels:meta.telegram.fields.allowedUsers.description', + required: true, + }, + ], + instructions: [ + 'channels:meta.telegram.instructions.0', + 'channels:meta.telegram.instructions.1', + 'channels:meta.telegram.instructions.2', + 'channels:meta.telegram.instructions.3', + 'channels:meta.telegram.instructions.4', + ], + }, + discord: { + id: 'discord', + name: 'Discord', + icon: '🎮', + description: 'channels:meta.discord.description', + connectionType: 'token', + docsUrl: 'channels:meta.discord.docsUrl', + configFields: [ + { + key: 'token', + label: 'channels:meta.discord.fields.token.label', + type: 'password', + placeholder: 'channels:meta.discord.fields.token.placeholder', + required: true, + envVar: 'DISCORD_BOT_TOKEN', + }, + { + key: 'guildId', + label: 'channels:meta.discord.fields.guildId.label', + type: 'text', + placeholder: 'channels:meta.discord.fields.guildId.placeholder', + required: true, + description: 'channels:meta.discord.fields.guildId.description', + }, + { + key: 'channelId', + label: 'channels:meta.discord.fields.channelId.label', + type: 'text', + placeholder: 'channels:meta.discord.fields.channelId.placeholder', + required: false, + description: 'channels:meta.discord.fields.channelId.description', + }, + ], + instructions: [ + 'channels:meta.discord.instructions.0', + 'channels:meta.discord.instructions.1', + 'channels:meta.discord.instructions.2', + 'channels:meta.discord.instructions.3', + 'channels:meta.discord.instructions.4', + 'channels:meta.discord.instructions.5', + ], + }, + + whatsapp: { + id: 'whatsapp', + name: 'WhatsApp', + icon: '📱', + description: 'channels:meta.whatsapp.description', + connectionType: 'qr', + docsUrl: 'channels:meta.whatsapp.docsUrl', + configFields: [], + instructions: [ + 'channels:meta.whatsapp.instructions.0', + 'channels:meta.whatsapp.instructions.1', + 'channels:meta.whatsapp.instructions.2', + 'channels:meta.whatsapp.instructions.3', + ], + }, + wechat: { + id: 'wechat', + name: 'WeChat', + icon: '💬', + description: 'channels:meta.wechat.description', + connectionType: 'qr', + docsUrl: 'channels:meta.wechat.docsUrl', + configFields: [], + instructions: [ + 'channels:meta.wechat.instructions.0', + 'channels:meta.wechat.instructions.1', + 'channels:meta.wechat.instructions.2', + 'channels:meta.wechat.instructions.3', + ], + isPlugin: true, + }, + signal: { + id: 'signal', + name: 'Signal', + icon: '🔒', + description: 'channels:meta.signal.description', + connectionType: 'token', + docsUrl: 'channels:meta.signal.docsUrl', + configFields: [ + { + key: 'phoneNumber', + label: 'channels:meta.signal.fields.phoneNumber.label', + type: 'text', + placeholder: 'channels:meta.signal.fields.phoneNumber.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.signal.instructions.0', + 'channels:meta.signal.instructions.1', + 'channels:meta.signal.instructions.2', + ], + }, + feishu: { + id: 'feishu', + name: 'Feishu / Lark', + icon: '🐦', + description: 'channels:meta.feishu.description', + connectionType: 'token', + docsUrl: 'channels:meta.feishu.docsUrl', + configFields: [ + { + key: 'appId', + label: 'channels:meta.feishu.fields.appId.label', + type: 'text', + placeholder: 'channels:meta.feishu.fields.appId.placeholder', + required: true, + envVar: 'FEISHU_APP_ID', + }, + { + key: 'appSecret', + label: 'channels:meta.feishu.fields.appSecret.label', + type: 'password', + placeholder: 'channels:meta.feishu.fields.appSecret.placeholder', + required: true, + envVar: 'FEISHU_APP_SECRET', + }, + ], + instructions: [ + 'channels:meta.feishu.instructions.0', + 'channels:meta.feishu.instructions.1', + 'channels:meta.feishu.instructions.2', + 'channels:meta.feishu.instructions.3', + ], + isPlugin: true, + }, + imessage: { + id: 'imessage', + name: 'iMessage', + icon: '💬', + description: 'channels:meta.imessage.description', + connectionType: 'token', + docsUrl: 'channels:meta.imessage.docsUrl', + configFields: [ + { + key: 'serverUrl', + label: 'channels:meta.imessage.fields.serverUrl.label', + type: 'text', + placeholder: 'channels:meta.imessage.fields.serverUrl.placeholder', + required: true, + }, + { + key: 'password', + label: 'channels:meta.imessage.fields.password.label', + type: 'password', + placeholder: 'channels:meta.imessage.fields.password.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.imessage.instructions.0', + 'channels:meta.imessage.instructions.1', + 'channels:meta.imessage.instructions.2', + ], + }, + matrix: { + id: 'matrix', + name: 'Matrix', + icon: '🔗', + description: 'channels:meta.matrix.description', + connectionType: 'token', + docsUrl: 'channels:meta.matrix.docsUrl', + configFields: [ + { + key: 'homeserver', + label: 'channels:meta.matrix.fields.homeserver.label', + type: 'text', + placeholder: 'channels:meta.matrix.fields.homeserver.placeholder', + required: true, + }, + { + key: 'accessToken', + label: 'channels:meta.matrix.fields.accessToken.label', + type: 'password', + placeholder: 'channels:meta.matrix.fields.accessToken.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.matrix.instructions.0', + 'channels:meta.matrix.instructions.1', + 'channels:meta.matrix.instructions.2', + ], + isPlugin: true, + }, + line: { + id: 'line', + name: 'LINE', + icon: '🟢', + description: 'channels:meta.line.description', + connectionType: 'token', + docsUrl: 'channels:meta.line.docsUrl', + configFields: [ + { + key: 'channelAccessToken', + label: 'channels:meta.line.fields.channelAccessToken.label', + type: 'password', + placeholder: 'channels:meta.line.fields.channelAccessToken.placeholder', + required: true, + envVar: 'LINE_CHANNEL_ACCESS_TOKEN', + }, + { + key: 'channelSecret', + label: 'channels:meta.line.fields.channelSecret.label', + type: 'password', + placeholder: 'channels:meta.line.fields.channelSecret.placeholder', + required: true, + envVar: 'LINE_CHANNEL_SECRET', + }, + ], + instructions: [ + 'channels:meta.line.instructions.0', + 'channels:meta.line.instructions.1', + 'channels:meta.line.instructions.2', + ], + isPlugin: true, + }, + msteams: { + id: 'msteams', + name: 'Microsoft Teams', + icon: '👔', + description: 'channels:meta.msteams.description', + connectionType: 'token', + docsUrl: 'channels:meta.msteams.docsUrl', + configFields: [ + { + key: 'appId', + label: 'channels:meta.msteams.fields.appId.label', + type: 'text', + placeholder: 'channels:meta.msteams.fields.appId.placeholder', + required: true, + envVar: 'MSTEAMS_APP_ID', + }, + { + key: 'appPassword', + label: 'channels:meta.msteams.fields.appPassword.label', + type: 'password', + placeholder: 'channels:meta.msteams.fields.appPassword.placeholder', + required: true, + envVar: 'MSTEAMS_APP_PASSWORD', + }, + ], + instructions: [ + 'channels:meta.msteams.instructions.0', + 'channels:meta.msteams.instructions.1', + 'channels:meta.msteams.instructions.2', + 'channels:meta.msteams.instructions.3', + ], + isPlugin: true, + }, + googlechat: { + id: 'googlechat', + name: 'Google Chat', + icon: '💭', + description: 'channels:meta.googlechat.description', + connectionType: 'webhook', + docsUrl: 'channels:meta.googlechat.docsUrl', + configFields: [ + { + key: 'serviceAccountKey', + label: 'channels:meta.googlechat.fields.serviceAccountKey.label', + type: 'text', + placeholder: 'channels:meta.googlechat.fields.serviceAccountKey.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.googlechat.instructions.0', + 'channels:meta.googlechat.instructions.1', + 'channels:meta.googlechat.instructions.2', + 'channels:meta.googlechat.instructions.3', + ], + }, + mattermost: { + id: 'mattermost', + name: 'Mattermost', + icon: '💠', + description: 'channels:meta.mattermost.description', + connectionType: 'token', + docsUrl: 'channels:meta.mattermost.docsUrl', + configFields: [ + { + key: 'serverUrl', + label: 'channels:meta.mattermost.fields.serverUrl.label', + type: 'text', + placeholder: 'channels:meta.mattermost.fields.serverUrl.placeholder', + required: true, + }, + { + key: 'botToken', + label: 'channels:meta.mattermost.fields.botToken.label', + type: 'password', + placeholder: 'channels:meta.mattermost.fields.botToken.placeholder', + required: true, + }, + ], + instructions: [ + 'channels:meta.mattermost.instructions.0', + 'channels:meta.mattermost.instructions.1', + 'channels:meta.mattermost.instructions.2', + ], + isPlugin: true, + }, +}; + +/** + * Get primary supported channels (non-plugin, commonly used) + */ +export function getPrimaryChannels(): ChannelType[] { + return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot']; +} + +/** + * Get all available channels including plugins + */ +export function getAllChannels(): ChannelType[] { + return Object.keys(CHANNEL_META) as ChannelType[]; +} diff --git a/shared/types/cron.ts b/shared/types/cron.ts new file mode 100644 index 00000000..81eba011 --- /dev/null +++ b/shared/types/cron.ts @@ -0,0 +1,91 @@ +/** + * Cron Job Type Definitions + * Types for scheduled tasks + */ + +import type { ChannelType } from './channel'; + +export type CronJobDeliveryMode = 'none' | 'announce'; + +export interface CronJobDelivery { + mode: CronJobDeliveryMode; + channel?: ChannelType | string; + to?: string; + accountId?: string; +} + +/** + * Cron job target (where to send the result) + */ +export interface CronJobTarget { + channelType: ChannelType | string; + channelId: string; + channelName: string; + recipient?: string; +} + +/** + * Cron job last run info + */ +export interface CronJobLastRun { + time: string; + success: boolean; + error?: string; + duration?: number; +} + +/** + * Gateway CronSchedule object format + */ +export type CronSchedule = + | { kind: 'at'; at: string } + | { kind: 'every'; everyMs: number; anchorMs?: number } + | { kind: 'cron'; expr: string; tz?: string }; + +/** + * Cron job data structure + * schedule can be a plain cron string or a Gateway CronSchedule object + */ +export interface CronJob { + id: string; + name: string; + message: string; + schedule: string | CronSchedule; + delivery?: CronJobDelivery; + target?: CronJobTarget; + enabled: boolean; + createdAt: string; + updatedAt: string; + lastRun?: CronJobLastRun; + nextRun?: string; + agentId: string; +} + +/** + * Input for creating a cron job from the UI. + */ +export interface CronJobCreateInput { + name: string; + message: string; + schedule: string; + delivery?: CronJobDelivery; + enabled?: boolean; + agentId?: string; +} + +/** + * Input for updating a cron job + */ +export interface CronJobUpdateInput { + name?: string; + message?: string; + schedule?: string; + delivery?: CronJobDelivery; + enabled?: boolean; + agentId?: string; +} + +/** + * Schedule type for UI picker + */ +export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom'; diff --git a/shared/types/gateway.ts b/shared/types/gateway.ts new file mode 100644 index 00000000..2b166f93 --- /dev/null +++ b/shared/types/gateway.ts @@ -0,0 +1,112 @@ +/** + * Gateway Type Definitions + * Types for Gateway communication and data structures + */ + +export type GatewayRuntimeJsonValue = + | string + | number + | boolean + | null + | GatewayRuntimeJsonValue[] + | { [key: string]: GatewayRuntimeJsonValue | undefined }; + +export type GatewayRuntimePayload = GatewayRuntimeJsonValue | undefined; +export type GatewayRuntimeRecord = { [key: string]: GatewayRuntimeJsonValue | undefined }; + +/** + * Gateway connection status + */ +export interface GatewayStatus { + state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; + port: number; + pid?: number; + uptime?: number; + error?: string; + connectedAt?: number; + version?: string; + reconnectAttempts?: number; + /** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */ + gatewayReady?: boolean; +} + +/** + * Gateway RPC response + */ +export interface GatewayRpcResponse { + success: boolean; + result?: T; + error?: string; +} + +/** + * Gateway health check response + */ +export interface GatewayCapabilityProbe { + state: 'unknown' | 'healthy' | 'degraded'; + checkedAt?: number; + durationMs?: number; + error?: string; + payload?: GatewayRuntimePayload; +} + +export interface GatewayCapabilitySnapshot { + core: { + process: GatewayStatus['state']; + transport: 'connected' | 'disconnected'; + rpcRouter: 'unknown' | 'ready' | 'blocked'; + lastProbe?: { + ok: boolean; + checkedAt: number; + durationMs?: number; + error?: string; + }; + }; + openclawHealth: GatewayCapabilityProbe; + openclawStatus: GatewayCapabilityProbe; + presence: GatewayCapabilityProbe; + channels: GatewayCapabilityProbe; + memory: GatewayCapabilityProbe; + diagnostics: { + lastAliveAt?: number; + lastRpcSuccessAt?: number; + lastRpcFailureAt?: number; + lastRpcFailureMethod?: string; + lastHeartbeatTimeoutAt?: number; + consecutiveHeartbeatMisses: number; + lastSocketCloseAt?: number; + lastSocketCloseCode?: number; + consecutiveRpcFailures: number; + }; +} + +export interface GatewayHealth { + ok: boolean; + error?: string; + uptime?: number; + version?: string; + capabilities?: GatewayCapabilitySnapshot; + openclawHealth?: GatewayRuntimePayload; + presence?: GatewayRuntimePayload; +} + +/** + * Gateway notification (server-initiated event) + */ +export interface GatewayNotification { + method: string; + params?: GatewayRuntimePayload; +} + +/** + * Provider configuration + */ +export interface ProviderConfig { + id: string; + name: string; + type: 'openai' | 'anthropic' | 'ollama' | 'custom'; + apiKey?: string; + baseUrl?: string; + model?: string; + enabled: boolean; +} diff --git a/shared/types/skill.ts b/shared/types/skill.ts new file mode 100644 index 00000000..4f663db3 --- /dev/null +++ b/shared/types/skill.ts @@ -0,0 +1,85 @@ +/** + * Skill Type Definitions + * Types for skills/plugins + */ + +/** + * Skill data structure + */ +export interface Skill { + id: string; + slug?: string; + name: string; + description: string; + enabled: boolean; + icon?: string; + version?: string; + author?: string; + configurable?: boolean; + config?: Record; + isCore?: boolean; + isBundled?: boolean; + dependencies?: string[]; + source?: string; + baseDir?: string; + filePath?: string; + marketplace?: { + provider: string; + slug?: string; + installedVersion?: string; + manifestPath?: string; + originPath?: string; + }; +} + +export interface QuickAccessSkill { + name: string; + description: string; + source: 'workspace' | 'openclaw' | 'agents' | 'legacy'; + sourceLabel: string; + manifestPath: string; + baseDir: string; +} + +/** + * Skill bundle (preset skill collection) + */ +export interface SkillBundle { + id: string; + name: string; + nameZh: string; + description: string; + descriptionZh: string; + icon: string; + skills: string[]; + recommended?: boolean; +} + + +/** + * Marketplace skill data + */ +export interface MarketplaceSkill { + slug: string; + name: string; + description: string; + version: string; + author?: string; + downloads?: number; + stars?: number; +} + +/** + * Skill configuration schema + */ +export interface SkillConfigSchema { + type: 'object'; + properties: Record; + required?: string[]; +} diff --git a/src/App.tsx b/src/App.tsx index 3716fd79..ce14be35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,11 @@ import { useSettingsStore } from './stores/settings'; import { useUpdateStore } from './stores/update'; import { useGatewayStore } from './stores/gateway'; import { useProviderStore } from './stores/providers'; -import { applyGatewayTransportPreference } from './lib/api-client'; import { rendererExtensionRegistry } from './extensions/registry'; import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated'; import { UpdateNotifier } from './components/update/UpdateNotifier'; +import { useNewChatAction } from './components/layout/use-new-chat-action'; +import { hostEvents } from './lib/host-events'; /** @@ -106,6 +107,7 @@ function App() { const initGateway = useGatewayStore((state) => state.init); const initUpdate = useUpdateStore((state) => state.init); const initProviders = useProviderStore((state) => state.init); + const handleNewChat = useNewChatAction(); useEffect(() => { let cancelled = false; @@ -147,14 +149,9 @@ function App() { // Listen for navigation events from main process useEffect(() => { - const handleNavigate = (...args: unknown[]) => { - const path = args[0]; - if (typeof path === 'string') { - navigate(path); - } - }; - - const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate); + const unsubscribe = hostEvents.onNavigate((path) => { + navigate(path); + }); return () => { if (typeof unsubscribe === 'function') { @@ -163,6 +160,16 @@ function App() { }; }, [navigate]); + useEffect(() => { + const unsubscribe = hostEvents.onNewChat(handleNewChat); + + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [handleNewChat]); + // Apply theme useEffect(() => { const root = window.document.documentElement; @@ -178,10 +185,6 @@ function App() { } }, [theme]); - useEffect(() => { - applyGatewayTransportPreference(); - }, []); - // Load external renderer extensions (generated by scripts/generate-ext-bridge.mjs) // and initialize all registered extensions. useEffect(() => { diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx index a48ff5d5..cb526ae3 100644 --- a/src/components/channels/ChannelConfigModal.tsx +++ b/src/components/channels/ChannelConfigModal.tsx @@ -20,9 +20,10 @@ import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { useChannelsStore } from '@/stores/channels'; -import { hostApiFetch } from '@/lib/host-api'; -import { subscribeHostEvent } from '@/lib/host-events'; +import { hostApi } from '@/lib/host-api'; +import { hostEvents } from '@/lib/host-events'; import { cn } from '@/lib/utils'; +import type { ChannelErrorEvent, ChannelQrEvent, ChannelSuccessEvent } from '@shared/host-events/contract'; import { CHANNEL_ICONS, CHANNEL_NAMES, @@ -33,7 +34,6 @@ import { type ChannelConfigField, } from '@/types/channel'; import { - buildQrChannelEventName, isCanonicalOpenClawAccountId, usesPluginManagedQrAccounts, } from '@/lib/channel-alias'; @@ -157,9 +157,9 @@ export function ChannelConfigModal({ (async () => { try { - const accountParam = accountIdForConfigLoad ? `?accountId=${encodeURIComponent(accountIdForConfigLoad)}` : ''; - const result = await hostApiFetch<{ success: boolean; values?: Record }>( - `/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}` + const result = await hostApi.channels.formValues( + selectedType, + accountIdForConfigLoad, ); if (cancelled) return; @@ -233,23 +233,22 @@ export function ChannelConfigModal({ if (!selectedType || meta?.connectionType !== 'qr') return; const channelType = selectedType; - const onQr = (...args: unknown[]) => { - const data = args[0] as { qr?: string; raw?: string }; + const onQr = (data: ChannelQrEvent) => { const nextQr = normalizeQrImageSource(data); if (!nextQr) return; setQrCode(nextQr); setConnecting(false); }; - const onSuccess = async (...args: unknown[]) => { - const data = args[0] as { accountId?: string } | undefined; + const onSuccess = async (data: ChannelSuccessEvent) => { void data?.accountId; toast.success(translateRef.current('toast.qrConnected', { name: CHANNEL_NAMES[channelType] })); try { if (channelType === 'whatsapp') { - const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { - method: 'POST', - body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }), + const saveResult = await hostApi.channels.saveConfig({ + channelType: 'whatsapp', + config: { enabled: true }, + accountId: resolvedAccountId, }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); @@ -269,27 +268,25 @@ export function ChannelConfigModal({ } }; - const onError = (...args: unknown[]) => { - const err = typeof args[0] === 'string' - ? args[0] - : String((args[0] as { message?: string } | undefined)?.message || args[0]); + const onError = (payload: ChannelErrorEvent) => { + const err = typeof payload === 'string' + ? payload + : String(payload.message || payload); toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err })); setQrCode(null); setConnecting(false); }; - const removeQrListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'qr'), onQr); - const removeSuccessListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'success'), onSuccess); - const removeErrorListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'error'), onError); + const removeQrListener = hostEvents.onChannelQr(channelType, onQr); + const removeSuccessListener = hostEvents.onChannelSuccess(channelType, onSuccess); + const removeErrorListener = hostEvents.onChannelError(channelType, onError); return () => { removeQrListener(); removeSuccessListener(); removeErrorListener(); - hostApiFetch(`/api/channels/${encodeURIComponent(channelType)}/cancel`, { - method: 'POST', - body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), - }).catch(() => { }); + hostApi.channels.cancelLogin(channelType, resolvedAccountId ? { accountId: resolvedAccountId } : undefined) + .catch(() => { }); }; }, [meta?.connectionType, resolvedAccountId, selectedType]); @@ -300,16 +297,7 @@ export function ChannelConfigModal({ setValidationResult(null); try { - const result = await hostApiFetch<{ - success: boolean; - valid?: boolean; - errors?: string[]; - warnings?: string[]; - details?: Record; - }>('/api/channels/credentials/validate', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config: configValues }), - }); + const result = await hostApi.channels.validateCredentials(selectedType, configValues); const warnings = result.warnings || []; if (result.valid && result.details) { @@ -370,24 +358,12 @@ export function ChannelConfigModal({ } if (meta.connectionType === 'qr') { - await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, { - method: 'POST', - body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), - }); + await hostApi.channels.startLogin(selectedType, resolvedAccountId ? { accountId: resolvedAccountId } : undefined); return; } if (meta.connectionType === 'token' && shouldUseCredentialValidation) { - const validationResponse = await hostApiFetch<{ - success: boolean; - valid?: boolean; - errors?: string[]; - warnings?: string[]; - details?: Record; - }>('/api/channels/credentials/validate', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config: configValues }), - }); + const validationResponse = await hostApi.channels.validateCredentials(selectedType, configValues); if (!validationResponse.valid) { setValidationResult({ @@ -415,14 +391,7 @@ export function ChannelConfigModal({ } const config: Record = { ...configValues }; - const saveResult = await hostApiFetch<{ - success?: boolean; - error?: string; - warning?: string; - }>('/api/channels/config', { - method: 'POST', - body: JSON.stringify({ channelType: selectedType, config, accountId: resolvedAccountId }), - }); + const saveResult = await hostApi.channels.saveConfig({ channelType: selectedType, config, accountId: resolvedAccountId }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save channel config'); } diff --git a/src/components/file-preview/ArtifactPanel.tsx b/src/components/file-preview/ArtifactPanel.tsx index 8ce4c124..85532750 100644 --- a/src/components/file-preview/ArtifactPanel.tsx +++ b/src/components/file-preview/ArtifactPanel.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { supportsRichDocumentPreview, type GeneratedFile } from '@/lib/generated-files'; -import { invokeIpc } from '@/lib/api-client'; +import { hostApi } from '@/lib/host-api'; import type { AgentSummary } from '@/types/agent'; import { useArtifactPanel } from '@/stores/artifact-panel'; import type { FilePreviewTarget } from './types'; @@ -56,7 +56,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art const handleRevealFocusedFile = () => { if (!focusedFile) return; - invokeIpc('shell:showItemInFolder', focusedFile.filePath).catch(() => { + hostApi.shell.showItemInFolder(focusedFile.filePath).catch(() => { toast.error(t('filePreview.errors.openInFinderFailed', 'Could not reveal in file manager')); }); }; diff --git a/src/components/file-preview/FilePreviewBody.tsx b/src/components/file-preview/FilePreviewBody.tsx index c1aff3b1..38c01ccd 100644 --- a/src/components/file-preview/FilePreviewBody.tsx +++ b/src/components/file-preview/FilePreviewBody.tsx @@ -27,7 +27,8 @@ import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; -import { invokeIpc, readTextFile, statFile, writeTextFile } from '@/lib/api-client'; +import { readTextFile, statFile, writeTextFile } from '@/lib/file-preview-client'; +import { hostApi } from '@/lib/host-api'; import type { FilePreviewTarget } from './types'; import { isHtmlPreviewExt, @@ -371,7 +372,7 @@ export function FilePreviewBody({ }, [state]); const handleOpenInFinder = useCallback(() => { - invokeIpc('shell:showItemInFolder', file.filePath).catch(() => { + hostApi.shell.showItemInFolder(file.filePath).catch(() => { toast.error(t('filePreview.errors.openInFinderFailed', 'Could not reveal in file manager')); }); }, [file, t]); diff --git a/src/components/file-preview/GeneratedFilesPanel.tsx b/src/components/file-preview/GeneratedFilesPanel.tsx index 4a262631..fc4f73d5 100644 --- a/src/components/file-preview/GeneratedFilesPanel.tsx +++ b/src/components/file-preview/GeneratedFilesPanel.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import { FolderOpen } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; -import { invokeIpc } from '@/lib/api-client'; +import { hostApi } from '@/lib/host-api'; import { computeLineStats, supportsInlineDiff, @@ -38,7 +38,7 @@ export function GeneratedFilesPanel({ onRevealInFileManager(file); return; } - void invokeIpc('shell:showItemInFolder', file.filePath); + void hostApi.shell.showItemInFolder(file.filePath); }; return ( diff --git a/src/components/file-preview/ImageViewer.tsx b/src/components/file-preview/ImageViewer.tsx index f46e65e8..3acb94a0 100644 --- a/src/components/file-preview/ImageViewer.tsx +++ b/src/components/file-preview/ImageViewer.tsx @@ -10,7 +10,7 @@ import { ZoomIn, ZoomOut } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { readBinaryFile } from '@/lib/api-client'; +import { readBinaryFile } from '@/lib/file-preview-client'; import { cn } from '@/lib/utils'; const IMAGE_MAX_BYTES = 50 * 1024 * 1024; diff --git a/src/components/file-preview/PdfViewer.tsx b/src/components/file-preview/PdfViewer.tsx index 582e6d74..564ef2e3 100644 --- a/src/components/file-preview/PdfViewer.tsx +++ b/src/components/file-preview/PdfViewer.tsx @@ -12,7 +12,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { readBinaryFile } from '@/lib/api-client'; +import { readBinaryFile } from '@/lib/file-preview-client'; import { cn } from '@/lib/utils'; const PDF_MAX_BYTES = 50 * 1024 * 1024; diff --git a/src/components/file-preview/SheetViewer.tsx b/src/components/file-preview/SheetViewer.tsx index 5d38ab3b..5bbcc0fc 100644 --- a/src/components/file-preview/SheetViewer.tsx +++ b/src/components/file-preview/SheetViewer.tsx @@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { readBinaryFile } from '@/lib/api-client'; +import { readBinaryFile } from '@/lib/file-preview-client'; import { cn } from '@/lib/utils'; const SHEET_MAX_BYTES = 50 * 1024 * 1024; diff --git a/src/components/file-preview/WorkspaceBrowserBody.tsx b/src/components/file-preview/WorkspaceBrowserBody.tsx index 32c68145..a22c4cb3 100644 --- a/src/components/file-preview/WorkspaceBrowserBody.tsx +++ b/src/components/file-preview/WorkspaceBrowserBody.tsx @@ -12,7 +12,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; -import { invokeIpc, readTextFile, statFile } from '@/lib/api-client'; +import { readTextFile, statFile } from '@/lib/file-preview-client'; +import { hostApi } from '@/lib/host-api'; import { isHtmlPreviewExt, isPdfPreviewExt, @@ -213,14 +214,14 @@ export function WorkspaceBrowserBody({ const handleOpenWorkspaceInFinder = useCallback(() => { if (!workspace) return; - invokeIpc('shell:openPath', workspace).catch(() => { + hostApi.shell.openPath(workspace).catch(() => { toast.error(t('filePreview.errors.openInFinderFailed', 'Could not reveal in file manager')); }); }, [workspace, t]); const handleOpenSelectedInFinder = useCallback(() => { if (!selectedNode || selectedNode.isDir) return; - invokeIpc('shell:showItemInFolder', selectedNode.absPath).catch(() => { + hostApi.shell.showItemInFolder(selectedNode.absPath).catch(() => { toast.error(t('filePreview.errors.openInFinderFailed', 'Could not reveal in file manager')); }); }, [selectedNode, t]); diff --git a/src/components/file-preview/open-file-utils.ts b/src/components/file-preview/open-file-utils.ts index af57baa5..0898ffda 100644 --- a/src/components/file-preview/open-file-utils.ts +++ b/src/components/file-preview/open-file-utils.ts @@ -1,4 +1,4 @@ -import { invokeIpc } from '@/lib/api-client'; +import { hostApi } from '@/lib/host-api'; import { formatFileSize } from './format'; export const DIRECT_OPEN_FALLBACK_EXTS = new Set(['.pdf', '.xls', '.xlsx']); @@ -33,7 +33,7 @@ export async function confirmAndOpenFile(params: { filePath, ].filter(Boolean).join('\n'); - const result = await invokeIpc<{ response?: number }>('dialog:message', { + const result = await hostApi.dialog.message({ type: 'question', buttons: [ t('filePreview.confirmOpen.cancel', { defaultValue: 'Cancel' }), @@ -52,7 +52,7 @@ export async function confirmAndOpenFile(params: { if (result?.response !== 1) return false; - const openResult = await invokeIpc('shell:openPath', filePath); + const openResult = await hostApi.shell.openPath(filePath); if (openResult) { throw new Error(openResult); } diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 3092672e..4bfc0c88 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -5,7 +5,7 @@ import { Outlet } from 'react-router-dom'; import { Sidebar } from './Sidebar'; import { TitleBar } from './TitleBar'; -import { MAC_SIDEBAR_CHROME_HEIGHT } from '../../../shared/sidebar-layout'; +import { MAC_SIDEBAR_CHROME_HEIGHT } from '@shared/sidebar-layout'; import { cn } from '@/lib/utils'; export function MainLayout() { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2adbbb94..70c82d82 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,8 +24,10 @@ import { ImagePlus, Moon, ChevronRight, + Loader2, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { isGatewayRestarting } from '@/lib/gateway-status'; import { rendererExtensionRegistry } from '@/extensions/registry'; import { useSettingsStore } from '@/stores/settings'; import { useChatStore } from '@/stores/chat'; @@ -36,11 +38,11 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { hostApiFetch } from '@/lib/host-api'; -import { invokeIpc } from '@/lib/api-client'; -import { SIDEBAR_COLLAPSED_WIDTH, MAC_SIDEBAR_CHROME_HEIGHT } from '../../../shared/sidebar-layout'; +import { hostApi } from '@/lib/host-api'; +import { SIDEBAR_COLLAPSED_WIDTH, MAC_SIDEBAR_CHROME_HEIGHT } from '@shared/sidebar-layout'; import { useTranslation } from 'react-i18next'; import logoSvg from '@/assets/logo.svg'; +import { useNewChatAction } from './use-new-chat-action'; interface NavItemProps { to: string; @@ -117,15 +119,16 @@ export function Sidebar() { const sessionLabels = useChatStore((s) => s.sessionLabels); const sessionLastActivity = useChatStore((s) => s.sessionLastActivity); const switchSession = useChatStore((s) => s.switchSession); - const newSession = useChatStore((s) => s.newSession); const deleteSession = useChatStore((s) => s.deleteSession); const renameSession = useChatStore((s) => s.renameSession); const loadSessions = useChatStore((s) => s.loadSessions); const loadHistory = useChatStore((s) => s.loadHistory); + const handleNewChat = useNewChatAction(); const gatewayStatus = useGatewayStore((s) => s.status); const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayReady = isGatewayRunning && gatewayStatus.gatewayReady !== false; + const gatewayRestarting = isGatewayRestarting(gatewayStatus); const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`; const hasLoadedCurrentRuntimeRef = useRef(false); @@ -153,7 +156,7 @@ export function Sidebar() { useEffect(() => { if (!isMac) return; - void invokeIpc('window:syncTrafficLightPosition', sidebarCollapsed); + void hostApi.window.syncTrafficLightPosition(sidebarCollapsed); }, [isMac, sidebarCollapsed]); const navigate = useNavigate(); @@ -162,13 +165,9 @@ export function Sidebar() { const getSessionLabel = (key: string, displayName?: string, label?: string) => sessionLabels[key] ?? label ?? displayName ?? key; - const openControlUi = async (path: string, label: string) => { + const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => { try { - const result = await hostApiFetch<{ - success: boolean; - url?: string; - error?: string; - }>(path); + const result = await hostApi.gateway.controlUi(view); if (result.success && result.url) { await window.electron.openExternal(result.url); } else { @@ -180,11 +179,12 @@ export function Sidebar() { }; const openDevConsole = async () => { - await openControlUi('/api/gateway/control-ui', 'OpenClaw Page'); + await openControlUi(undefined, 'OpenClaw Page'); }; const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [editingSessionKey, setEditingSessionKey] = useState(null); const [editingLabel, setEditingLabel] = useState(''); const [nowMs, setNowMs] = useState(INITIAL_NOW_MS); @@ -203,6 +203,12 @@ export function Sidebar() { void fetchAgents(); }, [fetchAgents]); + useEffect(() => { + if (deleteDialogOpen || !sessionToDelete) return; + const timer = window.setTimeout(() => setSessionToDelete(null), 160); + return () => window.clearTimeout(timer); + }, [deleteDialogOpen, sessionToDelete]); + const handleStartRename = (key: string, currentLabel: string) => { setEditingSessionKey(key); setEditingLabel(currentLabel); @@ -388,11 +394,7 @@ export function Sidebar() { - - + + ); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..12126012 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,64 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogTitle = DialogPrimitive.Title + +const DialogDescription = DialogPrimitive.Description + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/hooks/use-stick-to-bottom-instant.ts b/src/hooks/use-stick-to-bottom-instant.ts index 1ce06967..4fe1907c 100644 --- a/src/hooks/use-stick-to-bottom-instant.ts +++ b/src/hooks/use-stick-to-bottom-instant.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef } from "react"; import { useStickToBottom } from "use-stick-to-bottom"; +const ESCAPE_FROM_LOCK_OFFSET_PX = 70; + /** * A wrapper around useStickToBottom that ensures the initial scroll * to bottom happens instantly without any visible animation. @@ -22,7 +24,8 @@ export function useStickToBottomInstant(resetKey?: string, active = false) { resize: "instant", }); - const { scrollRef, contentRef, escapedFromLock } = result; + const { scrollRef, contentRef, escapedFromLock, stopScroll } = result; + const scrollEscapeCleanupRef = useRef<(() => void) | null>(null); // Keep the latest "should we pin?" inputs available inside the ResizeObserver // callback without re-creating the observer on every render. @@ -69,10 +72,33 @@ export function useStickToBottomInstant(resetKey?: string, active = false) { [contentRef, pinToBottom], ); + const combinedScrollRef = useCallback( + (element: HTMLElement | null) => { + scrollRef(element); + + scrollEscapeCleanupRef.current?.(); + scrollEscapeCleanupRef.current = null; + + if (!element) return; + + const handleScroll = () => { + const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop; + if (distanceFromBottom > ESCAPE_FROM_LOCK_OFFSET_PX) { + stopScroll(); + } + }; + element.addEventListener("scroll", handleScroll, { passive: true }); + scrollEscapeCleanupRef.current = () => element.removeEventListener("scroll", handleScroll); + }, + [scrollRef, stopScroll], + ); + useEffect(() => { return () => { pinObserverRef.current?.disconnect(); pinObserverRef.current = null; + scrollEscapeCleanupRef.current?.(); + scrollEscapeCleanupRef.current = null; }; }, []); @@ -111,5 +137,5 @@ export function useStickToBottomInstant(resetKey?: string, active = false) { return () => cancelAnimationFrame(frame1); }, [scrollRef, resetKey]); - return { ...result, contentRef: combinedContentRef }; + return { ...result, scrollRef: combinedScrollRef, contentRef: combinedContentRef }; } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index afce2e70..4f7ec283 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -4,55 +4,8 @@ import { SUPPORTED_LANGUAGE_CODES, resolveSupportedLanguage, type LanguageCode, -} from '../../shared/language'; - -// EN -import enCommon from './locales/en/common.json'; -import enSettings from './locales/en/settings.json'; -import enDashboard from './locales/en/dashboard.json'; -import enChat from './locales/en/chat.json'; -import enChannels from './locales/en/channels.json'; -import enAgents from './locales/en/agents.json'; -import enSkills from './locales/en/skills.json'; -import enCron from './locales/en/cron.json'; -import enDreams from './locales/en/dreams.json'; -import enSetup from './locales/en/setup.json'; - -// ZH -import zhCommon from './locales/zh/common.json'; -import zhSettings from './locales/zh/settings.json'; -import zhDashboard from './locales/zh/dashboard.json'; -import zhChat from './locales/zh/chat.json'; -import zhChannels from './locales/zh/channels.json'; -import zhAgents from './locales/zh/agents.json'; -import zhSkills from './locales/zh/skills.json'; -import zhCron from './locales/zh/cron.json'; -import zhDreams from './locales/zh/dreams.json'; -import zhSetup from './locales/zh/setup.json'; - -// JA -import jaCommon from './locales/ja/common.json'; -import jaSettings from './locales/ja/settings.json'; -import jaDashboard from './locales/ja/dashboard.json'; -import jaChat from './locales/ja/chat.json'; -import jaChannels from './locales/ja/channels.json'; -import jaAgents from './locales/ja/agents.json'; -import jaSkills from './locales/ja/skills.json'; -import jaCron from './locales/ja/cron.json'; -import jaDreams from './locales/ja/dreams.json'; -import jaSetup from './locales/ja/setup.json'; - -// RU -import ruCommon from './locales/ru/common.json'; -import ruSettings from './locales/ru/settings.json'; -import ruDashboard from './locales/ru/dashboard.json'; -import ruChat from './locales/ru/chat.json'; -import ruChannels from './locales/ru/channels.json'; -import ruAgents from './locales/ru/agents.json'; -import ruSkills from './locales/ru/skills.json'; -import ruCron from './locales/ru/cron.json'; -import ruDreams from './locales/ru/dreams.json'; -import ruSetup from './locales/ru/setup.json'; +} from '@shared/language'; +import { I18N_NAMESPACES, I18N_RESOURCES } from '@shared/i18n/resources'; export const SUPPORTED_LANGUAGES = [ { code: 'en', label: 'English' }, @@ -61,66 +14,15 @@ export const SUPPORTED_LANGUAGES = [ { code: 'ru', label: 'Русский' }, ] as const satisfies ReadonlyArray<{ code: LanguageCode; label: string }>; -const resources = { - en: { - common: enCommon, - settings: enSettings, - dashboard: enDashboard, - chat: enChat, - channels: enChannels, - agents: enAgents, - skills: enSkills, - cron: enCron, - dreams: enDreams, - setup: enSetup, - }, - zh: { - common: zhCommon, - settings: zhSettings, - dashboard: zhDashboard, - chat: zhChat, - channels: zhChannels, - agents: zhAgents, - skills: zhSkills, - cron: zhCron, - dreams: zhDreams, - setup: zhSetup, - }, - ja: { - common: jaCommon, - settings: jaSettings, - dashboard: jaDashboard, - chat: jaChat, - channels: jaChannels, - agents: jaAgents, - skills: jaSkills, - cron: jaCron, - dreams: jaDreams, - setup: jaSetup, - }, - ru: { - common: ruCommon, - settings: ruSettings, - dashboard: ruDashboard, - chat: ruChat, - channels: ruChannels, - agents: ruAgents, - skills: ruSkills, - cron: ruCron, - dreams: ruDreams, - setup: ruSetup, - }, -}; - i18n .use(initReactI18next) .init({ - resources, + resources: I18N_RESOURCES, lng: resolveSupportedLanguage(typeof navigator !== 'undefined' ? navigator.language : undefined), fallbackLng: 'en', supportedLngs: [...SUPPORTED_LANGUAGE_CODES], defaultNS: 'common', - ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'agents', 'skills', 'cron', 'dreams', 'setup'], + ns: [...I18N_NAMESPACES], interpolation: { escapeValue: false, // React already escapes }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts deleted file mode 100644 index 2a607ff9..00000000 --- a/src/lib/api-client.ts +++ /dev/null @@ -1,1182 +0,0 @@ -import { trackUiEvent } from './telemetry'; -import { - AppError, - type AppErrorCode, - mapBackendErrorCode, - normalizeAppError, -} from './error-model'; -export { AppError } from './error-model'; - -export type TransportKind = 'ipc' | 'ws' | 'http'; -export type GatewayTransportPreference = 'ws-first'; -type TransportInvoker = (channel: string, args: unknown[]) => Promise; -type TransportRequest = { channel: string; args: unknown[] }; - -type NormalizedTransportResponse = { - ok: boolean; - data?: unknown; - error?: unknown; -}; - -type UnifiedRequest = { - id: string; - module: string; - action: string; - payload?: unknown; -}; - -type UnifiedResponse = { - id?: string; - ok: boolean; - data?: unknown; - error?: { - code?: string; - message?: string; - details?: unknown; - }; -}; - -type TransportRule = { - matcher: string | RegExp; - order: TransportKind[]; -}; - -export type ApiClientTransportConfig = { - enabled: Record, boolean>; - rules: TransportRule[]; -}; - -const UNIFIED_CHANNELS = new Set([ - 'app:version', - 'app:name', - 'app:platform', - 'settings:getAll', - 'settings:get', - 'settings:set', - 'settings:setMany', - 'settings:reset', - 'provider:list', - 'provider:get', - 'provider:getDefault', - 'provider:hasApiKey', - 'provider:getApiKey', - 'provider:validateKey', - 'provider:save', - 'provider:delete', - 'provider:setApiKey', - 'provider:updateWithKey', - 'provider:deleteApiKey', - 'provider:setDefault', - 'update:status', - 'update:version', - 'update:check', - 'update:download', - 'update:install', - 'update:setChannel', - 'update:setAutoDownload', - 'update:cancelAutoInstall', - 'cron:list', - 'cron:create', - 'cron:update', - 'cron:delete', - 'cron:toggle', - 'cron:trigger', - 'usage:recentTokenHistory', -]); - -const customInvokers = new Map, TransportInvoker>(); -const GATEWAY_WS_DIAG_FLAG = 'clawx:gateway-ws-diagnostic'; - -let transportConfig: ApiClientTransportConfig = { - enabled: { - ws: false, - http: false, - }, - rules: [ - { matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] }, - { matcher: /^gateway:/, order: ['ipc'] }, - { matcher: /.*/, order: ['ipc'] }, - ], -}; - -type GatewayStatusLike = { - port?: unknown; -}; - -type HttpTransportOptions = { - endpointResolver: () => Promise | string; - headers?: HeadersInit; - timeoutMs?: number; - fetchImpl?: typeof fetch; - buildRequest?: (request: TransportRequest) => { - url?: string; - method?: string; - headers?: HeadersInit; - body?: BodyInit | null; - }; - parseResponse?: (response: Response) => Promise; -}; - -type WsTransportOptions = { - urlResolver: () => Promise | string; - timeoutMs?: number; - websocketFactory?: (url: string) => WebSocket; - buildMessage?: (requestId: string, request: TransportRequest) => unknown; - parseMessage?: (payload: unknown) => { id?: string; ok: boolean; data?: unknown; error?: unknown } | null; -}; - -type GatewayWsTransportOptions = { - urlResolver?: () => Promise | string; - tokenResolver?: () => Promise | string | null; - timeoutMs?: number; - 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(); - if (cachedGatewayPort && cachedGatewayPort.expiresAt > now) { - return cachedGatewayPort.port; - } - - const status = await invokeViaIpc('gateway:status', []); - const port = typeof status?.port === 'number' && status.port > 0 ? status.port : 18789; - cachedGatewayPort = { port, expiresAt: now + 5000 }; - return port; -} - -export async function resolveDefaultGatewayHttpBaseUrl(): Promise { - const port = await resolveGatewayPort(); - return `http://127.0.0.1:${port}`; -} - -export async function resolveDefaultGatewayWsUrl(): Promise { - const port = await resolveGatewayPort(); - return `ws://127.0.0.1:${port}/ws`; -} - -class TransportUnsupportedError extends Error { - transport: TransportKind; - - constructor(transport: TransportKind, message: string) { - super(message); - this.transport = transport; - } -} - -function mapUnifiedErrorCode(code?: string): AppErrorCode { - return mapBackendErrorCode(code); -} - -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 { - if (typeof matcher === 'string') { - if (matcher.endsWith('*')) { - return channel.startsWith(matcher.slice(0, -1)); - } - return matcher === channel; - } - return matcher.test(channel); -} - -function resolveTransportOrder(channel: string): TransportKind[] { - const now = Date.now(); - const matchedRule = transportConfig.rules.find((rule) => isRuleMatch(rule.matcher, channel)); - const order = matchedRule?.order ?? ['ipc']; - - return order.filter((kind) => { - if (kind === 'ipc') return true; - const backoffUntil = transportBackoffUntil[kind]; - if (typeof backoffUntil === 'number' && backoffUntil > now) { - return false; - } - return transportConfig.enabled[kind]; - }); -} - -function markTransportFailure(kind: TransportKind): void { - if (kind === 'ipc') return; - transportBackoffUntil[kind] = Date.now() + 5000; -} - -export function clearTransportBackoff(kind?: Exclude): void { - if (kind) { - delete transportBackoffUntil[kind]; - return; - } - delete transportBackoffUntil.ws; - delete transportBackoffUntil.http; -} - -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 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 { - const splitIndex = channel.indexOf(':'); - return { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - module: channel.slice(0, splitIndex), - action: channel.slice(splitIndex + 1), - payload: args.length <= 1 ? args[0] : args, - }; -} - -async function invokeViaIpc(channel: string, args: unknown[]): Promise { - if (channel !== 'app:request' && UNIFIED_CHANNELS.has(channel)) { - const request = toUnifiedRequest(channel, args); - - try { - const response = await window.electron.ipcRenderer.invoke('app:request', request) as UnifiedResponse; - if (!response?.ok) { - const message = response?.error?.message || 'Unified IPC request failed'; - if (message.includes('APP_REQUEST_UNSUPPORTED:')) { - throw new Error(message); - } - throw new AppError(mapUnifiedErrorCode(response?.error?.code), message, response?.error); - } - return response.data as T; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) { - // Fallback to legacy channel handlers. - } else { - throw normalizeAppError(err, { transport: 'ipc', channel, source: 'app:request' }); - } - } - } - - try { - return await window.electron.ipcRenderer.invoke(channel, ...args) as T; - } catch (err) { - throw normalizeAppError(err, { transport: 'ipc', channel, source: 'legacy-ipc' }); - } -} - -async function invokeViaTransport(kind: TransportKind, channel: string, args: unknown[]): Promise { - if (kind === 'ipc') { - return invokeViaIpc(channel, args); - } - - const invoker = customInvokers.get(kind); - if (!invoker) { - throw new TransportUnsupportedError(kind, `${kind.toUpperCase()} transport invoker is not registered`); - } - return invoker(channel, args); -} - -export function configureApiClient(next: Partial): void { - transportConfig = { - enabled: { - ...transportConfig.enabled, - ...(next.enabled ?? {}), - }, - rules: next.rules ?? transportConfig.rules, - }; -} - -export function getApiClientConfig(): ApiClientTransportConfig { - return { - enabled: { ...transportConfig.enabled }, - rules: [...transportConfig.rules], - }; -} - -export function registerTransportInvoker(kind: Exclude, invoker: TransportInvoker): void { - customInvokers.set(kind, invoker); -} - -export function unregisterTransportInvoker(kind: Exclude): void { - customInvokers.delete(kind); -} - -export function createHttpTransportInvoker(options: HttpTransportOptions): TransportInvoker { - const timeoutMs = options.timeoutMs ?? 15000; - const fetchImpl = options.fetchImpl ?? fetch; - - return async (channel: string, args: unknown[]): Promise => { - const baseUrl = await Promise.resolve(options.endpointResolver()); - if (!baseUrl) { - throw new Error('HTTP transport endpoint is empty'); - } - - const request = { channel, args }; - const built = options.buildRequest?.(request); - const url = built?.url ?? `${baseUrl.replace(/\/$/, '')}/rpc`; - const method = built?.method ?? 'POST'; - const headers = { - 'Content-Type': 'application/json', - ...(options.headers ?? {}), - ...(built?.headers ?? {}), - }; - const body = built?.body ?? JSON.stringify(request); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetchImpl(url, { - method, - headers, - body, - signal: controller.signal, - }); - const parsed = options.parseResponse - ? await options.parseResponse(response) - : await response.json() as NormalizedTransportResponse; - - if (!parsed?.ok) { - throw new Error(String(parsed?.error ?? 'HTTP transport request failed')); - } - return parsed.data as T; - } finally { - clearTimeout(timer); - } - }; -} - -export function createWsTransportInvoker(options: WsTransportOptions): TransportInvoker { - const timeoutMs = options.timeoutMs ?? 15000; - const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url)); - let socket: WebSocket | null = null; - let connectPromise: Promise | null = null; - const pending = new Map void; reject: (reason?: unknown) => void; timer: ReturnType }>(); - - const clearPending = (error: Error) => { - for (const [id, item] of pending.entries()) { - clearTimeout(item.timer); - item.reject(error); - pending.delete(id); - } - }; - - const ensureConnection = async (): Promise => { - if (socket && socket.readyState === WebSocket.OPEN) { - return socket; - } - if (connectPromise) { - return connectPromise; - } - - connectPromise = (async () => { - const url = await Promise.resolve(options.urlResolver()); - if (!url) { - throw new Error('WS transport URL is empty'); - } - const ws = websocketFactory(url); - - return await new Promise((resolve, reject) => { - const cleanup = () => { - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); - }; - const onOpen = () => { - cleanup(); - resolve(ws); - }; - const onError = (event: Event) => { - cleanup(); - reject(new Error(`WS transport connection failed: ${String(event.type)}`)); - }; - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); - }); - })(); - - try { - socket = await connectPromise; - socket.addEventListener('message', (event) => { - try { - const raw = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; - const parsed = options.parseMessage - ? options.parseMessage(raw) - : (raw as { id?: string; ok: boolean; data?: unknown; error?: unknown }); - - if (!parsed?.id) return; - const item = pending.get(parsed.id); - if (!item) return; - - clearTimeout(item.timer); - pending.delete(parsed.id); - - if (parsed.ok) { - item.resolve(parsed.data); - } else { - item.reject(new Error(String(parsed.error ?? 'WS transport request failed'))); - } - } catch { - // ignore malformed event payloads - } - }); - socket.addEventListener('close', () => { - socket = null; - clearPending(new Error('WS transport closed')); - }); - socket.addEventListener('error', () => { - socket = null; - }); - return socket; - } finally { - connectPromise = null; - } - }; - - return async (channel: string, args: unknown[]): Promise => { - const ws = await ensureConnection(); - const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const message = options.buildMessage - ? options.buildMessage(requestId, { channel, args }) - : { id: requestId, channel, args }; - - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(requestId); - reject(new Error('WS transport timeout')); - }, timeoutMs); - - pending.set(requestId, { - resolve: (value) => resolve(value as T), - reject, - timer, - }); - - try { - ws.send(JSON.stringify(message)); - } catch (err) { - clearTimeout(timer); - pending.delete(requestId); - reject(err); - } - }); - }; -} - -export function createGatewayHttpTransportInvoker( - _endpointResolver: () => Promise | string = resolveDefaultGatewayHttpBaseUrl, -): TransportInvoker { - return async (channel: string, args: unknown[]): Promise => { - if (channel !== 'gateway:rpc') { - throw new Error(`HTTP gateway transport does not support channel: ${channel}`); - } - const [method, params, timeoutOverride] = args; - if (typeof method !== 'string') { - throw new Error('gateway:rpc requires method string'); - } - validateGatewayRpcParams(method, params); - - const timeoutMs = - typeof timeoutOverride === 'number' && timeoutOverride > 0 - ? timeoutOverride - : 15000; - - const response = await invokeViaIpc<{ - ok?: boolean; - data?: unknown; - error?: unknown; - success?: boolean; - status?: number; - json?: unknown; - text?: string; - }>('gateway:httpProxy', [{ - path: '/rpc', - method: 'POST', - timeoutMs, - body: { - type: 'req', - method, - params, - }, - }]); - - 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) { - 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; - if (!payload || typeof payload !== 'object') { - throw new Error(response?.text || `Gateway HTTP returned non-JSON (status=${response?.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; - }; -} - -export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptions = {}): TransportInvoker { - const timeoutMs = options.timeoutMs ?? 15000; - const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url)); - const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl; - 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; - let handshakeDone = false; - let connectRequestId: string | null = null; - - const pending = new Map void; - reject: (reason?: unknown) => void; - timer: ReturnType; - }>(); - - const clearPending = (error: Error) => { - for (const [id, item] of pending.entries()) { - clearTimeout(item.timer); - item.reject(error); - pending.delete(id); - } - }; - - 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, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'openclaw-control-ui', - displayName: 'ClawX UI', - version: '1.0.0', - platform: window.electron?.platform ?? 'unknown', - mode: 'webchat', - }, - auth, - caps: [], - role: 'operator', - scopes: ['operator.admin'], - userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', - locale: typeof navigator !== 'undefined' ? navigator.language : 'en', - }, - })); - }; - - const ensureConnection = async (): Promise => { - if (socket && socket.readyState === WebSocket.OPEN && handshakeDone) { - return socket; - } - if (connectPromise) { - return connectPromise; - } - - connectPromise = (async () => { - const url = await Promise.resolve(resolveUrl()); - if (!url) { - throw new Error('Gateway WS URL is empty'); - } - const ws = websocketFactory(url); - socket = ws; - - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Gateway WS connect timeout')); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timer); - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); - }; - - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error('Gateway WS open failed')); - }; - - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); - }); - - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Gateway WS handshake timeout')); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timer); - ws.removeEventListener('message', onHandshakeMessage); - }; - - const onHandshakeMessage = (event: MessageEvent) => { - try { - const msg = JSON.parse(String(event.data)) as Record; - if (msg.type === 'event' && msg.event === 'connect.challenge') { - const payload = (msg.payload ?? {}) as Record; - const nonce = typeof payload.nonce === 'string' ? payload.nonce : ''; - if (!nonce) { - cleanup(); - reject(new Error('Gateway WS challenge nonce missing')); - return; - } - void sendConnect(nonce).catch((err) => { - cleanup(); - reject(err); - }); - return; - } - - if (msg.type === 'res' && typeof msg.id === 'string' && msg.id === connectRequestId) { - const ok = msg.ok !== false && !msg.error; - if (!ok) { - cleanup(); - reject(new Error(`Gateway WS connect failed: ${formatGatewayError(msg.error)}`)); - return; - } - handshakeDone = true; - cleanup(); - resolve(); - } - } catch { - // ignore parse errors during handshake - } - }; - - ws.addEventListener('message', onHandshakeMessage); - }); - - ws.addEventListener('message', (event) => { - try { - const msg = JSON.parse(String(event.data)) as Record; - if (msg.type !== 'res' || typeof msg.id !== 'string') return; - const item = pending.get(msg.id); - if (!item) return; - - clearTimeout(item.timer); - pending.delete(msg.id); - - const ok = msg.ok !== false && !msg.error; - if (!ok) { - item.reject(new Error(formatGatewayError(msg.error ?? 'Gateway WS request failed'))); - return; - } - item.resolve(normalizeGatewayRpcEnvelope(msg.payload ?? msg)); - } catch { - // ignore malformed payload - } - }); - ws.addEventListener('close', () => { - socket = null; - handshakeDone = false; - connectRequestId = null; - clearPending(new Error('Gateway WS closed')); - }); - ws.addEventListener('error', () => { - socket = null; - handshakeDone = false; - }); - - return ws; - })(); - - try { - return await connectPromise; - } finally { - connectPromise = null; - } - }; - - return async (channel: string, args: unknown[]): Promise => { - if (channel !== 'gateway:rpc') { - throw new Error(`Gateway WS transport does not support channel: ${channel}`); - } - const [method, params, timeoutOverride] = args; - if (typeof method !== 'string') { - throw new Error('gateway:rpc requires method string'); - } - validateGatewayRpcParams(method, params); - - const requestTimeoutMs = - typeof timeoutOverride === 'number' && timeoutOverride > 0 - ? timeoutOverride - : timeoutMs; - - const ws = await ensureConnection(); - const requestId = crypto.randomUUID(); - ws.send(JSON.stringify({ - type: 'req', - id: requestId, - method, - params, - })); - - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(requestId); - reject(new Error(`Gateway WS timeout: ${method}`)); - }, requestTimeoutMs); - - pending.set(requestId, { - resolve: (value) => resolve(value as T), - reject, - timer, - }); - }); - }; -} - -function validateGatewayRpcParams(method: string, params: unknown): void { - if (method !== 'config.patch') return; - if (!params || typeof params !== 'object' || Array.isArray(params)) { - throw new Error('gateway:rpc config.patch requires object params'); - } - const raw = (params as Record).raw; - if (typeof raw === 'string' && raw.trim()) return; - const patch = (params as Record).patch; - if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { - throw new Error('gateway:rpc config.patch requires raw string or object patch'); - } -} - -let defaultTransportsInitialized = false; - -export function initializeDefaultTransports(): void { - if (defaultTransportsInitialized) return; - registerTransportInvoker('ws', createGatewayWsTransportInvoker()); - registerTransportInvoker('http', createGatewayHttpTransportInvoker()); - applyGatewayTransportPreference(); - defaultTransportsInitialized = true; -} - -export function toUserMessage(error: unknown): string { - const appError = error instanceof AppError ? error : normalizeAppError(error); - - switch (appError.code) { - case 'AUTH_INVALID': - return 'Authentication failed. Check API key or login session and retry.'; - case 'TIMEOUT': - return 'Request timed out. Please retry.'; - case 'RATE_LIMIT': - return 'Too many requests. Please wait and try again.'; - case 'PERMISSION': - return 'Permission denied. Check your configuration and retry.'; - case 'CHANNEL_UNAVAILABLE': - return 'Service channel unavailable. Retry after restarting the app or gateway.'; - case 'NETWORK': - return 'Network error. Please verify connectivity and retry.'; - case 'CONFIG': - return 'Configuration is invalid. Please review settings.'; - case 'GATEWAY': - return 'Gateway is unavailable. Start or restart the gateway and retry.'; - default: - return appError.message || 'Unexpected error occurred.'; - } -} - -export async function invokeApi(channel: string, ...args: unknown[]): Promise { - const requestId = crypto.randomUUID(); - const order = resolveTransportOrder(channel); - let lastError: unknown; - - for (let i = 0; i < order.length; i += 1) { - const kind = order[i]; - const attempt = i + 1; - const startedAt = Date.now(); - try { - 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; - } - lastError = err; - // 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 normalizeAppError(err, { - requestId, - channel, - transport: kind, - attempt, - durationMs, - }); - } - } - - trackUiEvent('api.request_failed', { - requestId, - channel, - attempts: order.length, - message: lastError instanceof Error ? lastError.message : String(lastError), - }); - - throw normalizeAppError(lastError, { - requestId, - channel, - transport: 'ipc', - attempt: order.length, - }); -} - -export async function invokeIpc(channel: string, ...args: unknown[]): Promise { - return invokeApi(channel, ...args); -} - -export async function invokeIpcWithRetry( - channel: string, - args: unknown[] = [], - retries = 1, - retryable: AppErrorCode[] = ['TIMEOUT', 'NETWORK'], -): Promise { - let lastError: unknown; - - for (let i = 0; i <= retries; i += 1) { - try { - return await invokeApi(channel, ...args); - } catch (err) { - lastError = err; - if (!(err instanceof AppError) || !retryable.includes(err.code) || i === retries) { - throw err; - } - } - } - - throw normalizeAppError(lastError); -} - -// ── File preview wrappers ───────────────────────────────────────────── -// -// Thin typed wrappers over the sandboxed file:* IPC channels exposed by -// the main process. Callers stay free of `invokeIpc('file:readText', ...)` -// boilerplate and get exhaustive error codes. - -export type FilePreviewError = - | 'outsideSandbox' - | 'readOnlyRoot' - | 'tooLarge' - | 'binary' - | 'notFound' - | 'notDirectory' - | 'invalidContent' - | string; - -export interface ReadTextFileResult { - ok: boolean; - content?: string; - mimeType?: string; - size?: number; - /** - * Set by the main process when the resolved path lives in a read-only - * root (bundled skill, app resources, …). The renderer should disable - * editing affordances when this is true even if the caller passes - * `readOnly={false}`. - */ - readOnly?: boolean; - error?: FilePreviewError; -} - -export interface ReadBinaryFileResult { - ok: boolean; - data?: Uint8Array; - mimeType?: string; - size?: number; - readOnly?: boolean; - error?: FilePreviewError; -} - -export interface ReadBinaryFileOptions { - /** Optional override for the per-call ceiling (capped by the main-process limit). */ - maxBytes?: number; -} - -export interface WriteTextFileResult { - ok: boolean; - error?: FilePreviewError; -} - -export interface StatFileResult { - ok: boolean; - size?: number; - mtime?: number; - isFile?: boolean; - isDir?: boolean; - readOnly?: boolean; - error?: FilePreviewError; -} - -export interface ListDirEntry { - name: string; - path: string; - isDir: boolean; - size: number; -} - -export interface ListDirResult { - ok: boolean; - entries?: ListDirEntry[]; - error?: FilePreviewError; -} - -export interface TreeNode { - name: string; - relPath: string; - absPath: string; - isDir: boolean; - size?: number; - mtime?: number; - children?: TreeNode[]; -} - -export interface ListTreeOptions { - maxDepth?: number; - maxNodes?: number; - includeHidden?: boolean; -} - -export interface ListTreeResult { - ok: boolean; - root?: TreeNode; - truncated?: boolean; - error?: FilePreviewError; -} - -export const readTextFile = (path: string): Promise => - invokeIpc('file:readText', path); - -export const readBinaryFile = ( - path: string, - opts?: ReadBinaryFileOptions, -): Promise => - invokeIpc('file:readBinary', path, opts); - -export const writeTextFile = (path: string, content: string): Promise => - invokeIpc('file:writeText', path, content); - -export const statFile = (path: string): Promise => - invokeIpc('file:stat', path); - -export const listDir = (path: string): Promise => - invokeIpc('file:listDir', path); - -export const listTree = (path: string, opts?: ListTreeOptions): Promise => - invokeIpc('file:listTree', path, opts); diff --git a/src/lib/cron-session-history.ts b/src/lib/cron-session-history.ts new file mode 100644 index 00000000..ec324409 --- /dev/null +++ b/src/lib/cron-session-history.ts @@ -0,0 +1,7 @@ +import { hostApi } from '@/lib/host-api'; +import type { RawMessage } from '@/stores/chat/types'; + +export async function fetchCronSessionHistory(sessionKey: string, limit = 200): Promise { + const response = await hostApi.cron.sessionHistory({ sessionKey, limit }); + return Array.isArray(response.messages) ? response.messages : []; +} diff --git a/src/lib/error-message.ts b/src/lib/error-message.ts new file mode 100644 index 00000000..6dded4ce --- /dev/null +++ b/src/lib/error-message.ts @@ -0,0 +1,28 @@ +import { AppError, normalizeAppError } from './error-model'; + +export { AppError } from './error-model'; + +export function toUserMessage(error: unknown): string { + const appError = error instanceof AppError ? error : normalizeAppError(error); + + switch (appError.code) { + case 'AUTH_INVALID': + return 'Authentication failed. Check API key or login session and retry.'; + case 'TIMEOUT': + return 'Request timed out. Please retry.'; + case 'RATE_LIMIT': + return 'Too many requests. Please wait and try again.'; + case 'PERMISSION': + return 'Permission denied. Check your configuration and retry.'; + case 'CHANNEL_UNAVAILABLE': + return 'Service channel unavailable. Retry after restarting the app or gateway.'; + case 'NETWORK': + return 'Network error. Please verify connectivity and retry.'; + case 'CONFIG': + return 'Configuration is invalid. Please review settings.'; + case 'GATEWAY': + return 'Gateway is unavailable. Start or restart the gateway and retry.'; + default: + return appError.message || 'Unexpected error occurred.'; + } +} diff --git a/src/lib/file-preview-client.ts b/src/lib/file-preview-client.ts new file mode 100644 index 00000000..7b4b270f --- /dev/null +++ b/src/lib/file-preview-client.ts @@ -0,0 +1,32 @@ +import type { + FilePreviewTreeOptions, + FileReadBinaryOptions, +} from '@shared/host-api/contract'; +import { hostApi } from './host-api'; + +export type { + FileListDirEntry as ListDirEntry, + FileListDirResult as ListDirResult, + FilePreviewError, + FilePreviewTreeNode as TreeNode, + FilePreviewTreeOptions as ListTreeOptions, + FileReadBinaryOptions as ReadBinaryFileOptions, + FileListTreeResult as ListTreeResult, + ReadBinaryFileResult, + ReadTextFileResult, + StatFileResult, + WriteTextFileResult, +} from '@shared/host-api/contract'; + +export const readTextFile = (path: string) => hostApi.files.readText(path); +export const readBinaryFile = ( + path: string, + opts?: FileReadBinaryOptions, +) => hostApi.files.readBinary(path, opts); +export const writeTextFile = (path: string, content: string) => hostApi.files.writeText(path, content); +export const statFile = (path: string) => hostApi.files.stat(path); +export const listDir = (path: string) => hostApi.files.listDir(path); +export const listTree = ( + path: string, + opts?: FilePreviewTreeOptions, +) => hostApi.files.listTree(path, opts); diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts deleted file mode 100644 index 021164d1..00000000 --- a/src/lib/gateway-client.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { hostApiFetch } from './host-api'; - -type GatewayInfo = { - wsUrl: string; - token: string; - port: number; -}; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timeout: ReturnType; -}; - -type GatewayEventHandler = (payload: unknown) => void; - -class GatewayBrowserClient { - private ws: WebSocket | null = null; - private connectPromise: Promise | null = null; - private gatewayInfo: GatewayInfo | null = null; - private pendingRequests = new Map(); - private eventHandlers = new Map>(); - - async connect(): Promise { - if (this.ws?.readyState === WebSocket.OPEN) { - return; - } - if (this.connectPromise) { - await this.connectPromise; - return; - } - - this.connectPromise = this.openSocket(); - try { - await this.connectPromise; - } finally { - this.connectPromise = null; - } - } - - disconnect(): void { - if (this.ws) { - this.ws.close(); - this.ws = null; - } - for (const [, request] of this.pendingRequests) { - clearTimeout(request.timeout); - request.reject(new Error('Gateway connection closed')); - } - this.pendingRequests.clear(); - } - - async rpc(method: string, params?: unknown, timeoutMs = 30000): Promise { - await this.connect(); - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error('Gateway socket is not connected'); - } - - const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - const request = { - type: 'req', - id, - method, - params, - }; - - return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`Gateway RPC timeout: ${method}`)); - }, timeoutMs); - - this.pendingRequests.set(id, { - resolve: resolve as (value: unknown) => void, - reject, - timeout, - }); - this.ws!.send(JSON.stringify(request)); - }); - } - - on(eventName: string, handler: GatewayEventHandler): () => void { - const handlers = this.eventHandlers.get(eventName) || new Set(); - handlers.add(handler); - this.eventHandlers.set(eventName, handlers); - - return () => { - const current = this.eventHandlers.get(eventName); - current?.delete(handler); - if (current && current.size === 0) { - this.eventHandlers.delete(eventName); - } - }; - } - - private async openSocket(): Promise { - this.gatewayInfo = await hostApiFetch('/api/app/gateway-info'); - - await new Promise((resolve, reject) => { - const ws = new WebSocket(this.gatewayInfo!.wsUrl); - let resolved = false; - let challengeTimer: ReturnType | null = null; - - const cleanup = () => { - if (challengeTimer) { - clearTimeout(challengeTimer); - challengeTimer = null; - } - }; - - const resolveOnce = () => { - if (!resolved) { - resolved = true; - cleanup(); - resolve(); - } - }; - - const rejectOnce = (error: Error) => { - if (!resolved) { - resolved = true; - cleanup(); - reject(error); - } - }; - - ws.onopen = () => { - challengeTimer = setTimeout(() => { - rejectOnce(new Error('Gateway connect challenge timeout')); - ws.close(); - }, 10000); - }; - - ws.onmessage = (event) => { - try { - const message = JSON.parse(String(event.data)) as Record; - if (message.type === 'event' && message.event === 'connect.challenge') { - const nonce = (message.payload as { nonce?: string } | undefined)?.nonce; - if (!nonce) { - rejectOnce(new Error('Gateway connect.challenge missing nonce')); - return; - } - const connectFrame = { - type: 'req', - id: `connect-${Date.now()}`, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'gateway-client', - displayName: 'ClawX', - version: '0.1.0', - platform: navigator.platform, - mode: 'ui', - }, - auth: { - token: this.gatewayInfo?.token, - }, - caps: [], - role: 'operator', - scopes: ['operator.admin'], - }, - }; - ws.send(JSON.stringify(connectFrame)); - return; - } - - if (message.type === 'res' && typeof message.id === 'string') { - if (String(message.id).startsWith('connect-')) { - this.ws = ws; - resolveOnce(); - return; - } - - const pending = this.pendingRequests.get(message.id); - if (!pending) { - return; - } - clearTimeout(pending.timeout); - this.pendingRequests.delete(message.id); - if (message.ok === false || message.error) { - const errorMessage = typeof message.error === 'object' && message.error !== null - ? String((message.error as { message?: string }).message || JSON.stringify(message.error)) - : String(message.error || 'Gateway request failed'); - pending.reject(new Error(errorMessage)); - } else { - pending.resolve(message.payload); - } - return; - } - - if (message.type === 'event' && typeof message.event === 'string') { - this.emitEvent(message.event, message.payload); - return; - } - - if (typeof message.method === 'string') { - this.emitEvent(message.method, message.params); - } - } catch (error) { - rejectOnce(error instanceof Error ? error : new Error(String(error))); - } - }; - - ws.onerror = () => { - rejectOnce(new Error('Gateway WebSocket error')); - }; - - ws.onclose = () => { - this.ws = null; - if (!resolved) { - rejectOnce(new Error('Gateway WebSocket closed before connect')); - return; - } - for (const [, request] of this.pendingRequests) { - clearTimeout(request.timeout); - request.reject(new Error('Gateway connection closed')); - } - this.pendingRequests.clear(); - this.emitEvent('__close__', null); - }; - }); - } - - private emitEvent(eventName: string, payload: unknown): void { - const handlers = this.eventHandlers.get(eventName); - if (!handlers) return; - for (const handler of handlers) { - try { - handler(payload); - } catch { - // ignore handler failures - } - } - } -} - -export const gatewayClient = new GatewayBrowserClient(); diff --git a/src/lib/gateway-status.ts b/src/lib/gateway-status.ts new file mode 100644 index 00000000..186ba8fc --- /dev/null +++ b/src/lib/gateway-status.ts @@ -0,0 +1,11 @@ +import type { GatewayStatus } from '@/types/gateway'; + +export function isGatewayRestarting(status: GatewayStatus): boolean { + return status.state === 'starting' + || status.state === 'reconnecting' + || (status.state === 'running' && status.gatewayReady === false); +} + +export function isGatewayStopped(status: GatewayStatus): boolean { + return status.state === 'stopped' || status.state === 'error'; +} diff --git a/src/lib/host-api-client.ts b/src/lib/host-api-client.ts new file mode 100644 index 00000000..36585dc0 --- /dev/null +++ b/src/lib/host-api-client.ts @@ -0,0 +1,42 @@ +import type { + HostApiAction, + HostApiModule, + HostApiPayloadArgs, + HostApiResult, +} from '@shared/host-api/contract'; +import type { TypedHostRequest } from '@shared/host-api/types'; + +function createRequestId(): string { + return crypto.randomUUID(); +} + +export async function invokeHost< + M extends HostApiModule, + A extends HostApiAction, +>( + module: M, + action: A, + ...payloadArgs: HostApiPayloadArgs +): Promise> { + const bridge = window.clawx?.hostInvoke; + if (!bridge) { + throw new Error('Host invoke bridge is unavailable'); + } + + const request: TypedHostRequest = { + id: createRequestId(), + module, + action, + }; + if (payloadArgs.length > 0) { + request.payload = payloadArgs[0]; + } + + const response = await bridge>(request); + + if (!response.ok) { + throw new Error(response.error?.message || `Host request failed: ${module}.${action}`); + } + + return response.data; +} diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 1ffd7887..c4837de1 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -1,231 +1,338 @@ -import { invokeIpc } from '@/lib/api-client'; -import { trackUiEvent } from './telemetry'; -import { normalizeAppError } from './error-model'; +import type { + AgentCreatePayload, + AgentUpdatePayload, + ChannelAccountsPayload, + ChannelSaveConfigPayload, + ChannelTargetsPayload, + ChatSendWithMediaPayload, + ClawHubSearchPayload, + CronSessionHistoryPayload, + DialogMessagePayload, + DialogOpenPayload, + FilePreviewTreeOptions, + FileReadBinaryOptions, + ImageGenerationSettingsPayload, + MediaThumbnailEntry, + OpenClawDoctorMode, + OpenClawDoctorResult, + ProviderAccount, + ProviderConfig, + ProviderOAuthRequestPayload, + ProviderUpdateWithKeyPayload, + ProviderValidationPayload, + SaveImagePayload, + SettingsKey, + SettingsSnapshot, + SettingsValue, + ShellOpenExternalPayload, + ShellPathPayload, + SkillQuickAccessPayload, + SkillUpdateConfigPayload, + SkillUpdatePayload, + UpdateChannel, +} from '@shared/host-api/contract'; +import type { CronJobCreateInput, CronJobUpdateInput } from '@shared/types/cron'; +import { invokeHost } from './host-api-client'; -const HOST_API_PORT = 13210; -const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; +export type { + ChannelAccountsResult, + ChannelCredentialValidationResult, + ChannelFormValuesResult, + ChannelGroupItem, + ChannelSaveConfigResult, + ChannelTargetOption, + ChannelTargetsResult, + ChatSendWithMediaResult, + ClawHubInstalledSkill, + ClawHubListResult, + ClawHubSearchResult, + CronSessionHistoryResult, + DeliveryChannelAccount, + DeliveryChannelGroup, + DeliveryTargetsResult, + GatewayHealthSummary, + ImageGenerationProvidersResult, + ImageGenerationSettingsResult, + LocalSkillsResult, + LogContentResult, + LogDirResult, + OpenClawCliCommandResult, + OpenClawDoctorResult, + OpenClawStatusResult, + ProviderAccountKeyInfo, + ProviderDefaultAccountResult, + ProviderValidationResult, + SessionHistoryResult, + SessionLabelSummary, + SessionSummariesResult, + SettingsResetResult, + SettingsSnapshot, + SkillConfigsResult, + SkillsStatusResult, + StagedFileResult, + UsageHistoryEntry, +} from '@shared/host-api/contract'; -/** Cached Host API auth token, fetched once from the main process via IPC. */ -let cachedHostApiToken: string | null = null; - -async function getHostApiToken(): Promise { - if (cachedHostApiToken) return cachedHostApiToken; - try { - cachedHostApiToken = await invokeIpc('hostapi:token'); - } catch { - cachedHostApiToken = ''; - } - return cachedHostApiToken ?? ''; -} - -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; +export const hostApi = { + app: { + openClawDoctor: async (mode: OpenClawDoctorMode): Promise => ({ + ...(await invokeHost('app', 'openClawDoctor', { mode })), + mode, + }), + }, + openclaw: { + status: () => invokeHost('openclaw', 'status'), + getSkillsDir: () => invokeHost('openclaw', 'getSkillsDir'), + getCliCommand: () => invokeHost('openclaw', 'getCliCommand'), + }, + shell: { + openExternal: (url: string) => invokeHost('shell', 'openExternal', { url } satisfies ShellOpenExternalPayload), + showItemInFolder: (path: string) => invokeHost('shell', 'showItemInFolder', { path } satisfies ShellPathPayload), + openPath: (path: string) => invokeHost('shell', 'openPath', { path } satisfies ShellPathPayload), + }, + dialog: { + open: (input: DialogOpenPayload) => invokeHost('dialog', 'open', input), + message: (input: DialogMessagePayload) => invokeHost('dialog', 'message', input), + }, + window: { + syncTrafficLightPosition: (sidebarCollapsed: boolean) => ( + invokeHost('window', 'syncTrafficLightPosition', { sidebarCollapsed }) + ), + minimize: () => invokeHost('window', 'minimize'), + maximize: () => invokeHost('window', 'maximize'), + close: () => invokeHost('window', 'close'), + isMaximized: () => invokeHost('window', 'isMaximized'), + }, + updates: { + status: () => invokeHost('updates', 'status'), + version: () => invokeHost('updates', 'version'), + check: () => invokeHost('updates', 'check'), + download: () => invokeHost('updates', 'download'), + install: () => invokeHost('updates', 'install'), + setChannel: (channel: UpdateChannel) => invokeHost('updates', 'setChannel', { channel }), + setAutoDownload: (enable: boolean) => invokeHost('updates', 'setAutoDownload', { enable }), + cancelAutoInstall: () => invokeHost('updates', 'cancelAutoInstall'), + }, + uv: { + installAll: () => invokeHost('uv', 'installAll'), + }, + settings: { + getAll: () => invokeHost('settings', 'getAll'), + get: (key: SettingsKey) => invokeHost('settings', 'get', { key }), + set: (key: SettingsKey, value: SettingsValue) => invokeHost('settings', 'set', { key, value }), + setMany: (patch: Partial) => ( + invokeHost('settings', 'setMany', { patch }) + ), + reset: () => invokeHost('settings', 'reset'), + }, + gateway: { + status: () => invokeHost('gateway', 'status'), + start: () => invokeHost('gateway', 'start'), + stop: () => invokeHost('gateway', 'stop'), + restart: () => invokeHost('gateway', 'restart'), + health: (probe = false) => invokeHost('gateway', 'health', { probe }), + controlUi: (view?: 'dreams') => invokeHost('gateway', 'controlUi', { view }), + rpc: (method: string, params?: unknown, timeoutMs?: number) => ( + invokeHost('gateway', 'rpc', { method, params, timeoutMs }) as Promise + ), + }, + logs: { + recent: (tailLines = 100) => invokeHost('logs', 'recent', { tailLines }), + dir: () => invokeHost('logs', 'dir'), + listFiles: () => invokeHost('logs', 'listFiles'), + readFile: (path: string, tailLines?: number) => ( + invokeHost('logs', 'readFile', { path, tailLines }) + ), + }, + channels: { + accounts: (options?: ChannelAccountsPayload) => ( + invokeHost('channels', 'accounts', options) + ), + targets: (input: ChannelTargetsPayload) => ( + invokeHost('channels', 'targets', input) + ), + configured: () => invokeHost('channels', 'configured'), + formValues: (channelType: string, accountId?: string) => ( + invokeHost('channels', 'formValues', { channelType, accountId }) + ), + saveConfig: (input: ChannelSaveConfigPayload) => invokeHost('channels', 'saveConfig', input), + deleteConfig: (channelType: string, accountId?: string) => ( + invokeHost('channels', 'deleteConfig', { channelType, accountId }) + ), + validateCredentials: (channelType: string, config: Record) => ( + invokeHost('channels', 'validateCredentials', { channelType, config }) + ), + saveBinding: (input: { channelType: string; accountId: string; agentId: string }) => ( + invokeHost('channels', 'bindingSave', input) + ), + deleteBinding: (input: { channelType: string; accountId?: string }) => ( + invokeHost('channels', 'bindingDelete', input) + ), + startLogin: (channelType: string, input?: { accountId?: string }) => ( + invokeHost('channels', 'startLogin', { channelType, ...input }) + ), + cancelLogin: (channelType: string, input?: { accountId?: string }) => ( + invokeHost('channels', 'cancelLogin', { channelType, ...input }) + ), + }, + agents: { + list: () => invokeHost('agents', 'list'), + create: (input: AgentCreatePayload) => invokeHost('agents', 'create', input), + update: (id: string, input: Omit) => ( + invokeHost('agents', 'update', { + id, + ...input, + }) + ), + updateModel: (id: string, modelRef: string | null) => ( + invokeHost('agents', 'updateModel', { id, modelRef }) + ), + delete: (id: string) => invokeHost('agents', 'delete', { id }), + assignChannel: (id: string, channelType: string) => ( + invokeHost('agents', 'assignChannel', { id, channelType }) + ), + removeChannel: (id: string, channelType: string) => ( + invokeHost('agents', 'removeChannel', { id, channelType }) + ), + }, + diagnostics: { + gatewaySnapshot: () => invokeHost('diagnostics', 'gatewaySnapshot'), + }, + providers: { + list: () => invokeHost('providers', 'list'), + get: (providerId: string) => invokeHost('providers', 'get', { providerId }), + getDefault: () => invokeHost('providers', 'getDefault'), + hasApiKey: (providerId: string) => ( + invokeHost('providers', 'hasApiKey', { providerId }) + ), + getApiKey: (providerId: string) => ( + invokeHost('providers', 'getApiKey', { providerId }) + ), + validateKey: (input: ProviderValidationPayload) => invokeHost('providers', 'validateKey', input), + save: (input: { config: ProviderConfig; apiKey?: string }) => invokeHost('providers', 'save', input), + delete: (providerId: string) => invokeHost('providers', 'delete', { providerId }), + setApiKey: (providerId: string, apiKey: string) => ( + invokeHost('providers', 'setApiKey', { providerId, apiKey }) + ), + updateWithKey: (input: ProviderUpdateWithKeyPayload) => invokeHost('providers', 'updateWithKey', input), + deleteApiKey: (providerId: string) => ( + invokeHost('providers', 'deleteApiKey', { providerId }) + ), + setDefault: (providerId: string) => ( + invokeHost('providers', 'setDefault', { providerId }) + ), + accounts: () => invokeHost('providers', 'accounts'), + vendors: () => invokeHost('providers', 'vendors'), + accountKeyInfo: () => invokeHost('providers', 'accountKeyInfo'), + getDefaultAccount: () => invokeHost('providers', 'getDefaultAccount'), + getAccount: (accountId: string) => ( + invokeHost('providers', 'getAccount', { accountId }) + ), + getAccountApiKey: (accountId: string) => ( + invokeHost('providers', 'getAccountApiKey', { accountId }) + ), + hasAccountApiKey: (accountId: string) => ( + invokeHost('providers', 'hasAccountApiKey', { accountId }) + ), + createAccount: (input: { account: ProviderAccount; apiKey?: string }) => ( + invokeHost('providers', 'createAccount', input) + ), + updateAccount: (accountId: string, updates: Partial, apiKey?: string) => ( + invokeHost('providers', 'updateAccount', { accountId, updates, apiKey }) + ), + deleteAccount: (accountId: string) => ( + invokeHost('providers', 'deleteAccount', { accountId }) + ), + deleteAccountApiKey: (accountId: string) => ( + invokeHost('providers', 'deleteAccountApiKey', { accountId }) + ), + setDefaultAccount: (accountId: string) => ( + invokeHost('providers', 'setDefaultAccount', { accountId }) + ), + requestOAuth: (input: ProviderOAuthRequestPayload) => invokeHost('providers', 'requestOAuth', input), + cancelOAuth: () => invokeHost('providers', 'cancelOAuth'), + submitOAuth: (input: { code: string }) => invokeHost('providers', 'submitOAuth', input), + }, + files: { + stagePaths: (input: { filePaths: string[] }) => invokeHost('files', 'stagePaths', input), + stageBuffer: (input: { base64: string; fileName: string; mimeType?: string }) => ( + invokeHost('files', 'stageBuffer', input) + ), + readText: (path: string) => invokeHost('files', 'readText', { path }), + readBinary: (path: string, opts?: FileReadBinaryOptions) => ( + invokeHost('files', 'readBinary', { path, opts }) + ), + writeText: (path: string, content: string) => ( + invokeHost('files', 'writeText', { path, content }) + ), + stat: (path: string) => invokeHost('files', 'stat', { path }), + listDir: (path: string) => invokeHost('files', 'listDir', { path }), + listTree: (path: string, opts?: FilePreviewTreeOptions) => ( + invokeHost('files', 'listTree', { path, opts }) + ), + }, + media: { + thumbnails: (input: { paths: MediaThumbnailEntry[] }) => invokeHost('media', 'thumbnails', input), + saveImage: (input: SaveImagePayload) => invokeHost('media', 'saveImage', input), + imageGenerationSettings: () => invokeHost('media', 'imageGenerationSettings'), + saveImageGenerationSettings: (input: ImageGenerationSettingsPayload) => ( + invokeHost('media', 'saveImageGenerationSettings', input) + ), + imageGenerationProviders: () => invokeHost('media', 'imageGenerationProviders'), + testImageGeneration: (input: { agentId?: string; prompt?: string; model?: string }) => ( + invokeHost('media', 'testImageGeneration', input) + ), + }, + sessions: { + delete: (id: string) => invokeHost('sessions', 'delete', { id }), + rename: (id: string, title: string) => ( + invokeHost('sessions', 'rename', { id, title }) + ), + summaries: (input?: { sessionKeys?: string[]; limit?: number }) => invokeHost('sessions', 'summaries', input), + history: (input: { sessionKey?: string; agentId?: string; sessionId?: string; limit?: number }) => ( + invokeHost('sessions', 'history', input) + ), + }, + chat: { + sendWithMedia: (input: ChatSendWithMediaPayload) => invokeHost('chat', 'sendWithMedia', input), + }, + cron: { + list: () => invokeHost('cron', 'list'), + create: (input: CronJobCreateInput) => invokeHost('cron', 'create', input), + update: (id: string, input: CronJobUpdateInput) => invokeHost('cron', 'update', { id, input }), + delete: (id: string) => invokeHost('cron', 'delete', { id }), + toggle: (id: string, enabled: boolean) => invokeHost('cron', 'toggle', { id, enabled }), + trigger: (id: string) => invokeHost('cron', 'trigger', { id }), + sessionHistory: (input: CronSessionHistoryPayload) => invokeHost('cron', 'sessionHistory', input), + deliveryTargets: () => invokeHost('cron', 'deliveryTargets'), + }, + skills: { + local: () => invokeHost('skills', 'local'), + configs: () => invokeHost('skills', 'configs'), + allConfigs: () => invokeHost('skills', 'allConfigs'), + getConfig: (skillKey: string) => invokeHost('skills', 'getConfig', { skillKey }), + updateConfig: (input: SkillUpdateConfigPayload) => invokeHost('skills', 'updateConfig', input), + updateConfigs: (updates: SkillUpdateConfigPayload[]) => invokeHost('skills', 'updateConfigs', { updates }), + status: () => invokeHost('skills', 'status'), + update: (input: SkillUpdatePayload) => invokeHost('skills', 'update', input), + quickAccess: (input: SkillQuickAccessPayload) => invokeHost('skills', 'quickAccess', input), + clawhubCapability: () => invokeHost('skills', 'clawhubCapability'), + clawhubList: () => invokeHost('skills', 'clawhubList'), + clawhubSearch: (input: ClawHubSearchPayload) => invokeHost('skills', 'clawhubSearch', input), + clawhubInstall: (input: { slug: string; version?: string }) => invokeHost('skills', 'clawhubInstall', input), + clawhubUninstall: (input: { slug: string }) => invokeHost('skills', 'clawhubUninstall', input), + clawhubOpenSkillReadme: (input: { skillKey?: string; slug?: string; baseDir?: string }) => ( + invokeHost('skills', 'clawhubOpenSkillReadme', input) + ), + clawhubOpenSkillPath: (input: { skillKey?: string; slug?: string; baseDir?: string }) => ( + invokeHost('skills', 'clawhubOpenSkillPath', input) + ), + }, + usage: { + recentTokenHistory: (limit?: number) => ( + invokeHost('usage', 'recentTokenHistory', { limit }) + ), + }, }; -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}`; - try { - const payload = await response.json() as { error?: string }; - if (payload?.error) { - message = payload.error; - } - } catch { - // ignore body parse failure - } - throw normalizeAppError(new Error(message), { - source: 'browser-fallback', - status: response.status, - }); - } - - if (response.status === 204) { - return undefined as T; - } - - 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 { - const normalized = message.toLowerCase(); - return normalized.includes('invalid ipc channel: hostapi:fetch') - || normalized.includes("no handler registered for 'hostapi:fetch'") - || normalized.includes('no handler registered for "hostapi:fetch"') - || normalized.includes('no handler registered for hostapi:fetch') - || normalized.includes('window is not defined'); -} - -function allowLocalhostFallback(): boolean { - try { - return window.localStorage.getItem('clawx:allow-localhost-fallback') === '1'; - } catch { - return false; - } -} - -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 normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method }); - const message = normalized.message; - trackUiEvent('hostapi.fetch_error', { - path, - method, - source: 'ipc-proxy', - durationMs: Date.now() - startedAt, - message, - code: normalized.code, - }); - if (!shouldFallbackToBrowser(message)) { - throw normalized; - } - if (!allowLocalhostFallback()) { - trackUiEvent('hostapi.fetch_error', { - path, - method, - source: 'ipc-proxy', - durationMs: Date.now() - startedAt, - message: 'localhost fallback blocked by policy', - code: 'CHANNEL_UNAVAILABLE', - }); - throw normalized; - } - } - - // Browser-only fallback (non-Electron environments). - const token = await getHostApiToken(); - const response = await fetch(`${HOST_API_BASE}${path}`, { - ...init, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - ...(init?.headers || {}), - }, - }); - trackUiEvent('hostapi.fetch', { - path, - method, - source: 'browser-fallback', - durationMs: Date.now() - startedAt, - status: response.status, - }); - try { - return await parseResponse(response); - } catch (error) { - throw normalizeAppError(error, { source: 'browser-fallback', path, method }); - } -} - -export function createHostEventSource(path = '/api/events'): EventSource { - // EventSource does not support custom headers, so pass the auth token - // as a query parameter. The server accepts both mechanisms. - const separator = path.includes('?') ? '&' : '?'; - const tokenParam = `token=${encodeURIComponent(cachedHostApiToken ?? '')}`; - return new EventSource(`${HOST_API_BASE}${path}${separator}${tokenParam}`); -} - -export function getHostApiBase(): string { - return HOST_API_BASE; -} +export type HostApi = typeof hostApi; diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index d4b38cad..c1b2bdaf 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -1,80 +1,118 @@ -import { createHostEventSource } from './host-api'; +import { + buildHostChannelEventName, + HOST_EVENT_CHANNELS, + type HostEventArgs, + type HostEventHandler, + type HostEventModule, + type HostEventName, +} from '@shared/host-events/contract'; -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:health': 'gateway:health-changed', - 'gateway:presence': 'gateway:presence-changed', - 'gateway:chat-message': 'gateway:chat-message', - 'gateway:channel-status': 'gateway:channel-status', - 'chat:runtime-event': 'chat:runtime-event', - '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', - 'channel:wechat-qr': 'channel:wechat-qr', - 'channel:wechat-success': 'channel:wechat-success', - 'channel:wechat-error': 'channel:wechat-error', -}; - -function getEventSource(): EventSource { - if (!eventSource) { - eventSource = createHostEventSource(); - } - 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, +function onIpc< + M extends HostEventModule, + E extends HostEventName, +>( + channel: string, + handler: HostEventHandler, ): () => 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); - }; - // preload's `on()` wraps the callback in an internal subscription function - // and returns a cleanup function that removes that exact wrapper. We MUST - // use the returned cleanup rather than calling `off(channel, listener)`, - // because `listener` !== the internal wrapper and removeListener would be - // a no-op, leaking the subscription. - const unsubscribe = ipc.on(ipcChannel, listener); - if (typeof unsubscribe === 'function') { - return unsubscribe; - } - // Fallback for environments where on() doesn't return cleanup - return () => { - ipc.off(ipcChannel, listener); - }; - } - - if (!allowSseFallback()) { - console.warn(`[host-events] no IPC mapping for event "${eventName}", SSE fallback disabled`); + if (!ipc?.on) { + console.warn(`[host-events] IPC unavailable for ${channel}`); return () => {}; } - const source = getEventSource(); - const listener = (event: Event) => { - const payload = JSON.parse((event as MessageEvent).data) as T; - handler(payload); - }; - source.addEventListener(eventName, listener); - return () => { - source.removeEventListener(eventName, listener); - }; + const unsubscribe = ipc.on(channel, (...args: unknown[]) => { + (handler as (...eventArgs: HostEventArgs) => void)( + ...(args as HostEventArgs), + ); + }); + return typeof unsubscribe === 'function' + ? unsubscribe + : () => ipc.off?.(channel); } + +const onGatewayEvent = >( + event: E, + handler: HostEventHandler<'gateway', E>, +) => onIpc(HOST_EVENT_CHANNELS.gateway[event], handler); + +const onChatEvent = >( + event: E, + handler: HostEventHandler<'chat', E>, +) => onIpc(HOST_EVENT_CHANNELS.chat[event], handler); + +const onOAuthEvent = >( + event: E, + handler: HostEventHandler<'oauth', E>, +) => onIpc(HOST_EVENT_CHANNELS.oauth[event], handler); + +const onChannelEvent = >( + channel: string, + event: E, + handler: HostEventHandler<'channel', E>, +) => onIpc(buildHostChannelEventName(channel, event), handler); + +const onUpdateEvent = >( + event: E, + handler: HostEventHandler<'updates', E>, +) => onIpc(HOST_EVENT_CHANNELS.updates[event], handler); + +const onAppEvent = >( + event: E, + handler: HostEventHandler<'app', E>, +) => onIpc(HOST_EVENT_CHANNELS.app[event], handler); + +export const hostEvents = { + onGatewayStatus: (handler: HostEventHandler<'gateway', 'statusChanged'>) => ( + onGatewayEvent('statusChanged', handler) + ), + onGatewayMessage: (handler: HostEventHandler<'gateway', 'message'>) => ( + onGatewayEvent('message', handler) + ), + onGatewayError: (handler: HostEventHandler<'gateway', 'error'>) => ( + onGatewayEvent('error', handler) + ), + onGatewayNotification: (handler: HostEventHandler<'gateway', 'notification'>) => ( + onGatewayEvent('notification', handler) + ), + onGatewayHealth: (handler: HostEventHandler<'gateway', 'healthChanged'>) => ( + onGatewayEvent('healthChanged', handler) + ), + onGatewayPresence: (handler: HostEventHandler<'gateway', 'presenceChanged'>) => ( + onGatewayEvent('presenceChanged', handler) + ), + onGatewayChatMessage: (handler: HostEventHandler<'gateway', 'chatMessage'>) => ( + onGatewayEvent('chatMessage', handler) + ), + onGatewayChannelStatus: (handler: HostEventHandler<'gateway', 'channelStatus'>) => ( + onGatewayEvent('channelStatus', handler) + ), + onGatewayExit: (handler: HostEventHandler<'gateway', 'exit'>) => ( + onGatewayEvent('exit', handler) + ), + onChatRuntimeEvent: (handler: HostEventHandler<'chat', 'runtimeEvent'>) => ( + onChatEvent('runtimeEvent', handler) + ), + onOAuthCode: (handler: HostEventHandler<'oauth', 'code'>) => onOAuthEvent('code', handler), + onOAuthSuccess: (handler: HostEventHandler<'oauth', 'success'>) => onOAuthEvent('success', handler), + onOAuthError: (handler: HostEventHandler<'oauth', 'error'>) => onOAuthEvent('error', handler), + onChannelQr: (channel: string, handler: HostEventHandler<'channel', 'qr'>) => ( + onChannelEvent(channel, 'qr', handler) + ), + onChannelSuccess: (channel: string, handler: HostEventHandler<'channel', 'success'>) => ( + onChannelEvent(channel, 'success', handler) + ), + onChannelError: (channel: string, handler: HostEventHandler<'channel', 'error'>) => ( + onChannelEvent(channel, 'error', handler) + ), + onUpdateStatusChanged: (handler: HostEventHandler<'updates', 'statusChanged'>) => ( + onUpdateEvent('statusChanged', handler) + ), + onUpdateAutoInstallCountdown: ( + handler: HostEventHandler<'updates', 'autoInstallCountdown'>, + ) => onUpdateEvent('autoInstallCountdown', handler), + onNavigate: (handler: HostEventHandler<'app', 'navigate'>) => onAppEvent('navigate', handler), + onNewChat: (handler: HostEventHandler<'app', 'newChat'>) => onAppEvent('newChat', handler), + onOpenClawCliInstalled: ( + handler: HostEventHandler<'app', 'openClawCliInstalled'>, + ) => onAppEvent('openClawCliInstalled', handler), +}; diff --git a/src/lib/image-generation.ts b/src/lib/image-generation.ts index 8505aa7f..6c151af7 100644 --- a/src/lib/image-generation.ts +++ b/src/lib/image-generation.ts @@ -1,4 +1,4 @@ -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; export interface ImageGenerationModelConfig { primary: string | null; @@ -52,9 +52,7 @@ export interface ImageGenerationTestResult { } export async function fetchImageGenerationSettings(): Promise { - const response = await hostApiFetch<{ success: boolean } & ImageGenerationSettingsSnapshot>( - '/api/media/image-generation', - ); + const response = await hostApi.media.imageGenerationSettings(); if (response.success === false) { throw new Error('Failed to load image generation settings'); } @@ -74,14 +72,7 @@ export async function saveImageGenerationSettings(payload: { openAiRelayModel?: string | null; openAiRelayApiKey?: string; }): Promise { - const response = await hostApiFetch<{ success: boolean } & ImageGenerationSettingsSnapshot>( - '/api/media/image-generation', - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - ); + const response = await hostApi.media.saveImageGenerationSettings(payload); if (response.success === false) { throw new Error('Failed to save image generation settings'); } @@ -89,9 +80,7 @@ export async function saveImageGenerationSettings(payload: { } export async function fetchImageGenerationProviders(): Promise { - const response = await hostApiFetch<{ success: boolean; providers: ImageGenerationProviderRow[] }>( - '/api/media/image-generation/providers', - ); + const response = await hostApi.media.imageGenerationProviders(); if (response.success === false) { throw new Error('Failed to list image generation providers'); } @@ -115,11 +104,7 @@ export async function runImageGenerationTest(payload: { try { return await Promise.race([ - hostApiFetch('/api/media/image-generation/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }), + hostApi.media.testImageGeneration(payload), timeoutPromise, ]); } finally { diff --git a/src/lib/provider-accounts.ts b/src/lib/provider-accounts.ts index c2acb850..3b6348e6 100644 --- a/src/lib/provider-accounts.ts +++ b/src/lib/provider-accounts.ts @@ -1,4 +1,4 @@ -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; import type { ProviderAccount, ProviderType, @@ -35,7 +35,7 @@ export interface ProviderAccountKeyInfo { * * Equivalent to the backend's `providerAccountToConfig` + `hasKey/keyMasked` * augmentation, kept in lockstep so renderer-side derivation matches the - * legacy `/api/providers` payload. + * legacy provider payload. */ export function accountToProviderWithKeyInfo( account: ProviderAccount, @@ -61,8 +61,7 @@ export function accountToProviderWithKeyInfo( /** * Backward-compat helper for older fixtures and any external callers still - * publishing `ProviderWithKeyInfo[]` payloads via the legacy `/api/providers` - * route. + * publishing `ProviderWithKeyInfo[]` payloads. */ function fallbackStatusToAccount(status: ProviderWithKeyInfo): ProviderAccount { return { @@ -83,97 +82,24 @@ function fallbackStatusToAccount(status: ProviderWithKeyInfo): ProviderAccount { }; } -/** - * `hostApiFetch` returns the response body even on non-2xx HTTP status, so - * a 404 from the Host API surfaces as `{ success: false, error: "No route - * for GET ..." }` rather than a thrown error. Detect that shape so we can - * trigger the legacy fallback path when an older Host API build is missing - * the new account-companion routes (key-info, validate, oauth, api-key). - */ -function isRouteNotFoundBody(value: unknown): boolean { - if (!value || typeof value !== 'object') return false; - const record = value as Record; - if (record.success !== false) return false; - const error = record.error; - return typeof error === 'string' && /no\s+route\s+for/i.test(error); -} - -export function isHostApiRouteMissing(value: unknown): boolean { - return isRouteNotFoundBody(value); -} - -/** - * Detects thrown errors that look like a missing-route response (currently - * only emitted by the browser-fallback path in `host-api.ts`, which DOES - * throw on non-2xx). Returns true so callers can collapse that case into - * the same "use the legacy route" code path as the body-shape detection. - */ -function isRouteNotFoundError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return /no\s+route\s+for|404|not\s+found/i.test(error.message); -} - -/** - * Wrap `hostApiFetch` so that a missing route (either a thrown 404 or the - * `{ success: false, error: "No route ..." }` body) resolves to `null` and - * any *other* error propagates. Avoids the previous `.catch(() => null)` - * pattern which masked real failures (network outages, IPC unavailability, - * malformed payloads) and left the user staring at an empty list. - */ -async function fetchAllowingMissingRoute(path: string): Promise { - try { - const result = await hostApiFetch(path); - if (isRouteNotFoundBody(result)) { - return null; - } - return result as T; - } catch (error) { - if (isRouteNotFoundError(error)) { - return null; - } - throw error; - } -} - export async function fetchProviderSnapshot(): Promise { - // Primary path: read everything from the new /api/provider-accounts surface. - // Only the key-info call tolerates a missing route (older Host API builds - // predate it). All other endpoints have shipped for a while; if they fail, - // the snapshot fails and the store surfaces the error to the UI rather - // than presenting an empty/inconsistent provider list. const [accountsResult, keyInfoResult, vendors, defaultInfo] = await Promise.all([ - hostApiFetch('/api/provider-accounts'), - fetchAllowingMissingRoute('/api/provider-accounts/key-info'), - hostApiFetch('/api/provider-vendors'), - hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'), + hostApi.providers.accounts(), + hostApi.providers.accountKeyInfo(), + hostApi.providers.vendors(), + hostApi.providers.getDefaultAccount(), ]); let accounts = accountsResult ?? []; - let statuses: ProviderWithKeyInfo[]; + const keyInfoMap = new Map( + (keyInfoResult ?? []).map((entry) => [entry.accountId, entry] as const), + ); + let statuses = accounts.map((account) => accountToProviderWithKeyInfo(account, keyInfoMap.get(account.id))); - if (Array.isArray(keyInfoResult)) { - const keyInfoMap = new Map( - keyInfoResult.map((entry) => [entry.accountId, entry] as const), - ); - statuses = accounts.map((account) => accountToProviderWithKeyInfo(account, keyInfoMap.get(account.id))); - } else { - // ── Backward-compat fallback ──────────────────────────────────── - // Talking to an older Host API (no /api/provider-accounts/key-info - // route). Use the legacy /api/providers payload as the status source - // and synthesise accounts from it when the accounts list is empty - // (e.g. pre-migration installs). Any non-route-missing error here - // (network, IPC, parse) propagates so the UI can show it. - const legacyStatusesRaw = await fetchAllowingMissingRoute('/api/providers'); - if (legacyStatusesRaw === null) { - // Even the legacy route is missing — emit a single warn so the empty - // list isn't silently misattributed to "no providers configured". - console.warn('[provider-accounts] Both /api/provider-accounts/key-info and /api/providers are missing on this Host API; statuses will be empty.'); - } - const legacyStatuses = Array.isArray(legacyStatusesRaw) ? legacyStatusesRaw : []; - statuses = legacyStatuses; - if (accounts.length === 0 && legacyStatuses.length > 0) { - accounts = legacyStatuses.map(fallbackStatusToAccount); - } + if (accounts.length === 0) { + const legacyStatuses = await hostApi.providers.list(); + statuses = legacyStatuses ?? []; + accounts = statuses.map(fallbackStatusToAccount); } return { @@ -223,21 +149,21 @@ export function buildProviderAccountId( return vendor?.supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId; } -export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount { +export function legacyProviderToAccount(status: ProviderWithKeyInfo): ProviderAccount { return { - id: provider.id, - vendorId: provider.type, - label: provider.name, - authMode: provider.type === 'ollama' ? 'local' : 'api_key', - baseUrl: provider.baseUrl, - headers: provider.headers, - model: provider.model, - fallbackModels: provider.fallbackModels, - fallbackAccountIds: provider.fallbackProviderIds, - enabled: provider.enabled, + id: status.id, + vendorId: status.type, + label: status.name, + authMode: status.type === 'ollama' ? 'local' : 'api_key', + baseUrl: status.baseUrl, + headers: status.headers, + model: status.model, + fallbackModels: status.fallbackModels, + fallbackAccountIds: status.fallbackProviderIds, + enabled: status.enabled, isDefault: false, - createdAt: provider.createdAt, - updatedAt: provider.updatedAt, + createdAt: status.createdAt, + updatedAt: status.updatedAt, }; } diff --git a/src/lib/providers.ts b/src/lib/providers.ts index c2b06f4f..a7fea8e4 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -24,6 +24,17 @@ export const PROVIDER_TYPES = [ ] as const; export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export type ProviderProtocol = + | 'openai-completions' + | 'openai-responses' + | 'openai-codex-responses' + | 'anthropic-messages' + | 'google-generative-ai' + | 'github-copilot' + | 'bedrock-converse-stream' + | 'ollama' + | 'azure-openai-responses'; + export const BUILTIN_PROVIDER_TYPES = [ 'anthropic', 'openai', @@ -47,7 +58,7 @@ export interface ProviderConfig { name: string; type: ProviderType; baseUrl?: string; - apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + apiProtocol?: ProviderProtocol; headers?: Record; model?: string; fallbackModels?: string[]; @@ -115,7 +126,7 @@ export interface ProviderAccount { label: string; authMode: ProviderAuthMode; baseUrl?: string; - apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + apiProtocol?: ProviderProtocol; headers?: Record; model?: string; fallbackModels?: string[]; diff --git a/src/lib/quick-access-skills.ts b/src/lib/quick-access-skills.ts new file mode 100644 index 00000000..065aaf5b --- /dev/null +++ b/src/lib/quick-access-skills.ts @@ -0,0 +1,9 @@ +import { hostApi } from '@/lib/host-api'; +import type { QuickAccessSkill } from '@/types/skill'; + +export async function fetchQuickAccessSkills(input: { + workspace?: string; + agentDir?: string; +}): Promise<{ success: boolean; skills?: QuickAccessSkill[]; error?: string }> { + return hostApi.skills.quickAccess(input); +} diff --git a/src/lib/skill-files.ts b/src/lib/skill-files.ts index 8a30a4c3..91240765 100644 --- a/src/lib/skill-files.ts +++ b/src/lib/skill-files.ts @@ -6,7 +6,7 @@ * to one of four buckets so the Skills detail page can render * "Docs / Scripts / Hooks / Assets" sections. */ -import { listDir } from './api-client'; +import { listDir } from './file-preview-client'; import { basenameOf, classifyFileExt, diff --git a/src/lib/workspace-tree.ts b/src/lib/workspace-tree.ts index 2e62e1e4..3f3ab066 100644 --- a/src/lib/workspace-tree.ts +++ b/src/lib/workspace-tree.ts @@ -7,7 +7,7 @@ * so sibling configuration paths (`runs/`, `agents/`, * `auth-profiles.json`, …) under `~/.openclaw` are never exposed. */ -import { listTree, type TreeNode } from './api-client'; +import { listTree, type TreeNode } from './file-preview-client'; import { basenameOf, classifyFileExt, diff --git a/src/main.tsx b/src/main.tsx index cd5e83d8..58d9c496 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,9 +8,6 @@ import App from './App'; import './i18n'; import './styles/globals.css'; import 'katex/dist/katex.min.css'; -import { initializeDefaultTransports } from './lib/api-client'; - -initializeDefaultTransports(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 2d12c702..1bd30b1a 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -6,13 +6,14 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; import { Switch } from '@/components/ui/switch'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { useAgentsStore } from '@/stores/agents'; import { useGatewayStore } from '@/stores/gateway'; import { useProviderStore } from '@/stores/providers'; -import { hostApiFetch } from '@/lib/host-api'; -import { subscribeHostEvent } from '@/lib/host-events'; +import { hostApi, type ChannelGroupItem } from '@/lib/host-api'; +import { hostEvents } from '@/lib/host-events'; import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; import type { AgentSummary } from '@/types/agent'; import { @@ -32,23 +33,6 @@ import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; import qqIcon from '@/assets/channels/qq.svg'; -interface ChannelAccountItem { - accountId: string; - name: string; - configured: boolean; - status: 'connected' | 'connecting' | 'disconnected' | 'error'; - lastError?: string; - isDefault: boolean; - agentId?: string; -} - -interface ChannelGroupItem { - channelType: string; - defaultAccountId: string; - status: 'connected' | 'connecting' | 'disconnected' | 'error'; - accounts: ChannelAccountItem[]; -} - export function Agents() { const { t } = useTranslation('agents'); const gatewayStatus = useGatewayStore((state) => state.status); @@ -67,11 +51,12 @@ export function Agents() { const [showAddDialog, setShowAddDialog] = useState(false); const [activeAgentId, setActiveAgentId] = useState(null); + const [settingsModalAgent, setSettingsModalAgent] = useState(null); const [agentToDelete, setAgentToDelete] = useState(null); const fetchChannelAccounts = useCallback(async () => { try { - const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts'); + const response = await hostApi.channels.accounts(); setChannelGroups(response.channels || []); } catch { // Keep the last rendered snapshot when channel account refresh fails. @@ -92,7 +77,7 @@ export function Agents() { }, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]); useEffect(() => { - const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { + const unsubscribe = hostEvents.onGatewayChannelStatus(() => { void fetchChannelAccounts(); }); return () => { @@ -152,6 +137,7 @@ export function Agents() { {t('refresh')} @@ -925,19 +875,6 @@ export function Settings() {
-
-
- -

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

-
- -
-
diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index d5fc21b4..187d9aeb 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -26,8 +26,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; import { toast } from 'sonner'; -import { invokeIpc } from '@/lib/api-client'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; interface SetupStep { id: string; @@ -352,12 +351,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { // Check OpenClaw package status try { - const openclawStatus = await invokeIpc('openclaw:status') as { - packageExists: boolean; - isBuilt: boolean; - dir: string; - version?: string; - }; + const openclawStatus = await hostApi.openclaw.status(); setOpenclawDir(openclawStatus.dir); @@ -496,7 +490,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleShowLogs = async () => { try { - const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100'); + const logs = await hostApi.logs.recent(100); setLogContent(logs.content); setShowLogs(true); } catch { @@ -507,9 +501,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const handleOpenLogDir = async () => { try { - const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); + const { dir: logDir } = await hostApi.logs.dir(); if (logDir) { - await invokeIpc('shell:showItemInFolder', logDir); + await hostApi.shell.showItemInFolder(logDir); } } catch { // ignore @@ -684,10 +678,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp setOverallProgress(10); // Step 2: Call the backend to install uv and setup Python - const result = await invokeIpc('uv:install-all') as { - success: boolean; - error?: string - }; + const result = await hostApi.uv.installAll(); if (result.success) { setSkillStates(prev => prev.map(s => ({ ...s, status: 'completed' }))); diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index dd85ad75..9e74ad3e 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -23,8 +23,8 @@ import { useSkillsStore } from '@/stores/skills'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; -import { invokeIpc } from '@/lib/api-client'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; +import { isGatewayStopped } from '@/lib/gateway-status'; import { toast } from 'sonner'; import type { Skill } from '@/types/skill'; import type { GatewayStatus } from '@/types/gateway'; @@ -53,23 +53,10 @@ const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitErr const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']); const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']); -type SkillsGatewayBannerState = 'none' | 'starting' | 'stopped'; +type SkillsGatewayBannerState = 'none' | 'stopped'; -function isSkillsGatewayReady(status: GatewayStatus, skillsFeatureReady: boolean): boolean { - return status.state === 'running' && (status.gatewayReady !== false || skillsFeatureReady); -} - -function getSkillsGatewayBannerState( - status: GatewayStatus, - skillsFeatureReady: boolean, -): SkillsGatewayBannerState { - if (status.state === 'starting' || status.state === 'reconnecting') { - return 'starting'; - } - if (status.state === 'running' && !isSkillsGatewayReady(status, skillsFeatureReady)) { - return 'starting'; - } - if (status.state === 'stopped' || status.state === 'error') { +function getSkillsGatewayBannerState(status: GatewayStatus): SkillsGatewayBannerState { + if (isGatewayStopped(status)) { return 'stopped'; } return 'none'; @@ -279,8 +266,7 @@ export function Skills() { const gatewayRunning = gatewayStatus.state === 'running'; const gatewayReportedReady = gatewayStatus.gatewayReady !== false; const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`; - const [skillsFeatureReady, setSkillsFeatureReady] = useState(false); - const gatewayBannerState = getSkillsGatewayBannerState(gatewayStatus, skillsFeatureReady); + const gatewayBannerState = getSkillsGatewayBannerState(gatewayStatus); const [showGatewayBanner, setShowGatewayBanner] = useState(false); useEffect(() => { @@ -304,14 +290,12 @@ export function Skills() { const attemptFetch = async () => { const ok = await fetchSkills(); if (cancelled || !ok) return; - setSkillsFeatureReady(true); if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } }; - setSkillsFeatureReady(false); void attemptFetch(); if (gatewayRunning && !gatewayReportedReady) { @@ -330,7 +314,7 @@ export function Skills() { useEffect(() => { let cancelled = false; - void hostApiFetch<{ success: boolean; capability?: { canSearch?: boolean; canInstall?: boolean } }>('/api/skills/marketplace/capability') + void hostApi.skills.clawhubCapability() .then((result) => { if (cancelled) return; setMarketplaceAvailable(Boolean(result.success && (result.capability?.canInstall || result.capability?.canSearch))); @@ -388,11 +372,11 @@ export function Skills() { const handleOpenSkillsFolder = useCallback(async () => { try { - const skillsDir = await invokeIpc('openclaw:getSkillsDir'); + const skillsDir = await hostApi.openclaw.getSkillsDir(); if (!skillsDir) { throw new Error('Skills directory not available'); } - const result = await invokeIpc('shell:openPath', skillsDir); + const result = await hostApi.shell.openPath(skillsDir); if (result) { if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) { toast.error(t('toast.failedFolderNotFound')); @@ -407,13 +391,10 @@ export function Skills() { const handleOpenSkillFolder = useCallback(async (skill: Skill) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-path', { - method: 'POST', - body: JSON.stringify({ - skillKey: skill.id, - slug: skill.slug, - baseDir: skill.baseDir, - }), + const result = await hostApi.skills.clawhubOpenSkillPath({ + skillKey: skill.id, + slug: skill.slug, + baseDir: skill.baseDir, }); if (!result.success) { throw new Error(result.error || 'Failed to open folder'); @@ -426,8 +407,8 @@ export function Skills() { const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills'); useEffect(() => { - invokeIpc('openclaw:getSkillsDir') - .then((dir) => setSkillsDirPath(dir as string)) + hostApi.openclaw.getSkillsDir() + .then((dir) => setSkillsDirPath(dir)) .catch(console.error); }, []); @@ -511,26 +492,11 @@ export function Skills() {
- - - {gatewayBannerState === 'starting' ? t('gatewayStarting') : t('gatewayWarning')} + + + {t('gatewayWarning')}
)} @@ -740,7 +706,7 @@ export function Skills() {
invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`)} + onClick={() => hostApi.shell.openExternal(`https://clawhub.ai/s/${skill.slug}`)} >
diff --git a/src/stores/agents.ts b/src/stores/agents.ts index 7aacf02d..57ad0414 100644 --- a/src/stores/agents.ts +++ b/src/stores/agents.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; import type { ChannelType } from '@/types/channel'; import type { AgentSummary, AgentsSnapshot } from '@/types/agent'; @@ -46,7 +46,7 @@ export const useAgentsStore = create((set) => ({ fetchAgents: async () => { set({ loading: true, error: null }); try { - const snapshot = await hostApiFetch('/api/agents'); + const snapshot = await hostApi.agents.list(); set({ ...applySnapshot(snapshot), loading: false, @@ -59,9 +59,9 @@ export const useAgentsStore = create((set) => ({ createAgent: async (name: string, options?: { inheritWorkspace?: boolean }) => { set({ error: null }); try { - const snapshot = await hostApiFetch('/api/agents', { - method: 'POST', - body: JSON.stringify({ name, inheritWorkspace: options?.inheritWorkspace }), + const snapshot = await hostApi.agents.create({ + name, + inheritWorkspace: options?.inheritWorkspace, }); set(applySnapshot(snapshot)); } catch (error) { @@ -73,13 +73,7 @@ export const useAgentsStore = create((set) => ({ updateAgent: async (agentId: string, name: string) => { set({ error: null }); try { - const snapshot = await hostApiFetch( - `/api/agents/${encodeURIComponent(agentId)}`, - { - method: 'PUT', - body: JSON.stringify({ name }), - } - ); + const snapshot = await hostApi.agents.update(agentId, { name }); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); @@ -90,13 +84,7 @@ export const useAgentsStore = create((set) => ({ updateAgentModel: async (agentId: string, modelRef: string | null) => { set({ error: null }); try { - const snapshot = await hostApiFetch( - `/api/agents/${encodeURIComponent(agentId)}/model`, - { - method: 'PUT', - body: JSON.stringify({ modelRef }), - } - ); + const snapshot = await hostApi.agents.updateModel(agentId, modelRef); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); @@ -107,10 +95,7 @@ export const useAgentsStore = create((set) => ({ deleteAgent: async (agentId: string) => { set({ error: null }); try { - const snapshot = await hostApiFetch( - `/api/agents/${encodeURIComponent(agentId)}`, - { method: 'DELETE' } - ); + const snapshot = await hostApi.agents.delete(agentId); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); @@ -121,10 +106,7 @@ export const useAgentsStore = create((set) => ({ assignChannel: async (agentId: string, channelType: ChannelType) => { set({ error: null }); try { - const snapshot = await hostApiFetch( - `/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`, - { method: 'PUT' } - ); + const snapshot = await hostApi.agents.assignChannel(agentId, channelType); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); @@ -135,10 +117,7 @@ export const useAgentsStore = create((set) => ({ removeChannel: async (agentId: string, channelType: ChannelType) => { set({ error: null }); try { - const snapshot = await hostApiFetch( - `/api/agents/${encodeURIComponent(agentId)}/channels/${encodeURIComponent(channelType)}`, - { method: 'DELETE' } - ); + const snapshot = await hostApi.agents.removeChannel(agentId, channelType); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); diff --git a/src/stores/baseline-cache.ts b/src/stores/baseline-cache.ts index b40cb50b..39d9405b 100644 --- a/src/stores/baseline-cache.ts +++ b/src/stores/baseline-cache.ts @@ -8,7 +8,7 @@ * message), not shared across the whole session — otherwise a later run that * edits the same path would incorrectly diff against an older baseline. */ -import { readTextFile, type FilePreviewError } from '@/lib/api-client'; +import { readTextFile, type FilePreviewError } from '@/lib/file-preview-client'; import type { GeneratedFileBaseline } from '@/lib/generated-files'; const KEY_SEPARATOR = '\u0000'; diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 647dc721..09712b5f 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -3,7 +3,7 @@ * Manages messaging channel state */ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; import { isChannelRuntimeConnected, pickChannelRuntimeStatus, @@ -184,9 +184,7 @@ export const useChannelsStore = create((set, get) => ({ try { // Delete the channel configuration from openclaw.json - await hostApiFetch(`/api/channels/config/${encodeURIComponent(channelType)}`, { - method: 'DELETE', - }); + await hostApi.channels.deleteConfig(channelType); } catch (error) { console.error('Failed to delete channel config:', error); } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 0d0b48b0..866cedab 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,15 +1,16 @@ /** * Chat State Store * Manages chat messages, sessions, and streaming state. - * Chat RPC/control flows are Main-owned via Host API routes. + * Communicates with OpenClaw Gateway through the Main-owned host API. */ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi, type ChatSendWithMediaResult, type SessionLabelSummary } from '@/lib/host-api'; import { useGatewayStore } from './gateway'; import { useAgentsStore } from './agents'; import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events'; import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache'; -import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils'; +import { isCronSessionKey } from './chat/cron-session-utils'; +import { fetchCronSessionHistory } from '@/lib/cron-session-history'; import { pickStartupSessionFallback } from './chat/session-selection'; import { CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS, @@ -143,12 +144,6 @@ type PendingOptimisticUserMessage = { const _pendingOptimisticUserMessages = new Map(); -type SessionLabelSummary = { - sessionKey: string; - firstUserText: string | null; - lastTimestamp: number | null; -}; - function getSessionBackendLabel(session: ChatSession): string { return toSessionLabel(session.label || session.derivedTitle || ''); } @@ -173,13 +168,7 @@ function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void async function fetchSessionLabelSummaries(sessionKeys: string[]): Promise { if (sessionKeys.length === 0) return []; - const response = await hostApiFetch<{ - success?: boolean; - summaries?: SessionLabelSummary[]; - }>('/api/sessions/summaries', { - method: 'POST', - body: JSON.stringify({ sessionKeys }), - }); + const response = await hostApi.sessions.summaries({ sessionKeys }); return Array.isArray(response?.summaries) ? response.summaries : []; } @@ -1512,13 +1501,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { } try { - const thumbnails = await hostApiFetch>( - '/api/files/thumbnails', - { - method: 'POST', - body: JSON.stringify({ paths: needPreview }), - }, - ); + const thumbnails = await hostApi.media.thumbnails({ + paths: needPreview, + }); if (applyPreviewResults(messages, thumbnails)) { updatedAny = true; } @@ -1617,10 +1602,7 @@ function reconcileCurrentSessionIdleFromBackend( async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise { if (!isCronSessionKey(sessionKey)) return []; try { - const response = await hostApiFetch<{ messages?: RawMessage[] }>( - buildCronSessionHistoryPath(sessionKey, limit), - ); - return Array.isArray(response.messages) ? response.messages : []; + return await fetchCronSessionHistory(sessionKey, limit); } catch (error) { console.warn('Failed to load cron fallback history:', error); return []; @@ -1628,22 +1610,10 @@ async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promis } async function fetchChatSessionsList(): Promise> { - try { - const response = await hostApiFetch<{ - success: boolean; - result?: Record; - error?: string; - }>('/api/chat/sessions'); - if (response.success && response.result) { - return response.result; - } - throw new Error(response.error || 'Failed to load chat sessions'); - } catch { - return await useGatewayStore.getState().rpc>('sessions.list', { - includeDerivedTitles: true, - includeLastMessage: true, - }); - } + return useGatewayStore.getState().rpc>('sessions.list', { + includeDerivedTitles: true, + includeLastMessage: true, + }); } async function fetchChatHistory( @@ -1657,25 +1627,7 @@ async function fetchChatHistory( limit, ...(typeof maxChars === 'number' ? { maxChars } : {}), }; - try { - const response = await hostApiFetch<{ - success: boolean; - result?: Record; - error?: string; - }>('/api/chat/history', { - method: 'POST', - body: JSON.stringify({ - ...params, - ...(typeof timeoutMs === 'number' ? { timeoutMs } : {}), - }), - }); - if (response.success && response.result) { - return response.result; - } - throw new Error(response.error || 'Failed to load chat history'); - } catch { - return await useGatewayStore.getState().rpc>('chat.history', params, timeoutMs); - } + return useGatewayStore.getState().rpc>('chat.history', params, timeoutMs); } async function sendChatMessageViaHostApi(params: { @@ -1684,39 +1636,11 @@ async function sendChatMessageViaHostApi(params: { deliver?: boolean; idempotencyKey: string; }): Promise<{ runId?: string }> { - try { - const response = await hostApiFetch<{ - success: boolean; - result?: { runId?: string }; - error?: string; - }>('/api/chat/send', { - method: 'POST', - body: JSON.stringify(params), - }); - if (!response.success) { - throw new Error(response.error || 'Failed to send chat message'); - } - return response.result ?? {}; - } catch { - return await useGatewayStore.getState().rpc<{ runId?: string }>('chat.send', params, 120000); - } + return useGatewayStore.getState().rpc<{ runId?: string }>('chat.send', params, 120000); } async function abortChatRunViaHostApi(sessionKey: string): Promise { - try { - const response = await hostApiFetch<{ - success: boolean; - error?: string; - }>('/api/chat/abort', { - method: 'POST', - body: JSON.stringify({ sessionKey }), - }); - if (!response.success) { - throw new Error(response.error || 'Failed to abort chat run'); - } - } catch { - await useGatewayStore.getState().rpc('chat.abort', { sessionKey }); - } + await useGatewayStore.getState().rpc('chat.abort', { sessionKey }); } function normalizeAgentId(value: string | undefined | null): string { @@ -2711,13 +2635,7 @@ export const useChatStore = create((set, get) => ({ // .deleted.jsonl and .jsonl.reset.* siblings, then removes the // entry from sessions.json so sessions.list stops surfacing it. try { - const result = await hostApiFetch<{ - success: boolean; - error?: string; - }>('/api/sessions/delete', { - method: 'POST', - body: JSON.stringify({ sessionKey: key }), - }); + const result = await hostApi.sessions.delete(key); if (!result.success) { console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error); } @@ -2791,13 +2709,7 @@ export const useChatStore = create((set, get) => ({ } try { - const result = await hostApiFetch<{ - success: boolean; - error?: string; - }>('/api/sessions/rename', { - method: 'POST', - body: JSON.stringify({ sessionKey: key, label: normalized }), - }); + const result = await hostApi.sessions.rename(key, normalized); if (!result.success) { throw new Error(result.error || 'Failed to rename session'); } @@ -3700,26 +3612,20 @@ export const useChatStore = create((set, get) => ({ saveImageCache(_imageCache); } - let result: { success: boolean; result?: { runId?: string }; error?: string }; + let result: ChatSendWithMediaResult; if (hasMedia) { - result = await hostApiFetch<{ success: boolean; result?: { runId?: string }; error?: string }>( - '/api/chat/send-with-media', - { - method: 'POST', - body: JSON.stringify({ - 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, - })), - }), - }, - ); + result = await hostApi.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, + })), + }); } else { const rpcResult = await sendChatMessageViaHostApi({ sessionKey: currentSessionKey, diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index a8d6beaa..82b69807 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -1,4 +1,4 @@ -import { invokeIpc } from '@/lib/api-client'; +import { hostApi } from '@/lib/host-api'; import { isGeneratingStatusNarration, isInternalAssistantReplyText, @@ -1242,10 +1242,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { } try { - const thumbnails = await invokeIpc( - 'media:getThumbnails', - needPreview, - ) as Record; + const thumbnails = await hostApi.media.thumbnails({ + paths: needPreview, + }); if (applyPreviewResults(messages, thumbnails)) { updatedAny = true; } diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index c884940e..ef398d5b 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -1,5 +1,5 @@ -import { invokeIpc } from '@/lib/api-client'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; +import { fetchCronSessionHistory } from '@/lib/cron-session-history'; import { useGatewayStore } from '@/stores/gateway'; import { clearHistoryPoll, @@ -20,7 +20,7 @@ import { setLastChatEventAt, toMs, } from './helpers'; -import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils'; +import { isCronSessionKey } from './cron-session-utils'; import { CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, classifyHistoryStartupRetryError, @@ -41,10 +41,7 @@ const foregroundHistoryLoadSeen = new Set(); async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise { if (!isCronSessionKey(sessionKey)) return []; try { - const response = await hostApiFetch<{ messages?: RawMessage[] }>( - buildCronSessionHistoryPath(sessionKey, limit), - ); - return Array.isArray(response.messages) ? response.messages : []; + return await fetchCronSessionHistory(sessionKey, limit); } catch (error) { console.warn('Failed to load cron fallback history:', error); return []; @@ -304,16 +301,7 @@ export function createHistoryActions( params?: unknown, timeoutMs?: number, ): Promise => { - const result = await invokeIpc( - 'gateway:rpc', - method, - params, - ...(timeoutMs != null ? [timeoutMs] as const : []), - ) as { success: boolean; result?: T; error?: string }; - if (!result.success) { - throw new Error(result.error || `RPC ${method} failed`); - } - return result.result as T; + return hostApi.gateway.rpc(method, params, timeoutMs); }; const chatHistoryParams = buildChatHistoryRpcParams( currentSessionKey, @@ -330,19 +318,14 @@ export function createHistoryActions( } try { - result = await invokeIpc( - 'gateway:rpc', + const data = await hostApi.gateway.rpc>( 'chat.history', chatHistoryParams, - ...(historyTimeoutOverride != null ? [historyTimeoutOverride] as const : []), - ) as { success: boolean; result?: Record; error?: string }; - - if (result.success) { - lastError = null; - break; - } - - lastError = new Error(result.error || 'Failed to load chat history'); + historyTimeoutOverride, + ); + result = { success: true, result: data }; + lastError = null; + break; } catch (error) { lastError = error; } diff --git a/src/stores/chat/history-transcript-fallback.ts b/src/stores/chat/history-transcript-fallback.ts index 1bcf5a82..1755dfed 100644 --- a/src/stores/chat/history-transcript-fallback.ts +++ b/src/stores/chat/history-transcript-fallback.ts @@ -1,4 +1,4 @@ -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; import type { RawMessage } from './types'; export async function loadSessionTranscriptFallback( @@ -6,10 +6,7 @@ export async function loadSessionTranscriptFallback( limit = 200, ): Promise { try { - const params = new URLSearchParams({ sessionKey, limit: String(limit) }); - const response = await hostApiFetch<{ messages?: RawMessage[] }>( - `/api/sessions/transcript?${params.toString()}`, - ); + const response = await hostApi.sessions.history({ sessionKey, limit }); return Array.isArray(response.messages) ? response.messages : []; } catch (error) { console.warn('[chat.history] transcript fallback failed:', error); diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts index d176f885..b5dbb9b5 100644 --- a/src/stores/chat/runtime-send-actions.ts +++ b/src/stores/chat/runtime-send-actions.ts @@ -1,4 +1,4 @@ -import { invokeIpc } from '@/lib/api-client'; +import { hostApi, type ChatSendWithMediaResult } from '@/lib/host-api'; import { useAgentsStore } from '@/stores/agents'; import { clearErrorRecoveryTimer, @@ -206,29 +206,25 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick ({ - filePath: a.stagedPath, - mimeType: a.mimeType, - fileName: a.fileName, - })), - }, - ) as { success: boolean; result?: { runId?: string }; error?: string }; + result = await hostApi.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, + })), + }); } else { - result = await invokeIpc( - 'gateway:rpc', + const rpcResult = await hostApi.gateway.rpc<{ runId?: string }>( 'chat.send', { sessionKey: currentSessionKey, @@ -237,7 +233,8 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { try { - const result = await invokeIpc( - 'gateway:rpc', + const data = await hostApi.gateway.rpc>( 'sessions.list', { includeDerivedTitles: true, includeLastMessage: true, } - ) as { success: boolean; result?: Record; error?: string }; + ); - if (result.success && result.result) { - const data = result.result; + if (data) { const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; const sessions: ChatSession[] = rawSessions.map((s: Record) => ({ key: String(s.key || ''), @@ -230,16 +228,11 @@ export function createSessionActions( await Promise.all( batch.map(async ({ session, version }) => { try { - const r = await invokeIpc( - 'gateway:rpc', + const result = await hostApi.gateway.rpc>( 'chat.history', { sessionKey: session.key, limit: 1000 }, - ) as { success: boolean; result?: Record; error?: string }; - if (!r.success || !r.result) { - finishSessionLabelHydration(session.key, version, 'error'); - return; - } - const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : []; + ); + const msgs = Array.isArray(result.messages) ? result.messages as RawMessage[] : []; const firstUser = msgs.find((m) => m.role === 'user'); const lastMsg = msgs[msgs.length - 1]; const labelText = firstUser ? getMessageText(firstUser.content).trim() : ''; @@ -323,10 +316,7 @@ export function createSessionActions( // .deleted.jsonl and .jsonl.reset.* siblings, then removes the // entry from sessions.json so sessions.list stops surfacing it. try { - const result = await invokeIpc('session:delete', key) as { - success: boolean; - error?: string; - }; + const result = await hostApi.sessions.delete(key); if (!result.success) { console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error); } @@ -419,10 +409,7 @@ export function createSessionActions( // Persist the new label to sessions.json via IPC try { - const result = await invokeIpc('session:rename', key, normalized) as { - success: boolean; - error?: string; - }; + const result = await hostApi.sessions.rename(key, normalized); if (!result.success) { throw new Error(result.error || 'Failed to rename session'); } diff --git a/src/stores/chat/types.ts b/src/stores/chat/types.ts index 6e128396..dd721b99 100644 --- a/src/stores/chat/types.ts +++ b/src/stores/chat/types.ts @@ -1,169 +1 @@ -import type { ChatRuntimeEvent } from '../../../shared/chat-runtime-events'; - -/** Metadata for locally-attached files (not from Gateway) */ -export interface AttachedFileMeta { - fileName: string; - mimeType: string; - fileSize: number; - preview: string | null; - previewStatus?: 'unavailable'; - filePath?: string; - source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media'; - /** - * For Gateway-injected outgoing media (assistant-media). The Gateway emits - * an `image` content block with a relative URL like - * `/api/chat/media/outgoing///full`. The renderer - * cannot reach Gateway HTTP directly (CORS / env drift), so this URL is - * resolved through the Main-process proxy in `media:getThumbnails`, which - * looks up `~/.openclaw/media/outgoing/records/.json` and - * loads the original file off disk. - */ - gatewayUrl?: 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; - stopReason?: string; - stop_reason?: string; - errorMessage?: string; - error_message?: string; - /** 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; - /** - * Flat URL on an `image` block. Gateway-injected assistant-media messages - * use this shape: `{ type:'image', url:'/api/chat/media/outgoing/...', mimeType, width, height, alt, openUrl }`. - * Neither nested `source.url` nor flat `data` is set in that case; the - * renderer must read `block.url` directly to surface the artifact. - */ - url?: string; - /** Optional companion of `url` — points at a higher-resolution variant. */ - openUrl?: string; - /** Pixel width of the original image, used for layout hints. */ - width?: number; - /** Pixel height of the original image, used for layout hints. */ - height?: number; - /** Human-readable filename / alt text emitted by the Gateway. */ - alt?: string; - id?: string; - name?: string; - input?: unknown; - arguments?: unknown; - content?: unknown; -} - -/** Session from sessions.list */ -export interface ChatSession { - key: string; - label?: string; - displayName?: string; - derivedTitle?: string; - lastMessagePreview?: string; - thinkingLevel?: string; - model?: string; - updatedAt?: number; - status?: string; - hasActiveRun?: boolean; -} - -export interface ToolStatus { - id?: string; - toolCallId?: string; - name: string; - status: 'running' | 'completed' | 'error'; - durationMs?: number; - summary?: string; - updatedAt: number; -} - -export interface ChatRuntimeRunState { - runId: string; - sessionKey?: string; - status: 'running' | 'completed' | 'error' | 'aborted'; - startedAt?: number; - endedAt?: number; - assistantText: string; - thinkingText: string; - events: ChatRuntimeEvent[]; -} - -export interface ChatState { - // Messages - messages: RawMessage[]; - loading: boolean; - loadingMoreHistory: boolean; - hasMoreHistory: boolean; - error: string | null; - runError: 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[]; - runtimeRuns: Record; - - // Sessions - sessions: ChatSession[]; - currentSessionKey: string; - currentAgentId: 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 - thinkingLevel: string | null; - - // Actions - loadSessions: () => Promise; - switchSession: (key: string) => void; - newSession: () => void; - deleteSession: (key: string) => Promise; - renameSession: (key: string, label: string) => Promise; - cleanupEmptySession: () => void; - loadHistory: (quiet?: boolean) => Promise; - loadMoreHistory: () => Promise; - sendMessage: ( - text: string, - attachments?: Array<{ - fileName: string; - mimeType: string; - fileSize: number; - stagedPath: string; - preview: string | null; - }>, - targetAgentId?: string | null, - ) => Promise; - abortRun: () => Promise; - handleChatEvent: (event: Record) => void; - handleRuntimeEvent: (event: ChatRuntimeEvent) => void; - refresh: () => Promise; - clearError: () => void; -} - -export const DEFAULT_CANONICAL_PREFIX = 'agent:main'; -export const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`; +export * from '@shared/chat/types'; diff --git a/src/stores/cron.ts b/src/stores/cron.ts index 349adde0..0dd34553 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -3,7 +3,7 @@ * Manages scheduled task state */ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; import { useChatStore } from './chat'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; @@ -45,7 +45,7 @@ export const useCronStore = create((set) => ({ } try { - const result = await hostApiFetch('/api/cron/jobs'); + const result = await hostApi.cron.list(); // Gateway now correctly returns agentId for all jobs. // If Gateway returned fewer jobs than we have (e.g. race condition), preserve @@ -72,10 +72,7 @@ export const useCronStore = create((set) => ({ try { // Auto-capture currentAgentId if not provided const agentId = input.agentId ?? useChatStore.getState().currentAgentId; - const job = await hostApiFetch('/api/cron/jobs', { - method: 'POST', - body: JSON.stringify({ ...input, agentId }), - }); + const job = await hostApi.cron.create({ ...input, agentId }); set((state) => ({ jobs: [...state.jobs, job] })); return job; } catch (error) { @@ -86,10 +83,7 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { - const updatedJob = await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { - method: 'PUT', - body: JSON.stringify(input), - }); + const updatedJob = await hostApi.cron.update(id, input); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? updatedJob : job @@ -103,9 +97,7 @@ export const useCronStore = create((set) => ({ deleteJob: async (id) => { try { - await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, { - method: 'DELETE', - }); + await hostApi.cron.delete(id); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -117,10 +109,7 @@ export const useCronStore = create((set) => ({ toggleJob: async (id, enabled) => { try { - await hostApiFetch('/api/cron/toggle', { - method: 'POST', - body: JSON.stringify({ id, enabled }), - }); + await hostApi.cron.toggle(id, enabled); set((state) => ({ jobs: state.jobs.map((job) => job.id === id ? { ...job, enabled } : job @@ -134,13 +123,10 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { - await hostApiFetch('/api/cron/trigger', { - method: 'POST', - body: JSON.stringify({ id }), - }); + await hostApi.cron.trigger(id); // Refresh jobs after trigger to update lastRun/nextRun state try { - const result = await hostApiFetch('/api/cron/jobs'); + const result = await hostApi.cron.list(); set({ jobs: result }); } catch { // Ignore refresh error diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 6b3b5be6..b2f01420 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -1,12 +1,11 @@ /** * Gateway State Store - * Uses Host API + host events for lifecycle/status while Main owns runtime event transport. + * Uses typed Host API IPC for lifecycle/status and runtime RPC. */ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; -import { invokeIpc } from '@/lib/api-client'; -import { subscribeHostEvent } from '@/lib/host-events'; -import type { GatewayHealth, GatewayStatus } from '../types/gateway'; +import { hostApi } from '@/lib/host-api'; +import { hostEvents } from '@/lib/host-events'; +import type { GatewayNotification, GatewayHealth, GatewayStatus } from '../types/gateway'; import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events'; let gatewayInitPromise: Promise | null = null; @@ -169,11 +168,53 @@ function touchSessionActivity(sessionKey: string | null | undefined, activityMs .catch(() => {}); } -function handleGatewayNotification(notification: { method?: string; params?: Record } | undefined): void { +function getGatewayErrorMessage(payload: string | { message?: string }): string { + if (typeof payload === 'string') return payload || 'Gateway error'; + return payload.message || 'Gateway error'; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function handleGatewayNotification(notification: GatewayNotification | undefined): void { const payload = notification; if (!payload || payload.method === 'agent') { return; } + + const p = asRecord(payload.params); + const data = asRecord(p.data); + const phase = data.phase ?? p.phase; + const hasChatData = (p.state ?? data.state) || (p.message ?? data.message); + + if (hasChatData) { + const normalizedEvent: Record = { + ...data, + runId: p.runId ?? data.runId, + sessionKey: p.sessionKey ?? data.sessionKey, + stream: p.stream ?? data.stream, + seq: p.seq ?? data.seq, + state: p.state ?? data.state, + message: p.message ?? data.message, + }; + if (shouldProcessGatewayEvent(normalizedEvent)) { + import('./chat') + .then(({ useChatStore }) => { + useChatStore.getState().handleChatEvent(normalizedEvent); + }) + .catch(() => {}); + } + } + + if (phase === 'run.started' || phase === 'run.ended') { + const sessionKey = typeof (p.sessionKey ?? data.sessionKey) === 'string' + ? String(p.sessionKey ?? data.sessionKey) + : undefined; + touchSessionActivity(sessionKey); + } } function handleChatRuntimeEvent(event: ChatRuntimeEvent): void { @@ -276,12 +317,12 @@ export const useGatewayStore = create((set, get) => ({ gatewayInitPromise = (async () => { try { - const status = await hostApiFetch('/api/gateway/status'); + const status = await hostApi.gateway.status(); set({ status, isInitialized: true }); if (!gatewayEventUnsubscribers) { const unsubscribers: Array<() => void> = []; - unsubscribers.push(subscribeHostEvent('gateway:status', (payload) => { + unsubscribers.push(hostEvents.onGatewayStatus((payload) => { set({ status: payload }); // Trigger cron repair when gateway becomes ready @@ -295,35 +336,32 @@ export const useGatewayStore = create((set, get) => ({ .catch(() => {}); } })); - unsubscribers.push(subscribeHostEvent<{ message?: string }>('gateway:error', (payload) => { - set({ lastError: payload.message || 'Gateway error' }); + unsubscribers.push(hostEvents.onGatewayError((payload) => { + set({ lastError: getGatewayErrorMessage(payload) }); })); - unsubscribers.push(subscribeHostEvent<{ method?: string; params?: Record }>( - 'gateway:notification', + unsubscribers.push(hostEvents.onGatewayNotification( (payload) => { handleGatewayNotification(payload); }, )); - unsubscribers.push(subscribeHostEvent('gateway:health', (payload) => { + unsubscribers.push(hostEvents.onGatewayHealth((payload) => { const current = get().health; set({ health: { ...(current ?? { ok: true }), ok: true, openclawHealth: payload } }); })); - unsubscribers.push(subscribeHostEvent('gateway:presence', (payload) => { + unsubscribers.push(hostEvents.onGatewayPresence((payload) => { const current = get().health; set({ health: { ...(current ?? { ok: true }), presence: payload } }); })); - unsubscribers.push(subscribeHostEvent('gateway:chat-message', (payload) => { + unsubscribers.push(hostEvents.onGatewayChatMessage((payload) => { handleGatewayChatMessage(payload); })); - unsubscribers.push(subscribeHostEvent('chat:runtime-event', (payload) => { + unsubscribers.push(hostEvents.onChatRuntimeEvent((payload) => { handleChatRuntimeEvent(payload); })); - unsubscribers.push(subscribeHostEvent<{ channelId?: string; status?: string }>( - 'gateway:channel-status', + unsubscribers.push(hostEvents.onGatewayChannelStatus( (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) { @@ -344,18 +382,15 @@ export const useGatewayStore = create((set, get) => ({ // Periodic reconciliation safety net: every 30 seconds, check if the // renderer's view of gateway state has drifted from main process truth. - // This catches any future one-off IPC delivery failures without adding - // a constant polling load (single lightweight IPC invoke per interval). + // This catches any future one-off event delivery failures without adding + // a constant polling load (single lightweight Host API status call per interval). // Clear any previous timer first to avoid leaks during HMR reloads. if (gatewayReconcileTimer !== null) { clearInterval(gatewayReconcileTimer); } gatewayReconcileTimer = setInterval(() => { - const ipc = window.electron?.ipcRenderer; - if (!ipc) return; - ipc.invoke('gateway:status') - .then((result: unknown) => { - const latest = result as GatewayStatus; + hostApi.gateway.status() + .then((latest) => { const current = get().status; if (latest.state !== current.state) { console.info( @@ -373,7 +408,7 @@ export const useGatewayStore = create((set, get) => ({ // the initial fetch and the IPC listener setup, that event was lost. // A second fetch guarantees we pick up the latest state. try { - const refreshed = await hostApiFetch('/api/gateway/status'); + const refreshed = await hostApi.gateway.status(); const current = get().status; if (refreshed.state !== current.state) { set({ status: refreshed }); @@ -395,9 +430,7 @@ export const useGatewayStore = create((set, get) => ({ start: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/start', { - method: 'POST', - }); + const result = await hostApi.gateway.start(); if (!result.success) { set({ status: { ...get().status, state: 'error', error: result.error }, @@ -414,7 +447,7 @@ export const useGatewayStore = create((set, get) => ({ stop: async () => { try { - await hostApiFetch('/api/gateway/stop', { method: 'POST' }); + await hostApi.gateway.stop(); set({ status: { ...get().status, state: 'stopped' }, lastError: null }); } catch (error) { console.error('Failed to stop Gateway:', error); @@ -425,9 +458,7 @@ export const useGatewayStore = create((set, get) => ({ restart: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/restart', { - method: 'POST', - }); + const result = await hostApi.gateway.restart(); if (!result.success) { set({ status: { ...get().status, state: 'error', error: result.error }, @@ -444,7 +475,7 @@ export const useGatewayStore = create((set, get) => ({ checkHealth: async () => { try { - const result = await hostApiFetch('/api/gateway/health'); + const result = await hostApi.gateway.health(); set({ health: result }); return result; } catch (error) { @@ -455,15 +486,7 @@ export const useGatewayStore = create((set, get) => ({ }, rpc: async (method: string, params?: unknown, timeoutMs?: number): Promise => { - const response = await invokeIpc<{ - success: boolean; - result?: T; - error?: string; - }>('gateway:rpc', method, params, timeoutMs); - if (!response.success) { - throw new Error(response.error || `Gateway RPC failed: ${method}`); - } - return response.result as T; + return await hostApi.gateway.rpc(method, params, timeoutMs); }, setStatus: (status) => set({ status }), diff --git a/src/stores/providers.ts b/src/stores/providers.ts index e34bac1e..1f357911 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -10,11 +10,8 @@ import type { ProviderWithKeyInfo, } from '@/lib/providers'; import { normalizeProviderApiKeyInput } from '@/lib/providers'; -import { hostApiFetch } from '@/lib/host-api'; -import { - fetchProviderSnapshot, - isHostApiRouteMissing, -} from '@/lib/provider-accounts'; +import { hostApi } from '@/lib/host-api'; +import { fetchProviderSnapshot } from '@/lib/provider-accounts'; // Re-export types for consumers that imported from here export type { @@ -104,9 +101,7 @@ export const useProviderStore = create((set, get) => ({ // Legacy ProviderConfig-shaped alias kept for backward compatibility // with any stale caller. Internally projects the legacy config payload - // onto the new ProviderAccount surface and delegates to createAccount, - // so we hit /api/provider-accounts instead of the deprecated - // /api/providers POST route. + // onto the ProviderAccount surface and delegates to createAccount. addProvider: async (config, apiKey) => { try { const now = new Date().toISOString(); @@ -128,17 +123,14 @@ export const useProviderStore = create((set, get) => ({ }; await get().createAccount(account, apiKey); } catch (error) { - console.error('Failed to add provider:', error); + console.error('Failed to add provider', error); throw error; } }, createAccount: async (account, apiKey) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', { - method: 'POST', - body: JSON.stringify({ account, apiKey }), - }); + const result = await hostApi.providers.createAccount({ account, apiKey }); if (!result.success) { throw new Error(result.error || 'Failed to create provider account'); @@ -154,8 +146,7 @@ export const useProviderStore = create((set, get) => ({ addAccount: async (account, apiKey) => get().createAccount(account, apiKey), // Legacy ProviderConfig-shaped alias. Translates the partial ProviderConfig - // patch into a ProviderAccount patch and routes through updateAccount so we - // never hit the deprecated /api/providers/:id PUT route from the renderer. + // patch into a ProviderAccount patch and routes through updateAccount. updateProvider: async (providerId, updates, apiKey) => { try { const accountUpdates: Partial = {}; @@ -170,17 +161,18 @@ export const useProviderStore = create((set, get) => ({ if (updates.enabled !== undefined) accountUpdates.enabled = updates.enabled; await get().updateAccount(providerId, accountUpdates, apiKey); } catch (error) { - console.error('Failed to update provider:', error); + console.error('Failed to update provider', error); throw error; } }, updateAccount: async (accountId, updates, apiKey) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, { - method: 'PUT', - body: JSON.stringify({ updates, apiKey }), - }); + const result = await hostApi.providers.updateAccount( + accountId, + updates, + apiKey, + ); if (!result.success) { throw new Error(result.error || 'Failed to update provider account'); @@ -197,9 +189,7 @@ export const useProviderStore = create((set, get) => ({ removeAccount: async (accountId) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, { - method: 'DELETE', - }); + const result = await hostApi.providers.deleteAccount(accountId); if (!result.success) { throw new Error(result.error || 'Failed to delete provider account'); @@ -214,9 +204,9 @@ export const useProviderStore = create((set, get) => ({ deleteAccount: async (accountId) => get().removeAccount(accountId), - // Legacy alias kept for in-flight callers; routes the call to the new - // /api/provider-accounts/:id PUT endpoint via updateAccount, which is - // semantically equivalent to "set API key without other changes". + // Legacy alias kept for in-flight callers; routes the call through + // updateAccount, which is semantically equivalent to "set API key without + // other changes". setApiKey: async (providerId, apiKey) => get().updateAccount(providerId, {}, apiKey), updateProviderWithKey: async (providerId, updates, apiKey) => { @@ -238,14 +228,10 @@ export const useProviderStore = create((set, get) => ({ } }, - // Legacy alias — the new account API exposes the same `apiKeyOnly=1` - // contract, so we just route through it. + // Legacy alias that clears only the stored key for an account. deleteApiKey: async (providerId) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>( - `/api/provider-accounts/${encodeURIComponent(providerId)}?apiKeyOnly=1`, - { method: 'DELETE' }, - ); + const result = await hostApi.providers.deleteAccountApiKey(providerId); if (!result.success) { throw new Error(result.error || 'Failed to delete API key'); @@ -262,10 +248,7 @@ export const useProviderStore = create((set, get) => ({ setDefaultAccount: async (accountId) => { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', { - method: 'PUT', - body: JSON.stringify({ accountId }), - }); + const result = await hostApi.providers.setDefaultAccount(accountId); if (!result.success) { throw new Error(result.error || 'Failed to set default provider account'); @@ -281,44 +264,16 @@ export const useProviderStore = create((set, get) => ({ validateAccountApiKey: async (providerId, apiKey, options) => { try { const normalizedApiKey = normalizeProviderApiKeyInput(apiKey); - // The new endpoint accepts both `accountId` (preferred) and a bare - // `vendorId` (used during the Add-Provider flow when no account - // exists yet). We always send `providerId` too so older Host API - // builds that still own the legacy contract keep working when we - // fall back to /api/providers/validate below. - const fetchNew = async () => hostApiFetch<{ valid: boolean; error?: string }>('/api/provider-accounts/validate', { - method: 'POST', - body: JSON.stringify({ + const result = await hostApi.providers.validateKey({ accountId: providerId, vendorId: providerId, providerId, apiKey: normalizedApiKey, options, - }), }); - const fetchLegacy = async () => hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', { - method: 'POST', - body: JSON.stringify({ providerId, apiKey: normalizedApiKey, options }), - }); - - let result: { valid: boolean; error?: string } | { success: false; error: string }; - try { - result = await fetchNew(); - } catch (error) { - if (error instanceof Error && /404|not\s+found/i.test(error.message)) { - result = await fetchLegacy(); - } else { - throw error; - } - } - // hostApiFetch returns the body even for non-2xx (e.g. 404), so a - // missing route surfaces as { success: false, error: "No route ..." }. - // Detect that and fall back to the legacy endpoint before reporting - // back to the caller. - if (isHostApiRouteMissing(result)) { - result = await fetchLegacy(); - } - return result as { valid: boolean; error?: string }; + return result?.valid === true + ? { valid: true } + : { valid: false, error: result?.error }; } catch (error) { return { valid: false, error: String(error) }; } @@ -328,27 +283,7 @@ export const useProviderStore = create((set, get) => ({ getAccountApiKey: async (providerId) => { try { - const fetchNew = async () => hostApiFetch<{ apiKey: string | null } | { success: false; error: string }>( - `/api/provider-accounts/${encodeURIComponent(providerId)}/api-key`, - ); - const fetchLegacy = async () => hostApiFetch<{ apiKey: string | null }>( - `/api/providers/${encodeURIComponent(providerId)}/api-key`, - ); - - let result: { apiKey: string | null } | { success: false; error: string }; - try { - result = await fetchNew(); - } catch (error) { - if (error instanceof Error && /404|not\s+found/i.test(error.message)) { - result = await fetchLegacy(); - } else { - throw error; - } - } - if (isHostApiRouteMissing(result)) { - result = await fetchLegacy(); - } - return (result as { apiKey: string | null }).apiKey ?? null; + return await hostApi.providers.getAccountApiKey(providerId); } catch { return null; } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 1d2903c4..08bc2741 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -5,8 +5,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; -import { hostApiFetch } from '@/lib/host-api'; -import { resolveSupportedLanguage } from '../../shared/language'; +import { hostApi } from '@/lib/host-api'; +import { resolveSupportedLanguage } from '@shared/language'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; @@ -96,7 +96,7 @@ export const useSettingsStore = create()( init: async () => { try { - const settings = await hostApiFetch>('/api/settings'); + const settings = await hostApi.settings.getAll(); const resolvedLanguage = settings.language ? resolveSupportedLanguage(settings.language) : undefined; @@ -119,48 +119,30 @@ export const useSettingsStore = create()( setTheme: (theme) => { set({ theme }); - void hostApiFetch('/api/settings/theme', { - method: 'PUT', - body: JSON.stringify({ value: theme }), - }).catch(() => { }); + void hostApi.settings.set('theme', theme).catch(() => { }); }, setLanguage: (language) => { const resolvedLanguage = resolveSupportedLanguage(language); i18n.changeLanguage(resolvedLanguage); set({ language: resolvedLanguage }); - void hostApiFetch('/api/settings/language', { - method: 'PUT', - body: JSON.stringify({ value: resolvedLanguage }), - }).catch(() => { }); + void hostApi.settings.set('language', resolvedLanguage).catch(() => { }); }, setStartMinimized: (startMinimized) => set({ startMinimized }), setLaunchAtStartup: (launchAtStartup) => { set({ launchAtStartup }); - void hostApiFetch('/api/settings/launchAtStartup', { - method: 'PUT', - body: JSON.stringify({ value: launchAtStartup }), - }).catch(() => { }); + void hostApi.settings.set('launchAtStartup', launchAtStartup).catch(() => { }); }, setTelemetryEnabled: (telemetryEnabled) => { set({ telemetryEnabled }); - void hostApiFetch('/api/settings/telemetryEnabled', { - method: 'PUT', - body: JSON.stringify({ value: telemetryEnabled }), - }).catch(() => { }); + void hostApi.settings.set('telemetryEnabled', telemetryEnabled).catch(() => { }); }, setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); - void hostApiFetch('/api/settings/gatewayAutoStart', { - method: 'PUT', - body: JSON.stringify({ value: gatewayAutoStart }), - }).catch(() => { }); + void hostApi.settings.set('gatewayAutoStart', gatewayAutoStart).catch(() => { }); }, setGatewayPort: (gatewayPort) => { set({ gatewayPort }); - void hostApiFetch('/api/settings/gatewayPort', { - method: 'PUT', - body: JSON.stringify({ value: gatewayPort }), - }).catch(() => { }); + void hostApi.settings.set('gatewayPort', gatewayPort).catch(() => { }); }, setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), setProxyServer: (proxyServer) => set({ proxyServer }), @@ -171,20 +153,14 @@ export const useSettingsStore = create()( setUpdateChannel: (updateChannel) => set({ updateChannel }), setAutoCheckUpdate: (autoCheckUpdate) => { set({ autoCheckUpdate }); - void hostApiFetch('/api/settings/autoCheckUpdate', { - method: 'PUT', - body: JSON.stringify({ value: autoCheckUpdate }), - }).catch(() => { }); + void hostApi.settings.set('autoCheckUpdate', autoCheckUpdate).catch(() => { }); }, setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), setSidebarWidth: (sidebarWidth) => set({ sidebarWidth: clampSidebarWidth(sidebarWidth) }), setDevModeUnlocked: (devModeUnlocked) => { set({ devModeUnlocked }); - void hostApiFetch('/api/settings/devModeUnlocked', { - method: 'PUT', - body: JSON.stringify({ value: devModeUnlocked }), - }).catch(() => { }); + void hostApi.settings.set('devModeUnlocked', devModeUnlocked).catch(() => { }); }, markSetupComplete: () => set({ setupComplete: true }), resetSettings: () => set(defaultSettings), diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 6ee39248..7754b2bf 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -3,37 +3,12 @@ * Manages skill/plugin state */ import { create } from 'zustand'; -import { hostApiFetch } from '@/lib/host-api'; +import { hostApi } from '@/lib/host-api'; +import type { SkillsStatusResult } from '@/lib/host-api'; import { AppError, normalizeAppError } from '@/lib/error-model'; -import { useGatewayStore } from './gateway'; import type { Skill, MarketplaceSkill } from '../types/skill'; -type GatewaySkillStatus = { - skillKey: string; - slug?: string; - name?: string; - description?: string; - disabled?: boolean; - emoji?: string; - version?: string; - author?: string; - config?: Record; - bundled?: boolean; - always?: boolean; - source?: string; - baseDir?: string; - filePath?: string; -}; - -type GatewaySkillsStatusResult = { - skills?: GatewaySkillStatus[]; -}; - -type LocalSkillsResult = { - success: boolean; - skills?: Skill[]; - error?: string; -}; +type GatewaySkillStatus = NonNullable[number]; const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']); const GATEWAY_ONLY_APPENDABLE_SOURCES = new Set(['openclaw-plugin', 'openclaw-extra']); @@ -205,10 +180,12 @@ export const useSkillsStore = create((set, get) => ({ set({ loading: true, error: null }); } - const gatewayDataPromise = useGatewayStore.getState().rpc('skills.status'); + const gatewayDataPromise = hostApi.skills.status() + .then((value) => ({ status: 'fulfilled' as const, value })) + .catch((reason: unknown) => ({ status: 'rejected' as const, reason })); try { - const localResult = await hostApiFetch('/api/skills/local'); + const localResult = await hostApi.skills.local(); if (!localResult.success) { throw new Error(localResult.error || 'Failed to fetch local skills'); } @@ -216,42 +193,38 @@ export const useSkillsStore = create((set, get) => ({ const localSkills = Array.isArray(localResult.skills) ? localResult.skills : []; set({ skills: localSkills, loading: false, error: null }); - void gatewayDataPromise - .then((gatewayData) => { - set((state) => ({ - skills: mergeGatewaySkills(state.skills, gatewayData.skills), - loading: false, - })); - }) - .catch(() => { - // Local data is already rendered; runtime merge is best-effort only. - }); + void gatewayDataPromise.then((gatewayDataResult) => { + if (gatewayDataResult.status !== 'fulfilled') { + return; + } + set((state) => ({ + skills: mergeGatewaySkills(state.skills, gatewayDataResult.value.skills), + loading: false, + })); + }); return true; } catch (error) { console.error('Failed to fetch local skills:', error); - try { - const gatewayData = await gatewayDataPromise; - const gatewaySkills = mergeGatewaySkills([], gatewayData.skills); + const gatewayDataResult = await gatewayDataPromise; + if (gatewayDataResult.status === 'fulfilled') { + const gatewaySkills = mergeGatewaySkills([], gatewayDataResult.value.skills); set({ skills: gatewaySkills, loading: false, error: null }); return true; - } catch (gatewayError) { - console.error('Failed to fetch gateway skills fallback:', gatewayError); - const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); - const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch'); - set((prev) => ({ loading: false, error: errorKey ?? appError.message, skills: prev.skills })); - return false; } + + console.error('Failed to fetch gateway skills fallback:', gatewayDataResult.reason); + const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); + const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch'); + set((prev) => ({ loading: false, error: errorKey ?? appError.message, skills: prev.skills })); + return false; } }, searchSkills: async (query: string) => { set({ searching: true, searchError: null }); try { - const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/skills/marketplace/search', { - method: 'POST', - body: JSON.stringify({ query }), - }); + const result = await hostApi.skills.clawhubSearch({ query }); if (result.success) { set({ searchResults: result.results || [] }); } else { @@ -272,10 +245,7 @@ export const useSkillsStore = create((set, get) => ({ installSkill: async (slug: string, version?: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/marketplace/install', { - method: 'POST', - body: JSON.stringify({ slug, version }), - }); + const result = await hostApi.skills.clawhubInstall({ slug, version }); if (!result.success) { const appError = normalizeAppError(new Error(result.error || 'Install failed'), { module: 'skills', @@ -301,10 +271,7 @@ export const useSkillsStore = create((set, get) => ({ uninstallSkill: async (slug: string) => { set((state) => ({ installing: { ...state.installing, [slug]: true } })); try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/marketplace/uninstall', { - method: 'POST', - body: JSON.stringify({ slug }), - }); + const result = await hostApi.skills.clawhubUninstall({ slug }); if (!result.success) { throw new Error(result.error || 'Uninstall failed'); } @@ -332,12 +299,9 @@ export const useSkillsStore = create((set, get) => ({ } } - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/configs', { - method: 'PATCH', - body: JSON.stringify({ - updates: skillIds.map((skillKey) => ({ skillKey, enabled })), - }), - }); + const result = await hostApi.skills.updateConfigs( + skillIds.map((skillKey) => ({ skillKey, enabled })), + ); if (!result.success) { throw new Error(result.error || 'Failed to update skill config'); } diff --git a/src/stores/update.ts b/src/stores/update.ts index 5302da0f..24dd306a 100644 --- a/src/stores/update.ts +++ b/src/stores/update.ts @@ -4,30 +4,18 @@ */ import { create } from 'zustand'; import { useSettingsStore } from './settings'; -import { invokeIpc } from '@/lib/api-client'; +import { hostApi } from '@/lib/host-api'; +import { hostEvents } from '@/lib/host-events'; +import type { + UpdateChannel, + UpdateInfoSnapshot, + UpdateProgressSnapshot, + UpdateStatusSnapshot, +} from '@shared/host-api/contract'; -export interface UpdateInfo { - version: string; - releaseDate?: string; - releaseNotes?: string | null; -} - -export interface ProgressInfo { - total: number; - delta: number; - transferred: number; - percent: number; - bytesPerSecond: number; -} - -export type UpdateStatus = - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error'; +export type UpdateInfo = UpdateInfoSnapshot; +export type ProgressInfo = UpdateProgressSnapshot; +export type UpdateStatus = UpdateStatusSnapshot['status']; interface UpdateState { status: UpdateStatus; @@ -45,7 +33,7 @@ interface UpdateState { downloadUpdate: () => Promise; installUpdate: () => void; cancelAutoInstall: () => Promise; - setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise; + setChannel: (channel: UpdateChannel) => Promise; setAutoDownload: (enable: boolean) => Promise; clearError: () => void; } @@ -68,20 +56,15 @@ export const useUpdateStore = create((set, get) => ({ updateInitPromise = (async () => { // Get current version try { - const version = await invokeIpc('update:version'); - set({ currentVersion: version as string }); + const version = await hostApi.updates.version(); + set({ currentVersion: version }); } catch (error) { console.error('Failed to get version:', error); } // Get current status try { - const status = await invokeIpc<{ - status: UpdateStatus; - info?: UpdateInfo; - progress?: ProgressInfo; - error?: string; - }>('update:status'); + const status = await hostApi.updates.status(); set({ status: status.status, updateInfo: status.info || null, @@ -95,13 +78,7 @@ export const useUpdateStore = create((set, get) => ({ // Listen for update events // Single source of truth: listen only to update:status-changed // (sent by AppUpdater.updateStatus() in the main process) - window.electron.ipcRenderer.on('update:status-changed', (data) => { - const status = data as { - status: UpdateStatus; - info?: UpdateInfo; - progress?: ProgressInfo; - error?: string; - }; + hostEvents.onUpdateStatusChanged((status) => { set({ status: status.status, updateInfo: status.info || null, @@ -110,14 +87,13 @@ export const useUpdateStore = create((set, get) => ({ }); }); - window.electron.ipcRenderer.on('update:auto-install-countdown', (data) => { - const { seconds, cancelled } = data as { seconds: number; cancelled?: boolean }; + hostEvents.onUpdateAutoInstallCountdown(({ seconds, cancelled }) => { set({ autoInstallCountdown: cancelled ? null : seconds }); }); // New default is prompt-first: never auto-download/install unless the // user explicitly chooses Download from the notification or Settings. - void invokeIpc('update:setAutoDownload', false).catch(() => {}); + void hostApi.updates.setAutoDownload(false).catch(() => {}); set({ isInitialized: true }); @@ -144,18 +120,9 @@ export const useUpdateStore = create((set, get) => ({ try { const result = await Promise.race([ - invokeIpc('update:check'), - new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) - ]) as { - success: boolean; - error?: string; - status?: { - status: UpdateStatus; - info?: UpdateInfo; - progress?: ProgressInfo; - error?: string; - }; - }; + hostApi.updates.check(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) + ]); if (result.status) { set({ @@ -183,10 +150,7 @@ export const useUpdateStore = create((set, get) => ({ set({ status: 'downloading', error: null }); try { - const result = await invokeIpc<{ - success: boolean; - error?: string; - }>('update:download'); + const result = await hostApi.updates.download(); if (!result.success) { set({ status: 'error', error: result.error || 'Failed to download update' }); @@ -197,12 +161,12 @@ export const useUpdateStore = create((set, get) => ({ }, installUpdate: () => { - void invokeIpc('update:install'); + void hostApi.updates.install(); }, cancelAutoInstall: async () => { try { - await invokeIpc('update:cancelAutoInstall'); + await hostApi.updates.cancelAutoInstall(); } catch (error) { console.error('Failed to cancel auto-install:', error); } @@ -210,7 +174,7 @@ export const useUpdateStore = create((set, get) => ({ setChannel: async (channel) => { try { - await invokeIpc('update:setChannel', channel); + await hostApi.updates.setChannel(channel); } catch (error) { console.error('Failed to set update channel:', error); } @@ -221,7 +185,7 @@ export const useUpdateStore = create((set, get) => ({ // Compatibility shim for older UI paths: the updater is now prompt-first, // so we keep electron-updater.autoDownload disabled even if a stale // persisted setting says otherwise. - await invokeIpc('update:setAutoDownload', false); + await hostApi.updates.setAutoDownload(false); if (enable) { console.info('[Update] Auto-download preference ignored; update prompts are shown instead.'); } diff --git a/src/styles/globals.css b/src/styles/globals.css index ba964c4c..c546f77d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -239,6 +239,84 @@ .sidebar-nav-text.sidebar-nav-text { @apply text-sm font-normal leading-4; } + + .clawx-dialog-overlay { + @apply fixed inset-0 z-50 bg-black/30 dark:bg-black/60; + } + + .clawx-dialog-content { + @apply fixed left-1/2 top-1/2 z-50 w-full outline-none; + transform: translate(-50%, -50%); + transform-origin: center; + will-change: opacity, transform; + } + + .clawx-dialog-overlay[data-state='open'] { + animation: clawx-dialog-overlay-in 100ms ease-out both; + } + + .clawx-dialog-overlay[data-state='closed'] { + animation: clawx-dialog-overlay-out 80ms ease-in both; + } + + .clawx-dialog-content[data-state='open'] { + animation: clawx-dialog-content-in 100ms cubic-bezier(0.16, 1, 0.3, 1) both; + } + + .clawx-dialog-content[data-state='closed'] { + animation: clawx-dialog-content-out 80ms ease-in both; + } + + @media (prefers-reduced-motion: reduce) { + .clawx-dialog-overlay[data-state], + .clawx-dialog-content[data-state] { + animation: none; + } + } +} + +@keyframes clawx-dialog-overlay-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes clawx-dialog-overlay-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes clawx-dialog-content-in { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.985); + } + + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes clawx-dialog-content-out { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + to { + opacity: 0; + transform: translate(-50%, -50%) scale(0.99); + } } /* Custom scrollbar: keep scrollbars visually quiet until the user hovers a scroll area. */ diff --git a/src/types/agent.ts b/src/types/agent.ts index b286c799..e87c7d63 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -1,22 +1 @@ -export interface AgentSummary { - id: string; - name: string; - isDefault: boolean; - modelDisplay: string; - modelRef?: string | null; - overrideModelRef?: string | null; - inheritedModel: boolean; - workspace: string; - agentDir: string; - mainSessionKey: string; - channelTypes: string[]; -} - -export interface AgentsSnapshot { - agents: AgentSummary[]; - defaultAgentId: string; - defaultModelRef?: string | null; - configuredChannelTypes: string[]; - channelOwners: Record; - channelAccountOwners: Record; -} +export type * from '@shared/types/agent'; diff --git a/src/types/channel.ts b/src/types/channel.ts index aff73225..dd020136 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -1,568 +1 @@ -/** - * Channel Type Definitions - * Types for messaging channels (WhatsApp, Telegram, etc.) - */ - -/** - * Supported channel types - */ -export type ChannelType = - | 'whatsapp' - | 'wechat' - | 'dingtalk' - | 'telegram' - | 'discord' - | 'signal' - | 'feishu' - | 'wecom' - | 'imessage' - | 'matrix' - | 'line' - | 'msteams' - | 'googlechat' - | 'mattermost' - | 'qqbot'; - -/** - * Channel connection status - */ -export type ChannelStatus = 'connected' | 'disconnected' | 'connecting' | 'degraded' | 'error'; - -/** - * Channel connection type - */ -export type ChannelConnectionType = 'token' | 'qr' | 'oauth' | 'webhook'; - -/** - * Channel data structure - */ -export interface Channel { - id: string; - type: ChannelType; - name: string; - status: ChannelStatus; - accountId?: string; - lastActivity?: string; - error?: string; - avatar?: string; - metadata?: Record; -} - -/** - * Channel configuration field definition - */ -export interface ChannelConfigField { - key: string; - label: string; - type: 'text' | 'password' | 'select'; - placeholder?: string; - required?: boolean; - envVar?: string; - description?: string; - options?: { value: string; label: string }[]; -} - -/** - * Channel metadata with configuration info - */ -export interface ChannelMeta { - id: ChannelType; - name: string; - icon: string; - description: string; - connectionType: ChannelConnectionType; - docsUrl: string; - configFields: ChannelConfigField[]; - instructions: string[]; - isPlugin?: boolean; -} - -/** - * Channel icons mapping - */ -export const CHANNEL_ICONS: Record = { - whatsapp: '📱', - wechat: '💬', - dingtalk: '💬', - telegram: '✈️', - discord: '🎮', - signal: '🔒', - feishu: '🐦', - wecom: '💼', - imessage: '💬', - matrix: '🔗', - line: '🟢', - msteams: '👔', - googlechat: '💭', - mattermost: '💠', - qqbot: '🐧', -}; - -/** - * Channel display names - */ -export const CHANNEL_NAMES: Record = { - whatsapp: 'WhatsApp', - wechat: 'WeChat', - dingtalk: 'DingTalk', - telegram: 'Telegram', - discord: 'Discord', - signal: 'Signal', - feishu: 'Feishu / Lark', - wecom: 'WeCom', - imessage: 'iMessage', - matrix: 'Matrix', - line: 'LINE', - msteams: 'Microsoft Teams', - googlechat: 'Google Chat', - mattermost: 'Mattermost', - qqbot: 'QQ Bot', -}; - -/** - * Channel metadata with configuration information - */ -export const CHANNEL_META: Record = { - qqbot: { - id: 'qqbot', - name: 'QQ Bot', - icon: '🐧', - description: 'channels:meta.qqbot.description', - connectionType: 'token', - docsUrl: 'channels:meta.qqbot.docsUrl', - configFields: [ - { - key: 'appId', - label: 'channels:meta.qqbot.fields.appId.label', - type: 'text', - placeholder: 'channels:meta.qqbot.fields.appId.placeholder', - required: true, - }, - { - key: 'clientSecret', - label: 'channels:meta.qqbot.fields.clientSecret.label', - type: 'password', - placeholder: 'channels:meta.qqbot.fields.clientSecret.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.qqbot.instructions.0', - 'channels:meta.qqbot.instructions.1', - 'channels:meta.qqbot.instructions.2', - ], - }, - dingtalk: { - id: 'dingtalk', - name: 'DingTalk', - icon: '💬', - description: 'channels:meta.dingtalk.description', - connectionType: 'token', - docsUrl: 'channels:meta.dingtalk.docsUrl', - configFields: [ - { - key: 'clientId', - label: 'channels:meta.dingtalk.fields.clientId.label', - type: 'text', - placeholder: 'channels:meta.dingtalk.fields.clientId.placeholder', - required: true, - }, - { - key: 'clientSecret', - label: 'channels:meta.dingtalk.fields.clientSecret.label', - type: 'password', - placeholder: 'channels:meta.dingtalk.fields.clientSecret.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.dingtalk.instructions.0', - 'channels:meta.dingtalk.instructions.1', - 'channels:meta.dingtalk.instructions.2', - ], - isPlugin: true, - }, - wecom: { - id: 'wecom', - name: 'WeCom', - icon: '💼', - description: 'channels:meta.wecom.description', - connectionType: 'token', - docsUrl: 'channels:meta.wecom.docsUrl', - configFields: [ - { - key: 'botId', - label: 'channels:meta.wecom.fields.botId.label', - type: 'text', - placeholder: 'channels:meta.wecom.fields.botId.placeholder', - required: true, - }, - { - key: 'secret', - label: 'channels:meta.wecom.fields.secret.label', - type: 'password', - placeholder: 'channels:meta.wecom.fields.secret.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.wecom.instructions.0', - 'channels:meta.wecom.instructions.1', - 'channels:meta.wecom.instructions.2', - ], - isPlugin: true, - }, - telegram: { - id: 'telegram', - name: 'Telegram', - icon: '✈️', - description: 'channels:meta.telegram.description', - connectionType: 'token', - docsUrl: 'channels:meta.telegram.docsUrl', - configFields: [ - { - key: 'botToken', - label: 'channels:meta.telegram.fields.botToken.label', - type: 'password', - placeholder: 'channels:meta.telegram.fields.botToken.placeholder', - required: true, - envVar: 'TELEGRAM_BOT_TOKEN', - }, - { - key: 'allowedUsers', - label: 'channels:meta.telegram.fields.allowedUsers.label', - type: 'text', - placeholder: 'channels:meta.telegram.fields.allowedUsers.placeholder', - description: 'channels:meta.telegram.fields.allowedUsers.description', - required: true, - }, - ], - instructions: [ - 'channels:meta.telegram.instructions.0', - 'channels:meta.telegram.instructions.1', - 'channels:meta.telegram.instructions.2', - 'channels:meta.telegram.instructions.3', - 'channels:meta.telegram.instructions.4', - ], - }, - discord: { - id: 'discord', - name: 'Discord', - icon: '🎮', - description: 'channels:meta.discord.description', - connectionType: 'token', - docsUrl: 'channels:meta.discord.docsUrl', - configFields: [ - { - key: 'token', - label: 'channels:meta.discord.fields.token.label', - type: 'password', - placeholder: 'channels:meta.discord.fields.token.placeholder', - required: true, - envVar: 'DISCORD_BOT_TOKEN', - }, - { - key: 'guildId', - label: 'channels:meta.discord.fields.guildId.label', - type: 'text', - placeholder: 'channels:meta.discord.fields.guildId.placeholder', - required: true, - description: 'channels:meta.discord.fields.guildId.description', - }, - { - key: 'channelId', - label: 'channels:meta.discord.fields.channelId.label', - type: 'text', - placeholder: 'channels:meta.discord.fields.channelId.placeholder', - required: false, - description: 'channels:meta.discord.fields.channelId.description', - }, - ], - instructions: [ - 'channels:meta.discord.instructions.0', - 'channels:meta.discord.instructions.1', - 'channels:meta.discord.instructions.2', - 'channels:meta.discord.instructions.3', - 'channels:meta.discord.instructions.4', - 'channels:meta.discord.instructions.5', - ], - }, - - whatsapp: { - id: 'whatsapp', - name: 'WhatsApp', - icon: '📱', - description: 'channels:meta.whatsapp.description', - connectionType: 'qr', - docsUrl: 'channels:meta.whatsapp.docsUrl', - configFields: [], - instructions: [ - 'channels:meta.whatsapp.instructions.0', - 'channels:meta.whatsapp.instructions.1', - 'channels:meta.whatsapp.instructions.2', - 'channels:meta.whatsapp.instructions.3', - ], - }, - wechat: { - id: 'wechat', - name: 'WeChat', - icon: '💬', - description: 'channels:meta.wechat.description', - connectionType: 'qr', - docsUrl: 'channels:meta.wechat.docsUrl', - configFields: [], - instructions: [ - 'channels:meta.wechat.instructions.0', - 'channels:meta.wechat.instructions.1', - 'channels:meta.wechat.instructions.2', - 'channels:meta.wechat.instructions.3', - ], - isPlugin: true, - }, - signal: { - id: 'signal', - name: 'Signal', - icon: '🔒', - description: 'channels:meta.signal.description', - connectionType: 'token', - docsUrl: 'channels:meta.signal.docsUrl', - configFields: [ - { - key: 'phoneNumber', - label: 'channels:meta.signal.fields.phoneNumber.label', - type: 'text', - placeholder: 'channels:meta.signal.fields.phoneNumber.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.signal.instructions.0', - 'channels:meta.signal.instructions.1', - 'channels:meta.signal.instructions.2', - ], - }, - feishu: { - id: 'feishu', - name: 'Feishu / Lark', - icon: '🐦', - description: 'channels:meta.feishu.description', - connectionType: 'token', - docsUrl: 'channels:meta.feishu.docsUrl', - configFields: [ - { - key: 'appId', - label: 'channels:meta.feishu.fields.appId.label', - type: 'text', - placeholder: 'channels:meta.feishu.fields.appId.placeholder', - required: true, - envVar: 'FEISHU_APP_ID', - }, - { - key: 'appSecret', - label: 'channels:meta.feishu.fields.appSecret.label', - type: 'password', - placeholder: 'channels:meta.feishu.fields.appSecret.placeholder', - required: true, - envVar: 'FEISHU_APP_SECRET', - }, - ], - instructions: [ - 'channels:meta.feishu.instructions.0', - 'channels:meta.feishu.instructions.1', - 'channels:meta.feishu.instructions.2', - 'channels:meta.feishu.instructions.3', - ], - isPlugin: true, - }, - imessage: { - id: 'imessage', - name: 'iMessage', - icon: '💬', - description: 'channels:meta.imessage.description', - connectionType: 'token', - docsUrl: 'channels:meta.imessage.docsUrl', - configFields: [ - { - key: 'serverUrl', - label: 'channels:meta.imessage.fields.serverUrl.label', - type: 'text', - placeholder: 'channels:meta.imessage.fields.serverUrl.placeholder', - required: true, - }, - { - key: 'password', - label: 'channels:meta.imessage.fields.password.label', - type: 'password', - placeholder: 'channels:meta.imessage.fields.password.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.imessage.instructions.0', - 'channels:meta.imessage.instructions.1', - 'channels:meta.imessage.instructions.2', - ], - }, - matrix: { - id: 'matrix', - name: 'Matrix', - icon: '🔗', - description: 'channels:meta.matrix.description', - connectionType: 'token', - docsUrl: 'channels:meta.matrix.docsUrl', - configFields: [ - { - key: 'homeserver', - label: 'channels:meta.matrix.fields.homeserver.label', - type: 'text', - placeholder: 'channels:meta.matrix.fields.homeserver.placeholder', - required: true, - }, - { - key: 'accessToken', - label: 'channels:meta.matrix.fields.accessToken.label', - type: 'password', - placeholder: 'channels:meta.matrix.fields.accessToken.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.matrix.instructions.0', - 'channels:meta.matrix.instructions.1', - 'channels:meta.matrix.instructions.2', - ], - isPlugin: true, - }, - line: { - id: 'line', - name: 'LINE', - icon: '🟢', - description: 'channels:meta.line.description', - connectionType: 'token', - docsUrl: 'channels:meta.line.docsUrl', - configFields: [ - { - key: 'channelAccessToken', - label: 'channels:meta.line.fields.channelAccessToken.label', - type: 'password', - placeholder: 'channels:meta.line.fields.channelAccessToken.placeholder', - required: true, - envVar: 'LINE_CHANNEL_ACCESS_TOKEN', - }, - { - key: 'channelSecret', - label: 'channels:meta.line.fields.channelSecret.label', - type: 'password', - placeholder: 'channels:meta.line.fields.channelSecret.placeholder', - required: true, - envVar: 'LINE_CHANNEL_SECRET', - }, - ], - instructions: [ - 'channels:meta.line.instructions.0', - 'channels:meta.line.instructions.1', - 'channels:meta.line.instructions.2', - ], - isPlugin: true, - }, - msteams: { - id: 'msteams', - name: 'Microsoft Teams', - icon: '👔', - description: 'channels:meta.msteams.description', - connectionType: 'token', - docsUrl: 'channels:meta.msteams.docsUrl', - configFields: [ - { - key: 'appId', - label: 'channels:meta.msteams.fields.appId.label', - type: 'text', - placeholder: 'channels:meta.msteams.fields.appId.placeholder', - required: true, - envVar: 'MSTEAMS_APP_ID', - }, - { - key: 'appPassword', - label: 'channels:meta.msteams.fields.appPassword.label', - type: 'password', - placeholder: 'channels:meta.msteams.fields.appPassword.placeholder', - required: true, - envVar: 'MSTEAMS_APP_PASSWORD', - }, - ], - instructions: [ - 'channels:meta.msteams.instructions.0', - 'channels:meta.msteams.instructions.1', - 'channels:meta.msteams.instructions.2', - 'channels:meta.msteams.instructions.3', - ], - isPlugin: true, - }, - googlechat: { - id: 'googlechat', - name: 'Google Chat', - icon: '💭', - description: 'channels:meta.googlechat.description', - connectionType: 'webhook', - docsUrl: 'channels:meta.googlechat.docsUrl', - configFields: [ - { - key: 'serviceAccountKey', - label: 'channels:meta.googlechat.fields.serviceAccountKey.label', - type: 'text', - placeholder: 'channels:meta.googlechat.fields.serviceAccountKey.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.googlechat.instructions.0', - 'channels:meta.googlechat.instructions.1', - 'channels:meta.googlechat.instructions.2', - 'channels:meta.googlechat.instructions.3', - ], - }, - mattermost: { - id: 'mattermost', - name: 'Mattermost', - icon: '💠', - description: 'channels:meta.mattermost.description', - connectionType: 'token', - docsUrl: 'channels:meta.mattermost.docsUrl', - configFields: [ - { - key: 'serverUrl', - label: 'channels:meta.mattermost.fields.serverUrl.label', - type: 'text', - placeholder: 'channels:meta.mattermost.fields.serverUrl.placeholder', - required: true, - }, - { - key: 'botToken', - label: 'channels:meta.mattermost.fields.botToken.label', - type: 'password', - placeholder: 'channels:meta.mattermost.fields.botToken.placeholder', - required: true, - }, - ], - instructions: [ - 'channels:meta.mattermost.instructions.0', - 'channels:meta.mattermost.instructions.1', - 'channels:meta.mattermost.instructions.2', - ], - isPlugin: true, - }, -}; - -/** - * Get primary supported channels (non-plugin, commonly used) - */ -export function getPrimaryChannels(): ChannelType[] { - return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot']; -} - -/** - * Get all available channels including plugins - */ -export function getAllChannels(): ChannelType[] { - return Object.keys(CHANNEL_META) as ChannelType[]; -} +export * from '@shared/types/channel'; diff --git a/src/types/cron.ts b/src/types/cron.ts index 532ecfad..cb85988e 100644 --- a/src/types/cron.ts +++ b/src/types/cron.ts @@ -1,91 +1 @@ -/** - * Cron Job Type Definitions - * Types for scheduled tasks - */ - -import { ChannelType } from './channel'; - -export type CronJobDeliveryMode = 'none' | 'announce'; - -export interface CronJobDelivery { - mode: CronJobDeliveryMode; - channel?: ChannelType | string; - to?: string; - accountId?: string; -} - -/** - * Cron job target (where to send the result) - */ -export interface CronJobTarget { - channelType: ChannelType | string; - channelId: string; - channelName: string; - recipient?: string; -} - -/** - * Cron job last run info - */ -export interface CronJobLastRun { - time: string; - success: boolean; - error?: string; - duration?: number; -} - -/** - * Gateway CronSchedule object format - */ -export type CronSchedule = - | { kind: 'at'; at: string } - | { kind: 'every'; everyMs: number; anchorMs?: number } - | { kind: 'cron'; expr: string; tz?: string }; - -/** - * Cron job data structure - * schedule can be a plain cron string or a Gateway CronSchedule object - */ -export interface CronJob { - id: string; - name: string; - message: string; - schedule: string | CronSchedule; - delivery?: CronJobDelivery; - target?: CronJobTarget; - enabled: boolean; - createdAt: string; - updatedAt: string; - lastRun?: CronJobLastRun; - nextRun?: string; - agentId: string; -} - -/** - * Input for creating a cron job from the UI. - */ -export interface CronJobCreateInput { - name: string; - message: string; - schedule: string; - delivery?: CronJobDelivery; - enabled?: boolean; - agentId?: string; -} - -/** - * Input for updating a cron job - */ -export interface CronJobUpdateInput { - name?: string; - message?: string; - schedule?: string; - delivery?: CronJobDelivery; - enabled?: boolean; - agentId?: string; -} - -/** - * Schedule type for UI picker - */ -export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom'; +export type * from '@shared/types/cron'; diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e4ea69bf..9c80233b 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -3,6 +3,8 @@ * Types for the APIs exposed via contextBridge */ +import type { HostResponse, HostRequest } from '../lib/host-api-types'; + export interface IpcRenderer { invoke(channel: string, ...args: unknown[]): Promise; on(channel: string, callback: (...args: unknown[]) => void): (() => void) | void; @@ -18,9 +20,16 @@ export interface ElectronAPI { isDev: boolean; } +export type HostInvokeErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL'; +export type HostInvokeRequest = HostRequest; +export type HostInvokeResponse = HostResponse; + declare global { interface Window { electron: ElectronAPI; + clawx?: { + hostInvoke: (request: HostInvokeRequest) => Promise>; + }; } } diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 13318c7d..80d9100a 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -1,101 +1 @@ -/** - * Gateway Type Definitions - * Types for Gateway communication and data structures - */ - -/** - * Gateway connection status - */ -export interface GatewayStatus { - state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; - port: number; - pid?: number; - uptime?: number; - error?: string; - connectedAt?: number; - version?: string; - reconnectAttempts?: number; - /** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */ - gatewayReady?: boolean; -} - -/** - * Gateway RPC response - */ -export interface GatewayRpcResponse { - success: boolean; - result?: T; - error?: string; -} - -/** - * Gateway health check response - */ -export interface GatewayCapabilityProbe { - state: 'unknown' | 'healthy' | 'degraded'; - checkedAt?: number; - durationMs?: number; - error?: string; - payload?: unknown; -} - -export interface GatewayCapabilitySnapshot { - core: { - process: GatewayStatus['state']; - transport: 'connected' | 'disconnected'; - rpcRouter: 'unknown' | 'ready' | 'blocked'; - lastProbe?: { - ok: boolean; - checkedAt: number; - durationMs?: number; - error?: string; - }; - }; - openclawHealth: GatewayCapabilityProbe; - openclawStatus: GatewayCapabilityProbe; - presence: GatewayCapabilityProbe; - channels: GatewayCapabilityProbe; - memory: GatewayCapabilityProbe; - diagnostics: { - lastAliveAt?: number; - lastRpcSuccessAt?: number; - lastRpcFailureAt?: number; - lastRpcFailureMethod?: string; - lastHeartbeatTimeoutAt?: number; - consecutiveHeartbeatMisses: number; - lastSocketCloseAt?: number; - lastSocketCloseCode?: number; - consecutiveRpcFailures: number; - }; -} - -export interface GatewayHealth { - ok: boolean; - error?: string; - uptime?: number; - version?: string; - capabilities?: GatewayCapabilitySnapshot; - openclawHealth?: unknown; - presence?: unknown; -} - -/** - * Gateway notification (server-initiated event) - */ -export interface GatewayNotification { - method: string; - params?: unknown; -} - -/** - * Provider configuration - */ -export interface ProviderConfig { - id: string; - name: string; - type: 'openai' | 'anthropic' | 'ollama' | 'custom'; - apiKey?: string; - baseUrl?: string; - model?: string; - enabled: boolean; -} +export type * from '@shared/types/gateway'; diff --git a/src/types/skill.ts b/src/types/skill.ts index 4f663db3..ec43bb41 100644 --- a/src/types/skill.ts +++ b/src/types/skill.ts @@ -1,85 +1 @@ -/** - * Skill Type Definitions - * Types for skills/plugins - */ - -/** - * Skill data structure - */ -export interface Skill { - id: string; - slug?: string; - name: string; - description: string; - enabled: boolean; - icon?: string; - version?: string; - author?: string; - configurable?: boolean; - config?: Record; - isCore?: boolean; - isBundled?: boolean; - dependencies?: string[]; - source?: string; - baseDir?: string; - filePath?: string; - marketplace?: { - provider: string; - slug?: string; - installedVersion?: string; - manifestPath?: string; - originPath?: string; - }; -} - -export interface QuickAccessSkill { - name: string; - description: string; - source: 'workspace' | 'openclaw' | 'agents' | 'legacy'; - sourceLabel: string; - manifestPath: string; - baseDir: string; -} - -/** - * Skill bundle (preset skill collection) - */ -export interface SkillBundle { - id: string; - name: string; - nameZh: string; - description: string; - descriptionZh: string; - icon: string; - skills: string[]; - recommended?: boolean; -} - - -/** - * Marketplace skill data - */ -export interface MarketplaceSkill { - slug: string; - name: string; - description: string; - version: string; - author?: string; - downloads?: number; - stars?: number; -} - -/** - * Skill configuration schema - */ -export interface SkillConfigSchema { - type: 'object'; - properties: Record; - required?: string[]; -} +export type * from '@shared/types/skill'; diff --git a/tests/e2e/channels-account-id-validation.spec.ts b/tests/e2e/channels-account-id-validation.spec.ts index e06ebc18..0a2cbcbc 100644 --- a/tests/e2e/channels-account-id-validation.spec.ts +++ b/tests/e2e/channels-account-id-validation.spec.ts @@ -39,32 +39,36 @@ test.describe('Channels account ID validation', () => { await electronApp.evaluate(({ ipcMain }, responses) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__clawxE2eChannelConfigSaveCount = 0; - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { - const method = request?.method ?? 'GET'; - const path = request?.path ?? ''; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + const respond = (id: unknown, data: unknown) => ({ id: typeof id === 'string' ? id : undefined, ok: true, data }); - if (path === '/api/channels/accounts' && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: responses.channelsAccounts } }; + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event, request: { + id?: string; + module?: string; + action?: string; + }) => { + + if (request?.module === 'channels' && request.action === 'accounts') { + return respond(request.id, responses.channelsAccounts); } - if (path === '/api/agents' && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: responses.agents } }; + if (request?.module === 'agents' && request.action === 'list') { + return respond(request.id, responses.agents); } - if (path === '/api/channels/credentials/validate' && method === 'POST') { - return { ok: true, data: { status: 200, ok: true, json: responses.credentialsValidate } }; + if (request?.module === 'channels' && request.action === 'validateCredentials') { + return respond(request.id, responses.credentialsValidate); } - if (path === '/api/channels/config' && method === 'POST') { + if (request?.module === 'channels' && request.action === 'saveConfig') { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__clawxE2eChannelConfigSaveCount += 1; - return { ok: true, data: { status: 200, ok: true, json: responses.channelConfig } }; + return respond(request.id, responses.channelConfig); } - if (path.startsWith('/api/channels/config/') && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } }; + if (request?.module === 'channels' && request.action === 'formValues') { + return respond(request.id, { success: true, values: {} }); } - return { - ok: false, - error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` }, - }; + return originalHostInvoke?.(event, request) ?? respond(request?.id, {}); }); }, testConfigResponses); diff --git a/tests/e2e/channels-binding-regression.spec.ts b/tests/e2e/channels-binding-regression.spec.ts index 1fa7558f..ed2cd243 100644 --- a/tests/e2e/channels-binding-regression.spec.ts +++ b/tests/e2e/channels-binding-regression.spec.ts @@ -33,25 +33,33 @@ test.describe('Channels binding regression', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__clawxE2eBindingRegression = state; - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { - const method = request?.method ?? 'GET'; - const path = request?.path ?? ''; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + const respond = (id: unknown, data: unknown) => ({ id: typeof id === 'string' ? id : undefined, ok: true, data }); + + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event, request: { + id?: string; + module?: string; + action?: string; + payload?: Record; + }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const current = (globalThis as any).__clawxE2eBindingRegression as typeof state; - if (path === '/api/channels/accounts' && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: { success: true, channels: current.channels } } }; + if (request?.module === 'channels' && request.action === 'accounts') { + return respond(request.id, { success: true, channels: current.channels }); } - if (path === '/api/agents' && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: { success: true, agents: current.agents } } }; + if (request?.module === 'agents' && request.action === 'list') { + return respond(request.id, { success: true, agents: current.agents }); } - if (path === '/api/channels/credentials/validate' && method === 'POST') { - return { ok: true, data: { status: 200, ok: true, json: { success: true, valid: true, warnings: [] } } }; + if (request?.module === 'channels' && request.action === 'validateCredentials') { + return respond(request.id, { success: true, valid: true, warnings: [] }); } - if (path === '/api/channels/config' && method === 'POST') { + if (request?.module === 'channels' && request.action === 'saveConfig') { current.saveCount += 1; - const body = JSON.parse(request?.body ?? '{}') as { accountId?: string }; + const body = request.payload ?? {}; const accountId = body.accountId || current.nextAccountId; const feishu = current.channels[0]; if (!feishu.accounts.some((account) => account.accountId === accountId)) { @@ -63,11 +71,11 @@ test.describe('Channels binding regression', () => { isDefault: false, }); } - return { ok: true, data: { status: 200, ok: true, json: { success: true } } }; + return respond(request.id, { success: true }); } - if (path === '/api/channels/binding' && method === 'PUT') { + if (request?.module === 'channels' && request.action === 'bindingSave') { current.bindingCount += 1; - const body = JSON.parse(request?.body ?? '{}') as { channelType?: string; accountId?: string; agentId?: string }; + const body = request.payload ?? {}; if (body.channelType === 'feishu' && body.accountId) { const feishu = current.channels[0]; const account = feishu.accounts.find((entry) => entry.accountId === body.accountId); @@ -75,20 +83,17 @@ test.describe('Channels binding regression', () => { account.agentId = body.agentId; } } - return { ok: true, data: { status: 200, ok: true, json: { success: true } } }; + return respond(request.id, { success: true }); } - if (path === '/api/channels/binding' && method === 'DELETE') { + if (request?.module === 'channels' && request.action === 'bindingDelete') { current.bindingCount += 1; - return { ok: true, data: { status: 200, ok: true, json: { success: true } } }; + return respond(request.id, { success: true }); } - if (path.startsWith('/api/channels/config/') && method === 'GET') { - return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } }; + if (request?.module === 'channels' && request.action === 'formValues') { + return respond(request.id, { success: true, values: {} }); } - return { - ok: false, - error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` }, - }; + return originalHostInvoke?.(event, request) ?? respond(request?.id, {}); }); }); diff --git a/tests/e2e/channels-health-diagnostics.spec.ts b/tests/e2e/channels-health-diagnostics.spec.ts index 96b8df61..d35f0e01 100644 --- a/tests/e2e/channels-health-diagnostics.spec.ts +++ b/tests/e2e/channels-health-diagnostics.spec.ts @@ -3,71 +3,49 @@ import { completeSetup, expect, test } from './fixtures/electron'; test.describe('Channels health diagnostics', () => { test('does not flash a stale gateway-not-running banner while status is running', async ({ electronApp, page }) => { await electronApp.evaluate(({ ipcMain }) => { - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { - const method = request?.method ?? 'GET'; - const path = request?.path ?? ''; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + const respond = (id: unknown, data: unknown) => ({ id: typeof id === 'string' ? id : undefined, ok: true, data }); - if (path.startsWith('/api/channels/accounts') && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { - success: true, - gatewayHealth: { - state: 'degraded', - reasons: ['gateway_not_running'], - consecutiveHeartbeatMisses: 0, - }, - channels: [ + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event, request: { id?: string; module?: string; action?: string }) => { + if (request?.module === 'channels' && request.action === 'accounts') { + return respond(request.id, { + success: true, + gatewayHealth: { + state: 'degraded', + reasons: ['gateway_not_running'], + consecutiveHeartbeatMisses: 0, + }, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'connected', + accounts: [ { - channelType: 'feishu', - defaultAccountId: 'default', + accountId: 'default', + name: 'Primary Account', + configured: true, status: 'connected', - accounts: [ - { - accountId: 'default', - name: 'Primary Account', - configured: true, - status: 'connected', - isDefault: true, - }, - ], + isDefault: true, }, ], }, - }, - }; + ], + }); } - if (path === '/api/gateway/status' && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { state: 'running', port: 18789 }, - }, - }; + if (request?.module === 'gateway' && request.action === 'status') { + return respond(request.id, { state: 'running', port: 18789 }); } - if (path === '/api/agents' && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { success: true, agents: [] }, - }, - }; + if (request?.module === 'agents' && request.action === 'list') { + return respond(request.id, { success: true, agents: [] }); } - return { - ok: false, - error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` }, - }; + return originalHostInvoke?.(event, request) ?? respond(request?.id, {}); }); }); @@ -90,111 +68,76 @@ test.describe('Channels health diagnostics', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__clawxE2eChannelHealth = state; - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { - const method = request?.method ?? 'GET'; - const path = request?.path ?? ''; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + const respond = (id: unknown, data: unknown) => ({ id: typeof id === 'string' ? id : undefined, ok: true, data }); + + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event, request: { id?: string; module?: string; action?: string }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const current = (globalThis as any).__clawxE2eChannelHealth as typeof state; - if (path.startsWith('/api/channels/accounts') && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { - success: true, - gatewayHealth: { - state: 'degraded', - reasons: ['channels_status_timeout'], - consecutiveHeartbeatMisses: 1, - }, - channels: [ + if (request?.module === 'channels' && request.action === 'accounts') { + return respond(request.id, { + success: true, + gatewayHealth: { + state: 'degraded', + reasons: ['channels_status_timeout'], + consecutiveHeartbeatMisses: 1, + }, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'degraded', + statusReason: 'channels_status_timeout', + accounts: [ { - channelType: 'feishu', - defaultAccountId: 'default', + accountId: 'default', + name: 'Primary Account', + configured: true, status: 'degraded', statusReason: 'channels_status_timeout', - accounts: [ - { - accountId: 'default', - name: 'Primary Account', - configured: true, - status: 'degraded', - statusReason: 'channels_status_timeout', - isDefault: true, - }, - ], + isDefault: true, }, ], }, - }, - }; + ], + }); } - if (path === '/api/gateway/status' && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { state: 'running', port: 18789 }, - }, - }; + if (request?.module === 'gateway' && request.action === 'status') { + return respond(request.id, { state: 'running', port: 18789 }); } - if (path === '/api/agents' && method === 'GET') { - return { - ok: true, - data: { - status: 200, - ok: true, - json: { success: true, agents: [] }, - }, - }; + if (request?.module === 'agents' && request.action === 'list') { + return respond(request.id, { success: true, agents: [] }); } - if (path === '/api/gateway/restart' && method === 'POST') { + if (request?.module === 'gateway' && request.action === 'restart') { current.restartCount += 1; - return { - ok: true, - data: { - status: 200, - ok: true, - json: { success: true }, - }, - }; + return respond(request.id, { success: true }); } - if (path === '/api/diagnostics/gateway-snapshot' && method === 'GET') { + if (request?.module === 'diagnostics' && request.action === 'gatewaySnapshot') { current.diagnosticsCount += 1; - return { - ok: true, - data: { - status: 200, - ok: true, - json: { - capturedAt: 123, - platform: 'darwin', - gateway: { - state: 'degraded', - reasons: ['channels_status_timeout'], - consecutiveHeartbeatMisses: 1, - }, - channels: [], - clawxLogTail: 'clawx-log', - gatewayLogTail: 'gateway-log', - gatewayErrLogTail: '', + return respond(request.id, { + capturedAt: 123, + platform: 'darwin', + gateway: { + state: 'degraded', + reasons: ['channels_status_timeout'], + consecutiveHeartbeatMisses: 1, }, - }, - }; + channels: [], + clawxLogTail: 'clawx-log', + gatewayLogTail: 'gateway-log', + gatewayErrLogTail: '', + }); } - return { - ok: false, - error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` }, - }; + return originalHostInvoke?.(event, request) ?? respond(request?.id, {}); }); }); diff --git a/tests/e2e/chat-model-picker.spec.ts b/tests/e2e/chat-model-picker.spec.ts index c5f3da8d..6ba80f63 100644 --- a/tests/e2e/chat-model-picker.spec.ts +++ b/tests/e2e/chat-model-picker.spec.ts @@ -14,13 +14,13 @@ test.describe('ClawX chat model picker', () => { let currentModelRef = refs.alphaModelRef; const hostRequests: Array<{ path: string; method: string; body: unknown }> = []; const now = new Date().toISOString(); - const makeResponse = (json: unknown, status = 200) => ({ + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + const makeResponse = (id: unknown, data: unknown) => ({ + id: typeof id === 'string' ? id : undefined, ok: true, - data: { - status, - ok: status >= 200 && status < 300, - json, - }, + data, }); const agentsSnapshot = () => ({ @@ -60,25 +60,49 @@ test.describe('ClawX chat model picker', () => { return { success: true, result: {} }; }); - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string; body?: string | null }) => { - const path = request?.path ?? ''; - const method = request?.method ?? 'GET'; - const body = request?.body ? JSON.parse(request.body) : null; - hostRequests.push({ path, method, body }); + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: { + id?: string; + module?: string; + action?: string; + payload?: Record; + }) => { + const body = request?.payload ?? null; + hostRequests.push({ + path: `${request?.module ?? ''}:${request?.action ?? ''}`, + method: 'HOST', + body, + }); - if (path === '/api/gateway/status' && method === 'GET') { - return makeResponse({ state: 'running', port: 18789, pid: 12345, gatewayReady: true }); + if (request?.module === 'gateway' && request.action === 'status') { + return makeResponse(request.id, { state: 'running', port: 18789, pid: 12345, gatewayReady: true }); } - if (path === '/api/agents' && method === 'GET') { - return makeResponse(agentsSnapshot()); + if (request?.module === 'gateway' && request.action === 'rpc') { + const method = typeof body?.method === 'string' ? body.method : ''; + const params = body?.params ?? null; + hostRequests.push({ path: `gateway:${method}`, method: 'RPC', body: params }); + if (method === 'sessions.list') { + return makeResponse(request.id, { success: true, result: { sessions: [{ key: 'agent:main:main', displayName: 'main' }] } }); + } + if (method === 'chat.history') { + return makeResponse(request.id, { success: true, result: { messages: [] } }); + } + return makeResponse(request.id, { success: true, result: {} }); } - if (path === '/api/agents/main/model' && method === 'PUT') { - currentModelRef = body?.modelRef ?? refs.alphaModelRef; - return makeResponse(agentsSnapshot()); + if (request?.module === 'agents' && request.action === 'list') { + return makeResponse(request.id, agentsSnapshot()); } - if (path === '/api/provider-accounts' && method === 'GET') { - return makeResponse([ + if (request?.module === 'agents' && request.action === 'updateModel') { + currentModelRef = typeof body?.modelRef === 'string' ? body.modelRef : refs.alphaModelRef; + hostRequests.push({ + path: '/api/agents/main/model', + method: 'PUT', + body: { modelRef: currentModelRef }, + }); + return makeResponse(request.id, agentsSnapshot()); + } + if (request?.module === 'providers' && request.action === 'accounts') { + return makeResponse(request.id, [ { id: 'alpha1234', vendorId: 'custom', @@ -105,20 +129,26 @@ test.describe('ClawX chat model picker', () => { }, ]); } - if (path === '/api/providers' && method === 'GET') { - return makeResponse([ + if (request?.module === 'providers' && request.action === 'list') { + return makeResponse(request.id, [ { id: 'alpha1234', type: 'custom', name: 'Alpha', enabled: true, hasKey: true, keyMasked: 'sk-***', createdAt: now, updatedAt: now }, { id: 'beta5678', type: 'custom', name: 'Beta', enabled: true, hasKey: true, keyMasked: 'sk-***', createdAt: now, updatedAt: now }, ]); } - if (path === '/api/provider-vendors' && method === 'GET') { - return makeResponse([]); + if (request?.module === 'providers' && request.action === 'accountKeyInfo') { + return makeResponse(request.id, [ + { accountId: 'alpha1234', hasKey: true, keyMasked: 'sk-***' }, + { accountId: 'beta5678', hasKey: true, keyMasked: 'sk-***' }, + ]); } - if (path === '/api/provider-accounts/default' && method === 'GET') { - return makeResponse({ accountId: 'alpha1234' }); + if (request?.module === 'providers' && request.action === 'vendors') { + return makeResponse(request.id, []); + } + if (request?.module === 'providers' && request.action === 'getDefaultAccount') { + return makeResponse(request.id, { accountId: 'alpha1234' }); } - return makeResponse({}); + return originalHostInvoke?.(event, request) ?? makeResponse(request?.id, {}); }); (globalThis as typeof globalThis & { __chatModelPickerRequests?: typeof hostRequests }).__chatModelPickerRequests = hostRequests; @@ -150,6 +180,8 @@ test.describe('ClawX chat model picker', () => { expect(requests.some((request) => request.path === '/api/gateway/restart' || request.path === '/api/gateway/start' + || request.path === 'gateway:restart' + || request.path === 'gateway:start' || request.path === 'gateway:config.patch' )).toBe(false); } finally { diff --git a/tests/e2e/dialog-transitions.spec.ts b/tests/e2e/dialog-transitions.spec.ts new file mode 100644 index 00000000..5e59b09f --- /dev/null +++ b/tests/e2e/dialog-transitions.spec.ts @@ -0,0 +1,220 @@ +import type { Locator } from '@playwright/test'; +import { closeElectronApp, completeSetup, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const MAIN_SESSION_KEY = 'agent:main:main'; +const SESSIONS_LIST_PAYLOAD = { + includeDerivedTitles: true, + includeLastMessage: true, +}; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +async function expectSubtleDialogAnimation(locator: Locator): Promise { + await expect(locator).toBeVisible(); + await expect(locator).toHaveAttribute('data-state', 'open'); + + const animation = await locator.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + name: style.animationName, + duration: style.animationDuration, + }; + }); + + expect(animation.name).toContain('clawx-dialog-content-in'); + expect(animation.duration).toContain('0.1s'); + + const firstFrameOffset = await locator.evaluate(async (element) => { + const animation = element.getAnimations()[0]; + if (!animation) { + return null; + } + + animation.pause(); + animation.currentTime = 0; + await new Promise(requestAnimationFrame); + + const rect = element.getBoundingClientRect(); + const offset = { + x: rect.left + rect.width / 2 - window.innerWidth / 2, + y: rect.top + rect.height / 2 - window.innerHeight / 2, + }; + + animation.finish(); + return offset; + }); + + expect(firstFrameOffset).not.toBeNull(); + expect(Math.abs(firstFrameOffset!.x)).toBeLessThan(8); + expect(Math.abs(firstFrameOffset!.y)).toBeLessThan(12); +} + +test.describe('dialog transitions', () => { + test('uses the shared subtle transition for core modal dialogs', async ({ electronApp, page }) => { + await installIpcMocks(electronApp, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + gatewayRpc: {}, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + }, + }, + [stableStringify(['/api/cron/jobs', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: [], + }, + }, + [stableStringify(['/api/channels/accounts', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, channels: [] }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + agents: [{ + id: 'main', + name: 'Main Agent', + isDefault: true, + modelDisplay: 'Default Model', + modelRef: 'openai/gpt-5.5', + overrideModelRef: null, + inheritedModel: true, + workspace: '/tmp/clawx-main-agent', + agentDir: '/tmp/clawx-main-agent/agent', + mainSessionKey: 'main/default', + channelTypes: [], + }], + defaultAgentId: 'main', + defaultModelRef: 'openai/gpt-5.5', + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }, + }, + }, + }, + }); + + await completeSetup(page); + + await page.getByTestId('sidebar-nav-models').click(); + await page.getByTestId('providers-add-button').click(); + const providerDialog = page.getByTestId('add-provider-dialog'); + await expectSubtleDialogAnimation(providerDialog); + + await page.getByTestId('add-provider-close-button').click(); + await expect(providerDialog).toHaveAttribute('data-state', 'closed'); + await expect(providerDialog).toHaveCount(0); + + await page.getByTestId('sidebar-nav-agents').click(); + await page.getByTestId('agents-add-button').click(); + const agentDialog = page.getByTestId('add-agent-dialog'); + await expectSubtleDialogAnimation(agentDialog); + await page.keyboard.press('Escape'); + await expect(agentDialog).toHaveCount(0); + + await page.getByTestId('sidebar-nav-cron').click(); + await page.getByTestId('cron-new-task-button').click(); + await expectSubtleDialogAnimation(page.getByTestId('cron-task-dialog')); + }); + + test('keeps confirm dialog copy stable while closing', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + const nowMs = Date.now(); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true, connectedAt: nowMs }, + gatewayRpc: { + [stableStringify(['sessions.list', SESSIONS_LIST_PAYLOAD])]: { + sessions: [{ + key: MAIN_SESSION_KEY, + displayName: 'Preserved session', + updatedAt: nowMs, + }], + }, + [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { + messages: [], + }, + [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: { + messages: [], + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true, connectedAt: nowMs }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'Main' }] }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + const sessionRow = page.getByTestId('session-bucket-today').getByText('Preserved session'); + await expect(sessionRow).toBeVisible(); + await sessionRow.hover(); + await page.getByTestId(`sidebar-session-delete-${MAIN_SESSION_KEY}`).click(); + + const confirmDialog = page.getByRole('dialog'); + await expect(confirmDialog).toContainText('Preserved session'); + + await page.getByTestId('confirm-dialog-cancel-button').click(); + await expect(confirmDialog).toHaveAttribute('data-state', 'closed'); + await expect(confirmDialog).toContainText('Preserved session'); + await expect(confirmDialog).toHaveCount(0); + + await expect(sessionRow).toBeVisible(); + await sessionRow.hover(); + await page.getByTestId(`sidebar-session-delete-${MAIN_SESSION_KEY}`).click(); + + await expect(confirmDialog).toContainText('Preserved session'); + + await page.getByTestId('confirm-dialog-confirm-button').click(); + await expect(confirmDialog).toHaveAttribute('data-state', 'closed'); + await expect(confirmDialog).toContainText('Preserved session'); + await expect(confirmDialog).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/fixtures/electron.ts b/tests/e2e/fixtures/electron.ts index 7eee34c9..502f4dfb 100644 --- a/tests/e2e/fixtures/electron.ts +++ b/tests/e2e/fixtures/electron.ts @@ -1,6 +1,6 @@ import electronBinaryPath from 'electron'; import { _electron as electron, expect, test as base, type ElectronApplication, type Page } from '@playwright/test'; -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -114,11 +114,25 @@ async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Pr } } +async function seedE2eSettings(userDataDir: string): Promise { + const settingsPath = join(userDataDir, 'settings.json'); + try { + await access(settingsPath); + return; + } catch { + // Seed only once per isolated profile. Tests that switch language should + // keep their persisted setting across relaunches in the same profile. + } + + await writeFile(settingsPath, JSON.stringify({ language: 'en' }, null, 2), 'utf-8'); +} + async function launchClawXElectron( homeDir: string, userDataDir: string, options: LaunchElectronOptions = {}, ): Promise { + await seedE2eSettings(userDataDir); const hostApiPort = await allocatePort(); const electronEnv = process.platform === 'linux' ? { @@ -128,7 +142,7 @@ async function launchClawXElectron( : {}; return await electron.launch({ executablePath: electronBinaryPath, - args: [electronEntry], + args: ['--lang=en-US', electronEntry], env: { ...process.env, ...electronEnv, @@ -137,6 +151,9 @@ async function launchClawXElectron( APPDATA: join(homeDir, 'AppData', 'Roaming'), LOCALAPPDATA: join(homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: join(homeDir, '.config'), + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + LANGUAGE: 'en', CLAWX_E2E: '1', CLAWX_USER_DATA_DIR: userDataDir, ...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}), @@ -220,32 +237,178 @@ export async function installIpcMocks( return `{${entries.join(',')}}`; }; - if (mockConfig.gatewayRpc) { - ipcMain.removeHandler('gateway:rpc'); - ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => { - const key = stableStringify([method, payload ?? null]); - if (key in mockConfig.gatewayRpc!) { - return mockConfig.gatewayRpc![key]; - } - const fallbackKey = stableStringify([method, null]); - if (fallbackKey in mockConfig.gatewayRpc!) { - return mockConfig.gatewayRpc![fallbackKey]; - } - return { success: true, result: {} }; - }); - } + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + type IpcInvokeHandler = (event: unknown, ...args: unknown[]) => Promise; + const getInvokeHandler = (channel: string): IpcInvokeHandler | undefined => { + return (ipcMain as unknown as { + _invokeHandlers?: Map; + })._invokeHandlers?.get(channel); + }; - if (mockConfig.hostApi) { - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string }) => { - const key = stableStringify([request?.path ?? '', request?.method ?? 'GET']); - if (key in mockConfig.hostApi!) { - return mockConfig.hostApi![key]; + const respond = (id: unknown, data: unknown) => ({ + id: typeof id === 'string' ? id : undefined, + ok: true, + data, + }); + const fail = (id: unknown, message: string) => ({ + id: typeof id === 'string' ? id : undefined, + ok: false, + error: { code: 'INTERNAL', message }, + }); + + const unwrapLegacyResponse = (response: unknown): unknown => { + if (!response || typeof response !== 'object') return response; + const record = response as Record; + const data = record.data; + if (data && typeof data === 'object' && 'json' in (data as Record)) { + return (data as Record).json; + } + return data ?? response; + }; + const respondGatewayRpc = (id: unknown, response: unknown) => { + if (response && typeof response === 'object') { + const record = response as Record; + if (record.success === false) { + return fail(id, String(record.error || 'Gateway RPC failed')); } - return { - ok: true, - data: { status: 200, ok: true, json: {} }, - }; + if (record.success === true && 'result' in record) { + return respond(id, record.result); + } + } + return respond(id, response); + }; + const originalLegacyGatewayRpc = getInvokeHandler('gateway:rpc'); + const originalLegacyFileStat = getInvokeHandler('file:stat'); + const originalLegacyFileReadText = getInvokeHandler('file:readText'); + const getLegacyOverride = (channel: string, original?: IpcInvokeHandler) => { + const current = getInvokeHandler(channel); + return current && current !== original ? current : null; + }; + + const legacyPathForHostRequest = (request: { + module?: string; + action?: string; + payload?: Record; + }): [string, string] | null => { + const payload = request.payload ?? {}; + if (request.module === 'gateway') { + if (request.action === 'status') return ['/api/gateway/status', 'GET']; + if (request.action === 'start') return ['/api/gateway/start', 'POST']; + if (request.action === 'restart') return ['/api/gateway/restart', 'POST']; + } + if (request.module === 'agents' && request.action === 'list') return ['/api/agents', 'GET']; + if (request.module === 'settings' && request.action === 'getAll') return ['/api/settings', 'GET']; + if (request.module === 'channels') { + if (request.action === 'accounts') return ['/api/channels/accounts', 'GET']; + if (request.action === 'validateCredentials') return ['/api/channels/credentials/validate', 'POST']; + if (request.action === 'saveConfig') return ['/api/channels/config', 'POST']; + if (request.action === 'bindingSave') return ['/api/channels/binding', 'PUT']; + if (request.action === 'bindingDelete') return ['/api/channels/binding', 'DELETE']; + if (request.action === 'formValues') { + const channelType = encodeURIComponent(String(payload.channelType ?? '')); + return [`/api/channels/config/${channelType}`, 'GET']; + } + } + if (request.module === 'diagnostics' && request.action === 'gatewaySnapshot') { + return ['/api/diagnostics/gateway-snapshot', 'GET']; + } + if (request.module === 'cron' && request.action === 'list') return ['/api/cron/jobs', 'GET']; + if (request.module === 'skills' && request.action === 'quickAccess') return ['/api/skills/quick-access', 'POST']; + if (request.module === 'files' && request.action === 'thumbnails') return ['/api/files/thumbnails', 'POST']; + if (request.module === 'media') { + if (request.action === 'thumbnails') return ['/api/files/thumbnails', 'POST']; + if (request.action === 'imageGenerationSettings') return ['/api/media/image-generation', 'GET']; + if (request.action === 'saveImageGenerationSettings') return ['/api/media/image-generation', 'PUT']; + } + if (request.module === 'sessions') { + if (request.action === 'history') { + const params = new URLSearchParams(); + if (typeof payload.sessionKey === 'string') params.set('sessionKey', payload.sessionKey); + if (typeof payload.agentId === 'string') params.set('agentId', payload.agentId); + if (typeof payload.sessionId === 'string') params.set('sessionId', payload.sessionId); + if (typeof payload.limit === 'number') params.set('limit', String(payload.limit)); + return [`/api/sessions/transcript?${params.toString()}`, 'GET']; + } + if (request.action === 'summaries') return ['/api/sessions/summaries', 'POST']; + } + return null; + }; + + if (mockConfig.gatewayRpc || mockConfig.hostApi || mockConfig.gatewayStatus) { + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: { + id?: string; + module?: string; + action?: string; + payload?: Record; + }) => { + if (mockConfig.gatewayStatus && request?.module === 'gateway' && request.action === 'status') { + return respond(request.id, mockConfig.gatewayStatus); + } + + if (mockConfig.gatewayRpc && request?.module === 'gateway' && request.action === 'rpc') { + const payload = request.payload ?? {}; + const method = typeof payload.method === 'string' ? payload.method : ''; + const params = 'params' in payload ? payload.params : null; + const key = stableStringify([method, params ?? null]); + if (key in mockConfig.gatewayRpc) return respondGatewayRpc(request.id, mockConfig.gatewayRpc[key]); + if (method === 'sessions.list') { + const emptySessionsListKey = stableStringify([method, {}]); + if (emptySessionsListKey in mockConfig.gatewayRpc) { + return respondGatewayRpc(request.id, mockConfig.gatewayRpc[emptySessionsListKey]); + } + } + const fallbackKey = stableStringify([method, null]); + if (fallbackKey in mockConfig.gatewayRpc) return respondGatewayRpc(request.id, mockConfig.gatewayRpc[fallbackKey]); + const legacyGatewayRpc = getLegacyOverride('gateway:rpc', originalLegacyGatewayRpc); + if (legacyGatewayRpc) { + return respondGatewayRpc( + request.id, + await legacyGatewayRpc(event, method, params, payload.timeoutMs), + ); + } + return respond(request.id, {}); + } + + if (mockConfig.hostApi) { + const typedKey = stableStringify([ + request?.module ?? null, + request?.action ?? null, + request?.payload ?? null, + ]); + if (typedKey in mockConfig.hostApi) { + return respond(request.id, unwrapLegacyResponse(mockConfig.hostApi[typedKey])); + } + + const legacyPath = legacyPathForHostRequest(request ?? {}); + if (legacyPath) { + const key = stableStringify(legacyPath); + if (key in mockConfig.hostApi) { + return respond(request.id, unwrapLegacyResponse(mockConfig.hostApi[key])); + } + } + } + + if (request?.module === 'files') { + const payload = request.payload ?? {}; + const path = typeof payload.path === 'string' ? payload.path : ''; + if (request.action === 'stat') { + const legacyFileStat = getLegacyOverride('file:stat', originalLegacyFileStat); + if (legacyFileStat) { + return respond(request.id, await legacyFileStat(event, path)); + } + } + if (request.action === 'readText') { + const legacyFileReadText = getLegacyOverride('file:readText', originalLegacyFileReadText); + if (legacyFileReadText) { + return respond(request.id, await legacyFileReadText(event, path)); + } + } + } + + return originalHostInvoke?.(event, request) ?? respond(request?.id, {}); }); } diff --git a/tests/e2e/gateway-lifecycle.spec.ts b/tests/e2e/gateway-lifecycle.spec.ts index 0d3c4703..839ed244 100644 --- a/tests/e2e/gateway-lifecycle.spec.ts +++ b/tests/e2e/gateway-lifecycle.spec.ts @@ -9,6 +9,11 @@ function stableStringify(value: unknown): string { return `{${entries.join(',')}}`; } +const SESSIONS_LIST_PAYLOAD = { + includeDerivedTitles: true, + includeLastMessage: true, +}; + test.describe('ClawX gateway lifecycle resilience', () => { test('app remains fully navigable while gateway is disconnected', async ({ page }) => { // In E2E mode, gateway auto-start is skipped, so the app starts @@ -118,6 +123,95 @@ test.describe('ClawX gateway lifecycle resilience', () => { await expect(page.getByTestId('main-layout')).toBeVisible(); }); + test('shows gateway restart progress in the sidebar instead of page-level warnings', async ({ electronApp, page }) => { + await installIpcMocks(electronApp, { + gatewayStatus: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: true }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: true }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'main' }] }, + }, + }, + [stableStringify(['/api/channels/accounts', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, channels: [] }, + }, + }, + [stableStringify(['/api/cron/jobs', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: [], + }, + }, + }, + gatewayRpc: { + [stableStringify(['skills.status', null])]: { success: false, error: 'Gateway not connected' }, + }, + }); + + await completeSetup(page); + + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win?.webContents.send('gateway:status-changed', { + state: 'starting', + port: 18789, + gatewayReady: false, + }); + }); + + const restartIndicator = page.getByTestId('sidebar-gateway-restarting'); + await expect(restartIndicator).toHaveAttribute('data-state', 'visible'); + await expect(restartIndicator).toContainText(/gateway.*restart|重启中/i); + + const oldWarningCopy = /Gateway service is not running|Gateway is not running\.|Gateway 服务未运行|Agent 或频道变更|网关未运行。|没有活跃的网关|Scheduled tasks cannot be managed|无法管理定时任务|Channels cannot connect|无法管理频道/i; + + await page.getByTestId('sidebar-nav-agents').click(); + await expect(page.getByTestId('agents-page')).toBeVisible(); + await expect(page.getByText(oldWarningCopy)).toHaveCount(0); + + await page.getByTestId('sidebar-nav-channels').click(); + await expect(page.getByTestId('channels-page')).toBeVisible(); + await expect(page.getByText(oldWarningCopy)).toHaveCount(0); + + await page.getByTestId('sidebar-nav-cron').click(); + await expect(page.getByTestId('cron-page')).toBeVisible(); + await expect(page.getByText(oldWarningCopy)).toHaveCount(0); + + await page.getByTestId('sidebar-nav-skills').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0); + + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win?.webContents.send('gateway:status-changed', { + state: 'running', + port: 18789, + pid: 200, + connectedAt: 2, + gatewayReady: true, + }); + }); + + await expect(restartIndicator).toHaveAttribute('data-state', 'hidden'); + }); + test('chat sidebar history reloads when gateway becomes ready after restart', async ({ electronApp, page }) => { await installIpcMocks(electronApp, { gatewayStatus: { state: 'running', port: 18789, pid: 100, connectedAt: 1, gatewayReady: false }, @@ -140,27 +234,21 @@ test.describe('ClawX gateway lifecycle resilience', () => { }, }, gatewayRpc: { - [stableStringify(['sessions.list', {}])]: { - success: true, - result: { - sessions: [{ key: 'agent:main:main', displayName: 'main' }], - }, + [stableStringify(['sessions.list', SESSIONS_LIST_PAYLOAD])]: { + sessions: [{ key: 'agent:main:main', displayName: 'main' }], }, [stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200, maxChars: 500000 }])]: { - success: true, - result: { - messages: [ - { role: 'user', content: 'hello', timestamp: 1000 }, - { role: 'assistant', content: 'history after ready', timestamp: 1001 }, - ], - }, + messages: [ + { role: 'user', content: 'hello', timestamp: 1000 }, + { role: 'assistant', content: 'history after ready', timestamp: 1001 }, + ], }, }, }); await completeSetup(page); await page.getByTestId('sidebar-new-chat').click(); - await expect(page.getByText(/gateway starting \| port: 18789/i)).toBeVisible(); + await expect(page.getByTestId('sidebar-gateway-restarting')).toHaveAttribute('data-state', 'visible'); await expect(page.getByText('history after ready')).toHaveCount(0); await electronApp.evaluate(({ BrowserWindow }) => { @@ -175,6 +263,6 @@ test.describe('ClawX gateway lifecycle resilience', () => { }); await expect(page.getByText('history after ready')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(/gateway connected \| port: 18789/i)).toBeVisible(); + await expect(page.getByTestId('sidebar-gateway-restarting')).toHaveAttribute('data-state', 'hidden'); }); }); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 3bdc9e7d..f1489f79 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,5 +1,18 @@ +import type { ElectronApplication } from '@playwright/test'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +async function readNativeMenuLabels(app: ElectronApplication) { + return await app.evaluate(({ Menu }) => { + const menu = Menu.getApplicationMenu(); + const fileMenu = menu?.items.find((item) => item.label === '文件' || item.label === 'File'); + return { + topLevel: menu?.items.map((item) => item.label) ?? [], + file: fileMenu?.label, + newChat: fileMenu?.submenu?.items.find((item) => item.id === 'new-chat' || item.label === 'New Chat' || item.label === '新对话')?.label, + }; + }); +} + test.describe('ClawX main navigation without setup flow', () => { test('navigates between core pages with setup bypassed', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); @@ -26,4 +39,55 @@ test.describe('ClawX main navigation without setup flow', () => { await closeElectronApp(app); } }); + + test('native New Chat menu opens the same chat route as the sidebar action', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('chat-page')).toBeVisible(); + + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('models-page')).toBeVisible(); + + await app.evaluate(({ BrowserWindow, Menu }) => { + const menu = Menu.getApplicationMenu(); + const findMenuItem = (items: Electron.MenuItem[]): Electron.MenuItem | undefined => { + for (const item of items) { + if (item.id === 'new-chat') return item; + const child = item.submenu ? findMenuItem(item.submenu.items) : undefined; + if (child) return child; + } + return undefined; + }; + const newChatItem = menu ? findMenuItem(menu.items) : undefined; + newChatItem?.click(undefined, BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], undefined); + }); + + await expect(page.getByTestId('chat-page')).toBeVisible(); + await expect(page).toHaveURL(/#\/$/); + } finally { + await closeElectronApp(app); + } + }); + + test('refreshes native menu labels after switching language', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await getStableWindow(app); + + await page.getByTestId('sidebar-nav-settings').click(); + await page.getByRole('button', { name: 'English' }).click(); + await page.getByRole('button', { name: '中文' }).click(); + + await expect(page.getByText('菜单语言已更新')).toBeVisible(); + await expect.poll(() => readNativeMenuLabels(app)).toMatchObject({ + file: '文件', + newChat: '新对话', + }); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/tests/e2e/provider-lifecycle.spec.ts b/tests/e2e/provider-lifecycle.spec.ts index 5869cccf..81258587 100644 --- a/tests/e2e/provider-lifecycle.spec.ts +++ b/tests/e2e/provider-lifecycle.spec.ts @@ -73,11 +73,11 @@ test.describe('ClawX provider lifecycle', () => { await expect(page.getByTestId('add-provider-dialog')).toBeVisible(); await page.getByTestId('add-provider-type-openai').click(); - await expect(page.getByRole('button', { name: 'OAuth Login' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'API Key' })).toBeVisible(); + await expect(page.getByTestId('add-provider-auth-oauth-tab')).toBeVisible(); + await expect(page.getByTestId('add-provider-auth-apikey-tab')).toBeVisible(); - await page.getByRole('button', { name: 'OAuth Login' }).click(); - await expect(page.getByRole('button', { name: 'Login with Browser' })).toBeVisible(); + await page.getByTestId('add-provider-auth-oauth-tab').click(); + await expect(page.getByTestId('add-provider-oauth-login-button')).toBeVisible(); await expect(page.getByTestId('add-provider-api-key-input')).toHaveCount(0); }); @@ -91,77 +91,70 @@ test.describe('ClawX provider lifecycle', () => { let keyInfo: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = []; let statuses: Array> = []; let defaultAccountId: string | null = null; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); - const respond = (json: unknown, status = 200) => ({ + const respond = (id: unknown, data: unknown) => ({ + id: typeof id === 'string' ? id : undefined, ok: true, - data: { - status, - ok: status >= 200 && status < 300, - json, - }, + data, }); - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string; body?: string | null }) => { - const path = request?.path ?? ''; - const method = request?.method ?? 'GET'; - const body = request?.body ? JSON.parse(request.body) : null; - - // New account-based endpoints (preferred path). - if (path === '/api/provider-accounts' && method === 'GET') return respond(accounts); - if (path === '/api/provider-accounts/key-info' && method === 'GET') return respond(keyInfo); - if (path === '/api/provider-vendors' && method === 'GET') return respond([]); - if (path === '/api/provider-accounts/default' && method === 'GET') return respond({ accountId: defaultAccountId }); - - if (path === '/api/provider-accounts/validate' && method === 'POST') { - if (body?.apiKey !== 'sk-lm-test') { - return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400); - } - return respond({ valid: true }); + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: { + id?: string; + module?: string; + action?: string; + payload?: Record; + }) => { + if (request?.module !== 'providers') { + return originalHostInvoke?.(event, request) ?? respond(request?.id, undefined); } - if (path === '/api/provider-accounts' && method === 'POST') { - accounts = [body.account]; + const body = request.payload ?? {}; + if (request.action === 'accounts') return respond(request.id, accounts); + if (request.action === 'accountKeyInfo') return respond(request.id, keyInfo); + if (request.action === 'vendors') return respond(request.id, []); + if (request.action === 'getDefaultAccount') return respond(request.id, { accountId: defaultAccountId }); + if (request.action === 'list') return respond(request.id, statuses); + + if (request.action === 'validateKey') { + if (body.apiKey !== 'sk-lm-test') { + return respond(request.id, { valid: false, error: `unexpected key: ${String(body.apiKey)}` }); + } + return respond(request.id, { valid: true }); + } + + if (request.action === 'createAccount') { + const account = body.account as Record; + accounts = [account]; keyInfo = [{ - accountId: body.account.id, + accountId: String(account.id), hasKey: Boolean(body.apiKey), keyMasked: body.apiKey ? 'sk-***' : null, }]; - // Keep statuses populated for any consumer still on the legacy path. statuses = [{ - id: body.account.id, - name: body.account.label, - type: body.account.vendorId, - baseUrl: body.account.baseUrl, - model: body.account.model, - enabled: body.account.enabled, - createdAt: body.account.createdAt, - updatedAt: body.account.updatedAt, + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + model: account.model, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, hasKey: Boolean(body.apiKey), keyMasked: body.apiKey ? 'sk-***' : null, }]; - return respond({ success: true }); + return respond(request.id, { success: true, account }); } - if (path === '/api/provider-accounts/default' && method === 'PUT') { - defaultAccountId = body?.accountId ?? null; - return respond({ success: true }); + if (request.action === 'setDefaultAccount') { + defaultAccountId = typeof body.accountId === 'string' ? body.accountId : null; + return respond(request.id, { success: true }); } - // ── Legacy compatibility shims ───────────────────────────── - // Older renderer builds still reach for these. Keeping them - // wired up here exercises the backward-compat path in the - // route layer (it returns the same data, just without the - // newer key-info payload structure). - if (path === '/api/providers' && method === 'GET') return respond(statuses); - if (path === '/api/providers/validate' && method === 'POST') { - if (body?.apiKey !== 'sk-lm-test') { - return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400); - } - return respond({ valid: true }); - } - - return respond({}); + return respond(request.id, {}); }); }); @@ -187,7 +180,7 @@ test.describe('ClawX provider lifecycle', () => { await electronApp.evaluate(async ({ app: _app }) => { const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); - const provider = { + let provider = { id: 'moonshot-edit', vendorId: 'moonshot', label: 'Moonshot Edit', @@ -201,43 +194,53 @@ test.describe('ClawX provider lifecycle', () => { }; let storedKey = 'sk-existing'; let keyInfo = [{ accountId: provider.id, hasKey: true, keyMasked: 'sk-***' }]; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); - const respond = (json: unknown, status = 200) => ({ + const respond = (id: unknown, data: unknown) => ({ + id: typeof id === 'string' ? id : undefined, ok: true, - data: { - status, - ok: status >= 200 && status < 300, - json, - }, + data, }); - ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string; body?: string | null }) => { - const path = request?.path ?? ''; - const method = request?.method ?? 'GET'; - const body = request?.body ? JSON.parse(request.body) : null; + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: { + id?: string; + module?: string; + action?: string; + payload?: Record; + }) => { + if (request?.module !== 'providers') { + return originalHostInvoke?.(event, request) ?? respond(request?.id, undefined); + } - if (path === '/api/provider-accounts' && method === 'GET') return respond([provider]); - if (path === '/api/provider-accounts/key-info' && method === 'GET') return respond(keyInfo); - if (path === '/api/provider-vendors' && method === 'GET') return respond([]); - if (path === '/api/provider-accounts/default' && method === 'GET') return respond({ accountId: provider.id }); + const body = request.payload ?? {}; + if (request.action === 'accounts') return respond(request.id, [provider]); + if (request.action === 'accountKeyInfo') return respond(request.id, keyInfo); + if (request.action === 'vendors') return respond(request.id, []); + if (request.action === 'getDefaultAccount') return respond(request.id, { accountId: provider.id }); + if (request.action === 'list') return respond(request.id, [provider]); - if (path === '/api/provider-accounts/validate' && method === 'POST') { - if (body?.apiKey === 'sk-good') { - return respond({ valid: true }); + if (request.action === 'validateKey') { + if (body.apiKey === 'sk-good') { + return respond(request.id, { valid: true }); } - return respond({ valid: false, error: 'Invalid API key' }, 400); + return respond(request.id, { valid: false, error: 'Invalid API key' }); } - if (path.startsWith('/api/provider-accounts/') && method === 'PUT') { - if (body?.apiKey) storedKey = body.apiKey; + if (request.action === 'updateAccount') { + provider = { + ...provider, + ...(body.updates as Record | undefined), + updatedAt: new Date().toISOString(), + }; + if (body.apiKey) storedKey = String(body.apiKey); keyInfo = [{ accountId: provider.id, hasKey: Boolean(storedKey), keyMasked: 'sk-***' }]; - return respond({ success: true }); + return respond(request.id, { success: true, account: provider }); } - if (path === '/api/providers' && method === 'GET') return respond([provider]); - - return respond({}); + return respond(request.id, {}); }); }); diff --git a/tests/e2e/skills-gateway-readiness.spec.ts b/tests/e2e/skills-gateway-readiness.spec.ts index 40f50d8a..bea054ce 100644 --- a/tests/e2e/skills-gateway-readiness.spec.ts +++ b/tests/e2e/skills-gateway-readiness.spec.ts @@ -10,36 +10,30 @@ test.describe('Skills page gateway readiness', () => { '["skills.status",null]': { success: false, error: 'Gateway not connected' }, }, hostApi: { - '["/api/skills/marketplace/capability","GET"]': { - ok: true, - data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + '["skills","status",null]': { skills: [] }, + '["skills","clawhubCapability",null]': { + success: true, + capability: { canSearch: false, canInstall: false }, }, - '["/api/skills/local","GET"]': { - ok: true, - data: { - status: 200, - ok: true, - json: { - success: true, - skills: [{ - id: 'pdf', - slug: 'pdf', - name: 'PDF', - description: 'Local PDF tools', - enabled: true, - source: 'openclaw-managed', - baseDir: '/tmp/.openclaw/skills/pdf', - }, { - id: 'xlsx', - slug: 'xlsx', - name: 'XLSX', - description: 'Local spreadsheet tools', - enabled: false, - source: 'openclaw-managed', - baseDir: '/tmp/.openclaw/skills/xlsx', - }], - }, - }, + '["skills","local",null]': { + success: true, + skills: [{ + id: 'pdf', + slug: 'pdf', + name: 'PDF', + description: 'Local PDF tools', + enabled: true, + source: 'openclaw-managed', + baseDir: '/tmp/.openclaw/skills/pdf', + }, { + id: 'xlsx', + slug: 'xlsx', + name: 'XLSX', + description: 'Local spreadsheet tools', + enabled: false, + source: 'openclaw-managed', + baseDir: '/tmp/.openclaw/skills/xlsx', + }], }, }, }); @@ -69,28 +63,22 @@ test.describe('Skills page gateway readiness', () => { '["skills.status",null]': { success: false, error: 'Gateway not connected' }, }, hostApi: { - '["/api/skills/marketplace/capability","GET"]': { - ok: true, - data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + '["skills","status",null]': { skills: [] }, + '["skills","clawhubCapability",null]': { + success: true, + capability: { canSearch: false, canInstall: false }, }, - '["/api/skills/local","GET"]': { - ok: true, - data: { - status: 200, - ok: true, - json: { - success: true, - skills: [{ - id: 'browser-automation', - slug: 'browser-automation', - name: 'Browser Automation', - description: 'Plugin skill', - enabled: true, - source: 'openclaw-plugin', - baseDir: '/tmp/.openclaw/plugin-skills/browser-automation', - }], - }, - }, + '["skills","local",null]': { + success: true, + skills: [{ + id: 'browser-automation', + slug: 'browser-automation', + name: 'Browser Automation', + description: 'Plugin skill', + enabled: true, + source: 'openclaw-plugin', + baseDir: '/tmp/.openclaw/plugin-skills/browser-automation', + }], }, }, }); @@ -110,13 +98,14 @@ test.describe('Skills page gateway readiness', () => { '["skills.status",null]': { success: false, error: 'Gateway not connected' }, }, hostApi: { - '["/api/skills/marketplace/capability","GET"]': { - ok: true, - data: { status: 200, ok: true, json: { success: true, capability: { canSearch: false, canInstall: false } } }, + '["skills","status",null]': { skills: [] }, + '["skills","clawhubCapability",null]': { + success: true, + capability: { canSearch: false, canInstall: false }, }, - '["/api/skills/local","GET"]': { - ok: true, - data: { status: 200, ok: true, json: { success: true, skills: [] } }, + '["skills","local",null]': { + success: true, + skills: [], }, }, }); @@ -136,6 +125,34 @@ test.describe('Skills page gateway readiness', () => { }); }); + await expect(page.getByTestId('sidebar-gateway-restarting')).toHaveAttribute('data-state', 'visible'); await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 3_500 }); + + await installIpcMocks(electronApp, { + gatewayRpc: { + '["skills.status",null]': { success: true, result: { skills: [] } }, + }, + hostApi: { + '["skills","status",null]': { skills: [] }, + '["skills","local",null]': { success: true, skills: [] }, + '["skills","clawhubCapability",null]': { + success: true, + capability: { canSearch: false, canInstall: false }, + }, + }, + }); + + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win?.webContents.send('gateway:status-changed', { + state: 'running', + port: 18789, + pid: 12345, + connectedAt: 2, + gatewayReady: false, + }); + }); + + await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 2_000 }); }); }); diff --git a/tests/e2e/token-usage.spec.ts b/tests/e2e/token-usage.spec.ts index ff246e32..5371787f 100644 --- a/tests/e2e/token-usage.spec.ts +++ b/tests/e2e/token-usage.spec.ts @@ -143,7 +143,7 @@ test.describe('ClawX token usage history', () => { // TODO: This test needs a reliable way to inject mocked gateway status into // the renderer's Zustand store in CI (where no real OpenClaw runtime exists). - // The hostapi:fetch mock + page.reload approach fails because the reload + // The IPC mock + page.reload approach fails because the reload // re-triggers setup flow. Skipping until we add an E2E-aware store hook. test.skip('hides gateway internal usage rows from the usage list overview', async ({ page, homeDir }) => { await seedTokenUsageTranscripts(homeDir); diff --git a/tests/setup.ts b/tests/setup.ts index 0cf786f4..0a5beb6f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -22,6 +22,10 @@ vi.mock('electron', () => ({ whenReady: vi.fn().mockResolvedValue(undefined), }, BrowserWindow: vi.fn(), + Menu: { + buildFromTemplate: vi.fn((template) => ({ template })), + setApplicationMenu: vi.fn(), + }, ipcMain: { on: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() }, dialog: { showOpenDialog: vi.fn(), showMessageBox: vi.fn() }, shell: { openExternal: vi.fn() }, diff --git a/tests/unit/agents-page.test.tsx b/tests/unit/agents-page.test.tsx index 0c18e8e0..0e619206 100644 --- a/tests/unit/agents-page.test.tsx +++ b/tests/unit/agents-page.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Agents } from '../../src/pages/Agents/index'; -const hostApiFetchMock = vi.fn(); +const channelsAccountsMock = vi.fn(); const subscribeHostEventMock = vi.fn(); const fetchAgentsMock = vi.fn(); const updateAgentMock = vi.fn(); @@ -65,11 +65,17 @@ vi.mock('@/stores/providers', () => ({ })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + channels: { + accounts: (...args: unknown[]) => channelsAccountsMock(...args), + }, + }, })); vi.mock('@/lib/host-events', () => ({ - subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args), + hostEvents: { + onGatewayChannelStatus: (handler: unknown) => subscribeHostEventMock('gateway:channel-status', handler), + }, })); vi.mock('react-i18next', () => ({ @@ -100,7 +106,7 @@ describe('Agents page status refresh', () => { updateAgentMock.mockResolvedValue(undefined); updateAgentModelMock.mockResolvedValue(undefined); refreshProviderSnapshotMock.mockResolvedValue(undefined); - hostApiFetchMock.mockResolvedValue({ + channelsAccountsMock.mockResolvedValue({ success: true, channels: [], }); @@ -119,7 +125,7 @@ describe('Agents page status refresh', () => { await waitFor(() => { expect(fetchAgentsMock).toHaveBeenCalledTimes(1); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); + expect(channelsAccountsMock).toHaveBeenCalledWith(); }); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); @@ -128,8 +134,7 @@ describe('Agents page status refresh', () => { }); await waitFor(() => { - const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); - expect(channelFetchCalls).toHaveLength(2); + expect(channelsAccountsMock).toHaveBeenCalledTimes(2); }); }); @@ -140,7 +145,7 @@ describe('Agents page status refresh', () => { await waitFor(() => { expect(fetchAgentsMock).toHaveBeenCalledTimes(1); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); + expect(channelsAccountsMock).toHaveBeenCalledWith(); }); gatewayState.status = { state: 'running', port: 18789 }; @@ -149,11 +154,22 @@ describe('Agents page status refresh', () => { }); await waitFor(() => { - const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); - expect(channelFetchCalls).toHaveLength(2); + expect(channelsAccountsMock).toHaveBeenCalledTimes(2); }); }); + it('does not render the legacy gateway warning during transient stopped status', async () => { + gatewayState.status = { state: 'stopped', port: 18789 }; + + render(); + + await waitFor(() => { + expect(fetchAgentsMock).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByText('gatewayWarning')).not.toBeInTheDocument(); + }); + it('uses "Use default model" as form fill only and disables it when already default', async () => { agentsState.agents = [ { @@ -249,7 +265,7 @@ describe('Agents page status refresh', () => { agentsState.loading = true; fetchAgentsMock.mockImplementation(() => new Promise(() => {})); refreshProviderSnapshotMock.mockImplementation(() => new Promise(() => {})); - hostApiFetchMock.mockImplementation(() => new Promise(() => {})); + channelsAccountsMock.mockImplementation(() => new Promise(() => {})); const { container } = render(); diff --git a/tests/unit/agents-routes.test.ts b/tests/unit/agents-routes.test.ts deleted file mode 100644 index e592137e..00000000 --- a/tests/unit/agents-routes.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const originalPlatform = process.platform; - -const { mockExec } = vi.hoisted(() => ({ - mockExec: vi.fn(), -})); - -vi.mock('child_process', () => ({ - exec: mockExec, - default: { - exec: mockExec, - }, -})); - -vi.mock('@electron/utils/agent-config', () => ({ - assignChannelToAgent: vi.fn(), - clearChannelBinding: vi.fn(), - createAgent: vi.fn(), - deleteAgentConfig: vi.fn(), - listAgentsSnapshot: vi.fn(), - removeAgentWorkspaceDirectory: vi.fn(), - resolveAccountIdForAgent: vi.fn(), - updateAgentModel: vi.fn(), - updateAgentName: vi.fn(), -})); - -vi.mock('@electron/utils/channel-config', () => ({ - deleteChannelAccountConfig: vi.fn(), -})); - -vi.mock('@electron/services/providers/provider-runtime-sync', () => ({ - syncAllProviderAuthToRuntime: vi.fn(), - syncAgentModelOverrideToRuntime: vi.fn(), -})); - -vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: vi.fn(), - sendJson: vi.fn(), -})); - -function setPlatform(platform: string): void { - Object.defineProperty(process, 'platform', { value: platform, writable: true }); -} - -describe('restartGatewayForAgentDeletion', () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.resetModules(); - mockExec.mockImplementation((_cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => { - cb(null, ''); - return {} as never; - }); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); - }); - - it('uses taskkill tree strategy on Windows when gateway pid is known', async () => { - setPlatform('win32'); - const { restartGatewayForAgentDeletion } = await import('@electron/api/routes/agents'); - - const restart = vi.fn().mockResolvedValue(undefined); - const getStatus = vi.fn(() => ({ pid: 4321, port: 18789 })); - - await restartGatewayForAgentDeletion({ - gatewayManager: { - getStatus, - restart, - }, - } as never); - - expect(mockExec).toHaveBeenCalledWith( - 'taskkill /F /PID 4321 /T', - expect.any(Function), - ); - expect(restart).toHaveBeenCalledTimes(1); - }); -}); - -describe('handleAgentRoutes model updates', () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.resetModules(); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); - }); - - it.each(['linux', 'darwin', 'win32'])( - 'updates model config without gateway reload or restart on %s', - async (platform) => { - setPlatform(platform); - const routeUtils = await import('@electron/api/route-utils'); - const agentConfig = await import('@electron/utils/agent-config'); - const runtimeSync = await import('@electron/services/providers/provider-runtime-sync'); - const { handleAgentRoutes } = await import('@electron/api/routes/agents'); - - vi.mocked(routeUtils.parseJsonBody).mockResolvedValue({ modelRef: 'custom-alpha/model-alpha' }); - vi.mocked(agentConfig.updateAgentModel).mockResolvedValue({ - agents: [], - defaultAgentId: 'main', - defaultModelRef: 'custom-alpha/model-alpha', - configuredChannelTypes: [], - channelOwners: {}, - channelAccountOwners: {}, - }); - vi.mocked(runtimeSync.syncAllProviderAuthToRuntime).mockResolvedValue(undefined); - vi.mocked(runtimeSync.syncAgentModelOverrideToRuntime).mockResolvedValue(undefined); - - const gatewayManager = { - getStatus: vi.fn(() => ({ state: 'running', pid: 1234, port: 18789 })), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - restart: vi.fn(), - }; - - const handled = await handleAgentRoutes( - { method: 'PUT' } as never, - {} as never, - new URL('http://127.0.0.1/api/agents/main/model'), - { gatewayManager } as never, - ); - - expect(handled).toBe(true); - expect(agentConfig.updateAgentModel).toHaveBeenCalledWith('main', 'custom-alpha/model-alpha'); - expect(runtimeSync.syncAllProviderAuthToRuntime).toHaveBeenCalledTimes(1); - expect(runtimeSync.syncAgentModelOverrideToRuntime).toHaveBeenCalledWith('main'); - expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); - expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); - expect(gatewayManager.restart).not.toHaveBeenCalled(); - expect(routeUtils.sendJson).toHaveBeenCalledWith( - {}, - 200, - expect.objectContaining({ success: true }), - ); - }, - ); -}); diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts deleted file mode 100644 index 36ac8fc0..00000000 --- a/tests/unit/api-client.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - invokeIpc, - invokeIpcWithRetry, - AppError, - toUserMessage, - configureApiClient, - 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'] }], - }); - clearTransportBackoff(); - unregisterTransportInvoker('ws'); - unregisterTransportInvoker('http'); - }); - - it('forwards invoke arguments and returns result', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); - - const result = await invokeIpc<{ ok: boolean }>('settings:getAll', { a: 1 }); - - expect(result.ok).toBe(true); - expect(invoke).toHaveBeenCalledWith( - 'app:request', - expect.objectContaining({ - module: 'settings', - action: 'getAll', - }), - ); - }); - - it('normalizes timeout errors', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockRejectedValueOnce(new Error('Gateway Timeout')); - - await expect(invokeIpc('gateway:status')).rejects.toMatchObject({ code: 'TIMEOUT' }); - }); - - it('retries once for retryable errors', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke - .mockResolvedValueOnce({ ok: false, error: { code: 'TIMEOUT', message: 'network timeout' } }) - .mockResolvedValueOnce({ ok: true, data: { success: true } }); - - const result = await invokeIpcWithRetry<{ success: boolean }>('provider:list', [], 1); - - expect(result.success).toBe(true); - expect(invoke).toHaveBeenCalledTimes(2); - }); - - it('returns user-facing message for permission error', () => { - const msg = toUserMessage(new AppError('PERMISSION', 'forbidden')); - expect(msg).toContain('Permission denied'); - }); - - it('returns user-facing message for auth invalid error', () => { - const msg = toUserMessage(new AppError('AUTH_INVALID', 'Invalid Authentication')); - expect(msg).toContain('Authentication failed'); - }); - - it('returns user-facing message for channel unavailable error', () => { - const msg = toUserMessage(new AppError('CHANNEL_UNAVAILABLE', 'Invalid IPC channel')); - expect(msg).toContain('Service channel unavailable'); - }); - - it('falls back to legacy channel when unified route is unsupported', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke - .mockRejectedValueOnce(new Error('APP_REQUEST_UNSUPPORTED:settings.getAll')) - .mockResolvedValueOnce({ foo: 'bar' }); - - const result = await invokeIpc<{ foo: string }>('settings:getAll'); - expect(result.foo).toBe('bar'); - expect(invoke).toHaveBeenNthCalledWith(2, 'settings:getAll'); - }); - - it('sends tuple payload for multi-arg unified requests', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ ok: true, data: { success: true } }); - - const result = await invokeIpc<{ success: boolean }>('settings:set', 'language', 'en'); - - expect(result.success).toBe(true); - expect(invoke).toHaveBeenCalledWith( - 'app:request', - expect.objectContaining({ - module: 'settings', - action: 'set', - payload: ['language', 'en'], - }), - ); - }); - - it('falls through ws/http and succeeds via ipc when advanced transports fail', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } }); - - registerTransportInvoker('ws', async () => { - throw new Error('ws unavailable'); - }); - registerTransportInvoker('http', async () => { - throw new Error('http unavailable'); - }); - configureApiClient({ - enabled: { ws: true, http: true }, - rules: [{ matcher: 'gateway:rpc', order: ['ws', 'http', 'ipc'] }], - }); - - const result = await invokeIpc<{ ok: boolean }>('gateway:rpc', 'chat.history', {}); - expect(result.ok).toBe(true); - expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', {}); - }); - - it('backs off failed ws transport and skips it on immediate retry', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValue({ ok: true }); - const wsInvoker = vi.fn(async () => { - throw new Error('ws unavailable'); - }); - - registerTransportInvoker('ws', wsInvoker); - configureApiClient({ - enabled: { ws: true, http: false }, - rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], - }); - - await invokeIpc('gateway:rpc', 'chat.history', {}); - await invokeIpc('gateway:rpc', 'chat.history', {}); - - expect(wsInvoker).toHaveBeenCalledTimes(1); - expect(invoke).toHaveBeenCalledTimes(2); - }); - - it('retries ws transport after backoff is cleared', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValue({ ok: true }); - const wsInvoker = vi.fn(async () => { - throw new Error('ws unavailable'); - }); - - registerTransportInvoker('ws', wsInvoker); - configureApiClient({ - enabled: { ws: true, http: false }, - rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }], - }); - - await invokeIpc('gateway:rpc', 'chat.history', {}); - clearTransportBackoff('ws'); - await invokeIpc('gateway:rpc', 'chat.history', {}); - - 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'); - }); - - it('rejects invalid config.patch params before gateway:httpProxy call', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - const invoker = createGatewayHttpTransportInvoker(); - - await expect(invoker('gateway:rpc', ['config.patch', 'abc'])).rejects.toThrow( - 'gateway:rpc config.patch requires object params', - ); - expect(invoke).not.toHaveBeenCalled(); - }); - - it('rejects invalid config.patch.patch before gateway:httpProxy call', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - const invoker = createGatewayHttpTransportInvoker(); - - await expect(invoker('gateway:rpc', ['config.patch', { patch: 'abc' }])).rejects.toThrow( - 'gateway:rpc config.patch requires raw string or object patch', - ); - expect(invoke).not.toHaveBeenCalled(); - }); - - it('allows raw config.patch params for gateway-compatible merge patches', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ - ok: true, - data: { - status: 200, - ok: true, - json: { type: 'res', ok: true, payload: { ok: true } }, - }, - }); - const invoker = createGatewayHttpTransportInvoker(); - - await expect(invoker('gateway:rpc', ['config.patch', { raw: '{"plugins":{}}', baseHash: 'abc' }])).resolves.toEqual({ - success: true, - result: { ok: true }, - }); - expect(invoke).toHaveBeenCalledWith('gateway:httpProxy', expect.objectContaining({ - body: expect.objectContaining({ - method: 'config.patch', - params: { raw: '{"plugins":{}}', baseHash: 'abc' }, - }), - })); - }); -}); diff --git a/tests/unit/app-routes.test.ts b/tests/unit/app-routes.test.ts deleted file mode 100644 index 7f112e65..00000000 --- a/tests/unit/app-routes.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; - -const runOpenClawDoctorMock = vi.fn(); -const runOpenClawDoctorFixMock = vi.fn(); -const sendJsonMock = vi.fn(); -const sendNoContentMock = vi.fn(); - -vi.mock('@electron/utils/openclaw-doctor', () => ({ - runOpenClawDoctor: (...args: unknown[]) => runOpenClawDoctorMock(...args), - runOpenClawDoctorFix: (...args: unknown[]) => runOpenClawDoctorFixMock(...args), -})); - -vi.mock('@electron/api/route-utils', () => ({ - setCorsHeaders: vi.fn(), - parseJsonBody: vi.fn().mockResolvedValue({}), - sendJson: (...args: unknown[]) => sendJsonMock(...args), - sendNoContent: (...args: unknown[]) => sendNoContentMock(...args), -})); - -describe('handleAppRoutes', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('runs openclaw doctor through the host api', async () => { - runOpenClawDoctorMock.mockResolvedValueOnce({ success: true, exitCode: 0 }); - const { handleAppRoutes } = await import('@electron/api/routes/app'); - - const handled = await handleAppRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/app/openclaw-doctor'), - {} as never, - ); - - expect(handled).toBe(true); - expect(runOpenClawDoctorMock).toHaveBeenCalledTimes(1); - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true, exitCode: 0 }); - }); - - it('runs openclaw doctor fix when requested', async () => { - const { parseJsonBody } = await import('@electron/api/route-utils'); - vi.mocked(parseJsonBody).mockResolvedValueOnce({ mode: 'fix' }); - runOpenClawDoctorFixMock.mockResolvedValueOnce({ success: false, exitCode: 1 }); - const { handleAppRoutes } = await import('@electron/api/routes/app'); - - const handled = await handleAppRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/app/openclaw-doctor'), - {} as never, - ); - - expect(handled).toBe(true); - expect(runOpenClawDoctorFixMock).toHaveBeenCalledTimes(1); - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: false, exitCode: 1 }); - }); -}); diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts deleted file mode 100644 index e7032d5e..00000000 --- a/tests/unit/channel-routes.test.ts +++ /dev/null @@ -1,1437 +0,0 @@ -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -const listConfiguredChannelsMock = vi.fn(); -const listConfiguredChannelAccountsMock = vi.fn(); -const readOpenClawConfigMock = vi.fn(); -const listAgentsSnapshotMock = vi.fn(); -const sendJsonMock = vi.fn(); -const proxyAwareFetchMock = vi.fn(); -const saveChannelConfigMock = vi.fn(); -const getChannelFormValuesMock = vi.fn(); -const setChannelDefaultAccountMock = vi.fn(); -const assignChannelAccountToAgentMock = vi.fn(); -const clearChannelBindingMock = vi.fn(); -const parseJsonBodyMock = vi.fn(); -const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw'); - -vi.mock('@electron/utils/channel-config', () => ({ - cleanupDanglingWeChatPluginState: vi.fn(), - deleteChannelAccountConfig: vi.fn(), - deleteChannelConfig: vi.fn(), - getChannelFormValues: (...args: unknown[]) => getChannelFormValuesMock(...args), - listConfiguredChannelAccounts: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args), - listConfiguredChannelAccountsFromConfig: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args), - listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args), - listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args), - readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), - saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args), - setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args), - setChannelEnabled: vi.fn(), - validateChannelConfig: vi.fn(), - validateChannelCredentials: vi.fn(), -})); - -vi.mock('@electron/utils/agent-config', () => ({ - assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args), - clearAllBindingsForChannel: vi.fn(), - clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args), - listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), - listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args), -})); - -vi.mock('@electron/utils/plugin-install', () => ({ - ensureDiscordPluginInstalled: vi.fn(), - ensureDingTalkPluginInstalled: vi.fn(), - ensureFeishuPluginInstalled: vi.fn(), - ensureQQBotPluginInstalled: vi.fn(), - ensureWeChatPluginInstalled: vi.fn(), - ensureWeComPluginInstalled: vi.fn(), - ensureWhatsAppPluginInstalled: vi.fn(), -})); - -vi.mock('@electron/utils/wechat-login', () => ({ - cancelWeChatLoginSession: vi.fn(), - saveWeChatAccountState: vi.fn(), - startWeChatLoginSession: vi.fn(), - waitForWeChatLoginSession: vi.fn(), -})); - -vi.mock('@electron/utils/whatsapp-login', () => ({ - whatsAppLoginManager: { - start: vi.fn(), - stop: vi.fn(), - }, -})); - -vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -vi.mock('@electron/utils/paths', () => ({ - getOpenClawConfigDir: () => testOpenClawConfigDir, - getOpenClawDir: () => testOpenClawConfigDir, - getOpenClawResolvedDir: () => testOpenClawConfigDir, -})); - -vi.mock('@electron/utils/proxy-fetch', () => ({ - proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args), -})); - -// Stub openclaw SDK functions that are dynamically loaded via createRequire -// in the real code — the extracted utility module is easy to mock. -vi.mock('@electron/utils/openclaw-sdk', () => ({ - listDiscordDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), - listDiscordDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), - normalizeDiscordMessagingTarget: vi.fn().mockReturnValue(undefined), - listTelegramDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), - listTelegramDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), - normalizeTelegramMessagingTarget: vi.fn().mockReturnValue(undefined), - listSlackDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), - listSlackDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), - normalizeSlackMessagingTarget: vi.fn().mockReturnValue(undefined), - normalizeWhatsAppMessagingTarget: vi.fn().mockReturnValue(undefined), -})); - -describe('handleChannelRoutes', () => { - beforeEach(() => { - vi.resetAllMocks(); - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - proxyAwareFetchMock.mockReset(); - parseJsonBodyMock.mockResolvedValue({}); - getChannelFormValuesMock.mockResolvedValue(undefined); - listConfiguredChannelAccountsMock.mockReturnValue({}); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [], - channelOwners: {}, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: {}, - }); - }); - - afterAll(() => { - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - }); - - it('reports healthy running multi-account channels as connected', async () => { - listConfiguredChannelsMock.mockResolvedValue(['feishu']); - listConfiguredChannelAccountsMock.mockResolvedValue({ - feishu: { - defaultAccountId: 'default', - accountIds: ['default', 'feishu-2412524e'], - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: { - feishu: { - defaultAccount: 'default', - }, - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [], - channelAccountOwners: { - 'feishu:default': 'main', - 'feishu:feishu-2412524e': 'code', - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - channels: { - feishu: { - configured: true, - }, - }, - channelAccounts: { - feishu: [ - { - accountId: 'default', - configured: true, - connected: false, - running: true, - linked: false, - }, - { - accountId: 'feishu-2412524e', - configured: true, - connected: false, - running: true, - linked: false, - }, - ], - }, - channelDefaultAccountId: { - feishu: 'default', - }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/accounts'), - { - gatewayManager: { - rpc, - getStatus: () => ({ state: 'running' }), - getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(rpc).toHaveBeenCalledWith('channels.status', { probe: false }, 8000); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channels: [ - expect.objectContaining({ - channelType: 'feishu', - status: 'connected', - accounts: expect.arrayContaining([ - expect.objectContaining({ accountId: 'default', status: 'connected' }), - expect.objectContaining({ accountId: 'feishu-2412524e', status: 'connected' }), - ]), - }), - ], - }), - ); - }); - - it('rejects non-canonical account ID on channel config save', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: '测试账号', - config: { appId: 'cli_xxx', appSecret: 'secret' }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/config'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ - success: false, - error: expect.stringContaining('Invalid accountId format'), - }), - ); - expect(saveChannelConfigMock).not.toHaveBeenCalled(); - }); - - it('allows legacy non-canonical account ID on channel config save when account already exists', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'Legacy_Account', - config: { botToken: 'token', allowedUsers: '123456' }, - }); - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'Legacy_Account'], - }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/config'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(saveChannelConfigMock).toHaveBeenCalledWith( - 'telegram', - { botToken: 'token', allowedUsers: '123456' }, - 'Legacy_Account', - ); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ success: true }), - ); - }); - - it('rejects non-canonical account ID on default-account route', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: 'ABC', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/default-account'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ - success: false, - error: expect.stringContaining('Invalid accountId format'), - }), - ); - expect(setChannelDefaultAccountMock).not.toHaveBeenCalled(); - }); - - it('rejects non-canonical account ID on binding routes', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: 'Account-Upper', - agentId: 'main', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ - success: false, - error: expect.stringContaining('Invalid accountId format'), - }), - ); - expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled(); - - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: 'INVALID VALUE', - }); - await handleChannelRoutes( - { method: 'DELETE' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - expect(clearChannelBindingMock).not.toHaveBeenCalled(); - }); - - it('allows legacy non-canonical account ID on default-account and binding routes', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - feishu: { - defaultAccountId: 'default', - accountIds: ['default', 'Legacy_Account'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main Agent' }], - channelOwners: {}, - channelAccountOwners: {}, - }); - - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: 'Legacy_Account', - }); - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/default-account'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'Legacy_Account'); - - parseJsonBodyMock.mockResolvedValue({ - channelType: 'feishu', - accountId: 'Legacy_Account', - agentId: 'main', - }); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account'); - }); - - it('migrates legacy channel-wide fallback before manually binding a non-default account', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }], - channelOwners: { telegram: 'main' }, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'main', match: { channel: 'telegram' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - agentId: 'code', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'main', 'telegram', 'default'); - expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram'); - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4'); - }); - - it('does not synthesize a default binding when no legacy channel-wide binding exists', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }], - channelOwners: { telegram: 'code' }, - channelAccountOwners: { - 'telegram:telegram-a1b2c3d4': 'code', - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'code', match: { channel: 'telegram', accountId: 'telegram-a1b2c3d4' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-b2c3d4e5', - agentId: 'code', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(clearChannelBindingMock).not.toHaveBeenCalled(); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-b2c3d4e5'); - }); - - it('preserves mixed-case agent ids when migrating a legacy channel-wide binding', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }], - channelOwners: { telegram: 'mainagent' }, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'MainAgent', match: { channel: 'telegram' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - agentId: 'code', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default'); - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4'); - }); - - it('does not mutate legacy bindings when the requested agent does not exist', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main Agent' }], - channelOwners: { telegram: 'main' }, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'main', match: { channel: 'telegram' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - agentId: 'missing-agent', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(clearChannelBindingMock).not.toHaveBeenCalled(); - expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled(); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 500, - expect.objectContaining({ - success: false, - error: expect.stringContaining('Agent "missing-agent" not found'), - }), - ); - }); - - it('rejects binding requests without accountId before legacy migration runs', async () => { - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main Agent' }], - channelOwners: {}, - channelAccountOwners: {}, - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - agentId: 'main', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(clearChannelBindingMock).not.toHaveBeenCalled(); - expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled(); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ - success: false, - error: 'accountId is required', - }), - ); - }); - - it('falls back to the legacy owner when explicit default owner is stale', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }], - channelOwners: {}, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'MissingAgent', match: { channel: 'telegram', accountId: 'default' } }, - { agentId: 'MainAgent', match: { channel: 'telegram' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - agentId: 'code', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default'); - expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4'); - }); - - it('skips default binding migration when both explicit and legacy owners are stale', async () => { - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-a1b2c3d4'], - }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'code', name: 'Code Agent' }], - channelOwners: {}, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'MissingDefault', match: { channel: 'telegram', accountId: 'default' } }, - { agentId: 'MissingLegacy', match: { channel: 'telegram' } }, - ], - }); - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - agentId: 'code', - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/binding'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram'); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-a1b2c3d4'); - }); - - it('converts legacy channel-wide fallback into an explicit default binding when saving a non-default account', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'telegram-a1b2c3d4', - config: { botToken: 'token', allowedUsers: '123456' }, - }); - listAgentsSnapshotMock.mockResolvedValue({ - agents: [{ id: 'main', name: 'Main' }], - channelOwners: { telegram: 'main' }, - channelAccountOwners: {}, - }); - readOpenClawConfigMock.mockResolvedValue({ - bindings: [ - { agentId: 'main', match: { channel: 'telegram' } }, - ], - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/config'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(saveChannelConfigMock).toHaveBeenCalledWith( - 'telegram', - { botToken: 'token', allowedUsers: '123456' }, - 'telegram-a1b2c3d4', - ); - expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'telegram', 'default'); - expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram'); - expect(assignChannelAccountToAgentMock).not.toHaveBeenCalledWith('main', 'telegram', 'telegram-a1b2c3d4'); - }); - - it('keeps channel connected when one account is healthy and another errors', async () => { - listConfiguredChannelsMock.mockResolvedValue(['telegram']); - listConfiguredChannelAccountsMock.mockResolvedValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default', 'telegram-b'], - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: { - telegram: { - defaultAccount: 'default', - }, - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - channels: { - telegram: { - configured: true, - }, - }, - channelAccounts: { - telegram: [ - { - accountId: 'default', - configured: true, - connected: true, - running: true, - linked: false, - }, - { - accountId: 'telegram-b', - configured: true, - connected: false, - running: false, - linked: false, - lastError: 'secondary bot failed', - }, - ], - }, - channelDefaultAccountId: { - telegram: 'default', - }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/accounts'), - { - gatewayManager: { - rpc, - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channels: [ - expect.objectContaining({ - channelType: 'telegram', - status: 'connected', - accounts: expect.arrayContaining([ - expect.objectContaining({ accountId: 'default', status: 'connected' }), - expect.objectContaining({ accountId: 'telegram-b', status: 'error' }), - ]), - }), - ], - }), - ); - }); - - it('filters runtime-only stale accounts when not configured locally', async () => { - listConfiguredChannelsMock.mockResolvedValue(['feishu']); - listConfiguredChannelAccountsMock.mockResolvedValue({ - feishu: { - defaultAccountId: 'default', - accountIds: ['default'], - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: { - feishu: { - defaultAccount: 'default', - }, - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - channels: { - feishu: { - configured: true, - }, - }, - channelAccounts: { - feishu: [ - { - accountId: 'default', - configured: true, - connected: true, - running: true, - }, - { - accountId: '2', - configured: false, - connected: false, - running: false, - lastError: 'stale runtime session', - }, - ], - }, - channelDefaultAccountId: { - feishu: 'default', - }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/accounts'), - { - gatewayManager: { - rpc, - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channels: [ - expect.objectContaining({ - channelType: 'feishu', - accounts: [expect.objectContaining({ accountId: 'default' })], - }), - ], - }), - ); - const payload = sendJsonMock.mock.calls.at(-1)?.[2] as { - channels?: Array<{ channelType: string; accounts: Array<{ accountId: string }> }>; - }; - const feishu = payload.channels?.find((entry) => entry.channelType === 'feishu'); - expect(feishu?.accounts.map((entry) => entry.accountId)).toEqual(['default']); - }); - - it('returns degraded channel health when channels.status times out while gateway is still running', async () => { - listConfiguredChannelsMock.mockResolvedValue(['feishu']); - listConfiguredChannelAccountsMock.mockResolvedValue({ - feishu: { - defaultAccountId: 'default', - accountIds: ['default'], - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: { - feishu: { - defaultAccount: 'default', - }, - }, - }); - - const rpc = vi.fn().mockRejectedValue(new Error('RPC timeout: channels.status')); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/accounts'), - { - gatewayManager: { - rpc, - getStatus: () => ({ state: 'running' }), - getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - gatewayHealth: expect.objectContaining({ - state: 'degraded', - reasons: expect.arrayContaining(['channels_status_timeout']), - }), - channels: [ - expect.objectContaining({ - channelType: 'feishu', - status: 'degraded', - statusReason: 'channels_status_timeout', - accounts: [ - expect.objectContaining({ - accountId: 'default', - status: 'degraded', - }), - ], - }), - ], - }), - ); - }); - - it('keeps channel degraded when only filtered stale runtime accounts carry lastError', async () => { - listConfiguredChannelsMock.mockResolvedValue(['feishu']); - listConfiguredChannelAccountsMock.mockResolvedValue({ - feishu: { - defaultAccountId: 'default', - accountIds: ['default'], - }, - }); - readOpenClawConfigMock.mockResolvedValue({ - channels: { - feishu: { - defaultAccount: 'default', - }, - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - channels: { - feishu: { - configured: true, - }, - }, - channelAccounts: { - feishu: [ - { - accountId: 'default', - configured: true, - connected: true, - running: true, - linked: false, - }, - { - accountId: '2', - configured: false, - connected: false, - running: false, - lastError: 'stale runtime session', - }, - ], - }, - channelDefaultAccountId: { - feishu: 'default', - }, - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/accounts'), - { - gatewayManager: { - rpc, - getStatus: () => ({ state: 'running' }), - getDiagnostics: () => ({ consecutiveHeartbeatMisses: 1, consecutiveRpcFailures: 0 }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channels: [ - expect.objectContaining({ - channelType: 'feishu', - status: 'degraded', - accounts: [ - expect.objectContaining({ accountId: 'default', status: 'degraded' }), - ], - }), - ], - }), - ); - }); - - it('lists known QQ Bot targets for a configured account', async () => { - const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data'); - mkdirSync(knownUsersPath, { recursive: true }); - writeFileSync(join(knownUsersPath, 'known-users.json'), JSON.stringify([ - { - openid: '207A5B8339D01F6582911C014668B77B', - type: 'c2c', - nickname: 'Alice', - accountId: 'default', - lastSeenAt: 200, - }, - { - openid: 'member-openid', - type: 'group', - nickname: 'Weather Group', - groupOpenid: 'GROUP_OPENID_123', - accountId: 'default', - lastSeenAt: 100, - }, - ]), 'utf8'); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/targets?channelType=qqbot&accountId=default'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channelType: 'qqbot', - accountId: 'default', - targets: [ - expect.objectContaining({ - value: 'qqbot:c2c:207A5B8339D01F6582911C014668B77B', - kind: 'user', - }), - expect.objectContaining({ - value: 'qqbot:group:GROUP_OPENID_123', - kind: 'group', - }), - ], - }), - ); - }); - - it('lists Feishu targets for a configured account', async () => { - readOpenClawConfigMock.mockResolvedValue({ - channels: { - feishu: { - appId: 'cli_app_id', - appSecret: 'cli_app_secret', - allowFrom: ['ou_config_user'], - groups: { - oc_config_group: {}, - }, - }, - }, - }); - - proxyAwareFetchMock.mockImplementation(async (url: string, init?: RequestInit) => { - if (url.includes('/tenant_access_token/internal')) { - const body = JSON.parse(String(init?.body || '{}')) as { app_id?: string }; - if (body.app_id === 'cli_app_id') { - return { - ok: true, - json: async () => ({ - code: 0, - tenant_access_token: 'tenant-token', - }), - }; - } - } - - if (url.includes('/applications/cli_app_id')) { - return { - ok: true, - json: async () => ({ - code: 0, - data: { - app: { - creator_id: 'ou_owner', - owner: { - owner_type: 2, - owner_id: 'ou_owner', - }, - }, - }, - }), - }; - } - - if (url.includes('/contact/v3/users')) { - return { - ok: true, - json: async () => ({ - code: 0, - data: { - items: [ - { open_id: 'ou_live_user', name: 'Alice Feishu' }, - ], - }, - }), - }; - } - - if (url.includes('/im/v1/chats')) { - return { - ok: true, - json: async () => ({ - code: 0, - data: { - items: [ - { chat_id: 'oc_live_chat', name: 'Project Chat' }, - ], - }, - }), - }; - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/targets?channelType=feishu&accountId=default'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channelType: 'feishu', - accountId: 'default', - targets: expect.arrayContaining([ - expect.objectContaining({ value: 'user:ou_owner', kind: 'user' }), - expect.objectContaining({ value: 'user:ou_live_user', kind: 'user' }), - expect.objectContaining({ value: 'chat:oc_live_chat', kind: 'group' }), - ]), - }), - ); - }); - - it('lists WeCom targets from reqid cache and session history', async () => { - mkdirSync(join(testOpenClawConfigDir, 'wecom'), { recursive: true }); - writeFileSync( - join(testOpenClawConfigDir, 'wecom', 'reqid-map-default.json'), - JSON.stringify({ - 'chat-alpha': { reqId: 'req-1', ts: 100 }, - }), - 'utf8', - ); - mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true }); - writeFileSync( - join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'), - JSON.stringify({ - 'agent:main:wecom:chat-bravo': { - updatedAt: 200, - chatType: 'group', - displayName: 'Ops Group', - deliveryContext: { - channel: 'wecom', - accountId: 'default', - to: 'wecom:chat-bravo', - }, - }, - }), - 'utf8', - ); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/targets?channelType=wecom&accountId=default'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channelType: 'wecom', - accountId: 'default', - targets: expect.arrayContaining([ - expect.objectContaining({ value: 'wecom:chat-bravo', kind: 'group' }), - expect.objectContaining({ value: 'wecom:chat-alpha', kind: 'channel' }), - ]), - }), - ); - }); - - it('lists DingTalk targets from session history', async () => { - mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true }); - writeFileSync( - join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'), - JSON.stringify({ - 'agent:main:dingtalk:cid-group': { - updatedAt: 300, - chatType: 'group', - displayName: 'DingTalk Dev Group', - deliveryContext: { - channel: 'dingtalk', - accountId: 'default', - to: 'cidDeVGroup=', - }, - }, - }), - 'utf8', - ); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/targets?channelType=dingtalk&accountId=default'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channelType: 'dingtalk', - accountId: 'default', - targets: [ - expect.objectContaining({ - value: 'cidDeVGroup=', - kind: 'group', - }), - ], - }), - ); - }); - - it('lists WeChat targets from session history via the UI alias', async () => { - mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true }); - writeFileSync( - join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'), - JSON.stringify({ - 'agent:main:wechat:wxid_target': { - updatedAt: 400, - chatType: 'direct', - displayName: 'Alice WeChat', - deliveryContext: { - channel: 'openclaw-weixin', - accountId: 'wechat-bot', - to: 'wechat:wxid_target', - }, - }, - }), - 'utf8', - ); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/targets?channelType=wechat&accountId=wechat-bot'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart: vi.fn(), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - success: true, - channelType: 'wechat', - accountId: 'wechat-bot', - targets: [ - expect.objectContaining({ - value: 'wechat:wxid_target', - kind: 'user', - }), - ], - }), - ); - }); - - it('restarts gateway after a no-change channel config save', async () => { - parseJsonBodyMock.mockResolvedValue({ - channelType: 'telegram', - accountId: 'default', - config: { botToken: 'telegram-token', allowedUsers: '123456' }, - }); - getChannelFormValuesMock.mockResolvedValue({ botToken: 'telegram-token', allowedUsers: '123456' }); - listConfiguredChannelAccountsMock.mockReturnValue({ - telegram: { - defaultAccountId: 'default', - accountIds: ['default'], - }, - }); - const debouncedRestart = vi.fn(); - - const { handleChannelRoutes } = await import('@electron/api/routes/channels'); - const handled = await handleChannelRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/channels/config'), - { - gatewayManager: { - rpc: vi.fn(), - getStatus: () => ({ state: 'running' }), - debouncedReload: vi.fn(), - debouncedRestart, - }, - } as never, - ); - - expect(handled).toBe(true); - expect(saveChannelConfigMock).not.toHaveBeenCalled(); - expect(debouncedRestart).toHaveBeenCalledWith(150); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ success: true, noChange: true }), - ); - }); -}); diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx index 1536949d..4702576e 100644 --- a/tests/unit/channels-page.test.tsx +++ b/tests/unit/channels-page.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Channels } from '@/pages/Channels/index'; -const hostApiFetchMock = vi.fn(); +const hostApiCallMock = vi.fn(); const subscribeHostEventMock = vi.fn(); const toastSuccessMock = vi.fn(); const toastErrorMock = vi.fn(); @@ -19,11 +19,43 @@ vi.mock('@/stores/gateway', () => ({ })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + agents: { + list: () => hostApiCallMock('agents.list'), + }, + channels: { + accounts: (options?: { mode?: string; probe?: boolean }) => hostApiCallMock('channels.accounts', options), + formValues: (channelType: string, accountId?: string) => { + return hostApiCallMock('channels.formValues', { channelType, accountId }); + }, + saveConfig: (input: unknown) => hostApiCallMock('channels.saveConfig', input), + deleteConfig: (channelType: string, accountId?: string) => { + return hostApiCallMock('channels.deleteConfig', { channelType, accountId }); + }, + validateCredentials: (channelType: string, config: Record) => ( + hostApiCallMock('channels.validateCredentials', { channelType, config }) + ), + saveBinding: (input: unknown) => hostApiCallMock('channels.saveBinding', input), + deleteBinding: (input: unknown) => hostApiCallMock('channels.deleteBinding', input), + startLogin: (channelType: string, input?: unknown) => hostApiCallMock('channels.startLogin', { channelType, input }), + cancelLogin: (channelType: string, input?: unknown) => hostApiCallMock('channels.cancelLogin', { channelType, input }), + }, + diagnostics: { + gatewaySnapshot: () => hostApiCallMock('diagnostics.gatewaySnapshot'), + }, + gateway: { + restart: () => hostApiCallMock('gateway.restart', { method: 'POST' }), + }, + }, })); vi.mock('@/lib/host-events', () => ({ - subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args), + hostEvents: { + onGatewayChannelStatus: (handler: unknown) => subscribeHostEventMock('gateway:channel-status', handler), + onChannelQr: (channel: string, handler: unknown) => subscribeHostEventMock(`channel:${channel}-qr`, handler), + onChannelSuccess: (channel: string, handler: unknown) => subscribeHostEventMock(`channel:${channel}-success`, handler), + onChannelError: (channel: string, handler: unknown) => subscribeHostEventMock(`channel:${channel}-error`, handler), + }, })); vi.mock('react-i18next', () => ({ @@ -58,8 +90,8 @@ describe('Channels page status refresh', () => { configurable: true, }); gatewayState.status = { state: 'running', port: 18789 }; - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -86,7 +118,7 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [], @@ -99,8 +131,8 @@ describe('Channels page status refresh', () => { it('blocks saving when custom account ID is non-canonical', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, channels: [ @@ -122,14 +154,14 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [], }; } - if (path === '/api/channels/credentials/validate') { + if (path === 'channels.validateCredentials') { return { success: true, valid: true, @@ -137,7 +169,7 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/channels/config') { + if (path === 'channels.saveConfig') { return { success: true, }; @@ -175,13 +207,7 @@ describe('Channels page status refresh', () => { }); expect(toastErrorMock).toHaveBeenCalledWith('account.invalidCanonicalId'); - const saveCalls = hostApiFetchMock.mock.calls.filter(([path, init]) => ( - path === '/api/channels/config' - && typeof init === 'object' - && init != null - && 'method' in init - && (init as { method?: string }).method === 'POST' - )); + const saveCalls = hostApiCallMock.mock.calls.filter(([path]) => path === 'channels.saveConfig'); expect(saveCalls).toHaveLength(0); }); @@ -197,8 +223,8 @@ describe('Channels page status refresh', () => { render(); await waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/agents'); + expect(hostApiCallMock).toHaveBeenCalledWith('channels.accounts', expect.objectContaining({ mode: 'runtime' })); + expect(hostApiCallMock).toHaveBeenCalledWith('agents.list'); }); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); @@ -207,8 +233,10 @@ describe('Channels page status refresh', () => { }); await waitFor(() => { - const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); - const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents'); + const channelFetchCalls = hostApiCallMock.mock.calls.filter(([path, options]) => ( + path === 'channels.accounts' && (options as { mode?: string } | undefined)?.mode !== 'config' + )); + const agentFetchCalls = hostApiCallMock.mock.calls.filter(([path]) => path === 'agents.list'); expect(channelFetchCalls).toHaveLength(2); expect(agentFetchCalls).toHaveLength(1); }); @@ -220,8 +248,8 @@ describe('Channels page status refresh', () => { const { rerender } = render(); await waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/agents'); + expect(hostApiCallMock).toHaveBeenCalledWith('channels.accounts', expect.objectContaining({ mode: 'runtime' })); + expect(hostApiCallMock).toHaveBeenCalledWith('agents.list'); }); gatewayState.status = { state: 'running', port: 18789 }; @@ -230,8 +258,10 @@ describe('Channels page status refresh', () => { }); await waitFor(() => { - const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); - const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents'); + const channelFetchCalls = hostApiCallMock.mock.calls.filter(([path, options]) => ( + path === 'channels.accounts' && (options as { mode?: string } | undefined)?.mode !== 'config' + )); + const agentFetchCalls = hostApiCallMock.mock.calls.filter(([path]) => path === 'agents.list'); expect(channelFetchCalls).toHaveLength(2); expect(agentFetchCalls).toHaveLength(1); }); @@ -245,8 +275,8 @@ describe('Channels page status refresh', () => { agents: Array>; }>(); - hostApiFetchMock.mockImplementation((path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation((path: string) => { + if (path === 'channels.accounts') { return Promise.resolve({ success: true, channels: [ @@ -267,7 +297,7 @@ describe('Channels page status refresh', () => { ], }); } - if (path === '/api/agents') { + if (path === 'agents.list') { return agentsDeferred.promise; } throw new Error(`Unexpected host API path: ${path}`); @@ -284,8 +314,8 @@ describe('Channels page status refresh', () => { it('treats WeChat accounts as plugin-managed QR accounts', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, channels: [ @@ -307,14 +337,14 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [], }; } - if (path === '/api/channels/wechat/cancel') { + if (path === 'channels.cancelLogin') { return { success: true }; } @@ -349,8 +379,8 @@ describe('Channels page status refresh', () => { }>(); let refreshCallCount = 0; - hostApiFetchMock.mockImplementation((path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation((path: string) => { + if (path === 'channels.accounts') { if (refreshCallCount === 0) { refreshCallCount += 1; return Promise.resolve({ @@ -376,7 +406,7 @@ describe('Channels page status refresh', () => { return channelsDeferred.promise; } - if (path === '/api/agents') { + if (path === 'agents.list') { if (refreshCallCount === 1) { return Promise.resolve({ success: true, agents: [] }); } @@ -445,8 +475,8 @@ describe('Channels page status refresh', () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); const writeTextMock = vi.mocked(navigator.clipboard.writeText); - hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string, init?: { method?: string }) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -475,14 +505,14 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [], }; } - if (path === '/api/diagnostics/gateway-snapshot') { + if (path === 'diagnostics.gatewaySnapshot') { return { capturedAt: 123, platform: 'darwin', @@ -498,7 +528,7 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/gateway/restart' && init?.method === 'POST') { + if (path === 'gateway.restart' && init?.method === 'POST') { return { success: true }; } @@ -513,7 +543,7 @@ describe('Channels page status refresh', () => { fireEvent.click(screen.getByTestId('channels-copy-diagnostics')); await waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/diagnostics/gateway-snapshot'); + expect(hostApiCallMock).toHaveBeenCalledWith('diagnostics.gatewaySnapshot'); expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining('"platform": "darwin"')); }); }); @@ -521,8 +551,8 @@ describe('Channels page status refresh', () => { it('suppresses stale gateway-not-running health while gateway status is running', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -549,7 +579,7 @@ describe('Channels page status refresh', () => { }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [] }; } @@ -566,8 +596,8 @@ describe('Channels page status refresh', () => { it('surfaces diagnostics fetch failure payloads instead of caching them as snapshots', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -595,10 +625,10 @@ describe('Channels page status refresh', () => { ], }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [] }; } - if (path === '/api/diagnostics/gateway-snapshot') { + if (path === 'diagnostics.gatewaySnapshot') { return { success: false, error: 'snapshot failed' }; } @@ -619,8 +649,8 @@ describe('Channels page status refresh', () => { it('shows restart failure when gateway restart returns success=false', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); - hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string, init?: { method?: string }) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -648,10 +678,10 @@ describe('Channels page status refresh', () => { ], }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [] }; } - if (path === '/api/gateway/restart' && init?.method === 'POST') { + if (path === 'gateway.restart' && init?.method === 'POST') { return { success: false, error: 'restart failed' }; } @@ -673,8 +703,8 @@ describe('Channels page status refresh', () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); let diagnosticsFetchCount = 0; - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path.startsWith('/api/channels/accounts')) { + hostApiCallMock.mockImplementation(async (path: string) => { + if (path === 'channels.accounts') { return { success: true, gatewayHealth: { @@ -702,10 +732,10 @@ describe('Channels page status refresh', () => { ], }; } - if (path === '/api/agents') { + if (path === 'agents.list') { return { success: true, agents: [] }; } - if (path === '/api/diagnostics/gateway-snapshot') { + if (path === 'diagnostics.gatewaySnapshot') { diagnosticsFetchCount += 1; return { capturedAt: diagnosticsFetchCount, diff --git a/tests/unit/chat-copy-image.test.ts b/tests/unit/chat-copy-image.test.ts index 79ad77f9..952a8759 100644 --- a/tests/unit/chat-copy-image.test.ts +++ b/tests/unit/chat-copy-image.test.ts @@ -3,7 +3,7 @@ import { copyImageToClipboard } from '@/pages/Chat/copy-image'; const readBinaryFileMock = vi.fn(); -vi.mock('@/lib/api-client', () => ({ +vi.mock('@/lib/file-preview-client', () => ({ readBinaryFile: (...args: unknown[]) => readBinaryFileMock(...args), })); diff --git a/tests/unit/chat-helpers-enrichment.test.ts b/tests/unit/chat-helpers-enrichment.test.ts index 92698317..fd5bbca2 100644 --- a/tests/unit/chat-helpers-enrichment.test.ts +++ b/tests/unit/chat-helpers-enrichment.test.ts @@ -1,17 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { enrichWithToolResultFiles, enrichWithToolCallAttachments, enrichWithCachedImages, loadMissingPreviews, } from '@/stores/chat/helpers'; -import { invokeIpc } from '@/lib/api-client'; import type { RawMessage } from '@/stores/chat'; -vi.mock('@/lib/api-client', () => ({ - invokeIpc: vi.fn(), +const thumbnailsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/host-api', () => ({ + hostApi: { + media: { + thumbnails: (...args: unknown[]) => thumbnailsMock(...args), + }, + }, })); +beforeEach(() => { + thumbnailsMock.mockReset(); +}); + describe('enrichWithToolResultFiles', () => { it('does not promote image content blocks emitted by `read` tool results', () => { // The `read` tool re-encodes the file as JPEG so the model can "see" it. @@ -394,17 +403,17 @@ describe('loadMissingPreviews', () => { }, ]; - vi.mocked(invokeIpc) + thumbnailsMock .mockResolvedValueOnce({ [gatewayUrl]: { preview: null, fileSize: 0 } }) .mockResolvedValueOnce({ [gatewayUrl]: { preview: 'data:image/png;base64,ok', fileSize: 42 } }); const result = loadMissingPreviews(messages); - expect(invokeIpc).toHaveBeenCalledTimes(1); + expect(thumbnailsMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(300); await expect(result).resolves.toBe(true); - expect(invokeIpc).toHaveBeenCalledTimes(2); + expect(thumbnailsMock).toHaveBeenCalledTimes(2); expect(messages[0]?._attachedFiles?.[0]).toMatchObject({ preview: 'data:image/png;base64,ok', fileSize: 42, @@ -434,13 +443,13 @@ describe('loadMissingPreviews', () => { }, ]; - vi.mocked(invokeIpc).mockResolvedValue({ [gatewayUrl]: { preview: null, fileSize: 0 } }); + thumbnailsMock.mockResolvedValue({ [gatewayUrl]: { preview: null, fileSize: 0 } }); const result = loadMissingPreviews(messages); await vi.advanceTimersByTimeAsync(300 + 900 + 1800); await expect(result).resolves.toBe(true); - expect(invokeIpc).toHaveBeenCalledTimes(4); + expect(thumbnailsMock).toHaveBeenCalledTimes(4); expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBe('unavailable'); } finally { vi.useRealTimers(); diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index 7e4b13b1..51a2324f 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { chatHistoryRpcParams } from './gateway-rpc-test-utils'; -const invokeIpcMock = vi.fn(); +const gatewayRpcMock = vi.fn(); const hostApiFetchMock = vi.fn(); const gatewayStoreGetStateMock = vi.fn(); const clearHistoryPoll = vi.fn(); @@ -50,12 +50,30 @@ const setLastChatEventAt = vi.fn(); const loadMissingPreviews = vi.fn(async () => false); const toMs = vi.fn((ts: number) => ts < 1e12 ? ts * 1000 : ts); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), -})); - vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + gateway: { + rpc: async (method: string, params?: unknown, timeoutMs?: number) => { + const result = await gatewayRpcMock(method, params, timeoutMs) as { + success?: boolean; + result?: unknown; + error?: string; + }; + if (result?.success === false) { + throw new Error(result.error || `RPC ${method} failed`); + } + return result?.result; + }, + }, + cron: { + sessionHistory: async (input: { sessionKey: string; limit?: number }) => { + const params = new URLSearchParams({ sessionKey: input.sessionKey }); + if (input.limit != null) params.set('limit', String(input.limit)); + return hostApiFetchMock(`/api/cron/session-history?${params.toString()}`); + }, + }, + }, })); vi.mock('@/stores/gateway', () => ({ @@ -184,7 +202,7 @@ describe('chat history actions', () => { vi.resetAllMocks(); vi.resetModules(); vi.useRealTimers(); - invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } }); + gatewayRpcMock.mockResolvedValue({ success: true, result: { messages: [] } }); hostApiFetchMock.mockResolvedValue({ messages: [] }); const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import('@/stores/chat/history-rpc-params'); resetChatHistoryMaxCharsCache(); @@ -258,7 +276,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockRejectedValueOnce(new Error('Gateway unavailable')); + gatewayRpcMock.mockRejectedValueOnce(new Error('Gateway unavailable')); await actions.loadHistory(); @@ -278,7 +296,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -312,7 +330,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -343,7 +361,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -370,7 +388,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -405,7 +423,7 @@ describe('chat history actions', () => { status: { state: 'running', port: 18789, connectedAt: Date.now() - 40_000 }, }); - invokeIpcMock + gatewayRpcMock .mockResolvedValueOnce({ success: false, error: 'RPC timeout: chat.history' }) .mockResolvedValueOnce({ success: true, @@ -420,16 +438,14 @@ describe('chat history actions', () => { await vi.runAllTimersAsync(); await loadPromise; - expect(invokeIpcMock).toHaveBeenNthCalledWith( + expect(gatewayRpcMock).toHaveBeenNthCalledWith( 1, - 'gateway:rpc', 'chat.history', chatHistoryRpcParams('agent:main:main', 200), 35_000, ); - expect(invokeIpcMock).toHaveBeenNthCalledWith( + expect(gatewayRpcMock).toHaveBeenNthCalledWith( 2, - 'gateway:rpc', 'chat.history', chatHistoryRpcParams('agent:main:main', 200), 35_000, @@ -456,7 +472,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockImplementationOnce(async () => { + gatewayRpcMock.mockImplementationOnce(async () => { h.set({ currentSessionKey: 'agent:main:other', loading: false, @@ -467,7 +483,7 @@ describe('chat history actions', () => { await actions.loadHistory(); - expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(gatewayRpcMock).toHaveBeenCalledTimes(1); expect(h.read().currentSessionKey).toBe('agent:main:other'); expect(h.read().messages.map((message) => message.content)).toEqual(['other session']); expect(h.read().error).toBeNull(); @@ -484,7 +500,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValue({ + gatewayRpcMock.mockResolvedValue({ success: false, error: 'RPC timeout: chat.history', }); @@ -493,7 +509,7 @@ describe('chat history actions', () => { await vi.runAllTimersAsync(); await loadPromise; - expect(invokeIpcMock).toHaveBeenCalledTimes(5); + expect(gatewayRpcMock).toHaveBeenCalledTimes(5); expect(h.read().messages).toEqual([]); expect(h.read().error).toBe('RPC timeout: chat.history'); expect(warnSpy).toHaveBeenCalledWith( @@ -512,14 +528,14 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValue({ + gatewayRpcMock.mockResolvedValue({ success: false, error: 'RPC timeout: chat.history', }); await actions.loadHistory(true); - expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(gatewayRpcMock).toHaveBeenCalledTimes(1); expect(h.read().error).toBeNull(); }); @@ -530,14 +546,14 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValue({ + gatewayRpcMock.mockResolvedValue({ success: false, error: 'Validation failed: bad session key', }); await actions.loadHistory(); - expect(invokeIpcMock).toHaveBeenCalledTimes(1); + expect(gatewayRpcMock).toHaveBeenCalledTimes(1); expect(h.read().error).toBe('Validation failed: bad session key'); }); @@ -546,7 +562,7 @@ describe('chat history actions', () => { const h = makeHarness(); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -570,7 +586,7 @@ describe('chat history actions', () => { const h = makeHarness(); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -594,7 +610,7 @@ describe('chat history actions', () => { const h = makeHarness(); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -618,7 +634,7 @@ describe('chat history actions', () => { const h = makeHarness(); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -639,7 +655,7 @@ describe('chat history actions', () => { it('drops stale history results after the user switches sessions', async () => { const { createHistoryActions } = await import('@/stores/chat/history-actions'); let resolveHistory: ((value: unknown) => void) | null = null; - invokeIpcMock.mockImplementationOnce(() => new Promise((resolve) => { + gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => { resolveHistory = resolve; })); @@ -706,7 +722,7 @@ describe('chat history actions', () => { return true; }); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -775,7 +791,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ @@ -824,7 +840,7 @@ describe('chat history actions', () => { }); const actions = createHistoryActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { messages: [ diff --git a/tests/unit/chat-input.test.tsx b/tests/unit/chat-input.test.tsx index b1c3e0be..7c9818ac 100644 --- a/tests/unit/chat-input.test.tsx +++ b/tests/unit/chat-input.test.tsx @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ChatInput } from '@/pages/Chat/ChatInput'; import { TooltipProvider } from '@/components/ui/tooltip'; -import { hostApiFetch } from '@/lib/host-api'; - +const hostApiFetchMock = vi.hoisted(() => vi.fn()); const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks } = vi.hoisted(() => ({ agentsState: { agents: [] as Array>, @@ -48,11 +47,25 @@ vi.mock('@/stores/artifact-panel', () => ({ })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: vi.fn(), -})); - -vi.mock('@/lib/api-client', () => ({ - invokeIpc: vi.fn(), + hostApiFetch: hostApiFetchMock, + hostApi: { + files: { + stagePaths: (input: unknown) => hostApiFetchMock('/api/files/stage-paths', { + method: 'POST', + body: JSON.stringify(input), + }), + stageBuffer: (input: unknown) => hostApiFetchMock('/api/files/stage-buffer', { + method: 'POST', + body: JSON.stringify(input), + }), + }, + skills: { + quickAccess: (input: unknown) => hostApiFetchMock('/api/skills/quick-access', { + method: 'POST', + body: JSON.stringify(input), + }), + }, + }, })); function translate(key: string, vars?: Record): string { @@ -125,7 +138,7 @@ describe('ChatInput agent targeting', () => { providersState.statuses = []; providersState.defaultAccountId = null; providersState.refreshProviderSnapshot.mockReset(); - vi.mocked(hostApiFetch).mockReset(); + vi.mocked(hostApiFetchMock).mockReset(); artifactPanelMocks.openPreview.mockReset(); }); @@ -341,7 +354,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -373,7 +386,7 @@ describe('ChatInput agent targeting', () => { fireEvent.click(screen.getByTitle('Send')); expect(onSend).toHaveBeenCalledWith('Draft /create-skill a new helper', undefined, null); - expect(hostApiFetch).toHaveBeenCalledWith( + expect(hostApiFetchMock).toHaveBeenCalledWith( '/api/skills/quick-access', expect.objectContaining({ method: 'POST', @@ -396,7 +409,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -441,7 +454,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -489,7 +502,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -530,7 +543,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -572,7 +585,7 @@ describe('ChatInput agent targeting', () => { channelTypes: [], }, ]; - vi.mocked(hostApiFetch).mockResolvedValue({ + vi.mocked(hostApiFetchMock).mockResolvedValue({ success: true, skills: [ { @@ -607,7 +620,7 @@ describe('ChatInput agent targeting', () => { }); it('stages dropped folders via disk path instead of buffer upload', async () => { - vi.mocked(hostApiFetch).mockResolvedValueOnce([{ + vi.mocked(hostApiFetchMock).mockResolvedValueOnce([{ id: 'folder-id', fileName: 'Archive', mimeType: 'application/x-directory', @@ -632,7 +645,7 @@ describe('ChatInput agent targeting', () => { }); await waitFor(() => { - expect(hostApiFetch).toHaveBeenCalledWith('/api/files/stage-paths', { + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/files/stage-paths', { method: 'POST', body: JSON.stringify({ filePaths: ['/tmp/project-folder'] }), }); diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx index 0de35fa8..7d3409b0 100644 --- a/tests/unit/chat-message.test.tsx +++ b/tests/unit/chat-message.test.tsx @@ -3,8 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { ChatMessage } from '@/pages/Chat/ChatMessage'; import type { RawMessage } from '@/stores/chat'; -vi.mock('@/lib/api-client', () => ({ - invokeIpc: vi.fn(), +vi.mock('@/lib/file-preview-client', () => ({ readBinaryFile: vi.fn(), statFile: vi.fn(async (path: string) => { if (path.includes('missing') || path.includes('不存在')) { @@ -483,7 +482,7 @@ describe('ChatMessage image copy', () => { }); it('copies image bytes instead of the media URL text when an image attachment is present', async () => { - const { readBinaryFile } = await import('@/lib/api-client'); + const { readBinaryFile } = await import('@/lib/file-preview-client'); vi.mocked(readBinaryFile).mockResolvedValueOnce({ ok: true, data: Uint8Array.from([137, 80, 78, 71]), diff --git a/tests/unit/chat-session-actions.test.ts b/tests/unit/chat-session-actions.test.ts index 5da7fbb3..9b983e0d 100644 --- a/tests/unit/chat-session-actions.test.ts +++ b/tests/unit/chat-session-actions.test.ts @@ -1,9 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const invokeIpcMock = vi.fn(); +const gatewayRpcMock = vi.fn(); +const sessionDeleteMock = vi.fn(); +const sessionRenameMock = vi.fn(); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), +vi.mock('@/lib/host-api', () => ({ + hostApi: { + gateway: { + rpc: async (method: string, params?: unknown, timeoutMs?: number) => { + const result = await gatewayRpcMock(method, params, timeoutMs) as { + success?: boolean; + result?: unknown; + error?: string; + }; + if (result?.success === false) { + throw new Error(result.error || `RPC ${method} failed`); + } + return result?.result; + }, + }, + sessions: { + delete: (id: string) => sessionDeleteMock(id), + rename: (id: string, title: string) => sessionRenameMock(id, title), + }, + }, })); type ChatLikeState = { @@ -54,7 +74,9 @@ function makeHarness(initial?: Partial) { describe('chat session actions', () => { beforeEach(() => { vi.resetAllMocks(); - invokeIpcMock.mockResolvedValue({ success: true }); + gatewayRpcMock.mockResolvedValue({ success: true }); + sessionDeleteMock.mockResolvedValue({ success: true }); + sessionRenameMock.mockResolvedValue({ success: true }); }); it('switchSession preserves non-main session that has activity history', async () => { @@ -111,7 +133,7 @@ describe('chat session actions', () => { await actions.deleteSession('agent:foo:session-a'); const next = h.read(); - expect(invokeIpcMock).toHaveBeenCalledWith('session:delete', 'agent:foo:session-a'); + expect(sessionDeleteMock).toHaveBeenCalledWith('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(); @@ -151,7 +173,7 @@ describe('chat session actions', () => { }); const actions = createSessionActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { sessions: [ @@ -192,7 +214,7 @@ describe('chat session actions', () => { }); const actions = createSessionActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { sessions: [{ @@ -229,7 +251,7 @@ describe('chat session actions', () => { }); const actions = createSessionActions(h.set as never, h.get as never); - invokeIpcMock.mockResolvedValueOnce({ + gatewayRpcMock.mockResolvedValueOnce({ success: true, result: { sessions: [{ @@ -250,4 +272,3 @@ describe('chat session actions', () => { expect(next.lastUserMessageAt).toBe(2000); }); }); - diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index f3520150..fa6b31bb 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -26,6 +26,35 @@ vi.mock('@/stores/agents', () => ({ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + media: { + thumbnails: vi.fn(async () => ({})), + }, + sessions: { + history: async (input: { sessionKey?: string; agentId?: string; sessionId?: string; limit?: number }) => { + if (input?.sessionKey) { + const params = new URLSearchParams(); + params.set('sessionKey', input.sessionKey); + params.set('limit', String(input.limit ?? 200)); + return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); + } + const params = new URLSearchParams(); + if (input?.agentId) params.set('agentId', input.agentId); + if (input?.sessionId) params.set('sessionId', input.sessionId); + if (input?.limit) params.set('limit', String(input.limit)); + return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); + }, + summaries: async (input: unknown) => hostApiFetchMock('/api/sessions/summaries', { + method: 'POST', + body: JSON.stringify(input), + }), + delete: vi.fn(async () => ({ success: true })), + rename: vi.fn(async () => ({ success: true })), + }, + chat: { + sendWithMedia: vi.fn(async () => ({ success: true, result: { runId: 'run-media' } })), + }, + }, })); describe('useChatStore startup history retry', () => { diff --git a/tests/unit/chat-store-session-label-fetch.test.ts b/tests/unit/chat-store-session-label-fetch.test.ts index 970e2a15..039a7bec 100644 --- a/tests/unit/chat-store-session-label-fetch.test.ts +++ b/tests/unit/chat-store-session-label-fetch.test.ts @@ -31,6 +31,23 @@ vi.mock('@/stores/agents', () => ({ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + media: { + thumbnails: vi.fn(async () => ({})), + }, + sessions: { + summaries: (input: unknown) => hostApiFetchMock('/api/sessions/summaries', { + method: 'POST', + body: JSON.stringify(input), + }), + history: vi.fn(async () => ({ messages: [] })), + delete: vi.fn(async () => ({ success: true })), + rename: vi.fn(async () => ({ success: true })), + }, + chat: { + sendWithMedia: vi.fn(async () => ({ success: true, result: { runId: 'run-media' } })), + }, + }, })); describe('chat store session label summary hydration', () => { diff --git a/tests/unit/chat-target-routing.test.ts b/tests/unit/chat-target-routing.test.ts index f2e6bf48..7202b342 100644 --- a/tests/unit/chat-target-routing.test.ts +++ b/tests/unit/chat-target-routing.test.ts @@ -25,6 +25,26 @@ vi.mock('@/stores/agents', () => ({ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + gateway: { + rpc: (...args: unknown[]) => gatewayRpcMock(...args), + }, + media: { + thumbnails: vi.fn(async () => ({})), + }, + sessions: { + history: vi.fn(async () => ({ messages: [] })), + summaries: vi.fn(async () => ({ success: true, summaries: [] })), + delete: vi.fn(async () => ({ success: true })), + rename: vi.fn(async () => ({ success: true })), + }, + chat: { + sendWithMedia: async (input: unknown) => hostApiFetchMock('/api/chat/send-with-media', { + method: 'POST', + body: JSON.stringify(input), + }), + }, + }, })); describe('chat target routing', () => { @@ -129,13 +149,13 @@ describe('chat target routing', () => { expect(state.sessions.some((session) => session.key === 'agent:research:desk')).toBe(true); expect(state.messages.at(-1)?.content).toBe('Hello direct agent'); - const historyCall = hostApiFetchMock.mock.calls.find(([url]) => url === '/api/chat/history'); - expect(JSON.parse((historyCall?.[1] as { body: string } | undefined)?.body ?? '{}')).toEqual( + const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history'); + expect(historyCall?.[1]).toEqual( chatHistoryRpcParams('agent:research:desk', 200), ); - const sendCall = hostApiFetchMock.mock.calls.find(([url]) => url === '/api/chat/send'); - const sendPayload = JSON.parse((sendCall?.[1] as { body: string } | undefined)?.body ?? '{}'); + const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send'); + const sendPayload = (sendCall?.[1] ?? {}) as Record; expect(sendPayload).toMatchObject({ sessionKey: 'agent:research:desk', message: 'Hello direct agent', diff --git a/tests/unit/confirm-dialog.test.tsx b/tests/unit/confirm-dialog.test.tsx new file mode 100644 index 00000000..0f7acc0a --- /dev/null +++ b/tests/unit/confirm-dialog.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/ui/dialog', async () => { + const React = await vi.importActual('react'); + const DialogStateContext = React.createContext(true); + + return { + Dialog: ({ + open, + children, + }: { + open: boolean; + children: React.ReactNode; + }) => ( + + {children} + + ), + DialogContent: ({ children }: { children: React.ReactNode }) => { + const open = React.useContext(DialogStateContext); + return ( +
+ {children} +
+ ); + }, + DialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + }; +}); + +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; + +describe('ConfirmDialog', () => { + it('keeps the last open copy while the dialog is closing', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Delete "Important chat"?')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText('Delete "Important chat"?')).toBeInTheDocument(); + }); + + it('keeps the active copy when cancel clears the owner state', () => { + function Harness() { + const [open, setOpen] = useState(true); + const [label, setLabel] = useState('Important chat'); + + return ( + { + setOpen(false); + setLabel(''); + }} + /> + ); + } + + render(); + + fireEvent.click(screen.getByTestId('confirm-dialog-cancel-button')); + + expect(screen.getByText('Delete "Important chat"?')).toBeInTheDocument(); + expect(screen.queryByText('Delete ""?')).not.toBeInTheDocument(); + }); + + it('keeps the active copy when confirm clears the owner state', () => { + function Harness() { + const [open, setOpen] = useState(true); + const [label, setLabel] = useState('Important chat'); + + return ( + { + setOpen(false); + setLabel(''); + }} + onCancel={vi.fn()} + /> + ); + } + + render(); + + fireEvent.click(screen.getByTestId('confirm-dialog-confirm-button')); + + expect(screen.getByText('Delete "Important chat"?')).toBeInTheDocument(); + expect(screen.queryByText('Delete ""?')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/cron-routes.test.ts b/tests/unit/cron-routes.test.ts deleted file mode 100644 index 7746eceb..00000000 --- a/tests/unit/cron-routes.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; - -const parseJsonBodyMock = vi.fn(); -const sendJsonMock = vi.fn(); - -vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -describe('handleCronRoutes', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('creates cron jobs with external delivery configuration', async () => { - parseJsonBodyMock.mockResolvedValue({ - name: 'Weather delivery', - message: 'Summarize today', - schedule: '0 9 * * *', - delivery: { - mode: 'announce', - channel: 'feishu', - to: 'user:ou_weather', - }, - enabled: true, - }); - - const rpc = vi.fn().mockResolvedValue({ - id: 'job-1', - name: 'Weather delivery', - enabled: true, - createdAtMs: 1, - updatedAtMs: 2, - schedule: { kind: 'cron', expr: '0 9 * * *' }, - payload: { kind: 'agentTurn', message: 'Summarize today' }, - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, - state: {}, - }); - - const { handleCronRoutes } = await import('@electron/api/routes/cron'); - const handled = await handleCronRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/cron/jobs'), - { - gatewayManager: { rpc }, - } as never, - ); - - expect(handled).toBe(true); - expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({ - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, - })); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - id: 'job-1', - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' }, - }), - ); - }); - - it('updates cron jobs with transformed payload and delivery fields', async () => { - parseJsonBodyMock.mockResolvedValue({ - message: 'Updated prompt', - delivery: { - mode: 'announce', - channel: 'feishu', - to: 'user:ou_next', - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - id: 'job-2', - name: 'Updated job', - enabled: true, - createdAtMs: 1, - updatedAtMs: 3, - schedule: { kind: 'cron', expr: '0 9 * * *' }, - payload: { kind: 'agentTurn', message: 'Updated prompt' }, - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, - state: {}, - }); - - const { handleCronRoutes } = await import('@electron/api/routes/cron'); - await handleCronRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/cron/jobs/job-2'), - { - gatewayManager: { rpc }, - } as never, - ); - - expect(rpc).toHaveBeenCalledWith('cron.update', { - id: 'job-2', - patch: { - payload: { kind: 'agentTurn', message: 'Updated prompt' }, - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, - }, - }); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - id: 'job-2', - message: 'Updated prompt', - delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' }, - }), - ); - }); - - it('passes through delivery.accountId for multi-account cron jobs', async () => { - parseJsonBodyMock.mockResolvedValue({ - delivery: { - mode: 'announce', - channel: 'feishu', - to: 'user:ou_owner', - accountId: 'feishu-0d009958', - }, - }); - - const rpc = vi.fn().mockResolvedValue({ - id: 'job-account', - name: 'Account job', - enabled: true, - createdAtMs: 1, - updatedAtMs: 4, - schedule: { kind: 'cron', expr: '0 9 * * *' }, - payload: { kind: 'agentTurn', message: 'Prompt' }, - delivery: { mode: 'announce', channel: 'feishu', accountId: 'feishu-0d009958', to: 'user:ou_owner' }, - state: {}, - }); - - const { handleCronRoutes } = await import('@electron/api/routes/cron'); - await handleCronRoutes( - { method: 'PUT' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/cron/jobs/job-account'), - { - gatewayManager: { rpc }, - } as never, - ); - - expect(rpc).toHaveBeenCalledWith('cron.update', { - id: 'job-account', - patch: { - delivery: { - mode: 'announce', - channel: 'feishu', - to: 'user:ou_owner', - accountId: 'feishu-0d009958', - }, - }, - }); - }); - - it('allows WeChat scheduled delivery', async () => { - parseJsonBodyMock.mockResolvedValue({ - name: 'WeChat delivery', - message: 'Send update', - schedule: '0 10 * * *', - delivery: { - mode: 'announce', - channel: 'wechat', - to: 'wechat:wxid_target', - accountId: 'wechat-bot', - }, - enabled: true, - }); - - const rpc = vi.fn().mockResolvedValue({ - id: 'job-wechat', - name: 'WeChat delivery', - enabled: true, - createdAtMs: 1, - updatedAtMs: 2, - schedule: { kind: 'cron', expr: '0 10 * * *' }, - payload: { kind: 'agentTurn', message: 'Send update' }, - delivery: { mode: 'announce', channel: 'openclaw-weixin', to: 'wechat:wxid_target', accountId: 'wechat-bot' }, - state: {}, - }); - - const { handleCronRoutes } = await import('@electron/api/routes/cron'); - const handled = await handleCronRoutes( - { method: 'POST' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/cron/jobs'), - { - gatewayManager: { rpc }, - } as never, - ); - - expect(handled).toBe(true); - expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({ - delivery: expect.objectContaining({ mode: 'announce', to: 'wechat:wxid_target' }), - })); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - expect.objectContaining({ - id: 'job-wechat', - }), - ); - }); -}); diff --git a/tests/unit/cron-store-fetch-dedupe.test.ts b/tests/unit/cron-store-fetch-dedupe.test.ts index 4f732cd7..f1d2a35f 100644 --- a/tests/unit/cron-store-fetch-dedupe.test.ts +++ b/tests/unit/cron-store-fetch-dedupe.test.ts @@ -4,6 +4,11 @@ const hostApiFetchMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + cron: { + list: () => hostApiFetchMock('/api/cron/jobs'), + }, + }, })); vi.mock('@/stores/chat', () => ({ diff --git a/tests/unit/diagnostics-routes.test.ts b/tests/unit/diagnostics-routes.test.ts deleted file mode 100644 index 03638aac..00000000 --- a/tests/unit/diagnostics-routes.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import type { IncomingMessage, ServerResponse } from 'http'; - -const buildChannelAccountsViewMock = vi.fn(); -const getChannelStatusDiagnosticsMock = vi.fn(); -const sendJsonMock = vi.fn(); -const readLogFileMock = vi.fn(); - -const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'diagnostics-routes-openclaw'); - -vi.mock('@electron/api/routes/channels', () => ({ - buildChannelAccountsView: (...args: unknown[]) => buildChannelAccountsViewMock(...args), - getChannelStatusDiagnostics: (...args: unknown[]) => getChannelStatusDiagnosticsMock(...args), -})); - -vi.mock('@electron/api/route-utils', () => ({ - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -vi.mock('@electron/utils/logger', () => ({ - logger: { - readLogFile: (...args: unknown[]) => readLogFileMock(...args), - }, -})); - -vi.mock('@electron/utils/paths', () => ({ - getOpenClawConfigDir: () => testOpenClawConfigDir, -})); - -describe('handleDiagnosticsRoutes', () => { - beforeEach(() => { - vi.resetAllMocks(); - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - mkdirSync(join(testOpenClawConfigDir, 'logs'), { recursive: true }); - buildChannelAccountsViewMock.mockResolvedValue({ - channels: [ - { - channelType: 'feishu', - defaultAccountId: 'default', - status: 'degraded', - accounts: [ - { - accountId: 'default', - name: 'Primary Account', - configured: true, - status: 'degraded', - statusReason: 'channels_status_timeout', - isDefault: true, - }, - ], - }, - ], - gatewayHealth: { - state: 'degraded', - reasons: ['channels_status_timeout'], - consecutiveHeartbeatMisses: 1, - }, - }); - getChannelStatusDiagnosticsMock.mockReturnValue({ - lastChannelsStatusOkAt: 100, - lastChannelsStatusFailureAt: 200, - }); - readLogFileMock.mockResolvedValue('clawx-log-tail'); - }); - - afterAll(() => { - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - }); - - it('returns diagnostics snapshot with channel view and tailed logs', async () => { - writeFileSync(join(testOpenClawConfigDir, 'logs', 'gateway.log'), 'gateway-line-1\ngateway-line-2\n'); - - const { handleDiagnosticsRoutes } = await import('@electron/api/routes/diagnostics'); - const handled = await handleDiagnosticsRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/diagnostics/gateway-snapshot'), - { - gatewayManager: { - getStatus: () => ({ state: 'running', port: 18789, connectedAt: 50 }), - getDiagnostics: () => ({ - lastAliveAt: 60, - lastRpcSuccessAt: 70, - consecutiveHeartbeatMisses: 1, - consecutiveRpcFailures: 0, - }), - }, - } as never, - ); - - expect(handled).toBe(true); - const payload = sendJsonMock.mock.calls.at(-1)?.[2] as { - platform?: string; - channels?: Array<{ channelType: string; status: string }>; - clawxLogTail?: string; - gatewayLogTail?: string; - gatewayErrLogTail?: string; - gateway?: { state?: string; reasons?: string[] }; - }; - expect(payload.platform).toBe(process.platform); - expect(payload.channels).toEqual([ - expect.objectContaining({ - channelType: 'feishu', - status: 'degraded', - }), - ]); - expect(payload.clawxLogTail).toBe('clawx-log-tail'); - expect(payload.gatewayLogTail).toContain('gateway-line-1'); - expect(payload.gatewayErrLogTail).toBe(''); - expect(payload.gateway?.state).toBe('degraded'); - expect(payload.gateway?.reasons).toEqual(expect.arrayContaining(['gateway_degraded'])); - }); - - it('returns empty gateway log tails when log files are missing', async () => { - const { handleDiagnosticsRoutes } = await import('@electron/api/routes/diagnostics'); - await handleDiagnosticsRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/diagnostics/gateway-snapshot'), - { - gatewayManager: { - getStatus: () => ({ state: 'running', port: 18789 }), - getDiagnostics: () => ({ - consecutiveHeartbeatMisses: 0, - consecutiveRpcFailures: 0, - }), - }, - } as never, - ); - - const payload = sendJsonMock.mock.calls.at(-1)?.[2] as { - gatewayLogTail?: string; - gatewayErrLogTail?: string; - }; - expect(payload.gatewayLogTail).toBe(''); - expect(payload.gatewayErrLogTail).toBe(''); - }); - - it('reads tailed logs without leaking unread buffer bytes', async () => { - writeFileSync(join(testOpenClawConfigDir, 'logs', 'gateway.log'), 'only-one-line'); - - const { handleDiagnosticsRoutes } = await import('@electron/api/routes/diagnostics'); - await handleDiagnosticsRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/diagnostics/gateway-snapshot'), - { - gatewayManager: { - getStatus: () => ({ state: 'running', port: 18789 }), - getDiagnostics: () => ({ - consecutiveHeartbeatMisses: 0, - consecutiveRpcFailures: 0, - }), - }, - } as never, - ); - - const payload = sendJsonMock.mock.calls.at(-1)?.[2] as { - gatewayLogTail?: string; - }; - expect(payload.gatewayLogTail).toBe('only-one-line'); - }); -}); diff --git a/tests/unit/dreams-page.test.tsx b/tests/unit/dreams-page.test.tsx index d8a99289..d883d55c 100644 --- a/tests/unit/dreams-page.test.tsx +++ b/tests/unit/dreams-page.test.tsx @@ -3,10 +3,9 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { Dreams } from '@/pages/Dreams'; const rpcMock = vi.fn(); -const hostApiFetchMock = vi.fn(); const tMock = (key: string) => key; -const { gatewayState } = vi.hoisted(() => ({ +const { gatewayState, hostApiMock } = vi.hoisted(() => ({ gatewayState: { status: { state: 'running', port: 18789, gatewayReady: true } as { state: string; @@ -14,6 +13,30 @@ const { gatewayState } = vi.hoisted(() => ({ gatewayReady?: boolean; }, }, + hostApiMock: { + gateway: { + status: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + health: vi.fn(), + controlUi: vi.fn(), + rpc: vi.fn(), + }, + settings: { + getAll: vi.fn(), + get: vi.fn(), + set: vi.fn(), + setMany: vi.fn(), + reset: vi.fn(), + }, + logs: { + recent: vi.fn(), + dir: vi.fn(), + listFiles: vi.fn(), + readFile: vi.fn(), + }, + }, })); vi.mock('@/stores/gateway', () => ({ @@ -24,7 +47,7 @@ vi.mock('@/stores/gateway', () => ({ })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: hostApiMock, })); vi.mock('react-i18next', () => ({ diff --git a/tests/unit/error-message.test.ts b/tests/unit/error-message.test.ts new file mode 100644 index 00000000..bef9d766 --- /dev/null +++ b/tests/unit/error-message.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { AppError, toUserMessage } from '@/lib/error-message'; + +describe('error-message', () => { + it('returns user-facing message for permission error', () => { + const msg = toUserMessage(new AppError('PERMISSION', 'forbidden')); + expect(msg).toContain('Permission denied'); + }); + + it('returns user-facing message for auth invalid error', () => { + const msg = toUserMessage(new AppError('AUTH_INVALID', 'Invalid Authentication')); + expect(msg).toContain('Authentication failed'); + }); + + it('returns user-facing message for channel unavailable error', () => { + const msg = toUserMessage(new AppError('CHANNEL_UNAVAILABLE', 'Invalid IPC channel')); + expect(msg).toContain('Service channel unavailable'); + }); +}); diff --git a/tests/unit/error-model.test.ts b/tests/unit/error-model.test.ts index 11380512..7a99aafd 100644 --- a/tests/unit/error-model.test.ts +++ b/tests/unit/error-model.test.ts @@ -12,7 +12,7 @@ describe('error-model', () => { }); it('normalizes ipc channel errors into CHANNEL_UNAVAILABLE', () => { - const error = normalizeAppError(new Error('Invalid IPC channel: hostapi:fetch')); + const error = normalizeAppError(new Error('Invalid IPC channel: unsupported:channel')); expect(error.code).toBe('CHANNEL_UNAVAILABLE'); }); @@ -23,4 +23,3 @@ describe('error-model', () => { expect(normalized.details).toEqual({ a: 1, b: 2 }); }); }); - diff --git a/tests/unit/extension-host-api-contributions.test.ts b/tests/unit/extension-host-api-contributions.test.ts new file mode 100644 index 00000000..41459c53 --- /dev/null +++ b/tests/unit/extension-host-api-contributions.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { extensionRegistry } from '../../electron/extensions/registry'; +import type { ExtensionContext, HostApiProviderExtension } from '../../electron/extensions/types'; + +describe('extension host API contributions', () => { + beforeEach(async () => { + await extensionRegistry.teardownAll(); + }); + + afterEach(async () => { + await extensionRegistry.teardownAll(); + }); + + it('registers host IPC contributions during extension initialization and unregisters them on teardown', async () => { + const unregister = vi.fn(); + const hostApiRegister = vi.fn(() => unregister); + const contributions = [{ + module: 'diagnostics', + actions: { + gatewaySnapshot: vi.fn(() => ({ capturedAt: 1 })), + }, + }]; + const extension: HostApiProviderExtension = { + id: 'builtin/diagnostics', + setup: vi.fn(), + teardown: vi.fn(), + getHostApiContributions: vi.fn(() => contributions), + }; + const ctx = { + gatewayManager: {} as ExtensionContext['gatewayManager'], + getMainWindow: () => null, + hostApi: { register: hostApiRegister }, + } satisfies ExtensionContext; + + extensionRegistry.register(extension); + await extensionRegistry.initialize(ctx); + + expect(extension.getHostApiContributions).toHaveBeenCalledWith(ctx); + expect(hostApiRegister).toHaveBeenCalledWith('builtin/diagnostics', contributions); + + await extensionRegistry.teardownAll(); + + expect(unregister).toHaveBeenCalledTimes(1); + expect(extension.teardown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/file-preview-body.test.tsx b/tests/unit/file-preview-body.test.tsx index 6bdf71dd..19095fce 100644 --- a/tests/unit/file-preview-body.test.tsx +++ b/tests/unit/file-preview-body.test.tsx @@ -11,22 +11,29 @@ vi.mock('react-i18next', () => ({ }), })); -const invokeIpc = vi.fn(async (channel: string) => { - if (channel === 'dialog:message') return { response: 1 }; - if (channel === 'shell:openPath') return ''; - return {}; -}); +const dialogMessageMock = vi.fn(async () => ({ response: 1 })); +const shellOpenPathMock = vi.fn(async () => ''); const readTextFile = vi.fn(); const statFile = vi.fn(); const writeTextFile = vi.fn(); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpc(...args), +vi.mock('@/lib/file-preview-client', () => ({ readTextFile: (...args: unknown[]) => readTextFile(...args), statFile: (...args: unknown[]) => statFile(...args), writeTextFile: (...args: unknown[]) => writeTextFile(...args), })); +vi.mock('@/lib/host-api', () => ({ + hostApi: { + dialog: { + message: (...args: unknown[]) => dialogMessageMock(...args), + }, + shell: { + openPath: (...args: unknown[]) => shellOpenPathMock(...args), + }, + }, +})); + function makePreviewTarget(overrides: Partial = {}): FilePreviewTarget { return { filePath: '/tmp/large-report.pdf', @@ -85,10 +92,10 @@ describe('FilePreviewBody', () => { fireEvent.click(openButton); await waitFor(() => { - expect(invokeIpc).toHaveBeenCalledWith('dialog:message', expect.objectContaining({ + expect(dialogMessageMock).toHaveBeenCalledWith(expect.objectContaining({ buttons: expect.arrayContaining(['Open directly']), })); - expect(invokeIpc).toHaveBeenCalledWith('shell:openPath', '/tmp/large-report.pdf'); + expect(shellOpenPathMock).toHaveBeenCalledWith('/tmp/large-report.pdf'); }); }); }); diff --git a/tests/unit/file-preview-client.test.ts b/tests/unit/file-preview-client.test.ts new file mode 100644 index 00000000..406244f4 --- /dev/null +++ b/tests/unit/file-preview-client.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiMock = vi.hoisted(() => ({ + files: { + readText: vi.fn(), + readBinary: vi.fn(), + writeText: vi.fn(), + stat: vi.fn(), + listDir: vi.fn(), + listTree: vi.fn(), + }, +})); + +vi.mock('@/lib/host-api', () => ({ + hostApi: hostApiMock, +})); + +import { + listDir, + listTree, + readBinaryFile, + readTextFile, + statFile, + writeTextFile, +} from '@/lib/file-preview-client'; + +describe('file-preview-client', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('delegates file preview helpers through hostApi.files', async () => { + hostApiMock.files.readText.mockResolvedValueOnce({ ok: true, content: 'hello' }); + hostApiMock.files.readBinary.mockResolvedValueOnce({ ok: true, data: new Uint8Array([1]) }); + hostApiMock.files.writeText.mockResolvedValueOnce({ ok: true }); + hostApiMock.files.stat.mockResolvedValueOnce({ ok: true, isFile: true, size: 5 }); + hostApiMock.files.listDir.mockResolvedValueOnce({ ok: true, entries: [] }); + hostApiMock.files.listTree.mockResolvedValueOnce({ + ok: true, + root: { name: 'root', relPath: '', absPath: '/tmp', isDir: true }, + }); + + await expect(readTextFile('/tmp/a.txt')).resolves.toEqual({ ok: true, content: 'hello' }); + await expect(readBinaryFile('/tmp/b.png', { maxBytes: 32 })).resolves.toEqual({ + ok: true, + data: new Uint8Array([1]), + }); + await expect(writeTextFile('/tmp/a.txt', 'updated')).resolves.toEqual({ ok: true }); + await expect(statFile('/tmp/a.txt')).resolves.toEqual({ ok: true, isFile: true, size: 5 }); + await expect(listDir('/tmp')).resolves.toEqual({ ok: true, entries: [] }); + await expect(listTree('/tmp', { maxDepth: 2 })).resolves.toEqual({ + ok: true, + root: { name: 'root', relPath: '', absPath: '/tmp', isDir: true }, + }); + + expect(hostApiMock.files.readText).toHaveBeenCalledWith('/tmp/a.txt'); + expect(hostApiMock.files.readBinary).toHaveBeenCalledWith('/tmp/b.png', { maxBytes: 32 }); + expect(hostApiMock.files.writeText).toHaveBeenCalledWith('/tmp/a.txt', 'updated'); + expect(hostApiMock.files.stat).toHaveBeenCalledWith('/tmp/a.txt'); + expect(hostApiMock.files.listDir).toHaveBeenCalledWith('/tmp'); + expect(hostApiMock.files.listTree).toHaveBeenCalledWith('/tmp', { maxDepth: 2 }); + }); +}); diff --git a/tests/unit/files-routes.test.ts b/tests/unit/files-routes.test.ts deleted file mode 100644 index 28c47baa..00000000 --- a/tests/unit/files-routes.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Unit tests for /api/files/stage-paths. - */ - -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -const sendJsonMock = vi.fn(); -const parseJsonBodyMock = vi.fn(); - -const testRootDir = join(tmpdir(), 'clawx-tests', 'files-routes'); - -vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -function resetFixtures(): void { - rmSync(testRootDir, { recursive: true, force: true }); - mkdirSync(testRootDir, { recursive: true }); -} - -function makeReq(method = 'POST'): IncomingMessage { - return { method } as IncomingMessage; -} - -function makeRes(): ServerResponse { - return { - setHeader: vi.fn(), - end: vi.fn(), - } as unknown as ServerResponse; -} - -const STAGE_PATHS_URL = new URL('http://127.0.0.1:13210/api/files/stage-paths'); -const THUMBNAILS_URL = new URL('http://127.0.0.1:13210/api/files/thumbnails'); -const ctx = {} as never; - -describe('handleFileRoutes — POST /api/files/stage-paths', () => { - beforeEach(() => { - vi.resetAllMocks(); - resetFixtures(); - }); - - afterAll(() => { - rmSync(testRootDir, { recursive: true, force: true }); - }); - - it('returns directory metadata without copying the folder', async () => { - const folderPath = join(testRootDir, 'project-folder'); - mkdirSync(folderPath); - - parseJsonBodyMock.mockResolvedValueOnce({ filePaths: [folderPath] }); - - const { handleFileRoutes } = await import('@electron/api/routes/files'); - const handled = await handleFileRoutes(makeReq(), makeRes(), STAGE_PATHS_URL, ctx); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledTimes(1); - const [, status, payload] = sendJsonMock.mock.calls[0] as [ServerResponse, number, Array>]; - expect(status).toBe(200); - expect(payload).toHaveLength(1); - expect(payload[0]).toMatchObject({ - fileName: 'project-folder', - mimeType: 'application/x-directory', - fileSize: 0, - stagedPath: folderPath, - preview: null, - }); - }); - - it('returns SVG previews as data URLs from thumbnails', async () => { - const svgPath = join(testRootDir, 'plan.svg'); - const svg = ''; - writeFileSync(svgPath, svg); - - parseJsonBodyMock.mockResolvedValueOnce({ - paths: [{ filePath: svgPath, mimeType: 'image/svg+xml' }], - }); - - const { handleFileRoutes } = await import('@electron/api/routes/files'); - const handled = await handleFileRoutes(makeReq(), makeRes(), THUMBNAILS_URL, ctx); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledTimes(1); - const [, status, payload] = sendJsonMock.mock.calls[0] as [ - ServerResponse, - number, - Record, - ]; - expect(status).toBe(200); - expect(payload[svgPath]).toEqual({ - preview: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, - fileSize: Buffer.byteLength(svg), - }); - }); -}); diff --git a/tests/unit/gateway-control-ui-route.test.ts b/tests/unit/gateway-control-ui-route.test.ts deleted file mode 100644 index 9ffec90c..00000000 --- a/tests/unit/gateway-control-ui-route.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { HostApiContext } from '@electron/api/context'; -import { handleGatewayRoutes } from '@electron/api/routes/gateway'; -import { scheduleControlUiDeviceAutoApproval } from '@electron/utils/control-ui-device-pairing'; - -vi.mock('@electron/utils/store', () => ({ - getSetting: vi.fn(async () => 'clawx-route-token'), -})); - -vi.mock('@electron/utils/control-ui-device-pairing', () => ({ - scheduleControlUiDeviceAutoApproval: vi.fn(), -})); - -function createResponse() { - const headers = new Map(); - let body = ''; - const res = { - statusCode: 0, - setHeader: (name: string, value: string) => { - headers.set(name, value); - }, - end: (value: string) => { - body = value; - }, - } as unknown as ServerResponse; - - return { - res, - get json() { - return JSON.parse(body) as { success: boolean; url: string; token: string; port: number }; - }, - get statusCode() { - return (res as ServerResponse).statusCode; - }, - headers, - }; -} - -function createContext(): HostApiContext { - return { - gatewayManager: { - getStatus: () => ({ port: 19001 }), - }, - clawHubService: {}, - eventBus: {}, - mainWindow: null, - } as unknown as HostApiContext; -} - -describe('GET /api/gateway/control-ui', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns the default Control UI URL', async () => { - const response = createResponse(); - const handled = await handleGatewayRoutes( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/gateway/control-ui'), - createContext(), - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json).toMatchObject({ - success: true, - url: 'http://127.0.0.1:19001/#token=clawx-route-token', - token: 'clawx-route-token', - port: 19001, - }); - expect(scheduleControlUiDeviceAutoApproval).toHaveBeenCalledOnce(); - }); - - it('returns the Dreams Control UI URL', async () => { - const response = createResponse(); - const handled = await handleGatewayRoutes( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/gateway/control-ui?view=dreams'), - createContext(), - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json).toMatchObject({ - success: true, - url: 'http://127.0.0.1:19001/dreaming#token=clawx-route-token', - token: 'clawx-route-token', - port: 19001, - }); - }); - - it('falls back to the default Control UI URL for unknown views', async () => { - const response = createResponse(); - await handleGatewayRoutes( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/gateway/control-ui?view=unknown'), - createContext(), - ); - - expect(response.json.url).toBe('http://127.0.0.1:19001/#token=clawx-route-token'); - }); -}); diff --git a/tests/unit/gateway-events.test.ts b/tests/unit/gateway-events.test.ts index 2fd6f52e..b104c6f6 100644 --- a/tests/unit/gateway-events.test.ts +++ b/tests/unit/gateway-events.test.ts @@ -1,7 +1,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const hostApiFetchMock = vi.fn(); -const subscribeHostEventMock = vi.fn(); +const hostApiMock = vi.hoisted(() => ({ + gateway: { + status: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + health: vi.fn(), + controlUi: vi.fn(), + rpc: vi.fn(), + }, + settings: { + getAll: vi.fn(), + get: vi.fn(), + set: vi.fn(), + setMany: vi.fn(), + reset: vi.fn(), + }, + logs: { + recent: vi.fn(), + dir: vi.fn(), + listFiles: vi.fn(), + readFile: vi.fn(), + }, +})); +const hostEventSubscriptionMock = vi.fn(); function flushAsyncImports(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); @@ -18,25 +41,34 @@ function deferred() { } vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: hostApiMock, })); vi.mock('@/lib/host-events', () => ({ - subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args), + hostEvents: { + onGatewayStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:status', handler), + onGatewayError: (handler: unknown) => hostEventSubscriptionMock('gateway:error', handler), + onGatewayNotification: (handler: unknown) => hostEventSubscriptionMock('gateway:notification', handler), + onGatewayHealth: (handler: unknown) => hostEventSubscriptionMock('gateway:health', handler), + onGatewayPresence: (handler: unknown) => hostEventSubscriptionMock('gateway:presence', handler), + onGatewayChatMessage: (handler: unknown) => hostEventSubscriptionMock('gateway:chat-message', handler), + onChatRuntimeEvent: (handler: unknown) => hostEventSubscriptionMock('chat:runtime-event', handler), + onGatewayChannelStatus: (handler: unknown) => hostEventSubscriptionMock('gateway:channel-status', handler), + }, })); describe('gateway store event wiring', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - hostApiFetchMock.mockResolvedValue({ state: 'running', port: 18789 }); + hostApiMock.gateway.status.mockResolvedValue({ state: 'running', port: 18789 }); }); - it('subscribes to host events through subscribeHostEvent on init', async () => { - hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); + it('subscribes to typed host events on init', async () => { + hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -44,14 +76,14 @@ describe('gateway store event wiring', () => { 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:health', expect.any(Function)); - expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:presence', expect.any(Function)); - expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); - expect(subscribeHostEventMock).toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); - expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:status', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:error', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:health', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:presence', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); + expect(hostEventSubscriptionMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 }); expect(useGatewayStore.getState().status.state).toBe('stopped'); @@ -64,10 +96,10 @@ describe('gateway store event wiring', () => { }); it('propagates gatewayReady field from status events', async () => { - hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789, gatewayReady: false }); + hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789, gatewayReady: false }); const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -84,10 +116,10 @@ describe('gateway store event wiring', () => { }); it('treats undefined gatewayReady as ready for backwards compatibility', async () => { - hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); + hostApiMock.gateway.status.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -103,7 +135,7 @@ describe('gateway store event wiring', () => { it('does not clear chat sending state on non-terminal runtime events', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -147,12 +179,12 @@ describe('gateway store event wiring', () => { it('does not let a stale send RPC re-arm a completed run after a newer send starts', async () => { let now = 1773281731000; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); - const firstSend = deferred<{ success: boolean; result?: { runId?: string } }>(); - const secondSend = deferred<{ success: boolean; result?: { runId?: string } }>(); + const firstSend = deferred<{ runId?: string }>(); + const secondSend = deferred<{ runId?: string }>(); const sendPromises = [firstSend.promise, secondSend.promise]; - hostApiFetchMock.mockImplementation((path: string) => { - if (path === '/api/chat/send') return sendPromises.shift(); - return Promise.resolve({ success: true, result: {} }); + hostApiMock.gateway.rpc.mockImplementation((method: string) => { + if (method === 'chat.send') return sendPromises.shift(); + return Promise.resolve({}); }); const { useChatStore } = await import('@/stores/chat'); @@ -188,12 +220,12 @@ describe('gateway store event wiring', () => { expect(useChatStore.getState().sending).toBe(true); expect(useChatStore.getState().lastUserMessageAt).toBe(1773281732000); - firstSend.resolve({ success: true, result: { runId: 'run-first' } }); + firstSend.resolve({ runId: 'run-first' }); await first; expect(useChatStore.getState().activeRunId).not.toBe('run-first'); expect(useChatStore.getState().lastUserMessageAt).toBe(1773281732000); - secondSend.resolve({ success: true, result: { runId: 'run-second' } }); + secondSend.resolve({ runId: 'run-second' }); await second; expect(useChatStore.getState().activeRunId).toBe('run-second'); @@ -233,7 +265,7 @@ describe('gateway store event wiring', () => { it('retains inactive-session runtime events for graph reconstruction after switching back', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -284,7 +316,7 @@ describe('gateway store event wiring', () => { it('clears cached inactive-session run state when run.ended arrives while another session is selected', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -327,7 +359,7 @@ describe('gateway store event wiring', () => { it('clears chat sending state on terminal run.ended runtime event', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -364,7 +396,7 @@ describe('gateway store event wiring', () => { it('does not clear the active send when a stale run.ended arrives for the same session', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -400,7 +432,7 @@ describe('gateway store event wiring', () => { it('ignores session-less runtime terminals that do not match the active run', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -435,7 +467,7 @@ describe('gateway store event wiring', () => { it('tracks a current-session run.started even when the optimistic send is already active', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -466,7 +498,7 @@ describe('gateway store event wiring', () => { it('forces a terminal history reload when the runtime emits run.ended', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -511,7 +543,7 @@ describe('gateway store event wiring', () => { it('forwards normalized chat runtime events through the dedicated host event channel', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -566,7 +598,7 @@ describe('gateway store event wiring', () => { it('passes progressive delta notifications without seq through to chat store', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); @@ -615,7 +647,7 @@ describe('gateway store event wiring', () => { it('dedupes exact replayed delta notifications without seq', async () => { const handlers = new Map void>(); - subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); diff --git a/tests/unit/gateway-ws-trace.test.ts b/tests/unit/gateway-ws-trace.test.ts new file mode 100644 index 00000000..120309c5 --- /dev/null +++ b/tests/unit/gateway-ws-trace.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + redactGatewayFrameForTrace, + summarizeGatewayFrameForTrace, +} from '../../electron/gateway/ws-trace'; + +describe('gateway ws trace', () => { + it('redacts auth and device secrets', () => { + const redacted = redactGatewayFrameForTrace({ + type: 'req', + method: 'connect', + params: { + auth: { token: 'secret-token' }, + device: { signature: 'device-signature' }, + headers: { Authorization: 'Bearer abc' }, + }, + }); + + expect(JSON.stringify(redacted)).not.toContain('secret-token'); + expect(JSON.stringify(redacted)).not.toContain('device-signature'); + expect(JSON.stringify(redacted)).not.toContain('Bearer abc'); + expect(JSON.stringify(redacted)).toContain('[redacted]'); + }); + + it('summarizes request and event frames', () => { + expect(summarizeGatewayFrameForTrace({ type: 'req', id: '1', method: 'chat.history' })) + .toEqual('req id=1 method=chat.history'); + expect(summarizeGatewayFrameForTrace({ type: 'event', event: 'chat' })) + .toEqual('event chat'); + }); +}); diff --git a/tests/unit/harness-specs.test.ts b/tests/unit/harness-specs.test.ts index 6c10f4ef..15c37324 100644 --- a/tests/unit/harness-specs.test.ts +++ b/tests/unit/harness-specs.test.ts @@ -32,7 +32,7 @@ Body`); it('matches repository glob paths', () => { expect(pathMatchesAny('src/stores/chat/history-actions.ts', ['src/stores/chat/**'])).toBe(true); - expect(pathMatchesAny('src/lib/api-client.ts', ['src/lib/api-client.ts'])).toBe(true); + expect(pathMatchesAny('src/lib/host-api.ts', ['src/lib/host-api.ts'])).toBe(true); expect(pathMatchesAny('src/pages/Chat/index.tsx', ['electron/gateway/**'])).toBe(false); }); @@ -45,7 +45,7 @@ Body`); scenario: 'gateway-backend-communication', taskType: 'runtime-bridge', intent: 'Adjust backend communication.', - touchedAreas: ['src/lib/api-client.ts'], + touchedAreas: ['src/lib/host-api.ts'], expectedUserBehavior: ['Visible state remains consistent.'], requiredProfiles: ['fast'], acceptance: ['Comms compare passes.'], @@ -55,7 +55,7 @@ Body`); const scenarioSpec = { data: { requiredProfiles: ['fast', 'comms'], - ownedPaths: ['src/lib/api-client.ts'], + ownedPaths: ['src/lib/host-api.ts'], }, }; @@ -149,7 +149,7 @@ Body`); it('allows fallback flags only in their boundary modules', async () => { const failures = await scanBackendCommunicationBoundary([ - 'src/lib/api-client.ts', + 'src/lib/host-api-client.ts', 'src/lib/host-api.ts', 'src/lib/host-events.ts', ]); diff --git a/tests/unit/history-transcript-fa3ccd85-fixture.test.ts b/tests/unit/history-transcript-fa3ccd85-fixture.test.ts index 5d514fd8..1535326d 100644 --- a/tests/unit/history-transcript-fa3ccd85-fixture.test.ts +++ b/tests/unit/history-transcript-fa3ccd85-fixture.test.ts @@ -12,6 +12,16 @@ const { hostApiFetchMock } = vi.hoisted(() => ({ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + sessions: { + history: (input: { sessionKey?: string; limit?: number }) => { + const params = new URLSearchParams(); + if (input.sessionKey) params.set('sessionKey', input.sessionKey); + params.set('limit', String(input.limit ?? 200)); + return hostApiFetchMock(`/api/sessions/transcript?${params.toString()}`); + }, + }, + }, })); const SESSION_KEY = 'agent:main:session-long-reply'; diff --git a/tests/unit/host-api-facade.test.ts b/tests/unit/host-api-facade.test.ts new file mode 100644 index 00000000..f5fcceb0 --- /dev/null +++ b/tests/unit/host-api-facade.test.ts @@ -0,0 +1,633 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const hostInvoke = vi.fn(); + +beforeEach(() => { + hostInvoke.mockReset(); + vi.resetModules(); + vi.stubGlobal('window', { + clawx: { hostInvoke }, + }); +}); + +describe('hostApi facade', () => { + it('calls settings.getAll through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { theme: 'dark' } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.settings.getAll()).resolves.toEqual({ theme: 'dark' }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'settings', + action: 'getAll', + })); + }); + + it('throws response errors', async () => { + hostInvoke.mockResolvedValueOnce({ + id: 'req', + ok: false, + error: { code: 'INTERNAL', message: 'disk failed' }, + }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.settings.getAll()).rejects.toThrow('disk failed'); + }); + + it('calls settings.setMany and reset through hostInvoke', async () => { + hostInvoke + .mockResolvedValueOnce({ id: 'req-1', ok: true, data: { success: true } }) + .mockResolvedValueOnce({ id: 'req-2', ok: true, data: { success: true, settings: { theme: 'system' } } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.settings.setMany({ theme: 'dark' })).resolves.toEqual({ success: true }); + await expect(hostApi.settings.reset()).resolves.toEqual({ + success: true, + settings: { theme: 'system' }, + }); + expect(hostInvoke).toHaveBeenNthCalledWith(1, expect.objectContaining({ + module: 'settings', + action: 'setMany', + payload: { patch: { theme: 'dark' } }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(2, expect.objectContaining({ + module: 'settings', + action: 'reset', + })); + }); + + it('routes openclaw, shell, dialog, window, and updates methods through hostInvoke', async () => { + hostInvoke + .mockResolvedValueOnce({ id: 'req-1', ok: true, data: { packageExists: true, isBuilt: true, entryPath: '/openclaw/openclaw.mjs', dir: '/openclaw' } }) + .mockResolvedValueOnce({ id: 'req-2', ok: true, data: '' }) + .mockResolvedValueOnce({ id: 'req-3', ok: true, data: { canceled: false, filePaths: ['/tmp/a.txt'] } }) + .mockResolvedValueOnce({ id: 'req-4', ok: true, data: undefined }) + .mockResolvedValueOnce({ id: 'req-5', ok: true, data: { success: true, status: { status: 'not-available' } } }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.openclaw.status(); + await hostApi.shell.openPath('/tmp/a.txt'); + await hostApi.dialog.open({ properties: ['openFile'] }); + await hostApi.window.maximize(); + await hostApi.updates.check(); + + expect(hostInvoke).toHaveBeenNthCalledWith(1, expect.objectContaining({ + module: 'openclaw', + action: 'status', + })); + expect(hostInvoke).toHaveBeenNthCalledWith(2, expect.objectContaining({ + module: 'shell', + action: 'openPath', + payload: { path: '/tmp/a.txt' }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(3, expect.objectContaining({ + module: 'dialog', + action: 'open', + payload: { properties: ['openFile'] }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(4, expect.objectContaining({ + module: 'window', + action: 'maximize', + })); + expect(hostInvoke).toHaveBeenNthCalledWith(5, expect.objectContaining({ + module: 'updates', + action: 'check', + })); + }); + + it('routes uv installer setup through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.uv.installAll()).resolves.toEqual({ success: true }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'uv', + action: 'installAll', + })); + }); + + it('passes log file path and tail lines through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { content: 'tail' } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.logs.readFile('/tmp/clawx.log', 50)).resolves.toEqual({ content: 'tail' }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'logs', + action: 'readFile', + payload: { path: '/tmp/clawx.log', tailLines: 50 }, + })); + }); + + it('calls channels.accounts through hostInvoke with options', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, channels: [] } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.channels.accounts({ mode: 'config', probe: false })).resolves.toEqual({ + success: true, + channels: [], + }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'channels', + action: 'accounts', + payload: { mode: 'config', probe: false }, + })); + }); + + it('passes channel credential validation payload through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ + id: 'req', + ok: true, + data: { success: true, valid: true, errors: [], warnings: [] }, + }); + const { hostApi } = await import('@/lib/host-api'); + + const config = { appId: 'cli_a', appSecret: 'secret' }; + await expect(hostApi.channels.validateCredentials('feishu', config)).resolves.toEqual({ + success: true, + valid: true, + errors: [], + warnings: [], + }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'channels', + action: 'validateCredentials', + payload: { channelType: 'feishu', config }, + })); + }); + + it('passes channel target lookup payload through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ + id: 'req', + ok: true, + data: { success: true, channelType: 'feishu', accountId: 'default', targets: [] }, + }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.channels.targets({ + channelType: 'feishu', + accountId: 'default', + query: 'alice', + })).resolves.toEqual({ + success: true, + channelType: 'feishu', + accountId: 'default', + targets: [], + }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'channels', + action: 'targets', + payload: { channelType: 'feishu', accountId: 'default', query: 'alice' }, + })); + }); + + it('calls agents.list through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, agents: [] } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.agents.list()).resolves.toEqual({ success: true, agents: [] }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'agents', + action: 'list', + })); + }); + + it('calls providers.list through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: [] }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.providers.list()).resolves.toEqual([]); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'providers', + action: 'list', + })); + }); + + it('passes provider validation payload through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { valid: true } }); + const { hostApi } = await import('@/lib/host-api'); + + const input = { accountId: 'custom', apiKey: 'sk-test' }; + await expect(hostApi.providers.validateKey(input)).resolves.toEqual({ valid: true }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'providers', + action: 'validateKey', + payload: input, + })); + }); + + it('passes provider OAuth requests through hostInvoke', async () => { + hostInvoke + .mockResolvedValueOnce({ id: 'req-1', ok: true, data: { success: true } }) + .mockResolvedValueOnce({ id: 'req-2', ok: true, data: { success: true } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.providers.requestOAuth({ + ['provider']: 'openai', + accountId: 'openai', + label: 'OpenAI', + })).resolves.toEqual({ success: true }); + await expect(hostApi.providers.cancelOAuth()).resolves.toEqual({ success: true }); + expect(hostInvoke).toHaveBeenNthCalledWith(1, expect.objectContaining({ + module: 'providers', + action: 'requestOAuth', + payload: { ['provider']: 'openai', accountId: 'openai', label: 'OpenAI' }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(2, expect.objectContaining({ + module: 'providers', + action: 'cancelOAuth', + })); + }); + + it('calls chat.sendWithMedia through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true } }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.chat.sendWithMedia({ sessionKey: 'main', message: 'hello', idempotencyKey: 'k' }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'chat', + action: 'sendWithMedia', + })); + }); + + it('calls sessions.summaries through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, summaries: [] } }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.sessions.summaries({ limit: 20 }); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'sessions', + action: 'summaries', + })); + }); + + it('calls cron.list through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: [] }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.cron.list(); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'cron', + action: 'list', + })); + }); + + it('calls skills.clawhubList through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, results: [] } }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.skills.clawhubList(); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'skills', + action: 'clawhubList', + })); + }); + + it('calls usage.recentTokenHistory through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: [] }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.usage.recentTokenHistory(25); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'usage', + action: 'recentTokenHistory', + payload: { limit: 25 }, + })); + }); + + it('keeps hostApi response types on facade methods instead of call-site generics', () => { + const srcRoot = join(process.cwd(), 'src'); + const files: string[] = []; + const collect = (dir: string) => { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + collect(fullPath); + } else if (/\.(ts|tsx)$/.test(entry)) { + files.push(fullPath); + } + } + }; + collect(srcRoot); + + const violations = files.flatMap((file) => { + const text = readFileSync(file, 'utf8'); + const matches = text.match(/hostApi\.(?!gateway\.rpc\b)[A-Za-z0-9_]+\.[A-Za-z0-9_]+ `${file.replace(`${process.cwd()}/`, '')}: ${match}`); + }); + + expect(violations).toEqual([]); + }); + + it('uses a function-shaped host API contract to type host invocations', () => { + const contract = readFileSync(join(process.cwd(), 'shared/host-api/contract.ts'), 'utf8'); + const client = readFileSync(join(process.cwd(), 'src/lib/host-api-client.ts'), 'utf8'); + const facade = readFileSync(join(process.cwd(), 'src/lib/host-api.ts'), 'utf8'); + const mainContract = readFileSync(join(process.cwd(), 'electron/main/ipc/host-contract.ts'), 'utf8'); + + expect(contract).toContain('export type HostApiContract = {'); + expect(contract).toMatch(/openClawDoctor:\s*\(payload:/); + expect(contract).not.toMatch(/\binput\s*:[^;]+;\s*output\s*:/s); + + expect(client).not.toContain('export async function invokeHost('); + expect(client).not.toContain('module: string,\n action: string,\n payload?: unknown,'); + expect(facade).not.toContain('invokeHost<'); + expect(mainContract).not.toContain('HostServiceAction = (payload?: unknown) => Promise | unknown'); + }); + + it('keeps async handler flexibility out of the renderer-facing host API contract', () => { + const contract = readFileSync(join(process.cwd(), 'shared/host-api/contract.ts'), 'utf8'); + const mainContract = readFileSync(join(process.cwd(), 'electron/main/ipc/host-contract.ts'), 'utf8'); + + expect(contract).not.toContain('MaybePromise'); + expect(contract).toContain('version: () => string;'); + expect(mainContract).toContain('type MaybePromise = T | Promise;'); + expect(mainContract).toContain('MaybePromise>'); + }); + + it('keeps production main, preload, renderer, and shared imports on their side of the boundary', () => { + const collectFiles = (root: string): string[] => { + const files: string[] = []; + const collect = (dir: string) => { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + collect(fullPath); + } else if (/\.(ts|tsx)$/.test(entry)) { + files.push(fullPath); + } + } + }; + collect(join(process.cwd(), root)); + return files; + }; + + const findViolations = (root: string, patterns: RegExp[]): string[] => collectFiles(root).flatMap((file) => { + const relative = file.replace(`${process.cwd()}/`, ''); + const text = readFileSync(file, 'utf8'); + return patterns.flatMap((pattern) => ( + [...text.matchAll(pattern)].map((match) => `${relative}: ${match[0]}`) + )); + }); + + const electronToRenderer = findViolations('electron', [ + /\bfrom\s+['"][^'"]*src\//g, + /\bimport\(\s*['"][^'"]*src\//g, + /\brequire\(\s*['"][^'"]*src\//g, + ]); + const rendererToElectron = findViolations('src', [ + /\bfrom\s+['"]electron['"]/g, + /\bfrom\s+['"]@electron\//g, + /\bfrom\s+['"][^'"]*(?:electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + /\bimport\(\s*['"][^'"]*(?:@electron\/|electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + /\brequire\(\s*['"][^'"]*(?:@electron\/|electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + ]); + const sharedToAppLayer = findViolations('shared', [ + /\bfrom\s+['"]@\//g, + /\bfrom\s+['"]@electron\//g, + /\bfrom\s+['"][^'"]*(?:src\/|electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + /\bimport\(\s*['"][^'"]*(?:@\/|@electron\/|src\/|electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + /\brequire\(\s*['"][^'"]*(?:@\/|@electron\/|src\/|electron\/|dist-electron|preload|ipc-handlers|host-contract)/g, + ]); + + expect({ + electronToRenderer, + rendererToElectron, + sharedToAppLayer, + oldHostApiContractPathExists: existsSync(join(process.cwd(), 'src/lib/host-api-contract.ts')), + oldHostApiTypesPathExists: existsSync(join(process.cwd(), 'src/lib/host-api-types.ts')), + oldI18nLocalesPathExists: existsSync(join(process.cwd(), 'src/i18n/locales')), + }).toEqual({ + electronToRenderer: [], + rendererToElectron: [], + sharedToAppLayer: [], + oldHostApiContractPathExists: false, + oldHostApiTypesPathExists: false, + oldI18nLocalesPathExists: false, + }); + }); + + it('lets service handlers inherit payload types from the host API contract', () => { + const servicesRoot = join(process.cwd(), 'electron/services'); + const files = readdirSync(servicesRoot) + .filter((entry) => /-api\.ts$/.test(entry)) + .map((entry) => join(servicesRoot, entry)); + + const violations = files.flatMap((file) => { + const relative = file.replace(`${process.cwd()}/`, ''); + const text = readFileSync(file, 'utf8'); + const localIsRecord = text.match(/^function isRecord\(/m) ? [`${relative}: use shared payload-utils isRecord`] : []; + const unknownHandlers = [...text.matchAll(/^\s{4}[A-Za-z][A-Za-z0-9_]*:\s*(?:async\s*)?\(payload\?: unknown\)/gm)] + .map((match) => `${relative}: ${match[0].trim()}`); + return [...localIsRecord, ...unknownHandlers]; + }); + + expect(violations).toEqual([]); + }); + + it('does not keep hostApi-covered legacy direct IPC channels registered', () => { + const mainIpcHandlers = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8'); + const preload = readFileSync(join(process.cwd(), 'electron/preload/index.ts'), 'utf8'); + const hostApiCoveredLegacyChannels = [ + 'channel:saveConfig', + 'channel:getConfig', + 'channel:getFormValues', + 'channel:deleteConfig', + 'channel:listConfigured', + 'channel:setEnabled', + 'channel:validate', + 'channel:validateCredentials', + 'channel:requestWhatsAppQr', + 'channel:cancelWhatsAppQr', + 'chat:sendWithMedia', + 'clawhub:search', + 'clawhub:install', + 'clawhub:uninstall', + 'clawhub:list', + 'clawhub:openSkillReadme', + 'cron:list', + 'cron:create', + 'cron:update', + 'cron:delete', + 'cron:toggle', + 'cron:trigger', + 'file:stage', + 'file:stageBuffer', + 'log:getRecent', + 'log:readFile', + 'log:getFilePath', + 'log:getDir', + 'log:listFiles', + 'media:getThumbnails', + 'media:saveImage', + 'provider:listVendors', + 'provider:listAccounts', + 'provider:getAccount', + 'provider:requestOAuth', + 'provider:cancelOAuth', + 'session:delete', + 'session:rename', + 'skill:updateConfig', + 'skill:getConfig', + 'skill:getAllConfigs', + ]; + + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const violations = hostApiCoveredLegacyChannels.flatMap((channel) => { + const mainRegistration = new RegExp(`ipcMain\\.handle\\(\\s*['"]${escapeRegExp(channel)}['"]`).test(mainIpcHandlers) + ? [`electron/main/ipc-handlers.ts: remove legacy ${channel} handler`] + : []; + const preloadAllowlist = preload.includes(`'${channel}'`) + ? [`electron/preload/index.ts: remove legacy ${channel} allowlist entry`] + : []; + return [...mainRegistration, ...preloadAllowlist]; + }); + + expect(violations).toEqual([]); + }); + + it('does not keep uninvoked direct IPC channels registered', () => { + const mainIpcHandlers = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8'); + const preload = readFileSync(join(process.cwd(), 'electron/preload/index.ts'), 'utf8'); + const uninvokedChannels = [ + 'app:getPath', + 'app:quit', + 'app:relaunch', + 'dialog:save', + 'gateway:isConnected', + 'gateway:start', + 'gateway:stop', + 'gateway:restart', + 'gateway:getControlUiUrl', + 'gateway:health', + 'openclaw:isReady', + 'openclaw:getDir', + 'openclaw:getConfigDir', + 'uv:check', + ]; + + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const violations = uninvokedChannels.flatMap((channel) => { + const mainRegistration = new RegExp(`ipcMain\\.handle\\(\\s*['"]${escapeRegExp(channel)}['"]`).test(mainIpcHandlers) + ? [`electron/main/ipc-handlers.ts: remove uninvoked ${channel} handler`] + : []; + const preloadAllowlist = preload.includes(`'${channel}'`) + ? [`electron/preload/index.ts: remove uninvoked ${channel} allowlist entry`] + : []; + return [...mainRegistration, ...preloadAllowlist]; + }); + + expect(violations).toEqual([]); + }); + + it('does not keep the legacy unified app:request client path', () => { + const mainIpcHandlers = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8'); + const apiClientPath = join(process.cwd(), 'src/lib/api-client.ts'); + + const violations = [ + ...(existsSync(apiClientPath) ? ['src/lib/api-client.ts: remove legacy unified API client'] : []), + ...(mainIpcHandlers.includes("case 'cron':") + ? ['electron/main/ipc-handlers.ts: remove legacy app:request cron module'] + : []), + ]; + + expect(violations).toEqual([]); + }); + + it('does not keep legacy IPC helper exports or production call sites', () => { + const srcRoot = join(process.cwd(), 'src'); + const files: string[] = []; + const collect = (dir: string) => { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + collect(fullPath); + } else if (/\.(ts|tsx)$/.test(entry)) { + files.push(fullPath); + } + } + }; + collect(srcRoot); + + const violations = files.flatMap((file) => { + const relative = file.replace(`${process.cwd()}/`, ''); + const text = readFileSync(file, 'utf8'); + const legacyIpcHelper = `${'invoke'}${'Ipc'}`; + const legacyApiHelper = `${'invoke'}${'Api'}`; + const matches = text.match(new RegExp( + `\\b${legacyIpcHelper}(?:WithRetry)?\\b|\\b${legacyApiHelper}\\b`, + 'g', + )) ?? []; + return matches.map((match) => `${relative}: remove ${match} and route through hostApi`); + }); + + expect(violations).toEqual([]); + }); + + it('keeps hostApi response shapes imported from the facade instead of redeclared by consumers', () => { + const forbiddenDeclarations = [ + { + file: 'src/pages/Settings/index.tsx', + pattern: /const \[doctorResult, setDoctorResult\] = useState<\{/, + replacement: 'OpenClawDoctorResult', + }, + { + file: 'src/stores/chat.ts', + pattern: /type SessionLabelSummary = \{/, + replacement: 'SessionLabelSummary', + }, + { + file: 'src/stores/skills.ts', + pattern: /type GatewaySkillStatus = \{/, + replacement: 'SkillsStatusResult', + }, + { + file: 'src/stores/skills.ts', + pattern: /type ClawHubListResult = \{/, + replacement: 'ClawHubInstalledSkill', + }, + { + file: 'src/pages/Agents/index.tsx', + pattern: /interface Channel(?:Account|Group)Item \{/, + replacement: 'ChannelGroupItem', + }, + { + file: 'src/pages/Channels/index.tsx', + pattern: /interface Channel(?:Account|Group)Item \{/, + replacement: 'ChannelGroupItem', + }, + { + file: 'src/pages/Channels/index.tsx', + pattern: /type ChannelsResponse = \{/, + replacement: 'ChannelAccountsResult', + }, + { + file: 'src/pages/Cron/index.tsx', + pattern: /interface (?:DeliveryChannelAccount|DeliveryChannelGroup|ChannelTargetOption) \{/, + replacement: 'DeliveryChannelGroup and ChannelTargetOption', + }, + ]; + + const violations = forbiddenDeclarations.flatMap(({ file, pattern, replacement }) => { + const text = readFileSync(join(process.cwd(), file), 'utf8'); + return pattern.test(text) ? [`${file}: import ${replacement} from host-api instead of redeclaring it`] : []; + }); + + expect(violations).toEqual([]); + }); + + it('keeps diagnostics on the extension-contributed host API path', () => { + const mainIpcHandlers = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8'); + const builtinIndex = readFileSync(join(process.cwd(), 'electron/extensions/builtin/index.ts'), 'utf8'); + const diagnosticsExtension = readFileSync(join(process.cwd(), 'electron/extensions/builtin/diagnostics.ts'), 'utf8'); + + expect(mainIpcHandlers).not.toContain('diagnostics: createDiagnosticsApi'); + expect(builtinIndex).toContain("import { createDiagnosticsExtension } from './diagnostics';"); + expect(builtinIndex).toContain("registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);"); + expect(diagnosticsExtension).toContain('getHostApiContributions'); + expect(diagnosticsExtension).not.toContain('HostApiRouteExtension'); + }); +}); diff --git a/tests/unit/host-api.test.ts b/tests/unit/host-api.test.ts deleted file mode 100644 index 9d45d513..00000000 --- a/tests/unit/host-api.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -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(); - window.localStorage.removeItem('clawx:allow-localhost-fallback'); - }); - - 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('falls back to browser fetch when hostapi handler is not registered', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ fallback: true }), - }); - vi.stubGlobal('fetch', fetchMock); - window.localStorage.setItem('clawx:allow-localhost-fallback', '1'); - - invokeIpcMock.mockResolvedValueOnce({ - ok: false, - error: { message: 'No handler registered for 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:13210/api/test', - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - 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); - window.localStorage.setItem('clawx:allow-localhost-fallback', '1'); - - 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:13210/api/test', - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - it('does not use localhost fallback when policy flag is disabled', 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'); - await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid IPC channel: hostapi:fetch'); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/host-events.test.ts b/tests/unit/host-events.test.ts index 367d4f95..a40a49fa 100644 --- a/tests/unit/host-events.test.ts +++ b/tests/unit/host-events.test.ts @@ -1,97 +1,69 @@ 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 on = vi.fn(); +const off = vi.fn(); -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 captured: Array<(...args: unknown[]) => void> = []; - const cleanupSpy = vi.fn(); - onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => { - captured.push(cb); - return cleanupSpy; - }); - - 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 should use the cleanup returned by ipc.on() — NOT ipc.off() - // which would pass the wrong function reference (see preload wrapper mismatch) - unsubscribe(); - expect(cleanupSpy).toHaveBeenCalledTimes(1); - }); - - it('maps chat runtime events to the dedicated IPC channel', async () => { - const onMock = vi.mocked(window.electron.ipcRenderer.on); - const captured: Array<(...args: unknown[]) => void> = []; - const cleanupSpy = vi.fn(); - onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => { - captured.push(cb); - return cleanupSpy; - }); - - const { subscribeHostEvent } = await import('@/lib/host-events'); - const handler = vi.fn(); - const unsubscribe = subscribeHostEvent('chat:runtime-event', handler); - - expect(onMock).toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); - captured[0]({ type: 'run.started', runId: 'run-1' }); - expect(handler).toHaveBeenCalledWith({ type: 'run.started', runId: 'run-1' }); - - unsubscribe(); - expect(cleanupSpy).toHaveBeenCalledTimes(1); - }); - - 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)); +beforeEach(() => { + on.mockReset(); + off.mockReset(); + vi.resetModules(); + vi.stubGlobal('window', { + electron: { ipcRenderer: { on, off } }, }); }); +describe('hostEvents', () => { + it('subscribes to gateway status over IPC', async () => { + on.mockReturnValueOnce(() => undefined); + const { hostEvents } = await import('@/lib/host-events'); + const handler = vi.fn(); + + hostEvents.onGatewayStatus(handler); + + expect(on).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function)); + }); + + it('passes typed payloads from IPC callbacks', async () => { + const { hostEvents } = await import('@/lib/host-events'); + const handler = vi.fn(); + + hostEvents.onUpdateStatusChanged(handler); + const callback = on.mock.calls[0]?.[1] as ((payload: unknown) => void) | undefined; + callback?.({ status: 'available', info: { version: '1.2.3' } }); + + expect(on).toHaveBeenCalledWith('update:status-changed', expect.any(Function)); + expect(handler).toHaveBeenCalledWith({ status: 'available', info: { version: '1.2.3' } }); + }); + + it('subscribes to chat runtime events over IPC', async () => { + const { hostEvents } = await import('@/lib/host-events'); + const handler = vi.fn(); + + hostEvents.onChatRuntimeEvent(handler); + const callback = on.mock.calls[0]?.[1] as ((payload: unknown) => void) | undefined; + callback?.({ type: 'run.started', runId: 'run-1' }); + + expect(on).toHaveBeenCalledWith('chat:runtime-event', expect.any(Function)); + expect(handler).toHaveBeenCalledWith({ type: 'run.started', runId: 'run-1' }); + }); + + it('subscribes to dynamic channel QR events', async () => { + const { hostEvents } = await import('@/lib/host-events'); + const handler = vi.fn(); + + hostEvents.onChannelQr('wechat', handler); + + expect(on).toHaveBeenCalledWith('channel:wechat-qr', expect.any(Function)); + }); + + it('does not create EventSource fallback', async () => { + const eventSource = vi.fn(); + vi.stubGlobal('EventSource', eventSource); + on.mockReturnValueOnce(() => undefined); + const { hostEvents } = await import('@/lib/host-events'); + + hostEvents.onGatewayNotification(vi.fn()); + + expect(eventSource).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/host-invoke.test.ts b/tests/unit/host-invoke.test.ts new file mode 100644 index 00000000..996cff18 --- /dev/null +++ b/tests/unit/host-invoke.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createHostInvokeDispatcher, HostApiRegistry } from '../../electron/main/ipc/host-invoke'; + +describe('host invoke dispatcher', () => { + it('dispatches a typed request to the matching service action', async () => { + const payload = { scope: 'all' }; + const services = { + settings: { + getAll: vi.fn(async (receivedPayload: unknown) => ({ theme: 'dark', receivedPayload })), + }, + }; + const dispatch = createHostInvokeDispatcher(services); + + await expect(dispatch({ + id: 'req-1', + module: 'settings', + action: 'getAll', + payload, + })).resolves.toEqual({ + id: 'req-1', + ok: true, + data: { theme: 'dark', receivedPayload: payload }, + }); + + expect(services.settings.getAll).toHaveBeenCalledWith(payload); + }); + + it('returns a validation error for malformed requests', async () => { + const dispatch = createHostInvokeDispatcher({}); + + await expect(dispatch({ id: 'bad', module: '', action: 'getAll' })).resolves.toMatchObject({ + id: 'bad', + ok: false, + error: { code: 'VALIDATION' }, + }); + }); + + it('returns unsupported for unknown module/action pairs', async () => { + const dispatch = createHostInvokeDispatcher({ settings: {} }); + + await expect(dispatch({ + id: 'req-2', + module: 'settings', + action: 'missing', + })).resolves.toMatchObject({ + id: 'req-2', + ok: false, + error: { code: 'UNSUPPORTED' }, + }); + }); + + it('returns unsupported for inherited module and action names', async () => { + const inheritedAction = vi.fn(); + const inheritedModule = vi.fn(); + const settings = Object.create({ toString: inheritedAction }); + const services = Object.create({ + inherited: { getAll: inheritedModule }, + }); + services.settings = settings; + const dispatch = createHostInvokeDispatcher(services); + + await expect(dispatch({ + id: 'req-3', + module: 'settings', + action: 'toString', + })).resolves.toMatchObject({ + id: 'req-3', + ok: false, + error: { code: 'UNSUPPORTED' }, + }); + + await expect(dispatch({ + id: 'req-4', + module: 'inherited', + action: 'getAll', + })).resolves.toMatchObject({ + id: 'req-4', + ok: false, + error: { code: 'UNSUPPORTED' }, + }); + + expect(inheritedAction).not.toHaveBeenCalled(); + expect(inheritedModule).not.toHaveBeenCalled(); + }); + + it('returns internal when a service action throws', async () => { + const dispatch = createHostInvokeDispatcher({ + settings: { + getAll: vi.fn(() => { + throw new Error('settings unavailable'); + }), + }, + }); + + await expect(dispatch({ + id: 'req-5', + module: 'settings', + action: 'getAll', + })).resolves.toEqual({ + id: 'req-5', + ok: false, + error: { code: 'INTERNAL', message: 'settings unavailable' }, + }); + }); + + it('dispatches extension-contributed actions registered after dispatcher creation', async () => { + const registry = new HostApiRegistry(); + const dispatch = createHostInvokeDispatcher(registry); + const gatewaySnapshot = vi.fn(() => ({ capturedAt: 123 })); + + const unregister = registry.registerExtensionContributions('builtin/diagnostics', [{ + module: 'diagnostics', + actions: { gatewaySnapshot }, + }]); + + await expect(dispatch({ + id: 'req-6', + module: 'diagnostics', + action: 'gatewaySnapshot', + })).resolves.toEqual({ + id: 'req-6', + ok: true, + data: { capturedAt: 123 }, + }); + expect(gatewaySnapshot).toHaveBeenCalledWith(undefined); + + unregister(); + + await expect(dispatch({ + id: 'req-7', + module: 'diagnostics', + action: 'gatewaySnapshot', + })).resolves.toMatchObject({ + id: 'req-7', + ok: false, + error: { code: 'UNSUPPORTED' }, + }); + }); + + it('prevents extension actions from overriding existing host actions', () => { + const registry = new HostApiRegistry(); + registry.registerCoreServices({ + settings: { + getAll: vi.fn(() => ({ theme: 'dark' })), + }, + }); + + expect(() => registry.registerExtensionContributions('extension/conflict', [{ + module: 'settings', + actions: { getAll: vi.fn() }, + }])).toThrow('Host API action already registered: settings.getAll'); + }); +}); diff --git a/tests/unit/host-services.test.ts b/tests/unit/host-services.test.ts new file mode 100644 index 00000000..a26d1251 --- /dev/null +++ b/tests/unit/host-services.test.ts @@ -0,0 +1,854 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + applyProxySettingsMock, + assignChannelAccountToAgentMock, + assignChannelToAgentMock, + clearChannelBindingMock, + createAgentMock, + deleteAgentConfigMock, + deleteChannelAccountConfigMock, + deleteChannelConfigMock, + ensureFeishuPluginInstalledMock, + getAllSettingsMock, + getChannelFormValuesMock, + getSettingMock, + listLogFilesMock, + logDir, + listAgentsSnapshotFromConfigMock, + listAgentsSnapshotMock, + listConfiguredChannelAccountsFromConfigMock, + listConfiguredChannelsFromConfigMock, + listConfiguredChannelsMock, + providerAccountToConfigMock, + providerServiceMock, + readOpenClawConfigMock, + readLogFileMock, + removeAgentWorkspaceDirectoryMock, + resetSettingsMock, + saveChannelConfigMock, + setSettingMock, + syncDefaultProviderToRuntimeMock, + syncSavedProviderToRuntimeMock, + syncLaunchAtStartupSettingFromStoreMock, + syncProxyConfigToOpenClawMock, + testOpenClawConfigDir, + updateAgentNameMock, + validateApiKeyWithProviderMock, +} = vi.hoisted(() => ({ + applyProxySettingsMock: vi.fn(), + assignChannelAccountToAgentMock: vi.fn(), + assignChannelToAgentMock: vi.fn(), + clearChannelBindingMock: vi.fn(), + createAgentMock: vi.fn(), + deleteAgentConfigMock: vi.fn(), + deleteChannelAccountConfigMock: vi.fn(), + deleteChannelConfigMock: vi.fn(), + ensureFeishuPluginInstalledMock: vi.fn(), + getAllSettingsMock: vi.fn(), + getChannelFormValuesMock: vi.fn(), + getSettingMock: vi.fn(), + listLogFilesMock: vi.fn(), + logDir: '/tmp/clawx-host-services-test-logs', + listAgentsSnapshotFromConfigMock: vi.fn(), + listAgentsSnapshotMock: vi.fn(), + listConfiguredChannelAccountsFromConfigMock: vi.fn(), + listConfiguredChannelsFromConfigMock: vi.fn(), + listConfiguredChannelsMock: vi.fn(), + providerAccountToConfigMock: vi.fn((account: Record) => ({ + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + apiProtocol: account.apiProtocol, + model: account.model, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + })), + providerServiceMock: { + _deleteProviderApiKeyInternal: vi.fn(), + _deleteProviderInternal: vi.fn(), + _getDefaultProviderInternal: vi.fn(), + _getProviderApiKeyInternal: vi.fn(), + _getProviderInternal: vi.fn(), + _hasProviderApiKeyInternal: vi.fn(), + _listProvidersWithKeyInfoInternal: vi.fn(), + _saveProviderInternal: vi.fn(), + _setDefaultProviderInternal: vi.fn(), + _setProviderApiKeyInternal: vi.fn(), + createAccount: vi.fn(), + deleteAccount: vi.fn(), + getAccount: vi.fn(), + getAccountApiKey: vi.fn(), + getDefaultAccountId: vi.fn(), + hasAccountApiKey: vi.fn(), + listAccounts: vi.fn(), + listAccountsKeyInfo: vi.fn(), + listVendors: vi.fn(), + setDefaultAccount: vi.fn(), + updateAccount: vi.fn(), + }, + readOpenClawConfigMock: vi.fn(), + readLogFileMock: vi.fn(), + removeAgentWorkspaceDirectoryMock: vi.fn(), + resetSettingsMock: vi.fn(), + saveChannelConfigMock: vi.fn(), + setSettingMock: vi.fn(), + syncDefaultProviderToRuntimeMock: vi.fn(), + syncSavedProviderToRuntimeMock: vi.fn(), + syncLaunchAtStartupSettingFromStoreMock: vi.fn(), + syncProxyConfigToOpenClawMock: vi.fn(), + testOpenClawConfigDir: '/tmp/clawx-host-services-openclaw', + updateAgentNameMock: vi.fn(), + validateApiKeyWithProviderMock: vi.fn(), +})); + +vi.mock('@electron/utils/store', () => ({ + getAllSettings: (...args: unknown[]) => getAllSettingsMock(...args), + getSetting: (...args: unknown[]) => getSettingMock(...args), + resetSettings: (...args: unknown[]) => resetSettingsMock(...args), + setSetting: (...args: unknown[]) => setSettingMock(...args), +})); + +vi.mock('@electron/utils/openclaw-proxy', () => ({ + syncProxyConfigToOpenClaw: (...args: unknown[]) => syncProxyConfigToOpenClawMock(...args), +})); + +vi.mock('@electron/main/proxy', () => ({ + applyProxySettings: (...args: unknown[]) => applyProxySettingsMock(...args), +})); + +vi.mock('@electron/main/launch-at-startup', () => ({ + syncLaunchAtStartupSettingFromStore: (...args: unknown[]) => syncLaunchAtStartupSettingFromStoreMock(...args), +})); + +vi.mock('@electron/utils/logger', async (importOriginal) => { + const actual = await importOriginal(); + return { + logger: { + info: vi.fn(), + warn: vi.fn(), + getLogDir: () => logDir, + getLogFilePath: () => join(logDir, 'clawx-current.log'), + getRecentLogs: vi.fn(), + listLogFiles: (...args: unknown[]) => listLogFilesMock(...args), + readLogFile: (...args: unknown[]) => readLogFileMock(...args), + }, + readLogFileTail: actual.readLogFileTail, + }; +}); + +vi.mock('@electron/utils/channel-config', () => ({ + cleanupDanglingWeChatPluginState: vi.fn(), + deleteChannelAccountConfig: (...args: unknown[]) => deleteChannelAccountConfigMock(...args), + deleteChannelConfig: (...args: unknown[]) => deleteChannelConfigMock(...args), + getChannelFormValues: (...args: unknown[]) => getChannelFormValuesMock(...args), + listConfiguredChannelAccountsFromConfig: (...args: unknown[]) => listConfiguredChannelAccountsFromConfigMock(...args), + listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args), + listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsFromConfigMock(...args), + readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), + saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args), + setChannelDefaultAccount: vi.fn(), + setChannelEnabled: vi.fn(), + validateChannelConfig: vi.fn(), + validateChannelCredentials: vi.fn(), +})); + +vi.mock('@electron/utils/agent-config', () => ({ + assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args), + assignChannelToAgent: (...args: unknown[]) => assignChannelToAgentMock(...args), + clearAllBindingsForChannel: vi.fn(), + clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args), + createAgent: (...args: unknown[]) => createAgentMock(...args), + deleteAgentConfig: (...args: unknown[]) => deleteAgentConfigMock(...args), + listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), + listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotFromConfigMock(...args), + removeAgentWorkspaceDirectory: (...args: unknown[]) => removeAgentWorkspaceDirectoryMock(...args), + resolveAccountIdForAgent: vi.fn((agentId: string) => agentId === 'main' ? 'default' : agentId), + updateAgentModel: vi.fn(), + updateAgentName: (...args: unknown[]) => updateAgentNameMock(...args), +})); + +vi.mock('@electron/utils/plugin-install', () => ({ + ensureDiscordPluginInstalled: vi.fn(), + ensureDingTalkPluginInstalled: vi.fn(), + ensureFeishuPluginInstalled: (...args: unknown[]) => ensureFeishuPluginInstalledMock(...args), + ensureQQBotPluginInstalled: vi.fn(), + ensureWeChatPluginInstalled: vi.fn(), + ensureWeComPluginInstalled: vi.fn(), + ensureWhatsAppPluginInstalled: vi.fn(), +})); + +vi.mock('@electron/utils/openclaw-workspace', () => ({ + ensureClawXContext: vi.fn(), +})); + +vi.mock('@electron/services/providers/provider-runtime-sync', () => ({ + syncAllProviderAuthToRuntime: vi.fn(), + syncAgentModelOverrideToRuntime: vi.fn(), + syncDefaultProviderToRuntime: (...args: unknown[]) => syncDefaultProviderToRuntimeMock(...args), + syncDeletedProviderApiKeyToRuntime: vi.fn(), + syncDeletedProviderToRuntime: vi.fn(), + syncProviderApiKeyToRuntime: vi.fn(), + syncSavedProviderToRuntime: (...args: unknown[]) => syncSavedProviderToRuntimeMock(...args), + syncUpdatedProviderToRuntime: vi.fn(), + getOpenClawProviderKey: vi.fn((type: string) => type), +})); + +vi.mock('@electron/services/providers/provider-service', () => ({ + getProviderService: () => providerServiceMock, +})); + +vi.mock('@electron/services/providers/provider-store', () => ({ + providerAccountToConfig: (...args: unknown[]) => providerAccountToConfigMock(...args), +})); + +vi.mock('@electron/services/providers/provider-validation', () => ({ + validateApiKeyWithProvider: (...args: unknown[]) => validateApiKeyWithProviderMock(...args), +})); + +vi.mock('@electron/utils/browser-oauth', () => ({ + browserOAuthManager: { + setWindow: vi.fn(), + startFlow: vi.fn(), + stopFlow: vi.fn(), + submitManualCode: vi.fn(), + }, +})); + +vi.mock('@electron/utils/device-oauth', () => ({ + deviceOAuthManager: { + setWindow: vi.fn(), + startFlow: vi.fn(), + stopFlow: vi.fn(), + }, +})); + +vi.mock('@electron/utils/wechat-login', () => ({ + cancelWeChatLoginSession: vi.fn(), + saveWeChatAccountState: vi.fn(), + startWeChatLoginSession: vi.fn(), + waitForWeChatLoginSession: vi.fn(), +})); + +vi.mock('@electron/utils/whatsapp-login', () => ({ + whatsAppLoginManager: { + start: vi.fn(), + stop: vi.fn(), + }, +})); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawConfigDir: () => testOpenClawConfigDir, + getOpenClawDir: () => testOpenClawConfigDir, + getOpenClawResolvedDir: () => testOpenClawConfigDir, +})); + +vi.mock('@electron/utils/proxy-fetch', () => ({ + proxyAwareFetch: vi.fn(), +})); + +vi.mock('@electron/utils/openclaw-sdk', () => ({ + listDiscordDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), + listDiscordDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), + normalizeDiscordMessagingTarget: vi.fn().mockReturnValue(undefined), + listTelegramDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), + listTelegramDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), + normalizeTelegramMessagingTarget: vi.fn().mockReturnValue(undefined), + listSlackDirectoryGroupsFromConfig: vi.fn().mockResolvedValue([]), + listSlackDirectoryPeersFromConfig: vi.fn().mockResolvedValue([]), + normalizeSlackMessagingTarget: vi.fn().mockReturnValue(undefined), + normalizeWhatsAppMessagingTarget: vi.fn().mockReturnValue(undefined), +})); + +const baseSettings = { + proxyEnabled: false, + proxyServer: '', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: '', + proxyBypassRules: '', + launchAtStartup: false, + theme: 'system', +}; + +describe('host services', () => { + beforeEach(() => { + vi.clearAllMocks(); + getAllSettingsMock.mockResolvedValue(baseSettings); + readOpenClawConfigMock.mockResolvedValue({ channels: {} }); + listConfiguredChannelsMock.mockResolvedValue([]); + listConfiguredChannelsFromConfigMock.mockResolvedValue([]); + listConfiguredChannelAccountsFromConfigMock.mockReturnValue({}); + listAgentsSnapshotMock.mockResolvedValue({ + agents: [], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }); + listAgentsSnapshotFromConfigMock.mockResolvedValue({ + agents: [], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }); + getChannelFormValuesMock.mockResolvedValue(undefined); + providerServiceMock._listProvidersWithKeyInfoInternal.mockResolvedValue([]); + providerServiceMock.getAccount.mockResolvedValue(null); + providerServiceMock.getDefaultAccountId.mockResolvedValue(undefined); + providerServiceMock.listAccounts.mockResolvedValue([]); + providerServiceMock.listAccountsKeyInfo.mockResolvedValue([]); + providerServiceMock.listVendors.mockResolvedValue([]); + providerServiceMock.createAccount.mockImplementation(async (account: unknown) => account); + providerServiceMock.setDefaultAccount.mockResolvedValue(undefined); + validateApiKeyWithProviderMock.mockResolvedValue({ valid: true }); + ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true }); + rmSync(logDir, { recursive: true, force: true }); + rmSync(testOpenClawConfigDir, { recursive: true, force: true }); + mkdirSync(logDir, { recursive: true }); + mkdirSync(join(testOpenClawConfigDir, 'logs'), { recursive: true }); + }); + + it('runs proxy side effects and restarts a running gateway after settings.set', async () => { + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + restart: vi.fn(), + }; + const { createSettingsApi } = await import('@electron/services/settings-api'); + + await expect(createSettingsApi(gatewayManager as never).set({ + key: 'proxyServer', + value: 'http://127.0.0.1:7890', + })).resolves.toEqual({ success: true }); + + expect(setSettingMock).toHaveBeenCalledWith('proxyServer', 'http://127.0.0.1:7890'); + expect(syncProxyConfigToOpenClawMock).toHaveBeenCalledWith(baseSettings, { + preserveExistingWhenDisabled: false, + }); + expect(applyProxySettingsMock).toHaveBeenCalledWith(baseSettings); + expect(gatewayManager.restart).toHaveBeenCalledTimes(1); + }); + + it('runs launch-at-startup side effects after settings.setMany and reset', async () => { + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'stopped', port: 18789 })), + restart: vi.fn(), + }; + const { createSettingsApi } = await import('@electron/services/settings-api'); + const settingsApi = createSettingsApi(gatewayManager as never); + + await expect(settingsApi.setMany({ patch: { launchAtStartup: true } })).resolves.toEqual({ success: true }); + await expect(settingsApi.reset()).resolves.toEqual({ success: true, settings: baseSettings }); + + expect(setSettingMock).toHaveBeenCalledWith('launchAtStartup', true); + expect(resetSettingsMock).toHaveBeenCalledTimes(1); + expect(syncLaunchAtStartupSettingFromStoreMock).toHaveBeenCalledTimes(2); + expect(syncProxyConfigToOpenClawMock).toHaveBeenCalledTimes(1); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }); + + it('routes gateway rpc through backpressure', async () => { + const gatewayManager = { + rpc: vi.fn(async () => ({ ok: true })), + }; + const backpressure = { + run: vi.fn(async (_method, _params, _timeoutMs, runner) => runner('chat.history', { limit: 1 }, 42)), + }; + const { createGatewayApi } = await import('@electron/services/gateway-api'); + + await expect(createGatewayApi(gatewayManager as never, backpressure as never).rpc({ + method: 'chat.history', + params: { limit: 1 }, + timeoutMs: 42, + })).resolves.toEqual({ ok: true }); + + expect(backpressure.run).toHaveBeenCalledWith( + 'chat.history', + { limit: 1 }, + 42, + expect.any(Function), + ); + expect(gatewayManager.rpc).toHaveBeenCalledWith('chat.history', { limit: 1 }, 42); + }); + + it('exposes provider account snapshot actions through the typed providers service', async () => { + const account = { + id: 'custom-local', + vendorId: 'custom', + label: 'Local', + authMode: 'api_key', + baseUrl: 'http://127.0.0.1:1234/v1', + model: 'local-model', + enabled: true, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }; + const keyInfo = [{ accountId: 'custom-local', hasKey: true, keyMasked: 'sk-***' }]; + providerServiceMock.listAccounts.mockResolvedValue([account]); + providerServiceMock.listAccountsKeyInfo.mockResolvedValue(keyInfo); + providerServiceMock.listVendors.mockResolvedValue([{ id: 'custom', name: 'Custom' }]); + providerServiceMock.getDefaultAccountId.mockResolvedValue('custom-local'); + const { createProvidersApi } = await import('@electron/services/providers-api'); + const providersApi = createProvidersApi({ + gatewayManager: { debouncedReload: vi.fn() } as never, + mainWindow: {} as never, + }); + + await expect(providersApi.accounts()).resolves.toEqual([account]); + await expect(providersApi.accountKeyInfo()).resolves.toEqual(keyInfo); + await expect(providersApi.vendors()).resolves.toEqual([{ id: 'custom', name: 'Custom' }]); + await expect(providersApi.getDefaultAccount()).resolves.toEqual({ accountId: 'custom-local' }); + }); + + it('validates provider keys using account metadata and caller options', async () => { + providerServiceMock.getAccount.mockResolvedValue({ + id: 'custom-local', + vendorId: 'custom', + baseUrl: 'http://persisted.example/v1', + apiProtocol: 'openai-completions', + }); + validateApiKeyWithProviderMock.mockResolvedValue({ valid: true }); + const { createProvidersApi } = await import('@electron/services/providers-api'); + const providersApi = createProvidersApi({ + gatewayManager: {} as never, + mainWindow: {} as never, + }); + + await expect(providersApi.validateKey({ + accountId: 'custom-local', + apiKey: 'sk-test', + options: { baseUrl: 'http://live.example/v1', apiProtocol: 'openai-responses' }, + })).resolves.toEqual({ valid: true }); + + expect(validateApiKeyWithProviderMock).toHaveBeenCalledWith('custom', 'sk-test', { + baseUrl: 'http://live.example/v1', + apiProtocol: 'openai-responses', + }); + }); + + it('creates provider accounts and syncs runtime config through the typed providers service', async () => { + const account = { + id: 'custom-local', + vendorId: 'custom', + label: 'Local', + authMode: 'api_key', + baseUrl: 'http://127.0.0.1:1234/v1', + model: 'local-model', + enabled: true, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }; + providerServiceMock.createAccount.mockResolvedValue(account); + const gatewayManager = { debouncedReload: vi.fn() }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + await expect(createProvidersApi({ + gatewayManager: gatewayManager as never, + mainWindow: {} as never, + }).createAccount({ account, apiKey: 'sk-test' })).resolves.toEqual({ + success: true, + account, + }); + + expect(providerServiceMock.createAccount).toHaveBeenCalledWith(account, 'sk-test'); + expect(providerAccountToConfigMock).toHaveBeenCalledWith(account); + expect(syncSavedProviderToRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-local', type: 'custom' }), + 'sk-test', + gatewayManager, + ); + }); + + it('sets the default provider account and syncs runtime defaults', async () => { + providerServiceMock.getDefaultAccountId.mockResolvedValue('old-default'); + const gatewayManager = { debouncedReload: vi.fn() }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + await expect(createProvidersApi({ + gatewayManager: gatewayManager as never, + mainWindow: {} as never, + }).setDefaultAccount({ accountId: 'custom-local' })).resolves.toEqual({ success: true }); + + expect(providerServiceMock.setDefaultAccount).toHaveBeenCalledWith('custom-local'); + expect(syncDefaultProviderToRuntimeMock).toHaveBeenCalledWith('custom-local', gatewayManager); + }); + + it('builds channel accounts from config without gateway rpc in config mode', async () => { + const openClawConfig = { + channels: { + feishu: { + defaultAccount: 'default', + accounts: { + 'team-bot': { appId: 'cli_team', appSecret: 'secret' }, + }, + }, + }, + }; + readOpenClawConfigMock.mockResolvedValue(openClawConfig); + listConfiguredChannelsFromConfigMock.mockResolvedValue(['feishu']); + listConfiguredChannelAccountsFromConfigMock.mockReturnValue({ + feishu: { + defaultAccountId: 'team-bot', + accountIds: ['team-bot'], + }, + }); + listAgentsSnapshotFromConfigMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: { + 'feishu:team-bot': 'main', + }, + }); + const gatewayManager = { + rpc: vi.fn(), + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + getDiagnostics: vi.fn(() => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 })), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).accounts({ mode: 'config' })) + .resolves.toMatchObject({ + success: true, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'team-bot', + accounts: [ + { + accountId: 'team-bot', + configured: true, + isDefault: true, + agentId: 'main', + }, + ], + }, + ], + }); + + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + }); + + it('lists channel targets from session history and validates channel type', async () => { + const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, 'sessions.json'), JSON.stringify({ + sessions: [ + { + deliveryContext: { + channel: 'dingtalk', + accountId: 'ding-main', + to: 'cid-group-1', + }, + displayName: 'Release Room', + chatType: 'group', + updatedAt: 100, + }, + ], + })); + const { createChannelsApi } = await import('@electron/services/channels-api'); + const channelsApi = createChannelsApi({ + gatewayManager: { + getStatus: vi.fn(() => ({ state: 'running' })), + getDiagnostics: vi.fn(), + } as never, + }); + + await expect(channelsApi.targets({ channelType: 'dingtalk', accountId: 'ding-main' })) + .resolves.toEqual({ + success: true, + channelType: 'dingtalk', + accountId: 'ding-main', + targets: [ + { + value: 'cid-group-1', + label: 'Release Room (cid-group-1)', + kind: 'group', + }, + ], + }); + await expect(channelsApi.targets({ accountId: 'ding-main' })).rejects.toThrow('channelType is required'); + }); + + it('saves channel binding for existing agents and schedules channel refresh', async () => { + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: {}, + }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + debouncedRestart: vi.fn(), + debouncedReload: vi.fn(), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).bindingSave({ + channelType: 'feishu', + accountId: 'default', + agentId: 'main', + })).resolves.toEqual({ success: true }); + + expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'default'); + expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + }); + + it('installs plugin, saves config, ensures scoped binding, and schedules refresh on saveConfig', async () => { + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: {}, + }); + getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + debouncedRestart: vi.fn(), + debouncedReload: vi.fn(), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).saveConfig({ + channelType: 'feishu', + accountId: 'default', + config: { appId: 'cli_new', appSecret: 'new-secret' }, + })).resolves.toEqual({ success: true }); + + expect(ensureFeishuPluginInstalledMock).toHaveBeenCalledTimes(1); + expect(saveChannelConfigMock).toHaveBeenCalledWith( + 'feishu', + { appId: 'cli_new', appSecret: 'new-secret' }, + 'default', + ); + expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'default'); + expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); + }); + + it('deletes agents by restarting gateway, removing workspace, and returning snapshot', async () => { + const snapshot = { + agents: [], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }; + const removedEntry = { id: 'code', workspace: '/tmp/code-workspace' }; + deleteAgentConfigMock.mockResolvedValue({ snapshot, removedEntry }); + removeAgentWorkspaceDirectoryMock.mockResolvedValue(undefined); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + restart: vi.fn().mockResolvedValue(undefined), + }; + const { createAgentsApi } = await import('@electron/services/agents-api'); + + await expect(createAgentsApi({ gatewayManager: gatewayManager as never }).delete({ id: 'code' })) + .resolves.toEqual({ success: true, ...snapshot }); + + expect(deleteAgentConfigMock).toHaveBeenCalledWith('code'); + expect(gatewayManager.restart).toHaveBeenCalledTimes(1); + expect(removeAgentWorkspaceDirectoryMock).toHaveBeenCalledWith(removedEntry); + }); + + it('assigns agent channels and schedules gateway reload', async () => { + const snapshot = { + agents: [{ id: 'main', channelTypes: ['feishu'] }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: { feishu: 'main' }, + channelAccountOwners: {}, + }; + assignChannelToAgentMock.mockResolvedValue(snapshot); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + }; + const { createAgentsApi } = await import('@electron/services/agents-api'); + + await expect(createAgentsApi({ gatewayManager: gatewayManager as never }).assignChannel({ + id: 'main', + channelType: 'feishu', + })).resolves.toEqual({ success: true, ...snapshot }); + + expect(assignChannelToAgentMock).toHaveBeenCalledWith('main', 'feishu'); + expect(gatewayManager.debouncedReload).toHaveBeenCalledTimes(1); + }); + + it('returns diagnostics snapshot with channel view and log tails', async () => { + writeFileSync(join(testOpenClawConfigDir, 'logs', 'gateway.log'), 'gateway-one\ngateway-two\n'); + readLogFileMock.mockResolvedValue('clawx-log-tail'); + readOpenClawConfigMock.mockResolvedValue({ + channels: { + feishu: { + defaultAccount: 'default', + }, + }, + }); + listConfiguredChannelsFromConfigMock.mockResolvedValue(['feishu']); + listConfiguredChannelAccountsFromConfigMock.mockReturnValue({ + feishu: { + defaultAccountId: 'default', + accountIds: ['default'], + }, + }); + listAgentsSnapshotFromConfigMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: { + 'feishu:default': 'main', + }, + }); + const gatewayManager = { + rpc: vi.fn().mockResolvedValue({ + channels: { feishu: { configured: true } }, + channelAccounts: { + feishu: [{ accountId: 'default', configured: true, connected: true, running: true, linked: true }], + }, + channelDefaultAccountId: { feishu: 'default' }, + }), + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + getDiagnostics: vi.fn(() => ({ + consecutiveHeartbeatMisses: 0, + consecutiveRpcFailures: 0, + })), + getCapabilitySnapshot: vi.fn(() => ({ rpc: true })), + }; + const { createDiagnosticsApi } = await import('@electron/services/diagnostics-api'); + + const snapshot = await createDiagnosticsApi({ gatewayManager: gatewayManager as never }).gatewaySnapshot(); + + expect(snapshot).toMatchObject({ + platform: process.platform, + channels: [ + expect.objectContaining({ + channelType: 'feishu', + accounts: [expect.objectContaining({ accountId: 'default', agentId: 'main' })], + }), + ], + clawxLogTail: 'clawx-log-tail', + gateway: expect.objectContaining({ + state: 'healthy', + capabilities: { rpc: true }, + }), + }); + expect(snapshot.gatewayLogTail).toContain('gateway-one'); + expect(snapshot.gatewayErrLogTail).toBe(''); + }); + + it('reads only selected log files from the log directory', async () => { + const selectedLog = join(logDir, 'clawx-selected.log'); + writeFileSync(selectedLog, 'one\ntwo\nthree\n'); + listLogFilesMock.mockResolvedValue([{ name: 'clawx-selected.log', path: selectedLog, size: 14, modified: 'now' }]); + const { createLogsApi } = await import('@electron/services/logs-api'); + + await expect(createLogsApi().readFile({ path: selectedLog, tailLines: 2 })).resolves.toEqual({ + content: 'two\nthree\n', + }); + await expect(createLogsApi().readFile({ path: join(tmpdir(), 'outside.log') })).rejects.toThrow( + 'Invalid log file path', + ); + }); + + it('sends staged media through the typed chat service with gateway attachments', async () => { + const mediaPath = join(tmpdir(), `clawx-host-services-media-${Date.now()}.png`); + writeFileSync(mediaPath, 'fake-image-bytes'); + const gatewayManager = { + rpc: vi.fn().mockResolvedValue({ runId: 'run-123' }), + }; + const { createChatApi } = await import('@electron/services/chat-api'); + + await expect(createChatApi({ gatewayManager: gatewayManager as never }).sendWithMedia({ + sessionKey: 'agent:main:main', + message: 'inspect this', + idempotencyKey: 'idem-123', + media: [{ filePath: mediaPath, mimeType: 'image/png', fileName: 'image.png' }], + })).resolves.toEqual({ success: true, result: { runId: 'run-123' } }); + + expect(gatewayManager.rpc).toHaveBeenCalledWith( + 'chat.send', + { + sessionKey: 'agent:main:main', + message: `inspect this\n\n[media attached: ${mediaPath} (image/png) | ${mediaPath}]`, + deliver: false, + idempotencyKey: 'idem-123', + attachments: [{ + content: Buffer.from('fake-image-bytes').toString('base64'), + mimeType: 'image/png', + fileName: 'image.png', + }], + }, + 120000, + ); + }); + + it('loads session summaries and transcript history through the typed sessions service', async () => { + const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, 'sessions.json'), JSON.stringify({ + sessions: [ + { + key: 'agent:main:abc123', + file: 'abc123.jsonl', + }, + ], + })); + writeFileSync(join(sessionsDir, 'abc123.jsonl'), [ + JSON.stringify({ + type: 'message', + message: { + role: 'user', + content: 'Hello from transcript', + timestamp: 1000, + }, + }), + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: 'Hi', + timestamp: 1001, + }, + }), + ].join('\n')); + const { createSessionsApi } = await import('@electron/services/sessions-api'); + const sessionsApi = createSessionsApi(); + + await expect(sessionsApi.summaries({ sessionKeys: ['agent:main:abc123'] })) + .resolves.toEqual({ + success: true, + summaries: [{ + sessionKey: 'agent:main:abc123', + firstUserText: 'Hello from transcript', + lastTimestamp: 1001000, + }], + }); + await expect(sessionsApi.history({ sessionKey: 'agent:main:abc123', limit: 5 })) + .resolves.toMatchObject({ + success: true, + messages: [ + { role: 'user', content: 'Hello from transcript', timestamp: 1000 }, + { role: 'assistant', content: 'Hi', timestamp: 1001 }, + ], + }); + }); +}); diff --git a/tests/unit/i18n-locale-parity.test.ts b/tests/unit/i18n-locale-parity.test.ts index a52fcbac..33392236 100644 --- a/tests/unit/i18n-locale-parity.test.ts +++ b/tests/unit/i18n-locale-parity.test.ts @@ -1,7 +1,7 @@ /** * i18n locale parity test * - * Verifies that every locale under `src/i18n/locales/` exposes the same + * Verifies that every locale under `shared/i18n/locales/` exposes the same * namespace files and the same set of leaf keys. Also checks that * `{{interpolation}}` tokens used in the reference locale (English) are * preserved in every other locale, so we don't silently drop variables @@ -15,7 +15,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; const REFERENCE_LOCALE = 'en'; -const LOCALES_DIR = path.resolve(__dirname, '../../src/i18n/locales'); +const LOCALES_DIR = path.resolve(__dirname, '../../shared/i18n/locales'); type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; interface JsonObject { diff --git a/tests/unit/image-viewer.test.tsx b/tests/unit/image-viewer.test.tsx index fba34458..eec28631 100644 --- a/tests/unit/image-viewer.test.tsx +++ b/tests/unit/image-viewer.test.tsx @@ -16,7 +16,7 @@ vi.mock('react-i18next', () => ({ const readBinaryFile = vi.fn(); -vi.mock('@/lib/api-client', () => ({ +vi.mock('@/lib/file-preview-client', () => ({ readBinaryFile: (...args: unknown[]) => readBinaryFile(...args), })); diff --git a/tests/unit/local-skill-service.test.ts b/tests/unit/local-skill-service.test.ts index 8050e7ba..6671b44a 100644 --- a/tests/unit/local-skill-service.test.ts +++ b/tests/unit/local-skill-service.test.ts @@ -1,13 +1,24 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn(), +})); const listAgentsSnapshotMock = vi.fn(); const getOpenClawSkillsDirMock = vi.fn(); const getOpenClawResolvedDirMock = vi.fn(); const getAllSkillConfigsMock = vi.fn(); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => homedirMock(), + }; +}); + vi.mock('@electron/utils/agent-config', () => ({ listAgentsSnapshot: () => listAgentsSnapshotMock(), })); @@ -26,6 +37,14 @@ describe('local skill service', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + const homeDir = mkdtempSync(join(tmpdir(), 'clawx-local-skills-home-')); + homedirMock.mockReturnValue(homeDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('USERPROFILE', homeDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it('includes bundled skill-creator but filters out other bundled openclaw skills', async () => { diff --git a/tests/unit/media-api.test.ts b/tests/unit/media-api.test.ts new file mode 100644 index 00000000..9415d67f --- /dev/null +++ b/tests/unit/media-api.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const createFromPathMock = vi.hoisted(() => vi.fn(() => ({ + isEmpty: () => true, + getSize: () => ({ width: 1, height: 1 }), + resize: vi.fn(), + toPNG: vi.fn(), +}))); + +vi.mock('electron', () => ({ + dialog: { + showSaveDialog: vi.fn(), + }, + nativeImage: { + createFromPath: createFromPathMock, + }, +})); + +describe('media api', () => { + let testDir: string; + + beforeEach(async () => { + vi.resetModules(); + createFromPathMock.mockClear(); + testDir = await mkdtemp(join(tmpdir(), 'clawx-media-api-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('returns SVG thumbnails as original data URLs without nativeImage decoding', async () => { + const svgPath = join(testDir, 'plan.svg'); + const svg = ''; + await writeFile(svgPath, svg, 'utf8'); + + const { createMediaApi } = await import('../../electron/services/media-api'); + const mediaApi = createMediaApi(); + + const result = await mediaApi.thumbnails({ + paths: [{ filePath: svgPath, mimeType: 'image/svg+xml' }], + }); + + expect(createFromPathMock).not.toHaveBeenCalled(); + expect(result[svgPath]).toEqual({ + preview: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, + fileSize: Buffer.byteLength(svg), + }); + }); +}); diff --git a/tests/unit/models-page.test.tsx b/tests/unit/models-page.test.tsx index ad091398..466ceae0 100644 --- a/tests/unit/models-page.test.tsx +++ b/tests/unit/models-page.test.tsx @@ -24,6 +24,11 @@ vi.mock('@/stores/settings', () => ({ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + usage: { + recentTokenHistory: () => hostApiFetchMock('/api/usage/recent-token-history'), + }, + }, })); vi.mock('@/lib/telemetry', () => ({ diff --git a/tests/unit/provider-store-init.test.ts b/tests/unit/provider-store-init.test.ts index b3db4f1e..b4aac7ed 100644 --- a/tests/unit/provider-store-init.test.ts +++ b/tests/unit/provider-store-init.test.ts @@ -7,9 +7,10 @@ vi.mock('@/lib/provider-accounts', () => ({ fetchProviderSnapshot: (...args: unknown[]) => mockFetchProviderSnapshot(...args), })); -// Mock hostApiFetch (used by other store methods) vi.mock('@/lib/host-api', () => ({ - hostApiFetch: vi.fn(), + hostApi: { + providers: {}, + }, })); // Import store after mocks are in place diff --git a/tests/unit/provider-store-validation.test.ts b/tests/unit/provider-store-validation.test.ts index c89e6d57..d291b3ab 100644 --- a/tests/unit/provider-store-validation.test.ts +++ b/tests/unit/provider-store-validation.test.ts @@ -1,32 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockFetchProviderSnapshot = vi.fn(); -const mockHostApiFetch = vi.fn(); +const mockValidateKey = vi.fn(); +const mockGetAccountApiKey = vi.fn(); vi.mock('@/lib/provider-accounts', () => ({ fetchProviderSnapshot: (...args: unknown[]) => mockFetchProviderSnapshot(...args), - isHostApiRouteMissing: (value: unknown): boolean => { - if (!value || typeof value !== 'object') return false; - const record = value as Record; - if (record.success !== false) return false; - const error = record.error; - return typeof error === 'string' && /no\s+route\s+for/i.test(error); - }, })); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => mockHostApiFetch(...args), + hostApi: { + providers: { + validateKey: (...args: unknown[]) => mockValidateKey(...args), + getAccountApiKey: (...args: unknown[]) => mockGetAccountApiKey(...args), + }, + }, })); import { useProviderStore } from '@/stores/providers'; -describe('useProviderStore – validateAccountApiKey()', () => { +describe('useProviderStore - validateAccountApiKey()', () => { beforeEach(() => { vi.clearAllMocks(); }); it('trims API keys before sending provider validation requests', async () => { - mockHostApiFetch.mockResolvedValueOnce({ valid: true }); + mockValidateKey.mockResolvedValueOnce({ valid: true }); const result = await useProviderStore.getState().validateAccountApiKey('custom', ' sk-lm-test \n', { baseUrl: 'http://127.0.0.1:1234/v1', @@ -34,151 +33,63 @@ describe('useProviderStore – validateAccountApiKey()', () => { }); expect(result).toEqual({ valid: true }); - expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/validate', { - method: 'POST', - body: JSON.stringify({ - accountId: 'custom', - vendorId: 'custom', - providerId: 'custom', - apiKey: 'sk-lm-test', - options: { - baseUrl: 'http://127.0.0.1:1234/v1', - apiProtocol: 'openai-completions', - }, - }), + expect(mockValidateKey).toHaveBeenCalledWith({ + accountId: 'custom', + vendorId: 'custom', + providerId: 'custom', + apiKey: 'sk-lm-test', + options: { + baseUrl: 'http://127.0.0.1:1234/v1', + apiProtocol: 'openai-completions', + }, }); }); - it('falls back to legacy /api/providers/validate when the new route throws a 404', async () => { - // The browser-fallback path of `hostApiFetch` (used in non-Electron - // environments and surfaced by some IPC error normalisations) throws - // on non-2xx HTTP. Make sure the renderer treats those as missing-route. - mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found')); - mockHostApiFetch.mockResolvedValueOnce({ valid: true }); - - const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', { - baseUrl: 'http://127.0.0.1:1234/v1', - }); - - expect(result).toEqual({ valid: true }); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', { - method: 'POST', - body: JSON.stringify({ - providerId: 'custom', - apiKey: 'sk-lm-test', - options: { baseUrl: 'http://127.0.0.1:1234/v1' }, - }), - }); - }); - - it('falls back to legacy /api/providers/validate when the new route returns a route-not-found body', async () => { - // The Electron IPC proxy never throws on HTTP 404 — it surfaces the - // JSON body. Older Host API builds without the new validate route - // therefore return `{ success: false, error: "No route for ..." }`. - // The renderer must detect that body shape via `isHostApiRouteMissing` - // and replay the request against the legacy route. This is the path - // that actually runs in production today. - mockHostApiFetch.mockResolvedValueOnce({ - success: false, - error: 'No route for POST /api/provider-accounts/validate', - }); - mockHostApiFetch.mockResolvedValueOnce({ valid: true }); - - const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', { - baseUrl: 'http://127.0.0.1:1234/v1', - }); - - expect(result).toEqual({ valid: true }); - expect(mockHostApiFetch).toHaveBeenCalledTimes(2); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', { - method: 'POST', - body: JSON.stringify({ - providerId: 'custom', - apiKey: 'sk-lm-test', - options: { baseUrl: 'http://127.0.0.1:1234/v1' }, - }), - }); - }); - - it('does NOT fall back when the new route returns a real validation failure', async () => { - // `{ valid: false, error: ... }` is a legitimate validation result — - // it must NOT be confused with a missing-route body (whose discriminator - // is `success: false`). Otherwise we would silently retry against the - // legacy route and double-charge the upstream provider. - mockHostApiFetch.mockResolvedValueOnce({ valid: false, error: 'API key is rejected' }); + it('returns validation failures without throwing', async () => { + mockValidateKey.mockResolvedValueOnce({ valid: false, error: 'API key is rejected' }); const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test'); expect(result).toEqual({ valid: false, error: 'API key is rejected' }); - expect(mockHostApiFetch).toHaveBeenCalledTimes(1); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object)); + expect(mockValidateKey).toHaveBeenCalledTimes(1); + }); + + it('normalizes invocation failures into validation failures', async () => { + mockValidateKey.mockRejectedValueOnce(new Error('offline')); + + const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test'); + + expect(result).toEqual({ valid: false, error: 'Error: offline' }); }); }); -describe('useProviderStore – getAccountApiKey()', () => { +describe('useProviderStore - getAccountApiKey()', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('reads the key from the new account-namespaced endpoint by default', async () => { - mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' }); + it('reads the key through the typed provider API', async () => { + mockGetAccountApiKey.mockResolvedValueOnce('sk-stored-key'); const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); expect(apiKey).toBe('sk-stored-key'); - expect(mockHostApiFetch).toHaveBeenCalledTimes(1); - expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/openai-account-1/api-key'); + expect(mockGetAccountApiKey).toHaveBeenCalledWith('openai-account-1'); }); - it('falls back to legacy /api/providers/:id/api-key when the new route throws a 404', async () => { - // Browser-fallback path: thrown 404. - mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found')); - mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' }); - - const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); - - expect(apiKey).toBe('sk-legacy-key'); - expect(mockHostApiFetch).toHaveBeenCalledTimes(2); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key'); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key'); - }); - - it('falls back to legacy /api/providers/:id/api-key when the new route returns a route-not-found body', async () => { - // Electron IPC proxy path: 404 surfaces as a "No route" body. - mockHostApiFetch.mockResolvedValueOnce({ - success: false, - error: 'No route for GET /api/provider-accounts/openai-account-1/api-key', - }); - mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' }); - - const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); - - expect(apiKey).toBe('sk-legacy-key'); - expect(mockHostApiFetch).toHaveBeenCalledTimes(2); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key'); - expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key'); - }); - - it('returns null when the legacy fallback also reports no key', async () => { - mockHostApiFetch.mockResolvedValueOnce({ - success: false, - error: 'No route for GET /api/provider-accounts/missing-account/api-key', - }); - mockHostApiFetch.mockResolvedValueOnce({ apiKey: null }); + it('returns null when no key is stored', async () => { + mockGetAccountApiKey.mockResolvedValueOnce(null); const apiKey = await useProviderStore.getState().getAccountApiKey('missing-account'); expect(apiKey).toBeNull(); - expect(mockHostApiFetch).toHaveBeenCalledTimes(2); }); - it('encodes the account id so colons and slashes survive the request', async () => { - mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' }); + it('swallows key read failures', async () => { + mockGetAccountApiKey.mockRejectedValueOnce(new Error('keychain unavailable')); - await useProviderStore.getState().getAccountApiKey('vendor:weird/id'); + const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1'); - expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/vendor%3Aweird%2Fid/api-key'); + expect(apiKey).toBeNull(); }); }); diff --git a/tests/unit/session-delete-route.test.ts b/tests/unit/session-delete-route.test.ts deleted file mode 100644 index 6b383d5b..00000000 --- a/tests/unit/session-delete-route.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Unit tests for the /api/sessions/delete HTTP route. - * - * The route hard-deletes a conversation's transcript on disk: - * - .jsonl — the live transcript - * - .deleted.jsonl — leftovers from earlier soft-delete releases - * - .jsonl.reset.* — reset snapshots from sessions.reset - * It also removes the entry from sessions.json. - * - * These tests exercise the real `handleSessionRoutes` against a temp - * OpenClaw config directory so the FS contract is verified end-to-end. - */ - -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -const sendJsonMock = vi.fn(); -const parseJsonBodyMock = vi.fn(); - -const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'session-delete-route-openclaw'); - -vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -vi.mock('@electron/utils/paths', () => ({ - getOpenClawConfigDir: () => testOpenClawConfigDir, - getOpenClawDir: () => testOpenClawConfigDir, - getOpenClawResolvedDir: () => testOpenClawConfigDir, -})); - -const AGENT_ID = 'main'; -const SESSIONS_DIR = join(testOpenClawConfigDir, 'agents', AGENT_ID, 'sessions'); -const SESSIONS_JSON = join(SESSIONS_DIR, 'sessions.json'); - -function seedSessionsDir(): void { - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - mkdirSync(SESSIONS_DIR, { recursive: true }); -} - -function writeSessionsJson(payload: Record): void { - writeFileSync(SESSIONS_JSON, JSON.stringify(payload, null, 2), 'utf8'); -} - -function makeReq(method = 'POST'): IncomingMessage { - return { method } as IncomingMessage; -} - -function makeRes(): ServerResponse { - return { - setHeader: vi.fn(), - end: vi.fn(), - } as unknown as ServerResponse; -} - -const DELETE_URL = new URL('http://127.0.0.1:13210/api/sessions/delete'); -const ctx = {} as never; - -describe('handleSessionRoutes — POST /api/sessions/delete', () => { - beforeEach(() => { - vi.resetAllMocks(); - seedSessionsDir(); - }); - - afterAll(() => { - rmSync(testOpenClawConfigDir, { recursive: true, force: true }); - }); - - it('hard-deletes the live .jsonl and clears the entry from sessions.json', async () => { - const sessionKey = 'agent:main:session-aaa'; - const fileName = 'aaa-uuid.jsonl'; - writeFileSync(join(SESSIONS_DIR, fileName), 'message\n', 'utf8'); - writeSessionsJson({ - [sessionKey]: { sessionFile: join(SESSIONS_DIR, fileName), sessionId: 'aaa-uuid' }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - const handled = await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(handled).toBe(true); - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true }); - expect(existsSync(join(SESSIONS_DIR, fileName))).toBe(false); - const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')); - expect(updated[sessionKey]).toBeUndefined(); - }); - - it('also removes a leftover .deleted.jsonl from a prior soft-delete release', async () => { - const sessionKey = 'agent:main:session-bbb'; - const baseId = 'bbb-uuid'; - writeFileSync(join(SESSIONS_DIR, `${baseId}.jsonl`), '', 'utf8'); - writeFileSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`), '', 'utf8'); - writeSessionsJson({ - [sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(join(SESSIONS_DIR, `${baseId}.jsonl`))).toBe(false); - expect(existsSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`))).toBe(false); - }); - - it('removes every .jsonl.reset.* sibling that belongs to the same session id', async () => { - const sessionKey = 'agent:main:session-ccc'; - const baseId = 'ccc-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - const reset1 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-01T00-00-00.000Z`); - const reset2 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-02T00-00-00.000Z`); - writeFileSync(liveFile, '', 'utf8'); - writeFileSync(reset1, '', 'utf8'); - writeFileSync(reset2, '', 'utf8'); - writeSessionsJson({ - [sessionKey]: { sessionFile: liveFile, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(reset1)).toBe(false); - expect(existsSync(reset2)).toBe(false); - }); - - it('still succeeds and updates sessions.json when the transcript is already gone', async () => { - const sessionKey = 'agent:main:session-ddd'; - const baseId = 'ddd-uuid'; - // No transcript file on disk — only sessions.json knows about it. - writeSessionsJson({ - [sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true }); - const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')); - expect(updated[sessionKey]).toBeUndefined(); - }); - - it('rejects sessionKeys that are not agent-scoped with 400', async () => { - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'main' }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ success: false }), - ); - }); - - it('rejects agentIds that contain path-traversal segments with 400', async () => { - // Even if the caller manages to put `..` into the agent slot, the route - // must refuse before any FS access happens — otherwise sessionsDir would - // resolve outside ~/.openclaw/agents/. - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'agent:..:foo' }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ success: false }), - ); - }); - - it('refuses to unlink files when sessionFile points outside the agent sessions dir', async () => { - // Defence-in-depth: if a corrupt sessions.json claims the transcript - // lives in /tmp (or anywhere outside the agent sessions folder), the - // sweep must not run there and existing files must survive untouched. - const sessionKey = 'agent:main:session-escape'; - const escapeDir = join(testOpenClawConfigDir, 'unrelated-dir'); - mkdirSync(escapeDir, { recursive: true }); - const escapeFile = join(escapeDir, 'escape-uuid.jsonl'); - writeFileSync(escapeFile, 'must-not-be-deleted', 'utf8'); - writeSessionsJson({ - [sessionKey]: { sessionFile: escapeFile, sessionId: 'escape-uuid' }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 400, - expect.objectContaining({ success: false }), - ); - expect(existsSync(escapeFile)).toBe(true); - expect(readFileSync(escapeFile, 'utf8')).toBe('must-not-be-deleted'); - // sessions.json is left intact when the resolution fails — the entry - // stays so a follow-up fix can be applied without losing track of it. - const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')); - expect(updated[sessionKey]).toBeDefined(); - }); - - it("also sweeps OpenClaw's trajectory sidecars (.trajectory.jsonl + .trajectory-path.json)", async () => { - // OpenClaw writes `.trajectory.jsonl` (flight recorder) and - // `.trajectory-path.json` (pointer) next to the session file. - // Hard-deleting the conversation must leave neither behind, otherwise - // the next `sessions.list` is clean but the agent's sessions/ folder - // accumulates orphaned trajectory data. - const sessionKey = 'agent:main:session-traj'; - const baseId = 'traj-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`); - const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`); - writeFileSync(liveFile, '', 'utf8'); - writeFileSync(trajFile, '{"event":"session.started"}\n', 'utf8'); - writeFileSync( - pointerFile, - JSON.stringify({ - traceSchema: 'openclaw-trajectory-pointer', - schemaVersion: 1, - sessionId: baseId, - runtimeFile: trajFile, - }), - 'utf8', - ); - writeSessionsJson({ - [sessionKey]: { sessionFile: liveFile, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(trajFile)).toBe(false); - expect(existsSync(pointerFile)).toBe(false); - }); - - it('follows the trajectory pointer to unlink an OPENCLAW_TRAJECTORY_DIR-style off-sessions runtime file', async () => { - // When OPENCLAW_TRAJECTORY_DIR is set, the pointer's `runtimeFile` - // resolves outside sessions/. Without the pointer-follow, that file - // would be orphaned forever after deletion. - const sessionKey = 'agent:main:session-trajdir'; - const baseId = 'trajdir-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`); - const trajectoryDir = join(testOpenClawConfigDir, 'trajectory-dir'); - mkdirSync(trajectoryDir, { recursive: true }); - const offDiskRuntime = join(trajectoryDir, `${baseId}.jsonl`); - writeFileSync(liveFile, '', 'utf8'); - writeFileSync(offDiskRuntime, '{"event":"prompt.submitted"}\n', 'utf8'); - writeFileSync( - pointerFile, - JSON.stringify({ - traceSchema: 'openclaw-trajectory-pointer', - schemaVersion: 1, - sessionId: baseId, - runtimeFile: offDiskRuntime, - }), - 'utf8', - ); - writeSessionsJson({ - [sessionKey]: { sessionFile: liveFile, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(pointerFile)).toBe(false); - expect(existsSync(offDiskRuntime)).toBe(false); - }); - - it('refuses to follow a pointer whose runtimeFile is not an absolute .jsonl path', async () => { - // Defence-in-depth: a malformed/hostile pointer must NOT get us to - // unlink arbitrary files (e.g. /etc/passwd or relative paths). - const sessionKey = 'agent:main:session-evilpointer'; - const baseId = 'evilpointer-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`); - const bystander = join(testOpenClawConfigDir, 'bystander-dir', 'must-not-touch.txt'); - mkdirSync(join(testOpenClawConfigDir, 'bystander-dir'), { recursive: true }); - writeFileSync(bystander, 'kept', 'utf8'); - writeFileSync(liveFile, '', 'utf8'); - writeFileSync( - pointerFile, - JSON.stringify({ - traceSchema: 'openclaw-trajectory-pointer', - schemaVersion: 1, - sessionId: baseId, - // Wrong extension on purpose — not a `.jsonl`. - runtimeFile: bystander, - }), - 'utf8', - ); - writeSessionsJson({ - [sessionKey]: { sessionFile: liveFile, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - // The session and the (correctly-detected) pointer are gone, but the - // bystander file the pointer tried to escape to is untouched. - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(pointerFile)).toBe(false); - expect(existsSync(bystander)).toBe(true); - expect(readFileSync(bystander, 'utf8')).toBe('kept'); - }); - - it("tolerates a malformed pointer (still cleans local sidecars, doesn't fail the whole delete)", async () => { - const sessionKey = 'agent:main:session-malformedptr'; - const baseId = 'malformedptr-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`); - const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`); - writeFileSync(liveFile, '', 'utf8'); - writeFileSync(trajFile, '', 'utf8'); - // Garbage JSON — the sweep must not blow up. - writeFileSync(pointerFile, '{ not-json', 'utf8'); - writeSessionsJson({ - [sessionKey]: { sessionFile: liveFile, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true }); - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(trajFile)).toBe(false); - expect(existsSync(pointerFile)).toBe(false); - }); - - it("does not touch another session's trajectory sidecars during the sweep", async () => { - const targetKey = 'agent:main:session-trajiso-target'; - const survivorKey = 'agent:main:session-trajiso-keep'; - const targetBase = 'trajiso-target'; - const survivorBase = 'trajiso-keep'; - const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`); - const targetTraj = join(SESSIONS_DIR, `${targetBase}.trajectory.jsonl`); - const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`); - const survivorTraj = join(SESSIONS_DIR, `${survivorBase}.trajectory.jsonl`); - const survivorPointer = join(SESSIONS_DIR, `${survivorBase}.trajectory-path.json`); - writeFileSync(targetFile, '', 'utf8'); - writeFileSync(targetTraj, '', 'utf8'); - writeFileSync(survivorFile, 'kept', 'utf8'); - writeFileSync(survivorTraj, 'kept', 'utf8'); - writeFileSync(survivorPointer, JSON.stringify({ - traceSchema: 'openclaw-trajectory-pointer', - schemaVersion: 1, - sessionId: survivorBase, - runtimeFile: survivorTraj, - }), 'utf8'); - writeSessionsJson({ - [targetKey]: { sessionFile: targetFile, sessionId: targetBase }, - [survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(targetFile)).toBe(false); - expect(existsSync(targetTraj)).toBe(false); - expect(existsSync(survivorFile)).toBe(true); - expect(existsSync(survivorTraj)).toBe(true); - expect(existsSync(survivorPointer)).toBe(true); - }); - - it('treats Windows forward-slash absolute paths as absolute (cross-platform)', async () => { - // OpenClaw on Windows can write `sessionFile` as either `C:\…` (back- - // slash) or `C:/…` (forward-slash). Node's `path.win32.isAbsolute` - // accepts both; the resolver must too. We can't actually create a - // `C:/…` path on POSIX, so we cover the same code path with a Windows- - // style absolute that points back into our temp sessions dir using - // mixed slashes. The detector should still treat it as absolute and - // route through the in-scope sibling sweep. - const sessionKey = 'agent:main:session-win'; - const baseId = 'win-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - writeFileSync(liveFile, '', 'utf8'); - // Force forward slashes — historically this would have been classed as - // a *relative* filename on POSIX and `join`ed onto sessionsDir, which - // produced a junk path that no `readdir` could find. - const forwardSlashAbs = liveFile.replace(/\\/g, '/'); - writeSessionsJson({ - [sessionKey]: { sessionFile: forwardSlashAbs, sessionId: baseId }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true }); - expect(existsSync(liveFile)).toBe(false); - }); - - it('does not touch other sessions in the same directory', async () => { - const targetKey = 'agent:main:session-eee'; - const survivorKey = 'agent:main:session-fff'; - const targetBase = 'eee-uuid'; - const survivorBase = 'fff-uuid'; - const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`); - const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`); - writeFileSync(targetFile, '', 'utf8'); - writeFileSync(survivorFile, 'kept', 'utf8'); - writeSessionsJson({ - [targetKey]: { sessionFile: targetFile, sessionId: targetBase }, - [survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase }, - }); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(targetFile)).toBe(false); - expect(existsSync(survivorFile)).toBe(true); - expect(readFileSync(survivorFile, 'utf8')).toBe('kept'); - const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')); - expect(updated[targetKey]).toBeUndefined(); - expect(updated[survivorKey]).toBeDefined(); - }); - - it('also supports the array-shape sessions.json (sessions[] with id field)', async () => { - const sessionKey = 'agent:main:session-ggg'; - const baseId = 'ggg-uuid'; - const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`); - writeFileSync(liveFile, '', 'utf8'); - writeSessionsJson({ - sessions: [ - { key: sessionKey, id: baseId }, - { key: 'agent:main:keep', id: 'keep-uuid' }, - ], - }); - writeFileSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'), 'kept', 'utf8'); - parseJsonBodyMock.mockResolvedValueOnce({ sessionKey }); - - const { handleSessionRoutes } = await import('@electron/api/routes/sessions'); - await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx); - - expect(existsSync(liveFile)).toBe(false); - expect(existsSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'))).toBe(true); - const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')) as { sessions: Array<{ key: string }> }; - expect(updated.sessions.find((s) => s.key === sessionKey)).toBeUndefined(); - expect(updated.sessions.find((s) => s.key === 'agent:main:keep')).toBeDefined(); - }); -}); diff --git a/tests/unit/session-label-fetch.test.ts b/tests/unit/session-label-fetch.test.ts index 9ad12712..259b5342 100644 --- a/tests/unit/session-label-fetch.test.ts +++ b/tests/unit/session-label-fetch.test.ts @@ -1,9 +1,23 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -const invokeIpcMock = vi.fn(); +const gatewayRpcMock = vi.fn(); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), +vi.mock('@/lib/host-api', () => ({ + hostApi: { + gateway: { + rpc: async (method: string, params?: unknown, timeoutMs?: number) => { + const result = await gatewayRpcMock( + method, + params, + timeoutMs, + ) as { success?: boolean; result?: unknown; error?: string }; + if (result?.success === false) { + throw new Error(result.error || `RPC ${method} failed`); + } + return result?.result; + }, + }, + }, })); vi.mock('@/stores/chat/helpers', () => ({ @@ -18,7 +32,7 @@ describe('session label fetch concurrency', () => { }); it('skips sessions with existing frontend or backend labels', async () => { - invokeIpcMock.mockImplementation(async (_channel: string, method: string) => { + gatewayRpcMock.mockImplementation(async (method: string) => { if (method === 'sessions.list') { return { success: true, @@ -61,12 +75,12 @@ describe('session label fetch concurrency', () => { await actions.loadSessions(); await new Promise((resolve) => setTimeout(resolve, 10)); - const chatHistoryCalls = invokeIpcMock.mock.calls.filter(([, method]) => method === 'chat.history'); + const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); expect(chatHistoryCalls).toHaveLength(0); }); it('does not re-request unchanged sessions after an empty hydration result', async () => { - invokeIpcMock.mockImplementation(async (_channel: string, method: string) => { + gatewayRpcMock.mockImplementation(async (method: string) => { if (method === 'sessions.list') { return { success: true, @@ -110,13 +124,13 @@ describe('session label fetch concurrency', () => { await actions.loadSessions(); await new Promise((resolve) => setTimeout(resolve, 10)); - const chatHistoryCalls = invokeIpcMock.mock.calls.filter(([, method]) => method === 'chat.history'); + const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); expect(chatHistoryCalls).toHaveLength(1); }); it('re-requests a session when updatedAt changes after an empty result', async () => { let updatedAt = 1000; - invokeIpcMock.mockImplementation(async (_channel: string, method: string) => { + gatewayRpcMock.mockImplementation(async (method: string) => { if (method === 'sessions.list') { return { success: true, @@ -161,7 +175,7 @@ describe('session label fetch concurrency', () => { await actions.loadSessions(); await new Promise((resolve) => setTimeout(resolve, 10)); - const chatHistoryCalls = invokeIpcMock.mock.calls.filter(([, method]) => method === 'chat.history'); + const chatHistoryCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history'); expect(chatHistoryCalls).toHaveLength(2); }); }); diff --git a/tests/unit/session-summaries-route.test.ts b/tests/unit/session-summaries-route.test.ts deleted file mode 100644 index 7595e597..00000000 --- a/tests/unit/session-summaries-route.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { HostApiContext } from '@electron/api/context'; -import { handleSessionRoutes } from '@electron/api/routes/sessions'; - -const readFileMock = vi.fn(); -const parseJsonBodyMock = vi.fn(); - -vi.mock('node:fs/promises', () => ({ - readFile: (...args: unknown[]) => readFileMock(...args), -})); - -vi.mock('@electron/utils/paths', () => ({ - getOpenClawConfigDir: () => '/mock/.openclaw', -})); - -vi.mock('@electron/api/route-utils', async () => { - const actual = await vi.importActual('@electron/api/route-utils'); - return { - ...actual, - parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), - }; -}); - -function createResponse() { - const headers = new Map(); - let body = ''; - const res = { - statusCode: 0, - setHeader: (name: string, value: string) => { - headers.set(name, value); - }, - end: (value: string) => { - body = value; - }, - } as unknown as ServerResponse; - - return { - res, - get json() { - return JSON.parse(body) as { success: boolean; summaries?: Array> }; - }, - get statusCode() { - return (res as ServerResponse).statusCode; - }, - }; -} - -describe('POST /api/sessions/summaries', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('strips sender metadata and ignores internal untrusted injections when building titles', async () => { - parseJsonBodyMock.mockResolvedValue({ - sessionKeys: ['agent:main:session-a'], - }); - - readFileMock.mockImplementation(async (path: string) => { - if (path.endsWith('/agents/main/sessions/sessions.json')) { - return JSON.stringify({ - sessions: [ - { key: 'agent:main:session-a', file: 'session-a.jsonl' }, - ], - }); - } - if (path.endsWith('/agents/main/sessions/session-a.jsonl')) { - return [ - JSON.stringify({ - type: 'message', - message: { - role: 'user', - timestamp: 1700000000, - content: 'System (untrusted): internal noise', - }, - }), - JSON.stringify({ - type: 'message', - message: { - role: 'user', - timestamp: 1700000002, - content: 'Sender (untrusted): Alice\n\nHello from Alice', - }, - }), - ].join('\n'); - } - throw new Error(`Unexpected readFile path: ${path}`); - }); - - const response = createResponse(); - const handled = await handleSessionRoutes( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/sessions/summaries'), - {} as HostApiContext, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json).toMatchObject({ - success: true, - summaries: [ - { - sessionKey: 'agent:main:session-a', - firstUserText: 'Hello from Alice', - lastTimestamp: 1700000002000, - }, - ], - }); - }); - - it('drops sender json metadata blocks instead of using them as the label', async () => { - parseJsonBodyMock.mockResolvedValue({ - sessionKeys: ['agent:main:session-json'], - }); - - readFileMock.mockImplementation(async (path: string) => { - if (path.endsWith('/agents/main/sessions/sessions.json')) { - return JSON.stringify({ - sessions: [ - { key: 'agent:main:session-json', file: 'session-json.jsonl' }, - ], - }); - } - if (path.endsWith('/agents/main/sessions/session-json.jsonl')) { - return [ - JSON.stringify({ - type: 'message', - message: { - role: 'user', - timestamp: 1700000010, - content: 'Sender (untrusted): ```json\n{"name":"Alice","id":"u1"}\n```\n\nActual user title', - }, - }), - ].join('\n'); - } - throw new Error(`Unexpected readFile path: ${path}`); - }); - - const response = createResponse(); - await handleSessionRoutes( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/sessions/summaries'), - {} as HostApiContext, - ); - - expect(response.json).toMatchObject({ - success: true, - summaries: [ - { - sessionKey: 'agent:main:session-json', - firstUserText: 'Actual user title', - lastTimestamp: 1700000010000, - }, - ], - }); - }); -}); diff --git a/tests/unit/skills-errors.test.ts b/tests/unit/skills-errors.test.ts index 01dca8d0..d417f5d7 100644 --- a/tests/unit/skills-errors.test.ts +++ b/tests/unit/skills-errors.test.ts @@ -1,17 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const hostApiFetchMock = vi.fn(); -const rpcMock = vi.fn(); +const statusMock = vi.fn(); +const localMock = vi.fn(); +const clawhubSearchMock = vi.fn(); +const clawhubInstallMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - rpc: (...args: unknown[]) => rpcMock(...args), - }), + hostApi: { + skills: { + status: () => statusMock(), + local: () => localMock(), + clawhubSearch: (input: unknown) => clawhubSearchMock(input), + clawhubInstall: (input: unknown) => clawhubInstallMock(input), + clawhubUninstall: vi.fn(), + updateConfigs: vi.fn(), + }, }, })); @@ -22,8 +25,8 @@ describe('skills store error mapping', () => { }); it('maps fetchSkills rate-limit error when both local and gateway loading fail', async () => { - rpcMock.mockRejectedValueOnce(new Error('gateway unavailable')); - hostApiFetchMock.mockRejectedValueOnce(new Error('rate limit exceeded')); + statusMock.mockRejectedValueOnce(new Error('gateway unavailable')); + localMock.mockRejectedValueOnce(new Error('rate limit exceeded')); const { useSkillsStore } = await import('@/stores/skills'); await useSkillsStore.getState().fetchSkills(); @@ -32,20 +35,20 @@ describe('skills store error mapping', () => { }); it('maps searchSkills timeout error by AppError code', async () => { - hostApiFetchMock.mockRejectedValueOnce(new Error('request timeout')); + clawhubSearchMock.mockRejectedValueOnce(new Error('request timeout')); const { useSkillsStore } = await import('@/stores/skills'); await useSkillsStore.getState().searchSkills('git'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/marketplace/search', expect.objectContaining({ method: 'POST' })); + expect(clawhubSearchMock).toHaveBeenCalledWith({ query: 'git' }); expect(useSkillsStore.getState().searchError).toBe('searchTimeoutError'); }); it('maps installSkill timeout result into installTimeoutError', async () => { - hostApiFetchMock.mockResolvedValueOnce({ success: false, error: 'request timeout' }); + clawhubInstallMock.mockResolvedValueOnce({ success: false, error: 'request timeout' }); const { useSkillsStore } = await import('@/stores/skills'); await expect(useSkillsStore.getState().installSkill('demo-skill')).rejects.toThrow('installTimeoutError'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/marketplace/install', expect.objectContaining({ method: 'POST' })); + expect(clawhubInstallMock).toHaveBeenCalledWith({ slug: 'demo-skill', version: undefined }); }); }); diff --git a/tests/unit/skills-page-gateway-readiness.test.tsx b/tests/unit/skills-page-gateway-readiness.test.tsx index d13094dc..37b8e048 100644 --- a/tests/unit/skills-page-gateway-readiness.test.tsx +++ b/tests/unit/skills-page-gateway-readiness.test.tsx @@ -9,8 +9,10 @@ const setSkillsEnabledMock = vi.fn(); const searchSkillsMock = vi.fn(); const installSkillMock = vi.fn(); const uninstallSkillMock = vi.fn(); -const invokeIpcMock = vi.fn(); -const hostApiFetchMock = vi.fn(); +const clawhubCapabilityMock = vi.fn(); +const clawhubOpenSkillPathMock = vi.fn(); +const openclawGetSkillsDirMock = vi.fn(); +const shellOpenExternalMock = vi.fn(); const { gatewayState, skillsState } = vi.hoisted(() => ({ gatewayState: { @@ -48,12 +50,19 @@ vi.mock('@/stores/gateway', () => ({ useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState), })); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), -})); - vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), + hostApi: { + openclaw: { + getSkillsDir: () => openclawGetSkillsDirMock(), + }, + shell: { + openExternal: (...args: unknown[]) => shellOpenExternalMock(...args), + }, + skills: { + clawhubCapability: () => clawhubCapabilityMock(), + clawhubOpenSkillPath: (...args: unknown[]) => clawhubOpenSkillPathMock(...args), + }, + }, })); vi.mock('@/lib/telemetry', () => ({ @@ -87,16 +96,10 @@ describe('Skills page gateway readiness', () => { vi.clearAllMocks(); gatewayState.status = { state: 'running', port: 18789, gatewayReady: true }; skillsState.skills = []; - invokeIpcMock.mockResolvedValue('/tmp/.openclaw/skills'); - hostApiFetchMock.mockImplementation((path: unknown) => { - if (path === '/api/skills/marketplace/capability') { - return Promise.resolve({ success: true, capability: { canSearch: false, canInstall: false } }); - } - if (path === '/api/clawhub/open-path') { - return Promise.resolve({ success: true }); - } - return Promise.resolve({ success: true }); - }); + openclawGetSkillsDirMock.mockResolvedValue('/tmp/.openclaw/skills'); + shellOpenExternalMock.mockResolvedValue(undefined); + clawhubCapabilityMock.mockResolvedValue({ success: true, capability: { canSearch: false, canInstall: false } }); + clawhubOpenSkillPathMock.mockResolvedValue({ success: true }); fetchSkillsMock.mockResolvedValue(true); }); @@ -117,7 +120,7 @@ describe('Skills page gateway readiness', () => { expect(screen.queryByTestId('skills-gateway-banner')).not.toBeInTheDocument(); }); - it('shows a starting banner while the running gateway still cannot serve skills data', async () => { + it('keeps startup readiness feedback out of the Skills page banner', async () => { fetchSkillsMock.mockResolvedValue(false); gatewayState.status = { state: 'running', port: 18789, gatewayReady: false }; render(); @@ -128,7 +131,7 @@ describe('Skills page gateway readiness', () => { }); expect(fetchSkillsMock).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'starting'); + expect(screen.queryByTestId('skills-gateway-banner')).not.toBeInTheDocument(); }); it('still fetches local skills when the gateway is stopped', async () => { diff --git a/tests/unit/skills-store-fetch-parallel.test.ts b/tests/unit/skills-store-fetch-parallel.test.ts index 3a9624d9..fc864ddc 100644 --- a/tests/unit/skills-store-fetch-parallel.test.ts +++ b/tests/unit/skills-store-fetch-parallel.test.ts @@ -1,17 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const hostApiFetchMock = vi.fn(); -const rpcMock = vi.fn(); +const statusMock = vi.fn(); +const localMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -vi.mock('@/stores/gateway', () => ({ - useGatewayStore: { - getState: () => ({ - rpc: (...args: unknown[]) => rpcMock(...args), - }), + hostApi: { + skills: { + status: () => statusMock(), + local: () => localMock(), + clawhubSearch: vi.fn(), + clawhubInstall: vi.fn(), + clawhubUninstall: vi.fn(), + updateConfigs: vi.fn(), + }, }, })); @@ -34,11 +35,8 @@ describe('skills store local-first fetch', () => { it('starts local and gateway requests together, then returns after local skills load', async () => { const gatewayDeferred = deferred<{ skills: Array> }>(); const localDeferred = deferred<{ success: boolean; skills: Array> }>(); - rpcMock.mockReturnValueOnce(gatewayDeferred.promise); - hostApiFetchMock.mockImplementation((path: unknown) => { - if (path === '/api/skills/local') return localDeferred.promise; - return Promise.reject(new Error(`Unexpected path: ${String(path)}`)); - }); + statusMock.mockReturnValueOnce(gatewayDeferred.promise); + localMock.mockReturnValueOnce(localDeferred.promise); const { useSkillsStore } = await import('@/stores/skills'); useSkillsStore.setState({ skills: [], loading: false, error: null }); @@ -46,8 +44,8 @@ describe('skills store local-first fetch', () => { const fetchPromise = useSkillsStore.getState().fetchSkills(); await Promise.resolve(); - expect(rpcMock).toHaveBeenCalledWith('skills.status'); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/local'); + expect(statusMock).toHaveBeenCalledTimes(1); + expect(localMock).toHaveBeenCalledTimes(1); localDeferred.resolve({ success: true, @@ -61,21 +59,21 @@ describe('skills store local-first fetch', () => { gatewayDeferred.resolve({ skills: [{ skillKey: 'pdf', description: 'runtime', disabled: false, version: '2.0.0' }], }); - await Promise.resolve(); - await Promise.resolve(); - expect(useSkillsStore.getState().skills[0]).toMatchObject({ - id: 'pdf', - description: 'runtime', - version: '2.0.0', - enabled: true, + await vi.waitFor(() => { + expect(useSkillsStore.getState().skills[0]).toMatchObject({ + id: 'pdf', + description: 'runtime', + version: '2.0.0', + enabled: true, + }); }); }); it('does not append bundled gateway skills that are missing from local scan', async () => { const gatewayDeferred = deferred<{ skills: Array> }>(); - rpcMock.mockReturnValueOnce(gatewayDeferred.promise); - hostApiFetchMock.mockResolvedValueOnce({ success: true, skills: [] }); + statusMock.mockReturnValueOnce(gatewayDeferred.promise); + localMock.mockResolvedValueOnce({ success: true, skills: [] }); const { useSkillsStore } = await import('@/stores/skills'); useSkillsStore.setState({ skills: [], loading: false, error: null }); @@ -89,16 +87,16 @@ describe('skills store local-first fetch', () => { { skillKey: 'skill-creator', slug: 'skill-creator', name: 'skill-creator', bundled: true, disabled: false }, ], }); - await Promise.resolve(); - await Promise.resolve(); - expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual([]); + await vi.waitFor(() => { + expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual([]); + }); }); it('does not resurrect gateway-managed skills that are missing from local scan', async () => { const gatewayDeferred = deferred<{ skills: Array> }>(); - rpcMock.mockReturnValueOnce(gatewayDeferred.promise); - hostApiFetchMock.mockResolvedValueOnce({ success: true, skills: [] }); + statusMock.mockReturnValueOnce(gatewayDeferred.promise); + localMock.mockResolvedValueOnce({ success: true, skills: [] }); const { useSkillsStore } = await import('@/stores/skills'); useSkillsStore.setState({ skills: [], loading: false, error: null }); @@ -112,9 +110,9 @@ describe('skills store local-first fetch', () => { { skillKey: 'plugin-skill', slug: 'plugin-skill', name: 'plugin-skill', source: 'openclaw-plugin', disabled: false }, ], }); - await Promise.resolve(); - await Promise.resolve(); - expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual(['plugin-skill']); + await vi.waitFor(() => { + expect(useSkillsStore.getState().skills.map((skill) => skill.id)).toEqual(['plugin-skill']); + }); }); }); diff --git a/tests/unit/stores.test.ts b/tests/unit/stores.test.ts index 7e382a0c..00b5365f 100644 --- a/tests/unit/stores.test.ts +++ b/tests/unit/stores.test.ts @@ -5,8 +5,38 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useSettingsStore } from '@/stores/settings'; import { useGatewayStore } from '@/stores/gateway'; +const hostApiMock = vi.hoisted(() => ({ + gateway: { + status: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + health: vi.fn(), + rpc: vi.fn(), + }, + settings: { + getAll: vi.fn(), + get: vi.fn(), + set: vi.fn(), + setMany: vi.fn(), + reset: vi.fn(), + }, + logs: { + recent: vi.fn(), + dir: vi.fn(), + listFiles: vi.fn(), + readFile: vi.fn(), + }, +})); + +vi.mock('@/lib/host-api', () => ({ + hostApi: hostApiMock, +})); + describe('Settings Store', () => { beforeEach(() => { + vi.clearAllMocks(); + hostApiMock.settings.set.mockResolvedValue({ success: true }); // Reset store to default state useSettingsStore.setState({ theme: 'system', @@ -56,51 +86,23 @@ describe('Settings Store', () => { }); it('should unlock dev mode', () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ - ok: true, - data: { - status: 200, - ok: true, - json: { success: true }, - }, - }); + hostApiMock.settings.set.mockResolvedValueOnce({ success: true }); const { setDevModeUnlocked } = useSettingsStore.getState(); setDevModeUnlocked(true); expect(useSettingsStore.getState().devModeUnlocked).toBe(true); - expect(invoke).toHaveBeenCalledWith( - 'hostapi:fetch', - expect.objectContaining({ - path: '/api/settings/devModeUnlocked', - method: 'PUT', - }), - ); + expect(hostApiMock.settings.set).toHaveBeenCalledWith('devModeUnlocked', true); }); it('should persist launch-at-startup setting through host api', () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ - ok: true, - data: { - status: 200, - ok: true, - json: { success: true }, - }, - }); + hostApiMock.settings.set.mockResolvedValueOnce({ success: true }); const { setLaunchAtStartup } = useSettingsStore.getState(); setLaunchAtStartup(true); expect(useSettingsStore.getState().launchAtStartup).toBe(true); - expect(invoke).toHaveBeenCalledWith( - 'hostapi:fetch', - expect.objectContaining({ - path: '/api/settings/launchAtStartup', - method: 'PUT', - }), - ); + expect(hostApiMock.settings.set).toHaveBeenCalledWith('launchAtStartup', true); }); }); @@ -129,12 +131,11 @@ describe('Gateway Store', () => { }); it('should proxy gateway rpc through ipc', async () => { - const invoke = vi.mocked(window.electron.ipcRenderer.invoke); - invoke.mockResolvedValueOnce({ success: true, result: { ok: true } }); + hostApiMock.gateway.rpc.mockResolvedValueOnce({ ok: true }); const result = await useGatewayStore.getState().rpc<{ ok: boolean }>('chat.history', { limit: 10 }, 5000); expect(result.ok).toBe(true); - expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', { limit: 10 }, 5000); + expect(hostApiMock.gateway.rpc).toHaveBeenCalledWith('chat.history', { limit: 10 }, 5000); }); }); diff --git a/tests/unit/title-bar.test.tsx b/tests/unit/title-bar.test.tsx index 3ddfea9a..8c552090 100644 --- a/tests/unit/title-bar.test.tsx +++ b/tests/unit/title-bar.test.tsx @@ -2,16 +2,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { TitleBar } from '@/components/layout/TitleBar'; -const invokeIpcMock = vi.hoisted(() => vi.fn()); +const isMaximizedMock = vi.hoisted(() => vi.fn()); +const minimizeMock = vi.hoisted(() => vi.fn()); +const maximizeMock = vi.hoisted(() => vi.fn()); +const closeMock = vi.hoisted(() => vi.fn()); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), +vi.mock('@/lib/host-api', () => ({ + hostApi: { + window: { + isMaximized: (...args: unknown[]) => isMaximizedMock(...args), + minimize: (...args: unknown[]) => minimizeMock(...args), + maximize: (...args: unknown[]) => maximizeMock(...args), + close: (...args: unknown[]) => closeMock(...args), + }, + }, })); describe('TitleBar platform behavior', () => { beforeEach(() => { - invokeIpcMock.mockReset(); - invokeIpcMock.mockResolvedValue(false); + isMaximizedMock.mockReset(); + minimizeMock.mockReset(); + maximizeMock.mockReset(); + closeMock.mockReset(); + isMaximizedMock.mockResolvedValue(false); + minimizeMock.mockResolvedValue(undefined); + maximizeMock.mockResolvedValue(undefined); + closeMock.mockResolvedValue(undefined); }); it('does not render a standalone title bar on macOS', () => { @@ -21,7 +37,7 @@ describe('TitleBar platform behavior', () => { expect(container.firstChild).toBeNull(); expect(screen.queryByTitle('Minimize')).not.toBeInTheDocument(); - expect(invokeIpcMock).not.toHaveBeenCalled(); + expect(isMaximizedMock).not.toHaveBeenCalled(); }); it('renders custom controls on Windows', async () => { @@ -37,7 +53,7 @@ describe('TitleBar platform behavior', () => { expect(bar).not.toHaveClass('border-b'); await waitFor(() => { - expect(invokeIpcMock).toHaveBeenCalledWith('window:isMaximized'); + expect(isMaximizedMock).toHaveBeenCalled(); }); }); @@ -48,6 +64,6 @@ describe('TitleBar platform behavior', () => { expect(container.firstChild).toBeNull(); expect(screen.queryByTitle('Minimize')).not.toBeInTheDocument(); - expect(invokeIpcMock).not.toHaveBeenCalled(); + expect(isMaximizedMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/usage-routes.test.ts b/tests/unit/usage-routes.test.ts deleted file mode 100644 index 0d860463..00000000 --- a/tests/unit/usage-routes.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IncomingMessage, ServerResponse } from 'http'; - -const getRecentTokenUsageHistoryMock = vi.fn(); -const sendJsonMock = vi.fn(); - -vi.mock('@electron/utils/token-usage', () => ({ - getRecentTokenUsageHistory: (...args: unknown[]) => getRecentTokenUsageHistoryMock(...args), -})); - -vi.mock('@electron/api/route-utils', () => ({ - sendJson: (...args: unknown[]) => sendJsonMock(...args), -})); - -describe('handleUsageRoutes', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('passes undefined limit when query param is missing', async () => { - getRecentTokenUsageHistoryMock.mockResolvedValueOnce([{ totalTokens: 1 }]); - const { handleUsageRoutes } = await import('@electron/api/routes/usage'); - - const handled = await handleUsageRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/usage/recent-token-history'), - {} as never, - ); - - expect(handled).toBe(true); - expect(getRecentTokenUsageHistoryMock).toHaveBeenCalledWith(undefined); - expect(sendJsonMock).toHaveBeenCalledWith( - expect.anything(), - 200, - [{ totalTokens: 1 }], - ); - }); - - it('passes sanitized numeric limit when provided', async () => { - getRecentTokenUsageHistoryMock.mockResolvedValueOnce([]); - const { handleUsageRoutes } = await import('@electron/api/routes/usage'); - - await handleUsageRoutes( - { method: 'GET' } as IncomingMessage, - {} as ServerResponse, - new URL('http://127.0.0.1:13210/api/usage/recent-token-history?limit=50.9'), - {} as never, - ); - - expect(getRecentTokenUsageHistoryMock).toHaveBeenCalledWith(50); - }); -}); diff --git a/tests/unit/workspace-browser-body.test.tsx b/tests/unit/workspace-browser-body.test.tsx index 27c57377..5dc25db7 100644 --- a/tests/unit/workspace-browser-body.test.tsx +++ b/tests/unit/workspace-browser-body.test.tsx @@ -12,14 +12,21 @@ vi.mock('react-i18next', () => ({ })); const readTextFile = vi.fn(); -const invokeIpc = vi.fn(async () => ({})); -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpc(...args), +vi.mock('@/lib/file-preview-client', () => ({ readTextFile: (...args: unknown[]) => readTextFile(...args), statFile: vi.fn(), })); +vi.mock('@/lib/host-api', () => ({ + hostApi: { + shell: { + openPath: vi.fn(), + showItemInFolder: vi.fn(), + }, + }, +})); + const htmlNode: WorkspaceTreeNode = { name: 'dashboard.html', relPath: 'dashboard.html', diff --git a/tsconfig.json b/tsconfig.json index 5b04ba59..31bac6e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,33 +1,4 @@ { - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - - /* Paths */ - "baseUrl": ".", - "paths": { - "@/*": ["src/*"], - "@electron/*": ["electron/*"] - } - }, - "include": ["src"], - "exclude": ["node_modules"], - "references": [{ "path": "./tsconfig.node.json" }] + "files": [], + "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index ff0ab827..94bdc40a 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -24,8 +24,9 @@ /* Paths */ "baseUrl": ".", "paths": { - "@electron/*": ["electron/*"] + "@electron/*": ["electron/*"], + "@shared/*": ["shared/*"] } }, - "include": ["electron", "vite.config.ts"] + "include": ["electron", "shared", "shared/**/*.json", "vite.config.ts"] } diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 00000000..2f251369 --- /dev/null +++ b/tsconfig.web.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@electron/*": ["electron/*"], + "@shared/*": ["shared/*"] + } + }, + "include": ["src", "shared"], + "exclude": ["node_modules", "dist", "dist-electron"] +} diff --git a/vite.config.ts b/vite.config.ts index be276efe..6d48041c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,11 +27,16 @@ function getExtensionPackages(): Set { } const extensionPackages = getExtensionPackages(); +const alias = { + '@': resolve(__dirname, 'src'), + '@electron': resolve(__dirname, 'electron'), + '@shared': resolve(__dirname, 'shared'), +}; function isMainProcessExternal(id: string): boolean { if (!id || id.startsWith('\0')) return false; if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false; - if (id.startsWith('@/') || id.startsWith('@electron/')) return false; + if (id.startsWith('@/') || id.startsWith('@electron/') || id.startsWith('@shared/')) return false; for (const pkg of extensionPackages) { if (id === pkg || id.startsWith(pkg + '/')) return false; } @@ -55,6 +60,7 @@ export default defineConfig({ options.startup(); }, vite: { + resolve: { alias }, build: { outDir: 'dist-electron/main', rollupOptions: { @@ -70,6 +76,7 @@ export default defineConfig({ options.reload(); }, vite: { + resolve: { alias }, build: { outDir: 'dist-electron/preload', rollupOptions: { @@ -82,10 +89,7 @@ export default defineConfig({ renderer(), ], resolve: { - alias: { - '@': resolve(__dirname, 'src'), - '@electron': resolve(__dirname, 'electron'), - }, + alias, dedupe: ['react', 'react-dom', 'react-i18next', 'zustand', 'sonner', 'lucide-react'], }, server: { diff --git a/vitest.config.ts b/vitest.config.ts index 4590d45e..3d6506b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ alias: { '@': resolve(__dirname, 'src'), '@electron': resolve(__dirname, 'electron'), + '@shared': resolve(__dirname, 'shared'), }, }, });