diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 4fe753f..bcbf629 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-CK4u0iKH.js"); +require("./main-lgujyV0Y.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/docs/ClawX-Channels-Migration-Plan.md b/docs/ClawX-Channels-Migration-Plan.md new file mode 100644 index 0000000..82406c0 --- /dev/null +++ b/docs/ClawX-Channels-Migration-Plan.md @@ -0,0 +1,981 @@ +# ClawX 消息频道功能迁移到 zn-ai 的开发计划 + +## 1. 背景与目标 + +这次迁移的目标不是把 `zn-ai` 当前的 `/channels` 页面做得“更好看”,而是把它从“频道/账号归属管理页”升级成接近 `ClawX` 的完整消息频道控制台。 + +期望对齐的能力边界如下: + +1. 渲染层页面结构对齐 `ClawX` + - 顶部标题区 + - `Refresh` + - Gateway 健康横幅与 diagnostics + - 已配置频道列表 + - 支持的频道列表 + - 频道配置弹窗 + - 删除确认与收敛刷新 +2. 主进程 / Host API 对齐 `ClawX` + - `/api/channels/accounts` + - `/api/channels/targets` + - `/api/channels/config` + - `/api/channels/default-account` + - `/api/channels/binding` + - `/api/channels/config/enabled` + - `/api/channels/credentials/validate` + - QR 登录相关接口 +3. 运行时链路对齐 `ClawX` + - 多账号配置 + - 默认账号切换 + - gateway reload / restart 策略 + - runtime health summary + - diagnostics snapshot +4. 测试基线对齐 `ClawX` + - 单测覆盖配置、账号 ID、状态推断 + - E2E 覆盖绑定、诊断、账号编辑与回归场景 + +一句话总结: + +- `zn-ai` 需要从“只会改归属”升级为“可配置、可诊断、可验证、可收敛”的消息频道域。 + +## 2. ClawX 基线结论 + +## 2.1 页面职责不是“绑定页”,而是“频道控制台” + +`ClawX/src/pages/Channels/index.tsx` 承担的不是单一绑定入口,而是完整控制台: + +- 管理已配置频道与账号 +- 展示支持的频道目录 +- 打开配置弹窗 +- 修改账号到 Agent 的绑定 +- 删除账号或整条频道配置 +- 展示 Gateway 健康状态 +- 复制 / 展开 diagnostics +- 在 gateway 重启或 channel-status 事件后做收敛刷新 + +这决定了 `zn-ai` 不能继续停留在“表单 + 下拉框”的结构。 + +## 2.2 渲染层 IA + +ClawX 的 UI 信息架构可以概括成三层: + +1. 页面层 + - 标题、副标题、刷新按钮 +2. 运行态层 + - gateway warning + - gateway degraded / unresponsive banner + - diagnostics 操作与展开区 +3. 资源层 + - `Configured Channels` + - `Supported Channels` + - `ChannelConfigModal` + +其中两个关键特征必须保留: + +- “已配置频道”和“支持的频道”是并列区块,不是同一张表的两种状态。 +- `ChannelConfigModal` 是核心交互入口,不是可有可无的附属表单。 + +## 2.3 关键交互流程 + +ClawX 的前端交互主线如下: + +1. 页面初次加载时并行拉取 `/api/channels/accounts` 与 `/api/agents` +2. `Refresh` 触发 `probe=true` 的强探测拉取 +3. `gateway:channel-status` 事件到达后节流刷新 +4. Gateway 状态从非 `running` 变为 `running` 后触发一轮收敛刷新 +5. 点击“添加账号”后根据频道类型决定是否生成默认 `accountId` +6. 配置弹窗内根据 `connectionType` 分为: + - `token` + - `qr` + - `oauth/webhook` 扩展位 +7. 保存配置后不是立即认为状态稳定,而是继续做延迟回拉 +8. 删除配置后先本地移除,再补一次延迟拉取,避免旧状态回刷 + +这说明 `zn-ai` 迁移时不能只做 API 调用本身,还要迁移“刷新与收敛策略”。 + +## 2.4 前端状态模型 + +ClawX 页面目前虽然写在单文件里,但隐含了一套很清晰的状态模型: + +- `channelGroups` +- `agents` +- `gatewayHealth` +- `diagnosticsSnapshot` +- `selectedChannelType` +- `selectedAccountId` +- `allowExistingConfig` +- `allowEditAccountId` +- `existingAccountIds` +- `initialConfigValues` +- `deleteTarget` +- `fetchInFlight` +- `queuedFetchOptions` +- `convergenceRefreshTimers` + +迁移到 `zn-ai` 时,建议不要继续把这些状态直接堆在页面组件里,而要拆成: + +- `channelsApi` +- `useChannelsPageController` +- `ChannelConfigModal` +- `ChannelDiagnosticsPanel` +- `ChannelGroupCard` + +## 2.5 主进程 API 面 + +ClawX 的 `electron/api/routes/channels.ts` 是一个“厚路由”,负责: + +- 构建频道账号视图 +- accountId canonical 校验与 legacy 兼容 +- 默认账号切换 +- binding 写入与清理 +- config 读写 +- credentials validate +- channel enabled 切换 +- WeChat / WhatsApp 登录流程 +- target 目录发现 +- gateway reload / restart 调度 + +也就是说,消息频道域在 ClawX 中是“本地 Host API 优先”的能力,而不是依赖远端 API 的薄代理。 + +## 2.6 配置持久化模型 + +ClawX 的配置核心语义不是“频道只有一条 token”,而是: + +- 一个频道类型可有多个账号 +- `defaultAccount` 是路由与展示的重要字段 +- 普通频道使用 `accounts.` 多账号结构 +- 某些 strict-schema channel 仍需扁平写法 +- 默认账号配置会镜像到 channel 顶层 +- 旧 flat 结构仍然兼容 +- 绑定关系与频道配置共享同一份配置语义 + +这对 `zn-ai` 的启示是: + +- 需要先定义消息频道的持久化模型,再谈 UI。 +- 如果只补页面而不补多账号配置模型,后面会反复返工。 + +## 2.7 Gateway 与 Diagnostics + +ClawX 的运行态处理有三个关键点: + +1. `channels.status` 不等于“纯连接态” + - 会结合 `running` + - `lastError` + - recent activity + - probe 结果 +2. 并非所有 channel save 都能通过热 reload 收敛 + - 很多频道命中 `FORCE_RESTART_CHANNELS` +3. degraded != stopped + - gateway 仍在运行时也可能因为 health timeout 被标成 degraded + +迁移到 `zn-ai` 时,Gateway 状态与频道状态不要混为一谈。 + +## 2.8 测试基线 + +ClawX 的消息频道测试不是只测接口返回,而是测整条用户链路: + +- unit + - channel routes + - channel config + - channel status + - channels page +- e2e + - binding regression + - account ID validation + - account persistence + - health diagnostics + +这意味着 `zn-ai` 迁移计划必须从一开始就带测试分工,不能把测试留到最后补。 + +## 3. zn-ai 当前现状 + +## 3.1 已有能力 + +`zn-ai` 当前已经具备以下基础: + +1. 独立 `/channels` 路由与侧边栏入口 +2. 频道页可拉取 `/api/channels/accounts` +3. 频道页已支持: + - 频道级归属 + - 账号级归属 +4. 本地已有: + - `/api/channels/accounts` + - `/api/channels/targets` + - `/api/channels/binding` +5. `electron/utils/channels.ts` 已能做: + - selected channel 归一化 + - channel/account 分组 + - target 候选构建 + - 与 Cron 历史目标复用 +6. `agentsStore` 已保存: + - `channelOwners` + - `channelAccountOwners` +7. Agents 页已经把 Channels 视为归属写入口 + +## 3.2 当前最大缺口 + +相对于 ClawX,`zn-ai` 目前缺少的不是某一个按钮,而是整个消息频道域的“控制面”: + +1. 没有 `supported channels` 目录区 +2. 没有 `ChannelConfigModal` +3. 没有账号新增 / 编辑 / 删除链路 +4. 没有默认账号切换 +5. 没有 channel enabled / disabled 语义 +6. 没有 credentials validate +7. 没有 plugin install / QR login 流程 +8. 没有 Gateway 健康横幅与 diagnostics +9. 没有 ClawX 那套收敛刷新机制 +10. `/api/channels/targets` 仍优先走 upstream,而不是本地闭环 + +因此当前 `zn-ai/src/pages/Channels/index.tsx` 更像“归属控制页”,而不是“消息频道控制台”。 + +## 3.3 可复用资产 + +迁移时不需要全部推倒重来,以下内容建议保留并扩展: + +- `zn-ai/src/pages/Channels/index.tsx` + - 现有页面容器 + - refresh / error / feedback 壳 +- `zn-ai/electron/api/routes/channels.ts` + - 现有 binding 与 catalog 路由骨架 +- `zn-ai/electron/utils/channels.ts` + - 目录归一化 + - target 候选生成 +- `zn-ai/electron/utils/agent-config.ts` + - `channelOwners` + - `channelAccountOwners` +- `zn-ai/src/stores/agents.ts` + - runtime changed 刷新链路 +- `zn-ai` 现有 router / sidebar / i18n 基础 + +## 4. 迁移原则 + +## 4.1 功能对齐 ClawX,存储实现适配 zn-ai + +不建议机械复制 ClawX 的 `~/.openclaw/openclaw.json` 存储方式。 + +更适合 `zn-ai` 的方式是: + +1. 交互、API 语义、运行态策略对齐 ClawX +2. 底层存储适配 `zn-ai` 当前 `userData` 模式 +3. 需要兼容 OpenClaw runtime 的字段时,优先采用兼容 schema + +建议方案: + +- 新增 `channels.json` 作为频道配置真相源 +- `agents.json` 继续保存 Agent 域信息 +- Phase 1 先保留 `channelOwners` / `channelAccountOwners` 在 `agents.json` +- Phase 3 再评估是否合并成统一 `runtime-config.json` + +这样可以先拿到功能对齐,再决定是否做配置域收敛。 + +## 4.2 Host API 以本地优先为原则 + +消息频道迁移完成后,`zn-ai` 不应该继续把关键频道能力优先转发到 upstream。 + +最低要求: + +- `/api/channels/accounts` 本地完成 +- `/api/channels/targets` 本地完成 +- `/api/channels/config*` 本地完成 +- diagnostics 本地完成 + +否则 UI 与 runtime 的一致性会很差。 + +## 4.3 维持页面职责边界 + +迁移后依然保持: + +- `Agents` + - 展示摘要 + - 提供跳转入口 +- `Channels` + - channel/account 配置与绑定唯一写入口 +- `Cron` + - 消费 channel/account/target 目录 + +不要把 Channels 的写操作再反向塞回 Agents 页。 + +## 5. 推荐的目标文件映射 + +| ClawX 参考 | zn-ai 目标 | +| --- | --- | +| `src/pages/Channels/index.tsx` | `src/pages/Channels/index.tsx` 扩展为控制台 | +| `src/components/channels/ChannelConfigModal.tsx` | 新增 `src/components/channels/ChannelConfigModal.tsx` | +| `src/types/channel.ts` | 新增 `src/lib/channel-meta.ts` 或扩展 `src/lib/channel-types.ts` | +| `electron/api/routes/channels.ts` | 扩展 `electron/api/routes/channels.ts` | +| `electron/utils/channel-config.ts` | 新增 `electron/utils/channel-config.ts` | +| `electron/utils/channel-status.ts` | 新增 `electron/utils/channel-status.ts` | +| `electron/utils/wechat-login.ts` / `whatsapp-login.ts` | 新增 `electron/utils/channel-integrations/*` | +| `electron/utils/plugin-install.ts` | 新增 `electron/utils/channel-plugin-install.ts` | +| `tests/unit/channels-page.test.tsx` | 新增 `tests/unit/channels-page.test.tsx` | +| `tests/e2e/channels-*.spec.ts` | 新增 `tests/e2e/channels-*.spec.ts` | + +## 6. 分阶段迁移计划 + +## Phase 0:模型冻结与接口清单 + +目标: + +- 先冻结频道域模型,避免 UI 和后端分头实现后再返工。 + +交付: + +1. 设计 `channels.json` 结构 + - channel type + - accounts + - defaultAccountId + - enabled + - metadata +2. 明确 binding 真相源暂时仍在 `agents.json` +3. 明确 channel meta schema + - `name` + - `description` + - `connectionType` + - `configFields` + - `instructions` + - `docsUrl` + - `isPlugin` +4. 列出本地 Host API 终态清单 + +完成标准: + +- 频道配置模型定稿 +- route 清单定稿 +- 前后端字段命名定稿 + +## Phase 1:后端基础设施对齐 + +目标: + +- 先补齐本地配置读写与目录构建,让前端有可依赖的数据面。 + +交付: + +1. 新增 `electron/utils/channel-config.ts` +2. 扩展 `electron/api/routes/channels.ts` + - `GET /api/channels/accounts` + - `GET /api/channels/config/:type` + - `POST /api/channels/config` + - `DELETE /api/channels/config/:type` + - `PUT /api/channels/default-account` + - `PUT /api/channels/config/enabled` +3. 将 `/api/channels/targets` 收回本地优先 +4. 让 `accounts` 视图同时拼接: + - channel config + - agent binding + - runtime snapshot + +完成标准: + +- `zn-ai` 本地已能维护多账号频道配置 +- 默认账号切换可用 +- 无需 upstream 也能拿到 targets + +## Phase 2:运行时与诊断链路对齐 + +目标: + +- 把 ClawX 的 runtime 行为补进来,而不是只有配置文件。 + +交付: + +1. 新增 `electron/utils/channel-status.ts` +2. 增加 credentials validate +3. 增加 diagnostics snapshot 与 gateway health summary +4. 增加 gateway reload / restart 策略 +5. 引入 QR / plugin channel 的集成层 + - WeChat + - WhatsApp + - 其他 plugin channel 预留 +6. 在 runtime changed 里补齐: + - `channels` + - `channel-targets` + - `agents` + +完成标准: + +- 配置保存后能正确触发 reload / restart +- 页面可感知 degraded / unresponsive +- diagnostics 可复制 / 展开 + +## Phase 3:前端页面与弹窗对齐 + +目标: + +- 把 `zn-ai` 的 `/channels` 从绑定页升级成控制台。 + +交付: + +1. 重构 `src/pages/Channels/index.tsx` + - 标题区 + - Refresh + - 健康横幅 + - diagnostics 面板 + - configured groups + - supported channels +2. 新增 `src/components/channels/ChannelConfigModal.tsx` + - 类型选择 + - token 配置 + - QR 流程 + - accountId 编辑与校验 + - 预加载已有配置 +3. 引入前端 channel meta schema +4. 引入收敛刷新策略 + - `gateway:channel-status` + - gateway running 过渡 + - 删除后延迟刷新 +5. 补消息频道相关 i18n 文案 + +完成标准: + +- 视觉结构与 ClawX 接近 +- 新增 / 编辑 / 删除 / 绑定 / 刷新 / 诊断链路闭环 + +## Phase 4:回归测试与联调收口 + +目标: + +- 在 parity 接近完成后补齐防回归能力。 + +交付: + +1. unit tests + - channel routes + - channel config + - channel status + - channels page +2. e2e tests + - 新增账号默认不自动绑定 + - 非 canonical accountId 被拦截 + - 编辑已有账号保持配置值 + - diagnostics 复制 / 展开 / restart +3. 更新迁移文档与 smoke checklist + +完成标准: + +- 关键回归路径有自动化覆盖 +- 频道页不再依赖人工回归 + +## 7. Sub-agent 数量估算与分工建议 + +## 7.1 分析阶段建议:3 个 sub-agent + +分析阶段建议 3 个 sub-agent,正好对应这次拆法: + +1. `Renderer Explorer` + - 负责 `ClawX` 页面 IA、弹窗、事件收敛与前端测试 +2. `Main Process Explorer` + - 负责 `ClawX` routes、config、gateway、diagnostics、QR / plugin 流程 +3. `Gap Explorer` + - 负责 `zn-ai` 现状、可复用模块与差距评估 + +这样能最快把“前端长什么样”“后端怎么跑”“zn-ai 现在到哪一步了”三件事拆开。 + +## 7.2 实施阶段建议:5 个 sub-agent(结构性对齐基线) + +真正进入开发阶段,建议 5 个 sub-agent,已经接近并行上限,再多会明显增加冲突成本。 + +### Agent 1:Channels 页面壳与 Diagnostics UI + +负责文件: + +- `src/pages/Channels/index.tsx` +- `src/components/channels/ChannelDiagnosticsPanel.tsx` +- `src/components/channels/ChannelGroupCard.tsx` + +职责: + +- 页面 IA +- configured / supported 列表 +- health banner +- diagnostics 展开与复制 +- 页面级收敛刷新 + +### Agent 2:ChannelConfigModal 与前端 schema / i18n + +负责文件: + +- `src/components/channels/ChannelConfigModal.tsx` +- `src/components/channels/*` +- `src/lib/channel-meta.ts` +- `src/lib/channel-types.ts` +- `src/i18n/messages.ts` 或新增 locale 文件 + +职责: + +- 类型选择 +- token / QR 表单 +- accountId 编辑校验 +- docs / instructions / badge +- 文案与 schema 收敛 + +### Agent 3:配置持久化与 Host API 核心 + +负责文件: + +- `electron/api/routes/channels.ts` +- `electron/utils/channel-config.ts` +- `electron/utils/agent-config.ts` + +职责: + +- config CRUD +- default account +- enabled / disabled +- binding 与视图拼装 +- canonical / legacy accountId 处理 + +### Agent 4:Runtime / Gateway / Targets / Channel Integrations + +负责文件: + +- `electron/utils/channel-status.ts` +- `electron/utils/channels.ts` +- `electron/main.ts` +- `electron/gateway/**` +- `electron/utils/channel-integrations/*` + +职责: + +- local targets +- gateway health summary +- reload / restart 策略 +- runtime 事件广播 +- QR / plugin channel 集成 + +### Agent 5:测试与联调收口 + +负责文件: + +- `tests/unit/**` +- `tests/e2e/**` +- `docs/**` + +职责: + +- 建单测 / E2E 基线 +- 做回归矩阵 +- 跟进 smoke checklist +- 收敛文档和验收口径 + +## 7.3 推荐执行顺序 + +建议顺序: + +1. Agent 3 与 Agent 4 先行 +2. Agent 2 在 schema 稳定后进入 +3. Agent 1 在 API 能返回完整视图后进入 +4. Agent 5 全程跟进,但在 Phase 3 后集中补全 + +原因: + +- 消息频道功能是典型的“后端语义先行”场景。 +- 如果没有 config 模型、runtime 状态和 targets 本地化,前端很容易做成半成品。 + +## 7.4 完全对齐 ClawX 的 sub-agent 数量估算 + +如果目标从“结构性对齐”提升到“功能与运行时行为尽量完全对齐 ClawX”,建议按“不包含主控 Codex”的口径重新估算 sub-agent 数量。 + +建议口径如下: + +1. 最小编制:`5` 个 sub-agent + - 适合完成页面结构、核心 Host API、多账号配置、基础 diagnostics + - 风险是 QR / plugin channel、target 目录发现、自动化测试会相互挤压 +2. 推荐编制:`7` 个 sub-agent + - 适合并行推进后端配置、Host API、runtime/gateway、频道集成、前端控制台、弹窗/i18n、测试收口 + - 这是“期望完全对齐 ClawX”时最均衡的方案 +3. 峰值编制:`8` 个 sub-agent + - 仅建议在 Phase 2 到 Phase 4 短时启用 + - 用于把“核心 token 类 channel 集成”和“QR / plugin channel 集成”拆成两个独立实施包 + +结论: + +- 如果只是完成可演示版本,`5` 个 sub-agent 足够。 +- 如果目标是尽量完全对齐 `ClawX`,推荐采用 `7` 个 sub-agent。 +- 如果要求在较短周期内把 connector 细节与测试一起收口,峰值可以提升到 `8` 个 sub-agent,但不建议长期保持。 + +## 7.5 完全对齐场景下的推荐分工 + +推荐编制:`7` 个 sub-agent + `1` 个主控 Codex。 + +主控 Codex 职责: + +- 维护总计划 +- 冻结接口契约 +- 处理跨 agent 冲突 +- 合并阶段成果 +- 做最终验收与回归判断 + +### Agent A:配置模型与持久化 Owner + +负责范围: + +- `electron/utils/channel-config.ts` +- `electron/utils/agent-config.ts` 中与 `channelOwners` / `channelAccountOwners` 交界的部分 +- `channels.json` 或等价配置模型定义 + +核心任务: + +- 定义多账号配置模型 +- 定义 `defaultAccountId` +- 定义 `enabled` +- 定义 legacy accountId / canonical accountId 兼容规则 +- 定义配置读写与删除语义 + +交付件: + +- 本地频道配置真相源 +- config CRUD 基础能力 +- 配置 schema 文档 + +### Agent B:Host API 与视图拼装 Owner + +负责范围: + +- `electron/api/routes/channels.ts` +- `electron/api/router.ts` +- `electron/main.ts` 中 Host API 优先级分流逻辑 + +核心任务: + +- 扩展 `/api/channels/accounts` +- 扩展 `/api/channels/config/:type` +- 扩展 `/api/channels/config` +- 扩展 `/api/channels/default-account` +- 扩展 `/api/channels/config/enabled` +- 收回 `/api/channels/targets` 的本地优先权 + +交付件: + +- 完整本地 channels Host API +- 视图层所需的统一返回结构 +- upstream fallback 清理方案 + +### Agent C:Runtime / Gateway / Diagnostics Owner + +负责范围: + +- `electron/utils/channel-status.ts` +- `electron/gateway/**` +- `electron/main.ts` +- diagnostics 相关 route / util + +核心任务: + +- 构建 channel runtime status 推断逻辑 +- 接入 gateway health summary +- 定义 reload / restart 策略 +- 广播 `channels` / `channel-targets` / `agents` runtime event +- 提供 diagnostics snapshot + +交付件: + +- 页面可消费的 runtime health 数据 +- 配置保存后的收敛刷新能力 +- diagnostics / restart 链路 + +### Agent D:频道集成 Owner + +负责范围: + +- `electron/utils/channel-integrations/*` +- `electron/utils/wechat-login.ts` +- `electron/utils/whatsapp-login.ts` +- `electron/utils/channel-plugin-install.ts` +- `electron/utils/channels.ts` 中与远程目录发现相关的逻辑 + +核心任务: + +- 对齐 WeChat / WhatsApp 登录流程 +- 对齐 plugin install / detect 流程 +- 对齐 credentials validate +- 对齐 target 目录发现 +- 梳理各 channel 的 `connectionType` 与特殊字段规则 + +交付件: + +- connector 能力矩阵 +- QR / plugin channel 接入层 +- channel-specific validate / target discovery + +说明: + +- 如果进入峰值 `8` 个 sub-agent 模式,优先把 Agent D 拆成两个: +- `D1` 负责 Telegram / Discord / Feishu / QQ Bot 等 token 类 channel +- `D2` 负责 WeChat / WhatsApp / DingTalk / WeCom 等 QR / plugin 类 channel + +### Agent E:Channels 控制台页面 Owner + +负责范围: + +- `src/pages/Channels/index.tsx` +- `src/components/channels/ChannelDiagnosticsPanel.tsx` +- `src/components/channels/ChannelGroupCard.tsx` + +核心任务: + +- 对齐标题区与 refresh +- 对齐 configured / supported 双区块 +- 对齐 health banner +- 对齐 diagnostics 展开 / 复制 +- 对齐删除确认与收敛刷新体验 + +交付件: + +- ClawX 风格的频道控制台页面壳 +- 配置组卡片与账号行展示 +- diagnostics UI + +### Agent F:ChannelConfigModal / 前端 schema / i18n Owner + +负责范围: + +- `src/components/channels/ChannelConfigModal.tsx` +- `src/components/channels/*` +- `src/lib/channel-meta.ts` +- `src/lib/channel-types.ts` +- `src/i18n/messages.ts` 或拆分 locale 文件 + +核心任务: + +- 对齐 channel meta schema +- 对齐 `ChannelConfigModal` +- 对齐 token / QR / docs / instructions +- 对齐 accountId 编辑与校验 +- 对齐频道名称、badge、文案和提示语 + +交付件: + +- 可复用的 channel meta schema +- 可测试的配置弹窗 +- 对齐 ClawX 的文案与交互层 + +### Agent G:测试与联调收口 Owner + +负责范围: + +- `tests/unit/**` +- `tests/e2e/**` +- `docs/**` + +核心任务: + +- 为 route / config / status / page 建立单测 +- 建立 binding / diagnostics / account validation / persistence 的 E2E +- 维护 smoke checklist +- 跟踪阶段性回归 + +交付件: + +- 自动化回归基线 +- 联调问题清单 +- 阶段验收记录 + +## 7.6 按波次推进的开发安排 + +为了减少冲突,建议按 4 个波次推进,而不是 7 个 sub-agent 同时全量开工。 + +### Wave 0:契约冻结 + +参与: + +- 主控 Codex +- Agent A +- Agent B +- Agent C + +目标: + +- 冻结 `channels.json` 模型 +- 冻结 Host API 响应结构 +- 冻结 runtime event topic +- 冻结前端 channel meta schema + +产出: + +- 字段命名表 +- route contract +- runtime contract + +### Wave 1:数据面与本地 Host API + +参与: + +- Agent A +- Agent B +- Agent C + +目标: + +- 让本地能完整提供 `accounts` / `config` / `default-account` / `enabled` / `targets` +- 去掉 `/api/channels/targets` 对 upstream 的优先依赖 + +进入下一波条件: + +- 前端已能拿到稳定的 accounts view +- target 列表本地可用 +- default account 读写闭环 + +### Wave 2:运行时与 channel 集成 + +参与: + +- Agent C +- Agent D + +目标: + +- 接通 diagnostics +- 接通 reload / restart +- 接通 validate +- 接通 QR / plugin channel + +进入下一波条件: + +- 配置保存后能触发正确的 runtime 收敛 +- 至少核心 channel 流程能跑通 + +### Wave 3:页面与弹窗对齐 + +参与: + +- Agent E +- Agent F + +目标: + +- 对齐 ClawX 页面结构 +- 对齐 `ChannelConfigModal` +- 对齐 supported channels 与 configured groups +- 对齐删除、刷新、诊断与弹窗交互 + +进入下一波条件: + +- 消息频道控制台可端到端演示 +- 页面层不再依赖临时 mock 字段 + +### Wave 4:测试收口与完全对齐验收 + +参与: + +- Agent G +- 主控 Codex +- 视需要回拉 Agent C / D / E / F 修复回归 + +目标: + +- 补齐 unit / e2e +- 做 channel parity checklist +- 收敛剩余差距 + +最终验收以两份清单为准: + +1. 功能清单 + - 是否完全覆盖 ClawX 页面、Host API、diagnostics、runtime、绑定、多账号、默认账号、modal、targets +2. channel 清单 + - 是否覆盖 ClawX 当前支持的 channel 类型与对应连接方式 + +## 7.7 并行边界与冲突规避规则 + +为了让 sub-agent 真正能推进开发,而不是互相打架,建议明确以下边界: + +1. Agent A 不直接改前端文件 +2. Agent E / F 不直接改 `electron/api/routes/channels.ts` +3. Agent C 不直接改 `ChannelConfigModal` +4. Agent G 默认不改生产代码,除非明确接手缺陷修复 +5. `electron/main.ts` 只允许 Agent B 与 Agent C 协调改动 +6. `src/lib/channel-types.ts` 只允许 Agent F 主导,其他 agent 通过契约消费 +7. 如果启用峰值 `8` 个 sub-agent,必须把 D1 / D2 的 channel 列表先写死,避免重复改同一 connector + +## 7.8 当前推进状态(2026-04-18) + +本轮已经按推荐编制的一部分开始落地,当前实际分工与产出如下。 + +### 已投入的 sub-agent + +1. 主控 Codex + - 负责总装、接口收敛、页面整合、验证与文档回写 +2. Agent B/C 合流实施 + - 已完成 `channel-config.ts`、`channels.ts`、`agent-config.ts`、`routes/channels.ts`、`routes/gateway.ts`、`main.ts` 的首轮闭环 +3. Agent F + - 已完成 `src/components/channels/*`、`src/lib/channel-types.ts`、`src/lib/channel-meta.ts`、`src/i18n/messages.ts` 的首轮 schema 与弹窗骨架 +4. Agent E + - 已完成 `src/pages/Channels/index.tsx` 的控制台化改造首版 +5. Agent G + - 已完成 `tests/channels.test.ts`、`vitest` 基础接线与首批 util 测试 + +当前等价于已经启用 `5` 个实施向 sub-agent / owner 角色;如果继续追求完全对齐 ClawX,下一轮建议补齐 Agent D,并在测试压力上升时扩展到推荐编制 `7` 个。 + +### 当前已完成事项 + +1. Wave 1 基本完成 + - 本地 `channels.json` 配置真相源已经落地 + - `config/default-account/enabled/delete/targets` 本地 Host API 已闭环 + - `/api/channels/targets` 已切回本地优先 +2. Wave 2 部分完成 + - gateway health summary 已接入 + - channel status 推断已接入 + - runtime changed topic 已补到 `channels/channel-targets/agents` +3. Wave 3 已启动并形成首版可用页面 + - `Channels` 页已升级为“支持的频道 + 已配置频道 + 默认账号 + Agent 绑定 + Gateway 健康”的控制台结构 + - `ChannelConfigModal` 已能承接 channel schema、账号新增、账号编辑和保存 + - 频道矩阵已从业务占位类型切换为 ClawX channel meta + +### 当前仍未完成的差距 + +1. diagnostics snapshot 仍未补齐复制 / 展开面板 +2. `credentials validate` 尚未补齐 +3. WeChat / WhatsApp 的 QR 登录事件链路尚未接入 +4. plugin install / detect 仍未接入 +5. 页面级单测与 E2E 还缺失 +6. 删除后的延迟收敛刷新、gateway 运行态过渡探测,仍弱于 ClawX + +### 下一轮 sub-agent 投入建议 + +如果继续按“完全对齐 ClawX”推进,建议下一轮这样补齐: + +1. Agent D + - 专注 QR / plugin / validate / target discovery +2. Agent G + - 补 channels page unit test 与 diagnostics / binding / account regression E2E +3. 主控 Codex + - 继续负责跨层集成,把 diagnostics 面板、延迟收敛刷新和 page controller 拆分补上 + +## 8. 关键风险与决策点 + +1. `zn-ai` 当前 `/api/channels/targets` 仍有 upstream 优先逻辑 + - 这是本次迁移必须消除的分叉点 +2. 是否立即统一 `agents.json` 与 `channels.json` + - 建议先分开,Phase 3 再评估是否合并 +3. plugin / QR channel 是否首批全部上线 + - 建议先完成架构支持,再按频道类型灰度 +4. reload 与 restart 边界 + - 建议一开始保守,优先正确性 +5. canonical accountId 与 legacy 兼容 + - 必须在 Phase 1 明确规则,否则 UI 与后端会反复打架 +6. WeChat / WhatsApp 的状态目录清理 + - 删除与取消登录要加保护,避免误删 + +## 9. 验收标准 + +迁移完成后,至少满足以下标准: + +1. `zn-ai` 的 `/channels` 具备 ClawX 同级别页面结构 +2. 能本地完成频道与账号配置,不依赖 upstream 才能工作 +3. 支持 default account、多账号配置、binding、targets +4. 支持 gateway diagnostics 与 restart +5. 关键配置保存后能正确收敛到 runtime 状态 +6. Agents 页继续只展示摘要,不回收写操作 +7. 自动化测试覆盖新增账号、校验、诊断、绑定、编辑回归 + +## 10. 本轮建议结论 + +最合理的推进方式不是“一口气把 ClawX 代码整体搬过来”,而是: + +1. 对齐 ClawX 的交互与 API 语义 +2. 复用 `zn-ai` 已有的本地 route、agent store、channel utils +3. 在 `zn-ai` 内补一个真正的频道配置与运行时域 +4. 以 `7` 个 sub-agent 作为完全对齐的推荐编制,按波次推进,先后端、再前端、最后测试收口 +5. 若要压缩周期,可在 Phase 2 到 Phase 4 短时提升到峰值 `8` 个 sub-agent + +这样既能尽量对齐 ClawX,又不会把 `zn-ai` 现有结构全部打散。 diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 086a47b..36d218a 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -12,4 +12,6 @@ - 继续推进,下一步最值的是补 /api/channels/targets 和 provider runtime sync;这两段补上后,zn-ai 会更接近 ClawX 的完整闭环。估算sub-agent数量,安排sub-agent分工推进开发工作,期望完全对齐ClawX -- 替我确认真实 UI 操作流和 gateway 行为已经全部跑通。离“完全对齐 ClawX”还差两块更值钱的尾项:真实远端 target discovery,不只是本地候选合成;以及 provider/runtime 变更后的显式前端刷新事件,而不只是主进程侧同步。估算sub-agent数量,安排sub-agent分工推进开发工作,期望完全对齐ClawX。 \ No newline at end of file +- 替我确认真实 UI 操作流和 gateway 行为已经全部跑通。离“完全对齐 ClawX”还差两块更值钱的尾项:真实远端 target discovery,不只是本地候选合成;以及 provider/runtime 变更后的显式前端刷新事件,而不只是主进程侧同步。估算sub-agent数量,安排sub-agent分工推进开发工作,期望完全对齐ClawX。 + +- 在ClwaX项目中深度分析消息频道功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析消息频道功能实现思路,期望迁移功能对齐ClawX。 \ No newline at end of file diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 95db3dc..f07994f 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -3,9 +3,20 @@ import type { NormalizedHostApiRequest } from '../route-utils'; import { fail, ok, parseJsonBody } from '../route-utils'; import { assignChannelToAgent, + clearAllChannelBindings, clearChannelBinding, listAgentsSnapshot, } from '../../utils/agent-config'; +import { + getChannelFormValues, + hasStoredChannelAccount, + isCanonicalChannelAccountId, + listStoredChannelTypes, + saveChannelConfig, + setChannelDefaultAccount, + setChannelEnabled, + deleteChannelConfig, +} from '../../utils/channel-config'; import { listSelectedChannelAccountGroups, listSelectedChannelTargets, @@ -34,6 +45,13 @@ export async function handleChannelRoutes( }); } + if (pathname === '/api/channels/configured' && method === 'GET') { + return ok({ + success: true, + channels: listStoredChannelTypes(), + }); + } + if (pathname === '/api/channels/targets' && method === 'GET') { const channelType = request.url.searchParams.get('channelType')?.trim() || ''; const accountId = request.url.searchParams.get('accountId')?.trim() || null; @@ -49,6 +67,67 @@ export async function handleChannelRoutes( }); } + if (pathname === '/api/channels/default-account' && method === 'PUT') { + try { + const body = parseJsonBody<{ + channelType?: string; + accountId?: string | null; + }>(request.body); + const channelType = String(body?.channelType ?? '').trim(); + const accountId = String(body?.accountId ?? '').trim(); + + if (!channelType) { + return fail(400, 'channelType is required'); + } + if (!accountId) { + return fail(400, 'accountId is required'); + } + + setChannelDefaultAccount(channelType, accountId); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['channels', 'channel-targets', 'agents'], + reason: 'channels:default-account-updated', + channelType, + accountId, + }); + + return ok({ + success: true, + channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (pathname === '/api/channels/config/enabled' && method === 'PUT') { + try { + const body = parseJsonBody<{ + channelType?: string; + enabled?: boolean; + }>(request.body); + const channelType = String(body?.channelType ?? '').trim(); + + if (!channelType) { + return fail(400, 'channelType is required'); + } + + setChannelEnabled(channelType, body?.enabled !== false); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['channels', 'channel-targets', 'agents'], + reason: 'channels:enabled-updated', + channelType, + }); + + return ok({ + success: true, + channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + if (pathname === '/api/channels/binding' && method === 'PUT') { try { const body = parseJsonBody<{ @@ -96,6 +175,56 @@ export async function handleChannelRoutes( } } + if (pathname === '/api/channels/config' && method === 'POST') { + try { + const body = parseJsonBody<{ + channelType?: string; + accountId?: string | null; + channelLabel?: string | null; + accountName?: string | null; + channelUrl?: string | null; + enabled?: boolean; + config?: Record; + metadata?: Record; + }>(request.body); + const channelType = String(body?.channelType ?? '').trim(); + const accountId = String(body?.accountId ?? '').trim(); + + if (!channelType) { + return fail(400, 'channelType is required'); + } + + if (accountId && !isCanonicalChannelAccountId(accountId) && !hasStoredChannelAccount(channelType, accountId)) { + return fail(400, 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'); + } + + saveChannelConfig({ + channelType, + accountId: accountId || undefined, + channelLabel: body?.channelLabel, + accountName: body?.accountName, + channelUrl: body?.channelUrl, + enabled: body?.enabled, + config: body?.config, + metadata: body?.metadata, + }); + + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['channels', 'channel-targets', 'agents'], + reason: 'channels:config-saved', + channelType, + accountId: accountId || undefined, + }); + + return ok({ + success: true, + channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + if (pathname === '/api/channels/binding' && method === 'DELETE') { try { const body = parseJsonBody<{ @@ -129,5 +258,46 @@ export async function handleChannelRoutes( } } + if (pathname.startsWith('/api/channels/config/') && method === 'GET') { + try { + const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length)); + const accountId = request.url.searchParams.get('accountId')?.trim() || null; + return ok({ + success: true, + values: getChannelFormValues(channelType, accountId) ?? {}, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (pathname.startsWith('/api/channels/config/') && method === 'DELETE') { + try { + const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length)); + const accountId = request.url.searchParams.get('accountId')?.trim() || null; + + deleteChannelConfig(channelType, accountId); + if (accountId) { + clearChannelBinding(channelType, accountId, accounts, defaultAccountId); + } else { + clearAllChannelBindings(channelType, accounts, defaultAccountId); + } + + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['channels', 'channel-targets', 'agents'], + reason: 'channels:config-deleted', + channelType, + accountId: accountId || undefined, + }); + + return ok({ + success: true, + channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + return null; } diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts index 6cd5bfd..c7b9649 100644 --- a/electron/api/routes/gateway.ts +++ b/electron/api/routes/gateway.ts @@ -1,6 +1,19 @@ import type { HostApiContext } from '../context'; import type { NormalizedHostApiRequest } from '../route-utils'; import { fail, ok } from '../route-utils'; +import { buildGatewayDiagnosticsSummary } from '../../gateway/diagnostics'; +import { listAgentsSnapshot } from '../../utils/agent-config'; +import { listSelectedChannelAccountGroups } from '../../utils/channels'; + +function buildChannelGroups(ctx: HostApiContext) { + const accounts = ctx.providerApiService + .getAccounts() + .filter((account) => account.enabled !== false); + const defaultAccountId = ctx.providerApiService.getDefault().accountId; + const snapshot = listAgentsSnapshot(accounts, defaultAccountId); + + return listSelectedChannelAccountGroups(snapshot); +} export async function handleGatewayRoutes( request: NormalizedHostApiRequest, @@ -9,21 +22,31 @@ export async function handleGatewayRoutes( const { pathname, method } = request; if (pathname === '/api/app/gateway-info' && method === 'GET') { - const status = ctx.gatewayManager.getStatus(); + const health = await ctx.gatewayManager.checkHealth(); + const summary = buildGatewayDiagnosticsSummary(health, buildChannelGroups(ctx)); return ok({ transport: 'ipc-bridge', rpcChannel: 'gateway:rpc', eventChannel: 'gateway:event', - ...status, + ...health, + summary, }); } if (pathname === '/api/gateway/status' && method === 'GET') { - return ok(ctx.gatewayManager.getStatus()); + const status = await ctx.gatewayManager.checkHealth(); + return ok({ + ...status, + summary: buildGatewayDiagnosticsSummary(status, buildChannelGroups(ctx)), + }); } if (pathname === '/api/gateway/health' && method === 'GET') { - return ok(await ctx.gatewayManager.checkHealth()); + const health = await ctx.gatewayManager.checkHealth(); + return ok({ + ...health, + summary: buildGatewayDiagnosticsSummary(health, buildChannelGroups(ctx)), + }); } if (pathname === '/api/gateway/start' && method === 'POST') { diff --git a/electron/gateway/diagnostics.ts b/electron/gateway/diagnostics.ts new file mode 100644 index 0000000..f01677b --- /dev/null +++ b/electron/gateway/diagnostics.ts @@ -0,0 +1,40 @@ +import type { ChannelAccountCatalogGroup, ChannelConnectionStatus } from '@src/lib/channel-types'; +import { buildChannelStatusSummary, type ChannelStatusSummary } from '@electron/utils/channel-status'; + +export interface GatewayHealthSnapshot { + ok: boolean; + status: 'connected' | 'disconnected' | 'reconnecting'; + initialized: boolean; + mode: 'in-process'; +} + +export interface GatewayDiagnosticsSummary { + status: ChannelConnectionStatus; + gateway: GatewayHealthSnapshot; + channels: ChannelStatusSummary; +} + +function normalizeGatewayStatus(status: GatewayHealthSnapshot['status']): ChannelConnectionStatus { + if (status === 'reconnecting') return 'connecting'; + return status; +} + +export function buildGatewayDiagnosticsSummary( + health: GatewayHealthSnapshot, + channelGroups: readonly ChannelAccountCatalogGroup[], +): GatewayDiagnosticsSummary { + const channels = buildChannelStatusSummary(channelGroups); + const gatewayStatus = normalizeGatewayStatus(health.status); + + return { + status: health.ok + ? (channels.status === 'connected' + ? 'connected' + : channels.status === 'disconnected' + ? 'degraded' + : channels.status) + : gatewayStatus, + gateway: health, + channels, + }; +} diff --git a/electron/main.ts b/electron/main.ts index 1a271b5..6fcf19b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -35,11 +35,6 @@ function refreshProviderRuntime(): { warnings: string[] } { } } -function shouldPreferUpstreamHostApi(path: string, method: string): boolean { - const pathname = new URL(path, 'http://127.0.0.1').pathname; - return method.toUpperCase() === 'GET' && pathname === '/api/channels/targets'; -} - async function requestUpstreamHostApi(path: string, method: string, headers: Record | undefined, body: unknown) { const url = `${HOST_API_BASE_URL}${path}`; try { @@ -81,13 +76,6 @@ async function requestUpstreamHostApi(path: string, method: string, headers: Rec ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => { const normalizedMethod = method || 'GET'; - if (shouldPreferUpstreamHostApi(path, normalizedMethod)) { - const upstreamPreferred = await requestUpstreamHostApi(path, normalizedMethod, headers, body); - if (upstreamPreferred.success !== false && upstreamPreferred.ok !== false) { - return upstreamPreferred; - } - } - // 1. 优先本地处理 Host API 路由(逐步对齐 ClawX) const localResult = await dispatchLocalHostApi({ path, diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 5f9c7d3..9e6bf68 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -4,6 +4,7 @@ import type { ProviderAccount } from '@runtime/lib/providers'; import { DEFAULT_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX, type AgentSummary, type AgentsSnapshot } from '@runtime/lib/agents'; import { buildMainSessionKey, normalizeAgentId } from '@runtime/lib/models'; import { getUserDataDir } from './paths'; +import { listStoredChannelTypes } from './channel-config'; interface StoredAgentEntry { id: string; @@ -237,6 +238,7 @@ function buildSnapshotFromStore( ]; const configuredChannelTypes = Array.from(new Set([ + ...listStoredChannelTypes(), ...Object.keys(channelOwners), ...Object.keys(channelAccountOwners).map((key) => key.split(':')[0]).filter(Boolean), ])); @@ -498,3 +500,28 @@ export function clearChannelBinding( writeStore(store); return buildSnapshotFromStore(store, accounts, defaultAccountId); } + +export function clearAllChannelBindings( + channelType: string, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedChannelType = String(channelType ?? '').trim(); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const store = readStore(); + const nextChannelOwners = { ...(store.channelOwners ?? {}) }; + delete nextChannelOwners[normalizedChannelType]; + store.channelOwners = nextChannelOwners; + + const nextChannelAccountOwners = Object.fromEntries( + Object.entries(store.channelAccountOwners ?? {}).filter(([key]) => !key.startsWith(`${normalizedChannelType}:`)), + ); + store.channelAccountOwners = nextChannelAccountOwners; + + syncAgentChannelMembership(store, normalizedChannelType); + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts new file mode 100644 index 0000000..8346189 --- /dev/null +++ b/electron/utils/channel-config.ts @@ -0,0 +1,391 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getUserDataDir } from './paths'; + +export const CHANNEL_STORE_FILE_NAME = 'channels.json'; +export const DEFAULT_CHANNEL_ACCOUNT_ID = 'default'; + +export interface StoredChannelAccountEntry { + accountId: string; + name?: string | null; + channelUrl?: string | null; + enabled?: boolean; + config?: Record; + metadata?: Record; + createdAt?: string; + updatedAt?: string; +} + +export interface StoredChannelEntry { + channelType: string; + channelLabel?: string | null; + defaultAccountId?: string | null; + enabled?: boolean; + accounts?: Record; + createdAt?: string; + updatedAt?: string; +} + +interface StoredChannelsDocument { + channels?: Record; +} + +export interface StoredChannelAccountRecord { + channelType: string; + channelLabel: string; + defaultAccountId: string; + channelEnabled: boolean; + accountId: string; + accountName: string; + accountEnabled: boolean; + channelUrl?: string; + config: Record; + metadata: Record; +} + +function getStorePath(): string { + return path.join(getUserDataDir(), CHANNEL_STORE_FILE_NAME); +} + +function formatChannelLabel(channelType: string, fallback?: string | null): string { + const preferred = String(fallback ?? '').trim(); + if (preferred) return preferred; + + const parts = String(channelType ?? '') + .split(/[-_]/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return String(channelType ?? '').trim(); + } + + return parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function normalizeChannelType(value: string): string { + return String(value ?? '').trim().toLowerCase(); +} + +function normalizeAccountId(value: string | null | undefined): string { + const trimmed = String(value ?? '').trim(); + return trimmed || DEFAULT_CHANNEL_ACCOUNT_ID; +} + +function ensureStoreDir(): void { + fs.mkdirSync(path.dirname(getStorePath()), { recursive: true }); +} + +function readStore(): StoredChannelsDocument { + try { + const filePath = getStorePath(); + if (!fs.existsSync(filePath)) { + return { channels: {} }; + } + + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredChannelsDocument; + return { + channels: parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {}, + }; + } catch { + return { channels: {} }; + } +} + +function writeStore(store: StoredChannelsDocument): void { + ensureStoreDir(); + fs.writeFileSync(getStorePath(), JSON.stringify(store, null, 2), 'utf-8'); +} + +function ensureChannelEntry( + store: StoredChannelsDocument, + channelType: string, +): StoredChannelEntry { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + if (!store.channels) { + store.channels = {}; + } + + const existing = store.channels[normalizedChannelType]; + if (existing) { + if (!existing.accounts || typeof existing.accounts !== 'object') { + existing.accounts = {}; + } + existing.channelType = normalizedChannelType; + existing.channelLabel = formatChannelLabel(normalizedChannelType, existing.channelLabel); + existing.defaultAccountId = normalizeAccountId(existing.defaultAccountId); + existing.enabled = existing.enabled !== false; + return existing; + } + + const now = new Date().toISOString(); + const created: StoredChannelEntry = { + channelType: normalizedChannelType, + channelLabel: formatChannelLabel(normalizedChannelType), + defaultAccountId: DEFAULT_CHANNEL_ACCOUNT_ID, + enabled: true, + accounts: {}, + createdAt: now, + updatedAt: now, + }; + store.channels[normalizedChannelType] = created; + return created; +} + +function ensureAccountEntry( + channel: StoredChannelEntry, + accountId: string, +): StoredChannelAccountEntry { + const normalizedAccountId = normalizeAccountId(accountId); + if (!channel.accounts || typeof channel.accounts !== 'object') { + channel.accounts = {}; + } + + const existing = channel.accounts[normalizedAccountId]; + if (existing) { + existing.accountId = normalizedAccountId; + existing.enabled = existing.enabled !== false; + return existing; + } + + const now = new Date().toISOString(); + const created: StoredChannelAccountEntry = { + accountId: normalizedAccountId, + name: normalizedAccountId, + enabled: true, + config: {}, + metadata: {}, + createdAt: now, + updatedAt: now, + }; + channel.accounts[normalizedAccountId] = created; + return created; +} + +function coerceFormValues(config: Record | undefined): Record | undefined { + if (!config || typeof config !== 'object') return undefined; + + const values: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (value == null) continue; + if (typeof value === 'string') { + values[key] = value; + continue; + } + if (typeof value === 'number' || typeof value === 'boolean') { + values[key] = String(value); + } + } + + return Object.keys(values).length > 0 ? values : undefined; +} + +export function isCanonicalChannelAccountId(value: string): boolean { + return /^[a-z0-9](?:[a-z0-9_-]{0,63})$/.test(String(value ?? '').trim()); +} + +export function listStoredChannelTypes(): string[] { + return Object.keys(readStore().channels ?? {}).sort((left, right) => left.localeCompare(right, 'zh-CN')); +} + +export function hasStoredChannelAccount(channelType: string, accountId?: string | null): boolean { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) return false; + + const channel = readStore().channels?.[normalizedChannelType]; + if (!channel) return false; + + const normalizedAccountId = normalizeAccountId(accountId); + return Boolean(channel.accounts?.[normalizedAccountId]); +} + +export function listStoredChannelAccountRecords(): StoredChannelAccountRecord[] { + const store = readStore(); + const records: StoredChannelAccountRecord[] = []; + + for (const [rawChannelType, rawChannel] of Object.entries(store.channels ?? {})) { + const channelType = normalizeChannelType(rawChannelType); + if (!channelType || !rawChannel || typeof rawChannel !== 'object') continue; + + const channelLabel = formatChannelLabel(channelType, rawChannel.channelLabel); + const channelEnabled = rawChannel.enabled !== false; + const accounts = rawChannel.accounts && typeof rawChannel.accounts === 'object' + ? rawChannel.accounts + : {}; + const sortedAccountIds = Object.keys(accounts).sort((left, right) => { + if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1; + if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1; + return left.localeCompare(right, 'zh-CN'); + }); + + const defaultAccountId = normalizeAccountId( + rawChannel.defaultAccountId && sortedAccountIds.includes(normalizeAccountId(rawChannel.defaultAccountId)) + ? rawChannel.defaultAccountId + : sortedAccountIds[0], + ); + + for (const accountId of sortedAccountIds) { + const account = accounts[accountId]; + if (!account || typeof account !== 'object') continue; + + records.push({ + channelType, + channelLabel, + defaultAccountId, + channelEnabled, + accountId, + accountName: String(account.name ?? accountId).trim() || accountId, + accountEnabled: account.enabled !== false, + channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined, + config: account.config && typeof account.config === 'object' ? account.config : {}, + metadata: account.metadata && typeof account.metadata === 'object' ? account.metadata : {}, + }); + } + } + + return records.sort((left, right) => { + if (left.channelLabel !== right.channelLabel) { + return left.channelLabel.localeCompare(right.channelLabel, 'zh-CN'); + } + return left.accountName.localeCompare(right.accountName, 'zh-CN'); + }); +} + +export function getChannelFormValues( + channelType: string, + accountId?: string | null, +): Record | undefined { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) return undefined; + + const channel = readStore().channels?.[normalizedChannelType]; + if (!channel) return undefined; + + const account = channel.accounts?.[normalizeAccountId(accountId)]; + if (!account) return undefined; + + return coerceFormValues(account.config); +} + +export function saveChannelConfig(input: { + channelType: string; + accountId?: string | null; + channelLabel?: string | null; + accountName?: string | null; + channelUrl?: string | null; + enabled?: boolean; + config?: Record; + metadata?: Record; +}): StoredChannelEntry { + const normalizedChannelType = normalizeChannelType(input.channelType); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const normalizedAccountId = normalizeAccountId(input.accountId); + const store = readStore(); + const channel = ensureChannelEntry(store, normalizedChannelType); + const account = ensureAccountEntry(channel, normalizedAccountId); + const now = new Date().toISOString(); + + channel.channelLabel = formatChannelLabel(normalizedChannelType, input.channelLabel ?? channel.channelLabel); + channel.enabled = input.enabled ?? channel.enabled ?? true; + channel.defaultAccountId = normalizeAccountId(channel.defaultAccountId || normalizedAccountId); + channel.updatedAt = now; + + account.name = String(input.accountName ?? account.name ?? normalizedAccountId).trim() || normalizedAccountId; + account.channelUrl = typeof input.channelUrl === 'string' + ? input.channelUrl.trim() || undefined + : account.channelUrl ?? undefined; + account.enabled = input.enabled ?? account.enabled ?? true; + account.config = input.config && typeof input.config === 'object' ? input.config : {}; + account.metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : (account.metadata ?? {}); + account.updatedAt = now; + + if (!channel.accounts || Object.keys(channel.accounts).length === 1) { + channel.defaultAccountId = normalizedAccountId; + } + + writeStore(store); + return channel; +} + +export function setChannelDefaultAccount(channelType: string, accountId: string): StoredChannelEntry { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const normalizedAccountId = normalizeAccountId(accountId); + const store = readStore(); + const channel = ensureChannelEntry(store, normalizedChannelType); + if (!channel.accounts?.[normalizedAccountId]) { + throw new Error(`Channel account "${normalizedChannelType}:${normalizedAccountId}" not found`); + } + + channel.defaultAccountId = normalizedAccountId; + channel.updatedAt = new Date().toISOString(); + writeStore(store); + return channel; +} + +export function setChannelEnabled(channelType: string, enabled: boolean): StoredChannelEntry { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const store = readStore(); + const channel = ensureChannelEntry(store, normalizedChannelType); + channel.enabled = Boolean(enabled); + channel.updatedAt = new Date().toISOString(); + writeStore(store); + return channel; +} + +export function deleteChannelConfig(channelType: string, accountId?: string | null): void { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const store = readStore(); + const channels = store.channels ?? {}; + const channel = channels[normalizedChannelType]; + if (!channel) return; + + const normalizedAccountId = accountId == null ? '' : normalizeAccountId(accountId); + if (!normalizedAccountId) { + delete channels[normalizedChannelType]; + writeStore(store); + return; + } + + if (channel.accounts?.[normalizedAccountId]) { + delete channel.accounts[normalizedAccountId]; + } + + const remainingAccountIds = Object.keys(channel.accounts ?? {}); + if (remainingAccountIds.length === 0) { + delete channels[normalizedChannelType]; + writeStore(store); + return; + } + + if (!remainingAccountIds.includes(normalizeAccountId(channel.defaultAccountId))) { + channel.defaultAccountId = remainingAccountIds.sort((left, right) => { + if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1; + if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1; + return left.localeCompare(right, 'zh-CN'); + })[0]; + } + + channel.updatedAt = new Date().toISOString(); + writeStore(store); +} diff --git a/electron/utils/channel-status.ts b/electron/utils/channel-status.ts new file mode 100644 index 0000000..9973a7d --- /dev/null +++ b/electron/utils/channel-status.ts @@ -0,0 +1,184 @@ +import type { ChannelAccountCatalogGroup, ChannelConnectionStatus } from '@src/lib/channel-types'; + +const KNOWN_CHANNEL_STATUSES: ChannelConnectionStatus[] = [ + 'connected', + 'connecting', + 'disconnected', + 'error', + 'degraded', +]; + +export interface ChannelStatusInferenceInput { + status?: unknown; + configured?: boolean; + channelUrl?: string | null; + lastError?: string | null; + error?: unknown; + warnings?: readonly string[] | null; + warningCount?: number; + isConnecting?: boolean; + isLoading?: boolean; + connectionState?: unknown; + hasBinding?: boolean; + degraded?: boolean; +} + +export interface ChannelStatusSummary { + status: ChannelConnectionStatus; + counts: Record; + groupCount: number; + accountCount: number; +} + +function isMeaningfulText(value: unknown): boolean { + return String(value ?? '').trim().length > 0; +} + +function isValidUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +export function normalizeChannelConnectionStatus(value: unknown): ChannelConnectionStatus | null { + const normalized = String(value ?? '').trim().toLowerCase(); + return KNOWN_CHANNEL_STATUSES.includes(normalized as ChannelConnectionStatus) + ? (normalized as ChannelConnectionStatus) + : null; +} + +export function inferChannelConnectionStatus( + input: ChannelStatusInferenceInput = {}, +): ChannelConnectionStatus { + const explicitStatus = normalizeChannelConnectionStatus(input.status); + const normalizedConnectionState = normalizeChannelConnectionStatus(input.connectionState); + const channelUrl = String(input.channelUrl ?? '').trim(); + const warningCount = typeof input.warningCount === 'number' + ? input.warningCount + : Array.isArray(input.warnings) + ? input.warnings.filter(isMeaningfulText).length + : 0; + + if (isMeaningfulText(input.error) || isMeaningfulText(input.lastError)) { + return 'error'; + } + + if (explicitStatus === 'error' || normalizedConnectionState === 'error') { + return 'error'; + } + + if (input.configured === false || !channelUrl) { + return 'disconnected'; + } + + if (!isValidUrl(channelUrl)) { + return 'error'; + } + + if ( + explicitStatus === 'connecting' + || normalizedConnectionState === 'connecting' + || input.isConnecting + || input.isLoading + ) { + return 'connecting'; + } + + if (explicitStatus === 'disconnected' || normalizedConnectionState === 'disconnected') { + return 'disconnected'; + } + + if (explicitStatus === 'degraded' || normalizedConnectionState === 'degraded') { + return 'degraded'; + } + + if (warningCount > 0 || input.degraded || input.hasBinding === false) { + return 'degraded'; + } + + if (explicitStatus === 'connected') { + return 'connected'; + } + + return 'connected'; +} + +export function summarizeChannelConnectionStatuses( + statuses: readonly ChannelConnectionStatus[], +): ChannelConnectionStatus { + const counts = statuses.reduce>( + (acc, status) => { + acc[status] += 1; + return acc; + }, + { + connected: 0, + connecting: 0, + disconnected: 0, + error: 0, + degraded: 0, + }, + ); + + if (counts.error > 0) { + return counts.connected > 0 || counts.connecting > 0 || counts.degraded > 0 || counts.disconnected > 0 + ? 'degraded' + : 'error'; + } + + if (counts.connecting > 0) { + return 'connecting'; + } + + if (counts.degraded > 0) { + return 'degraded'; + } + + if (counts.disconnected > 0 && counts.connected === 0) { + return 'disconnected'; + } + + if (counts.connected > 0 && counts.disconnected > 0) { + return 'degraded'; + } + + return counts.connected > 0 ? 'connected' : 'disconnected'; +} + +export function buildChannelStatusSummary( + groups: readonly ChannelAccountCatalogGroup[], +): ChannelStatusSummary { + const counts: Record = { + connected: 0, + connecting: 0, + disconnected: 0, + error: 0, + degraded: 0, + }; + + let accountCount = 0; + + for (const group of groups) { + accountCount += group.accounts.length; + for (const account of group.accounts) { + counts[account.status] += 1; + } + } + + const accountStatuses: ChannelConnectionStatus[] = []; + for (const group of groups) { + for (const account of group.accounts) { + accountStatuses.push(account.status); + } + } + + return { + status: summarizeChannelConnectionStatuses(accountStatuses), + counts, + groupCount: groups.length, + accountCount, + }; +} diff --git a/electron/utils/channels.ts b/electron/utils/channels.ts index 3c37b96..2d3f92e 100644 --- a/electron/utils/channels.ts +++ b/electron/utils/channels.ts @@ -2,6 +2,11 @@ import { CONFIG_KEYS } from '@runtime/lib/constants'; import { normalizeAgentId, type AgentsSnapshot } from '@runtime/lib/models'; import configManager from '@electron/service/config-service'; import { listCronJobs } from './cron-store'; +import { listStoredChannelAccountRecords } from './channel-config'; +import { + buildChannelStatusSummary, + inferChannelConnectionStatus, +} from './channel-status'; import type { ChannelAccountCatalogGroup, ChannelConnectionStatus, @@ -23,6 +28,12 @@ export interface LocalChannelAccount { channelName: string; channelUrl: string; label: string; + configured: boolean; + enabled: boolean; + channelEnabled: boolean; + status: ChannelConnectionStatus; + isDefault: boolean; + lastError?: string; ownerAgentId: string | null; ownerAgentName: string | null; bindingScope: 'account' | 'channel' | null; @@ -280,32 +291,93 @@ export function getSelectedChannelsConfig(): SelectedChannelConfigItem[] { } export function listSelectedChannelAccounts(snapshot?: Pick): LocalChannelAccount[] { - const channels = getSelectedChannelsConfig(); const agentNameById = new Map( Array.isArray(snapshot?.agents) ? snapshot.agents.map((agent) => [normalizeAgentId(agent.id), agent.name || normalizeAgentId(agent.id)]) : [], ); + const accounts = new Map(); - return channels.map((item) => { + for (const record of listStoredChannelAccountRecords()) { + const accountOwnerKey = `${record.channelType}:${record.accountId}`; + const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey]; + const channelOwnerId = snapshot?.channelOwners?.[record.channelType]; + const ownerAgentId = accountOwnerId || channelOwnerId || null; + const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null; + const key = `${record.channelType}:${record.accountId}`; + const status = inferChannelConnectionStatus({ + configured: true, + channelUrl: record.channelUrl ?? '', + status: record.channelEnabled && record.accountEnabled ? 'connected' : 'disconnected', + hasBinding: Boolean(accountOwnerId || channelOwnerId), + degraded: !record.channelEnabled || !record.accountEnabled, + }); + + accounts.set(key, { + id: record.accountId, + accountId: record.accountId, + channelType: record.channelType, + channelName: record.channelLabel, + channelUrl: record.channelUrl ?? '', + label: record.accountName || record.accountId, + configured: true, + enabled: record.channelEnabled && record.accountEnabled, + channelEnabled: record.channelEnabled, + status, + isDefault: record.accountId === record.defaultAccountId, + lastError: status === 'error' + ? '渠道链接格式无效' + : undefined, + ownerAgentId: normalizedOwnerId, + ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null, + bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null, + }); + } + + const legacyChannels = getSelectedChannelsConfig(); + for (const item of legacyChannels) { const channelType = inferChannelType(item); + const key = `${channelType}:${item.id}`; + if (accounts.has(key)) continue; + const accountOwnerKey = `${channelType}:${item.id}`; const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey]; const channelOwnerId = snapshot?.channelOwners?.[channelType]; const ownerAgentId = accountOwnerId || channelOwnerId || null; const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null; + const status = inferChannelConnectionStatus({ + configured: true, + channelUrl: item.channelUrl, + status: ownerAgentId ? 'connected' : undefined, + hasBinding: Boolean(accountOwnerId || channelOwnerId), + }); - return { + accounts.set(key, { id: item.id, accountId: item.id, channelType, channelName: item.channelName, channelUrl: item.channelUrl, label: item.channelName, + configured: true, + enabled: true, + channelEnabled: true, + status, + isDefault: false, + lastError: status === 'error' + ? '渠道链接格式无效' + : undefined, ownerAgentId: normalizedOwnerId, ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null, bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null, - }; + }); + } + + return Array.from(accounts.values()).sort((left, right) => { + if (left.channelName !== right.channelName) { + return left.channelName.localeCompare(right.channelName, 'zh-CN'); + } + return left.label.localeCompare(right.label, 'zh-CN'); }); } @@ -319,22 +391,38 @@ export function listSelectedChannelAccountGroups( const existing = groups.get(account.channelType) ?? { channelType: account.channelType, channelLabel: account.channelName || formatChannelLabel(account.channelType), - defaultAccountId: account.accountId, - status: 'connected' as ChannelConnectionStatus, + defaultAccountId: account.isDefault ? account.accountId : '', + enabled: account.channelEnabled, + status: account.status, accounts: [], }; + existing.enabled = existing.enabled !== false && account.channelEnabled; existing.accounts.push({ accountId: account.accountId, name: account.label || account.channelName || account.accountId, - configured: true, - status: 'connected', - isDefault: false, + configured: account.configured, + enabled: account.enabled, + status: account.status, + lastError: account.lastError, + isDefault: account.isDefault, agentId: account.ownerAgentId ?? undefined, bindingScope: account.bindingScope ?? undefined, channelUrl: account.channelUrl, }); + if (!existing.defaultAccountId && account.isDefault) { + existing.defaultAccountId = account.accountId; + } + + if (existing.status !== 'error' && account.status === 'error') { + existing.status = 'error'; + } else if (existing.status === 'disconnected' && account.status !== 'disconnected') { + existing.status = account.status; + } else if (existing.status !== 'connected' && account.status === 'connected') { + existing.status = 'connected'; + } + groups.set(account.channelType, existing); } @@ -342,13 +430,20 @@ export function listSelectedChannelAccountGroups( .map((group) => { const sortedAccounts = [...group.accounts].sort((left, right) => left.name.localeCompare(right.name)); const defaultAccountId = group.defaultAccountId || sortedAccounts[0]?.accountId || 'default'; + const status = buildChannelStatusSummary([ + { + ...group, + accounts: sortedAccounts, + }, + ]).status; return { ...group, defaultAccountId, + status, accounts: sortedAccounts.map((account) => ({ ...account, - isDefault: account.accountId === defaultAccountId, + isDefault: account.isDefault || account.accountId === defaultAccountId, })), }; }) diff --git a/package.json b/package.json index dd7cce1..7362a85 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "vite", "build": "vite build && electron-builder", "build:vite": "vite build", + "test": "vitest run", "smoke:agents": "node scripts/agents-runtime-smoke.mjs", "package": "vite build", "icons": "zx scripts/generate-icons.mjs", diff --git a/src/components/channels/ChannelAccountIdField.tsx b/src/components/channels/ChannelAccountIdField.tsx new file mode 100644 index 0000000..fd037a2 --- /dev/null +++ b/src/components/channels/ChannelAccountIdField.tsx @@ -0,0 +1,34 @@ +type ChannelAccountIdFieldProps = { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + helpText?: string; + disabled?: boolean; +}; + +export default function ChannelAccountIdField({ + label, + value, + onChange, + placeholder, + helpText, + disabled, +}: ChannelAccountIdFieldProps) { + return ( +
+
{label}
+ onChange(event.target.value)} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100" + /> + {helpText ? ( +
{helpText}
+ ) : null} +
+ ); +} diff --git a/src/components/channels/ChannelConfigActions.tsx b/src/components/channels/ChannelConfigActions.tsx new file mode 100644 index 0000000..8927ba4 --- /dev/null +++ b/src/components/channels/ChannelConfigActions.tsx @@ -0,0 +1,39 @@ +type ChannelConfigActionsProps = { + cancelLabel: string; + confirmLabel: string; + onClose: () => void; + onConfirm: () => void; + disabled?: boolean; + submitting?: boolean; +}; + +export default function ChannelConfigActions({ + cancelLabel, + confirmLabel, + onClose, + onConfirm, + disabled, + submitting, +}: ChannelConfigActionsProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/components/channels/ChannelConfigFields.tsx b/src/components/channels/ChannelConfigFields.tsx new file mode 100644 index 0000000..ed7f655 --- /dev/null +++ b/src/components/channels/ChannelConfigFields.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react'; +import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types'; +import ChannelAccountIdField from './ChannelAccountIdField'; +import ChannelTokenField from './ChannelTokenField'; + +type ChannelConfigFieldsProps = { + fields: ChannelConfigFieldMeta[]; + values: ChannelConfigFieldValueMap; + onValueChange: (key: string, value: string) => void; + disabled?: boolean; + accountIdLabel: string; + accountIdHelpText?: string; + tokenHelpText?: string; +}; + +function renderField({ + field, + value, + onChange, + disabled, + accountIdLabel, + accountIdHelpText, + tokenHelpText, +}: { + field: ChannelConfigFieldMeta; + value: string; + onChange: (nextValue: string) => void; + disabled?: boolean; + accountIdLabel: string; + accountIdHelpText?: string; + tokenHelpText?: string; +}): ReactNode { + const commonProps = { + label: field.label, + value, + onChange, + placeholder: field.placeholder, + helpText: field.description, + disabled, + }; + + if (field.key === 'accountId') { + return ( + + ); + } + + if (field.kind === 'token' || field.kind === 'password') { + return ; + } + + if (field.kind === 'textarea') { + return ( +
+
{field.label}
+