diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js
index 48b2026..b4e89e9 100644
--- a/dist-electron/main/main.js
+++ b/dist-electron/main/main.js
@@ -1,7 +1 @@
-"use strict";
-require("electron");
-require("./main-ByCp1zrw.js");
-require("electron-squirrel-startup");
-require("electron-log");
-require("bytenode");
-require("axios");
+"use strict";require("electron");require("./main-BEhicpdt.js");require("electron-squirrel-startup");require("electron-log");require("bytenode");require("axios");
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 28380ff..fd3af34 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -1,17 +1,16 @@
-
-
-
-
- NIANXX
-
-
-
-
-
-
-
-
-
+
+
+
+
+ NIANXX
+
+
+
+
+
+
+
+
diff --git a/docs/OpenClaw-Chat-Alignment-Plan.md b/docs/OpenClaw-Chat-Alignment-Plan.md
new file mode 100644
index 0000000..fc35dc2
--- /dev/null
+++ b/docs/OpenClaw-Chat-Alignment-Plan.md
@@ -0,0 +1,229 @@
+# zn-ai 对齐 ClawX 对话能力的 OpenClaw 集成与迁移计划
+
+## 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,降低一次性重写风险。
+
+## 2. ClawX 集成 OpenClaw 的实现思路
+
+| 层级 | 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 对话体验的关键差异点 |
+
+## 3. zn-ai 当前基础与差距
+
+### 3.1 已有基础
+
+| 能力 | 当前实现 | 关键文件 | 评估 |
+| --- | --- | --- | --- |
+| 主进程聊天入口 | 已有 `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/*` | 可迭代增强 |
+
+### 3.2 主要差距
+
+| 差距 | 当前状态 | 对齐目标 |
+| --- | --- | --- |
+| 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 的启动、重启、健康、孤儿进程治理逻辑 |
+
+## 4. 推荐目标架构
+
+```text
+Vue Renderer
+ ├─ src/stores/chat.ts
+ ├─ src/lib/gateway-client.ts
+ └─ src/lib/host-api.ts
+ │
+ │ IPC + 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
+ │
+ │ utilityProcess + ~/.openclaw
+ ▼
+OpenClaw Gateway
+ ├─ providers / auth profiles
+ ├─ agents / sessions / transcripts
+ ├─ tool use / thinking / attachments
+ └─ channel / cron / skills / model routing
+```
+
+### 架构原则
+
+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` 测试脚本、调试脚本、文档 | 通过验收清单,具备对外演示条件 |
+
+## 7. sub-agent 数量估算
+
+### 7.1 推荐编制
+
+- 分析集成 sub-agent:`2`
+- 功能迁移 sub-agent:`5`
+- 集成验收 sub-agent:`1`
+- 推荐总数:`8`
+
+### 7.2 最小可行编制
+
+- 分析集成 sub-agent:`2`
+- 功能迁移 sub-agent:`4`
+- 集成验收由主协调 agent 兼任
+- 最小总数:`6`
+
+### 7.3 为什么推荐 2 + 5 + 1
+
+- `ClawX` 与 `zn-ai` 的主要差距横跨“打包、主进程、Host API、配置同步、Renderer 状态机、UI、转录/执行图”七个面。
+- 其中 `OpenClaw runtime 嵌入` 和 `Renderer 对话能力对齐` 之间依赖明确,但文件改动面基本可分离,适合并行。
+- 如果迁移 sub-agent 少于 `4`,会出现“主进程迁移完成但 Renderer 无法及时接入”或“UI 已变更但 transcript/API 尚未可用”的串行瓶颈。
+
+## 8. 功能迁移 sub-agent 分工方案
+
+| 角色 | 数量 | 负责范围 | 建议文件所有权 |
+| --- | --- | --- | --- |
+| 分析 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、发布说明 | 测试与文档,不抢占前面文件所有权 |
+
+### 8.1 并行施工波次
+
+| 波次 | 并行 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/执行图,并完成端到端验收 |
+
+### 8.2 依赖关系
+
+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 双态验证 |
+
+## 11. 完成验收标准
+
+- `zn-ai` 可以在开发态和打包态拉起 OpenClaw Gateway。
+- 聊天页默认使用本地默认模型配置,且修改默认模型后下一轮对话生效。
+- 会话列表支持新建、切换、加载、删除、重命名。
+- 聊天页支持流式文本、Markdown、thinking、tool cards、图片/文件附件。
+- `Abort`、错误恢复、历史回灌稳定可用。
+- `@agent` 能把消息送到目标 Agent 主会话,而不是仅显示占位 chip。
+- 至少一个子任务 transcript 能被加载并展示为执行图或子任务明细。
+- 不影响 `Scripts`、`Tasks`、模型管理页的现有主流程。
+
+## 12. 与现有文档的关系
+
+- `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
new file mode 100644
index 0000000..592c8e2
--- /dev/null
+++ b/docs/Vue-Exit-Checklist.md
@@ -0,0 +1,136 @@
+# zn-ai Vue 退场清单
+
+目标:在 React 接管默认入口后,按清单删除所有仅服务 Vue 的资产,最终收敛为 `React-only`。
+
+## 当前状态
+
+- `Home` 的 React 主链路已经接入真实 `chat/task/channel` store、真实 IPC,以及 `TaskOperationDialog / AddChannelDialog`。
+- `Home` 这批旧 Vue 文件目前仍被 `src/router/index.ts -> src/pages/home/index.vue` 的 Vue fallback 引用,因此本轮不能直接删除。
+- 其余主页面虽然已有 `src-react/pages/*` 路由壳,但当前大多还是占位页,尚不满足“删除 Vue 路由与页面”的退场条件。
+
+当前删除策略:
+
+1. 先停止新增对 `src/pages/home/**`、`src/stores/chat.ts`、`src/stores/task.ts`、`src/stores/channel.ts` 的 React 侧依赖。
+2. 等 `Agents / Knowledge / Skills / Cron / Scripts / Setting / Login` 的 React 页面完成真实迁移后,再统一删除 `src/router/*`、`.vue` 页面和旧 Pinia store。
+3. 在此之前,只做“替换引用”和“收口依赖”,不做会破坏 Vue fallback 的物理删除。
+
+## 1. 入口与切换层
+
+- `src/main-vue.ts`
+- `src/framework.ts`
+- `src/main.ts`
+- `src/App.vue`
+- `src/permission.ts`
+
+退场标准:
+
+- `src/main.ts` 不再分流 Vue 入口。
+- `src/framework.ts` 不再保留 `vue` 分支。
+- `src/main-vue.ts` 删除。
+- `src/App.vue` 和 `src/permission.ts` 只要还依赖 Vue 生命周期或 Router 守卫,就必须迁入 React 后再删除旧文件。
+
+## 2. Vue 页面
+
+以下页面都属于 Vue 退场范围,React 版本完成后删除:
+
+- `src/pages/home/index.vue`
+- `src/pages/home/ChatBox.vue`
+- `src/pages/home/ChatHistory.vue`
+- `src/pages/home/TaskCenter.vue`
+- `src/pages/home/components/*.vue`
+- `src/pages/login/index.vue`
+- `src/pages/agents/index.vue`
+- `src/pages/knowledge/index.vue`
+- `src/pages/skills/index.vue`
+- `src/pages/skills/components/*.vue`
+- `src/pages/cron/index.vue`
+- `src/pages/cron/components/*.vue`
+- `src/pages/setting/index.vue`
+- `src/pages/setting/components/*.vue`
+
+建议删除顺序:
+
+1. 先迁 `home`,再迁其余业务页。
+2. 先删纯展示/表单页,再删聊天主链路。
+3. 最后统一清理页面下的 Vue 子组件目录。
+
+## 3. Vue Store
+
+以下 `Pinia` store 是 Vue 体系的核心状态,React 完成对等实现后删除:
+
+- `src/stores/chat.ts`
+- `src/stores/providers.ts`
+- `src/stores/channel.ts`
+- `src/stores/cron.ts`
+- `src/stores/script.ts`
+- `src/stores/skills.ts`
+- `src/stores/task.ts`
+- `src/stores/theme.ts`
+- `src/stores/locale.ts`
+- `src/stores/update.ts`
+- `src/stores/userinfo.ts`
+- `src/stores/sharedStore.ts`
+
+退场标准:
+
+- React 侧已具备对等 store 后,旧 Pinia store 不再被任何路由或组件引用。
+- `src/stores/*` 不再承担主流程状态。
+
+## 4. Router 与守卫
+
+需要替换或删除的 Vue 路由资产:
+
+- `src/router/index.ts`
+- `src/constant/menus.ts`
+- `src/permission.ts`
+- `src/components/SideMenus/index.vue`
+
+退场标准:
+
+- 路由切换完全由 React Router 接管。
+- 菜单配置不再依赖 Vue 路由表。
+- 登录守卫迁到 React 侧后,旧守卫文件删除。
+
+## 5. 仅服务 Vue 的共享组件
+
+这些组件如果只被 Vue 页面使用,应在 React 对等实现后删除:
+
+- `src/components/Layout/index.vue`
+- `src/components/Layout/TitleBar/index.vue`
+- `src/components/NativeTooltip/index.vue`
+- `src/components/Pagination/index.vue`
+- `src/components/TitleSection/index.vue`
+
+## 6. Vue 依赖与构建项
+
+`package.json` 中后续需要删除或替换的 Vue 依赖:
+
+- `vue`
+- `vue-router`
+- `pinia`
+- `vue-i18n`
+- `element-plus`
+- `@vueuse/core`
+- `@vitejs/plugin-vue`
+- `@lucide/vue`
+- `@remixicon/vue`
+- `vue-codemirror`
+- `vue-markdown-render`
+
+`vite.config.ts` 中后续需要清理的 Vue 构建项:
+
+- `vue()` 插件
+- `unplugin-auto-import` 的 Vue 自动导入配置
+- 与 Vue 页面强绑定的 `src/auto-imports.d.ts` 生成链路
+- 所有仅为 Vue 入口保留的分支判断
+
+## 7. 最终删除判定
+
+只有在以下条件都满足后,才允许进入最终清理提交:
+
+- 默认入口只剩 React。
+- 所有主页面都已迁到 `src-react/`。
+- `src/pages/**` 中不再存在可执行的 Vue 页面。
+- `src/stores/**` 中不再存在被运行时调用的 Vue 状态。
+- `package.json` 和 `vite.config.ts` 中不再保留 Vue 构建依赖。
+- 仓库中不再需要 `VITE_UI_FRAMEWORK=vue` 作为运行开关。
diff --git a/docs/Vue-to-React-Replacement-Plan.md b/docs/Vue-to-React-Replacement-Plan.md
new file mode 100644
index 0000000..3f7bf40
--- /dev/null
+++ b/docs/Vue-to-React-Replacement-Plan.md
@@ -0,0 +1,469 @@
+# 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 技术栈
+
+| 维度 | 当前实现 | 关键路径 |
+| --- | --- | --- |
+| 应用入口 | Vue 根应用 | `zn-ai/src/main.ts` |
+| 根组件 | `App.vue + router-view + keep-alive` | `zn-ai/src/App.vue` |
+| 路由 | `vue-router` | `zn-ai/src/router/index.ts` |
+| 状态管理 | `Pinia` | `zn-ai/src/stores/*` |
+| UI 组件体系 | `Element Plus + 自定义 Vue 组件 + Tailwind` | `zn-ai/src/components/*` |
+| 页面目录 | Vue 页面分散在 `src/pages/*` | `zn-ai/src/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:先迁基础壳
+
+- `App.vue`
+- `src/main.ts`
+- `src/router/index.ts`
+- `src/components/Layout/index.vue`
+- `src/components/SideMenus/index.vue`
+- `src/components/Layout/TitleBar/index.vue`
+
+原因:
+
+- 这是 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/*`
+- `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/electron/gateway/handlers/chat.ts b/electron/gateway/handlers/chat.ts
index 73d5a38..c34dad9 100644
--- a/electron/gateway/handlers/chat.ts
+++ b/electron/gateway/handlers/chat.ts
@@ -3,7 +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 type { RawMessage } from '@src/pages/home/model/ChatModel';
+import type { RawMessage } from '@shared/chat-model';
import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
@@ -197,3 +197,10 @@ export function handleChatAbort(
export function handleSessionList(): GatewayRpcReturns['session.list'] {
return sessionStore.getAllKeys();
}
+
+export function handleSessionDelete(
+ params: GatewayRpcParams['session.delete']
+): GatewayRpcReturns['session.delete'] {
+ sessionStore.deleteSession(params.sessionKey);
+ return { success: true };
+}
diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts
index 4938501..6b748f9 100644
--- a/electron/gateway/manager.ts
+++ b/electron/gateway/manager.ts
@@ -31,6 +31,8 @@ class GatewayManager {
return chatHandlers.handleChatAbort(params, (event) => this.broadcast(event));
case 'session.list':
return chatHandlers.handleSessionList();
+ case 'session.delete':
+ return chatHandlers.handleSessionDelete(params);
case 'provider.list':
return providerHandlers.handleProviderList();
case 'provider.getDefault':
diff --git a/electron/gateway/session-store.ts b/electron/gateway/session-store.ts
index aca0508..dba0983 100644
--- a/electron/gateway/session-store.ts
+++ b/electron/gateway/session-store.ts
@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import logManager from '@electron/service/logger';
-import type { RawMessage } from '@src/pages/home/model/ChatModel';
+import type { RawMessage } from '@shared/chat-model';
let sessionsFilePath: string | null = null;
diff --git a/electron/gateway/types.ts b/electron/gateway/types.ts
index cbca474..d2b8abc 100644
--- a/electron/gateway/types.ts
+++ b/electron/gateway/types.ts
@@ -1,4 +1,4 @@
-import type { RawMessage } from '@src/pages/home/model/ChatModel';
+import type { RawMessage } from '@shared/chat-model';
/// Gateway 向 Renderer 推送的事件类型
export type GatewayEvent =
@@ -47,6 +47,9 @@ export interface GatewayRpcParams {
sessionKey: string;
};
'session.list': Record;
+ 'session.delete': {
+ sessionKey: string;
+ };
'provider.list': Record;
'provider.getDefault': Record;
}
@@ -57,6 +60,7 @@ export interface GatewayRpcReturns {
'chat.history': RawMessage[];
'chat.abort': void;
'session.list': string[];
+ 'session.delete': { success: boolean };
'provider.list': { accounts: any[]; defaultAccountId: string | null };
'provider.getDefault': { accountId: string | null };
}
diff --git a/electron/process/runTaskOperationService.ts b/electron/process/runTaskOperationService.ts
index b16f7e7..02d4e7e 100644
--- a/electron/process/runTaskOperationService.ts
+++ b/electron/process/runTaskOperationService.ts
@@ -230,7 +230,14 @@ export function runTaskOperationService() {
ipcMain.handle(IPC_EVENTS.EXECUTE_SCRIPT, async (_event, options: any) => {
try {
const taskId = options.taskId || randomUUID();
- const roomType = options.roomList.find((item: any) => item.id === options.roomType);
+ const roomTypeRaw = options.roomList.find((item: any) => item.id === options.roomType);
+ const roomType = roomTypeRaw
+ ? {
+ ...roomTypeRaw,
+ dyHotSpringName: roomTypeRaw.dyHotSpringName ?? roomTypeRaw.dyHotSrpingName,
+ dyHotSrpingName: roomTypeRaw.dyHotSrpingName ?? roomTypeRaw.dyHotSpringName,
+ }
+ : null;
const pairs: Array<[string, string]> = [
['fzName', 'fg_trace.js'],
@@ -238,7 +245,12 @@ export function runTaskOperationService() {
['dyHotelName', 'dy_hotel_trace.js'],
['dyHotSpringName', 'dy_hot_spring_trace.js']
]
- const scriptEntries = pairs.filter(([prop]) => roomType?.[prop])
+ const scriptEntries = pairs.filter(([prop]) => {
+ if (prop === 'dyHotSpringName') {
+ return roomType?.dyHotSpringName || roomType?.dyHotSrpingName
+ }
+ return roomType?.[prop]
+ })
const scriptsDir = getScriptsDir()
@@ -301,7 +313,9 @@ export function runTaskOperationService() {
const result = await executeScriptServiceInstance.executeScript(
item.scriptPath,
{
- roomType: roomType[item.channel],
+ roomType: item.channel === 'dyHotSpringName'
+ ? (roomType.dyHotSpringName || roomType.dyHotSrpingName)
+ : roomType[item.channel],
startTime: options.startTime,
endTime: options.endTime,
operation: options.operation,
diff --git a/package.json b/package.json
index fbe451c..634ba8f 100644
--- a/package.json
+++ b/package.json
@@ -48,8 +48,11 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.3.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-vue": "^6.0.3",
+ "@vitejs/plugin-react": "^5.0.0",
"electron": "^40.8.5",
"electron-builder": "^26.8.1",
"esbuild": "^0.27.4",
@@ -97,6 +100,9 @@
"openai": "^6.14.0",
"pinia": "^2.3.1",
"playwright": "^1.58.2",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.13.0",
"ts-node": "^10.9.2",
"uuid": "^13.0.0",
"vue": "^3.5.22",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e8f6ef..674be1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,7 +37,7 @@ importers:
version: 1.14.0
browser-use-sdk:
specifier: ^2.0.12
- version: 2.0.15(react@19.2.4)(zod@3.25.76)
+ version: 2.0.15(react@18.3.1)(zod@3.25.76)
bytenode:
specifier: ^1.5.7
version: 1.5.7
@@ -104,6 +104,15 @@ importers:
playwright:
specifier: ^1.58.2
version: 1.59.1
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-router-dom:
+ specifier: ^7.13.0
+ version: 7.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.5.2)(typescript@5.9.3)
@@ -147,9 +156,18 @@ importers:
'@types/node':
specifier: ^25.3.0
version: 25.5.2
+ '@types/react':
+ specifier: ^18.3.12
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: ^18.3.1
+ version: 18.3.7(@types/react@18.3.28)
'@typescript-eslint/parser':
specifier: ^5.62.0
version: 5.62.0(eslint@8.57.1)(typescript@5.9.3)
+ '@vitejs/plugin-react':
+ specifier: ^5.0.0
+ version: 5.2.0(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.5(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.32(typescript@5.9.3))
@@ -202,14 +220,40 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
+ '@babel/compat-data@7.29.0':
+ resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
@@ -218,11 +262,31 @@ packages:
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.2':
+ resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -752,6 +816,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
+ '@rolldown/pluginutils@1.0.0-rc.3':
+ resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
+
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
@@ -1031,6 +1098,18 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
@@ -1076,6 +1155,17 @@ packages:
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.28':
+ resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
+
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
@@ -1125,6 +1215,12 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ '@vitejs/plugin-react@5.2.0':
+ resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+
'@vitejs/plugin-vue@6.0.5':
resolution: {integrity: sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1314,6 +1410,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ baseline-browser-mapping@2.10.19:
+ resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
bing-translate-api@4.2.0:
resolution: {integrity: sha512-7a9yo1NbGcHPS8zXTdz8tCOymHZp2pvCuYOChCaXKjOX8EIwdV3SLd4D7RGIqZt1UhffypYBUcAV2gDcTgK0rA==}
@@ -1345,6 +1446,11 @@ packages:
react: ^18 || ^19
zod: ^4
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
@@ -1388,6 +1494,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ caniuse-lite@1.0.30001788:
+ resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1488,6 +1597,13 @@ packages:
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -1692,6 +1808,9 @@ packages:
resolution: {integrity: sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==}
engines: {node: '>=20'}
+ electron-to-chromium@1.5.339:
+ resolution: {integrity: sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==}
+
electron-updater@6.8.3:
resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==}
@@ -1978,6 +2097,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -2371,6 +2494,10 @@ packages:
resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==}
engines: {node: '>=8.0'}
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lowercase-keys@2.0.0:
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
engines: {node: '>=8'}
@@ -2378,6 +2505,9 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
@@ -2573,6 +2703,9 @@ packages:
node-readfiles@0.2.0:
resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==}
+ node-releases@2.0.37:
+ resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
+
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -2816,8 +2949,34 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
- react@19.2.4:
- resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-refresh@0.18.0:
+ resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
+ engines: {node: '>=0.10.0'}
+
+ react-router-dom@7.14.1:
+ resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.14.1:
+ resolution: {integrity: sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
read-binary-file-arch@1.0.6:
@@ -2910,6 +3069,9 @@ packages:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@@ -2933,6 +3095,9 @@ packages:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3238,6 +3403,12 @@ packages:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -3404,6 +3575,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@@ -3452,6 +3626,28 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/compat-data@7.29.0': {}
+
+ '@babel/core@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.29.2
+ '@babel/parser': 7.29.2
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.2
@@ -3460,16 +3656,59 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.29.0
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
'@babel/helper-globals@7.28.0': {}
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.28.6': {}
+
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.29.2':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -4018,6 +4257,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.2': {}
+ '@rolldown/pluginutils@1.0.0-rc.3': {}
+
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
@@ -4202,6 +4443,27 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.2.0
@@ -4253,6 +4515,17 @@ snapshots:
xmlbuilder: 15.1.1
optional: true
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.28)':
+ dependencies:
+ '@types/react': 18.3.28
+
+ '@types/react@18.3.28':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
'@types/responselike@1.0.3':
dependencies:
'@types/node': 25.5.2
@@ -4309,6 +4582,18 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
+ '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0))':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
+ '@rolldown/pluginutils': 1.0.0-rc.3
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitejs/plugin-vue@6.0.5(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
@@ -4539,6 +4824,8 @@ snapshots:
base64-js@1.5.1: {}
+ baseline-browser-mapping@2.10.19: {}
+
bing-translate-api@4.2.0:
dependencies:
got: 11.8.6
@@ -4569,12 +4856,20 @@ snapshots:
dependencies:
fill-range: 7.1.1
- browser-use-sdk@2.0.15(react@19.2.4)(zod@3.25.76):
+ browser-use-sdk@2.0.15(react@18.3.1)(zod@3.25.76):
dependencies:
fast-json-stable-stringify: 2.1.0
- react: 19.2.4
+ react: 18.3.1
zod: 3.25.76
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.19
+ caniuse-lite: 1.0.30001788
+ electron-to-chromium: 1.5.339
+ node-releases: 2.0.37
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
buffer-crc32@0.2.13: {}
buffer-from@1.1.2: {}
@@ -4650,6 +4945,8 @@ snapshots:
callsites@3.1.0: {}
+ caniuse-lite@1.0.30001788: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -4752,6 +5049,10 @@ snapshots:
confbox@0.2.4: {}
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
core-util-is@1.0.2:
optional: true
@@ -4981,6 +5282,8 @@ snapshots:
conf: 15.1.0
type-fest: 5.5.0
+ electron-to-chromium@1.5.339: {}
+
electron-updater@6.8.3:
dependencies:
builder-util-runtime: 9.5.1
@@ -5369,6 +5672,8 @@ snapshots:
function-bind@1.1.2: {}
+ gensync@1.0.0-beta.2: {}
+
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
@@ -5738,10 +6043,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lowercase-keys@2.0.0: {}
lru-cache@10.4.3: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
@@ -5949,6 +6262,8 @@ snapshots:
dependencies:
es6-promise: 3.3.1
+ node-releases@2.0.37: {}
+
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
@@ -6210,7 +6525,31 @@ snapshots:
quick-lru@5.1.1: {}
- react@19.2.4: {}
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-refresh@0.18.0: {}
+
+ react-router-dom@7.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-router: 7.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
+ react-router@7.14.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ cookie: 1.1.1
+ react: 18.3.1
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
read-binary-file-arch@1.0.6:
dependencies:
@@ -6322,6 +6661,10 @@ snapshots:
sax@1.6.0: {}
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
scule@1.3.0: {}
semver-compare@1.0.0:
@@ -6338,6 +6681,8 @@ snapshots:
type-fest: 0.13.1
optional: true
+ set-cookie-parser@2.7.2: {}
+
sharp@0.33.5:
dependencies:
color: 4.2.3
@@ -6693,6 +7038,12 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -6820,6 +7171,8 @@ snapshots:
y18n@5.0.8: {}
+ yallist@3.1.1: {}
+
yallist@4.0.0: {}
yallist@5.0.0: {}
diff --git a/src-react/App.tsx b/src-react/App.tsx
new file mode 100644
index 0000000..ed0af8a
--- /dev/null
+++ b/src-react/App.tsx
@@ -0,0 +1,16 @@
+import { useEffect } from 'react';
+import { HashRouter } from 'react-router-dom';
+import { AppRouter } from './router';
+import { initSettingsStore } from './stores';
+
+export default function App() {
+ useEffect(() => {
+ void initSettingsStore();
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/src-react/components/chat/ChatComposer.tsx b/src-react/components/chat/ChatComposer.tsx
new file mode 100644
index 0000000..28e4448
--- /dev/null
+++ b/src-react/components/chat/ChatComposer.tsx
@@ -0,0 +1,125 @@
+import { useRef } from 'react';
+import type { AttachedFileMeta } from '@shared/chat-model';
+
+type ChatComposerProps = {
+ value: string;
+ isSending: boolean;
+ attachments: AttachedFileMeta[];
+ error?: string | null;
+ onChange: (value: string) => void;
+ onSend: () => void;
+ onStop: () => void;
+ onAttach: (files: File[]) => void | Promise;
+ onRemoveAttachment: (index: number) => void;
+ onDismissError?: () => void;
+};
+
+export default function ChatComposer({
+ value,
+ isSending,
+ attachments,
+ error,
+ onChange,
+ onSend,
+ onStop,
+ onAttach,
+ onRemoveAttachment,
+ onDismissError,
+}: ChatComposerProps) {
+ const fileInputRef = useRef(null);
+
+ return (
+
+
+
+
+ AI
+
+
+ {error ? (
+
+ {error}
+ {onDismissError ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src-react/components/chat/ChatHistoryPanel.tsx b/src-react/components/chat/ChatHistoryPanel.tsx
new file mode 100644
index 0000000..5195b38
--- /dev/null
+++ b/src-react/components/chat/ChatHistoryPanel.tsx
@@ -0,0 +1,108 @@
+import type { ChatHistoryBucket } from './types';
+
+type ChatHistoryPanelProps = {
+ buckets: ChatHistoryBucket[];
+ selectedConversationId?: string;
+ loading?: boolean;
+ onNewChat?: () => void;
+ onSelectConversation?: (conversationId: string) => void;
+ onRenameConversation?: (conversationId: string) => void;
+ onDeleteConversation?: (conversationId: string) => void;
+};
+
+export default function ChatHistoryPanel({
+ buckets,
+ selectedConversationId,
+ loading,
+ onNewChat,
+ onSelectConversation,
+ onRenameConversation,
+ onDeleteConversation,
+}: ChatHistoryPanelProps) {
+ const hasSessions = buckets.some((bucket) => bucket.sessions.length > 0);
+
+ return (
+
+ );
+}
diff --git a/src-react/components/chat/ChatMessageList.tsx b/src-react/components/chat/ChatMessageList.tsx
new file mode 100644
index 0000000..46cc6c2
--- /dev/null
+++ b/src-react/components/chat/ChatMessageList.tsx
@@ -0,0 +1,92 @@
+import { useEffect, useRef } from 'react';
+import type { ChatMessageItem } from './types';
+
+type ChatMessageListProps = {
+ messages: ChatMessageItem[];
+ loading?: boolean;
+};
+
+export default function ChatMessageList({ messages, loading }: ChatMessageListProps) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.scrollTop = container.scrollHeight;
+ }, [messages]);
+
+ return (
+
+
+ {loading ? (
+
+ 正在加载会话内容...
+
+ ) : null}
+ {!loading && messages.length === 0 ? (
+
+ 输入你的问题开始一段新对话,现有会话和流式响应都会直接显示在这里。
+
+ ) : null}
+ {messages.map((message) => (
+
+
+ {message.role === 'assistant' ? 'AI' : 'ME'}
+
+
+
+
+
{message.name}
+
{message.time}
+
+
{message.content}
+ {message.attachments && message.attachments.length > 0 ? (
+
+ {message.attachments.map((attachment, index) => {
+ const attachmentKey = attachment.filePath || `${attachment.fileName}-${index}`;
+ const isImage = attachment.mimeType.startsWith('image/') && Boolean(attachment.preview);
+
+ if (isImage && attachment.preview) {
+ return (
+
+

+
+ );
+ }
+
+ return (
+
+ {attachment.fileName}
+
+ );
+ })}
+
+ ) : null}
+ {message.isStreaming ? (
+
正在生成回复...
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src-react/components/chat/TaskBoard.tsx b/src-react/components/chat/TaskBoard.tsx
new file mode 100644
index 0000000..4c00ffc
--- /dev/null
+++ b/src-react/components/chat/TaskBoard.tsx
@@ -0,0 +1,114 @@
+import type { TaskItem } from './types';
+
+type TaskBoardProps = {
+ activeTab: 'pending' | 'completed';
+ pendingItems: TaskItem[];
+ completedItems: TaskItem[];
+ onTabChange: (tab: 'pending' | 'completed') => void;
+ onRemoveTask?: (taskId: string) => void;
+ onRetryTask?: (taskId: string) => void;
+ currentDateLabel?: string;
+ currentTime?: string;
+};
+
+export default function TaskBoard({
+ activeTab,
+ pendingItems,
+ completedItems,
+ onTabChange,
+ onRemoveTask,
+ onRetryTask,
+ currentDateLabel,
+ currentTime,
+}: TaskBoardProps) {
+ const items = activeTab === 'pending' ? pendingItems : completedItems;
+
+ return (
+
+ );
+}
diff --git a/src-react/components/chat/index.ts b/src-react/components/chat/index.ts
new file mode 100644
index 0000000..5f06f83
--- /dev/null
+++ b/src-react/components/chat/index.ts
@@ -0,0 +1,5 @@
+export { default as ChatComposer } from './ChatComposer';
+export { default as ChatHistoryPanel } from './ChatHistoryPanel';
+export { default as ChatMessageList } from './ChatMessageList';
+export { default as TaskBoard } from './TaskBoard';
+export type { ChatHistoryBucket, ChatMessageItem, TaskItem, TaskTabValue } from './types';
diff --git a/src-react/components/chat/types.ts b/src-react/components/chat/types.ts
new file mode 100644
index 0000000..cc66359
--- /dev/null
+++ b/src-react/components/chat/types.ts
@@ -0,0 +1,33 @@
+import type { AttachedFileMeta } from '@shared/chat-model';
+
+export type TaskTabValue = 'pending' | 'completed';
+
+export type ChatHistoryBucket = {
+ key: string;
+ label: string;
+ sessions: Array<{
+ conversationId: string;
+ title: string;
+ updatedAt: string;
+ }>;
+};
+
+export type ChatMessageItem = {
+ id: string;
+ role: 'assistant' | 'user';
+ name: string;
+ time: string;
+ content: string;
+ attachments?: AttachedFileMeta[];
+ isStreaming?: boolean;
+ isError?: boolean;
+};
+
+export type TaskItem = {
+ id: string;
+ removeTaskId?: string;
+ title: string;
+ description: string;
+ status: string;
+ meta?: string;
+};
diff --git a/src-react/components/layout/MainLayout.tsx b/src-react/components/layout/MainLayout.tsx
new file mode 100644
index 0000000..632a448
--- /dev/null
+++ b/src-react/components/layout/MainLayout.tsx
@@ -0,0 +1,23 @@
+import { Outlet } from 'react-router-dom';
+import Sidebar from './Sidebar';
+import TitleBar from './TitleBar';
+
+export default function MainLayout() {
+ const platform = (window as any).api?.platform ?? '';
+
+ return (
+
+ );
+}
diff --git a/src-react/components/layout/Sidebar.tsx b/src-react/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..d3c66a6
--- /dev/null
+++ b/src-react/components/layout/Sidebar.tsx
@@ -0,0 +1,67 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import { NAV_ITEMS, normalizeWorkspacePath } from '../../router/routes';
+
+const MENU_MARKS: Record = {
+ '/home': '首',
+ '/knowledge': '知',
+ '/agents': '模',
+ '/skills': '技',
+ '/cron': '时',
+ '/scripts': '脚',
+ '/setting': '设',
+};
+
+export default function Sidebar() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const currentId = normalizeWorkspacePath(location.pathname);
+
+ return (
+
+ );
+}
diff --git a/src-react/components/layout/TitleBar.tsx b/src-react/components/layout/TitleBar.tsx
new file mode 100644
index 0000000..5a220d3
--- /dev/null
+++ b/src-react/components/layout/TitleBar.tsx
@@ -0,0 +1,71 @@
+type TitleBarProps = {
+ variant?: 'default' | 'light';
+};
+
+export default function TitleBar({ variant = 'default' }: TitleBarProps) {
+ const platform = (window as any).api?.platform ?? '';
+
+ if (platform === 'linux') return null;
+
+ const iconColorClass =
+ variant === 'light' ? 'text-white' : 'text-[#525866] dark:text-gray-300';
+ const borderColorClass =
+ variant === 'light' ? 'border-b-white/30' : 'border-b-gray-300 dark:border-gray-700';
+
+ if (platform === 'darwin') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src-react/components/layout/index.ts b/src-react/components/layout/index.ts
new file mode 100644
index 0000000..a353fd4
--- /dev/null
+++ b/src-react/components/layout/index.ts
@@ -0,0 +1,3 @@
+export { default as MainLayout } from './MainLayout';
+export { default as Sidebar } from './Sidebar';
+export { default as TitleBar } from './TitleBar';
diff --git a/src-react/i18n/constants.ts b/src-react/i18n/constants.ts
new file mode 100644
index 0000000..7df4514
--- /dev/null
+++ b/src-react/i18n/constants.ts
@@ -0,0 +1,15 @@
+import type { LanguageCode } from '../types/runtime';
+
+export const SUPPORTED_LANGUAGE_CODES = ['en', 'zh', 'ja'] as const satisfies readonly LanguageCode[];
+
+export type { LanguageCode };
+
+export const SUPPORTED_LANGUAGES = [
+ { code: 'en', label: 'English' },
+ { code: 'zh', label: '中文' },
+ { code: 'ja', label: '日本語' },
+] as const;
+
+export type Namespace = 'common' | 'conversation' | 'setting' | 'window';
+
+export const NAMESPACES = ['common', 'conversation', 'setting', 'window'] as const satisfies readonly Namespace[];
diff --git a/src-react/i18n/index.ts b/src-react/i18n/index.ts
new file mode 100644
index 0000000..e2beefc
--- /dev/null
+++ b/src-react/i18n/index.ts
@@ -0,0 +1,107 @@
+import { DEFAULT_LANGUAGE } from '../lib/constants';
+import type { LanguageCode } from '../types/runtime';
+import type { Namespace } from './constants';
+import { detectSystemLanguage, resolveSupportedLanguage } from './resolver';
+import { messages, type MessageTree } from './messages';
+
+type InterpolationParams = Record;
+
+function lookupMessage(source: MessageTree, path: string): unknown {
+ return path.split('.').reduce((current, segment) => {
+ if (!current || typeof current !== 'object') return undefined;
+ return (current as Record)[segment];
+ }, source);
+}
+
+function interpolate(template: string, params?: InterpolationParams): string {
+ if (!params) return template;
+
+ return template.replace(/\{(\w+)\}/g, (_match, token) => {
+ const value = params[token];
+ return typeof value === 'undefined' ? `{${token}}` : String(value);
+ });
+}
+
+export interface I18nBridge {
+ getLocale(): LanguageCode;
+ setLocale(locale: string | null | undefined): LanguageCode;
+ t(path: string, params?: InterpolationParams): string;
+ has(path: string): boolean;
+ getMessages(locale?: LanguageCode): MessageTree;
+ subscribe(listener: (locale: LanguageCode) => void): () => void;
+}
+
+export function createI18nBridge(initialLocale?: string | null): I18nBridge {
+ let locale = resolveSupportedLanguage(initialLocale ?? detectSystemLanguage() ?? DEFAULT_LANGUAGE);
+ const listeners = new Set<(locale: LanguageCode) => void>();
+
+ const notify = () => {
+ for (const listener of listeners) {
+ listener(locale);
+ }
+ };
+
+ const api: I18nBridge = {
+ getLocale() {
+ return locale;
+ },
+ setLocale(nextLocale: string | null | undefined) {
+ const resolved = resolveSupportedLanguage(nextLocale, locale);
+ if (resolved !== locale) {
+ locale = resolved;
+ notify();
+ }
+ return locale;
+ },
+ t(path: string, params?: InterpolationParams) {
+ const localeMessages = messages[locale] ?? messages[DEFAULT_LANGUAGE];
+ const fallbackMessages = messages[DEFAULT_LANGUAGE];
+ const translated = lookupMessage(localeMessages, path) ?? lookupMessage(fallbackMessages, path);
+
+ if (typeof translated === 'string' || typeof translated === 'number') {
+ return interpolate(String(translated), params);
+ }
+
+ return path;
+ },
+ has(path: string) {
+ const localeMessages = messages[locale] ?? messages[DEFAULT_LANGUAGE];
+ const fallbackMessages = messages[DEFAULT_LANGUAGE];
+ return typeof lookupMessage(localeMessages, path) !== 'undefined' || typeof lookupMessage(fallbackMessages, path) !== 'undefined';
+ },
+ getMessages(targetLocale?: LanguageCode) {
+ return messages[targetLocale ?? locale] ?? messages[DEFAULT_LANGUAGE];
+ },
+ subscribe(listener: (locale: LanguageCode) => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ };
+
+ return api;
+}
+
+export const i18n = createI18nBridge();
+
+export function setLocale(locale: string | null | undefined): LanguageCode {
+ return i18n.setLocale(locale);
+}
+
+export function getLocale(): LanguageCode {
+ return i18n.getLocale();
+}
+
+export function t(path: string, params?: InterpolationParams): string {
+ return i18n.t(path, params);
+}
+
+export function hasMessage(path: string): boolean {
+ return i18n.has(path);
+}
+
+export function getMessages(locale?: LanguageCode): MessageTree {
+ return i18n.getMessages(locale);
+}
+
+export { SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES, NAMESPACES } from './constants';
+export type { LanguageCode, Namespace };
diff --git a/src-react/i18n/messages.ts b/src-react/i18n/messages.ts
new file mode 100644
index 0000000..aa3b25c
--- /dev/null
+++ b/src-react/i18n/messages.ts
@@ -0,0 +1,112 @@
+import type { LanguageCode } from '../types/runtime';
+
+export interface MessageTree {
+ [key: string]: string | number | MessageTree;
+}
+
+export type I18nMessages = Record;
+
+export const messages: I18nMessages = {
+ en: {
+ app: {
+ title: 'ZN-AI',
+ },
+ window: {
+ minimize: 'Minimize',
+ maximize: 'Maximize',
+ restore: 'Restore',
+ close: 'Close',
+ },
+ dialog: {
+ cancel: 'Cancel',
+ confirm: 'Confirm',
+ },
+ theme: {
+ light: 'Light',
+ dark: 'Dark',
+ system: 'System',
+ },
+ language: {
+ zh: 'Chinese',
+ en: 'English',
+ ja: 'Japanese',
+ },
+ common: {
+ loading: 'Loading...',
+ retry: 'Retry',
+ unknownError: 'Unknown error',
+ },
+ conversation: {
+ newConversation: 'New conversation',
+ emptyState: 'No messages yet',
+ },
+ },
+ zh: {
+ app: {
+ title: 'ZN-AI',
+ },
+ window: {
+ minimize: '最小化',
+ maximize: '最大化',
+ restore: '还原',
+ close: '关闭',
+ },
+ dialog: {
+ cancel: '取消',
+ confirm: '确认',
+ },
+ theme: {
+ light: '浅色',
+ dark: '深色',
+ system: '跟随系统',
+ },
+ language: {
+ zh: '中文',
+ en: '英文',
+ ja: '日语',
+ },
+ common: {
+ loading: '加载中...',
+ retry: '重试',
+ unknownError: '未知错误',
+ },
+ conversation: {
+ newConversation: '新建对话',
+ emptyState: '暂无消息',
+ },
+ },
+ ja: {
+ app: {
+ title: 'ZN-AI',
+ },
+ window: {
+ minimize: '最小化',
+ maximize: '最大化',
+ restore: '復元',
+ close: '閉じる',
+ },
+ dialog: {
+ cancel: 'キャンセル',
+ confirm: '確認',
+ },
+ theme: {
+ light: 'ライト',
+ dark: 'ダーク',
+ system: 'システムに従う',
+ },
+ language: {
+ zh: '中国語',
+ en: '英語',
+ ja: '日本語',
+ },
+ common: {
+ loading: '読み込み中...',
+ retry: '再試行',
+ unknownError: '不明なエラー',
+ },
+ conversation: {
+ newConversation: '新しい会話',
+ emptyState: 'メッセージはまだありません',
+ },
+ },
+};
diff --git a/src-react/i18n/resolver.ts b/src-react/i18n/resolver.ts
new file mode 100644
index 0000000..b474e4c
--- /dev/null
+++ b/src-react/i18n/resolver.ts
@@ -0,0 +1,24 @@
+import { SUPPORTED_LANGUAGE_CODES } from './constants';
+import type { LanguageCode } from '../types/runtime';
+
+const SUPPORTED_LANGUAGE_SET = new Set(SUPPORTED_LANGUAGE_CODES);
+
+export function normalizeLocale(locale: string | null | undefined): string {
+ return locale?.trim().toLowerCase().split('_').join('-') ?? '';
+}
+
+export function resolveSupportedLanguage(
+ locale: string | null | undefined,
+ fallback: LanguageCode = 'zh',
+): LanguageCode {
+ const normalizedLocale = normalizeLocale(locale);
+ if (!normalizedLocale) return fallback;
+
+ const [baseLanguage] = normalizedLocale.split('-');
+ return SUPPORTED_LANGUAGE_SET.has(baseLanguage) ? (baseLanguage as LanguageCode) : fallback;
+}
+
+export function detectSystemLanguage(): LanguageCode {
+ if (typeof navigator === 'undefined') return 'zh';
+ return resolveSupportedLanguage(navigator.language);
+}
diff --git a/src-react/lib/constants.ts b/src-react/lib/constants.ts
new file mode 100644
index 0000000..d2be6f0
--- /dev/null
+++ b/src-react/lib/constants.ts
@@ -0,0 +1,31 @@
+import { CONFIG_KEYS as RUNTIME_CONFIG_KEYS } from '../types/runtime';
+
+export const IPC_EVENTS = {
+ HOST_API_FETCH: 'hostapi:fetch',
+ GATEWAY_RPC: 'gateway:rpc',
+ GATEWAY_EVENT: 'gateway:event',
+ GET_CONFIG: 'get-config',
+ SET_CONFIG: 'set-config',
+ GET_THEME_MODE: 'get-theme-mode',
+ SET_THEME_MODE: 'set-theme-mode',
+ THEME_MODE_UPDATED: 'theme-mode-updated',
+ GET_WINDOW_ID: 'get-window-id',
+ TASK_PROGRESS: 'task:progress',
+ TASK_STARTED: 'task:started',
+ TASK_COMPLETED: 'task:completed',
+ OPEN_CHANNEL: 'open-channel',
+ EXECUTE_SCRIPT: 'execute-script',
+} as const;
+
+export const CONFIG_KEYS = RUNTIME_CONFIG_KEYS;
+
+export const WINDOW_NAMES = {
+ MAIN: 'main',
+ SETTING: 'setting',
+ DIALOG: 'dialog',
+ LOADING: 'loading',
+} as const;
+
+export const DEFAULT_THEME_MODE = 'system' as const;
+
+export const DEFAULT_LANGUAGE = 'zh' as const;
diff --git a/src-react/lib/gateway-client.ts b/src-react/lib/gateway-client.ts
new file mode 100644
index 0000000..3f5f47c
--- /dev/null
+++ b/src-react/lib/gateway-client.ts
@@ -0,0 +1,12 @@
+import { IPC_EVENTS } from './constants';
+import { invokeIpc, onIpc } from './host-api';
+import type { GatewayEvent } from '../types/runtime';
+
+export async function gatewayRpc(method: string, params?: unknown): Promise {
+ return invokeIpc(IPC_EVENTS.GATEWAY_RPC, method, params);
+}
+
+export function onGatewayEvent(callback: (event: GatewayEvent) => void): () => void {
+ return onIpc(IPC_EVENTS.GATEWAY_EVENT, callback as (...args: any[]) => void);
+}
+
diff --git a/src-react/lib/host-api.ts b/src-react/lib/host-api.ts
new file mode 100644
index 0000000..03743d5
--- /dev/null
+++ b/src-react/lib/host-api.ts
@@ -0,0 +1,142 @@
+import { IPC_EVENTS } from './constants';
+import type { HostApiResult } from '../types/runtime';
+
+type RequestInitLike = Pick;
+
+type LooseIpcBridge = {
+ invoke(channel: string, ...args: any[]): Promise;
+ on?(channel: string, callback: (...args: any[]) => void): () => void;
+};
+
+function normalizeHeaders(headers?: HeadersInit): Headers {
+ return new Headers(headers ?? {});
+}
+
+function readCookie(name: string): string | null {
+ if (typeof document === 'undefined' || !document.cookie) return null;
+
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`));
+ return match ? decodeURIComponent(match[1]) : null;
+}
+
+function readToken(): string | null {
+ if (typeof window === 'undefined') return null;
+
+ const storageCandidates = [window.sessionStorage, window.localStorage];
+ for (const storage of storageCandidates) {
+ for (const key of ['token', 'access_token', 'refresh_token']) {
+ const value = storage.getItem(key);
+ if (value) return value;
+ }
+ }
+
+ return readCookie('token') ?? readCookie('access_token') ?? readCookie('refresh_token');
+}
+
+function normalizeBody(body: BodyInit | null | undefined): BodyInit | null | undefined {
+ if (body == null) return body;
+ if (
+ typeof body === 'string' ||
+ body instanceof Blob ||
+ body instanceof FormData ||
+ body instanceof URLSearchParams ||
+ body instanceof ArrayBuffer
+ ) {
+ return body;
+ }
+
+ if (ArrayBuffer.isView(body)) {
+ return body;
+ }
+
+ return JSON.stringify(body);
+}
+
+function extractResult(response: unknown): T {
+ if (response && typeof response === 'object') {
+ const result = response as HostApiResult;
+ if (result.success === false || result.ok === false) {
+ throw new Error(result.error || result.text || 'Request failed');
+ }
+
+ if (typeof result.json !== 'undefined') return result.json;
+ if (typeof result.data !== 'undefined') {
+ const data = result.data as { json?: T } | T;
+ return (data && typeof data === 'object' && 'json' in data ? data.json : data) as T;
+ }
+ }
+
+ return response as T;
+}
+
+export function hasHostApiBridge(): boolean {
+ return typeof window !== 'undefined' && Boolean(window.api?.invoke);
+}
+
+export async function invokeIpc(channel: string, ...args: any[]): Promise {
+ if (!hasHostApiBridge()) {
+ throw new Error(`IPC bridge is unavailable for ${channel}`);
+ }
+
+ const bridge = window.api as unknown as LooseIpcBridge;
+ return bridge.invoke(channel, ...args);
+}
+
+export function onIpc(channel: string, callback: (...args: any[]) => void): () => void {
+ if (!hasHostApiBridge() || !window.api?.on) {
+ return () => {};
+ }
+
+ const bridge = window.api as unknown as LooseIpcBridge;
+ return bridge.on ? bridge.on(channel, callback) : () => {};
+}
+
+export async function hostApiFetch(path: string, init?: RequestInitLike): Promise {
+ const method = init?.method ?? 'GET';
+ const headers = normalizeHeaders(init?.headers);
+ const token = readToken();
+
+ if (token && !headers.has('Authorization')) {
+ headers.set('Authorization', `Bearer ${token}`);
+ }
+
+ const request = {
+ path,
+ method,
+ headers: (() => {
+ const headerObject: Record = {};
+ headers.forEach((value, key) => {
+ headerObject[key] = value;
+ });
+ return headerObject;
+ })(),
+ body: init?.body ?? null,
+ };
+
+ if (hasHostApiBridge()) {
+ const response = await invokeIpc(IPC_EVENTS.HOST_API_FETCH, request);
+ return extractResult(response);
+ }
+
+ if (typeof fetch === 'function') {
+ const response = await fetch(path, {
+ method,
+ headers,
+ body: normalizeBody(init?.body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || response.statusText || `Request failed with ${response.status}`);
+ }
+
+ const contentType = response.headers.get('content-type') ?? '';
+ if (contentType.includes('application/json')) {
+ return (await response.json()) as T;
+ }
+
+ return (await response.text()) as unknown as T;
+ }
+
+ throw new Error(`No HTTP bridge available for ${path}`);
+}
diff --git a/src-react/lib/index.ts b/src-react/lib/index.ts
new file mode 100644
index 0000000..288b2af
--- /dev/null
+++ b/src-react/lib/index.ts
@@ -0,0 +1,52 @@
+export {
+ CONFIG_KEYS,
+ DEFAULT_LANGUAGE,
+ DEFAULT_THEME_MODE,
+ IPC_EVENTS,
+ WINDOW_NAMES,
+} from './constants';
+
+export {
+ hostApiFetch,
+ hostApiFetch as fetchFromHost,
+ hasHostApiBridge,
+ invokeIpc,
+ onIpc,
+} from './host-api';
+
+export {
+ gatewayRpc,
+ gatewayRpc as callGateway,
+ onGatewayEvent,
+} from './gateway-client';
+
+export {
+ applyThemeModeToDocument,
+ detectSystemTheme,
+ resolveAppliedTheme,
+ watchSystemTheme,
+} from './theme';
+
+export {
+ detectRuntimePlatform,
+ detectWindowName,
+ hasIpcBridge,
+ resolveWindowIdentity,
+} from './runtime';
+
+export type {
+ ConfigKey,
+ ConfigValueKey,
+ ConfigValueMap,
+ GatewayEvent,
+ HostApiResult,
+ IpcArgs,
+ IpcListener,
+ LanguageCode,
+ ResolvedThemeMode,
+ RuntimePlatform,
+ ThemeMode,
+ WindowApiBridge,
+ WindowIdentity,
+ WindowName,
+} from '../types/runtime';
diff --git a/src-react/lib/runtime.ts b/src-react/lib/runtime.ts
new file mode 100644
index 0000000..b12a959
--- /dev/null
+++ b/src-react/lib/runtime.ts
@@ -0,0 +1,76 @@
+import { IPC_EVENTS, WINDOW_NAMES } from './constants';
+import { invokeIpc } from './host-api';
+import type { RuntimePlatform, WindowIdentity, WindowName } from '../types/runtime';
+
+function normalizePlatform(platform: string | undefined | null): RuntimePlatform {
+ const value = platform?.toLowerCase() ?? '';
+ if (value === 'win32' || value.includes('win')) return 'win32';
+ if (value === 'darwin' || value.includes('mac')) return 'darwin';
+ if (value === 'linux') return 'linux';
+ if (value) return 'unknown';
+ return 'web';
+}
+
+export function hasIpcBridge(): boolean {
+ return typeof window !== 'undefined' && Boolean(window.api?.invoke);
+}
+
+export function detectRuntimePlatform(): RuntimePlatform {
+ if (typeof window === 'undefined') return 'unknown';
+ const exposedPlatform = window.api?.platform;
+ if (exposedPlatform) return normalizePlatform(exposedPlatform);
+ if (typeof navigator !== 'undefined') {
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
+ return normalizePlatform(nav.platform || nav.userAgentData?.platform);
+ }
+ return window.api ? 'unknown' : 'web';
+}
+
+function readWindowIdFromBridge(): string | number | null {
+ if (typeof window.api?.getCurrentWindowId === 'function') {
+ return window.api.getCurrentWindowId();
+ }
+
+ return null;
+}
+
+export function detectWindowName(windowId?: string | number | null): WindowName {
+ if (typeof windowId === 'string') {
+ const normalized = windowId.toLowerCase();
+ if (normalized.includes('setting')) return WINDOW_NAMES.SETTING;
+ if (normalized.includes('dialog')) return WINDOW_NAMES.DIALOG;
+ if (normalized.includes('loading')) return WINDOW_NAMES.LOADING;
+ }
+ return WINDOW_NAMES.MAIN;
+}
+
+export async function resolveWindowIdentity(): Promise {
+ const platform = detectRuntimePlatform();
+ const isElectron = platform !== 'web' && platform !== 'unknown';
+
+ if (!hasIpcBridge()) {
+ return {
+ platform,
+ windowId: null,
+ windowName: WINDOW_NAMES.MAIN,
+ isElectron,
+ };
+ }
+
+ let windowId: string | number | null = null;
+ try {
+ windowId = readWindowIdFromBridge();
+ if (windowId === null) {
+ windowId = await invokeIpc(IPC_EVENTS.GET_WINDOW_ID);
+ }
+ } catch {
+ windowId = null;
+ }
+
+ return {
+ platform,
+ windowId,
+ windowName: detectWindowName(windowId),
+ isElectron,
+ };
+}
diff --git a/src-react/lib/theme.ts b/src-react/lib/theme.ts
new file mode 100644
index 0000000..5f96cf0
--- /dev/null
+++ b/src-react/lib/theme.ts
@@ -0,0 +1,51 @@
+import type { ResolvedThemeMode, ThemeMode } from '../types/runtime';
+
+export function detectSystemTheme(): ResolvedThemeMode {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return 'light';
+ }
+
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+export function resolveAppliedTheme(
+ themeMode: ThemeMode,
+ systemTheme: ResolvedThemeMode = detectSystemTheme(),
+): ResolvedThemeMode {
+ return themeMode === 'system' ? systemTheme : themeMode;
+}
+
+export function applyThemeModeToDocument(
+ themeMode: ThemeMode,
+ systemTheme: ResolvedThemeMode = detectSystemTheme(),
+): ResolvedThemeMode {
+ const appliedTheme = resolveAppliedTheme(themeMode, systemTheme);
+
+ if (typeof document !== 'undefined') {
+ const root = document.documentElement;
+ root.classList.toggle('dark', appliedTheme === 'dark');
+ root.dataset.theme = appliedTheme;
+ root.style.colorScheme = appliedTheme;
+ }
+
+ return appliedTheme;
+}
+
+export function watchSystemTheme(onChange: (theme: ResolvedThemeMode) => void): () => void {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return () => {};
+ }
+
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handler = (event: MediaQueryListEvent) => {
+ onChange(event.matches ? 'dark' : 'light');
+ };
+
+ if (typeof mediaQuery.addEventListener === 'function') {
+ mediaQuery.addEventListener('change', handler);
+ return () => mediaQuery.removeEventListener('change', handler);
+ }
+
+ mediaQuery.addListener(handler);
+ return () => mediaQuery.removeListener(handler);
+}
diff --git a/src-react/main.tsx b/src-react/main.tsx
new file mode 100644
index 0000000..e39f30f
--- /dev/null
+++ b/src-react/main.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+export function mountReactApp(): void {
+ const container = document.getElementById('app');
+
+ if (!container) {
+ throw new Error('Missing #app container for React entry.');
+ }
+
+ ReactDOM.createRoot(container).render(
+
+
+ ,
+ );
+}
diff --git a/src-react/pages/Agents/index.tsx b/src-react/pages/Agents/index.tsx
new file mode 100644
index 0000000..b322042
--- /dev/null
+++ b/src-react/pages/Agents/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function AgentsPage() {
+ return (
+
+ );
+}
diff --git a/src-react/pages/Cron/index.tsx b/src-react/pages/Cron/index.tsx
new file mode 100644
index 0000000..49449e3
--- /dev/null
+++ b/src-react/pages/Cron/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function CronPage() {
+ return (
+
+ );
+}
diff --git a/src-react/pages/Home/components/AddChannelDialog.tsx b/src-react/pages/Home/components/AddChannelDialog.tsx
new file mode 100644
index 0000000..fae07d0
--- /dev/null
+++ b/src-react/pages/Home/components/AddChannelDialog.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from 'react';
+import type { ChannelItem } from '../../../stores';
+import DialogSurface from './DialogSurface';
+
+type AddChannelDialogProps = {
+ open: boolean;
+ loading: boolean;
+ availableChannels: ChannelItem[];
+ initialSelected: ChannelItem[];
+ onClose: () => void;
+ onConfirm: (items: ChannelItem[]) => Promise;
+};
+
+export default function AddChannelDialog({
+ open,
+ loading,
+ availableChannels,
+ initialSelected,
+ onClose,
+ onConfirm,
+}: AddChannelDialogProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [localSelected, setLocalSelected] = useState([]);
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+
+ setSearchQuery('');
+ setError(null);
+ setSubmitting(false);
+ setLocalSelected(initialSelected.map((item) => ({ ...item })));
+ }, [open, initialSelected]);
+
+ const query = searchQuery.trim().toLowerCase();
+ const existingChannelUrls = new Set(localSelected.map((item) => item.channelUrl));
+ const candidateChannels = availableChannels.filter((item) => !existingChannelUrls.has(item.channelUrl));
+ const filteredChannels = (query
+ ? candidateChannels.filter((item) => (
+ item.channelName.toLowerCase().includes(query)
+ || item.channelUrl.toLowerCase().includes(query)
+ ))
+ : candidateChannels)
+ .slice(0, 8);
+
+ function addChannel(item: ChannelItem): void {
+ setLocalSelected((current) => {
+ if (current.some((channel) => channel.channelUrl === item.channelUrl)) return current;
+ return [...current, { ...item }];
+ });
+ setSearchQuery('');
+ }
+
+ function removeChannel(id: string): void {
+ setLocalSelected((current) => current.filter((item) => item.id !== id));
+ }
+
+ async function handleConfirm(): Promise {
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ await onConfirm(localSelected.map((item) => ({ ...item })));
+ } catch (confirmError) {
+ setError(confirmError instanceof Error ? confirmError.message : String(confirmError));
+ setSubmitting(false);
+ return;
+ }
+
+ setSubmitting(false);
+ }
+
+ return (
+
+
+
+
搜索添加渠道
+
setSearchQuery(event.target.value)}
+ placeholder="输入渠道名称或链接"
+ className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
+ />
+
+
+ {loading ? (
+
正在加载可用渠道...
+ ) : filteredChannels.length > 0 ? (
+
+ {filteredChannels.map((item) => (
+
+ ))}
+
+ ) : (
+
+ {availableChannels.length === 0 ? '暂无可用渠道,请先检查脚本配置。' : '没有匹配到可添加的渠道。'}
+
+ )}
+
+
+
+
+
已选渠道
+
+ {localSelected.length > 0 ? (
+ localSelected.map((item) => (
+
+
+
{item.channelName}
+
{item.channelUrl}
+
+
+
+
+ ))
+ ) : (
+
+ 未选择任何渠道
+
+ )}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src-react/pages/Home/components/DialogSurface.tsx b/src-react/pages/Home/components/DialogSurface.tsx
new file mode 100644
index 0000000..10a6fdf
--- /dev/null
+++ b/src-react/pages/Home/components/DialogSurface.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from 'react';
+
+type DialogSurfaceProps = {
+ open: boolean;
+ title: string;
+ widthClassName?: string;
+ onClose: () => void;
+ children: ReactNode;
+};
+
+export default function DialogSurface({
+ open,
+ title,
+ widthClassName = 'max-w-[560px]',
+ onClose,
+ children,
+}: DialogSurfaceProps) {
+ if (!open) return null;
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+ {title}
+
+
+
+
+
+
{children}
+
+
+ );
+}
diff --git a/src-react/pages/Home/components/TaskOperationDialog.tsx b/src-react/pages/Home/components/TaskOperationDialog.tsx
new file mode 100644
index 0000000..b96fc3a
--- /dev/null
+++ b/src-react/pages/Home/components/TaskOperationDialog.tsx
@@ -0,0 +1,203 @@
+import { useEffect, useState } from 'react';
+import { hotelStaffTypeMappingListUsingPost } from '@api/typeMapping';
+import type { RoomTypeMapping } from '@api/types';
+import type { TaskOperationInput } from '../../../stores';
+import DialogSurface from './DialogSurface';
+
+type TaskOperationDialogProps = {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: (options: TaskOperationInput) => Promise;
+};
+
+type RoomTypeMappingLike = RoomTypeMapping & {
+ dyHotSpringName?: string;
+};
+
+const OPERATION_OPTIONS: Array<{ label: string; value: 'open' | 'close' }> = [
+ { label: '开启', value: 'open' },
+ { label: '关闭', value: 'close' },
+];
+
+function normalizeRoomType(item: RoomTypeMappingLike): RoomTypeMappingLike {
+ return {
+ ...item,
+ dyHotSpringName: item.dyHotSpringName ?? item.dyHotSrpingName ?? '',
+ dyHotSrpingName: item.dyHotSrpingName ?? item.dyHotSpringName ?? '',
+ };
+}
+
+export default function TaskOperationDialog({
+ open,
+ onClose,
+ onConfirm,
+}: TaskOperationDialogProps) {
+ const [roomList, setRoomList] = useState([]);
+ const [roomType, setRoomType] = useState('');
+ const [startTime, setStartTime] = useState('');
+ const [endTime, setEndTime] = useState('');
+ const [operation, setOperation] = useState<'open' | 'close' | ''>('');
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open) return;
+
+ setRoomType('');
+ setStartTime('');
+ setEndTime('');
+ setOperation('');
+ setError(null);
+ setSubmitting(false);
+ setLoading(true);
+
+ void (async () => {
+ try {
+ const response = await hotelStaffTypeMappingListUsingPost({ body: {} as RoomTypeMapping });
+ setRoomList(Array.isArray(response?.data) ? response.data.map((item: RoomTypeMappingLike) => normalizeRoomType(item)) : []);
+ } catch (requestError) {
+ setRoomList([]);
+ setError(requestError instanceof Error ? requestError.message : String(requestError));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, [open]);
+
+ async function handleConfirm(): Promise {
+ if (!roomType) {
+ setError('请选择房型');
+ return;
+ }
+
+ if (!startTime || !endTime) {
+ setError('请选择日期范围');
+ return;
+ }
+
+ if (startTime > endTime) {
+ setError('开始日期不能晚于结束日期');
+ return;
+ }
+
+ if (!operation) {
+ setError('请选择操作');
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ await onConfirm({
+ roomType,
+ startTime,
+ endTime,
+ operation,
+ roomList: roomList.map((item) => ({ ...item })),
+ });
+ } catch (confirmError) {
+ setError(confirmError instanceof Error ? confirmError.message : String(confirmError));
+ setSubmitting(false);
+ return;
+ }
+
+ setSubmitting(false);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 房型列表沿用现有 `typeMapping` 接口,提交时会兼容 `dyHotSrpingName / dyHotSpringName` 两种字段,避免任务执行时遗漏抖音温泉渠道。
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src-react/pages/Home/components/index.ts b/src-react/pages/Home/components/index.ts
new file mode 100644
index 0000000..c751199
--- /dev/null
+++ b/src-react/pages/Home/components/index.ts
@@ -0,0 +1,2 @@
+export { default as AddChannelDialog } from './AddChannelDialog';
+export { default as TaskOperationDialog } from './TaskOperationDialog';
diff --git a/src-react/pages/Home/index.tsx b/src-react/pages/Home/index.tsx
new file mode 100644
index 0000000..853368c
--- /dev/null
+++ b/src-react/pages/Home/index.tsx
@@ -0,0 +1,446 @@
+import { useEffect, useState } from 'react';
+import type { RawMessage } from '@shared/chat-model';
+import { extractText, isInternalMessage } from '@shared/chat-model';
+import { taskCenterList, type taskCenterItem } from '@constant/taskCenterList';
+import {
+ ChatComposer,
+ ChatHistoryPanel,
+ ChatMessageList,
+ TaskBoard,
+ type ChatHistoryBucket,
+ type ChatMessageItem,
+ type TaskItem,
+ type TaskTabValue,
+} from '../../components/chat';
+import { IPC_EVENTS } from '../../lib/constants';
+import { invokeIpc } from '../../lib/host-api';
+import {
+ channelStore,
+ chatStore,
+ getCompletedTasks,
+ getPendingTasks,
+ taskStore,
+ useChannelStore,
+ useChatStore,
+ useTaskStore,
+ type StagedAttachment,
+} from '../../stores';
+import { AddChannelDialog, TaskOperationDialog } from './components';
+
+type SessionBucketKey = 'today' | 'yesterday' | 'withinWeek' | 'withinTwoWeeks' | 'withinMonth' | 'older';
+
+const HISTORY_BUCKET_META: Array<{ key: SessionBucketKey; label: string }> = [
+ { key: 'today', label: '今天' },
+ { key: 'yesterday', label: '昨天' },
+ { key: 'withinWeek', label: '近7天' },
+ { key: 'withinTwoWeeks', label: '近14天' },
+ { key: 'withinMonth', label: '近30天' },
+ { key: 'older', label: '更早' },
+];
+
+function getMessageTime(timestamp?: number): string {
+ if (!timestamp) return '--';
+ const date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp);
+ return date.toLocaleTimeString('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function getHistoryBucket(activityMs: number, currentMs: number): SessionBucketKey {
+ if (!activityMs || activityMs <= 0) return 'older';
+
+ const now = new Date(currentMs);
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+ const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
+
+ if (activityMs >= startOfToday) return 'today';
+ if (activityMs >= startOfYesterday) return 'yesterday';
+
+ const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
+ if (daysAgo <= 7) return 'withinWeek';
+ if (daysAgo <= 14) return 'withinTwoWeeks';
+ if (daysAgo <= 30) return 'withinMonth';
+ return 'older';
+}
+
+function formatHistoryTime(activityMs: number, currentMs: number): string {
+ if (!activityMs || activityMs <= 0) return '--';
+
+ const date = new Date(activityMs);
+ const current = new Date(currentMs);
+ const sameDay = date.toDateString() === current.toDateString();
+ if (sameDay) {
+ return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+ }
+
+ const yesterday = new Date(current);
+ yesterday.setDate(current.getDate() - 1);
+ if (date.toDateString() === yesterday.toDateString()) {
+ return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
+ }
+
+ return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+}
+
+function getTaskDateLabel(createdAt?: string): string {
+ if (!createdAt) return '执行时段';
+
+ const current = new Date(createdAt);
+ const today = new Date();
+ const y = today.getFullYear();
+ const m = today.getMonth();
+ const d = today.getDate();
+
+ const cy = current.getFullYear();
+ const cm = current.getMonth();
+ const cd = current.getDate();
+
+ if (cy === y && cm === m && cd === d) return '今天';
+
+ const yesterday = new Date(y, m, d - 1);
+ if (cy === yesterday.getFullYear() && cm === yesterday.getMonth() && cd === yesterday.getDate()) {
+ return '昨天';
+ }
+
+ return `${String(cm + 1).padStart(2, '0')}/${String(cd).padStart(2, '0')}`;
+}
+
+function getTaskRoomTypeLabel(task: { roomType: string; roomList: Array<{ id?: string; pmsName?: string }> }): string {
+ const matchedRoom = Array.isArray(task.roomList)
+ ? task.roomList.find((item) => item.id === task.roomType)
+ : null;
+
+ return matchedRoom?.pmsName || task.roomType;
+}
+
+function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null): ChatMessageItem[] {
+ const visibleMessages = messages
+ .filter((message) => !isInternalMessage(message))
+ .filter((message) => {
+ if (message.role === 'user' || message.role === 'assistant') return true;
+ return Boolean(extractText(message).trim());
+ })
+ .map((message): ChatMessageItem => ({
+ id: message.id || `msg-${message.timestamp || Math.random()}`,
+ role: message.role === 'user' ? 'user' : 'assistant',
+ name: message.role === 'user' ? '你' : 'YINIAN',
+ time: getMessageTime(message.timestamp),
+ content: extractText(message),
+ attachments: message._attachedFiles,
+ isError: Boolean(message.isError),
+ }));
+
+ if (streamingMessage && extractText(streamingMessage).trim()) {
+ visibleMessages.push({
+ id: streamingMessage.id || `stream-${Date.now()}`,
+ role: 'assistant',
+ name: 'YINIAN',
+ time: getMessageTime(streamingMessage.timestamp),
+ content: extractText(streamingMessage),
+ attachments: streamingMessage._attachedFiles,
+ isStreaming: true,
+ });
+ }
+
+ return visibleMessages;
+}
+
+export default function HomePage() {
+ const chat = useChatStore();
+ const taskState = useTaskStore();
+ const channelState = useChannelStore();
+ const [inputMessage, setInputMessage] = useState('');
+ const [attachments, setAttachments] = useState([]);
+ const [activeTaskTab, setActiveTaskTab] = useState('pending');
+ const [taskCenterNotice, setTaskCenterNotice] = useState(null);
+ const [taskDialogOpen, setTaskDialogOpen] = useState(false);
+ const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
+
+ useEffect(() => {
+ void chatStore.init();
+ void taskStore.init();
+ void channelStore.init();
+ }, []);
+
+ const currentMs = Date.now();
+ const historyBuckets: ChatHistoryBucket[] = HISTORY_BUCKET_META.map((bucket) => ({
+ ...bucket,
+ sessions: [],
+ }));
+ const bucketMap = new Map(historyBuckets.map((bucket) => [bucket.key, bucket]));
+
+ for (const session of [...chat.sessions].sort((left, right) => {
+ const leftTime = chat.sessionLastActivity[left.key] || left.updatedAt || 0;
+ const rightTime = chat.sessionLastActivity[right.key] || right.updatedAt || 0;
+ return rightTime - leftTime;
+ })) {
+ const activityMs = chat.sessionLastActivity[session.key] || session.updatedAt || 0;
+ const bucketKey = getHistoryBucket(activityMs, currentMs);
+ const targetBucket = bucketMap.get(bucketKey);
+ if (!targetBucket) continue;
+
+ targetBucket.sessions.push({
+ conversationId: session.key,
+ title: chat.sessionLabels[session.key] || session.displayName || session.key,
+ updatedAt: formatHistoryTime(activityMs, currentMs),
+ });
+ }
+
+ const pendingTasks = getPendingTasks(taskState.tasks);
+ const completedTasks = getCompletedTasks(taskState.tasks);
+ const pendingTaskItems: TaskItem[] = pendingTasks.flatMap((task) => task.subTasks.map((subTask) => ({
+ id: subTask.id,
+ removeTaskId: task.id,
+ title: subTask.name,
+ description: subTask.message || task.title,
+ status: subTask.status,
+ meta: task.title,
+ })));
+ const completedTaskItems: TaskItem[] = completedTasks.map((task) => ({
+ id: task.id,
+ title: task.title,
+ description: `${task.operation === 'open' ? '开启' : '关闭'} ${getTaskRoomTypeLabel(task)}`,
+ status: task.status,
+ meta: `${task.dateRange[0]} ~ ${task.dateRange[1]}`,
+ }));
+ const currentTaskSource = activeTaskTab === 'pending' ? pendingTasks : completedTasks;
+ const latestTask = currentTaskSource[0];
+
+ const visibleMessages = mapMessages(chat.messages, chat.streamingMessage);
+
+ async function handleSendMessage(): Promise {
+ const sent = await chatStore.sendMessage(inputMessage, attachments);
+ if (sent) {
+ setInputMessage('');
+ setAttachments([]);
+ }
+ }
+
+ async function handleAttach(files: File[]): Promise {
+ const stagedFiles = await chatStore.stageAttachmentFiles(files);
+ setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
+ }
+
+ async function handleTaskCenterItem(item: taskCenterItem): Promise {
+ setTaskCenterNotice(null);
+
+ if (item.type === 'channel') {
+ if (!channelState.selectedChannels.length) {
+ setTaskCenterNotice('请先在当前项目里配置“已选渠道”,再执行一键打开。');
+ return;
+ }
+
+ try {
+ await invokeIpc(IPC_EVENTS.OPEN_CHANNEL, channelState.selectedChannels);
+ setTaskCenterNotice('已触发“打开渠道”操作。');
+ } catch (error) {
+ setTaskCenterNotice(error instanceof Error ? error.message : String(error));
+ }
+ return;
+ }
+
+ setTaskDialogOpen(true);
+ }
+
+ async function handleSaveChannels(items: typeof channelState.selectedChannels): Promise {
+ await channelStore.saveSelectedChannels(items);
+ setTaskCenterNotice('已更新“打开渠道”配置。');
+ setAddChannelDialogOpen(false);
+ }
+
+ async function handleCreateTask(options: Parameters[0]): Promise {
+ const { task, result } = await taskStore.createAndExecuteTask(options);
+ setTaskDialogOpen(false);
+
+ if (result.success) {
+ setTaskCenterNotice(`已创建任务“${task.title}”。`);
+ return;
+ }
+
+ setTaskCenterNotice(result.error || `任务“${task.title}”创建后执行失败。`);
+ }
+
+ async function handleRetryTask(taskId: string): Promise {
+ const result = await taskStore.retryFailedSubTasks(taskId);
+ if (result.success) {
+ setTaskCenterNotice('已重新触发失败子任务。');
+ return;
+ }
+
+ setTaskCenterNotice(result.error || '重试失败,请稍后再试。');
+ }
+
+ return (
+
+
+
{
+ void chatStore.newSession();
+ }}
+ onSelectConversation={(conversationId) => {
+ chatStore.switchSession(conversationId);
+ }}
+ onRenameConversation={(conversationId) => {
+ const currentLabel = chat.sessionLabels[conversationId]
+ || chat.sessions.find((session) => session.key === conversationId)?.displayName
+ || '';
+ const nextLabel = window.prompt('重命名对话', currentLabel);
+ if (nextLabel) {
+ chatStore.renameSession(conversationId, nextLabel);
+ }
+ }}
+ onDeleteConversation={(conversationId) => {
+ const confirmed = window.confirm('确定删除该会话吗?删除后将无法恢复。');
+ if (confirmed) {
+ void chatStore.deleteSession(conversationId);
+ }
+ }}
+ />
+
+
+
+
+
智能对话
+
+ 网关状态:{chat.gatewayStatus === 'connected' ? '已连接' : chat.gatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
+
+
+
+
+
+
+
+ chatStore.clearError()}
+ onRemoveAttachment={(index) => {
+ setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
+ }}
+ onSend={() => {
+ void handleSendMessage();
+ }}
+ onStop={() => {
+ void chatStore.abortRun();
+ }}
+ />
+
+
+
+
+
+
+ {taskCenterNotice ? (
+
+ {taskCenterNotice}
+
+ ) : null}
+
+
+ {taskCenterList.map((item) => (
+
+ ))}
+
+
+
+
+
+ {
+ taskStore.removeTask(taskId);
+ }}
+ onRetryTask={(taskId) => {
+ void handleRetryTask(taskId);
+ }}
+ onTabChange={setActiveTaskTab}
+ />
+
+
+ setTaskDialogOpen(false)}
+ onConfirm={handleCreateTask}
+ />
+
+ setAddChannelDialogOpen(false)}
+ onConfirm={handleSaveChannels}
+ />
+
+ );
+}
diff --git a/src-react/pages/Knowledge/index.tsx b/src-react/pages/Knowledge/index.tsx
new file mode 100644
index 0000000..94c878b
--- /dev/null
+++ b/src-react/pages/Knowledge/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function KnowledgePage() {
+ return (
+
+ );
+}
diff --git a/src-react/pages/Login/index.tsx b/src-react/pages/Login/index.tsx
new file mode 100644
index 0000000..44619ac
--- /dev/null
+++ b/src-react/pages/Login/index.tsx
@@ -0,0 +1,96 @@
+import { Link } from 'react-router-dom';
+import TitleBar from '../../components/layout/TitleBar';
+
+export default function LoginPage() {
+ return (
+
+
+
+
+
+
+
+
+
+ ◌
+
+
+ 欢迎回到 zn-ai
+
+
+ React 登录壳
+
+
+
+
+
+
用户名
+
+ username@example.com
+
+
+
+
+
+
+
验证码
+
+ 请输入验证码
+
+ 1234
+
+
+
+
+
+
+
+ 迁移阶段先提供静态登录壳,后续再接入真实认证。
+
+
+
+ 进入应用
+
+
+
+
+
+
+
+
+
+ 平移原有视觉壳,逐步接管 React 入口
+
+
+ 当前先保持当前 Vue 登录页的结构感和信息密度,后续认证、主题和窗口行为会按迁移计划逐步接入。
+
+
+
+
+
+
+ );
+}
diff --git a/src-react/pages/PagePlaceholder.tsx b/src-react/pages/PagePlaceholder.tsx
new file mode 100644
index 0000000..1062b5b
--- /dev/null
+++ b/src-react/pages/PagePlaceholder.tsx
@@ -0,0 +1,73 @@
+import type { ReactNode } from 'react';
+
+type Stat = {
+ label: string;
+ value: string;
+};
+
+type PagePlaceholderProps = {
+ tag: string;
+ title: string;
+ subtitle: string;
+ description: string;
+ stats?: Stat[];
+ actions?: ReactNode;
+};
+
+export default function PagePlaceholder({
+ tag,
+ title,
+ subtitle,
+ description,
+ stats = [],
+ actions,
+}: PagePlaceholderProps) {
+ return (
+
+
+
+
+
+ {tag}
+
+
+ {title}
+
+
{subtitle}
+
+
+ {actions ?
{actions}
: null}
+
+
+
+
+
+
+ {stats.map((stat) => (
+
+
{stat.label}
+
+ {stat.value}
+
+
+ ))}
+
+
+
+ 这里先保留 React 页面壳,后续会按原有 Vue 页面逐块迁移真实内容。
+
+
+
+
+ );
+}
diff --git a/src-react/pages/Scripts/index.tsx b/src-react/pages/Scripts/index.tsx
new file mode 100644
index 0000000..f2beeef
--- /dev/null
+++ b/src-react/pages/Scripts/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function ScriptsPage() {
+ return (
+
+ );
+}
diff --git a/src-react/pages/Setting/index.tsx b/src-react/pages/Setting/index.tsx
new file mode 100644
index 0000000..e80f1eb
--- /dev/null
+++ b/src-react/pages/Setting/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function SettingPage() {
+ return (
+
+ );
+}
diff --git a/src-react/pages/Skills/index.tsx b/src-react/pages/Skills/index.tsx
new file mode 100644
index 0000000..23507fb
--- /dev/null
+++ b/src-react/pages/Skills/index.tsx
@@ -0,0 +1,16 @@
+import PagePlaceholder from '../PagePlaceholder';
+
+export default function SkillsPage() {
+ return (
+
+ );
+}
diff --git a/src-react/router/index.tsx b/src-react/router/index.tsx
new file mode 100644
index 0000000..57aedd3
--- /dev/null
+++ b/src-react/router/index.tsx
@@ -0,0 +1,32 @@
+import { Navigate, Route, Routes } from 'react-router-dom';
+import MainLayout from '../components/layout/MainLayout';
+import HomePage from '../pages/Home';
+import LoginPage from '../pages/Login';
+import AgentsPage from '../pages/Agents';
+import SkillsPage from '../pages/Skills';
+import CronPage from '../pages/Cron';
+import ScriptsPage from '../pages/Scripts';
+import SettingPage from '../pages/Setting';
+import KnowledgePage from '../pages/Knowledge';
+import { DEFAULT_PATH } from './routes';
+
+export function AppRouter() {
+ return (
+
+ } />
+ } />
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+ );
+}
diff --git a/src-react/router/routes.ts b/src-react/router/routes.ts
new file mode 100644
index 0000000..6dc41f6
--- /dev/null
+++ b/src-react/router/routes.ts
@@ -0,0 +1,44 @@
+export type AppPath =
+ | '/home'
+ | '/agents'
+ | '/skills'
+ | '/cron'
+ | '/scripts'
+ | '/setting'
+ | '/knowledge'
+ | '/login';
+
+export type WorkspacePath = Exclude;
+
+export type NavItem = {
+ path: WorkspacePath;
+ label: string;
+ description: string;
+};
+
+export const DEFAULT_PATH: WorkspacePath = '/home';
+
+export const NAV_ITEMS: NavItem[] = [
+ { path: '/home', label: '首页', description: '对话与首页入口' },
+ { path: '/knowledge', label: '知识库', description: '知识库与内容管理' },
+ { path: '/agents', label: '模型', description: '智能体与提供方' },
+ { path: '/skills', label: '技能', description: '技能与能力集' },
+ { path: '/cron', label: '定时任务', description: '计划与调度' },
+ { path: '/scripts', label: '脚本', description: '脚本与自动化' },
+ { path: '/setting', label: '设置', description: '应用配置' },
+];
+
+export function normalizeWorkspacePath(pathname: string): WorkspacePath {
+ switch (pathname) {
+ case '/knowledge':
+ case '/agents':
+ case '/skills':
+ case '/cron':
+ case '/scripts':
+ case '/setting':
+ return pathname;
+ case '/home':
+ default:
+ return DEFAULT_PATH;
+ }
+}
diff --git a/src-react/stores/channel.ts b/src-react/stores/channel.ts
new file mode 100644
index 0000000..f7c555f
--- /dev/null
+++ b/src-react/stores/channel.ts
@@ -0,0 +1,202 @@
+import { useSyncExternalStore } from 'react';
+import type { AutomationScript } from '@lib/script-types';
+import { scriptApi } from '@lib/script-api';
+import { resolveChannel } from '@constant/channel';
+import { CONFIG_KEYS, IPC_EVENTS } from '../lib/constants';
+import { invokeIpc } from '../lib/host-api';
+
+export interface ChannelItem {
+ id: string;
+ channelName: string;
+ channelUrl: string;
+}
+
+export interface ChannelStoreState {
+ initialized: boolean;
+ loading: boolean;
+ selectedChannels: ChannelItem[];
+ availableChannels: ChannelItem[];
+ error: string | null;
+}
+
+const listeners = new Set<() => void>();
+let initPromise: Promise | null = null;
+let state: ChannelStoreState = {
+ initialized: false,
+ loading: false,
+ selectedChannels: [],
+ availableChannels: [],
+ error: null,
+};
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function patchState(patch: Partial): ChannelStoreState {
+ state = { ...state, ...patch };
+ emit();
+ return state;
+}
+
+function normalizeChannelItem(item: Partial | null | undefined): ChannelItem | null {
+ const channelUrl = String(item?.channelUrl ?? '').trim();
+ if (!channelUrl) return null;
+
+ const channelName = String(item?.channelName ?? channelUrl).trim() || channelUrl;
+ const id = String(item?.id ?? channelUrl).trim() || channelUrl;
+
+ return {
+ id,
+ channelName,
+ channelUrl,
+ };
+}
+
+function dedupeChannels(items: Array | null | undefined>): ChannelItem[] {
+ const channelMap = new Map();
+
+ for (const item of items) {
+ const normalized = normalizeChannelItem(item);
+ if (!normalized || channelMap.has(normalized.channelUrl)) continue;
+ channelMap.set(normalized.channelUrl, normalized);
+ }
+
+ return Array.from(channelMap.values());
+}
+
+function mapScriptsToChannels(scripts: AutomationScript[]): ChannelItem[] {
+ const items: ChannelItem[] = [];
+
+ for (const script of scripts) {
+ if (!script.channel) continue;
+
+ const resolved = resolveChannel(script.channel);
+ const channelUrl = String(resolved.url ?? '').trim();
+ if (!channelUrl) continue;
+
+ items.push({
+ id: channelUrl,
+ channelName: String(resolved.name ?? channelUrl).trim() || channelUrl,
+ channelUrl,
+ });
+ }
+
+ return dedupeChannels(items);
+}
+
+async function loadSelectedChannels(): Promise {
+ const saved = await invokeIpc(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS);
+ return Array.isArray(saved) ? dedupeChannels(saved) : [];
+}
+
+async function loadAvailableChannels(): Promise {
+ const scripts = await scriptApi.list();
+ return mapScriptsToChannels(Array.isArray(scripts) ? scripts : []);
+}
+
+async function hydrate(): Promise {
+ patchState({ loading: true, error: null });
+
+ try {
+ const [selectedChannels, availableChannels] = await Promise.all([
+ loadSelectedChannels(),
+ loadAvailableChannels(),
+ ]);
+
+ patchState({
+ initialized: true,
+ loading: false,
+ selectedChannels,
+ availableChannels,
+ error: null,
+ });
+ } catch (error) {
+ patchState({
+ initialized: true,
+ loading: false,
+ selectedChannels: [],
+ availableChannels: [],
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+async function refreshAvailableChannels(): Promise {
+ patchState({ loading: true, error: null });
+
+ try {
+ const availableChannels = await loadAvailableChannels();
+ patchState({
+ loading: false,
+ availableChannels,
+ error: null,
+ });
+ return availableChannels;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ patchState({
+ loading: false,
+ error: message,
+ });
+ throw error;
+ }
+}
+
+async function saveSelectedChannels(items: ChannelItem[]): Promise {
+ const nextItems = dedupeChannels(items);
+ patchState({ selectedChannels: nextItems, error: null });
+
+ await invokeIpc(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS, nextItems);
+ return nextItems;
+}
+
+function setSelectedChannels(items: ChannelItem[]): ChannelItem[] {
+ const nextItems = dedupeChannels(items);
+ patchState({ selectedChannels: nextItems });
+ return nextItems;
+}
+
+function addSelectedChannel(item: ChannelItem): ChannelItem[] {
+ return setSelectedChannels([...state.selectedChannels, item]);
+}
+
+function removeSelectedChannel(id: string): ChannelItem[] {
+ return setSelectedChannels(state.selectedChannels.filter((item) => item.id !== id));
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): ChannelStoreState {
+ return state;
+}
+
+async function initChannelStore(): Promise {
+ if (!initPromise) {
+ initPromise = hydrate();
+ }
+
+ await initPromise;
+}
+
+export const channelStore = {
+ subscribe,
+ getSnapshot,
+ getState: () => state,
+ init: initChannelStore,
+ loadSelectedChannels,
+ refreshAvailableChannels,
+ saveSelectedChannels,
+ setSelectedChannels,
+ addSelectedChannel,
+ removeSelectedChannel,
+};
+
+export function useChannelStore(): ChannelStoreState {
+ return useSyncExternalStore(channelStore.subscribe, channelStore.getSnapshot, channelStore.getSnapshot);
+}
diff --git a/src-react/stores/chat.ts b/src-react/stores/chat.ts
new file mode 100644
index 0000000..103e73d
--- /dev/null
+++ b/src-react/stores/chat.ts
@@ -0,0 +1,788 @@
+import { useSyncExternalStore } from 'react';
+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';
+
+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;
+
+export interface StagedAttachment {
+ fileName: string;
+ mimeType: string;
+ fileSize: number;
+ stagedPath: string;
+ preview: string | null;
+}
+
+export interface ChatStoreState {
+ initialized: boolean;
+ messages: RawMessage[];
+ loading: boolean;
+ error: string | null;
+ sending: boolean;
+ activeRunId: string | null;
+ streamingMessage: RawMessage | null;
+ streamingTools: ToolStatus[];
+ pendingFinal: boolean;
+ lastUserMessageAt: number | null;
+ sessions: ChatSession[];
+ currentSessionKey: string;
+ currentAgentId: string;
+ sessionLabels: Record;
+ sessionLastActivity: Record;
+ gatewayStatus: 'connected' | 'disconnected' | 'reconnecting';
+}
+
+const listeners = new Set<() => void>();
+const historyLoadInFlight = new Map>();
+const lastHistoryLoadAtBySession = new Map();
+const chatEventDedupe = new Map();
+
+let gatewaySubscribed = false;
+let loadSessionsInFlight: Promise | null = null;
+let lastLoadSessionsAt = 0;
+let state: ChatStoreState = {
+ initialized: false,
+ messages: [],
+ loading: false,
+ error: null,
+ sending: false,
+ activeRunId: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ sessions: [],
+ currentSessionKey: DEFAULT_SESSION_KEY,
+ currentAgentId: DEFAULT_AGENT_ID,
+ sessionLabels: {},
+ sessionLastActivity: {},
+ gatewayStatus: 'disconnected',
+};
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function patchState(patch: Partial): ChatStoreState {
+ state = { ...state, ...patch };
+ emit();
+ return state;
+}
+
+function getAgentIdFromSessionKey(sessionKey: string): string {
+ if (!sessionKey.startsWith('agent:')) return DEFAULT_AGENT_ID;
+ const parts = sessionKey.split(':');
+ return parts[1] || DEFAULT_AGENT_ID;
+}
+
+function clearSessionEntryFromMap>(entries: T, sessionKey: string): T {
+ return Object.fromEntries(Object.entries(entries).filter(([key]) => key !== sessionKey)) as T;
+}
+
+function ensureSessionEntry(sessions: ChatSession[], sessionKey: string, displayName?: string): ChatSession[] {
+ if (sessions.some((session) => session.key === sessionKey)) return sessions;
+ return [...sessions, { key: sessionKey, displayName: displayName || 'New Chat' }];
+}
+
+function toMs(ts: number): number {
+ return ts < 1e12 ? ts * 1000 : ts;
+}
+
+function buildSessionSwitchPatch(currentState: ChatStoreState, nextSessionKey: string): Partial {
+ const leavingEmpty =
+ !currentState.currentSessionKey.endsWith(':main') &&
+ currentState.messages.length === 0 &&
+ !currentState.sessionLastActivity[currentState.currentSessionKey] &&
+ !currentState.sessionLabels[currentState.currentSessionKey];
+
+ const nextSessions = leavingEmpty
+ ? currentState.sessions.filter((session) => session.key !== currentState.currentSessionKey)
+ : currentState.sessions;
+
+ return {
+ currentSessionKey: nextSessionKey,
+ currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
+ sessions: ensureSessionEntry(nextSessions, nextSessionKey),
+ sessionLabels: leavingEmpty
+ ? clearSessionEntryFromMap(currentState.sessionLabels, currentState.currentSessionKey)
+ : currentState.sessionLabels,
+ sessionLastActivity: leavingEmpty
+ ? clearSessionEntryFromMap(currentState.sessionLastActivity, currentState.currentSessionKey)
+ : currentState.sessionLastActivity,
+ messages: [],
+ streamingMessage: null,
+ streamingTools: [],
+ activeRunId: null,
+ error: null,
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ };
+}
+
+function pruneChatEventDedupe(now: number): void {
+ for (const [key, timestamp] of chatEventDedupe.entries()) {
+ if (now - timestamp > CHAT_EVENT_DEDUPE_TTL_MS) {
+ chatEventDedupe.delete(key);
+ }
+ }
+}
+
+function buildChatEventDedupeKey(event: GatewayEvent): string | null {
+ const runId = 'runId' in event && typeof event.runId === 'string' ? event.runId : '';
+ const sessionKey = 'sessionKey' in event && typeof event.sessionKey === 'string' ? event.sessionKey : '';
+ const type = event.type;
+ if (!runId && !sessionKey && !type) return null;
+ return `${runId}|${sessionKey}|${type}`;
+}
+
+function isDuplicateChatEvent(event: GatewayEvent): boolean {
+ const key = buildChatEventDedupeKey(event);
+ if (!key) return false;
+
+ const now = Date.now();
+ pruneChatEventDedupe(now);
+ if (chatEventDedupe.has(key)) return true;
+ chatEventDedupe.set(key, now);
+ return false;
+}
+
+async function resolveDefaultAccountId(): Promise {
+ try {
+ const result = await gatewayRpc<{ accountId: string | null }>('provider.getDefault', {});
+ if (result?.accountId) return result.accountId;
+ } catch {
+ // fall through
+ }
+
+ try {
+ const result = await hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default');
+ return result.accountId ?? null;
+ } catch {
+ return null;
+ }
+}
+
+async function stageBuffer(base64: string, fileName: string, mimeType: string): Promise {
+ try {
+ const result = await hostApiFetch('/api/files/stage-buffer', {
+ method: 'POST',
+ body: JSON.stringify({ base64, fileName, mimeType }),
+ });
+
+ if (result?.stagedPath) {
+ return result;
+ }
+ } catch {
+ // fall through to local fallback
+ }
+
+ const dataUrl = `data:${mimeType};base64,${base64}`;
+ return {
+ fileName,
+ mimeType,
+ fileSize: Math.ceil(base64.length * 0.75),
+ stagedPath: dataUrl,
+ preview: mimeType.startsWith('image/') ? dataUrl : null,
+ };
+}
+
+async function subscribeToGateway(): Promise {
+ if (gatewaySubscribed) return;
+
+ gatewaySubscribed = true;
+ onGatewayEvent((event) => {
+ if (event.type === 'gateway:status') {
+ patchState({ gatewayStatus: event.status });
+ return;
+ }
+
+ if (typeof event.sessionKey === 'string' && event.sessionKey !== state.currentSessionKey) {
+ return;
+ }
+
+ void handleGatewayEvent(event);
+ });
+}
+
+async function loadSessions(): Promise {
+ const now = Date.now();
+ if (loadSessionsInFlight) {
+ await loadSessionsInFlight;
+ return;
+ }
+ if (now - lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) {
+ return;
+ }
+
+ loadSessionsInFlight = (async () => {
+ try {
+ const localKeys = await gatewayRpc('session.list', {});
+ let sessions: ChatSession[] = localKeys.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];
+
+ let nextSessionKey = state.currentSessionKey || DEFAULT_SESSION_KEY;
+ if (!sessions.find((session) => session.key === nextSessionKey) && sessions.length > 0) {
+ nextSessionKey = sessions[0].key;
+ }
+
+ const sessionsWithCurrent =
+ !sessions.find((session) => session.key === nextSessionKey) && nextSessionKey
+ ? [...sessions, { key: nextSessionKey, displayName: nextSessionKey }]
+ : sessions;
+
+ const discoveredActivity = Object.fromEntries(
+ sessionsWithCurrent
+ .filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
+ .map((session) => [session.key, session.updatedAt!]),
+ );
+
+ patchState({
+ initialized: true,
+ sessions: sessionsWithCurrent,
+ currentSessionKey: nextSessionKey,
+ currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
+ sessionLastActivity: {
+ ...state.sessionLastActivity,
+ ...discoveredActivity,
+ },
+ });
+
+ if (nextSessionKey && nextSessionKey !== DEFAULT_SESSION_KEY) {
+ await loadHistory(nextSessionKey, true);
+ }
+
+ void Promise.all(
+ sessionsWithCurrent
+ .filter((session) => !state.sessionLabels[session.key])
+ .map(async (session) => {
+ try {
+ const messages = await gatewayRpc('chat.history', {
+ sessionKey: session.key,
+ limit: 50,
+ });
+ const firstUser = messages.find((message) => message.role === 'user');
+ const lastMessage = messages[messages.length - 1];
+ const nextPatch: Partial = {};
+
+ if (firstUser) {
+ const labelText = extractText(firstUser).trim();
+ if (labelText) {
+ nextPatch.sessionLabels = {
+ ...state.sessionLabels,
+ [session.key]: labelText.length > 50 ? `${labelText.slice(0, 50)}...` : labelText,
+ };
+ }
+ }
+
+ if (lastMessage?.timestamp) {
+ nextPatch.sessionLastActivity = {
+ ...state.sessionLastActivity,
+ [session.key]: toMs(lastMessage.timestamp),
+ };
+ }
+
+ if (Object.keys(nextPatch).length > 0) {
+ patchState(nextPatch);
+ }
+ } catch {
+ // ignore background label loads
+ }
+ }),
+ );
+ } finally {
+ lastLoadSessionsAt = Date.now();
+ }
+ })();
+
+ try {
+ await loadSessionsInFlight;
+ } finally {
+ loadSessionsInFlight = null;
+ }
+}
+
+async function loadHistory(sessionKey = state.currentSessionKey, quiet = false): Promise {
+ if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) {
+ patchState({
+ messages: [],
+ loading: false,
+ });
+ return;
+ }
+
+ const existingLoad = historyLoadInFlight.get(sessionKey);
+ if (existingLoad) {
+ await existingLoad;
+ return;
+ }
+
+ const lastLoadAt = lastHistoryLoadAtBySession.get(sessionKey) || 0;
+ if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) {
+ return;
+ }
+
+ if (!quiet) {
+ patchState({ loading: true });
+ }
+
+ const loadPromise = (async () => {
+ try {
+ const messages = await gatewayRpc('chat.history', {
+ sessionKey,
+ limit: 50,
+ });
+
+ if (state.currentSessionKey !== sessionKey) return;
+
+ let nextMessages = messages.filter((message) => !message || !('role' in message) || message.role);
+
+ if (state.sending && state.lastUserMessageAt) {
+ const hasRecentUser = nextMessages.some((message) => (
+ message.role === 'user' &&
+ message.timestamp &&
+ Math.abs(toMs(message.timestamp) - state.lastUserMessageAt!) < 5000
+ ));
+
+ if (!hasRecentUser) {
+ const optimistic = [...state.messages].reverse().find((message) => (
+ message.role === 'user' &&
+ message.timestamp &&
+ Math.abs(toMs(message.timestamp) - state.lastUserMessageAt!) < 5000
+ ));
+
+ if (optimistic) {
+ nextMessages = [...nextMessages, optimistic];
+ }
+ }
+ }
+
+ const nextPatch: Partial = {
+ messages: nextMessages,
+ loading: false,
+ };
+
+ const firstUser = nextMessages.find((message) => message.role === 'user');
+ if (firstUser && !sessionKey.endsWith(':main')) {
+ const labelText = extractText(firstUser).trim();
+ if (labelText) {
+ nextPatch.sessionLabels = {
+ ...state.sessionLabels,
+ [sessionKey]: labelText.length > 50 ? `${labelText.slice(0, 50)}...` : labelText,
+ };
+ }
+ }
+
+ const lastMessage = nextMessages[nextMessages.length - 1];
+ if (lastMessage?.timestamp) {
+ nextPatch.sessionLastActivity = {
+ ...state.sessionLastActivity,
+ [sessionKey]: toMs(lastMessage.timestamp),
+ };
+ }
+
+ patchState(nextPatch);
+ } catch (error) {
+ patchState({
+ error: quiet ? state.error : String(error),
+ loading: false,
+ });
+ }
+ })();
+
+ historyLoadInFlight.set(sessionKey, loadPromise);
+ try {
+ await loadPromise;
+ } finally {
+ lastHistoryLoadAtBySession.set(sessionKey, Date.now());
+ if (historyLoadInFlight.get(sessionKey) === loadPromise) {
+ historyLoadInFlight.delete(sessionKey);
+ }
+ }
+}
+
+async function newSession(): Promise {
+ const defaultAccountId = await resolveDefaultAccountId();
+ if (!defaultAccountId) {
+ patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
+ return;
+ }
+
+ 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 nextSessions = leavingEmpty
+ ? state.sessions.filter((session) => session.key !== state.currentSessionKey)
+ : state.sessions;
+
+ patchState({
+ currentSessionKey: newKey,
+ currentAgentId: 'local',
+ sessions: [...nextSessions, { key: newKey, displayName: 'New Chat' }],
+ sessionLabels: leavingEmpty
+ ? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey)
+ : state.sessionLabels,
+ sessionLastActivity: leavingEmpty
+ ? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey)
+ : state.sessionLastActivity,
+ messages: [],
+ streamingMessage: null,
+ streamingTools: [],
+ activeRunId: null,
+ error: null,
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ });
+}
+
+function switchSession(sessionKey: string): void {
+ if (sessionKey === state.currentSessionKey) return;
+ patchState(buildSessionSwitchPatch(state, sessionKey));
+ void loadHistory(sessionKey);
+}
+
+async function deleteSession(sessionKey: string): Promise {
+ try {
+ await gatewayRpc('session.delete', { sessionKey });
+ } catch {
+ // keep local cleanup even if gateway delete fails
+ }
+
+ const remaining = state.sessions.filter((session) => session.key !== sessionKey);
+ const basePatch: Partial = {
+ sessions: remaining,
+ sessionLabels: clearSessionEntryFromMap(state.sessionLabels, sessionKey),
+ sessionLastActivity: clearSessionEntryFromMap(state.sessionLastActivity, sessionKey),
+ };
+
+ if (state.currentSessionKey === sessionKey) {
+ const nextSession = remaining[0]?.key ?? DEFAULT_SESSION_KEY;
+ patchState({
+ ...basePatch,
+ currentSessionKey: nextSession,
+ currentAgentId: getAgentIdFromSessionKey(nextSession),
+ messages: [],
+ streamingMessage: null,
+ streamingTools: [],
+ activeRunId: null,
+ error: null,
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ });
+
+ if (nextSession !== DEFAULT_SESSION_KEY) {
+ await loadHistory(nextSession);
+ }
+ return;
+ }
+
+ patchState(basePatch);
+}
+
+function renameSession(sessionKey: string, nextLabel: string): void {
+ const trimmedLabel = nextLabel.trim();
+ if (!trimmedLabel) return;
+
+ patchState({
+ sessionLabels: {
+ ...state.sessionLabels,
+ [sessionKey]: trimmedLabel,
+ },
+ });
+}
+
+async function sendMessage(text: string, attachments: StagedAttachment[] = []): Promise {
+ const trimmedText = text.trim();
+ if (!trimmedText && attachments.length === 0) return false;
+
+ const defaultAccountId = await resolveDefaultAccountId();
+ if (!defaultAccountId) {
+ 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',
+ content: trimmedText || (attachments.length > 0 ? '(file attached)' : ''),
+ timestamp: nowMs,
+ id: crypto.randomUUID(),
+ _attachedFiles: attachments.map((attachment) => ({
+ fileName: attachment.fileName,
+ mimeType: attachment.mimeType,
+ fileSize: attachment.fileSize,
+ preview: attachment.preview,
+ filePath: attachment.stagedPath,
+ })),
+ };
+
+ const nextSessions = ensureSessionEntry(state.sessions, targetSessionKey, 'New Chat');
+ const isFirstUserMessage = !state.messages.some((message) => message.role === 'user');
+ const nextLabels =
+ !targetSessionKey.endsWith(':main') && isFirstUserMessage && trimmedText
+ ? {
+ ...state.sessionLabels,
+ [targetSessionKey]: trimmedText.length > 50 ? `${trimmedText.slice(0, 50)}...` : trimmedText,
+ }
+ : state.sessionLabels;
+
+ patchState({
+ messages: [...state.messages, userMessage],
+ sending: true,
+ activeRunId: null,
+ error: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: nowMs,
+ sessions: nextSessions,
+ currentSessionKey: targetSessionKey,
+ currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
+ sessionLabels: nextLabels,
+ sessionLastActivity: {
+ ...state.sessionLastActivity,
+ [targetSessionKey]: nowMs,
+ },
+ });
+
+ try {
+ let messageContent = trimmedText;
+ if (attachments.length > 0) {
+ const refs = attachments
+ .map((attachment) => `[media attached: ${attachment.fileName} (${attachment.mimeType}) | ${attachment.stagedPath}]`)
+ .join('\n');
+ messageContent = messageContent ? `${messageContent}\n\n${refs}` : refs;
+ }
+
+ const result = await gatewayRpc<{ runId: string }>('chat.send', {
+ sessionKey: targetSessionKey,
+ message: {
+ role: 'user',
+ content: messageContent,
+ },
+ options: {
+ providerAccountId: defaultAccountId,
+ },
+ });
+
+ patchState({
+ activeRunId: result.runId,
+ });
+ return true;
+ } catch (error) {
+ patchState({
+ error: String(error),
+ sending: false,
+ activeRunId: null,
+ lastUserMessageAt: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ });
+ return false;
+ }
+}
+
+async function abortRun(): Promise {
+ const sessionKey = state.currentSessionKey;
+
+ patchState({
+ sending: false,
+ activeRunId: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ });
+
+ if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) return;
+
+ try {
+ await gatewayRpc('chat.abort', { sessionKey });
+ } catch (error) {
+ patchState({ error: String(error) });
+ }
+}
+
+async function handleGatewayEvent(event: GatewayEvent): Promise {
+ if (isDuplicateChatEvent(event)) return;
+ if (state.activeRunId && 'runId' in event && typeof event.runId === 'string' && event.runId !== state.activeRunId) return;
+
+ switch (event.type) {
+ case 'chat:delta': {
+ const previousContent = state.streamingMessage ? extractText(state.streamingMessage) : '';
+ patchState({
+ sending: true,
+ error: null,
+ activeRunId: typeof event.runId === 'string' ? event.runId : state.activeRunId,
+ streamingMessage: {
+ role: 'assistant',
+ content: previousContent + event.delta,
+ timestamp: Date.now(),
+ id: state.streamingMessage?.id || `stream-${event.runId || Date.now()}`,
+ },
+ });
+ break;
+ }
+ case 'chat:final': {
+ const composedMessage = state.streamingMessage && typeof event.message.content === 'string'
+ ? {
+ ...event.message,
+ content: `${extractText(state.streamingMessage)}${event.message.content}`,
+ }
+ : event.message;
+
+ const messageId = composedMessage.id || `run-${event.runId || Date.now()}`;
+ const hasOutput = Boolean(extractText(composedMessage).trim());
+ const toolOnly = isToolOnlyMessage(composedMessage);
+
+ if (!state.messages.some((message) => message.id === messageId)) {
+ patchState({
+ messages: [...state.messages, { ...composedMessage, id: messageId }],
+ sessionLastActivity: {
+ ...state.sessionLastActivity,
+ [state.currentSessionKey]: composedMessage.timestamp ? toMs(composedMessage.timestamp) : Date.now(),
+ },
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: !hasOutput || toolOnly,
+ sending: !hasOutput || toolOnly,
+ activeRunId: hasOutput && !toolOnly ? null : state.activeRunId,
+ lastUserMessageAt: hasOutput && !toolOnly ? null : state.lastUserMessageAt,
+ });
+ } else {
+ patchState({
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: !hasOutput || toolOnly,
+ sending: !hasOutput || toolOnly,
+ activeRunId: hasOutput && !toolOnly ? null : state.activeRunId,
+ lastUserMessageAt: hasOutput && !toolOnly ? null : state.lastUserMessageAt,
+ });
+ }
+
+ if (hasOutput && !toolOnly) {
+ await loadHistory(state.currentSessionKey, true);
+ }
+ break;
+ }
+ case 'chat:error': {
+ if (state.streamingMessage && !state.messages.some((message) => message.id === state.streamingMessage?.id)) {
+ patchState({
+ messages: [
+ ...state.messages,
+ {
+ ...state.streamingMessage,
+ id: state.streamingMessage.id || `error-snap-${Date.now()}`,
+ },
+ ],
+ });
+ }
+
+ patchState({
+ error: event.error,
+ sending: false,
+ activeRunId: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ });
+ break;
+ }
+ case 'chat:aborted': {
+ patchState({
+ sending: false,
+ activeRunId: null,
+ streamingMessage: null,
+ streamingTools: [],
+ pendingFinal: false,
+ lastUserMessageAt: null,
+ });
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+function clearError(): void {
+ patchState({ error: null });
+}
+
+async function initChatStore(): Promise {
+ await subscribeToGateway();
+ await loadSessions();
+}
+
+async function stageAttachmentFiles(files: File[]): Promise {
+ const stagedFiles: StagedAttachment[] = [];
+
+ for (const file of files) {
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
+ reader.onloadend = () => {
+ const dataUrl = String(reader.result || '');
+ resolve(dataUrl.split(',')[1] || '');
+ };
+ reader.readAsDataURL(file);
+ });
+
+ stagedFiles.push(await stageBuffer(base64, file.name, file.type || 'application/octet-stream'));
+ }
+
+ return stagedFiles;
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): ChatStoreState {
+ return state;
+}
+
+export const chatStore = {
+ subscribe,
+ getSnapshot,
+ getState: () => state,
+ init: initChatStore,
+ loadSessions,
+ loadHistory,
+ switchSession,
+ newSession,
+ deleteSession,
+ renameSession,
+ sendMessage,
+ abortRun,
+ clearError,
+ stageAttachmentFiles,
+};
+
+export function useChatStore(): ChatStoreState {
+ return useSyncExternalStore(chatStore.subscribe, chatStore.getSnapshot, chatStore.getSnapshot);
+}
diff --git a/src-react/stores/index.ts b/src-react/stores/index.ts
new file mode 100644
index 0000000..1d2caf7
--- /dev/null
+++ b/src-react/stores/index.ts
@@ -0,0 +1,15 @@
+export * from './settings';
+export * from './chat';
+export * from './task';
+export * from './channel';
+export {
+ settingsStore as appSettingsStore,
+ settingsStore,
+ useSettingsStore,
+ initSettingsStore,
+ updateThemeMode as setThemeMode,
+ updateLanguage as setLanguage,
+ updateFontSize as setFontSize,
+ updateMinimizeToTray as setMinimizeToTray,
+ updatePrimaryColor as setPrimaryColor,
+} from './settings';
diff --git a/src-react/stores/settings.ts b/src-react/stores/settings.ts
new file mode 100644
index 0000000..23d2aa5
--- /dev/null
+++ b/src-react/stores/settings.ts
@@ -0,0 +1,374 @@
+import { useSyncExternalStore } from 'react';
+import { CONFIG_KEYS, DEFAULT_LANGUAGE, DEFAULT_THEME_MODE, IPC_EVENTS } from '../lib/constants';
+import { hasHostApiBridge, hostApiFetch, invokeIpc, onIpc } from '../lib/host-api';
+import { applyThemeModeToDocument, detectSystemTheme, resolveAppliedTheme, watchSystemTheme } from '../lib/theme';
+import { detectRuntimePlatform, resolveWindowIdentity } from '../lib/runtime';
+import { i18n, setLocale as setI18nLocale } from '../i18n';
+import { detectSystemLanguage, resolveSupportedLanguage } from '../i18n/resolver';
+import type {
+ ConfigValueMap,
+ LanguageCode,
+ ResolvedThemeMode,
+ RuntimePlatform,
+ ThemeMode,
+ WindowIdentity,
+ WindowName,
+} from '../types/runtime';
+
+export interface SettingsState {
+ initialized: boolean;
+ platform: RuntimePlatform;
+ windowId: string | number | null;
+ windowName: WindowName;
+ themeMode: ThemeMode;
+ systemTheme: ResolvedThemeMode;
+ appliedTheme: ResolvedThemeMode;
+ language: LanguageCode;
+ primaryColor: string;
+ fontSize: number;
+ minimizeToTray: boolean;
+ providerId: string | null;
+ defaultModel: string | null;
+}
+
+const STORAGE_PREFIX = 'zn-ai-react:';
+const listeners = new Set<() => void>();
+let initPromise: Promise | null = null;
+let unsubscribeThemeWatcher: (() => void) | null = null;
+let unsubscribeThemeEvent: (() => void) | null = null;
+
+function getStorageValue(key: string): T | undefined {
+ if (typeof window === 'undefined') return undefined;
+
+ const raw = window.localStorage.getItem(`${STORAGE_PREFIX}${key}`);
+ if (!raw) return undefined;
+
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return raw as unknown as T;
+ }
+}
+
+function setStorageValue(key: string, value: unknown): void {
+ if (typeof window === 'undefined') return;
+ window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
+}
+
+function createInitialState(): SettingsState {
+ const systemTheme = detectSystemTheme();
+ const systemLanguage = detectSystemLanguage();
+
+ return {
+ initialized: false,
+ platform: detectRuntimePlatform(),
+ windowId: null,
+ windowName: 'main',
+ themeMode: DEFAULT_THEME_MODE,
+ systemTheme,
+ appliedTheme: resolveAppliedTheme(DEFAULT_THEME_MODE, systemTheme),
+ language: systemLanguage ?? DEFAULT_LANGUAGE,
+ primaryColor: '#1677ff',
+ fontSize: 14,
+ minimizeToTray: false,
+ providerId: null,
+ defaultModel: null,
+ };
+}
+
+let state: SettingsState = createInitialState();
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function patchState(patch: Partial): SettingsState {
+ state = { ...state, ...patch };
+ emit();
+ return state;
+}
+
+async function readConfigValue(key: keyof ConfigValueMap, fallback: T): Promise {
+ try {
+ if (hasHostApiBridge()) {
+ const value = await invokeIpc(IPC_EVENTS.GET_CONFIG, key);
+ return (typeof value === 'undefined' || value === null ? fallback : value) as T;
+ }
+ } catch {
+ // fall back to local storage below
+ }
+
+ const stored = getStorageValue(String(key));
+ return typeof stored === 'undefined' ? fallback : stored;
+}
+
+async function writeConfigValue(key: keyof ConfigValueMap, value: T): Promise {
+ try {
+ if (hasHostApiBridge()) {
+ await invokeIpc(IPC_EVENTS.SET_CONFIG, key, value);
+ return;
+ }
+ } catch {
+ // fall back to local storage below
+ }
+
+ setStorageValue(String(key), value);
+}
+
+async function readThemeMode(): Promise {
+ try {
+ if (hasHostApiBridge()) {
+ const value = await invokeIpc(IPC_EVENTS.GET_THEME_MODE);
+ if (value === true) return 'dark';
+ if (value === false) return 'light';
+ if (value === 'light' || value === 'dark' || value === 'system') return value;
+ }
+ } catch {
+ // fallback below
+ }
+
+ return (getStorageValue('themeMode') ?? DEFAULT_THEME_MODE) as ThemeMode;
+}
+
+async function writeThemeMode(themeMode: ThemeMode): Promise {
+ try {
+ if (hasHostApiBridge()) {
+ await invokeIpc(IPC_EVENTS.SET_THEME_MODE, themeMode);
+ return;
+ }
+ } catch {
+ // fallback below
+ }
+
+ setStorageValue('themeMode', themeMode);
+}
+
+function applyLocale(locale: LanguageCode): void {
+ if (typeof document !== 'undefined') {
+ document.documentElement.lang = locale;
+ }
+ setI18nLocale(locale);
+}
+
+function applyTheme(themeMode: ThemeMode, systemTheme: ResolvedThemeMode): ResolvedThemeMode {
+ const appliedTheme = applyThemeModeToDocument(themeMode, systemTheme);
+ if (typeof document !== 'undefined') {
+ document.documentElement.dataset.themeMode = themeMode;
+ }
+ return appliedTheme;
+}
+
+function syncSystemTheme(): void {
+ if (unsubscribeThemeWatcher) return;
+
+ unsubscribeThemeWatcher = watchSystemTheme((systemTheme) => {
+ const appliedTheme = state.themeMode === 'system' ? applyTheme(state.themeMode, systemTheme) : state.appliedTheme;
+ patchState({
+ systemTheme,
+ appliedTheme,
+ });
+ });
+}
+
+async function syncWindowIdentity(): Promise {
+ const identity = await resolveWindowIdentity();
+ patchState({
+ platform: identity.platform,
+ windowId: identity.windowId,
+ windowName: identity.windowName,
+ });
+ return identity;
+}
+
+async function syncThemeEvent(): Promise {
+ if (unsubscribeThemeEvent || typeof window === 'undefined' || !window.api?.on) return;
+
+ unsubscribeThemeEvent = onIpc(IPC_EVENTS.THEME_MODE_UPDATED, (payload: boolean | ThemeMode) => {
+ const themeMode: ThemeMode = typeof payload === 'boolean' ? (payload ? 'dark' : 'light') : payload;
+ const appliedTheme = applyTheme(themeMode, state.systemTheme);
+ patchState({
+ themeMode,
+ appliedTheme,
+ });
+ });
+}
+
+async function hydrate(): Promise {
+ if (initPromise) return initPromise;
+
+ initPromise = (async () => {
+ const identity = await syncWindowIdentity();
+ const systemTheme = detectSystemTheme();
+ const systemLanguage = detectSystemLanguage();
+
+ const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel] = await Promise.all([
+ readThemeMode(),
+ readConfigValue(CONFIG_KEYS.LANGUAGE, systemLanguage),
+ readConfigValue(CONFIG_KEYS.FONT_SIZE, 14),
+ readConfigValue(CONFIG_KEYS.MINIMIZE_TO_TRAY, false),
+ readConfigValue(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
+ readConfigValue(CONFIG_KEYS.PROVIDER, null),
+ readConfigValue(CONFIG_KEYS.DEFAULT_MODEL, null),
+ ]);
+
+ const resolvedLanguage = resolveSupportedLanguage(language ?? systemLanguage);
+ const appliedTheme = applyTheme(themeMode, systemTheme);
+
+ patchState({
+ initialized: true,
+ platform: identity.platform,
+ windowId: identity.windowId,
+ windowName: identity.windowName,
+ themeMode,
+ systemTheme,
+ appliedTheme,
+ language: resolvedLanguage,
+ primaryColor: primaryColor ?? '#1677ff',
+ fontSize: fontSize ?? 14,
+ minimizeToTray: Boolean(minimizeToTray),
+ providerId: providerId ?? null,
+ defaultModel: defaultModel ?? null,
+ });
+
+ applyLocale(resolvedLanguage);
+ syncSystemTheme();
+ await syncThemeEvent();
+
+ return state;
+ })();
+
+ return initPromise;
+}
+
+async function setThemeMode(themeMode: ThemeMode): Promise {
+ const nextThemeMode = themeMode === 'light' || themeMode === 'dark' || themeMode === 'system' ? themeMode : DEFAULT_THEME_MODE;
+ if (state.themeMode === nextThemeMode && state.initialized) return state;
+
+ const appliedTheme = applyTheme(nextThemeMode, state.systemTheme);
+ patchState({
+ themeMode: nextThemeMode,
+ appliedTheme,
+ });
+
+ await writeThemeMode(nextThemeMode);
+ return state;
+}
+
+async function setLanguage(language: string | null | undefined, persist = true): Promise {
+ const resolved = resolveSupportedLanguage(language, state.language);
+ if (state.language === resolved && state.initialized) return state;
+
+ applyLocale(resolved);
+ patchState({
+ language: resolved,
+ });
+
+ if (persist) {
+ await writeConfigValue(CONFIG_KEYS.LANGUAGE, resolved);
+ }
+
+ return state;
+}
+
+async function setFontSize(fontSize: number, persist = true): Promise {
+ const next = Number.isFinite(fontSize) ? fontSize : state.fontSize;
+ if (state.fontSize === next && state.initialized) return state;
+
+ patchState({
+ fontSize: next,
+ });
+
+ if (persist) {
+ await writeConfigValue(CONFIG_KEYS.FONT_SIZE, next);
+ }
+
+ return state;
+}
+
+async function setMinimizeToTray(minimizeToTray: boolean, persist = true): Promise {
+ const next = Boolean(minimizeToTray);
+ if (state.minimizeToTray === next && state.initialized) return state;
+
+ patchState({
+ minimizeToTray: next,
+ });
+
+ if (persist) {
+ await writeConfigValue(CONFIG_KEYS.MINIMIZE_TO_TRAY, next);
+ }
+
+ return state;
+}
+
+async function setPrimaryColor(primaryColor: string, persist = true): Promise {
+ const next = primaryColor || '#1677ff';
+ if (state.primaryColor === next && state.initialized) return state;
+
+ patchState({
+ primaryColor: next,
+ });
+
+ if (persist) {
+ await writeConfigValue(CONFIG_KEYS.PRIMARY_COLOR, next);
+ }
+
+ return state;
+}
+
+function getSnapshot(): SettingsState {
+ return state;
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export const settingsStore = {
+ init: hydrate,
+ getState: getSnapshot,
+ subscribe,
+ setThemeMode,
+ setLanguage,
+ setFontSize,
+ setMinimizeToTray,
+ setPrimaryColor,
+ hostApiFetch,
+};
+
+export function useSettingsStore(selector?: (state: SettingsState) => T): T {
+ const select = selector ?? ((current: SettingsState) => current as unknown as T);
+ return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
+}
+
+export function getSettingsState(): SettingsState {
+ return getSnapshot();
+}
+
+export async function initSettingsStore(): Promise {
+ return hydrate();
+}
+
+export async function updateThemeMode(themeMode: ThemeMode): Promise {
+ return setThemeMode(themeMode);
+}
+
+export async function updateLanguage(language: string | null | undefined, persist = true): Promise {
+ return setLanguage(language, persist);
+}
+
+export async function updateFontSize(fontSize: number, persist = true): Promise {
+ return setFontSize(fontSize, persist);
+}
+
+export async function updatePrimaryColor(primaryColor: string, persist = true): Promise {
+ return setPrimaryColor(primaryColor, persist);
+}
+
+export async function updateMinimizeToTray(minimizeToTray: boolean, persist = true): Promise {
+ return setMinimizeToTray(minimizeToTray, persist);
+}
+
+export { i18n };
diff --git a/src-react/stores/task.ts b/src-react/stores/task.ts
new file mode 100644
index 0000000..2666b8b
--- /dev/null
+++ b/src-react/stores/task.ts
@@ -0,0 +1,367 @@
+import { useSyncExternalStore } from 'react';
+import type { RoomTypeMapping } from '@api/types';
+import type { SubTask, Task, TaskProgressPayload } from '@lib/task-types';
+import { CONFIG_KEYS, IPC_EVENTS } from '../lib/constants';
+import { invokeIpc, onIpc } from '../lib/host-api';
+
+export interface TaskStoreState {
+ initialized: boolean;
+ tasks: Task[];
+}
+
+type RoomTypeMappingLike = RoomTypeMapping & {
+ dyHotSpringName?: string;
+ [key: string]: any;
+};
+
+export type TaskOperationInput = {
+ taskId?: string;
+ roomType: string;
+ startTime: string;
+ endTime: string;
+ operation: 'open' | 'close';
+ roomList: RoomTypeMappingLike[];
+};
+
+type ExecuteTaskResult = {
+ success: boolean;
+ error?: string;
+ result?: unknown;
+};
+
+const listeners = new Set<() => void>();
+let eventSubscriptionsBound = false;
+let initPromise: Promise | null = null;
+let state: TaskStoreState = {
+ initialized: false,
+ tasks: [],
+};
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function patchState(patch: Partial): TaskStoreState {
+ state = { ...state, ...patch };
+ emit();
+ return state;
+}
+
+function deriveTaskStatus(task: Task): Task['status'] {
+ if (task.subTasks.every((subTask) => subTask.status === 'success')) return 'success';
+ if (task.subTasks.every((subTask) => subTask.status === 'failed')) return 'failed';
+ if (task.subTasks.some((subTask) => subTask.status === 'failed') && task.subTasks.some((subTask) => subTask.status === 'success')) {
+ return 'partial_failed';
+ }
+ if (task.subTasks.some((subTask) => subTask.status === 'running')) return 'running';
+ return 'pending';
+}
+
+function normalizeRoomType(item: RoomTypeMappingLike): RoomTypeMappingLike {
+ return {
+ ...item,
+ dyHotSpringName: item.dyHotSpringName ?? item.dyHotSrpingName ?? '',
+ dyHotSrpingName: item.dyHotSrpingName ?? item.dyHotSpringName ?? '',
+ };
+}
+
+function normalizeRoomList(roomList: RoomTypeMappingLike[]): RoomTypeMappingLike[] {
+ return Array.isArray(roomList) ? roomList.map((item) => normalizeRoomType(item ?? {})) : [];
+}
+
+function buildTaskSubTasks(taskId: string, roomType: RoomTypeMappingLike | undefined): SubTask[] {
+ const scriptMappings = [
+ { prop: 'fzName', scriptId: 'fg_trace.js', name: '飞猪房态追踪', hasValue: Boolean(roomType?.fzName) },
+ { prop: 'mtName', scriptId: 'mt_trace.js', name: '美团房态追踪', hasValue: Boolean(roomType?.mtName) },
+ { prop: 'dyHotelName', scriptId: 'dy_hotel_trace.js', name: '抖音酒店房态追踪', hasValue: Boolean(roomType?.dyHotelName) },
+ {
+ prop: 'dyHotSpringName',
+ scriptId: 'dy_hot_spring_trace.js',
+ name: '抖音温泉房态追踪',
+ hasValue: Boolean(roomType?.dyHotSpringName || roomType?.dyHotSrpingName),
+ },
+ ];
+
+ return scriptMappings
+ .filter((mapping) => mapping.hasValue)
+ .map((mapping) => ({
+ id: `${taskId}_${mapping.prop}`,
+ taskId,
+ scriptId: mapping.scriptId,
+ name: mapping.name,
+ status: 'pending' as const,
+ progress: 0,
+ message: '等待执行',
+ stdoutTail: '',
+ stderrTail: '',
+ startedAt: new Date().toISOString(),
+ }));
+}
+
+async function persistTasks(tasks: Task[]): Promise {
+ try {
+ await invokeIpc(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.TASK_LIST, tasks);
+ } catch {
+ // ignore persistence failures in UI store
+ }
+}
+
+function updateTaskCollection(updater: (tasks: Task[]) => Task[]): void {
+ const nextTasks = updater(state.tasks);
+ patchState({ tasks: nextTasks });
+ void persistTasks(nextTasks);
+}
+
+function markTaskFailed(taskId: string, error: string): void {
+ updateTaskCollection((tasks) => tasks.map((task) => {
+ if (task.id !== taskId) return task;
+
+ const nextSubTasks = task.subTasks.map((subTask) => {
+ if (subTask.status === 'success') return subTask;
+
+ return {
+ ...subTask,
+ status: 'failed' as const,
+ message: error,
+ error,
+ completedAt: new Date().toISOString(),
+ };
+ });
+
+ return {
+ ...task,
+ subTasks: nextSubTasks,
+ status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
+ updatedAt: new Date().toISOString(),
+ };
+ }));
+}
+
+function handleTaskProgress(payload: TaskProgressPayload & { taskId: string; subTaskId: string }): void {
+ updateTaskCollection((tasks) => tasks.map((task) => {
+ if (task.id !== payload.taskId) return task;
+
+ const nextSubTasks: SubTask[] = task.subTasks.map((subTask) => {
+ if (subTask.id !== payload.subTaskId) return subTask;
+
+ const nextStatus: SubTask['status'] = subTask.status === 'pending' ? 'running' : subTask.status;
+ return {
+ ...subTask,
+ status: nextStatus,
+ progress: payload.progress ?? subTask.progress,
+ message: payload.message ?? subTask.message,
+ stdoutTail: payload.stdoutTail ?? subTask.stdoutTail,
+ stderrTail: payload.stderrTail ?? subTask.stderrTail,
+ };
+ });
+
+ return {
+ ...task,
+ subTasks: nextSubTasks,
+ status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
+ updatedAt: new Date().toISOString(),
+ };
+ }));
+}
+
+function handleTaskCompleted(payload: { taskId: string; subTaskId: string; success: boolean; exitCode: number | null; error?: string }): void {
+ updateTaskCollection((tasks) => tasks.map((task) => {
+ if (task.id !== payload.taskId) return task;
+
+ const nextSubTasks: SubTask[] = task.subTasks.map((subTask) => {
+ if (subTask.id !== payload.subTaskId) return subTask;
+
+ const nextStatus: SubTask['status'] = payload.success ? 'success' : 'failed';
+ return {
+ ...subTask,
+ status: nextStatus,
+ progress: payload.success ? 100 : subTask.progress,
+ error: payload.error,
+ completedAt: new Date().toISOString(),
+ };
+ });
+
+ return {
+ ...task,
+ subTasks: nextSubTasks,
+ status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
+ updatedAt: new Date().toISOString(),
+ };
+ }));
+}
+
+async function loadTasks(): Promise {
+ try {
+ const savedTasks = await invokeIpc(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.TASK_LIST);
+ patchState({
+ initialized: true,
+ tasks: Array.isArray(savedTasks) ? savedTasks : [],
+ });
+ } catch {
+ patchState({
+ initialized: true,
+ tasks: [],
+ });
+ }
+}
+
+async function initTaskStore(): Promise {
+ if (!initPromise) {
+ initPromise = loadTasks();
+ }
+
+ if (!eventSubscriptionsBound) {
+ eventSubscriptionsBound = true;
+ onIpc(IPC_EVENTS.TASK_PROGRESS, handleTaskProgress as (...args: any[]) => void);
+ onIpc(IPC_EVENTS.TASK_STARTED, ((payload: { taskId: string; subTaskId: string }) => {
+ handleTaskProgress({
+ ...payload,
+ progress: 0,
+ message: '开始执行',
+ });
+ }) as (...args: any[]) => void);
+ onIpc(IPC_EVENTS.TASK_COMPLETED, handleTaskCompleted as (...args: any[]) => void);
+ }
+
+ await initPromise;
+}
+
+function createTask(options: TaskOperationInput): Task {
+ const taskId = options.taskId ?? crypto.randomUUID();
+ const roomList = normalizeRoomList(options.roomList);
+ const roomType = roomList.find((item) => item.id === options.roomType);
+ const subTasks = buildTaskSubTasks(taskId, roomType);
+
+ const task: Task = {
+ id: taskId,
+ title: `${options.operation === 'open' ? '开启' : '关闭'}渠道房型 - ${roomType?.pmsName || ''}`,
+ operation: options.operation,
+ roomType: options.roomType,
+ dateRange: [options.startTime, options.endTime],
+ status: 'pending',
+ subTasks,
+ roomList,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+
+ updateTaskCollection((tasks) => [task, ...tasks]);
+ return task;
+}
+
+async function executeTask(taskId: string): Promise {
+ const task = state.tasks.find((item) => item.id === taskId);
+ if (!task) {
+ return { success: false, error: '任务不存在,无法执行。' };
+ }
+
+ try {
+ const result = await invokeIpc(IPC_EVENTS.EXECUTE_SCRIPT, {
+ taskId: task.id,
+ roomType: task.roomType,
+ startTime: task.dateRange[0],
+ endTime: task.dateRange[1],
+ operation: task.operation,
+ roomList: normalizeRoomList(task.roomList as RoomTypeMappingLike[]),
+ });
+
+ if (!result?.success) {
+ markTaskFailed(task.id, result?.error || '任务执行失败');
+ }
+
+ return result;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ markTaskFailed(task.id, message);
+ return {
+ success: false,
+ error: message,
+ };
+ }
+}
+
+async function createAndExecuteTask(options: TaskOperationInput): Promise<{ task: Task; result: ExecuteTaskResult }> {
+ const task = createTask(options);
+ const result = await executeTask(task.id);
+ return { task, result };
+}
+
+async function retryFailedSubTasks(taskId: string): Promise {
+ const task = state.tasks.find((item) => item.id === taskId);
+ if (!task) {
+ return { success: false, error: '任务不存在,无法重试。' };
+ }
+
+ const hasFailedSubTask = task.subTasks.some((subTask) => subTask.status === 'failed');
+ if (!hasFailedSubTask) {
+ return { success: false, error: '当前任务没有可重试的失败子任务。' };
+ }
+
+ updateTaskCollection((tasks) => tasks.map((currentTask) => {
+ if (currentTask.id !== taskId) return currentTask;
+
+ const nextSubTasks = currentTask.subTasks.map((subTask) => {
+ if (subTask.status !== 'failed') return subTask;
+
+ return {
+ ...subTask,
+ status: 'pending' as const,
+ progress: 0,
+ message: '等待执行',
+ stdoutTail: '',
+ stderrTail: '',
+ error: undefined,
+ completedAt: undefined,
+ };
+ });
+
+ return {
+ ...currentTask,
+ subTasks: nextSubTasks,
+ status: 'pending',
+ updatedAt: new Date().toISOString(),
+ };
+ }));
+
+ return executeTask(taskId);
+}
+
+function removeTask(taskId: string): void {
+ updateTaskCollection((tasks) => tasks.filter((task) => task.id !== taskId));
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): TaskStoreState {
+ return state;
+}
+
+export const taskStore = {
+ subscribe,
+ getSnapshot,
+ getState: () => state,
+ init: initTaskStore,
+ load: loadTasks,
+ createTask,
+ createAndExecuteTask,
+ retryFailedSubTasks,
+ executeTask,
+ removeTask,
+};
+
+export function useTaskStore(): TaskStoreState {
+ return useSyncExternalStore(taskStore.subscribe, taskStore.getSnapshot, taskStore.getSnapshot);
+}
+
+export function getPendingTasks(tasks = state.tasks): Task[] {
+ return tasks.filter((task) => task.status === 'pending' || task.status === 'running');
+}
+
+export function getCompletedTasks(tasks = state.tasks): Task[] {
+ return tasks.filter((task) => task.status === 'success' || task.status === 'failed' || task.status === 'partial_failed');
+}
diff --git a/src-react/styles.css b/src-react/styles.css
new file mode 100644
index 0000000..c36526e
--- /dev/null
+++ b/src-react/styles.css
@@ -0,0 +1,27 @@
+html,
+body,
+#app {
+ margin: 0;
+ min-height: 100%;
+}
+
+body {
+ font-family:
+ "SF Pro Text",
+ "PingFang SC",
+ "Helvetica Neue",
+ Arial,
+ sans-serif;
+ background: #020617;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
diff --git a/src-react/types/index.ts b/src-react/types/index.ts
new file mode 100644
index 0000000..2bc3e79
--- /dev/null
+++ b/src-react/types/index.ts
@@ -0,0 +1 @@
+export * from './runtime';
diff --git a/src-react/types/runtime.ts b/src-react/types/runtime.ts
new file mode 100644
index 0000000..4b8778e
--- /dev/null
+++ b/src-react/types/runtime.ts
@@ -0,0 +1,104 @@
+import type { Task } from '@lib/task-types';
+import type { RawMessage } from '@shared/chat-model';
+
+export type ThemeMode = 'light' | 'dark' | 'system';
+
+export type ResolvedThemeMode = 'light' | 'dark';
+
+export type LanguageCode = 'en' | 'zh' | 'ja';
+
+export type RuntimePlatform = 'win32' | 'darwin' | 'linux' | 'web' | 'unknown';
+
+export type WindowName = 'main' | 'setting' | 'dialog' | 'loading';
+
+export type IpcArgs = readonly unknown[];
+
+export type IpcListener = (...args: unknown[]) => void;
+
+export const CONFIG_KEYS = {
+ THEME_MODE: 'themeMode',
+ PRIMARY_COLOR: 'primaryColor',
+ LANGUAGE: 'language',
+ FONT_SIZE: 'fontSize',
+ MINIMIZE_TO_TRAY: 'minimizeToTray',
+ PROVIDER: 'provider',
+ DEFAULT_MODEL: 'defaultModel',
+ SELECTED_CHANNELS: 'selectedChannels',
+ IMAGE_CACHE: 'imageCache',
+ TASK_LIST: 'taskList',
+} as const;
+
+export type ConfigKey = (typeof CONFIG_KEYS)[keyof typeof CONFIG_KEYS];
+
+export type ConfigValueKey = keyof ConfigValueMap;
+
+export interface ConfigValueMap {
+ themeMode: ThemeMode;
+ primaryColor: string;
+ language: LanguageCode;
+ fontSize: number;
+ minimizeToTray: boolean;
+ provider: string | null;
+ defaultModel: string | null;
+ selectedChannels: Array<{ id: string; channelName: string; channelUrl: string }>;
+ imageCache: Array<[string, unknown]>;
+ taskList: Task[];
+}
+
+export interface WindowApiBridge {
+ versions?: Record;
+ platform?: string;
+ invoke?(channel: string, ...args: IpcArgs): Promise;
+ invokeAsync?(channel: string, ...args: IpcArgs): Promise;
+ on?(channel: string, callback: IpcListener): () => void;
+ send?(channel: string, ...args: IpcArgs): void;
+ getCurrentWindowId?(): string | number;
+}
+
+export interface WindowIdentity {
+ platform: RuntimePlatform;
+ windowId: string | number | null;
+ windowName: WindowName;
+ isElectron: boolean;
+}
+
+export interface HostApiResult {
+ success?: boolean;
+ ok?: boolean;
+ json?: T;
+ data?: T | { json?: T };
+ text?: string;
+ error?: string;
+ status?: number;
+}
+
+export type GatewayEvent =
+ | {
+ type: 'chat:delta';
+ sessionKey: string;
+ runId: string;
+ delta: string;
+ }
+ | {
+ type: 'chat:final';
+ sessionKey: string;
+ runId: string;
+ message: RawMessage;
+ }
+ | {
+ type: 'chat:error';
+ sessionKey: string;
+ runId: string;
+ error: string;
+ }
+ | {
+ type: 'chat:aborted';
+ sessionKey: string;
+ runId: string;
+ }
+ | {
+ type: 'gateway:status';
+ status: 'connected' | 'disconnected' | 'reconnecting';
+ };
+
+export {};
diff --git a/src/framework.ts b/src/framework.ts
new file mode 100644
index 0000000..b10a254
--- /dev/null
+++ b/src/framework.ts
@@ -0,0 +1,24 @@
+export type UiFramework = 'vue' | 'react';
+
+export const DEFAULT_UI_FRAMEWORK: UiFramework = 'react';
+
+export function resolveUiFramework(): UiFramework {
+ const rawValue = String(import.meta.env.VITE_UI_FRAMEWORK ?? DEFAULT_UI_FRAMEWORK)
+ .trim()
+ .toLowerCase();
+
+ return rawValue === 'vue' ? 'vue' : 'react';
+}
+
+export async function mountUiApp(): Promise {
+ const framework = resolveUiFramework();
+
+ if (framework === 'vue') {
+ const { mountVueApp } = await import('./main-vue');
+ mountVueApp();
+ return;
+ }
+
+ const { mountReactApp } = await import('../src-react/main');
+ mountReactApp();
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 9df5037..2f389ac 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,3 +1,4 @@
+import type { Task } from './task-types';
import { WINDOW_NAMES, CONFIG_KEYS } from './constants';
export type WindowNames = `${WINDOW_NAMES}`;
@@ -18,10 +19,16 @@ export interface IConfig {
[CONFIG_KEYS.PROVIDER]?: string;
// 默认模型
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
+ // 自动检查更新
+ [CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean;
+ // 自动下载更新
+ [CONFIG_KEYS.AUTO_DOWNLOAD_UPDATE]?: boolean;
// 选中的渠道
[CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
// 图片缓存
[CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>;
+ // 任务列表
+ [CONFIG_KEYS.TASK_LIST]?: Task[];
}
export interface Provider {
@@ -39,4 +46,4 @@ export interface Provider {
export interface OpenAISetting {
baseURL?: string;
apiKey?: string;
-}
\ No newline at end of file
+}
diff --git a/src/main-vue.ts b/src/main-vue.ts
new file mode 100644
index 0000000..2ee47e5
--- /dev/null
+++ b/src/main-vue.ts
@@ -0,0 +1,35 @@
+import { createApp, type Plugin } from "vue";
+import { createPinia } from "pinia";
+import errorHandler from "@utils/errorHandler";
+import router from "./router";
+import App from "./App.vue";
+
+import ElementPlus from "element-plus";
+import locale from "element-plus/es/locale/lang/zh-cn";
+
+import i18n from "./i18n";
+import "./permission";
+
+import "./styles/index.css";
+import "element-plus/dist/index.css";
+import "element-plus/theme-chalk/dark/css-vars.css";
+
+import Layout from "@components/Layout/index.vue";
+
+const components: Plugin = (app) => {
+ app.component("Layout", Layout);
+};
+
+export function mountVueApp(): void {
+ const app = createApp(App);
+ const pinia = createPinia();
+
+ app.use(pinia);
+ app.use(router);
+ app.use(ElementPlus, { locale });
+ app.use(components);
+ app.use(i18n);
+ app.use(errorHandler);
+
+ app.mount("#app");
+}
diff --git a/src/main.ts b/src/main.ts
index 5c9c6e7..da2fd2f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,40 +1,5 @@
-import { createApp, type Plugin } from "vue"
-import { createPinia } from "pinia"
-import errorHandler from "@utils/errorHandler"
-import router from "./router"
-import App from "./App.vue"
+import { mountUiApp } from './framework';
-// 引入 Element Plus 组件库
-import ElementPlus from 'element-plus'
-import locale from 'element-plus/es/locale/lang/zh-cn'
-
-// 引入 i18n 插件
-import i18n from './i18n'
-import './permission'
-
-// 样式文件隔离
-import "./styles/index.css";
-import 'element-plus/dist/index.css'
-import 'element-plus/theme-chalk/dark/css-vars.css'
-
-// 引入全局组件
-import Layout from '@components/Layout/index.vue'
-
-const components: Plugin = (app) => {
- app.component('Layout', Layout);
-}
-
-// 创建 Vue 应用实例
-const app = createApp(App);
-const pinia = createPinia();
-
-// 使用 Pinia 状态管理
-app.use(pinia);
-app.use(router);
-app.use(ElementPlus, { locale })
-app.use(components)
-app.use(i18n)
-app.use(errorHandler)
-
-// 挂载应用到 DOM
-app.mount("#app");
+void mountUiApp().catch((error) => {
+ console.error('Failed to bootstrap UI framework', error);
+});
diff --git a/src/pages/home/model/ChatModel.ts b/src/pages/home/model/ChatModel.ts
index c03fae5..80fbe2c 100644
--- a/src/pages/home/model/ChatModel.ts
+++ b/src/pages/home/model/ChatModel.ts
@@ -1,167 +1 @@
-/// 附件文件元数据(与 ClawX 对齐)
-export interface AttachedFileMeta {
- fileName: string;
- mimeType: string;
- fileSize: number;
- preview: string | null;
- filePath?: string;
- source?: 'user-upload' | 'tool-result' | 'message-ref';
-}
-
-/// 内容块(与 ClawX 对齐,用于未来扩展结构化消息)
-export interface ContentBlock {
- type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
- text?: string;
- thinking?: string;
- source?: { type: string; media_type?: string; data?: string; url?: string };
- data?: string;
- mimeType?: string;
- id?: string;
- name?: string;
- input?: unknown;
- arguments?: unknown;
- content?: unknown;
-}
-
-/// 原始消息(与 ClawX RawMessage 对齐)
-export interface RawMessage {
- role: 'user' | 'assistant' | 'system' | 'toolresult';
- content: string | ContentBlock[];
- timestamp?: number;
- id?: string;
- toolCallId?: string;
- toolName?: string;
- details?: unknown;
- isError?: boolean;
- /** zn-ai 特有:问题标签(保留现有能力) */
- question?: string[];
- /** zn-ai 特有:工具调用结果(保留现有能力) */
- toolCall?: any;
- /** 本地-only:附件 */
- _attachedFiles?: AttachedFileMeta[];
-}
-
-/// 工具状态(与 ClawX 对齐)
-export interface ToolStatus {
- id?: string;
- toolCallId?: string;
- name: string;
- status: 'running' | 'completed' | 'error';
- durationMs?: number;
- summary?: string;
- updatedAt: number;
-}
-
-/// 会话(与 ClawX ChatSession 对齐)
-export interface ChatSession {
- key: string;
- label?: string;
- displayName?: string;
- thinkingLevel?: string;
- model?: string;
- updatedAt?: number;
-}
-
-/// 流式消息辅助:从 RawMessage 提取纯文本
-export function extractText(message?: RawMessage | null): string {
- if (!message) return '';
- const content = message.content;
- if (typeof content === 'string') return content;
- if (Array.isArray(content)) {
- return (content as Array<{ type?: string; text?: string }>)
- .filter((b) => b.type === 'text' && b.text)
- .map((b) => b.text!)
- .join('\n');
- }
- return '';
-}
-
-/// 流式消息辅助:从 RawMessage 提取 thinking 文本
-export function extractThinking(message?: RawMessage | null): string | null {
- if (!message) return null;
- const content = message.content;
- if (Array.isArray(content)) {
- const block = content.find((b: any) => b.type === 'thinking');
- return block?.thinking || null;
- }
- return null;
-}
-
-/// 流式消息辅助:从 RawMessage 提取图片
-export function extractImages(message?: RawMessage | null): Array<{ url?: string; data?: string; mimeType: string }> {
- if (!message) return [];
- const content = message.content;
- if (!Array.isArray(content)) return [];
- const images: Array<{ url?: string; data?: string; mimeType: string }> = [];
- for (const block of content as ContentBlock[]) {
- if (block.type === 'image') {
- if (block.source) {
- const src = block.source;
- if (src.type === 'base64' && src.data) {
- images.push({ data: src.data, mimeType: src.media_type || 'image/jpeg' });
- } else if (src.type === 'url' && src.url) {
- images.push({ url: src.url, mimeType: src.media_type || 'image/jpeg' });
- }
- } else if (block.data) {
- images.push({ data: block.data, mimeType: block.mimeType || 'image/jpeg' });
- }
- }
- if ((block.type === 'tool_result' || block.type === 'toolResult') && block.content) {
- images.push(...extractImages({ role: 'toolresult', content: block.content }));
- }
- }
- return images;
-}
-
-/// 流式消息辅助:从 RawMessage 提取 tool_use
-export function extractToolUse(message?: RawMessage | null): Array<{ id?: string; name: string; input?: unknown }> {
- if (!message) return [];
- const content = message.content;
- if (!Array.isArray(content)) return [];
- return (content as ContentBlock[])
- .filter((b) => b.type === 'tool_use' || b.type === 'toolCall')
- .map((b) => ({ id: b.id, name: b.name || b.id || 'tool', input: b.input ?? b.arguments }));
-}
-
-/// 格式化时间戳(秒/ms 兼容)
-export function formatTimestamp(ts?: number): string {
- if (!ts) return '';
- const ms = ts < 1e12 ? ts * 1000 : ts;
- return new Date(ms).toLocaleString();
-}
-
-/// 判断是否为 tool-only 消息
-export function isToolOnlyMessage(message?: RawMessage): boolean {
- if (!message) return false;
- const role = message.role;
- if (role === 'toolresult' || role === 'tool_result') return true;
- const content = message.content;
- if (Array.isArray(content)) {
- const hasTool = content.some((b: any) =>
- ['tool_use', 'tool_result', 'toolCall', 'toolResult'].includes(b.type)
- );
- const hasText = content.some(
- (b: any) => b.type === 'text' && b.text?.trim?.()
- );
- const hasImage = content.some((b: any) => b.type === 'image');
- return hasTool && !hasText && !hasImage;
- }
- return false;
-}
-
-/// 判断是否为 tool result 角色
-export function isToolResultRole(role?: string): boolean {
- if (!role) return false;
- const normalized = role.toLowerCase();
- return normalized === 'toolresult' || normalized === 'tool_result';
-}
-
-/// 判断是否为内部消息(不应展示在 UI)
-export function isInternalMessage(msg: { role?: string; content?: unknown }): boolean {
- if (msg.role === 'system') return true;
- if (msg.role === 'assistant') {
- const text = typeof msg.content === 'string' ? msg.content : extractText(msg as RawMessage);
- if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) return true;
- }
- return false;
-}
+export * from '@shared/chat-model';
diff --git a/src/shared/chat-model.ts b/src/shared/chat-model.ts
new file mode 100644
index 0000000..9f60973
--- /dev/null
+++ b/src/shared/chat-model.ts
@@ -0,0 +1,173 @@
+export interface AttachedFileMeta {
+ fileName: string;
+ mimeType: string;
+ fileSize: number;
+ preview: string | null;
+ filePath?: string;
+ source?: 'user-upload' | 'tool-result' | 'message-ref';
+}
+
+export interface ContentBlockSource {
+ type: string;
+ media_type?: string;
+ data?: string;
+ url?: string;
+}
+
+export interface ContentBlock {
+ type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
+ text?: string;
+ thinking?: string;
+ source?: ContentBlockSource;
+ data?: string;
+ mimeType?: string;
+ id?: string;
+ name?: string;
+ input?: unknown;
+ arguments?: unknown;
+ content?: string | ContentBlock[];
+}
+
+export type RawMessageRole = 'user' | 'assistant' | 'system' | 'toolresult' | 'tool_result';
+
+export interface RawMessage {
+ role: RawMessageRole;
+ content: string | ContentBlock[];
+ timestamp?: number;
+ id?: string;
+ toolCallId?: string;
+ toolName?: string;
+ details?: unknown;
+ isError?: boolean;
+ question?: string[];
+ toolCall?: Record | null;
+ _attachedFiles?: AttachedFileMeta[];
+}
+
+export interface ToolStatus {
+ id?: string;
+ toolCallId?: string;
+ name: string;
+ status: 'running' | 'completed' | 'error';
+ durationMs?: number;
+ summary?: string;
+ updatedAt: number;
+}
+
+export interface ChatSession {
+ key: string;
+ label?: string;
+ displayName?: string;
+ thinkingLevel?: string;
+ model?: string;
+ updatedAt?: number;
+}
+
+export function extractText(message?: RawMessage | null): string {
+ if (!message) return '';
+
+ if (typeof message.content === 'string') {
+ return message.content;
+ }
+
+ return message.content
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
+ .map((block) => block.text ?? '')
+ .join('\n');
+}
+
+export function extractThinking(message?: RawMessage | null): string | null {
+ if (!message || !Array.isArray(message.content)) return null;
+
+ const thinkingBlock = message.content.find((block) => block.type === 'thinking');
+ return thinkingBlock?.thinking ?? null;
+}
+
+export function extractImages(message?: RawMessage | null): Array<{ url?: string; data?: string; mimeType: string }> {
+ if (!message || !Array.isArray(message.content)) return [];
+
+ const images: Array<{ url?: string; data?: string; mimeType: string }> = [];
+
+ for (const block of message.content) {
+ if (block.type === 'image') {
+ if (block.source?.type === 'base64' && block.source.data) {
+ images.push({
+ data: block.source.data,
+ mimeType: block.source.media_type || 'image/jpeg',
+ });
+ } else if (block.source?.type === 'url' && block.source.url) {
+ images.push({
+ url: block.source.url,
+ mimeType: block.source.media_type || 'image/jpeg',
+ });
+ } else if (block.data) {
+ images.push({
+ data: block.data,
+ mimeType: block.mimeType || 'image/jpeg',
+ });
+ }
+ }
+
+ if ((block.type === 'tool_result' || block.type === 'toolResult') && Array.isArray(block.content)) {
+ images.push(...extractImages({ role: 'toolresult', content: block.content }));
+ }
+ }
+
+ return images;
+}
+
+export function extractToolUse(message?: RawMessage | null): Array<{ id?: string; name: string; input?: unknown }> {
+ if (!message || !Array.isArray(message.content)) return [];
+
+ return message.content
+ .filter((block) => block.type === 'tool_use' || block.type === 'toolCall')
+ .map((block) => ({
+ id: block.id,
+ name: block.name || block.id || 'tool',
+ input: block.input ?? block.arguments,
+ }));
+}
+
+export function formatTimestamp(ts?: number): string {
+ if (!ts) return '';
+
+ const ms = ts < 1e12 ? ts * 1000 : ts;
+ return new Date(ms).toLocaleString();
+}
+
+export function isToolResultRole(role?: string): boolean {
+ if (!role) return false;
+
+ const normalized = role.toLowerCase();
+ return normalized === 'toolresult' || normalized === 'tool_result';
+}
+
+export function isToolOnlyMessage(message?: RawMessage): boolean {
+ if (!message) return false;
+ if (isToolResultRole(message.role)) return true;
+ if (!Array.isArray(message.content)) return false;
+
+ const hasToolBlock = message.content.some((block) =>
+ ['tool_use', 'tool_result', 'toolCall', 'toolResult'].includes(block.type),
+ );
+ const hasTextBlock = message.content.some((block) => block.type === 'text' && block.text?.trim());
+ const hasImageBlock = message.content.some((block) => block.type === 'image');
+
+ return hasToolBlock && !hasTextBlock && !hasImageBlock;
+}
+
+export function isInternalMessage(message: { role?: string; content?: unknown }): boolean {
+ if (message.role === 'system') return true;
+
+ if (message.role === 'assistant') {
+ const text = typeof message.content === 'string'
+ ? message.content
+ : extractText(message as RawMessage);
+
+ if (/^(HEARTBEAT_OK|NO_REPLY)\s*$/.test(text)) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/stores/chat.ts b/src/stores/chat.ts
index de77607..ee4078b 100644
--- a/src/stores/chat.ts
+++ b/src/stores/chat.ts
@@ -4,8 +4,8 @@ import type {
AttachedFileMeta,
ToolStatus,
ChatSession,
-} from '@src/pages/home/model/ChatModel'
-import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } from '@src/pages/home/model/ChatModel'
+} from '@shared/chat-model'
+import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } from '@shared/chat-model'
import { hostApiFetch } from '@lib/host-api'
import { gatewayRpc, onGatewayEvent } from '@lib/gateway-client'
import { useProviderStore } from '@stores/providers'
diff --git a/tsconfig.app.json b/tsconfig.app.json
index af73659..a9e82b5 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -27,6 +27,8 @@
"baseUrl": "./",
"paths": {
"@src/*": ["src/*"],
+ "@src-react": ["src-react"],
+ "@src-react/*": ["src-react/*"],
"@api/*": ["src/api/*"],
"@shared/*": ["src/shared/*"],
"@stores/*": ["src/stores/*"],
@@ -39,13 +41,16 @@
"@hooks/*": ["src/hooks/*"],
"@components/*": ["src/components/*"],
},
- "types": []
+ "types": ["vite/client", "node", "react", "react-dom"]
},
"include": [
"forge.env.d.ts",
"src/**/*.vue",
+ "src-react/**/*.ts",
+ "src-react/**/*.tsx",
"**/*.d.ts",
"**/*.ts",
+ "**/*.tsx",
"**/*.js",
"src/permission.ts",
"electron/scripts/*.js"
diff --git a/tsconfig.json b/tsconfig.json
index 791dead..33afdff 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,8 @@
"baseUrl": "./",
"paths": {
"@src/*": ["src/*"],
+ "@src-react": ["src-react"],
+ "@src-react/*": ["src-react/*"],
"@api/*": ["src/api/*"],
"@shared/*": ["src/shared/*"],
"@stores/*": ["src/stores/*"],
diff --git a/vite.config.ts b/vite.config.ts
index fc18bc6..1ac9925 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,6 +3,7 @@ import { resolve } from 'path';
import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import vue from '@vitejs/plugin-vue';
+import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import autoImport from 'unplugin-auto-import/vite';
import electronBytecode from './plugins/bytenode/vite-plugin-electron-encrypt';
@@ -33,6 +34,8 @@ function isMainProcessExternal(id: string): boolean {
export default defineConfig(({ mode, command }) => {
// Determine if we're in development mode
const isDev = mode === 'development';
+ const uiFramework = process.env.VITE_UI_FRAMEWORK ?? 'react';
+ const isVueUI = uiFramework === 'vue';
// Development server URL - in dev mode, this will be the Vite dev server URL
// In production, this should be undefined
@@ -49,11 +52,11 @@ export default defineConfig(({ mode, command }) => {
plugins: [
vue(),
- tailwindcss(),
- autoImport({
+ ...(isVueUI ? [autoImport({
imports: ['vue', 'vue-router', 'pinia', 'vue-i18n', '@vueuse/core'],
dts: 'src/auto-imports.d.ts'
- }),
+ })] : [react()]),
+ tailwindcss(),
electron([
{
// Main process entry file
@@ -77,16 +80,6 @@ export default defineConfig(({ mode, command }) => {
exclude: ['**/electron/scripts/**', '**/scripts.meta.json'],
},
},
- resolve: {
- alias: {
- "@electron": resolve(__dirname, "./electron"),
- "@lib": resolve(__dirname, "./src/lib"),
- "@src": resolve(__dirname, "./src"),
- "@locales": resolve(__dirname, "./src/i18n/locales"),
- "@service": resolve(__dirname, "./electron/service"),
- "@utils": resolve(__dirname, "./electron/utils"),
- },
- },
},
},
{
@@ -129,6 +122,7 @@ export default defineConfig(({ mode, command }) => {
resolve: {
alias: {
"@src": resolve(__dirname, "./src"),
+ "@src-react": resolve(__dirname, "./src-react"),
"@api": resolve(__dirname, "./src/api"),
"@assets": resolve(__dirname, "./src/assets"),
"@lib": resolve(__dirname, "./src/lib"),
@@ -162,4 +156,4 @@ export default defineConfig(({ mode, command }) => {
emptyOutDir: true,
},
}
-});
\ No newline at end of file
+});