refactor(new merge) (#369)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e28eba01e1
commit
3d664c017a
@@ -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向けにパッケージ化
|
||||
```
|
||||
|
||||
### 技術スタック
|
||||
|
||||
| レイヤー | 技術 |
|
||||
|
||||
58
README.md
58
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 |
|
||||
|
||||
@@ -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 打包
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
@@ -29,6 +32,13 @@ export async function handleProviderRoutes(
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
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<ProviderConfig>; 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);
|
||||
|
||||
@@ -10,8 +10,14 @@ export async function handleUsageRoutes(
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1652,6 +1652,14 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
|
||||
*/
|
||||
function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
const providerService = getProviderService();
|
||||
const legacyProviderChannelsWarned = new Set<string>();
|
||||
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<ProviderConfig>,
|
||||
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);
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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<ProviderDefinition[]> {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
@@ -103,20 +116,21 @@ export class ProviderService {
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = providerConfigToAccount(config, options);
|
||||
await saveProviderAccount(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
await ensureProviderStoreMigrated();
|
||||
const accounts = await listProviderAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() + secret-store based key summary.
|
||||
*/
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
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<ProviderConfig | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = await getProviderAccount(providerId);
|
||||
return account ? providerAccountToConfig(account) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use createAccount()/updateAccount().
|
||||
*/
|
||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||
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<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use setDefaultAccount(accountId).
|
||||
*/
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
|
||||
await this.setDefaultAccount(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getDefaultAccountId().
|
||||
*/
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
|
||||
return this.getDefaultAccountId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
|
||||
return storeApiKey(providerId, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return getApiKey(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
|
||||
return deleteApiKey(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return hasApiKey(providerId);
|
||||
}
|
||||
|
||||
|
||||
178
refactor.md
178
refactor.md
@@ -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.
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||
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<string, unknown>): 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<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
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<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
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<T>(channel: string, ...args: unknown[]): Promise
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw normalizeError(err, {
|
||||
throw normalizeAppError(err, {
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
@@ -1069,7 +1015,7 @@ export async function invokeApi<T>(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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
throw normalizeError(lastError);
|
||||
throw normalizeAppError(lastError);
|
||||
}
|
||||
|
||||
102
src/lib/error-model.ts
Normal file
102
src/lib/error-model.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||
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<string, unknown>): 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);
|
||||
}
|
||||
|
||||
@@ -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<T>(response: Response): Promise<T> {
|
||||
} 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<T>(
|
||||
}
|
||||
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -139,16 +147,18 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
|
||||
return parseLegacyProxyResponse<T>(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<T>(path: string, init?: RequestInit): Promise
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status,
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
try {
|
||||
return await parseResponse<T>(response);
|
||||
} catch (error) {
|
||||
throw normalizeAppError(error, { source: 'browser-fallback', path, method });
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostEventSource(path = '/api/events'): EventSource {
|
||||
|
||||
@@ -61,9 +61,13 @@ export function Dashboard() {
|
||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('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();
|
||||
|
||||
@@ -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<SkillsState>((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<SkillsState>((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<SkillsState>((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<SkillsState>((set, get) => ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
26
tests/unit/error-model.test.ts
Normal file
26
tests/unit/error-model.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
49
tests/unit/skills-errors.test.ts
Normal file
49
tests/unit/skills-errors.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
53
tests/unit/usage-routes.test.ts
Normal file
53
tests/unit/usage-routes.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user