mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
refactor: migrate to Streamdown and restore GPU rendering for performance, reduce Gateway restarts via optimized OpenClaw config delivery (#1224)
This commit is contained in:
@@ -21,12 +21,15 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
| Comms baseline refresh | `pnpm run comms:baseline` |
|
||||
| Comms regression compare | `pnpm run comms:compare` |
|
||||
| E2E tests (Playwright) | `pnpm run test:e2e` |
|
||||
| Chat performance profiles | `pnpm run perf:chat` |
|
||||
| Electron Main inspector | `pnpm run profile:main` |
|
||||
| Build frontend only | `pnpm run build:vite` |
|
||||
|
||||
### Non-obvious caveats
|
||||
|
||||
- **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing.
|
||||
- **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC).
|
||||
- **Performance profiling**: `pnpm run perf:chat` writes synthetic Renderer/Main CPU profiles and versioned metrics under ignored Playwright `test-results/`. For live Renderer CDP use `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev`; for live Main inspection use `pnpm run profile:main` and port 9229.
|
||||
- **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes.
|
||||
- **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore.
|
||||
- **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately.
|
||||
|
||||
+15
-3
@@ -101,13 +101,13 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、エージェント応答の Markdown レンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]` を含む)をサポートし、ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴に加え、シンタックスハイライト付きのフェンスコード、CJK 対応の解析、GitHub 風テーブル、KaTeX による LaTeX 数式(`$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]`)を含む、エージェント応答のストリーミング Markdown レンダリングをサポートします。ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行はソフトラップされ、ストリーミング完了後にローカライズされたコピー操作を利用できます。
|
||||
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx` と `.pptx` ファイルを読み取り専用でプレビューできます。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
|
||||
### ローカル HTML プレビュー
|
||||
Chat の右パネルにはワークスペース、プレビュー、変更だけがあり、汎用ウェブブラウザ、ホーム画面、アドレスバーはありません。許可済みのローカル `.html` / `.htm` 添付ファイル、ファイルアクティビティ、ワークスペースファイルは既定でプレビューに開きます。ファイル操作では ClawX 内蔵プレビューまたはシステムアプリを選択でき、プレビューのヘッダーから現在の HTML ファイルをシステムブラウザで開くこともできます。
|
||||
@@ -223,6 +223,8 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
|
||||
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||
|
||||
OpenClaw の設定配信も Electron Main が一元管理します。Gateway の実行中は `config.get` の正規スナップショットを基準にし、変更を `config.set` でコミットします。Gateway が停止中または起動中の場合は、同じコーディネーターが解決済みの JSON5 設定ファイルだけを更新し、Gateway を起動しません。そのため、通常の Provider、Agent、Channel、バインディング、Skill、モデル変更では Gateway プロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更、ユーザーによる明示的な操作、ヘルスチェックやクラッシュ復旧に限定されます。認証プロファイルを SQLite に書き込んだ後は OpenClaw の `secrets.reload` を呼び出し、実行中の Agent がプロセス再起動なしで新しい認証情報を読み取れるようにします。
|
||||
|
||||
Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。
|
||||
|
||||
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
|
||||
@@ -380,6 +382,8 @@ pnpm typecheck # TypeScriptの型チェック
|
||||
pnpm test # ユニットテストを実行
|
||||
pnpm run test:e2e # Electron E2E スモークテストを実行
|
||||
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
|
||||
pnpm run perf:chat # 合成 Chat の Renderer/Main CPU プロファイルを取得
|
||||
pnpm run profile:main # ビルド済みアプリを Main inspector の 9229 番ポートで起動
|
||||
pnpm run comms:replay # 通信リプレイ指標を算出
|
||||
pnpm run comms:baseline # 通信ベースラインを更新
|
||||
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
||||
@@ -395,6 +399,14 @@ pnpm package:linux # Linux向けにパッケージ化
|
||||
|
||||
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
|
||||
|
||||
### Electron パフォーマンス診断
|
||||
|
||||
`pnpm run perf:chat` は隔離された合成 ACP 負荷を実行し、ストリーミング応答と、リッチな静的 Markdown 会話でのサイドバーおよびスクロール操作を測定します。Playwright の `test-results/` には、バージョン付きメトリクスと Renderer/Main CPU プロファイルが出力されます。Renderer プロファイルは本番の store/render 経路とフレームペーシングを対象とします。ストリーミング Main プロファイルは Main から Renderer への IPC fanout を測定し、操作時の Main プロファイルは Renderer 操作中に Main がアイドルのままかを確認します。どちらも上流の OpenClaw/ACP サブプロセスや GPU プロセスの経路は含みません。CPU プロファイルは Chrome DevTools で開けます。アーティファクトには生成されたテスト文字列だけが含まれ、製品テレメトリーには送信されません。測定値はハードウェアに依存するため、共通の絶対閾値ではなく同じマシン上の複数回の結果を比較してください。
|
||||
|
||||
実際の Renderer を記録する場合は `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` で開発環境を起動し、Playwright または Chrome DevTools を `localhost:9223` に接続します。Electron Main を記録する場合は `pnpm run profile:main` を実行し、`chrome://inspect` で `localhost:9229` を設定して Electron Main target を選択します。WebSocket trace 自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE` は設定しないでください。
|
||||
|
||||
ClawX は Chromium のハードウェアアクセラレーションを既定で有効なままにし、長い文書、スクロール、レイアウトアニメーションで GPU コンポジットとラスタライズを利用します。グラフィックスドライバーに問題があるマシンでは、Chromium 標準の `--disable-gpu` コマンドラインスイッチをトラブルシューティング用のフォールバックとして利用できます。
|
||||
|
||||
### 通信回帰チェック
|
||||
|
||||
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||
|
||||
@@ -101,13 +101,13 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
|
||||
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
|
||||
|
||||
### 💬 Intelligent Chat Interface
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered as streaming Markdown with syntax-highlighted fenced code, CJK-aware parsing, GitHub-flavored tables, and KaTeX-powered LaTeX math (`$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes.
|
||||
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
|
||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
|
||||
### Local HTML Preview
|
||||
The Chat right panel has Workspace, Preview, and Changes tabs; it no longer includes a general Web Browser, Home page, or address bar. Authorized local `.html` and `.htm` attachments, file activities, and Workspace files open in Preview by default. Their file actions let you choose the built-in Preview or a system application, and the Preview header can open the current HTML file in the system browser.
|
||||
@@ -226,6 +226,8 @@ Notes:
|
||||
|
||||
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
|
||||
|
||||
Electron Main also owns OpenClaw configuration delivery. While the Gateway is running, ClawX reads the authoritative `config.get` snapshot and commits changes with `config.set`; while it is stopped or starting, the same coordinator updates the resolved JSON5 config file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts remain for process-launch environment changes such as proxy settings, explicit user actions, and health or crash recovery. Auth-profile SQLite updates use OpenClaw's `secrets.reload` RPC so running agents see new credentials without a process restart.
|
||||
|
||||
Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
|
||||
|
||||
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
|
||||
@@ -383,6 +385,8 @@ pnpm typecheck # TypeScript validation
|
||||
pnpm test # Run unit tests
|
||||
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
|
||||
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
|
||||
pnpm run perf:chat # Capture synthetic Chat Renderer/Main CPU profiles
|
||||
pnpm run profile:main # Launch the built app with Main inspector on port 9229
|
||||
pnpm run comms:replay # Compute communication replay metrics
|
||||
pnpm run comms:baseline # Refresh communication baseline snapshot
|
||||
pnpm run comms:compare # Compare replay metrics against baseline thresholds
|
||||
@@ -398,6 +402,14 @@ pnpm package:linux # Package for Linux
|
||||
|
||||
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
### Electron Performance Diagnostics
|
||||
|
||||
`pnpm run perf:chat` runs isolated synthetic ACP workloads for streaming and for rich static Markdown sidebar/scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. The Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout; the interaction Main profile shows whether Main remains idle while Renderer interactions run. Neither includes the upstream OpenClaw/ACP subprocess or GPU-process paths. Open a CPU profile in Chrome DevTools; the artifacts contain generated fixture text only and are not product telemetry. Results are hardware-dependent, so compare repeated runs on the same machine instead of applying one cross-platform absolute threshold.
|
||||
|
||||
For a live Renderer recording, start development with `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` and attach Playwright or Chrome DevTools to `localhost:9223`. For a live Electron Main recording, run `pnpm run profile:main`, open `chrome://inspect`, configure `localhost:9229`, and select the Electron Main target. Leave `CLAWX_GATEWAY_WS_TRACE` unset unless WebSocket tracing itself is being measured.
|
||||
|
||||
ClawX leaves Chromium hardware acceleration enabled by default so long documents, scrolling, and layout animations can use GPU compositing and rasterization. Chromium still honors the native `--disable-gpu` command-line switch as a troubleshooting fallback for a machine with a broken graphics driver.
|
||||
|
||||
### Communication Regression Checks
|
||||
|
||||
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
|
||||
|
||||
+15
-3
@@ -102,13 +102,13 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、以 Markdown 渲染智能体回复(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`),用户输入则始终按原始文本显示;同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录,并以流式 Markdown 渲染智能体回复,支持带语法高亮的围栏代码块、面向中日韩文本的解析、GitHub 风格表格,以及由 KaTeX 渲染的 LaTeX 数学公式(`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`);用户输入则始终按原始文本显示。同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。围栏代码会保留源码换行、自动软换行,并在流式输出结束后提供本地化的复制操作。
|
||||
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx` 和 `.pptx` 文件。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
|
||||
### 本地 HTML 预览
|
||||
Chat 右侧面板只包含工作空间、预览和变更,不再提供通用网页浏览器、主页或地址栏。已授权的本地 `.html` 和 `.htm` 附件、文件活动及工作空间文件默认在预览中打开。文件操作可以选择 ClawX 内置预览或系统应用,预览标题栏也可将当前 HTML 文件交给系统浏览器打开。
|
||||
@@ -227,6 +227,8 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化、用户显式操作以及健康检查或崩溃恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
|
||||
|
||||
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
|
||||
|
||||
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
|
||||
@@ -384,6 +386,8 @@ pnpm typecheck # TypeScript 类型检查
|
||||
pnpm test # 运行单元测试
|
||||
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
|
||||
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
|
||||
pnpm run perf:chat # 采集合成 Chat 场景的 Renderer/Main CPU Profile
|
||||
pnpm run profile:main # 启动构建产物并在 9229 端口调试 Main
|
||||
pnpm run comms:replay # 计算通信回放指标
|
||||
pnpm run comms:baseline # 刷新通信基线快照
|
||||
pnpm run comms:compare # 将回放指标与基线阈值对比
|
||||
@@ -399,6 +403,14 @@ pnpm package:linux # 为 Linux 打包
|
||||
|
||||
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
|
||||
|
||||
### Electron 性能诊断
|
||||
|
||||
`pnpm run perf:chat` 会运行隔离的合成 ACP 负载,分别覆盖流式响应,以及富 Markdown 静态会话中的侧栏和滚动交互,并在 Playwright 的 `test-results/` 目录输出版本化指标与 Renderer/Main CPU Profile。Renderer Profile 覆盖生产 store/render 路径和帧节奏;流式 Main Profile 测量 Main 到 Renderer 的 IPC fanout,交互 Main Profile 用于确认 Renderer 交互期间 Main 是否保持空闲。两者都不包含上游 OpenClaw/ACP 子进程或 GPU 进程路径。CPU Profile 可直接用 Chrome DevTools 打开;其中只包含生成的测试文本,不会上报为产品遥测。性能数据依赖硬件,应在同一机器上多次运行后对比,不应使用统一的跨平台绝对阈值。
|
||||
|
||||
录制真实 Renderer 时,使用 `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` 启动开发环境,再让 Playwright 或 Chrome DevTools 连接 `localhost:9223`。录制真实 Electron Main 时,运行 `pnpm run profile:main`,在 `chrome://inspect` 中配置 `localhost:9229` 并选择 Electron Main target。除非正在测量 WebSocket trace 本身,否则不要设置 `CLAWX_GATEWAY_WS_TRACE`。
|
||||
|
||||
ClawX 默认保留 Chromium 硬件加速,使长文档、滚动和布局动画能够使用 GPU 合成与光栅化。若某台机器的显卡驱动存在问题,仍可使用 Chromium 原生的 `--disable-gpu` 命令行参数作为排障回退。
|
||||
|
||||
### 通信回归检查
|
||||
|
||||
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* Gateway WebSocket Client
|
||||
* Provides a typed interface for Gateway RPC calls
|
||||
*/
|
||||
import { GatewayManager, GatewayStatus } from './manager';
|
||||
|
||||
/**
|
||||
* Channel types supported by OpenClaw
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel status
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
lastActivity?: string;
|
||||
error?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill definition
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
configurable?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill bundle definition
|
||||
*/
|
||||
export interface SkillBundle {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
icon?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron task definition
|
||||
*/
|
||||
export interface CronTask {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
lastRun?: string;
|
||||
nextRun?: string;
|
||||
status: 'idle' | 'running' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Client
|
||||
* Typed wrapper around GatewayManager for making RPC calls
|
||||
*/
|
||||
export class GatewayClient {
|
||||
constructor(private manager: GatewayManager) { }
|
||||
|
||||
/**
|
||||
* Get current gateway status
|
||||
*/
|
||||
getStatus(): GatewayStatus {
|
||||
return this.manager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gateway is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.manager.isConnected();
|
||||
}
|
||||
|
||||
// ==================== Channel Methods ====================
|
||||
|
||||
/**
|
||||
* List all channels
|
||||
*/
|
||||
async listChannels(): Promise<Channel[]> {
|
||||
return this.manager.rpc<Channel[]>('channels.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID
|
||||
*/
|
||||
async getChannel(channelId: string): Promise<Channel> {
|
||||
return this.manager.rpc<Channel>('channels.get', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a channel
|
||||
*/
|
||||
async connectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.connect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a channel
|
||||
*/
|
||||
async disconnectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.disconnect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for channel connection (e.g., WhatsApp)
|
||||
*/
|
||||
async getChannelQRCode(channelType: ChannelType): Promise<string> {
|
||||
return this.manager.rpc<string>('channels.getQRCode', { channelType });
|
||||
}
|
||||
|
||||
// ==================== Skill Methods ====================
|
||||
|
||||
/**
|
||||
* List all skills
|
||||
*/
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.manager.rpc<Skill[]>('skills.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a skill
|
||||
*/
|
||||
async enableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.enable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a skill
|
||||
*/
|
||||
async disableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.disable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill configuration
|
||||
*/
|
||||
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill configuration
|
||||
*/
|
||||
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
|
||||
}
|
||||
|
||||
// ==================== Chat Methods ====================
|
||||
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
|
||||
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history
|
||||
*/
|
||||
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chat history
|
||||
*/
|
||||
async clearChatHistory(): Promise<void> {
|
||||
return this.manager.rpc<void>('chat.clear');
|
||||
}
|
||||
|
||||
// ==================== Cron Methods ====================
|
||||
|
||||
/**
|
||||
* List all cron tasks
|
||||
*/
|
||||
async listCronTasks(): Promise<CronTask[]> {
|
||||
return this.manager.rpc<CronTask[]>('cron.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cron task
|
||||
*/
|
||||
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.create', task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a cron task
|
||||
*/
|
||||
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cron task
|
||||
*/
|
||||
async deleteCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.delete', { taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a cron task immediately
|
||||
*/
|
||||
async runCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.run', { taskId });
|
||||
}
|
||||
|
||||
// ==================== Provider Methods ====================
|
||||
|
||||
/**
|
||||
* List configured AI providers
|
||||
*/
|
||||
async listProviders(): Promise<ProviderConfig[]> {
|
||||
return this.manager.rpc<ProviderConfig[]>('providers.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a provider
|
||||
*/
|
||||
async setProvider(provider: ProviderConfig): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.set', provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
*/
|
||||
async removeProvider(providerId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.remove', { providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
|
||||
}
|
||||
|
||||
// ==================== System Methods ====================
|
||||
|
||||
/**
|
||||
* Get Gateway health status
|
||||
*/
|
||||
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
|
||||
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway configuration
|
||||
*/
|
||||
async getConfig(): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('system.config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Gateway configuration
|
||||
*/
|
||||
async updateConfig(config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('system.updateConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway version info
|
||||
*/
|
||||
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
|
||||
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available skill bundles
|
||||
*/
|
||||
async getSkillBundles(): Promise<SkillBundle[]> {
|
||||
return this.manager.rpc<SkillBundle[]>('skills.bundles');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill bundle
|
||||
*/
|
||||
async installBundle(bundleId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.installBundle', { bundleId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import JSON5 from 'json5';
|
||||
import type { GatewayManager } from './manager';
|
||||
import { withConfigLock } from '../utils/config-mutex';
|
||||
import { resolveOpenClawConfigPath } from '../utils/paths';
|
||||
|
||||
export type OpenClawConfig = Record<string, unknown>;
|
||||
/** Mutators may be replayed after a compare-and-swap conflict and must not perform external writes. */
|
||||
export type OpenClawConfigMutator = (
|
||||
config: OpenClawConfig,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type ConfigDeliveryGatewayManager = Pick<GatewayManager, 'getStatus' | 'rpc'>;
|
||||
|
||||
interface ConfigSnapshot {
|
||||
config?: unknown;
|
||||
raw?: unknown;
|
||||
hash?: unknown;
|
||||
}
|
||||
|
||||
interface ActiveMutationContext {
|
||||
config: OpenClawConfig;
|
||||
active: boolean;
|
||||
sourceExists: boolean;
|
||||
}
|
||||
|
||||
export interface OpenClawConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface FileConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
raw: string | undefined;
|
||||
}
|
||||
|
||||
let gatewayManager: ConfigDeliveryGatewayManager | undefined;
|
||||
let transactionTail: Promise<void> = Promise.resolve();
|
||||
const activeMutation = new AsyncLocalStorage<ActiveMutationContext>();
|
||||
|
||||
function parseConfig(raw: string): OpenClawConfig {
|
||||
const parsed = JSON5.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('OpenClaw config must be an object');
|
||||
}
|
||||
return parsed as OpenClawConfig;
|
||||
}
|
||||
|
||||
function serializeConfig(config: OpenClawConfig): string {
|
||||
return `${JSON.stringify(config, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function parseRunningConfigSnapshot(snapshot: ConfigSnapshot | undefined): OpenClawConfig {
|
||||
if (snapshot?.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) {
|
||||
return structuredClone(snapshot.config) as OpenClawConfig;
|
||||
}
|
||||
const raw = typeof snapshot?.raw === 'string' ? snapshot.raw : '';
|
||||
if (!raw.trim()) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
return parseConfig(raw);
|
||||
}
|
||||
|
||||
function isBaseHashConflict(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /config changed since last load; re-run config\.get and retry/i.test(message);
|
||||
}
|
||||
|
||||
async function mutateRunningConfig(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
const hash = typeof snapshot?.hash === 'string' ? snapshot.hash.trim() : '';
|
||||
if (!hash) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
|
||||
const config = parseRunningConfigSnapshot(snapshot);
|
||||
if (!await applyMutator(config, mutator, true)) return false;
|
||||
|
||||
try {
|
||||
await manager.rpc('config.set', {
|
||||
raw: serializeConfig(config),
|
||||
baseHash: hash,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isBaseHashConflict(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyMutator(
|
||||
config: OpenClawConfig,
|
||||
mutator: OpenClawConfigMutator,
|
||||
sourceExists: boolean,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(config);
|
||||
const context: ActiveMutationContext = { config, active: true, sourceExists };
|
||||
try {
|
||||
await activeMutation.run(context, async () => await mutator(config));
|
||||
} finally {
|
||||
context.active = false;
|
||||
}
|
||||
return !isDeepStrictEqual(config, baseline);
|
||||
}
|
||||
|
||||
async function applyNestedMutator(
|
||||
context: ActiveMutationContext,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(context.config);
|
||||
await mutator(context.config);
|
||||
return !isDeepStrictEqual(context.config, baseline);
|
||||
}
|
||||
|
||||
async function readFileConfig(configPath: string): Promise<FileConfigSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(configPath, 'utf8');
|
||||
return { config: parseConfig(raw), raw };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { config: {}, raw: undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileRaw(configPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(configPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemporaryFile(temporaryPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(temporaryPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateFileConfig(
|
||||
manager: ConfigDeliveryGatewayManager | undefined,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
return await withConfigLock(async () => {
|
||||
const configPath = resolveOpenClawConfigPath();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await readFileConfig(configPath);
|
||||
const changed = await applyMutator(snapshot.config, mutator, snapshot.raw !== undefined);
|
||||
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (!changed) return false;
|
||||
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, serializeConfig(snapshot.config), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
try {
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
|
||||
const currentRaw = await readFileRaw(configPath);
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (currentRaw !== snapshot.raw) {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error('OpenClaw config changed during file mutation; retry the mutation');
|
||||
}
|
||||
|
||||
await rename(temporaryPath, configPath);
|
||||
return true;
|
||||
} finally {
|
||||
await removeTemporaryFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMutation(mutator: OpenClawConfigMutator): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
return await mutateFileConfig(manager, mutator);
|
||||
}
|
||||
|
||||
async function runRead(): Promise<OpenClawConfigSnapshot> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
return { config: parseRunningConfigSnapshot(snapshot), exists: true };
|
||||
}
|
||||
|
||||
const snapshot = await readFileConfig(resolveOpenClawConfigPath());
|
||||
return { config: snapshot.config, exists: snapshot.raw !== undefined };
|
||||
}
|
||||
|
||||
async function runSecretsReload(): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state !== 'running') return false;
|
||||
await manager.rpc('secrets.reload', {});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerOpenClawConfigCoordinator(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
): void {
|
||||
gatewayManager = manager;
|
||||
}
|
||||
|
||||
export function mutateOpenClawConfig(
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return applyNestedMutator(context, mutator);
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runMutation(mutator),
|
||||
() => runMutation(mutator),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function readOpenClawConfigSnapshot(): Promise<OpenClawConfigSnapshot> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return Promise.resolve({
|
||||
config: structuredClone(context.config),
|
||||
exists: context.sourceExists,
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runRead(),
|
||||
() => runRead(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function reloadOpenClawSecretsIfRunning(): Promise<boolean> {
|
||||
const transaction = transactionTail.then(
|
||||
() => runSecretsReload(),
|
||||
() => runSecretsReload(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function resetOpenClawConfigCoordinatorForTests(): void {
|
||||
gatewayManager = undefined;
|
||||
transactionTail = Promise.resolve();
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { safeRmSync } from '../utils/safe-fs';
|
||||
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
|
||||
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
@@ -196,7 +196,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
safeRmSync(fsPath(targetDir));
|
||||
cpSyncSafe(bundledDir, targetDir);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
||||
succeeded = false;
|
||||
@@ -205,7 +204,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
// Same version already installed — still patch manifest ID in case it was
|
||||
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -219,7 +217,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
// Skip only if installed AND same version — but still patch manifest ID.
|
||||
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -229,7 +226,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
|
||||
succeeded = false;
|
||||
@@ -268,7 +264,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void {
|
||||
async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise<void> {
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
@@ -276,7 +272,7 @@ function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: stri
|
||||
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
|
||||
// when the channel is no longer configured, so reconcile this on every
|
||||
// launch rather than hiding it behind the directory-maintenance cache.
|
||||
removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
await removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,9 +536,9 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
// Always refresh trusted install metadata through ClawX — this must not
|
||||
// be skipped when plugin-maintenance is cache-hit, otherwise official
|
||||
// external plugins like WhatsApp fail openKeyedStore at runtime.
|
||||
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
|
||||
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
repairTrustedOfficialPluginInstallRecords();
|
||||
await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => {
|
||||
await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
await repairTrustedOfficialPluginInstallRecords();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to auto-upgrade plugins:', err);
|
||||
|
||||
@@ -27,7 +27,6 @@ export function dispatchProtocolEvent(
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
@@ -53,14 +52,17 @@ export function dispatchJsonRpcNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
if (notification.method === 'agent') {
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
} else {
|
||||
emitter.emit('notification', notification);
|
||||
}
|
||||
switch (notification.method) {
|
||||
case 'agent':
|
||||
break;
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
|
||||
break;
|
||||
|
||||
@@ -50,11 +50,6 @@ import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycl
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import { GatewayRestartController } from './restart-controller';
|
||||
import { GatewayRestartGovernor } from './restart-governor';
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
type GatewayReloadPolicy,
|
||||
} from './reload-policy';
|
||||
import {
|
||||
classifyGatewayStderrMessage,
|
||||
GATEWAY_STARTUP_SLOW_STAGE_MS,
|
||||
@@ -195,16 +190,11 @@ export class GatewayManager extends EventEmitter {
|
||||
private readonly lifecycleController = new GatewayLifecycleController();
|
||||
private readonly restartController = new GatewayRestartController();
|
||||
private readonly restartGovernor = new GatewayRestartGovernor();
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null;
|
||||
private upgradeSnapshotCleanupAttempted = false;
|
||||
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
private reloadPolicyLoadedAt = 0;
|
||||
private reloadPolicyRefreshPromise: Promise<void> | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 4;
|
||||
@@ -343,8 +333,6 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info(`Gateway start requested (port=${this.status.port})`);
|
||||
this.lastSpawnSummary = null;
|
||||
this.shouldReconnect = true;
|
||||
await this.refreshReloadPolicy(true);
|
||||
|
||||
// Lazily load device identity (async file I/O + key generation).
|
||||
// Must happen before connect() which uses the identity for the handshake.
|
||||
await this.initDeviceIdentity();
|
||||
@@ -687,138 +675,6 @@ export class GatewayManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Gateway process to reload config in-place when possible.
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.refreshReloadPolicy();
|
||||
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`,
|
||||
);
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.restartController.isRestartDeferred({
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
})) {
|
||||
this.restartController.markDeferredRestart('reload', {
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.process?.pid;
|
||||
logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`);
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running');
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedForMs = this.status.connectedAt
|
||||
? Date.now() - this.status.connectedAt
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`,
|
||||
);
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows does not support SIGUSR1 for in-process reload.
|
||||
// Fall back to a full restart. The connectedForMs < 8000 guard above
|
||||
// already skips unnecessary restarts for recently-started processes.
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGUSR1');
|
||||
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||
// If process state doesn't recover quickly, fall back to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy');
|
||||
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
} else {
|
||||
const pidAfter = this.process.pid;
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error');
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs?: number): void {
|
||||
void this.refreshReloadPolicy();
|
||||
const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs;
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.debug(
|
||||
`Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`,
|
||||
);
|
||||
this.debouncedRestart(effectiveDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
private async refreshReloadPolicy(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadPolicyRefreshPromise) {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadPolicyRefreshPromise = (async () => {
|
||||
const nextPolicy = await loadGatewayReloadPolicy();
|
||||
this.reloadPolicy = nextPolicy;
|
||||
this.reloadPolicyLoadedAt = Date.now();
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
} finally {
|
||||
this.reloadPolicyRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active timers
|
||||
*/
|
||||
@@ -829,10 +685,6 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
this.connectionMonitor.clear();
|
||||
this.restartController.clearDebounceTimer();
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
this.resetGatewayReadyFallback();
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
|
||||
|
||||
export type GatewayReloadPolicy = {
|
||||
mode: GatewayReloadMode;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
|
||||
mode: 'hybrid',
|
||||
debounceMs: 1200,
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const MAX_DEBOUNCE_MS = 60_000;
|
||||
|
||||
function normalizeMode(value: unknown): GatewayReloadMode {
|
||||
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
|
||||
}
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) return 0;
|
||||
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
const root = config as Record<string, unknown>;
|
||||
const gateway = (root.gateway && typeof root.gateway === 'object'
|
||||
? root.gateway
|
||||
: {}) as Record<string, unknown>;
|
||||
const reload = (gateway.reload && typeof gateway.reload === 'object'
|
||||
? gateway.reload
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
mode: normalizeMode(reload.mode),
|
||||
debounceMs: normalizeDebounceMs(reload.debounceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return parseGatewayReloadPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
|
||||
|
||||
type QueuedRpc = {
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => (
|
||||
`${JSON.stringify(key)}:${stableStringify(record[key])}`
|
||||
)).join(',')}}`;
|
||||
}
|
||||
|
||||
export interface GatewayRpcBackpressureOptions {
|
||||
maxConcurrentHistory?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents renderer fan-out from forwarding an unbounded number of expensive
|
||||
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
|
||||
* this class only coalesces duplicate in-flight history calls and runs distinct
|
||||
* history requests through a small FIFO queue.
|
||||
*/
|
||||
export class GatewayRpcBackpressure {
|
||||
private readonly maxConcurrentHistory: number;
|
||||
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
|
||||
private readonly queue: QueuedRpc[] = [];
|
||||
private activeHistory = 0;
|
||||
|
||||
constructor(options: GatewayRpcBackpressureOptions = {}) {
|
||||
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
|
||||
}
|
||||
|
||||
run(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
runner: GatewayRpcRunner,
|
||||
): Promise<unknown> {
|
||||
if (method !== 'chat.history') {
|
||||
return runner(method, params, timeoutMs);
|
||||
}
|
||||
|
||||
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
|
||||
const existing = this.inFlightHistory.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
|
||||
.finally(() => {
|
||||
if (this.inFlightHistory.get(key) === promise) {
|
||||
this.inFlightHistory.delete(key);
|
||||
}
|
||||
});
|
||||
this.inFlightHistory.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
|
||||
return {
|
||||
activeHistory: this.activeHistory,
|
||||
queuedHistory: this.queue.length,
|
||||
inFlightHistory: this.inFlightHistory.size,
|
||||
};
|
||||
}
|
||||
|
||||
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queued: QueuedRpc = {
|
||||
run: async () => {
|
||||
this.activeHistory += 1;
|
||||
try {
|
||||
resolve(await work());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.activeHistory -= 1;
|
||||
this.drain();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.queue.push(queued);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
while (this.activeHistory < this.maxConcurrentHistory) {
|
||||
const next = this.queue.shift();
|
||||
if (!next) return;
|
||||
void next.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const SECRET_KEYS = new Set([
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
]);
|
||||
const CONFIG_WRITE_METHODS = new Set(['config.set', 'config.patch', 'config.apply']);
|
||||
|
||||
export function isGatewayWsTraceEnabled(): boolean {
|
||||
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
|
||||
@@ -22,9 +23,17 @@ export function redactGatewayFrameForTrace(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const redactConfigRaw = typeof record.method === 'string' && CONFIG_WRITE_METHODS.has(record.method);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (redactConfigRaw && key === 'params' && item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const params = redactGatewayFrameForTrace(item) as Record<string, unknown>;
|
||||
if (Object.hasOwn(params, 'raw')) params.raw = '[redacted]';
|
||||
result[key] = params;
|
||||
continue;
|
||||
}
|
||||
result[key] = SECRET_KEYS.has(normalizedKey)
|
||||
? '[redacted]'
|
||||
: redactGatewayFrameForTrace(item);
|
||||
|
||||
+2
-16
@@ -5,6 +5,7 @@
|
||||
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerOpenClawConfigCoordinator } from '../gateway/config-delivery';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
@@ -67,22 +68,6 @@ if (isE2EMode && requestedUserDataDir) {
|
||||
app.setPath('userData', requestedUserDataDir);
|
||||
}
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
//
|
||||
// Rationale (following VS Code's philosophy):
|
||||
// - Page/file loading is async data fetching — zero GPU dependency.
|
||||
// - The original per-platform GPU branching was added to avoid CPU rendering
|
||||
// competing with sync I/O on Windows, but all file I/O is now async
|
||||
// (fs/promises), so that concern no longer applies.
|
||||
// - Software rendering is deterministic across all hardware; GPU compositing
|
||||
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
|
||||
// driver versions, making it the #1 source of rendering bugs in Electron.
|
||||
//
|
||||
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
|
||||
// set `"disable-hardware-acceleration": false` in the app config (future).
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
|
||||
// On Wayland this maps the running window to clawx.desktop (→ icon + app grouping);
|
||||
// on X11 it supplements the StartupWMClass matching.
|
||||
@@ -604,6 +589,7 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
clawHubService = new ClawHubService();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
|
||||
@@ -25,8 +25,6 @@ import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
@@ -42,7 +40,6 @@ import {
|
||||
} from '../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
||||
import { appUpdater } from './updater';
|
||||
import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
|
||||
import { HostApiRegistry, registerHostInvokeHandler } from './ipc/host-invoke';
|
||||
import { createAppApi } from '../services/app-api';
|
||||
import { createOpenClawApi } from '../services/openclaw-api';
|
||||
@@ -78,8 +75,6 @@ import {
|
||||
} from './ipc/request-helpers';
|
||||
import { createMenu } from './menu';
|
||||
|
||||
const gatewayRpcBackpressure = new GatewayRpcBackpressure();
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
@@ -167,7 +162,7 @@ function registerTypedHostHandlers(
|
||||
updates: createUpdatesApi(appUpdater),
|
||||
uv: createUvApi(),
|
||||
settings: createSettingsApi(gatewayManager),
|
||||
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
|
||||
gateway: createGatewayApi(gatewayManager),
|
||||
logs: createLogsApi(),
|
||||
channels: createChannelsApi({ gatewayManager, mainWindow }),
|
||||
agents: createAgentsApi({ gatewayManager }),
|
||||
@@ -298,11 +293,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw provider config:', err);
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -317,14 +308,10 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
if (existing?.type) {
|
||||
try {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
@@ -345,11 +332,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
try {
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
@@ -395,11 +378,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -429,12 +408,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
try {
|
||||
if (ock) {
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
if (ock) {
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -451,11 +426,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setDefaultLegacyProvider(providerId);
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
if (provider) {
|
||||
try {
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set OpenClaw default model:', err);
|
||||
}
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
@@ -724,12 +695,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
|
||||
// Gateway RPC call
|
||||
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
|
||||
try {
|
||||
const result = await gatewayRpcBackpressure.run(
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
const result = await gatewayManager.rpc(method, params, timeoutMs);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`);
|
||||
@@ -818,18 +784,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs.
|
||||
// Keep a longer debounce (8s) so provider config writes and OAuth token persistence
|
||||
// can settle before applying the process-level refresh.
|
||||
deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => {
|
||||
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
|
||||
gatewayManager.debouncedRestart(8000);
|
||||
});
|
||||
browserOAuthManager.on('oauth:success', ({ provider, accountId }) => {
|
||||
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
|
||||
gatewayManager.debouncedRestart(8000);
|
||||
});
|
||||
|
||||
// Get all providers with key info
|
||||
ipcMain.handle('provider:list', async () => {
|
||||
logLegacyProviderChannel('provider:list');
|
||||
@@ -856,20 +810,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||
|
||||
// Also write to OpenClaw auth-profiles.json so the gateway can use it
|
||||
try {
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the provider configuration to openclaw.json so Gateway knows about it
|
||||
try {
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw provider config:', err);
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -882,16 +828,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
logLegacyProviderChannel('provider:delete');
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
|
||||
// Best-effort cleanup in OpenClaw auth profiles & openclaw.json config
|
||||
if (existing?.type) {
|
||||
try {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -908,11 +848,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
// Also write to OpenClaw auth-profiles.json
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
try {
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -961,11 +897,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
|
||||
// Sync the provider configuration to openclaw.json so Gateway knows about it
|
||||
try {
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -996,11 +928,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
|
||||
// Keep OpenClaw auth-profiles.json in sync with local key storage
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
try {
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -1027,11 +955,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setDefaultLegacyProvider(providerId);
|
||||
|
||||
// Update OpenClaw config to use this provider's default model
|
||||
try {
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set OpenClaw default model:', err);
|
||||
}
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,24 +27,7 @@ function requireString(payload: unknown, key: string): string {
|
||||
return payload[key].trim();
|
||||
}
|
||||
|
||||
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
return;
|
||||
}
|
||||
void reason;
|
||||
}
|
||||
|
||||
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
console.log('[agents] Gateway restart completed after agent deletion');
|
||||
} catch (err) {
|
||||
console.warn('[agents] Gateway restart after agent deletion failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
|
||||
export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
|
||||
return {
|
||||
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
|
||||
create: async (payload) => {
|
||||
@@ -54,7 +37,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
|
||||
syncAllProviderAuthToRuntime().catch((err) => {
|
||||
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
|
||||
});
|
||||
scheduleGatewayReload(ctx, 'create-agent');
|
||||
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
|
||||
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
|
||||
});
|
||||
@@ -64,29 +46,19 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
|
||||
const agentId = requireString(payload, 'id');
|
||||
const name = requireString(payload, 'name');
|
||||
const snapshot = await updateAgentName(agentId, name);
|
||||
scheduleGatewayReload(ctx, 'update-agent');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
updateModel: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
|
||||
const snapshot = await updateAgentModel(agentId, modelRef);
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
} catch (syncError) {
|
||||
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
|
||||
}
|
||||
// Agent model changes must be picked up by the running Gateway before
|
||||
// the next send; otherwise the UI can show the new selection while the
|
||||
// active runtime still answers with the previous model.
|
||||
scheduleGatewayReload(ctx, 'update-agent-model');
|
||||
await syncAllProviderAuthToRuntime();
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
delete: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
|
||||
await restartGatewayForAgentDeletion(ctx);
|
||||
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
|
||||
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
|
||||
});
|
||||
@@ -96,7 +68,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
|
||||
const agentId = requireString(payload, 'id');
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const snapshot = await assignChannelToAgent(agentId, channelType);
|
||||
scheduleGatewayReload(ctx, 'assign-channel');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
removeChannel: async (payload) => {
|
||||
@@ -122,7 +93,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
|
||||
await clearChannelBinding(channelType, accountId);
|
||||
}
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
assignChannelAccountToAgent,
|
||||
clearAllBindingsForChannel,
|
||||
clearChannelBinding,
|
||||
ensureScopedChannelBinding as ensureAgentScopedChannelBinding,
|
||||
listAgentsSnapshot,
|
||||
listAgentsSnapshotFromConfig,
|
||||
} from '../utils/agent-config';
|
||||
@@ -153,11 +154,6 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
|
||||
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
|
||||
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
|
||||
|
||||
const FORCE_RESTART_CHANNELS = new Set([
|
||||
'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost',
|
||||
]);
|
||||
|
||||
function requireString(payload: unknown, key: string): string {
|
||||
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
|
||||
throw new Error(`${key} is required`);
|
||||
@@ -924,83 +920,8 @@ async function listChannelTargetOptions(params: {
|
||||
return targets;
|
||||
}
|
||||
|
||||
async function readChannelBindingOwner(channelType: string, accountId?: string): Promise<string | null> {
|
||||
const config = await readOpenClawConfig();
|
||||
const bindings = Array.isArray((config as { bindings?: unknown }).bindings)
|
||||
? (config as { bindings: unknown[] }).bindings
|
||||
: [];
|
||||
for (const binding of bindings) {
|
||||
if (!binding || typeof binding !== 'object') continue;
|
||||
const candidate = binding as {
|
||||
agentId?: unknown;
|
||||
match?: { channel?: unknown; accountId?: unknown } | unknown;
|
||||
};
|
||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue;
|
||||
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue;
|
||||
const match = candidate.match as { channel?: unknown; accountId?: unknown };
|
||||
if (match.channel !== channelType) continue;
|
||||
const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : '';
|
||||
if ((accountId?.trim() || '') !== bindingAccountId) continue;
|
||||
return candidate.agentId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
|
||||
const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default');
|
||||
const legacyOwner = await readChannelBindingOwner(channelType);
|
||||
if (!legacyOwner) return;
|
||||
|
||||
const agents = await listAgentsSnapshot();
|
||||
const validAgentIds = new Set(agents.agents.map((agent) => agent.id));
|
||||
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
|
||||
? explicitDefaultOwner
|
||||
: (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null);
|
||||
|
||||
if (defaultOwner) {
|
||||
await assignChannelAccountToAgent(defaultOwner, channelType, 'default');
|
||||
}
|
||||
await clearChannelBinding(channelType);
|
||||
}
|
||||
|
||||
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (!accountId) return;
|
||||
const agents = await listAgentsSnapshot();
|
||||
if (!agents.agents || agents.agents.length === 0) return;
|
||||
|
||||
if (accountId === 'default') {
|
||||
if (agents.agents.some((entry) => entry.id === 'main')) {
|
||||
await assignChannelAccountToAgent('main', storedChannelType, 'default');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (agents.agents.some((entry) => entry.id === accountId)) {
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
||||
ctx.gatewayManager.debouncedRestart(150);
|
||||
void reason;
|
||||
return;
|
||||
}
|
||||
ctx.gatewayManager.debouncedReload(150);
|
||||
void reason;
|
||||
await ensureAgentScopedChannelBinding(resolveStoredChannelType(channelType), accountId);
|
||||
}
|
||||
|
||||
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
|
||||
@@ -1072,7 +993,6 @@ async function awaitWeChatQrLogin(
|
||||
});
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
|
||||
|
||||
if (activeQrLogins.get(loginKey) !== sessionKey) return;
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
@@ -1135,7 +1055,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = requireString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await setChannelDefaultAccount(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingSave: async (payload) => {
|
||||
@@ -1148,11 +1067,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (accountId !== 'default') {
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
if (accountId === 'default') {
|
||||
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
|
||||
} else {
|
||||
await assignChannelAccountToAgent(
|
||||
agentId,
|
||||
storedChannelType,
|
||||
accountId,
|
||||
{ migrateLegacy: true },
|
||||
);
|
||||
}
|
||||
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingDelete: async (payload) => {
|
||||
@@ -1160,7 +1084,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = optionalString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
validateConfig: async (payload) => {
|
||||
@@ -1182,19 +1105,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const existingValues = await getChannelFormValues(channelType, accountId);
|
||||
if (isSameConfigValues(existingValues, config)) {
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
|
||||
return { success: true, noChange: true };
|
||||
}
|
||||
await saveChannelConfig(channelType, config, accountId);
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
setEnabled: async (payload) => {
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const enabled = isRecord(payload) && payload.enabled === true;
|
||||
await setChannelEnabled(channelType, enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
|
||||
return { success: true };
|
||||
},
|
||||
formValues: async (payload) => {
|
||||
@@ -1209,11 +1129,9 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
if (accountId) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
|
||||
} else {
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(storedChannelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
@@ -1,45 +1,8 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { logger } from '../utils/logger';
|
||||
import { createAcpChatService } from './acp-chat-service';
|
||||
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
const VISION_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/bmp',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
type ChatSendWithMediaPayload = {
|
||||
sessionKey?: unknown;
|
||||
message?: unknown;
|
||||
deliver?: unknown;
|
||||
idempotencyKey?: unknown;
|
||||
media?: unknown;
|
||||
};
|
||||
|
||||
type MediaPayload = {
|
||||
filePath?: unknown;
|
||||
mimeType?: unknown;
|
||||
fileName?: unknown;
|
||||
};
|
||||
|
||||
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
|
||||
if (!Array.isArray(media)) return [];
|
||||
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const item = entry as MediaPayload;
|
||||
if (typeof item.filePath !== 'string' || !item.filePath) return [];
|
||||
return [{
|
||||
filePath: item.filePath,
|
||||
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
|
||||
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatApi({
|
||||
gatewayManager,
|
||||
@@ -53,75 +16,6 @@ export function createChatApi({
|
||||
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
|
||||
|
||||
return {
|
||||
sendWithMedia: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
|
||||
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
|
||||
if (!sessionKey || !idempotencyKey) {
|
||||
return { success: false, error: 'Invalid chat send payload' };
|
||||
}
|
||||
|
||||
try {
|
||||
let message = typeof body.message === 'string' ? body.message : '';
|
||||
const imageAttachments: Array<Record<string, unknown>> = [];
|
||||
const fileReferences: string[] = [];
|
||||
const media = normalizeMedia(body.media);
|
||||
|
||||
if (media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const item of media) {
|
||||
const exists = await fsP.access(item.filePath).then(() => true, () => false);
|
||||
logger.info(
|
||||
`[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`,
|
||||
);
|
||||
|
||||
fileReferences.push(
|
||||
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
|
||||
);
|
||||
|
||||
if (VISION_MIME_TYPES.has(item.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(item.filePath);
|
||||
const base64Data = fileBuffer.toString('base64');
|
||||
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||
imageAttachments.push({
|
||||
content: base64Data,
|
||||
mimeType: item.mimeType,
|
||||
fileName: item.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileReferences.length > 0) {
|
||||
const refs = fileReferences.join('\n');
|
||||
message = message ? `${message}\n\n${refs}` : refs;
|
||||
}
|
||||
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
sessionKey,
|
||||
message,
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey,
|
||||
};
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[chat:sendWithMedia] Sending: messageLength=${message.length}, attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
|
||||
);
|
||||
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
const hasRunId = isRecord(result) && typeof result.runId === 'string';
|
||||
logger.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`);
|
||||
const response = hasRunId
|
||||
? { runId: result.runId as string }
|
||||
: undefined;
|
||||
return { success: true, ...(response ? { result: response } : {}) };
|
||||
} catch (error) {
|
||||
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
loadAcpSession: (payload) => acpChat.loadSession(payload),
|
||||
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
|
||||
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { approvePendingLocalDeviceRequests } from '../utils/control-ui-device-pairing';
|
||||
@@ -26,10 +25,7 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
export function createGatewayApi(
|
||||
gatewayManager: GatewayManager,
|
||||
gatewayRpcBackpressure: GatewayRpcBackpressure,
|
||||
): CompleteHostServiceRegistry['gateway'] {
|
||||
export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] {
|
||||
return {
|
||||
status: () => gatewayManager.getStatus(),
|
||||
start: async () => {
|
||||
@@ -65,12 +61,7 @@ export function createGatewayApi(
|
||||
throw new Error('Invalid gateway RPC method');
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(body.timeoutMs);
|
||||
return gatewayRpcBackpressure.run(
|
||||
method,
|
||||
body.params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
return gatewayManager.rpc(method, body.params, timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,8 +216,8 @@ async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager
|
||||
const providerId = getProviderId(payload, 'delete');
|
||||
try {
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
@@ -372,12 +372,12 @@ async function deleteAccount(
|
||||
? 'openai'
|
||||
: undefined;
|
||||
if (apiKeyOnly) {
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
return { success: true };
|
||||
}
|
||||
const currentDefaultAccountId = await providerService.getDefaultAccountId();
|
||||
@@ -385,10 +385,9 @@ async function deleteAccount(
|
||||
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
|
||||
: undefined;
|
||||
|
||||
await providerService.deleteAccount(accountId);
|
||||
if (replacementDefault) {
|
||||
await providerService.setDefaultAccount(replacementDefault.id);
|
||||
await syncDefaultProviderToRuntime(replacementDefault.id);
|
||||
await providerService.setDefaultAccount(replacementDefault.id);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
@@ -396,6 +395,7 @@ async function deleteAccount(
|
||||
gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService.deleteAccount(accountId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
|
||||
@@ -185,29 +185,6 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
|
||||
return results;
|
||||
}
|
||||
|
||||
type GatewayRefreshMode = 'reload' | 'restart';
|
||||
|
||||
function scheduleGatewayRefresh(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
if (options?.mode === 'restart') {
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
return;
|
||||
}
|
||||
gatewayManager.debouncedReload(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
@@ -522,29 +499,21 @@ export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<
|
||||
export async function syncSavedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUpdatedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
@@ -579,22 +548,14 @@ export async function syncUpdatedProviderToRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
@@ -604,11 +565,6 @@ export async function syncDeletedProviderToRuntime(
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeDeletedProviderFromOpenClaw(provider, providerId, ock);
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
{ mode: 'restart' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderApiKeyToRuntime(
|
||||
@@ -626,7 +582,7 @@ export async function syncDeletedProviderApiKeyToRuntime(
|
||||
|
||||
export async function syncDefaultProviderToRuntime(
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
@@ -742,15 +698,7 @@ export async function syncDefaultProviderToRuntime(
|
||||
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
|
||||
);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
|
||||
}
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
);
|
||||
await syncAgentModelsToRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,18 +723,14 @@ export async function syncDefaultProviderToRuntime(
|
||||
|
||||
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
|
||||
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -803,15 +747,6 @@ export async function syncDefaultProviderToRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
|
||||
+223
-85
@@ -1,9 +1,9 @@
|
||||
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { copyFile, lstat, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { join, normalize } from 'path';
|
||||
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import { deleteAgentChannelAccounts, listConfiguredChannelsFromConfig, readOpenClawConfig } from './channel-config';
|
||||
import type { OpenClawConfig } from './channel-config';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { expandPath, getOpenClawConfigDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { toUiChannelType } from './channel-alias';
|
||||
@@ -62,6 +62,11 @@ interface BindingConfig extends Record<string, unknown> {
|
||||
match?: BindingMatch;
|
||||
}
|
||||
|
||||
interface ChannelBindingConfig extends BindingConfig {
|
||||
agentId: string;
|
||||
match: BindingMatch & { channel: string };
|
||||
}
|
||||
|
||||
interface ChannelSectionConfig extends Record<string, unknown> {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
defaultAccount?: string;
|
||||
@@ -147,7 +152,7 @@ function slugifyAgentId(name: string): string {
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -217,7 +222,7 @@ function normalizeAgentsConfig(config: AgentConfigDocument): {
|
||||
};
|
||||
}
|
||||
|
||||
function isChannelBinding(binding: unknown): binding is BindingConfig {
|
||||
function isChannelBinding(binding: unknown): binding is ChannelBindingConfig {
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
const candidate = binding as BindingConfig;
|
||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
||||
@@ -460,7 +465,8 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel
|
||||
|
||||
async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise<AgentsSnapshot> {
|
||||
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const configuredChannels = preloadedChannels ?? await listConfiguredChannels();
|
||||
const configuredChannels = preloadedChannels
|
||||
?? await listConfiguredChannelsFromConfig(config as OpenClawConfig);
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
||||
const channelOwners: Record<string, string> = {};
|
||||
@@ -472,15 +478,11 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
|
||||
for (const channelType of configuredChannels) {
|
||||
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
|
||||
let primaryOwner: string | undefined;
|
||||
const hasExplicitAccountBindingForChannel = accountIds.some((accountId) =>
|
||||
accountToAgent.has(`${channelType}:${accountId}`),
|
||||
);
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const owner =
|
||||
accountToAgent.get(`${channelType}:${accountId}`)
|
||||
|| (
|
||||
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? channelToAgent.get(channelType)
|
||||
: undefined
|
||||
);
|
||||
@@ -543,16 +545,25 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
|
||||
}
|
||||
|
||||
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth');
|
||||
const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record<string, unknown>);
|
||||
if (modified) {
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Pruned stale runtime agent model refs from openclaw.json');
|
||||
}
|
||||
return buildSnapshotFromConfig(config);
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
let prunedRuntimeModelRefs = false;
|
||||
const {
|
||||
getActiveAuthProfileProviders,
|
||||
pruneStaleRuntimeAgentModelRefs,
|
||||
} = await import('./openclaw-auth');
|
||||
const authProfileProviders = await getActiveAuthProfileProviders();
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
prunedRuntimeModelRefs = await pruneStaleRuntimeAgentModelRefs(
|
||||
config as unknown as Record<string, unknown>,
|
||||
authProfileProviders,
|
||||
);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
if (prunedRuntimeModelRefs) {
|
||||
logger.info('Pruned stale runtime agent model refs from openclaw.json');
|
||||
}
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise<AgentsSnapshot> {
|
||||
@@ -589,8 +600,12 @@ export async function createAgent(
|
||||
name: string,
|
||||
options?: { inheritWorkspace?: boolean },
|
||||
): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
let createdAgentId = '';
|
||||
let agentToProvision: AgentListEntry | undefined;
|
||||
let provisioningConfig: AgentConfigDocument | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const existingIds = new Set(entries.map((entry) => entry.id));
|
||||
@@ -621,18 +636,61 @@ export async function createAgent(
|
||||
list: nextEntries,
|
||||
};
|
||||
|
||||
await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace });
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace });
|
||||
return buildSnapshotFromConfig(config);
|
||||
createdAgentId = nextId;
|
||||
agentToProvision = newAgent;
|
||||
provisioningConfig = structuredClone(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
const createdAgent = agentToProvision!;
|
||||
const workspaceExisted = await fileExists(expandPath(createdAgent.workspace!));
|
||||
const runtimeDirectory = join(getOpenClawConfigDir(), 'agents', createdAgent.id);
|
||||
const runtimeDirectoryExisted = await fileExists(runtimeDirectory);
|
||||
try {
|
||||
await provisionAgentFilesystem(provisioningConfig!, createdAgent, { inheritWorkspace: options?.inheritWorkspace });
|
||||
} catch (provisioningError) {
|
||||
let rollbackError: unknown;
|
||||
try {
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const createdIndex = entries.findIndex((entry) => (
|
||||
entry.id === createdAgent.id && isDeepStrictEqual(entry, createdAgent)
|
||||
));
|
||||
if (createdIndex === -1) return;
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: entries.filter((_, index) => index !== createdIndex),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackError = error;
|
||||
}
|
||||
|
||||
if (!workspaceExisted) {
|
||||
await removeAgentWorkspaceDirectory(createdAgent);
|
||||
}
|
||||
if (!runtimeDirectoryExisted) {
|
||||
await removeAgentRuntimeDirectory(createdAgent.id);
|
||||
}
|
||||
if (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[provisioningError, rollbackError],
|
||||
`Failed to provision agent "${createdAgent.id}" and roll back its config entry`,
|
||||
{ cause: provisioningError },
|
||||
);
|
||||
}
|
||||
throw provisioningError;
|
||||
}
|
||||
logger.info('Created agent config entry', { agentId: createdAgentId, inheritWorkspace: !!options?.inheritWorkspace });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
@@ -648,10 +706,10 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
||||
list: entries,
|
||||
};
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent name', { agentId, name: normalizedName });
|
||||
return buildSnapshotFromConfig(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Updated agent name', { agentId, name: normalizedName });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
function isValidModelRef(modelRef: string): boolean {
|
||||
@@ -660,15 +718,16 @@ function isValidModelRef(modelRef: string): boolean {
|
||||
}
|
||||
|
||||
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||
const nextEntry: AgentListEntry = { ...entries[index] };
|
||||
|
||||
if (!normalizedModelRef) {
|
||||
@@ -708,21 +767,24 @@ export async function updateAgentModel(agentId: string, modelRef: string | null)
|
||||
list: entries,
|
||||
};
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||
return buildSnapshotFromConfig(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
||||
return withConfigLock(async () => {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
}
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
let result: { snapshot: AgentsSnapshot; removedEntry: AgentListEntry } | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
|
||||
const bindingsBeforeDeletion = Array.isArray(config.bindings)
|
||||
? config.bindings.filter(isChannelBinding)
|
||||
: [];
|
||||
const removedEntry = entries.find((entry) => entry.id === agentId);
|
||||
const nextEntries = entries.filter((entry) => entry.id !== agentId);
|
||||
if (!removedEntry || nextEntries.length === entries.length) {
|
||||
@@ -746,81 +808,87 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag
|
||||
|
||||
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
|
||||
const legacyAccountId = resolveAccountIdForAgent(agentId);
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(bindingsBeforeDeletion);
|
||||
const boundChannelTypes = new Set(bindingsBeforeDeletion.map((binding) => binding.match.channel));
|
||||
const ownedLegacyAccounts = new Set(
|
||||
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
|
||||
.filter(([channelAccountKey, owner]) => {
|
||||
if (owner !== normalizedAgentId) return false;
|
||||
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
|
||||
return accountId === legacyAccountId;
|
||||
[...boundChannelTypes]
|
||||
.filter((channelType) => {
|
||||
const accountOwner = accountToAgent.get(`${channelType}:${legacyAccountId}`);
|
||||
const effectiveOwner = accountOwner
|
||||
?? (legacyAccountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined);
|
||||
return effectiveOwner === normalizedAgentId;
|
||||
})
|
||||
.map(([channelAccountKey]) => channelAccountKey),
|
||||
.map((channelType) => `${channelType}:${legacyAccountId}`),
|
||||
);
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
// NOTE: workspace directory is NOT deleted here intentionally.
|
||||
// The caller (route handler) defers workspace removal until after
|
||||
// the Gateway process has fully restarted, so that any in-flight
|
||||
// process.chdir(workspace) calls complete before the directory
|
||||
// disappears (otherwise process.cwd() throws ENOENT for the rest
|
||||
// of the Gateway's lifetime).
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
||||
result = { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
||||
});
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
// The caller removes the workspace only after the coordinator commit above.
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return result!;
|
||||
}
|
||||
|
||||
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (!entries.some((entry) => entry.id === agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function assignChannelAccountToAgent(
|
||||
agentId: string,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
options?: { migrateLegacy?: boolean },
|
||||
): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (!entries.some((entry) => entry.id === agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
if (!accountId.trim()) {
|
||||
throw new Error('accountId is required');
|
||||
if (options?.migrateLegacy) {
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
}
|
||||
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });
|
||||
return buildSnapshotFromConfig(config);
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, trimmedAccountId);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: trimmedAccountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared channel binding', { channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Cleared channel binding', { channelType, accountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
if (!Array.isArray(config.bindings)) return;
|
||||
|
||||
const nextBindings = config.bindings.filter((binding) => {
|
||||
@@ -829,7 +897,77 @@ export async function clearAllBindingsForChannel(channelType: string): Promise<v
|
||||
});
|
||||
|
||||
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared all bindings for channel', { channelType });
|
||||
});
|
||||
logger.info('Cleared all bindings for channel', { channelType });
|
||||
}
|
||||
|
||||
function migrateLegacyChannelBindingInConfig(
|
||||
config: AgentConfigDocument,
|
||||
channelType: string,
|
||||
validAgentIds: Set<string>,
|
||||
): void {
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||
const legacyOwner = channelToAgent.get(channelType);
|
||||
if (!legacyOwner) return;
|
||||
|
||||
const explicitDefaultOwner = accountToAgent.get(`${channelType}:${DEFAULT_ACCOUNT_ID}`);
|
||||
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
|
||||
? explicitDefaultOwner
|
||||
: (validAgentIds.has(legacyOwner) ? legacyOwner : null);
|
||||
if (defaultOwner) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
defaultOwner,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null);
|
||||
}
|
||||
|
||||
export async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
});
|
||||
logger.info('Migrated legacy channel-wide binding', { channelType });
|
||||
}
|
||||
|
||||
export async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
const normalizedAccountId = accountId?.trim();
|
||||
if (!normalizedAccountId) return;
|
||||
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (entries.length === 0) return;
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
|
||||
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
const mainAgent = entries.find((entry) => entry.id === MAIN_AGENT_ID);
|
||||
if (mainAgent) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
mainAgent.id,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
const accountAgent = entries.find((entry) => entry.id === normalizedAccountId);
|
||||
if (accountAgent) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
accountAgent.id,
|
||||
normalizedAccountId,
|
||||
);
|
||||
}
|
||||
});
|
||||
logger.info('Ensured scoped channel binding', { channelType, accountId: normalizedAccountId });
|
||||
}
|
||||
|
||||
+208
-142
@@ -8,10 +8,10 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promis
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawResolvedDir } from './paths';
|
||||
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
isWechatChannelType,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from './channel-alias';
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
@@ -154,8 +153,7 @@ function sanitizeDiscordGuilds(config: unknown): void {
|
||||
/**
|
||||
* Strip `defaultAccount` from channel sections whose plugin schema
|
||||
* declares additionalProperties:false without listing `defaultAccount`.
|
||||
* Call right before every `writeOpenClawConfig` in channel-config
|
||||
* mutation functions.
|
||||
* Call before committing channel-config mutations.
|
||||
*/
|
||||
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
|
||||
if (!config.channels) return;
|
||||
@@ -366,6 +364,51 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
|
||||
currentConfig.plugins.entries[pluginId].enabled = true;
|
||||
}
|
||||
|
||||
function syncPluginChannelAccountMirror(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return;
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
if (!channelSection) {
|
||||
removePluginRegistration(currentConfig, channelType);
|
||||
return;
|
||||
}
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return;
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
pluginEntry.accounts = structuredClone(accounts);
|
||||
} else {
|
||||
delete pluginEntry.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePluginChannelAccountMirror(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
): boolean {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return false;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return false;
|
||||
const accounts = getChannelAccountsMap(pluginEntry);
|
||||
if (!accounts?.[accountId]) return false;
|
||||
|
||||
delete accounts[accountId];
|
||||
const remainingAccountIds = Object.keys(accounts).sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
if (remainingAccountIds.length === 0) {
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
} else if (pluginEntry.defaultAccount === accountId) {
|
||||
pluginEntry.defaultAccount = remainingAccountIds[0];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanupLegacyBuiltInChannelPluginRegistration(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
@@ -455,22 +498,10 @@ export interface OpenClawConfig {
|
||||
|
||||
// ── Config I/O ───────────────────────────────────────────────────
|
||||
|
||||
async function ensureConfigDir(): Promise<void> {
|
||||
if (!(await fileExists(OPENCLAW_DIR))) {
|
||||
await mkdir(OPENCLAW_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
await ensureConfigDir();
|
||||
|
||||
if (!(await fileExists(CONFIG_FILE))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(content) as OpenClawConfig;
|
||||
const snapshot = await readOpenClawConfigSnapshot();
|
||||
return snapshot.config as OpenClawConfig;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read OpenClaw config', error);
|
||||
console.error('Failed to read OpenClaw config:', error);
|
||||
@@ -478,26 +509,6 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
|
||||
await ensureConfigDir();
|
||||
|
||||
try {
|
||||
// Enable graceful in-process reload authorization for SIGUSR1 flows.
|
||||
const commands =
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {};
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to write OpenClaw config', error);
|
||||
console.error('Failed to write OpenClaw config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel operations ───────────────────────────────────────────
|
||||
|
||||
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
|
||||
@@ -832,10 +843,12 @@ export async function saveChannelConfig(
|
||||
config: ChannelConfigData,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
let transformedKeys: string[] = [];
|
||||
|
||||
await mutateOpenClawConfig(async (snapshot) => {
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
|
||||
@@ -859,6 +872,7 @@ export async function saveChannelConfig(
|
||||
|
||||
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
|
||||
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
|
||||
transformedKeys = Object.keys(transformedConfig);
|
||||
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType];
|
||||
if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') {
|
||||
const rawCredentialValue = transformedConfig[uniqueKey] as string;
|
||||
@@ -920,16 +934,15 @@ export async function saveChannelConfig(
|
||||
}
|
||||
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Channel config saved', {
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
configFile: CONFIG_FILE,
|
||||
rawKeys: Object.keys(config),
|
||||
transformedKeys: Object.keys(transformedConfig),
|
||||
});
|
||||
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
|
||||
});
|
||||
logger.info('Channel config saved', {
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
configFile: resolveOpenClawConfigPath(),
|
||||
rawKeys: Object.keys(config),
|
||||
transformedKeys,
|
||||
});
|
||||
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
|
||||
}
|
||||
|
||||
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
|
||||
@@ -1003,35 +1016,54 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
|
||||
}
|
||||
|
||||
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let deleteWeChatAccount = false;
|
||||
let deletedAccount = false;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
deleteWeChatAccount = false;
|
||||
deletedAccount = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const deletedPluginAccount = deletePluginChannelAccountMirror(
|
||||
currentConfig,
|
||||
resolvedChannelType,
|
||||
accountId,
|
||||
);
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatAccountState(accountId);
|
||||
deleteWeChatAccount = true;
|
||||
}
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
const existingAccounts = getChannelAccountsMap(channelSection);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(channelSection)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) {
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
|
||||
&& channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount.trim()
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
if (!accounts?.[accountId]) {
|
||||
// Account not found; just ensure top-level mirror is consistent
|
||||
const mirroredAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() ? channelSection.defaultAccount : DEFAULT_ACCOUNT_ID;
|
||||
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccountData) {
|
||||
for (const [key, value] of Object.entries(defaultAccountData)) {
|
||||
channelSection[key] = value;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!accounts?.[accountId]) return;
|
||||
|
||||
delete accounts[accountId];
|
||||
deletedAccount = true;
|
||||
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete currentConfig.channels![resolvedChannelType];
|
||||
@@ -1064,21 +1096,31 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
}
|
||||
}
|
||||
|
||||
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
await deleteWeChatAccountState(accountId);
|
||||
deleteWeChatAccount = true;
|
||||
}
|
||||
});
|
||||
if (deleteWeChatAccount) {
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
if (deletedAccount) {
|
||||
logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId });
|
||||
console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let deleteWeChat = false;
|
||||
let deletedConfig: 'channel' | 'plugin' | undefined;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
deleteWeChat = false;
|
||||
deletedConfig = undefined;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
if (currentConfig.channels?.[resolvedChannelType]) {
|
||||
@@ -1103,37 +1145,43 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
|
||||
}
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
await deleteWeChatState();
|
||||
deleteWeChat = true;
|
||||
}
|
||||
console.log(`Deleted channel config for ${resolvedChannelType}`);
|
||||
deletedConfig = 'channel';
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
|
||||
deletedConfig = 'plugin';
|
||||
}
|
||||
} else if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatState();
|
||||
}
|
||||
|
||||
if (resolvedChannelType === 'whatsapp') {
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (await fileExists(whatsappDir)) {
|
||||
await rm(whatsappDir, { recursive: true, force: true });
|
||||
console.log('Deleted WhatsApp credentials directory');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete WhatsApp credentials:', error);
|
||||
}
|
||||
deleteWeChat = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteWeChat) {
|
||||
await deleteWeChatState();
|
||||
}
|
||||
if (deletedConfig === 'channel') {
|
||||
console.log(`Deleted channel config for ${resolvedChannelType}`);
|
||||
} else if (deletedConfig === 'plugin') {
|
||||
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
|
||||
}
|
||||
|
||||
if (resolvedChannelType === 'whatsapp') {
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (await fileExists(whatsappDir)) {
|
||||
await rm(whatsappDir, { recursive: true, force: true });
|
||||
console.log('Deleted WhatsApp credentials directory');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete WhatsApp credentials:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
||||
@@ -1247,14 +1295,14 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
|
||||
}
|
||||
|
||||
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
|
||||
@@ -1275,38 +1323,37 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
|
||||
}
|
||||
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
|
||||
});
|
||||
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
|
||||
}
|
||||
|
||||
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
if (!currentConfig.channels) return;
|
||||
let modified = false;
|
||||
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
|
||||
|
||||
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
|
||||
let modified = false;
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
modified = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channels = currentConfig.channels ?? {};
|
||||
for (const channelType of Object.keys(channels)) {
|
||||
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
|
||||
const section = channels[channelType];
|
||||
const existingAccounts = getChannelAccountsMap(section);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(section)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) continue;
|
||||
|
||||
for (const channelType of Object.keys(currentConfig.channels)) {
|
||||
const section = currentConfig.channels[channelType];
|
||||
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
|
||||
const currentDefaultAccountId = typeof section.defaultAccount === 'string'
|
||||
&& section.defaultAccount.trim()
|
||||
? section.defaultAccount.trim()
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId);
|
||||
const accounts = getChannelAccountsMap(section);
|
||||
if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) {
|
||||
// Ensure top-level mirror is consistent.
|
||||
const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID;
|
||||
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccountData) {
|
||||
for (const [key, value] of Object.entries(defaultAccountData)) {
|
||||
section[key] = value;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!accounts?.[accountId]) continue;
|
||||
|
||||
delete accounts[accountId];
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete currentConfig.channels[channelType];
|
||||
delete channels[channelType];
|
||||
} else {
|
||||
if (section.defaultAccount === accountId) {
|
||||
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
|
||||
@@ -1331,21 +1378,37 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
}
|
||||
}
|
||||
}
|
||||
syncPluginChannelAccountMirror(currentConfig, channelType);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const pluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: Object.keys(currentConfig.plugins?.entries ?? {});
|
||||
for (const channelType of pluginChannelTypes) {
|
||||
if (deletePluginChannelAccountMirror(currentConfig, channelType, accountId)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
|
||||
}
|
||||
});
|
||||
if (modified) {
|
||||
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let pluginChannel = false;
|
||||
|
||||
await mutateOpenClawConfig(async (snapshot) => {
|
||||
pluginChannel = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
@@ -1357,6 +1420,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
}
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
pluginChannel = true;
|
||||
if (enabled) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
} else {
|
||||
@@ -1369,8 +1433,6 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
|
||||
pluginEntry.enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1378,32 +1440,36 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
|
||||
currentConfig.channels[resolvedChannelType].enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
});
|
||||
console.log(`Set ${pluginChannel ? 'plugin channel' : 'channel'} ${resolvedChannelType} enabled: ${enabled}`);
|
||||
}
|
||||
|
||||
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
|
||||
return withConfigLock(async () => {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
let cleanedDanglingState = false;
|
||||
let hasConfiguredWeChatAccounts = false;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
cleanedDanglingState = false;
|
||||
hasConfiguredWeChatAccounts = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
|
||||
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
|
||||
hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
|
||||
const hadPluginRegistration = Boolean(
|
||||
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|
||||
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
|
||||
);
|
||||
|
||||
if (hasConfiguredWeChatAccounts) {
|
||||
return { cleanedDanglingState: false };
|
||||
return;
|
||||
}
|
||||
|
||||
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
if (modified) {
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
}
|
||||
await deleteWeChatState();
|
||||
return { cleanedDanglingState: hadPluginRegistration || modified };
|
||||
cleanedDanglingState = hadPluginRegistration || modified;
|
||||
});
|
||||
if (!hasConfiguredWeChatAccounts) {
|
||||
await deleteWeChatState();
|
||||
}
|
||||
return { cleanedDanglingState };
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
|
||||
+234
-257
@@ -28,7 +28,11 @@ import {
|
||||
isOpenClawOAuthPluginProviderKey,
|
||||
} from './provider-keys';
|
||||
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
mutateOpenClawConfig,
|
||||
readOpenClawConfigSnapshot,
|
||||
reloadOpenClawSecretsIfRunning,
|
||||
} from '../gateway/config-delivery';
|
||||
import {
|
||||
ensureMemorySearchFtsDefault,
|
||||
hasUserMemorySearchConfig,
|
||||
@@ -430,12 +434,6 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
|
||||
const jsonStore = await readAuthProfilesJson(agentId);
|
||||
if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) {
|
||||
try {
|
||||
writeAuthProfilesToSqlite(jsonStore, agentId);
|
||||
console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error);
|
||||
}
|
||||
return jsonStore;
|
||||
}
|
||||
|
||||
@@ -444,19 +442,27 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
|
||||
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
|
||||
writeAuthProfilesToSqlite(store, agentId);
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
try {
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update compatibility auth-profiles.json for agent "${agentId}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */
|
||||
export async function migrateAllAgentAuthProfilesToSqlite(): Promise<void> {
|
||||
const agentIds = await discoverAgentIds();
|
||||
let migrated = false;
|
||||
for (const agentId of agentIds) {
|
||||
try {
|
||||
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
|
||||
migrated = await migrateAuthProfilesJsonToSqliteIfNeeded(agentId) || migrated;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
|
||||
}
|
||||
}
|
||||
if (migrated) {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
}
|
||||
}
|
||||
|
||||
function getApiKeyFromAuthProfilesStore(
|
||||
@@ -524,7 +530,6 @@ async function discoverAgentIds(): Promise<string[]> {
|
||||
|
||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
|
||||
@@ -691,8 +696,11 @@ async function getProvidersFromAuthProfileStores(
|
||||
return providers;
|
||||
}
|
||||
|
||||
async function collectActiveProviderIdsFromConfig(config: Record<string, unknown>): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
function collectActiveProviderIdsFromConfig(
|
||||
config: Record<string, unknown>,
|
||||
authProfileProviders: Iterable<string> = [],
|
||||
): Set<string> {
|
||||
const activeProviders = new Set(authProfileProviders);
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
@@ -724,11 +732,6 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
|
||||
{ includeRawKeys: true },
|
||||
);
|
||||
|
||||
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
for (const provider of authProfileProviders) {
|
||||
activeProviders.add(provider);
|
||||
}
|
||||
|
||||
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
|
||||
activeProviders.delete(deprecated);
|
||||
}
|
||||
@@ -737,7 +740,7 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
|
||||
}
|
||||
|
||||
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
||||
return (await readOpenClawConfigSnapshot()).config;
|
||||
}
|
||||
|
||||
async function resolveInstalledFeishuPluginId(): Promise<string | null> {
|
||||
@@ -937,21 +940,6 @@ function backfillCustomProviderModelContextWindows(config: Record<string, unknow
|
||||
return backfilled;
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
|
||||
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
// ── Exported Functions (all async) ───────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -998,6 +986,7 @@ export async function saveOAuthTokenToOpenClaw(
|
||||
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1059,6 +1048,7 @@ export async function saveProviderKeyToOpenClaw(
|
||||
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1071,13 +1061,18 @@ export async function removeProviderKeyFromOpenClaw(
|
||||
): Promise<void> {
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
let modified = false;
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) {
|
||||
await writeAuthProfiles(store, id);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
}
|
||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1135,7 +1130,6 @@ function isRuntimeGeneratedProviderKey(providerKey: string): boolean {
|
||||
function pruneStaleRuntimeModelConfig(
|
||||
modelCfg: Record<string, unknown>,
|
||||
activeProviders: Set<string>,
|
||||
context: string,
|
||||
): boolean {
|
||||
let modified = false;
|
||||
const primary = typeof modelCfg.primary === 'string' ? modelCfg.primary.trim() : '';
|
||||
@@ -1148,7 +1142,6 @@ function pruneStaleRuntimeModelConfig(
|
||||
) {
|
||||
delete modelCfg.primary;
|
||||
modified = true;
|
||||
console.log(`Removed stale runtime model ref "${primary}" from ${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,8 +1165,13 @@ function pruneStaleRuntimeModelConfig(
|
||||
* Drop agent model refs that point at deleted custom/ollama runtime providers.
|
||||
* Built-in providers are left intact because they may still resolve via auth/env.
|
||||
*/
|
||||
export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unknown>): Promise<boolean> {
|
||||
const activeProviders = await getActiveOpenClawProviders();
|
||||
export async function pruneStaleRuntimeAgentModelRefs(
|
||||
config: Record<string, unknown>,
|
||||
authProfileProviders?: Iterable<string>,
|
||||
): Promise<boolean> {
|
||||
const activeProviders = authProfileProviders
|
||||
? collectActiveProviderIdsFromConfig(config, authProfileProviders)
|
||||
: await getActiveOpenClawProviders();
|
||||
const agents = config.agents;
|
||||
if (!isPlainRecord(agents)) return false;
|
||||
|
||||
@@ -1181,7 +1179,7 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
|
||||
|
||||
const agentDefaults = agents.defaults;
|
||||
if (isPlainRecord(agentDefaults) && isPlainRecord(agentDefaults.model)) {
|
||||
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders, 'agents.defaults.model')) {
|
||||
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders)) {
|
||||
deleteModelConfigIfEmpty(agentDefaults);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1190,8 +1188,7 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
|
||||
if (Array.isArray(agents.list)) {
|
||||
for (const entry of agents.list) {
|
||||
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
|
||||
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
|
||||
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders, `agent "${agentId}" model override`)) {
|
||||
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders)) {
|
||||
deleteModelConfigIfEmpty(entry);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1202,50 +1199,13 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
|
||||
}
|
||||
|
||||
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
|
||||
// 1. Remove from auth-profiles.json.
|
||||
// We must also remove entries whose raw `provider` field maps to this UI
|
||||
// provider key via AUTH_PROFILE_PROVIDER_KEY_MAP (e.g. "openai-codex" → "openai").
|
||||
// If those entries survive, getProvidersFromAuthProfileStores() will re-add
|
||||
// the provider and trigger a re-seed loop in listAccounts().
|
||||
const providerKeysToRemove = expandProviderKeysForDeletion(provider);
|
||||
const agentIds = await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
let storeModified = false;
|
||||
for (const key of providerKeysToRemove) {
|
||||
if (removeProfilesForProvider(store, key)) {
|
||||
storeModified = true;
|
||||
}
|
||||
}
|
||||
if (storeModified) {
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
try {
|
||||
if (await fileExists(modelsPath)) {
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove from openclaw.json
|
||||
try {
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
let authProfilesModified = false;
|
||||
// Commit the authoritative config first. If this fails, sidecar credentials
|
||||
// and model registries remain untouched and the caller can safely retry.
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
let modified = false;
|
||||
|
||||
// Remove plugin registrations for OAuth providers (e.g. MiniMax).
|
||||
@@ -1253,7 +1213,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
const { canonicalPluginId, stalePluginIds } = getOAuthPluginRegistration(provider);
|
||||
if (removePluginRegistrations(config, [canonicalPluginId, ...stalePluginIds])) {
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw plugin registrations for provider "${provider}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,7 +1222,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (providers[provider]) {
|
||||
delete providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
|
||||
const auth = (config.auth && typeof config.auth === 'object'
|
||||
@@ -1284,7 +1242,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
}
|
||||
delete authProfiles[profileId];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw auth profile: ${profileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1258,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (removeProviderPrefixFromModelConfig(modelCfg, providerPrefix)) {
|
||||
deleteModelConfigIfEmpty(agentDefaults);
|
||||
modified = true;
|
||||
console.log(`Removed deleted provider "${provider}" from agents.defaults.model`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1309,21 +1265,69 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (Array.isArray(agentList)) {
|
||||
for (const entry of agentList) {
|
||||
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
|
||||
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
|
||||
if (removeProviderPrefixFromModelConfig(entry.model, providerPrefix)) {
|
||||
deleteModelConfigIfEmpty(entry);
|
||||
modified = true;
|
||||
console.log(`Removed deleted provider "${provider}" from agent "${agentId}" model override`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||
});
|
||||
|
||||
// Remove the provider from each per-agent model registry used by pi-ai.
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
if (!(await fileExists(modelsPath))) continue;
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove auth entries whose raw provider maps to this UI provider key
|
||||
// (for example "openai-codex" -> "openai"). Keep this last so every
|
||||
// successful auth batch can immediately refresh the running snapshot.
|
||||
let authWriteError: unknown;
|
||||
try {
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
let storeModified = false;
|
||||
for (const key of providerKeysToRemove) {
|
||||
if (removeProfilesForProvider(store, key)) {
|
||||
storeModified = true;
|
||||
}
|
||||
}
|
||||
if (storeModified) {
|
||||
await writeAuthProfiles(store, id);
|
||||
authProfilesModified = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
authWriteError = error;
|
||||
}
|
||||
if (authProfilesModified) {
|
||||
try {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
} catch (reloadError) {
|
||||
if (authWriteError) {
|
||||
throw new AggregateError(
|
||||
[authWriteError, reloadError],
|
||||
`Failed to remove provider "${provider}" auth profiles and refresh OpenClaw secrets`,
|
||||
{ cause: reloadError },
|
||||
);
|
||||
}
|
||||
throw reloadError;
|
||||
}
|
||||
}
|
||||
if (authWriteError) {
|
||||
throw authWriteError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,8 +1456,8 @@ function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record<string, u
|
||||
|
||||
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
removed.length = 0;
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
let modified = false;
|
||||
@@ -1485,7 +1489,7 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
|
||||
if (modified) {
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
@@ -1515,8 +1519,7 @@ export async function setOpenClawDefaultModel(
|
||||
modelOverride?: string,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
const model = normalizeModelRef(provider, modelOverride);
|
||||
@@ -1596,7 +1599,7 @@ export async function setOpenClawDefaultModel(
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||
});
|
||||
}
|
||||
@@ -1805,8 +1808,8 @@ function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>)
|
||||
*/
|
||||
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
|
||||
const healed: string[] = [];
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
healed.length = 0;
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
let modified = false;
|
||||
@@ -1825,7 +1828,7 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]>
|
||||
if (modified) {
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
}
|
||||
});
|
||||
return healed;
|
||||
@@ -1991,12 +1994,11 @@ function upsertOpenClawProviderEntry(
|
||||
*/
|
||||
export async function ensureOpenClawProviderAgentRuntimePins(): Promise<string[]> {
|
||||
let pinned: string[] = [];
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
|
||||
if (pinned.length > 0) {
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
}
|
||||
});
|
||||
return pinned;
|
||||
@@ -2087,8 +2089,7 @@ export async function syncProviderConfigToOpenClaw(
|
||||
modelId: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
if (override.baseUrl && override.api) {
|
||||
@@ -2109,7 +2110,7 @@ export async function syncProviderConfigToOpenClaw(
|
||||
ensureOAuthPluginEnabled(config, provider);
|
||||
}
|
||||
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2177,9 +2178,7 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
apiKey?: string;
|
||||
imageModelIds?: string[];
|
||||
}): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
await mutateOpenClawConfig((config) => {
|
||||
if (!params.enabled) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
@@ -2196,15 +2195,24 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
const primary = typeof imageGenerationModel?.primary === 'string'
|
||||
? imageGenerationModel.primary.trim().toLowerCase()
|
||||
: '';
|
||||
if (defaults && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
|
||||
delete defaults.imageGenerationModel;
|
||||
if (defaults && imageGenerationModel && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
|
||||
const remainingFallbacks = Array.isArray(imageGenerationModel.fallbacks)
|
||||
? imageGenerationModel.fallbacks.filter((fallback): fallback is string => (
|
||||
typeof fallback === 'string'
|
||||
&& !fallback.trim().toLowerCase().startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)
|
||||
))
|
||||
: [];
|
||||
if (remainingFallbacks.length > 0) {
|
||||
imageGenerationModel.primary = remainingFallbacks.shift();
|
||||
} else {
|
||||
delete imageGenerationModel.primary;
|
||||
}
|
||||
if (Array.isArray(imageGenerationModel.fallbacks)) {
|
||||
imageGenerationModel.fallbacks = remainingFallbacks;
|
||||
}
|
||||
}
|
||||
removePluginRegistrations(config, [CLAWX_OPENAI_IMAGE_PROVIDER_KEY]);
|
||||
await writeOpenClawJson(config);
|
||||
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
}
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2215,6 +2223,12 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
if (modelIds.length === 0) {
|
||||
modelIds.push(CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
|
||||
}
|
||||
const existingModels = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY)?.models;
|
||||
const existingModelsById = new Map(
|
||||
(Array.isArray(existingModels) ? existingModels : [])
|
||||
.filter((model): model is Record<string, unknown> => isPlainRecord(model) && typeof model.id === 'string')
|
||||
.map((model) => [model.id as string, model]),
|
||||
);
|
||||
upsertOpenClawProviderEntry(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY, {
|
||||
baseUrl,
|
||||
api: 'openai-completions',
|
||||
@@ -2222,13 +2236,24 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
mergeExistingModels: false,
|
||||
request: { allowPrivateNetwork: true },
|
||||
});
|
||||
ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
await writeOpenClawJson(config);
|
||||
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
const relayProvider = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
if (relayProvider && Array.isArray(relayProvider.models)) {
|
||||
relayProvider.models = relayProvider.models.map((model) => {
|
||||
if (!isPlainRecord(model) || typeof model.id !== 'string') return model;
|
||||
const existing = existingModelsById.get(model.id);
|
||||
return existing ? { ...model, ...existing, id: model.id } : model;
|
||||
});
|
||||
}
|
||||
ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
});
|
||||
|
||||
if (!params.enabled) {
|
||||
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
}
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAiCompatibleImageRelayState(
|
||||
@@ -2259,8 +2284,7 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
override: RuntimeProviderConfigOverride,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
await mutateOpenClawConfig((config) => {
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
const model = normalizeModelRef(provider, modelOverride);
|
||||
@@ -2304,7 +2328,7 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
ensureOAuthPluginEnabled(config, provider);
|
||||
}
|
||||
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
console.log(
|
||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||
);
|
||||
@@ -2319,67 +2343,24 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
// These may still linger in openclaw.json from older versions.
|
||||
const DEPRECATED_PROVIDER_IDS = new Set(['qwen-portal']);
|
||||
|
||||
export async function getActiveAuthProfileProviders(): Promise<Set<string>> {
|
||||
return await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
}
|
||||
|
||||
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
|
||||
try {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
// 1. models.providers
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
activeProviders.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. plugins.entries for OAuth providers
|
||||
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
activeProviders.add(pluginId.replace(/-auth$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. agents.defaults.model.primary — the default model reference encodes
|
||||
// the provider prefix (e.g. "modelstudio/qwen3.6-plus" → "modelstudio").
|
||||
// This covers providers that are active via OAuth or env-key but don't
|
||||
// have an explicit models.providers entry.
|
||||
const agents = config.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
const modelConfig = defaults?.model as Record<string, unknown> | undefined;
|
||||
const primaryModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined;
|
||||
if (primaryModel?.includes('/')) {
|
||||
activeProviders.add(primaryModel.split('/')[0]);
|
||||
}
|
||||
|
||||
// 4. auth.profiles — OAuth/device-token based providers may exist only in
|
||||
// auth-profiles without explicit models.providers entries yet.
|
||||
// Raw keys (e.g. "openai-codex") are included so downstream logic can
|
||||
// distinguish OAuth runtime providers from their UI alias ("openai").
|
||||
const auth = config.auth as Record<string, unknown> | undefined;
|
||||
addProvidersFromProfileEntries(
|
||||
auth?.profiles as Record<string, unknown> | undefined,
|
||||
activeProviders,
|
||||
{ includeRawKeys: true },
|
||||
const [config, authProfileProviders] = await Promise.all([
|
||||
readOpenClawJson(),
|
||||
getActiveAuthProfileProviders(),
|
||||
]);
|
||||
return collectActiveProviderIdsFromConfig(
|
||||
config,
|
||||
authProfileProviders,
|
||||
);
|
||||
|
||||
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
for (const provider of authProfileProviders) {
|
||||
activeProviders.add(provider);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json for active providers:', err);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Remove deprecated providers that may still linger in config/auth files.
|
||||
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
|
||||
activeProviders.delete(deprecated);
|
||||
}
|
||||
|
||||
return activeProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2448,9 +2429,8 @@ function applyControlUiAllowedOrigins(controlUi: Record<string, unknown>, port:
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const gateway = (
|
||||
config.gateway && typeof config.gateway === 'object'
|
||||
? { ...(config.gateway as Record<string, unknown>) }
|
||||
@@ -2472,16 +2452,15 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
? { ...(gateway.controlUi as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
applyControlUiAllowedOrigins(controlUi, gatewayPort);
|
||||
gateway.controlUi = controlUi;
|
||||
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
});
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2535,9 +2514,7 @@ function ensureWebFetchSsrfPolicyInConfig(config: Record<string, unknown>): bool
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const browser = (
|
||||
config.browser && typeof config.browser === 'object'
|
||||
? { ...(config.browser as Record<string, unknown>) }
|
||||
@@ -2573,7 +2550,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
if (!changed) return;
|
||||
|
||||
config.browser = browser;
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
console.log('Synced browser and web_fetch config to openclaw.json');
|
||||
});
|
||||
}
|
||||
@@ -2591,9 +2568,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
||||
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
|
||||
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const session = (
|
||||
config.session && typeof config.session === 'object'
|
||||
? { ...(config.session as Record<string, unknown>) }
|
||||
@@ -2612,22 +2587,36 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
||||
session.idleMinutes = DEFAULT_IDLE_MINUTES;
|
||||
config.session = session;
|
||||
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
console.log(`Synced session.idleMinutes=${DEFAULT_IDLE_MINUTES} (7d) to openclaw.json`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-apply gateway token, browser config, and session idle minutes in a
|
||||
* single config lock + read + write cycle. Replaces three separate
|
||||
* withConfigLock calls during pre-launch sync.
|
||||
* single coordinator transaction. Replaces three separate config mutations
|
||||
* during pre-launch sync.
|
||||
*/
|
||||
export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
const memorySearchMigrationVersion = Number(
|
||||
await getSetting('memorySearchFtsMigrationVersion'),
|
||||
) || 0;
|
||||
const shouldMigrateLegacyMemorySearch =
|
||||
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
|
||||
const hasOpenAiEmbeddingKey = Boolean(await getProviderApiKeyFromOpenClaw('openai'));
|
||||
let pinnedProviderRuntimes: string[] = [];
|
||||
let compactionLog: string | undefined;
|
||||
let memorySearchDefaultResult: 'migrated' | 'seeded' | 'unchanged' = 'unchanged';
|
||||
let backfilledContextWindows: string[] = [];
|
||||
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
const changed = await mutateOpenClawConfig((config) => {
|
||||
let modified = true;
|
||||
pinnedProviderRuntimes = [];
|
||||
compactionLog = undefined;
|
||||
memorySearchDefaultResult = 'unchanged';
|
||||
backfilledContextWindows = [];
|
||||
|
||||
// ── Gateway token + controlUi ──
|
||||
const gateway = (
|
||||
@@ -2650,7 +2639,6 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
? { ...(gateway.controlUi as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
applyControlUiAllowedOrigins(controlUi, gatewayPort);
|
||||
gateway.controlUi = controlUi;
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
@@ -2691,10 +2679,9 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
if (pinnedProviderRuntimes.length > 0) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
|
||||
}
|
||||
|
||||
// ── Session idle minutes ──
|
||||
@@ -2716,58 +2703,65 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
// ── Compaction safeguard default ──
|
||||
if (ensureCompactionSafeguardDefault(config)) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
|
||||
compactionLog = `[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
|
||||
} else if (backfillCompactionReserveTokensFloor(config)) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
|
||||
compactionLog = `[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
|
||||
}
|
||||
|
||||
// ── Memory search default ──
|
||||
// OpenClaw 2026.7.1 supports provider=none as an explicit FTS-only mode.
|
||||
// Migrate ClawX's exact legacy disabled default once, and otherwise seed
|
||||
// FTS only when the user has no memorySearch config or OpenAI embedding key.
|
||||
const memorySearchMigrationVersion = Number(
|
||||
await getSetting('memorySearchFtsMigrationVersion'),
|
||||
) || 0;
|
||||
const shouldMigrateLegacyMemorySearch =
|
||||
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
|
||||
let memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
|
||||
memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
|
||||
&& hasUserMemorySearchConfig(config)
|
||||
? ensureMemorySearchFtsDefault(config, true)
|
||||
: 'unchanged';
|
||||
|
||||
if (memorySearchDefaultResult === 'unchanged'
|
||||
&& !hasUserMemorySearchConfig(config)
|
||||
&& !(await getProviderApiKeyFromOpenClaw('openai'))) {
|
||||
&& !hasOpenAiEmbeddingKey) {
|
||||
memorySearchDefaultResult = ensureMemorySearchFtsDefault(config);
|
||||
}
|
||||
|
||||
if (memorySearchDefaultResult !== 'unchanged') {
|
||||
modified = true;
|
||||
console.log(
|
||||
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
|
||||
+ 'agents.defaults.memorySearch to FTS-only mode',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom provider contextWindow backfill ──
|
||||
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
|
||||
backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
|
||||
}
|
||||
if (shouldMigrateLegacyMemorySearch) {
|
||||
await setSetting(
|
||||
'memorySearchFtsMigrationVersion',
|
||||
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
|
||||
);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
}
|
||||
});
|
||||
if (pinnedProviderRuntimes.length > 0) {
|
||||
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
|
||||
}
|
||||
if (compactionLog) {
|
||||
console.log(compactionLog);
|
||||
}
|
||||
if (memorySearchDefaultResult !== 'unchanged') {
|
||||
console.log(
|
||||
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
|
||||
+ 'agents.defaults.memorySearch to FTS-only mode',
|
||||
);
|
||||
}
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
|
||||
}
|
||||
if (changed) {
|
||||
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
|
||||
}
|
||||
if (shouldMigrateLegacyMemorySearch) {
|
||||
await setSetting(
|
||||
'memorySearchFtsMigrationVersion',
|
||||
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2913,25 +2907,22 @@ function ensureToolDenyIncludes(
|
||||
}
|
||||
|
||||
export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
// Skip sanitization if the config file does not exist yet.
|
||||
// Creating a skeleton config here would overwrite any data written
|
||||
// by the Gateway on its first run.
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
|
||||
return;
|
||||
}
|
||||
// The prelaunch file fallback must not turn a missing or corrupt config into
|
||||
// a valid-looking skeleton. The coordinator performs the successful mutation.
|
||||
let sourceExists: boolean;
|
||||
try {
|
||||
sourceExists = (await readOpenClawConfigSnapshot()).exists;
|
||||
} catch {
|
||||
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
|
||||
return;
|
||||
}
|
||||
if (!sourceExists) {
|
||||
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
|
||||
return;
|
||||
}
|
||||
const authProfileProviders = await getActiveAuthProfileProviders();
|
||||
|
||||
// Read the raw file directly instead of going through readOpenClawJson()
|
||||
// which coalesces null → {}. We need to distinguish a genuinely empty
|
||||
// file (valid, proceed normally) from a corrupt/unreadable file (null,
|
||||
// bail out to avoid overwriting the user's data with a skeleton config).
|
||||
const rawConfig = await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH);
|
||||
if (rawConfig === null) {
|
||||
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = rawConfig;
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
let modified = false;
|
||||
|
||||
// ── skills section ──────────────────────────────────────────────
|
||||
@@ -3022,20 +3013,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands section ───────────────────────────────────────────
|
||||
// Required for SIGUSR1 in-process reload authorization.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
if (commands.restart !== true) {
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
modified = true;
|
||||
console.log('[sanitize] Enabling commands.restart for graceful reload support');
|
||||
}
|
||||
|
||||
// ── tools.web.search.kimi ─────────────────────────────────────
|
||||
// OpenClaw moved moonshot web search config under
|
||||
// plugins.entries.moonshot.config.webSearch. Migrate the old key and strip
|
||||
@@ -3512,7 +3489,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
const bundled = discoverBundledPlugins();
|
||||
const installedExtensionIds = await discoverInstalledExtensionPluginIds();
|
||||
const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config);
|
||||
const activeProviderIds = await collectActiveProviderIdsFromConfig(config);
|
||||
const activeProviderIds = collectActiveProviderIdsFromConfig(config, authProfileProviders);
|
||||
|
||||
const explicitlyEnabledBundledPluginIds = Object.keys(pEntries)
|
||||
.filter((pluginId) => {
|
||||
@@ -3721,7 +3698,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Read/write agents.defaults.imageGenerationModel and per-agent auth readiness.
|
||||
*/
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import { readOpenClawConfig } from './channel-config';
|
||||
import {
|
||||
getOAuthTokenFromOpenClaw,
|
||||
getProviderApiKeyFromOpenClaw,
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
syncOpenAiCompatibleImageRelay,
|
||||
} from './openclaw-auth';
|
||||
import { ensureClawXOpenAiImagePluginInstalled } from './plugin-install';
|
||||
import { listAgentsSnapshot, type AgentsSnapshot } from './agent-config';
|
||||
import {
|
||||
listAgentsSnapshot,
|
||||
listAgentsSnapshotFromConfig,
|
||||
type AgentsSnapshot,
|
||||
} from './agent-config';
|
||||
import { expandPath } from './paths';
|
||||
import {
|
||||
generateImageInProcess,
|
||||
@@ -84,6 +88,7 @@ type AgentModelConfigShape = {
|
||||
primary?: string;
|
||||
fallbacks?: string[];
|
||||
timeoutMs?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -130,12 +135,12 @@ function parseImageGenerationModelConfig(raw: unknown): ImageGenerationModelConf
|
||||
|
||||
function buildImageGenerationModelConfigWrite(
|
||||
config: ImageGenerationModelConfig,
|
||||
existing: unknown,
|
||||
): AgentModelConfigShape | undefined {
|
||||
if (!config.primary && config.fallbacks.length === 0 && config.timeoutMs === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next: AgentModelConfigShape = {};
|
||||
const next: AgentModelConfigShape = isRecord(existing) ? { ...existing } : {};
|
||||
delete next.primary;
|
||||
delete next.fallbacks;
|
||||
delete next.timeoutMs;
|
||||
if (config.primary) {
|
||||
next.primary = config.primary;
|
||||
}
|
||||
@@ -145,7 +150,7 @@ function buildImageGenerationModelConfigWrite(
|
||||
if (config.timeoutMs !== null) {
|
||||
next.timeoutMs = config.timeoutMs;
|
||||
}
|
||||
return next;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
export function parseProviderFromModelRef(modelRef: string): string | null {
|
||||
@@ -207,8 +212,8 @@ export async function setImageGenerationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig();
|
||||
let savedConfig: ImageGenerationModelConfig | undefined;
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const agents = (config.agents && typeof config.agents === 'object'
|
||||
? { ...(config.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
@@ -220,7 +225,7 @@ export async function setImageGenerationConfig(
|
||||
primary: next.primary,
|
||||
fallbacks: [...new Set(next.fallbacks.map((ref) => ref.trim()).filter(Boolean))],
|
||||
timeoutMs: next.timeoutMs,
|
||||
});
|
||||
}, defaults.imageGenerationModel);
|
||||
|
||||
if (writeValue) {
|
||||
defaults.imageGenerationModel = writeValue;
|
||||
@@ -234,10 +239,9 @@ export async function setImageGenerationConfig(
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
await writeOpenClawConfig(config);
|
||||
|
||||
return readImageGenerationConfig();
|
||||
savedConfig = parseImageGenerationModelConfig(defaults.imageGenerationModel);
|
||||
});
|
||||
return savedConfig!;
|
||||
}
|
||||
|
||||
async function buildAgentAuthRows(
|
||||
@@ -316,10 +320,10 @@ function resolveOpenAiImageRelayModelId(
|
||||
}
|
||||
|
||||
export async function getImageGenerationSettingsSnapshot(): Promise<ImageGenerationSettingsSnapshot> {
|
||||
const config = await readImageGenerationConfig();
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
const openclawConfig = await readOpenClawConfig();
|
||||
const defaults = getAgentsDefaults(openclawConfig);
|
||||
const config = parseImageGenerationModelConfig(defaults?.imageGenerationModel);
|
||||
const snapshot = await listAgentsSnapshotFromConfig(openclawConfig);
|
||||
const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false;
|
||||
|
||||
const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null;
|
||||
@@ -348,6 +352,12 @@ export async function applyOpenAiImageRelaySettings(params: {
|
||||
apiKey?: string;
|
||||
model?: string | null;
|
||||
}): Promise<void> {
|
||||
if (params.enabled) {
|
||||
const plugin = await ensureClawXOpenAiImagePluginInstalled();
|
||||
if (!plugin.installed) {
|
||||
throw new Error(plugin.warning || 'Failed to install ClawX OpenAI Image plugin');
|
||||
}
|
||||
}
|
||||
const imageModelIds: string[] = [];
|
||||
const explicitModel = params.model?.trim();
|
||||
if (explicitModel) {
|
||||
@@ -364,14 +374,11 @@ export async function applyOpenAiImageRelaySettings(params: {
|
||||
apiKey: params.apiKey,
|
||||
imageModelIds,
|
||||
});
|
||||
if (params.enabled) {
|
||||
ensureClawXOpenAiImagePluginInstalled();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listImageGenerationProvidersFromRuntime(): Promise<ImageGenerationProviderRow[]> {
|
||||
const cfg = await readOpenClawConfig();
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
const snapshot = await listAgentsSnapshotFromConfig(cfg);
|
||||
const rows = await listImageGenerationProvidersInProcess({
|
||||
config: cfg,
|
||||
isProviderConfigured: (providerId) => isImageProviderAuthenticated(providerId, snapshot.defaultAgentId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import type { OpenClawConfig } from './channel-config';
|
||||
import { resolveProxySettings, type ProxySettings } from './proxy';
|
||||
import { logger } from './logger';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
|
||||
interface SyncProxyOptions {
|
||||
/**
|
||||
@@ -19,23 +19,26 @@ export async function syncProxyConfigToOpenClaw(
|
||||
settings: ProxySettings,
|
||||
options: SyncProxyOptions = {},
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig();
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const syncState: { result: 'unchanged' | 'preserved' | 'updated' } = { result: 'unchanged' };
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
syncState.result = 'unchanged';
|
||||
const config = snapshot as OpenClawConfig;
|
||||
const telegramConfig = config.channels?.telegram;
|
||||
|
||||
if (!telegramConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
|
||||
|
||||
if (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) {
|
||||
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
|
||||
syncState.result = 'preserved';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +60,12 @@ export async function syncProxyConfigToOpenClaw(
|
||||
delete config.channels.telegram.proxy;
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
|
||||
syncState.result = 'updated';
|
||||
});
|
||||
|
||||
if (syncState.result === 'preserved') {
|
||||
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
|
||||
} else if (syncState.result === 'updated') {
|
||||
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
removePluginInstallRecordsFromSqlite,
|
||||
ensureOpenClawStateDirExists,
|
||||
} from './plugin-install-index';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
|
||||
function normalizeFsPathForWindows(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
@@ -264,8 +265,6 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
/**
|
||||
* Channel plugins whose ClawX-managed mirrors need synchronized install
|
||||
* metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks;
|
||||
@@ -381,33 +380,30 @@ function pluginInstallRecordIds(pluginDirName: string): string[] {
|
||||
].filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): boolean {
|
||||
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) return false;
|
||||
async function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): Promise<boolean> {
|
||||
const removedIds = new Set<string>();
|
||||
const changed = await mutateOpenClawConfig((config) => {
|
||||
removedIds.clear();
|
||||
const plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return;
|
||||
const pluginsRecord = plugins as Record<string, unknown>;
|
||||
const installs = pluginsRecord.installs;
|
||||
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return;
|
||||
|
||||
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false;
|
||||
const pluginsRecord = plugins as Record<string, unknown>;
|
||||
const installs = pluginsRecord.installs;
|
||||
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return false;
|
||||
|
||||
const installsRecord = installs as Record<string, unknown>;
|
||||
const removedIds = pluginIds.filter((pluginId) => Object.hasOwn(installsRecord, pluginId));
|
||||
if (removedIds.length === 0) return false;
|
||||
for (const pluginId of removedIds) {
|
||||
delete installsRecord[pluginId];
|
||||
const installsRecord = installs as Record<string, unknown>;
|
||||
for (const pluginId of pluginIds) {
|
||||
if (!Object.hasOwn(installsRecord, pluginId)) continue;
|
||||
delete installsRecord[pluginId];
|
||||
removedIds.add(pluginId);
|
||||
}
|
||||
if (removedIds.size > 0 && Object.keys(installsRecord).length === 0) {
|
||||
delete pluginsRecord.installs;
|
||||
}
|
||||
});
|
||||
if (removedIds.size > 0) {
|
||||
logger.info(`[plugin] Removed legacy config install metadata for: ${[...removedIds].join(', ')}`);
|
||||
}
|
||||
if (Object.keys(installsRecord).length === 0) {
|
||||
delete pluginsRecord.installs;
|
||||
}
|
||||
writeFileSync(
|
||||
fsPath(OPENCLAW_CONFIG_PATH),
|
||||
`${JSON.stringify(config, null, 2)}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
logger.info(`[plugin] Removed legacy config install metadata for: ${removedIds.join(', ')}`);
|
||||
return true;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function canonicalComparablePath(filePath: string): string {
|
||||
@@ -524,10 +520,10 @@ function persistTrustedOfficialPluginInstallRecordsToSqlite(
|
||||
* migration input, so remove that transient copy instead of recreating it.
|
||||
* Safe to call repeatedly; no-ops when metadata is already current.
|
||||
*/
|
||||
export function syncTrustedOfficialPluginInstallRecord(
|
||||
export async function syncTrustedOfficialPluginInstallRecord(
|
||||
pluginDirName: string,
|
||||
targetDir: string,
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
if (!expected) return false;
|
||||
|
||||
@@ -544,7 +540,7 @@ export function syncTrustedOfficialPluginInstallRecord(
|
||||
let jsonChanged = false;
|
||||
try {
|
||||
ensureOpenClawStateDirExists();
|
||||
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
} catch (error) {
|
||||
// Keep the canonical SQLite repair available even if legacy config cleanup
|
||||
// cannot be completed in this pass.
|
||||
@@ -567,13 +563,13 @@ export function syncTrustedOfficialPluginInstallRecord(
|
||||
* run even when its extension directory is already missing: stale records are
|
||||
* themselves enough to fail OpenClaw's post-core payload smoke check.
|
||||
*/
|
||||
export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): boolean {
|
||||
export async function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): Promise<boolean> {
|
||||
const recordIds = pluginInstallRecordIds(pluginDirName);
|
||||
if (recordIds.length === 0) return false;
|
||||
|
||||
let jsonChanged = false;
|
||||
try {
|
||||
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
} catch (error) {
|
||||
logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error);
|
||||
}
|
||||
@@ -582,13 +578,13 @@ export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string):
|
||||
}
|
||||
|
||||
/** Repair managed install metadata and host peer links for all mirrors on disk. */
|
||||
export function repairTrustedOfficialPluginInstallRecords(): void {
|
||||
export async function repairTrustedOfficialPluginInstallRecords(): Promise<void> {
|
||||
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
||||
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||
continue;
|
||||
}
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,11 +723,11 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
||||
|
||||
// ── Core install / upgrade logic ─────────────────────────────────────────────
|
||||
|
||||
export function ensurePluginInstalled(
|
||||
export async function ensurePluginInstalled(
|
||||
pluginDirName: string,
|
||||
candidateSources: string[],
|
||||
pluginLabel: string,
|
||||
): { installed: boolean; warning?: string } {
|
||||
): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
const targetPkgJson = join(targetDir, 'package.json');
|
||||
@@ -741,13 +737,13 @@ export function ensurePluginInstalled(
|
||||
// If already installed, check whether an upgrade is available
|
||||
if (existsSync(fsPath(targetManifest))) {
|
||||
if (!sourceDir) {
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // no bundled source to compare, keep existing
|
||||
}
|
||||
const installedVersion = readPluginVersion(targetPkgJson);
|
||||
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
||||
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version or unable to compare
|
||||
}
|
||||
// Version differs — fall through to overwrite install
|
||||
@@ -771,7 +767,7 @@ export function ensurePluginInstalled(
|
||||
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
||||
}
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||
return { installed: true };
|
||||
} catch (error) {
|
||||
@@ -820,7 +816,7 @@ export function ensurePluginInstalled(
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true };
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -838,7 +834,7 @@ export function ensurePluginInstalled(
|
||||
);
|
||||
}
|
||||
} else if (existsSync(fsPath(targetManifest))) {
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version, already installed
|
||||
}
|
||||
}
|
||||
@@ -874,15 +870,15 @@ export function buildCandidateSources(pluginDirName: string): string[] {
|
||||
|
||||
// ── Per-channel plugin helpers ───────────────────────────────────────────────
|
||||
|
||||
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
|
||||
}
|
||||
|
||||
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
|
||||
}
|
||||
|
||||
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled(
|
||||
'feishu-openclaw-plugin',
|
||||
buildCandidateSources('feishu-openclaw-plugin'),
|
||||
@@ -892,23 +888,23 @@ export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: s
|
||||
|
||||
|
||||
|
||||
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
}
|
||||
|
||||
export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
|
||||
}
|
||||
|
||||
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
|
||||
}
|
||||
|
||||
export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
|
||||
}
|
||||
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): { installed: boolean; warning?: string } {
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
return ensurePluginInstalled(
|
||||
'clawx-openai-image',
|
||||
buildCandidateSources('clawx-openai-image'),
|
||||
@@ -941,7 +937,7 @@ const ALL_BUNDLED_PLUGINS = [
|
||||
export async function ensureAllBundledPluginsInstalled(): Promise<void> {
|
||||
for (const { fn, label } of ALL_BUNDLED_PLUGINS) {
|
||||
try {
|
||||
const result = fn();
|
||||
const result = await fn();
|
||||
if (result.warning) {
|
||||
logger.warn(`[plugin] ${label}: ${result.warning}`);
|
||||
}
|
||||
@@ -949,5 +945,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise<void> {
|
||||
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
|
||||
}
|
||||
}
|
||||
repairTrustedOfficialPluginInstallRecords();
|
||||
await repairTrustedOfficialPluginInstallRecords();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
/**
|
||||
* Skill Config Utilities
|
||||
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
* Skill configuration reads and coordinated mutations for openclaw.json.
|
||||
*/
|
||||
import { readFile, writeFile, access, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawDir, getOpenClawResolvedDir, getResourcesDir } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { cpAsyncSafe } from './plugin-install';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']);
|
||||
|
||||
export interface SkillConfigUpdates {
|
||||
@@ -60,52 +55,35 @@ interface PreinstalledMarker {
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current OpenClaw config
|
||||
*/
|
||||
async function readConfig(): Promise<OpenClawConfig> {
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
return (await readOpenClawConfigSnapshot()).config as OpenClawConfig;
|
||||
} catch (err) {
|
||||
console.error('Failed to read openclaw config:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the OpenClaw config
|
||||
*/
|
||||
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
}
|
||||
|
||||
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
|
||||
if (skillKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
return withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.skills) {
|
||||
config.skills = {};
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const skillConfig = config as OpenClawConfig;
|
||||
if (!skillConfig.skills) {
|
||||
skillConfig.skills = {};
|
||||
}
|
||||
if (!config.skills.entries) {
|
||||
config.skills.entries = {};
|
||||
if (!skillConfig.skills.entries) {
|
||||
skillConfig.skills.entries = {};
|
||||
}
|
||||
for (const skillKey of skillKeys) {
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
const entry = skillConfig.skills.entries[skillKey] || {};
|
||||
entry.enabled = enabled;
|
||||
config.skills.entries[skillKey] = entry;
|
||||
skillConfig.skills.entries[skillKey] = entry;
|
||||
}
|
||||
await writeConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,12 +187,10 @@ export async function updateSkillConfigs(
|
||||
updates: Array<{ skillKey: string } & SkillConfigUpdates>,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
return await withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
await applySkillConfigUpdates(config, updates);
|
||||
await writeConfig(config);
|
||||
return { success: true };
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
await applySkillConfigUpdates(config as OpenClawConfig, updates);
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to update skill config:', err);
|
||||
return { success: false, error: String(err) };
|
||||
@@ -227,25 +203,25 @@ export async function removeSkillConfig(skillKey: string): Promise<{ success: bo
|
||||
|
||||
export async function removeSkillConfigs(skillKeys: string[]): Promise<{ success: boolean; removed: number; error?: string }> {
|
||||
try {
|
||||
return await withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
const existingEntries = config.skills?.entries || {};
|
||||
const normalizedSkillKeys = skillKeys
|
||||
.map((skillKey) => skillKey.trim())
|
||||
.filter(Boolean);
|
||||
const removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
|
||||
const normalizedSkillKeys = skillKeys
|
||||
.map((skillKey) => skillKey.trim())
|
||||
.filter(Boolean);
|
||||
let removed = 0;
|
||||
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
const skillConfig = config as OpenClawConfig;
|
||||
const existingEntries = skillConfig.skills?.entries || {};
|
||||
removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
|
||||
if (removed === 0) {
|
||||
return { success: true, removed: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
await applySkillConfigUpdates(
|
||||
config,
|
||||
skillConfig,
|
||||
normalizedSkillKeys.map((skillKey) => ({ skillKey, remove: true })),
|
||||
);
|
||||
await writeConfig(config);
|
||||
return { success: true, removed };
|
||||
});
|
||||
return { success: true, removed };
|
||||
} catch (err) {
|
||||
console.error('Failed to remove skill configs:', err);
|
||||
return { success: false, removed: 0, error: String(err) };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
import { normalizeOpenClawAccountId } from './channel-alias';
|
||||
import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution';
|
||||
|
||||
@@ -209,18 +209,9 @@ function isLoginFresh(login: ActiveLogin): boolean {
|
||||
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
const envPath = process.env.OPENCLAW_CONFIG?.trim();
|
||||
if (envPath) return envPath;
|
||||
return join(OPENCLAW_DIR, 'openclaw.json');
|
||||
}
|
||||
|
||||
function loadWeChatRouteTag(accountId?: string): string | undefined {
|
||||
async function loadWeChatRouteTag(accountId?: string): Promise<string | undefined> {
|
||||
try {
|
||||
const configPath = resolveConfigPath();
|
||||
if (!existsSync(configPath)) return undefined;
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
const parsed = (await readOpenClawConfigSnapshot()).config as {
|
||||
channels?: Record<string, {
|
||||
routeTag?: string | number;
|
||||
accounts?: Record<string, { routeTag?: string | number }>;
|
||||
@@ -246,7 +237,7 @@ async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType
|
||||
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
||||
const headers: Record<string, string> = {};
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
const routeTag = await loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
@@ -265,7 +256,7 @@ async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?
|
||||
const headers: Record<string, string> = {
|
||||
'iLink-App-ClientVersion': '1',
|
||||
};
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
const routeTag = await loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ACP Chat Architecture And Timeline
|
||||
|
||||
Status: current architecture reference, reviewed 2026-07-15.
|
||||
Status: current architecture reference, reviewed 2026-07-27.
|
||||
|
||||
Related scenario: `acp-chat-experience`
|
||||
|
||||
@@ -19,7 +19,7 @@ Chat UI -> host-api -> Main ACP service -> openclaw acp
|
||||
session/update -> Main routing envelope -> Renderer reducer -> timeline -> React
|
||||
```
|
||||
|
||||
Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
|
||||
Gateway remains responsible for non-Chat capabilities. Renderer Chat does not call ordinary Gateway `chat.history` or `chat.send`, and Main has no Chat-history polling, coalescing, or backpressure specialization. Generic Gateway RPC requests retain Main-owned validation and timeout handling before direct `GatewayManager.rpc` dispatch. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
|
||||
|
||||
## Identity And Race Protection
|
||||
|
||||
@@ -33,7 +33,7 @@ While `session/prompt` is pending, Main retains a bounded session-id routing con
|
||||
|
||||
ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay can restore tools and file activity; transcript-only fallback must not invent them.
|
||||
|
||||
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, live updates continue through the normal host-event route. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
|
||||
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, each live update continues through the normal host-event route and is applied immediately without a Renderer batching timer. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
|
||||
|
||||
There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary.
|
||||
|
||||
@@ -88,7 +88,7 @@ Available attachment cards contain a primary semantic action with keyboard activ
|
||||
|
||||
## Chat Behaviors
|
||||
|
||||
- The primary Chat view does not render the legacy Execution Graph.
|
||||
- The primary Chat view renders process activity directly in the ordered ACP timeline.
|
||||
- A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible.
|
||||
- The working indicator follows the same sending state as the Stop action and supports reduced motion.
|
||||
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. When open, the directory floats above the conversation without changing the chat column width. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Electron Rendering Performance
|
||||
|
||||
Status: hardware-acceleration policy and interaction profile baselined 2026-08-01.
|
||||
|
||||
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
|
||||
|
||||
Related rule: `electron-rendering-performance`
|
||||
|
||||
Related task: `restore-hardware-accelerated-rendering`
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
ClawX leaves Electron and Chromium hardware acceleration enabled by default. Main must not call `app.disableHardwareAcceleration()` or append a global `disable-gpu` switch. Chromium owns driver detection and fallback; users with a broken driver may still launch ClawX with Chromium's native `--disable-gpu` switch.
|
||||
|
||||
Headless Linux and virtualized CI may report software compositing because no usable GPU is present. Tests must distinguish that environment fallback from an application-owned global disable policy. Desktop GPU assertions therefore run only where the test environment provides a real desktop GPU.
|
||||
|
||||
## Diagnostic Contract
|
||||
|
||||
`pnpm run perf:chat` covers both high-frequency ACP streaming and idle interaction with a rich static Markdown document. The interaction workload records sidebar-collapse and vertical-scroll frame intervals, Renderer performance metrics, DOM size, GPU feature status, and Renderer/Main CPU profiles. It uses generated content and writes only ignored Playwright artifacts.
|
||||
|
||||
For a reported desktop regression, first reproduce with the user's real conversation and record `app.isHardwareAccelerationEnabled()` plus `app.getGPUFeatureStatus()` after `gpu-info-update`. Compare repeated runs on the same machine. Main CPU profiles do not include browser/GPU process rasterization or compositing, so a profile dominated by Chromium `(program)` time must be interpreted together with frame pacing and GPU status rather than as unexplained React work.
|
||||
|
||||
Do not add machine-independent frame-time gates. Preserve semantic assertions, generated workload shape, and artifact schemas; compare repeated local or controlled-run medians when reviewing rendering changes.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
- Main policy: `electron/main/index.ts` and `tests/unit/main-hardware-acceleration.test.ts`.
|
||||
- Desktop runtime behavior: `tests/e2e/hardware-acceleration.spec.ts`.
|
||||
- Streaming and interaction profiles: `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Markdown Rendering
|
||||
|
||||
Status: migration contract baselined 2026-08-01; implementation is validated by the related task.
|
||||
|
||||
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
|
||||
|
||||
Related rule: `markdown-rendering-safety-and-performance`
|
||||
|
||||
Related task: `replace-markdown-renderer-with-streamdown`
|
||||
|
||||
## Rendering Ownership
|
||||
|
||||
ClawX has two application Markdown surfaces with distinct update behavior and one shared renderer configuration:
|
||||
|
||||
| Surface | Mode | Content |
|
||||
| --- | --- | --- |
|
||||
| ACP Chat | `streaming` | Assistant message and process Markdown parts |
|
||||
| Markdown file preview | `static` | Authorized local Markdown file content |
|
||||
|
||||
User messages remain literal React text and tool output remains preformatted. Neither enters Streamdown. The migration is presentation-only: ACP transport, event ordering, timeline reduction, store cadence, history, and Renderer/Main boundaries do not change.
|
||||
|
||||
The shared plugin, rehype, component, animation, controls, and link-safety values remain module-scoped. Stable references allow Streamdown to retain completed block output instead of invalidating memoized blocks on each chunk.
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
Exactly these optional capabilities are enabled:
|
||||
|
||||
- `@streamdown/code` for Shiki-backed fenced-code highlighting.
|
||||
- `@streamdown/math` for KaTeX, configured with `singleDollarTextMath: true`.
|
||||
- `@streamdown/cjk` for CJK-aware autolink and punctuation boundaries.
|
||||
|
||||
`@streamdown/mermaid` is not a direct dependency and is not configured. A `mermaid` fence remains an ordinary highlighted code block and never becomes a diagram, SVG, or interactive Mermaid container.
|
||||
|
||||
KaTeX remains a direct dependency because the math plugin requires its CSS. The application imports `katex/dist/katex.min.css` exactly once. It also imports `streamdown/styles.css` exactly once so the selected animation keyframes and data-attribute styles exist. Tailwind scans Streamdown and each installed plugin distribution, but no Mermaid distribution path.
|
||||
|
||||
## Content Safety
|
||||
|
||||
Streamdown does not expand the authority of generated content:
|
||||
|
||||
- User text and tool output remain literal outside the Markdown renderer.
|
||||
- The shared rehype list retains Streamdown sanitization and hardening but omits raw-HTML parsing. Source HTML such as `<script>alert(1)</script>` remains visible text and does not create an element.
|
||||
- Links render through `BrowserLink`, which has no interactive anchor role or navigation. Streamdown link-safety UI is disabled because links are already inert.
|
||||
- ACP Markdown images continue through `isSafeAcpImageSource`; an unapproved source does not become an image request.
|
||||
- Table, Mermaid, code download, and line-number controls are disabled. Fenced code alone exposes Streamdown's copy control with its label supplied through `react-i18next`; the control remains disabled while a response is streaming.
|
||||
|
||||
Static preview keeps `remark-frontmatter` for YAML (`---`) and TOML (`+++`) frontmatter. Parsed frontmatter is omitted from visible output. There is no custom frontmatter splitter, metadata card, or metadata `<pre>`.
|
||||
|
||||
## Streaming And Animation
|
||||
|
||||
ACP Chat repairs incomplete Markdown while the response is active, but animation state is narrower than transport state. The Renderer derives active segment IDs from the open ACP assistant message segments only while send or cancel is active. A Markdown part receives `isAnimating`, word animation, and `caret="circle"` only when all of these conditions hold:
|
||||
|
||||
- Its assistant segment is currently open.
|
||||
- It is the segment's final part.
|
||||
- Its part kind is Markdown.
|
||||
|
||||
Earlier parts, completed segments, thoughts, user messages, and tool output never animate. The animation is word-level `fadeIn` with `duration: 140` and `stagger: 0`; character-level animation is forbidden. Previously completed words and blocks must remain stable as later chunks arrive, and the caret disappears when the send settles.
|
||||
|
||||
## Presentation Contract
|
||||
|
||||
Chat keeps the assistant-without-bubble layout and ClawX's established prose rhythm. Scoped Streamdown selectors restore heading and horizontal-rule margins over Streamdown's root spacing utility, compact ordered, unordered, and task-list items, and remove table wrapper borders while retaining the cell grid. Fenced code preserves Shiki's source-row spans as block lines, soft-wraps long lines, uses a compact right-aligned language header with vertically centered actions, and exposes copy without download; file preview keeps its preview-specific headings and inline code. Styling uses existing ClawX surfaces, text colors, dark-mode variants, and other design tokens; Streamdown defaults must not leak broad global changes into unrelated prose.
|
||||
|
||||
The supported math contract includes `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. CJK tests anchor punctuation exclusion from autolinks. Code tests wait for Shiki token output rather than assuming highlighting is synchronous.
|
||||
|
||||
## Performance Baseline And Review
|
||||
|
||||
`pnpm run perf:chat` builds the production Renderer and executes a deterministic 80-turn history plus 300 streaming chunks. Before renderer changes and after migration, run it three times on the same machine and retain each generated `renderer-benchmark.json`, `renderer.cpuprofile`, and `main.cpuprofile` under ignored `test-results/` paths.
|
||||
|
||||
Compare before/after medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout and style duration, long-task count and duration, and sampled Markdown/React CPU stacks. Median TaskDuration and ScriptDuration must each stay within 10 percent of baseline. At least one of median ScriptDuration or sampled Markdown/render CPU time must improve by 10 percent or more. A miss requires profiling animation, Shiki, and last-block costs rather than weakening the threshold. Absolute machine timings are evidence for the local comparison, not automated cross-machine gates.
|
||||
|
||||
Build with `pnpm exec vite build --sourcemap` and inspect chunk sizes and source maps. Streamdown and Shiki are expected costs. The review must confirm no direct Mermaid plugin and no unexpected eager Mermaid renderer chunk. Dormant code retained by Streamdown core is measured and documented rather than described as Mermaid UI support.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
Shared configuration is anchored by `src/components/markdown/streamdown-config.ts` and `tests/unit/streamdown-config.test.tsx`.
|
||||
|
||||
Static preview behavior is anchored by `src/components/file-preview/MarkdownPreview.tsx`, `tests/unit/markdown-preview.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/e2e/markdown-file-preview.spec.ts`.
|
||||
|
||||
Streaming state and rendering are anchored by `src/pages/Chat/AcpTimeline.tsx`, `src/pages/Chat/AcpAssistantTurn.tsx`, `src/pages/Chat/AcpMessageSegment.tsx`, `tests/unit/acp-chat-components.test.tsx`, and `tests/e2e/chat-streamdown-rendering.spec.ts`.
|
||||
|
||||
Existing soft-wrap, KaTeX, plain-assistant, and table-theme behavior remains anchored by `tests/e2e/chat-code-block-wrap.spec.ts`, `tests/e2e/chat-latex-rendering.spec.ts`, `tests/e2e/chat-assistant-markdown-plain.spec.ts`, and `tests/e2e/chat-table-header-light.spec.ts`. Performance evidence is produced by `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
|
||||
@@ -0,0 +1,21 @@
|
||||
# OpenClaw Config Delivery
|
||||
|
||||
ClawX bundles OpenClaw 2026.7.1. OpenClaw owns the field-level decision between a no-op snapshot update, hot application, subsystem restart, and in-process Gateway restart.
|
||||
|
||||
Provider, Agent, Channel, skill, proxy, image-generation, and plugin-install helpers express config changes as mutators. One Main-owned coordinator owns selection of the authoritative baseline and the commit:
|
||||
|
||||
1. If Gateway is running, call `config.get` and require its runtime-shaped `config` object and `hash`. The coordinator accepts `raw` only as a compatibility fallback for older responses.
|
||||
2. Clone the runtime-shaped config, apply the mutator, and call `config.set` with the serialized result and `baseHash: hash`. Using source-shaped `raw` as the preferred baseline can misalign redacted secret paths with OpenClaw's runtime-shaped restore baseline.
|
||||
3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway.
|
||||
4. Treat success as converged and do not send `SIGUSR1` or replace the process.
|
||||
5. If Gateway is stopped or starting, apply the same mutator to `resolveOpenClawConfigPath()` under the shared config lock and do not start the Gateway.
|
||||
|
||||
This is not a write-then-notify design. No provider, Agent, Channel, skill, proxy, image-generation, or plugin-install helper may write the active config independently. The coordinator prevents a locally read stale snapshot from overwriting concurrent Gateway or CLI config changes.
|
||||
|
||||
Gateway WebSocket tracing must redact the complete serialized `raw` payload for `config.set`, `config.patch`, and `config.apply`; key-based structural redaction cannot inspect secrets embedded inside that string.
|
||||
|
||||
Coordinator-backed reads follow the same authority rule: prefer the runtime-shaped `config.get.config` object while Gateway is running and use JSON5 file parsing while it is not. Compound views derive all config-backed fields from one snapshot.
|
||||
|
||||
OpenClaw 2026.7.1 keeps auth-profile SQLite snapshots in memory. After a completed auth-store write batch, ClawX calls `secrets.reload` once when Gateway is running. `config.set` does not replace this refresh. Agent `models.json` needs no explicit RPC because OpenClaw re-reads it when its file fingerprint changes.
|
||||
|
||||
Full ClawX process replacement remains necessary after a successful coordinator commit when values are injected only at process creation, including proxy environment changes, or for explicit manual lifecycle and health/crash recovery. OpenClaw config categories must not be duplicated as a ClawX restart whitelist.
|
||||
@@ -182,6 +182,6 @@ Do not begin this migration merely because a type exists in an unbundled upstrea
|
||||
|
||||
Primary implementation anchors are `shared/chat/types.ts`, `src/stores/gateway.ts`, `src/stores/chat.ts`, `src/stores/chat/session-catalog.ts`, `src/stores/chat/session-status.ts`, `src/stores/chat/session-label-hydration.ts`, `src/stores/session-attention.ts`, `src/components/layout/Sidebar.tsx`, and `src/pages/Chat/index.tsx`.
|
||||
|
||||
Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-store-history-retry.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
|
||||
Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-session-management.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
|
||||
|
||||
Communication changes require the task's Harness validation, communication replay/compare, typecheck, lint, Vite build, targeted unit tests, and Electron E2E test.
|
||||
|
||||
@@ -8,7 +8,7 @@ appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: electron-rendering-performance
|
||||
title: Electron Rendering Performance
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- acp-chat-experience
|
||||
- chat-workspace-and-navigation
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
---
|
||||
|
||||
Leave Electron hardware acceleration enabled by default. Do not call `app.disableHardwareAcceleration()` or globally append `disable-gpu`; Chromium must retain driver detection and its native `--disable-gpu` troubleshooting fallback. Treat software compositing reported by headless or GPU-less CI as an environment result, not a reason to force every desktop renderer onto software rasterization.
|
||||
|
||||
Rendering performance investigations must combine frame pacing, Renderer metrics/profile data, and `app.getGPUFeatureStatus()` captured after `gpu-info-update`. Main CPU profiles do not cover browser/GPU process rasterization. Do not attribute Chromium `(program)` samples to React without an isolated variable that changes the result.
|
||||
|
||||
Keep `pnpm run perf:chat` coverage for both ACP streaming and rich static Markdown interaction. The interaction workload must exercise the production sidebar width animation and vertical Chat scroll path, record generated-only artifacts, and avoid hardware-independent timing gates. Compare repeated runs on the same machine while preserving semantic E2E assertions.
|
||||
|
||||
The full runtime policy and validation anchors are recorded in `harness/reference/electron-rendering-performance.md`.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: markdown-rendering-safety-and-performance
|
||||
title: Markdown Rendering Safety And Performance
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- acp-chat-experience
|
||||
- chat-workspace-and-navigation
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
---
|
||||
|
||||
Use one module-scoped Streamdown configuration for application Markdown. ACP assistant and process Markdown uses streaming mode with incomplete-Markdown repair; Markdown file preview uses static mode. User messages and tool output remain literal and must not enter Streamdown. Enable only the code, math, and CJK plugins. Keep single-dollar math enabled, retain the direct KaTeX dependency and one application KaTeX stylesheet import, and import Streamdown styles once. Do not install or configure the Mermaid plugin; Mermaid fences remain code.
|
||||
|
||||
Preserve the existing content boundary. Build the rehype list from Streamdown defaults without raw-HTML parsing while retaining sanitization and hardening, so raw HTML is visible literal text and never becomes active DOM. Render links through inert `BrowserLink` and disable Streamdown link-safety UI because no anchor remains interactive. Markdown images must continue through `isSafeAcpImageSource`. Enable only the localized code-copy control; disable table, Mermaid, code-download, and line-number controls. Parse YAML and TOML frontmatter in static preview and omit it from output; do not restore the custom splitter or metadata card.
|
||||
|
||||
Keep plugin arrays, component maps, animation options, and security options at module scope so reference churn does not invalidate block memoization. Only the open assistant message segment's final Markdown part may set animation or caret props. Use word-level `fadeIn` with duration 140, stagger 0, and a circle caret; never animate by character. Completed segments, user messages, thoughts, earlier parts, and inactive sends must remain stable and must not acquire or restart animation.
|
||||
|
||||
Preserve ClawX design tokens, assistant-without-bubble layout, prose block spacing, compact lists, the cell-only table grid and themes, source-line-preserving soft-wrapped code with a compact right-aligned language header, vertically centered copy action, and existing inert-link and image styling. Add Electron E2E coverage for streaming Chat and static preview. Tests must cover incomplete Markdown, highlighted and copyable multiline code, all supported math delimiters, CJK punctuation, Mermaid-as-code, literal raw HTML, inert links, safe images, literal user and tool output, frontmatter omission, active-part-only animation, completed-block stability, and the existing visual contracts.
|
||||
|
||||
Capture three successful 80-turn and 300-chunk `pnpm run perf:chat` profiles before and after renderer changes on the same machine, retaining ignored Renderer metrics plus Renderer/Main CPU profiles. Compare three-run medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout duration, long-task count and duration, and sampled Markdown/React stacks. TaskDuration and ScriptDuration may each regress by at most 10 percent; median ScriptDuration or sampled Markdown/render CPU time must improve by at least 10 percent. Inspect a production sourcemap build for Streamdown, Shiki, and unexpected Mermaid cost. Do not replace these relative checks with machine-specific automated timing gates.
|
||||
|
||||
The complete rationale, ownership, safety policy, and validation anchors are recorded in `harness/reference/markdown-rendering.md`.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: openclaw-config-delivery
|
||||
title: OpenClaw Config Delivery
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- comms
|
||||
references:
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
---
|
||||
|
||||
ClawX must defer runtime config planning to the bundled OpenClaw Gateway.
|
||||
|
||||
The Main-owned config coordinator must own the entire read-modify-write transaction. Production helpers must not write the active OpenClaw config and then notify another layer afterward.
|
||||
|
||||
When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write.
|
||||
|
||||
Coordinator mutators are replayable transformations. They must not perform filesystem writes, SQLite writes, settings writes, lifecycle actions, or other non-idempotent external effects; preload required external inputs before entering the mutator and perform follow-up effects only after a successful commit.
|
||||
|
||||
Gateway WebSocket traces must replace serialized `raw` config-write payloads with a redacted marker. They must not log credentials introduced by a mutator.
|
||||
|
||||
When the Gateway is stopped or starting, the same coordinator mutates the resolved config file under the shared config lock. It must not start the Gateway solely to apply a config mutation.
|
||||
|
||||
ClawX may replace the Gateway process for process-launch environment or argument changes, explicit user restart, application lifecycle, health/crash recovery, or a failed config-delivery fallback. Provider, Agent, Channel, binding, skill, model, and ordinary plugin-entry config changes must not carry a blanket ClawX restart policy when OpenClaw can plan them.
|
||||
|
||||
All coordinator file fallback reads and writes must resolve the active config through `resolveOpenClawConfigPath()` so file delivery and Gateway RPC target the same config. No other production module may write that file.
|
||||
@@ -12,6 +12,7 @@ ownedPaths:
|
||||
- electron/services/attachment-access.ts
|
||||
- electron/services/attachment-open-with.ts
|
||||
- electron/services/files-api.ts
|
||||
- electron/main/index.ts
|
||||
- resources/scripts/attachment-open-with.ps1
|
||||
- src/lib/acp/**
|
||||
- src/lib/file-preview-client.ts
|
||||
@@ -27,12 +28,20 @@ ownedPaths:
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
conditionalProfiles:
|
||||
e2e:
|
||||
- ACP timeline presentation changes
|
||||
- Chat Markdown rendering, syntax highlighting, or animation changes
|
||||
- send, cancel, permission, media, or history behavior changes
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
@@ -44,6 +53,8 @@ requiredRules:
|
||||
- tool-derived-file-safety
|
||||
- office-preview-safety
|
||||
- ui-i18n-design-tokens
|
||||
- markdown-rendering-safety-and-performance
|
||||
- electron-rendering-performance
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
---
|
||||
@@ -52,4 +63,4 @@ ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduc
|
||||
|
||||
Main owns ACP transport, routing, transcript retrieval and timing extraction, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility and timing alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay remains authoritative for historical turns and content; transcript-derived timing may only annotate an unambiguously matched ACP turn. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
|
||||
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, and `harness/reference/office-document-preview.md`.
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, Markdown rendering, Electron rendering performance policy, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, `harness/reference/markdown-rendering.md`, and `harness/reference/electron-rendering-performance.md`.
|
||||
|
||||
@@ -6,11 +6,11 @@ ownedPaths:
|
||||
- shared/workspace.ts
|
||||
- shared/chat/session-title.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/main/index.ts
|
||||
- src/lib/workspace-context.ts
|
||||
- src/hooks/use-workspace-availability.ts
|
||||
- src/stores/settings.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/session-attention.ts
|
||||
- src/stores/chat/session-status.ts
|
||||
@@ -19,6 +19,7 @@ ownedPaths:
|
||||
- src/components/file-preview/ArtifactPanel.tsx
|
||||
- src/components/file-preview/WorkspaceBrowserBody.tsx
|
||||
- src/components/file-preview/FilePreviewBody.tsx
|
||||
- src/components/file-preview/MarkdownPreview.tsx
|
||||
- src/components/file-preview/DocxViewer.tsx
|
||||
- src/components/file-preview/PptxViewer.tsx
|
||||
- src/components/file-preview/build-preview-target.ts
|
||||
@@ -30,6 +31,7 @@ ownedPaths:
|
||||
- src/pages/Chat/AcpTurnFileActivity.tsx
|
||||
- src/pages/Chat/AcpAttachmentPart.tsx
|
||||
- src/components/web-browser/**
|
||||
- src/components/markdown/**
|
||||
- src/stores/artifact-panel.ts
|
||||
- src/components/layout/MainLayout.tsx
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
@@ -49,9 +51,10 @@ ownedPaths:
|
||||
- tests/unit/i18n-locale-parity.test.ts
|
||||
- tests/unit/session-buckets.test.ts
|
||||
- tests/unit/generated-files.test.ts
|
||||
- tests/unit/generated-files-panel.test.tsx
|
||||
- tests/unit/open-file-utils.test.ts
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/markdown-preview.test.tsx
|
||||
- tests/unit/streamdown-config.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
- tests/unit/office-file-viewers.test.tsx
|
||||
- tests/unit/artifact-panel.test.tsx
|
||||
@@ -64,11 +67,15 @@ ownedPaths:
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/chat-file-changes.spec.ts
|
||||
- tests/e2e/office-document-preview.spec.ts
|
||||
- tests/e2e/markdown-file-preview.spec.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
conditionalProfiles:
|
||||
e2e:
|
||||
- workspace selection, binding, sidebar, browser, or question navigation changes
|
||||
- Markdown file-preview rendering or syntax highlighting changes
|
||||
requiredRules:
|
||||
- session-workspace-authority
|
||||
- renderer-main-boundary
|
||||
@@ -76,11 +83,13 @@ requiredRules:
|
||||
- sidebar-session-attention-authority
|
||||
- office-preview-safety
|
||||
- web-browser-security-and-lifecycle
|
||||
- markdown-rendering-safety-and-performance
|
||||
- electron-rendering-performance
|
||||
- docs-sync
|
||||
---
|
||||
|
||||
This scenario covers inheriting the selected conversation's effective workspace when creating a new Chat; selecting persisted recent, known-session, or newly browsed workspaces while the new Chat remains unbound; validating workspace availability before ACP load; deriving a newly visible local-session title atomically from its first prompt; replacing matching synthetic UUID-date fallback titles with transcript prompts; recovering from deleted global or inherited workspace paths; marking unavailable non-default sidebar groups; permanently deleting their sessions after confirmation; binding workspaces through OpenClaw ACP cwd; targeting another agent without losing that agent's workspace or first prompt; restoring historical workspace context; renaming imported workspace display labels; navigating workspace-grouped sessions with busy, unread, and relative-time status; browsing the effective workspace; previewing authorized local HTML and supported Office documents under their documented safety boundaries; and jumping among user questions from an overlay that leaves the conversation width unchanged.
|
||||
|
||||
Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`.
|
||||
Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`; static Markdown rendering and safety requirements are documented in `harness/reference/markdown-rendering.md`; desktop compositing and interaction profiling requirements are documented in `harness/reference/electron-rendering-performance.md`.
|
||||
|
||||
DOCX and PPTX files are accepted as read-only inline previews only at or below the 20 MB compressed-input boundary. Scoped workspace and attachment references retain their authorized read route without naked-path fallback, while Workspace Browser retains its Host-validated absolute-path flow. PPTX visibility must preserve the single mounted PPTX viewer invariant across the kept-mounted Workspace and Preview surfaces. Workspace ownership remains in `harness/reference/chat-workspace-and-navigation.md`; the complete Office contract is `harness/reference/office-document-preview.md`.
|
||||
|
||||
@@ -21,7 +21,7 @@ ownedPaths:
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-label-hydration.test.ts
|
||||
- tests/e2e/chat-sidebar-session-attention.spec.ts
|
||||
@@ -44,6 +44,7 @@ conditionalProfiles:
|
||||
- channels/agents/settings UI depends on new backend response shape
|
||||
- Web Browser guest, navigation, session, permission, or data policy changes
|
||||
requiredRules:
|
||||
- openclaw-config-delivery
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
@@ -75,6 +76,8 @@ forbiddenPatterns:
|
||||
|
||||
Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
|
||||
|
||||
Coordinator-owned OpenClaw config mutations and their `config.get`/`config.set` transaction contract are documented in `harness/reference/openclaw-config-delivery.md`.
|
||||
|
||||
Allowed flow:
|
||||
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI.
|
||||
|
||||
@@ -82,6 +85,8 @@ Renderer code must not own transport selection, direct IPC channels, direct Gate
|
||||
|
||||
Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging.
|
||||
|
||||
Typed generic Gateway RPC requests are validated by `electron/services/gateway-api.ts` and delegated directly to `GatewayManager.rpc`, including an optional positive finite timeout. This path has no Renderer Chat history/send specialization, polling queue, coalescing, or backpressure layer. ACP `session/load`, `session/prompt`, and `session/cancel` own ordinary Chat history and composer behavior independently.
|
||||
|
||||
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
|
||||
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
|
||||
@@ -42,7 +42,7 @@ Treat these as the same incident family until proven otherwise:
|
||||
- `[gateway-startup] Slow managed Gateway startup detected`
|
||||
- `[gateway:rpc] doctor.memory.status failed`
|
||||
- `[gateway:rpc] doctor.memory.dreamDiary failed`
|
||||
- `chat.history unavailable during gateway startup`
|
||||
- `sessions.list unavailable during gateway startup`
|
||||
- Port `18789` is listening, but Gateway HTTP or WebSocket RPC does not return.
|
||||
|
||||
Important distinction:
|
||||
@@ -188,7 +188,7 @@ Expected mitigation:
|
||||
|
||||
Symptoms:
|
||||
|
||||
- Gateway handshake completes, but `system-presence`, `chat.history`, or `doctor.memory.*` times out during the first minutes.
|
||||
- Gateway handshake completes, but `system-presence`, `sessions.list`, or `doctor.memory.*` times out during the first minutes.
|
||||
- Logs mention cron repair, channel account checks, session lock cleanup, memory-core cron reconciliation, or active embedded/task runs.
|
||||
|
||||
Expected behavior:
|
||||
@@ -288,7 +288,7 @@ pnpm exec vitest run tests/unit/openclaw-auth.test.ts tests/unit/skills-symlink-
|
||||
pnpm run build:vite
|
||||
```
|
||||
|
||||
If the change touches Gateway send/receive, fallback, readiness, or chat history, also run:
|
||||
If the change touches Gateway send/receive, generic RPC dispatch, fallback, or readiness, also run:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
|
||||
@@ -76,7 +76,7 @@ touchedAreas:
|
||||
- tests/unit/acp-host-contract.test.ts
|
||||
- tests/unit/acp-chat-service.test.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/attachment-access.test.ts
|
||||
- tests/unit/files-api-workspace.test.ts
|
||||
- tests/unit/media-api.test.ts
|
||||
@@ -98,7 +98,6 @@ touchedAreas:
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-new-session-date.spec.ts
|
||||
- tests/e2e/chat-question-directory.spec.ts
|
||||
@@ -106,7 +105,7 @@ touchedAreas:
|
||||
- tests/e2e/chat-scroll-pin-bottom.spec.ts
|
||||
- tests/e2e/chat-scroll-to-latest.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- tests/e2e/chat-workspace-context.spec.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
|
||||
@@ -30,15 +30,14 @@ touchedAreas:
|
||||
- tests/unit/acp-*.test.tsx
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/host-api-facade.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-cli.test.ts
|
||||
- tests/unit/task-visualization.test.ts
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
@@ -46,7 +45,6 @@ expectedUserBehavior:
|
||||
- Opening a Chat session loads history through ACP session/load replay.
|
||||
- Sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
|
||||
- Thinking, tool calls, permission requests, plans, generated files, and generated images appear as inline timeline blocks in ACP event order.
|
||||
- The old Execution Graph aggregation is not used for the ACP Chat path.
|
||||
- Renderer does not call Gateway HTTP or WebSocket endpoints directly.
|
||||
- Gateway-backed models, providers, plugins, skills, doctor, workspace, settings, and media configuration continue to work.
|
||||
requiredProfiles:
|
||||
@@ -67,9 +65,9 @@ requiredRules:
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-page-execution-graph.test.tsx tests/unit/task-visualization.test.ts
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-task-visualizer.spec.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
@@ -77,7 +75,7 @@ acceptance:
|
||||
- Main forwards ACP SessionNotification envelopes and permission request envelopes without translating text, thinking, tools, or media into legacy Chat events.
|
||||
- Renderer reduces ACP notifications into an in-memory ordered timeline.
|
||||
- No ClawX ACP replay ledger, Chat history cache, or reduced timeline persistence is introduced.
|
||||
- The primary Chat page does not use gateway:chat-message or chat:runtime-event as ordinary Chat timeline sources; restricted image-generation compatibility evidence remains allowed.
|
||||
- The primary Chat page uses ACP notifications and replay as its ordinary timeline sources; bounded image-generation compatibility evidence remains allowed.
|
||||
- Inline process blocks preserve ordering between assistant message segments.
|
||||
docs:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: add-chat-performance-diagnostics
|
||||
title: Add chat performance diagnostics
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Establish repeatable Renderer and Electron Main profiling for ACP chat streaming, then remove measured per-update work without changing OpenClaw.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/add-chat-performance-diagnostics.md
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
- src/pages/Chat/**
|
||||
- src/lib/acp/**
|
||||
- src/stores/acp-chat-session.ts
|
||||
- electron/services/acp-trace.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/main/index.ts
|
||||
- tests/unit/**
|
||||
- package.json
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- AGENTS.md
|
||||
expectedUserBehavior:
|
||||
- ACP chat output remains semantically identical while long streaming responses stay responsive.
|
||||
- Developers can capture bounded Renderer and Main CPU profiles from a deterministic synthetic chat workload.
|
||||
- Profiling uses isolated synthetic data and never modifies OpenClaw.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- host-events-fallback-policy
|
||||
- diagnostics-trace-safety
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm run perf:chat
|
||||
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts tests/unit/acp-trace.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run build:vite
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- The performance command writes versioned Renderer metrics plus standard Renderer and Main CPU profile artifacts.
|
||||
- The synthetic workload covers a populated ACP timeline and a growing live Markdown response.
|
||||
- Performance artifacts contain only generated fixture content and remain outside source control.
|
||||
- Each confirmed optimization has focused regression coverage and preserves ACP event ordering and rendered output.
|
||||
- Renderer continues to receive backend events through the existing typed host event boundary.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task creates a deterministic profiling loop for Electron Main and Renderer, records a baseline, and applies only optimizations supported by those recordings.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to the OpenClaw source tree or bundled package.
|
||||
- Hardware-independent absolute timing gates.
|
||||
- Uploading CPU profiles or performance traces as product telemetry.
|
||||
- GPU-process or native-code profiling.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: apply-acp-stream-updates-immediately
|
||||
title: Apply live ACP stream updates immediately
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove Renderer-side live ACP chunk batching so Streamdown receives each accepted host event without a 16 ms queue.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/apply-acp-stream-updates-immediately.md
|
||||
- harness/reference/acp-chat.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- src/stores/acp-chat-session.ts
|
||||
- tests/unit/acp-chat-store.test.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Live assistant output advances as each ACP host event arrives instead of waiting for a Renderer batching timer.
|
||||
- Stream ordering, stale-generation rejection, permission handling, and completed prompt behavior remain unchanged.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- host-events-fallback-policy
|
||||
- acp-chat-state-and-history
|
||||
- comms-regression
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- Each accepted live ACP session update is applied synchronously through the existing typed host-event subscription.
|
||||
- Two consecutive live ACP chunks produce two ordered Store notifications rather than one timer-batched notification.
|
||||
- Historical session replay retains its existing generation-scoped reduction behavior.
|
||||
- The Renderer introduces no replacement queue, timer, transport, or fallback path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task changes only the Renderer Store update cadence for live ACP host events. It does not change ACP transport, Main-process routing, timeline reduction semantics, Streamdown configuration, or persisted history authority.
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
id: chat-tool-events-runtime-pipeline
|
||||
title: Make Chat runtime event-first with Main-owned Gateway communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Move Chat send/history/control and streamed runtime events to a Main-owned pipeline, consume OpenClaw tool events as the active-run source of truth, simplify Execution Graph active rendering, and remove the legacy dual-track Chat store path.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/chat-tool-events-runtime-pipeline.md
|
||||
- electron/api/routes/gateway.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/ws-client.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/preload/index.ts
|
||||
- shared/chat-runtime-events.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/pages/Chat/image-generation-status.ts
|
||||
- src/pages/Chat/task-visualization.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/**
|
||||
- src/stores/chat/runtime-graph.ts
|
||||
- src/stores/gateway.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-runtime-event-handlers.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/image-generation-status.test.ts
|
||||
- tests/unit/task-visualization.test.ts
|
||||
expectedUserBehavior:
|
||||
- Chat send/history/abort flows no longer depend on renderer direct Gateway WebSocket transport.
|
||||
- Active chat runs stream tool lifecycle and related process updates through Main-owned runtime events.
|
||||
- Execution Graph for the active run reflects streamed runtime events instead of inferring the live timeline from history polling.
|
||||
- Final assistant reply continues to render as a normal chat bubble, while Execution Graph focuses on process steps.
|
||||
- Tool-produced file artifacts continue to surface under the final assistant message.
|
||||
- Historical sessions still reconstruct process graphs from transcript/message history as a fallback.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/chat-runtime-event-handlers.test.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- Renderer Chat code uses Host API / Host events rather than renderer-owned Gateway transport.
|
||||
- Main Gateway connection declares the capability needed to receive streamed tool events.
|
||||
- Main normalizes OpenClaw chat/agent runtime events before forwarding them to the renderer.
|
||||
- Active-run Execution Graph is driven by streamed runtime events and only updates existing steps by stable runtime identifiers.
|
||||
- Default chat history polling is removed from the active-run happy path.
|
||||
- Chat store no longer maintains separate live logic in both the monolithic store and an unused duplicate action path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -47,7 +47,7 @@ touchedAreas:
|
||||
- tests/unit/session-title.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/sessions-api-workspace.test.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
@@ -79,7 +79,7 @@ requiredRules:
|
||||
requiredTests:
|
||||
- pnpm harness validate --spec harness/specs/tasks/chat-workspace-context.md
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
|
||||
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-session-management.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
|
||||
- pnpm run comms:replay
|
||||
|
||||
@@ -68,4 +68,3 @@ for the model. Try /reset (or /new) ...". Users also had no
|
||||
- Renderer UI for editing contextWindow per model.
|
||||
- Writing `maxTokens` for non-anthropic providers (changes request payloads).
|
||||
- Backfill for non-custom (registry/ollama) provider entries.
|
||||
- Image attachment compression in `chat:sendWithMedia`.
|
||||
|
||||
@@ -25,8 +25,8 @@ touchedAreas:
|
||||
- src/stores/settings.ts
|
||||
- tests/unit/use-workspace-availability.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/settings-store.test.ts
|
||||
- tests/e2e/chat-workspace-context.spec.ts
|
||||
expectedUserBehavior:
|
||||
@@ -51,7 +51,7 @@ requiredRules:
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm harness validate --spec harness/specs/tasks/delete-unavailable-chat-workspace.md
|
||||
- pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-store-history-retry.test.ts tests/unit/settings-store.test.ts
|
||||
- pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-session-management.test.ts tests/unit/settings-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run build:vite
|
||||
|
||||
@@ -12,11 +12,9 @@ touchedAreas:
|
||||
- shared/chat/types.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/e2e/chat-new-session-date.spec.ts
|
||||
@@ -32,7 +30,7 @@ requiredRules:
|
||||
- session-workspace-authority
|
||||
- sidebar-session-attention-authority
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-actions.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-new-session-date.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
id: fix-chat-history-gateway-timeout
|
||||
title: Fix chat history timeout through gateway backend communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Adjust backend communication behavior for chat history loading.
|
||||
touchedAreas:
|
||||
- src/lib/api-client.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
expectedUserBehavior:
|
||||
- Chat history loads through the existing host API boundary.
|
||||
- Gateway timeout does not leave the visible chat in a stale state.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/chat-history-actions.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Comms replay and compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Example task spec for gateway backend communication work. Copy this file to a task-specific name before starting an AI Coding change.
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
id: fix-chat-history-gateway-timeout
|
||||
title: Fix chat history timeout through gateway backend communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Reduce startup chat.history contention so foreground history loads do not time out behind sidebar background hydration.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-chat-history-gateway-timeout.md
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/gateway/rpc-backpressure.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/pages/Chat/message-utils.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/stores/chat/history-startup-retry.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-label-hydration.ts
|
||||
- src/stores/chat/store-api.ts
|
||||
- src/stores/chat/types.ts
|
||||
- tests/setup.ts
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-rpc-backpressure.test.ts
|
||||
- tests/unit/history-startup-retry.test.ts
|
||||
- tests/unit/session-label-fetch.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
expectedUserBehavior:
|
||||
- Foreground chat history loading is prioritized during gateway startup and restart.
|
||||
- Sidebar/session label hydration does not compete with the first visible history load.
|
||||
- Sidebar label hydration uses the existing host API boundary instead of heavy gateway chat.history fan-out.
|
||||
- Renderer continues to use the existing host/store boundary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/history-startup-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-rpc-backpressure.test.ts
|
||||
- tests/unit/session-label-fetch.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Startup/restart no longer fans out sidebar label chat.history calls before the visible session history finishes loading.
|
||||
- Sidebar label hydration no longer depends on gateway chat.history full-session scans.
|
||||
- Foreground history uses a bounded startup RPC wait and falls back to local transcript reads instead of surfacing transient RPC timeout errors.
|
||||
- Foreground startup history can show local transcript data while chat.history is pending, then replace it with Gateway history without disabling startup retry.
|
||||
- Main-process chat.history RPCs are single-flighted/backpressured before reaching the Gateway.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: fix-chat-stuck-processing-tool-results
|
||||
title: Clear stale chat run state when Gateway reports the session is idle
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent the renderer from staying stuck on “Processing tool results…” when a terminal Gateway lifecycle event was missed but sessions.list reports the current session has no active run.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/types.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
expectedUserBehavior:
|
||||
- If Gateway reports the current session has status done or hasActiveRun=false after the user's send timestamp, the chat composer and tool-processing indicator return to idle.
|
||||
- A fresh in-flight send is not prematurely cleared by stale sessions.list metadata from before the user message.
|
||||
- Renderer continues to use the existing gateway:rpc Main-process boundary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-session-actions.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls outside the existing api-client invocation path.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- sessions.list idle metadata reconciles stale sending/activeRunId/pendingFinal state for the current session.
|
||||
- sessions.list metadata older than the current user send does not clear active state.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
id: fix-first-chat-no-response-fallback-poll
|
||||
title: Restore fallback transcript polling so missing streamed events do not fail the first chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent the false "The model did not respond within 120 seconds" / "No response received from the model" errors on the first chat after Gateway startup when streamed chat/runtime events never reach the renderer, by polling chat.history as a fallback progress source during active sends.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
expectedUserBehavior:
|
||||
- When a send receives no streamed chat/runtime events (e.g. first run after Gateway startup or a silent WS drop), the renderer polls chat.history and surfaces transcript progress instead of firing the 120s/130s no-response safety errors.
|
||||
- When the transcript shows a conclusive assistant reply, the run closes normally (sending cleared, reply rendered) without any error banner.
|
||||
- While streamed events are fresh, the fallback poll issues no extra chat.history RPCs, so healthy streamed runs are unaffected.
|
||||
- Renderer continues to use the existing gateway rpc Main-process boundary for chat.history polling.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-store-history-retry.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- The active sendMessage path arms a fallback transcript poll that only issues chat.history RPCs after sustained streamed-event silence.
|
||||
- Streamed chat events no longer permanently clear the fallback poll timer; the poll self-throttles via event freshness instead.
|
||||
- A run whose transcript contains a final assistant reply closes without emitting the no-response safety errors even when zero streamed events arrive.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client invocation path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: fix-gateway-agent-phase-end-state
|
||||
title: Keep chat run active across non-terminal gateway phase end events
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent Gateway agent lifecycle hints from prematurely clearing the chat sending state while tool execution continues.
|
||||
touchedAreas:
|
||||
- .gitignore
|
||||
- harness/specs/tasks/fix-gateway-agent-phase-end-state.md
|
||||
- src/stores/gateway.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
expectedUserBehavior:
|
||||
- The chat composer keeps showing the stop control while an agent run continues across tool rounds.
|
||||
- Intermediate `phase: end` notifications refresh history without making the run look interrupted.
|
||||
- Progressive streaming delta notifications without sequence numbers continue updating the visible response.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/gateway-events.test.ts tests/unit/chat-event-dedupe.test.ts
|
||||
- pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- `phase: end` no longer clears `sending`, `activeRunId`, `pendingFinal`, or `lastUserMessageAt`.
|
||||
- Terminal phases such as `completed` still clear chat run state.
|
||||
- Gateway event dedupe does not suppress same-run delta notifications that do not carry `seq`.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
+8
-9
@@ -1,24 +1,23 @@
|
||||
---
|
||||
id: fix-gateway-ready-chat-history-reload
|
||||
title: Fix delayed sidebar chat history reload after gateway restart
|
||||
id: fix-gateway-ready-session-catalog-reload
|
||||
title: Fix delayed sidebar session catalog reload after gateway restart
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Align gateway readiness signaling with sidebar history refresh after restart.
|
||||
intent: Align gateway readiness signaling with sidebar session catalog refresh after restart.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
|
||||
- harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
|
||||
- electron/gateway/manager.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Settings/index.tsx
|
||||
- src/pages/Setup/index.tsx
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
expectedUserBehavior:
|
||||
- After gateway restart, sidebar history reloads as soon as the gateway becomes RPC-ready.
|
||||
- After gateway restart, the sidebar session catalog reloads as soon as the gateway becomes RPC-ready.
|
||||
- UI does not show a fully healthy green running state while gatewayReady is still false.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
@@ -31,13 +30,13 @@ requiredRules:
|
||||
requiredTests:
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Gateway running-but-not-ready state is surfaced distinctly from fully ready.
|
||||
- Sidebar reloads sessions/history when gatewayReady becomes true after a restart.
|
||||
- Sidebar reloads the session catalog when gatewayReady becomes true after a restart.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -1,21 +1,22 @@
|
||||
---
|
||||
id: fix-image-generation-message-delivery
|
||||
title: Surface async image-generation message-tool deliveries in Chat
|
||||
title: Surface async image-generation deliveries in ACP Chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Ensure generated images delivered through the OpenClaw message tool remain visible in ClawX chat even when Gateway does not append an assistant-media transcript bubble.
|
||||
intent: Ensure trusted OpenClaw image-generation completion deliveries remain visible in ACP Chat through the bounded compatibility projection.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-image-generation-message-delivery.md
|
||||
- src/lib/acp/image-generation-compat.ts
|
||||
- src/lib/acp/reducer.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/helpers.ts
|
||||
- src/stores/acp-chat-session.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/unit/chat-helpers-enrichment.test.ts
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/acp-image-generation-compat.test.ts
|
||||
- tests/unit/acp-chat-store.test.ts
|
||||
expectedUserBehavior:
|
||||
- When async image generation completes and the message tool returns mediaUrl/mediaUrls, the sourceReply caption and image appear as a final assistant reply.
|
||||
- Chat image generation pending state can settle from message-tool delivery records without relying solely on assistant-media bubbles.
|
||||
- Renderer continues to use existing Host API / Gateway history paths and does not call Gateway HTTP directly.
|
||||
- When async image generation completes with trusted internal-UI delivery evidence, the sourceReply caption and image appear in the matching ACP timeline.
|
||||
- ACP image-generation pending state settles from accepted completion evidence without relying on a legacy Chat message renderer.
|
||||
- Renderer continues to use existing Host API and Host event boundaries and does not call Gateway HTTP directly.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
@@ -25,14 +26,17 @@ requiredRules:
|
||||
- api-client-transport-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- diagnostics-trace-safety
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-helpers-enrichment.test.ts tests/unit/chat-page-execution-graph.test.tsx
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
|
||||
- pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/acp-chat-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts -g "projects OpenClaw image-generation"
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- message tool call arguments using media/mediaUrl/mediaUrls are promoted to chat attachments unless a matching internal-UI delivery reply exists.
|
||||
- successful internal-UI message tool results with mediaUrl/mediaUrls/sourceReply.mediaUrls become a standalone assistant reply before toolresult rows are filtered.
|
||||
- Existing safeguards still avoid promoting arbitrary image paths from read/exec tool output.
|
||||
- ClawX accepts only trusted ACP or Gateway completion evidence that matches the ACP session and recent image-generation context.
|
||||
- Internal-UI sourceReply text remains authoritative for successful media replies and text-only failure replies.
|
||||
- Existing safeguards still reject arbitrary image paths and generic tool output without approved image-generation context.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
@@ -11,7 +11,6 @@ touchedAreas:
|
||||
- harness/specs/rules/session-workspace-authority.md
|
||||
- src/pages/Chat/index.tsx
|
||||
- shared/chat/types.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
|
||||
@@ -8,9 +8,9 @@ touchedAreas:
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/utils/session-files.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- harness/specs/tasks/hard-delete-session-jsonl.md
|
||||
- AGENTS.md
|
||||
expectedUserBehavior:
|
||||
@@ -19,19 +19,18 @@ expectedUserBehavior:
|
||||
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
|
||||
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
|
||||
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
|
||||
- Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles.
|
||||
- Token usage history reported by the Dashboard stops including the deleted session.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
acceptance:
|
||||
- Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
|
||||
- Typed host session deletion and the legacy session:delete IPC handler unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
|
||||
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
|
||||
- Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run.
|
||||
- Renderer delete-session paths clear catalog labels, activity, selection, and persisted attention for deleted keys.
|
||||
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
|
||||
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
|
||||
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: maintain-session-catalog-reconciliation
|
||||
title: Maintain session catalog reconciliation alongside ACP Chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Adjust session catalog reconciliation without creating a second ACP history or prompt-state authority.
|
||||
touchedAreas:
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/chat.ts
|
||||
expectedUserBehavior:
|
||||
- Gateway reconnects reconcile normalized session rows without replacing the selected ACP timeline.
|
||||
- Delayed catalog metadata cannot overwrite a newer local session selection or mutation.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- ACP session/load remains the ordinary Chat history authority.
|
||||
- Session catalog updates remain generation- and mutation-fenced.
|
||||
- Comms replay and compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Structural example for maintaining the Gateway-backed session catalog alongside ACP Chat through the backend communication scenario. Copy it to a task-specific file and replace the example identity before starting an AI Coding change.
|
||||
@@ -30,7 +30,6 @@ touchedAreas:
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- tests/unit/generated-files.test.ts
|
||||
- tests/unit/generated-files-panel.test.tsx
|
||||
- tests/unit/open-file-utils.test.ts
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
@@ -54,7 +53,7 @@ expectedUserBehavior:
|
||||
- Authorized PPTX files at or below 20 MB render one slide at a time with localized previous and next controls, while at most one viewer is mounted in the Renderer.
|
||||
- The Chat Preview header offers a localized fullscreen toggle that fills the Renderer viewport, preserves the current target and slide position, and exits from its header control or Escape.
|
||||
- Legacy DOC and PPT files, remote attachments, and over-limit Office files retain their existing safe system-open, unsupported, or too-large behavior according to target authority.
|
||||
- Existing image, PDF, spreadsheet, HTML, Markdown, source, diff, attachment, and workspace behavior remains unchanged.
|
||||
- Existing image, PDF, spreadsheet, HTML, Markdown, source, ACP Changes, attachment, and workspace behavior remains unchanged.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
@@ -66,7 +65,7 @@ requiredRules:
|
||||
- office-preview-safety
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/generated-files-panel.test.tsx tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
id: remove-pre-acp-chat
|
||||
title: Remove pre-ACP Chat implementation
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove the superseded pre-ACP Chat history, send, runtime-state, and visualization pipeline while preserving ACP Chat, bounded image-generation compatibility, and session-management behavior.
|
||||
touchedAreas:
|
||||
- AGENTS.md
|
||||
- package.json
|
||||
- harness/specs/tasks/**
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/scenarios/gateway-startup-diagnostics.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/rules/acp-compatibility-content-safety.md
|
||||
- harness/specs/rules/host-events-fallback-policy.md
|
||||
- harness/specs/rules/sidebar-session-attention-authority.md
|
||||
- harness/reference/acp-chat.md
|
||||
- harness/reference/acp-generated-media-and-diagnostics.md
|
||||
- harness/reference/sidebar-session-attention.md
|
||||
- shared/acp-chat/**
|
||||
- shared/chat/**
|
||||
- shared/chat-runtime-events.ts
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/gateway/client.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/rpc-backpressure.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc/**
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/acp-chat-service.ts
|
||||
- electron/services/chat-api.ts
|
||||
- electron/services/gateway-api.ts
|
||||
- src/lib/acp/**
|
||||
- src/lib/generated-files.ts
|
||||
- src/components/file-preview/**
|
||||
- src/lib/host-api.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/stores/**
|
||||
- src/pages/Chat/**
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/styles/globals.css
|
||||
- shared/i18n/locales/en/chat.json
|
||||
- shared/i18n/locales/zh/chat.json
|
||||
- shared/i18n/locales/ja/chat.json
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/**
|
||||
- tests/e2e/**
|
||||
- tests/e2e/fixtures/**
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Opening or revisiting a normal conversation renders its history only from ACP session/load replay, with the existing bounded ACP transcript supplements retained.
|
||||
- Sending and stopping from the composer use only ACP session/prompt and session/cancel.
|
||||
- ACP text, thinking, tools, permissions, plans, attachments, generated files, generated images, and errors continue to render through the ACP timeline without the legacy ChatMessage or Execution Graph pipeline.
|
||||
- Session discovery, sidebar ordering and attention, title hydration, history supplementation, selection, deletion, and rename continue to behave as before.
|
||||
- Catalog reconciliation repairs removed selections atomically, resets suppression and timestamp fences across Gateway generations, withholds a selected persisted session until its workspace summary settles, and never overwrites a newer local catalog or selection mutation with the delayed snapshot.
|
||||
- A failed hard delete keeps the session and confirmation dialog intact; navigation and dialog closure happen only after host-confirmed deletion.
|
||||
- Bulk deletion repairs a deleted current selection atomically with the remaining catalog, preferring a valid non-cron/non-channel session or creating an agent-local placeholder.
|
||||
- Asynchronous OpenClaw image-generation completions continue to appear when accepted by the existing bounded ACP compatibility checks.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- attachment-access-safety
|
||||
- diagnostics-trace-safety
|
||||
- session-workspace-authority
|
||||
- sidebar-session-attention-authority
|
||||
- tool-derived-file-safety
|
||||
- ui-i18n-design-tokens
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx tests/unit/chat-toolbar.test.tsx
|
||||
- pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-session-selection.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/host-services.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm exec vitest run tests/unit/gateway-manager-diagnostics.test.ts tests/unit/gateway-ws-trace.test.ts tests/unit/stores.test.ts
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/file-preview-body.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/openclaw-file-activities.test.ts tests/unit/i18n-locale-parity.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts tests/e2e/chat-acp-attachments.spec.ts tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts tests/e2e/chat-sidebar-session-attention.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm harness validate --spec harness/specs/tasks/remove-pre-acp-chat.md
|
||||
- pnpm run harness:ci
|
||||
- git diff --check
|
||||
acceptance:
|
||||
- ACP session/load replay is the only normal Chat history render path; no Gateway Chat history, transcript reconstruction, legacy message array, or persisted parallel replay ledger renders ordinary Chat turns.
|
||||
- ACP session/prompt and session/cancel are the only active composer send and stop paths; ACP permission responses remain a separate protocol action.
|
||||
- Legacy ChatMessage, Execution Graph, task visualization, RawMessage rendering helpers, and useChatStore send/runtime state and actions are removed from production code and their legacy-only tests are deleted or replaced with ACP assertions.
|
||||
- ACP-owned in-memory prompt state, including sending, cancelling, optimistic user segments, retained live prompt snapshots, and generation guards, remains intact.
|
||||
- gateway:chat-message and chat:runtime-event contracts, Main forwarding, and Renderer subscriptions remain only where required for bounded ACP image-generation completion compatibility; they do not feed ordinary messages, history, composer state, tool state, task visualization, or session run state.
|
||||
- Existing image-generation compatibility continues to require matching ACP session, generation, recent image_generate context, trusted completion evidence, deduplication, and stale-result rejection.
|
||||
- Session catalog, sidebar and attention state, ACP history supplements, automatic title hydration, session selection, hard delete, and rename behavior remain supported without depending on removed legacy message or runtime state.
|
||||
- Session deletion or hiding never leaves a dangling current selection; fallback selection follows the normal non-cron/non-channel policy or creates a fresh agent-local placeholder.
|
||||
- Single and bulk deletion use the same current-selection repair policy and never select a cron/channel survivor implicitly or leave the absent default key selected.
|
||||
- Selected persisted sessions with missing workspace identity are not published to ACP consumers before batched summary hydration settles, and stale summaries remain fenced by Gateway generation, session incarnation, and a local session catalog/selection mutation revision.
|
||||
- The unreachable chat media-send shared/Main/Renderer contract is removed; ACP load, prompt, cancel, and permission operations remain the exact typed Chat host actions.
|
||||
- The unused standalone Gateway client and Chat-history-only RPC backpressure layer are removed; typed generic Gateway RPC validates the same method and timeout inputs before delegating directly to GatewayManager.rpc.
|
||||
- Generic Gateway RPC diagnostics and proxy tests use non-Chat methods; one Electron E2E assertion prevents ordinary Chat from resurrecting a chat.history call.
|
||||
- No OpenClaw source, installed package source, bundled OpenClaw distribution, or OpenClaw patch is modified.
|
||||
- Typecheck, focused ACP and host tests, relevant ACP Electron E2E, communication regression checks, task validation, and harness CI pass.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Delete the pre-ACP Chat implementation after separating the session catalog and session-management responsibilities that are still consumed by ACP Chat. Remove legacy renderer history reduction, Gateway send orchestration, runtime send state, Execution Graph/task derivation, and their dead contracts, services, localization, and tests.
|
||||
|
||||
Keep the ACP timeline and composer as the sole normal Chat implementation. Preserve only the explicitly bounded Gateway-event bridge needed to project asynchronous image-generation completion evidence into the ACP timeline.
|
||||
|
||||
## Preserved Boundaries
|
||||
|
||||
- ACP `session/load`, `session/prompt`, `session/cancel`, `session/update`, and `session/request_permission` retain their existing Main/Renderer ownership and generation guards.
|
||||
- The existing transcript-derived ACP supplements remain bounded to approved generated-media and attachment content plus metadata-only timing; they do not become a second ordinary history path.
|
||||
- Session catalog, sidebar attention, workspace binding, title hydration, selection, deletion, and rename remain independent of legacy message rendering and runtime send state.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Modifying OpenClaw source, package contents, or bundled runtime behavior.
|
||||
- Replacing ACP replay with Gateway Chat history or broad transcript reconstruction.
|
||||
- Removing or widening the bounded ACP image-generation completion compatibility path.
|
||||
- Redesigning the ACP timeline, composer, sidebar, workspace, attachment, permission, or file-activity experiences.
|
||||
@@ -3,21 +3,18 @@ id: render-cron-run-live-status
|
||||
title: Render live execution status for cron-triggered runs without a session switch
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: When a scheduled (cron) job fires while the user is viewing that cron session, ClawX must render the live running state (Thinking indicator, Execution Graph, tool steps) in realtime. Today the Gateway streams runtime events under the run-scoped session key (agent:<id>:cron:<jobId>:run:<sessionId>) while the UI tracks the base cron key (agent:<id>:cron:<jobId>), so events are dropped by strict session-key equality and the user must switch sessions to force a transcript reload.
|
||||
intent: When a scheduled cron job runs while the user is viewing that session, render its ACP updates and available cron history in the inline timeline without requiring a session switch.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/render-cron-run-live-status.md
|
||||
- src/lib/cron-session-history.ts
|
||||
- src/stores/acp-chat-session.ts
|
||||
- src/stores/chat/cron-session-utils.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/gateway.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- tests/unit/cron-session-utils.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
expectedUserBehavior:
|
||||
- When a cron job triggers while the user is viewing that cron session, the renderer adopts the run, surfaces the running/Thinking state, and renders the Execution Graph live from streamed runtime events.
|
||||
- Runtime events whose sessionKey carries the run-scoped suffix are treated as belonging to the equivalent base cron session the user is viewing.
|
||||
- When the cron run ends, the renderer reloads the transcript for the current session so the completed graph and final reply render without a manual session switch.
|
||||
- Background :main heartbeat runs continue to NOT surface a Thinking indicator.
|
||||
- ACP tool and message updates for the selected cron session render inline without a manual session switch.
|
||||
- Run-scoped cron session keys are normalized to the equivalent base cron session where catalog reconciliation requires it.
|
||||
- When ACP replay is empty, authorized cron session history can supply the visible prompt and final reply.
|
||||
- Renderer continues to use Host events / api-client boundaries; no new direct IPC or Gateway HTTP calls are added.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
@@ -30,13 +27,12 @@ requiredRules:
|
||||
- gateway-readiness-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts
|
||||
- pnpm exec vitest run tests/unit/gateway-events.test.ts
|
||||
- pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- A cron session-key equivalence helper treats the base cron key and its run-scoped variant as the same session.
|
||||
- chat store handleChatEvent / handleRuntimeEvent apply cron run-scoped events to the equivalent base cron session currently in view.
|
||||
- Cron sessions are treated as trackable inbound runs so run.started arms the running state, while :main heartbeats remain suppressed.
|
||||
- gateway runtime-event dispatch reloads history for the current cron session on run end (and start) using equivalence rather than strict equality.
|
||||
- ACP notifications for the selected cron session reduce into the same ordered in-memory timeline as other Chat sessions.
|
||||
- Empty ACP replay may be supplemented only by the authorized cron session-history route for that session.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client / host-events path.
|
||||
docs:
|
||||
required: false
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
id: replace-markdown-renderer-with-streamdown
|
||||
title: Replace application Markdown rendering with Streamdown
|
||||
scenario: acp-chat-experience
|
||||
taskType: runtime-bridge
|
||||
intent: Replace ReactMarkdown with one stable Streamdown configuration for streaming ACP Chat Markdown and static file previews while preserving content safety, presentation, and measured Chat performance.
|
||||
touchedAreas:
|
||||
- harness/reference/markdown-rendering.md
|
||||
- harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
|
||||
- harness/specs/rules/markdown-rendering-safety-and-performance.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- tailwind.config.js
|
||||
- src/main.tsx
|
||||
- src/components/markdown/**
|
||||
- src/components/file-preview/MarkdownPreview.tsx
|
||||
- src/pages/Chat/AcpTimeline.tsx
|
||||
- src/pages/Chat/AcpAssistantTurn.tsx
|
||||
- src/pages/Chat/AcpMessageSegment.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/styles/globals.css
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- tests/unit/streamdown-config.test.tsx
|
||||
- tests/unit/markdown-preview.test.tsx
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/acp-chat-components.test.tsx
|
||||
- tests/unit/browser-link.test.tsx
|
||||
- tests/e2e/markdown-file-preview.spec.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Assistant and process Markdown renders incrementally during ACP Chat streaming, while completed blocks remain stable and only the active final Markdown part receives subtle word animation and a circle caret.
|
||||
- Markdown files render in static mode with highlighted fenced code, KaTeX math, CJK-aware parsing, and omitted YAML or TOML frontmatter; Mermaid fences remain code.
|
||||
- User messages and tool output remain literal, raw HTML remains visible literal text, links remain inert, and unsafe Markdown image sources remain rejected.
|
||||
- Chat and preview preserve prose spacing and compact lists; tables show only the cell grid; fenced code preserves source lines and soft-wraps with a compact right-aligned language header and a vertically centered localized copy action.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- acp-compatibility-content-safety
|
||||
- attachment-access-safety
|
||||
- ui-i18n-design-tokens
|
||||
- markdown-rendering-safety-and-performance
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/streamdown-config.test.tsx tests/unit/markdown-preview.test.tsx tests/unit/file-preview-body.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/browser-link.test.tsx
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/markdown-file-preview.spec.ts tests/e2e/chat-streamdown-rendering.spec.ts tests/e2e/chat-code-block-wrap.spec.ts tests/e2e/chat-latex-rendering.spec.ts tests/e2e/chat-assistant-markdown-plain.spec.ts tests/e2e/chat-table-header-light.spec.ts --workers=1
|
||||
- pnpm run perf:chat
|
||||
- pnpm exec vite build --sourcemap
|
||||
- pnpm harness validate --spec harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
|
||||
- pnpm run harness:ci
|
||||
acceptance:
|
||||
- ACP assistant and process Markdown uses Streamdown streaming mode, Markdown file preview uses static mode, and user messages plus tool output stay outside Streamdown.
|
||||
- One module-scoped configuration enables code, math with single-dollar support, and CJK plugins without installing or configuring a Mermaid plugin.
|
||||
- Raw HTML is rendered as literal text, links remain inert through BrowserLink, Markdown images retain isSafeAcpImageSource validation, only localized code copy is enabled, and table, Mermaid, code-download, and line-number controls stay disabled.
|
||||
- YAML and TOML frontmatter is parsed and omitted without the previous custom metadata card.
|
||||
- Only the open assistant segment's final Markdown part animates, using word-level fadeIn with duration 140 and stagger 0 plus a circle caret; completed blocks do not restart animation.
|
||||
- Focused unit and Electron E2E checks cover incomplete streaming Markdown, code highlighting, wrapping and copying, prose spacing, compact lists, cell-only table borders, math, CJK punctuation, Mermaid-as-code, raw HTML, inert links, safe images, literal user and tool content, frontmatter omission, active-part animation, and retained visual contracts.
|
||||
- Production source-map review confirms the expected Streamdown and Shiki cost, no direct @streamdown/mermaid dependency, and no unexpected eager Mermaid renderer chunk.
|
||||
- Three before and three after runs of the same 80-turn and 300-chunk workload retain metrics and CPU profiles; median Renderer TaskDuration and ScriptDuration each regress by no more than 10 percent, and median ScriptDuration or sampled Markdown and React CPU time improves by at least 10 percent.
|
||||
- ReactMarkdown and obsolete direct Markdown plugin dependencies are absent after both renderers migrate, KaTeX remains direct with exactly one stylesheet import, and multilingual README documentation matches the delivered behavior without claiming Mermaid support.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task covers the shared renderer configuration, ACP Chat streaming presentation, static Markdown file preview, dependency cleanup, focused safety and presentation tests, Electron E2E coverage, bundle inspection, and measured before/after performance validation. It does not change ACP transport, timeline reduction, event batching, store update cadence, Renderer/Main APIs, or fallback policy.
|
||||
|
||||
The durable renderer, safety, and profiling contract is documented in `harness/reference/markdown-rendering.md` and enforced by `harness/specs/rules/markdown-rendering-safety-and-performance.md`.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
Performance artifacts are generated fixture data and remain ignored under `test-results/`. Compare three-run medians from the same machine; do not encode machine-specific absolute timings in automated tests or weaken the percentage thresholds when a migration misses them.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: restore-hardware-accelerated-rendering
|
||||
title: Restore hardware-accelerated Electron rendering
|
||||
scenario: acp-chat-experience
|
||||
taskType: runtime-bridge
|
||||
intent: Restore Chromium GPU compositing and rasterization by default, then retain a rich-Markdown interaction profile that catches sidebar and scroll regressions missed by streaming-only measurements.
|
||||
touchedAreas:
|
||||
- electron/main/index.ts
|
||||
- tests/unit/main-hardware-acceleration.test.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- harness/reference/electron-rendering-performance.md
|
||||
- harness/specs/rules/electron-rendering-performance.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- harness/specs/tasks/restore-hardware-accelerated-rendering.md
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Long rich-Markdown conversations scroll smoothly on supported desktop GPUs.
|
||||
- Collapsing or expanding the sidebar preserves its existing animation without software-rasterization frame drops.
|
||||
- A user with a broken graphics driver can still opt into Chromium's native `--disable-gpu` fallback.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
requiredRules:
|
||||
- electron-rendering-performance
|
||||
- markdown-rendering-safety-and-performance
|
||||
- diagnostics-trace-safety
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/main-hardware-acceleration.test.ts
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/hardware-acceleration.spec.ts --workers=1
|
||||
- pnpm run perf:chat
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm harness validate --spec harness/specs/tasks/restore-hardware-accelerated-rendering.md
|
||||
acceptance:
|
||||
- Electron Main no longer disables hardware acceleration globally.
|
||||
- A desktop Electron E2E confirms hardware acceleration and GPU compositing are enabled where the runner provides a real GPU.
|
||||
- The existing sidebar animation and Markdown rendering behavior remain unchanged.
|
||||
- The permanent performance command records frame pacing and profiles for a generated rich static Markdown conversation during sidebar collapse and vertical scrolling.
|
||||
- Real-conversation before/after profiles on the same machine show the stable frame-pacing regression is removed without relying on machine-specific automated timing thresholds.
|
||||
- The native `--disable-gpu` troubleshooting path remains available without a new settings or compatibility layer.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task removes the application-owned global software-rendering policy, adds focused policy/runtime coverage, and extends the existing profiling harness to the idle interactions from the reported real conversation. It does not redesign the sidebar, virtualize Chat history, change Markdown output, or profile the GPU process directly.
|
||||
@@ -29,8 +29,7 @@ touchedAreas:
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/gateway-bisection-0d794cd.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-label-hydration.test.ts
|
||||
- tests/unit/sidebar-session-buckets.test.ts
|
||||
@@ -59,7 +58,7 @@ requiredRules:
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/i18n-locale-parity.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: use-openclaw-native-config-delivery
|
||||
title: Use OpenClaw-native config delivery and restart planning
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Make one Main-owned coordinator the only ClawX path for OpenClaw config mutation, using config.get and config.set while running and a file fallback before startup.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/use-openclaw-native-config-delivery.md
|
||||
- harness/specs/rules/openclaw-config-delivery.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
- electron/gateway/config-delivery.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/ws-trace.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/agents-api.ts
|
||||
- electron/services/channels-api.ts
|
||||
- electron/services/providers/provider-runtime-sync.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/agent-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- electron/utils/openclaw-image-generation.ts
|
||||
- electron/utils/plugin-install.ts
|
||||
- electron/utils/skill-config.ts
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-ws-trace.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/openclaw-image-generation.test.ts
|
||||
- tests/unit/provider-runtime-sync.test.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Saving providers, agents, channels, bindings, skills, or other hot-applicable OpenClaw configuration does not replace the Gateway process.
|
||||
- Every ClawX mutation of the active OpenClaw config goes through one coordinator-owned read-modify-write transaction.
|
||||
- A running Gateway mutation uses config.get as its baseline and config.set as its commit instead of writing the file directly.
|
||||
- Saving config while the Gateway is stopped does not start it.
|
||||
- Proxy and other process-launch environment changes still replace the running Gateway process.
|
||||
- Manual restart and health/crash recovery behavior remains available.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- backend-communication-boundary
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-ws-trace.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/provider-runtime-sync.test.ts
|
||||
acceptance:
|
||||
- Electron Main owns config delivery; Renderer does not add direct Gateway transport calls.
|
||||
- A running Gateway mutation prefers the runtime-shaped config.get `config` object as its baseline, applies its mutator, and commits the serialized result with config.set and the returned hash as baseHash; source-shaped `raw` is only a compatibility fallback.
|
||||
- Successful config.set delivery does not send SIGUSR1 and does not call GatewayManager.restart.
|
||||
- The coordinator serializes mutations and retries one base-hash conflict from a fresh config.get snapshot.
|
||||
- When Gateway is stopped or starting, the same coordinator performs the mutation against the resolved file path and does not start the Gateway.
|
||||
- A non-conflict RPC mutation failure does not perform an out-of-band file write; it fails the mutation without silently diverging live and persisted config.
|
||||
- Provider deletion, OAuth completion, Agent deletion, Channel save/delete/binding, skill config, image config, and hidden self-heal writes do not carry independent blanket restart policy.
|
||||
- Deleting a custom default Channel account does not recreate its mirrored top-level credentials under a literal `default` account.
|
||||
- Channel account deletion removes matching credentials from plugin-backed account mirrors, including Agent deletion paths.
|
||||
- Gateway WebSocket traces redact serialized raw config-write payloads before logging them.
|
||||
- Proxy environment changes, manual restart, heartbeat recovery, process crash recovery, app update, and app shutdown/startup retain their lifecycle behavior.
|
||||
- No production helper outside the coordinator writes the active OpenClaw config file.
|
||||
- Coordinator file fallback uses the configured OpenClaw config path rather than a hardcoded home-directory path.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Use this task spec for the OpenClaw 2026.7.1 config-delivery convergence change.
|
||||
+8
-5
@@ -52,8 +52,10 @@
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm run build:vite && playwright test",
|
||||
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
|
||||
"perf:chat": "pnpm run build:vite && playwright test tests/e2e/renderer-performance.spec.ts --workers=1",
|
||||
"profile:main": "pnpm run build:vite && electron --inspect=9229 .",
|
||||
"harness": "pnpm --filter @clawx/harness start --",
|
||||
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/fix-chat-history-gateway-timeout.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
|
||||
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/maintain-session-catalog-reconciliation.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
|
||||
"comms:replay": "node scripts/comms/replay.mjs",
|
||||
"comms:baseline": "node scripts/comms/baseline.mjs",
|
||||
"comms:compare": "node scripts/comms/compare.mjs",
|
||||
@@ -91,6 +93,7 @@
|
||||
"@agentclientprotocol/sdk": "1.1.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"json5": "2.2.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"posthog-node": "^5.28.0",
|
||||
"tar": "^6.2.1",
|
||||
@@ -127,6 +130,9 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"@soimy/dingtalk": "3.6.6",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@tencent-connect/qqbot-connector": "^1.1.0",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.4.6",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -181,15 +187,12 @@
|
||||
"react-arborist": "^3.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
Generated
+1211
-36
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import type { HostEventContract } from '../host-events/contract';
|
||||
import type { AcpSessionUpdateEnvelope } from './types';
|
||||
|
||||
export const acpChatHostApiActions = [
|
||||
'sendWithMedia',
|
||||
'loadAcpSession',
|
||||
'sendAcpPrompt',
|
||||
'cancelAcpSession',
|
||||
|
||||
+8
-73
@@ -1,6 +1,4 @@
|
||||
import type { ChatRuntimeEvent } from '../chat-runtime-events';
|
||||
|
||||
/** Metadata for locally-attached files (not from Gateway) */
|
||||
/** Metadata for files attached to ACP prompts or projected by bounded ACP media compatibility. */
|
||||
export interface AttachedFileMeta {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
@@ -21,7 +19,7 @@ export interface AttachedFileMeta {
|
||||
gatewayUrl?: string;
|
||||
}
|
||||
|
||||
/** Raw message from OpenClaw chat.history */
|
||||
/** Structured OpenClaw transcript message used by bounded ACP supplements. */
|
||||
export interface RawMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||
content: unknown; // string | ContentBlock[]
|
||||
@@ -48,7 +46,7 @@ export interface RawMessage {
|
||||
workspaceDir?: string;
|
||||
}>;
|
||||
};
|
||||
/** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */
|
||||
/** Renderer metadata for user-selected files included in an ACP prompt. */
|
||||
_attachedFiles?: AttachedFileMeta[];
|
||||
}
|
||||
|
||||
@@ -122,56 +120,16 @@ export type LoadSessionsOptions = {
|
||||
gatewayGeneration?: number;
|
||||
};
|
||||
|
||||
export interface ToolStatus {
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ChatRuntimeRunState {
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
status: 'running' | 'completed' | 'error' | 'aborted';
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
assistantText: string;
|
||||
thinkingText: string;
|
||||
events: ChatRuntimeEvent[];
|
||||
}
|
||||
|
||||
export interface DeleteSessionsResult {
|
||||
deletedKeys: string[];
|
||||
failedKeys: string[];
|
||||
}
|
||||
|
||||
export type DeleteSessionResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export interface ChatState {
|
||||
// Messages
|
||||
messages: RawMessage[];
|
||||
loading: boolean;
|
||||
loadingMoreHistory: boolean;
|
||||
hasMoreHistory: boolean;
|
||||
error: string | null;
|
||||
runError: string | null;
|
||||
/** Per-session runError text dismissed by the user (sessionKey -> error message). */
|
||||
dismissedRunErrors: Record<string, string>;
|
||||
|
||||
// Streaming
|
||||
sending: boolean;
|
||||
activeRunId: string | null;
|
||||
streamingText: string;
|
||||
streamingMessage: unknown | null;
|
||||
streamingTools: ToolStatus[];
|
||||
pendingFinal: boolean;
|
||||
lastUserMessageAt: number | null;
|
||||
/** Images collected from tool results, attached to the next assistant message */
|
||||
pendingToolImages: AttachedFileMeta[];
|
||||
runtimeRuns: Record<string, ChatRuntimeRunState>;
|
||||
|
||||
// Sessions
|
||||
sessions: ChatSession[];
|
||||
currentSessionKey: string;
|
||||
currentAgentId: string;
|
||||
@@ -180,38 +138,15 @@ export interface ChatState {
|
||||
/** Last message timestamp (ms) per session key, used for sorting */
|
||||
sessionLastActivity: Record<string, number>;
|
||||
|
||||
// Thinking
|
||||
thinkingLevel: string | null;
|
||||
|
||||
// Actions
|
||||
loadSessions: (options?: LoadSessionsOptions) => Promise<void>;
|
||||
handleSessionsChanged: (payload: GatewaySessionsChangedPayload) => void;
|
||||
switchSession: (key: string) => void;
|
||||
selectAcpSession: (key: string, workspacePath?: string) => void;
|
||||
newSession: () => void;
|
||||
acknowledgeAcpSessionCreated: (key: string, workspacePath?: string, initialPrompt?: string) => void;
|
||||
deleteSession: (key: string) => Promise<void>;
|
||||
deleteSession: (key: string) => Promise<DeleteSessionResult>;
|
||||
deleteSessions: (keys: string[]) => Promise<DeleteSessionsResult>;
|
||||
renameSession: (key: string, label: string) => Promise<void>;
|
||||
cleanupEmptySession: () => void;
|
||||
loadHistory: (quiet?: boolean) => Promise<void>;
|
||||
loadMoreHistory: () => Promise<void>;
|
||||
sendMessage: (
|
||||
text: string,
|
||||
attachments?: Array<{
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
}>,
|
||||
targetAgentId?: string | null,
|
||||
) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
handleRuntimeEvent: (event: ChatRuntimeEvent) => void;
|
||||
refresh: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
|
||||
|
||||
@@ -717,18 +717,6 @@ export type SessionSummariesResult = HostSuccess & {
|
||||
export type SessionDeletePayload = { id: string };
|
||||
export type SessionRenamePayload = { id: string; title: string };
|
||||
|
||||
export type ChatMediaItem = { filePath: string; mimeType?: string; fileName?: string };
|
||||
export type ChatSendWithMediaPayload = {
|
||||
sessionKey: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: ChatMediaItem[];
|
||||
};
|
||||
export type ChatSendWithMediaResult = HostSuccess & {
|
||||
result?: { runId?: string };
|
||||
};
|
||||
|
||||
export type CronUpdatePayload = { id: string; input: CronJobUpdateInput };
|
||||
export type CronIdPayload = { id: string };
|
||||
export type CronTogglePayload = CronIdPayload & { enabled: boolean };
|
||||
@@ -987,7 +975,6 @@ export type HostApiContract = {
|
||||
turnTimings: (payload: SessionTurnTimingsPayload) => SessionTurnTimingsResult;
|
||||
};
|
||||
chat: {
|
||||
sendWithMedia: (payload: ChatSendWithMediaPayload) => ChatSendWithMediaResult;
|
||||
loadAcpSession: (payload: AcpChatLoadPayload) => AcpChatOperationResult;
|
||||
sendAcpPrompt: (payload: AcpChatPromptPayload) => AcpChatOperationResult;
|
||||
cancelAcpSession: (payload: AcpChatCancelPayload) => AcpChatOperationResult;
|
||||
|
||||
@@ -12,19 +12,13 @@
|
||||
},
|
||||
"noLogs": "(No logs available yet)",
|
||||
"scrollToLatest": "Back to bottom",
|
||||
"loadMoreHistory": "Load earlier messages",
|
||||
"loadingMoreHistory": "Loading more…",
|
||||
"toolbar": {
|
||||
"refresh": "Refresh chat",
|
||||
"currentAgent": "Talking to {{agent}}",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"title": "File changes ({{count}})",
|
||||
"created": "Created",
|
||||
"modified": "Modified",
|
||||
"openFolder": "Open folder",
|
||||
"viewAll": "View all changes"
|
||||
"openFolder": "Open folder"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "Created",
|
||||
@@ -111,9 +105,7 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "Source",
|
||||
"preview": "Preview",
|
||||
"changes": "Changes",
|
||||
"info": "Info"
|
||||
"preview": "Preview"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
@@ -127,11 +119,6 @@
|
||||
"openHtmlExternally": "Open HTML in system browser",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"info": {
|
||||
"path": "Path",
|
||||
"size": "Size",
|
||||
"type": "Type"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Saved to disk",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
@@ -146,8 +133,8 @@
|
||||
"notFound": "File not found",
|
||||
"loadFailed": "Load failed: {{error}}",
|
||||
"noPreview": "No preview available for this file",
|
||||
"unsupportedFormatTitle": "This file format is not supported for built-in preview or diff",
|
||||
"unsupportedFormatHint": "Built-in preview and diff currently support directly readable text/Markdown-style files only. Open the file in the file manager instead.",
|
||||
"unsupportedFormatTitle": "This file format is not supported for built-in preview",
|
||||
"unsupportedFormatHint": "Built-in preview currently supports directly readable text/Markdown-style files only. Open the file in the file manager instead.",
|
||||
"largeBinaryOpenTitle": "This file is too large for built-in preview",
|
||||
"largeBinaryOpenHint": "This file is {{size}}. ClawX cannot preview it inline, but you can confirm and open it with the system default app.",
|
||||
"openInFinderFailed": "Could not reveal file",
|
||||
@@ -158,13 +145,6 @@
|
||||
"html": {
|
||||
"crashed": "HTML preview stopped"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "No changes to display",
|
||||
"newFile": "New file — no original to compare",
|
||||
"tooLarge": "File too large — diff view disabled, switch to the Source tab to view the full content",
|
||||
"unavailable": "The current chat did not capture a precise baseline for this file, so no diff is available.",
|
||||
"unavailableHint": "Use the Preview tab for the current file; use Git or another VCS for an exact diff."
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "Open file",
|
||||
"message": "Open “{{fileName}}” with the system default app?",
|
||||
@@ -220,35 +200,8 @@
|
||||
"openRootInFinder": "Reveal workspace in file manager"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "Run View",
|
||||
"title": "Task Outline",
|
||||
"emptyTitle": "No structured steps yet",
|
||||
"emptyBody": "Once a run starts, ClawX will surface thinking, tool calls, and handoff states here.",
|
||||
"status": {
|
||||
"idle": "Idle",
|
||||
"running_one": "1 active step",
|
||||
"running_other": "{{count}} active steps"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "Running",
|
||||
"completed": "Done",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "Execution Graph",
|
||||
"branchLabel": "branch",
|
||||
"thinkingLabel": "Thinking",
|
||||
"errorLabel": "Error",
|
||||
"imageGenerateLabel": "Image generation",
|
||||
"agentRun": "{{agent}} execution",
|
||||
"collapsedSummary": "{{toolCount}} tool calls · {{processCount}} process messages",
|
||||
"collapseAction": "Collapse execution graph"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "Generating image, please wait…",
|
||||
"previewLoading": "Loading image preview…",
|
||||
"previewUnavailable": "Image generation completed, but the preview could not be loaded.",
|
||||
"generatedReady": "Generated image is ready.",
|
||||
"generatedReadyWithMissing": "Generated image is ready. Some images could not be loaded."
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"hide": "Hide",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "Copy code"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
|
||||
@@ -12,19 +12,13 @@
|
||||
},
|
||||
"noLogs": "(ログはまだありません)",
|
||||
"scrollToLatest": "最下部に戻る",
|
||||
"loadMoreHistory": "以前のメッセージを読み込む",
|
||||
"loadingMoreHistory": "読み込み中…",
|
||||
"toolbar": {
|
||||
"refresh": "チャットを更新",
|
||||
"currentAgent": "現在の会話相手: {{agent}}",
|
||||
"workspace": "ワークスペース"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"title": "ファイルの変更({{count}} 件)",
|
||||
"created": "新規",
|
||||
"modified": "更新",
|
||||
"openFolder": "フォルダーを開く",
|
||||
"viewAll": "ファイル変更を見る"
|
||||
"openFolder": "フォルダーを開く"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "作成済み",
|
||||
@@ -111,9 +105,7 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "ソース",
|
||||
"preview": "プレビュー",
|
||||
"changes": "変更",
|
||||
"info": "情報"
|
||||
"preview": "プレビュー"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
@@ -127,11 +119,6 @@
|
||||
"openHtmlExternally": "HTML をシステムブラウザで開く",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"info": {
|
||||
"path": "パス",
|
||||
"size": "サイズ",
|
||||
"type": "種類"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "ディスクに保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
@@ -146,8 +133,8 @@
|
||||
"notFound": "ファイルが見つかりません",
|
||||
"loadFailed": "読み込みに失敗しました: {{error}}",
|
||||
"noPreview": "このファイルにはプレビューがありません",
|
||||
"unsupportedFormatTitle": "このファイル形式は内蔵プレビューまたは差分表示に未対応です",
|
||||
"unsupportedFormatHint": "現在、内蔵プレビューと差分表示は直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
|
||||
"unsupportedFormatTitle": "このファイル形式は内蔵プレビューに未対応です",
|
||||
"unsupportedFormatHint": "現在、内蔵プレビューは直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
|
||||
"largeBinaryOpenTitle": "このファイルは大きいため内蔵プレビューできません",
|
||||
"largeBinaryOpenHint": "このファイルは {{size}} です。ClawX では内蔵プレビューできませんが、確認後にシステム既定のアプリで開けます。",
|
||||
"openInFinderFailed": "ファイルマネージャーで表示できません",
|
||||
@@ -158,13 +145,6 @@
|
||||
"html": {
|
||||
"crashed": "HTML プレビューが停止しました"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "表示する変更はありません",
|
||||
"newFile": "新規ファイルのため比較対象がありません",
|
||||
"tooLarge": "ファイルが大きすぎるため diff ビューは無効化されました。完全な内容は「ソース」タブで確認してください",
|
||||
"unavailable": "この会話ではこのファイルの正確な変更ベースラインを取得できなかったため、差分を生成できません。",
|
||||
"unavailableHint": "「プレビュー」で現在の内容を確認するか、Git などで正確な差分を参照してください。"
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "ファイルを開く",
|
||||
"message": "「{{fileName}}」をシステム既定のアプリで開きますか?",
|
||||
@@ -220,35 +200,8 @@
|
||||
"openRootInFinder": "ルートをファイルマネージャーで表示"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "実行ビュー",
|
||||
"title": "タスクの流れ",
|
||||
"emptyTitle": "まだ構造化されたステップはありません",
|
||||
"emptyBody": "実行が始まると、ClawX は思考・ツール呼び出し・最終化の状態をここに表示します。",
|
||||
"status": {
|
||||
"idle": "待機中",
|
||||
"running_one": "進行中 1 件",
|
||||
"running_other": "進行中 {{count}} 件"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "実行中",
|
||||
"completed": "完了",
|
||||
"error": "エラー"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "実行グラフ",
|
||||
"branchLabel": "branch",
|
||||
"thinkingLabel": "考え中",
|
||||
"errorLabel": "エラー",
|
||||
"imageGenerateLabel": "画像生成",
|
||||
"agentRun": "{{agent}} の実行",
|
||||
"collapsedSummary": "ツール呼び出し {{toolCount}} 件 · プロセスメッセージ {{processCount}} 件",
|
||||
"collapseAction": "実行グラフを折りたたむ"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "画像を生成しています。しばらくお待ちください…",
|
||||
"previewLoading": "画像プレビューを読み込み中…",
|
||||
"previewUnavailable": "画像生成は完了しましたが、プレビューを読み込めませんでした。",
|
||||
"generatedReady": "生成された画像の準備ができました。",
|
||||
"generatedReadyWithMissing": "生成された画像の準備ができましたが、一部の画像を読み込めませんでした。"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"hide": "非表示",
|
||||
"clear": "クリア"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "コードをコピー"
|
||||
},
|
||||
"status": {
|
||||
"running": "実行中",
|
||||
"stopped": "停止",
|
||||
|
||||
@@ -12,19 +12,13 @@
|
||||
},
|
||||
"noLogs": "(Журналы ещё недоступны)",
|
||||
"scrollToLatest": "Вернуться вниз",
|
||||
"loadMoreHistory": "Загрузить предыдущие сообщения",
|
||||
"loadingMoreHistory": "Загрузка…",
|
||||
"toolbar": {
|
||||
"refresh": "Обновить чат",
|
||||
"currentAgent": "Общение с {{agent}}",
|
||||
"workspace": "Рабочая область"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"title": "Изменения файлов ({{count}})",
|
||||
"created": "Создано",
|
||||
"modified": "Изменено",
|
||||
"openFolder": "Открыть папку",
|
||||
"viewAll": "Все изменения"
|
||||
"openFolder": "Открыть папку"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "Создан",
|
||||
@@ -111,9 +105,7 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "Исходник",
|
||||
"preview": "Предпросмотр",
|
||||
"changes": "Изменения",
|
||||
"info": "Информация"
|
||||
"preview": "Предпросмотр"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
@@ -127,11 +119,6 @@
|
||||
"openHtmlExternally": "Открыть HTML в системном браузере",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"info": {
|
||||
"path": "Путь",
|
||||
"size": "Размер",
|
||||
"type": "Тип"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Сохранено на диск",
|
||||
"saveFailed": "Ошибка сохранения: {{error}}"
|
||||
@@ -146,8 +133,8 @@
|
||||
"notFound": "Файл не найден",
|
||||
"loadFailed": "Ошибка загрузки: {{error}}",
|
||||
"noPreview": "Для этого файла предпросмотр недоступен",
|
||||
"unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр или diff",
|
||||
"unsupportedFormatHint": "Встроенный предпросмотр и diff сейчас поддерживают только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
|
||||
"unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр",
|
||||
"unsupportedFormatHint": "Встроенный предпросмотр сейчас поддерживает только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
|
||||
"largeBinaryOpenTitle": "Этот файл слишком большой для встроенного предпросмотра",
|
||||
"largeBinaryOpenHint": "Размер файла — {{size}}. ClawX не может показать его встроенно, но вы можете подтвердить открытие в системном приложении по умолчанию.",
|
||||
"openInFinderFailed": "Не удалось показать файл",
|
||||
@@ -158,13 +145,6 @@
|
||||
"html": {
|
||||
"crashed": "Предпросмотр HTML остановлен"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "Нет изменений для отображения",
|
||||
"newFile": "Новый файл — нет оригинала для сравнения",
|
||||
"tooLarge": "Файл слишком большой — режим diff отключён, откройте вкладку «Источник» для просмотра содержимого",
|
||||
"unavailable": "В этом чате не удалось зафиксировать точную базовую версию этого файла, поэтому diff недоступен.",
|
||||
"unavailableHint": "Откройте вкладку «Просмотр» для текущего содержимого; точное сравнение — в Git или другой системе."
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "Открыть файл",
|
||||
"message": "Открыть «{{fileName}}» в системном приложении по умолчанию?",
|
||||
@@ -220,35 +200,8 @@
|
||||
"openRootInFinder": "Показать корень в файловом менеджере"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "Вид выполнения",
|
||||
"title": "Структура задачи",
|
||||
"emptyTitle": "Пока нет структурированных шагов",
|
||||
"emptyBody": "Когда начнётся выполнение, ClawX покажет размышления, вызовы инструментов и состояния передачи здесь.",
|
||||
"status": {
|
||||
"idle": "Бездействует",
|
||||
"running_one": "1 активный шаг",
|
||||
"running_other": "{{count}} активных шагов"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "Выполняется",
|
||||
"completed": "Готово",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "Граф выполнения",
|
||||
"branchLabel": "ветвь",
|
||||
"thinkingLabel": "Думаю",
|
||||
"errorLabel": "Ошибка модели",
|
||||
"imageGenerateLabel": "Генерация изображения",
|
||||
"agentRun": "Выполнение {{agent}}",
|
||||
"collapsedSummary": "Вызовов инструментов: {{toolCount}} · Промежуточных сообщений: {{processCount}}",
|
||||
"collapseAction": "Свернуть граф выполнения"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "Изображение генерируется, подождите…",
|
||||
"previewLoading": "Загрузка предпросмотра изображения…",
|
||||
"previewUnavailable": "Создание изображения завершено, но предпросмотр загрузить не удалось.",
|
||||
"generatedReady": "Сгенерированное изображение готово.",
|
||||
"generatedReadyWithMissing": "Сгенерированное изображение готово, но некоторые изображения не удалось загрузить."
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"hide": "Скрыть",
|
||||
"clear": "Очистить"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "Копировать код"
|
||||
},
|
||||
"status": {
|
||||
"running": "Запущен",
|
||||
"stopped": "Остановлен",
|
||||
|
||||
@@ -12,19 +12,13 @@
|
||||
},
|
||||
"noLogs": "(暂无日志)",
|
||||
"scrollToLatest": "回到底部",
|
||||
"loadMoreHistory": "加载更早的消息",
|
||||
"loadingMoreHistory": "加载更多中…",
|
||||
"toolbar": {
|
||||
"refresh": "刷新聊天",
|
||||
"currentAgent": "当前对话对象:{{agent}}",
|
||||
"workspace": "工作空间"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"title": "文件变更({{count}} 个)",
|
||||
"created": "新增",
|
||||
"modified": "修改",
|
||||
"openFolder": "打开文件夹",
|
||||
"viewAll": "查看文件变更"
|
||||
"openFolder": "打开文件夹"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "已创建",
|
||||
@@ -111,9 +105,7 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "源码",
|
||||
"preview": "预览",
|
||||
"changes": "变更",
|
||||
"info": "信息"
|
||||
"preview": "预览"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
@@ -127,11 +119,6 @@
|
||||
"openHtmlExternally": "在系统浏览器中打开 HTML",
|
||||
"retry": "重试"
|
||||
},
|
||||
"info": {
|
||||
"path": "路径",
|
||||
"size": "大小",
|
||||
"type": "类型"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "已保存到磁盘",
|
||||
"saveFailed": "保存失败:{{error}}"
|
||||
@@ -146,8 +133,8 @@
|
||||
"notFound": "文件不存在",
|
||||
"loadFailed": "加载失败:{{error}}",
|
||||
"noPreview": "该文件没有预览",
|
||||
"unsupportedFormatTitle": "此文件格式暂不支持内置预览或变更",
|
||||
"unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在文件管理器中打开该文件。",
|
||||
"unsupportedFormatTitle": "此文件格式暂不支持内置预览",
|
||||
"unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览。请在文件管理器中打开该文件。",
|
||||
"largeBinaryOpenTitle": "该文件较大,暂不支持内置预览",
|
||||
"largeBinaryOpenHint": "当前文件为 {{size}},ClawX 不提供内置预览。你可以确认后直接使用系统默认应用打开。",
|
||||
"openInFinderFailed": "无法在文件管理器中显示",
|
||||
@@ -158,13 +145,6 @@
|
||||
"html": {
|
||||
"crashed": "HTML 预览已停止运行"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "没有可显示的变更",
|
||||
"newFile": "这是新增文件,无对比内容",
|
||||
"tooLarge": "文件过大,已禁用 diff 视图,请到「源码」标签查看完整内容",
|
||||
"unavailable": "本会话没有抓到这个文件的精确变更基线,无法生成 diff。",
|
||||
"unavailableHint": "可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。"
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "打开文件",
|
||||
"message": "确认直接打开「{{fileName}}」?",
|
||||
@@ -220,35 +200,8 @@
|
||||
"openRootInFinder": "在文件管理器中显示根目录"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "运行视图",
|
||||
"title": "任务脉络",
|
||||
"emptyTitle": "还没有结构化步骤",
|
||||
"emptyBody": "当一次运行开始后,ClawX 会在这里展示思考、工具调用和收尾状态。",
|
||||
"status": {
|
||||
"idle": "空闲",
|
||||
"running_one": "1 个活动步骤",
|
||||
"running_other": "{{count}} 个活动步骤"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "进行中",
|
||||
"completed": "完成",
|
||||
"error": "错误"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "执行关系图",
|
||||
"branchLabel": "分支",
|
||||
"thinkingLabel": "思考中",
|
||||
"errorLabel": "模型调用失败",
|
||||
"imageGenerateLabel": "图片生成",
|
||||
"agentRun": "{{agent}} 执行",
|
||||
"collapsedSummary": "{{toolCount}} 个工具调用,{{processCount}} 条过程消息",
|
||||
"collapseAction": "收起执行关系图"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "图片生成中,请稍候…",
|
||||
"previewLoading": "正在加载图片预览…",
|
||||
"previewUnavailable": "图片生成已完成,但无法加载预览。",
|
||||
"generatedReady": "生成的图片已准备好。",
|
||||
"generatedReadyWithMissing": "生成的图片已准备好,但有部分图片无法加载。"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"hide": "隐藏",
|
||||
"clear": "清空"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "复制代码"
|
||||
},
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"stopped": "已停止",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"streamdown": {
|
||||
"source": "vercel/streamdown",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/streamdown/SKILL.md",
|
||||
"computedHash": "c842552c75b80abb74ef8f92481d34684c709884dd2df22c2ab3b183aee582fe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,14 @@
|
||||
* Inline file preview body.
|
||||
*
|
||||
* Renders the icon header (file name / path / save / revert) and a
|
||||
* minimal tabbed view for a single file. Documents (Markdown) render
|
||||
* only their `preview` tab; code / other text files render `source`.
|
||||
* A `diff` tab is added when the file is editable AND there are
|
||||
* AI-applied edits to display. The metadata `info` tab has been
|
||||
* intentionally removed — path / size are visible in the header bar.
|
||||
* minimal tabbed view for a single file. Documents (Markdown) render
|
||||
* their `preview` tab; code / other text files render `source`.
|
||||
*
|
||||
* The `mode` prop narrows the tab set for callers that want a single,
|
||||
* fixed view (e.g. the artifact panel's preview tab forces `preview`, and
|
||||
* the changes tab's right pane forces `diff`).
|
||||
* The `mode` prop lets callers force a read-only preview surface.
|
||||
*
|
||||
* Used by:
|
||||
* - `FilePreviewOverlay` for the Skills detail Sheet (read-only).
|
||||
* - `ArtifactPanel`'s ChangesTab and PreviewTab.
|
||||
* - `ArtifactPanel`'s PreviewTab.
|
||||
*
|
||||
* All sandbox / read-only / large-file / binary edge cases are handled
|
||||
* here so callers only pass a `FilePreviewTarget` and a `readOnly` flag.
|
||||
@@ -36,10 +31,7 @@ import {
|
||||
} from '@/lib/file-preview-client';
|
||||
import { getFilePreviewTargetIdentity, type FilePreviewTarget } from './types';
|
||||
import { previewDisplayPath } from './build-preview-target';
|
||||
import {
|
||||
isHtmlPreviewExt,
|
||||
supportsInlineDiff,
|
||||
} from '@/lib/generated-files';
|
||||
import { isHtmlPreviewExt } from '@/lib/generated-files';
|
||||
import {
|
||||
filePreviewKind,
|
||||
isFilePreviewWithinSizeLimit,
|
||||
@@ -57,7 +49,6 @@ import ImageViewer from './ImageViewer';
|
||||
import { HtmlPreviewAnchor } from '@/components/web-browser/WebBrowserAnchor';
|
||||
|
||||
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
|
||||
const MonacoDiffViewerLazy = lazy(() => import('./MonacoDiffViewer'));
|
||||
const PdfViewerLazy = lazy(() => import('./PdfViewer'));
|
||||
const SheetViewerLazy = lazy(() => import('./SheetViewer'));
|
||||
const DocxViewerLazy = lazy(() => import('./DocxViewer'));
|
||||
@@ -66,12 +57,10 @@ const PptxViewerLazy = lazy(() => import('./PptxViewer'));
|
||||
/**
|
||||
* Tab set for the body.
|
||||
*
|
||||
* - 'full' – default: preview / source / diff as appropriate.
|
||||
* - 'preview' – render-only; hides the diff tab and forces read-only.
|
||||
* - 'diff' – diff-only; the body collapses to a single diff view
|
||||
* with no tab strip, save/revert, or source toggle.
|
||||
* - 'full' – default: preview / source as appropriate.
|
||||
* - 'preview' – render-only and read-only.
|
||||
*/
|
||||
export type FilePreviewBodyMode = 'full' | 'preview' | 'diff';
|
||||
export type FilePreviewBodyMode = 'full' | 'preview';
|
||||
|
||||
export interface FilePreviewBodyProps {
|
||||
file: FilePreviewTarget;
|
||||
@@ -84,7 +73,7 @@ export interface FilePreviewBodyProps {
|
||||
trailingHeader?: React.ReactNode;
|
||||
/** Optional left padding override for headers rendered beneath native window chrome. */
|
||||
headerLeftInset?: number;
|
||||
/** Limit the visible tabs. Default: 'full'. */
|
||||
/** Control whether the surface is editable. Default: 'full'. */
|
||||
mode?: FilePreviewBodyMode;
|
||||
/** When true, hide the file header (name / path / actions). */
|
||||
hideHeader?: boolean;
|
||||
@@ -102,14 +91,9 @@ type LoadState =
|
||||
| { identity: string; status: 'outsideSandbox' }
|
||||
| { identity: string; status: 'error'; message: string };
|
||||
|
||||
type Tab = 'source' | 'preview' | 'diff';
|
||||
|
||||
function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[] {
|
||||
// Diff-only mode short-circuits. Callers (e.g. the changes tab's right
|
||||
// pane) want a pure git-style diff with no tab strip — but only for
|
||||
// formats where inline diff is actually supported.
|
||||
if (mode === 'diff') return supportsInlineDiff(file) ? ['diff'] : [];
|
||||
type Tab = 'source' | 'preview';
|
||||
|
||||
function tabsForFile(file: FilePreviewTarget): Tab[] {
|
||||
const previewKind = filePreviewKind(file);
|
||||
if (!previewKind) return [];
|
||||
const richPreview = richFilePreviewKind(file);
|
||||
@@ -127,87 +111,14 @@ function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[]
|
||||
} else {
|
||||
tabs.push('source');
|
||||
}
|
||||
// Diff tab appears in 'full' mode whenever we captured a Write/Edit
|
||||
// payload — read-only is fine, the diff is informational only.
|
||||
if (
|
||||
mode === 'full' &&
|
||||
supportsInlineDiff(file) &&
|
||||
(file.fullContent != null || (file.edits != null && file.edits.length > 0))
|
||||
) {
|
||||
tabs.push('diff');
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function pickInitialTab(tabs: Tab[], file: FilePreviewTarget): Tab {
|
||||
if (file.contentType === 'document' && tabs.includes('preview')) return 'preview';
|
||||
// For changes view (edited code), prefer the diff tab if present so
|
||||
// the user sees the change immediately on click.
|
||||
if (tabs.includes('diff') && file.contentType !== 'document') return 'diff';
|
||||
return tabs[0] ?? 'source';
|
||||
}
|
||||
|
||||
function normaliseEol(s: string): string {
|
||||
return s.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
type DiffPair = {
|
||||
/** Left pane. `null` makes Monaco render the side as empty (new-file). */
|
||||
oldContent: string | null;
|
||||
/** Right pane. */
|
||||
newContent: string;
|
||||
/**
|
||||
* - `whole` – right pane is the full file (Write tool).
|
||||
* - `snippet` – left/right are the joined Edit op old/new strings.
|
||||
* - `unavailable` – the chat captured no payload to diff against.
|
||||
*/
|
||||
kind: 'whole' | 'snippet' | 'unavailable';
|
||||
};
|
||||
|
||||
/** Visual separator between MultiEdit hunks. */
|
||||
const SNIPPET_SEPARATOR = '\n\n';
|
||||
|
||||
/**
|
||||
* Build a diff pair purely from the captured tool payload — never reads
|
||||
* disk. Edit tools show the exact old → new snippet swap. Write-family
|
||||
* tools prefer the captured baseline when available, so modified files
|
||||
* render a true before/after diff instead of a misleading all-green view.
|
||||
*/
|
||||
function computeDiffPair(file: FilePreviewTarget): DiffPair {
|
||||
// Edit / StrReplace / MultiEdit — show the snippet swap directly.
|
||||
if (file.edits && file.edits.length > 0) {
|
||||
const lefts = file.edits.map((op) => normaliseEol(op.old ?? ''));
|
||||
const rights = file.edits.map((op) => normaliseEol(op.new ?? ''));
|
||||
const left = lefts.join(SNIPPET_SEPARATOR);
|
||||
const right = rights.join(SNIPPET_SEPARATOR);
|
||||
if (left || right) {
|
||||
return { oldContent: left, newContent: right, kind: 'snippet' };
|
||||
}
|
||||
}
|
||||
|
||||
if (file.fullContent != null) {
|
||||
if (file.baseline?.status === 'ok') {
|
||||
return {
|
||||
oldContent: normaliseEol(file.baseline.content),
|
||||
newContent: normaliseEol(file.fullContent),
|
||||
kind: 'whole',
|
||||
};
|
||||
}
|
||||
if (file.baseline?.status === 'missing') {
|
||||
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
|
||||
}
|
||||
if (file.baseline?.status === 'unavailable') {
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
if (file.action === 'created') {
|
||||
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
|
||||
}
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
|
||||
export function FilePreviewBody({
|
||||
file,
|
||||
readOnly = false,
|
||||
@@ -232,16 +143,14 @@ export function FilePreviewBody({
|
||||
const [tab, setTab] = useState<Tab>('source');
|
||||
const [size, setSize] = useState<number | undefined>(file.size);
|
||||
|
||||
// Preview / diff modes are read-only by definition — those views are
|
||||
// for inspecting content, not editing it.
|
||||
const enforcedReadOnly = readOnly || !!file.attachmentFileRef || !!file.workspaceFileRef || mode === 'preview' || mode === 'diff';
|
||||
const tabs = useMemo(() => tabsForFile(file, mode), [file, mode]);
|
||||
const unsupportedPreviewFormat = mode !== 'diff' && filePreviewKind(file) == null;
|
||||
const unsupportedDiffFormat = mode === 'diff' && !supportsInlineDiff(file);
|
||||
// Preview mode is for inspecting content, not editing it.
|
||||
const enforcedReadOnly = readOnly || !!file.attachmentFileRef || !!file.workspaceFileRef || mode === 'preview';
|
||||
const tabs = useMemo(() => tabsForFile(file), [file]);
|
||||
const unsupportedPreviewFormat = filePreviewKind(file) == null;
|
||||
const richPreview = richFilePreviewKind(file);
|
||||
// Binary document previews own their own loading
|
||||
// pipeline — we must not pipe them through `readTextFile` (which would
|
||||
// reject them as binary) and the diff tab is intentionally hidden.
|
||||
// reject them as binary).
|
||||
const isRichDocumentPreview = richPreview === 'pdf'
|
||||
|| richPreview === 'sheet'
|
||||
|| richPreview === 'docx'
|
||||
@@ -261,16 +170,6 @@ export function FilePreviewBody({
|
||||
setTab(pickInitialTab(tabs, file));
|
||||
setSize(file.size);
|
||||
|
||||
// Diff-only mode renders entirely from the captured tool payload —
|
||||
// no disk read needed, so we can mark the body "ready" immediately.
|
||||
if (mode === 'diff') {
|
||||
setState({ identity: loadIdentity, status: 'ready', content: '', readOnly: enforcedReadOnly });
|
||||
setDraft(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (unsupportedPreviewFormat) {
|
||||
setState({ identity: loadIdentity, status: 'ready', content: '', readOnly: enforcedReadOnly });
|
||||
setDraft(null);
|
||||
@@ -399,7 +298,7 @@ export function FilePreviewBody({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, loadIdentity, enforcedReadOnly, mode, tabs, unsupportedPreviewFormat, isRichDocumentPreview, richPreview, richPreviewLimitTarget]);
|
||||
}, [file, loadIdentity, enforcedReadOnly, tabs, unsupportedPreviewFormat, isRichDocumentPreview, richPreview, richPreviewLimitTarget]);
|
||||
|
||||
const effectiveReadOnly = state.status === 'ready' ? state.readOnly : true;
|
||||
const allowSystemActions = !file.attachmentFileRef && !file.workspaceFileRef;
|
||||
@@ -461,7 +360,7 @@ export function FilePreviewBody({
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{directOpen
|
||||
? t('filePreview.errors.largeBinaryOpenTitle', 'This file is too large for inline preview')
|
||||
: t('filePreview.errors.unsupportedFormatTitle', 'This file format is not supported for inline preview or diff')}
|
||||
: t('filePreview.errors.unsupportedFormatTitle', 'This file format is not supported for inline preview')}
|
||||
</p>
|
||||
{allowSystemActions && <p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
{directOpen
|
||||
@@ -471,7 +370,7 @@ export function FilePreviewBody({
|
||||
})
|
||||
: t(
|
||||
'filePreview.errors.unsupportedFormatHint',
|
||||
'Only directly readable files such as text and Markdown support inline preview and diff. Please open this file in your file manager.',
|
||||
'Only directly readable files such as text and Markdown support inline preview. Please open this file in your file manager.',
|
||||
)}
|
||||
</p>}
|
||||
</div>
|
||||
@@ -491,7 +390,7 @@ export function FilePreviewBody({
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (unsupportedPreviewFormat || unsupportedDiffFormat) {
|
||||
if (unsupportedPreviewFormat) {
|
||||
return renderUnsupportedFormat();
|
||||
}
|
||||
if (state.status === 'loading') {
|
||||
@@ -701,46 +600,6 @@ export function FilePreviewBody({
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{tabs.includes('diff') && (
|
||||
<TabsContent value="diff" className="m-0 h-full">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
const pair = computeDiffPair(file);
|
||||
if (pair.kind === 'unavailable') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{t(
|
||||
'filePreview.diff.unavailable',
|
||||
'This session did not capture an exact baseline for this file, so a diff cannot be generated.',
|
||||
)}
|
||||
</p>
|
||||
<p className="max-w-md text-2xs text-muted-foreground/90">
|
||||
{t(
|
||||
'filePreview.diff.unavailableHint',
|
||||
'Use the Preview tab above to view the current file contents. For an exact diff, compare versions in Git or another external tool.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MonacoDiffViewerLazy
|
||||
filePath={file.filePath}
|
||||
original={pair.oldContent}
|
||||
modified={pair.newContent}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -781,7 +640,6 @@ export function FilePreviewBody({
|
||||
<TabsTrigger key={id} value={id} className="px-2.5 py-1 text-xs">
|
||||
{id === 'source' && t('filePreview.tabs.source', 'Source')}
|
||||
{id === 'preview' && t('filePreview.tabs.preview', 'Preview')}
|
||||
{id === 'diff' && t('filePreview.tabs.changes', 'Changes')}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Inline panel showing files the AI wrote/edited in the current run.
|
||||
* Lives directly under the ExecutionGraphCard for each user trigger
|
||||
* (see Chat/index.tsx).
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import {
|
||||
computeLineStats,
|
||||
supportsInlineDiff,
|
||||
supportsInlineDocumentPreview,
|
||||
supportsRichDocumentPreview,
|
||||
type GeneratedFile,
|
||||
} from '@/lib/generated-files';
|
||||
|
||||
export interface GeneratedFilesPanelProps {
|
||||
files: GeneratedFile[];
|
||||
onOpen: (file: GeneratedFile) => void;
|
||||
onRevealInFileManager?: (file: GeneratedFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GeneratedFilesPanel({
|
||||
files,
|
||||
onOpen,
|
||||
onRevealInFileManager,
|
||||
className,
|
||||
}: GeneratedFilesPanelProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
if (!files.length) return null;
|
||||
|
||||
const revealInFileManager = (file: GeneratedFile) => {
|
||||
if (onRevealInFileManager) {
|
||||
onRevealInFileManager(file);
|
||||
return;
|
||||
}
|
||||
void hostApi.shell.showItemInFolder(file.filePath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="px-1">
|
||||
<p className="text-xs font-semibold text-foreground/75">
|
||||
{t('generatedFiles.title', { count: files.length, defaultValue: 'File changes ({{count}})' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file) => {
|
||||
const lineStats = computeLineStats(file);
|
||||
const clickable = supportsInlineDiff(file) || supportsInlineDocumentPreview(file.ext);
|
||||
const revealOnly = supportsRichDocumentPreview(file.ext);
|
||||
if (revealOnly) {
|
||||
return (
|
||||
<button
|
||||
key={`${file.filePath}-${file.lastSeenIndex}`}
|
||||
type="button"
|
||||
onClick={() => revealInFileManager(file)}
|
||||
className={cn(
|
||||
'group inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-black/8 bg-black/[0.035] px-3.5 py-2 text-left transition-colors',
|
||||
'hover:border-black/12 hover:bg-black/[0.055] dark:hover:bg-white/[0.07]',
|
||||
'dark:border-white/10 dark:bg-white/[0.04]',
|
||||
)}
|
||||
title={file.filePath}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-2 overflow-hidden whitespace-nowrap text-meta leading-none">
|
||||
<span className="shrink-0 font-medium text-foreground">{file.fileName}</span>
|
||||
<span className="truncate text-muted-foreground">{file.filePath}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 rounded-full border border-black/8 bg-black/[0.045] px-1.5 py-0.5 text-2xs text-foreground/70 dark:border-white/10 dark:bg-white/[0.06] dark:text-foreground/75"
|
||||
>
|
||||
<FolderOpen className="mr-1 h-3 w-3" />
|
||||
{t('generatedFiles.openFolder', 'Open folder')}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${file.filePath}-${file.lastSeenIndex}`}
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={() => {
|
||||
if (clickable) onOpen(file);
|
||||
}}
|
||||
className={cn(
|
||||
'group inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-black/8 bg-black/[0.035] px-3.5 py-2 text-left transition-colors disabled:opacity-100',
|
||||
clickable && 'hover:border-black/12 hover:bg-black/[0.055] dark:hover:bg-white/[0.07]',
|
||||
!clickable && 'cursor-default',
|
||||
'dark:border-white/10 dark:bg-white/[0.04]',
|
||||
)}
|
||||
title={file.filePath}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-2 overflow-hidden whitespace-nowrap text-meta leading-none">
|
||||
<span className="shrink-0 font-medium text-foreground">{file.fileName}</span>
|
||||
<span className="truncate text-muted-foreground">{file.filePath}</span>
|
||||
</div>
|
||||
{lineStats && (
|
||||
<div className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-none tabular-nums">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{lineStats.added}</span>
|
||||
<span className="text-rose-600 dark:text-rose-400">-{lineStats.removed}</span>
|
||||
</div>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 rounded-full border border-black/8 bg-black/[0.045] px-1.5 py-0.5 text-2xs text-foreground/70 dark:border-white/10 dark:bg-white/[0.06] dark:text-foreground/75"
|
||||
>
|
||||
{file.action === 'created'
|
||||
? t('generatedFiles.created', 'Created')
|
||||
: t('generatedFiles.modified', 'Modified')}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneratedFilesPanel;
|
||||
@@ -1,26 +1,19 @@
|
||||
/**
|
||||
* Rendered Markdown preview.
|
||||
*
|
||||
* Two important deltas from a vanilla ReactMarkdown setup:
|
||||
*
|
||||
* 1. `remark-frontmatter` is enabled with the `yaml`/`toml` flavours so a
|
||||
* file like `SKILL.md` doesn't render its `---\nname: …\n---` block as
|
||||
* a giant paragraph between two horizontal rules. The frontmatter is
|
||||
* extracted into a side-rendered metadata card above the body so users
|
||||
* can still see it without the noise.
|
||||
*
|
||||
* 2. We render heading / inline-code / fenced-code with explicit Tailwind
|
||||
* classes instead of relying solely on `.prose`, because the project
|
||||
* doesn't ship `@tailwindcss/typography` — only the small subset of
|
||||
* `.prose` rules from `globals.css` exists.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import {
|
||||
defaultRemarkPlugins,
|
||||
Streamdown,
|
||||
type Components,
|
||||
type StreamdownProps,
|
||||
} from 'streamdown';
|
||||
import { BrowserLink } from '@/components/common/BrowserLink';
|
||||
import {
|
||||
streamdownControls,
|
||||
streamdownLinkSafety,
|
||||
streamdownPlugins,
|
||||
streamdownRehypePlugins,
|
||||
} from '@/components/markdown/streamdown-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface MarkdownPreviewProps {
|
||||
@@ -28,90 +21,64 @@ export interface MarkdownPreviewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterSplit {
|
||||
body: string;
|
||||
yaml: string | null;
|
||||
}
|
||||
const previewRemarkPlugins: NonNullable<StreamdownProps['remarkPlugins']> = [
|
||||
...Object.values(defaultRemarkPlugins),
|
||||
[remarkFrontmatter, ['yaml', 'toml']],
|
||||
];
|
||||
|
||||
function splitFrontmatter(source: string): FrontmatterSplit {
|
||||
if (!source.startsWith('---')) {
|
||||
return { body: source, yaml: null };
|
||||
}
|
||||
// Match opening fence + YAML body + closing fence on its own line.
|
||||
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) return { body: source, yaml: null };
|
||||
const yaml = match[1].trim();
|
||||
const body = source.slice(match[0].length);
|
||||
return { body, yaml: yaml.length > 0 ? yaml : null };
|
||||
}
|
||||
const previewComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-3 mt-4 text-2xl font-semibold text-foreground first:mt-0" data-streamdown="heading-1">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 mt-4 text-xl font-semibold text-foreground first:mt-0" data-streamdown="heading-2">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-3 text-base font-semibold text-foreground first:mt-0" data-streamdown="heading-3">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="mb-1.5 mt-3 text-sm font-semibold text-foreground first:mt-0" data-streamdown="heading-4">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
a: ({ children, href }) => href ? (
|
||||
<BrowserLink href={href} className="text-primary underline-offset-2 hover:underline">
|
||||
{children}
|
||||
</BrowserLink>
|
||||
) : <>{children}</>,
|
||||
inlineCode: ({ children }) => (
|
||||
<code className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.9em] text-foreground dark:bg-white/10">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
};
|
||||
|
||||
export default function MarkdownPreview({ source, className }: MarkdownPreviewProps) {
|
||||
const { body, yaml } = useMemo(() => splitFrontmatter(source), [source]);
|
||||
const { t } = useTranslation('common');
|
||||
const translations = useMemo(() => ({
|
||||
copyCode: t('markdown.copyCode'),
|
||||
}), [t]);
|
||||
|
||||
return (
|
||||
<div className={cn('prose max-w-none px-6 py-4 text-sm leading-relaxed', className)}>
|
||||
{yaml && (
|
||||
<pre className="mb-4 rounded-lg border border-black/5 bg-black/[.03] px-3 py-2 text-2xs leading-relaxed text-foreground/70 dark:border-white/10 dark:bg-white/5">
|
||||
<code className="font-mono">{yaml}</code>
|
||||
</pre>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkFrontmatter, ['yaml', 'toml']], remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1 className="mb-3 mt-4 text-2xl font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2 className="mb-2 mt-4 text-xl font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3 className="mb-2 mt-3 text-base font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="mb-1.5 mt-3 text-sm font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
a: ({ children, href }) => href ? (
|
||||
<BrowserLink href={href} className="text-primary underline-offset-2 hover:underline">
|
||||
{children}
|
||||
</BrowserLink>
|
||||
) : <>{children}</>,
|
||||
code: ({ className: codeClass, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(codeClass || '');
|
||||
const isInline = !match && !codeClass && !String(children).includes('\n');
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.9em] text-foreground dark:bg-white/10"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={cn('font-mono text-[0.9em]', codeClass)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-black/5 p-3 text-xs leading-relaxed dark:bg-white/10">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<Streamdown
|
||||
className={cn('clawx-markdown clawx-markdown-preview clawx-streamdown prose max-w-none px-6 py-4 text-sm leading-relaxed', className)}
|
||||
components={previewComponents}
|
||||
controls={streamdownControls}
|
||||
lineNumbers={false}
|
||||
linkSafety={streamdownLinkSafety}
|
||||
mode="static"
|
||||
plugins={streamdownPlugins}
|
||||
rehypePlugins={streamdownRehypePlugins}
|
||||
remarkPlugins={previewRemarkPlugins}
|
||||
translations={translations}
|
||||
>
|
||||
{source}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -775,7 +775,7 @@ export function WorkspaceBrowserBody({
|
||||
|
||||
function WorkspaceTreeContainerRow<T>({ attrs, innerRef, children }: RowRendererProps<T>) {
|
||||
return (
|
||||
<div {...attrs} ref={innerRef} onClick={undefined}>
|
||||
<div {...attrs} ref={innerRef} onClick={undefined} className="!min-w-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* tree, skills page, …) can import the type without pulling in the Sheet /
|
||||
* Monaco component graph.
|
||||
*/
|
||||
import type { FileContentType, FileEditOp, GeneratedFileBaseline } from '@/lib/generated-files';
|
||||
import type { FileContentType } from '@/lib/generated-files';
|
||||
import type { AttachmentFileRef, WorkspaceFileRef } from '@/lib/file-preview-client';
|
||||
|
||||
export interface FilePreviewTarget {
|
||||
@@ -18,33 +18,8 @@ export interface FilePreviewTarget {
|
||||
contentType: FileContentType;
|
||||
/** Known file size from chat attachment metadata, when available. */
|
||||
size?: number;
|
||||
/**
|
||||
* From chat extraction only. Drives the badge in the changes list and is
|
||||
* not used by the diff view itself (which derives "before/after" from
|
||||
* `fullContent` / `edits` directly, WorkBuddy-style).
|
||||
*/
|
||||
/** From file activity metadata, when available. */
|
||||
action?: 'created' | 'modified';
|
||||
/**
|
||||
* Full new content of the file when the tool payload provides it (Write
|
||||
* family).
|
||||
*/
|
||||
fullContent?: string;
|
||||
/**
|
||||
* Content of the file *before* the AI's write, captured from disk when
|
||||
* the tool_use was first detected in the stream.
|
||||
*
|
||||
* - `ok` → render a real before/after diff
|
||||
* - `missing` → render a new-file diff (empty left pane)
|
||||
* - `unavailable`→ avoid pretending the file was new; show diff unavailable
|
||||
*/
|
||||
baseline?: GeneratedFileBaseline;
|
||||
/**
|
||||
* Edit operations from Edit / StrReplace / MultiEdit. The diff view
|
||||
* renders these directly as a snippet diff (left = joined `op.old`,
|
||||
* right = joined `op.new`) — exactly what the AI changed, no disk
|
||||
* reads, no reverse-application.
|
||||
*/
|
||||
edits?: FileEditOp[];
|
||||
}
|
||||
|
||||
export function getFilePreviewTargetIdentity(
|
||||
|
||||
@@ -159,7 +159,6 @@ export function Sidebar() {
|
||||
const deleteSessions = useChatStore((s) => s.deleteSessions);
|
||||
const renameSession = useChatStore((s) => s.renameSession);
|
||||
const loadSessions = useChatStore((s) => s.loadSessions);
|
||||
const loadHistory = useChatStore((s) => s.loadHistory);
|
||||
const sessionAttentionByKey = useSessionAttentionStore((s) => s.bySessionKey);
|
||||
const markRead = useSessionAttentionStore((s) => s.markRead);
|
||||
const handleNewChat = useNewChatAction();
|
||||
@@ -170,26 +169,10 @@ export function Sidebar() {
|
||||
const gatewayRestarting = isGatewayRestarting(gatewayStatus);
|
||||
const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`;
|
||||
|
||||
const hasLoadedCurrentRuntimeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
hasLoadedCurrentRuntimeRef.current = false;
|
||||
}, [gatewayRuntimeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGatewayReady) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await loadSessions();
|
||||
if (cancelled) return;
|
||||
if (hasLoadedCurrentRuntimeRef.current) return;
|
||||
hasLoadedCurrentRuntimeRef.current = true;
|
||||
await loadHistory(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gatewayRuntimeKey, isGatewayReady, loadHistory, loadSessions]);
|
||||
void loadSessions();
|
||||
}, [gatewayRuntimeKey, isGatewayReady, loadSessions]);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
|
||||
@@ -765,9 +748,7 @@ export function Sidebar() {
|
||||
aria-current={isCurrentSession ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
markRead(s.key);
|
||||
if (currentSessionKey === s.key) {
|
||||
void loadHistory(false);
|
||||
} else {
|
||||
if (currentSessionKey !== s.key) {
|
||||
switchSession(s.key);
|
||||
}
|
||||
navigate('/');
|
||||
@@ -988,7 +969,8 @@ export function Sidebar() {
|
||||
onConfirm={async () => {
|
||||
const targetSession = sessionToDelete;
|
||||
if (!targetSession) return;
|
||||
await deleteSession(targetSession.key);
|
||||
const result = await deleteSession(targetSession.key);
|
||||
if (!result.success) return;
|
||||
if (currentSessionKey === targetSession.key) navigate('/');
|
||||
setDeleteDialogOpen(false);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import { createCodePlugin } from '@streamdown/code';
|
||||
import { createMathPlugin } from '@streamdown/math';
|
||||
import {
|
||||
defaultRehypePlugins,
|
||||
type AnimateOptions,
|
||||
type ControlsConfig,
|
||||
type LinkSafetyConfig,
|
||||
type PluginConfig,
|
||||
} from 'streamdown';
|
||||
|
||||
export const streamdownPlugins: PluginConfig = {
|
||||
code: createCodePlugin({ themes: ['github-light', 'github-dark'] }),
|
||||
math: createMathPlugin({ singleDollarTextMath: true }),
|
||||
cjk,
|
||||
};
|
||||
|
||||
const { raw: omittedRawPlugin, ...safeDefaultRehypePlugins } = defaultRehypePlugins;
|
||||
void omittedRawPlugin;
|
||||
|
||||
export const streamdownRehypePlugins = Object.values(safeDefaultRehypePlugins);
|
||||
|
||||
export const streamdownLinkSafety = {
|
||||
enabled: false,
|
||||
} as const satisfies LinkSafetyConfig;
|
||||
|
||||
export const streamdownControls = {
|
||||
code: { copy: true, download: false },
|
||||
mermaid: false,
|
||||
table: false,
|
||||
} as const satisfies ControlsConfig;
|
||||
|
||||
export const streamdownAnimation = {
|
||||
animation: 'fadeIn',
|
||||
duration: 140,
|
||||
stagger: 0,
|
||||
sep: 'word',
|
||||
} as const satisfies AnimateOptions;
|
||||
@@ -40,7 +40,7 @@ function appendUserItem(groups: AcpTimelineDisplayGroup[], item: MessageSegmentI
|
||||
if (previous !== group) groups.push(group);
|
||||
|
||||
const { attachments, remaining } = extractAttachments(item.parts);
|
||||
group.items.push({ ...item, parts: remaining });
|
||||
group.items.push(remaining === item.parts ? item : { ...item, parts: remaining });
|
||||
group.attachments.push(...attachments);
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ function extractAttachments(parts: RenderPart[]): {
|
||||
remaining: RenderPart[];
|
||||
} {
|
||||
const attachments: AttachmentRenderPart[] = [];
|
||||
const remaining: RenderPart[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.kind === 'attachment') attachments.push(part);
|
||||
else remaining.push(part);
|
||||
}
|
||||
if (attachments.length === 0) return { attachments, remaining: parts };
|
||||
const remaining = parts.filter((part) => part.kind !== 'attachment');
|
||||
return { attachments, remaining };
|
||||
}
|
||||
|
||||
@@ -84,19 +84,19 @@ function appendAssistantItem(groups: AcpTimelineDisplayGroup[], item: TimelineIt
|
||||
if (item.kind === 'message-segment') {
|
||||
const { attachments, remaining } = extractAttachments(item.parts);
|
||||
group.attachments.push(...attachments);
|
||||
if (remaining.length > 0) group.items.push({ ...item, parts: remaining });
|
||||
if (remaining.length > 0) group.items.push(remaining === item.parts ? item : { ...item, parts: remaining });
|
||||
return;
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
const { attachments, remaining } = extractAttachments(item.parts);
|
||||
group.attachments.push(...attachments);
|
||||
group.items.push({ ...item, parts: remaining });
|
||||
group.items.push(remaining === item.parts ? item : { ...item, parts: remaining });
|
||||
return;
|
||||
}
|
||||
if (item.kind === 'tool-call') {
|
||||
const { attachments, remaining } = extractAttachments(item.outputParts);
|
||||
group.attachments.push(...attachments);
|
||||
group.items.push({ ...item, outputParts: remaining });
|
||||
group.items.push(remaining === item.outputParts ? item : { ...item, outputParts: remaining });
|
||||
return;
|
||||
}
|
||||
group.items.push(item);
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Generated files extraction.
|
||||
*
|
||||
* Inspects a run segment of chat messages (the slice from a user trigger
|
||||
* to its terminating assistant reply) and surfaces files the AI wrote /
|
||||
* edited via tool calls. Used by `GeneratedFilesPanel` to render inline
|
||||
* file cards under each run, and by `FilePreviewBody` / `ArtifactPanel`
|
||||
* to power the diff view.
|
||||
*/
|
||||
import { diffLines } from 'diff';
|
||||
import type { ContentBlock, RawMessage } from '@/stores/chat';
|
||||
|
||||
export type FileContentType =
|
||||
| 'snapshot'
|
||||
| 'code'
|
||||
@@ -18,93 +6,6 @@ export type FileContentType =
|
||||
| 'audio'
|
||||
| 'other';
|
||||
|
||||
/** A single (old_string -> new_string) replacement extracted from an edit tool. */
|
||||
export interface FileEditOp {
|
||||
old: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export type GeneratedFileBaseline =
|
||||
| { status: 'ok'; content: string }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable'; reason: string };
|
||||
|
||||
export interface FileLineStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
}
|
||||
|
||||
export interface GeneratedFile {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
mimeType: string;
|
||||
contentType: FileContentType;
|
||||
size?: number;
|
||||
action: 'created' | 'modified';
|
||||
/**
|
||||
* Full new content of the file when known (only set by `Write`-family
|
||||
* tools that provide the whole document in their input).
|
||||
*/
|
||||
fullContent?: string;
|
||||
/**
|
||||
* Ordered list of edits applied to this file during the run (Edit /
|
||||
* StrReplace / MultiEdit). The diff view renders these directly as a
|
||||
* snippet diff (joined `old` vs joined `new`), matching WorkBuddy /
|
||||
* Codex behaviour.
|
||||
*/
|
||||
edits?: FileEditOp[];
|
||||
/**
|
||||
* File content captured immediately before a Write-family tool executed.
|
||||
* `missing` means the file did not exist yet. `unavailable` means the
|
||||
* renderer could not read the existing file precisely enough to build a
|
||||
* trustworthy before/after diff (outside sandbox, binary, too large, ...).
|
||||
*/
|
||||
baseline?: GeneratedFileBaseline;
|
||||
/** Index of the latest tool call that touched this file (for stable ordering). */
|
||||
lastSeenIndex: number;
|
||||
}
|
||||
|
||||
/** Visual separator between multiple edit hunks. */
|
||||
const SNIPPET_SEPARATOR = '\n\n';
|
||||
|
||||
/**
|
||||
* True when the chat extraction captured enough tool payload to render a
|
||||
* diff (Write `fullContent` and/or non-empty Edit ops). Entries without
|
||||
* this should not appear in generated-file UIs.
|
||||
*/
|
||||
export function generatedFileHasDiffPayload(file: Pick<GeneratedFile, 'fullContent' | 'edits'>): boolean {
|
||||
if (file.fullContent != null) return true;
|
||||
if (file.edits?.length) {
|
||||
return file.edits.some((op) => (op.old ?? '') !== '' || (op.new ?? '') !== '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const WRITE_TOOLS = new Set([
|
||||
'Write',
|
||||
'write_file',
|
||||
'create_file',
|
||||
'WriteFile',
|
||||
'createFile',
|
||||
'write',
|
||||
]);
|
||||
|
||||
const EDIT_TOOLS = new Set([
|
||||
'Edit',
|
||||
'edit',
|
||||
'edit_file',
|
||||
'EditFile',
|
||||
'StrReplace',
|
||||
'str_replace',
|
||||
'str_replace_editor',
|
||||
'MultiEdit',
|
||||
'multi_edit',
|
||||
'multiEdit',
|
||||
]);
|
||||
|
||||
const FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path'];
|
||||
|
||||
/** Best-effort detector that mirrors the buckets WorkBuddy uses internally. */
|
||||
const SNAPSHOT_EXTS = new Set([
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
|
||||
@@ -233,15 +134,6 @@ export function isPptxPreviewExt(ext: string | null | undefined): boolean {
|
||||
return PPTX_PREVIEW_EXTS.has(ext.toLowerCase());
|
||||
}
|
||||
|
||||
export function supportsInlineDiff(file: Pick<GeneratedFile, 'ext' | 'contentType'>): boolean {
|
||||
if (file.contentType === 'document') {
|
||||
if (supportsRichDocumentPreview(file.ext)) return false;
|
||||
return supportsInlineDocumentPreview(file.ext);
|
||||
}
|
||||
if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function basenameOf(path: string): string {
|
||||
if (!path) return '';
|
||||
const norm = path.replace(/\\/g, '/');
|
||||
@@ -255,248 +147,3 @@ export function extnameOf(path: string): string {
|
||||
if (dot <= 0) return '';
|
||||
return name.slice(dot);
|
||||
}
|
||||
|
||||
function asRecord(input: unknown): Record<string, unknown> | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickFilePath(input: unknown): string | null {
|
||||
const rec = asRecord(input);
|
||||
if (!rec) return null;
|
||||
for (const key of FILE_PATH_KEYS) {
|
||||
const value = rec[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickWriteContent(input: unknown): string | undefined {
|
||||
const rec = asRecord(input);
|
||||
if (!rec) return undefined;
|
||||
for (const key of [
|
||||
'content',
|
||||
'contents',
|
||||
'text',
|
||||
'body',
|
||||
'data',
|
||||
'new_content',
|
||||
'new_string',
|
||||
'newString',
|
||||
'string',
|
||||
'source',
|
||||
]) {
|
||||
const value = rec[key];
|
||||
if (typeof value === 'string') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Aliases mirror WorkBuddy's tool-arg normaliser so we accept whichever
|
||||
// naming convention the agent emits (Codex, Claude, Cursor, Cline …).
|
||||
const OLD_KEYS = [
|
||||
'old_string', 'oldString', 'old_str', 'oldStr',
|
||||
'old_text', 'oldText',
|
||||
'old', 'oldContent', 'before', 'find', 'search',
|
||||
];
|
||||
const NEW_KEYS = [
|
||||
'new_string', 'newString', 'new_str', 'newStr',
|
||||
'new_text', 'newText',
|
||||
'new', 'newContent', 'after', 'replace', 'replacement',
|
||||
];
|
||||
|
||||
function pickStringByKeys(rec: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const k of keys) {
|
||||
const v = rec[k];
|
||||
if (typeof v === 'string') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickEditOps(input: unknown): FileEditOp[] {
|
||||
const rec = asRecord(input);
|
||||
if (!rec) return [];
|
||||
const ops: FileEditOp[] = [];
|
||||
const singleOld = pickStringByKeys(rec, OLD_KEYS);
|
||||
const singleNew = pickStringByKeys(rec, NEW_KEYS);
|
||||
if (singleOld !== undefined || singleNew !== undefined) {
|
||||
ops.push({ old: singleOld ?? '', new: singleNew ?? '' });
|
||||
}
|
||||
const edits = rec.edits;
|
||||
if (Array.isArray(edits)) {
|
||||
for (const edit of edits as Array<Record<string, unknown>>) {
|
||||
const o = pickStringByKeys(edit, OLD_KEYS) ?? '';
|
||||
const n = pickStringByKeys(edit, NEW_KEYS) ?? '';
|
||||
if (o !== '' || n !== '') ops.push({ old: o, new: n });
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function determineWriteAction(
|
||||
existing: GeneratedFile | undefined,
|
||||
baseline: GeneratedFileBaseline | undefined,
|
||||
): 'created' | 'modified' {
|
||||
if (existing?.action === 'created') return 'created';
|
||||
if (!baseline) return existing ? 'modified' : 'created';
|
||||
return baseline.status === 'missing' ? 'created' : 'modified';
|
||||
}
|
||||
|
||||
function buildGeneratedFile(
|
||||
filePath: string,
|
||||
action: 'created' | 'modified',
|
||||
parts: { fullContent?: string; edits?: FileEditOp[]; baseline?: GeneratedFileBaseline } | undefined,
|
||||
index: number,
|
||||
): GeneratedFile {
|
||||
const fileName = basenameOf(filePath);
|
||||
const ext = extnameOf(filePath);
|
||||
return {
|
||||
filePath,
|
||||
fileName,
|
||||
ext,
|
||||
mimeType: getMimeTypeForExt(ext),
|
||||
contentType: classifyFileExt(ext),
|
||||
action,
|
||||
fullContent: parts?.fullContent,
|
||||
edits: parts?.edits,
|
||||
baseline: parts?.baseline,
|
||||
lastSeenIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseEol(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function joinEditText(edits: FileEditOp[], side: 'old' | 'new'): string {
|
||||
return edits.map((op) => normaliseEol(op[side] ?? '')).join(SNIPPET_SEPARATOR);
|
||||
}
|
||||
|
||||
function countLogicalLines(text: string): number {
|
||||
const normalized = normaliseEol(text);
|
||||
if (!normalized) return 0;
|
||||
const parts = normalized.split('\n');
|
||||
return normalized.endsWith('\n') ? Math.max(1, parts.length - 1) : parts.length;
|
||||
}
|
||||
|
||||
function diffLineStats(oldText: string, newText: string): FileLineStats {
|
||||
const pieces = diffLines(normaliseEol(oldText), normaliseEol(newText));
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const piece of pieces) {
|
||||
const count = typeof piece.count === 'number' ? piece.count : countLogicalLines(piece.value);
|
||||
if (piece.added) added += count;
|
||||
if (piece.removed) removed += count;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
export function computeLineStats(file: GeneratedFile): FileLineStats | null {
|
||||
if (!supportsInlineDiff(file)) return null;
|
||||
|
||||
if (file.edits?.length) {
|
||||
return diffLineStats(joinEditText(file.edits, 'old'), joinEditText(file.edits, 'new'));
|
||||
}
|
||||
|
||||
if (file.fullContent == null) return null;
|
||||
|
||||
if (file.baseline?.status === 'ok') {
|
||||
return diffLineStats(file.baseline.content, file.fullContent);
|
||||
}
|
||||
|
||||
if (file.baseline?.status === 'missing') {
|
||||
return { added: countLogicalLines(file.fullContent), removed: 0 };
|
||||
}
|
||||
|
||||
if (file.baseline?.status === 'unavailable') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file.action === 'created') {
|
||||
return { added: countLogicalLines(file.fullContent), removed: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the messages in `[triggerIndex, segmentEnd]` (inclusive) and
|
||||
* collect the unique files written or edited by tool calls in that
|
||||
* window. Deduplicates by `filePath`; if the file is touched by both
|
||||
* a `Write` and a later `Edit`, the action is upgraded to `'modified'`
|
||||
* but the diff content is kept from the last edit.
|
||||
*/
|
||||
export function extractGeneratedFiles(
|
||||
messages: RawMessage[],
|
||||
triggerIndex: number,
|
||||
segmentEnd: number,
|
||||
baselineGetter?: (filePath: string) => GeneratedFileBaseline | undefined,
|
||||
): GeneratedFile[] {
|
||||
const map = new Map<string, GeneratedFile>();
|
||||
const start = Math.max(0, Math.min(triggerIndex + 1, messages.length));
|
||||
const end = Math.max(start - 1, Math.min(segmentEnd, messages.length - 1));
|
||||
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
const message = messages[i];
|
||||
if (!message || message.role !== 'assistant') continue;
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type !== 'tool_use' && block.type !== 'toolCall') continue;
|
||||
const name = typeof block.name === 'string' ? block.name : '';
|
||||
if (!name) continue;
|
||||
const input = block.input ?? block.arguments;
|
||||
const filePath = pickFilePath(input);
|
||||
if (!filePath) continue;
|
||||
|
||||
const isWrite = WRITE_TOOLS.has(name);
|
||||
const isEdit = EDIT_TOOLS.has(name);
|
||||
const looksLikeFile = isWrite || isEdit || /\.[a-z0-9]{1,8}$/i.test(basenameOf(filePath));
|
||||
if (!isWrite && !isEdit && !looksLikeFile) continue;
|
||||
|
||||
const existing = map.get(filePath);
|
||||
|
||||
if (isWrite) {
|
||||
const newContent = pickWriteContent(input);
|
||||
const baseline = baselineGetter?.(filePath);
|
||||
const next = buildGeneratedFile(
|
||||
filePath,
|
||||
determineWriteAction(existing, baseline),
|
||||
{ fullContent: newContent, edits: undefined, baseline },
|
||||
i,
|
||||
);
|
||||
map.set(filePath, next);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const newOps = pickEditOps(input);
|
||||
const next = buildGeneratedFile(
|
||||
filePath,
|
||||
'modified',
|
||||
{
|
||||
fullContent: existing?.fullContent,
|
||||
edits: [...(existing?.edits ?? []), ...newOps],
|
||||
baseline: existing?.baseline,
|
||||
},
|
||||
i,
|
||||
);
|
||||
map.set(filePath, next);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown tool but its input mentions a file path with extension —
|
||||
// surface as best-effort "modified" without diff content.
|
||||
if (!existing) {
|
||||
map.set(filePath, buildGeneratedFile(filePath, 'modified', undefined, i));
|
||||
} else {
|
||||
existing.lastSeenIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.lastSeenIndex - b.lastSeenIndex);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
ChannelAccountsPayload,
|
||||
ChannelSaveConfigPayload,
|
||||
ChannelTargetsPayload,
|
||||
ChatSendWithMediaPayload,
|
||||
ClawHubSearchPayload,
|
||||
CronSessionHistoryPayload,
|
||||
DialogMessagePayload,
|
||||
@@ -65,7 +64,6 @@ export type {
|
||||
ChannelSaveConfigResult,
|
||||
ChannelTargetOption,
|
||||
ChannelTargetsResult,
|
||||
ChatSendWithMediaResult,
|
||||
ClawHubInstalledSkill,
|
||||
ClawHubListResult,
|
||||
ClawHubSearchResult,
|
||||
@@ -365,7 +363,6 @@ export const hostApi = {
|
||||
),
|
||||
},
|
||||
chat: {
|
||||
sendWithMedia: (input: ChatSendWithMediaPayload) => invokeHost('chat', 'sendWithMedia', input),
|
||||
loadAcpSession: (input: AcpChatLoadPayload) => invokeHost('chat', 'loadAcpSession', input),
|
||||
sendAcpPrompt: (input: AcpChatPromptPayload) => invokeHost('chat', 'sendAcpPrompt', input),
|
||||
cancelAcpSession: (input: AcpChatCancelPayload) => invokeHost('chat', 'cancelAcpSession', input),
|
||||
|
||||
@@ -8,6 +8,7 @@ import App from './App';
|
||||
import './i18n';
|
||||
import './styles/globals.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'streamdown/styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -118,12 +118,14 @@ function AcpTurnDuration({ timing }: { timing: AcpTurnTiming }) {
|
||||
|
||||
export function AcpAssistantTurn({
|
||||
group,
|
||||
streamingSegmentIds,
|
||||
fileSummaries = [],
|
||||
workspaceRoot,
|
||||
timing,
|
||||
onPermissionSelect,
|
||||
}: {
|
||||
group: AcpAssistantTurnDisplayGroup;
|
||||
streamingSegmentIds: ReadonlySet<string>;
|
||||
fileSummaries?: AcpTurnFileSummary[];
|
||||
workspaceRoot?: string;
|
||||
timing?: AcpTurnTiming;
|
||||
@@ -156,9 +158,18 @@ export function AcpAssistantTurn({
|
||||
if (item.kind === 'message-segment') {
|
||||
if (item.role === 'user') return <AcpMessageSegment key={item.id} item={item} />;
|
||||
return (
|
||||
<div key={item.id} data-acp-item-id={item.id} data-testid="acp-assistant-message" className="flex min-w-0 flex-col gap-2">
|
||||
<div key={item.id} data-acp-item-id={item.id} data-testid="acp-assistant-message" className="flex min-w-0 flex-col gap-2 w-[calc(100%-3rem)]">
|
||||
{item.parts.map((part, index) => (
|
||||
<AcpRenderPart key={`${part.kind}:${index}`} part={part} tone="assistant" />
|
||||
<AcpRenderPart
|
||||
key={`${part.kind}:${index}`}
|
||||
part={part}
|
||||
tone="assistant"
|
||||
{...(streamingSegmentIds.has(item.id)
|
||||
&& index === item.parts.length - 1
|
||||
&& part.kind === 'markdown'
|
||||
? { isAnimating: true }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, Check, Copy, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Streamdown, type Components } from 'streamdown';
|
||||
import { BrowserLink } from '@/components/common/BrowserLink';
|
||||
import {
|
||||
streamdownAnimation,
|
||||
streamdownControls,
|
||||
streamdownLinkSafety,
|
||||
streamdownPlugins,
|
||||
streamdownRehypePlugins,
|
||||
} from '@/components/markdown/streamdown-config';
|
||||
import type { MessageSegmentItem, RenderPart } from '@/lib/acp/timeline-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AcpImagePart, isSafeAcpImageSource } from './AcpImagePart';
|
||||
@@ -13,6 +17,46 @@ import { AcpAttachmentPart } from './AcpAttachmentPart';
|
||||
|
||||
type RenderTone = 'assistant' | 'user' | 'process';
|
||||
|
||||
const chatRemend = { linkMode: 'text-only' } as const;
|
||||
|
||||
function AcpMarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const imageSource = typeof src === 'string' ? src : '';
|
||||
if (!imageSource || !isSafeAcpImageSource(imageSource)) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={typeof alt === 'string' ? alt : t('acp.image')}
|
||||
className="max-w-full rounded-lg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const chatMarkdownComponents: Components = {
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold" data-streamdown="strong">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
a: ({ href, children }) => href ? (
|
||||
<BrowserLink href={href} className="break-all text-primary hover:underline">
|
||||
{children}
|
||||
</BrowserLink>
|
||||
) : <>{children}</>,
|
||||
img: ({ src, alt }) => (
|
||||
<AcpMarkdownImage
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={typeof alt === 'string' ? alt : undefined}
|
||||
/>
|
||||
),
|
||||
inlineCode: ({ children }) => (
|
||||
<code className="break-all font-mono text-sm">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
};
|
||||
|
||||
function normalizeLatexDelimiters(input: string): string {
|
||||
if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) return input;
|
||||
|
||||
@@ -27,63 +71,45 @@ function normalizeLatexDelimiters(input: string): string {
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function AcpMarkdownPart({ text }: { text: string }) {
|
||||
const { t } = useTranslation('chat');
|
||||
function AcpMarkdownPart({ text, isAnimating = false }: { text: string; isAnimating?: boolean }) {
|
||||
const { t } = useTranslation('common');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const translations = useMemo(() => ({
|
||||
copyCode: t('markdown.copyCode'),
|
||||
}), [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnimating) return;
|
||||
|
||||
for (const element of containerRef.current?.querySelectorAll<HTMLElement>('[data-sd-animate]') ?? []) {
|
||||
element.removeAttribute('data-sd-animate');
|
||||
element.style.removeProperty('--sd-animation');
|
||||
element.style.removeProperty('--sd-duration');
|
||||
element.style.removeProperty('--sd-easing');
|
||||
element.style.removeProperty('--sd-delay');
|
||||
if (!element.style.length) element.removeAttribute('style');
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none break-words text-foreground dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className && !String(children).includes('\n');
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="font-mono text-sm break-all" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={cn('font-mono text-sm', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children, ...props }) {
|
||||
return (
|
||||
<pre
|
||||
className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-black/5 p-4 dark:bg-white/10"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return href ? (
|
||||
<BrowserLink href={href} className="break-all text-primary hover:underline">
|
||||
{children}
|
||||
</BrowserLink>
|
||||
) : <>{children}</>;
|
||||
},
|
||||
img({ src, alt }) {
|
||||
const imageSource = typeof src === 'string' ? src : '';
|
||||
if (!imageSource || !isSafeAcpImageSource(imageSource)) return null;
|
||||
return (
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={typeof alt === 'string' ? alt : t('acp.image')}
|
||||
className="max-w-full rounded-lg"
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
<div ref={containerRef} className="contents">
|
||||
<Streamdown
|
||||
animated={isAnimating ? streamdownAnimation : false}
|
||||
className="clawx-markdown clawx-streamdown prose prose-sm max-w-none break-words text-foreground dark:prose-invert"
|
||||
components={chatMarkdownComponents}
|
||||
controls={streamdownControls}
|
||||
isAnimating={isAnimating}
|
||||
lineNumbers={false}
|
||||
linkSafety={streamdownLinkSafety}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown={isAnimating}
|
||||
plugins={streamdownPlugins}
|
||||
rehypePlugins={streamdownRehypePlugins}
|
||||
remend={isAnimating ? chatRemend : undefined}
|
||||
translations={translations}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +172,15 @@ export function AcpAssistantHoverBar({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AcpRenderPart({ part, tone = 'assistant' }: { part: RenderPart; tone?: RenderTone }) {
|
||||
export const AcpRenderPart = memo(function AcpRenderPart({
|
||||
part,
|
||||
tone = 'assistant',
|
||||
isAnimating = false,
|
||||
}: {
|
||||
part: RenderPart;
|
||||
tone?: RenderTone;
|
||||
isAnimating?: boolean;
|
||||
}) {
|
||||
if (part.kind === 'markdown') {
|
||||
if (tone === 'user') {
|
||||
return (
|
||||
@@ -155,15 +189,15 @@ export function AcpRenderPart({ part, tone = 'assistant' }: { part: RenderPart;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <AcpMarkdownPart text={part.text} />;
|
||||
return <AcpMarkdownPart text={part.text} isAnimating={isAnimating} />;
|
||||
}
|
||||
|
||||
if (part.kind === 'image') return <AcpImagePart part={part} />;
|
||||
if (part.kind === 'attachment') return <AcpAttachmentPart part={part} />;
|
||||
return <AcpErrorPart message={part.message} />;
|
||||
}
|
||||
});
|
||||
|
||||
export function AcpMessageSegment({ item }: { item: MessageSegmentItem }) {
|
||||
export const AcpMessageSegment = memo(function AcpMessageSegment({ item }: { item: MessageSegmentItem }) {
|
||||
const isUser = item.role === 'user';
|
||||
const clipboardText = useMemo(() => clipboardTextForParts(item.parts), [item.parts]);
|
||||
const orderedParts = useMemo(() => isUser
|
||||
@@ -193,4 +227,4 @@ export function AcpMessageSegment({ item }: { item: MessageSegmentItem }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,17 +8,9 @@ import type { AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activiti
|
||||
import { AcpAttachmentPart } from './AcpAttachmentPart';
|
||||
import type { AcpTurnTiming } from '@/lib/acp/turn-timings';
|
||||
|
||||
export function AcpTimeline({
|
||||
snapshot,
|
||||
error,
|
||||
errorKind = 'load',
|
||||
onDismissError,
|
||||
onPermissionSelect,
|
||||
fileActivity,
|
||||
workspaceRoot,
|
||||
turnTimingsByUserMessageId = {},
|
||||
}: {
|
||||
type AcpTimelineProps = {
|
||||
snapshot: AcpTimelineSnapshot;
|
||||
isStreaming?: boolean;
|
||||
error?: string | null;
|
||||
errorKind?: 'load' | 'prompt';
|
||||
onDismissError?: () => void;
|
||||
@@ -26,8 +18,39 @@ export function AcpTimeline({
|
||||
fileActivity?: AcpFileActivityProjection;
|
||||
workspaceRoot?: string;
|
||||
turnTimingsByUserMessageId?: Record<string, AcpTurnTiming>;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function streamingMessageSegmentIds(
|
||||
snapshot: AcpTimelineSnapshot,
|
||||
isStreaming: boolean,
|
||||
): Set<string> {
|
||||
if (!isStreaming) return new Set();
|
||||
|
||||
const terminalItemId = snapshot.itemOrder.at(-1);
|
||||
const terminalItem = terminalItemId ? snapshot.itemsById[terminalItemId] : undefined;
|
||||
if (
|
||||
terminalItem?.kind !== 'message-segment'
|
||||
|| terminalItem.role !== 'assistant'
|
||||
|| snapshot.openMessageSegments[terminalItem.messageId] !== terminalItem.id
|
||||
|| terminalItem.parts.at(-1)?.kind !== 'markdown'
|
||||
) return new Set();
|
||||
|
||||
return new Set([terminalItem.id]);
|
||||
}
|
||||
|
||||
export function AcpTimeline({
|
||||
snapshot,
|
||||
isStreaming = false,
|
||||
error,
|
||||
errorKind = 'load',
|
||||
onDismissError,
|
||||
onPermissionSelect,
|
||||
fileActivity,
|
||||
workspaceRoot,
|
||||
turnTimingsByUserMessageId = {},
|
||||
}: AcpTimelineProps) {
|
||||
const groups = groupAcpTimelineItems(snapshot);
|
||||
const streamingSegmentIds = streamingMessageSegmentIds(snapshot, isStreaming);
|
||||
|
||||
return (
|
||||
<div data-testid="acp-chat-timeline" className="flex flex-col gap-4">
|
||||
@@ -62,6 +85,7 @@ export function AcpTimeline({
|
||||
<div key={group.id} data-acp-group-id={group.id}>
|
||||
<AcpAssistantTurn
|
||||
group={group}
|
||||
streamingSegmentIds={streamingSegmentIds}
|
||||
fileSummaries={fileActivity?.turnSummariesByTurnId[group.id]}
|
||||
workspaceRoot={workspaceRoot}
|
||||
timing={group.userMessageId ? turnTimingsByUserMessageId[group.userMessageId] : undefined}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Textarea with send button and universal file upload support.
|
||||
* Enter to send, Shift+Enter for new line.
|
||||
* Supports: native file picker, clipboard paste, drag & drop.
|
||||
* Files are staged to disk via IPC — only lightweight path references
|
||||
* are sent with the message (no base64 over WebSocket).
|
||||
* Files are staged through the typed Host API and included as local media
|
||||
* references in the ACP session/prompt request.
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, FolderOpen, Loader2, AtSign, Search, ChevronDown, Check } from 'lucide-react';
|
||||
@@ -36,7 +36,7 @@ export interface FileAttachment {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string; // disk path for gateway
|
||||
stagedPath: string; // Host-staged path included in ACP prompt media
|
||||
preview: string | null; // data URL for images, null for others
|
||||
status: 'staging' | 'ready' | 'error';
|
||||
error?: string;
|
||||
|
||||
@@ -1,919 +0,0 @@
|
||||
/**
|
||||
* Chat Message Component
|
||||
* Renders user / assistant / system / toolresult messages
|
||||
* with markdown and images. Tool steps render in ExecutionGraphCard;
|
||||
* streaming runs may show a compact ToolStatusBar. Thinking output is
|
||||
* surfaced via ExecutionGraphCard, not inside message bubbles.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, memo } from 'react';
|
||||
import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { BrowserLink } from '@/components/common/BrowserLink';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statFile } from '@/lib/file-preview-client';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||
import { extractText, extractImages, extractToolUse, formatTimestamp, isUnresolvableImageUrl } from './message-utils';
|
||||
import { copyImageToClipboard, type ImageCopyTarget } from './copy-image';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: RawMessage;
|
||||
textOverride?: string;
|
||||
suppressToolCards?: boolean;
|
||||
suppressProcessAttachments?: boolean;
|
||||
/**
|
||||
* When true, hides the assistant text bubble (and any thinking block that
|
||||
* would be shown above it). Used when the message's text is being folded
|
||||
* into an ExecutionGraphCard as a narration step, to prevent the same text
|
||||
* from appearing both inside the graph and as an orphan bubble in the chat
|
||||
* stream.
|
||||
*/
|
||||
suppressAssistantText?: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamingTools?: Array<{
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
}>;
|
||||
/**
|
||||
* Optional callback invoked when a non-image file card is clicked.
|
||||
* When provided, the file opens in the in-app preview panel instead of
|
||||
* the system default editor.
|
||||
*/
|
||||
onOpenFile?: (file: AttachedFileMeta) => void;
|
||||
}
|
||||
|
||||
interface ExtractedImage { url?: string; data?: string; mimeType: string; }
|
||||
|
||||
const DIRECTORY_MIME_TYPE = 'application/x-directory';
|
||||
|
||||
function isChatPreviewDocument(file: AttachedFileMeta): boolean {
|
||||
const name = file.fileName.toLowerCase();
|
||||
const mime = file.mimeType.toLowerCase();
|
||||
return (
|
||||
mime === 'application/pdf'
|
||||
|| mime === 'application/vnd.ms-excel'
|
||||
|| mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| name.endsWith('.pdf')
|
||||
|| name.endsWith('.xls')
|
||||
|| name.endsWith('.xlsx')
|
||||
);
|
||||
}
|
||||
|
||||
function isDirectoryAttachment(file: AttachedFileMeta): boolean {
|
||||
return file.mimeType === DIRECTORY_MIME_TYPE;
|
||||
}
|
||||
|
||||
function isSkillFileAttachment(file: AttachedFileMeta): boolean {
|
||||
const path = file.filePath ?? '';
|
||||
return (
|
||||
/(?:^|[\\/])\.openclaw[\\/]skills[\\/][^\\/]+[\\/].+\.[A-Za-z0-9]+$/i.test(path)
|
||||
|| /(?:^|[\\/])skills[\\/][^\\/]+[\\/]SKILL\.md$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isHtmlOrMarkdownPreview(file: AttachedFileMeta): boolean {
|
||||
const name = file.fileName.toLowerCase();
|
||||
const mime = file.mimeType.toLowerCase();
|
||||
return (
|
||||
mime === 'text/html'
|
||||
|| mime === 'text/markdown'
|
||||
|| name.endsWith('.html')
|
||||
|| name.endsWith('.htm')
|
||||
|| name.endsWith('.md')
|
||||
|| name.endsWith('.markdown')
|
||||
);
|
||||
}
|
||||
|
||||
/** User-facing artifacts that must stay visible when process output is folded into the graph. */
|
||||
function isUserFacingAttachmentWhenFolded(file: AttachedFileMeta): boolean {
|
||||
if (file.mimeType.startsWith('image/')) return true;
|
||||
if (isDirectoryAttachment(file)) return true;
|
||||
if (isSkillFileAttachment(file)) return true;
|
||||
if (isChatPreviewDocument(file)) return true;
|
||||
// Paths parsed from the assistant reply (e.g. "/workspace/demo.html") are
|
||||
// intentional user-facing links. Generic tool-result markdown attachments
|
||||
// (e.g. CHECKLIST.md emitted mid-run) stay folded into the execution graph.
|
||||
if (file.source === 'message-ref' && isHtmlOrMarkdownPreview(file)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null {
|
||||
if (!file.filePath) return null;
|
||||
// User-selected uploads and already enriched attachments are trusted enough
|
||||
// for immediate display. Regex-derived message refs start at size 0/null and
|
||||
// are validated through main-process stat before becoming clickable cards.
|
||||
if (file.source !== 'message-ref' && file.source !== 'tool-result') return null;
|
||||
if (file.fileSize > 0 || file.preview) return null;
|
||||
return isDirectoryAttachment(file) ? 'dir' : 'file';
|
||||
}
|
||||
|
||||
function previewMimeFromPath(filePath: string): string | null {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
|
||||
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
|
||||
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).pop() || 'file';
|
||||
}
|
||||
|
||||
function trimPathTerminators(filePath: string): string {
|
||||
return filePath.replace(/[,。;;,.!?]+$/u, '');
|
||||
}
|
||||
|
||||
function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] {
|
||||
if (!text) return [];
|
||||
const refs: AttachedFileMeta[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushRef = (filePath: string, mimeType: string) => {
|
||||
const normalizedPath = trimPathTerminators(filePath);
|
||||
if (!normalizedPath || seen.has(normalizedPath)) return;
|
||||
seen.add(normalizedPath);
|
||||
refs.push({
|
||||
fileName: fileNameFromPath(normalizedPath),
|
||||
mimeType,
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath: normalizedPath,
|
||||
source: 'message-ref',
|
||||
});
|
||||
};
|
||||
// Deliberately narrow this render-layer fallback to user-facing artifacts:
|
||||
// HTML / Markdown / PDF / spreadsheet previews and OpenClaw skill directories.
|
||||
// The store-level extractor still handles broad file categories; this keeps
|
||||
// visible outputs clickable even before history enrichment runs.
|
||||
const exts = 'html?|md|markdown|pdf|xlsx?|HTML?|MD|MARKDOWN|PDF|XLSX?';
|
||||
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g');
|
||||
const unixRegex = new RegExp('(?<![\\w./:])((?:\\/|~\\/)[^\\s\\n"\'`()\\[\\],<>]*?\\.(?:' + exts + '))', 'g');
|
||||
const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])';
|
||||
const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+';
|
||||
const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?';
|
||||
const skillDirRegex = new RegExp(
|
||||
`(?<![\\w./:])((?:~[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathPart})|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathPart}))${skillPathBoundary}`,
|
||||
'gi',
|
||||
);
|
||||
const skillMarkdownRegex = new RegExp(
|
||||
`(?<![\\w./:])((?:~[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathTail}\\.md)|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathTail}\\.md))${skillPathBoundary}`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
let workingText = text;
|
||||
let taggedMatch: RegExpExecArray | null;
|
||||
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
|
||||
const filePath = taggedMatch[1];
|
||||
const mimeType = previewMimeFromPath(filePath);
|
||||
if (mimeType) pushRef(filePath, mimeType);
|
||||
const start = taggedMatch.index;
|
||||
const end = start + taggedMatch[0].length;
|
||||
workingText = workingText.slice(0, start) + ' '.repeat(end - start) + workingText.slice(end);
|
||||
}
|
||||
|
||||
for (const regex of [unixRegex, skillMarkdownRegex, skillDirRegex]) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(workingText)) !== null) {
|
||||
const filePath = match[1];
|
||||
const mimeType = regex === skillDirRegex ? DIRECTORY_MIME_TYPE : previewMimeFromPath(filePath);
|
||||
if (mimeType) pushRef(filePath, mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize LaTeX delimiters so `remark-math` can detect them.
|
||||
*
|
||||
* Many LLMs emit LaTeX using `\(` / `\)` for inline math and `\[` / `\]`
|
||||
* for block math (OpenAI style), which are NOT recognized by remark-math.
|
||||
* remark-math only parses `$...$` and `$$...$$`.
|
||||
*
|
||||
* We convert the backslash-paren/bracket forms to dollar-sign forms so the
|
||||
* math is rendered regardless of which convention the model uses.
|
||||
*
|
||||
* Transformations are skipped inside fenced/inline code spans to avoid
|
||||
* clobbering code samples that legitimately contain `\(` etc.
|
||||
*/
|
||||
function normalizeLatexDelimiters(input: string): string {
|
||||
if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const parts = input.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
if (part.startsWith('```') || part.startsWith('`')) continue;
|
||||
let next = part.replace(/\\\[([\s\S]+?)\\\]/g, (_m, body: string) => `\n$$\n${body.trim()}\n$$\n`);
|
||||
next = next.replace(/\\\(([\s\S]+?)\\\)/g, (_m, body: string) => `$${body}$`);
|
||||
parts[i] = next;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** Resolve an ExtractedImage to a displayable src string, or null if not possible. */
|
||||
function imageSrc(img: ExtractedImage): string | null {
|
||||
if (img.url) return isUnresolvableImageUrl(img.url) ? null : img.url;
|
||||
if (img.data) return `data:${img.mimeType};base64,${img.data}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
textOverride,
|
||||
suppressToolCards = false,
|
||||
suppressProcessAttachments = false,
|
||||
suppressAssistantText = false,
|
||||
isStreaming = false,
|
||||
streamingTools = [],
|
||||
onOpenFile,
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
|
||||
const isToolResult = role === 'toolresult' || role === 'tool_result';
|
||||
const text = textOverride ?? extractText(message);
|
||||
// When text is folded into an ExecutionGraphCard, treat the message as
|
||||
// having no text for rendering purposes. Keeping this behind a flag (vs
|
||||
// blanking `text` outright) lets future hover affordances still read the
|
||||
// original content without surfacing the bubble.
|
||||
const hideAssistantText = suppressAssistantText && !isUser;
|
||||
const hasText = !hideAssistantText && text.trim().length > 0;
|
||||
const images = extractImages(message);
|
||||
const resolvableContentImages = images.filter((img) => imageSrc(img) != null);
|
||||
const tools = extractToolUse(message);
|
||||
const visibleTools = suppressToolCards ? [] : tools;
|
||||
const [validatedPaths, setValidatedPaths] = useState<Record<string, boolean>>({});
|
||||
const rawAttachedFiles = message._attachedFiles || [];
|
||||
const textPreviewFiles = isUser ? [] : extractPreviewDocumentPaths(text);
|
||||
const rawAttachedPaths = new Set(rawAttachedFiles.map((file) => file.filePath).filter(Boolean));
|
||||
const derivedAttachedFiles = [
|
||||
...rawAttachedFiles,
|
||||
...textPreviewFiles.filter((file) => !file.filePath || !rawAttachedPaths.has(file.filePath)),
|
||||
];
|
||||
const validationTargets = derivedAttachedFiles
|
||||
.map((file) => {
|
||||
const kind = validationKindForAttachment(file);
|
||||
return kind && file.filePath ? { filePath: file.filePath, kind } : null;
|
||||
})
|
||||
.filter((target): target is { filePath: string; kind: 'file' | 'dir' } => !!target);
|
||||
const validationKey = validationTargets
|
||||
.map((target) => `${target.kind}:${target.filePath}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
useEffect(() => {
|
||||
if (!validationKey) return;
|
||||
const pendingTargets = validationTargets.filter((target) => validatedPaths[target.filePath] === undefined);
|
||||
if (pendingTargets.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
pendingTargets.map(async (target) => {
|
||||
try {
|
||||
const stat = await statFile(target.filePath);
|
||||
return {
|
||||
filePath: target.filePath,
|
||||
exists: !!stat.ok && (target.kind === 'dir' ? !!stat.isDir : !!stat.isFile),
|
||||
};
|
||||
} catch {
|
||||
return { filePath: target.filePath, exists: false };
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
setValidatedPaths((current) => {
|
||||
const next = { ...current };
|
||||
for (const result of results) next[result.filePath] = result.exists;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [validationKey, validationTargets, validatedPaths]);
|
||||
const existingDerivedAttachedFiles = derivedAttachedFiles.filter((file) => {
|
||||
const kind = validationKindForAttachment(file);
|
||||
if (!kind || !file.filePath) return true;
|
||||
return validatedPaths[file.filePath] === true;
|
||||
});
|
||||
const filteredProcessAttachments = derivedAttachedFiles.filter((file) => {
|
||||
if (file.source !== 'tool-result' && file.source !== 'message-ref') return true;
|
||||
// Runtime-produced user-facing artifacts (images, HTML/Markdown/PDF/XLSX,
|
||||
// skill directories, ...) must remain visible in the reply bubble even
|
||||
// when generic process attachments are folded into the execution graph.
|
||||
// The graph card itself does not render `_attachedFiles`, so dropping
|
||||
// them here would leave the user with no way to open previews from chat.
|
||||
return isUserFacingAttachmentWhenFolded(file);
|
||||
});
|
||||
// When a message is attachment-only, keep those attachments visible even if
|
||||
// process attachments are generally suppressed for this run segment —
|
||||
// otherwise the reply disappears entirely.
|
||||
const processVisibleAttachments = filteredProcessAttachments.filter((file) => {
|
||||
const kind = validationKindForAttachment(file);
|
||||
if (!kind || !file.filePath) return true;
|
||||
return validatedPaths[file.filePath] === true;
|
||||
});
|
||||
const attachedFiles = suppressProcessAttachments && (hasText || resolvableContentImages.length > 0 || visibleTools.length > 0)
|
||||
? processVisibleAttachments
|
||||
: existingDerivedAttachedFiles;
|
||||
const imageCopyTarget = resolvePrimaryImageCopyTarget(resolvableContentImages, attachedFiles);
|
||||
const showAssistantHoverBar = !isUser && (hasText || imageCopyTarget != null);
|
||||
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
|
||||
|
||||
// Never render tool result messages in chat UI
|
||||
if (isToolResult) return null;
|
||||
|
||||
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
|
||||
if (!hasText && resolvableContentImages.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 group',
|
||||
isUser ? 'flex-row-reverse' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
{/* Avatar — vertical center aligned with the first line of the reply.
|
||||
The outer slot is sized to one prose-sm line (h-6 = 24px) so its
|
||||
midpoint coincides with the first text line's midpoint; the 32px
|
||||
avatar inside is centered within that slot and intentionally
|
||||
overflows ±4px above/below the line, which mirrors how chat avatars
|
||||
sit alongside a single line of text. */}
|
||||
{!isUser && (
|
||||
<div className="flex h-6 shrink-0 items-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/5 dark:bg-white/5 text-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full min-w-0 max-w-[80%] space-y-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{isStreaming && !isUser && streamingTools.length > 0 && (
|
||||
<ToolStatusBar tools={streamingTools} />
|
||||
)}
|
||||
|
||||
{/* Images — rendered ABOVE text bubble for user messages */}
|
||||
{/* Images from content blocks (Gateway session data / channel push photos) */}
|
||||
{isUser && resolvableContentImages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolvableContentImages.map((img, i) => {
|
||||
const src = imageSrc(img);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<ImageThumbnail
|
||||
key={`content-${i}`}
|
||||
src={src}
|
||||
fileName="image"
|
||||
base64={img.data}
|
||||
mimeType={img.mimeType}
|
||||
onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File attachments — images above text for user, file cards below */}
|
||||
{isUser && attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, i) => {
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
// Skip image attachments if we already have images from content blocks
|
||||
if (isImage && resolvableContentImages.length > 0) return null;
|
||||
if (isImage) {
|
||||
return file.preview ? (
|
||||
<ImageThumbnail
|
||||
key={`local-${i}`}
|
||||
src={file.preview}
|
||||
fileName={file.fileName}
|
||||
filePath={file.filePath}
|
||||
mimeType={file.mimeType}
|
||||
onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
|
||||
/>
|
||||
) : (
|
||||
<ImagePreviewPlaceholder key={`local-${i}`} file={file} />
|
||||
);
|
||||
}
|
||||
// Non-image files → file card
|
||||
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main text */}
|
||||
{hasText && (
|
||||
isUser ? (
|
||||
<UserMessageBubble text={text} />
|
||||
) : (
|
||||
<AssistantMarkdown text={text} isStreaming={isStreaming} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Images from content blocks — assistant messages (below text) */}
|
||||
{!isUser && resolvableContentImages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolvableContentImages.map((img, i) => {
|
||||
const src = imageSrc(img);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<ImagePreviewCard
|
||||
key={`content-${i}`}
|
||||
src={src}
|
||||
fileName="image"
|
||||
base64={img.data}
|
||||
mimeType={img.mimeType}
|
||||
onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File attachments — assistant messages (below text) */}
|
||||
{!isUser && attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, i) => {
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
if (isImage && resolvableContentImages.length > 0) return null;
|
||||
if (isImage && file.preview) {
|
||||
return (
|
||||
<ImagePreviewCard
|
||||
key={`local-${i}`}
|
||||
src={file.preview}
|
||||
fileName={file.fileName}
|
||||
filePath={file.filePath}
|
||||
mimeType={file.mimeType}
|
||||
onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isImage && !file.preview) {
|
||||
return <ImagePreviewPlaceholder key={`local-${i}`} file={file} />;
|
||||
}
|
||||
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover row for user messages — timestamp only */}
|
||||
{isUser && message.timestamp && (
|
||||
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Hover row for assistant messages */}
|
||||
{showAssistantHoverBar && (
|
||||
<AssistantHoverBar text={text} timestamp={message.timestamp} imageCopyTarget={imageCopyTarget} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image lightbox portal */}
|
||||
{lightboxImg && (
|
||||
<ImageLightbox
|
||||
src={lightboxImg.src}
|
||||
fileName={lightboxImg.fileName}
|
||||
filePath={lightboxImg.filePath}
|
||||
base64={lightboxImg.base64}
|
||||
mimeType={lightboxImg.mimeType}
|
||||
onClose={() => setLightboxImg(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function formatDuration(durationMs?: number): string | null {
|
||||
if (!durationMs || !Number.isFinite(durationMs)) return null;
|
||||
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function ToolStatusBar({
|
||||
tools,
|
||||
}: {
|
||||
tools: Array<{
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full space-y-1" data-testid="chat-streaming-tool-status-bar">
|
||||
{tools.map((tool) => {
|
||||
const duration = formatDuration(tool.durationMs);
|
||||
const isRunning = tool.status === 'running';
|
||||
const isError = tool.status === 'error';
|
||||
return (
|
||||
<div
|
||||
key={tool.toolCallId || tool.id || tool.name}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
|
||||
isRunning && 'border-primary/30 bg-primary/5 text-foreground',
|
||||
!isRunning && !isError && 'border-border/50 bg-surface-input/20 text-muted-foreground',
|
||||
isError && 'border-destructive/30 bg-destructive/5 text-destructive',
|
||||
)}
|
||||
>
|
||||
{isRunning && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />}
|
||||
{!isRunning && !isError && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
|
||||
{isError && <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
|
||||
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span className="font-mono text-xs font-medium">{tool.name}</span>
|
||||
{duration && <span className="text-tiny opacity-60">{tool.summary ? `(${duration})` : duration}</span>}
|
||||
{tool.summary && (
|
||||
<span className="truncate text-tiny opacity-70">{tool.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePrimaryImageCopyTarget(
|
||||
images: Array<{ mimeType: string; data?: string; url?: string }>,
|
||||
attachedFiles: AttachedFileMeta[],
|
||||
): ImageCopyTarget | null {
|
||||
for (const file of attachedFiles) {
|
||||
if (!file.mimeType.startsWith('image/')) continue;
|
||||
if (file.filePath || file.preview?.startsWith('data:')) {
|
||||
return {
|
||||
filePath: file.filePath,
|
||||
preview: file.preview,
|
||||
mimeType: file.mimeType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
if (image.data) {
|
||||
return { base64: image.data, mimeType: image.mimeType };
|
||||
}
|
||||
if (image.url?.startsWith('data:')) {
|
||||
return { preview: image.url, mimeType: image.mimeType };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Assistant hover bar (timestamp + copy, shown on group hover) ─
|
||||
|
||||
function AssistantHoverBar({
|
||||
text,
|
||||
timestamp,
|
||||
imageCopyTarget,
|
||||
}: {
|
||||
text: string;
|
||||
timestamp?: number;
|
||||
imageCopyTarget?: ImageCopyTarget | null;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyContent = useCallback(async () => {
|
||||
if (imageCopyTarget) {
|
||||
const copiedImage = await copyImageToClipboard(imageCopyTarget);
|
||||
if (copiedImage) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!text.trim()) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [text, imageCopyTarget]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{timestamp ? formatTimestamp(timestamp) : ''}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={copyContent}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── User Message Bubble ─────────────────────────────────────────
|
||||
|
||||
function UserMessageBubble({
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-2xl px-4 py-3 bg-brand text-white shadow-sm">
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Assistant Markdown ──────────────────────────────────────────
|
||||
|
||||
function AssistantMarkdown({
|
||||
text,
|
||||
isStreaming,
|
||||
}: {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert w-full max-w-none break-words text-foreground">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className && !String(children).includes('\n');
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="text-sm font-mono break-words break-all" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={cn('text-sm font-mono', className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children, ...props }) {
|
||||
return (
|
||||
<pre
|
||||
className="bg-black/5 dark:bg-white/5 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap break-words"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
return href ? (
|
||||
<BrowserLink href={href} className="text-primary hover:underline break-words break-all">
|
||||
{children}
|
||||
</BrowserLink>
|
||||
) : <>{children}</>;
|
||||
},
|
||||
img({ src, alt }) {
|
||||
if (!src || isUnresolvableImageUrl(String(src))) return null;
|
||||
return (
|
||||
<img src={String(src)} alt={typeof alt === 'string' ? alt : 'image'} className="max-w-full rounded-lg" />
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{normalizeLatexDelimiters(text)}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File Card (for user-uploaded non-image files) ───────────────
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function FileIcon({ mimeType, className }: { mimeType: string; className?: string }) {
|
||||
if (mimeType === DIRECTORY_MIME_TYPE) return <FolderOpen className={className} />;
|
||||
if (mimeType.startsWith('video/')) return <Film className={className} />;
|
||||
if (mimeType.startsWith('audio/')) return <Music className={className} />;
|
||||
if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') return <FileText className={className} />;
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return <FileArchive className={className} />;
|
||||
if (mimeType === 'application/pdf') return <FileText className={className} />;
|
||||
return <File className={className} />;
|
||||
}
|
||||
|
||||
function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) {
|
||||
const handleOpen = useCallback(() => {
|
||||
if (!file.filePath) return;
|
||||
if (onOpen) {
|
||||
onOpen(file);
|
||||
} else {
|
||||
void hostApi.shell.openPath(file.filePath);
|
||||
}
|
||||
}, [file, onOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-black/10 dark:border-white/10 px-3 py-2.5 bg-black/5 dark:bg-white/5 max-w-[220px]",
|
||||
file.filePath && "cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
title={file.filePath ? "Open file" : undefined}
|
||||
>
|
||||
<FileIcon mimeType={file.mimeType} className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-xs font-medium truncate">{file.fileName}</p>
|
||||
<p className="text-2xs text-muted-foreground">
|
||||
{file.mimeType === DIRECTORY_MIME_TYPE ? '文件夹' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePreviewPlaceholder({ file }: { file: AttachedFileMeta }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const unavailable = file.previewStatus === 'unavailable';
|
||||
const label = unavailable
|
||||
? t('imageGeneration.previewUnavailable')
|
||||
: t('imageGeneration.previewLoading');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-36 w-36 flex-col items-center justify-center gap-2 rounded-xl border border-black/10 bg-black/5 px-3 text-center text-muted-foreground dark:border-white/10 dark:bg-white/5',
|
||||
unavailable && 'border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400',
|
||||
)}
|
||||
data-testid={unavailable ? 'image-preview-unavailable' : 'image-preview-loading'}
|
||||
title={file.fileName}
|
||||
>
|
||||
{unavailable ? (
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-primary" />
|
||||
)}
|
||||
<span className="text-xs leading-4">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image Thumbnail (user bubble — square crop with zoom hint) ──
|
||||
|
||||
function ImageThumbnail({
|
||||
src,
|
||||
fileName,
|
||||
filePath,
|
||||
base64,
|
||||
mimeType,
|
||||
onPreview,
|
||||
}: {
|
||||
src: string;
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
base64?: string;
|
||||
mimeType?: string;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
void filePath; void base64; void mimeType;
|
||||
return (
|
||||
<div
|
||||
className="relative w-36 h-36 rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<img src={src} alt={fileName} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/25 transition-colors flex items-center justify-center">
|
||||
<ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image Preview Card (assistant bubble — natural size with overlay actions) ──
|
||||
|
||||
function ImagePreviewCard({
|
||||
src,
|
||||
fileName,
|
||||
filePath,
|
||||
base64,
|
||||
mimeType,
|
||||
onPreview,
|
||||
}: {
|
||||
src: string;
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
base64?: string;
|
||||
mimeType?: string;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
void filePath; void base64; void mimeType;
|
||||
return (
|
||||
<div
|
||||
className="relative max-w-xs rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<img src={src} alt={fileName} className="block w-full" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image Lightbox ───────────────────────────────────────────────
|
||||
|
||||
function ImageLightbox({
|
||||
src,
|
||||
fileName,
|
||||
filePath,
|
||||
base64,
|
||||
mimeType,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
base64?: string;
|
||||
mimeType?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
void src; void base64; void mimeType; void fileName;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleShowInFolder = useCallback(() => {
|
||||
if (filePath) {
|
||||
void hostApi.shell.showItemInFolder(filePath);
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Image + buttons stacked */}
|
||||
<div
|
||||
className="flex flex-col items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
className="max-w-[90vw] max-h-[85vh] rounded-lg shadow-2xl object-contain"
|
||||
/>
|
||||
|
||||
{/* Action buttons below image */}
|
||||
<div className="flex items-center gap-2">
|
||||
{filePath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
|
||||
onClick={handleShowInFolder}
|
||||
title="在文件夹中显示"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WORKSPACE_BROWSER_ENABLED } from '@/components/file-preview/workspace-browser-config';
|
||||
|
||||
type ChatToolbarProps = {
|
||||
export type ChatToolbarProps = {
|
||||
questionDirectoryOpen?: boolean;
|
||||
questionDirectoryCount?: number;
|
||||
onToggleQuestionDirectory?: () => void;
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
// Legacy Gateway Chat visualization retained only for tests or non-ACP migration cleanup.
|
||||
// ACP Chat renders ordered inline timeline blocks instead.
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, GitBranch, Link, MessageSquare, Wrench, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BrowserLink } from '@/components/common/BrowserLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskStep } from './task-visualization';
|
||||
|
||||
interface ExecutionGraphCardProps {
|
||||
agentLabel: string;
|
||||
steps: TaskStep[];
|
||||
active: boolean;
|
||||
/** Hide the trailing "Thinking ..." indicator even when active. */
|
||||
suppressThinking?: boolean;
|
||||
/**
|
||||
* When provided, the card becomes fully controlled: the parent owns the
|
||||
* expand state (e.g. to persist across remounts) and toggling goes through
|
||||
* `onExpandedChange`. When omitted, the card manages its own local state.
|
||||
*/
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
const TOOL_ROW_EXTRA_INDENT_PX = 8;
|
||||
|
||||
function AnimatedDots({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5 leading-none text-muted-foreground', className)} aria-hidden="true">
|
||||
<span className="inline-block animate-bounce [animation-delay:0ms]">.</span>
|
||||
<span className="inline-block animate-bounce [animation-delay:150ms]">.</span>
|
||||
<span className="inline-block animate-bounce [animation-delay:300ms]">.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphStatusIcon({ status }: { status: TaskStep['status'] }) {
|
||||
if (status === 'completed') return <CheckCircle2 className="h-4 w-4" />;
|
||||
if (status === 'error') return <XCircle className="h-4 w-4" />;
|
||||
return <CircleDashed className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function StepDetailCard({ step }: { step: TaskStep }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDetail = !!step.detail;
|
||||
// Narration steps (intermediate pure-text assistant messages folded from
|
||||
// the chat stream) are rendered without a label/status pill: the message
|
||||
// text IS the primary content.
|
||||
const isNarration = step.kind === 'message';
|
||||
const isTool = step.kind === 'tool';
|
||||
const isThinking = step.kind === 'thinking';
|
||||
const displayToolLabel = isTool && step.label === 'image_generate'
|
||||
? t('executionGraph.imageGenerateLabel')
|
||||
: step.label;
|
||||
// System steps (subagent branch roots etc.) share the tool row layout:
|
||||
// bold label + truncated single-line detail preview + click-to-expand,
|
||||
// i.e. no rounded card / no separate detail line below the title.
|
||||
const isSystem = step.kind === 'system';
|
||||
const isFlatRow = isTool || isSystem;
|
||||
const showRunningDots = (isTool || isThinking) && step.status === 'running';
|
||||
const hideStatusText = (isTool || isSystem) && step.status === 'completed';
|
||||
const detailPreview = step.detail?.replace(/\s+/g, ' ').trim();
|
||||
const canExpand = hasDetail;
|
||||
const displayLabel = isThinking ? t('executionGraph.thinkingLabel') : (isTool ? displayToolLabel : step.label);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 text-muted-foreground',
|
||||
isFlatRow || isNarration || isThinking
|
||||
? 'px-0 py-0'
|
||||
: 'rounded-xl border border-black/10 bg-white/40 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full gap-2 text-left',
|
||||
isFlatRow ? 'items-center' : 'items-start',
|
||||
canExpand ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!canExpand) return;
|
||||
setExpanded((value) => !value);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{(!isNarration && !isThinking || expanded) && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-sm font-medium text-muted-foreground">{displayLabel}</p>
|
||||
{isTool && step.label === 'web_fetch' && step.url && (
|
||||
<BrowserLink
|
||||
href={step.url}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title={step.url}
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
</BrowserLink>
|
||||
)}
|
||||
{isFlatRow && detailPreview && !expanded && (
|
||||
<p className="min-w-0 truncate text-xs leading-4 text-muted-foreground/80">
|
||||
{detailPreview}
|
||||
</p>
|
||||
)}
|
||||
{!hideStatusText && !showRunningDots && (
|
||||
<span className="shrink-0 whitespace-nowrap rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
|
||||
{t(`taskPanel.stepStatus.${step.status}`)}
|
||||
</span>
|
||||
)}
|
||||
{showRunningDots && (
|
||||
<AnimatedDots className="text-sm" />
|
||||
)}
|
||||
{step.depth > 1 && (
|
||||
<span className="shrink-0 whitespace-nowrap rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
|
||||
{t('executionGraph.branchLabel')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{step.detail && !expanded && !isFlatRow && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
isThinking
|
||||
? 'mt-0.5 text-meta leading-5 line-clamp-2'
|
||||
: 'text-meta leading-6 text-muted-foreground line-clamp-2',
|
||||
)}
|
||||
>
|
||||
{step.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canExpand && (
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{step.detail && expanded && canExpand && isFlatRow && (() => {
|
||||
// Tool inputs are typically JSON; system payloads (e.g. subagent
|
||||
// session keys) are usually plain strings. Pretty-print if the
|
||||
// detail parses as JSON, otherwise fall back to the raw text so
|
||||
// session keys render unchanged.
|
||||
let formatted = step.detail;
|
||||
try {
|
||||
formatted = JSON.stringify(JSON.parse(step.detail), null, 2);
|
||||
} catch { /* not valid JSON */ }
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{formatted}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{step.detail && expanded && canExpand && (isNarration || isThinking) && (
|
||||
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<pre
|
||||
className="whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{step.detail}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionGraphCard({
|
||||
agentLabel,
|
||||
steps,
|
||||
active,
|
||||
suppressThinking = false,
|
||||
expanded: controlledExpanded,
|
||||
onExpandedChange,
|
||||
}: ExecutionGraphCardProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// Active runs should stay expanded by default so the user can follow the
|
||||
// execution live. Once the run completes, the default state returns to
|
||||
// collapsed. Explicit user toggles remain controlled by the parent override.
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(active);
|
||||
const [prevActive, setPrevActive] = useState(active);
|
||||
if (prevActive !== active) {
|
||||
setPrevActive(active);
|
||||
if (controlledExpanded == null && uncontrolledExpanded !== active) {
|
||||
setUncontrolledExpanded(active);
|
||||
}
|
||||
}
|
||||
|
||||
const isControlled = controlledExpanded != null;
|
||||
const expanded = isControlled ? controlledExpanded : uncontrolledExpanded;
|
||||
const setExpanded = (next: boolean) => {
|
||||
if (!isControlled) setUncontrolledExpanded(next);
|
||||
onExpandedChange?.(next);
|
||||
};
|
||||
|
||||
const toolCount = steps.filter((step) => step.kind === 'tool').length;
|
||||
const processCount = steps.length - toolCount;
|
||||
const shouldShowTrailingThinking = active && !suppressThinking;
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-execution-graph"
|
||||
data-collapsed="true"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
<span className="truncate">
|
||||
{t('executionGraph.collapsedSummary', { toolCount, processCount })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-execution-graph"
|
||||
data-collapsed="false"
|
||||
className="w-full px-0 py-0 text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-execution-graph-collapse"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
|
||||
aria-label={t('executionGraph.collapseAction')}
|
||||
title={t('executionGraph.collapseAction')}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 rotate-90" />
|
||||
<span className="truncate">{t('executionGraph.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-0 px-0 py-0">
|
||||
<div className="mt-0.5 flex items-center gap-0.5" style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}>
|
||||
<div className="flex w-6 shrink-0 justify-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium text-muted-foreground">
|
||||
{t('executionGraph.agentRun', { agent: agentLabel })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{steps.map((step) => {
|
||||
const alignedIndentOffset = (
|
||||
step.kind === 'tool'
|
||||
|| step.kind === 'message'
|
||||
|| step.kind === 'thinking'
|
||||
) ? TOOL_ROW_EXTRA_INDENT_PX : 0;
|
||||
const rowMarginLeft = (Math.max(step.depth - 1, 0) * 24) + alignedIndentOffset;
|
||||
return (
|
||||
<div key={step.id} className="mt-0.5">
|
||||
<div
|
||||
className="pl-3"
|
||||
style={{ marginLeft: `${rowMarginLeft}px` }}
|
||||
>
|
||||
<div className="ml-3 h-1 w-px bg-border" />
|
||||
</div>
|
||||
<div
|
||||
className="flex items-start gap-0.5"
|
||||
data-testid="chat-execution-step"
|
||||
style={{ marginLeft: `${rowMarginLeft}px` }}
|
||||
>
|
||||
<div className="flex w-6 shrink-0 justify-center">
|
||||
<div className="relative flex items-center justify-center">
|
||||
{step.depth > 1 && (
|
||||
<div className="absolute -left-3 top-1/2 h-px w-3 -translate-y-1/2 bg-border" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{step.kind === 'thinking'
|
||||
? <MessageSquare className="h-3.5 w-3.5" />
|
||||
: step.kind === 'tool'
|
||||
? <Wrench className="h-3.5 w-3.5" />
|
||||
: step.kind === 'message'
|
||||
? <MessageSquare className="h-3.5 w-3.5" />
|
||||
: <GraphStatusIcon status={step.status} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StepDetailCard step={step} />
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
{shouldShowTrailingThinking && (
|
||||
<div className="mt-0.5">
|
||||
<div className="pl-3" style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}>
|
||||
<div className="ml-3 h-1 w-px bg-border" />
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
data-testid="chat-execution-step-thinking-trailing"
|
||||
style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}
|
||||
>
|
||||
<div className="w-6 shrink-0" />
|
||||
<div className="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||
<span className="font-medium">{t('executionGraph.thinkingLabel')}</span>
|
||||
<AnimatedDots className="ml-1 inline-flex text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { RawMessage, ToolStatus } from '@/stores/chat';
|
||||
import {
|
||||
extractImages,
|
||||
extractToolUse,
|
||||
isGeneratingStatusNarration,
|
||||
isInternalAssistantReplyText,
|
||||
} from './message-utils';
|
||||
|
||||
const IMAGE_GENERATE_TOOL = 'image_generate';
|
||||
const ASYNC_IMAGE_TASK_START_RE = /Background task started for image generation \(([0-9a-f-]{36})\)/i;
|
||||
const INTER_SESSION_IMAGE_TASK_RE = /sourceSession=image_generate:([0-9a-f-]{36})/i;
|
||||
/** Match OpenClaw agents.defaults.imageGenerationModel.timeoutMs default. */
|
||||
export const IMAGE_GENERATION_TIMEOUT_MS = 180_000;
|
||||
const IMAGE_GENERATION_TIMEOUT_BUFFER_MS = 15_000;
|
||||
|
||||
function toMs(timestamp: number | undefined): number | null {
|
||||
if (timestamp == null || !Number.isFinite(timestamp)) return null;
|
||||
return timestamp < 1e12 ? timestamp * 1000 : timestamp;
|
||||
}
|
||||
|
||||
function getMessagePlainText(message: RawMessage): string {
|
||||
const { content } = message;
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
const parts: string[] = [];
|
||||
for (const block of content as Array<{ type?: string; text?: string }>) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function looksLikeImagePath(value: unknown): boolean {
|
||||
return typeof value === 'string' && /\.(?:png|jpe?g|gif|webp|bmp|avif|svg)(?:$|[?#])/i.test(value.trim());
|
||||
}
|
||||
|
||||
function messageToolResultDeliveredImage(message: RawMessage): boolean {
|
||||
const role = String(message.role ?? '').toLowerCase();
|
||||
if (role !== 'toolresult' && role !== 'tool_result') return false;
|
||||
if (message.toolName !== 'message') return false;
|
||||
|
||||
const details = (message as unknown as { details?: unknown }).details;
|
||||
if (!details || typeof details !== 'object') return false;
|
||||
const record = details as Record<string, unknown>;
|
||||
const sourceReply = record.sourceReply && typeof record.sourceReply === 'object'
|
||||
? record.sourceReply as Record<string, unknown>
|
||||
: null;
|
||||
const mediaValues = [
|
||||
record.mediaUrl,
|
||||
...(Array.isArray(record.mediaUrls) ? record.mediaUrls : []),
|
||||
sourceReply?.mediaUrl,
|
||||
...(Array.isArray(sourceReply?.mediaUrls) ? sourceReply.mediaUrls : []),
|
||||
];
|
||||
return mediaValues.some(looksLikeImagePath);
|
||||
}
|
||||
|
||||
function messageHasDeliveredImage(message: RawMessage): boolean {
|
||||
if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) {
|
||||
return true;
|
||||
}
|
||||
if (extractImages(message).length > 0) return true;
|
||||
return messageToolResultDeliveredImage(message);
|
||||
}
|
||||
|
||||
function findLastImageGenerateToolCallIndex(segmentMessages: RawMessage[]): number {
|
||||
for (let index = segmentMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = segmentMessages[index];
|
||||
if (message.role !== 'assistant') continue;
|
||||
if (extractToolUse(message).some((tool) => tool.name === IMAGE_GENERATE_TOOL)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collectAsyncImageTaskStarts(
|
||||
segmentMessages: RawMessage[],
|
||||
): Array<{ taskId: string; startedAtMs: number | null }> {
|
||||
const starts: Array<{ taskId: string; startedAtMs: number | null }> = [];
|
||||
for (const message of segmentMessages) {
|
||||
const role = String(message.role ?? '').toLowerCase();
|
||||
if (role !== 'toolresult' && role !== 'tool_result') continue;
|
||||
if (message.toolName !== IMAGE_GENERATE_TOOL) continue;
|
||||
const match = getMessagePlainText(message).match(ASYNC_IMAGE_TASK_START_RE);
|
||||
if (!match?.[1]) continue;
|
||||
starts.push({
|
||||
taskId: match[1],
|
||||
startedAtMs: toMs(message.timestamp),
|
||||
});
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function collectCompletedAsyncImageTaskIds(segmentMessages: RawMessage[]): Set<string> {
|
||||
const completed = new Set<string>();
|
||||
for (const message of segmentMessages) {
|
||||
const role = String(message.role ?? '').toLowerCase();
|
||||
if (role !== 'user') continue;
|
||||
const text = getMessagePlainText(message).trim();
|
||||
if (!/^\[Inter-session message\]/i.test(text)) continue;
|
||||
const match = text.match(INTER_SESSION_IMAGE_TASK_RE);
|
||||
if (match?.[1]) completed.add(match[1]);
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
function hasTimedOut(startedAtMs: number | null, now: number): boolean {
|
||||
if (startedAtMs == null) return false;
|
||||
return now - startedAtMs > IMAGE_GENERATION_TIMEOUT_MS + IMAGE_GENERATION_TIMEOUT_BUFFER_MS;
|
||||
}
|
||||
|
||||
function hasDeliveredImageAfter(segmentMessages: RawMessage[], fromIndex: number): boolean {
|
||||
for (let index = fromIndex + 1; index < segmentMessages.length; index += 1) {
|
||||
if (messageHasDeliveredImage(segmentMessages[index])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasTerminalReplyAfterToolCall(segmentMessages: RawMessage[], fromIndex: number): boolean {
|
||||
for (let index = fromIndex + 1; index < segmentMessages.length; index += 1) {
|
||||
const message = segmentMessages[index];
|
||||
if (message.role !== 'assistant') continue;
|
||||
if (extractToolUse(message).length > 0) continue;
|
||||
|
||||
const text = getMessagePlainText(message).trim();
|
||||
if (!text || isInternalAssistantReplyText(text)) continue;
|
||||
if (isGeneratingStatusNarration(text)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFreshRunningStreamingTool(streamingTools: ToolStatus[], now: number): boolean {
|
||||
return streamingTools.some((tool) => {
|
||||
if (tool.name !== IMAGE_GENERATE_TOOL || tool.status !== 'running') return false;
|
||||
return now - tool.updatedAt <= IMAGE_GENERATION_TIMEOUT_MS + IMAGE_GENERATION_TIMEOUT_BUFFER_MS;
|
||||
});
|
||||
}
|
||||
|
||||
export function hasDeliveredImageGenerationResult(segmentMessages: RawMessage[]): boolean {
|
||||
const toolCallIndex = findLastImageGenerateToolCallIndex(segmentMessages);
|
||||
if (toolCallIndex >= 0 && hasDeliveredImageAfter(segmentMessages, toolCallIndex)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const asyncStarts = collectAsyncImageTaskStarts(segmentMessages);
|
||||
if (asyncStarts.length === 0) return false;
|
||||
return segmentMessages.some((message) => message.role === 'assistant' && messageHasDeliveredImage(message));
|
||||
}
|
||||
|
||||
/** True while an async `image_generate` task is in flight for this run segment. */
|
||||
export function isImageGenerationPending(
|
||||
segmentMessages: RawMessage[],
|
||||
streamingTools: ToolStatus[] = [],
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const toolCallIndex = findLastImageGenerateToolCallIndex(segmentMessages);
|
||||
const asyncStarts = collectAsyncImageTaskStarts(segmentMessages);
|
||||
const completedTaskIds = collectCompletedAsyncImageTaskIds(segmentMessages);
|
||||
|
||||
if (toolCallIndex < 0 && asyncStarts.length === 0) {
|
||||
return hasFreshRunningStreamingTool(streamingTools, now);
|
||||
}
|
||||
|
||||
const toolCallStartedAt = toolCallIndex >= 0
|
||||
? toMs(segmentMessages[toolCallIndex]?.timestamp)
|
||||
: null;
|
||||
const latestAsyncStartedAt = asyncStarts.reduce<number | null>((latest, start) => {
|
||||
if (start.startedAtMs == null) return latest;
|
||||
return latest == null ? start.startedAtMs : Math.max(latest, start.startedAtMs);
|
||||
}, null);
|
||||
const anchorStartedAt = [toolCallStartedAt, latestAsyncStartedAt]
|
||||
.filter((value): value is number => value != null)
|
||||
.reduce<number | null>((latest, value) => (latest == null ? value : Math.max(latest, value)), null);
|
||||
|
||||
if (hasTimedOut(anchorStartedAt, now)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDeliveredImageGenerationResult(segmentMessages)) return false;
|
||||
|
||||
const hasOpenAsyncTask = asyncStarts.some((start) => !completedTaskIds.has(start.taskId));
|
||||
if (asyncStarts.length > 0) {
|
||||
if (!hasOpenAsyncTask) return false;
|
||||
} else if (toolCallIndex >= 0 && hasTerminalReplyAfterToolCall(segmentMessages, toolCallIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasFreshRunningStreamingTool(streamingTools, now)
|
||||
|| toolCallIndex >= 0
|
||||
|| hasOpenAsyncTask;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user