diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 29d57f0..cbdcb48 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -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"); diff --git a/docs/Gateway-Configuration-Migration-Plan.md b/docs/Gateway-Configuration-Migration-Plan.md new file mode 100644 index 0000000..97029d1 --- /dev/null +++ b/docs/Gateway-Configuration-Migration-Plan.md @@ -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 诊断放到第二批。 + diff --git a/docs/todo-list.md b/docs/todo-list.md index 02d3393..7690f36 100644 --- a/docs/todo-list.md +++ b/docs/todo-list.md @@ -6,4 +6,7 @@ 4、把龙虾包装到对话 - 完成 5、迁移频道功能 - 完成 6、迁移agent功能 - 完成 -7、知识库调整成上传文件,查看文件列表 - 完成 \ No newline at end of file +7、知识库调整成上传文件,查看文件列表 - 完成 + +模型配置添加弹窗移除 +任务列表移除 diff --git a/electron/api/router.ts b/electron/api/router.ts index bc5556a..5d5c36b 100644 --- a/electron/api/router.ts +++ b/electron/api/router.ts @@ -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, ]; diff --git a/electron/api/routes/logs.ts b/electron/api/routes/logs.ts new file mode 100644 index 0000000..ec9d21e --- /dev/null +++ b/electron/api/routes/logs.ts @@ -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 | 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; +} diff --git a/electron/api/routes/settings.ts b/electron/api/routes/settings.ts new file mode 100644 index 0000000..01995e7 --- /dev/null +++ b/electron/api/routes/settings.ts @@ -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 { + 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 | 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(request.body); + if (!isPlainObject(body)) { + return fail(400, 'settings payload must be an object'); + } + + configManager.update(body as Partial); + + 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(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; +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 696738e..e06fcd2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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, diff --git a/electron/service/config-service/index.ts b/electron/service/config-service/index.ts index d6d6722..35edda5 100644 --- a/electron/service/config-service/index.ts +++ b/electron/service/config-service/index.ts @@ -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({ + this._store = new Store({ 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, autoSave: boolean = true): void { + public update(updates: Partial, 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); diff --git a/electron/service/logger/index.ts b/electron/service/logger/index.ts index 34f39dd..a6c08f3 100644 --- a/electron/service/logger/index.ts +++ b/electron/service/logger/index.ts @@ -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 { + 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(); diff --git a/global.d.ts b/global.d.ts index 5fddcdc..2f896d1 100644 --- a/global.d.ts +++ b/global.d.ts @@ -234,5 +234,3 @@ declare global { content: string; } } - - diff --git a/runtime-shared/lib/constants.ts b/runtime-shared/lib/constants.ts index eaf0035..b3eacfd 100644 --- a/runtime-shared/lib/constants.ts +++ b/runtime-shared/lib/constants.ts @@ -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', diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 6dd0752..49612da 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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: '更新を確認', diff --git a/src/pages/Setting/components/GeneralSettingsPanel.tsx b/src/pages/Setting/components/GeneralSettingsPanel.tsx index 4fe2bfb..6eace64 100644 --- a/src/pages/Setting/components/GeneralSettingsPanel.tsx +++ b/src/pages/Setting/components/GeneralSettingsPanel.tsx @@ -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; onLanguageChange: (language: LanguageCode) => void | Promise; + gatewayState: GatewaySettingState; updateState: SettingUpdateState; }; @@ -49,14 +51,46 @@ function getUpdateStatusText(t: ReturnType['t'], updateState: Se } } +function getGatewayStatusMeta( + t: ReturnType['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 (
@@ -126,6 +160,118 @@ export default function GeneralSettingsPanel({ +
+
+ {t('settings.general.gatewayTitle')} +
+ +
+
+
+
+
+ {t('settings.general.gatewayStatusLabel')} +
+
+ + {t('settings.general.gatewayPortLabel')}: {gatewayPort} + +
+
+ {gatewayState.error ?? t('settings.general.gatewayDescription')} +
+ +
+ +
+
+ + {gatewayStatusMeta.label} + + {gatewayState.loading ? ( + + {t('common.loading')} + + ) : null} +
+ + + +
+
+ +
+
+
+ {t('settings.general.gatewayAutoStartTitle')} +
+
+ {t('settings.general.gatewayAutoStartDescription')} +
+
+ + { + if (gatewayState.loading) return; + void gatewayState.setGatewayAutoStart(nextValue); + }} + /> +
+ + {gatewayState.showLogs ? ( +
+
+ {t('settings.general.gatewayLogs')} +
+
+                  {gatewayState.loading && !gatewayState.logContent
+                    ? t('settings.general.gatewayLogsLoading')
+                    : gatewayState.logContent || t('settings.general.gatewayLogsEmpty')}
+                
+
+ ) : null} +
+
+
+
{t('settings.general.updatesTitle')} diff --git a/src/pages/Setting/index.tsx b/src/pages/Setting/index.tsx index 1a1fb91..1fdc0a7 100644 --- a/src/pages/Setting/index.tsx +++ b/src/pages/Setting/index.tsx @@ -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} /> )} diff --git a/src/pages/Setting/useGatewaySettingState.ts b/src/pages/Setting/useGatewaySettingState.ts new file mode 100644 index 0000000..6b8dca4 --- /dev/null +++ b/src/pages/Setting/useGatewaySettingState.ts @@ -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; + restartGateway: () => Promise; + toggleLogs: () => Promise; + loadLogs: (tailLines?: number) => Promise; + viewLogs: (tailLines?: number) => Promise; + setGatewayAutoStart: (nextValue: boolean) => Promise; +}; + +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(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(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + + useEffect(() => { + let active = true; + + async function init() { + setStatusLoading(true); + setError(null); + + try { + await initSettingsStore(); + const response = await hostApiFetch('/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('/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(`/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, + }; +} diff --git a/src/stores/index.ts b/src/stores/index.ts index 32c1731..45eef2d 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -13,4 +13,6 @@ export { updateFontSize as setFontSize, updateMinimizeToTray as setMinimizeToTray, updatePrimaryColor as setPrimaryColor, + updateGatewayAutoStart, + updateGatewayAutoStart as setGatewayAutoStart, } from './settings'; diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 23d2aa5..0e895b0 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -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 { 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(CONFIG_KEYS.LANGUAGE, systemLanguage), readConfigValue(CONFIG_KEYS.FONT_SIZE, 14), @@ -211,6 +213,7 @@ async function hydrate(): Promise { readConfigValue(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'), readConfigValue(CONFIG_KEYS.PROVIDER, null), readConfigValue(CONFIG_KEYS.DEFAULT_MODEL, null), + readConfigValue(CONFIG_KEYS.GATEWAY_AUTO_START, true), ]); const resolvedLanguage = resolveSupportedLanguage(language ?? systemLanguage); @@ -230,6 +233,7 @@ async function hydrate(): Promise { 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 { + 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 { + return setGatewayAutoStart(gatewayAutoStart, persist); +} + export { i18n }; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 1fcd56d..bc427d9 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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[];