diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js
index 86f81e5..a8c04dd 100644
--- a/dist-electron/main/main.js
+++ b/dist-electron/main/main.js
@@ -1,7 +1,2 @@
-"use strict";
-require("electron");
-require("./main-B0AKNiSn.js");
-require("electron-squirrel-startup");
-require("electron-log");
-require("bytenode");
-require("axios");
+require('bytenode')
+require('./main.jsc')
\ No newline at end of file
diff --git a/dist-electron/preload/preload.js b/dist-electron/preload/preload.js
index c4f6639..956f473 100644
--- a/dist-electron/preload/preload.js
+++ b/dist-electron/preload/preload.js
@@ -1,137 +1 @@
-"use strict";
-const electron = require("electron");
-var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => {
- IPC_EVENTS2["EXTERNAL_OPEN"] = "external-open";
- IPC_EVENTS2["APP_SET_FRAMELESS"] = "app:set-frameless";
- IPC_EVENTS2["APP_LOAD_PAGE"] = "app:load-page";
- IPC_EVENTS2["TAB_CREATE"] = "tab:create";
- IPC_EVENTS2["TAB_LIST"] = "tab:list";
- IPC_EVENTS2["TAB_NAVIGATE"] = "tab:navigate";
- IPC_EVENTS2["TAB_RELOAD"] = "tab:reload";
- IPC_EVENTS2["TAB_BACK"] = "tab:back";
- IPC_EVENTS2["TAB_FORWARD"] = "tab:forward";
- IPC_EVENTS2["TAB_SWITCH"] = "tab:switch";
- IPC_EVENTS2["TAB_CLOSE"] = "tab:close";
- IPC_EVENTS2["LOG_TO_MAIN"] = "log-to-main";
- IPC_EVENTS2["READ_FILE"] = "read-file";
- IPC_EVENTS2["INVOKE"] = "ipc:invoke";
- IPC_EVENTS2["INVOKE_ASYNC"] = "ipc:invokeAsync";
- IPC_EVENTS2["APP_MINIMIZE"] = "app:minimize";
- IPC_EVENTS2["APP_MAXIMIZE"] = "app:maximize";
- IPC_EVENTS2["APP_QUIT"] = "app:quit";
- IPC_EVENTS2["FILE_READ"] = "file:read";
- IPC_EVENTS2["FILE_WRITE"] = "file:write";
- IPC_EVENTS2["GET_WINDOW_ID"] = "get-window-id";
- IPC_EVENTS2["CUSTOM_EVENT"] = "custom:event";
- IPC_EVENTS2["TIME_UPDATE"] = "time:update";
- IPC_EVENTS2["RENDERER_IS_READY"] = "renderer-ready";
- IPC_EVENTS2["SHOW_CONTEXT_MENU"] = "show-context-menu";
- IPC_EVENTS2["START_A_DIALOGUE"] = "start-a-dialogue";
- IPC_EVENTS2["OPEN_WINDOW"] = "open-window";
- IPC_EVENTS2["LOG_DEBUG"] = "log-debug";
- IPC_EVENTS2["LOG_INFO"] = "log-info";
- IPC_EVENTS2["LOG_WARN"] = "log-warn";
- IPC_EVENTS2["LOG_ERROR"] = "log-error";
- IPC_EVENTS2["CONFIG_UPDATED"] = "config-updated";
- IPC_EVENTS2["SET_CONFIG"] = "set-config";
- IPC_EVENTS2["GET_CONFIG"] = "get-config";
- IPC_EVENTS2["UPDATE_CONFIG"] = "update-config";
- IPC_EVENTS2["SET_THEME_MODE"] = "set-theme-mode";
- IPC_EVENTS2["GET_THEME_MODE"] = "get-theme-mode";
- IPC_EVENTS2["IS_DARK_THEME"] = "is-dark-theme";
- IPC_EVENTS2["THEME_MODE_UPDATED"] = "theme-mode-updated";
- IPC_EVENTS2["EXECUTE_SCRIPT"] = "execute-script";
- IPC_EVENTS2["TASK_PROGRESS"] = "task:progress";
- IPC_EVENTS2["TASK_STARTED"] = "task:started";
- IPC_EVENTS2["TASK_COMPLETED"] = "task:completed";
- IPC_EVENTS2["OPEN_CHANNEL"] = "open-channel";
- IPC_EVENTS2["SCRIPT_LIST"] = "script:list";
- IPC_EVENTS2["SCRIPT_GET"] = "script:get";
- IPC_EVENTS2["SCRIPT_SAVE"] = "script:save";
- IPC_EVENTS2["SCRIPT_DELETE"] = "script:delete";
- IPC_EVENTS2["SCRIPT_TOGGLE"] = "script:toggle";
- IPC_EVENTS2["SCRIPT_RUN"] = "script:run";
- IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start";
- IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop";
- IPC_EVENTS2["SCRIPT_CODEGEN"] = "script:codegen";
- IPC_EVENTS2["GATEWAY_RPC"] = "gateway:rpc";
- IPC_EVENTS2["GATEWAY_EVENT"] = "gateway:event";
- IPC_EVENTS2["UPDATE_CHECK"] = "update:check";
- IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download";
- IPC_EVENTS2["UPDATE_INSTALL"] = "update:install";
- IPC_EVENTS2["UPDATE_VERSION"] = "update:version";
- IPC_EVENTS2["UPDATE_STATUS_CHANGED"] = "update:status-changed";
- return IPC_EVENTS2;
-})(IPC_EVENTS || {});
-const api = {
- versions: process.versions,
- external: {
- open: (url) => electron.ipcRenderer.invoke("external-open", url)
- },
- platform: process.platform,
- windowMinimize: () => electron.ipcRenderer.invoke("window:minimize"),
- windowMaximize: () => electron.ipcRenderer.invoke("window:maximize"),
- windowClose: () => electron.ipcRenderer.invoke("window:close"),
- windowIsMaximized: () => electron.ipcRenderer.invoke("window:isMaximized"),
- viewIsReady: () => electron.ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY),
- app: {
- setFrameless: (route) => electron.ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route),
- loadPage: (page) => electron.ipcRenderer.invoke(IPC_EVENTS.APP_LOAD_PAGE, page)
- },
- // 通过 IPC 调用主进程
- readFile: (filePath) => electron.ipcRenderer.invoke(IPC_EVENTS.READ_FILE, filePath),
- // 异步调用(映射为 electron 的 invoke)
- invoke: (channel, ...args) => electron.ipcRenderer.invoke(channel, ...args),
- // 异步调用(为了兼容老代码)
- invokeAsync: (channel, ...args) => electron.ipcRenderer.invoke(channel, ...args),
- // 监听主进程消息
- on: (event, callback) => {
- const subscription = (_event, ...args) => callback(...args);
- electron.ipcRenderer.on(event, subscription);
- return () => electron.ipcRenderer.removeListener(event, subscription);
- },
- // 发送消息到主进程
- send: (channel, ...args) => electron.ipcRenderer.send(channel, ...args),
- // 获取窗口ID
- getCurrentWindowId: () => electron.ipcRenderer.sendSync(IPC_EVENTS.GET_WINDOW_ID),
- // 发送日志
- logger: {
- debug: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_DEBUG, message, ...meta),
- info: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_INFO, message, ...meta),
- warn: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_WARN, message, ...meta),
- error: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_ERROR, message, ...meta)
- },
- // 执行脚本
- executeScript: (params) => electron.ipcRenderer.invoke(IPC_EVENTS.EXECUTE_SCRIPT, params),
- // 任务事件
- onTaskProgress: (cb) => {
- const subscription = (_event, payload) => cb(payload);
- electron.ipcRenderer.on(IPC_EVENTS.TASK_PROGRESS, subscription);
- return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_PROGRESS, subscription);
- },
- onTaskStarted: (cb) => {
- const subscription = (_event, payload) => cb(payload);
- electron.ipcRenderer.on(IPC_EVENTS.TASK_STARTED, subscription);
- return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_STARTED, subscription);
- },
- onTaskCompleted: (cb) => {
- const subscription = (_event, payload) => cb(payload);
- electron.ipcRenderer.on(IPC_EVENTS.TASK_COMPLETED, subscription);
- return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_COMPLETED, subscription);
- },
- // 打开渠道
- openChannel: (channels) => electron.ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels),
- // 脚本管理
- scriptApi: {
- list: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_LIST),
- get: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_GET, id),
- save: (input) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_SAVE, input),
- delete: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_DELETE, id),
- toggle: (id, enabled) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_TOGGLE, id, enabled),
- run: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RUN, id),
- startRecording: (url) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_START, url),
- stopRecording: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_STOP),
- codegen: (id, url) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_CODEGEN, id, url)
- }
-};
-electron.contextBridge.exposeInMainWorld("api", api);
+"use strict";const r=require("electron");var i=(e=>(e.EXTERNAL_OPEN="external-open",e.APP_SET_FRAMELESS="app:set-frameless",e.APP_LOAD_PAGE="app:load-page",e.TAB_CREATE="tab:create",e.TAB_LIST="tab:list",e.TAB_NAVIGATE="tab:navigate",e.TAB_RELOAD="tab:reload",e.TAB_BACK="tab:back",e.TAB_FORWARD="tab:forward",e.TAB_SWITCH="tab:switch",e.TAB_CLOSE="tab:close",e.LOG_TO_MAIN="log-to-main",e.READ_FILE="read-file",e.INVOKE="ipc:invoke",e.INVOKE_ASYNC="ipc:invokeAsync",e.APP_MINIMIZE="app:minimize",e.APP_MAXIMIZE="app:maximize",e.APP_QUIT="app:quit",e.FILE_READ="file:read",e.FILE_WRITE="file:write",e.GET_WINDOW_ID="get-window-id",e.CUSTOM_EVENT="custom:event",e.TIME_UPDATE="time:update",e.RENDERER_IS_READY="renderer-ready",e.SHOW_CONTEXT_MENU="show-context-menu",e.START_A_DIALOGUE="start-a-dialogue",e.OPEN_WINDOW="open-window",e.LOG_DEBUG="log-debug",e.LOG_INFO="log-info",e.LOG_WARN="log-warn",e.LOG_ERROR="log-error",e.CONFIG_UPDATED="config-updated",e.SET_CONFIG="set-config",e.GET_CONFIG="get-config",e.UPDATE_CONFIG="update-config",e.SET_THEME_MODE="set-theme-mode",e.GET_THEME_MODE="get-theme-mode",e.IS_DARK_THEME="is-dark-theme",e.THEME_MODE_UPDATED="theme-mode-updated",e.EXECUTE_SCRIPT="execute-script",e.TASK_PROGRESS="task:progress",e.TASK_STARTED="task:started",e.TASK_COMPLETED="task:completed",e.OPEN_CHANNEL="open-channel",e.SCRIPT_LIST="script:list",e.SCRIPT_GET="script:get",e.SCRIPT_SAVE="script:save",e.SCRIPT_DELETE="script:delete",e.SCRIPT_TOGGLE="script:toggle",e.SCRIPT_RUN="script:run",e.SCRIPT_RECORD_START="script:record-start",e.SCRIPT_RECORD_STOP="script:record-stop",e.SCRIPT_CODEGEN="script:codegen",e.GATEWAY_RPC="gateway:rpc",e.GATEWAY_EVENT="gateway:event",e.UPDATE_CHECK="update:check",e.UPDATE_DOWNLOAD="update:download",e.UPDATE_INSTALL="update:install",e.UPDATE_VERSION="update:version",e.UPDATE_STATUS_CHANGED="update:status-changed",e))(i||{});const d={versions:process.versions,external:{open:e=>r.ipcRenderer.invoke("external-open",e)},platform:process.platform,windowMinimize:()=>r.ipcRenderer.invoke("window:minimize"),windowMaximize:()=>r.ipcRenderer.invoke("window:maximize"),windowClose:()=>r.ipcRenderer.invoke("window:close"),windowIsMaximized:()=>r.ipcRenderer.invoke("window:isMaximized"),viewIsReady:()=>r.ipcRenderer.send(i.RENDERER_IS_READY),app:{setFrameless:e=>r.ipcRenderer.invoke(i.APP_SET_FRAMELESS,e),loadPage:e=>r.ipcRenderer.invoke(i.APP_LOAD_PAGE,e)},readFile:e=>r.ipcRenderer.invoke(i.READ_FILE,e),invoke:(e,...n)=>r.ipcRenderer.invoke(e,...n),invokeAsync:(e,...n)=>r.ipcRenderer.invoke(e,...n),on:(e,n)=>{const t=(o,...R)=>n(...R);return r.ipcRenderer.on(e,t),()=>r.ipcRenderer.removeListener(e,t)},send:(e,...n)=>r.ipcRenderer.send(e,...n),getCurrentWindowId:()=>r.ipcRenderer.sendSync(i.GET_WINDOW_ID),logger:{debug:(e,...n)=>r.ipcRenderer.send(i.LOG_DEBUG,e,...n),info:(e,...n)=>r.ipcRenderer.send(i.LOG_INFO,e,...n),warn:(e,...n)=>r.ipcRenderer.send(i.LOG_WARN,e,...n),error:(e,...n)=>r.ipcRenderer.send(i.LOG_ERROR,e,...n)},executeScript:e=>r.ipcRenderer.invoke(i.EXECUTE_SCRIPT,e),onTaskProgress:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_PROGRESS,n),()=>r.ipcRenderer.removeListener(i.TASK_PROGRESS,n)},onTaskStarted:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_STARTED,n),()=>r.ipcRenderer.removeListener(i.TASK_STARTED,n)},onTaskCompleted:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_COMPLETED,n),()=>r.ipcRenderer.removeListener(i.TASK_COMPLETED,n)},openChannel:e=>r.ipcRenderer.invoke(i.OPEN_CHANNEL,e),scriptApi:{list:()=>r.ipcRenderer.invoke(i.SCRIPT_LIST),get:e=>r.ipcRenderer.invoke(i.SCRIPT_GET,e),save:e=>r.ipcRenderer.invoke(i.SCRIPT_SAVE,e),delete:e=>r.ipcRenderer.invoke(i.SCRIPT_DELETE,e),toggle:(e,n)=>r.ipcRenderer.invoke(i.SCRIPT_TOGGLE,e,n),run:e=>r.ipcRenderer.invoke(i.SCRIPT_RUN,e),startRecording:e=>r.ipcRenderer.invoke(i.SCRIPT_RECORD_START,e),stopRecording:()=>r.ipcRenderer.invoke(i.SCRIPT_RECORD_STOP),codegen:(e,n)=>r.ipcRenderer.invoke(i.SCRIPT_CODEGEN,e,n)}};r.contextBridge.exposeInMainWorld("api",d);
diff --git a/dist/index.html b/dist/index.html
index a7a7ccb..c19a495 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -8,8 +8,8 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn"
/>
-
-
+
+
diff --git a/docs/OpenClaw-Chat-Alignment-Plan.md b/docs/OpenClaw-Chat-Alignment-Plan.md
index fc35dc2..92767dc 100644
--- a/docs/OpenClaw-Chat-Alignment-Plan.md
+++ b/docs/OpenClaw-Chat-Alignment-Plan.md
@@ -1,64 +1,91 @@
# zn-ai 对齐 ClawX 对话能力的 OpenClaw 集成与迁移计划
-## 1. 结论摘要
+## 1. 当前结论
-- `zn-ai` 当前已经具备一套可工作的本地聊天骨架:`gateway:rpc`、`gateway:event`、`providerApiService`、流式渲染、会话列表、附件发送、`thinking/tool_use/tool_result` 数据结构都已存在。
-- `ClawX` 的核心优势不在单个聊天组件,而在于一整套“`Electron Main` 托管 `OpenClaw Gateway` + `Host API` 统一入口 + `~/.openclaw` 运行时配置 + Agent/Session/Transcript`”的闭环。
-- 如果目标是“对齐 ClawX 的对话能力”,推荐采用 **ClawX 原生方案对齐**,而不是继续把 `zn-ai` 现有轻量 gateway 做成长期主架构。
-- 推荐实施路径是:**先保持 `zn-ai` 现有 `gateway:rpc` / `gateway:event` 契约不变,逐步把底层执行面替换成 OpenClaw**。这样可以最大化复用 Vue Renderer,降低一次性重写风险。
+- `zn-ai` 已完成 `React` 平替,当前 Renderer 主链路已经是 `src/App.tsx` + `src/router/index.tsx` + `src/pages/Home/index.tsx`,不再按 `Vue` 页面作为迁移前提。
+- 这意味着接下来的 `OpenClaw` 对齐重点不再是“技术栈迁移”,而是 `Host API / Gateway Lifecycle / Session / Transcript / Agent Routing / Runtime Packaging` 这几个能力闭环。
+- 推荐继续保留 `zn-ai` 现有的 `gateway:rpc` 与 `hostApiFetch()` 契约,逐步把底层执行面替换成 `ClawX + OpenClaw` 风格,而不是直接推翻现有 React 聊天页。
+- 第一批集成开发已经开始:本地 `Host API` 路由骨架已从 `electron/main.ts` 拆出,新增了 `Gateway / Sessions / Files / Providers` 四类本地路由,为后续 `OpenClaw` 接入打底。
-## 2. ClawX 集成 OpenClaw 的实现思路
+## 2. React 平替后的新前提
-| 层级 | ClawX 做法 | 关键文件 | 对 zn-ai 的启发 |
-| --- | --- | --- | --- |
-| OpenClaw 运行时打包 | 把 `openclaw` 包和全部运行时依赖打进应用资源目录,同时额外打包 `uv`、Windows Node 二进制 | `ClawX/scripts/bundle-openclaw.mjs` `ClawX/scripts/download-bundled-uv.mjs` `ClawX/electron/utils/paths.ts` | `zn-ai` 不能只复用接口,必须把 OpenClaw runtime 一起嵌入,才能真正对齐 ClawX 的行为与目录结构 |
-| Gateway 生命周期 | `Electron utilityProcess` 启动 `openclaw.mjs`,主进程负责 start/stop/restart/health/reconnect/orphan cleanup | `ClawX/electron/gateway/manager.ts` `ClawX/electron/gateway/process-launcher.ts` `ClawX/electron/gateway/supervisor.ts` | `zn-ai` 现有 in-process gateway 可作为过渡层,但目标应切到独立 OpenClaw 进程托管 |
-| Host API 统一接入 | 主进程启动本地 Host API Server,Renderer 统一通过 `hostApiFetch()` 与各类 `/api/*` 路由通信 | `ClawX/electron/api/server.ts` `ClawX/electron/api/routes/gateway.ts` `ClawX/electron/api/routes/sessions.ts` `ClawX/src/lib/host-api.ts` | `zn-ai` 目前 `hostapi:fetch` 同时承担“本地 provider”与“远端业务后端”两套职责,后续需要把 OpenClaw 相关能力彻底本地化 |
-| Renderer 与 Gateway 通信 | Renderer 通过 `gateway:rpc` 和 `/api/app/gateway-info` 建立统一通信链路,不直接知道 OpenClaw 进程细节 | `ClawX/src/lib/gateway-client.ts` `ClawX/electron/api/routes/gateway.ts` | `zn-ai` 已经有相同方向的封装,这部分最值得保留 |
-| 对话状态机 | Chat Store 负责流式消息、历史回灌、错误恢复、附件缓存、tool/thinking 归并、会话切换 | `ClawX/src/stores/chat.ts` `ClawX/src/stores/chat/runtime-send-actions.ts` `ClawX/src/stores/chat/runtime-event-handlers.ts` | `zn-ai` 现有 `src/stores/chat.ts` 已经很接近,可以按契约对齐而不必推倒重来 |
-| 对话展示能力 | Markdown、thinking 展示、tool cards、图片/文件附件、`@agent` 路由、执行图、sub-agent transcript | `ClawX/src/pages/Chat/index.tsx` `ClawX/src/pages/Chat/ChatInput.tsx` `ClawX/src/pages/Chat/ChatMessage.tsx` | `zn-ai` 需要补的是“真实能力闭环”,而不是单纯补 UI 外观 |
-| 子任务/转录读取 | 通过 Session Transcript API 读取子 agent 的 `jsonl` 转录,驱动执行图 | `ClawX/electron/api/routes/sessions.ts` `ClawX/src/pages/Chat/index.tsx` | 这是 `zn-ai` 当前缺失最明显的一块,也是对齐 ClawX 对话体验的关键差异点 |
+### 2.1 当前 React 主链路
-## 3. zn-ai 当前基础与差距
+| 模块 | 当前文件 | 说明 |
+| --- | --- | --- |
+| React App 入口 | `src/App.tsx` | 已由 React Router 托管 |
+| 路由 | `src/router/index.tsx` | 主页面已经走 React 路由 |
+| 主聊天页 | `src/pages/Home/index.tsx` | 当前对话、任务中心、渠道入口都在这里 |
+| 聊天组件 | `src/components/chat/*` | 会话列表、消息列表、输入框、任务板 |
+| Chat Store | `src/stores/chat.ts` | 已支持流式输出、历史回灌、附件 staging、session 删除 |
+| Task/Channel Store | `src/stores/task.ts` `src/stores/channel.ts` | 已有任务与渠道配置的 React 状态层 |
-### 3.1 已有基础
+### 2.2 已完成或已落地的基础能力
-| 能力 | 当前实现 | 关键文件 | 评估 |
-| --- | --- | --- | --- |
-| 主进程聊天入口 | 已有 `gateway:rpc` 与 `hostapi:fetch` | `zn-ai/electron/main.ts` | 可复用 |
-| 本地 gateway 骨架 | 已有 `GatewayManager`、`chat.send/history/abort/session.list` | `zn-ai/electron/gateway/manager.ts` `zn-ai/electron/gateway/handlers/chat.ts` `zn-ai/electron/gateway/types.ts` | 适合做过渡层 |
-| Provider 本地存储 | Provider 账户、默认账户、API Key 已本地化 | `zn-ai/electron/service/provider-api-service/index.ts` | 可作为 OpenClaw 配置同步源 |
-| Renderer chat 状态机 | 已支持流式更新、错误恢复、历史回读、附件、tool status | `zn-ai/src/stores/chat.ts` | 高复用价值 |
-| 消息模型 | 已定义 `thinking/tool_use/tool_result/image` | `zn-ai/src/pages/home/model/ChatModel.ts` | 结构已接近 ClawX |
-| 聊天 UI | 已有消息列表、输入框、附件、Markdown、会话侧栏 | `zn-ai/src/pages/home/ChatBox.vue` `zn-ai/src/pages/home/ChatHistory.vue` `zn-ai/src/pages/home/components/chat/*` | 可迭代增强 |
+| 能力 | 当前状态 | 关键文件 |
+| --- | --- | --- |
+| Renderer 已 React 化 | 已完成 | `src/App.tsx` `src/router/index.tsx` |
+| Home/Chat 主链路 | 已完成 React 化 | `src/pages/Home/index.tsx` `src/components/chat/*` |
+| gateway:rpc / gateway:event | 已可用 | `electron/gateway/manager.ts` `src/lib/gateway-client.ts` |
+| hostApiFetch | 已可用 | `src/lib/host-api.ts` `electron/main.ts` |
+| Transcript 写入 | 已有 jsonl 写入 | `electron/gateway/handlers/chat.ts` `electron/utils/token-usage-writer.ts` |
+| Provider 本地化 | 已可用 | `electron/service/provider-api-service/index.ts` |
+| 本地 Session Key | 已初步收敛到 `agent:*` | `src/stores/chat.ts` |
-### 3.2 主要差距
+### 2.3 仍未对齐 ClawX 的关键差距
| 差距 | 当前状态 | 对齐目标 |
| --- | --- | --- |
-| OpenClaw runtime | 还没有嵌入 `openclaw` 包、`utilityProcess` 启动链路、打包脚本 | 与 ClawX 一样由主进程托管 OpenClaw Gateway |
-| Host API 本地闭环 | `hostapi:fetch` 仍混合本地代理和远端服务转发 | OpenClaw 相关 `/api/*` 统一落在本地主进程 Host API |
-| Session key 约定 | 仍会生成 `local:{defaultAccountId}:{uuid}` 本地会话键 | 统一收敛到 ClawX 风格的 `agent:{agentId}:{sessionId}` |
-| `@agent` 直达路由 | 输入框已有 Agent chip,但当前是占位能力,未接入真实 Agent 列表和主会话映射 | 像 ClawX 一样把下一条消息直接送到目标 Agent 的主会话 |
-| Agent/Session/Transcript | 缺少完整的 Agent 配置、main session 映射、子 agent transcript 加载 | 对齐 ClawX 的 Agent/Session/Transcript API |
-| 执行图与子任务闭环 | 当前没有基于 transcript 的执行图、handoff、sub-agent 展示 | 对齐 ClawX 的任务执行可视化能力 |
-| Gateway 生命周期治理 | 当前 gateway 只是主进程 service,没有 OpenClaw 进程治理、健康检查、重连策略 | 对齐 ClawX 的启动、重启、健康、孤儿进程治理逻辑 |
+| OpenClaw runtime 打包 | 还没有 `openclaw` / `uv` / resource bundling | 像 ClawX 一样由主进程打包并启动 |
+| Gateway 生命周期治理 | 当前仍是 in-process manager | 对齐 `start / stop / restart / health / supervisor` |
+| Host API 路由层 | 刚开始拆分,本地路由还不是完整体系 | 对齐 `ClawX/electron/api/routes/*` 风格 |
+| Session Transcript API | 刚开始补 `/api/sessions/transcript` | 对齐 transcript 读取、删除、后续执行图基础 |
+| Agent/Session 主会话映射 | 仍未有完整 `agents` 本地接口 | 对齐 `@agent` 路由与主会话映射 |
+| 执行图 / 子任务展示 | 尚未接入 UI | 对齐 `ClawX` 的 task visualizer / transcript tree |
-## 4. 推荐目标架构
+## 3. ClawX 最小闭环对齐点
+
+当前不需要一次搬运整个 `ClawX`。根据已完成分析,最值得先对齐的是这 5 个契约:
+
+1. `hostApiFetch` 所依赖的本地 `Host API` transport 契约。
+2. `gateway.status / gateway.health / gateway.ready` 生命周期契约。
+3. `sessions.list / sessions.delete / sessions.transcript` 会话目录与 transcript 契约。
+4. `chat.history` 历史回灌契约。
+5. `chat.send / chat.abort / chat:delta / chat:final / chat:error` 流式发送契约。
+
+对应的 `ClawX` 关键参考文件:
+
+- `ClawX/electron/api/routes/gateway.ts`
+- `ClawX/electron/api/routes/sessions.ts`
+- `ClawX/electron/gateway/manager.ts`
+- `ClawX/electron/gateway/process-launcher.ts`
+- `ClawX/src/lib/host-api.ts`
+- `ClawX/src/stores/chat.ts`
+
+当前建议只迁“最小闭环”,不一次性搬全:
+
+- 先做 `gateway-info / gateway-status / sessions-transcript / stage-buffer`。
+- 再做 `OpenClaw runtime` 打包与进程托管。
+- 最后才做 `Agent Routing / Execution Graph / Control UI / Doctor`。
+
+## 4. 目标架构
```text
-Vue Renderer
+React Renderer
+ ├─ src/pages/Home/index.tsx
├─ src/stores/chat.ts
+ ├─ src/stores/task.ts
+ ├─ src/stores/channel.ts
├─ src/lib/gateway-client.ts
└─ src/lib/host-api.ts
│
- │ IPC + Host API
+ │ IPC + Local Host API
▼
Electron Main
- ├─ GatewayManager (OpenClaw process owner)
- ├─ Host API Server (/api/gateway /api/providers /api/agents /api/sessions ...)
- ├─ Provider/Agent/Session sync layer
- └─ file stage / upload / local config bridge
+ ├─ GatewayManager (过渡期 in-process,目标是 OpenClaw process owner)
+ ├─ Host API Router (/api/providers /api/gateway /api/sessions /api/files ...)
+ ├─ Provider / Agent / Session config sync
+ └─ Runtime / packaging / paths
│
│ utilityProcess + ~/.openclaw
▼
@@ -66,164 +93,135 @@ OpenClaw Gateway
├─ providers / auth profiles
├─ agents / sessions / transcripts
├─ tool use / thinking / attachments
- └─ channel / cron / skills / model routing
+ └─ sub-agent / execution graph / control ui
```
-### 架构原则
+## 5. 分阶段迁移计划
-1. Renderer 尽量不直接感知 OpenClaw 进程细节,只认 `gateway:rpc` 和 `hostApiFetch`。
-2. `zn-ai` 已有聊天 UI 保留,主要做协议对齐和能力补全,不追求把 React 页面逐组件翻译成 Vue。
-3. `providerApiService` 在过渡期作为配置真源,后续逐步演进为 “zn-ai 配置面板 <-> OpenClaw runtime config” 的双向同步桥。
-4. 第一阶段只聚焦“对齐 ClawX 对话能力”,不把 `ClawHub`、完整 Channel UI、完整 Skills UI 一次性并入首个里程碑。
-
-## 5. 目标对齐范围
-
-### 5.1 P0:必须对齐的对话核心能力
-
-- 多会话创建、切换、历史加载、删除、重命名
-- 流式输出、Markdown 渲染、错误恢复、Abort
-- thinking 展示开关
-- 工具调用状态与工具结果归并
-- 图片/文件附件发送与展示
-- 默认模型选择生效
-- 主进程 Gateway 自动启动、状态展示、重启
-
-### 5.2 P1:推荐在首轮迁移后立即补齐
-
-- `@agent` 直接路由到目标 Agent 主会话
-- Agent 级 provider/model override
-- Session Transcript API
-- 执行图卡片与子任务可视化
-- 历史轮询与 history fallback 逻辑对齐
-
-### 5.3 P2:对话增强能力
-
-- sub-agent transcript 树展示
-- channel 会话历史与对话页面联动
-- OpenClaw doctor / Control UI / 调试入口
-- 更完整的 tool_result 文件回灌与 message-ref 展示
-
-## 6. 分阶段迁移计划
-
-| 阶段 | 目标 | 主要改动路径 | 退出标准 |
+| 阶段 | 状态 | 目标 | 主要文件 |
| --- | --- | --- | --- |
-| Phase 0:契约冻结 | 冻结 Chat/Gateway/Session/Agent 的目标契约,避免边做边漂移 | 新增本计划文档;整理 `gateway:rpc`、`GatewayEvent`、`RawMessage`、`sessionKey` 约定 | 所有 sub-agent 使用同一套消息模型与接口名 |
-| Phase 1:OpenClaw 运行时嵌入 | 在 `zn-ai` 中打进 `openclaw`、`uv`、必要运行时,能由主进程拉起 Gateway | `zn-ai/package.json` `zn-ai/scripts/*` `zn-ai/electron/gateway/**` `zn-ai/electron/utils/paths.ts` | 开发态与打包态都能启动 OpenClaw Gateway,并能返回 health/status |
-| Phase 2:Host API 与配置同步 | 把 Provider/Agent/Session/Gateway 相关接口统一落到本地主进程;建立 `zn-ai -> ~/.openclaw` 配置同步 | `zn-ai/electron/main.ts` `zn-ai/electron/api/**` `zn-ai/electron/service/provider-api-service/**` | Renderer 可从本地拉到 gateway-info、providers、agents、sessions、transcripts |
-| Phase 3:Renderer Chat Store 对齐 | 保留 Vue UI,改造 chat store 与 OpenClaw 事件/历史契约对齐 | `zn-ai/src/stores/chat.ts` `zn-ai/src/lib/gateway-client.ts` `zn-ai/src/lib/host-api.ts` `zn-ai/src/pages/home/model/ChatModel.ts` | 流式聊天、历史回灌、Abort、错误恢复、附件发送全链路稳定 |
-| Phase 4:对话 UI 对齐 | 补齐当前 Agent、thinking、tool cards、session sidebar、gateway 状态等体验 | `zn-ai/src/pages/home/ChatBox.vue` `zn-ai/src/pages/home/ChatHistory.vue` `zn-ai/src/pages/home/components/chat/*` | 视觉与交互能力对齐 ClawX 聊天页的核心功能 |
-| Phase 5:Agent 直达与执行图 | 接入 Agent 主会话映射、transcript 读取、执行图、sub-agent 展示 | `zn-ai/electron/api/routes/sessions.ts` `zn-ai/src/pages/home/components/*` 新增执行图组件 | 至少支持一个子任务 transcript 展示与执行过程可视化 |
-| Phase 6:验收与回归 | 做端到端验收与回归检查,避免对脚本、任务、模型管理页面造成回归 | `zn-ai` 测试脚本、调试脚本、文档 | 通过验收清单,具备对外演示条件 |
+| Phase 0:React 基线收口 | 已完成 | 让聊天页、任务中心、渠道入口都运行在 React 主链路 | `src/App.tsx` `src/router/index.tsx` `src/pages/Home/index.tsx` |
+| Phase 1:Host API 路由层重构 | 进行中 | 把本地 Provider/Gateway/Sessions/Files 从 `electron/main.ts` 中拆成独立 Host API 路由 | `electron/api/*` `electron/main.ts` |
+| Phase 2:OpenClaw Runtime/Packaging | 待开始 | 引入 `openclaw`、`uv`、runtime path 与 dev/package 双态启动能力 | `package.json` `scripts/*` `electron/utils/paths.ts` `electron/gateway/*` |
+| Phase 3:Gateway Lifecycle + Config Sync | 待开始 | 让 `GatewayManager` 从 in-process 过渡到 OpenClaw 进程 owner,并同步 provider/agent/session 配置 | `electron/gateway/*` `electron/service/provider-api-service/*` |
+| Phase 4:React Chat Store 对齐 | 待开始 | 让 React Chat Store 对齐 ClawX 的 session/history/send/abort/transcript 契约 | `src/stores/chat.ts` `src/lib/host-api.ts` `src/lib/gateway-client.ts` |
+| Phase 5:Agent Routing + Execution Graph | 待开始 | 接入 `@agent` 主会话映射、transcript 读取、执行图和子任务展示 | `src/pages/Home/*` `src/components/chat/*` `electron/api/routes/sessions.ts` |
+| Phase 6:验收与收口 | 待开始 | 做 E2E 与回归,清理过渡实现,准备进入 OpenClaw 主架构 | 文档、测试、构建脚本 |
+
+## 6. 第一批已开工内容
+
+这轮已经开始落地的文件:
+
+- `electron/api/router.ts`
+- `electron/api/context.ts`
+- `electron/api/route-utils.ts`
+- `electron/api/routes/providers.ts`
+- `electron/api/routes/gateway.ts`
+- `electron/api/routes/files.ts`
+- `electron/api/routes/sessions.ts`
+- `electron/gateway/manager.ts`
+- `electron/main.ts`
+
+当前已完成的第一批能力:
+
+- 本地 `Host API` 路由分发骨架已经建立。
+- `Provider` 本地接口已从 `electron/main.ts` 的大 switch 逻辑中拆分。
+- 新增 `Gateway` 本地路由:`/api/app/gateway-info`、`/api/gateway/status`、`/api/gateway/health`。
+- 新增 `Sessions` 本地路由:`/api/sessions/transcript`、`/api/sessions/delete`。
+- 新增 `Files` 本地路由:`/api/files/stage-buffer`、`/api/files/stage-paths`。
+- `GatewayManager` 已补充最小 `status / health / start / stop / restart` 接口,作为向 `OpenClaw process owner` 演进的过渡层。
## 7. sub-agent 数量估算
### 7.1 推荐编制
-- 分析集成 sub-agent:`2`
-- 功能迁移 sub-agent:`5`
+- 分析 sub-agent:`2`
+- 迁移开发 sub-agent:`5`
- 集成验收 sub-agent:`1`
- 推荐总数:`8`
### 7.2 最小可行编制
-- 分析集成 sub-agent:`2`
-- 功能迁移 sub-agent:`4`
-- 集成验收由主协调 agent 兼任
+- 分析 sub-agent:`2`
+- 迁移开发 sub-agent:`4`
+- 验收由主协调 agent 兼任
- 最小总数:`6`
-### 7.3 为什么推荐 2 + 5 + 1
+### 7.3 为什么推荐 8 个
-- `ClawX` 与 `zn-ai` 的主要差距横跨“打包、主进程、Host API、配置同步、Renderer 状态机、UI、转录/执行图”七个面。
-- 其中 `OpenClaw runtime 嵌入` 和 `Renderer 对话能力对齐` 之间依赖明确,但文件改动面基本可分离,适合并行。
-- 如果迁移 sub-agent 少于 `4`,会出现“主进程迁移完成但 Renderer 无法及时接入”或“UI 已变更但 transcript/API 尚未可用”的串行瓶颈。
+- `React` 技术栈迁移已经完成,所以不再需要专门的“Vue/React 双栈迁移” sub-agent。
+- 但 `OpenClaw` 对齐仍横跨 `Runtime Packaging`、`Gateway Lifecycle`、`Host API`、`Config Sync`、`React Chat Store`、`Transcript / Execution Graph` 六条线。
+- 如果迁移 sub-agent 少于 `5`,很容易出现“主进程基础已经就绪,但 React 聊天页迟迟接不上”或“Transcript API 已有但 UI 不可见”的串行瓶颈。
-## 8. 功能迁移 sub-agent 分工方案
+## 8. sub-agent 分工与开工安排
-| 角色 | 数量 | 负责范围 | 建议文件所有权 |
+### 8.1 分工方案
+
+| 角色 | 数量 | 负责范围 | 文件所有权 |
| --- | --- | --- | --- |
-| 分析 A1:ClawX/OpenClaw 契约分析 | 1 | 梳理 ClawX 的 OpenClaw runtime、Gateway、Host API、Session Transcript 契约 | 只读分析,不改文件 |
-| 分析 A2:zn-ai 落点映射 | 1 | 梳理 `zn-ai` 当前可复用模块、差距、替换顺序 | 只读分析,不改文件 |
-| 迁移 M1:Runtime/Packaging | 1 | 嵌入 `openclaw`、迁移启动脚本、补 `paths`、补打包资源 | `package.json` `scripts/*` `electron/gateway/**` `electron/utils/paths.ts` |
-| 迁移 M2:Host API/Config Sync | 1 | 新建/迁移 Host API Server、Gateway routes、Provider/Agent/Session 同步 | `electron/main.ts` `electron/api/**` `electron/service/provider-api-service/**` |
-| 迁移 M3:Renderer 协议与 Chat Store | 1 | 让 Vue chat store 对齐 ClawX 的 `chat.send/history/abort/session.list` 与事件状态机 | `src/stores/chat.ts` `src/lib/gateway-client.ts` `src/lib/host-api.ts` `src/pages/home/model/ChatModel.ts` |
-| 迁移 M4:聊天 UI/侧栏/输入框 | 1 | 侧栏会话管理、Agent picker、gateway 状态、thinking/tool/file 展示对齐 | `src/pages/home/ChatBox.vue` `src/pages/home/ChatHistory.vue` `src/pages/home/components/chat/*` |
-| 迁移 M5:Transcript/Execution Graph | 1 | transcript API、执行图、sub-agent 展示、验收脚本 | `electron/api/routes/sessions.ts` 新增执行图组件与测试/调试脚本 |
-| 验收 I1:集成收口 | 1 | 联调、回归、验收 checklist、发布说明 | 测试与文档,不抢占前面文件所有权 |
+| A1:ClawX/OpenClaw 契约分析 | 1 | 提炼 ClawX 的最小闭环契约、关键文件与最小迁移子集 | 只读分析 |
+| A2:zn-ai 差距映射 | 1 | 盘点当前 React/Gateway/Host API 的实际状态与缺口 | 只读分析 |
+| M1:Host API / Gateway Foundation | 1 | 拆本地 Host API 路由,补 Gateway info/status/health,建立本地接口框架 | `electron/api/*` `electron/main.ts` `electron/gateway/manager.ts` |
+| M2:OpenClaw Runtime / Packaging | 1 | 迁移 `openclaw` 打包、`uv`、runtime 路径和 dev/package 启动链路 | `package.json` `scripts/*` `electron/utils/paths.ts` `electron/gateway/*` |
+| M3:Config Sync / Providers / Agents / Sessions | 1 | 把 provider 配置逐步收敛成 OpenClaw 可消费格式,并补本地 agent/session 接口 | `electron/service/provider-api-service/*` `electron/api/routes/*` |
+| M4:React Chat Store / History / Send | 1 | 对齐 `chat.send/history/abort/session.list` 与 transcript fallback | `src/stores/chat.ts` `src/lib/host-api.ts` `src/lib/gateway-client.ts` |
+| M5:React Chat UI / Agent Routing / Execution Graph | 1 | 接 `@agent`、thinking/tool cards、transcript 展示与执行图入口 | `src/pages/Home/*` `src/components/chat/*` |
+| I1:集成验收与收口 | 1 | 联调、回归、验收 checklist、发布说明 | 测试与文档 |
-### 8.1 并行施工波次
+### 8.2 当前已启动的角色
-| 波次 | 并行 sub-agent | 说明 |
+| 角色 | 当前状态 | 备注 |
| --- | --- | --- |
-| Wave 1 | A1 + A2 | 只读分析,同步冻结契约与边界 |
-| Wave 2 | M1 + M2 | 一边搭 OpenClaw runtime,一边准备 Host API 与配置同步 |
-| Wave 3 | M3 + M4 | Runtime 和 API 基础稳定后,同时推进 store 与 UI |
-| Wave 4 | M5 + I1 | 接入 transcript/执行图,并完成端到端验收 |
+| A1 | 已启动并完成首轮分析 | 已明确最小闭环是 `transport -> gateway lifecycle -> sessions -> history -> send/stream` |
+| A2 | 已启动但中途断流 | 不阻塞主线,已由主协调 agent 接管现状映射 |
+| M1 | 已启动 | 第一批 `Host API` 路由骨架已落地 |
+| 主协调 agent | 已启动 | 正在更新总计划文档并推进 Phase 1 基建 |
-### 8.2 依赖关系
+### 8.3 建议并行波次
-1. `M3` 依赖 `M2` 提供稳定的 Host API/Gateway 契约。
-2. `M4` 依赖 `M3` 暴露真实的 Agent/session/tool/thinking 状态。
-3. `M5` 依赖 `M2` 的 session/transcript API 和 `M3` 的消息模型归一。
-4. `I1` 在 `M1-M5` 基本完成后统一收口。
-
-## 9. 推荐实施细节
-
-### 9.1 优先保留的 zn-ai 资产
-
-- `src/stores/chat.ts`
-- `src/pages/home/model/ChatModel.ts`
-- `src/pages/home/components/chat/ChatMessage.vue`
-- `src/pages/home/ChatHistory.vue`
-- `src/lib/gateway-client.ts`
-- `electron/service/provider-api-service/index.ts`
-
-这些模块的价值在于:
-
-- 已经具备较成熟的流式状态机,不需要重新从零写一套 Vue Chat Store。
-- 已经定义了与 ClawX 接近的结构化消息模型。
-- 已经把 Provider 配置和默认模型选择本地化,可以作为 OpenClaw 配置同步源。
-
-### 9.2 建议替换或重构的部分
-
-- 当前 `electron/gateway/manager.ts` 及 `handlers/chat.ts`
-- 当前 `hostapi:fetch` 里“远端后端代理优先”的混合职责
-- 当前 `local:{accountId}:{uuid}` 会话键体系
-- 当前只做占位的 Agent mention 逻辑
-
-### 9.3 首个里程碑不建议纳入的范围
-
-- ClawHub 全量迁移
-- Skills 页面全量迁移
-- Channels 页面全量迁移
-- Cron 与对话的深度联动
-
-原因是这几个模块都依赖 OpenClaw runtime 完整稳定,但不属于“先对齐 ClawX 对话能力”的最小闭环。
-
-## 10. 风险与决策点
-
-| 风险 | 说明 | 建议 |
+| 波次 | 并行角色 | 目标 |
| --- | --- | --- |
-| 远端 Host API 与本地 Host API 混用 | 迁移期间容易出现一部分接口走远端、一部分走本地,导致行为不一致 | 明确约定:OpenClaw 相关接口全部本地化,其余业务接口保留远端 |
-| Session key 不统一 | `local:*` 与 `agent:*` 并存会导致历史、侧栏、Agent 路由混乱 | Phase 0 就冻结统一命名规则 |
-| Provider 配置双写 | `zn-ai` 本地配置与 `~/.openclaw` 运行时配置如果不同步,会出现“设置页一套、对话页一套” | Phase 2 建立单向真源和同步策略 |
-| Vue/React 页面一比一翻译冲动 | 会导致迁移周期失控 | 只对齐能力,不对齐组件实现细节 |
-| 打包态差异 | `OpenClaw` 在开发态和打包态的路径、资源、uv/node 依赖不同 | M1 单独负责 dev/package 双态验证 |
+| Wave 1 | A1 + A2 + 主协调 agent | 冻结契约与当前状态,确认迁移边界 |
+| Wave 2 | M1 + M2 | 一边拆 Host API,一边准备 OpenClaw runtime 打包与路径层 |
+| Wave 3 | M3 + M4 | 配置同步与 React Chat Store 并行接入 |
+| Wave 4 | M5 + I1 | 执行图 / Agent Routing / UI 收口与联调验收 |
-## 11. 完成验收标准
+## 9. 接下来的直接开发任务
-- `zn-ai` 可以在开发态和打包态拉起 OpenClaw Gateway。
-- 聊天页默认使用本地默认模型配置,且修改默认模型后下一轮对话生效。
-- 会话列表支持新建、切换、加载、删除、重命名。
-- 聊天页支持流式文本、Markdown、thinking、tool cards、图片/文件附件。
-- `Abort`、错误恢复、历史回灌稳定可用。
-- `@agent` 能把消息送到目标 Agent 主会话,而不是仅显示占位 chip。
-- 至少一个子任务 transcript 能被加载并展示为执行图或子任务明细。
-- 不影响 `Scripts`、`Tasks`、模型管理页的现有主流程。
+按优先级建议这样推进:
-## 12. 与现有文档的关系
+1. 完成 `Phase 1`:
+ - 继续把 `hostapi:fetch` 中本地可处理接口迁入 `electron/api/routes/*`。
+ - 明确 `gateway-info` 与 `session-transcript` 的返回结构,冻结给前端使用。
+2. 启动 `Phase 2`:
+ - 新增 `electron/utils/paths.ts`,对齐 `ClawX` 的 runtime 路径管理。
+ - 迁移 `openclaw` bundling 脚本与 `uv` 下载脚本。
+3. 启动 `Phase 3`:
+ - 增加 `agents` 本地接口与 `main session` 映射。
+ - 让当前 `DEFAULT_AGENT_ID / DEFAULT_SESSION_KEY` 不再硬编码。
+4. 启动 `Phase 4`:
+ - 让 React Chat Store 能读取 `Host API` 的 `gateway-info` 与 `session-transcript`。
+ - 把当前 history/session 逻辑从“本地过渡实现”逐步切向 `OpenClaw` 兼容契约。
+
+## 10. 风险与约束
+
+| 风险 | 说明 | 当前策略 |
+| --- | --- | --- |
+| Host API 本地/远端混用 | 迁移期容易出现一部分接口本地、一部分接口远端,行为不一致 | 所有 `OpenClaw` 相关接口逐步收敛到本地 Host API,业务后台接口继续远端代理 |
+| Gateway 仍是 in-process | 当前还不是真正的 OpenClaw 进程托管 | 先补生命周期接口与路由层,再替换运行时 |
+| Agent 路由尚未真实接入 | 当前 `sessionKey` 虽是 `agent:*` 风格,但主会话映射仍不完整 | 放在 M3 / M5 联合推进 |
+| Transcript 能力有后端无前端 | 当前先补 Host API 路由,不代表 UI 已展示 | 通过 M4 / M5 分批接前端 |
+| 仓库已有 TS 历史债 | 当前 `tsc` 仍存在一批既有错误 | 先以构建通过和新增链路可用为准,不在本轮一口气清旧债 |
+
+## 11. 当前验收口径
+
+当前这一轮不以“OpenClaw 全部接通”为验收,而是以以下条件作为 Phase 1 开工完成标准:
+
+- `zn-ai` 仍能正常构建。
+- 本地 `Host API` 路由骨架已经独立存在,不再全部堆在 `electron/main.ts`。
+- 已有 `gateway-info / gateway-status / session-transcript / file-stage` 这类 `ClawX` 风格本地路由。
+- 文档中的 sub-agent 分工和实际代码开工状态一致。
+
+本轮之后,正式进入 `Phase 2-4` 的并行迁移。
-- `docs/model-chat-migration-plan.md`:更偏“模型对话重构”的早期版本,可继续作为 Renderer/Gateway 迁移参考。
-- `docs/ChatPageMigrationPlan.md`:更偏聊天页面结构与 UI 迁移。
-- `docs/agents.md`:更偏 Agent 系统分析。
-本文件是上述文档的 **OpenClaw 对话能力总计划整合版**,适合作为后续 sub-agent 排活与里程碑跟踪的主文档。
diff --git a/docs/Vue-Exit-Checklist.md b/docs/Vue-Exit-Checklist.md
deleted file mode 100644
index 2aa5762..0000000
--- a/docs/Vue-Exit-Checklist.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# zn-ai Vue Exit Checklist
-
-Goal: move `zn-ai` from "React renderer plus leftover Vue-era assets/config" to a truly `React-only` repo, while keeping build and typecheck green at each step.
-
-## Current Status
-
-- Renderer bootstrap is React-only.
- - `index.html`
- - `src/main.tsx`
-- `src/**` is now the active React renderer tree and no longer reaches back into the removed Vue code.
-- Electron/shared runtime no longer imports from `src/lib/*`, `src/shared/*`, or `src/i18n/locales/*`.
- - active runtime callers now use `@runtime/* -> runtime-shared/*`
-- Legacy compatibility/config cleanup is complete.
- - removed `@vitejs/plugin-vue`
- - removed `unplugin-auto-import`
- - removed Vue module declarations from `global.d.ts`
- - removed stale `src/**` path aliases from Vite and TypeScript
- - moved `openapi-ts-request` output to `src/api`
- - added `src/api/request.ts` as the React-side generated request bridge
-- The old Vue tree is gone end-to-end.
- - deleted `src/main-vue.ts`
- - deleted `src/router/index.ts`
- - deleted `src/permission.ts`
- - deleted `src/App.vue`
- - deleted the remaining `src/**` directory, including `src/main.ts` and `src/framework.ts`
-- Vue-era runtime dependencies are gone from `package.json`.
- - removed `vue`
- - removed `pinia`
- - removed `vue-router`
- - removed `element-plus`
- - removed `vue-i18n`
- - removed `@remixicon/vue`
- - removed `@lucide/vue`
- - removed `vue-codemirror`
- - removed `vue-markdown-render`
- - removed `@vueuse/core`
-
-## What This Means
-
-- The old Vue runtime path is no longer present in the source tree.
-- `src/**` is no longer a live runtime or config boundary.
-- The remaining follow-up is mostly housekeeping, not migration-blocking runtime work.
- - stale historical references in docs
- - optional cleanup of `package-lock.json` if the repo standardizes on `pnpm` only
- - the known bytecode warning when the local Electron binary is missing
-
-## File Plan
-
-### 1. `package.json`
-
-#### Done
-
-- removed the Vue-era runtime/UI dependencies from the manifest
-- refreshed `pnpm-lock.yaml`
-
-### 2. `global.d.ts`
-
-#### Done
-
-- removed `declare module "@stores/*";`
-- removed `declare module "@service/*";`
-- removed `declare module "@utils/*";`
-- removed `declare module "@constant/*";`
-- removed `declare module "vue-router";`
-- removed `declare module "*.vue"`
-- removed `declare module "@remixicon/vue"`
-- removed `declare module "@assets/images/*"`
-
-### 3. `tsconfig.app.json`
-
-#### Done
-
-- removed explicit include for `src/permission.ts`
-- removed `include: "src/**/*.vue"`
-- removed the remaining dead `src/**` path aliases
-
-### 4. `vite.config.ts`
-
-#### Done
-
-- active renderer build is React-only
-- Electron shared runtime uses `@runtime/*`
-- removed the legacy `src/**` alias surface from the Vite config
-
-## Recommended Execution Order
-
-1. Done: remove low-risk compatibility items from `package.json` and `global.d.ts`
-2. Done: remove the dead Vue bootstrap chain
-3. Done: remove the dead Vue page/component/store/request tree
-4. Done: delete `src/main.ts`, `src/framework.ts`, and the remaining dead `src/**` files
-5. Done: trim stale TypeScript/Vite/OpenAPI alias surface that pointed into deleted legacy areas
-6. Done: remove the remaining Vue-era dependencies from `package.json`
-
-## Residual Notes
-
-- Keep `@runtime/* -> runtime-shared/*`, which is now the active Electron/shared-runtime boundary.
-- `package-lock.json` still contains historical Vue-era entries, but the active workspace lockfile is `pnpm-lock.yaml`.
-
-## Next Gate
-
-Vue exit is effectively complete for source/runtime once all of the following remain true:
-
-1. no source file imports any Vue-era runtime package
-2. `package.json` stays free of the Vue-era dependencies and `pnpm-lock.yaml` stays refreshed
-3. Vite/TypeScript/OpenAPI config stays off `src/**`
-4. `pnpm typecheck` and `pnpm build:vite` stay green
diff --git a/docs/Vue-to-React-Replacement-Plan.md b/docs/Vue-to-React-Replacement-Plan.md
deleted file mode 100644
index 7725b8d..0000000
--- a/docs/Vue-to-React-Replacement-Plan.md
+++ /dev/null
@@ -1,472 +0,0 @@
-# zn-ai 对齐 ClawX 的 Vue -> React 平替迁移计划
-
-## 1. 目标与结论
-
-- `zn-ai` 当前前端是完整的 `Vue 3 + Pinia + Vue Router + Element Plus + Tailwind` 体系,不是少量页面级 Vue 组件。
-- `ClawX` 当前前端是完整的 `React 19 + React Router + Zustand + Radix/shadcn + Tailwind` 体系。
-- 如果目标是“对齐 ClawX 使用 React”,建议迁移目标不只是“把 `.vue` 改成 `.tsx`”,而是 **整体对齐前端工程组织方式**:
- - 路由层对齐 `React Router`
- - 状态层对齐 `Zustand`
- - 组件层逐步摆脱 `Element Plus`
- - 页面层按业务模块分波次平替
-- 最终目标是 **React-only**:Vue 只允许作为短期过渡层存在,迁移完成后必须移除。
-- 推荐路径是 **短期双栈过渡、按路由/页面壳分批平替、迁完即收口**,不建议一次性大爆炸重写。
-
-## 2. 当前现状
-
-### 2.1 zn-ai 当前 Vue 技术栈
-
-| 维度 | 当前实现 | 关键路径 |
-| --- | --- | --- |
-| 应用入口 | React-only 兼容引导(`src/main.ts -> src-react/main.tsx`) | `zn-ai/src/main.ts` |
-| 根组件 | `App.tsx + HashRouter + AppRouter` | `zn-ai/src-react/App.tsx` |
-| 路由 | `react-router-dom` | `zn-ai/src-react/router/index.tsx` |
-| 状态管理 | `React stores + shared runtime modules` | `zn-ai/src-react/stores/*` |
-| UI 组件体系 | `React 组件 + Tailwind` | `zn-ai/src-react/components/*` |
-| 页面目录 | React 页面位于 `src-react/pages/*`,旧 Vue 页面树已移除 | `zn-ai/src-react/pages/*` |
-| Electron 交互 | `window.api + hostapi:fetch + gateway:rpc` | `zn-ai/electron/*` `zn-ai/src/lib/*` |
-| 样式 | `Tailwind v4 + 全局 CSS + Element Plus 主题` | `zn-ai/src/styles/*` |
-
-### 2.2 ClawX 当前 React 参照栈
-
-| 维度 | ClawX 做法 | 关键路径 |
-| --- | --- | --- |
-| 应用入口 | `main.tsx` + `HashRouter` | `ClawX/src/main.tsx` |
-| 根组件 | `App.tsx` + `Routes/Route` | `ClawX/src/App.tsx` |
-| 布局 | `MainLayout + Sidebar + TitleBar` | `ClawX/src/components/layout/*` |
-| 状态管理 | `Zustand` | `ClawX/src/stores/*` |
-| UI 组件体系 | `Radix + 自建 ui 组件 + Tailwind` | `ClawX/src/components/ui/*` |
-| 页面目录 | 按页面模块划分 | `ClawX/src/pages/*` |
-| API/Gateway | `host-api.ts` / `gateway-client.ts` / typed stores | `ClawX/src/lib/*` |
-| 样式 | `globals.css + CSS variables + Tailwind utilities` | `ClawX/src/styles/globals.css` |
-
-## 3. 迁移目标
-
-### 3.1 技术目标
-
-1. `zn-ai` Renderer 入口从 Vue 根应用切换为 React 根应用。
-2. 路由系统从 `vue-router` 切换为 `react-router-dom`。
-3. 核心状态逐步从 `Pinia` 迁到 `Zustand`。
-4. 页面与通用组件逐步从 `.vue` 平替为 `.tsx`。
-5. UI 层逐步减少 `Element Plus` 依赖,最终以 Tailwind + React 组件为主。
-6. Electron 主进程、preload、IPC、Host API 相关逻辑尽量保持不动,避免把“框架迁移”和“桌面能力迁移”绑死在同一波次。
-
-### 3.2 业务目标
-
-- 保持 `Home/Chat`、`Agents`、`Skills`、`Cron`、`Scripts`、`Setting`、`Login` 可持续可用。
-- 在迁移过程中不影响 Electron 打包、启动、窗口控制、IPC 调用。
-- React 平替后,后续功能开发可以直接复用 ClawX 的 React 组织方式与组件思想。
-
-## 4. 推荐迁移策略
-
-## 4.1 不推荐:大爆炸式重写
-
-特点:
-
-- 一次性删除 Vue 根应用
-- 一次性切到 React
-- 所有页面同步重写
-
-问题:
-
-- 风险最高
-- 回归面最大
-- 业务停滞时间最长
-- 很难快速验证 Electron 集成链路是否稳定
-
-## 4.2 推荐:短期双栈过渡、分波次平替
-
-特点:
-
-- 在同一仓库临时同时支持 `@vitejs/plugin-vue` 和 `@vitejs/plugin-react`
-- 先把 React 根应用、路由、布局、状态基建搭起来
-- 再按页面分批把 Vue 页面平替成 React 页面
-- React 接管默认入口后,立即进入 Vue 清退阶段
-- 最后删除 Vue 页面、Vue store、Vue Router、Element Plus 与 Vue 构建链
-
-优点:
-
-- 风险可控
-- 可以边迁移边交付
-- 页面出现问题时回滚范围小
-- 更适合 `zn-ai` 这种 Electron 桌面应用
-
-边界要求:
-
-- 双栈只用于迁移窗口期,不作为长期架构存在
-- Phase 2 之后默认 Renderer 入口必须是 React
-- Phase 3 起禁止新增 Vue 页面、Vue store、Element Plus 依赖
-- Phase 5 必须完成 Vue 依赖与 Vue 代码清退,否则不算迁移完成
-
-### 4.3 推荐迁移粒度
-
-建议按这个粒度迁移:
-
-1. 应用壳:入口、路由、主布局、标题栏、侧边栏
-2. 核心页面:`Home/Chat`
-3. 平台页面:`Agents`、`Setting`
-4. 工具页面:`Skills`、`Cron`、`Scripts`
-5. 低频或独立页面:`Knowledge`、`Login`
-6. 最后移除 Vue 运行时与旧依赖
-
-不建议按“单个小组件岛”零散迁移,因为:
-
-- `zn-ai` 的页面级状态和路由耦合很重
-- `Layout + SideMenus + 页面容器 + store` 是一整套
-- 组件岛太细会让 Vue/React 互相嵌套,维护复杂度更高
-
-### 4.4 终态约束
-
-最终态不是 “Vue/React 双栈长期共存”,而是:
-
-- Renderer 只保留 React 入口
-- 路由只保留 `react-router-dom`
-- 状态层只保留 React 可用的 store 体系
-- UI 只保留当前视觉风格对应的 React 组件实现
-- `vue`、`vue-router`、`pinia`、`element-plus`、`@vitejs/plugin-vue` 从生产依赖与构建链中移除
-
-## 5. 工程平替方案
-
-### 5.1 目录策略
-
-推荐迁移期间采用双目录:
-
-```text
-src/ # 保留现有 Vue 代码
-src-react/ # 新增 React 代码
-```
-
-或者更偏最终态的方式:
-
-```text
-src/
- react/
- electron/
- shared/
-```
-
-更推荐第一种,原因是:
-
-- 对当前工程侵入更小
-- 平替期间边界更清晰
-- 方便按页面对照迁移
-
-但这个目录策略只服务于迁移期。最终收口时应执行以下动作之一:
-
-- 将 `src-react/` 合并回最终 Renderer 目录
-- 或者删除旧 `src/` 中仅服务 Vue 的前端代码,只保留共享与 Electron 代码
-
-### 5.2 构建策略
-
-建议分两步:
-
-1. 先在 `vite.config.ts` 里加入 `@vitejs/plugin-react`
-2. 保留 `plugin-vue` 一段时间,直到最后一个 Vue 页面被移除
-
-迁移初期目标不是删 Vue,而是:
-
-- 先让 React 页面可以在 Electron Renderer 中跑起来
-- 再让 React 尽快接手默认入口
-
-构建收口要求:
-
-- 默认启动入口一旦切到 React,就不再新增任何 Vue 侧能力开发
-- 最后一阶段必须移除 `@vitejs/plugin-vue`
-- 打包与开发脚本最终只保留 React 所需链路
-
-### 5.3 状态管理策略
-
-| 当前 | 目标 | 建议 |
-| --- | --- | --- |
-| `Pinia` | `Zustand` | 新 React 页面只写 Zustand,不再新增 Pinia store |
-| 旧 Pinia store | 过渡兼容 | 迁移期间允许 React 通过桥接层复用部分现有逻辑 |
-| 领域状态 | 切分迁移 | 优先迁移 `chat/providers/theme/locale/settings` |
-
-推荐做法:
-
-- 对每个核心领域新增 React 版 store
-- 在短期内允许 Pinia 与 React store 并存
-- 页面迁走后,再删除对应 Pinia store
-- 从 Phase 3 开始禁止新增 Pinia 代码
-
-### 5.4 UI 组件策略
-
-| 当前 | 目标 | 建议 |
-| --- | --- | --- |
-| `Element Plus` | `Tailwind + React 组件` | 不直接一比一平替 Element Plus 组件 API |
-| `Vue 单文件组件` | `TSX 组件` | 先迁页面壳,再抽通用组件 |
-| Tailwind 样式 | 保留 | 继续沿用颜色、间距、布局 token |
-
-建议:
-
-- 不要把 `Element Plus` 在 React 中“硬套”一层兼容壳
-- 更适合借鉴 ClawX 的做法,逐步建设 `src-react/components/ui/*`
-- 先做基础组件:Button、Input、Dialog、Tabs、Tooltip、Dropdown、Toast
-
-### 5.5 共享能力策略
-
-以下模块尽量不重写,只做前端适配:
-
-- `electron/main.ts`
-- `electron/preload/*`
-- `electron/gateway/*`
-- `src/lib/host-api.ts` 的底层契约
-- `src/lib/gateway-client.ts` 的底层契约
-- 绝大部分 `electron/service/*`
-
-换句话说:
-
-- 框架迁移主要发生在 Renderer
-- 主进程和 Electron 基础设施尽量保持稳定
-
-## 6. 页面迁移优先级
-
-### 6.1 P0:先迁基础壳
-
-- `src/main.ts`
-- `src-react/main.tsx`
-- `src-react/App.tsx`
-- `src-react/router/*`
-- `src-react/components/layout/MainLayout.tsx`
-- `src-react/components/layout/Sidebar.tsx`
-- `src-react/components/layout/TitleBar.tsx`
-
-原因:
-
-- 这是 React 页面承接的运行底座
-- 不先迁应用壳,后面页面只能继续挂在 Vue 下
-
-### 6.2 P1:高价值页面
-
-- `src/pages/home/index.vue`
-- `src/pages/home/ChatBox.vue`
-- `src/pages/home/ChatHistory.vue`
-- `src/pages/home/components/chat/*`
-- `src/stores/chat.ts`
-- `src/stores/providers.ts`
-
-原因:
-
-- 首页/对话页是使用频率最高的模块
-- 同时它也是最能直接复用 ClawX React 经验的地方
-
-### 6.3 P2:管理页面
-
-- `src/pages/agents/index.vue`
-- `src/pages/setting/index.vue`
-- `src/pages/skills/index.vue`
-- `src/pages/cron/index.vue`
-- `src/pages/scripts/index.vue`
-
-### 6.4 P3:低频与收尾页面
-
-- `src/pages/knowledge/index.vue`
-- `src/pages/login/index.vue`
-- 零散公用组件
-- Vue/Pinia/Element Plus 清理
-
-## 7. 分阶段实施计划
-
-| 阶段 | 目标 | 主要产出 | 退出标准 |
-| --- | --- | --- | --- |
-| Phase 0 | 冻结迁移边界 | 迁移文档、路由清单、页面资产清单、Vue 退场清单 | 团队对范围、命名、目录、状态策略与 Vue 退场标准达成一致 |
-| Phase 1 | 建短期双栈基建 | React 依赖、React 入口、临时双栈 Vite 配置 | Electron 中可启动 React 根页面 |
-| Phase 2 | React 接管应用壳 | React `App`、React Router、MainLayout、Sidebar、TitleBar、默认入口切换 | Vue 根壳退出默认路径,React 成为默认 Renderer |
-| Phase 3 | 平替核心页 | Home/Chat 与核心 store 迁到 React | 主路径用户完全走 React,Vue 不再承接核心流程 |
-| Phase 4 | 平替剩余页面 | Agents/Setting/Skills/Cron/Scripts/Knowledge/Login 迁到 React | 所有用户可见主页面全部脱离 Vue |
-| Phase 5 | Vue 退场与依赖清理 | 删除 `.vue` 页面、Pinia/Vue Router/Element Plus 依赖与构建链 | 项目代码库不再依赖 Vue 运行时与 Vue 构建插件 |
-| Phase 6 | 回归与交付 | 回归测试、打包验证、文档更新 | Dev/Build/Package 全链路稳定,React-only 终态成立 |
-
-## 8. sub-agent 数量估算
-
-### 8.1 标准推荐编制
-
-- 架构收口 sub-agent:`1`
-- 功能迁移 sub-agent:`5`
-- Vue 清退 sub-agent:`1`
-- 集成验收 sub-agent:`1`
-- 标准总数:`8`
-
-这是最适合当前 `zn-ai` 体量的推荐方案,能覆盖:
-
-- React-only 架构冻结
-- 应用壳迁移
-- 状态与通信层迁移
-- 视觉样式与通用组件平替
-- 高复杂页面迁移
-- Vue 退场
-- 联调验收
-
-### 8.2 扩展并行编制
-
-- 架构收口 sub-agent:`1`
-- 功能迁移 sub-agent:`6`
-- Vue 清退 sub-agent:`1`
-- 集成验收 sub-agent:`1`
-- 扩展总数:`9`
-
-适用于你希望更快并行推进、并把页面迁移与 Vue 清退准备拆得更细的情况。
-
-### 8.3 最小可行编制
-
-- 架构收口 sub-agent:`1`
-- 功能迁移 sub-agent:`4`
-- Vue 清退与集成验收由主协调 agent 兼任
-- 最小总数:`5`
-
-这个方案能做,但节奏会更紧,回归与清尾风险更高。
-
-### 8.4 为什么比“只迁聊天页”需要更多 sub-agent
-
-这次不是单一页面迁移,而是:
-
-- 前端框架替换
-- 路由替换
-- 状态管理替换
-- UI 体系替换
-- 页面级别平替
-- Electron Renderer 构建链调整
-
-因此复杂度明显高于单一业务模块迁移。
-
-## 9. 推荐 sub-agent 分工
-
-### 9.1 标准 8-agent 分工
-
-| 角色 | 数量 | 负责范围 | 建议文件所有权 |
-| --- | --- | --- | --- |
-| A1:React-only 架构收口 | 1 | 冻结迁移边界、禁止新增 Vue 扩面、维护 Vue 退场清单 | 只读分析,不改文件 |
-| M1:构建与入口切换 | 1 | React 依赖、Vite、Renderer 入口切换、保留短期双栈兜底 | `package.json` `vite.config.ts` `tsconfig*` `src/main.ts` `src/framework.ts` |
-| M2:应用壳与路由 | 1 | React `App`、React Router、布局、标题栏、侧栏,并保持当前视觉风格 | `src-react/App.tsx` `src-react/router/*` `src-react/components/layout/*` |
-| M3:共享基础设施 | 1 | i18n、theme、host-api、gateway-client、window api 类型桥接 | `src-react/lib/*` `src-react/stores/*` `src-react/i18n/*` `src-react/types/*` |
-| M4:Home/Chat 主链路 | 1 | Home、Chat、ChatHistory、聊天 store、对话组件,保持当前 UI 布局与交互 | `src-react/pages/Home/*` `src-react/components/chat/*` `src-react/stores/chat/*` |
-| M5:非聊天页面平替 | 1 | Agents、Setting、Skills、Cron、Scripts、Knowledge、Login 等页面平替,视觉沿用当前实现 | `src-react/pages/Agents/*` `src-react/pages/Setting/*` `src-react/pages/Skills/*` `src-react/pages/Cron/*` `src-react/pages/Scripts/*` `src-react/pages/Knowledge/*` `src-react/pages/Login/*` |
-| C1:Vue 清退与依赖下线 | 1 | 删除 `.vue` 页面、Pinia/Vue Router/Element Plus、清理 Vue 构建链与过渡文件 | `src/*.vue` `src/router/*` `src/stores/*` `vite.config.ts` `package.json` |
-| I1:联调与验收 | 1 | 回归、打包验证、迁移文档回填、React-only 验收 | 测试脚本、验收记录、迁移记录 |
-
-### 9.2 扩展 9-agent 分工
-
-如果采用扩展并行编制,可以把标准方案里的 `M5` 再拆成两组:
-
-- 迁移 M5:平台与设置页
-- 迁移 M6:技能与工具页
-
-这样页面并行度更高,更适合在 React 接管默认入口后快速清空剩余 Vue 页面。
-
-### 9.3 当前阶段继续执行的推荐编组
-
-如果从当前阶段继续往下推进,推荐立即投入这 `4` 个执行向 sub-agent,再由主协调 agent 持续整合:
-
-- `Gibbs`:负责 `M1`,继续把默认入口、构建配置和 Vue 兜底边界收紧,禁止双栈长期化。
-- `Mendel`:负责 `M2 + M5`,优先把应用壳、侧栏、标题栏和非聊天页面按当前视觉样式迁到 React。
-- `Dalton`:负责 `M3`,继续打通 React 侧 i18n、theme、host-api、gateway-client 与 settings store。
-- 新增 `C1/M4` 组合 worker:优先迁 `Home/Chat` 主链路,并同步维护 Vue 退场清单,避免聊天页迁完后又回头返工。
-
-## 10. 推荐并行波次
-
-| 波次 | 并行 sub-agent | 说明 |
-| --- | --- | --- |
-| Wave 1 | A1 | 先冻结 React-only 边界、Vue 退场标准与视觉复用策略 |
-| Wave 2 | M1 + M2 + M3 | 一边搭建 React 运行底座,一边准备共享桥接层,并让 React 接手默认入口 |
-| Wave 3 | M4 + M5 | 核心页与剩余页面并行平替,要求沿用当前视觉样式 |
-| Wave 4 | C1 + I1 | 集中执行 Vue 清退、构建链收口、联调与验收 |
-
-如果采用扩展 9-agent 方案,则 `Wave 3` 可改为:
-
-- `M4 + M5 + M6` 并行
-- `C1` 提前介入维护 Vue 删除清单
-- `I1` 提前介入做持续验收
-
-## 11. 文件级拆分建议
-
-### 11.1 M1:构建与短期双栈基建
-
-- `zn-ai/package.json`
-- `zn-ai/vite.config.ts`
-- `zn-ai/tsconfig.app.json`
-- `zn-ai/tsconfig.json`
-
-### 11.2 M2:应用壳与路由
-
-- `zn-ai/src-react/main.tsx`
-- `zn-ai/src-react/App.tsx`
-- `zn-ai/src-react/router/*`
-- `zn-ai/src-react/components/layout/*`
-
-### 11.3 M3:共享基础设施
-
-- `zn-ai/src-react/lib/host-api.ts`
-- `zn-ai/src-react/lib/gateway-client.ts`
-- `zn-ai/src-react/lib/*`
-- `zn-ai/src-react/i18n/*`
-- `zn-ai/src-react/stores/settings.ts`
-
-### 11.4 M4:Home/Chat
-
-- `zn-ai/src-react/pages/Home/*`
-- `zn-ai/src-react/components/chat/*`
-- `zn-ai/src-react/stores/chat/*`
-
-### 11.5 M5:非聊天页面平替
-
-- `zn-ai/src-react/pages/Agents/*`
-- `zn-ai/src-react/pages/Setting/*`
-- `zn-ai/src-react/pages/Login/*`
-- `zn-ai/src-react/pages/Skills/*`
-- `zn-ai/src-react/pages/Cron/*`
-- `zn-ai/src-react/pages/Scripts/*`
-- `zn-ai/src-react/pages/Knowledge/*`
-
-### 11.6 C1:Vue 清退与依赖下线
-
-- 已删除:`zn-ai/src/main-vue.ts`
-- 已删除:`zn-ai/src/router/index.ts`
-- 已删除:`zn-ai/src/permission.ts`
-- 已删除:`zn-ai/src/App.vue`
-- `zn-ai/src/stores/*`
-- `zn-ai/src/components/**/*.vue`
-- `zn-ai/src/pages/**/*.vue`
-- `zn-ai/package.json`
-- `zn-ai/vite.config.ts`
-
-### 11.7 扩展方案 M6:技能与工具页
-
-- `zn-ai/src-react/pages/Skills/*`
-- `zn-ai/src-react/pages/Cron/*`
-- `zn-ai/src-react/pages/Scripts/*`
-- `zn-ai/src-react/pages/Knowledge/*`
-
-## 12. 主要风险
-
-| 风险 | 说明 | 建议 |
-| --- | --- | --- |
-| Vue/React 双栈期间复杂度上升 | 两套路由、样式、状态会短期并存 | 严格限定过渡期目录与职责边界,并对双栈设置明确退出时间点 |
-| Element Plus 替换成本被低估 | 不是只替换组件,还会影响交互与样式结构 | 先迁页面壳和逻辑,再逐步建设 React UI 库 |
-| Pinia 逻辑复制导致状态漂移 | 同一业务状态在 Vue/React 两边各维护一套容易分叉 | 核心领域先做桥接层,明确单一真源 |
-| 页面迁移顺序不当 | 先迁低价值页会拉长工期,主路径收益低 | 先壳后首页,再平台页 |
-| 打包链路回归 | Electron Renderer 切换入口后容易影响 dev/build/package | M1 和 I1 都要覆盖打包验证 |
-
-## 13. 验收标准
-
-- React 入口可以作为默认 Renderer 启动入口。
-- 主布局、路由、标题栏、侧边导航全部由 React 承接。
-- `Home/Chat`、`Agents`、`Setting`、`Skills`、`Cron`、`Scripts` 至少完成 React 版主路径迁移。
-- Electron IPC、Host API、Gateway 调用在 React 页面中保持可用。
-- 迁移完成后,项目必须移除 `vue`、`pinia`、`vue-router`、`element-plus` 与 `@vitejs/plugin-vue`。
-- `pnpm dev`、`pnpm build`、打包主流程可正常运行。
-
-## 14. 最终建议
-
-- **推荐方案**:以 `React-only` 为唯一终态,短期双栈仅作为过渡。
-- **标准推荐 sub-agent 数量**:`8` 个。
-- **扩展并行数量**:`9` 个。
-- **最小可行数量**:`5` 个。
-- **最重要的执行原则**:先让 React 接管默认入口,再迁高价值页面,随后立即清退 Vue。
-
-从投入产出比来看,最值得优先平替的是:
-
-1. 应用壳与路由
-2. Home/Chat
-3. Agents/Setting
-
-只要这三块完成,`zn-ai` 就已经从“Vue 项目”实质性切到“React 项目”了;剩余工作不再是“是否保留 Vue”的讨论,而是明确执行 Vue 清退与 React-only 收口。
diff --git a/docs/WindowControlsMigrationPlan.md b/docs/WindowControlsMigrationPlan.md
deleted file mode 100644
index 9b12458..0000000
--- a/docs/WindowControlsMigrationPlan.md
+++ /dev/null
@@ -1,423 +0,0 @@
-# Window Controls 迁移计划(对齐 ClawX)
-
-> 目标:将 zn-ai 的窗口控制体系**全面对齐 ClawX**,废弃 zn-ai 现有相关代码,按平台重建跨平台标题栏方案。UI 视觉延用 zn-ai 设计,图标库统一使用 `@lucide/vue`。
-
----
-
-## 1. 现状分析
-
-### 1.1 ClawX 实现思路
-
-| 平台 | 方案 | 关键配置 |
-|------|------|----------|
-| **macOS** | 原生 traffic lights | `titleBarStyle: 'hiddenInset'` |
-| **Windows** | 自定义 React 标题栏 | `titleBarStyle: 'hidden'` + `frame: false` |
-| **Linux** | 保留原生窗口边框 | `frame: true`(默认) |
-
-- **macOS**:前端仅渲染一个 `drag-region`(高度 40px),红绿灯由系统原生绘制。
-- **Windows**:前端自定义最小化、最大化/恢复、关闭三个按钮,通过 `invoke` IPC 调用主进程 `BrowserWindow` API。
-- **Linux**:出于 IME 兼容性考虑,不隐藏原生边框,前端不渲染任何自定义标题栏。
-
-### 1.2 zn-ai 现状(待废弃)
-
-| 项 | 现状 | 决策 |
-|----|------|------|
-| 主进程窗口配置 | 所有平台统一 `frame: false` + `titleBarStyle: 'hidden'` | **废弃,重写** |
-| 前端组件 | `HeaderBar/index.vue` 同时包含 macOS 自定义按钮 + Windows 自定义按钮 | **废弃,删除** |
-| 窗口管理 Hook | `useWinManager.ts` 封装 `ref` + `onMounted` 状态管理 | **废弃,删除** |
-| 拖拽区域组件 | `DragRegion/index.vue` 独立组件 | **废弃,删除**(直接内联到 TitleBar) |
-| Layout 集成 | `Layout/index.vue` 嵌套 `header-bar` + `drag-region` | **重写** |
-| IPC 通信 | `WINDOW_CLOSE` / `WINDOW_MINIMIZE` / `WINDOW_MAXIMIZE` / `IS_WINDOW_MAXIMIZED`(`send`/`on` 混合) | **废弃,改为 `invoke/handle` 对齐 ClawX** |
-| 图标库 | `@iconify/vue` + `@iconify-json/material-symbols` | **废弃,替换为 `@lucide/vue`** |
-
-### 1.3 核心差异
-
-1. **macOS**:ClawX 使用原生 traffic lights;zn-ai 使用自定义按钮(将原生按钮移出可视区域),行为不完整。
-2. **Linux**:ClawX 保留原生边框;zn-ai 隐藏了原生边框,存在 IME 兼容性风险。
-3. **IPC 模式**:ClawX 使用 `ipcMain.handle` + `invokeIpc`(请求-响应),zn-ai 使用 `ipcMain.on` + `ipcRenderer.send`(事件驱动),模式不统一。
-4. **组件层级**:ClawX 的 `TitleBar` 是一个自包含组件,内部自带 `drag-region`/`no-drag` 逻辑;zn-ai 将其拆成了 `HeaderBar` + `DragRegion` + `useWinManager` 三个文件,过度拆分。
-
----
-
-## 2. 迁移方案
-
-### 2.1 设计原则
-
-- **全面对齐 ClawX**:架构、IPC 模式、平台分支逻辑直接复刻 ClawX 实现。
-- **zn-ai 既有代码可抛弃**:`HeaderBar`、`DragRegion`、`useWinManager` 及相关 IPC 直接删除,不用做兼容改造。
-- **UI 视觉延用 zn-ai**:Windows 按钮的 hover 颜色、尺寸保持 zn-ai 现有设计(`#999` / `#ff0000`)。
-- **图标统一使用 `@lucide/vue`**:废弃 `@iconify/vue`。
-
-### 2.2 废弃清单(Delete List)
-
-| 文件/目录 | 说明 |
-|-----------|------|
-| `src/components/HeaderBar/index.vue` | 既有自定义按钮组件,完全废弃 |
-| `src/components/DragRegion/index.vue` | 拖拽区域组件,逻辑内联到新的 `TitleBar` |
-| `src/hooks/useWinManager.ts` | 窗口管理 Hook,逻辑内联到新的 `TitleBar` |
-| `src/main.ts` 中 `HeaderBar` / `DragRegion` 的全局注册 | 删除全局组件注册 |
-
-### 2.3 废弃的 IPC 与常量
-
-zn-ai 现有窗口控制 IPC(基于 `send`/`on`):
-```ts
-// constants.ts 中废弃以下事件
-WINDOW_MINIMIZE = 'window-minimize'
-WINDOW_MAXIMIZE = 'window-maximize'
-WINDOW_CLOSE = 'window-close'
-IS_WINDOW_MAXIMIZED = 'is-window-maximized'
-```
-
-**废弃后替换为 ClawX 风格的 `invoke` channel**:
-```ts
-// 新增(对齐 ClawX)
-'window:minimize'
-'window:maximize'
-'window:close'
-'window:isMaximized'
-```
-
-> 若项目中其他模块也使用了旧的 `WINDOW_MINIMIZE` 等常量,需要一并迁移。经排查,旧常量仅在 `HeaderBar` / `useWinManager` / `window-service` / `preload` 中使用,可随本次重构一并删除。
-
-### 2.4 新建/重写清单(Create/Rewrite List)
-
-| 新建/重写项 | 说明 |
-|-------------|------|
-| `src/components/layout/TitleBar/index.vue` | **新建**。对齐 ClawX `TitleBar.tsx`,Vue 实现。 |
-| `src/components/layout/Layout/index.vue` | **重写**。移除 `header-bar`/`drag-region` 引用,改用 `TitleBar`。 |
-| `electron/service/window-service/index.ts` | **重写 `SHARED_WINDOW_OPTIONS`**。按平台设置 `frame`/`titleBarStyle`/`trafficLightPosition`。 |
-| `electron/main.ts` 或新建 `electron/main/ipc-handlers.ts` | **新增/重写**。注册 `window:*` 的 `ipcMain.handle`。 |
-| `electron/preload/index.ts` | **重写**。移除旧 `send/on` API,新增 `invoke('window:*')` 和 `platform`。 |
-| `src/lib/api-client.ts`(或类似文件) | **新增 `invokeIpc` 辅助函数**。对齐 ClawX 的 `invokeIpc` 调用风格。 |
-| `src/styles/index.css` | 保留 `.drag-region` / `.no-drag`,供 `TitleBar` 使用。 |
-
-### 2.5 主进程窗口配置改造
-
-**目标代码(对齐 ClawX)**:
-```ts
-// electron/service/window-service/index.ts
-const isMac = process.platform === 'darwin';
-const isWindows = process.platform === 'win32';
-const useCustomTitleBar = isWindows;
-
-const SHARED_WINDOW_OPTIONS = {
- frame: isMac || !useCustomTitleBar,
- titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
- trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
- show: false,
- title: 'NIANXX',
- darkTheme: themeManager.isDark,
- backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF',
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- sandbox: true,
- backgroundThrottling: false,
- preload: MAIN_WINDOW_VITE_DEV_SERVER_URL
- ? path.join(process.cwd(), 'dist-electron/preload/preload.js')
- : path.join(__dirname, 'preload.js'),
- },
-} as BrowserWindowConstructorOptions;
-```
-
-**注意**:
-- 删除旧的 `trafficLightPosition: { x: -100, y: -100 }`。
-- macOS 使用 `hiddenInset` 后,红绿灯由系统原生绘制,前端无需硬编码左侧占位。
-
-### 2.6 新增 IPC Handlers(对齐 ClawX)
-
-新建或复用文件(建议新建 `electron/ipc/window-handlers.ts`,方便模块化):
-
-```ts
-import { ipcMain, BrowserWindow } from 'electron';
-
-export function registerWindowHandlers(mainWindow: BrowserWindow): void {
- ipcMain.handle('window:minimize', () => {
- mainWindow.minimize();
- });
-
- ipcMain.handle('window:maximize', () => {
- if (mainWindow.isMaximized()) {
- mainWindow.unmaximize();
- } else {
- mainWindow.maximize();
- }
- });
-
- ipcMain.handle('window:close', () => {
- mainWindow.close();
- });
-
- ipcMain.handle('window:isMaximized', () => {
- return mainWindow.isMaximized();
- });
-}
-```
-
-在 `electron/main.ts` 或 `electron/wins/index.ts` 的适当时机调用 `registerWindowHandlers(mainWindow)`。
-
-### 2.7 Preload 改造(对齐 ClawX `invokeIpc` 风格)
-
-```ts
-// electron/preload/index.ts
-const api: WindowApi = {
- // ... 保留既有 API
-
- platform: process.platform,
-
- // 窗口控制 — 对齐 ClawX 的 invoke 风格
- windowMinimize: () => ipcRenderer.invoke('window:minimize'),
- windowMaximize: () => ipcRenderer.invoke('window:maximize'),
- windowClose: () => ipcRenderer.invoke('window:close'),
- windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'),
-
- // 移除旧的 minimizeWindow / maximizeWindow / closeWindow / onWindowMaximized / isWindowMaximized
-}
-```
-
-### 2.8 前端 `invokeIpc` 辅助函数(可选,强烈建议)
-
-ClawX 使用统一的 `invokeIpc` 函数调用主进程,zn-ai 建议新增该辅助函数以保持一致风格:
-
-```ts
-// src/lib/api-client.ts
-export function invokeIpc(channel: string, ...args: any[]): Promise {
- return window.api.invoke(channel, ...args);
-}
-```
-
-> 如果 zn-ai 已有类似封装(如 `window.api.invoke`),可直接使用,无需额外文件。
-
-### 2.9 新建 TitleBar 组件(对齐 ClawX)
-
-**新建文件**:`src/components/layout/TitleBar/index.vue`
-
-```vue
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-**样式说明**:
-- `.drag-region` / `.no-drag` 复用 `src/styles/index.css` 中已有定义。
-- `bg-background` / `text-muted-foreground` 等 Token 若 zn-ai 未定义,可替换为具体色值(如 `bg-white`、`text-[#525866]`),保持视觉一致即可。
-
-### 2.10 Layout 重写
-
-**重写文件**:`src/components/Layout/index.vue`
-
-```vue
-
-
-
-
-
-```
-
-**注意**:`src/main.ts` 中删除 `HeaderBar` 和 `DragRegion` 的全局注册。
-
-### 2.11 Icon 体系迁移:iconify → @lucide/vue
-
-**依赖变更**:
-```bash
-# 安装
-pnpm add @lucide/vue
-
-# 卸载(确认项目内无其他 iconify 引用后执行)
-pnpm remove @iconify/vue @iconify-json/material-symbols
-```
-
-**图标映射关系**:
-
-| 功能 | 原 iconify icon | lucide-vue 替代 |
-|------|-----------------|-----------------|
-| 最小化 | `material-symbols:check-indeterminate-small` | `Minus` |
-| 最大化 | `material-symbols:chrome-maximize-outline-sharp` | `Square` |
-| 恢复 | `material-symbols:chrome-restore-outline-sharp` | `Copy` |
-| 关闭 | `material-symbols:close` | `X` |
-
-**类型声明清理**:
-删除 `global.d.ts` 中 `@iconify/vue` 的 `declare module` 段落(约第 230-240 行)。
-
----
-
-## 3. 改造清单(Task Breakdown)
-
-### Task 1:主进程窗口配置重写
-- **文件**:`electron/service/window-service/index.ts`
-- **内容**:
- 1. 引入 `isMac` / `isWindows` 平台判断。
- 2. 重写 `SHARED_WINDOW_OPTIONS` 的 `frame`、`titleBarStyle`、`trafficLightPosition`。
- 3. 移除 `trafficLightPosition: { x: -100, y: -100 }`。
-- **验收标准**:
- - macOS 窗口出现原生 traffic lights。
- - Windows 窗口无原生标题栏。
- - Linux 窗口保留原生标题栏。
-
-### Task 2:新增 IPC Handlers(对齐 ClawX)
-- **新建文件**:`electron/ipc/window-handlers.ts`(或写入现有 ipc 文件)
-- **内容**:
- 1. 使用 `ipcMain.handle` 注册 `window:minimize`、`window:maximize`、`window:close`、`window:isMaximized`。
- 2. 在 `electron/wins/index.ts` 或 `electron/main.ts` 中引入并调用 `registerWindowHandlers(mainWindow)`。
-- **验收标准**:
- - 通过 DevTools Console 测试 `await window.api.windowIsMaximized()` 返回布尔值。
- - `windowMinimize` / `windowMaximize` / `windowClose` 调用后窗口行为正确。
-
-### Task 3:Preload 重写
-- **文件**:`electron/preload/index.ts`
-- **内容**:
- 1. 移除旧的 `minimizeWindow`、`maximizeWindow`、`closeWindow`、`onWindowMaximized`、`isWindowMaximized`。
- 2. 新增 `platform`、`windowMinimize`、`windowMaximize`、`windowClose`、`windowIsMaximized`。
- 3. 同步更新 `global.d.ts` 中的 `WindowApi` 类型定义。
-- **验收标准**:
- - 渲染进程 TypeScript 无报错。
- - `window.api.platform` 能正确读取当前平台。
-
-### Task 4:前端组件废弃与重建
-- **废弃文件**:
- - `src/components/HeaderBar/index.vue`(删除)
- - `src/components/DragRegion/index.vue`(删除)
- - `src/hooks/useWinManager.ts`(删除)
-- **新建文件**:
- - `src/components/layout/TitleBar/index.vue`(对齐 ClawX `TitleBar.tsx`)
-- **重写文件**:
- - `src/components/Layout/index.vue`(改用 `TitleBar`,Linux 下隐藏)
- - `src/main.ts`(移除 `HeaderBar` / `DragRegion` 全局注册)
-- **验收标准**:
- - macOS 仅显示顶部 40px 拖拽区域,无自定义按钮。
- - Windows 显示自定义最小化/最大化/关闭按钮,功能正常。
- - Linux 不渲染任何自定义标题栏元素。
-
-### Task 5:依赖清理
-- **文件**:`package.json`、`global.d.ts`
-- **内容**:
- 1. 安装 `@lucide/vue`。
- 2. 卸载 `@iconify/vue` 和 `@iconify-json/material-symbols`。
- 3. 删除 `global.d.ts` 中 `@iconify/vue` 的类型声明。
-- **验收标准**:
- - `pnpm install` 后无 iconify 包残留。
- - 项目编译通过。
-
-### Task 6:跨平台回归测试
-- **内容**:
- 1. **macOS**:验证 traffic lights 位置、拖拽行为、窗口最大化/恢复、关闭。
- 2. **Windows**:验证自定义按钮 hover 样式、最大化/恢复状态切换、最小化到任务栏、关闭。
- 3. **Linux**:验证原生边框存在、无自定义标题栏残留、窗口操作正常。
-
----
-
-## 4. 工作量评估(Sub Agent 分配)
-
-本任务涉及 **Electron 主进程**、**前端组件重建**、**跨平台回归** 三个独立领域,推荐由 **3 个 Sub Agent** 并行/串行完成:
-
-### Agent 1:Electron 主进程专家
-- **职责**:Task 1 + Task 2 + Task 3
-- **技能要求**:熟悉 Electron BrowserWindow 选项、IPC `handle/invoke` 模式、preload 安全模型。
-- **预计耗时**:2~3 小时
-- **产出**:`window-service` + `ipc-handlers` + `preload` 的改造 PR。
-
-### Agent 2:Vue 前端组件专家
-- **职责**:Task 4 + Task 5
-- **技能要求**:熟悉 Vue 3、组件重构、Tailwind CSS、依赖清理。
-- **预计耗时**:2~4 小时
-- **产出**:`TitleBar` / `Layout` 重建 PR + 依赖清理补丁。
-
-### Agent 3:跨平台测试与集成专家
-- **职责**:Task 6(回归测试)+ 边缘情况修复
-- **技能要求**:能在 macOS / Windows / Linux 环境下运行 Electron 应用,熟悉 frameless 窗口陷阱。
-- **预计耗时**:2~4 小时(取决于平台覆盖度)
-- **产出**:测试报告 + 对 Agent 1/2 PR 的修复补丁。
-
----
-
-## 5. 风险与注意事项
-
-1. **macOS traffic light 位置**:`hiddenInset` 默认内边距可能因 Electron 版本略有差异,建议通过 `trafficLightPosition` 微调至 `{x: 16, y: 16}`(与 ClawX 一致)。
-2. **Linux 原生边框与主题**:若 zn-ai 深色模式下 Linux 原生标题栏颜色不匹配,可后续通过 GTK 主题或 `darkTheme` 选项优化,但不在本次核心迁移范围内。
-3. **Windows 最大化白边**:`frame: false` 的窗口在 Windows 最大化时可能出现 1px 白边。ClawX 未做特殊处理,可后续按需优化。
-4. **关闭行为一致性**:zn-ai 主窗口关闭逻辑原会根据 `MINIMIZE_TO_TRAY` 配置决定 `hide()` 或 `close()`。改造后 `window:close` 直接调用 `mainWindow.close()`,若需保留托盘最小化逻辑,可在 `window:close` handler 中判断 `MINIMIZE_TO_TRAY` 并调用 `mainWindow.hide()` 阻止默认关闭。
-5. **旧常量清理**:若 `IPC_EVENTS` 中 `WINDOW_MINIMIZE` / `WINDOW_MAXIMIZE` / `WINDOW_CLOSE` / `IS_WINDOW_MAXIMIZED` 被其他未发现的模块引用,删除后会导致编译失败。建议在删除前用全局搜索再次确认。
-
----
-
-## 6. 参考资料
-
-- ClawX 主窗口创建:`ClawX/electron/main/index.ts`
-- ClawX TitleBar 组件:`ClawX/src/components/layout/TitleBar.tsx`
-- ClawX 窗口 IPC:`ClawX/electron/main/ipc-handlers.ts`(`registerWindowHandlers`)
-- zn-ai 现有窗口服务:`zn-ai/electron/service/window-service/index.ts`
-- zn-ai 现有 HeaderBar:`zn-ai/src/components/HeaderBar/index.vue`
diff --git a/docs/agents.md b/docs/agents.md
deleted file mode 100644
index 5b5e72f..0000000
--- a/docs/agents.md
+++ /dev/null
@@ -1,415 +0,0 @@
-# ClawX项目Agent实现分析报告与zn-ai Agent开发计划
-
-## 一、ClawX项目Agent实现全面分析报告
-
-### 1. 架构概述
-
-ClawX项目采用**模块化多Agent架构**,每个Agent拥有独立的工作区隔离机制、文件系统空间和会话管理系统。该架构旨在为OpenClaw AI代理运行时提供图形化管理界面,支持多Agent并发执行、任务可视化展示和外部频道集成。
-
-### 2. 核心组件
-
-| 组件 | 文件路径 | 功能描述 |
-|------|----------|---------|
-| **Agent配置管理器** | `electron/utils/agent-config.ts` | 管理Agent的工作区、文件系统、配置读写、频道绑定 |
-| **Agent API路由层** | `electron/api/routes/agents.ts` | 提供RESTful API接口(创建、更新、删除、绑定频道等) |
-| **Agent状态管理** | `src/stores/agents.ts` | 使用Zustand管理Agent状态,提供类型安全的操作函数 |
-| **Agent类型定义** | `src/types/agent.ts` | 定义`AgentSummary`、`AgentsSnapshot`等核心数据结构 |
-| **Agent UI管理界面** | `src/pages/Agents/index.tsx` | 图形化Agent管理界面,支持创建、配置、删除Agent |
-| **Gateway连接管理器** | `electron/gateway/manager.ts` | 管理与OpenClaw Gateway的WebSocket连接和进程生命周期 |
-| **任务可视化系统** | `src/pages/Chat/task-visualization.ts` | 解析Agent执行步骤,生成可视化任务执行树 |
-| **会话管理API** | `electron/api/routes/sessions.ts` | 处理会话数据加载、删除和转录文件管理 |
-
-### 3. 工作流程
-
-#### 3.1 Agent创建流程
-```
-用户通过UI输入Agent名称和选项
-系统生成唯一Agent ID和独立工作区目录
-创建配置文件并初始化文件系统结构
-可选择从主Agent继承工作区内容
-```
-
-#### 3.2 消息路由流程
-```
-用户通过频道(Telegram、Slack等)发送消息
-系统根据频道绑定规则路由到对应Agent
-支持`@agent`语法直接指定目标Agent
-消息通过Gateway转发给OpenClaw运行时
-```
-
-#### 3.3 任务执行流程
-```
-Agent接收任务后创建会话
-执行过程中生成任务步骤(思考、工具调用、系统操作)
-支持创建子Agent处理复杂任务,形成执行树结构
-任务状态实时可视化展示
-```
-
-#### 3.4 工作区隔离机制
-```
-~/.openclaw/
-├── agents/
-│ ├── {agentId}/
-│ │ ├── agent/ # Agent运行时目录
-│ │ ├── sessions/ # 会话存储
-│ │ └── config.json # Agent配置
-├── workspace-{agentId}/ # Agent专用工作区目录
-│ ├── .clawx-managed/ # ClawX管理标记
-│ ├── tasks/ # 任务数据
-│ └── files/ # 工作文件
-```
-
-### 4. 关键技术
-
-#### 4.1 JSON-RPC 2.0通信协议
-- 使用标准化协议与OpenClaw Gateway通信
-- 支持双向通信和事件通知机制
-- 消息格式:`{ type: "req", id: "...", method: "...", params: {...} }`
-
-#### 4.2 任务可视化系统
-- 从消息历史解析任务执行步骤
-- 支持`thinking`、`tool`、`system`、`subagent`等步骤类型
-- 自动构建执行树拓扑结构,展示父子关系
-
-#### 4.3 子Agent执行树
-- 支持`sessions_spawn`工具创建子Agent
-- 父子Agent形成任务执行树结构
-- 子Agent可独立工作并返回结果给父Agent
-
-#### 4.4 频道绑定机制
-- Agent可绑定到多种外部频道(Telegram、Slack、Discord、WeChat等)
-- 支持账号级别的精细绑定控制
-- 消息自动路由到对应Agent
-
-#### 4.5 工作区文件系统隔离
-- 每个Agent拥有独立的文件系统环境
-- 支持工作区克隆和继承
-- 确保任务执行的隔离性和安全性
-
-#### 4.6 会话管理系统
-- 支持会话历史持久化存储
-- 会话文件使用JSONL格式存储
-- 支持会话删除和转录文件管理
-
-### 5. 设计模式
-
-#### 5.1 配置驱动设计
-- Agent行为通过JSON配置文件管理
-- 支持运行时配置更新
-- 配置变更触发Gateway重新加载
-
-#### 5.2 关注点分离
-- 前端UI与后端逻辑分离
-- Agent管理与任务执行分离
-- 通信协议与业务逻辑分离
-
-#### 5.3 可扩展架构
-- 支持多Agent并发执行
-- 支持自定义技能和工具
-- 支持多种外部频道集成
-
-#### 5.4 安全性设计
-- 工作区文件系统隔离
-- 敏感数据加密存储
-- API密钥的安全管理
-
-#### 5.5 用户友好性
-- 图形化Agent管理界面
-- 任务可视化展示
-- 多语言国际化支持
-
-### 6. 技术栈
-
-| 技术领域 | 具体技术 |
-|----------|----------|
-| **桌面框架** | Electron 40+ |
-| **前端框架** | React 19 + TypeScript |
-| **样式系统** | Tailwind CSS + shadcn/ui |
-| **状态管理** | Zustand |
-| **构建工具** | Vite + electron-builder |
-| **测试框架** | Vitest + Playwright |
-| **动画库** | Framer Motion |
-| **图标库** | Lucide React |
-| **国际化** | i18next + react-i18next |
-| **OpenClaw集成** | @openclaw/gateway-client |
-
-### 7. 应用场景
-
-1. **多Agent协作**:多个Agent分工合作处理复杂任务
-2. **频道集成**:通过外部通讯平台与用户交互
-3. **任务可视化**:实时监控AI任务执行过程
-4. **工作区管理**:为不同任务类型创建专用工作环境
-5. **模型配置**:为不同Agent配置专用AI模型
-
-### 8. 总结与评估
-
-#### 8.1 技术优势
-1. **完整的工作区隔离**:为每个Agent提供独立的文件系统环境,确保任务执行的隔离性和安全性
-2. **灵活的通信机制**:基于JSON-RPC 2.0的标准化协议,支持复杂的消息路由和会话管理
-3. **可视化任务管理**:创新的任务可视化系统,使复杂的AI任务执行过程透明化、可监控
-4. **模块化架构设计**:清晰的层次结构,便于功能扩展和维护
-
-#### 8.2 创新亮点
-1. **`@agent`直接路由**:用户可以直接通过`@agent`语法将消息路由到特定Agent,提高交互效率
-2. **子Agent执行树**:支持创建子Agent处理复杂任务,形成任务执行树结构
-3. **多通道绑定**:Agent可以绑定到多种通信渠道,实现统一的管理界面
-4. **智能配置继承**:支持Agent工作区和配置的克隆与继承,便于Agent模板化管理
-
-#### 8.3 架构成熟度
-ClawX的Agent架构展现了较高的工程成熟度:
-- **完整的类型安全**:TypeScript全面覆盖,提供编译时类型检查
-- **完善的测试覆盖**:包含单元测试、集成测试和E2E测试
-- **良好的文档支持**:详细的代码注释和架构说明
-- **国际化支持**:多语言界面,支持中文、英文、日文等
-
----
-
-## 二、zn-ai项目Agent系统开发计划
-
-基于ClawX项目的先进架构分析,结合zn-ai项目的实际需求,制定以下Agent系统开发计划。
-
-### 1. 项目背景与目标
-
-#### 1.1 项目背景
-zn-ai项目目前已具备基础的Electron应用框架、构建系统、国际化支持和基本的AI功能。为提升产品竞争力,需要引入多Agent系统,支持以下功能:
-- 多任务并行处理能力
-- 任务执行过程可视化
-- 外部渠道集成(如企业微信、钉钉等)
-- 工作区隔离管理
-
-#### 1.2 核心目标
-1. **构建模块化Agent系统**:借鉴ClawX的架构设计,实现可扩展的多Agent管理框架
-2. **集成任务可视化**:提供用户友好的任务执行监控界面
-3. **支持工作区隔离**:为不同任务类型创建独立的工作环境
-4. **实现频道绑定**:支持与企业通讯工具集成
-
-### 2. 技术选型与架构设计
-
-#### 2.1 技术选型
-| 技术组件 | 选型方案 | 说明 |
-|----------|----------|------|
-| **前端框架** | React + TypeScript | 保持与现有技术栈一致 |
-| **状态管理** | Zustand | 轻量级状态管理,与ClawX保持一致 |
-| **UI组件库** | shadcn/ui + Tailwind CSS | 使用现有样式系统 |
-| **通信协议** | JSON-RPC 2.0 | 标准化协议,便于与后端集成 |
-| **数据存储** | 本地文件系统 + SQLite | 会话历史和配置存储 |
-| **国际化** | i18next | 与现有国际化方案集成 |
-
-#### 2.2 架构设计
-```
-zn-ai Agent系统架构
-├── 前端层 (Renderer)
-│ ├── Agent管理界面
-│ ├── 任务可视化组件
-│ ├── 频道绑定配置
-│ └── 工作区浏览器
-├── 业务逻辑层 (Main Process)
-│ ├── Agent配置管理器
-│ ├── 会话管理器
-│ ├── 频道路由引擎
-│ └── Gateway通信代理
-├── 数据存储层
-│ ├── 配置文件 (JSON)
-│ ├── 会话文件 (JSONL)
-│ ├── 工作区文件系统
-│ └── SQLite数据库 (可选)
-└── 外部集成层
- ├── 企业微信API
- ├── 钉钉机器人
- ├── 飞书Webhook
- └── 自定义Webhook
-```
-
-### 3. 核心功能模块
-
-#### 3.1 Agent管理模块
-- **Agent创建/删除**:支持创建新Agent,分配独立工作区
-- **Agent配置**:模型选择、参数设置、技能配置
-- **Agent状态监控**:运行状态、资源使用、任务队列
-
-#### 3.2 任务可视化模块
-- **执行步骤解析**:从AI响应中提取思考过程、工具调用
-- **拓扑结构展示**:父子Agent关系、任务依赖关系
-- **实时状态更新**:任务进度、错误信息、执行结果
-
-#### 3.3 工作区管理模块
-- **工作区创建**:为每个Agent创建独立文件空间
-- **文件同步**:支持工作区内容克隆和同步
-- **资源隔离**:确保不同Agent间的文件系统隔离
-
-#### 3.4 频道集成模块
-- **多渠道支持**:企业微信、钉钉、飞书、自定义Webhook
-- **消息路由**:根据规则将消息路由到指定Agent
-- **账号绑定**:支持多账号、多群组绑定
-
-#### 3.5 会话管理模块
-- **会话持久化**:保存完整的对话历史
-- **会话恢复**:支持从历史记录恢复执行状态
-- **会话分析**:提供会话统计数据和分析报告
-
-### 4. 实施路线图
-
-#### 第一阶段:基础框架搭建 (预计2-3周)
-1. **项目分析与规划**
- - 分析zn-ai现有架构,确定集成点
- - 设计Agent系统数据库 schema
- - 制定详细的技术方案
-
-2. **核心组件开发**
- - Agent配置管理器 (参考ClawX `agent-config.ts`)
- - 基础类型定义 (Agent, Session, Channel等)
- - 状态管理Store (使用Zustand)
-
-3. **基础界面开发**
- - Agent列表页面
- - Agent创建/编辑表单
- - 基础的任务展示组件
-
-#### 第二阶段:核心功能实现 (预计3-4周)
-1. **工作区隔离系统**
- - 实现工作区目录管理
- - 文件系统隔离机制
- - 工作区同步功能
-
-2. **任务可视化系统**
- - 消息解析引擎 (参考ClawX `task-visualization.ts`)
- - 执行步骤可视化组件
- - 实时状态更新机制
-
-3. **会话管理系统**
- - 会话存储格式设计 (JSONL)
- - 会话历史管理界面
- - 会话导入/导出功能
-
-#### 第三阶段:高级功能集成 (预计3-4周)
-1. **频道绑定系统**
- - 企业微信集成
- - 钉钉机器人集成
- - 消息路由引擎
-
-2. **子Agent系统**
- - 子Agent创建与管理
- - 任务分解与分配
- - 结果汇总机制
-
-3. **性能优化与稳定性**
- - 大规模会话性能优化
- - 错误处理与恢复机制
- - 内存和资源管理
-
-#### 第四阶段:测试与优化 (预计2周)
-1. **功能测试**
- - 单元测试覆盖核心组件
- - 集成测试验证系统交互
- - E2E测试模拟用户场景
-
-2. **性能测试**
- - 多Agent并发性能测试
- - 大文件工作区性能测试
- - 长时间运行稳定性测试
-
-3. **用户体验优化**
- - 界面交互优化
- - 错误提示改进
- - 文档编写与用户引导
-
-### 5. 技术挑战与应对策略
-
-#### 5.1 技术挑战
-1. **工作区隔离安全性**:确保不同Agent间的完全隔离
-2. **大规模会话管理**:高效存储和检索大量会话数据
-3. **实时任务可视化**:复杂执行步骤的实时解析和展示
-4. **多频道同步**:多平台消息的同步和一致性
-
-#### 5.2 应对策略
-1. **采用进程级隔离**:对于高安全需求场景,考虑使用独立进程
-2. **分页和索引优化**:会话数据采用分页加载和高效索引
-3. **增量更新机制**:任务可视化采用增量更新,减少渲染开销
-4. **消息队列和去重**:频道消息使用队列处理和去重机制
-
-### 6. 成功指标与验收标准
-
-#### 6.1 功能指标
-- [ ] 支持至少5个Agent并发执行
-- [ ] 工作区隔离100%安全
-- [ ] 任务可视化延迟 < 500ms
-- [ ] 支持至少3种外部频道集成
-- [ ] 会话加载时间 < 2秒 (100条记录)
-
-#### 6.2 性能指标
-- [ ] Agent创建时间 < 3秒
-- [ ] 内存占用增量 < 50MB/Agent
-- [ ] 消息路由延迟 < 100ms
-- [ ] 系统可用性 > 99.9%
-
-#### 6.3 用户体验指标
-- [ ] 界面响应时间 < 200ms
-- [ ] 操作成功率 > 99%
-- [ ] 用户满意度评分 > 4.5/5
-- [ ] 新手引导完成率 > 90%
-
-### 7. 风险管理
-
-#### 7.1 技术风险
-- **风险**:工作区隔离机制可能影响性能
-- **缓解**:采用轻量级隔离方案,性能关键路径优化
-- **备选**:提供配置选项,允许用户选择隔离级别
-
-#### 7.2 时间风险
-- **风险**:复杂功能开发可能超出预期时间
-- **缓解**:采用敏捷开发,分阶段交付核心功能
-- **备选**:优先级排序,非核心功能延后实现
-
-#### 7.3 兼容性风险
-- **风险**:新系统可能与现有功能冲突
-- **缓解**:充分测试,逐步集成
-- **备选**:提供兼容性开关,可回退到旧模式
-
-### 8. 下一步行动计划
-
-1. **立即行动** (本周)
- - 组建开发团队,分配角色
- - 搭建开发环境,创建分支
- - 编写详细的技术设计文档
-
-2. **短期计划** (1-2周)
- - 实现Agent配置管理基础功能
- - 开发Agent管理界面
- - 建立基础测试框架
-
-3. **中期计划** (3-5周)
- - 完成工作区隔离系统
- - 实现任务可视化核心功能
- - 集成第一种外部频道
-
-4. **长期计划** (6-8周)
- - 完成所有核心功能
- - 进行全面测试和优化
- - 准备发布版本
-
----
-
-## 三、附录:参考实现与资源
-
-### 1. ClawX项目关键文件参考
-- `electron/utils/agent-config.ts` - Agent配置管理核心
-- `src/stores/agents.ts` - 状态管理最佳实践
-- `src/pages/Chat/task-visualization.ts` - 任务可视化实现
-- `electron/gateway/manager.ts` - Gateway通信管理
-
-### 2. 技术文档参考
-- [JSON-RPC 2.0规范](https://www.jsonrpc.org/specification)
-- [Electron进程通信指南](https://www.electronjs.org/docs/latest/tutorial/ipc)
-- [Zustand状态管理文档](https://docs.pmnd.rs/zustand/getting-started/introduction)
-
-### 3. 外部API文档
-- [企业微信机器人API](https://developer.work.weixin.qq.com/document/path/91770)
-- [钉钉机器人API](https://open.dingtalk.com/document/robots/robot-overview)
-- [飞书机器人API](https://open.feishu.cn/document/client-docs/bot-v3/overview)
-
----
-
-**文档版本**:v1.0
-**创建时间**:2026-04-08
-**最后更新**:2026-04-08
-**负责人**:zn-ai开发团队
\ No newline at end of file
diff --git a/docs/package_mac_diagnosis_report.md b/docs/package_mac_diagnosis_report.md
deleted file mode 100644
index 83eb773..0000000
--- a/docs/package_mac_diagnosis_report.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# package:mac 打包诊断报告
-
-**诊断时间**: 2026-04-11
-**项目路径**: `/Users/duanshuwen/Documents/workspace/electron/zn-ai`
-**执行命令**: `pnpm run package:mac`
-
----
-
-## 1. 结论摘要
-
-**本次打包失败不是因为项目源码缺少文件或 `electron-builder.yml` 配置项缺失,而是因为**
-
-1. **网络下载中断**:`electron-builder` 在构建 DMG 时需要下载 `dmgbuild-bundle-x86_64`,该请求被服务器中断(`ReadError: The server aborted pending request`)。
-2. **并发锁竞争**:`mac.target` 同时配置了 `x64` 与 `arm64` 两种架构,`electron-builder` 并行打包时两个进程争抢 `proper-lockfile` 的缓存锁,导致下载失败后的清理阶段抛出 `Lock file is already being held` 错误。
-3. **(非致命)代码签名证书已过期**:本地唯一的 `Apple Development` 证书状态为 `CSSMERR_TP_CERT_EXPIRED`,因此 `electron-builder` 跳过了正式签名,仅做了 ad-hoc 签名。这不会导致打包中断,但会影响最终 app 在 macOS Gatekeeper 下的可运行性。
-
----
-
-## 2. 关键错误日志
-
-```text
- • downloading release=dmg-builder@1.2.0 file=dmgbuild-bundle-x86_64-75c8a6c.tar.gz
-...
-ReadError: The server aborted pending request
-...
-Error: Lock file is already being held
- at /Users/duanshuwen/Documents/workspace/electron/zn-ai/node_modules/proper-lockfile/lib/lockfile.js:68:47
-...
- ELIFECYCLE Command failed with exit code 1.
-```
-
----
-
-## 3. 配置与文件检查结果
-
-| 检查项 | 状态 | 说明 |
-|--------|------|------|
-| `package.json` | 正常 | 存在且 `package:mac` 脚本定义正确 |
-| `electron-builder.yml` | 正常 | 配置完整,`mac` / `dmg` / `win` / `linux` 分平台配置齐全 |
-| `resources/icons/icon.icns` | 正常 | `mac.icon` 指向的文件存在 |
-| `entitlements.mac.plist` | 正常 | 文件存在并已配置 |
-| `dist` / `dist-electron` | 正常 | `vite build` 阶段成功通过,产物已生成 |
-| 发布产物 | 部分生成 | `zip`(x64 / arm64)已成功生成,但 DMG(x64 / arm64)因下载失败未完成 |
-
----
-
-## 4. 详细原因分析
-
-### 4.1 网络问题导致 dmg-builder 下载中断
-`electron-builder` 在首次构建 DMG 时需要拉取预编译的 `dmg-builder` bundle(`dmgbuild-bundle-x86_64-75c8a6c.tar.gz`)。由于该资源托管在 GitHub / AWS CDN 上,在国内网络环境下极易出现连接被重置或请求被服务器中断的情况。
-
-### 4.2 并发构建加剧锁竞争
-`electron-builder.yml` 中配置了同时打包两种架构:
-
-```yaml
-mac:
- target:
- - target: dmg
- arch:
- - x64
- - arm64
- - target: zip
- arch:
- - x64
- - arm64
-```
-
-这导致 `electron-builder` 会并行启动多个打包任务。当它们同时尝试下载/解压同一个 `dmg-builder` 缓存时,`proper-lockfile` 产生竞争;一旦其中一个下载失败,另一个在清理阶段也会因无法获取锁而报错,最终整体构建失败。
-
-### 4.3 代码签名证书过期(仅提示,不中断构建)
-日志中出现的:
-
-```text
-skipped macOS application code signing reason=cannot find valid "Developer ID Application" identity ...
-"Apple Development: 562304744@qq.com (Z27TQS657B)" (CSSMERR_TP_CERT_EXPIRED)
-```
-
-这说明本地证书已过期,不会影响打包流程本身,但生成的 `.app` 没有有效的开发者签名,分发后用户打开时会遇到 "已损坏,无法打开" 的 Gatekeeper 提示。
-
----
-
-## 5. 解决方案与建议
-
-### 方案 A:分架构单独打包(推荐,最稳定)
-先避免并发锁竞争,同时降低单次构建对网络的敏感度:
-
-```bash
-# 仅打包 arm64(Apple Silicon)
-cd zn-ai
-npx electron-builder --mac --arm64 --publish never
-
-# 仅打包 x64(Intel)
-npx electron-builder --mac --x64 --publish never
-```
-
-若网络仍不稳定,可配置代理或手动下载缓存(见方案 B)。
-
-### 方案 B:配置 electron-builder 缓存
-如果网络受限,可预先设置缓存目录并手动下载所需 bundle:
-
-```bash
-export ELECTRON_BUILDER_CACHE="$HOME/.electron-builder-cache"
-```
-
-然后手动将 `dmgbuild-bundle-x86_64-75c8a6c.tar.gz` 放置到缓存目录的 `cache/dmg-builder` 子目录下,再执行打包。
-
-### 方案 C:修改 `electron-builder.yml` 降低并发
-在 `mac.target` 里先只保留当前常用架构(如 `arm64`),需要时再切换:
-
-```yaml
-mac:
- target:
- - target: dmg
- arch:
- - arm64
- - target: zip
- arch:
- - arm64
-```
-
-### 方案 D:修复代码签名(分发前必须)
-若计划将应用分发给其他用户,需要:
-1. 在 Apple Developer 后台续期或重新申请 **Developer ID Application** 证书;
-2. 将 `notarize` 改为 `true` 并配置正确的 `teamId`;
-3. 在构建机器上安装新证书后重新打包。
-
----
-
-## 6. 附录:构建产物现状
-
-截至诊断时,`release/` 目录下已生成:
-
-- `NIANXX-1.0.0-mac-x64.zip` (133 MB)
-- `NIANXX-1.0.0-mac-arm64.zip` (129 MB)
-- 对应的 `.blockmap` 文件
-
-**未生成**:
-- `NIANXX-1.0.0-mac-x64.dmg`
-- `NIANXX-1.0.0-mac-arm64.dmg`
-
-原因即上述网络下载中断 + 并发锁竞争。
-
----
-
-*报告结束*
diff --git a/docs/plan.md b/docs/plan.md
deleted file mode 100644
index dbb8d56..0000000
--- a/docs/plan.md
+++ /dev/null
@@ -1,401 +0,0 @@
-# ClawX 项目编译打包实现思路
-
-## 项目概述
-
-ClawX 是一个基于 Electron 的图形化 AI 助手应用,核心功能依赖于 OpenClaw 网关。项目采用现代前端技术栈:
-- **前端框架**: React + TypeScript
-- **构建工具**: Vite
-- **包管理器**: pnpm(使用工作区与虚拟存储)
-- **桌面框架**: Electron
-- **打包工具**: electron-builder
-
-项目结构遵循标准 Electron 应用布局:
-- `electron/main/` – 主进程代码
-- `electron/preload/` – 预加载脚本
-- `electron/gateway/` – 网关通信层
-- `src/` – 渲染进程(React 应用)
-- `resources/` – 静态资源(图标、 CLI 工具、预置技能)
-- `scripts/` – 构建与打包辅助脚本
-- `dist/` – Vite 构建输出(渲染进程)
-- `dist-electron/` – Electron 主进程与预加载脚本构建输出
-
-## 核心构建流程
-
-### 1. 开发构建 (`pnpm run dev`)
-- 使用 Vite 开发服务器启动渲染进程热重载
-- `vite-plugin-electron` 自动编译主进程与预加载脚本,并启动 Electron
-
-### 2. 生产构建 (`pnpm run build`)
-完整构建命令依次执行以下步骤:
-
-```bash
-vite build &&
-zx scripts/bundle-openclaw.mjs &&
-zx scripts/bundle-openclaw-plugins.mjs &&
-zx scripts/bundle-preinstalled-skills.mjs &&
-electron-builder
-```
-
-#### 2.1 渲染进程构建 (`vite build`)
-- 基于 `vite.config.ts` 配置
-- 使用 `@vitejs/plugin-react` 处理 React
-- 输出目录: `dist/`
-- 关键配置:
- - `base: './'` – 确保 Electron 文件协议下资源路径正确
- - 别名映射:`@` → `src/`,`@electron` → `electron/`
-
-#### 2.2 主进程与预加载脚本构建 (`vite-plugin-electron`)
-- 配置在 `vite.config.ts` 的 `electron()` 插件中
-- 两个入口:
- - `electron/main/index.ts` → 输出到 `dist-electron/main/`
- - `electron/preload/index.ts` → 输出到 `dist-electron/preload/`
-- 主进程构建使用自定义 `external` 规则,排除 Node.js 内置模块和绝对路径模块
-
-#### 2.3 OpenClaw 核心包打包 (`scripts/bundle-openclaw.mjs`)
-**核心挑战**:pnpm 虚拟存储导致依赖分散,直接复制 `node_modules/openclaw` 会丢失运行时依赖。
-
-**解决方案**:广度优先遍历 pnpm 虚拟存储,收集所有传递依赖。
-
-**步骤**:
-1. 解析 `node_modules/openclaw` 的真实路径(跟随 pnpm 符号链接)
-2. 确定 openclaw 所在的虚拟存储 `node_modules` 目录(例如 `.pnpm/openclaw@版本/node_modules/`)
-3. BFS 遍历:
- - 扫描虚拟存储 `node_modules` 下的所有包(排除 `.bin`)
- - 对每个包解析真实路径,记录包名与路径
- - 找到该包自身的虚拟存储 `node_modules`,继续遍历其依赖
-4. 跳过开发依赖(如 `typescript`、`@playwright/test`)和特定作用域包(如 `@types/`)
-5. 将收集到的所有包复制到 `build/openclaw/node_modules/`(扁平化结构)
-6. 去重处理:相同包名只保留首次遇到的版本(避免版本冲突)
-7. 清理:移除开发文档、测试目录、源码映射、类型定义等无用文件
-8. 修补已知问题模块:
- - `node-domexception` – 修复 CJS 导出为 undefined 的问题
- - `lru-cache` – 添加 `LRUCache` 命名导出,解决 Node.js 22+ ESM 互操作问题
- - 运行时 spawn 调用 – 添加 `windowsHide: true` 避免 Windows 控制台窗口闪烁
-
-#### 2.4 插件打包 (`scripts/bundle-openclaw-plugins.mjs`)
-- 打包第三方 OpenClaw 插件:钉钉、企业微信、飞书、微信
-- 使用类似的 BFS 算法收集插件及其传递依赖
-- 输出到 `build/openclaw-plugins/` 各插件目录
-
-#### 2.5 预置技能打包 (`scripts/bundle-preinstalled-skills.mjs`)
-- 将 `resources/skills/` 下的预置技能打包到 `build/preinstalled-skills/`
-- 确保技能配置文件(`SKILL.md`)与依赖完整
-
-#### 2.6 Electron 应用打包 (`electron-builder`)
-- 配置文件:`electron-builder.yml`
-- 输入文件:
- - `dist/` – 渲染进程构建结果
- - `dist-electron/` – 主进程与预加载脚本
- - `package.json` – 应用元数据
-- 额外资源(`extraResources`):
- - `resources/` – 静态资源(排除图标源文件、截图等)
- - `build/openclaw/` – 打包后的 OpenClaw 核心
- - `build/preinstalled-skills/` – 预置技能包
-- **关键问题**:`electron-builder` 遵循 `.gitignore` 规则,会跳过 `node_modules/` 目录,导致 `build/openclaw/node_modules/` 无法被复制。
-
-**解决方案**:在 `afterPack` 钩子(`scripts/after-pack.cjs`)中手动复制。
-
-### 3. 平台特定打包命令
-- `package:mac` – 仅构建 macOS 安装包(DMG/ZIP)
-- `package:win` – 构建 Windows 安装包(NSIS),预先下载 Windows 平台所需的 UV 和 Node.js 二进制文件
-- `package:linux` – 构建 Linux 安装包(AppImage/DEB/RPM)
-
-## 依赖管理与打包优化
-
-### pnpm 虚拟存储适配
-- pnpm 使用内容寻址存储,依赖通过符号链接组织
-- 构建脚本必须解析真实路径,遍历虚拟存储以收集完整依赖树
-- 支持 Windows 长路径(前缀 `\\?\` 绕过 260 字符限制)
-
-### 体积优化
-1. **开发文件移除**:删除 `.d.ts`、`.map`、测试目录、文档等
-2. **平台特定裁剪**:
- - `koffi` 本地库:只保留目标平台架构的预构建二进制文件
- - 作用域本地包(如 `@napi-rs/canvas-*`、`@img/sharp-*`):移除非目标平台变体
-3. **大文件移除**:删除 `node-llama-cpp/llama`、`pdfjs-dist/legacy` 等非必需大目录
-
-### 兼容性修补
-1. **CJS/ESM 互操作**:
- - `lru-cache` 旧版本缺少命名导出,通过追加 `exports.LRUCache` 补丁解决
- - `https-proxy-agent` 补充 `require` 导出条件,避免循环依赖错误
-2. **Windows 控制台隐藏**:修补 spawn 调用,添加 `windowsHide: true` 选项
-3. **插件 ID 修正**:某些插件编译后硬编码的 ID 与声明不一致,在打包后修正
-
-## 安装包生成与分发
-
-### 多平台配置
-- **macOS**:
- - 目标格式:DMG、ZIP
- - 启用强运行时(`hardenedRuntime`)与公证(`notarize`)
- - 自定义 DMG 背景与窗口布局
-- **Windows**:
- - 目标格式:NSIS 安装程序
- - 禁用更新签名验证(使用 OSS 分发)
- - 优化安装速度:修补 NSIS 脚本,直接解压到安装目录,避免 Defender 扫描导致的缓慢文件复制
-- **Linux**:
- - 目标格式:AppImage、DEB、RPM
- - 适配 Ubuntu 24.04 t64 ABI 过渡(使用 `|` 语法声明备选包名)
-
-### 自动更新
-- 主分发渠道:阿里云 OSS(国内用户速度优先)
-- 备用渠道:GitHub Releases
-- 使用 `electron-updater` 实现后台更新
-
-## 构建脚本角色概览
-
-| 脚本文件 | 功能描述 |
-|----------|----------|
-| `bundle-openclaw.mjs` | 打包 OpenClaw 核心及其所有传递依赖 |
-| `bundle-openclaw-plugins.mjs` | 打包第三方插件(钉钉、企业微信等) |
-| `bundle-preinstalled-skills.mjs` | 打包预置技能包 |
-| `after-pack.cjs` | `electron-builder` 后处理钩子,负责:
• 复制被忽略的 `node_modules`
• 平台特定文件清理
• 模块兼容性修补
• Windows NSIS 脚本优化 |
-| `download-bundled-uv.mjs` | 下载平台特定的 UV 二进制(Python 包管理器) |
-| `download-bundled-node.mjs` | 下载平台特定的 Node.js 运行时 |
-| `generate-icons.mjs` | 生成各平台所需图标格式 |
-
-## 关键配置要点
-
-### `electron-builder.yml` 要点
-- `extraResources` 复制资源,但需注意 `.gitignore` 排除问题
-- `asar: true` 打包为归档,但解压 `*.node` 原生模块和 `lru-cache` 以便修补
-- `npmRebuild: false` 禁止重建原生模块(所有原生模块属于 OpenClaw,在独立进程中运行)
-- 发布配置支持 OSS 与 GitHub 双渠道
-
-### `vite.config.ts` 要点
-- 使用 `vite-plugin-electron` 一体化构建主进程与预加载脚本
-- 主进程外部化规则确保 Node.js 内置模块不打包
-- 路径别名简化导入
-
-## 总结
-
-ClawX 的构建打包流程体现了对现代 Electron 应用复杂依赖管理的深刻理解,主要特点包括:
-
-1. **深度 pnpm 集成**:专门处理虚拟存储的依赖收集,确保运行时完整性。
-2. **分层打包**:将 OpenClaw 核心、插件、技能分别打包,模块清晰。
-3. **跨平台优化**:针对各平台特性进行资源裁剪、性能优化与体验改进。
-4. **兼容性保障**:主动修补第三方模块的 CJS/ESM 互操作问题,确保 Electron 40+(Node.js 22+)稳定运行。
-5. **构建效率**:通过 BFS 依赖收集、开发文件清理、Windows 安装过程优化等手段,控制包体积与构建时间。
-
-此流程确保了 ClawX 能够在三大桌面平台提供一致、稳定、高效的 AI 助手体验。
-
-# zn-ai 项目打包编译重构计划
-
-## 项目现状分析
-
-zn-ai 项目当前使用以下技术栈:
-- **前端框架**: Vue 3 + TypeScript
-- **构建工具**: Vite(分拆配置:`vite.main.config.ts`、`vite.renderer.config.ts`、`vite.preload.config.ts`)
-- **包管理器**: npm(检测到 `package-lock.json`)
-- **桌面框架**: Electron 38.2.2
-- **打包工具**: electron-forge + @electron-forge/plugin-vite
-- **代码保护**: bytenode 字节码编译(通过自定义 `vite-plugin-electron-encrypt` 插件实现)
-
-当前构建流程:
-1. 开发构建:`pnpm run dev`
-2. 生产构建:`pnpm run package`、`pnpm run build:encrypt`
-3. 自定义脚本:`clean.js`、`generateProdEntry.js` 用于清理和生成字节码入口
-
-## 重构目标
-
-将 ClawX 项目的现代化构建打包思路应用于 zn-ai 项目,主要目标:
-1. **统一构建配置**:使用单个 `vite.config.ts` 替代多个 Vite 配置文件
-2. **迁移打包工具**:从 electron-forge 迁移到 electron-builder
-3. **优化构建流程**:采用模块化构建脚本,提高可维护性
-4. **保持代码保护**:保留或改进 bytenode 字节码保护机制
-5. **提升跨平台体验**:借鉴 ClawX 的平台特定优化策略
-
-## 可行性评估
-
-### 可行方面
-1. **技术栈兼容性**:Vite + Electron 组合在两个项目中都得到验证
-2. **配置迁移**:Vite 配置可以合并,别名映射可以统一
-3. **打包工具迁移**:electron-builder 功能覆盖 electron-forge,且配置更简洁
-4. **脚本模块化**:ClawX 的脚本设计可以作为参考
-
-### 挑战与风险
-1. **bytenode 集成**:ClawX 未使用字节码保护,需要设计新的集成方案
-2. **依赖管理差异**:zn-ai 使用 npm,ClawX 使用 pnpm 虚拟存储,依赖收集策略可能不同
-3. **目录结构调整**:需要适应新的输出目录结构(`dist/`、`dist-electron/`)
-4. **现有功能兼容性**:确保所有现有功能在重构后正常工作
-
-## 详细实施步骤
-
-### 第一阶段:基础配置迁移(预计 2-3 天)
-
-#### 1.1 更新依赖项
-- 移除 `@electron-forge` 相关依赖
-- 添加 `electron-builder`、`vite-plugin-electron`、`vite-plugin-electron-renderer`
-- 更新 `electron` 版本到与 ClawX 兼容的版本(当前 40.6.0)
-- 检查其他依赖兼容性
-
-#### 1.2 创建统一的 Vite 配置
-- 创建 `vite.config.ts`,整合现有三个配置文件的设置
-- 配置 `vite-plugin-electron` 插件,处理主进程和预加载脚本
-- 配置 `vite-plugin-electron-renderer` 插件
-- 统一别名映射,保持现有别名兼容性
-- 保留 Vue 相关插件配置(`@vitejs/plugin-vue`、`unplugin-auto-import`、`@tailwindcss/vite`)
-
-#### 1.3 配置 electron-builder
-- 创建 `electron-builder.yml` 配置文件
-- 基于 zn-ai 需求定制:
- - 应用 ID、产品名称、版权信息
- - 输出目录配置
- - 文件包含规则(`dist/`、`dist-electron/`、`package.json`)
- - 额外资源(`public/`、`src/main/scripts/` 等)
- - 平台特定配置(Windows、macOS、Linux)
-
-#### 1.4 更新 package.json 脚本
-- 更新 `scripts` 部分:
- - `dev`: 使用 Vite 开发服务器 + electron 插件
- - `build`: 生产构建流程
- - `package`: 仅打包应用,不生成安装包
- - `package:win`、`package:mac`、`package:linux`: 平台特定打包
- - `release`: 发布版本
-- 移除不再需要的脚本(`generate-prod-entry`、`clean` 等)
-
-### 第二阶段:bytenode 保护机制重构(预计 1-2 天)
-
-#### 2.1 分析现有保护机制
-- 当前机制:通过 `vite-plugin-electron-encrypt` 插件在主进程构建后调用 bytenode 编译
-- 输出:`.jsc` 字节码文件,并生成新的入口文件
-
-#### 2.2 设计新的集成方案
-**方案 A:保留现有插件,适配新构建流程**
-- 修改 `vite-plugin-electron-encrypt` 插件,使其与 `vite-plugin-electron` 协同工作
-- 在 `vite-plugin-electron` 的 `onstart` 或构建完成后钩子中集成字节码编译
-
-**推荐方案**:方案 A,因为字节码编译应在主进程构建完成后立即进行,而不是在打包后。
-
-#### 2.3 实现保护机制
-- 创建新的插件或修改现有插件
-- 确保开发模式下不进行字节码编译(保持源码可调试)
-- 生产构建时自动编译并替换入口
-
-### 第三阶段:构建脚本优化(预计 1-2 天)
-
-#### 3.1 分析 zn-ai 的特殊需求
-- 检查是否有类似 ClawX 的复杂依赖收集需求
-- 分析 `src/main/scripts/` 目录的处理需求
-- 检查是否需要平台特定的二进制文件
-
-#### 3.2 创建必要的构建脚本
-- 如果需要依赖收集,参考 ClawX 的 `bundle-openclaw.mjs` 实现
-- 创建脚本处理 `src/main/scripts/` 的打包(类似现有 forge.config.ts 中的 `packageAfterCopy` 钩子)
-- 考虑创建 `after-pack.cjs` 钩子处理 electron-builder 的特殊需求
-
-#### 3.3 优化构建性能
-- 实现开发文件清理(类似 ClawX 的体积优化)
-- 考虑平台特定资源裁剪
-
-### 第四阶段:测试与验证(预计 1-2 天)
-
-#### 4.1 开发构建测试
-- 运行 `pnpm run dev`,验证开发服务器正常启动
-- 测试热重载、主进程重载等功能
-
-#### 4.2 生产构建测试
-- 运行 `pnpm run build`,验证完整构建流程
-- 检查输出目录结构是否正确
-- 验证字节码保护是否生效
-
-#### 4.3 安装包测试
-- 生成各平台安装包(Windows、macOS、Linux)
-- 测试安装、运行、卸载流程
-- 验证所有功能正常
-
-#### 4.4 回归测试
-- 确保现有功能不受影响
-- 测试关键业务场景
-
-## 迁移后的构建流程
-
-### 开发构建流程
-```
-pnpm run dev
-```
-1. Vite 启动渲染进程开发服务器
-2. `vite-plugin-electron` 编译主进程和预加载脚本
-3. Electron 启动,连接开发服务器
-
-### 生产构建流程
-```
-pnpm run build
-```
-1. `vite build` 编译渲染进程 → `dist/`
-2. `vite-plugin-electron` 编译主进程和预加载脚本 → `dist-electron/`
-3. 字节码编译(生产模式) → 生成 `.jsc` 文件并更新入口
-4. `electron-builder` 打包应用 → `release/` 目录
-
-### 平台特定打包
-```
-pnpm run package:win # Windows 安装包
-pnpm run package:mac # macOS 安装包
-pnpm run package:linux # Linux 安装包
-```
-
-## 配置迁移对照表
-
-| 当前配置 (zn-ai) | 目标配置 (ClawX 风格) | 迁移说明 |
-|-----------------|----------------------|----------|
-| `forge.config.ts` | `electron-builder.yml` | 功能映射,注意钩子转换 |
-| `vite.main.config.ts` | `vite.config.ts` 中的 `electron()` 配置 | 合并到统一配置 |
-| `vite.renderer.config.ts` | `vite.config.ts` 中的根配置 | 合并渲染进程配置 |
-| `vite.preload.config.ts` | `vite.config.ts` 中的 `electron()` 配置 | 作为第二个 electron 条目 |
-| `build/scripts/clean.js` | `electron-builder.yml` 的 `afterPack` 或构建脚本 | 清理逻辑集成到构建流程 |
-| `build/scripts/generateProdEntry.js` | 新的字节码编译插件 | 功能整合到 Vite 插件 |
-| `package.json` scripts | 更新为 ClawX 风格的脚本 | 保持功能,改进组织 |
-
-## 风险评估与应对策略
-
-### 高风险项
-1. **bytenode 集成失败**
- - **影响**:代码保护失效,可能影响商业化需求
- - **应对**:先实现基础构建流程,再单独攻关保护机制;保留回滚方案
-
-2. **依赖兼容性问题**
- - **影响**:构建失败或运行时错误
- - **应对**:逐步迁移,分阶段测试;保持 electron 版本与现有依赖兼容
-
-3. **目录结构变更导致路径错误**
- - **影响**:资源加载失败,功能异常
- - **应对**:全面更新路径引用;使用别名简化路径管理
-
-### 中风险项
-1. **跨平台打包问题**
- - **影响**:某些平台安装包生成失败
- - **应对**:逐个平台测试验证;参考 ClawX 的平台特定配置
-
-2. **构建性能下降**
- - **影响**:开发体验变差,构建时间延长
- - **应对**:优化配置,启用缓存;对比新旧构建时间
-
-## 成功标准
-
-1. **功能完整性**:所有现有功能在重构后正常工作
-2. **构建成功**:开发构建、生产构建、各平台打包均成功
-3. **性能相当**:构建时间不超过原有方案的 120%
-4. **代码保护**:字节码保护机制有效,主进程代码得到保护
-5. **可维护性提升**:配置更简洁,脚本更模块化
-
-## 后续优化建议
-
-1. **依赖管理升级**:考虑从 npm 迁移到 pnpm,获得更好的依赖管理性能
-2. **自动更新集成**:集成 electron-updater,实现自动更新功能
-3. **构建缓存优化**:配置持久化缓存,加速构建过程
-4. **CI/CD 集成**:将新构建流程集成到持续集成系统
-
-## 时间估算
-
-| 阶段 | 任务 | 预计时间 | 备注 |
-|------|------|----------|------|
-| 第一阶段 | 基础配置迁移 | 2-3 天 | 包括依赖更新、配置创建、脚本更新 |
-| 第二阶段 | bytenode 保护机制重构 | 1-2 天 | 关键风险点,需要充分测试 |
-| 第三阶段 | 构建脚本优化 | 1-2 天 | 根据实际需求调整 |
-| 第四阶段 | 测试与验证 | 1-2 天 | 全面测试,确保质量 |
-| **总计** | | **5-9 天** | 实际时间取决于具体实现复杂度 |
-
-## 结论
-
-将 ClawX 项目的打包编译思路迁移到 zn-ai 项目是可行的,但需要谨慎处理 bytenode 代码保护机制的集成。重构后的构建系统将更现代化、模块化,且易于维护。建议按照上述计划分阶段实施,每个阶段完成后进行验证,确保整体项目稳定性。
-
-**建议先进行第一阶段的基础配置迁移,验证基础构建流程可行后,再继续进行后续阶段。**
diff --git a/electron/api/context.ts b/electron/api/context.ts
new file mode 100644
index 0000000..f317bc4
--- /dev/null
+++ b/electron/api/context.ts
@@ -0,0 +1,9 @@
+import type { BrowserWindow } from 'electron';
+import type { gatewayManager } from '@electron/gateway/manager';
+import type { providerApiService } from '@electron/service/provider-api-service';
+
+export interface HostApiContext {
+ gatewayManager: typeof gatewayManager;
+ providerApiService: typeof providerApiService;
+ mainWindow: BrowserWindow | null;
+}
diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts
new file mode 100644
index 0000000..0bac184
--- /dev/null
+++ b/electron/api/route-utils.ts
@@ -0,0 +1,63 @@
+import type { HostApiResult } from '@src/types/runtime';
+
+export interface HostApiRequest {
+ path: string;
+ method?: string;
+ headers?: Record;
+ body?: unknown;
+}
+
+export interface NormalizedHostApiRequest {
+ path: string;
+ pathname: string;
+ method: string;
+ headers: Record;
+ body: unknown;
+ url: URL;
+}
+
+export function normalizeRequest(request: HostApiRequest): NormalizedHostApiRequest {
+ const path = String(request.path || '/').trim() || '/';
+
+ return {
+ path,
+ pathname: new URL(path, 'http://127.0.0.1').pathname,
+ method: String(request.method || 'GET').trim().toUpperCase(),
+ headers: request.headers || {},
+ body: request.body ?? null,
+ url: new URL(path, 'http://127.0.0.1'),
+ };
+}
+
+export function parseJsonBody(body: unknown): T {
+ if (body == null || body === '') {
+ return {} as T;
+ }
+
+ if (typeof body === 'string') {
+ return JSON.parse(body) as T;
+ }
+
+ return body as T;
+}
+
+export function ok(data: T, status = 200): HostApiResult {
+ return {
+ success: true,
+ ok: true,
+ status,
+ json: data,
+ data,
+ };
+}
+
+export function fail(status: number, error: string, data?: T): HostApiResult {
+ return {
+ success: false,
+ ok: false,
+ status,
+ error,
+ text: error,
+ data,
+ };
+}
diff --git a/electron/api/router.ts b/electron/api/router.ts
new file mode 100644
index 0000000..04fd779
--- /dev/null
+++ b/electron/api/router.ts
@@ -0,0 +1,46 @@
+import { BrowserWindow } from 'electron';
+import { gatewayManager } from '@electron/gateway/manager';
+import { providerApiService } from '@electron/service/provider-api-service';
+import type { HostApiContext } from './context';
+import type { HostApiRequest } from './route-utils';
+import { normalizeRequest } from './route-utils';
+import { handleAgentRoutes } from './routes/agents';
+import { handleFileRoutes } from './routes/files';
+import { handleGatewayRoutes } from './routes/gateway';
+import { handleProviderRoutes } from './routes/providers';
+import { handleSessionRoutes } from './routes/sessions';
+
+type RouteHandler = (
+ request: ReturnType,
+ ctx: HostApiContext,
+) => Promise;
+
+const routeHandlers: RouteHandler[] = [
+ handleProviderRoutes,
+ handleAgentRoutes,
+ handleGatewayRoutes,
+ handleFileRoutes,
+ handleSessionRoutes,
+];
+
+function createContext(): HostApiContext {
+ return {
+ gatewayManager,
+ providerApiService,
+ mainWindow: BrowserWindow.getAllWindows()[0] ?? null,
+ };
+}
+
+export async function dispatchLocalHostApi(request: HostApiRequest) {
+ const normalized = normalizeRequest(request);
+ const ctx = createContext();
+
+ for (const handler of routeHandlers) {
+ const result = await handler(normalized, ctx);
+ if (result) {
+ return result;
+ }
+ }
+
+ return null;
+}
diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts
new file mode 100644
index 0000000..6e87179
--- /dev/null
+++ b/electron/api/routes/agents.ts
@@ -0,0 +1,91 @@
+import type { ProviderAccount } from '@runtime/lib/providers';
+import {
+ DEFAULT_AGENT_ID,
+ DEFAULT_MAIN_SESSION_SUFFIX,
+ buildMainSessionKey,
+ normalizeAgentId,
+ type AgentSummary,
+} from '@runtime/lib/agents';
+import type { HostApiContext } from '../context';
+import type { NormalizedHostApiRequest } from '../route-utils';
+import { ok } from '../route-utils';
+
+function formatModelDisplay(modelRef: string | null | undefined, fallbackLabel: string): string {
+ const trimmed = String(modelRef ?? '').trim();
+ if (!trimmed) return fallbackLabel;
+
+ const parts = trimmed.split('/');
+ return parts[parts.length - 1] || trimmed;
+}
+
+function buildMainAgent(defaultAccount: ProviderAccount | null): AgentSummary {
+ return {
+ id: DEFAULT_AGENT_ID,
+ name: 'Main Agent',
+ isDefault: true,
+ providerAccountId: defaultAccount?.id ?? null,
+ modelRef: defaultAccount?.model ?? null,
+ modelDisplay: formatModelDisplay(defaultAccount?.model, defaultAccount?.label || 'Unassigned'),
+ mainSessionKey: buildMainSessionKey(DEFAULT_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX),
+ vendorId: defaultAccount?.vendorId ?? null,
+ source: 'synthetic-main',
+ };
+}
+
+function buildProviderBackedAgents(accounts: ProviderAccount[]): AgentSummary[] {
+ const seen = new Set();
+ const summaries: AgentSummary[] = [];
+
+ for (const account of accounts) {
+ const agentId = normalizeAgentId(account.id);
+ if (seen.has(agentId) || agentId === DEFAULT_AGENT_ID) continue;
+ seen.add(agentId);
+
+ summaries.push({
+ id: agentId,
+ name: account.label || agentId,
+ isDefault: false,
+ providerAccountId: account.id,
+ modelRef: account.model ?? null,
+ modelDisplay: formatModelDisplay(account.model, account.label || agentId),
+ mainSessionKey: buildMainSessionKey(agentId, DEFAULT_MAIN_SESSION_SUFFIX),
+ vendorId: account.vendorId,
+ source: 'provider-account',
+ });
+ }
+
+ return summaries;
+}
+
+export async function handleAgentRoutes(
+ request: NormalizedHostApiRequest,
+ ctx: HostApiContext,
+) {
+ const { pathname, method } = request;
+ if (pathname !== '/api/agents' || method !== 'GET') {
+ return null;
+ }
+
+ const accounts = ctx.providerApiService
+ .getAccounts()
+ .filter((account) => account.enabled !== false);
+ const defaultAccountId = ctx.providerApiService.getDefault().accountId;
+ const defaultAccount = accounts.find((account) => account.id === defaultAccountId) ?? accounts[0] ?? null;
+
+ const agents = [
+ buildMainAgent(defaultAccount),
+ ...buildProviderBackedAgents(accounts),
+ ];
+
+ return ok({
+ success: true,
+ agents,
+ defaultAgentId: DEFAULT_AGENT_ID,
+ defaultProviderAccountId: defaultAccount?.id ?? null,
+ defaultModelRef: defaultAccount?.model ?? null,
+ mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX,
+ configuredChannelTypes: [],
+ channelOwners: {},
+ channelAccountOwners: {},
+ });
+}
diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts
new file mode 100644
index 0000000..5371b62
--- /dev/null
+++ b/electron/api/routes/files.ts
@@ -0,0 +1,115 @@
+import crypto from 'node:crypto';
+import { app, nativeImage } from 'electron';
+import { extname, join } from 'node:path';
+import type { HostApiContext } from '../context';
+import type { NormalizedHostApiRequest } from '../route-utils';
+import { ok, parseJsonBody } from '../route-utils';
+
+const EXT_MIME_MAP: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.svg': 'image/svg+xml',
+ '.bmp': 'image/bmp',
+ '.txt': 'text/plain',
+ '.json': 'application/json',
+ '.pdf': 'application/pdf',
+};
+
+function getMimeType(ext: string): string {
+ return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
+}
+
+function mimeToExt(mimeType: string): string {
+ for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
+ if (mime === mimeType) return ext;
+ }
+ return '';
+}
+
+const OUTBOUND_DIR = join(app.getPath('userData'), 'openclaw-media', 'outbound');
+
+async function generateImagePreview(filePath: string, mimeType: string): Promise {
+ try {
+ const image = nativeImage.createFromPath(filePath);
+ if (image.isEmpty()) return null;
+
+ const size = image.getSize();
+ const maxDim = 512;
+ const normalized = size.width > maxDim || size.height > maxDim
+ ? (size.width >= size.height ? image.resize({ width: maxDim }) : image.resize({ height: maxDim }))
+ : image;
+
+ return `data:${mimeType};base64,${normalized.toPNG().toString('base64')}`;
+ } catch {
+ return null;
+ }
+}
+
+export async function handleFileRoutes(
+ request: NormalizedHostApiRequest,
+ _ctx: HostApiContext,
+) {
+ const { pathname, method } = request;
+
+ if (pathname === '/api/files/stage-buffer' && method === 'POST') {
+ const body = parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(request.body);
+ const fsP = await import('node:fs/promises');
+ await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
+
+ const id = crypto.randomUUID();
+ const ext = extname(body.fileName) || mimeToExt(body.mimeType);
+ const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
+ const buffer = Buffer.from(body.base64 || '', 'base64');
+ await fsP.writeFile(stagedPath, buffer);
+
+ const mimeType = body.mimeType || getMimeType(ext);
+ const preview = mimeType.startsWith('image/')
+ ? await generateImagePreview(stagedPath, mimeType)
+ : null;
+
+ return ok({
+ id,
+ fileName: body.fileName,
+ mimeType,
+ fileSize: buffer.length,
+ stagedPath,
+ preview,
+ });
+ }
+
+ if (pathname === '/api/files/stage-paths' && method === 'POST') {
+ const body = parseJsonBody<{ filePaths: string[] }>(request.body);
+ const fsP = await import('node:fs/promises');
+ await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
+
+ const results = [];
+ for (const filePath of body.filePaths || []) {
+ const id = crypto.randomUUID();
+ const ext = extname(filePath);
+ const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
+ await fsP.copyFile(filePath, stagedPath);
+ const stats = await fsP.stat(stagedPath);
+ const mimeType = getMimeType(ext);
+ const fileName = filePath.split(/[\\/]/).pop() || 'file';
+ const preview = mimeType.startsWith('image/')
+ ? await generateImagePreview(stagedPath, mimeType)
+ : null;
+
+ results.push({
+ id,
+ fileName,
+ mimeType,
+ fileSize: stats.size,
+ stagedPath,
+ preview,
+ });
+ }
+
+ return ok(results);
+ }
+
+ return null;
+}
diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts
new file mode 100644
index 0000000..6cd5bfd
--- /dev/null
+++ b/electron/api/routes/gateway.ts
@@ -0,0 +1,49 @@
+import type { HostApiContext } from '../context';
+import type { NormalizedHostApiRequest } from '../route-utils';
+import { fail, ok } from '../route-utils';
+
+export async function handleGatewayRoutes(
+ request: NormalizedHostApiRequest,
+ ctx: HostApiContext,
+) {
+ const { pathname, method } = request;
+
+ if (pathname === '/api/app/gateway-info' && method === 'GET') {
+ const status = ctx.gatewayManager.getStatus();
+ return ok({
+ transport: 'ipc-bridge',
+ rpcChannel: 'gateway:rpc',
+ eventChannel: 'gateway:event',
+ ...status,
+ });
+ }
+
+ if (pathname === '/api/gateway/status' && method === 'GET') {
+ return ok(ctx.gatewayManager.getStatus());
+ }
+
+ if (pathname === '/api/gateway/health' && method === 'GET') {
+ return ok(await ctx.gatewayManager.checkHealth());
+ }
+
+ if (pathname === '/api/gateway/start' && method === 'POST') {
+ await ctx.gatewayManager.start();
+ return ok({ success: true });
+ }
+
+ if (pathname === '/api/gateway/stop' && method === 'POST') {
+ await ctx.gatewayManager.stop();
+ return ok({ success: true });
+ }
+
+ if (pathname === '/api/gateway/restart' && method === 'POST') {
+ try {
+ await ctx.gatewayManager.restart();
+ return ok({ success: true });
+ } catch (error) {
+ return fail(500, error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ return null;
+}
diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts
new file mode 100644
index 0000000..373e2f7
--- /dev/null
+++ b/electron/api/routes/providers.ts
@@ -0,0 +1,74 @@
+import type { HostApiContext } from '../context';
+import type { NormalizedHostApiRequest } from '../route-utils';
+import { ok } from '../route-utils';
+
+export async function handleProviderRoutes(
+ request: NormalizedHostApiRequest,
+ ctx: HostApiContext,
+) {
+ const { pathname, method } = request;
+ const parsedBody = request.body && typeof request.body === 'string'
+ ? JSON.parse(request.body)
+ : request.body;
+
+ if (pathname === '/api/provider-vendors' && method === 'GET') {
+ return ok(ctx.providerApiService.getVendors());
+ }
+ if (pathname === '/api/provider-accounts' && method === 'GET') {
+ return ok(ctx.providerApiService.getAccounts());
+ }
+ if (pathname === '/api/providers' && method === 'GET') {
+ return ok(ctx.providerApiService.getProviders());
+ }
+ if (pathname === '/api/provider-accounts/default' && method === 'GET') {
+ return ok(ctx.providerApiService.getDefault());
+ }
+ if (pathname === '/api/provider-accounts' && method === 'POST') {
+ return ok(ctx.providerApiService.createAccount(parsedBody || {}));
+ }
+ if (pathname === '/api/provider-accounts/default' && method === 'PUT') {
+ const result = ctx.providerApiService.setDefault(parsedBody || {});
+ return ok(result, result.success ? 200 : 400);
+ }
+ if (pathname.startsWith('/api/provider-accounts/') && method === 'PUT') {
+ const id = decodeURIComponent(pathname.replace('/api/provider-accounts/', ''));
+ const result = ctx.providerApiService.updateAccount(id, parsedBody || {});
+ return ok(result, result.success ? 200 : 404);
+ }
+ if (pathname.startsWith('/api/provider-accounts/') && method === 'DELETE') {
+ const id = decodeURIComponent(pathname.replace('/api/provider-accounts/', ''));
+ return ok(ctx.providerApiService.deleteAccount(id));
+ }
+ if (pathname === '/api/providers/default' && method === 'PUT') {
+ const result = ctx.providerApiService.setDefault({ accountId: (parsedBody as any)?.providerId });
+ return ok(result, result.success ? 200 : 400);
+ }
+ if (pathname.startsWith('/api/providers/') && pathname.endsWith('/api-key') && method === 'GET') {
+ const id = decodeURIComponent(pathname.replace('/api/providers/', '').replace('/api-key', ''));
+ return ok(ctx.providerApiService.getApiKey(id));
+ }
+ if (pathname.startsWith('/api/providers/') && method === 'PUT') {
+ const id = decodeURIComponent(pathname.replace('/api/providers/', ''));
+ const result = ctx.providerApiService.updateAccount(id, parsedBody || {});
+ return ok(result, result.success ? 200 : 404);
+ }
+ if (pathname.startsWith('/api/providers/') && method === 'DELETE') {
+ const rawPath = request.path.replace('/api/providers/', '');
+ const [rawId, query] = rawPath.split('?');
+ const id = decodeURIComponent(rawId);
+ if (query && query.includes('apiKeyOnly=1')) {
+ return ok(ctx.providerApiService.deleteApiKey(id));
+ }
+ return ok(ctx.providerApiService.deleteAccount(id));
+ }
+ if (pathname === '/api/providers/validate' && method === 'POST') {
+ return ok(await ctx.providerApiService.validateApiKey(parsedBody || {}));
+ }
+ if (pathname === '/api/usage/recent-token-history' && method === 'GET') {
+ const limitRaw = request.url.searchParams.get('limit');
+ const limit = limitRaw ? Number(limitRaw) : undefined;
+ return ok(await ctx.providerApiService.getUsageHistory(limit));
+ }
+
+ return null;
+}
diff --git a/electron/api/routes/sessions.ts b/electron/api/routes/sessions.ts
new file mode 100644
index 0000000..3349c93
--- /dev/null
+++ b/electron/api/routes/sessions.ts
@@ -0,0 +1,113 @@
+import { sessionStore } from '@electron/gateway/session-store';
+import { getTranscriptFilePath } from '@electron/utils/token-usage-writer';
+import { buildAgentSessionKey, normalizeAgentSessionKey, parseSessionKey } from '@runtime/lib/agents';
+import type { HostApiContext } from '../context';
+import type { NormalizedHostApiRequest } from '../route-utils';
+import { fail, ok, parseJsonBody } from '../route-utils';
+
+function parseSessionIdentity(request: NormalizedHostApiRequest): { agentId: string; sessionId: string; sessionKey: string } | null {
+ const sessionKey = normalizeAgentSessionKey(request.url.searchParams.get('sessionKey')?.trim() || '');
+ const parsed = parseSessionKey(sessionKey);
+ if (sessionKey && parsed.isAgentSession) {
+ return {
+ agentId: parsed.agentId,
+ sessionId: parsed.sessionId,
+ sessionKey: parsed.sessionKey,
+ };
+ }
+
+ const agentId = request.url.searchParams.get('agentId')?.trim() || '';
+ const sessionId = request.url.searchParams.get('sessionId')?.trim() || '';
+ if (!agentId || !sessionId) return null;
+
+ return {
+ agentId: parseSessionKey(buildAgentSessionKey(agentId, sessionId)).agentId,
+ sessionId: parseSessionKey(buildAgentSessionKey(agentId, sessionId)).sessionId,
+ sessionKey: buildAgentSessionKey(agentId, sessionId),
+ };
+}
+
+export async function handleSessionRoutes(
+ request: NormalizedHostApiRequest,
+ _ctx: HostApiContext,
+) {
+ const { pathname, method } = request;
+
+ if (pathname === '/api/sessions/transcript' && method === 'GET') {
+ const identity = parseSessionIdentity(request);
+ if (!identity) {
+ return fail(400, 'sessionKey or agentId/sessionId is required');
+ }
+
+ try {
+ const fsP = await import('node:fs/promises');
+ const transcriptPath = getTranscriptFilePath(identity.sessionKey);
+ let raw: string;
+
+ try {
+ raw = await fsP.readFile(transcriptPath, 'utf8');
+ } catch (error: any) {
+ const requestedSessionKey = request.url.searchParams.get('sessionKey')?.trim() || '';
+ if (error?.code === 'ENOENT' && requestedSessionKey && requestedSessionKey !== identity.sessionKey) {
+ raw = await fsP.readFile(getTranscriptFilePath(requestedSessionKey), 'utf8');
+ } else {
+ throw error;
+ }
+ }
+
+ const lines = raw.split(/\r?\n/).filter(Boolean);
+ const messages = lines.flatMap((line) => {
+ try {
+ const entry = JSON.parse(line) as { type?: string; message?: unknown; timestamp?: string };
+ return entry.type === 'message' && entry.message ? [{ ...entry.message, timestamp: entry.timestamp }] : [];
+ } catch {
+ return [];
+ }
+ });
+
+ return ok({
+ agentId: identity.agentId,
+ sessionId: identity.sessionId,
+ sessionKey: identity.sessionKey,
+ transcriptPath,
+ messages,
+ });
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') {
+ return fail(404, 'Transcript not found');
+ }
+ return fail(500, error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ if (pathname === '/api/sessions/delete' && method === 'POST') {
+ const body = parseJsonBody<{ sessionKey: string }>(request.body);
+ const rawSessionKey = String(body?.sessionKey || '').trim();
+ const sessionKey = normalizeAgentSessionKey(rawSessionKey);
+ if (!sessionKey) {
+ return fail(400, 'sessionKey is required');
+ }
+
+ sessionStore.deleteSession(sessionKey);
+
+ const transcriptPath = getTranscriptFilePath(sessionKey);
+ try {
+ const fsP = await import('node:fs/promises');
+ await fsP.rename(transcriptPath, transcriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
+ } catch {
+ if (rawSessionKey && rawSessionKey !== sessionKey) {
+ try {
+ const fsP = await import('node:fs/promises');
+ const legacyTranscriptPath = getTranscriptFilePath(rawSessionKey);
+ await fsP.rename(legacyTranscriptPath, legacyTranscriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
+ } catch {
+ // Best effort: transcript may not exist yet.
+ }
+ }
+ }
+
+ return ok({ success: true });
+ }
+
+ return null;
+}
diff --git a/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts
index 273ec1b..f5e881a 100644
--- a/electron/gateway/handlers/chat.ts
+++ b/electron/gateway/handlers/chat.ts
@@ -3,6 +3,7 @@ import { createProvider } from '@electron/providers';
import type { BaseProvider } from '@electron/providers/BaseProvider';
import { providerApiService } from '@electron/service/provider-api-service';
import logManager from '@electron/service/logger';
+import { normalizeAgentSessionKey } from '@runtime/lib/agents';
import type { RawMessage } from '@runtime/shared/chat-model';
import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
@@ -110,7 +111,8 @@ export function handleChatSend(
params: GatewayRpcParams['chat.send'],
broadcast: (event: GatewayEvent) => void
): GatewayRpcReturns['chat.send'] {
- const { sessionKey, message, options } = params;
+ const sessionKey = normalizeAgentSessionKey(params.sessionKey);
+ const { message, options } = params;
const runId = randomUUID();
// 1. Append user message
@@ -175,20 +177,21 @@ export function handleChatSend(
export function handleChatHistory(
params: GatewayRpcParams['chat.history']
): GatewayRpcReturns['chat.history'] {
- return sessionStore.getMessages(params.sessionKey, params.limit ?? 50);
+ return sessionStore.getMessages(normalizeAgentSessionKey(params.sessionKey), params.limit ?? 50);
}
export function handleChatAbort(
params: GatewayRpcParams['chat.abort'],
broadcast: (event: GatewayEvent) => void
): GatewayRpcReturns['chat.abort'] {
- const activeRun = sessionStore.getActiveRun(params.sessionKey);
+ const sessionKey = normalizeAgentSessionKey(params.sessionKey);
+ const activeRun = sessionStore.getActiveRun(sessionKey);
if (activeRun) {
activeRun.abortController.abort();
- sessionStore.clearActiveRun(params.sessionKey);
+ sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:aborted',
- sessionKey: params.sessionKey,
+ sessionKey,
runId: activeRun.runId,
});
}
@@ -201,6 +204,6 @@ export function handleSessionList(): GatewayRpcReturns['session.list'] {
export function handleSessionDelete(
params: GatewayRpcParams['session.delete']
): GatewayRpcReturns['session.delete'] {
- sessionStore.deleteSession(params.sessionKey);
+ sessionStore.deleteSession(normalizeAgentSessionKey(params.sessionKey));
return { success: true };
}
diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts
index 6b748f9..040aef6 100644
--- a/electron/gateway/manager.ts
+++ b/electron/gateway/manager.ts
@@ -7,14 +7,60 @@ import * as providerHandlers from './handlers/provider';
class GatewayManager {
private initialized = false;
+ private status: 'connected' | 'disconnected' | 'reconnecting' = 'disconnected';
+
+ private setStatus(status: 'connected' | 'disconnected' | 'reconnecting'): void {
+ this.status = status;
+ this.broadcast({ type: 'gateway:status', status });
+ }
async init(): Promise {
if (this.initialized) return;
this.initialized = true;
+ this.status = 'connected';
logManager.info('GatewayManager initialized');
this.broadcast({ type: 'gateway:status', status: 'connected' });
}
+ async start(): Promise {
+ await this.init();
+ }
+
+ async stop(): Promise {
+ this.initialized = false;
+ this.setStatus('disconnected');
+ }
+
+ async restart(): Promise {
+ this.initialized = false;
+ this.setStatus('reconnecting');
+ await this.init();
+ }
+
+ getStatus(): {
+ status: 'connected' | 'disconnected' | 'reconnecting';
+ initialized: boolean;
+ mode: 'in-process';
+ } {
+ return {
+ status: this.status,
+ initialized: this.initialized,
+ mode: 'in-process',
+ };
+ }
+
+ async checkHealth(): Promise<{
+ ok: boolean;
+ status: 'connected' | 'disconnected' | 'reconnecting';
+ initialized: boolean;
+ mode: 'in-process';
+ }> {
+ return {
+ ok: this.initialized && this.status === 'connected',
+ ...this.getStatus(),
+ };
+ }
+
async rpc(method: string, params: any): Promise {
if (!this.initialized) {
await this.init();
diff --git a/electron/gateway/openclaw-process-owner.ts b/electron/gateway/openclaw-process-owner.ts
new file mode 100644
index 0000000..3a8415c
--- /dev/null
+++ b/electron/gateway/openclaw-process-owner.ts
@@ -0,0 +1,112 @@
+import {
+ ensureOpenClawRuntimeLayout,
+ getOpenClawRuntimePaths,
+ type OpenClawRuntimePaths,
+} from '@electron/utils/paths';
+
+export type OpenClawProcessOwnerState =
+ | 'idle'
+ | 'preparing'
+ | 'running'
+ | 'stopping'
+ | 'stopped'
+ | 'failed';
+
+export interface OpenClawProcessOwnerStatus {
+ state: OpenClawProcessOwnerState;
+ prepared: boolean;
+ runtimePaths: OpenClawRuntimePaths;
+ lastError?: string;
+}
+
+export interface OpenClawProcessOwnerOptions {
+ runtimePaths?: Partial;
+}
+
+export interface OpenClawProcessOwnerLike {
+ prepare(): Promise;
+ start(): Promise;
+ stop(): Promise;
+ restart(): Promise;
+ getStatus(): OpenClawProcessOwnerStatus;
+ getRuntimePaths(): OpenClawRuntimePaths;
+}
+
+function mergeRuntimePaths(
+ base: OpenClawRuntimePaths,
+ override?: Partial,
+): OpenClawRuntimePaths {
+ if (!override) {
+ return base;
+ }
+
+ return {
+ configDir: override.configDir ?? base.configDir,
+ runtimeDir: override.runtimeDir ?? base.runtimeDir,
+ dir: override.dir ?? base.dir,
+ resolvedDir: override.resolvedDir ?? base.resolvedDir,
+ entryPath: override.entryPath ?? base.entryPath,
+ };
+}
+
+export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
+ private status: OpenClawProcessOwnerStatus;
+
+ constructor(options?: OpenClawProcessOwnerOptions) {
+ const runtimePaths = mergeRuntimePaths(
+ getOpenClawRuntimePaths(),
+ options?.runtimePaths,
+ );
+
+ this.status = {
+ state: 'idle',
+ prepared: false,
+ runtimePaths,
+ };
+ }
+
+ async prepare(): Promise {
+ if (this.status.prepared) {
+ return;
+ }
+
+ this.status.state = 'preparing';
+ ensureOpenClawRuntimeLayout(this.status.runtimePaths);
+ this.status.prepared = true;
+ this.status.state = 'idle';
+ }
+
+ async start(): Promise {
+ if (this.status.state === 'running') {
+ return;
+ }
+
+ await this.prepare();
+ this.status.state = 'running';
+ }
+
+ async stop(): Promise {
+ if (this.status.state === 'idle' || this.status.state === 'stopped') {
+ return;
+ }
+
+ this.status.state = 'stopping';
+ this.status.state = 'stopped';
+ }
+
+ async restart(): Promise {
+ await this.stop();
+ await this.start();
+ }
+
+ getStatus(): OpenClawProcessOwnerStatus {
+ return {
+ ...this.status,
+ runtimePaths: { ...this.status.runtimePaths },
+ };
+ }
+
+ getRuntimePaths(): OpenClawRuntimePaths {
+ return { ...this.status.runtimePaths };
+ }
+}
diff --git a/electron/gateway/session-store.ts b/electron/gateway/session-store.ts
index 881c9d8..edd1091 100644
--- a/electron/gateway/session-store.ts
+++ b/electron/gateway/session-store.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import logManager from '@electron/service/logger';
+import { normalizeAgentSessionKey } from '@runtime/lib/agents';
import type { RawMessage } from '@runtime/shared/chat-model';
let sessionsFilePath: string | null = null;
@@ -41,12 +42,21 @@ class SessionStore {
string,
Omit
>;
+ let migrated = false;
for (const [key, entry] of Object.entries(data)) {
- this.sessions.set(key, {
+ const normalizedKey = normalizeAgentSessionKey(key);
+ if (normalizedKey !== key) {
+ migrated = true;
+ }
+ this.sessions.set(normalizedKey, {
...entry,
+ key: normalizedKey,
activeRun: undefined,
});
}
+ if (migrated) {
+ this.saveToDisk();
+ }
}
} catch (e) {
logManager.error('Failed to load sessions from disk:', e);
@@ -73,21 +83,22 @@ class SessionStore {
getOrCreate(key: string): SessionEntry {
this.ensureLoaded();
- let session = this.sessions.get(key);
+ const normalizedKey = normalizeAgentSessionKey(key);
+ let session = this.sessions.get(normalizedKey);
if (!session) {
session = {
- key,
+ key: normalizedKey,
messages: [],
updatedAt: Date.now(),
};
- this.sessions.set(key, session);
+ this.sessions.set(normalizedKey, session);
}
return session;
}
get(key: string): SessionEntry | undefined {
this.ensureLoaded();
- return this.sessions.get(key);
+ return this.sessions.get(normalizeAgentSessionKey(key));
}
getAllKeys(): string[] {
@@ -114,18 +125,18 @@ class SessionStore {
}
clearActiveRun(key: string): void {
- const session = this.sessions.get(key);
+ const session = this.sessions.get(normalizeAgentSessionKey(key));
if (session) {
session.activeRun = undefined;
}
}
getActiveRun(key: string): { runId: string; abortController: AbortController } | undefined {
- return this.sessions.get(key)?.activeRun;
+ return this.sessions.get(normalizeAgentSessionKey(key))?.activeRun;
}
deleteSession(key: string): void {
- this.sessions.delete(key);
+ this.sessions.delete(normalizeAgentSessionKey(key));
this.saveToDisk();
}
}
diff --git a/electron/main.ts b/electron/main.ts
index 7bd5afa..84a75b9 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -10,8 +10,9 @@ import log from 'electron-log';
import 'bytenode'; // Ensure bytenode is bundled/externalized correctly
import { appUpdater } from '@electron/service/updater';
import axios from 'axios';
-import { providerApiService, onProviderChange } from '@electron/service/provider-api-service';
+import { onProviderChange } from '@electron/service/provider-api-service';
import { gatewayManager } from '@electron/gateway/manager';
+import { dispatchLocalHostApi } from '@electron/api/router';
// 初始化 updater,确保在 app ready 之前或者之中注册好 IPC
appUpdater.init();
@@ -20,78 +21,14 @@ appUpdater.init();
// 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端
const HOST_API_BASE_URL = process.env.VITE_SERVICE_URL || 'http://8.138.234.141/ingress';
-async function handleLocalProviderApi(path: string, method: string, body: any) {
- const parsedBody = typeof body === 'string' && body ? JSON.parse(body) : body;
-
- if (path === '/api/provider-vendors' && method === 'GET') {
- return { success: true, ok: true, json: providerApiService.getVendors(), data: providerApiService.getVendors() };
- }
- if (path === '/api/provider-accounts' && method === 'GET') {
- return { success: true, ok: true, json: providerApiService.getAccounts(), data: providerApiService.getAccounts() };
- }
- if (path === '/api/providers' && method === 'GET') {
- return { success: true, ok: true, json: providerApiService.getProviders(), data: providerApiService.getProviders() };
- }
- if (path === '/api/provider-accounts/default' && method === 'GET') {
- return { success: true, ok: true, json: providerApiService.getDefault(), data: providerApiService.getDefault() };
- }
- if (path === '/api/provider-accounts' && method === 'POST') {
- const result = providerApiService.createAccount(parsedBody || {});
- return { success: true, ok: true, json: result, data: result };
- }
- if (path === '/api/provider-accounts/default' && method === 'PUT') {
- const result = providerApiService.setDefault(parsedBody || {});
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path.startsWith('/api/provider-accounts/') && method === 'PUT') {
- const id = decodeURIComponent(path.replace('/api/provider-accounts/', ''));
- const result = providerApiService.updateAccount(id, parsedBody || {});
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path.startsWith('/api/provider-accounts/') && method === 'DELETE') {
- const id = decodeURIComponent(path.replace('/api/provider-accounts/', ''));
- const result = providerApiService.deleteAccount(id);
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path === '/api/providers/default' && method === 'PUT') {
- const result = providerApiService.setDefault({ accountId: parsedBody?.providerId });
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path.startsWith('/api/providers/') && path.endsWith('/api-key') && method === 'GET') {
- const id = decodeURIComponent(path.replace('/api/providers/', '').replace('/api-key', ''));
- const result = providerApiService.getApiKey(id);
- return { success: true, ok: true, json: result, data: result };
- }
- if (path.startsWith('/api/providers/') && method === 'PUT') {
- // Provider updates are mapped to account updates for local storage
- const id = decodeURIComponent(path.replace('/api/providers/', ''));
- const result = providerApiService.updateAccount(id, parsedBody || {});
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path.startsWith('/api/providers/') && method === 'DELETE') {
- const [rawId, query] = path.replace('/api/providers/', '').split('?');
- const id = decodeURIComponent(rawId);
- if (query && query.includes('apiKeyOnly=1')) {
- const result = providerApiService.deleteApiKey(id);
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- const result = providerApiService.deleteAccount(id);
- return { success: result.success, ok: result.success, json: result, data: result };
- }
- if (path === '/api/providers/validate' && method === 'POST') {
- const result = await providerApiService.validateApiKey(parsedBody || {});
- return { success: true, ok: true, json: result, data: result };
- }
- if (path === '/api/usage/recent-token-history' && method === 'GET') {
- const usageHistory = await providerApiService.getUsageHistory();
- return { success: true, ok: true, json: usageHistory, data: usageHistory };
- }
- return null;
-}
-
ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => {
- // 1. 优先本地处理模型管理接口
- const localResult = await handleLocalProviderApi(path, method || 'GET', body);
+ // 1. 优先本地处理 Host API 路由(逐步对齐 ClawX)
+ const localResult = await dispatchLocalHostApi({
+ path,
+ method: method || 'GET',
+ headers,
+ body,
+ });
if (localResult) return localResult;
// 2. 其余接口代理到远端后端
diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts
new file mode 100644
index 0000000..4d33fd1
--- /dev/null
+++ b/electron/utils/paths.ts
@@ -0,0 +1,74 @@
+import { app } from 'electron';
+import { existsSync, mkdirSync, realpathSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+export const OPENCLAW_CONFIG_DIR_NAME = '.openclaw';
+export const OPENCLAW_RUNTIME_DIR_NAME = 'runtime';
+export const OPENCLAW_PACKAGE_DIR_NAME = 'openclaw';
+export const OPENCLAW_ENTRY_FILE_NAME = 'openclaw.mjs';
+
+export interface OpenClawRuntimePaths {
+ configDir: string;
+ runtimeDir: string;
+ dir: string;
+ resolvedDir: string;
+ entryPath: string;
+}
+
+export function getOpenClawConfigDir(): string {
+ return join(homedir(), OPENCLAW_CONFIG_DIR_NAME);
+}
+
+export function getOpenClawRuntimeDir(): string {
+ return join(getOpenClawConfigDir(), OPENCLAW_RUNTIME_DIR_NAME);
+}
+
+export function getOpenClawDir(): string {
+ if (app.isPackaged) {
+ return join(process.resourcesPath, OPENCLAW_PACKAGE_DIR_NAME);
+ }
+ return join(app.getAppPath(), 'node_modules', OPENCLAW_PACKAGE_DIR_NAME);
+}
+
+export function getOpenClawResolvedDir(): string {
+ const dir = getOpenClawDir();
+ if (!existsSync(dir)) {
+ return dir;
+ }
+
+ try {
+ return realpathSync(dir);
+ } catch {
+ return dir;
+ }
+}
+
+export function getOpenClawEntryPath(): string {
+ return join(getOpenClawDir(), OPENCLAW_ENTRY_FILE_NAME);
+}
+
+export function ensureDir(dir: string): string {
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ return dir;
+}
+
+export function ensureOpenClawRuntimeLayout(
+ paths: OpenClawRuntimePaths = getOpenClawRuntimePaths(),
+): OpenClawRuntimePaths {
+ ensureDir(paths.configDir);
+ ensureDir(paths.runtimeDir);
+ return paths;
+}
+
+export function getOpenClawRuntimePaths(): OpenClawRuntimePaths {
+ return {
+ configDir: getOpenClawConfigDir(),
+ runtimeDir: getOpenClawRuntimeDir(),
+ dir: getOpenClawDir(),
+ resolvedDir: getOpenClawResolvedDir(),
+ entryPath: getOpenClawEntryPath(),
+ };
+}
diff --git a/electron/utils/token-usage-writer.ts b/electron/utils/token-usage-writer.ts
index c285721..f6903ad 100644
--- a/electron/utils/token-usage-writer.ts
+++ b/electron/utils/token-usage-writer.ts
@@ -1,23 +1,12 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
+import { parseSessionKey } from '@runtime/lib/agents';
export function getTranscriptFilePath(sessionKey: string): string {
- let agentId: string;
- let sessionId: string;
-
- if (sessionKey.startsWith('agent:')) {
- const parts = sessionKey.split(':');
- agentId = parts[1] ?? 'default';
- sessionId = parts.slice(2).join(':') || sessionKey;
- } else if (sessionKey.startsWith('local:')) {
- const parts = sessionKey.split(':');
- agentId = parts[1] ?? 'local';
- sessionId = parts.slice(2).join(':') || sessionKey;
- } else {
- agentId = 'default';
- sessionId = sessionKey;
- }
+ const parsed = parseSessionKey(sessionKey);
+ let agentId = parsed.isAgentSession ? parsed.agentId : 'default';
+ let sessionId = parsed.isAgentSession ? parsed.sessionId : sessionKey;
if (!sessionId) {
sessionId = 'unknown';
diff --git a/runtime-shared/lib/agents.ts b/runtime-shared/lib/agents.ts
new file mode 100644
index 0000000..da329e7
--- /dev/null
+++ b/runtime-shared/lib/agents.ts
@@ -0,0 +1,92 @@
+export const DEFAULT_AGENT_ID = 'main';
+export const DEFAULT_MAIN_SESSION_SUFFIX = 'main';
+
+export interface AgentSummary {
+ id: string;
+ name: string;
+ isDefault: boolean;
+ providerAccountId: string | null;
+ modelRef: string | null;
+ modelDisplay: string;
+ mainSessionKey: string;
+ vendorId?: string | null;
+ source?: 'synthetic-main' | 'provider-account';
+}
+
+export interface AgentsSnapshot {
+ agents: AgentSummary[];
+ defaultAgentId: string;
+ defaultProviderAccountId: string | null;
+ defaultModelRef: string | null;
+ mainSessionSuffix: string;
+ configuredChannelTypes: string[];
+ channelOwners: Record;
+ channelAccountOwners: Record;
+}
+
+export interface ParsedSessionKey {
+ sessionKey: string;
+ agentId: string;
+ sessionId: string;
+ isAgentSession: boolean;
+}
+
+export function normalizeAgentId(value: string | null | undefined): string {
+ const normalized = String(value ?? '').trim().toLowerCase();
+ return normalized || DEFAULT_AGENT_ID;
+}
+
+export function normalizeSessionSuffix(value: string | null | undefined): string {
+ const normalized = String(value ?? '').trim().toLowerCase();
+ return normalized || DEFAULT_MAIN_SESSION_SUFFIX;
+}
+
+export function buildAgentSessionKey(agentId: string, sessionId: string): string {
+ return `agent:${normalizeAgentId(agentId)}:${normalizeSessionSuffix(sessionId)}`;
+}
+
+export function buildMainSessionKey(
+ agentId: string,
+ sessionId = DEFAULT_MAIN_SESSION_SUFFIX,
+): string {
+ return buildAgentSessionKey(agentId, sessionId);
+}
+
+export function parseSessionKey(sessionKey: string): ParsedSessionKey {
+ const trimmed = String(sessionKey ?? '').trim();
+
+ if (trimmed.startsWith('agent:')) {
+ const parts = trimmed.split(':');
+ const agentId = normalizeAgentId(parts[1]);
+ const sessionId = normalizeSessionSuffix(parts.slice(2).join(':'));
+ return {
+ sessionKey: buildAgentSessionKey(agentId, sessionId),
+ agentId,
+ sessionId,
+ isAgentSession: true,
+ };
+ }
+
+ if (trimmed.startsWith('local:')) {
+ const parts = trimmed.split(':');
+ const agentId = normalizeAgentId(parts[1]);
+ const sessionId = normalizeSessionSuffix(parts.slice(2).join(':'));
+ return {
+ sessionKey: buildAgentSessionKey(agentId, sessionId),
+ agentId,
+ sessionId,
+ isAgentSession: true,
+ };
+ }
+
+ return {
+ sessionKey: trimmed,
+ agentId: DEFAULT_AGENT_ID,
+ sessionId: normalizeSessionSuffix(trimmed),
+ isAgentSession: false,
+ };
+}
+
+export function normalizeAgentSessionKey(sessionKey: string): string {
+ return parseSessionKey(sessionKey).sessionKey;
+}
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
index 32b0b7e..1b644a4 100644
--- a/src/pages/Home/index.tsx
+++ b/src/pages/Home/index.tsx
@@ -15,11 +15,13 @@ import {
import { IPC_EVENTS } from '../../lib/constants';
import { invokeIpc } from '../../lib/host-api';
import {
+ agentsStore,
channelStore,
chatStore,
getCompletedTasks,
getPendingTasks,
taskStore,
+ useAgentsStore,
useChannelStore,
useChatStore,
useTaskStore,
@@ -147,6 +149,7 @@ function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null
}
export default function HomePage() {
+ const agentsState = useAgentsStore();
const chat = useChatStore();
const taskState = useTaskStore();
const channelState = useChannelStore();
@@ -158,6 +161,7 @@ export default function HomePage() {
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
useEffect(() => {
+ void agentsStore.init();
void chatStore.init();
void taskStore.init();
void channelStore.init();
@@ -208,6 +212,10 @@ export default function HomePage() {
const latestTask = currentTaskSource[0];
const visibleMessages = mapMessages(chat.messages, chat.streamingMessage);
+ const selectedAgentId = agentsState.agents.some((agent) => agent.id === chat.currentAgentId)
+ ? chat.currentAgentId
+ : agentsState.defaultAgentId;
+ const currentAgent = agentsState.agents.find((agent) => agent.id === selectedAgentId) || null;
async function handleSendMessage(): Promise {
const sent = await chatStore.sendMessage(inputMessage, attachments);
@@ -279,7 +287,7 @@ export default function HomePage() {
loading={!chat.initialized}
selectedConversationId={chat.currentSessionKey}
onNewChat={() => {
- void chatStore.newSession();
+ void chatStore.newSession(selectedAgentId || undefined);
}}
onSelectConversation={(conversationId) => {
chatStore.switchSession(conversationId);
@@ -307,18 +315,39 @@ export default function HomePage() {
智能对话
网关状态:{chat.gatewayStatus === 'connected' ? '已连接' : chat.gatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
+ {currentAgent ? ` · 当前代理:${currentAgent.name}` : ''}
-
+
+
+
+
diff --git a/src/pages/agents/components/AgentsSection.tsx b/src/pages/agents/components/AgentsSection.tsx
new file mode 100644
index 0000000..be15087
--- /dev/null
+++ b/src/pages/agents/components/AgentsSection.tsx
@@ -0,0 +1,84 @@
+import { useEffect } from 'react';
+import { agentsStore, useAgentsStore } from '../../../stores';
+
+const CHIP_CLASS_NAME = [
+ 'rounded-full border px-2.5 py-1 text-[11px] leading-none',
+ 'border-[#E5E8EE] text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300',
+].join(' ');
+
+export default function AgentsSection() {
+ const agentState = useAgentsStore();
+
+ useEffect(() => {
+ void agentsStore.init();
+ }, []);
+
+ return (
+
+
+
+
+ Agents Snapshot
+
+
+ 当前本地 `agents` 契约与 `mainSessionKey` 映射。
+
+
+
+
+
+ {agentState.error ? (
+
+ {agentState.error}
+
+ ) : null}
+
+
+ {agentState.agents.map((agent) => (
+
+
+
+
+ {agent.name}
+
+
{agent.id}
+
+ {agent.isDefault ? (
+
+ 默认
+
+ ) : null}
+
+
+
+ Provider: {agent.providerAccountId || '--'}
+ Model: {agent.modelDisplay || '--'}
+
+
+
+
mainSessionKey
+
{agent.mainSessionKey}
+
+
+ ))}
+
+ {!agentState.loading && agentState.agents.length === 0 ? (
+
+ 当前还没有可用 agent。先在下方配置 provider 账号后,这里会自动生成可路由的 agent snapshot。
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/pages/agents/index.tsx b/src/pages/agents/index.tsx
index f941c09..03d2d06 100644
--- a/src/pages/agents/index.tsx
+++ b/src/pages/agents/index.tsx
@@ -1,3 +1,4 @@
+import AgentsSection from './components/AgentsSection';
import ProvidersSection from './components/ProvidersSection';
import UsageHistorySection from './components/UsageHistorySection';
@@ -17,6 +18,7 @@ export default function AgentsPage() {
diff --git a/src/router/index.tsx b/src/router/index.tsx
index a87f3fa..743fc8e 100644
--- a/src/router/index.tsx
+++ b/src/router/index.tsx
@@ -3,7 +3,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
import MainLayout from '../components/layout/MainLayout';
import HomePage from '../pages/Home';
import LoginPage from '../pages/Login';
-import AgentsPage from '../pages/Agents';
+import AgentsPage from '../pages/agents';
import SkillsPage from '../pages/Skills';
import CronPage from '../pages/Cron';
import ScriptsPage from '../pages/Scripts';
diff --git a/src/stores/agents.ts b/src/stores/agents.ts
new file mode 100644
index 0000000..993910a
--- /dev/null
+++ b/src/stores/agents.ts
@@ -0,0 +1,144 @@
+import { useSyncExternalStore } from 'react';
+import {
+ DEFAULT_AGENT_ID,
+ DEFAULT_MAIN_SESSION_SUFFIX,
+ buildMainSessionKey,
+ normalizeAgentId,
+ type AgentSummary,
+ type AgentsSnapshot,
+} from '@runtime/lib/agents';
+import { hostApiFetch } from '../lib/host-api';
+
+export interface AgentsStoreState {
+ initialized: boolean;
+ loading: boolean;
+ error: string | null;
+ agents: AgentSummary[];
+ defaultAgentId: string;
+ defaultProviderAccountId: string | null;
+ defaultModelRef: string | null;
+ mainSessionSuffix: string;
+}
+
+const listeners = new Set<() => void>();
+
+let loadAgentsInFlight: Promise | null = null;
+let state: AgentsStoreState = {
+ initialized: false,
+ loading: false,
+ error: null,
+ agents: [],
+ defaultAgentId: DEFAULT_AGENT_ID,
+ defaultProviderAccountId: null,
+ defaultModelRef: null,
+ mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX,
+};
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function patchState(patch: Partial): AgentsStoreState {
+ state = { ...state, ...patch };
+ emit();
+ return state;
+}
+
+function sanitizeAgent(agent: AgentSummary): AgentSummary {
+ const normalizedId = normalizeAgentId(agent.id);
+ const normalizedMainSessionKey = agent.mainSessionKey || buildMainSessionKey(normalizedId);
+
+ return {
+ id: normalizedId,
+ name: agent.name || normalizedId,
+ isDefault: Boolean(agent.isDefault),
+ providerAccountId: agent.providerAccountId ?? null,
+ modelRef: agent.modelRef ?? null,
+ modelDisplay: agent.modelDisplay || agent.modelRef || agent.name || normalizedId,
+ mainSessionKey: normalizedMainSessionKey,
+ vendorId: agent.vendorId ?? null,
+ source: agent.source,
+ };
+}
+
+async function loadAgents(): Promise {
+ if (loadAgentsInFlight) {
+ await loadAgentsInFlight;
+ return;
+ }
+
+ loadAgentsInFlight = (async () => {
+ patchState({ loading: true, error: null });
+
+ try {
+ const snapshot = await hostApiFetch('/api/agents');
+ const agents = Array.isArray(snapshot?.agents)
+ ? snapshot.agents.map((agent) => sanitizeAgent(agent))
+ : [];
+
+ patchState({
+ initialized: true,
+ loading: false,
+ error: null,
+ agents,
+ defaultAgentId: snapshot?.defaultAgentId ? normalizeAgentId(snapshot.defaultAgentId) : DEFAULT_AGENT_ID,
+ defaultProviderAccountId: snapshot?.defaultProviderAccountId ?? null,
+ defaultModelRef: snapshot?.defaultModelRef ?? null,
+ mainSessionSuffix: snapshot?.mainSessionSuffix || DEFAULT_MAIN_SESSION_SUFFIX,
+ });
+ } catch (error) {
+ patchState({
+ initialized: true,
+ loading: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ })();
+
+ try {
+ await loadAgentsInFlight;
+ } finally {
+ loadAgentsInFlight = null;
+ }
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): AgentsStoreState {
+ return state;
+}
+
+function getAgentById(agentId: string | null | undefined): AgentSummary | undefined {
+ const normalizedId = normalizeAgentId(agentId);
+ return state.agents.find((agent) => agent.id === normalizedId);
+}
+
+function resolveMainSessionKey(agentId: string | null | undefined): string {
+ const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
+ return getAgentById(normalizedId)?.mainSessionKey || buildMainSessionKey(normalizedId, state.mainSessionSuffix);
+}
+
+function resolveProviderAccountId(agentId: string | null | undefined): string | null {
+ const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
+ return getAgentById(normalizedId)?.providerAccountId ?? state.defaultProviderAccountId;
+}
+
+export const agentsStore = {
+ subscribe,
+ getSnapshot,
+ getState: () => state,
+ init: loadAgents,
+ load: loadAgents,
+ getAgentById,
+ resolveMainSessionKey,
+ resolveProviderAccountId,
+};
+
+export function useAgentsStore(): AgentsStoreState {
+ return useSyncExternalStore(agentsStore.subscribe, agentsStore.getSnapshot, agentsStore.getSnapshot);
+}
diff --git a/src/stores/chat.ts b/src/stores/chat.ts
index 415b3bd..69fda03 100644
--- a/src/stores/chat.ts
+++ b/src/stores/chat.ts
@@ -1,15 +1,24 @@
import { useSyncExternalStore } from 'react';
+import {
+ DEFAULT_AGENT_ID as FALLBACK_AGENT_ID,
+ DEFAULT_MAIN_SESSION_SUFFIX,
+ buildAgentSessionKey,
+ buildMainSessionKey,
+ normalizeAgentId,
+ normalizeAgentSessionKey,
+ parseSessionKey,
+} from '@runtime/lib/agents';
import type { ChatSession, RawMessage, ToolStatus } from '../shared/chat-model';
import { extractText, isToolOnlyMessage } from '../shared/chat-model';
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
import { hostApiFetch } from '../lib/host-api';
import type { GatewayEvent } from '../types/runtime';
+import { agentsStore } from './agents';
-const DEFAULT_SESSION_KEY = 'agent:main:main';
-const DEFAULT_AGENT_ID = '1953462165250859011';
const SESSION_LOAD_MIN_INTERVAL_MS = 1200;
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
const CHAT_EVENT_DEDUPE_TTL_MS = 30000;
+const FALLBACK_MAIN_SESSION_KEY = buildMainSessionKey(FALLBACK_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX);
export interface StagedAttachment {
fileName: string;
@@ -19,6 +28,18 @@ export interface StagedAttachment {
preview: string | null;
}
+interface TranscriptMessageLike extends Omit {
+ timestamp?: string | number;
+}
+
+interface SessionTranscriptResponse {
+ sessionKey: string;
+ sessionId: string;
+ agentId: string;
+ transcriptPath: string;
+ messages: TranscriptMessageLike[];
+}
+
export interface ChatStoreState {
initialized: boolean;
messages: RawMessage[];
@@ -58,8 +79,8 @@ let state: ChatStoreState = {
pendingFinal: false,
lastUserMessageAt: null,
sessions: [],
- currentSessionKey: DEFAULT_SESSION_KEY,
- currentAgentId: DEFAULT_AGENT_ID,
+ currentSessionKey: FALLBACK_MAIN_SESSION_KEY,
+ currentAgentId: FALLBACK_AGENT_ID,
sessionLabels: {},
sessionLastActivity: {},
gatewayStatus: 'disconnected',
@@ -78,9 +99,25 @@ function patchState(patch: Partial): ChatStoreState {
}
function getAgentIdFromSessionKey(sessionKey: string): string {
- if (!sessionKey.startsWith('agent:')) return DEFAULT_AGENT_ID;
- const parts = sessionKey.split(':');
- return parts[1] || DEFAULT_AGENT_ID;
+ const parsed = parseSessionKey(normalizeAgentSessionKey(sessionKey));
+ if (parsed.isAgentSession) return parsed.agentId;
+ return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
+}
+
+function getDefaultAgentId(): string {
+ return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
+}
+
+function getDefaultMainSessionKey(): string {
+ return agentsStore.resolveMainSessionKey(getDefaultAgentId()) || FALLBACK_MAIN_SESSION_KEY;
+}
+
+function resolveMainSessionKeyForAgent(agentId: string | null | undefined): string {
+ return agentsStore.resolveMainSessionKey(agentId || getDefaultAgentId()) || getDefaultMainSessionKey();
+}
+
+function buildNewSessionKey(agentId: string | null | undefined): string {
+ return buildAgentSessionKey(agentId || getDefaultAgentId(), `session-${Date.now()}`);
}
function clearSessionEntryFromMap>(entries: T, sessionKey: string): T {
@@ -170,6 +207,14 @@ async function resolveDefaultAccountId(): Promise {
}
}
+async function resolveProviderAccountIdForAgent(agentId: string | null | undefined): Promise {
+ const mappedAccountId = agentsStore.resolveProviderAccountId(agentId);
+ if (mappedAccountId) {
+ return mappedAccountId;
+ }
+ return resolveDefaultAccountId();
+}
+
async function stageBuffer(base64: string, fileName: string, mimeType: string): Promise {
try {
const result = await hostApiFetch('/api/files/stage-buffer', {
@@ -194,6 +239,31 @@ async function stageBuffer(base64: string, fileName: string, mimeType: string):
};
}
+function normalizeTranscriptMessage(message: TranscriptMessageLike): RawMessage {
+ const rawTimestamp = message.timestamp;
+ let timestamp: number | undefined;
+
+ if (typeof rawTimestamp === 'number') {
+ timestamp = rawTimestamp;
+ } else if (typeof rawTimestamp === 'string') {
+ const parsed = Date.parse(rawTimestamp);
+ timestamp = Number.isFinite(parsed) ? parsed : undefined;
+ }
+
+ return {
+ ...message,
+ timestamp,
+ };
+}
+
+async function loadTranscriptHistory(sessionKey: string): Promise {
+ const query = new URLSearchParams({ sessionKey });
+ const response = await hostApiFetch(`/api/sessions/transcript?${query.toString()}`);
+ return Array.isArray(response?.messages)
+ ? response.messages.map((message) => normalizeTranscriptMessage(message))
+ : [];
+}
+
async function subscribeToGateway(): Promise {
if (gatewaySubscribed) return;
@@ -213,6 +283,7 @@ async function subscribeToGateway(): Promise {
}
async function loadSessions(): Promise {
+ await agentsStore.init();
const now = Date.now();
if (loadSessionsInFlight) {
await loadSessionsInFlight;
@@ -225,19 +296,46 @@ async function loadSessions(): Promise {
loadSessionsInFlight = (async () => {
try {
const localKeys = await gatewayRpc('session.list', {});
- let sessions: ChatSession[] = localKeys.map((key) => ({
+ const normalizedKeys = Array.from(
+ new Set(localKeys.map((key) => normalizeAgentSessionKey(key)).filter(Boolean)),
+ );
+ const canonicalBySuffix = new Map();
+ for (const key of normalizedKeys) {
+ const parsed = parseSessionKey(key);
+ if (parsed.isAgentSession && parsed.sessionId && !canonicalBySuffix.has(parsed.sessionId)) {
+ canonicalBySuffix.set(parsed.sessionId, parsed.sessionKey);
+ }
+ }
+
+ let sessions: ChatSession[] = normalizedKeys.map((key) => ({
key,
displayName: state.sessionLabels[key] || 'New Chat',
updatedAt: state.sessionLastActivity[key] || Date.now(),
}));
- const existingNonLocal = state.sessions.filter((session) => !session.key.startsWith('local:'));
- sessions = [...existingNonLocal, ...sessions];
+ const existingTransient = state.sessions.filter((session) => {
+ const parsed = parseSessionKey(session.key);
+ return !parsed.isAgentSession;
+ });
+ sessions = [...existingTransient, ...sessions];
- let nextSessionKey = state.currentSessionKey || DEFAULT_SESSION_KEY;
+ let nextSessionKey = state.currentSessionKey || getDefaultMainSessionKey();
+ const normalizedCurrentSessionKey = normalizeAgentSessionKey(nextSessionKey);
+ if (normalizedCurrentSessionKey.startsWith('agent:')) {
+ nextSessionKey = normalizedCurrentSessionKey;
+ }
+ if (!nextSessionKey.startsWith('agent:')) {
+ const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
+ if (canonicalMatch) {
+ nextSessionKey = canonicalMatch;
+ }
+ }
if (!sessions.find((session) => session.key === nextSessionKey) && sessions.length > 0) {
nextSessionKey = sessions[0].key;
}
+ if (!nextSessionKey) {
+ nextSessionKey = getDefaultMainSessionKey();
+ }
const sessionsWithCurrent =
!sessions.find((session) => session.key === nextSessionKey) && nextSessionKey
@@ -261,7 +359,7 @@ async function loadSessions(): Promise {
},
});
- if (nextSessionKey && nextSessionKey !== DEFAULT_SESSION_KEY) {
+ if (nextSessionKey) {
await loadHistory(nextSessionKey, true);
}
@@ -316,7 +414,7 @@ async function loadSessions(): Promise {
}
async function loadHistory(sessionKey = state.currentSessionKey, quiet = false): Promise {
- if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) {
+ if (!sessionKey) {
patchState({
messages: [],
loading: false,
@@ -341,10 +439,24 @@ async function loadHistory(sessionKey = state.currentSessionKey, quiet = false):
const loadPromise = (async () => {
try {
- const messages = await gatewayRpc('chat.history', {
- sessionKey,
- limit: 50,
- });
+ let messages: RawMessage[] = [];
+
+ try {
+ messages = await gatewayRpc('chat.history', {
+ sessionKey,
+ limit: 50,
+ });
+ } catch {
+ messages = [];
+ }
+
+ if (!Array.isArray(messages) || messages.length === 0) {
+ try {
+ messages = await loadTranscriptHistory(sessionKey);
+ } catch {
+ messages = [];
+ }
+ }
if (state.currentSessionKey !== sessionKey) return;
@@ -414,20 +526,14 @@ async function loadHistory(sessionKey = state.currentSessionKey, quiet = false):
}
}
-async function newSession(): Promise {
- const defaultAccountId = await resolveDefaultAccountId();
- if (!defaultAccountId) {
- patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
- return;
- }
-
+async function newSession(agentId = state.currentAgentId): Promise {
const leavingEmpty =
!state.currentSessionKey.endsWith(':main') &&
state.messages.length === 0 &&
!state.sessionLastActivity[state.currentSessionKey] &&
!state.sessionLabels[state.currentSessionKey];
- const newKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
+ const newKey = buildNewSessionKey(agentId || getDefaultAgentId());
const nextSessions = leavingEmpty
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
: state.sessions;
@@ -458,6 +564,16 @@ function switchSession(sessionKey: string): void {
void loadHistory(sessionKey);
}
+function selectAgent(agentId: string): void {
+ const normalizedAgentId = normalizeAgentId(agentId);
+ const mainSessionKey = resolveMainSessionKeyForAgent(normalizedAgentId);
+ if (mainSessionKey === state.currentSessionKey) {
+ patchState({ currentAgentId: normalizedAgentId });
+ return;
+ }
+ switchSession(mainSessionKey);
+}
+
async function deleteSession(sessionKey: string): Promise {
try {
await gatewayRpc('session.delete', { sessionKey });
@@ -473,7 +589,7 @@ async function deleteSession(sessionKey: string): Promise {
};
if (state.currentSessionKey === sessionKey) {
- const nextSession = remaining[0]?.key ?? DEFAULT_SESSION_KEY;
+ const nextSession = remaining[0]?.key ?? getDefaultMainSessionKey();
patchState({
...basePatch,
currentSessionKey: nextSession,
@@ -487,7 +603,7 @@ async function deleteSession(sessionKey: string): Promise {
lastUserMessageAt: null,
});
- if (nextSession !== DEFAULT_SESSION_KEY) {
+ if (nextSession) {
await loadHistory(nextSession);
}
return;
@@ -512,17 +628,18 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
const trimmedText = text.trim();
if (!trimmedText && attachments.length === 0) return false;
- const defaultAccountId = await resolveDefaultAccountId();
- if (!defaultAccountId) {
+ let targetSessionKey = state.currentSessionKey || resolveMainSessionKeyForAgent(state.currentAgentId);
+ targetSessionKey = normalizeAgentSessionKey(targetSessionKey);
+ if (!targetSessionKey) {
+ targetSessionKey = getDefaultMainSessionKey();
+ }
+ const targetAgentId = getAgentIdFromSessionKey(targetSessionKey);
+ const providerAccountId = await resolveProviderAccountIdForAgent(targetAgentId);
+ if (!providerAccountId) {
patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
return false;
}
- let targetSessionKey = state.currentSessionKey;
- if (!targetSessionKey || targetSessionKey === DEFAULT_SESSION_KEY) {
- targetSessionKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
- }
-
const nowMs = Date.now();
const userMessage: RawMessage = {
role: 'user',
@@ -559,7 +676,7 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
lastUserMessageAt: nowMs,
sessions: nextSessions,
currentSessionKey: targetSessionKey,
- currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
+ currentAgentId: targetAgentId,
sessionLabels: nextLabels,
sessionLastActivity: {
...state.sessionLastActivity,
@@ -583,7 +700,7 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
content: messageContent,
},
options: {
- providerAccountId: defaultAccountId,
+ providerAccountId,
},
});
@@ -617,7 +734,7 @@ async function abortRun(): Promise {
lastUserMessageAt: null,
});
- if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) return;
+ if (!sessionKey) return;
try {
await gatewayRpc('chat.abort', { sessionKey });
@@ -733,6 +850,7 @@ function clearError(): void {
}
async function initChatStore(): Promise {
+ await agentsStore.init();
await subscribeToGateway();
await loadSessions();
}
@@ -775,6 +893,7 @@ export const chatStore = {
loadHistory,
switchSession,
newSession,
+ selectAgent,
deleteSession,
renameSession,
sendMessage,
diff --git a/src/stores/index.ts b/src/stores/index.ts
index 1d2caf7..32c1731 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -1,4 +1,5 @@
export * from './settings';
+export * from './agents';
export * from './chat';
export * from './task';
export * from './channel';