feat: add gateway management features and settings
- Implemented new API routes for handling logs and settings related to the gateway. - Added a new Gateway section in the General Settings panel to manage gateway status and auto-start options. - Introduced a state management hook for gateway settings, including status, logs, and auto-start functionality. - Updated configuration service to include gateway auto-start setting. - Enhanced internationalization support for new gateway-related messages. - Refactored existing settings store to accommodate new gateway settings. - Cleaned up code and improved logging functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
require("electron");
|
||||
require("./main-Bc3HYL6W.js");
|
||||
require("./main-Cjv0_4P-.js");
|
||||
require("electron-squirrel-startup");
|
||||
require("electron-log");
|
||||
require("bytenode");
|
||||
|
||||
433
docs/Gateway-Configuration-Migration-Plan.md
Normal file
433
docs/Gateway-Configuration-Migration-Plan.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Gateway 配置迁移计划
|
||||
|
||||
## 1. 目标与结论
|
||||
|
||||
目标是在 `zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` 中新增“网关配置”能力,并尽量复用 `ClawX` 已验证过的交互与主进程职责拆分。
|
||||
|
||||
先给结论:
|
||||
|
||||
- `ClawX` 的网关设置不是单纯一段 UI,而是“设置持久化 + Gateway 生命周期管理 + Host API/IPC + 日志能力 + 调试能力”的完整链路。
|
||||
- `zn-ai` 当前已经具备一部分基础能力:`/api/gateway/status|health|start|stop|restart`、`gateway:rpc`、`gateway:event`、`config-service`、`theme-service`、`updater`。
|
||||
- `zn-ai` 当前缺少的是“网关设置模型”和“日志/文件夹打开/开发者辅助能力”,因此不能直接把 `ClawX` 的设置页 JSX 拷过来,必须先补设置域和主进程桥接。
|
||||
- 推荐采用“两阶段迁移”:
|
||||
- Phase 1:先落地面向普通用户的网关设置能力。
|
||||
- Phase 2:再补开发者向的代理、令牌、诊断能力。
|
||||
|
||||
## 2. ClawX 功能盘点
|
||||
|
||||
### 2.1 已落地的网关设置功能
|
||||
|
||||
`ClawX/src/pages/Settings/index.tsx` 中的网关设置实际包含以下能力:
|
||||
|
||||
1. 公共网关区块
|
||||
- 状态徽标
|
||||
- 端口展示
|
||||
- 重启按钮
|
||||
- 日志按钮
|
||||
- 自动启动网关开关
|
||||
|
||||
2. 开发者扩展区块
|
||||
- 代理总开关
|
||||
- 代理字段草稿编辑与一次性保存
|
||||
- Control UI token 拉取与复制
|
||||
- WS 诊断开关
|
||||
- 额外的 CLI / Doctor / Telemetry Viewer
|
||||
|
||||
### 2.2 交互特征
|
||||
|
||||
- 状态展示依赖 `useGatewayStore`,不是页面自己轮询。
|
||||
- `gatewayAutoStart` 这类简单开关是“切换即持久化”。
|
||||
- 代理配置是“本地 draft -> dirty 检测 -> save 一次性提交”。
|
||||
- 日志是“按需读取最近 N 行”,不是预加载。
|
||||
- Control UI token 是“点击加载后显示”,不是默认常驻拉取。
|
||||
- 开发者能力全部在 `devModeUnlocked` 后显示,普通用户默认不暴露。
|
||||
|
||||
### 2.3 主进程职责
|
||||
|
||||
`ClawX` 主进程里与网关设置直接相关的职责有:
|
||||
|
||||
- `settings` store:持久化 `gatewayAutoStart`、代理、token、devModeUnlocked、telemetryEnabled`
|
||||
- `gatewayManager`:网关状态、启停、重启、健康检查、事件广播
|
||||
- `settings` routes / IPC:通用设置读写
|
||||
- `gateway` routes / IPC:生命周期与 Control UI 信息
|
||||
- `logs` routes / IPC:日志读取、日志目录打开
|
||||
- `launch-at-startup`:OS 级开机启动
|
||||
- `proxy`:Electron 代理应用与 Gateway 重启联动
|
||||
|
||||
## 3. zn-ai 当前现状
|
||||
|
||||
### 3.1 已有能力
|
||||
|
||||
`zn-ai` 目前已经有这些可复用基础:
|
||||
|
||||
- 设置页容器:`src/pages/Setting/index.tsx`
|
||||
- 通用设置面板:`src/pages/Setting/components/GeneralSettingsPanel.tsx`
|
||||
- 全局设置 store:`src/stores/settings.ts`
|
||||
- 配置持久化:`electron/service/config-service/index.ts`
|
||||
- 主题服务:`electron/service/theme-service/index.ts`
|
||||
- 更新服务:`src/pages/Setting/useSettingUpdateState.ts`
|
||||
- Gateway Host API:
|
||||
- `GET /api/gateway/status`
|
||||
- `GET /api/gateway/health`
|
||||
- `POST /api/gateway/start`
|
||||
- `POST /api/gateway/stop`
|
||||
- `POST /api/gateway/restart`
|
||||
- Gateway IPC:
|
||||
- `gateway:rpc`
|
||||
- `gateway:event`
|
||||
|
||||
### 3.2 关键差异
|
||||
|
||||
和 `ClawX` 相比,`zn-ai` 目前存在这些差异:
|
||||
|
||||
1. `zn-ai` 的 `GeneralSettingsPanel` 还是纯展示组件,没有 gateway props。
|
||||
2. `src/stores/settings.ts` 只管理主题/语言/托盘/主色等字段,没有网关设置字段。
|
||||
3. `src/types/runtime.ts` 和 `ConfigValueMap` 没有网关相关 key。
|
||||
4. `config-service` 默认配置里没有网关设置默认值。
|
||||
5. `electron/api/router.ts` 没有 `/api/settings` 与 `/api/logs` 这类本地设置/日志路由。
|
||||
6. `electron/service/logger/index.ts` 负责写日志,但没有对 renderer 暴露“读取最近日志 / 获取日志目录”的能力。
|
||||
7. `electron/preload/index.ts` 目前也没有 `showItemInFolder` 之类 shell 能力。
|
||||
8. `zn-ai/electron/gateway/manager.ts` 当前更像 in-process bridge,状态维度只有 `connected|disconnected|reconnecting`,没有 `ClawX` 那种 `running|starting|stopped|error + port + pid` 的完整生命周期视图。
|
||||
|
||||
### 3.3 迁移判断
|
||||
|
||||
因此这次迁移不能按“页面复制”推进,而应该按下面的结构推进:
|
||||
|
||||
- 先补 `zn-ai` 的网关设置模型
|
||||
- 再补主进程读写与日志能力
|
||||
- 最后在 `GeneralSettingsPanel` 挂 UI
|
||||
|
||||
## 4. 推荐迁移范围
|
||||
|
||||
### 4.1 Phase 1:建议优先迁移
|
||||
|
||||
这些功能优先级最高,且对 `GeneralSettingsPanel` 最有价值:
|
||||
|
||||
1. 网关状态展示
|
||||
2. 重启按钮
|
||||
3. 查看日志
|
||||
4. 自动启动网关开关
|
||||
5. 基础日志目录打开能力
|
||||
|
||||
原因:
|
||||
|
||||
- 这几项直接对应用户最常见的“网关是否可用、出问题怎么恢复、去哪看日志”。
|
||||
- 它们对主进程改造要求相对集中。
|
||||
- 能在不引入大量开发者专属复杂度的前提下快速产出。
|
||||
|
||||
### 4.2 Phase 2:建议作为增强能力
|
||||
|
||||
这些功能建议第二阶段进入:
|
||||
|
||||
1. 代理配置
|
||||
2. Control UI token 拉取与复制
|
||||
3. WS 诊断开关
|
||||
4. 开发者模式开关
|
||||
5. 匿名使用数据开关
|
||||
|
||||
原因:
|
||||
|
||||
- 代理配置需要联动 Electron session、Gateway 重启,风险高于普通开关。
|
||||
- token / WS 诊断更偏开发与排障。
|
||||
- `zn-ai` 还没有 `ClawX` 同等级别的 Host API / preload / shell 白名单体系,直接硬迁会增加耦合。
|
||||
|
||||
### 4.3 Phase 3:建议暂不迁移
|
||||
|
||||
以下内容不建议一开始放入 `GeneralSettingsPanel`:
|
||||
|
||||
1. OpenClaw CLI 命令展示
|
||||
2. OpenClaw Doctor
|
||||
3. Telemetry Viewer
|
||||
|
||||
原因:
|
||||
|
||||
- 它们和 `zn-ai` 当前产品主线关联较弱。
|
||||
- 实现成本明显高于用户价值。
|
||||
- 会把通用设置页拉成调试控制台,不利于信息密度控制。
|
||||
|
||||
## 5. 功能映射方案
|
||||
|
||||
### 5.1 UI 映射
|
||||
|
||||
建议在 `GeneralSettingsPanel` 的“语言”与“版本更新”之间新增一个 `Gateway` section。
|
||||
|
||||
建议结构:
|
||||
|
||||
1. 网关状态卡片
|
||||
- 状态文案
|
||||
- 当前模式/状态
|
||||
- 可选端口展示
|
||||
- `重启` 按钮
|
||||
- `日志` 按钮
|
||||
|
||||
2. 网关自动启动开关
|
||||
- 标题
|
||||
- 描述
|
||||
- Toggle
|
||||
|
||||
3. 可选高级区
|
||||
- 仅在后续需要时加入
|
||||
- 代理配置、token、诊断开关
|
||||
|
||||
### 5.2 状态层映射
|
||||
|
||||
推荐新增一个独立 hook,而不是把所有逻辑塞进 `GeneralSettingsPanel`:
|
||||
|
||||
- `src/pages/Setting/useGatewaySettingState.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 读取 `gatewayAutoStart`
|
||||
- 拉取 `/api/gateway/status`
|
||||
- 执行 `restartGateway()`
|
||||
- 读取日志文本
|
||||
- 打开日志目录
|
||||
- 管理本地 loading / error / lastFetchedAt
|
||||
|
||||
理由:
|
||||
|
||||
- 与现有 `useSettingUpdateState.ts` 保持模式一致
|
||||
- 避免 `GeneralSettingsPanel` 过度膨胀
|
||||
- 后续接入代理草稿状态更自然
|
||||
|
||||
### 5.3 配置模型映射
|
||||
|
||||
建议在 `src/types/runtime.ts` 和 `runtime-shared/lib/constants.ts` 统一补齐这些 key:
|
||||
|
||||
- `gatewayAutoStart`
|
||||
- `gatewayProxyEnabled`
|
||||
- `gatewayProxyServer`
|
||||
- `gatewayProxyHttpServer`
|
||||
- `gatewayProxyHttpsServer`
|
||||
- `gatewayProxyAllServer`
|
||||
- `gatewayProxyBypassRules`
|
||||
- `devModeUnlocked`
|
||||
- `telemetryEnabled`
|
||||
|
||||
注意:
|
||||
|
||||
- 先只实现 `gatewayAutoStart` 也可以,但类型层最好一次补齐未来要用的 key,避免下一轮再改类型。
|
||||
- 若担心一次性改动过大,也可以只先补 `gatewayAutoStart`,其余在 Phase 2 再补。
|
||||
|
||||
## 6. 主进程改造方案
|
||||
|
||||
### 6.1 配置持久化
|
||||
|
||||
需要修改:
|
||||
|
||||
- `electron/service/config-service/index.ts`
|
||||
- `src/types/runtime.ts`
|
||||
- `runtime-shared/lib/constants.ts`
|
||||
|
||||
目标:
|
||||
|
||||
- 给网关配置提供默认值
|
||||
- 让 renderer 和 main 对同一组 key 有一致类型
|
||||
|
||||
### 6.2 本地设置路由
|
||||
|
||||
建议新增:
|
||||
|
||||
- `electron/api/routes/settings.ts`
|
||||
|
||||
并在 `electron/api/router.ts` 注册。
|
||||
|
||||
职责:
|
||||
|
||||
- `GET /api/settings`
|
||||
- `PUT /api/settings`
|
||||
- `GET /api/settings/:key`
|
||||
- `PUT /api/settings/:key`
|
||||
|
||||
理由:
|
||||
|
||||
- 这样 `zn-ai` 可以和 `ClawX` 对齐成 “Host API 管理设置 + renderer store 初始化” 的模式。
|
||||
- 后续代理设置和开发者开关也可以挂在这条线,不必每个设置都手写 IPC。
|
||||
|
||||
### 6.3 日志路由与 shell 能力
|
||||
|
||||
建议新增:
|
||||
|
||||
- `electron/api/routes/logs.ts`
|
||||
- `showItemInFolder` 或等价 shell handler
|
||||
|
||||
至少需要:
|
||||
|
||||
- `GET /api/logs?tailLines=100`
|
||||
- `GET /api/logs/dir`
|
||||
|
||||
主进程需要给 `logger` 增补:
|
||||
|
||||
- 读取当前/最近日志文件内容
|
||||
- 返回日志目录
|
||||
|
||||
### 6.4 Gateway 生命周期适配
|
||||
|
||||
当前 `zn-ai/electron/gateway/manager.ts` 状态粒度不足,不一定要完全复制 `ClawX`,但至少要满足设置页展示。
|
||||
|
||||
建议最低改造目标:
|
||||
|
||||
- `checkHealth()` 返回稳定状态快照
|
||||
- `/api/gateway/status` 的返回值包含可用于 UI 的状态字段
|
||||
- 若暂时没有真实 `port/pid`,UI 层应允许缺省,不要强依赖
|
||||
|
||||
建议策略:
|
||||
|
||||
- Phase 1 不强求对齐 `ClawX` 的 `running|starting|stopped|error` 全状态模型
|
||||
- 先做 `connected/disconnected/reconnecting` 的 UI 映射
|
||||
- 如果后面需要“运行中 / 已停止 / 错误”更细粒度状态,再扩 `gatewayManager`
|
||||
|
||||
## 7. 实施步骤
|
||||
|
||||
### Milestone 0:建模
|
||||
|
||||
1. 扩展 runtime config key 与类型
|
||||
2. 扩展 `config-service` 默认值
|
||||
3. 统一 renderer/main 的读写入口
|
||||
|
||||
### Milestone 1:主进程桥接
|
||||
|
||||
1. 新增 `/api/settings`
|
||||
2. 新增 `/api/logs`
|
||||
3. 新增打开日志目录能力
|
||||
4. 核对 `gateway` status/restart 路由输出是否满足 UI 需要
|
||||
|
||||
### Milestone 2:设置状态层
|
||||
|
||||
1. 新增 `useGatewaySettingState.ts`
|
||||
2. 将 `gatewayAutoStart` 纳入 settings store 或独立 hook
|
||||
3. 定义日志读取、重启、状态刷新行为
|
||||
|
||||
### Milestone 3:渲染层
|
||||
|
||||
1. 给 `GeneralSettingsPanel` 增加 gateway section
|
||||
2. 在 `Setting/index.tsx` 注入 gateway state
|
||||
3. 补齐中英日文案
|
||||
|
||||
### Milestone 4:增强能力
|
||||
|
||||
1. 代理 draft/save 模式
|
||||
2. token 读取与复制
|
||||
3. WS 诊断
|
||||
4. dev mode / telemetry
|
||||
|
||||
## 8. 风险与注意点
|
||||
|
||||
1. `zn-ai` 当前 gateway manager 是 in-process 模式,和 `ClawX` 的独立进程 Gateway 生命周期不完全同构。
|
||||
2. `ClawX` 的 `gatewayPort` 在设置模型里存在,但主流程未必实际使用;迁移时不要把“端口可编辑”误当成必须功能。
|
||||
3. `zn-ai` 现在的 `useSettingUpdateState` 已经直接使用 `get-config/update-config`,说明 config key 在多个层定义并不完全一致;这次最好顺手补齐类型债。
|
||||
4. 若代理功能进入 Phase 1,会额外牵动 Electron `session.setProxy`、Gateway 重启、网络回归验证,建议放到 Phase 2。
|
||||
5. 如果没有 `showItemInFolder`,日志按钮只能先做“查看最近日志”,不能做“打开文件夹”。
|
||||
|
||||
## 9. Sub-agent 数量估算与分工
|
||||
|
||||
### 9.1 调研阶段
|
||||
|
||||
本次调研适合 3 个 explorer 并行:
|
||||
|
||||
1. `ClawX` 渲染层网关设置
|
||||
2. `ClawX` 主进程 / IPC / 存储 / 日志
|
||||
3. `zn-ai` 设置页与现有 Gateway 能力
|
||||
|
||||
### 9.2 开发阶段推荐数量
|
||||
|
||||
推荐 **4 个 sub-agents** 并行开发,不含主协调 agent。
|
||||
|
||||
如果要把联调/回归单独拆开,建议扩为 **5 个**。
|
||||
|
||||
### 9.3 推荐分工
|
||||
|
||||
#### Worker 1:渲染层 Gateway Panel
|
||||
|
||||
负责范围:
|
||||
|
||||
- `src/pages/Setting/components/GeneralSettingsPanel.tsx`
|
||||
- `src/pages/Setting/index.tsx`
|
||||
- 可选:`src/pages/Setting/components/SectionHeader.tsx`
|
||||
- `src/i18n/messages.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 新增 Gateway section
|
||||
- 接好状态、按钮、空态、错误态、加载态
|
||||
- 控制视觉层级,不让设置页变成调试面板
|
||||
|
||||
#### Worker 2:设置模型与状态层
|
||||
|
||||
负责范围:
|
||||
|
||||
- `src/types/runtime.ts`
|
||||
- `runtime-shared/lib/constants.ts`
|
||||
- `src/stores/settings.ts`
|
||||
- 新增 `src/pages/Setting/useGatewaySettingState.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 补齐 config key 与类型
|
||||
- 统一 `gatewayAutoStart` 等字段的读取/写入
|
||||
- 为渲染层提供稳定 state API
|
||||
|
||||
#### Worker 3:主进程设置/日志桥接
|
||||
|
||||
负责范围:
|
||||
|
||||
- `electron/api/router.ts`
|
||||
- 新增 `electron/api/routes/settings.ts`
|
||||
- 新增 `electron/api/routes/logs.ts`
|
||||
- `electron/service/config-service/index.ts`
|
||||
- `electron/service/logger/index.ts`
|
||||
- `electron/preload/index.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 暴露本地 settings route
|
||||
- 暴露 logs route
|
||||
- 补 shell 打开目录能力
|
||||
- 打通 renderer <-> main 的配置和日志桥
|
||||
|
||||
#### Worker 4:Gateway 生命周期适配
|
||||
|
||||
负责范围:
|
||||
|
||||
- `electron/gateway/manager.ts`
|
||||
- `electron/api/routes/gateway.ts`
|
||||
- `electron/main.ts`
|
||||
- 可选:新增更细的 gateway status typing
|
||||
|
||||
职责:
|
||||
|
||||
- 校准状态返回结构
|
||||
- 保证 restart/status/health 对设置页可用
|
||||
- 如果需要,补自动启动策略与更清晰的状态快照
|
||||
|
||||
### 9.4 可选第 5 个 sub-agent
|
||||
|
||||
若要提高并行度,可增加一个 QA / Integration worker:
|
||||
|
||||
- 回归设置页、更新页、主题、语言、日志
|
||||
- 检查 config key 兼容性
|
||||
- 覆盖“无网关 / 重启中 / 读日志失败 / 配置缺省值”场景
|
||||
|
||||
## 10. 建议的开工顺序
|
||||
|
||||
建议按以下顺序派发任务:
|
||||
|
||||
1. Worker 2 先补类型与设置模型
|
||||
2. Worker 3 并行补 settings/logs 路由与 logger 能力
|
||||
3. Worker 4 校准 gateway 状态输出
|
||||
4. Worker 1 在前 3 者接口稳定后接 UI
|
||||
|
||||
这样可以减少渲染层返工。
|
||||
|
||||
## 11. 本轮建议
|
||||
|
||||
如果下一步直接进入开发,我建议按以下范围作为第一批提交:
|
||||
|
||||
1. `gatewayAutoStart`
|
||||
2. gateway 状态展示
|
||||
3. restart 按钮
|
||||
4. 日志查看
|
||||
5. 日志目录打开
|
||||
|
||||
把代理、token、WS 诊断放到第二批。
|
||||
|
||||
@@ -6,4 +6,7 @@
|
||||
4、把龙虾包装到对话 - 完成
|
||||
5、迁移频道功能 - 完成
|
||||
6、迁移agent功能 - 完成
|
||||
7、知识库调整成上传文件,查看文件列表 - 完成
|
||||
7、知识库调整成上传文件,查看文件列表 - 完成
|
||||
|
||||
模型配置添加弹窗移除
|
||||
任务列表移除
|
||||
|
||||
@@ -11,7 +11,9 @@ import { handleCronRoutes } from './routes/cron';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleKnowledgeRoutes } from './routes/knowledge';
|
||||
import { handleLogRoutes } from './routes/logs';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
import { handleSkillRoutes } from './routes/skills';
|
||||
|
||||
@@ -27,8 +29,10 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleCronRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleKnowledgeRoutes,
|
||||
handleLogRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleSkillRoutes,
|
||||
];
|
||||
|
||||
|
||||
35
electron/api/routes/logs.ts
Normal file
35
electron/api/routes/logs.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import logManager from '@electron/service/logger';
|
||||
import type { HostApiResult } from '@src/types/runtime';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok } from '../route-utils';
|
||||
|
||||
const DEFAULT_TAIL_LINES = 100;
|
||||
|
||||
function parseTailLines(rawValue: string | null): number {
|
||||
const parsed = Number(rawValue ?? DEFAULT_TAIL_LINES);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_TAIL_LINES;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor(parsed));
|
||||
}
|
||||
|
||||
export async function handleLogRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<HostApiResult<unknown> | null> {
|
||||
const { pathname, method, url } = request;
|
||||
|
||||
if (pathname === '/api/logs' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
content: await logManager.readRecentLogText(parseTailLines(url.searchParams.get('tailLines'))),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
100
electron/api/routes/settings.ts
Normal file
100
electron/api/routes/settings.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ConfigKeys, IConfig } from '@runtime/lib/types';
|
||||
import configManager from '@electron/service/config-service';
|
||||
import type { HostApiResult } from '@src/types/runtime';
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
|
||||
function extractSettingKey(pathname: string): string | null {
|
||||
if (!pathname.startsWith('/api/settings/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawKey = pathname.slice('/api/settings/'.length).trim();
|
||||
if (!rawKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeURIComponent(rawKey);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function unwrapSettingValue(body: unknown): unknown {
|
||||
if (isPlainObject(body) && Object.prototype.hasOwnProperty.call(body, 'value')) {
|
||||
return body.value;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function handleSettingsRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<HostApiResult<unknown> | null> {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/settings' && method === 'GET') {
|
||||
try {
|
||||
return ok(configManager.getConfig());
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/settings' && method === 'PUT') {
|
||||
try {
|
||||
const body = parseJsonBody<unknown>(request.body);
|
||||
if (!isPlainObject(body)) {
|
||||
return fail(400, 'settings payload must be an object');
|
||||
}
|
||||
|
||||
configManager.update(body as Partial<IConfig>);
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
settings: configManager.getConfig(),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
const key = extractSettingKey(pathname);
|
||||
if (pathname.startsWith('/api/settings/') && !key) {
|
||||
return fail(400, 'setting key is required');
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
value: configManager.get(key as ConfigKeys),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'PUT') {
|
||||
try {
|
||||
const value = unwrapSettingValue(parseJsonBody<unknown>(request.body));
|
||||
configManager.set(key as ConfigKeys, value);
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const api: WindowApi = {
|
||||
versions: process.versions,
|
||||
|
||||
external: {
|
||||
open: (url: string) => ipcRenderer.invoke('external-open', url)
|
||||
open: (url: string) => ipcRenderer.invoke('external-open', url),
|
||||
},
|
||||
|
||||
platform: process.platform,
|
||||
|
||||
@@ -6,7 +6,11 @@ import { debounce } from '@runtime/lib/utils'
|
||||
import logManager from '@electron/service/logger'
|
||||
import { getUserDataDir } from '@electron/utils/paths'
|
||||
|
||||
const DEFAULT_CONFIG: IConfig = {
|
||||
type AppConfig = IConfig & {
|
||||
[CONFIG_KEYS.GATEWAY_AUTO_START]: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
[CONFIG_KEYS.THEME_MODE]: 'system',
|
||||
[CONFIG_KEYS.PRIMARY_COLOR]: '#BB5BE7',
|
||||
[CONFIG_KEYS.LANGUAGE]: 'zh',
|
||||
@@ -17,14 +21,15 @@ const DEFAULT_CONFIG: IConfig = {
|
||||
[CONFIG_KEYS.SELECTED_CHANNELS]: [],
|
||||
[CONFIG_KEYS.IMAGE_CACHE]: [],
|
||||
[CONFIG_KEYS.TASK_LIST]: [],
|
||||
[CONFIG_KEYS.GATEWAY_AUTO_START]: true,
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private static _instance: ConfigService;
|
||||
private _store: any;
|
||||
private _defaultConfig: IConfig = DEFAULT_CONFIG;
|
||||
private _defaultConfig: AppConfig = DEFAULT_CONFIG;
|
||||
|
||||
private _listeners: Array<(config: IConfig) => void> = [];
|
||||
private _listeners: Array<(config: AppConfig) => void> = [];
|
||||
|
||||
|
||||
private constructor() {
|
||||
@@ -34,7 +39,7 @@ export class ConfigService {
|
||||
public async init() {
|
||||
if (this._store) return;
|
||||
const { default: Store } = await import('electron-store');
|
||||
this._store = new Store<IConfig>({
|
||||
this._store = new Store<AppConfig>({
|
||||
name: 'config',
|
||||
cwd: getUserDataDir(),
|
||||
defaults: DEFAULT_CONFIG,
|
||||
@@ -74,7 +79,7 @@ export class ConfigService {
|
||||
this._listeners.forEach(listener => listener({ ...this._store.store }));
|
||||
}
|
||||
|
||||
public getConfig(): IConfig {
|
||||
public getConfig(): AppConfig {
|
||||
if (!this._store) {
|
||||
return { ...this._defaultConfig };
|
||||
}
|
||||
@@ -98,7 +103,7 @@ export class ConfigService {
|
||||
autoSave && this._notifyListeners();
|
||||
}
|
||||
|
||||
public update(updates: Partial<IConfig>, autoSave: boolean = true): void {
|
||||
public update(updates: Partial<AppConfig>, autoSave: boolean = true): void {
|
||||
this._ensureStore();
|
||||
(Object.keys(updates) as ConfigKeys[]).forEach((key) => {
|
||||
this._store.set(key, updates[key]);
|
||||
@@ -115,7 +120,7 @@ export class ConfigService {
|
||||
this._notifyListeners();
|
||||
}
|
||||
|
||||
public onConfigChange(listener: ((config: IConfig) => void)): () => void {
|
||||
public onConfigChange(listener: ((config: AppConfig) => void)): () => void {
|
||||
this._listeners.push(listener);
|
||||
|
||||
return () => this._listeners = this._listeners.filter(l => l !== listener);
|
||||
|
||||
@@ -13,6 +13,7 @@ const unlinkAsync = promisify(fs.unlink);
|
||||
|
||||
class LogService {
|
||||
private static _instance: LogService;
|
||||
private readonly logDirPath: string;
|
||||
|
||||
// 日志保留天数,默认7天
|
||||
private LOG_RETENTION_DAYS = 7;
|
||||
@@ -21,7 +22,8 @@ class LogService {
|
||||
private readonly CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
const logPath = path.join(getUserDataDir(), 'logs');
|
||||
this.logDirPath = path.join(getUserDataDir(), 'logs');
|
||||
const logPath = this.logDirPath;
|
||||
// c:users/{username}/AppData/Roaming/{appName}/logs
|
||||
|
||||
// 创建日志目录
|
||||
@@ -175,6 +177,54 @@ class LogService {
|
||||
this.info(`User Operation: ${operation} by ${userId}, Details: ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
public async readRecentLogText(tailLines: number = 200): Promise<string> {
|
||||
const safeTailLines = Number.isFinite(tailLines) ? Math.max(1, Math.floor(tailLines)) : 200;
|
||||
const filePath = this._getCurrentLogFilePath();
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const file = await fs.promises.open(filePath, 'r');
|
||||
|
||||
try {
|
||||
const fileStat = await file.stat();
|
||||
if (fileStat.size === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chunkSize = 64 * 1024;
|
||||
let position = fileStat.size;
|
||||
let content = '';
|
||||
let lineCount = 0;
|
||||
|
||||
while (position > 0 && lineCount <= safeTailLines) {
|
||||
const bytesToRead = Math.min(chunkSize, position);
|
||||
position -= bytesToRead;
|
||||
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
await file.read(buffer, 0, bytesToRead, position);
|
||||
content = `${buffer.toString('utf-8')}${content}`;
|
||||
lineCount = content.split('\n').length - 1;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= safeTailLines) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return lines.slice(-safeTailLines).join('\n');
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _getCurrentLogFilePath(): string {
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
return path.join(this.logDirPath, `${formattedDate}.log`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const logManager = LogService.getInstance();
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -234,5 +234,3 @@ declare global {
|
||||
content: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export enum CONFIG_KEYS {
|
||||
DEFAULT_MODEL = 'defaultModel',
|
||||
AUTO_CHECK_UPDATE = 'autoCheckUpdate',
|
||||
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
|
||||
GATEWAY_AUTO_START = 'gatewayAutoStart',
|
||||
SELECTED_CHANNELS = 'selectedChannels',
|
||||
IMAGE_CACHE = 'imageCache',
|
||||
TASK_LIST = 'taskList',
|
||||
|
||||
@@ -431,6 +431,20 @@ export const messages: I18nMessages = {
|
||||
description: 'Customize the look and feel of the application.',
|
||||
themeSection: 'Theme Settings',
|
||||
languageSection: 'Language',
|
||||
gatewayTitle: 'Gateway',
|
||||
gatewayDescription: 'View the current Gateway state and basic runtime controls.',
|
||||
gatewayStatusLabel: 'Status',
|
||||
gatewayPortLabel: 'Port',
|
||||
gatewayConnected: 'Running',
|
||||
gatewayDisconnected: 'Stopped',
|
||||
gatewayReconnecting: 'Restarting',
|
||||
gatewayRestart: 'Restart',
|
||||
gatewayLogs: 'Logs',
|
||||
gatewayHideLogs: 'Hide Logs',
|
||||
gatewayLogsEmpty: 'No logs available yet.',
|
||||
gatewayLogsLoading: 'Loading logs...',
|
||||
gatewayAutoStartTitle: 'Auto-start Gateway',
|
||||
gatewayAutoStartDescription: 'Start the Gateway automatically when the app launches',
|
||||
updatesTitle: 'Updates',
|
||||
currentVersion: 'Current Version',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
@@ -893,6 +907,20 @@ export const messages: I18nMessages = {
|
||||
description: '自定义应用的外观与使用体验。',
|
||||
themeSection: '主题设置',
|
||||
languageSection: '语言',
|
||||
gatewayTitle: '网关',
|
||||
gatewayDescription: '查看当前网关状态与基础运行控制。',
|
||||
gatewayStatusLabel: '状态',
|
||||
gatewayPortLabel: '端口',
|
||||
gatewayConnected: '运行中',
|
||||
gatewayDisconnected: '已停止',
|
||||
gatewayReconnecting: '重启中',
|
||||
gatewayRestart: '重启',
|
||||
gatewayLogs: '日志',
|
||||
gatewayHideLogs: '收起日志',
|
||||
gatewayLogsEmpty: '暂无日志内容。',
|
||||
gatewayLogsLoading: '正在加载日志...',
|
||||
gatewayAutoStartTitle: '自动启动网关',
|
||||
gatewayAutoStartDescription: '应用启动时自动启动网关',
|
||||
updatesTitle: '版本更新',
|
||||
currentVersion: '当前版本',
|
||||
checkForUpdates: '检查更新',
|
||||
@@ -1355,6 +1383,20 @@ export const messages: I18nMessages = {
|
||||
description: 'アプリの見た目と操作感をカスタマイズします。',
|
||||
themeSection: 'テーマ設定',
|
||||
languageSection: '言語',
|
||||
gatewayTitle: 'ゲートウェイ',
|
||||
gatewayDescription: '現在のゲートウェイ状態と基本的なランタイム操作を確認します。',
|
||||
gatewayStatusLabel: '状態',
|
||||
gatewayPortLabel: 'ポート',
|
||||
gatewayConnected: '稼働中',
|
||||
gatewayDisconnected: '停止中',
|
||||
gatewayReconnecting: '再起動中',
|
||||
gatewayRestart: '再起動',
|
||||
gatewayLogs: 'ログ',
|
||||
gatewayHideLogs: 'ログを閉じる',
|
||||
gatewayLogsEmpty: 'まだログはありません。',
|
||||
gatewayLogsLoading: 'ログを読み込み中...',
|
||||
gatewayAutoStartTitle: 'ゲートウェイを自動起動',
|
||||
gatewayAutoStartDescription: 'アプリ起動時にゲートウェイを自動起動します',
|
||||
updatesTitle: 'アップデート',
|
||||
currentVersion: '現在のバージョン',
|
||||
checkForUpdates: '更新を確認',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Moon, Sun, Monitor, RefreshCw } from 'lucide-react';
|
||||
import { FileText, Moon, Sun, Monitor, RefreshCw } from 'lucide-react';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '../../../i18n/constants';
|
||||
import type { LanguageCode, ThemeMode } from '../../../types/runtime';
|
||||
import type { GatewayConnectionStatus, GatewaySettingState } from '../useGatewaySettingState';
|
||||
import type { SettingUpdateState } from '../useSettingUpdateState';
|
||||
import SectionHeader from './SectionHeader';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
@@ -11,6 +12,7 @@ type GeneralSettingsPanelProps = {
|
||||
language: LanguageCode;
|
||||
onThemeChange: (theme: ThemeMode) => void | Promise<void>;
|
||||
onLanguageChange: (language: LanguageCode) => void | Promise<void>;
|
||||
gatewayState: GatewaySettingState;
|
||||
updateState: SettingUpdateState;
|
||||
};
|
||||
|
||||
@@ -49,14 +51,46 @@ function getUpdateStatusText(t: ReturnType<typeof useI18n>['t'], updateState: Se
|
||||
}
|
||||
}
|
||||
|
||||
function getGatewayStatusMeta(
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
status: GatewayConnectionStatus,
|
||||
): {
|
||||
label: string;
|
||||
className: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return {
|
||||
label: t('settings.general.gatewayConnected'),
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300',
|
||||
};
|
||||
case 'reconnecting':
|
||||
return {
|
||||
label: t('settings.general.gatewayReconnecting'),
|
||||
className:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: t('settings.general.gatewayDisconnected'),
|
||||
className:
|
||||
'border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/10 dark:text-slate-300',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function GeneralSettingsPanel({
|
||||
themeMode,
|
||||
language,
|
||||
onThemeChange,
|
||||
onLanguageChange,
|
||||
gatewayState,
|
||||
updateState,
|
||||
}: GeneralSettingsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const gatewayStatusMeta = getGatewayStatusMeta(t, gatewayState.status);
|
||||
const gatewayPort = gatewayState.gateway.port ?? 18789;
|
||||
|
||||
return (
|
||||
<section className="flex-1 h-full p-5 select-none">
|
||||
@@ -126,6 +160,118 @@ export default function GeneralSettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
|
||||
{t('settings.general.gatewayTitle')}
|
||||
</div>
|
||||
|
||||
<div className="p-5 dark:border-gray-700 dark:bg-[#222226]">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-[14px] text-[#525866] dark:text-gray-400">
|
||||
{t('settings.general.gatewayStatusLabel')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-[14px] text-[#667085] dark:text-gray-400">
|
||||
{t('settings.general.gatewayPortLabel')}: {gatewayPort}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'mt-3 text-[14px]',
|
||||
gatewayState.error ? 'text-red-500' : 'text-[#667085] dark:text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{gatewayState.error ?? t('settings.general.gatewayDescription')}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-full border px-3.5 py-2 text-[14px] font-medium',
|
||||
gatewayStatusMeta.className,
|
||||
].join(' ')}
|
||||
>
|
||||
{gatewayStatusMeta.label}
|
||||
</span>
|
||||
{gatewayState.loading ? (
|
||||
<span className="text-[13px] text-[#7a8699] dark:text-gray-500">
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void gatewayState.restartGateway();
|
||||
}}
|
||||
disabled={gatewayState.loading}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[#E5E8EE] bg-white px-3.5 py-2 text-[14px] font-medium text-[#171717] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
<RefreshCw
|
||||
className={[
|
||||
'h-4 w-4',
|
||||
gatewayState.loading ? 'animate-spin' : '',
|
||||
].join(' ')}
|
||||
/>
|
||||
{t('settings.general.gatewayRestart')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void gatewayState.toggleLogs();
|
||||
}}
|
||||
disabled={gatewayState.loading && !gatewayState.showLogs}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[#E5E8EE] bg-white px-3.5 py-2 text-[14px] font-medium text-[#171717] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{gatewayState.showLogs
|
||||
? t('settings.general.gatewayHideLogs')
|
||||
: t('settings.general.gatewayLogs')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-dashed border-[#E5E8EE] pt-5 dark:border-gray-700">
|
||||
<div>
|
||||
<div className="mb-1 text-[16px] text-[#171717] dark:text-gray-100">
|
||||
{t('settings.general.gatewayAutoStartTitle')}
|
||||
</div>
|
||||
<div className="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
||||
{t('settings.general.gatewayAutoStartDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
checked={gatewayState.gatewayAutoStart}
|
||||
onChange={(nextValue) => {
|
||||
if (gatewayState.loading) return;
|
||||
void gatewayState.setGatewayAutoStart(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gatewayState.showLogs ? (
|
||||
<div className="rounded-[18px] bg-[#0F172A] px-4 py-4 shadow-inner">
|
||||
<div className="mb-3 text-[13px] font-medium text-white/70">
|
||||
{t('settings.general.gatewayLogs')}
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words text-[12px] leading-6 text-[#D6E4FF]">
|
||||
{gatewayState.loading && !gatewayState.logContent
|
||||
? t('settings.general.gatewayLogsLoading')
|
||||
: gatewayState.logContent || t('settings.general.gatewayLogsEmpty')}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
|
||||
{t('settings.general.updatesTitle')}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LanguageCode, ThemeMode } from '../../types/runtime';
|
||||
import AccountSettingsPanel from './components/AccountSettingsPanel';
|
||||
import GeneralSettingsPanel from './components/GeneralSettingsPanel';
|
||||
import SettingMenu, { type SettingView } from './components/SettingMenu';
|
||||
import { useGatewaySettingState } from './useGatewaySettingState';
|
||||
import { useSettingUpdateState } from './useSettingUpdateState';
|
||||
|
||||
export default function SettingPage() {
|
||||
@@ -11,6 +12,7 @@ export default function SettingPage() {
|
||||
const themeMode = useSettingsStore((state) => state.themeMode);
|
||||
const language = useSettingsStore((state) => state.language);
|
||||
const updateState = useSettingUpdateState();
|
||||
const gatewayState = useGatewaySettingState();
|
||||
|
||||
const handleThemeChange = async (nextTheme: ThemeMode) => {
|
||||
await updateThemeMode(nextTheme);
|
||||
@@ -33,6 +35,7 @@ export default function SettingPage() {
|
||||
language={language}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
gatewayState={gatewayState}
|
||||
updateState={updateState}
|
||||
/>
|
||||
)}
|
||||
|
||||
276
src/pages/Setting/useGatewaySettingState.ts
Normal file
276
src/pages/Setting/useGatewaySettingState.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onGatewayEvent } from '../../lib/gateway-client';
|
||||
import { hostApiFetch } from '../../lib/host-api';
|
||||
import {
|
||||
initSettingsStore,
|
||||
updateGatewayAutoStart,
|
||||
useSettingsStore,
|
||||
} from '../../stores/settings';
|
||||
|
||||
export type GatewayConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
type GatewayStatusResponse = {
|
||||
ok?: boolean;
|
||||
status?: GatewayConnectionStatus;
|
||||
initialized?: boolean;
|
||||
mode?: string | null;
|
||||
port?: number | string | null;
|
||||
pid?: number | string | null;
|
||||
};
|
||||
|
||||
type GatewayLogsResponse =
|
||||
| {
|
||||
content?: string;
|
||||
}
|
||||
| string;
|
||||
|
||||
export type GatewayStatusSnapshot = {
|
||||
ok: boolean;
|
||||
status: GatewayConnectionStatus;
|
||||
initialized: boolean;
|
||||
mode: string | null;
|
||||
port: number | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
export type GatewaySettingState = {
|
||||
status: GatewayConnectionStatus;
|
||||
loading: boolean;
|
||||
showLogs: boolean;
|
||||
gatewayAutoStart: boolean;
|
||||
gateway: GatewayStatusSnapshot;
|
||||
logContent: string;
|
||||
statusLoading: boolean;
|
||||
logLoading: boolean;
|
||||
restarting: boolean;
|
||||
autoStartUpdating: boolean;
|
||||
error: string | null;
|
||||
lastFetchedAt: string | null;
|
||||
refreshStatus: () => Promise<void>;
|
||||
restartGateway: () => Promise<void>;
|
||||
toggleLogs: () => Promise<void>;
|
||||
loadLogs: (tailLines?: number) => Promise<void>;
|
||||
viewLogs: (tailLines?: number) => Promise<void>;
|
||||
setGatewayAutoStart: (nextValue: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_GATEWAY_STATUS: GatewayStatusSnapshot = {
|
||||
ok: false,
|
||||
status: 'disconnected',
|
||||
initialized: false,
|
||||
mode: null,
|
||||
port: null,
|
||||
pid: null,
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeStatus(status: unknown): GatewayConnectionStatus {
|
||||
if (status === 'connected' || status === 'disconnected' || status === 'reconnecting') {
|
||||
return status;
|
||||
}
|
||||
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
function normalizeOptionalNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeGatewayStatus(response: GatewayStatusResponse | null | undefined): GatewayStatusSnapshot {
|
||||
const status = normalizeStatus(response?.status);
|
||||
const initialized = Boolean(response?.initialized);
|
||||
|
||||
return {
|
||||
ok: typeof response?.ok === 'boolean' ? response.ok : initialized && status === 'connected',
|
||||
status,
|
||||
initialized,
|
||||
mode: typeof response?.mode === 'string' && response.mode ? response.mode : null,
|
||||
port: normalizeOptionalNumber(response?.port),
|
||||
pid: normalizeOptionalNumber(response?.pid),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogContent(response: GatewayLogsResponse): string {
|
||||
if (typeof response === 'string') {
|
||||
return response;
|
||||
}
|
||||
|
||||
return typeof response.content === 'string' ? response.content : '';
|
||||
}
|
||||
|
||||
export function useGatewaySettingState(): GatewaySettingState {
|
||||
const gatewayAutoStart = useSettingsStore((current) => current.gatewayAutoStart);
|
||||
const [gateway, setGateway] = useState<GatewayStatusSnapshot>(DEFAULT_GATEWAY_STATUS);
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [autoStartUpdating, setAutoStartUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function init() {
|
||||
setStatusLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await initSettingsStore();
|
||||
const response = await hostApiFetch<GatewayStatusResponse>('/api/gateway/status');
|
||||
|
||||
if (!active) return;
|
||||
|
||||
setGateway(normalizeGatewayStatus(response));
|
||||
setLastFetchedAt(new Date().toISOString());
|
||||
} catch (initError) {
|
||||
if (!active) return;
|
||||
setError(getErrorMessage(initError));
|
||||
} finally {
|
||||
if (active) {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
const unsubscribe = onGatewayEvent((event) => {
|
||||
if (!active || event.type !== 'gateway:status') {
|
||||
return;
|
||||
}
|
||||
|
||||
setGateway((current) => ({
|
||||
...current,
|
||||
ok: event.status === 'connected',
|
||||
status: event.status,
|
||||
initialized: event.status !== 'disconnected' || current.initialized,
|
||||
}));
|
||||
setError(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshStatus = async () => {
|
||||
setStatusLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await hostApiFetch<GatewayStatusResponse>('/api/gateway/status');
|
||||
setGateway(normalizeGatewayStatus(response));
|
||||
setLastFetchedAt(new Date().toISOString());
|
||||
} catch (refreshError) {
|
||||
setError(getErrorMessage(refreshError));
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restartGateway = async () => {
|
||||
setRestarting(true);
|
||||
setError(null);
|
||||
setGateway((current) => ({
|
||||
...current,
|
||||
ok: false,
|
||||
status: 'reconnecting',
|
||||
}));
|
||||
|
||||
try {
|
||||
await hostApiFetch<{ success?: boolean }>('/api/gateway/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch (restartError) {
|
||||
setError(getErrorMessage(restartError));
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async (tailLines = 100) => {
|
||||
setLogLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await hostApiFetch<GatewayLogsResponse>(`/api/logs?tailLines=${encodeURIComponent(String(tailLines))}`);
|
||||
setLogContent(normalizeLogContent(response));
|
||||
setLastFetchedAt(new Date().toISOString());
|
||||
} catch (logsError) {
|
||||
setLogContent('');
|
||||
setError(getErrorMessage(logsError));
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLogs = async () => {
|
||||
const nextValue = !showLogs;
|
||||
setShowLogs(nextValue);
|
||||
|
||||
if (!nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadLogs();
|
||||
};
|
||||
|
||||
const setGatewayAutoStart = async (nextValue: boolean) => {
|
||||
setAutoStartUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateGatewayAutoStart(nextValue);
|
||||
setLastFetchedAt(new Date().toISOString());
|
||||
} catch (updateError) {
|
||||
setError(getErrorMessage(updateError));
|
||||
} finally {
|
||||
setAutoStartUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loading =
|
||||
statusLoading
|
||||
|| logLoading
|
||||
|| restarting
|
||||
|| autoStartUpdating;
|
||||
|
||||
return {
|
||||
status: gateway.status,
|
||||
loading,
|
||||
showLogs,
|
||||
gatewayAutoStart,
|
||||
gateway,
|
||||
logContent,
|
||||
statusLoading,
|
||||
logLoading,
|
||||
restarting,
|
||||
autoStartUpdating,
|
||||
error,
|
||||
lastFetchedAt,
|
||||
refreshStatus,
|
||||
restartGateway,
|
||||
toggleLogs,
|
||||
loadLogs,
|
||||
viewLogs: loadLogs,
|
||||
setGatewayAutoStart,
|
||||
};
|
||||
}
|
||||
@@ -13,4 +13,6 @@ export {
|
||||
updateFontSize as setFontSize,
|
||||
updateMinimizeToTray as setMinimizeToTray,
|
||||
updatePrimaryColor as setPrimaryColor,
|
||||
updateGatewayAutoStart,
|
||||
updateGatewayAutoStart as setGatewayAutoStart,
|
||||
} from './settings';
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SettingsState {
|
||||
minimizeToTray: boolean;
|
||||
providerId: string | null;
|
||||
defaultModel: string | null;
|
||||
gatewayAutoStart: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'zn-ai-react:';
|
||||
@@ -73,6 +74,7 @@ function createInitialState(): SettingsState {
|
||||
minimizeToTray: false,
|
||||
providerId: null,
|
||||
defaultModel: null,
|
||||
gatewayAutoStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +205,7 @@ async function hydrate(): Promise<SettingsState> {
|
||||
const systemTheme = detectSystemTheme();
|
||||
const systemLanguage = detectSystemLanguage();
|
||||
|
||||
const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel] = await Promise.all([
|
||||
const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel, gatewayAutoStart] = await Promise.all([
|
||||
readThemeMode(),
|
||||
readConfigValue<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage),
|
||||
readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14),
|
||||
@@ -211,6 +213,7 @@ async function hydrate(): Promise<SettingsState> {
|
||||
readConfigValue<string>(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null),
|
||||
readConfigValue<boolean>(CONFIG_KEYS.GATEWAY_AUTO_START, true),
|
||||
]);
|
||||
|
||||
const resolvedLanguage = resolveSupportedLanguage(language ?? systemLanguage);
|
||||
@@ -230,6 +233,7 @@ async function hydrate(): Promise<SettingsState> {
|
||||
minimizeToTray: Boolean(minimizeToTray),
|
||||
providerId: providerId ?? null,
|
||||
defaultModel: defaultModel ?? null,
|
||||
gatewayAutoStart: Boolean(gatewayAutoStart),
|
||||
});
|
||||
|
||||
applyLocale(resolvedLanguage);
|
||||
@@ -317,6 +321,21 @@ async function setPrimaryColor(primaryColor: string, persist = true): Promise<Se
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
|
||||
const next = Boolean(gatewayAutoStart);
|
||||
if (state.gatewayAutoStart === next && state.initialized) return state;
|
||||
|
||||
patchState({
|
||||
gatewayAutoStart: next,
|
||||
});
|
||||
|
||||
if (persist) {
|
||||
await writeConfigValue(CONFIG_KEYS.GATEWAY_AUTO_START, next);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function getSnapshot(): SettingsState {
|
||||
return state;
|
||||
}
|
||||
@@ -335,6 +354,7 @@ export const settingsStore = {
|
||||
setFontSize,
|
||||
setMinimizeToTray,
|
||||
setPrimaryColor,
|
||||
setGatewayAutoStart,
|
||||
hostApiFetch,
|
||||
};
|
||||
|
||||
@@ -371,4 +391,8 @@ export async function updateMinimizeToTray(minimizeToTray: boolean, persist = tr
|
||||
return setMinimizeToTray(minimizeToTray, persist);
|
||||
}
|
||||
|
||||
export async function updateGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
|
||||
return setGatewayAutoStart(gatewayAutoStart, persist);
|
||||
}
|
||||
|
||||
export { i18n };
|
||||
|
||||
@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
|
||||
MINIMIZE_TO_TRAY: 'minimizeToTray',
|
||||
PROVIDER: 'provider',
|
||||
DEFAULT_MODEL: 'defaultModel',
|
||||
GATEWAY_AUTO_START: 'gatewayAutoStart',
|
||||
SELECTED_CHANNELS: 'selectedChannels',
|
||||
IMAGE_CACHE: 'imageCache',
|
||||
TASK_LIST: 'taskList',
|
||||
@@ -47,6 +48,7 @@ export interface ConfigValueMap {
|
||||
minimizeToTray: boolean;
|
||||
provider: string | null;
|
||||
defaultModel: string | null;
|
||||
gatewayAutoStart: boolean;
|
||||
selectedChannels: Array<{ id: string; channelName: string; channelUrl: string }>;
|
||||
imageCache: Array<[string, unknown]>;
|
||||
taskList: Task[];
|
||||
|
||||
Reference in New Issue
Block a user