refactor/channel & ipc (#349)
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
8b45960662
commit
e28eba01e1
@@ -31,3 +31,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
|||||||
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
||||||
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
||||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||||
|
- **Renderer/Main API boundary (important)**:
|
||||||
|
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
|
||||||
|
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
|
||||||
|
- Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift.
|
||||||
|
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
|
||||||
|
- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit.
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
|||||||
|
|
||||||
## アーキテクチャ
|
## アーキテクチャ
|
||||||
|
|
||||||
ClawXは、UIの関心事とAIランタイム操作を分離する**デュアルプロセスアーキテクチャ**を採用しています:
|
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@@ -198,18 +198,29 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル
|
|||||||
│ │ • 自動アップデートオーケストレーション │ │
|
│ │ • 自動アップデートオーケストレーション │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ IPC │
|
│ │ IPC(権威ある制御プレーン) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ React レンダラープロセス │ │
|
│ │ React レンダラープロセス │ │
|
||||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||||
│ │ • Zustandによるステート管理 │ │
|
│ │ • Zustandによるステート管理 │ │
|
||||||
│ │ • リアルタイムWebSocket通信 │ │
|
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||||
│ │ • リッチなMarkdownレンダリング │ │
|
│ │ • リッチなMarkdownレンダリング │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ WebSocket (JSON-RPC)
|
│ Main管理のトランスポート戦略
|
||||||
|
│(WS優先、HTTP次点、IPCフォールバック)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Host API と Main プロキシ層 │
|
||||||
|
│ │
|
||||||
|
│ • hostapi:fetch(Mainプロキシ、CORS回避) │
|
||||||
|
│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │
|
||||||
|
│ • 統一エラーマッピングとリトライ/バックオフ │
|
||||||
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ WS / HTTP / IPC フォールバック
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ OpenClaw ゲートウェイ │
|
│ OpenClaw ゲートウェイ │
|
||||||
@@ -224,9 +235,11 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル
|
|||||||
### 設計原則
|
### 設計原則
|
||||||
|
|
||||||
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
|
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
|
||||||
- **グレースフルリカバリ**: 指数バックオフ付きの再接続ロジックが、一時的な障害を自動的に処理します
|
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
|
||||||
|
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
|
||||||
|
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
|
||||||
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
|
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
|
||||||
- **ホットリロード**: 開発モードでは、ゲートウェイを再起動せずにUIの即時更新をサポートします
|
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -187,7 +187,7 @@ Notes:
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
ClawX employs a **dual-process architecture** that separates UI concerns from AI runtime operations:
|
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@@ -201,18 +201,29 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
|
|||||||
│ │ • Auto-update orchestration │ │
|
│ │ • Auto-update orchestration │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ IPC │
|
│ │ IPC (authoritative control plane) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ React Renderer Process │ │
|
│ │ React Renderer Process │ │
|
||||||
│ │ • Modern component-based UI (React 19) │ │
|
│ │ • Modern component-based UI (React 19) │ │
|
||||||
│ │ • State management with Zustand │ │
|
│ │ • State management with Zustand │ │
|
||||||
│ │ • Real-time WebSocket communication │ │
|
│ │ • Unified host-api/api-client calls │ │
|
||||||
│ │ • Rich Markdown rendering │ │
|
│ │ • Rich Markdown rendering │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ WebSocket (JSON-RPC)
|
│ Main-owned transport strategy
|
||||||
|
│ (WS first, HTTP then IPC fallback)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Host API & Main Process Proxies │
|
||||||
|
│ │
|
||||||
|
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │
|
||||||
|
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │
|
||||||
|
│ • Unified error mapping & retry/backoff │
|
||||||
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ WS / HTTP / IPC fallback
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ OpenClaw Gateway │
|
│ OpenClaw Gateway │
|
||||||
@@ -227,9 +238,11 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
|
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
|
||||||
- **Graceful Recovery**: Built-in reconnection logic with exponential backoff handles transient failures automatically
|
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
|
||||||
|
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
|
||||||
|
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
|
||||||
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
|
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
|
||||||
- **Hot Reload**: Development mode supports instant UI updates without restarting the gateway
|
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
|||||||
|
|
||||||
## 系统架构
|
## 系统架构
|
||||||
|
|
||||||
ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@@ -202,18 +202,29 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
|||||||
│ │ • 自动更新编排 │ │
|
│ │ • 自动更新编排 │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ IPC │
|
│ │ IPC(权威控制面) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ React 渲染进程 │ │
|
│ │ React 渲染进程 │ │
|
||||||
│ │ • 现代组件化 UI(React 19) │ │
|
│ │ • 现代组件化 UI(React 19) │ │
|
||||||
│ │ • Zustand 状态管理 │ │
|
│ │ • Zustand 状态管理 │ │
|
||||||
│ │ • WebSocket 实时通信 │ │
|
│ │ • 统一 host-api/api-client 调用 │ │
|
||||||
│ │ • Markdown 富文本渲染 │ │
|
│ │ • Markdown 富文本渲染 │ │
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ WebSocket (JSON-RPC)
|
│ 主进程统一传输策略
|
||||||
|
│(WS 优先,HTTP 次之,IPC 回退)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Host API 与主进程代理层 │
|
||||||
|
│ │
|
||||||
|
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
|
||||||
|
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │
|
||||||
|
│ • 统一错误映射与重试/退避策略 │
|
||||||
|
└──────────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ WS / HTTP / IPC 回退
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ OpenClaw 网关 │
|
│ OpenClaw 网关 │
|
||||||
@@ -228,9 +239,11 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
|||||||
### 设计原则
|
### 设计原则
|
||||||
|
|
||||||
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
|
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
|
||||||
- **优雅恢复**:内置带指数退避的重连逻辑,自动处理瞬时故障
|
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
|
||||||
|
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
|
||||||
|
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
|
||||||
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
|
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
|
||||||
- **热重载**:开发模式支持即时 UI 更新,无需重启网关
|
- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import {
|
import {
|
||||||
deleteApiKey,
|
|
||||||
deleteProvider,
|
|
||||||
getAllProvidersWithKeyInfo,
|
|
||||||
getApiKey,
|
|
||||||
getDefaultProvider,
|
|
||||||
getProvider,
|
|
||||||
hasApiKey,
|
|
||||||
saveProvider,
|
|
||||||
setDefaultProvider,
|
|
||||||
storeApiKey,
|
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
} from '../../utils/secure-storage';
|
} from '../../utils/secure-storage';
|
||||||
import {
|
import {
|
||||||
@@ -135,19 +125,19 @@ export async function handleProviderRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||||
sendJson(res, 200, await getAllProvidersWithKeyInfo());
|
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
||||||
sendJson(res, 200, { providerId: await getDefaultProvider() ?? null });
|
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||||
await setDefaultProvider(body.providerId);
|
await providerService.setDefaultLegacyProvider(body.providerId);
|
||||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -159,7 +149,7 @@ export async function handleProviderRoutes(
|
|||||||
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req);
|
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req);
|
||||||
const provider = await getProvider(body.providerId);
|
const provider = await providerService.getLegacyProvider(body.providerId);
|
||||||
const providerType = provider?.type || body.providerId;
|
const providerType = provider?.type || body.providerId;
|
||||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||||
@@ -211,11 +201,11 @@ export async function handleProviderRoutes(
|
|||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||||
const config = body.config;
|
const config = body.config;
|
||||||
await saveProvider(config);
|
await providerService.saveLegacyProvider(config);
|
||||||
if (body.apiKey !== undefined) {
|
if (body.apiKey !== undefined) {
|
||||||
const trimmedKey = body.apiKey.trim();
|
const trimmedKey = body.apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await storeApiKey(config.id, trimmedKey);
|
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,15 +221,15 @@ export async function handleProviderRoutes(
|
|||||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||||
if (providerId.endsWith('/api-key')) {
|
if (providerId.endsWith('/api-key')) {
|
||||||
const actualId = providerId.slice(0, -('/api-key'.length));
|
const actualId = providerId.slice(0, -('/api-key'.length));
|
||||||
sendJson(res, 200, { apiKey: await getApiKey(actualId) });
|
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (providerId.endsWith('/has-api-key')) {
|
if (providerId.endsWith('/has-api-key')) {
|
||||||
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
||||||
sendJson(res, 200, { hasKey: await hasApiKey(actualId) });
|
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
sendJson(res, 200, await getProvider(providerId));
|
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,20 +237,20 @@ export async function handleProviderRoutes(
|
|||||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
||||||
const existing = await getProvider(providerId);
|
const existing = await providerService.getLegacyProvider(providerId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||||
await saveProvider(nextConfig);
|
await providerService.saveLegacyProvider(nextConfig);
|
||||||
if (body.apiKey !== undefined) {
|
if (body.apiKey !== undefined) {
|
||||||
const trimmedKey = body.apiKey.trim();
|
const trimmedKey = body.apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await storeApiKey(providerId, trimmedKey);
|
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
|
||||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,14 +265,14 @@ export async function handleProviderRoutes(
|
|||||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
||||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||||
try {
|
try {
|
||||||
const existing = await getProvider(providerId);
|
const existing = await providerService.getLegacyProvider(providerId);
|
||||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||||
await deleteApiKey(providerId);
|
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await deleteProvider(providerId);
|
await providerService.deleteLegacyProvider(providerId);
|
||||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
} from '../services/providers/provider-runtime-sync';
|
} from '../services/providers/provider-runtime-sync';
|
||||||
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
||||||
import { appUpdater } from './updater';
|
import { appUpdater } from './updater';
|
||||||
|
import { PORTS } from '../utils/config';
|
||||||
|
|
||||||
type AppRequest = {
|
type AppRequest = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -79,6 +80,7 @@ export function registerIpcHandlers(
|
|||||||
): void {
|
): void {
|
||||||
// Unified request protocol (non-breaking: legacy channels remain available)
|
// Unified request protocol (non-breaking: legacy channels remain available)
|
||||||
registerUnifiedRequestHandlers(gatewayManager);
|
registerUnifiedRequestHandlers(gatewayManager);
|
||||||
|
registerHostApiProxyHandlers();
|
||||||
|
|
||||||
// Gateway handlers
|
// Gateway handlers
|
||||||
registerGatewayHandlers(gatewayManager, mainWindow);
|
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||||
@@ -135,6 +137,68 @@ export function registerIpcHandlers(
|
|||||||
registerFileHandlers();
|
registerFileHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostApiFetchRequest = {
|
||||||
|
path: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerHostApiProxyHandlers(): void {
|
||||||
|
ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => {
|
||||||
|
try {
|
||||||
|
const path = typeof request?.path === 'string' ? request.path : '';
|
||||||
|
if (!path || !path.startsWith('/')) {
|
||||||
|
throw new Error(`Invalid host API path: ${String(request?.path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (request.method || 'GET').toUpperCase();
|
||||||
|
const headers: Record<string, string> = { ...(request.headers || {}) };
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
|
||||||
|
if (request.body !== undefined && request.body !== null) {
|
||||||
|
if (typeof request.body === 'string') {
|
||||||
|
body = request.body;
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify(request.body);
|
||||||
|
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: { status: number; ok: boolean; json?: unknown; text?: string } = {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.status !== 204) {
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
data.json = await response.json().catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
data.text = await response.text().catch(() => '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
|
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
|
||||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
if (msg.includes('timeout')) return 'TIMEOUT';
|
if (msg.includes('timeout')) return 'TIMEOUT';
|
||||||
@@ -156,6 +220,7 @@ function isProxyKey(key: keyof AppSettings): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||||
|
const providerService = getProviderService();
|
||||||
const handleProxySettingsChange = async () => {
|
const handleProxySettingsChange = async () => {
|
||||||
const settings = await getAllSettings();
|
const settings = await getAllSettings();
|
||||||
await applyProxySettings(settings);
|
await applyProxySettings(settings);
|
||||||
@@ -194,32 +259,32 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
}
|
}
|
||||||
case 'provider': {
|
case 'provider': {
|
||||||
if (request.action === 'list') {
|
if (request.action === 'list') {
|
||||||
data = await getAllProvidersWithKeyInfo();
|
data = await providerService.listLegacyProvidersWithKeyInfo();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'get') {
|
if (request.action === 'get') {
|
||||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||||
if (!providerId) throw new Error('Invalid provider.get payload');
|
if (!providerId) throw new Error('Invalid provider.get payload');
|
||||||
data = await getProvider(providerId);
|
data = await providerService.getLegacyProvider(providerId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'getDefault') {
|
if (request.action === 'getDefault') {
|
||||||
data = await getDefaultProvider();
|
data = await providerService.getDefaultLegacyProvider();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'hasApiKey') {
|
if (request.action === 'hasApiKey') {
|
||||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||||
if (!providerId) throw new Error('Invalid provider.hasApiKey payload');
|
if (!providerId) throw new Error('Invalid provider.hasApiKey payload');
|
||||||
data = await hasApiKey(providerId);
|
data = await providerService.hasLegacyProviderApiKey(providerId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'getApiKey') {
|
if (request.action === 'getApiKey') {
|
||||||
const payload = request.payload as { providerId?: string } | string | undefined;
|
const payload = request.payload as { providerId?: string } | string | undefined;
|
||||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||||
if (!providerId) throw new Error('Invalid provider.getApiKey payload');
|
if (!providerId) throw new Error('Invalid provider.getApiKey payload');
|
||||||
data = await getApiKey(providerId);
|
data = await providerService.getLegacyProviderApiKey(providerId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'validateKey') {
|
if (request.action === 'validateKey') {
|
||||||
@@ -234,7 +299,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
throw new Error('Invalid provider.validateKey payload');
|
throw new Error('Invalid provider.validateKey payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await getProvider(providerId);
|
const provider = await providerService.getLegacyProvider(providerId);
|
||||||
const providerType = provider?.type || providerId;
|
const providerType = provider?.type || providerId;
|
||||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||||
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||||
@@ -251,12 +316,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
if (!config) throw new Error('Invalid provider.save payload');
|
if (!config) throw new Error('Invalid provider.save payload');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveProvider(config);
|
await providerService.saveLegacyProvider(config);
|
||||||
|
|
||||||
if (apiKey !== undefined) {
|
if (apiKey !== undefined) {
|
||||||
const trimmedKey = apiKey.trim();
|
const trimmedKey = apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await storeApiKey(config.id, trimmedKey);
|
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +343,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
if (!providerId) throw new Error('Invalid provider.delete payload');
|
if (!providerId) throw new Error('Invalid provider.delete payload');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await getProvider(providerId);
|
const existing = await providerService.getLegacyProvider(providerId);
|
||||||
await deleteProvider(providerId);
|
await providerService.deleteLegacyProvider(providerId);
|
||||||
if (existing?.type) {
|
if (existing?.type) {
|
||||||
try {
|
try {
|
||||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||||
@@ -303,8 +368,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload');
|
if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storeApiKey(providerId, apiKey);
|
await providerService.setLegacyProviderApiKey(providerId, apiKey);
|
||||||
const provider = await getProvider(providerId);
|
const provider = await providerService.getLegacyProvider(providerId);
|
||||||
const providerType = provider?.type || providerId;
|
const providerType = provider?.type || providerId;
|
||||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||||
try {
|
try {
|
||||||
@@ -328,13 +393,13 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey;
|
const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey;
|
||||||
if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload');
|
if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload');
|
||||||
|
|
||||||
const existing = await getProvider(providerId);
|
const existing = await providerService.getLegacyProvider(providerId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
data = { success: false, error: 'Provider not found' };
|
data = { success: false, error: 'Provider not found' };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousKey = await getApiKey(providerId);
|
const previousKey = await providerService.getLegacyProviderApiKey(providerId);
|
||||||
const previousOck = getOpenClawProviderKey(existing.type, providerId);
|
const previousOck = getOpenClawProviderKey(existing.type, providerId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -344,15 +409,15 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
|
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
|
||||||
await saveProvider(nextConfig);
|
await providerService.saveLegacyProvider(nextConfig);
|
||||||
|
|
||||||
if (apiKey !== undefined) {
|
if (apiKey !== undefined) {
|
||||||
const trimmedKey = apiKey.trim();
|
const trimmedKey = apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await storeApiKey(providerId, trimmedKey);
|
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
|
||||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||||
await removeProviderFromOpenClaw(ock);
|
await removeProviderFromOpenClaw(ock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,12 +431,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
data = { success: true };
|
data = { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await saveProvider(existing);
|
await providerService.saveLegacyProvider(existing);
|
||||||
if (previousKey) {
|
if (previousKey) {
|
||||||
await storeApiKey(providerId, previousKey);
|
await providerService.setLegacyProviderApiKey(providerId, previousKey);
|
||||||
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||||
await removeProviderFromOpenClaw(previousOck);
|
await removeProviderFromOpenClaw(previousOck);
|
||||||
}
|
}
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
@@ -387,8 +452,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
|
||||||
if (!providerId) throw new Error('Invalid provider.deleteApiKey payload');
|
if (!providerId) throw new Error('Invalid provider.deleteApiKey payload');
|
||||||
try {
|
try {
|
||||||
await deleteApiKey(providerId);
|
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||||
const provider = await getProvider(providerId);
|
const provider = await providerService.getLegacyProvider(providerId);
|
||||||
const providerType = provider?.type || providerId;
|
const providerType = provider?.type || providerId;
|
||||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||||
try {
|
try {
|
||||||
@@ -410,8 +475,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
if (!providerId) throw new Error('Invalid provider.setDefault payload');
|
if (!providerId) throw new Error('Invalid provider.setDefault payload');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDefaultProvider(providerId);
|
await providerService.setDefaultLegacyProvider(providerId);
|
||||||
const provider = await getProvider(providerId);
|
const provider = await providerService.getLegacyProvider(providerId);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
try {
|
try {
|
||||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||||
|
|||||||
48
electron/main/provider-model-sync.ts
Normal file
48
electron/main/provider-model-sync.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { getProviderConfig } from '../utils/provider-registry';
|
||||||
|
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
|
||||||
|
import type { ProviderConfig } from '../utils/secure-storage';
|
||||||
|
|
||||||
|
export interface AgentProviderUpdatePayload {
|
||||||
|
providerKey: string;
|
||||||
|
entry: {
|
||||||
|
baseUrl: string;
|
||||||
|
api: string;
|
||||||
|
apiKey: string | undefined;
|
||||||
|
models: Array<{ id: string; name: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelIdFromRef(modelRef: string | undefined, providerKey: string): string | undefined {
|
||||||
|
if (!modelRef) return undefined;
|
||||||
|
if (modelRef.startsWith(`${providerKey}/`)) {
|
||||||
|
return modelRef.slice(providerKey.length + 1);
|
||||||
|
}
|
||||||
|
return modelRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNonOAuthAgentProviderUpdate(
|
||||||
|
provider: ProviderConfig,
|
||||||
|
providerId: string,
|
||||||
|
modelRef: string | undefined
|
||||||
|
): AgentProviderUpdatePayload | null {
|
||||||
|
if (provider.type === 'custom' || provider.type === 'ollama' || isOAuthProviderType(provider.type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerKey = getOpenClawProviderKeyForType(provider.type, providerId);
|
||||||
|
const meta = getProviderConfig(provider.type);
|
||||||
|
const baseUrl = provider.baseUrl || meta?.baseUrl;
|
||||||
|
const api = meta?.api;
|
||||||
|
if (!baseUrl || !api) return null;
|
||||||
|
|
||||||
|
const modelId = getModelIdFromRef(modelRef, providerKey);
|
||||||
|
return {
|
||||||
|
providerKey,
|
||||||
|
entry: {
|
||||||
|
baseUrl,
|
||||||
|
api,
|
||||||
|
apiKey: meta?.apiKeyEnv,
|
||||||
|
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const electronAPI = {
|
|||||||
'gateway:restart',
|
'gateway:restart',
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'gateway:httpProxy',
|
'gateway:httpProxy',
|
||||||
|
'hostapi:fetch',
|
||||||
'gateway:health',
|
'gateway:health',
|
||||||
'gateway:getControlUiUrl',
|
'gateway:getControlUiUrl',
|
||||||
// OpenClaw
|
// OpenClaw
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import { logger } from '../../utils/logger';
|
|||||||
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
||||||
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
||||||
|
|
||||||
|
type RuntimeProviderSyncContext = {
|
||||||
|
runtimeProviderKey: string;
|
||||||
|
meta: ReturnType<typeof getProviderConfig>;
|
||||||
|
api: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function getOpenClawProviderKey(type: string, providerId: string): string {
|
export function getOpenClawProviderKey(type: string, providerId: string): string {
|
||||||
if (type === 'custom' || type === 'ollama') {
|
if (type === 'custom' || type === 'ollama') {
|
||||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||||
@@ -172,63 +178,119 @@ export async function syncAllProviderAuthToRuntime(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSavedProviderToRuntime(
|
async function syncProviderSecretToRuntime(
|
||||||
config: ProviderConfig,
|
config: ProviderConfig,
|
||||||
|
runtimeProviderKey: string,
|
||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
gatewayManager?: GatewayManager,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ock = await resolveRuntimeProviderKey(config);
|
|
||||||
const secret = await getProviderSecret(config.id);
|
const secret = await getProviderSecret(config.id);
|
||||||
|
|
||||||
if (apiKey !== undefined) {
|
if (apiKey !== undefined) {
|
||||||
const trimmedKey = apiKey.trim();
|
const trimmedKey = apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
|
||||||
}
|
}
|
||||||
} else if (secret?.type === 'api_key') {
|
return;
|
||||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
}
|
||||||
} else if (secret?.type === 'oauth') {
|
|
||||||
await saveOAuthTokenToOpenClaw(ock, {
|
if (secret?.type === 'api_key') {
|
||||||
|
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret?.type === 'oauth') {
|
||||||
|
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
|
||||||
access: secret.accessToken,
|
access: secret.accessToken,
|
||||||
refresh: secret.refreshToken,
|
refresh: secret.refreshToken,
|
||||||
expires: secret.expiresAt,
|
expires: secret.expiresAt,
|
||||||
email: secret.email,
|
email: secret.email,
|
||||||
projectId: secret.subject,
|
projectId: secret.subject,
|
||||||
});
|
});
|
||||||
} else if (secret?.type === 'local' && secret.apiKey) {
|
|
||||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = getProviderConfig(config.type);
|
|
||||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
if (secret?.type === 'local' && secret.apiKey) {
|
||||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||||
api,
|
}
|
||||||
apiKeyEnv: meta?.apiKeyEnv,
|
}
|
||||||
headers: meta?.headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.type === 'custom' || config.type === 'ollama') {
|
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
|
||||||
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
|
||||||
if (resolvedKey && config.baseUrl) {
|
const meta = getProviderConfig(config.type);
|
||||||
const modelId = config.model;
|
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||||
await updateAgentModelProvider(ock, {
|
if (!api) {
|
||||||
baseUrl: config.baseUrl,
|
return null;
|
||||||
api: 'openai-completions',
|
}
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
|
||||||
apiKey: resolvedKey,
|
return {
|
||||||
});
|
runtimeProviderKey,
|
||||||
}
|
meta,
|
||||||
|
api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRuntimeProviderConfig(
|
||||||
|
config: ProviderConfig,
|
||||||
|
context: RuntimeProviderSyncContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
|
||||||
|
baseUrl: config.baseUrl || context.meta?.baseUrl,
|
||||||
|
api: context.api,
|
||||||
|
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||||
|
headers: context.meta?.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCustomProviderAgentModel(
|
||||||
|
config: ProviderConfig,
|
||||||
|
runtimeProviderKey: string,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (config.type !== 'custom' && config.type !== 'ollama') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
||||||
|
if (!resolvedKey || !config.baseUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = config.model;
|
||||||
|
await updateAgentModelProvider(runtimeProviderKey, {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
api: 'openai-completions',
|
||||||
|
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||||
|
apiKey: resolvedKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProviderToRuntime(
|
||||||
|
config: ProviderConfig,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
): Promise<RuntimeProviderSyncContext | null> {
|
||||||
|
const context = await resolveRuntimeSyncContext(config);
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncProviderSecretToRuntime(config, context.runtimeProviderKey, apiKey);
|
||||||
|
await syncRuntimeProviderConfig(config, context);
|
||||||
|
await syncCustomProviderAgentModel(config, context.runtimeProviderKey, apiKey);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSavedProviderToRuntime(
|
||||||
|
config: ProviderConfig,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
gatewayManager?: GatewayManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const context = await syncProviderToRuntime(config, apiKey);
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleGatewayRestart(
|
scheduleGatewayRestart(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway restart after saving provider "${ock}" config`,
|
`Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,54 +299,13 @@ export async function syncUpdatedProviderToRuntime(
|
|||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
gatewayManager?: GatewayManager,
|
gatewayManager?: GatewayManager,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ock = await resolveRuntimeProviderKey(config);
|
const context = await syncProviderToRuntime(config, apiKey);
|
||||||
const fallbackModels = await getProviderFallbackModelRefs(config);
|
if (!context) {
|
||||||
const meta = getProviderConfig(config.type);
|
|
||||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
|
||||||
const secret = await getProviderSecret(config.id);
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey !== undefined) {
|
const ock = context.runtimeProviderKey;
|
||||||
const trimmedKey = apiKey.trim();
|
const fallbackModels = await getProviderFallbackModelRefs(config);
|
||||||
if (trimmedKey) {
|
|
||||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
|
||||||
}
|
|
||||||
} else if (secret?.type === 'api_key') {
|
|
||||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
|
||||||
} else if (secret?.type === 'oauth') {
|
|
||||||
await saveOAuthTokenToOpenClaw(ock, {
|
|
||||||
access: secret.accessToken,
|
|
||||||
refresh: secret.refreshToken,
|
|
||||||
expires: secret.expiresAt,
|
|
||||||
email: secret.email,
|
|
||||||
projectId: secret.subject,
|
|
||||||
});
|
|
||||||
} else if (secret?.type === 'local' && secret.apiKey) {
|
|
||||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
|
||||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
|
||||||
api,
|
|
||||||
apiKeyEnv: meta?.apiKeyEnv,
|
|
||||||
headers: meta?.headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.type === 'custom' || config.type === 'ollama') {
|
|
||||||
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
|
||||||
if (resolvedKey && config.baseUrl) {
|
|
||||||
const modelId = config.model;
|
|
||||||
await updateAgentModelProvider(ock, {
|
|
||||||
baseUrl: config.baseUrl,
|
|
||||||
api: 'openai-completions',
|
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
|
||||||
apiKey: resolvedKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProviderId = await getDefaultProvider();
|
const defaultProviderId = await getDefaultProvider();
|
||||||
if (defaultProviderId === config.id) {
|
if (defaultProviderId === config.id) {
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteApiKey,
|
deleteApiKey,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
getAllProviders,
|
|
||||||
getAllProvidersWithKeyInfo,
|
|
||||||
getApiKey,
|
getApiKey,
|
||||||
getDefaultProvider,
|
|
||||||
getProvider,
|
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
saveProvider,
|
saveProvider,
|
||||||
setDefaultProvider,
|
setDefaultProvider,
|
||||||
@@ -32,6 +28,14 @@ import {
|
|||||||
} from '../../utils/secure-storage';
|
} from '../../utils/secure-storage';
|
||||||
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||||
|
|
||||||
|
function maskApiKey(apiKey: string | null): string | null {
|
||||||
|
if (!apiKey) return null;
|
||||||
|
if (apiKey.length > 12) {
|
||||||
|
return `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
|
||||||
|
}
|
||||||
|
return '*'.repeat(apiKey.length);
|
||||||
|
}
|
||||||
|
|
||||||
export class ProviderService {
|
export class ProviderService {
|
||||||
async listVendors(): Promise<ProviderDefinition[]> {
|
async listVendors(): Promise<ProviderDefinition[]> {
|
||||||
return PROVIDER_DEFINITIONS;
|
return PROVIDER_DEFINITIONS;
|
||||||
@@ -49,7 +53,7 @@ export class ProviderService {
|
|||||||
|
|
||||||
async getDefaultAccountId(): Promise<string | undefined> {
|
async getDefaultAccountId(): Promise<string | undefined> {
|
||||||
await ensureProviderStoreMigrated();
|
await ensureProviderStoreMigrated();
|
||||||
return (await getDefaultProvider()) ?? getDefaultProviderAccountId();
|
return getDefaultProviderAccountId();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
||||||
@@ -107,31 +111,54 @@ export class ProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||||
return getAllProviders();
|
await ensureProviderStoreMigrated();
|
||||||
|
const accounts = await listProviderAccounts();
|
||||||
|
return accounts.map(providerAccountToConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||||
return getAllProvidersWithKeyInfo();
|
const providers = await this.listLegacyProviders();
|
||||||
|
const results: ProviderWithKeyInfo[] = [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
const apiKey = await getApiKey(provider.id);
|
||||||
|
results.push({
|
||||||
|
...provider,
|
||||||
|
hasKey: !!apiKey,
|
||||||
|
keyMasked: maskApiKey(apiKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||||
return getProvider(providerId);
|
await ensureProviderStoreMigrated();
|
||||||
|
const account = await getProviderAccount(providerId);
|
||||||
|
return account ? providerAccountToConfig(account) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||||
await saveProvider(config);
|
await ensureProviderStoreMigrated();
|
||||||
|
const account = providerConfigToAccount(config);
|
||||||
|
const existing = await getProviderAccount(config.id);
|
||||||
|
if (existing) {
|
||||||
|
await this.updateAccount(config.id, account);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.createAccount(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
||||||
return deleteProvider(providerId);
|
await ensureProviderStoreMigrated();
|
||||||
|
await this.deleteAccount(providerId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||||
await setDefaultProvider(providerId);
|
await this.setDefaultAccount(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||||
return getDefaultProvider();
|
return this.getDefaultAccountId();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -386,21 +386,14 @@ export async function setOpenClawDefaultModel(
|
|||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
const rawModel = modelOverride || getProviderDefaultModel(provider);
|
const model = normalizeModelRef(provider, modelOverride);
|
||||||
const model = rawModel
|
|
||||||
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
|
|
||||||
: undefined;
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
console.warn(`No default model mapping for provider "${provider}"`);
|
console.warn(`No default model mapping for provider "${provider}"`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelId = model.startsWith(`${provider}/`)
|
const modelId = extractModelId(provider, model);
|
||||||
? model.slice(provider.length + 1)
|
const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
|
||||||
: model;
|
|
||||||
const fallbackModelIds = fallbackModels
|
|
||||||
.filter((fallback) => fallback.startsWith(`${provider}/`))
|
|
||||||
.map((fallback) => fallback.slice(provider.length + 1));
|
|
||||||
|
|
||||||
// Set the default model for the agents
|
// Set the default model for the agents
|
||||||
const agents = (config.agents || {}) as Record<string, unknown>;
|
const agents = (config.agents || {}) as Record<string, unknown>;
|
||||||
@@ -415,51 +408,16 @@ export async function setOpenClawDefaultModel(
|
|||||||
// Configure models.providers for providers that need explicit registration.
|
// Configure models.providers for providers that need explicit registration.
|
||||||
const providerCfg = getProviderConfig(provider);
|
const providerCfg = getProviderConfig(provider);
|
||||||
if (providerCfg) {
|
if (providerCfg) {
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
upsertOpenClawProviderEntry(config, provider, {
|
||||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
|
||||||
const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers);
|
|
||||||
|
|
||||||
const existingProvider =
|
|
||||||
providers[provider] && typeof providers[provider] === 'object'
|
|
||||||
? (providers[provider] as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const existingModels = Array.isArray(existingProvider.models)
|
|
||||||
? (existingProvider.models as Array<Record<string, unknown>>)
|
|
||||||
: [];
|
|
||||||
const registryModels = (providerCfg.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>;
|
|
||||||
|
|
||||||
const mergedModels = [...registryModels];
|
|
||||||
for (const item of existingModels) {
|
|
||||||
const id = typeof item?.id === 'string' ? item.id : '';
|
|
||||||
if (id && !mergedModels.some((m) => m.id === id)) {
|
|
||||||
mergedModels.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const candidateModelId of [modelId, ...fallbackModelIds]) {
|
|
||||||
if (candidateModelId && !mergedModels.some((m) => m.id === candidateModelId)) {
|
|
||||||
mergedModels.push({ id: candidateModelId, name: candidateModelId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerEntry: Record<string, unknown> = {
|
|
||||||
...existingProvider,
|
|
||||||
baseUrl: providerCfg.baseUrl,
|
baseUrl: providerCfg.baseUrl,
|
||||||
api: providerCfg.api,
|
api: providerCfg.api,
|
||||||
apiKey: providerCfg.apiKeyEnv,
|
apiKeyEnv: providerCfg.apiKeyEnv,
|
||||||
models: mergedModels,
|
headers: providerCfg.headers,
|
||||||
};
|
modelIds: [modelId, ...fallbackModelIds],
|
||||||
if (providerCfg.headers && Object.keys(providerCfg.headers).length > 0) {
|
includeRegistryModels: true,
|
||||||
providerEntry.headers = providerCfg.headers;
|
mergeExistingModels: true,
|
||||||
}
|
});
|
||||||
providers[provider] = providerEntry;
|
|
||||||
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
|
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
|
||||||
if (removedLegacyMoonshot) {
|
|
||||||
console.log('Removed legacy models.providers.moonshot alias entry');
|
|
||||||
}
|
|
||||||
|
|
||||||
models.providers = providers;
|
|
||||||
config.models = models;
|
|
||||||
} else {
|
} else {
|
||||||
// Built-in provider: remove any stale models.providers entry
|
// Built-in provider: remove any stale models.providers entry
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
@@ -489,6 +447,99 @@ interface RuntimeProviderConfigOverride {
|
|||||||
authHeader?: boolean;
|
authHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderEntryBuildOptions = {
|
||||||
|
baseUrl: string;
|
||||||
|
api: string;
|
||||||
|
apiKeyEnv?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
authHeader?: boolean;
|
||||||
|
modelIds?: string[];
|
||||||
|
includeRegistryModels?: boolean;
|
||||||
|
mergeExistingModels?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeModelRef(provider: string, modelOverride?: string): string | undefined {
|
||||||
|
const rawModel = modelOverride || getProviderDefaultModel(provider);
|
||||||
|
if (!rawModel) return undefined;
|
||||||
|
return rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractModelId(provider: string, modelRef: string): string {
|
||||||
|
return modelRef.startsWith(`${provider}/`) ? modelRef.slice(provider.length + 1) : modelRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFallbackModelIds(provider: string, fallbackModels: string[]): string[] {
|
||||||
|
return fallbackModels
|
||||||
|
.filter((fallback) => fallback.startsWith(`${provider}/`))
|
||||||
|
.map((fallback) => fallback.slice(provider.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProviderModels(
|
||||||
|
...groups: Array<Array<Record<string, unknown>>>
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
|
const merged: Array<Record<string, unknown>> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const item of group) {
|
||||||
|
const id = typeof item?.id === 'string' ? item.id : '';
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
merged.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertOpenClawProviderEntry(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
provider: string,
|
||||||
|
options: ProviderEntryBuildOptions,
|
||||||
|
): void {
|
||||||
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
|
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||||
|
const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers);
|
||||||
|
const existingProvider = (
|
||||||
|
providers[provider] && typeof providers[provider] === 'object'
|
||||||
|
? (providers[provider] as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingModels = options.mergeExistingModels && Array.isArray(existingProvider.models)
|
||||||
|
? (existingProvider.models as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const registryModels = options.includeRegistryModels
|
||||||
|
? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const runtimeModels = (options.modelIds ?? []).map((id) => ({ id, name: id }));
|
||||||
|
|
||||||
|
const nextProvider: Record<string, unknown> = {
|
||||||
|
...existingProvider,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
api: options.api,
|
||||||
|
models: mergeProviderModels(registryModels, existingModels, runtimeModels),
|
||||||
|
};
|
||||||
|
if (options.apiKeyEnv) nextProvider.apiKey = options.apiKeyEnv;
|
||||||
|
if (options.headers && Object.keys(options.headers).length > 0) {
|
||||||
|
nextProvider.headers = options.headers;
|
||||||
|
} else {
|
||||||
|
delete nextProvider.headers;
|
||||||
|
}
|
||||||
|
if (options.authHeader !== undefined) {
|
||||||
|
nextProvider.authHeader = options.authHeader;
|
||||||
|
} else {
|
||||||
|
delete nextProvider.authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
providers[provider] = nextProvider;
|
||||||
|
models.providers = providers;
|
||||||
|
config.models = models;
|
||||||
|
|
||||||
|
if (removedLegacyMoonshot) {
|
||||||
|
console.log('Removed legacy models.providers.moonshot alias entry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeLegacyMoonshotProviderEntry(
|
function removeLegacyMoonshotProviderEntry(
|
||||||
_provider: string,
|
_provider: string,
|
||||||
_providers: Record<string, unknown>
|
_providers: Record<string, unknown>
|
||||||
@@ -528,26 +579,13 @@ export async function syncProviderConfigToOpenClaw(
|
|||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
if (override.baseUrl && override.api) {
|
if (override.baseUrl && override.api) {
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
upsertOpenClawProviderEntry(config, provider, {
|
||||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
|
||||||
removeLegacyMoonshotProviderEntry(provider, providers);
|
|
||||||
|
|
||||||
const nextModels: Array<Record<string, unknown>> = [];
|
|
||||||
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
|
||||||
|
|
||||||
const nextProvider: Record<string, unknown> = {
|
|
||||||
baseUrl: override.baseUrl,
|
baseUrl: override.baseUrl,
|
||||||
api: override.api,
|
api: override.api,
|
||||||
models: nextModels,
|
apiKeyEnv: override.apiKeyEnv,
|
||||||
};
|
headers: override.headers,
|
||||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
modelIds: modelId ? [modelId] : [],
|
||||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
});
|
||||||
nextProvider.headers = override.headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
providers[provider] = nextProvider;
|
|
||||||
models.providers = providers;
|
|
||||||
config.models = models;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
||||||
@@ -580,21 +618,14 @@ export async function setOpenClawDefaultModelWithOverride(
|
|||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
const rawModel = modelOverride || getProviderDefaultModel(provider);
|
const model = normalizeModelRef(provider, modelOverride);
|
||||||
const model = rawModel
|
|
||||||
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
|
|
||||||
: undefined;
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
console.warn(`No default model mapping for provider "${provider}"`);
|
console.warn(`No default model mapping for provider "${provider}"`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelId = model.startsWith(`${provider}/`)
|
const modelId = extractModelId(provider, model);
|
||||||
? model.slice(provider.length + 1)
|
const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
|
||||||
: model;
|
|
||||||
const fallbackModelIds = fallbackModels
|
|
||||||
.filter((fallback) => fallback.startsWith(`${provider}/`))
|
|
||||||
.map((fallback) => fallback.slice(provider.length + 1));
|
|
||||||
|
|
||||||
const agents = (config.agents || {}) as Record<string, unknown>;
|
const agents = (config.agents || {}) as Record<string, unknown>;
|
||||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||||
@@ -606,33 +637,14 @@ export async function setOpenClawDefaultModelWithOverride(
|
|||||||
config.agents = agents;
|
config.agents = agents;
|
||||||
|
|
||||||
if (override.baseUrl && override.api) {
|
if (override.baseUrl && override.api) {
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
upsertOpenClawProviderEntry(config, provider, {
|
||||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
|
||||||
removeLegacyMoonshotProviderEntry(provider, providers);
|
|
||||||
|
|
||||||
const nextModels: Array<Record<string, unknown>> = [];
|
|
||||||
for (const candidateModelId of [modelId, ...fallbackModelIds]) {
|
|
||||||
if (candidateModelId && !nextModels.some((entry) => entry.id === candidateModelId)) {
|
|
||||||
nextModels.push({ id: candidateModelId, name: candidateModelId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProvider: Record<string, unknown> = {
|
|
||||||
baseUrl: override.baseUrl,
|
baseUrl: override.baseUrl,
|
||||||
api: override.api,
|
api: override.api,
|
||||||
models: nextModels,
|
apiKeyEnv: override.apiKeyEnv,
|
||||||
};
|
headers: override.headers,
|
||||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
authHeader: override.authHeader,
|
||||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
modelIds: [modelId, ...fallbackModelIds],
|
||||||
nextProvider.headers = override.headers;
|
});
|
||||||
}
|
|
||||||
if (override.authHeader !== undefined) {
|
|
||||||
nextProvider.authHeader = override.authHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
providers[provider] = nextProvider;
|
|
||||||
models.providers = providers;
|
|
||||||
config.models = models;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export interface AppSettings {
|
|||||||
proxyHttpsServer: string;
|
proxyHttpsServer: string;
|
||||||
proxyAllServer: string;
|
proxyAllServer: string;
|
||||||
proxyBypassRules: string;
|
proxyBypassRules: string;
|
||||||
gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: 'stable' | 'beta' | 'dev';
|
updateChannel: 'stable' | 'beta' | 'dev';
|
||||||
@@ -74,7 +73,6 @@ const defaults: AppSettings = {
|
|||||||
proxyHttpsServer: '',
|
proxyHttpsServer: '',
|
||||||
proxyAllServer: '',
|
proxyAllServer: '',
|
||||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||||
gatewayTransportPreference: 'ws-first',
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: 'stable',
|
updateChannel: 'stable',
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export default [
|
|||||||
selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']",
|
selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']",
|
||||||
message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.',
|
message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.name='fetch'] Literal[value=/^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?\\//]",
|
||||||
|
message: 'Do not call local endpoints directly from renderer. Route through host-api/api-client proxies.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
35
refactor.md
35
refactor.md
@@ -141,3 +141,38 @@ This branch captures local refactors focused on frontend UX polish, IPC call con
|
|||||||
- explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback.
|
- explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback.
|
||||||
- Removed forced uppercase rendering for bucket headers to preserve localized casing.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ function App() {
|
|||||||
const initSettings = useSettingsStore((state) => state.init);
|
const initSettings = useSettingsStore((state) => state.init);
|
||||||
const theme = useSettingsStore((state) => state.theme);
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
const language = useSettingsStore((state) => state.language);
|
const language = useSettingsStore((state) => state.language);
|
||||||
const gatewayTransportPreference = useSettingsStore((state) => state.gatewayTransportPreference);
|
|
||||||
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
||||||
const initGateway = useGatewayStore((state) => state.init);
|
const initGateway = useGatewayStore((state) => state.init);
|
||||||
|
|
||||||
@@ -152,8 +151,8 @@ function App() {
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyGatewayTransportPreference(gatewayTransportPreference);
|
applyGatewayTransportPreference();
|
||||||
}, [gatewayTransportPreference]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
"restart": "Restart"
|
"restart": "Restart",
|
||||||
|
"show": "Show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"clear": "Clear"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
|
|||||||
@@ -207,6 +207,19 @@
|
|||||||
"cliPowershell": "PowerShell command.",
|
"cliPowershell": "PowerShell command.",
|
||||||
"cmdUnavailable": "Command unavailable",
|
"cmdUnavailable": "Command unavailable",
|
||||||
"cmdCopied": "CLI command copied",
|
"cmdCopied": "CLI command copied",
|
||||||
|
"wsDiagnostic": "WS Diagnostic Mode",
|
||||||
|
"wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.",
|
||||||
|
"wsDiagnosticEnabled": "WS diagnostic mode enabled",
|
||||||
|
"wsDiagnosticDisabled": "WS diagnostic mode disabled",
|
||||||
|
"telemetryViewer": "Telemetry Viewer",
|
||||||
|
"telemetryViewerDesc": "Local-only UX/performance telemetry, latest 200 entries.",
|
||||||
|
"telemetryAggregated": "Top Events",
|
||||||
|
"telemetryTotal": "Total",
|
||||||
|
"telemetryErrors": "Errors",
|
||||||
|
"telemetrySlow": "Slow (>=800ms)",
|
||||||
|
"telemetryEmpty": "No telemetry yet",
|
||||||
|
"telemetryCopied": "Telemetry copied",
|
||||||
|
"telemetryCleared": "Telemetry cleared",
|
||||||
"installCmd": "Install \"openclaw\" Command",
|
"installCmd": "Install \"openclaw\" Command",
|
||||||
"installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)",
|
"installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)",
|
||||||
"installTitle": "Install OpenClaw Command",
|
"installTitle": "Install OpenClaw Command",
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
"back": "戻る",
|
"back": "戻る",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
"skip": "スキップ",
|
"skip": "スキップ",
|
||||||
"restart": "再起動"
|
"restart": "再起動",
|
||||||
|
"show": "表示",
|
||||||
|
"hide": "非表示",
|
||||||
|
"clear": "クリア"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"running": "実行中",
|
"running": "実行中",
|
||||||
|
|||||||
@@ -206,6 +206,19 @@
|
|||||||
"cliPowershell": "PowerShell コマンド。",
|
"cliPowershell": "PowerShell コマンド。",
|
||||||
"cmdUnavailable": "コマンドが利用できません",
|
"cmdUnavailable": "コマンドが利用できません",
|
||||||
"cmdCopied": "CLI コマンドをコピーしました",
|
"cmdCopied": "CLI コマンドをコピーしました",
|
||||||
|
"wsDiagnostic": "WS 診断モード",
|
||||||
|
"wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。",
|
||||||
|
"wsDiagnosticEnabled": "WS 診断モードを有効化しました",
|
||||||
|
"wsDiagnosticDisabled": "WS 診断モードを無効化しました",
|
||||||
|
"telemetryViewer": "テレメトリビューア",
|
||||||
|
"telemetryViewerDesc": "ローカル専用の UX/性能テレメトリ(最新 200 件)。",
|
||||||
|
"telemetryAggregated": "イベント集計",
|
||||||
|
"telemetryTotal": "合計",
|
||||||
|
"telemetryErrors": "エラー",
|
||||||
|
"telemetrySlow": "遅延(>=800ms)",
|
||||||
|
"telemetryEmpty": "テレメトリはまだありません",
|
||||||
|
"telemetryCopied": "テレメトリをコピーしました",
|
||||||
|
"telemetryCleared": "テレメトリをクリアしました",
|
||||||
"installCmd": "\"openclaw\" コマンドをインストール",
|
"installCmd": "\"openclaw\" コマンドをインストール",
|
||||||
"installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)",
|
"installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)",
|
||||||
"installTitle": "OpenClaw コマンドをインストール",
|
"installTitle": "OpenClaw コマンドをインストール",
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
"back": "返回",
|
"back": "返回",
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
"skip": "跳过",
|
"skip": "跳过",
|
||||||
"restart": "重启"
|
"restart": "重启",
|
||||||
|
"show": "显示",
|
||||||
|
"hide": "隐藏",
|
||||||
|
"clear": "清空"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"running": "运行中",
|
"running": "运行中",
|
||||||
|
|||||||
@@ -207,6 +207,19 @@
|
|||||||
"cliPowershell": "PowerShell 命令。",
|
"cliPowershell": "PowerShell 命令。",
|
||||||
"cmdUnavailable": "命令不可用",
|
"cmdUnavailable": "命令不可用",
|
||||||
"cmdCopied": "CLI 命令已复制",
|
"cmdCopied": "CLI 命令已复制",
|
||||||
|
"wsDiagnostic": "WS 诊断模式",
|
||||||
|
"wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。",
|
||||||
|
"wsDiagnosticEnabled": "已启用 WS 诊断模式",
|
||||||
|
"wsDiagnosticDisabled": "已关闭 WS 诊断模式",
|
||||||
|
"telemetryViewer": "埋点查看器",
|
||||||
|
"telemetryViewerDesc": "仅本地 UX/性能埋点,显示最近 200 条。",
|
||||||
|
"telemetryAggregated": "事件聚合",
|
||||||
|
"telemetryTotal": "总数",
|
||||||
|
"telemetryErrors": "错误",
|
||||||
|
"telemetrySlow": "慢请求(>=800ms)",
|
||||||
|
"telemetryEmpty": "暂无埋点",
|
||||||
|
"telemetryCopied": "埋点已复制",
|
||||||
|
"telemetryCleared": "埋点已清空",
|
||||||
"installCmd": "安装 \"openclaw\" 命令",
|
"installCmd": "安装 \"openclaw\" 命令",
|
||||||
"installCmdDesc": "安装 ~/.local/bin/openclaw(无需管理员权限)",
|
"installCmdDesc": "安装 ~/.local/bin/openclaw(无需管理员权限)",
|
||||||
"installTitle": "安装 OpenClaw 命令",
|
"installTitle": "安装 OpenClaw 命令",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { trackUiEvent } from './telemetry';
|
||||||
|
|
||||||
export type AppErrorCode =
|
export type AppErrorCode =
|
||||||
| 'TIMEOUT'
|
| 'TIMEOUT'
|
||||||
| 'RATE_LIMIT'
|
| 'RATE_LIMIT'
|
||||||
@@ -8,12 +10,7 @@ export type AppErrorCode =
|
|||||||
| 'UNKNOWN';
|
| 'UNKNOWN';
|
||||||
|
|
||||||
export type TransportKind = 'ipc' | 'ws' | 'http';
|
export type TransportKind = 'ipc' | 'ws' | 'http';
|
||||||
export type GatewayTransportPreference =
|
export type GatewayTransportPreference = 'ws-first';
|
||||||
| 'ws-first'
|
|
||||||
| 'http-first'
|
|
||||||
| 'ws-only'
|
|
||||||
| 'http-only'
|
|
||||||
| 'ipc-only';
|
|
||||||
type TransportInvoker = <T>(channel: string, args: unknown[]) => Promise<T>;
|
type TransportInvoker = <T>(channel: string, args: unknown[]) => Promise<T>;
|
||||||
type TransportRequest = { channel: string; args: unknown[] };
|
type TransportRequest = { channel: string; args: unknown[] };
|
||||||
|
|
||||||
@@ -90,6 +87,7 @@ const UNIFIED_CHANNELS = new Set<string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const customInvokers = new Map<Exclude<TransportKind, 'ipc'>, TransportInvoker>();
|
const customInvokers = new Map<Exclude<TransportKind, 'ipc'>, TransportInvoker>();
|
||||||
|
const GATEWAY_WS_DIAG_FLAG = 'clawx:gateway-ws-diagnostic';
|
||||||
|
|
||||||
let transportConfig: ApiClientTransportConfig = {
|
let transportConfig: ApiClientTransportConfig = {
|
||||||
enabled: {
|
enabled: {
|
||||||
@@ -136,8 +134,21 @@ type GatewayWsTransportOptions = {
|
|||||||
websocketFactory?: (url: string) => WebSocket;
|
websocketFactory?: (url: string) => WebSocket;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GatewayControlUiResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeGatewayRpcEnvelope(value: unknown): { success: boolean; result?: unknown; error?: string } {
|
||||||
|
if (value && typeof value === 'object' && 'success' in (value as Record<string, unknown>)) {
|
||||||
|
return value as { success: boolean; result?: unknown; error?: string };
|
||||||
|
}
|
||||||
|
return { success: true, result: value };
|
||||||
|
}
|
||||||
|
|
||||||
let cachedGatewayPort: { port: number; expiresAt: number } | null = null;
|
let cachedGatewayPort: { port: number; expiresAt: number } | null = null;
|
||||||
const transportBackoffUntil: Partial<Record<Exclude<TransportKind, 'ipc'>, number>> = {};
|
const transportBackoffUntil: Partial<Record<Exclude<TransportKind, 'ipc'>, number>> = {};
|
||||||
|
const SLOW_REQUEST_THRESHOLD_MS = 800;
|
||||||
|
|
||||||
async function resolveGatewayPort(): Promise<number> {
|
async function resolveGatewayPort(): Promise<number> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -173,11 +184,13 @@ class TransportUnsupportedError extends Error {
|
|||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
code: AppErrorCode;
|
code: AppErrorCode;
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
|
||||||
constructor(code: AppErrorCode, message: string, cause?: unknown) {
|
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||||
super(message);
|
super(message);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.cause = cause;
|
this.cause = cause;
|
||||||
|
this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,30 +211,60 @@ function mapUnifiedErrorCode(code?: string): AppErrorCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeError(err: unknown): AppError {
|
function normalizeError(err: unknown, details?: Record<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 message = err instanceof Error ? err.message : String(err);
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
|
|
||||||
if (lower.includes('timeout')) {
|
if (lower.includes('timeout')) {
|
||||||
return new AppError('TIMEOUT', message, err);
|
return new AppError('TIMEOUT', message, err, details);
|
||||||
}
|
}
|
||||||
if (lower.includes('rate limit')) {
|
if (lower.includes('rate limit')) {
|
||||||
return new AppError('RATE_LIMIT', message, err);
|
return new AppError('RATE_LIMIT', message, err, details);
|
||||||
}
|
}
|
||||||
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
|
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
|
||||||
return new AppError('PERMISSION', message, err);
|
return new AppError('PERMISSION', message, err, details);
|
||||||
}
|
}
|
||||||
if (lower.includes('network') || lower.includes('fetch')) {
|
if (lower.includes('network') || lower.includes('fetch')) {
|
||||||
return new AppError('NETWORK', message, err);
|
return new AppError('NETWORK', message, err, details);
|
||||||
}
|
}
|
||||||
if (lower.includes('gateway')) {
|
if (lower.includes('gateway')) {
|
||||||
return new AppError('GATEWAY', message, err);
|
return new AppError('GATEWAY', message, err, details);
|
||||||
}
|
}
|
||||||
if (lower.includes('config') || lower.includes('invalid')) {
|
if (lower.includes('config') || lower.includes('invalid')) {
|
||||||
return new AppError('CONFIG', message, err);
|
return new AppError('CONFIG', message, err, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AppError('UNKNOWN', message, err);
|
return new AppError('UNKNOWN', message, err, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldLogApiRequests(): boolean {
|
||||||
|
try {
|
||||||
|
return import.meta.env.DEV || window.localStorage.getItem('clawx:api-log') === '1';
|
||||||
|
} catch {
|
||||||
|
return !!import.meta.env.DEV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logApiAttempt(entry: {
|
||||||
|
requestId: string;
|
||||||
|
channel: string;
|
||||||
|
transport: TransportKind;
|
||||||
|
attempt: number;
|
||||||
|
durationMs: number;
|
||||||
|
ok: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}): void {
|
||||||
|
if (!shouldLogApiRequests()) return;
|
||||||
|
const base = `[api-client] id=${entry.requestId} channel=${entry.channel} transport=${entry.transport} attempt=${entry.attempt} durationMs=${entry.durationMs}`;
|
||||||
|
if (entry.ok) {
|
||||||
|
console.info(`${base} result=ok`);
|
||||||
|
} else {
|
||||||
|
console.warn(`${base} result=error`, entry.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRuleMatch(matcher: string | RegExp, channel: string): boolean {
|
function isRuleMatch(matcher: string | RegExp, channel: string): boolean {
|
||||||
@@ -263,53 +306,58 @@ export function clearTransportBackoff(kind?: Exclude<TransportKind, 'ipc'>): voi
|
|||||||
delete transportBackoffUntil.http;
|
delete transportBackoffUntil.http;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayRulesForPreference(preference: GatewayTransportPreference): TransportRule[] {
|
export function applyGatewayTransportPreference(): void {
|
||||||
switch (preference) {
|
const wsDiagnosticEnabled = getGatewayWsDiagnosticEnabled();
|
||||||
case 'http-first':
|
clearTransportBackoff();
|
||||||
return [
|
if (wsDiagnosticEnabled) {
|
||||||
{ matcher: /^gateway:rpc$/, order: ['http', 'ws', 'ipc'] },
|
configureApiClient({
|
||||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
enabled: {
|
||||||
{ matcher: /.*/, order: ['ipc'] },
|
ws: true,
|
||||||
];
|
http: true,
|
||||||
case 'ws-only':
|
},
|
||||||
return [
|
rules: [
|
||||||
{ matcher: /^gateway:rpc$/, order: ['ws', 'ipc'] },
|
|
||||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
|
||||||
{ matcher: /.*/, order: ['ipc'] },
|
|
||||||
];
|
|
||||||
case 'http-only':
|
|
||||||
return [
|
|
||||||
{ matcher: /^gateway:rpc$/, order: ['http', 'ipc'] },
|
|
||||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
|
||||||
{ matcher: /.*/, order: ['ipc'] },
|
|
||||||
];
|
|
||||||
case 'ipc-only':
|
|
||||||
return [
|
|
||||||
{ matcher: /^gateway:rpc$/, order: ['ipc'] },
|
|
||||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
|
||||||
{ matcher: /.*/, order: ['ipc'] },
|
|
||||||
];
|
|
||||||
case 'ws-first':
|
|
||||||
default:
|
|
||||||
return [
|
|
||||||
{ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] },
|
{ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] },
|
||||||
{ matcher: /^gateway:/, order: ['ipc'] },
|
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||||
{ matcher: /.*/, order: ['ipc'] },
|
{ matcher: /.*/, order: ['ipc'] },
|
||||||
];
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability-first default:
|
||||||
|
// keep IPC as the authoritative runtime path.
|
||||||
|
configureApiClient({
|
||||||
|
enabled: {
|
||||||
|
ws: false,
|
||||||
|
http: false,
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{ matcher: /^gateway:rpc$/, order: ['ipc'] },
|
||||||
|
{ matcher: /^gateway:/, order: ['ipc'] },
|
||||||
|
{ matcher: /.*/, order: ['ipc'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGatewayWsDiagnosticEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(GATEWAY_WS_DIAG_FLAG) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGatewayTransportPreference(preference: GatewayTransportPreference): void {
|
export function setGatewayWsDiagnosticEnabled(enabled: boolean): void {
|
||||||
const enableWs = preference === 'ws-first' || preference === 'http-first' || preference === 'ws-only';
|
try {
|
||||||
const enableHttp = preference === 'ws-first' || preference === 'http-first' || preference === 'http-only';
|
if (enabled) {
|
||||||
clearTransportBackoff();
|
window.localStorage.setItem(GATEWAY_WS_DIAG_FLAG, '1');
|
||||||
configureApiClient({
|
} else {
|
||||||
enabled: {
|
window.localStorage.removeItem(GATEWAY_WS_DIAG_FLAG);
|
||||||
ws: enableWs,
|
}
|
||||||
http: enableHttp,
|
} catch {
|
||||||
},
|
// ignore localStorage errors
|
||||||
rules: gatewayRulesForPreference(preference),
|
}
|
||||||
});
|
applyGatewayTransportPreference();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
|
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
|
||||||
@@ -341,7 +389,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
|||||||
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
|
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
|
||||||
// Fallback to legacy channel handlers.
|
// Fallback to legacy channel handlers.
|
||||||
} else {
|
} else {
|
||||||
throw normalizeError(err);
|
throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +397,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
|||||||
try {
|
try {
|
||||||
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
|
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw normalizeError(err);
|
throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,12 +616,13 @@ export function createGatewayHttpTransportInvoker(
|
|||||||
: 15000;
|
: 15000;
|
||||||
|
|
||||||
const response = await invokeViaIpc<{
|
const response = await invokeViaIpc<{
|
||||||
success: boolean;
|
|
||||||
status?: number;
|
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
success?: boolean;
|
||||||
|
status?: number;
|
||||||
json?: unknown;
|
json?: unknown;
|
||||||
text?: string;
|
text?: string;
|
||||||
error?: string;
|
|
||||||
}>('gateway:httpProxy', [{
|
}>('gateway:httpProxy', [{
|
||||||
path: '/rpc',
|
path: '/rpc',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -585,8 +634,42 @@ export function createGatewayHttpTransportInvoker(
|
|||||||
},
|
},
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
if (response && 'data' in response && typeof response.ok === 'boolean') {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errObj = response.error as { message?: string } | string | undefined;
|
||||||
|
throw new Error(
|
||||||
|
typeof errObj === 'string'
|
||||||
|
? errObj
|
||||||
|
: (errObj?.message || 'Gateway HTTP proxy failed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const proxyData = response.data as { status?: number; ok?: boolean; json?: unknown; text?: string } | undefined;
|
||||||
|
const payload = proxyData?.json as Record<string, unknown> | undefined;
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new Error(proxyData?.text || `Gateway HTTP returned non-JSON (status=${proxyData?.status ?? 'unknown'})`);
|
||||||
|
}
|
||||||
|
if (payload.type === 'res') {
|
||||||
|
if (payload.ok === false || payload.error) {
|
||||||
|
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||||
|
}
|
||||||
|
return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T;
|
||||||
|
}
|
||||||
|
if ('ok' in payload) {
|
||||||
|
if (!payload.ok) {
|
||||||
|
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||||
|
}
|
||||||
|
return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T;
|
||||||
|
}
|
||||||
|
return normalizeGatewayRpcEnvelope(payload) as T;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
throw new Error(response?.error || 'Gateway HTTP proxy failed');
|
const errObj = response?.error as { message?: string } | string | undefined;
|
||||||
|
throw new Error(
|
||||||
|
typeof errObj === 'string'
|
||||||
|
? errObj
|
||||||
|
: (errObj?.message || 'Gateway HTTP proxy failed'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = response?.json as Record<string, unknown> | undefined;
|
const payload = response?.json as Record<string, unknown> | undefined;
|
||||||
@@ -598,16 +681,16 @@ export function createGatewayHttpTransportInvoker(
|
|||||||
if (payload.ok === false || payload.error) {
|
if (payload.ok === false || payload.error) {
|
||||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||||
}
|
}
|
||||||
return (payload.payload ?? payload) as T;
|
return normalizeGatewayRpcEnvelope(payload.payload ?? payload) as T;
|
||||||
}
|
}
|
||||||
if ('ok' in payload) {
|
if ('ok' in payload) {
|
||||||
if (!payload.ok) {
|
if (!payload.ok) {
|
||||||
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
throw new Error(String(payload.error ?? 'Gateway HTTP request failed'));
|
||||||
}
|
}
|
||||||
return (payload.data ?? payload) as T;
|
return normalizeGatewayRpcEnvelope(payload.data ?? payload) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as T;
|
return normalizeGatewayRpcEnvelope(payload) as T;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +698,13 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
|||||||
const timeoutMs = options.timeoutMs ?? 15000;
|
const timeoutMs = options.timeoutMs ?? 15000;
|
||||||
const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url));
|
const websocketFactory = options.websocketFactory ?? ((url: string) => new WebSocket(url));
|
||||||
const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl;
|
const resolveUrl = options.urlResolver ?? resolveDefaultGatewayWsUrl;
|
||||||
const resolveToken = options.tokenResolver ?? (() => invokeViaIpc<string | null>('settings:get', ['gatewayToken']));
|
const resolveToken = options.tokenResolver ?? (async () => {
|
||||||
|
const controlUi = await invokeViaIpc<GatewayControlUiResponse>('gateway:getControlUiUrl', []);
|
||||||
|
if (controlUi?.success && typeof controlUi.token === 'string' && controlUi.token.trim()) {
|
||||||
|
return controlUi.token;
|
||||||
|
}
|
||||||
|
return await invokeViaIpc<string | null>('settings:get', [{ key: 'gatewayToken' }]);
|
||||||
|
});
|
||||||
|
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
let connectPromise: Promise<WebSocket> | null = null;
|
let connectPromise: Promise<WebSocket> | null = null;
|
||||||
@@ -636,12 +725,36 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatGatewayError = (errorValue: unknown): string => {
|
||||||
|
if (errorValue == null) return 'unknown';
|
||||||
|
if (typeof errorValue === 'string') return errorValue;
|
||||||
|
if (typeof errorValue === 'object') {
|
||||||
|
const asRecord = errorValue as Record<string, unknown>;
|
||||||
|
const message = typeof asRecord.message === 'string' ? asRecord.message : null;
|
||||||
|
const code = typeof asRecord.code === 'string' || typeof asRecord.code === 'number'
|
||||||
|
? String(asRecord.code)
|
||||||
|
: null;
|
||||||
|
if (message && code) return `${code}: ${message}`;
|
||||||
|
if (message) return message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(errorValue);
|
||||||
|
} catch {
|
||||||
|
return String(errorValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(errorValue);
|
||||||
|
};
|
||||||
|
|
||||||
const sendConnect = async (_challengeNonce: string) => {
|
const sendConnect = async (_challengeNonce: string) => {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
throw new Error('Gateway WS not open during connect handshake');
|
throw new Error('Gateway WS not open during connect handshake');
|
||||||
}
|
}
|
||||||
const token = await Promise.resolve(resolveToken());
|
const token = await Promise.resolve(resolveToken());
|
||||||
connectRequestId = `connect-${Date.now()}`;
|
connectRequestId = `connect-${Date.now()}`;
|
||||||
|
const auth =
|
||||||
|
typeof token === 'string' && token.trim().length > 0
|
||||||
|
? { token }
|
||||||
|
: undefined;
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'req',
|
type: 'req',
|
||||||
id: connectRequestId,
|
id: connectRequestId,
|
||||||
@@ -650,18 +763,18 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
|||||||
minProtocol: 3,
|
minProtocol: 3,
|
||||||
maxProtocol: 3,
|
maxProtocol: 3,
|
||||||
client: {
|
client: {
|
||||||
id: 'clawx-ui',
|
id: 'openclaw-control-ui',
|
||||||
displayName: 'ClawX UI',
|
displayName: 'ClawX UI',
|
||||||
version: '0.1.0',
|
version: '1.0.0',
|
||||||
platform: window.electron?.platform ?? 'unknown',
|
platform: window.electron?.platform ?? 'unknown',
|
||||||
mode: 'ui',
|
mode: 'webchat',
|
||||||
},
|
},
|
||||||
auth: {
|
auth,
|
||||||
token: token ?? null,
|
caps: ['tool-events'],
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
role: 'operator',
|
role: 'operator',
|
||||||
scopes: ['operator.admin'],
|
scopes: ['operator.admin'],
|
||||||
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
||||||
|
locale: typeof navigator !== 'undefined' ? navigator.language : 'en',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -738,7 +851,7 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
|||||||
const ok = msg.ok !== false && !msg.error;
|
const ok = msg.ok !== false && !msg.error;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error(`Gateway WS connect failed: ${String(msg.error ?? 'unknown')}`));
|
reject(new Error(`Gateway WS connect failed: ${formatGatewayError(msg.error)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handshakeDone = true;
|
handshakeDone = true;
|
||||||
@@ -765,10 +878,10 @@ export function createGatewayWsTransportInvoker(options: GatewayWsTransportOptio
|
|||||||
|
|
||||||
const ok = msg.ok !== false && !msg.error;
|
const ok = msg.ok !== false && !msg.error;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
item.reject(new Error(String(msg.error ?? 'Gateway WS request failed')));
|
item.reject(new Error(formatGatewayError(msg.error ?? 'Gateway WS request failed')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.resolve(msg.payload ?? msg);
|
item.resolve(normalizeGatewayRpcEnvelope(msg.payload ?? msg));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed payload
|
// ignore malformed payload
|
||||||
}
|
}
|
||||||
@@ -838,7 +951,7 @@ export function initializeDefaultTransports(): void {
|
|||||||
if (defaultTransportsInitialized) return;
|
if (defaultTransportsInitialized) return;
|
||||||
registerTransportInvoker('ws', createGatewayWsTransportInvoker());
|
registerTransportInvoker('ws', createGatewayWsTransportInvoker());
|
||||||
registerTransportInvoker('http', createGatewayHttpTransportInvoker());
|
registerTransportInvoker('http', createGatewayHttpTransportInvoker());
|
||||||
applyGatewayTransportPreference('ws-first');
|
applyGatewayTransportPreference();
|
||||||
defaultTransportsInitialized = true;
|
defaultTransportsInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,15 +977,65 @@ export function toUserMessage(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
|
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
const order = resolveTransportOrder(channel);
|
const order = resolveTransportOrder(channel);
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (const kind of order) {
|
for (let i = 0; i < order.length; i += 1) {
|
||||||
|
const kind = order[i];
|
||||||
|
const attempt = i + 1;
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
return await invokeViaTransport<T>(kind, channel, args);
|
const value = await invokeViaTransport<T>(kind, channel, args);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
logApiAttempt({
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: kind,
|
||||||
|
attempt,
|
||||||
|
durationMs,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS || attempt > 1) {
|
||||||
|
trackUiEvent('api.request', {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: kind,
|
||||||
|
attempt,
|
||||||
|
durationMs,
|
||||||
|
fallbackUsed: attempt > 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
logApiAttempt({
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: kind,
|
||||||
|
attempt,
|
||||||
|
durationMs,
|
||||||
|
ok: false,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
trackUiEvent('api.request_error', {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: kind,
|
||||||
|
attempt,
|
||||||
|
durationMs,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
|
||||||
if (err instanceof TransportUnsupportedError) {
|
if (err instanceof TransportUnsupportedError) {
|
||||||
markTransportFailure(kind);
|
markTransportFailure(kind);
|
||||||
|
trackUiEvent('api.transport_fallback', {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
from: kind,
|
||||||
|
reason: 'unsupported',
|
||||||
|
nextAttempt: attempt + 1,
|
||||||
|
});
|
||||||
lastError = err;
|
lastError = err;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -880,13 +1043,38 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
|
|||||||
// For non-IPC transports, fail open to the next transport.
|
// For non-IPC transports, fail open to the next transport.
|
||||||
if (kind !== 'ipc') {
|
if (kind !== 'ipc') {
|
||||||
markTransportFailure(kind);
|
markTransportFailure(kind);
|
||||||
|
trackUiEvent('api.transport_fallback', {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
from: kind,
|
||||||
|
reason: 'error',
|
||||||
|
nextAttempt: attempt + 1,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw err;
|
throw normalizeError(err, {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: kind,
|
||||||
|
attempt,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw normalizeError(lastError);
|
trackUiEvent('api.request_failed', {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
attempts: order.length,
|
||||||
|
message: lastError instanceof Error ? lastError.message : String(lastError),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw normalizeError(lastError, {
|
||||||
|
requestId,
|
||||||
|
channel,
|
||||||
|
transport: 'ipc',
|
||||||
|
attempt: order.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
|
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||||
|
|||||||
@@ -1,6 +1,39 @@
|
|||||||
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import { trackUiEvent } from './telemetry';
|
||||||
|
|
||||||
const HOST_API_PORT = 3210;
|
const HOST_API_PORT = 3210;
|
||||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
||||||
|
|
||||||
|
type HostApiProxyResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
data?: {
|
||||||
|
status?: number;
|
||||||
|
ok?: boolean;
|
||||||
|
json?: unknown;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
error?: { message?: string } | string;
|
||||||
|
// backward compatibility fields
|
||||||
|
success: boolean;
|
||||||
|
status?: number;
|
||||||
|
json?: unknown;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostApiProxyData = {
|
||||||
|
status?: number;
|
||||||
|
ok?: boolean;
|
||||||
|
json?: unknown;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function headersToRecord(headers?: HeadersInit): Record<string, string> {
|
||||||
|
if (!headers) return {};
|
||||||
|
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||||
|
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||||
|
return { ...headers };
|
||||||
|
}
|
||||||
|
|
||||||
async function parseResponse<T>(response: Response): Promise<T> {
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `${response.status} ${response.statusText}`;
|
let message = `${response.status} ${response.statusText}`;
|
||||||
@@ -22,7 +55,104 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
|||||||
return await response.json() as T;
|
return await response.json() as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string {
|
||||||
|
return typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: (error?.message || 'Host API proxy request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnifiedProxyResponse<T>(
|
||||||
|
response: HostApiProxyResponse,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
startedAt: number,
|
||||||
|
): T {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(resolveProxyErrorMessage(response.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: HostApiProxyData = response.data ?? {};
|
||||||
|
trackUiEvent('hostapi.fetch', {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
source: 'ipc-proxy',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
status: data.status ?? 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.status === 204) return undefined as T;
|
||||||
|
if (data.json !== undefined) return data.json as T;
|
||||||
|
return data.text as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyProxyResponse<T>(
|
||||||
|
response: HostApiProxyResponse,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
startedAt: number,
|
||||||
|
): T {
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(resolveProxyErrorMessage(response.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = response.text
|
||||||
|
|| (typeof response.json === 'object' && response.json != null && 'error' in (response.json as Record<string, unknown>)
|
||||||
|
? String((response.json as Record<string, unknown>).error)
|
||||||
|
: `HTTP ${response.status ?? 'unknown'}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackUiEvent('hostapi.fetch', {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
source: 'ipc-proxy-legacy',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
status: response.status ?? 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
if (response.json !== undefined) return response.json as T;
|
||||||
|
return response.text as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackToBrowser(message: string): boolean {
|
||||||
|
return message.includes('Invalid IPC channel: hostapi:fetch')
|
||||||
|
|| message.includes('window is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const method = init?.method || 'GET';
|
||||||
|
// In Electron renderer, always proxy through main process to avoid CORS.
|
||||||
|
try {
|
||||||
|
const response = await invokeIpc<HostApiProxyResponse>('hostapi:fetch', {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: headersToRecord(init?.headers),
|
||||||
|
body: init?.body ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof response?.ok === 'boolean' && 'data' in response) {
|
||||||
|
return parseUnifiedProxyResponse<T>(response, path, method, startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseLegacyProxyResponse<T>(response, path, method, startedAt);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
trackUiEvent('hostapi.fetch_error', {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
source: 'ipc-proxy',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
if (!shouldFallbackToBrowser(message)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser-only fallback (non-Electron environments).
|
||||||
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -30,6 +160,13 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
|||||||
...(init?.headers || {}),
|
...(init?.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
trackUiEvent('hostapi.fetch', {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
source: 'browser-fallback',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
return parseResponse<T>(response);
|
return parseResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { createHostEventSource } from './host-api';
|
|||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
let eventSource: EventSource | null = null;
|
||||||
|
|
||||||
|
const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||||
|
'gateway:status': 'gateway:status-changed',
|
||||||
|
'gateway:error': 'gateway:error',
|
||||||
|
'gateway:notification': 'gateway:notification',
|
||||||
|
'gateway:chat-message': 'gateway:chat-message',
|
||||||
|
'gateway:channel-status': 'gateway:channel-status',
|
||||||
|
'gateway:exit': 'gateway:exit',
|
||||||
|
'oauth:code': 'oauth:code',
|
||||||
|
'oauth:success': 'oauth:success',
|
||||||
|
'oauth:error': 'oauth:error',
|
||||||
|
'channel:whatsapp-qr': 'channel:whatsapp-qr',
|
||||||
|
'channel:whatsapp-success': 'channel:whatsapp-success',
|
||||||
|
'channel:whatsapp-error': 'channel:whatsapp-error',
|
||||||
|
};
|
||||||
|
|
||||||
function getEventSource(): EventSource {
|
function getEventSource(): EventSource {
|
||||||
if (!eventSource) {
|
if (!eventSource) {
|
||||||
eventSource = createHostEventSource();
|
eventSource = createHostEventSource();
|
||||||
@@ -9,10 +24,35 @@ function getEventSource(): EventSource {
|
|||||||
return eventSource;
|
return eventSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowSseFallback(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem('clawx:allow-sse-fallback') === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeHostEvent<T = unknown>(
|
export function subscribeHostEvent<T = unknown>(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
handler: (payload: T) => void,
|
handler: (payload: T) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
|
const ipc = window.electron?.ipcRenderer;
|
||||||
|
const ipcChannel = HOST_EVENT_TO_IPC_CHANNEL[eventName];
|
||||||
|
if (ipcChannel && ipc?.on && ipc?.off) {
|
||||||
|
const listener = (payload: unknown) => {
|
||||||
|
handler(payload as T);
|
||||||
|
};
|
||||||
|
ipc.on(ipcChannel, listener);
|
||||||
|
return () => {
|
||||||
|
ipc.off(ipcChannel, listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowSseFallback()) {
|
||||||
|
console.warn(`[host-events] no IPC mapping for event "${eventName}", SSE fallback disabled`);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
const source = getEventSource();
|
const source = getEventSource();
|
||||||
const listener = (event: Event) => {
|
const listener = (event: Event) => {
|
||||||
const payload = JSON.parse((event as MessageEvent).data) as T;
|
const payload = JSON.parse((event as MessageEvent).data) as T;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
type TelemetryPayload = Record<string, unknown>;
|
type TelemetryPayload = Record<string, unknown>;
|
||||||
|
export type UiTelemetryEntry = {
|
||||||
|
id: number;
|
||||||
|
event: string;
|
||||||
|
payload: TelemetryPayload;
|
||||||
|
count: number;
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
|
||||||
const counters = new Map<string, number>();
|
const counters = new Map<string, number>();
|
||||||
|
const history: UiTelemetryEntry[] = [];
|
||||||
|
const listeners = new Set<(entry: UiTelemetryEntry) => void>();
|
||||||
|
let nextEntryId = 1;
|
||||||
|
const MAX_HISTORY = 500;
|
||||||
|
|
||||||
function safeStringify(payload: TelemetryPayload): string {
|
function safeStringify(payload: TelemetryPayload): string {
|
||||||
try {
|
try {
|
||||||
@@ -14,10 +25,29 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
|||||||
const count = (counters.get(event) ?? 0) + 1;
|
const count = (counters.get(event) ?? 0) + 1;
|
||||||
counters.set(event, count);
|
counters.set(event, count);
|
||||||
|
|
||||||
const logPayload = {
|
const normalizedPayload = {
|
||||||
...payload,
|
...payload,
|
||||||
|
};
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const entry: UiTelemetryEntry = {
|
||||||
|
id: nextEntryId,
|
||||||
|
event,
|
||||||
|
payload: normalizedPayload,
|
||||||
count,
|
count,
|
||||||
ts: new Date().toISOString(),
|
ts,
|
||||||
|
};
|
||||||
|
nextEntryId += 1;
|
||||||
|
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.splice(0, history.length - MAX_HISTORY);
|
||||||
|
}
|
||||||
|
listeners.forEach((listener) => listener(entry));
|
||||||
|
|
||||||
|
const logPayload = {
|
||||||
|
...normalizedPayload,
|
||||||
|
count,
|
||||||
|
ts,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local-only telemetry for UX diagnostics.
|
// Local-only telemetry for UX diagnostics.
|
||||||
@@ -27,3 +57,54 @@ export function trackUiEvent(event: string, payload: TelemetryPayload = {}): voi
|
|||||||
export function getUiCounter(event: string): number {
|
export function getUiCounter(event: string): number {
|
||||||
return counters.get(event) ?? 0;
|
return counters.get(event) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function trackUiTiming(
|
||||||
|
event: string,
|
||||||
|
durationMs: number,
|
||||||
|
payload: TelemetryPayload = {},
|
||||||
|
): void {
|
||||||
|
trackUiEvent(event, {
|
||||||
|
...payload,
|
||||||
|
durationMs: Math.round(durationMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startUiTiming(
|
||||||
|
event: string,
|
||||||
|
payload: TelemetryPayload = {},
|
||||||
|
): (nextPayload?: TelemetryPayload) => number {
|
||||||
|
const start = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
return (nextPayload: TelemetryPayload = {}): number => {
|
||||||
|
const end = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
const durationMs = Math.max(0, end - start);
|
||||||
|
trackUiTiming(event, durationMs, { ...payload, ...nextPayload });
|
||||||
|
return durationMs;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUiTelemetrySnapshot(limit = 200): UiTelemetryEntry[] {
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (limit >= history.length) {
|
||||||
|
return [...history];
|
||||||
|
}
|
||||||
|
return history.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUiTelemetry(): void {
|
||||||
|
counters.clear();
|
||||||
|
history.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeUiTelemetry(listener: (entry: UiTelemetryEntry) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Settings Page
|
* Settings Page
|
||||||
* Application configuration
|
* Application configuration
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -30,8 +30,19 @@ import { useGatewayStore } from '@/stores/gateway';
|
|||||||
import { useUpdateStore } from '@/stores/update';
|
import { useUpdateStore } from '@/stores/update';
|
||||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||||
import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
import {
|
||||||
import { trackUiEvent } from '@/lib/telemetry';
|
getGatewayWsDiagnosticEnabled,
|
||||||
|
invokeIpc,
|
||||||
|
setGatewayWsDiagnosticEnabled,
|
||||||
|
toUserMessage,
|
||||||
|
} from '@/lib/api-client';
|
||||||
|
import {
|
||||||
|
clearUiTelemetry,
|
||||||
|
getUiTelemetrySnapshot,
|
||||||
|
subscribeUiTelemetry,
|
||||||
|
trackUiEvent,
|
||||||
|
type UiTelemetryEntry,
|
||||||
|
} from '@/lib/telemetry';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
@@ -41,8 +52,6 @@ type ControlUiInfo = {
|
|||||||
port: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const {
|
const {
|
||||||
@@ -58,14 +67,12 @@ export function Settings() {
|
|||||||
proxyHttpsServer,
|
proxyHttpsServer,
|
||||||
proxyAllServer,
|
proxyAllServer,
|
||||||
proxyBypassRules,
|
proxyBypassRules,
|
||||||
gatewayTransportPreference,
|
|
||||||
setProxyEnabled,
|
setProxyEnabled,
|
||||||
setProxyServer,
|
setProxyServer,
|
||||||
setProxyHttpServer,
|
setProxyHttpServer,
|
||||||
setProxyHttpsServer,
|
setProxyHttpsServer,
|
||||||
setProxyAllServer,
|
setProxyAllServer,
|
||||||
setProxyBypassRules,
|
setProxyBypassRules,
|
||||||
setGatewayTransportPreference,
|
|
||||||
autoCheckUpdate,
|
autoCheckUpdate,
|
||||||
setAutoCheckUpdate,
|
setAutoCheckUpdate,
|
||||||
autoDownloadUpdate,
|
autoDownloadUpdate,
|
||||||
@@ -88,14 +95,9 @@ export function Settings() {
|
|||||||
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
|
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
|
||||||
const [showAdvancedProxy, setShowAdvancedProxy] = useState(false);
|
const [showAdvancedProxy, setShowAdvancedProxy] = useState(false);
|
||||||
const [savingProxy, setSavingProxy] = useState(false);
|
const [savingProxy, setSavingProxy] = useState(false);
|
||||||
|
const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false);
|
||||||
const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [
|
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
|
||||||
{ value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' },
|
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
|
||||||
{ value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' },
|
|
||||||
{ value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' },
|
|
||||||
{ value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' },
|
|
||||||
{ value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isWindows = window.electron.platform === 'win32';
|
const isWindows = window.electron.platform === 'win32';
|
||||||
const showCliTools = true;
|
const showCliTools = true;
|
||||||
@@ -222,6 +224,25 @@ export function Settings() {
|
|||||||
return () => { unsubscribe?.(); };
|
return () => { unsubscribe?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWsDiagnosticEnabled(getGatewayWsDiagnosticEnabled());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!devModeUnlocked) return;
|
||||||
|
setTelemetryEntries(getUiTelemetrySnapshot(200));
|
||||||
|
const unsubscribe = subscribeUiTelemetry((entry) => {
|
||||||
|
setTelemetryEntries((prev) => {
|
||||||
|
const next = [...prev, entry];
|
||||||
|
if (next.length > 200) {
|
||||||
|
next.splice(0, next.length - 200);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [devModeUnlocked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProxyEnabledDraft(proxyEnabled);
|
setProxyEnabledDraft(proxyEnabled);
|
||||||
}, [proxyEnabled]);
|
}, [proxyEnabled]);
|
||||||
@@ -279,8 +300,99 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const telemetryStats = useMemo(() => {
|
||||||
|
let errorCount = 0;
|
||||||
|
let slowCount = 0;
|
||||||
|
for (const entry of telemetryEntries) {
|
||||||
|
if (entry.event.endsWith('_error') || entry.event.includes('request_error')) {
|
||||||
|
errorCount += 1;
|
||||||
|
}
|
||||||
|
const durationMs = typeof entry.payload.durationMs === 'number'
|
||||||
|
? entry.payload.durationMs
|
||||||
|
: Number.NaN;
|
||||||
|
if (Number.isFinite(durationMs) && durationMs >= 800) {
|
||||||
|
slowCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { total: telemetryEntries.length, errorCount, slowCount };
|
||||||
|
}, [telemetryEntries]);
|
||||||
|
|
||||||
|
const telemetryByEvent = useMemo(() => {
|
||||||
|
const map = new Map<string, {
|
||||||
|
event: string;
|
||||||
|
count: number;
|
||||||
|
errorCount: number;
|
||||||
|
slowCount: number;
|
||||||
|
totalDuration: number;
|
||||||
|
timedCount: number;
|
||||||
|
lastTs: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const entry of telemetryEntries) {
|
||||||
|
const current = map.get(entry.event) ?? {
|
||||||
|
event: entry.event,
|
||||||
|
count: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
slowCount: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
timedCount: 0,
|
||||||
|
lastTs: entry.ts,
|
||||||
|
};
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
current.lastTs = entry.ts;
|
||||||
|
|
||||||
|
if (entry.event.endsWith('_error') || entry.event.includes('request_error')) {
|
||||||
|
current.errorCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = typeof entry.payload.durationMs === 'number'
|
||||||
|
? entry.payload.durationMs
|
||||||
|
: Number.NaN;
|
||||||
|
if (Number.isFinite(durationMs)) {
|
||||||
|
current.totalDuration += durationMs;
|
||||||
|
current.timedCount += 1;
|
||||||
|
if (durationMs >= 800) {
|
||||||
|
current.slowCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(entry.event, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()]
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 12);
|
||||||
|
}, [telemetryEntries]);
|
||||||
|
|
||||||
|
const handleCopyTelemetry = async () => {
|
||||||
|
try {
|
||||||
|
const serialized = telemetryEntries.map((entry) => JSON.stringify(entry)).join('\n');
|
||||||
|
await navigator.clipboard.writeText(serialized);
|
||||||
|
toast.success(t('developer.telemetryCopied'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${t('common:status.error')}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearTelemetry = () => {
|
||||||
|
clearUiTelemetry();
|
||||||
|
setTelemetryEntries([]);
|
||||||
|
toast.success(t('developer.telemetryCleared'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWsDiagnosticToggle = (enabled: boolean) => {
|
||||||
|
setGatewayWsDiagnosticEnabled(enabled);
|
||||||
|
setWsDiagnosticEnabled(enabled);
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? t('developer.wsDiagnosticEnabled')
|
||||||
|
: t('developer.wsDiagnosticDisabled'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -289,7 +401,7 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('appearance.title')}</CardTitle>
|
<CardTitle>{t('appearance.title')}</CardTitle>
|
||||||
<CardDescription>{t('appearance.description')}</CardDescription>
|
<CardDescription>{t('appearance.description')}</CardDescription>
|
||||||
@@ -343,7 +455,7 @@ export function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* AI Providers */}
|
{/* AI Providers */}
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Key className="h-5 w-5" />
|
<Key className="h-5 w-5" />
|
||||||
@@ -357,7 +469,7 @@ export function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Gateway */}
|
{/* Gateway */}
|
||||||
<Card>
|
<Card className="order-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('gateway.title')}</CardTitle>
|
<CardTitle>{t('gateway.title')}</CardTitle>
|
||||||
<CardDescription>{t('gateway.description')}</CardDescription>
|
<CardDescription>{t('gateway.description')}</CardDescription>
|
||||||
@@ -430,34 +542,8 @@ export function Settings() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-4">
|
{devModeUnlocked ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<div>
|
|
||||||
<Label>{t('gateway.proxyTitle')}</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('gateway.proxyDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={proxyEnabledDraft}
|
|
||||||
onCheckedChange={setProxyEnabledDraft}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="proxy-server">{t('gateway.proxyServer')}</Label>
|
|
||||||
<Input
|
|
||||||
id="proxy-server"
|
|
||||||
value={proxyServerDraft}
|
|
||||||
onChange={(event) => setProxyServerDraft(event.target.value)}
|
|
||||||
placeholder="http://127.0.0.1:7890"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('gateway.proxyServerHelp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{devModeUnlocked && (
|
|
||||||
<div className="rounded-md border border-border/60 p-3">
|
<div className="rounded-md border border-border/60 p-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -474,81 +560,111 @@ export function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
{showAdvancedProxy && (
|
{showAdvancedProxy && (
|
||||||
<div className="mt-3 space-y-4">
|
<div className="mt-3 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
|
<div>
|
||||||
<Input
|
<Label>{t('gateway.proxyTitle')}</Label>
|
||||||
id="proxy-http-server"
|
<p className="text-sm text-muted-foreground">
|
||||||
value={proxyHttpServerDraft}
|
{t('gateway.proxyDesc')}
|
||||||
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
|
</p>
|
||||||
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
<p className="text-xs text-muted-foreground">
|
checked={proxyEnabledDraft}
|
||||||
{t('gateway.proxyHttpServerHelp')}
|
onCheckedChange={setProxyEnabledDraft}
|
||||||
</p>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="proxy-https-server">{t('gateway.proxyHttpsServer')}</Label>
|
<Label htmlFor="proxy-server">{t('gateway.proxyServer')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="proxy-https-server"
|
id="proxy-server"
|
||||||
value={proxyHttpsServerDraft}
|
value={proxyServerDraft}
|
||||||
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
|
onChange={(event) => setProxyServerDraft(event.target.value)}
|
||||||
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
placeholder="http://127.0.0.1:7890"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('gateway.proxyHttpsServerHelp')}
|
{t('gateway.proxyServerHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="proxy-all-server">{t('gateway.proxyAllServer')}</Label>
|
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="proxy-all-server"
|
id="proxy-http-server"
|
||||||
value={proxyAllServerDraft}
|
value={proxyHttpServerDraft}
|
||||||
onChange={(event) => setProxyAllServerDraft(event.target.value)}
|
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
|
||||||
placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'}
|
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('gateway.proxyAllServerHelp')}
|
{t('gateway.proxyHttpServerHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-https-server">{t('gateway.proxyHttpsServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-https-server"
|
||||||
|
value={proxyHttpsServerDraft}
|
||||||
|
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
|
||||||
|
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyHttpsServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-all-server">{t('gateway.proxyAllServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-all-server"
|
||||||
|
value={proxyAllServerDraft}
|
||||||
|
onChange={(event) => setProxyAllServerDraft(event.target.value)}
|
||||||
|
placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyAllServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-bypass">{t('gateway.proxyBypass')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-bypass"
|
||||||
|
value={proxyBypassRulesDraft}
|
||||||
|
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
|
||||||
|
placeholder="<local>;localhost;127.0.0.1;::1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyBypassHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/40 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('gateway.proxyRestartNote')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveProxySettings}
|
||||||
|
disabled={savingProxy}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
|
||||||
|
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="proxy-bypass">{t('gateway.proxyBypass')}</Label>
|
|
||||||
<Input
|
|
||||||
id="proxy-bypass"
|
|
||||||
value={proxyBypassRulesDraft}
|
|
||||||
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
|
|
||||||
placeholder="<local>;localhost;127.0.0.1;::1"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('gateway.proxyBypassHelp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/40 p-3">
|
<div className="rounded-md border border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
{t('advanced.devModeDesc')}
|
||||||
{t('gateway.proxyRestartNote')}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSaveProxySettings}
|
|
||||||
disabled={savingProxy}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
|
|
||||||
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Updates */}
|
{/* Updates */}
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Download className="h-5 w-5" />
|
<Download className="h-5 w-5" />
|
||||||
@@ -593,7 +709,7 @@ export function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advanced */}
|
{/* Advanced */}
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('advanced.title')}</CardTitle>
|
<CardTitle>{t('advanced.title')}</CardTitle>
|
||||||
<CardDescription>{t('advanced.description')}</CardDescription>
|
<CardDescription>{t('advanced.description')}</CardDescription>
|
||||||
@@ -616,40 +732,12 @@ export function Settings() {
|
|||||||
|
|
||||||
{/* Developer */}
|
{/* Developer */}
|
||||||
{devModeUnlocked && (
|
{devModeUnlocked && (
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('developer.title')}</CardTitle>
|
<CardTitle>{t('developer.title')}</CardTitle>
|
||||||
<CardDescription>{t('developer.description')}</CardDescription>
|
<CardDescription>{t('developer.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label>{t('advanced.transport.label')}</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('advanced.transport.desc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{transportOptions.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
variant={gatewayTransportPreference === option.value ? 'default' : 'outline'}
|
|
||||||
className="justify-between"
|
|
||||||
onClick={() => {
|
|
||||||
setGatewayTransportPreference(option.value);
|
|
||||||
toast.success(t('advanced.transport.saved'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{t(option.labelKey)}</span>
|
|
||||||
<span className="text-xs opacity-80">{t(option.descKey)}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('developer.console')}</Label>
|
<Label>{t('developer.console')}</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -729,12 +817,116 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border/60 p-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('developer.wsDiagnostic')}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('developer.wsDiagnosticDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={wsDiagnosticEnabled}
|
||||||
|
onCheckedChange={handleWsDiagnosticToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{t('developer.telemetryViewer')}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('developer.telemetryViewerDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowTelemetryViewer((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showTelemetryViewer
|
||||||
|
? t('common:actions.hide')
|
||||||
|
: t('common:actions.show')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTelemetryViewer && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/60 p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">{t('developer.telemetryTotal')}: {telemetryStats.total}</Badge>
|
||||||
|
<Badge variant={telemetryStats.errorCount > 0 ? 'destructive' : 'secondary'}>
|
||||||
|
{t('developer.telemetryErrors')}: {telemetryStats.errorCount}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={telemetryStats.slowCount > 0 ? 'secondary' : 'outline'}>
|
||||||
|
{t('developer.telemetrySlow')}: {telemetryStats.slowCount}
|
||||||
|
</Badge>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleCopyTelemetry}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{t('common:actions.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleClearTelemetry}>
|
||||||
|
{t('common:actions.clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 overflow-auto rounded-md border border-border/50 bg-muted/20">
|
||||||
|
{telemetryByEvent.length > 0 && (
|
||||||
|
<div className="border-b border-border/50 bg-background/70 p-2">
|
||||||
|
<p className="mb-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
{t('developer.telemetryAggregated')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 text-[11px]">
|
||||||
|
{telemetryByEvent.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.event}
|
||||||
|
className="grid grid-cols-[minmax(0,1.6fr)_0.7fr_0.9fr_0.8fr_1fr] gap-2 rounded border border-border/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium" title={item.event}>{item.event}</span>
|
||||||
|
<span className="text-muted-foreground">n={item.count}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
avg={item.timedCount > 0 ? Math.round(item.totalDuration / item.timedCount) : 0}ms
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">slow={item.slowCount}</span>
|
||||||
|
<span className="text-muted-foreground">err={item.errorCount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1 p-2 font-mono text-xs">
|
||||||
|
{telemetryEntries.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground">{t('developer.telemetryEmpty')}</div>
|
||||||
|
) : (
|
||||||
|
telemetryEntries
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((entry) => (
|
||||||
|
<div key={entry.id} className="rounded border border-border/40 bg-background/60 p-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-semibold">{entry.event}</span>
|
||||||
|
<span className="text-muted-foreground">{entry.ts}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap text-[11px] text-muted-foreground">
|
||||||
|
{JSON.stringify({ count: entry.count, ...entry.payload }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* About */}
|
{/* About */}
|
||||||
<Card>
|
<Card className="order-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('about.title')}</CardTitle>
|
<CardTitle>{t('about.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
842
src/stores/chat/helpers.ts
Normal file
842
src/stores/chat/helpers.ts
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import type { AttachedFileMeta, ChatSession, ContentBlock, RawMessage, ToolStatus } from './types';
|
||||||
|
|
||||||
|
// Module-level timestamp tracking the last chat event received.
|
||||||
|
// Used by the safety timeout to avoid false-positive "no response" errors
|
||||||
|
// during tool-use conversations where streamingMessage is temporarily cleared
|
||||||
|
// between tool-result finals and the next delta.
|
||||||
|
let _lastChatEventAt = 0;
|
||||||
|
|
||||||
|
/** Normalize a timestamp to milliseconds. Handles both seconds and ms. */
|
||||||
|
function toMs(ts: number): number {
|
||||||
|
// Timestamps < 1e12 are in seconds (before ~2033); >= 1e12 are milliseconds
|
||||||
|
return ts < 1e12 ? ts * 1000 : ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer for fallback history polling during active sends.
|
||||||
|
// If no streaming events arrive within a few seconds, we periodically
|
||||||
|
// poll chat.history to surface intermediate tool-call turns.
|
||||||
|
let _historyPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Timer for delayed error finalization. When the Gateway reports a mid-stream
|
||||||
|
// error (e.g. "terminated"), it may retry internally and recover. We wait
|
||||||
|
// before committing the error to give the recovery path a chance.
|
||||||
|
let _errorRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearErrorRecoveryTimer(): void {
|
||||||
|
if (_errorRecoveryTimer) {
|
||||||
|
clearTimeout(_errorRecoveryTimer);
|
||||||
|
_errorRecoveryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistoryPoll(): void {
|
||||||
|
if (_historyPollTimer) {
|
||||||
|
clearTimeout(_historyPollTimer);
|
||||||
|
_historyPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local image cache ─────────────────────────────────────────
|
||||||
|
// The Gateway doesn't store image attachments in session content blocks,
|
||||||
|
// so we cache them locally keyed by staged file path (which appears in the
|
||||||
|
// [media attached: <path> ...] reference in the Gateway's user message text).
|
||||||
|
// Keying by path avoids the race condition of keying by runId (which is only
|
||||||
|
// available after the RPC returns, but history may load before that).
|
||||||
|
const IMAGE_CACHE_KEY = 'clawx:image-cache';
|
||||||
|
const IMAGE_CACHE_MAX = 100; // max entries to prevent unbounded growth
|
||||||
|
|
||||||
|
function loadImageCache(): Map<string, AttachedFileMeta> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(IMAGE_CACHE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>;
|
||||||
|
return new Map(entries);
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveImageCache(cache: Map<string, AttachedFileMeta>): void {
|
||||||
|
try {
|
||||||
|
// Evict oldest entries if over limit
|
||||||
|
const entries = Array.from(cache.entries());
|
||||||
|
const trimmed = entries.length > IMAGE_CACHE_MAX
|
||||||
|
? entries.slice(entries.length - IMAGE_CACHE_MAX)
|
||||||
|
: entries;
|
||||||
|
localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed));
|
||||||
|
} catch { /* ignore quota errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const _imageCache = loadImageCache();
|
||||||
|
|
||||||
|
function upsertImageCacheEntry(filePath: string, file: Omit<AttachedFileMeta, 'filePath'>): void {
|
||||||
|
_imageCache.set(filePath, { ...file, filePath });
|
||||||
|
saveImageCache(_imageCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract plain text from message content (string or content blocks) */
|
||||||
|
function getMessageText(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return (content as Array<{ type?: string; text?: string }>)
|
||||||
|
.filter(b => b.type === 'text' && b.text)
|
||||||
|
.map(b => b.text!)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract media file refs from [media attached: <path> (<mime>) | ...] patterns */
|
||||||
|
function extractMediaRefs(text: string): Array<{ filePath: string; mimeType: string }> {
|
||||||
|
const refs: Array<{ filePath: string; mimeType: string }> = [];
|
||||||
|
const regex = /\[media attached:\s*([^\s(]+)\s*\(([^)]+)\)\s*\|[^\]]*\]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
refs.push({ filePath: match[1], mimeType: match[2] });
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map common file extensions to MIME types */
|
||||||
|
function mimeFromExtension(filePath: string): string {
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'bmp': 'image/bmp',
|
||||||
|
'avif': 'image/avif',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
// Documents
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'doc': 'application/msword',
|
||||||
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'xls': 'application/vnd.ms-excel',
|
||||||
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'csv': 'text/csv',
|
||||||
|
'md': 'text/markdown',
|
||||||
|
'rtf': 'application/rtf',
|
||||||
|
'epub': 'application/epub+zip',
|
||||||
|
// Archives
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
'rar': 'application/vnd.rar',
|
||||||
|
'7z': 'application/x-7z-compressed',
|
||||||
|
// Audio
|
||||||
|
'mp3': 'audio/mpeg',
|
||||||
|
'wav': 'audio/wav',
|
||||||
|
'ogg': 'audio/ogg',
|
||||||
|
'aac': 'audio/aac',
|
||||||
|
'flac': 'audio/flac',
|
||||||
|
'm4a': 'audio/mp4',
|
||||||
|
// Video
|
||||||
|
'mp4': 'video/mp4',
|
||||||
|
'mov': 'video/quicktime',
|
||||||
|
'avi': 'video/x-msvideo',
|
||||||
|
'mkv': 'video/x-matroska',
|
||||||
|
'webm': 'video/webm',
|
||||||
|
'm4v': 'video/mp4',
|
||||||
|
};
|
||||||
|
return map[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract raw file paths from message text.
|
||||||
|
* Detects absolute paths (Unix: / or ~/, Windows: C:\ etc.) ending with common file extensions.
|
||||||
|
* Handles both image and non-image files, consistent with channel push message behavior.
|
||||||
|
*/
|
||||||
|
function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: string }> {
|
||||||
|
const refs: Array<{ filePath: string; mimeType: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v';
|
||||||
|
// Unix absolute paths (/... or ~/...) — lookbehind rejects mid-token slashes
|
||||||
|
// (e.g. "path/to/file.mp4", "https://example.com/file.mp4")
|
||||||
|
const unixRegex = new RegExp(`(?<![\\w./:])((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'gi');
|
||||||
|
// Windows absolute paths (C:\... D:\...) — lookbehind rejects drive letter glued to a word
|
||||||
|
const winRegex = new RegExp(`(?<![\\w])([A-Za-z]:\\\\[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'gi');
|
||||||
|
for (const regex of [unixRegex, winRegex]) {
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
const p = match[1];
|
||||||
|
if (p && !seen.has(p)) {
|
||||||
|
seen.add(p);
|
||||||
|
refs.push({ filePath: p, mimeType: mimeFromExtension(p) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract images from a content array (including nested tool_result content).
|
||||||
|
* Converts them to AttachedFileMeta entries with preview set to data URL or remote URL.
|
||||||
|
*/
|
||||||
|
function extractImagesAsAttachedFiles(content: unknown): AttachedFileMeta[] {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
const files: AttachedFileMeta[] = [];
|
||||||
|
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if (block.type === 'image') {
|
||||||
|
// Path 1: Anthropic source-wrapped format {source: {type, media_type, data}}
|
||||||
|
if (block.source) {
|
||||||
|
const src = block.source;
|
||||||
|
const mimeType = src.media_type || 'image/jpeg';
|
||||||
|
|
||||||
|
if (src.type === 'base64' && src.data) {
|
||||||
|
files.push({
|
||||||
|
fileName: 'image',
|
||||||
|
mimeType,
|
||||||
|
fileSize: 0,
|
||||||
|
preview: `data:${mimeType};base64,${src.data}`,
|
||||||
|
});
|
||||||
|
} else if (src.type === 'url' && src.url) {
|
||||||
|
files.push({
|
||||||
|
fileName: 'image',
|
||||||
|
mimeType,
|
||||||
|
fileSize: 0,
|
||||||
|
preview: src.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Path 2: Flat format from Gateway tool results {data, mimeType}
|
||||||
|
else if (block.data) {
|
||||||
|
const mimeType = block.mimeType || 'image/jpeg';
|
||||||
|
files.push({
|
||||||
|
fileName: 'image',
|
||||||
|
mimeType,
|
||||||
|
fileSize: 0,
|
||||||
|
preview: `data:${mimeType};base64,${block.data}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recurse into tool_result content blocks
|
||||||
|
if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) {
|
||||||
|
files.push(...extractImagesAsAttachedFiles(block.content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an AttachedFileMeta entry for a file ref, using cache if available.
|
||||||
|
*/
|
||||||
|
function makeAttachedFile(ref: { filePath: string; mimeType: string }): AttachedFileMeta {
|
||||||
|
const cached = _imageCache.get(ref.filePath);
|
||||||
|
if (cached) return { ...cached, filePath: ref.filePath };
|
||||||
|
const fileName = ref.filePath.split(/[\\/]/).pop() || 'file';
|
||||||
|
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file path from a tool call's arguments by toolCallId.
|
||||||
|
* Searches common argument names: file_path, filePath, path, file.
|
||||||
|
*/
|
||||||
|
function getToolCallFilePath(msg: RawMessage, toolCallId: string): string | undefined {
|
||||||
|
if (!toolCallId) return undefined;
|
||||||
|
|
||||||
|
// Anthropic/normalized format — toolCall blocks in content array
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id === toolCallId) {
|
||||||
|
const args = (block.input ?? block.arguments) as Record<string, unknown> | undefined;
|
||||||
|
if (args) {
|
||||||
|
const fp = args.file_path ?? args.filePath ?? args.path ?? args.file;
|
||||||
|
if (typeof fp === 'string') return fp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI format — tool_calls array on the message itself
|
||||||
|
const msgAny = msg as unknown as Record<string, unknown>;
|
||||||
|
const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls;
|
||||||
|
if (Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||||
|
if (tc.id !== toolCallId) continue;
|
||||||
|
const fn = (tc.function ?? tc) as Record<string, unknown>;
|
||||||
|
let args: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record<string, unknown>;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (args) {
|
||||||
|
const fp = args.file_path ?? args.filePath ?? args.path ?? args.file;
|
||||||
|
if (typeof fp === 'string') return fp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all tool call file paths from a message into a Map<toolCallId, filePath>.
|
||||||
|
*/
|
||||||
|
function collectToolCallPaths(msg: RawMessage, paths: Map<string, string>): void {
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.id) {
|
||||||
|
const args = (block.input ?? block.arguments) as Record<string, unknown> | undefined;
|
||||||
|
if (args) {
|
||||||
|
const fp = args.file_path ?? args.filePath ?? args.path ?? args.file;
|
||||||
|
if (typeof fp === 'string') paths.set(block.id, fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msgAny = msg as unknown as Record<string, unknown>;
|
||||||
|
const toolCalls = msgAny.tool_calls ?? msgAny.toolCalls;
|
||||||
|
if (Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||||
|
const id = typeof tc.id === 'string' ? tc.id : '';
|
||||||
|
if (!id) continue;
|
||||||
|
const fn = (tc.function ?? tc) as Record<string, unknown>;
|
||||||
|
let args: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : (fn.arguments ?? fn.input) as Record<string, unknown>;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (args) {
|
||||||
|
const fp = args.file_path ?? args.filePath ?? args.path ?? args.file;
|
||||||
|
if (typeof fp === 'string') paths.set(id, fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before filtering tool_result messages from history, scan them for any file/image
|
||||||
|
* content and attach those to the immediately following assistant message.
|
||||||
|
* This mirrors channel push message behavior where tool outputs surface files to the UI.
|
||||||
|
* Handles:
|
||||||
|
* - Image content blocks (base64 / url)
|
||||||
|
* - [media attached: path (mime) | path] text patterns in tool result output
|
||||||
|
* - Raw file paths in tool result text
|
||||||
|
*/
|
||||||
|
function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] {
|
||||||
|
const pending: AttachedFileMeta[] = [];
|
||||||
|
const toolCallPaths = new Map<string, string>();
|
||||||
|
|
||||||
|
return messages.map((msg) => {
|
||||||
|
// Track file paths from assistant tool call arguments for later matching
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
collectToolCallPaths(msg, toolCallPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolResultRole(msg.role)) {
|
||||||
|
// Resolve file path from the matching tool call
|
||||||
|
const matchedPath = msg.toolCallId ? toolCallPaths.get(msg.toolCallId) : undefined;
|
||||||
|
|
||||||
|
// 1. Image/file content blocks in the structured content array
|
||||||
|
const imageFiles = extractImagesAsAttachedFiles(msg.content);
|
||||||
|
if (matchedPath) {
|
||||||
|
for (const f of imageFiles) {
|
||||||
|
if (!f.filePath) {
|
||||||
|
f.filePath = matchedPath;
|
||||||
|
f.fileName = matchedPath.split(/[\\/]/).pop() || 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.push(...imageFiles);
|
||||||
|
|
||||||
|
// 2. [media attached: ...] patterns in tool result text output
|
||||||
|
const text = getMessageText(msg.content);
|
||||||
|
if (text) {
|
||||||
|
const mediaRefs = extractMediaRefs(text);
|
||||||
|
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
|
||||||
|
for (const ref of mediaRefs) {
|
||||||
|
pending.push(makeAttachedFile(ref));
|
||||||
|
}
|
||||||
|
// 3. Raw file paths in tool result text (documents, audio, video, etc.)
|
||||||
|
for (const ref of extractRawFilePaths(text)) {
|
||||||
|
if (!mediaRefPaths.has(ref.filePath)) {
|
||||||
|
pending.push(makeAttachedFile(ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg; // will be filtered later
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'assistant' && pending.length > 0) {
|
||||||
|
const toAttach = pending.splice(0);
|
||||||
|
// Deduplicate against files already on the assistant message
|
||||||
|
const existingPaths = new Set(
|
||||||
|
(msg._attachedFiles || []).map(f => f.filePath).filter(Boolean),
|
||||||
|
);
|
||||||
|
const newFiles = toAttach.filter(f => !f.filePath || !existingPaths.has(f.filePath));
|
||||||
|
if (newFiles.length === 0) return msg;
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
_attachedFiles: [...(msg._attachedFiles || []), ...newFiles],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore _attachedFiles for messages loaded from history.
|
||||||
|
* Handles:
|
||||||
|
* 1. [media attached: path (mime) | path] patterns (attachment-button flow)
|
||||||
|
* 2. Raw image file paths typed in message text (e.g. /Users/.../image.png)
|
||||||
|
* Uses local cache for previews when available; missing previews are loaded async.
|
||||||
|
*/
|
||||||
|
function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] {
|
||||||
|
return messages.map((msg, idx) => {
|
||||||
|
// Only process user and assistant messages; skip if already enriched
|
||||||
|
if ((msg.role !== 'user' && msg.role !== 'assistant') || msg._attachedFiles) return msg;
|
||||||
|
const text = getMessageText(msg.content);
|
||||||
|
|
||||||
|
// Path 1: [media attached: path (mime) | path] — guaranteed format from attachment button
|
||||||
|
const mediaRefs = extractMediaRefs(text);
|
||||||
|
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
|
||||||
|
|
||||||
|
// Path 2: Raw file paths.
|
||||||
|
// For assistant messages: scan own text AND the nearest preceding user message text,
|
||||||
|
// but only for non-tool-only assistant messages (i.e. the final answer turn).
|
||||||
|
// Tool-only messages (thinking + tool calls) should not show file previews — those
|
||||||
|
// belong to the final answer message that comes after the tool results.
|
||||||
|
// User messages never get raw-path previews so the image is not shown twice.
|
||||||
|
let rawRefs: Array<{ filePath: string; mimeType: string }> = [];
|
||||||
|
if (msg.role === 'assistant' && !isToolOnlyMessage(msg)) {
|
||||||
|
// Own text
|
||||||
|
rawRefs = extractRawFilePaths(text).filter(r => !mediaRefPaths.has(r.filePath));
|
||||||
|
|
||||||
|
// Nearest preceding user message text (look back up to 5 messages)
|
||||||
|
const seenPaths = new Set(rawRefs.map(r => r.filePath));
|
||||||
|
for (let i = idx - 1; i >= Math.max(0, idx - 5); i--) {
|
||||||
|
const prev = messages[i];
|
||||||
|
if (!prev) break;
|
||||||
|
if (prev.role === 'user') {
|
||||||
|
const prevText = getMessageText(prev.content);
|
||||||
|
for (const ref of extractRawFilePaths(prevText)) {
|
||||||
|
if (!mediaRefPaths.has(ref.filePath) && !seenPaths.has(ref.filePath)) {
|
||||||
|
seenPaths.add(ref.filePath);
|
||||||
|
rawRefs.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break; // only use the nearest user message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRefs = [...mediaRefs, ...rawRefs];
|
||||||
|
if (allRefs.length === 0) return msg;
|
||||||
|
|
||||||
|
const files: AttachedFileMeta[] = allRefs.map(ref => {
|
||||||
|
const cached = _imageCache.get(ref.filePath);
|
||||||
|
if (cached) return { ...cached, filePath: ref.filePath };
|
||||||
|
const fileName = ref.filePath.split(/[\\/]/).pop() || 'file';
|
||||||
|
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath };
|
||||||
|
});
|
||||||
|
return { ...msg, _attachedFiles: files };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async: load missing previews from disk via IPC for messages that have
|
||||||
|
* _attachedFiles with null previews. Updates messages in-place and triggers re-render.
|
||||||
|
* Handles both [media attached: ...] patterns and raw filePath entries.
|
||||||
|
*/
|
||||||
|
async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
|
||||||
|
// Collect all image paths that need previews
|
||||||
|
const needPreview: Array<{ filePath: string; mimeType: string }> = [];
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg._attachedFiles) continue;
|
||||||
|
|
||||||
|
// Path 1: files with explicit filePath field (raw path detection or enriched refs)
|
||||||
|
for (const file of msg._attachedFiles) {
|
||||||
|
const fp = file.filePath;
|
||||||
|
if (!fp || seenPaths.has(fp)) continue;
|
||||||
|
// Images: need preview. Non-images: need file size (for FileCard display).
|
||||||
|
const needsLoad = file.mimeType.startsWith('image/')
|
||||||
|
? !file.preview
|
||||||
|
: file.fileSize === 0;
|
||||||
|
if (needsLoad) {
|
||||||
|
seenPaths.add(fp);
|
||||||
|
needPreview.push({ filePath: fp, mimeType: file.mimeType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: [media attached: ...] patterns (legacy — in case filePath wasn't stored)
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
const text = getMessageText(msg.content);
|
||||||
|
const refs = extractMediaRefs(text);
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const file = msg._attachedFiles[i];
|
||||||
|
const ref = refs[i];
|
||||||
|
if (!file || !ref || seenPaths.has(ref.filePath)) continue;
|
||||||
|
const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0;
|
||||||
|
if (needsLoad) {
|
||||||
|
seenPaths.add(ref.filePath);
|
||||||
|
needPreview.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needPreview.length === 0) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thumbnails = await invokeIpc(
|
||||||
|
'media:getThumbnails',
|
||||||
|
needPreview,
|
||||||
|
) as Record<string, { preview: string | null; fileSize: number }>;
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg._attachedFiles) continue;
|
||||||
|
|
||||||
|
// Update files that have filePath
|
||||||
|
for (const file of msg._attachedFiles) {
|
||||||
|
const fp = file.filePath;
|
||||||
|
if (!fp) continue;
|
||||||
|
const thumb = thumbnails[fp];
|
||||||
|
if (thumb && (thumb.preview || thumb.fileSize)) {
|
||||||
|
if (thumb.preview) file.preview = thumb.preview;
|
||||||
|
if (thumb.fileSize) file.fileSize = thumb.fileSize;
|
||||||
|
_imageCache.set(fp, { ...file });
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: update by index for [media attached: ...] refs
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
const text = getMessageText(msg.content);
|
||||||
|
const refs = extractMediaRefs(text);
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const file = msg._attachedFiles[i];
|
||||||
|
const ref = refs[i];
|
||||||
|
if (!file || !ref || file.filePath) continue; // skip if already handled via filePath
|
||||||
|
const thumb = thumbnails[ref.filePath];
|
||||||
|
if (thumb && (thumb.preview || thumb.fileSize)) {
|
||||||
|
if (thumb.preview) file.preview = thumb.preview;
|
||||||
|
if (thumb.fileSize) file.fileSize = thumb.fileSize;
|
||||||
|
_imageCache.set(ref.filePath, { ...file });
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) saveImageCache(_imageCache);
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[loadMissingPreviews] Failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanonicalPrefixFromSessions(sessions: ChatSession[]): string | null {
|
||||||
|
const canonical = sessions.find((s) => s.key.startsWith('agent:'))?.key;
|
||||||
|
if (!canonical) return null;
|
||||||
|
const parts = canonical.split(':');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return `${parts[0]}:${parts[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolOnlyMessage(message: RawMessage | undefined): boolean {
|
||||||
|
if (!message) return false;
|
||||||
|
if (isToolResultRole(message.role)) return true;
|
||||||
|
|
||||||
|
const msg = message as unknown as Record<string, unknown>;
|
||||||
|
const content = message.content;
|
||||||
|
|
||||||
|
// Check OpenAI-format tool_calls field (real-time streaming from OpenAI-compatible models)
|
||||||
|
const toolCalls = msg.tool_calls ?? msg.toolCalls;
|
||||||
|
const hasOpenAITools = Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
// Content is not an array — check if there's OpenAI-format tool_calls
|
||||||
|
if (hasOpenAITools) {
|
||||||
|
// Has tool calls but content might be empty/string — treat as tool-only
|
||||||
|
// if there's no meaningful text content
|
||||||
|
const textContent = typeof content === 'string' ? content.trim() : '';
|
||||||
|
return textContent.length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTool = hasOpenAITools;
|
||||||
|
let hasText = false;
|
||||||
|
let hasNonToolContent = false;
|
||||||
|
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'toolCall' || block.type === 'toolResult') {
|
||||||
|
hasTool = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.type === 'text' && block.text && block.text.trim()) {
|
||||||
|
hasText = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only actual image output disqualifies a tool-only message.
|
||||||
|
// Thinking blocks are internal reasoning that can accompany tool_use — they
|
||||||
|
// should NOT prevent the message from being treated as an intermediate tool step.
|
||||||
|
if (block.type === 'image') {
|
||||||
|
hasNonToolContent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasTool && !hasText && !hasNonToolContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolResultRole(role: unknown): boolean {
|
||||||
|
if (!role) return false;
|
||||||
|
const normalized = String(role).toLowerCase();
|
||||||
|
return normalized === 'toolresult' || normalized === 'tool_result';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (!Array.isArray(content)) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
parts.push(block.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeToolOutput(text: string): string | undefined {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) return undefined;
|
||||||
|
const summaryLines = lines.slice(0, 2);
|
||||||
|
let summary = summaryLines.join(' / ');
|
||||||
|
if (summary.length > 160) {
|
||||||
|
summary = `${summary.slice(0, 157)}...`;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolStatus(rawStatus: unknown, fallback: 'running' | 'completed'): ToolStatus['status'] {
|
||||||
|
const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';
|
||||||
|
if (status === 'error' || status === 'failed') return 'error';
|
||||||
|
if (status === 'completed' || status === 'success' || status === 'done') return 'completed';
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDurationMs(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
const parsed = typeof value === 'string' ? Number(value) : NaN;
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolUseUpdates(message: unknown): ToolStatus[] {
|
||||||
|
if (!message || typeof message !== 'object') return [];
|
||||||
|
const msg = message as Record<string, unknown>;
|
||||||
|
const updates: ToolStatus[] = [];
|
||||||
|
|
||||||
|
// Path 1: Anthropic/normalized format — tool blocks inside content array
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue;
|
||||||
|
updates.push({
|
||||||
|
id: block.id || block.name,
|
||||||
|
toolCallId: block.id,
|
||||||
|
name: block.name,
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: OpenAI format — tool_calls array on the message itself
|
||||||
|
if (updates.length === 0) {
|
||||||
|
const toolCalls = msg.tool_calls ?? msg.toolCalls;
|
||||||
|
if (Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||||
|
const fn = (tc.function ?? tc) as Record<string, unknown>;
|
||||||
|
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||||
|
if (!name) continue;
|
||||||
|
const id = typeof tc.id === 'string' ? tc.id : name;
|
||||||
|
updates.push({
|
||||||
|
id,
|
||||||
|
toolCallId: typeof tc.id === 'string' ? tc.id : undefined,
|
||||||
|
name,
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolResultBlocks(message: unknown, eventState: string): ToolStatus[] {
|
||||||
|
if (!message || typeof message !== 'object') return [];
|
||||||
|
const msg = message as Record<string, unknown>;
|
||||||
|
const content = msg.content;
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
|
||||||
|
const updates: ToolStatus[] = [];
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if (block.type !== 'tool_result' && block.type !== 'toolResult') continue;
|
||||||
|
const outputText = extractTextFromContent(block.content ?? block.text ?? '');
|
||||||
|
const summary = summarizeToolOutput(outputText);
|
||||||
|
updates.push({
|
||||||
|
id: block.id || block.name || 'tool',
|
||||||
|
toolCallId: block.id,
|
||||||
|
name: block.name || block.id || 'tool',
|
||||||
|
status: normalizeToolStatus(undefined, eventState === 'delta' ? 'running' : 'completed'),
|
||||||
|
summary,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolResultUpdate(message: unknown, eventState: string): ToolStatus | null {
|
||||||
|
if (!message || typeof message !== 'object') return null;
|
||||||
|
const msg = message as Record<string, unknown>;
|
||||||
|
const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : '';
|
||||||
|
if (!isToolResultRole(role)) return null;
|
||||||
|
|
||||||
|
const toolName = typeof msg.toolName === 'string' ? msg.toolName : (typeof msg.name === 'string' ? msg.name : '');
|
||||||
|
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
|
||||||
|
const details = (msg.details && typeof msg.details === 'object') ? msg.details as Record<string, unknown> : undefined;
|
||||||
|
const rawStatus = (msg.status ?? details?.status);
|
||||||
|
const fallback = eventState === 'delta' ? 'running' : 'completed';
|
||||||
|
const status = normalizeToolStatus(rawStatus, fallback);
|
||||||
|
const durationMs = parseDurationMs(details?.durationMs ?? details?.duration ?? (msg as Record<string, unknown>).durationMs);
|
||||||
|
|
||||||
|
const outputText = (details && typeof details.aggregated === 'string')
|
||||||
|
? details.aggregated
|
||||||
|
: extractTextFromContent(msg.content);
|
||||||
|
const summary = summarizeToolOutput(outputText) ?? summarizeToolOutput(String(details?.error ?? msg.error ?? ''));
|
||||||
|
|
||||||
|
const name = toolName || toolCallId || 'tool';
|
||||||
|
const id = toolCallId || name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
toolCallId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
summary,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeToolStatus(existing: ToolStatus['status'], incoming: ToolStatus['status']): ToolStatus['status'] {
|
||||||
|
const order: Record<ToolStatus['status'], number> = { running: 0, completed: 1, error: 2 };
|
||||||
|
return order[incoming] >= order[existing] ? incoming : existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] {
|
||||||
|
if (updates.length === 0) return current;
|
||||||
|
const next = [...current];
|
||||||
|
for (const update of updates) {
|
||||||
|
const key = update.toolCallId || update.id || update.name;
|
||||||
|
if (!key) continue;
|
||||||
|
const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key);
|
||||||
|
if (index === -1) {
|
||||||
|
next.push(update);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = next[index];
|
||||||
|
next[index] = {
|
||||||
|
...existing,
|
||||||
|
...update,
|
||||||
|
name: update.name || existing.name,
|
||||||
|
status: mergeToolStatus(existing.status, update.status),
|
||||||
|
durationMs: update.durationMs ?? existing.durationMs,
|
||||||
|
summary: update.summary ?? existing.summary,
|
||||||
|
updatedAt: update.updatedAt || existing.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] {
|
||||||
|
const updates: ToolStatus[] = [];
|
||||||
|
const toolResultUpdate = extractToolResultUpdate(message, eventState);
|
||||||
|
if (toolResultUpdate) updates.push(toolResultUpdate);
|
||||||
|
updates.push(...extractToolResultBlocks(message, eventState));
|
||||||
|
updates.push(...extractToolUseUpdates(message));
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNonToolAssistantContent(message: RawMessage | undefined): boolean {
|
||||||
|
if (!message) return false;
|
||||||
|
if (typeof message.content === 'string' && message.content.trim()) return true;
|
||||||
|
|
||||||
|
const content = message.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content as ContentBlock[]) {
|
||||||
|
if (block.type === 'text' && block.text && block.text.trim()) return true;
|
||||||
|
if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true;
|
||||||
|
if (block.type === 'image') return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = message as unknown as Record<string, unknown>;
|
||||||
|
if (typeof msg.text === 'string' && msg.text.trim()) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHistoryPollTimer(timer: ReturnType<typeof setTimeout> | null): void {
|
||||||
|
_historyPollTimer = timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorRecoveryTimer(): boolean {
|
||||||
|
return _errorRecoveryTimer != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setErrorRecoveryTimer(timer: ReturnType<typeof setTimeout> | null): void {
|
||||||
|
_errorRecoveryTimer = timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLastChatEventAt(value: number): void {
|
||||||
|
_lastChatEventAt = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastChatEventAt(): number {
|
||||||
|
return _lastChatEventAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
toMs,
|
||||||
|
clearErrorRecoveryTimer,
|
||||||
|
clearHistoryPoll,
|
||||||
|
extractImagesAsAttachedFiles,
|
||||||
|
getMessageText,
|
||||||
|
extractMediaRefs,
|
||||||
|
extractRawFilePaths,
|
||||||
|
makeAttachedFile,
|
||||||
|
enrichWithToolResultFiles,
|
||||||
|
isToolResultRole,
|
||||||
|
enrichWithCachedImages,
|
||||||
|
loadMissingPreviews,
|
||||||
|
upsertImageCacheEntry,
|
||||||
|
getCanonicalPrefixFromSessions,
|
||||||
|
getToolCallFilePath,
|
||||||
|
collectToolUpdates,
|
||||||
|
upsertToolStatuses,
|
||||||
|
hasNonToolAssistantContent,
|
||||||
|
isToolOnlyMessage,
|
||||||
|
setHistoryPollTimer,
|
||||||
|
hasErrorRecoveryTimer,
|
||||||
|
setErrorRecoveryTimer,
|
||||||
|
setLastChatEventAt,
|
||||||
|
getLastChatEventAt,
|
||||||
|
};
|
||||||
148
src/stores/chat/history-actions.ts
Normal file
148
src/stores/chat/history-actions.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import {
|
||||||
|
clearHistoryPoll,
|
||||||
|
enrichWithCachedImages,
|
||||||
|
enrichWithToolResultFiles,
|
||||||
|
getMessageText,
|
||||||
|
hasNonToolAssistantContent,
|
||||||
|
isToolResultRole,
|
||||||
|
loadMissingPreviews,
|
||||||
|
toMs,
|
||||||
|
} from './helpers';
|
||||||
|
import type { RawMessage } from './types';
|
||||||
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
|
||||||
|
export function createHistoryActions(
|
||||||
|
set: ChatSet,
|
||||||
|
get: ChatGet,
|
||||||
|
): Pick<SessionHistoryActions, 'loadHistory'> {
|
||||||
|
return {
|
||||||
|
loadHistory: async (quiet = false) => {
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
if (!quiet) set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invokeIpc(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.history',
|
||||||
|
{ sessionKey: currentSessionKey, limit: 200 }
|
||||||
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const data = result.result;
|
||||||
|
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
||||||
|
|
||||||
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||||
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||||
|
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
|
||||||
|
// Restore file attachments for user/assistant messages (from cache + text patterns)
|
||||||
|
const enrichedMessages = enrichWithCachedImages(filteredMessages);
|
||||||
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
|
|
||||||
|
// Preserve the optimistic user message during an active send.
|
||||||
|
// The Gateway may not include the user's message in chat.history
|
||||||
|
// until the run completes, causing it to flash out of the UI.
|
||||||
|
let finalMessages = enrichedMessages;
|
||||||
|
const userMsgAt = get().lastUserMessageAt;
|
||||||
|
if (get().sending && userMsgAt) {
|
||||||
|
const userMsMs = toMs(userMsgAt);
|
||||||
|
const hasRecentUser = enrichedMessages.some(
|
||||||
|
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
|
||||||
|
);
|
||||||
|
if (!hasRecentUser) {
|
||||||
|
const currentMsgs = get().messages;
|
||||||
|
const optimistic = [...currentMsgs].reverse().find(
|
||||||
|
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
|
||||||
|
);
|
||||||
|
if (optimistic) {
|
||||||
|
finalMessages = [...enrichedMessages, optimistic];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ messages: finalMessages, thinkingLevel, loading: false });
|
||||||
|
|
||||||
|
// Extract first user message text as a session label for display in the toolbar.
|
||||||
|
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
|
||||||
|
// displayName (e.g. the configured agent name "ClawX") instead.
|
||||||
|
const isMainSession = currentSessionKey.endsWith(':main');
|
||||||
|
if (!isMainSession) {
|
||||||
|
const firstUserMsg = finalMessages.find((m) => m.role === 'user');
|
||||||
|
if (firstUserMsg) {
|
||||||
|
const labelText = getMessageText(firstUserMsg.content).trim();
|
||||||
|
if (labelText) {
|
||||||
|
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||||
|
set((s) => ({
|
||||||
|
sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record last activity time from the last message in history
|
||||||
|
const lastMsg = finalMessages[finalMessages.length - 1];
|
||||||
|
if (lastMsg?.timestamp) {
|
||||||
|
const lastAt = toMs(lastMsg.timestamp);
|
||||||
|
set((s) => ({
|
||||||
|
sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async: load missing image previews from disk (updates in background)
|
||||||
|
loadMissingPreviews(finalMessages).then((updated) => {
|
||||||
|
if (updated) {
|
||||||
|
// Create new object references so React.memo detects changes.
|
||||||
|
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
||||||
|
// must produce fresh message + file references for each affected msg.
|
||||||
|
set({
|
||||||
|
messages: finalMessages.map(msg =>
|
||||||
|
msg._attachedFiles
|
||||||
|
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
||||||
|
: msg
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
||||||
|
|
||||||
|
// If we're sending but haven't received streaming events, check
|
||||||
|
// whether the loaded history reveals intermediate tool-call activity.
|
||||||
|
// This surfaces progress via the pendingFinal → ActivityIndicator path.
|
||||||
|
const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0;
|
||||||
|
const isAfterUserMsg = (msg: RawMessage): boolean => {
|
||||||
|
if (!userMsTs || !msg.timestamp) return true;
|
||||||
|
return toMs(msg.timestamp) >= userMsTs;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSendingNow && !pendingFinal) {
|
||||||
|
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
|
||||||
|
if (msg.role !== 'assistant') return false;
|
||||||
|
return isAfterUserMsg(msg);
|
||||||
|
});
|
||||||
|
if (hasRecentAssistantActivity) {
|
||||||
|
set({ pendingFinal: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pendingFinal, check whether the AI produced a final text response.
|
||||||
|
if (pendingFinal || get().pendingFinal) {
|
||||||
|
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
|
||||||
|
if (msg.role !== 'assistant') return false;
|
||||||
|
if (!hasNonToolAssistantContent(msg)) return false;
|
||||||
|
return isAfterUserMsg(msg);
|
||||||
|
});
|
||||||
|
if (recentAssistant) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
set({ sending: false, activeRunId: null, pendingFinal: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ messages: [], loading: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load chat history:', err);
|
||||||
|
set({ messages: [], loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
70
src/stores/chat/internal.ts
Normal file
70
src/stores/chat/internal.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { DEFAULT_SESSION_KEY, type ChatState } from './types';
|
||||||
|
import { createRuntimeActions } from './runtime-actions';
|
||||||
|
import { createSessionHistoryActions } from './session-history-actions';
|
||||||
|
import type { ChatGet, ChatSet } from './store-api';
|
||||||
|
|
||||||
|
export const initialChatState: Pick<
|
||||||
|
ChatState,
|
||||||
|
| 'messages'
|
||||||
|
| 'loading'
|
||||||
|
| 'error'
|
||||||
|
| 'sending'
|
||||||
|
| 'activeRunId'
|
||||||
|
| 'streamingText'
|
||||||
|
| 'streamingMessage'
|
||||||
|
| 'streamingTools'
|
||||||
|
| 'pendingFinal'
|
||||||
|
| 'lastUserMessageAt'
|
||||||
|
| 'pendingToolImages'
|
||||||
|
| 'sessions'
|
||||||
|
| 'currentSessionKey'
|
||||||
|
| 'sessionLabels'
|
||||||
|
| 'sessionLastActivity'
|
||||||
|
| 'showThinking'
|
||||||
|
| 'thinkingLevel'
|
||||||
|
> = {
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
|
||||||
|
sessions: [],
|
||||||
|
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
|
||||||
|
showThinking: true,
|
||||||
|
thinkingLevel: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createChatActions(
|
||||||
|
set: ChatSet,
|
||||||
|
get: ChatGet,
|
||||||
|
): Pick<
|
||||||
|
ChatState,
|
||||||
|
| 'loadSessions'
|
||||||
|
| 'switchSession'
|
||||||
|
| 'newSession'
|
||||||
|
| 'deleteSession'
|
||||||
|
| 'cleanupEmptySession'
|
||||||
|
| 'loadHistory'
|
||||||
|
| 'sendMessage'
|
||||||
|
| 'abortRun'
|
||||||
|
| 'handleChatEvent'
|
||||||
|
| 'toggleThinking'
|
||||||
|
| 'refresh'
|
||||||
|
| 'clearError'
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
...createSessionHistoryActions(set, get),
|
||||||
|
...createRuntimeActions(set, get),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/stores/chat/runtime-actions.ts
Normal file
12
src/stores/chat/runtime-actions.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
||||||
|
import { createRuntimeEventActions } from './runtime-event-actions';
|
||||||
|
import { createRuntimeSendActions } from './runtime-send-actions';
|
||||||
|
import { createRuntimeUiActions } from './runtime-ui-actions';
|
||||||
|
|
||||||
|
export function createRuntimeActions(set: ChatSet, get: ChatGet): RuntimeActions {
|
||||||
|
return {
|
||||||
|
...createRuntimeSendActions(set, get),
|
||||||
|
...createRuntimeEventActions(set, get),
|
||||||
|
...createRuntimeUiActions(set, get),
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/stores/chat/runtime-event-actions.ts
Normal file
52
src/stores/chat/runtime-event-actions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { clearHistoryPoll, setLastChatEventAt } from './helpers';
|
||||||
|
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
||||||
|
import { handleRuntimeEventState } from './runtime-event-handlers';
|
||||||
|
|
||||||
|
export function createRuntimeEventActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'handleChatEvent'> {
|
||||||
|
return {
|
||||||
|
handleChatEvent: (event: Record<string, unknown>) => {
|
||||||
|
const runId = String(event.runId || '');
|
||||||
|
const eventState = String(event.state || '');
|
||||||
|
const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null;
|
||||||
|
const { activeRunId, currentSessionKey } = get();
|
||||||
|
|
||||||
|
// Only process events for the current session (when sessionKey is present)
|
||||||
|
if (eventSessionKey != null && eventSessionKey !== currentSessionKey) return;
|
||||||
|
|
||||||
|
// Only process events for the active run (or if no active run set)
|
||||||
|
if (activeRunId && runId && runId !== activeRunId) return;
|
||||||
|
|
||||||
|
setLastChatEventAt(Date.now());
|
||||||
|
|
||||||
|
// Defensive: if state is missing but we have a message, try to infer state.
|
||||||
|
let resolvedState = eventState;
|
||||||
|
if (!resolvedState && event.message && typeof event.message === 'object') {
|
||||||
|
const msg = event.message as Record<string, unknown>;
|
||||||
|
const stopReason = msg.stopReason ?? msg.stop_reason;
|
||||||
|
if (stopReason) {
|
||||||
|
resolvedState = 'final';
|
||||||
|
} else if (msg.role || msg.content) {
|
||||||
|
resolvedState = 'delta';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pause the history poll when we receive actual streaming data.
|
||||||
|
// The gateway sends "agent" events with { phase, startedAt } that carry
|
||||||
|
// no message — these must NOT kill the poll, since the poll is our only
|
||||||
|
// way to track progress when the gateway doesn't stream intermediate turns.
|
||||||
|
const hasUsefulData = resolvedState === 'delta' || resolvedState === 'final'
|
||||||
|
|| resolvedState === 'error' || resolvedState === 'aborted';
|
||||||
|
if (hasUsefulData) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
// Adopt run started from another client (e.g. console at 127.0.0.1:18789):
|
||||||
|
// show loading/streaming in the app when this session has an active run.
|
||||||
|
const { sending } = get();
|
||||||
|
if (!sending && runId) {
|
||||||
|
set({ sending: true, activeRunId: runId, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRuntimeEventState(set, get, event, resolvedState, runId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
286
src/stores/chat/runtime-event-handlers.ts
Normal file
286
src/stores/chat/runtime-event-handlers.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import {
|
||||||
|
clearErrorRecoveryTimer,
|
||||||
|
clearHistoryPoll,
|
||||||
|
collectToolUpdates,
|
||||||
|
extractImagesAsAttachedFiles,
|
||||||
|
extractMediaRefs,
|
||||||
|
extractRawFilePaths,
|
||||||
|
getMessageText,
|
||||||
|
getToolCallFilePath,
|
||||||
|
hasErrorRecoveryTimer,
|
||||||
|
hasNonToolAssistantContent,
|
||||||
|
isToolOnlyMessage,
|
||||||
|
isToolResultRole,
|
||||||
|
makeAttachedFile,
|
||||||
|
setErrorRecoveryTimer,
|
||||||
|
upsertToolStatuses,
|
||||||
|
} from './helpers';
|
||||||
|
import type { AttachedFileMeta, RawMessage } from './types';
|
||||||
|
import type { ChatGet, ChatSet } from './store-api';
|
||||||
|
|
||||||
|
export function handleRuntimeEventState(
|
||||||
|
set: ChatSet,
|
||||||
|
get: ChatGet,
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
resolvedState: string,
|
||||||
|
runId: string,
|
||||||
|
): void {
|
||||||
|
switch (resolvedState) {
|
||||||
|
case 'started': {
|
||||||
|
// Run just started (e.g. from console); show loading immediately.
|
||||||
|
const { sending: currentSending } = get();
|
||||||
|
if (!currentSending && runId) {
|
||||||
|
set({ sending: true, activeRunId: runId, error: null });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delta': {
|
||||||
|
// If we're receiving new deltas, the Gateway has recovered from any
|
||||||
|
// prior error — cancel the error finalization timer and clear the
|
||||||
|
// stale error banner so the user sees the live stream again.
|
||||||
|
if (hasErrorRecoveryTimer()) {
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
set({ error: null });
|
||||||
|
}
|
||||||
|
const updates = collectToolUpdates(event.message, resolvedState);
|
||||||
|
set((s) => ({
|
||||||
|
streamingMessage: (() => {
|
||||||
|
if (event.message && typeof event.message === 'object') {
|
||||||
|
const msgRole = (event.message as RawMessage).role;
|
||||||
|
if (isToolResultRole(msgRole)) return s.streamingMessage;
|
||||||
|
}
|
||||||
|
return event.message ?? s.streamingMessage;
|
||||||
|
})(),
|
||||||
|
streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'final': {
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
if (get().error) set({ error: null });
|
||||||
|
// Message complete - add to history and clear streaming
|
||||||
|
const finalMsg = event.message as RawMessage | undefined;
|
||||||
|
if (finalMsg) {
|
||||||
|
const updates = collectToolUpdates(finalMsg, resolvedState);
|
||||||
|
if (isToolResultRole(finalMsg.role)) {
|
||||||
|
// Resolve file path from the streaming assistant message's matching tool call
|
||||||
|
const currentStreamForPath = get().streamingMessage as RawMessage | null;
|
||||||
|
const matchedPath = (currentStreamForPath && finalMsg.toolCallId)
|
||||||
|
? getToolCallFilePath(currentStreamForPath, finalMsg.toolCallId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Mirror enrichWithToolResultFiles: collect images + file refs for next assistant msg
|
||||||
|
const toolFiles: AttachedFileMeta[] = [
|
||||||
|
...extractImagesAsAttachedFiles(finalMsg.content),
|
||||||
|
];
|
||||||
|
if (matchedPath) {
|
||||||
|
for (const f of toolFiles) {
|
||||||
|
if (!f.filePath) {
|
||||||
|
f.filePath = matchedPath;
|
||||||
|
f.fileName = matchedPath.split(/[\\/]/).pop() || 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = getMessageText(finalMsg.content);
|
||||||
|
if (text) {
|
||||||
|
const mediaRefs = extractMediaRefs(text);
|
||||||
|
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
|
||||||
|
for (const ref of mediaRefs) toolFiles.push(makeAttachedFile(ref));
|
||||||
|
for (const ref of extractRawFilePaths(text)) {
|
||||||
|
if (!mediaRefPaths.has(ref.filePath)) toolFiles.push(makeAttachedFile(ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set((s) => {
|
||||||
|
// Snapshot the current streaming assistant message (thinking + tool_use) into
|
||||||
|
// messages[] before clearing it. The Gateway does NOT send separate 'final'
|
||||||
|
// events for intermediate tool-use turns — it only sends deltas and then the
|
||||||
|
// tool result. Without snapshotting here, the intermediate thinking+tool steps
|
||||||
|
// would be overwritten by the next turn's deltas and never appear in the UI.
|
||||||
|
const currentStream = s.streamingMessage as RawMessage | null;
|
||||||
|
const snapshotMsgs: RawMessage[] = [];
|
||||||
|
if (currentStream) {
|
||||||
|
const streamRole = currentStream.role;
|
||||||
|
if (streamRole === 'assistant' || streamRole === undefined) {
|
||||||
|
// Use message's own id if available, otherwise derive a stable one from runId
|
||||||
|
const snapId = currentStream.id
|
||||||
|
|| `${runId || 'run'}-turn-${s.messages.length}`;
|
||||||
|
if (!s.messages.some(m => m.id === snapId)) {
|
||||||
|
snapshotMsgs.push({
|
||||||
|
...(currentStream as RawMessage),
|
||||||
|
role: 'assistant',
|
||||||
|
id: snapId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messages: snapshotMsgs.length > 0 ? [...s.messages, ...snapshotMsgs] : s.messages,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
pendingFinal: true,
|
||||||
|
pendingToolImages: toolFiles.length > 0
|
||||||
|
? [...s.pendingToolImages, ...toolFiles]
|
||||||
|
: s.pendingToolImages,
|
||||||
|
streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const toolOnly = isToolOnlyMessage(finalMsg);
|
||||||
|
const hasOutput = hasNonToolAssistantContent(finalMsg);
|
||||||
|
const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`);
|
||||||
|
set((s) => {
|
||||||
|
const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools;
|
||||||
|
const streamingTools = hasOutput ? [] : nextTools;
|
||||||
|
|
||||||
|
// Attach any images collected from preceding tool results
|
||||||
|
const pendingImgs = s.pendingToolImages;
|
||||||
|
const msgWithImages: RawMessage = pendingImgs.length > 0
|
||||||
|
? {
|
||||||
|
...finalMsg,
|
||||||
|
role: (finalMsg.role || 'assistant') as RawMessage['role'],
|
||||||
|
id: msgId,
|
||||||
|
_attachedFiles: [...(finalMsg._attachedFiles || []), ...pendingImgs],
|
||||||
|
}
|
||||||
|
: { ...finalMsg, role: (finalMsg.role || 'assistant') as RawMessage['role'], id: msgId };
|
||||||
|
const clearPendingImages = { pendingToolImages: [] as AttachedFileMeta[] };
|
||||||
|
|
||||||
|
// Check if message already exists (prevent duplicates)
|
||||||
|
const alreadyExists = s.messages.some(m => m.id === msgId);
|
||||||
|
if (alreadyExists) {
|
||||||
|
return toolOnly ? {
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
pendingFinal: true,
|
||||||
|
streamingTools,
|
||||||
|
...clearPendingImages,
|
||||||
|
} : {
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
sending: hasOutput ? false : s.sending,
|
||||||
|
activeRunId: hasOutput ? null : s.activeRunId,
|
||||||
|
pendingFinal: hasOutput ? false : true,
|
||||||
|
streamingTools,
|
||||||
|
...clearPendingImages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return toolOnly ? {
|
||||||
|
messages: [...s.messages, msgWithImages],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
pendingFinal: true,
|
||||||
|
streamingTools,
|
||||||
|
...clearPendingImages,
|
||||||
|
} : {
|
||||||
|
messages: [...s.messages, msgWithImages],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
sending: hasOutput ? false : s.sending,
|
||||||
|
activeRunId: hasOutput ? null : s.activeRunId,
|
||||||
|
pendingFinal: hasOutput ? false : true,
|
||||||
|
streamingTools,
|
||||||
|
...clearPendingImages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// After the final response, quietly reload history to surface all intermediate
|
||||||
|
// tool-use turns (thinking + tool blocks) from the Gateway's authoritative record.
|
||||||
|
if (hasOutput && !toolOnly) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
void get().loadHistory(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No message in final event - reload history to get complete data
|
||||||
|
set({ streamingText: '', streamingMessage: null, pendingFinal: true });
|
||||||
|
get().loadHistory();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
const errorMsg = String(event.errorMessage || 'An error occurred');
|
||||||
|
const wasSending = get().sending;
|
||||||
|
|
||||||
|
// Snapshot the current streaming message into messages[] so partial
|
||||||
|
// content ("Let me get that written down...") is preserved in the UI
|
||||||
|
// rather than being silently discarded.
|
||||||
|
const currentStream = get().streamingMessage as RawMessage | null;
|
||||||
|
if (currentStream && (currentStream.role === 'assistant' || currentStream.role === undefined)) {
|
||||||
|
const snapId = (currentStream as RawMessage).id
|
||||||
|
|| `error-snap-${Date.now()}`;
|
||||||
|
const alreadyExists = get().messages.some(m => m.id === snapId);
|
||||||
|
if (!alreadyExists) {
|
||||||
|
set((s) => ({
|
||||||
|
messages: [...s.messages, { ...currentStream, role: 'assistant' as const, id: snapId }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
error: errorMsg,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
pendingToolImages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't immediately give up: the Gateway often retries internally
|
||||||
|
// after transient API failures (e.g. "terminated"). Keep `sending`
|
||||||
|
// true for a grace period so that recovery events are processed and
|
||||||
|
// the agent-phase-completion handler can still trigger loadHistory.
|
||||||
|
if (wasSending) {
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
const ERROR_RECOVERY_GRACE_MS = 15_000;
|
||||||
|
setErrorRecoveryTimer(setTimeout(() => {
|
||||||
|
setErrorRecoveryTimer(null);
|
||||||
|
const state = get();
|
||||||
|
if (state.sending && !state.streamingMessage) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
// Grace period expired with no recovery — finalize the error
|
||||||
|
set({
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
});
|
||||||
|
// One final history reload in case the Gateway completed in the
|
||||||
|
// background and we just missed the event.
|
||||||
|
state.loadHistory(true);
|
||||||
|
}
|
||||||
|
}, ERROR_RECOVERY_GRACE_MS));
|
||||||
|
} else {
|
||||||
|
clearHistoryPoll();
|
||||||
|
set({ sending: false, activeRunId: null, lastUserMessageAt: null });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'aborted': {
|
||||||
|
clearHistoryPoll();
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
set({
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Unknown or empty state — if we're currently sending and receive an event
|
||||||
|
// with a message, attempt to process it as streaming data. This handles
|
||||||
|
// edge cases where the Gateway sends events without a state field.
|
||||||
|
const { sending } = get();
|
||||||
|
if (sending && event.message && typeof event.message === 'object') {
|
||||||
|
console.warn(`[handleChatEvent] Unknown event state "${resolvedState}", treating message as streaming delta. Event keys:`, Object.keys(event));
|
||||||
|
const updates = collectToolUpdates(event.message, 'delta');
|
||||||
|
set((s) => ({
|
||||||
|
streamingMessage: event.message ?? s.streamingMessage,
|
||||||
|
streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/stores/chat/runtime-send-actions.ts
Normal file
194
src/stores/chat/runtime-send-actions.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import {
|
||||||
|
clearErrorRecoveryTimer,
|
||||||
|
clearHistoryPoll,
|
||||||
|
getLastChatEventAt,
|
||||||
|
setHistoryPollTimer,
|
||||||
|
setLastChatEventAt,
|
||||||
|
upsertImageCacheEntry,
|
||||||
|
} from './helpers';
|
||||||
|
import type { RawMessage } from './types';
|
||||||
|
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
||||||
|
|
||||||
|
export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'sendMessage' | 'abortRun'> {
|
||||||
|
return {
|
||||||
|
sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
||||||
|
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
|
||||||
|
// Add user message optimistically (with local file metadata for UI display)
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const userMsg: RawMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: trimmed || (attachments?.length ? '(file attached)' : ''),
|
||||||
|
timestamp: nowMs / 1000,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
_attachedFiles: attachments?.map(a => ({
|
||||||
|
fileName: a.fileName,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
fileSize: a.fileSize,
|
||||||
|
preview: a.preview,
|
||||||
|
filePath: a.stagedPath,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
set((s) => ({
|
||||||
|
messages: [...s.messages, userMsg],
|
||||||
|
sending: true,
|
||||||
|
error: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: nowMs,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update session label with first user message text as soon as it's sent
|
||||||
|
const { sessionLabels, messages } = get();
|
||||||
|
const isFirstMessage = !messages.slice(0, -1).some((m) => m.role === 'user');
|
||||||
|
if (!currentSessionKey.endsWith(':main') && isFirstMessage && !sessionLabels[currentSessionKey] && trimmed) {
|
||||||
|
const truncated = trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed;
|
||||||
|
set((s) => ({ sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this session as most recently active
|
||||||
|
set((s) => ({ sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: nowMs } }));
|
||||||
|
|
||||||
|
// Start the history poll and safety timeout IMMEDIATELY (before the
|
||||||
|
// RPC await) because the gateway's chat.send RPC may block until the
|
||||||
|
// entire agentic conversation finishes — the poll must run in parallel.
|
||||||
|
setLastChatEventAt(Date.now());
|
||||||
|
clearHistoryPoll();
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
|
||||||
|
const POLL_START_DELAY = 3_000;
|
||||||
|
const POLL_INTERVAL = 4_000;
|
||||||
|
const pollHistory = () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.sending) { clearHistoryPoll(); return; }
|
||||||
|
if (state.streamingMessage) {
|
||||||
|
setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.loadHistory(true);
|
||||||
|
setHistoryPollTimer(setTimeout(pollHistory, POLL_INTERVAL));
|
||||||
|
};
|
||||||
|
setHistoryPollTimer(setTimeout(pollHistory, POLL_START_DELAY));
|
||||||
|
|
||||||
|
const SAFETY_TIMEOUT_MS = 90_000;
|
||||||
|
const checkStuck = () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.sending) return;
|
||||||
|
if (state.streamingMessage || state.streamingText) return;
|
||||||
|
if (state.pendingFinal) {
|
||||||
|
setTimeout(checkStuck, 10_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - getLastChatEventAt() < SAFETY_TIMEOUT_MS) {
|
||||||
|
setTimeout(checkStuck, 10_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearHistoryPoll();
|
||||||
|
set({
|
||||||
|
error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.',
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setTimeout(checkStuck, 30_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idempotencyKey = crypto.randomUUID();
|
||||||
|
const hasMedia = attachments && attachments.length > 0;
|
||||||
|
if (hasMedia) {
|
||||||
|
console.log('[sendMessage] Media paths:', attachments!.map(a => a.stagedPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache image attachments BEFORE the IPC call to avoid race condition:
|
||||||
|
// history may reload (via Gateway event) before the RPC returns.
|
||||||
|
// Keyed by staged file path which appears in [media attached: <path> ...].
|
||||||
|
if (hasMedia && attachments) {
|
||||||
|
for (const a of attachments) {
|
||||||
|
upsertImageCacheEntry(a.stagedPath, {
|
||||||
|
fileName: a.fileName,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
fileSize: a.fileSize,
|
||||||
|
preview: a.preview,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: { success: boolean; result?: { runId?: string }; error?: string };
|
||||||
|
|
||||||
|
// Longer timeout for chat sends to tolerate high-latency networks (avoids connect error)
|
||||||
|
const CHAT_SEND_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
if (hasMedia) {
|
||||||
|
result = await invokeIpc(
|
||||||
|
'chat:sendWithMedia',
|
||||||
|
{
|
||||||
|
sessionKey: currentSessionKey,
|
||||||
|
message: trimmed || 'Process the attached file(s).',
|
||||||
|
deliver: false,
|
||||||
|
idempotencyKey,
|
||||||
|
media: attachments.map((a) => ({
|
||||||
|
filePath: a.stagedPath,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
fileName: a.fileName,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||||
|
} else {
|
||||||
|
result = await invokeIpc(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.send',
|
||||||
|
{
|
||||||
|
sessionKey: currentSessionKey,
|
||||||
|
message: trimmed,
|
||||||
|
deliver: false,
|
||||||
|
idempotencyKey,
|
||||||
|
},
|
||||||
|
CHAT_SEND_TIMEOUT_MS,
|
||||||
|
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
set({ error: result.error || 'Failed to send message', sending: false });
|
||||||
|
} else if (result.result?.runId) {
|
||||||
|
set({ activeRunId: result.result.runId });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearHistoryPoll();
|
||||||
|
set({ error: String(err), sending: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Abort active run ──
|
||||||
|
|
||||||
|
abortRun: async () => {
|
||||||
|
clearHistoryPoll();
|
||||||
|
clearErrorRecoveryTimer();
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] });
|
||||||
|
set({ streamingTools: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invokeIpc(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.abort',
|
||||||
|
{ sessionKey: currentSessionKey },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Handle incoming chat events from Gateway ──
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/stores/chat/runtime-ui-actions.ts
Normal file
16
src/stores/chat/runtime-ui-actions.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
|
||||||
|
|
||||||
|
export function createRuntimeUiActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'toggleThinking' | 'refresh' | 'clearError'> {
|
||||||
|
return {
|
||||||
|
toggleThinking: () => set((s) => ({ showThinking: !s.showThinking })),
|
||||||
|
|
||||||
|
// ── Refresh: reload history + sessions ──
|
||||||
|
|
||||||
|
refresh: async () => {
|
||||||
|
const { loadHistory, loadSessions } = get();
|
||||||
|
await Promise.all([loadHistory(), loadSessions()]);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
};
|
||||||
|
}
|
||||||
266
src/stores/chat/session-actions.ts
Normal file
266
src/stores/chat/session-actions.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||||
|
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||||
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
|
||||||
|
export function createSessionActions(
|
||||||
|
set: ChatSet,
|
||||||
|
get: ChatGet,
|
||||||
|
): Pick<SessionHistoryActions, 'loadSessions' | 'switchSession' | 'newSession' | 'deleteSession' | 'cleanupEmptySession'> {
|
||||||
|
return {
|
||||||
|
loadSessions: async () => {
|
||||||
|
try {
|
||||||
|
const result = await invokeIpc(
|
||||||
|
'gateway:rpc',
|
||||||
|
'sessions.list',
|
||||||
|
{}
|
||||||
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const data = result.result;
|
||||||
|
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
|
||||||
|
key: String(s.key || ''),
|
||||||
|
label: s.label ? String(s.label) : undefined,
|
||||||
|
displayName: s.displayName ? String(s.displayName) : undefined,
|
||||||
|
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
||||||
|
model: s.model ? String(s.model) : undefined,
|
||||||
|
})).filter((s: ChatSession) => s.key);
|
||||||
|
|
||||||
|
const canonicalBySuffix = new Map<string, string>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session.key.startsWith('agent:')) continue;
|
||||||
|
const parts = session.key.split(':');
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const suffix = parts.slice(2).join(':');
|
||||||
|
if (suffix && !canonicalBySuffix.has(suffix)) {
|
||||||
|
canonicalBySuffix.set(suffix, session.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate: if both short and canonical existed, keep canonical only
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const dedupedSessions = sessions.filter((s) => {
|
||||||
|
if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false;
|
||||||
|
if (seen.has(s.key)) return false;
|
||||||
|
seen.add(s.key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
|
||||||
|
if (!nextSessionKey.startsWith('agent:')) {
|
||||||
|
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
|
||||||
|
if (canonicalMatch) {
|
||||||
|
nextSessionKey = canonicalMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
|
||||||
|
// Current session not found in the backend list
|
||||||
|
const isNewEmptySession = get().messages.length === 0;
|
||||||
|
if (!isNewEmptySession) {
|
||||||
|
nextSessionKey = dedupedSessions[0].key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
|
||||||
|
? [
|
||||||
|
...dedupedSessions,
|
||||||
|
{ key: nextSessionKey, displayName: nextSessionKey },
|
||||||
|
]
|
||||||
|
: dedupedSessions;
|
||||||
|
|
||||||
|
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey });
|
||||||
|
|
||||||
|
if (currentSessionKey !== nextSessionKey) {
|
||||||
|
get().loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||||
|
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
|
||||||
|
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||||
|
if (sessionsToLabel.length > 0) {
|
||||||
|
void Promise.all(
|
||||||
|
sessionsToLabel.map(async (session) => {
|
||||||
|
try {
|
||||||
|
const r = await invokeIpc(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.history',
|
||||||
|
{ sessionKey: session.key, limit: 1000 },
|
||||||
|
) as { success: boolean; result?: Record<string, unknown> };
|
||||||
|
if (!r.success || !r.result) return;
|
||||||
|
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||||
|
const firstUser = msgs.find((m) => m.role === 'user');
|
||||||
|
const lastMsg = msgs[msgs.length - 1];
|
||||||
|
set((s) => {
|
||||||
|
const next: Partial<typeof s> = {};
|
||||||
|
if (firstUser) {
|
||||||
|
const labelText = getMessageText(firstUser.content).trim();
|
||||||
|
if (labelText) {
|
||||||
|
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||||
|
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastMsg?.timestamp) {
|
||||||
|
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch { /* ignore per-session errors */ }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load sessions:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Switch session ──
|
||||||
|
|
||||||
|
switchSession: (key: string) => {
|
||||||
|
const { currentSessionKey, messages } = get();
|
||||||
|
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||||
|
set((s) => ({
|
||||||
|
currentSessionKey: key,
|
||||||
|
messages: [],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
...(leavingEmpty ? {
|
||||||
|
sessions: s.sessions.filter((s) => s.key !== currentSessionKey),
|
||||||
|
sessionLabels: Object.fromEntries(
|
||||||
|
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
|
||||||
|
),
|
||||||
|
sessionLastActivity: Object.fromEntries(
|
||||||
|
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
|
||||||
|
),
|
||||||
|
} : {}),
|
||||||
|
}));
|
||||||
|
get().loadHistory();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Delete session ──
|
||||||
|
//
|
||||||
|
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
|
||||||
|
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
|
||||||
|
// Deletion is therefore a local-only UI operation: the session is removed from
|
||||||
|
// the sidebar list and its labels/activity maps are cleared. The underlying
|
||||||
|
// JSONL history file on disk is intentionally left intact, consistent with the
|
||||||
|
// newSession() design that avoids sessions.reset to preserve history.
|
||||||
|
|
||||||
|
deleteSession: async (key: string) => {
|
||||||
|
// Soft-delete the session's JSONL transcript on disk.
|
||||||
|
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||||
|
// sessions.list and token-usage queries both skip it automatically.
|
||||||
|
try {
|
||||||
|
const result = await invokeIpc('session:delete', key) as {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[deleteSession] IPC call failed for ${key}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentSessionKey, sessions } = get();
|
||||||
|
const remaining = sessions.filter((s) => s.key !== key);
|
||||||
|
|
||||||
|
if (currentSessionKey === key) {
|
||||||
|
// Switched away from deleted session — pick the first remaining or create new
|
||||||
|
const next = remaining[0];
|
||||||
|
set((s) => ({
|
||||||
|
sessions: remaining,
|
||||||
|
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
|
||||||
|
sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)),
|
||||||
|
messages: [],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
|
||||||
|
}));
|
||||||
|
if (next) {
|
||||||
|
get().loadHistory();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set((s) => ({
|
||||||
|
sessions: remaining,
|
||||||
|
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
|
||||||
|
sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── New session ──
|
||||||
|
|
||||||
|
newSession: () => {
|
||||||
|
// Generate a new unique session key and switch to it.
|
||||||
|
// NOTE: We intentionally do NOT call sessions.reset on the old session.
|
||||||
|
// sessions.reset archives (renames) the session JSONL file, making old
|
||||||
|
// conversation history inaccessible when the user switches back to it.
|
||||||
|
const { currentSessionKey, messages } = get();
|
||||||
|
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||||
|
const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX;
|
||||||
|
const newKey = `${prefix}:session-${Date.now()}`;
|
||||||
|
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
||||||
|
set((s) => ({
|
||||||
|
currentSessionKey: newKey,
|
||||||
|
sessions: [
|
||||||
|
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
|
||||||
|
newSessionEntry,
|
||||||
|
],
|
||||||
|
sessionLabels: leavingEmpty
|
||||||
|
? Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey))
|
||||||
|
: s.sessionLabels,
|
||||||
|
sessionLastActivity: leavingEmpty
|
||||||
|
? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey))
|
||||||
|
: s.sessionLastActivity,
|
||||||
|
messages: [],
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Cleanup empty session on navigate away ──
|
||||||
|
|
||||||
|
cleanupEmptySession: () => {
|
||||||
|
const { currentSessionKey, messages } = get();
|
||||||
|
// Only remove non-main sessions that were never used (no messages sent).
|
||||||
|
// This mirrors the "leavingEmpty" logic in switchSession so that creating
|
||||||
|
// a new session and immediately navigating away doesn't leave a ghost entry
|
||||||
|
// in the sidebar.
|
||||||
|
const isEmptyNonMain = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||||
|
if (!isEmptyNonMain) return;
|
||||||
|
set((s) => ({
|
||||||
|
sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey),
|
||||||
|
sessionLabels: Object.fromEntries(
|
||||||
|
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
|
||||||
|
),
|
||||||
|
sessionLastActivity: Object.fromEntries(
|
||||||
|
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Load chat history ──
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/stores/chat/session-history-actions.ts
Normal file
10
src/stores/chat/session-history-actions.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||||
|
import { createHistoryActions } from './history-actions';
|
||||||
|
import { createSessionActions } from './session-actions';
|
||||||
|
|
||||||
|
export function createSessionHistoryActions(set: ChatSet, get: ChatGet): SessionHistoryActions {
|
||||||
|
return {
|
||||||
|
...createSessionActions(set, get),
|
||||||
|
...createHistoryActions(set, get),
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/stores/chat/store-api.ts
Normal file
18
src/stores/chat/store-api.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ChatState } from './types';
|
||||||
|
|
||||||
|
export type ChatSet = (
|
||||||
|
partial: Partial<ChatState> | ((state: ChatState) => Partial<ChatState>),
|
||||||
|
replace?: false,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type ChatGet = () => ChatState;
|
||||||
|
|
||||||
|
export type SessionHistoryActions = Pick<
|
||||||
|
ChatState,
|
||||||
|
'loadSessions' | 'switchSession' | 'newSession' | 'deleteSession' | 'cleanupEmptySession' | 'loadHistory'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type RuntimeActions = Pick<
|
||||||
|
ChatState,
|
||||||
|
'sendMessage' | 'abortRun' | 'handleChatEvent' | 'toggleThinking' | 'refresh' | 'clearError'
|
||||||
|
>;
|
||||||
113
src/stores/chat/types.ts
Normal file
113
src/stores/chat/types.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/** Metadata for locally-attached files (not from Gateway) */
|
||||||
|
export interface AttachedFileMeta {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
preview: string | null;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw message from OpenClaw chat.history */
|
||||||
|
export interface RawMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||||
|
content: unknown; // string | ContentBlock[]
|
||||||
|
timestamp?: number;
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
details?: unknown;
|
||||||
|
isError?: boolean;
|
||||||
|
/** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */
|
||||||
|
_attachedFiles?: AttachedFileMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content block inside a message */
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
source?: { type: string; media_type?: string; data?: string; url?: string };
|
||||||
|
/** Flat image format from Gateway tool results (no source wrapper) */
|
||||||
|
data?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: unknown;
|
||||||
|
arguments?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session from sessions.list */
|
||||||
|
export interface ChatSession {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
displayName?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolStatus {
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'completed' | 'error';
|
||||||
|
durationMs?: number;
|
||||||
|
summary?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
// Messages
|
||||||
|
messages: RawMessage[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Streaming
|
||||||
|
sending: boolean;
|
||||||
|
activeRunId: string | null;
|
||||||
|
streamingText: string;
|
||||||
|
streamingMessage: unknown | null;
|
||||||
|
streamingTools: ToolStatus[];
|
||||||
|
pendingFinal: boolean;
|
||||||
|
lastUserMessageAt: number | null;
|
||||||
|
/** Images collected from tool results, attached to the next assistant message */
|
||||||
|
pendingToolImages: AttachedFileMeta[];
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
sessions: ChatSession[];
|
||||||
|
currentSessionKey: string;
|
||||||
|
/** First user message text per session key, used as display label */
|
||||||
|
sessionLabels: Record<string, string>;
|
||||||
|
/** Last message timestamp (ms) per session key, used for sorting */
|
||||||
|
sessionLastActivity: Record<string, number>;
|
||||||
|
|
||||||
|
// Thinking
|
||||||
|
showThinking: boolean;
|
||||||
|
thinkingLevel: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadSessions: () => Promise<void>;
|
||||||
|
switchSession: (key: string) => void;
|
||||||
|
newSession: () => void;
|
||||||
|
deleteSession: (key: string) => Promise<void>;
|
||||||
|
cleanupEmptySession: () => void;
|
||||||
|
loadHistory: (quiet?: boolean) => Promise<void>;
|
||||||
|
sendMessage: (
|
||||||
|
text: string,
|
||||||
|
attachments?: Array<{
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
stagedPath: string;
|
||||||
|
preview: string | null;
|
||||||
|
}>
|
||||||
|
) => Promise<void>;
|
||||||
|
abortRun: () => Promise<void>;
|
||||||
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
|
toggleThinking: () => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
|
||||||
|
export const DEFAULT_SESSION_KEY = `${DEFAULT_CANONICAL_PREFIX}:main`;
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
* Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC.
|
* Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC.
|
||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { createHostEventSource, hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import { subscribeHostEvent } from '@/lib/host-events';
|
||||||
import type { GatewayStatus } from '../types/gateway';
|
import type { GatewayStatus } from '../types/gateway';
|
||||||
|
|
||||||
let gatewayInitPromise: Promise<void> | null = null;
|
let gatewayInitPromise: Promise<void> | null = null;
|
||||||
let gatewayEventSource: EventSource | null = null;
|
let gatewayEventUnsubscribers: Array<() => void> | null = null;
|
||||||
|
|
||||||
interface GatewayHealth {
|
interface GatewayHealth {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -148,37 +149,39 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
const status = await hostApiFetch<GatewayStatus>('/api/gateway/status');
|
const status = await hostApiFetch<GatewayStatus>('/api/gateway/status');
|
||||||
set({ status, isInitialized: true });
|
set({ status, isInitialized: true });
|
||||||
|
|
||||||
if (!gatewayEventSource) {
|
if (!gatewayEventUnsubscribers) {
|
||||||
gatewayEventSource = createHostEventSource();
|
const unsubscribers: Array<() => void> = [];
|
||||||
gatewayEventSource.addEventListener('gateway:status', (event) => {
|
unsubscribers.push(subscribeHostEvent<GatewayStatus>('gateway:status', (payload) => {
|
||||||
set({ status: JSON.parse((event as MessageEvent).data) as GatewayStatus });
|
set({ status: payload });
|
||||||
});
|
}));
|
||||||
gatewayEventSource.addEventListener('gateway:error', (event) => {
|
unsubscribers.push(subscribeHostEvent<{ message?: string }>('gateway:error', (payload) => {
|
||||||
const payload = JSON.parse((event as MessageEvent).data) as { message?: string };
|
|
||||||
set({ lastError: payload.message || 'Gateway error' });
|
set({ lastError: payload.message || 'Gateway error' });
|
||||||
});
|
}));
|
||||||
gatewayEventSource.addEventListener('gateway:notification', (event) => {
|
unsubscribers.push(subscribeHostEvent<{ method?: string; params?: Record<string, unknown> }>(
|
||||||
handleGatewayNotification(JSON.parse((event as MessageEvent).data) as {
|
'gateway:notification',
|
||||||
method?: string;
|
(payload) => {
|
||||||
params?: Record<string, unknown>;
|
handleGatewayNotification(payload);
|
||||||
});
|
},
|
||||||
});
|
));
|
||||||
gatewayEventSource.addEventListener('gateway:chat-message', (event) => {
|
unsubscribers.push(subscribeHostEvent('gateway:chat-message', (payload) => {
|
||||||
handleGatewayChatMessage(JSON.parse((event as MessageEvent).data));
|
handleGatewayChatMessage(payload);
|
||||||
});
|
}));
|
||||||
gatewayEventSource.addEventListener('gateway:channel-status', (event) => {
|
unsubscribers.push(subscribeHostEvent<{ channelId?: string; status?: string }>(
|
||||||
import('./channels')
|
'gateway:channel-status',
|
||||||
.then(({ useChannelsStore }) => {
|
(update) => {
|
||||||
const update = JSON.parse((event as MessageEvent).data) as { channelId?: string; status?: string };
|
import('./channels')
|
||||||
if (!update.channelId || !update.status) return;
|
.then(({ useChannelsStore }) => {
|
||||||
const state = useChannelsStore.getState();
|
if (!update.channelId || !update.status) return;
|
||||||
const channel = state.channels.find((item) => item.type === update.channelId);
|
const state = useChannelsStore.getState();
|
||||||
if (channel) {
|
const channel = state.channels.find((item) => item.type === update.channelId);
|
||||||
state.updateChannel(channel.id, { status: mapChannelStatus(update.status) });
|
if (channel) {
|
||||||
}
|
state.updateChannel(channel.id, { status: mapChannelStatus(update.status) });
|
||||||
})
|
}
|
||||||
.catch(() => {});
|
})
|
||||||
});
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
));
|
||||||
|
gatewayEventUnsubscribers = unsubscribers;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize Gateway:', error);
|
console.error('Failed to initialize Gateway:', error);
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { invokeIpc } from '@/lib/api-client';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||||
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
// General
|
// General
|
||||||
@@ -28,7 +26,6 @@ interface SettingsState {
|
|||||||
proxyHttpsServer: string;
|
proxyHttpsServer: string;
|
||||||
proxyAllServer: string;
|
proxyAllServer: string;
|
||||||
proxyBypassRules: string;
|
proxyBypassRules: string;
|
||||||
gatewayTransportPreference: GatewayTransportPreference;
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: UpdateChannel;
|
updateChannel: UpdateChannel;
|
||||||
@@ -56,7 +53,6 @@ interface SettingsState {
|
|||||||
setProxyHttpsServer: (value: string) => void;
|
setProxyHttpsServer: (value: string) => void;
|
||||||
setProxyAllServer: (value: string) => void;
|
setProxyAllServer: (value: string) => void;
|
||||||
setProxyBypassRules: (value: string) => void;
|
setProxyBypassRules: (value: string) => void;
|
||||||
setGatewayTransportPreference: (value: GatewayTransportPreference) => void;
|
|
||||||
setUpdateChannel: (channel: UpdateChannel) => void;
|
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||||
setAutoCheckUpdate: (value: boolean) => void;
|
setAutoCheckUpdate: (value: boolean) => void;
|
||||||
setAutoDownloadUpdate: (value: boolean) => void;
|
setAutoDownloadUpdate: (value: boolean) => void;
|
||||||
@@ -84,7 +80,6 @@ const defaultSettings = {
|
|||||||
proxyHttpsServer: '',
|
proxyHttpsServer: '',
|
||||||
proxyAllServer: '',
|
proxyAllServer: '',
|
||||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||||
gatewayTransportPreference: 'ws-first' as GatewayTransportPreference,
|
|
||||||
updateChannel: 'stable' as UpdateChannel,
|
updateChannel: 'stable' as UpdateChannel,
|
||||||
autoCheckUpdate: true,
|
autoCheckUpdate: true,
|
||||||
autoDownloadUpdate: false,
|
autoDownloadUpdate: false,
|
||||||
@@ -142,10 +137,6 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }),
|
setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }),
|
||||||
setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }),
|
setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }),
|
||||||
setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }),
|
setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }),
|
||||||
setGatewayTransportPreference: (gatewayTransportPreference) => {
|
|
||||||
set({ gatewayTransportPreference });
|
|
||||||
void invokeIpc('settings:set', 'gatewayTransportPreference', gatewayTransportPreference).catch(() => {});
|
|
||||||
},
|
|
||||||
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||||
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||||
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import {
|
|||||||
registerTransportInvoker,
|
registerTransportInvoker,
|
||||||
unregisterTransportInvoker,
|
unregisterTransportInvoker,
|
||||||
clearTransportBackoff,
|
clearTransportBackoff,
|
||||||
|
getApiClientConfig,
|
||||||
|
applyGatewayTransportPreference,
|
||||||
|
createGatewayHttpTransportInvoker,
|
||||||
|
getGatewayWsDiagnosticEnabled,
|
||||||
|
setGatewayWsDiagnosticEnabled,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
|
|
||||||
describe('api-client', () => {
|
describe('api-client', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
window.localStorage.removeItem('clawx:gateway-ws-diagnostic');
|
||||||
configureApiClient({
|
configureApiClient({
|
||||||
enabled: { ws: false, http: false },
|
enabled: { ws: false, http: false },
|
||||||
rules: [{ matcher: /.*/, order: ['ipc'] }],
|
rules: [{ matcher: /.*/, order: ['ipc'] }],
|
||||||
@@ -150,4 +156,82 @@ describe('api-client', () => {
|
|||||||
expect(wsInvoker).toHaveBeenCalledTimes(2);
|
expect(wsInvoker).toHaveBeenCalledTimes(2);
|
||||||
expect(invoke).toHaveBeenCalledTimes(2);
|
expect(invoke).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults transport preference to ipc-only', () => {
|
||||||
|
applyGatewayTransportPreference();
|
||||||
|
const config = getApiClientConfig();
|
||||||
|
expect(config.enabled.ws).toBe(false);
|
||||||
|
expect(config.enabled.http).toBe(false);
|
||||||
|
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ipc'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables ws->http->ipc order when ws diagnostic is on', () => {
|
||||||
|
setGatewayWsDiagnosticEnabled(true);
|
||||||
|
expect(getGatewayWsDiagnosticEnabled()).toBe(true);
|
||||||
|
|
||||||
|
const config = getApiClientConfig();
|
||||||
|
expect(config.enabled.ws).toBe(true);
|
||||||
|
expect(config.enabled.http).toBe(true);
|
||||||
|
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses gateway:httpProxy unified envelope response', async () => {
|
||||||
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||||
|
invoke.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: { type: 'res', ok: true, payload: { rows: [1, 2] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoker = createGatewayHttpTransportInvoker();
|
||||||
|
const result = await invoker<{ success: boolean; result: { rows: number[] } }>(
|
||||||
|
'gateway:rpc',
|
||||||
|
['chat.history', { sessionKey: 's1' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.result.rows).toEqual([1, 2]);
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
'gateway:httpProxy',
|
||||||
|
expect.objectContaining({
|
||||||
|
path: '/rpc',
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws meaningful error when gateway:httpProxy unified envelope fails', async () => {
|
||||||
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||||
|
invoke.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'proxy unavailable' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoker = createGatewayHttpTransportInvoker();
|
||||||
|
await expect(invoker('gateway:rpc', ['chat.history', {}])).rejects.toThrow('proxy unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes raw gateway:httpProxy payload into ipc-style envelope', async () => {
|
||||||
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||||
|
invoke.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: { channels: [{ id: 'telegram-default' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoker = createGatewayHttpTransportInvoker();
|
||||||
|
const result = await invoker<{ success: boolean; result: { channels: Array<{ id: string }> } }>(
|
||||||
|
'gateway:rpc',
|
||||||
|
['channels.status', { probe: false }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.result.channels[0].id).toBe('telegram-default');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
166
tests/unit/chat-runtime-event-handlers.test.ts
Normal file
166
tests/unit/chat-runtime-event-handlers.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const clearErrorRecoveryTimer = vi.fn();
|
||||||
|
const clearHistoryPoll = vi.fn();
|
||||||
|
const collectToolUpdates = vi.fn(() => []);
|
||||||
|
const extractImagesAsAttachedFiles = vi.fn(() => []);
|
||||||
|
const extractMediaRefs = vi.fn(() => []);
|
||||||
|
const extractRawFilePaths = vi.fn(() => []);
|
||||||
|
const getMessageText = vi.fn(() => '');
|
||||||
|
const getToolCallFilePath = vi.fn(() => undefined);
|
||||||
|
const hasErrorRecoveryTimer = vi.fn(() => false);
|
||||||
|
const hasNonToolAssistantContent = vi.fn(() => true);
|
||||||
|
const isToolOnlyMessage = vi.fn(() => false);
|
||||||
|
const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult');
|
||||||
|
const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }) => ({
|
||||||
|
fileName: ref.filePath.split('/').pop() || 'file',
|
||||||
|
mimeType: ref.mimeType,
|
||||||
|
fileSize: 0,
|
||||||
|
preview: null,
|
||||||
|
filePath: ref.filePath,
|
||||||
|
}));
|
||||||
|
const setErrorRecoveryTimer = vi.fn();
|
||||||
|
const upsertToolStatuses = vi.fn((_current, updates) => updates);
|
||||||
|
|
||||||
|
vi.mock('@/stores/chat/helpers', () => ({
|
||||||
|
clearErrorRecoveryTimer: (...args: unknown[]) => clearErrorRecoveryTimer(...args),
|
||||||
|
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
||||||
|
collectToolUpdates: (...args: unknown[]) => collectToolUpdates(...args),
|
||||||
|
extractImagesAsAttachedFiles: (...args: unknown[]) => extractImagesAsAttachedFiles(...args),
|
||||||
|
extractMediaRefs: (...args: unknown[]) => extractMediaRefs(...args),
|
||||||
|
extractRawFilePaths: (...args: unknown[]) => extractRawFilePaths(...args),
|
||||||
|
getMessageText: (...args: unknown[]) => getMessageText(...args),
|
||||||
|
getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args),
|
||||||
|
hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args),
|
||||||
|
hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args),
|
||||||
|
isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args),
|
||||||
|
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
|
||||||
|
makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args),
|
||||||
|
setErrorRecoveryTimer: (...args: unknown[]) => setErrorRecoveryTimer(...args),
|
||||||
|
upsertToolStatuses: (...args: unknown[]) => upsertToolStatuses(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ChatLikeState = {
|
||||||
|
sending: boolean;
|
||||||
|
activeRunId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
streamingMessage: unknown | null;
|
||||||
|
streamingTools: unknown[];
|
||||||
|
messages: Array<Record<string, unknown>>;
|
||||||
|
pendingToolImages: unknown[];
|
||||||
|
pendingFinal: boolean;
|
||||||
|
lastUserMessageAt: number | null;
|
||||||
|
streamingText: string;
|
||||||
|
loadHistory: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||||
|
let state: ChatLikeState = {
|
||||||
|
sending: false,
|
||||||
|
activeRunId: null,
|
||||||
|
error: 'stale error',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
messages: [],
|
||||||
|
pendingToolImages: [],
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
streamingText: '',
|
||||||
|
loadHistory: vi.fn(),
|
||||||
|
...initial,
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
|
||||||
|
const next = typeof partial === 'function' ? partial(state) : partial;
|
||||||
|
state = { ...state, ...next };
|
||||||
|
};
|
||||||
|
const get = () => state;
|
||||||
|
return { set, get, read: () => state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat runtime event handlers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
hasErrorRecoveryTimer.mockReturnValue(false);
|
||||||
|
collectToolUpdates.mockReturnValue([]);
|
||||||
|
upsertToolStatuses.mockImplementation((_current, updates) => updates);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks sending on started event', async () => {
|
||||||
|
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||||
|
const h = makeHarness({ sending: false, activeRunId: null, error: 'err' });
|
||||||
|
|
||||||
|
handleRuntimeEventState(h.set as never, h.get as never, {}, 'started', 'run-1');
|
||||||
|
const next = h.read();
|
||||||
|
expect(next.sending).toBe(true);
|
||||||
|
expect(next.activeRunId).toBe('run-1');
|
||||||
|
expect(next.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies delta event and clears stale error when recovery timer exists', async () => {
|
||||||
|
hasErrorRecoveryTimer.mockReturnValue(true);
|
||||||
|
collectToolUpdates.mockReturnValue([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
|
||||||
|
|
||||||
|
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||||
|
const h = makeHarness({
|
||||||
|
error: 'old',
|
||||||
|
streamingTools: [],
|
||||||
|
streamingMessage: { role: 'assistant', content: 'old' },
|
||||||
|
});
|
||||||
|
const event = { message: { role: 'assistant', content: 'delta' } };
|
||||||
|
|
||||||
|
handleRuntimeEventState(h.set as never, h.get as never, event, 'delta', 'run-2');
|
||||||
|
const next = h.read();
|
||||||
|
expect(clearErrorRecoveryTimer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(next.error).toBeNull();
|
||||||
|
expect(next.streamingMessage).toEqual(event.message);
|
||||||
|
expect(next.streamingTools).toEqual([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads history when final event has no message', async () => {
|
||||||
|
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||||
|
const h = makeHarness();
|
||||||
|
|
||||||
|
handleRuntimeEventState(h.set as never, h.get as never, {}, 'final', 'run-3');
|
||||||
|
const next = h.read();
|
||||||
|
expect(next.pendingFinal).toBe(true);
|
||||||
|
expect(next.streamingMessage).toBeNull();
|
||||||
|
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles error event and finalizes immediately when not sending', async () => {
|
||||||
|
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||||
|
const h = makeHarness({ sending: false, activeRunId: 'r1', lastUserMessageAt: 123 });
|
||||||
|
|
||||||
|
handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'boom' }, 'error', 'r1');
|
||||||
|
const next = h.read();
|
||||||
|
expect(clearHistoryPoll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(next.error).toBe('boom');
|
||||||
|
expect(next.sending).toBe(false);
|
||||||
|
expect(next.activeRunId).toBeNull();
|
||||||
|
expect(next.lastUserMessageAt).toBeNull();
|
||||||
|
expect(next.streamingTools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears runtime state on aborted event', async () => {
|
||||||
|
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||||
|
const h = makeHarness({
|
||||||
|
sending: true,
|
||||||
|
activeRunId: 'r2',
|
||||||
|
streamingText: 'abc',
|
||||||
|
pendingFinal: true,
|
||||||
|
lastUserMessageAt: 5,
|
||||||
|
pendingToolImages: [{ fileName: 'x' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
handleRuntimeEventState(h.set as never, h.get as never, {}, 'aborted', 'r2');
|
||||||
|
const next = h.read();
|
||||||
|
expect(next.sending).toBe(false);
|
||||||
|
expect(next.activeRunId).toBeNull();
|
||||||
|
expect(next.streamingText).toBe('');
|
||||||
|
expect(next.pendingFinal).toBe(false);
|
||||||
|
expect(next.lastUserMessageAt).toBeNull();
|
||||||
|
expect(next.pendingToolImages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
123
tests/unit/chat-session-actions.test.ts
Normal file
123
tests/unit/chat-session-actions.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const invokeIpcMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ChatLikeState = {
|
||||||
|
currentSessionKey: string;
|
||||||
|
sessions: Array<{ key: string; displayName?: string }>;
|
||||||
|
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
||||||
|
sessionLabels: Record<string, string>;
|
||||||
|
sessionLastActivity: Record<string, number>;
|
||||||
|
streamingText: string;
|
||||||
|
streamingMessage: unknown | null;
|
||||||
|
streamingTools: unknown[];
|
||||||
|
activeRunId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
pendingFinal: boolean;
|
||||||
|
lastUserMessageAt: number | null;
|
||||||
|
pendingToolImages: unknown[];
|
||||||
|
loadHistory: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||||
|
let state: ChatLikeState = {
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
sessions: [{ key: 'agent:main:main' }],
|
||||||
|
messages: [],
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
streamingTools: [],
|
||||||
|
activeRunId: null,
|
||||||
|
error: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingToolImages: [],
|
||||||
|
loadHistory: vi.fn(),
|
||||||
|
...initial,
|
||||||
|
};
|
||||||
|
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
|
||||||
|
const patch = typeof partial === 'function' ? partial(state) : partial;
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
};
|
||||||
|
const get = () => state;
|
||||||
|
return { set, get, read: () => state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat session actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
invokeIpcMock.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchSession removes empty non-main leaving session and loads history', async () => {
|
||||||
|
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:foo:session-a',
|
||||||
|
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
|
||||||
|
messages: [],
|
||||||
|
sessionLabels: { 'agent:foo:session-a': 'A' },
|
||||||
|
sessionLastActivity: { 'agent:foo:session-a': 1 },
|
||||||
|
});
|
||||||
|
const actions = createSessionActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
actions.switchSession('agent:foo:main');
|
||||||
|
const next = h.read();
|
||||||
|
expect(next.currentSessionKey).toBe('agent:foo:main');
|
||||||
|
expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeUndefined();
|
||||||
|
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
|
||||||
|
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
|
||||||
|
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteSession updates current session and keeps sidebar consistent', async () => {
|
||||||
|
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:foo:session-a',
|
||||||
|
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
|
||||||
|
sessionLabels: { 'agent:foo:session-a': 'A' },
|
||||||
|
sessionLastActivity: { 'agent:foo:session-a': 1 },
|
||||||
|
messages: [{ role: 'user' }],
|
||||||
|
});
|
||||||
|
const actions = createSessionActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
await actions.deleteSession('agent:foo:session-a');
|
||||||
|
const next = h.read();
|
||||||
|
expect(invokeIpcMock).toHaveBeenCalledWith('session:delete', 'agent:foo:session-a');
|
||||||
|
expect(next.currentSessionKey).toBe('agent:foo:main');
|
||||||
|
expect(next.sessions.map((s) => s.key)).toEqual(['agent:foo:main']);
|
||||||
|
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
|
||||||
|
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
|
||||||
|
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('newSession creates a canonical session key and clears transient state', async () => {
|
||||||
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111);
|
||||||
|
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:foo:main',
|
||||||
|
sessions: [{ key: 'agent:foo:main' }],
|
||||||
|
messages: [{ role: 'assistant' }],
|
||||||
|
streamingText: 'streaming',
|
||||||
|
activeRunId: 'r1',
|
||||||
|
pendingFinal: true,
|
||||||
|
});
|
||||||
|
const actions = createSessionActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
actions.newSession();
|
||||||
|
const next = h.read();
|
||||||
|
expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111');
|
||||||
|
expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true);
|
||||||
|
expect(next.messages).toEqual([]);
|
||||||
|
expect(next.streamingText).toBe('');
|
||||||
|
expect(next.activeRunId).toBeNull();
|
||||||
|
expect(next.pendingFinal).toBe(false);
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
41
tests/unit/gateway-events.test.ts
Normal file
41
tests/unit/gateway-events.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const subscribeHostEventMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-events', () => ({
|
||||||
|
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('gateway store event wiring', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes to host events through subscribeHostEvent on init', async () => {
|
||||||
|
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||||
|
|
||||||
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||||
|
handlers.set(eventName, handler);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useGatewayStore } = await import('@/stores/gateway');
|
||||||
|
await useGatewayStore.getState().init();
|
||||||
|
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:status', expect.any(Function));
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:error', expect.any(Function));
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function));
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function));
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function));
|
||||||
|
|
||||||
|
handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 });
|
||||||
|
expect(useGatewayStore.getState().status.state).toBe('stopped');
|
||||||
|
});
|
||||||
|
});
|
||||||
88
tests/unit/host-api.test.ts
Normal file
88
tests/unit/host-api.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const invokeIpcMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('host-api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses IPC proxy and returns unified envelope json', async () => {
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: { success: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
|
const result = await hostApiFetch<{ success: boolean }>('/api/settings');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(invokeIpcMock).toHaveBeenCalledWith(
|
||||||
|
'hostapi:fetch',
|
||||||
|
expect.objectContaining({ path: '/api/settings', method: 'GET' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports legacy proxy envelope response', async () => {
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: { ok: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
|
const result = await hostApiFetch<{ ok: number }>('/api/settings');
|
||||||
|
expect(result.ok).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws proxy error from unified envelope', async () => {
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'No handler registered for hostapi:fetch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
|
await expect(hostApiFetch('/api/test')).rejects.toThrow('No handler registered for hostapi:fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws message from legacy non-ok envelope', async () => {
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: { error: 'Invalid Authentication' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
|
await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to browser fetch only when IPC channel is unavailable', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ fallback: true }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch'));
|
||||||
|
|
||||||
|
const { hostApiFetch } = await import('@/lib/host-api');
|
||||||
|
const result = await hostApiFetch<{ fallback: boolean }>('/api/test');
|
||||||
|
|
||||||
|
expect(result.fallback).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://127.0.0.1:3210/api/test',
|
||||||
|
expect.objectContaining({ headers: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/unit/host-events.test.ts
Normal file
74
tests/unit/host-events.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const addEventListenerMock = vi.fn();
|
||||||
|
const removeEventListenerMock = vi.fn();
|
||||||
|
const eventSourceMock = {
|
||||||
|
addEventListener: addEventListenerMock,
|
||||||
|
removeEventListener: removeEventListenerMock,
|
||||||
|
} as unknown as EventSource;
|
||||||
|
|
||||||
|
const createHostEventSourceMock = vi.fn(() => eventSourceMock);
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
createHostEventSource: () => createHostEventSourceMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('host-events', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes through IPC for mapped host events', async () => {
|
||||||
|
const onMock = vi.mocked(window.electron.ipcRenderer.on);
|
||||||
|
const offMock = vi.mocked(window.electron.ipcRenderer.off);
|
||||||
|
const captured: Array<(...args: unknown[]) => void> = [];
|
||||||
|
onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => {
|
||||||
|
captured.push(cb);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||||
|
const handler = vi.fn();
|
||||||
|
const unsubscribe = subscribeHostEvent('gateway:status', handler);
|
||||||
|
|
||||||
|
expect(onMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
|
||||||
|
expect(createHostEventSourceMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
captured[0]({ state: 'running' });
|
||||||
|
expect(handler).toHaveBeenCalledWith({ state: 'running' });
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use SSE fallback by default for unknown events', async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||||
|
const unsubscribe = subscribeHostEvent('unknown:event', vi.fn());
|
||||||
|
expect(createHostEventSourceMock).not.toHaveBeenCalled();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[host-events] no IPC mapping for event "unknown:event", SSE fallback disabled',
|
||||||
|
);
|
||||||
|
unsubscribe();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses SSE fallback only when explicitly enabled', async () => {
|
||||||
|
window.localStorage.setItem('clawx:allow-sse-fallback', '1');
|
||||||
|
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||||
|
const handler = vi.fn();
|
||||||
|
const unsubscribe = subscribeHostEvent('unknown:event', handler);
|
||||||
|
|
||||||
|
expect(createHostEventSourceMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
|
||||||
|
|
||||||
|
const listener = addEventListenerMock.mock.calls[0][1] as (event: Event) => void;
|
||||||
|
listener({ data: JSON.stringify({ x: 1 }) } as unknown as Event);
|
||||||
|
expect(handler).toHaveBeenCalledWith({ x: 1 });
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(removeEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
89
tests/unit/provider-model-sync.test.ts
Normal file
89
tests/unit/provider-model-sync.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildNonOAuthAgentProviderUpdate, getModelIdFromRef } from '@electron/main/provider-model-sync';
|
||||||
|
import type { ProviderConfig } from '@electron/utils/secure-storage';
|
||||||
|
|
||||||
|
function providerConfig(overrides: Partial<ProviderConfig>): ProviderConfig {
|
||||||
|
return {
|
||||||
|
id: 'provider-id',
|
||||||
|
name: 'Provider',
|
||||||
|
type: 'moonshot',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: '2026-03-09T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-09T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('provider-model-sync', () => {
|
||||||
|
it('extracts model ID from provider/model refs', () => {
|
||||||
|
expect(getModelIdFromRef('moonshot/kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
|
||||||
|
expect(getModelIdFromRef('kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
|
||||||
|
expect(getModelIdFromRef(undefined, 'moonshot')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds models.json update payload for moonshot default switch', () => {
|
||||||
|
const payload = buildNonOAuthAgentProviderUpdate(
|
||||||
|
providerConfig({ type: 'moonshot', id: 'moonshot' }),
|
||||||
|
'moonshot',
|
||||||
|
'moonshot/kimi-k2.5',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
providerKey: 'moonshot',
|
||||||
|
entry: {
|
||||||
|
baseUrl: 'https://api.moonshot.cn/v1',
|
||||||
|
api: 'openai-completions',
|
||||||
|
apiKey: 'MOONSHOT_API_KEY',
|
||||||
|
models: [{ id: 'kimi-k2.5', name: 'kimi-k2.5' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers provider custom baseUrl and omits models when modelRef is missing', () => {
|
||||||
|
const payload = buildNonOAuthAgentProviderUpdate(
|
||||||
|
providerConfig({
|
||||||
|
type: 'ark',
|
||||||
|
id: 'ark',
|
||||||
|
baseUrl: 'https://custom-ark.example.com/v3',
|
||||||
|
}),
|
||||||
|
'ark',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
providerKey: 'ark',
|
||||||
|
entry: {
|
||||||
|
baseUrl: 'https://custom-ark.example.com/v3',
|
||||||
|
api: 'openai-completions',
|
||||||
|
apiKey: 'ARK_API_KEY',
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for oauth and multi-instance providers', () => {
|
||||||
|
expect(
|
||||||
|
buildNonOAuthAgentProviderUpdate(
|
||||||
|
providerConfig({ type: 'qwen-portal', id: 'qwen-portal' }),
|
||||||
|
'qwen-portal',
|
||||||
|
'qwen-portal/coder-model',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildNonOAuthAgentProviderUpdate(
|
||||||
|
providerConfig({ type: 'custom', id: 'custom-123' }),
|
||||||
|
'custom-123',
|
||||||
|
'custom-123/model',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildNonOAuthAgentProviderUpdate(
|
||||||
|
providerConfig({ type: 'ollama', id: 'ollama' }),
|
||||||
|
'ollama',
|
||||||
|
'ollama/qwen3:latest',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user