From 3d664c017a8f7ba9ca4c7805029d68c6f2b74233 Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Mon, 9 Mar 2026 20:18:25 +0800 Subject: [PATCH] refactor(new merge) (#369) Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan --- README.ja-JP.md | 58 +++--- README.md | 58 +++--- README.zh-CN.md | 58 +++--- electron/api/routes/providers.ts | 20 ++ electron/api/routes/usage.ts | 10 +- electron/main/ipc-handlers.ts | 20 ++ .../services/providers/provider-service.ts | 64 ++++++- refactor.md | 178 ------------------ src/lib/api-client.ts | 90 ++------- src/lib/error-model.ts | 102 ++++++++++ src/lib/host-api.ts | 26 ++- src/pages/Dashboard/index.tsx | 6 +- src/stores/skills.ts | 59 +++--- tests/unit/api-client.test.ts | 10 + tests/unit/error-model.test.ts | 26 +++ tests/unit/host-api.test.ts | 17 +- tests/unit/skills-errors.test.ts | 49 +++++ tests/unit/usage-routes.test.ts | 53 ++++++ 18 files changed, 514 insertions(+), 390 deletions(-) delete mode 100644 refactor.md create mode 100644 src/lib/error-model.ts create mode 100644 tests/unit/error-model.test.ts create mode 100644 tests/unit/skills-errors.test.ts create mode 100644 tests/unit/usage-routes.test.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index fb6e45fc1..7e93e34ec 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -144,7 +144,6 @@ pnpm run init # 開発モードで起動 pnpm dev ``` - ### 初回起動 ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします: @@ -172,7 +171,6 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ ```text プロキシサーバー: http://127.0.0.1:7890 ``` - 注意事項: - `host:port`のみの値はHTTPとして扱われます。 @@ -186,8 +184,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します: -``` -┌─────────────────────────────────────────────────────────────────┐ +```┌─────────────────────────────────────────────────────────────────┐ │ ClawX デスクトップアプリ │ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ @@ -231,7 +228,6 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を │ • プロバイダー抽象化レイヤー │ └─────────────────────────────────────────────────────────────────┘ ``` - ### 設計原則 - **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します @@ -268,34 +264,31 @@ AI を開発ワークフローに統合できます。エージェントを使 ### プロジェクト構成 +```ClawX/ +├── electron/ # Electron メインプロセス +│ ├── api/ # メイン側 API ルーターとハンドラー +│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール +│ ├── services/ # Provider/Secrets/ランタイムサービス +│ │ ├── providers/ # provider/account モデル同期ロジック +│ │ └── secrets/ # OS キーチェーンと秘密情報管理 +│ ├── shared/ # 共通 Provider スキーマ/定数 +│ │ └── providers/ +│ ├── main/ # アプリ入口、ウィンドウ、IPC 登録 +│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー +│ ├── preload/ # セキュア IPC ブリッジ +│ └── utils/ # ユーティリティ(ストレージ、認証、パス) +├── src/ # React レンダラープロセス +│ ├── lib/ # フロントエンド統一 API とエラーモデル +│ ├── stores/ # Zustand ストア(settings/chat/gateway) +│ ├── components/ # 再利用可能な UI コンポーネント +│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings +│ ├── i18n/ # ローカライズリソース +│ └── types/ # TypeScript 型定義 +├── tests/ +│ └── unit/ # Vitest ユニット/統合寄りテスト +├── resources/ # 静的アセット(アイコン、画像) +└── scripts/ # ビルド/ユーティリティスクリプト ``` -ClawX/ -├── electron/ # Electron メインプロセス -│ ├── main/ # アプリケーションエントリ、ウィンドウ管理 -│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー -│ ├── preload/ # セキュアIPCブリッジスクリプト -│ └── utils/ # ユーティリティ(ストレージ、認証、パス) -├── src/ # React レンダラープロセス -│ ├── components/ # 再利用可能なUIコンポーネント -│ │ ├── ui/ # ベースコンポーネント(shadcn/ui) -│ │ ├── layout/ # レイアウトコンポーネント(サイドバー、ヘッダー) -│ │ └── common/ # 共通コンポーネント -│ ├── pages/ # アプリケーションページ -│ │ ├── Setup/ # 初期セットアップウィザード -│ │ ├── Dashboard/ # ホームダッシュボード -│ │ ├── Chat/ # AIチャットインターフェース -│ │ ├── Channels/ # チャネル管理 -│ │ ├── Skills/ # スキルブラウザ&マネージャー -│ │ ├── Cron/ # スケジュールタスク -│ │ └── Settings/ # 設定パネル -│ ├── stores/ # Zustand ステートストア -│ ├── lib/ # フロントエンドユーティリティ -│ └── types/ # TypeScript 型定義 -├── resources/ # 静的アセット(アイコン、画像) -├── scripts/ # ビルド&ユーティリティスクリプト -└── tests/ # テストスイート -``` - ### 利用可能なコマンド ```bash @@ -318,7 +311,6 @@ pnpm package:mac # macOS向けにパッケージ化 pnpm package:win # Windows向けにパッケージ化 pnpm package:linux # Linux向けにパッケージ化 ``` - ### 技術スタック | レイヤー | 技術 | diff --git a/README.md b/README.md index 25f6adc5a..111c38eba 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,6 @@ pnpm run init # Start in development mode pnpm dev ``` - ### First Launch When you launch ClawX for the first time, the **Setup Wizard** will guide you through: @@ -175,7 +174,6 @@ Recommended local examples: ```text Proxy Server: http://127.0.0.1:7890 ``` - Notes: - A bare `host:port` value is treated as HTTP. @@ -189,8 +187,7 @@ Notes: ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle: -``` -┌─────────────────────────────────────────────────────────────────┐ +```┌─────────────────────────────────────────────────────────────────┐ │ ClawX Desktop App │ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ @@ -234,7 +231,6 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The │ • Provider abstraction layer │ └─────────────────────────────────────────────────────────────────┘ ``` - ### Design Principles - **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation @@ -271,34 +267,31 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro ### Project Structure +```ClawX/ +├── electron/ # Electron Main Process +│ ├── api/ # Main-side API router and handlers +│ │ └── routes/ # RPC/HTTP proxy route modules +│ ├── services/ # Provider, secrets and runtime services +│ │ ├── providers/ # Provider/account model sync logic +│ │ └── secrets/ # OS keychain and secret storage +│ ├── shared/ # Shared provider schemas/constants +│ │ └── providers/ +│ ├── main/ # App entry, windows, IPC registration +│ ├── gateway/ # OpenClaw Gateway process manager +│ ├── preload/ # Secure IPC bridge +│ └── utils/ # Utilities (storage, auth, paths) +├── src/ # React Renderer Process +│ ├── lib/ # Unified frontend API + error model +│ ├── stores/ # Zustand stores (settings/chat/gateway) +│ ├── components/ # Reusable UI components +│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings +│ ├── i18n/ # Localization resources +│ └── types/ # TypeScript type definitions +├── tests/ +│ └── unit/ # Vitest unit/integration-like tests +├── resources/ # Static assets (icons/images) +└── scripts/ # Build and utility scripts ``` -ClawX/ -├── electron/ # Electron Main Process -│ ├── main/ # Application entry, window management -│ ├── gateway/ # OpenClaw Gateway process manager -│ ├── preload/ # Secure IPC bridge scripts -│ └── utils/ # Utilities (storage, auth, paths) -├── src/ # React Renderer Process -│ ├── components/ # Reusable UI components -│ │ ├── ui/ # Base components (shadcn/ui) -│ │ ├── layout/ # Layout components (sidebar, header) -│ │ └── common/ # Shared components -│ ├── pages/ # Application pages -│ │ ├── Setup/ # Initial setup wizard -│ │ ├── Dashboard/ # Home dashboard -│ │ ├── Chat/ # AI chat interface -│ │ ├── Channels/ # Channel management -│ │ ├── Skills/ # Skill browser & manager -│ │ ├── Cron/ # Scheduled tasks -│ │ └── Settings/ # Configuration panels -│ ├── stores/ # Zustand state stores -│ ├── lib/ # Frontend utilities -│ └── types/ # TypeScript type definitions -├── resources/ # Static assets (icons, images) -├── scripts/ # Build & utility scripts -└── tests/ # Test suites -``` - ### Available Commands ```bash @@ -321,7 +314,6 @@ pnpm package:mac # Package for macOS pnpm package:win # Package for Windows pnpm package:linux # Package for Linux ``` - ### Tech Stack | Layer | Technology | diff --git a/README.zh-CN.md b/README.zh-CN.md index 63c8b7fea..43cda7dc4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -145,7 +145,6 @@ pnpm run init # 以开发模式启动 pnpm dev ``` - ### 首次启动 首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤: @@ -176,7 +175,6 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 ```text 代理服务器: http://127.0.0.1:7890 ``` - 说明: - 只填写 `host:port` 时,会按 HTTP 代理处理。 @@ -190,8 +188,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理: -``` -┌─────────────────────────────────────────────────────────────────┐ +```┌─────────────────────────────────────────────────────────────────┐ │ ClawX 桌面应用 │ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ @@ -235,7 +232,6 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 │ • 供应商抽象层 │ └─────────────────────────────────────────────────────────────────┘ ``` - ### 设计原则 - **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应 @@ -272,34 +268,31 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 ### 项目结构 +```ClawX/ +├── electron/ # Electron 主进程 +│ ├── api/ # 主进程 API 路由与处理器 +│ │ └── routes/ # RPC/HTTP 代理路由模块 +│ ├── services/ # Provider、Secrets 与运行时服务 +│ │ ├── providers/ # Provider/account 模型同步逻辑 +│ │ └── secrets/ # 系统钥匙串与密钥存储 +│ ├── shared/ # 共享 Provider schema/常量 +│ │ └── providers/ +│ ├── main/ # 应用入口、窗口、IPC 注册 +│ ├── gateway/ # OpenClaw 网关进程管理 +│ ├── preload/ # 安全 IPC 桥接 +│ └── utils/ # 工具模块(存储、认证、路径) +├── src/ # React 渲染进程 +│ ├── lib/ # 前端统一 API 与错误模型 +│ ├── stores/ # Zustand 状态仓库(settings/chat/gateway) +│ ├── components/ # 可复用 UI 组件 +│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings +│ ├── i18n/ # 国际化资源 +│ └── types/ # TypeScript 类型定义 +├── tests/ +│ └── unit/ # Vitest 单元/集成型测试 +├── resources/ # 静态资源(图标、图片) +└── scripts/ # 构建与工具脚本 ``` -ClawX/ -├── electron/ # Electron 主进程 -│ ├── main/ # 应用入口、窗口管理 -│ ├── gateway/ # OpenClaw 网关进程管理 -│ ├── preload/ # 安全 IPC 桥接脚本 -│ └── utils/ # 工具模块(存储、认证、路径) -├── src/ # React 渲染进程 -│ ├── components/ # 可复用 UI 组件 -│ │ ├── ui/ # 基础组件(shadcn/ui) -│ │ ├── layout/ # 布局组件(侧边栏、顶栏) -│ │ └── common/ # 公共组件 -│ ├── pages/ # 应用页面 -│ │ ├── Setup/ # 初始设置向导 -│ │ ├── Dashboard/ # 首页仪表盘 -│ │ ├── Chat/ # AI 聊天界面 -│ │ ├── Channels/ # 频道管理 -│ │ ├── Skills/ # 技能浏览与管理 -│ │ ├── Cron/ # 定时任务 -│ │ └── Settings/ # 配置面板 -│ ├── stores/ # Zustand 状态仓库 -│ ├── lib/ # 前端工具库 -│ └── types/ # TypeScript 类型定义 -├── resources/ # 静态资源(图标、图片) -├── scripts/ # 构建与工具脚本 -└── tests/ # 测试套件 -``` - ### 常用命令 ```bash @@ -322,7 +315,6 @@ pnpm package:mac # 为 macOS 打包 pnpm package:win # 为 Windows 打包 pnpm package:linux # 为 Linux 打包 ``` - ### 技术栈 | 层级 | 技术 | diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index b1c800f87..e69c34af6 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -21,6 +21,9 @@ import { validateApiKeyWithProvider } from '../../services/providers/provider-va 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(); export async function handleProviderRoutes( req: IncomingMessage, @@ -29,6 +32,13 @@ export async function handleProviderRoutes( 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()); @@ -125,16 +135,19 @@ export async function handleProviderRoutes( } if (url.pathname === '/api/providers' && req.method === 'GET') { + logLegacyProviderRoute('GET /api/providers'); sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo()); return true; } if (url.pathname === '/api/providers/default' && req.method === 'GET') { + logLegacyProviderRoute('GET /api/providers/default'); sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? 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); await providerService.setDefaultLegacyProvider(body.providerId); @@ -147,6 +160,7 @@ export async function handleProviderRoutes( } 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 } }>(req); const provider = await providerService.getLegacyProvider(body.providerId); @@ -161,6 +175,7 @@ export async function handleProviderRoutes( } if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') { + logLegacyProviderRoute('POST /api/providers/oauth/start'); try { const body = await parseJsonBody<{ provider: OAuthProviderType | BrowserOAuthProviderType; @@ -187,6 +202,7 @@ export async function handleProviderRoutes( } if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') { + logLegacyProviderRoute('POST /api/providers/oauth/cancel'); try { await deviceOAuthManager.stopFlow(); await browserOAuthManager.stopFlow(); @@ -198,6 +214,7 @@ export async function handleProviderRoutes( } 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; @@ -218,6 +235,7 @@ export async function handleProviderRoutes( } 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)); @@ -234,6 +252,7 @@ export async function handleProviderRoutes( } 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); @@ -263,6 +282,7 @@ export async function handleProviderRoutes( } 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.getLegacyProvider(providerId); diff --git a/electron/api/routes/usage.ts b/electron/api/routes/usage.ts index d72be8d3b..9c97da752 100644 --- a/electron/api/routes/usage.ts +++ b/electron/api/routes/usage.ts @@ -10,8 +10,14 @@ export async function handleUsageRoutes( _ctx: HostApiContext, ): Promise { if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') { - const parsedLimit = Number(url.searchParams.get('limit') || ''); - const limit = Number.isFinite(parsedLimit) ? Math.max(Math.floor(parsedLimit), 1) : undefined; + 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; } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 344a8bd07..e5db2bc0e 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1652,6 +1652,14 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { */ function registerProviderHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); + const legacyProviderChannelsWarned = new Set(); + const logLegacyProviderChannel = (channel: string): void => { + if (legacyProviderChannelsWarned.has(channel)) return; + legacyProviderChannelsWarned.add(channel); + logger.warn( + `[provider-migration] Legacy IPC channel "${channel}" is deprecated. Prefer app:request provider actions and account APIs.`, + ); + }; // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. // Use a longer debounce (8s) so that provider:setDefault — which writes the full config @@ -1669,6 +1677,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get all providers with key info ipcMain.handle('provider:list', async () => { + logLegacyProviderChannel('provider:list'); return await providerService.listLegacyProvidersWithKeyInfo(); }); @@ -1687,11 +1696,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { + logLegacyProviderChannel('provider:get'); return await providerService.getLegacyProvider(providerId); }); // Save a provider configuration ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => { + logLegacyProviderChannel('provider:save'); try { // Save the provider config await providerService.saveLegacyProvider(config); @@ -1726,6 +1737,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { + logLegacyProviderChannel('provider:delete'); try { const existing = await providerService.getLegacyProvider(providerId); await providerService.deleteLegacyProvider(providerId); @@ -1747,6 +1759,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Update API key for a provider ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { + logLegacyProviderChannel('provider:setApiKey'); try { await providerService.setLegacyProviderApiKey(providerId, apiKey); @@ -1774,6 +1787,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { updates: Partial, apiKey?: string ) => { + logLegacyProviderChannel('provider:updateWithKey'); const existing = await providerService.getLegacyProvider(providerId); if (!existing) { return { success: false, error: 'Provider not found' }; @@ -1834,6 +1848,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { + logLegacyProviderChannel('provider:deleteApiKey'); try { await providerService.deleteLegacyProviderApiKey(providerId); @@ -1853,16 +1868,19 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Check if a provider has an API key ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => { + logLegacyProviderChannel('provider:hasApiKey'); return await providerService.hasLegacyProviderApiKey(providerId); }); // Get the actual API key (for internal use only - be careful!) ipcMain.handle('provider:getApiKey', async (_, providerId: string) => { + logLegacyProviderChannel('provider:getApiKey'); return await providerService.getLegacyProviderApiKey(providerId); }); // Set default provider and update OpenClaw default model ipcMain.handle('provider:setDefault', async (_, providerId: string) => { + logLegacyProviderChannel('provider:setDefault'); try { await providerService.setDefaultLegacyProvider(providerId); @@ -1883,6 +1901,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get default provider ipcMain.handle('provider:getDefault', async () => { + logLegacyProviderChannel('provider:getDefault'); return await providerService.getDefaultLegacyProvider(); }); @@ -1896,6 +1915,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { apiKey: string, options?: { baseUrl?: string } ) => { + logLegacyProviderChannel('provider:validateKey'); try { // First try to get existing provider const provider = await providerService.getLegacyProvider(providerId); diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 1531d3d75..93af8d283 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -27,6 +27,7 @@ import { storeApiKey, } from '../../utils/secure-storage'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; +import { logger } from '../../utils/logger'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; @@ -36,6 +37,18 @@ function maskApiKey(apiKey: string | null): string | null { return '*'.repeat(apiKey.length); } +const legacyProviderApiWarned = new Set(); + +function logLegacyProviderApiUsage(method: string, replacement: string): void { + if (legacyProviderApiWarned.has(method)) { + return; + } + legacyProviderApiWarned.add(method); + logger.warn( + `[provider-migration] Legacy provider API "${method}" is deprecated. Migrate to "${replacement}".`, + ); +} + export class ProviderService { async listVendors(): Promise { return PROVIDER_DEFINITIONS; @@ -103,20 +116,21 @@ export class ProviderService { return deleteProvider(accountId); } - async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise { - await ensureProviderStoreMigrated(); - const account = providerConfigToAccount(config, options); - await saveProviderAccount(account); - return account; - } - + /** + * @deprecated Use listAccounts() and map account data in callers. + */ async listLegacyProviders(): Promise { + logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts'); await ensureProviderStoreMigrated(); const accounts = await listProviderAccounts(); return accounts.map(providerAccountToConfig); } + /** + * @deprecated Use listAccounts() + secret-store based key summary. + */ async listLegacyProvidersWithKeyInfo(): Promise { + logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts'); const providers = await this.listLegacyProviders(); const results: ProviderWithKeyInfo[] = []; for (const provider of providers) { @@ -130,13 +144,21 @@ export class ProviderService { return results; } + /** + * @deprecated Use getAccount(accountId). + */ async getLegacyProvider(providerId: string): Promise { + logLegacyProviderApiUsage('getLegacyProvider', 'getAccount'); await ensureProviderStoreMigrated(); const account = await getProviderAccount(providerId); return account ? providerAccountToConfig(account) : null; } + /** + * @deprecated Use createAccount()/updateAccount(). + */ async saveLegacyProvider(config: ProviderConfig): Promise { + logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount'); await ensureProviderStoreMigrated(); const account = providerConfigToAccount(config); const existing = await getProviderAccount(config.id); @@ -147,33 +169,61 @@ export class ProviderService { await this.createAccount(account); } + /** + * @deprecated Use deleteAccount(accountId). + */ async deleteLegacyProvider(providerId: string): Promise { + logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount'); await ensureProviderStoreMigrated(); await this.deleteAccount(providerId); return true; } + /** + * @deprecated Use setDefaultAccount(accountId). + */ async setDefaultLegacyProvider(providerId: string): Promise { + logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount'); await this.setDefaultAccount(providerId); } + /** + * @deprecated Use getDefaultAccountId(). + */ async getDefaultLegacyProvider(): Promise { + logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId'); return this.getDefaultAccountId(); } + /** + * @deprecated Use secret-store APIs by accountId. + */ async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise { + logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)'); return storeApiKey(providerId, apiKey); } + /** + * @deprecated Use secret-store APIs by accountId. + */ async getLegacyProviderApiKey(providerId: string): Promise { + logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)'); return getApiKey(providerId); } + /** + * @deprecated Use secret-store APIs by accountId. + */ async deleteLegacyProviderApiKey(providerId: string): Promise { + logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)'); return deleteApiKey(providerId); } + /** + * @deprecated Use secret-store APIs by accountId. + */ async hasLegacyProviderApiKey(providerId: string): Promise { + logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)'); return hasApiKey(providerId); } diff --git a/refactor.md b/refactor.md deleted file mode 100644 index 757edee4a..000000000 --- a/refactor.md +++ /dev/null @@ -1,178 +0,0 @@ -# Refactor Summary - -## Scope -This branch captures local refactors focused on frontend UX polish, IPC call consolidation, transport abstraction, and channel page responsiveness. - -## Key Changes - -### 1. Frontend IPC consolidation -- Replaced scattered direct `window.electron.ipcRenderer.invoke(...)` calls with unified `invokeIpc(...)` usage. -- Added lint guard to prevent new direct renderer IPC invokes outside the API layer. -- Introduced a centralized API client with: - - error normalization (`AppError`) - - unified `app:request` support + compatibility fallback - - retry helper for timeout/network errors - -### 2. Transport abstraction (extensible protocol layer) -- Added transport routing abstraction inside `src/lib/api-client.ts`: - - `ipc`, `ws`, `http` - - rule-based channel routing - - transport registration/unregistration - - failure backoff and fallback behavior -- Added default transport initialization in app entry. -- Added gateway-specific transport adapters for WS/HTTP. - -### 3. HTTP path moved to Electron main-process proxy -- Added `gateway:httpProxy` IPC handler in main process to avoid renderer-side CORS issues. -- Preload allowlist updated for `gateway:httpProxy`. -- Gateway HTTP transport now uses IPC proxy instead of browser `fetch` direct-to-gateway. - -### 4. Settings improvements (Developer-focused transport control) -- Added persisted setting `gatewayTransportPreference`. -- Added runtime application of transport preference in app bootstrap. -- Added UI option (Developer section) to choose routing strategy: - - WS First / HTTP First / WS Only / HTTP Only / IPC Only -- Added i18n strings for EN/ZH/JA. - -### 5. Channel page performance optimization -- `fetchChannels` now supports options: - - `probe` (manual refresh can force probe) - - `silent` (background refresh without full-page loading lock) -- Channel status event refresh now debounced (300ms) to reduce refresh storms. -- Initial loading spinner only shown when no existing data. -- Manual refresh uses local spinner state and non-blocking update. - -### 6. UX and component enhancements -- Added shared feedback state component for consistent empty/loading/error states. -- Added telemetry helpers and quick-action/dashboard refinements. -- Setup/settings/providers/chat/skills/cron pages received targeted UX and reliability fixes. - -### 7. IPC main handler compatibility improvements -- Expanded `app:request` coverage for provider/update/settings/cron/usage actions. -- Unsupported app requests now return structured error response instead of throwing, reducing noisy handler exceptions. - -### 8. Tests -- Added unit tests for API client behavior and feedback state rendering. -- Added transport fallback/backoff coverage in API client tests. - -## Files Added -- `src/lib/api-client.ts` -- `src/lib/telemetry.ts` -- `src/components/common/FeedbackState.tsx` -- `tests/unit/api-client.test.ts` -- `tests/unit/feedback-state.test.tsx` -- `refactor.md` - -## Notes -- Navigation order in sidebar is kept aligned with `main` ordering. -- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work. - -## Incremental Updates (2026-03-08) - -### 9. Channel i18n fixes -- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback: - - `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm` -- Fixed confirm dialog namespace usage on Channels page: - - `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel` - -### 10. Channel save/delete behavior aligned to reload-first strategy -- Added Gateway reload capability in `GatewayManager`: - - `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms) - - `debouncedReload()` for coalesced config-change reloads -- Wired channel config operations to reload pipeline: - - `channel:saveConfig` - - `channel:deleteConfig` - - `channel:setEnabled` -- Removed redundant renderer-side forced restart call after WhatsApp configuration. - -### 11. OpenClaw config compatibility for graceful reload -- Ensured `commands.restart = true` is persisted in OpenClaw config write paths: - - `electron/utils/channel-config.ts` - - `electron/utils/openclaw-auth.ts` -- Added sanitize fallback that auto-enables `commands.restart` before Gateway start. - -### 12. Channels page data consistency fixes -- Unified configured state derivation so the following sections share one source: - - stats cards - - configured channels list - - available channel configured badge -- Fixed post-delete refresh by explicitly refetching both: - - configured channel types - - channel status list - -### 13. Channels UX resilience during Gateway restart/reconnect -- Added delayed gateway warning display to reduce transient false alarms. -- Added "running snapshot" rendering strategy: - - keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty - - avoids UI flashing to zero counts / empty configured state -- Added automatic refresh once Gateway transitions back to `running`. - -### 14. Channel enable/disable UX rollback -- Rolled back renderer-side channel enable/disable controls due to multi-channel state mixing risk. -- Removed channel-card toggle entry point and setup-dialog enable switch. -- Restored stable channel configuration flow (save/delete + refresh consistency). - -### 15. Cron i18n completion and consistency -- Replaced remaining hardcoded Cron UI strings with i18n keys: - - dialog actions (`Cancel`, `Saving...`) - - card actions (`Edit`, `Delete`) - - trigger failure message - - confirm dialog namespace usage (`common:actions.*`) -- Refactored cron schedule display parser to return localized strings instead of hardcoded English. -- Added new locale keys in EN/ZH/JA: - - `toast.failedTrigger` - - `schedule.everySeconds/everyMinutes/everyHours/everyDays/onceAt/weeklyAt/monthlyAtDay/dailyAt/unknown` - -### 16. Gateway log noise reduction -- Added stderr classification downgrade for expected loopback websocket transient logs: - - `[ws] handshake timeout ... remote=127.0.0.1` - - `[ws] closed before connect ... remote=127.0.0.1` -- These lines now log at debug level instead of warn during reload/reconnect windows. - -### 17. External gateway shutdown compatibility -- Added capability cache for externally managed Gateway shutdown RPC. -- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings. - -### 18. Chat history sidebar grouping (ChatGPT-style buckets) -- Updated chat session history display in sidebar to time buckets: - - Today / Yesterday / Within 1 Week / Within 2 Weeks / Within 1 Month / Older than 1 Month -- Added `historyBuckets` locale keys in EN/ZH/JA (`chat` namespace). -- Fixed i18n namespace usage for bucket labels in sidebar: - - explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback. -- Removed forced uppercase rendering for bucket headers to preserve localized casing. -- Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior. - -### 19. `refactor_clawx_1` × `main` merge outcome (main-first baseline) -- Performed a dedicated merge line with `origin/main` as the conflict-resolution baseline for high-risk files. -- Resolved wide conflict surface in gateway/store/page layers by prioritizing compile-safe `main` implementations, then selectively re-applying compatible refactor behavior. -- Merge result focus: - - keep app runnable and type-safe first - - avoid partial hybrid states that mix incompatible host-api/ipc patterns in a single module - - retain low-risk UX/flow improvements only when behavior parity is clear - -### 20. Post-merge compile recovery -- Fixed merge-induced breakages causing `tsc`/build failures: - - malformed blocks in settings/channels/chat/store files - - duplicated variable declarations in `electron/gateway/manager.ts` - - mismatched transport helper usage introduced by partial conflict picks -- Re-aligned broken modules to `origin/main` where necessary to restore a stable build baseline. -- Current status after cleanup: - - `pnpm run typecheck` passes - - app-side Vite/Electron compile succeeds; packaging step may still fail under restricted proxy/network environments (non-code issue) - -### 21. IPC event compatibility fix after merge -- Fixed runtime error `Invalid IPC channel: gateway:channel-status` by restoring preload allowlist compatibility with renderer subscriptions. -- Reconciled `electron/preload/index.ts` event channel whitelist with active gateway event usage: - - `gateway:status-changed` - - `gateway:message` - - `gateway:notification` - - `gateway:channel-status` - - `gateway:chat-message` - - `gateway:exit` - - `gateway:error` - -### 22. Transport strategy consolidation (no user toggle) -- Removed manual transport selection UI from Settings/Developer to reduce operator complexity and avoid invalid combinations. -- Kept transport abstraction in code, but locked runtime policy to a single fixed chain: - - `WS first -> HTTP fallback -> IPC fallback` -- Applied this as bootstrap default in app initialization rather than user preference switching. diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e841d4790..31921dba3 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,13 +1,11 @@ import { trackUiEvent } from './telemetry'; - -export type AppErrorCode = - | 'TIMEOUT' - | 'RATE_LIMIT' - | 'PERMISSION' - | 'NETWORK' - | 'CONFIG' - | 'GATEWAY' - | 'UNKNOWN'; +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'; @@ -181,64 +179,8 @@ class TransportUnsupportedError extends Error { } } -export class AppError extends Error { - code: AppErrorCode; - cause?: unknown; - details?: Record; - - constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record) { - super(message); - this.code = code; - this.cause = cause; - this.details = details; - } -} - function mapUnifiedErrorCode(code?: string): AppErrorCode { - switch (code) { - case 'TIMEOUT': - return 'TIMEOUT'; - case 'PERMISSION': - return 'PERMISSION'; - case 'GATEWAY': - return 'GATEWAY'; - case 'VALIDATION': - return 'CONFIG'; - case 'UNSUPPORTED': - return 'UNKNOWN'; - default: - return 'UNKNOWN'; - } -} - -function normalizeError(err: unknown, details?: Record): AppError { - if (err instanceof AppError) { - return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) }); - } - - const message = err instanceof Error ? err.message : String(err); - const lower = message.toLowerCase(); - - if (lower.includes('timeout')) { - return new AppError('TIMEOUT', message, err, details); - } - if (lower.includes('rate limit')) { - return new AppError('RATE_LIMIT', message, err, details); - } - if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) { - return new AppError('PERMISSION', message, err, details); - } - if (lower.includes('network') || lower.includes('fetch')) { - return new AppError('NETWORK', message, err, details); - } - if (lower.includes('gateway')) { - return new AppError('GATEWAY', message, err, details); - } - if (lower.includes('config') || lower.includes('invalid')) { - return new AppError('CONFIG', message, err, details); - } - - return new AppError('UNKNOWN', message, err, details); + return mapBackendErrorCode(code); } function shouldLogApiRequests(): boolean { @@ -389,7 +331,7 @@ async function invokeViaIpc(channel: string, args: unknown[]): Promise { if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) { // Fallback to legacy channel handlers. } else { - throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' }); + throw normalizeAppError(err, { transport: 'ipc', channel, source: 'app:request' }); } } } @@ -397,7 +339,7 @@ async function invokeViaIpc(channel: string, args: unknown[]): Promise { try { return await window.electron.ipcRenderer.invoke(channel, ...args) as T; } catch (err) { - throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' }); + throw normalizeAppError(err, { transport: 'ipc', channel, source: 'legacy-ipc' }); } } @@ -956,15 +898,19 @@ export function initializeDefaultTransports(): void { } export function toUserMessage(error: unknown): string { - const appError = error instanceof AppError ? error : normalizeError(error); + 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': @@ -1052,7 +998,7 @@ export async function invokeApi(channel: string, ...args: unknown[]): Promise }); continue; } - throw normalizeError(err, { + throw normalizeAppError(err, { requestId, channel, transport: kind, @@ -1069,7 +1015,7 @@ export async function invokeApi(channel: string, ...args: unknown[]): Promise message: lastError instanceof Error ? lastError.message : String(lastError), }); - throw normalizeError(lastError, { + throw normalizeAppError(lastError, { requestId, channel, transport: 'ipc', @@ -1100,5 +1046,5 @@ export async function invokeIpcWithRetry( } } - throw normalizeError(lastError); + throw normalizeAppError(lastError); } diff --git a/src/lib/error-model.ts b/src/lib/error-model.ts new file mode 100644 index 000000000..61bd7429c --- /dev/null +++ b/src/lib/error-model.ts @@ -0,0 +1,102 @@ +export type AppErrorCode = + | 'AUTH_INVALID' + | 'TIMEOUT' + | 'RATE_LIMIT' + | 'PERMISSION' + | 'CHANNEL_UNAVAILABLE' + | 'NETWORK' + | 'CONFIG' + | 'GATEWAY' + | 'UNKNOWN'; + +export class AppError extends Error { + code: AppErrorCode; + cause?: unknown; + details?: Record; + + constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record) { + super(message); + this.code = code; + this.cause = cause; + this.details = details; + } +} + +export function mapBackendErrorCode(code?: string): AppErrorCode { + switch (code) { + case 'TIMEOUT': + return 'TIMEOUT'; + case 'PERMISSION': + return 'PERMISSION'; + case 'GATEWAY': + return 'GATEWAY'; + case 'VALIDATION': + return 'CONFIG'; + case 'UNSUPPORTED': + return 'CHANNEL_UNAVAILABLE'; + default: + return 'UNKNOWN'; + } +} + +function classifyMessage(message: string): AppErrorCode { + const lower = message.toLowerCase(); + + if ( + lower.includes('invalid ipc channel') + || lower.includes('no handler registered') + || lower.includes('window is not defined') + || lower.includes('unsupported') + ) { + return 'CHANNEL_UNAVAILABLE'; + } + if ( + lower.includes('invalid authentication') + || lower.includes('unauthorized') + || lower.includes('auth failed') + || lower.includes('401') + ) { + return 'AUTH_INVALID'; + } + if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('abort')) { + return 'TIMEOUT'; + } + if (lower.includes('rate limit') || lower.includes('429')) { + return 'RATE_LIMIT'; + } + if ( + lower.includes('permission') + || lower.includes('forbidden') + || lower.includes('denied') + || lower.includes('403') + ) { + return 'PERMISSION'; + } + if ( + lower.includes('network') + || lower.includes('fetch') + || lower.includes('econnrefused') + || lower.includes('econnreset') + || lower.includes('enotfound') + ) { + return 'NETWORK'; + } + if (lower.includes('gateway')) { + return 'GATEWAY'; + } + if (lower.includes('config') || lower.includes('invalid') || lower.includes('validation') || lower.includes('400')) { + return 'CONFIG'; + } + + return 'UNKNOWN'; +} + +export function normalizeAppError(err: unknown, details?: Record): AppError { + if (err instanceof AppError) { + return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) }); + } + + const message = err instanceof Error ? err.message : String(err); + return new AppError(classifyMessage(message), message, err, details); +} + diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index 884e5492f..8c68cdd42 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -1,5 +1,6 @@ import { invokeIpc } from '@/lib/api-client'; import { trackUiEvent } from './telemetry'; +import { normalizeAppError } from './error-model'; const HOST_API_PORT = 3210; const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; @@ -45,7 +46,10 @@ async function parseResponse(response: Response): Promise { } catch { // ignore body parse failure } - throw new Error(message); + throw normalizeAppError(new Error(message), { + source: 'browser-fallback', + status: response.status, + }); } if (response.status === 204) { @@ -117,8 +121,12 @@ function parseLegacyProxyResponse( } function shouldFallbackToBrowser(message: string): boolean { - return message.includes('Invalid IPC channel: hostapi:fetch') - || message.includes('window is not defined'); + 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'); } export async function hostApiFetch(path: string, init?: RequestInit): Promise { @@ -139,16 +147,18 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise return parseLegacyProxyResponse(response, path, method, startedAt); } catch (error) { - const message = error instanceof Error ? error.message : String(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 error; + throw normalized; } } @@ -167,7 +177,11 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise durationMs: Date.now() - startedAt, status: response.status, }); - return parseResponse(response); + try { + return await parseResponse(response); + } catch (error) { + throw normalizeAppError(error, { source: 'browser-fallback', path, method }); + } } export function createHostEventSource(path = '/api/events'): EventSource { diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 0d72066ae..8252418e1 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -61,9 +61,13 @@ export function Dashboard() { const [usageWindow, setUsageWindow] = useState('7d'); const [usagePage, setUsagePage] = useState(1); - // Fetch data only when gateway is running + // Track page view on mount only. useEffect(() => { trackUiEvent('dashboard.page_viewed'); + }, []); + + // Fetch data only when gateway is running. + useEffect(() => { if (isGatewayRunning) { fetchChannels(); fetchSkills(); diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 552b39f9f..59560dcd8 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import { hostApiFetch } from '@/lib/host-api'; +import { AppError, normalizeAppError } from '@/lib/error-model'; import { useGatewayStore } from './gateway'; import type { Skill, MarketplaceSkill } from '../types/skill'; @@ -30,6 +31,27 @@ type ClawHubListResult = { version?: string; }; +function mapErrorCodeToSkillErrorKey( + code: AppError['code'], + operation: 'fetch' | 'search' | 'install', +): string { + if (code === 'TIMEOUT') { + return operation === 'search' + ? 'searchTimeoutError' + : operation === 'install' + ? 'installTimeoutError' + : 'fetchTimeoutError'; + } + if (code === 'RATE_LIMIT') { + return operation === 'search' + ? 'searchRateLimitError' + : operation === 'install' + ? 'installRateLimitError' + : 'fetchRateLimitError'; + } + return 'rateLimitError'; +} + interface SkillsState { skills: Skill[]; searchResults: MarketplaceSkill[]; @@ -131,13 +153,8 @@ export const useSkillsStore = create((set, get) => ({ set({ skills: combinedSkills, loading: false }); } catch (error) { console.error('Failed to fetch skills:', error); - let errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg.includes('Timeout')) { - errorMsg = 'timeoutError'; - } else if (errorMsg.toLowerCase().includes('rate limit')) { - errorMsg = 'rateLimitError'; - } - set({ loading: false, error: errorMsg }); + const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); + set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') }); } }, @@ -151,16 +168,14 @@ export const useSkillsStore = create((set, get) => ({ if (result.success) { set({ searchResults: result.results || [] }); } else { - if (result.error?.includes('Timeout')) { - throw new Error('searchTimeoutError'); - } - if (result.error?.toLowerCase().includes('rate limit')) { - throw new Error('searchRateLimitError'); - } - throw new Error(result.error || 'Search failed'); + throw normalizeAppError(new Error(result.error || 'Search failed'), { + module: 'skills', + operation: 'search', + }); } } catch (error) { - set({ searchError: String(error) }); + const appError = normalizeAppError(error, { module: 'skills', operation: 'search' }); + set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') }); } finally { set({ searching: false }); } @@ -174,13 +189,11 @@ export const useSkillsStore = create((set, get) => ({ body: JSON.stringify({ slug, version }), }); if (!result.success) { - if (result.error?.includes('Timeout')) { - throw new Error('installTimeoutError'); - } - if (result.error?.toLowerCase().includes('rate limit')) { - throw new Error('installRateLimitError'); - } - throw new Error(result.error || 'Install failed'); + const appError = normalizeAppError(new Error(result.error || 'Install failed'), { + module: 'skills', + operation: 'install', + }); + throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install')); } // Refresh skills after install await get().fetchSkills(); @@ -258,4 +271,4 @@ export const useSkillsStore = create((set, get) => ({ ), })); }, -})); \ No newline at end of file +})); diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts index af6e5c5f1..aea400ac6 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -68,6 +68,16 @@ describe('api-client', () => { 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 diff --git a/tests/unit/error-model.test.ts b/tests/unit/error-model.test.ts new file mode 100644 index 000000000..11380512f --- /dev/null +++ b/tests/unit/error-model.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { AppError, mapBackendErrorCode, normalizeAppError } from '@/lib/error-model'; + +describe('error-model', () => { + it('maps backend UNSUPPORTED to CHANNEL_UNAVAILABLE', () => { + expect(mapBackendErrorCode('UNSUPPORTED')).toBe('CHANNEL_UNAVAILABLE'); + }); + + it('normalizes auth errors into AUTH_INVALID', () => { + const error = normalizeAppError(new Error('HTTP 401: Invalid Authentication')); + expect(error.code).toBe('AUTH_INVALID'); + }); + + it('normalizes ipc channel errors into CHANNEL_UNAVAILABLE', () => { + const error = normalizeAppError(new Error('Invalid IPC channel: hostapi:fetch')); + expect(error.code).toBe('CHANNEL_UNAVAILABLE'); + }); + + it('preserves AppError and merges details', () => { + const base = new AppError('TIMEOUT', 'request timeout', undefined, { a: 1 }); + const normalized = normalizeAppError(base, { b: 2 }); + expect(normalized.code).toBe('TIMEOUT'); + expect(normalized.details).toEqual({ a: 1, b: 2 }); + }); +}); + diff --git a/tests/unit/host-api.test.ts b/tests/unit/host-api.test.ts index d60c9900a..be703a977 100644 --- a/tests/unit/host-api.test.ts +++ b/tests/unit/host-api.test.ts @@ -44,14 +44,27 @@ describe('host-api', () => { expect(result.ok).toBe(1); }); - it('throws proxy error from unified envelope', async () => { + 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); + invokeIpcMock.mockResolvedValueOnce({ ok: false, error: { message: 'No handler registered for hostapi:fetch' }, }); const { hostApiFetch } = await import('@/lib/host-api'); - await expect(hostApiFetch('/api/test')).rejects.toThrow('No handler registered for hostapi:fetch'); + const result = await hostApiFetch<{ fallback: boolean }>('/api/test'); + + expect(result.fallback).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:3210/api/test', + expect.objectContaining({ headers: expect.any(Object) }), + ); }); it('throws message from legacy non-ok envelope', async () => { diff --git a/tests/unit/skills-errors.test.ts b/tests/unit/skills-errors.test.ts new file mode 100644 index 000000000..76769c5c9 --- /dev/null +++ b/tests/unit/skills-errors.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiFetchMock = vi.fn(); +const rpcMock = vi.fn(); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: { + getState: () => ({ + rpc: (...args: unknown[]) => rpcMock(...args), + }), + }, +})); + +describe('skills store error mapping', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('maps fetchSkills rate-limit error by AppError code', async () => { + rpcMock.mockResolvedValueOnce({ skills: [] }); + hostApiFetchMock.mockRejectedValueOnce(new Error('rate limit exceeded')); + + const { useSkillsStore } = await import('@/stores/skills'); + await useSkillsStore.getState().fetchSkills(); + + expect(useSkillsStore.getState().error).toBe('fetchRateLimitError'); + }); + + it('maps searchSkills timeout error by AppError code', async () => { + hostApiFetchMock.mockRejectedValueOnce(new Error('request timeout')); + + const { useSkillsStore } = await import('@/stores/skills'); + await useSkillsStore.getState().searchSkills('git'); + + expect(useSkillsStore.getState().searchError).toBe('searchTimeoutError'); + }); + + it('maps installSkill timeout result into installTimeoutError', async () => { + hostApiFetchMock.mockResolvedValueOnce({ success: false, error: 'request timeout' }); + + const { useSkillsStore } = await import('@/stores/skills'); + await expect(useSkillsStore.getState().installSkill('demo-skill')).rejects.toThrow('installTimeoutError'); + }); +}); diff --git a/tests/unit/usage-routes.test.ts b/tests/unit/usage-routes.test.ts new file mode 100644 index 000000000..9e27e9810 --- /dev/null +++ b/tests/unit/usage-routes.test.ts @@ -0,0 +1,53 @@ +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:3210/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:3210/api/usage/recent-token-history?limit=50.9'), + {} as never, + ); + + expect(getRecentTokenUsageHistoryMock).toHaveBeenCalledWith(50); + }); +});