diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 5778cd7..4fe753f 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-Bp9J8VEe.js"); +require("./main-CK4u0iKH.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/dist/index.html b/dist/index.html index d53804b..5c1a562 100644 --- a/dist/index.html +++ b/dist/index.html @@ -8,8 +8,8 @@ http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://8.138.234.141 https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com; connect-src 'self' http://8.138.234.141 https://api.iconify.design wss://onefeel.brother7.cn" /> - - + +
diff --git a/docs/ClawX-Agents-Migration-Plan.md b/docs/ClawX-Agents-Migration-Plan.md new file mode 100644 index 0000000..9310e81 --- /dev/null +++ b/docs/ClawX-Agents-Migration-Plan.md @@ -0,0 +1,546 @@ +# ClawX Agents 功能迁移到 zn-ai 的更新版计划 + +## 1. 背景与本次修正 + +本版文档基于两条新的约束重新整理: + +- 最新 Agents 参考图已经明确,页面信息架构必须回到 `ClawX` 的列表主视图。 +- 开发需求已经明显变化,后续实现必须严格对齐 `ClawX` Agents 功能,而不是继续延展一版“更强的 Models/Agent 控制台”。 + +这意味着上一版计划里最重要的一条判断需要作废: + +- 上一版曾把 `zn-ai` 的 `/agents` 页面导向“列表 + 固定右侧详情”的控制台详情页。 +- 这个方向与最新参考图和 `ClawX` 真实交互不一致,后续不应继续推进。 + +本轮统一后的基线结论如下: + +1. `/agents` 必须保持独立路由与侧边栏入口,不能再并回 `/models`。 +2. 页面 IA 必须切回 `ClawX` 的“列表主视图”。 +3. Agents 页主内容只保留卡片摘要、刷新、新增和设置入口,不把设置详情常驻在主布局中。 +4. `Add Agent` 必须是独立弹窗,且字段极简,只收: + - `name` + - `inheritWorkspace` +5. Settings 详情建议改为 `modal` 或 `sheet`,而不是主布局双栏右侧 inspector。 +6. Channels 继续作为 channel/account 归属绑定的唯一写入口。 +7. Models 继续负责 Provider、默认模型、Usage,不承载 Agent 实体管理。 +8. Home/Chat/Cron 继续作为 Agents 域的消费页面,不反向定义 Agents 页形态。 + +一句话总结: + +- 当前 `zn-ai` 已经补上了不少 Agents 基础设施。 +- 下一轮的重点不再是“有没有 `/agents`”,而是“把 `/agents` 的页面 IA、交互层级和职责边界重新拉回 `ClawX`”。 + +## 2. ClawX 基线:严格对齐什么 + +### 2.1 路由入口与页面装配 + +`ClawX` 的 Agents 是独立域,不是 Models 的子视图。 + +基线文件主要集中在: + +- `ClawX/src/pages/Agents/index.tsx` +- `ClawX/src/pages/Channels/index.tsx` +- `ClawX/src/stores/agents.ts` +- `ClawX/electron/api/routes/agents.ts` + +它的装配方式很明确: + +- 侧边栏有独立 `Agents` 入口。 +- `/agents` 是独立页面。 +- `/models` 与 `/agents` 是并列关系,不混用页面职责。 + +### 2.2 页面 IA:列表主视图,而不是控制台详情页 + +`ClawX` 的 Agents 首页首先是一个列表工作台,不是一个主布局双栏的详情编辑器。 + +页面骨架应理解为: + +1. 顶部标题区 + - Title + - Subtitle + - `Refresh` + - `Add Agent` +2. Warning / Error banner +3. Agent card 列表 +4. 卡片上的 `Settings` / `Delete` 入口 +5. 二级弹层 + - `AddAgentDialog` + - `AgentSettingsModal` + - `AgentModelModal` + +明确不要做的事: + +- 不要在页面右侧常驻一个大号设置面板。 +- 不要把“选中某个 agent 后右边出现详情”的双栏结构作为主交互。 +- 不要把创建、命名、模型、绑定全部平铺到首页主布局。 +- 不要让卡片列表承担复杂表单职责。 + +这也是本轮文档更新里最核心的 IA 修正。 + +### 2.3 Add Agent 弹窗:字段与交互 + +`ClawX` 的 `AddAgentDialog` 很克制,创建阶段只做最小信息采集。 + +建议严格对齐以下字段与交互: + +- 标题与简短说明 +- `name` 输入框 + - 必填 + - 非空后才能提交 +- `inheritWorkspace` 开关 + - 含义是“是否继承主 Agent 工作区” + - 默认保持轻量,不在创建弹窗里叠加更多高级参数 +- `Cancel` +- `Save / Create` + +创建阶段明确不应该出现的字段: + +- Provider 选择 +- Model override +- Channel binding +- Workspace 路径高级配置 + +创建成功后的回流建议也对齐 `ClawX`: + +- 关闭弹窗 +- 回到列表主视图 +- 列表出现新卡片 +- 可给 toast,但不要自动把用户带进重型详情页 + +### 2.4 Agents 首页卡片:只保留摘要与设置入口 + +`ClawX` 的卡片不是“迷你详情页”,而是摘要卡片。 + +建议卡片层面保留这些信息: + +- 头像 / icon +- `name` +- `default` badge +- 模型摘要 + - 当前模型 + - 是否继承默认模型 +- 频道归属摘要 + - 只展示结果,不提供写操作 + +建议卡片层面只保留这些动作: + +- `Settings` +- `Delete` + +不建议放在卡片正面的内容: + +- 大段 workspace / agentDir / sessionKey 技术细节 +- channel/account 绑定下拉 +- provider/model 大表单 +- 右侧伴随卡片选中状态的持久编辑区 + +如果需要展示更多运行时信息,优先放进设置弹层,而不是挤进首页卡片。 + +### 2.5 设置详情:建议 modal / sheet,而不是主布局双栏 + +`ClawX` 的设置入口是二级层级,不是首页主布局的一部分。 + +因此在 `zn-ai` 中更推荐: + +- 桌面端使用 `modal` 或 `side sheet` +- 移动端使用全屏 `sheet` / `dialog` + +不推荐继续保留当前这类形态: + +- 左侧列表 +- 右侧常驻详情 inspector +- 页面刷新或切换 agent 时整块右栏一起抖动 + +原因有三点: + +1. 不符合 `ClawX` 的交互层级。 +2. 会把“列表主视图”错误拉成“详情驱动”页面。 +3. 不利于后续继续保持 Cards 摘要化。 + +Settings 弹层建议承载的内容: + +- Identity + - 名称 + - Agent ID +- Model + - provider account + - model ref + - inherit default model + - 预览当前 effective provider / model +- Channel binding summary + - 只读展示当前 channel/account 归属 + - 提供“去 Channels 管理”的入口 + +如果仍需要二级模型编辑体验,可以继续保留嵌套的 `AgentModelModal`,但它依然属于弹层体系,而不是首页主布局。 + +### 2.6 与 Channels / Models 的职责边界 + +这是本轮必须写死的边界。 + +#### Agents 页负责什么 + +- Agent 实体列表 +- 新建 / 删除 / 重命名 +- per-agent model override +- 归属摘要展示 +- 设置入口 + +#### Agents 页不负责什么 + +- channel/account 绑定写操作 +- provider account CRUD +- 默认 provider 设定 +- usage 历史查询主视图 + +#### Channels 页负责什么 + +- channel 级归属 +- account 级归属 +- binding 写操作唯一入口 +- routing fallback 关系说明 + +#### Models 页负责什么 + +- provider account CRUD +- default provider / default model +- provider catalog / vendors +- usage history +- provider 视角的 models snapshot + +边界落地后的页面关系应该是: + +- `Agents` 看 Agent 和摘要 +- `Channels` 改归属 +- `Models` 管 Provider + +### 2.7 与 Home / Chat / Cron 的联动 + +`ClawX` 的 Agents 不孤立存在,它是多个页面共享的域。 + +最关键的消费关系如下: + +- Home / Chat + - 当前 Agent 名称 + - 当前 Agent 模型 + - 会话切换与发送路由 +- Cron + - 任务归属 `agentId` + - delivery channel / account / target + - 编辑时展示 Agent 与 delivery 摘要 + +因此后续实现时要记住: + +- Home/Chat/Cron 是 Agents 域的消费者。 +- 它们的联动验证很重要。 +- 但它们不应该反过来决定 `/agents` 首页长什么样。 + +## 3. zn-ai 当前现状:已经落地的部分与剩余偏差 + +### 3.1 已经落地的部分 + +与上一版文档相比,`zn-ai` 当前已经不是“Agents 完全缺失”的状态。 + +已经具备的基础包括: + +1. 独立 `/agents` 路由与侧边栏入口 +2. `agentsStore` +3. 本地 `/api/agents` +4. 本地 `/api/channels/accounts` +5. 本地 `/api/channels/targets` +6. Channels 页的 channel/account 归属写操作 +7. Cron 页的: + - `agent` + - `channel` + - `account` + - `target` +8. provider runtime sync 与 gateway reload/restart 链路 +9. Home 聊天顶部 Agent 切换与发送前模型校验 + +换句话说: + +- “Agents 域基础设施缺失”已经不再是主问题。 +- “UI/IA 没有回到 ClawX 基线”才是当前最主要的剩余问题。 + +### 3.2 仍然存在的关键偏差 + +尽管能力已经补了不少,当前 `zn-ai` 仍然和 `ClawX` 有几个显著偏差。 + +#### 偏差 1:页面仍然太像“详情页” + +当前实现已经是“列表 + 右侧设置区”的形态,这比最早的 `/agents -> /models` 已经前进很多,但仍然不是 `ClawX` 的列表主视图。 + +需要明确回退: + +- 去掉“固定右侧详情”作为主布局 +- 恢复“卡片列表 + 设置弹层” + +#### 偏差 2:Add Agent 还是 prompt / confirm 式流转 + +当前实现仍然偏命令式: + +- `prompt` 输名字 +- `confirm` 选 inherit workspace + +这不符合 `ClawX` 的统一弹层体验,也不利于后续继续扩展创建态文案和校验。 + +#### 偏差 3:Agents 页承担了过多常驻编辑语义 + +当前页面把: + +- identity +- model +- binding summary + +长期固定在主布局中展示,导致首页不再是纯列表。 + +这会让视觉重心从“列表工作台”滑向“控制台详情页”。 + +#### 偏差 4:需要继续守住 Channels / Models 边界 + +`zn-ai` 当前的方向整体是对的: + +- Channels 已经承担 binding 写操作 +- Agents 已经开始只读展示 binding summary + +但文档和实现都要继续避免回摆: + +- 不要因为 Agents settings 看起来“顺手”,就再把绑定下拉搬回去 +- 不要因为 Agents 能改 model,就把 provider CRUD 一并塞进 Agents + +#### 偏差 5:Models Snapshot 仍然不能替代 Agents 页 + +`/api/models` 当前仍然更接近“provider account 视角的合成 snapshot”,不是 Agent 域真相。 + +因此后续文档、QA 和页面说明都应避免混淆: + +- Models Snapshot 只能验证 provider 视角 +- Agent override 与 Agent 路由应以 `/api/agents`、Agents 页和 Home/Chat 行为为准 + +## 4. 新一轮 sub-agent 数量估算与分工 + +### 4.1 推荐并行规模 + +按当前代码基础判断,下一轮如果目标是“严格对齐 ClawX Agents UI 与职责边界”,推荐按 `6` 个 sub-agent 并行推进。 + +这不是为了把任务拆得更碎,而是为了减少 UI / store / local route / cross-page 联动之间的冲突。 + +但结合 `zn-ai` 当前已经具备的 `/agents` 页面骨架、`agentsStore`、本地 `/api/agents`、Channels 绑定链路,以及这轮需求已经明显收敛到“参考图驱动的 UI / IA 重构”,本轮实际执行建议按 `4` 个 sub-agent 推进更合适: + +- `1` 个负责 `Agents` 首页 IA 与视觉重构 +- `1` 个负责 `Add Agent / Settings` 弹层与文案收口 +- `1` 个负责 store / backend 能力缺口与回归风险核对 +- `1` 个负责迁移计划、验收口径和回归说明同步 + +### 4.2 推荐分工 + +| sub-agent | 写入范围 | 目标 | +| --- | --- | --- | +| 1 | `src/pages/Agents/**`, `src/router/**`, `src/components/layout/**` | 把 `/agents` 页面 IA 调回列表主视图,保证入口、标题区、刷新、新增和卡片层对齐 ClawX | +| 2 | `src/pages/Agents/**` | 重做 `Add Agent` 弹窗,以及 `Settings modal/sheet`、`Agent model modal` 的交互层级 | +| 3 | `src/stores/agents.ts`, shared agent types, 页面数据拼装逻辑 | 收口 Agents 页面所需字段,确保卡片摘要、设置详情、只读 binding summary 的数据来源稳定 | +| 4 | `electron/api/routes/agents.ts`, runtime sync 相关实现, `agent-config` | 校验 create / rename / model override / delete 的 runtime sync、reload、restart 语义是否与 UI 新交互一致 | +| 5 | `src/pages/Channels/**`, `src/pages/Models/**`, `src/pages/Home/**`, `src/pages/Cron/**` | 守住 Channels/Models/Home/Cron 的职责边界,避免 Agents IA 回正后引入新耦合 | +| 6 | `docs/**`, smoke tests, regression notes | 更新迁移文档、手工冒烟路径、验收标准和回归清单 | + +### 4.3 资源不足时的降配方案 + +如果并行资源不足,可以降到 `4` 个 sub-agent,但不要把下面两类工作混在一个人身上: + +- 首页 IA 重构 +- runtime / route / sync 硬逻辑 + +推荐合并方式: + +- `1 + 2` +- `5 + 6` + +### 4.4 本轮实际执行分工 + +结合当前会话的资源与改动范围,本轮按 `4` 个 sub-agent 推进: + +| sub-agent | 本轮职责 | 产出 | +| --- | --- | --- | +| A | 参考图与 `ClawX` IA 差异分析 | 列表主视图、卡片字段、弹层层级修正建议 | +| B | backend / store 能力核对 | 确认 `createAgent(inheritWorkspace)`、rename、model override、delete 是否足够支撑新 UI | +| C | 迁移计划文档更新 | 把“控制台详情页”基线修正为“ClawX 列表主视图” | +| D | i18n 与页面文案收口 | 让标题、副标题、添加弹窗和设置语义贴近参考图与 ClawX 心智 | + +## 5. 分阶段实施建议 + +本轮建议不再按“从零搭 Agents 域”的顺序推进,而改成“在已落地能力上收口 UI 和边界”。 + +### 5.1 阶段 0:冻结新基线 + +目标: + +- 明确上一版“控制台详情页 / 双栏布局”判断失效 +- 团队统一采用 `ClawX` 列表主视图基线 + +产出: + +- 更新后的本计划文档 +- 统一的页面 IA 草图 +- 明确的职责边界 + +验收标准: + +- 团队确认 `/agents` 首页不做固定右侧详情栏 +- 团队确认 `Add Agent` 只收最小字段 +- 团队确认 Channels / Models / Agents 三页边界 + +预计工作量: + +- `0.5` 天 + +### 5.2 阶段 1:把首页 IA 从双栏拉回列表主视图 + +目标: + +- 先把首页结构改对,再谈细节 + +建议改动: + +- 首页只保留: + - title + - subtitle + - refresh + - add + - banner + - card list +- 去掉常驻右侧 inspector + +验收标准: + +- `/agents` 首屏看起来就是列表主视图 +- 即使不打开任何设置,也能完整浏览所有 Agent 卡片 + +预计工作量: + +- `0.5 ~ 1` 天 + +### 5.3 阶段 2:补齐 Add Agent 弹窗与设置弹层 + +目标: + +- 把所有二级编辑交互收进 overlay 层 + +建议改动: + +- `Add Agent dialog` + - `name` + - `inheritWorkspace` +- `Agent settings modal / sheet` + - identity + - model + - binding summary +- `Agent model modal` + - provider + model ref + +验收标准: + +- 创建、重命名、模型覆盖都不再依赖首页双栏 +- 桌面和窄屏下的弹层层级都清晰 + +预计工作量: + +- `1 ~ 1.5` 天 + +### 5.4 阶段 3:收口首页卡片摘要 + +目标: + +- 让首页卡片真正回到摘要视角 + +建议改动: + +- 卡片只保留必要摘要 +- workspace / agentDir / sessionKey 等技术细节尽量移入设置层 +- hover 动作保持轻量 + +验收标准: + +- 卡片读起来像 `ClawX` +- 首页不出现重型编辑表单 + +预计工作量: + +- `0.5` 天 + +### 5.5 阶段 4:守住 Channels / Models / Agents 边界 + +目标: + +- 在 UI 回正后,避免功能重新耦合 + +建议改动: + +- Agents settings 里的绑定区继续只读 +- “Manage bindings” 统一跳转 `Channels` +- Models 保持 provider / default / usage,不接 Agent CRUD +- 默认模型语义留在 Models,Agent override 语义留在 Agents + +验收标准: + +- 团队对“去哪改什么”没有歧义 +- QA 能明确知道每类问题该在哪页验证 + +预计工作量: + +- `0.5` 天 + +### 5.6 阶段 5:联动与回归验证 + +目标: + +- 验证 UI 改造没有破坏现有 Agents 域能力 + +重点验证链路: + +1. Agents 创建后,Home/Chat 能看到新 Agent +2. Agent model override 更新后,Home 发送仍可走通 +3. Channels 绑定变更后,Agents settings 摘要能反映 +4. Cron 里 agent/channel/account/target 仍然可用 +5. `/api/channels/targets` 与手工输入兜底都正常 +6. 删除 Agent 后 reload/restart 行为仍稳定 + +预计工作量: + +- `1` 天 + +## 6. 风险清单 + +| 风险 | 描述 | 应对 | +| --- | --- | --- | +| 旧判断残留 | 团队继续沿用“右侧详情栏”思路实现后续 UI | 在阶段 0 明确作废旧 IA 结论,并把弹层方案写死 | +| Add Agent 范围膨胀 | 创建弹窗被不断塞入 provider/model/channel 字段 | 规定创建阶段只收 `name + inheritWorkspace` | +| 绑定边界回摆 | 因为 settings 层已有 binding summary,顺手加下拉写操作 | Agents 只读展示,Channels 保持唯一写入口 | +| Models 语义漂移 | Provider CRUD 再次被拉进 Agents | Models 专注 provider/default/usage,不承载 Agent 实体管理 | +| runtime sync 假通过 | UI 保存成功,但真实 runtime 文件或 gateway 状态没同步 | 回归必须覆盖 create / update model / delete 三类动作 | +| Cron targets 体验退化 | 因为 UI 改造误伤 target 自动加载与手输兜底 | 把 `/api/channels/targets` 与手工输入都纳入冒烟清单 | + +## 7. 验收重点 + +严格对齐 `ClawX` 时,验收不要只看“功能能不能点”,还要看“页面是不是回到了正确的形态”。 + +本轮建议把下面几条列为强验收项: + +1. `/agents` 首页是否是列表主视图,而不是双栏详情页 +2. `Add Agent` 是否是正式弹窗,而不是 prompt / confirm +3. 首页卡片是否只保留摘要与设置入口 +4. Settings 是否是 modal / sheet,而不是主布局常驻 +5. Agents 是否只读展示 binding summary,并把写操作引导到 Channels +6. Models 是否仍然只负责 provider/default/usage +7. Home / Cron 是否继续消费同一份 Agents 域数据 + +## 8. 最终建议 + +当前 `zn-ai` 已经把独立 `/agents`、`agentsStore`、本地 routes、runtime sync、Channels/Cron 联动这些“底座”补得差不多了。 + +所以下一轮不要再把重心放在“有没有 Agents 能力”,而要放在下面四件事: + +1. 把 `/agents` 的页面 IA 严格拉回 `ClawX` +2. 把 `Add Agent` 与 Settings 改成标准弹层体系 +3. 把 Agents / Channels / Models 的职责边界写死并落实到 UI +4. 用 Home / Cron / targets / runtime sync 做真实闭环回归 + +如果这四件事做对,`zn-ai` 的 Agents 才会从“功能拼装完成”真正进入“体验与职责都对齐 ClawX”的阶段。 diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 40a3e25..086a47b 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -4,4 +4,12 @@ - 本地安装了@vitejs/plugin-react,可以直接构建,下一波直接做Knowledge / Scripts / Login,然后再收 vite.config.ts 和 Vue-only 启动链。估算sub-agent数量,安排sub-agent分工开始工作。 -- 继续做Knowledge / Scripts / Login,构建工作,检查React平替Vue技术栈是否全部完成,估算sub-agent数量,安排sub-agent分工开始工作。 \ No newline at end of file +- 继续做Knowledge / Scripts / Login,构建工作,检查React平替Vue技术栈是否全部完成,估算sub-agent数量,安排sub-agent分工开始工作。 + +- 在ClwaX项目中深度分析Agents功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析Agents功能实现思路,期望迁移功能对齐ClawX的Agents功能。 + +- 按迁移计划继续做 Channels/Cron 接线和 agent model/provider 的可视化编辑。估算sub-agent数量,安排sub-agent分工推进开发工作,期望完全对齐ClawX + +- 继续推进,下一步最值的是补 /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 diff --git a/electron/api/router.ts b/electron/api/router.ts index e8df467..3e7f1d4 100644 --- a/electron/api/router.ts +++ b/electron/api/router.ts @@ -4,6 +4,9 @@ import { providerApiService } from '@electron/service/provider-api-service'; import type { HostApiContext } from './context'; import type { HostApiRequest } from './route-utils'; import { normalizeRequest } from './route-utils'; +import { handleAgentRoutes } from './routes/agents'; +import { handleChannelRoutes } from './routes/channels'; +import { handleCronRoutes } from './routes/cron'; import { handleFileRoutes } from './routes/files'; import { handleGatewayRoutes } from './routes/gateway'; import { handleModelRoutes } from './routes/models'; @@ -17,7 +20,10 @@ type RouteHandler = ( const routeHandlers: RouteHandler[] = [ handleProviderRoutes, + handleChannelRoutes, + handleAgentRoutes, handleModelRoutes, + handleCronRoutes, handleGatewayRoutes, handleFileRoutes, handleSessionRoutes, diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts new file mode 100644 index 0000000..17dbf03 --- /dev/null +++ b/electron/api/routes/agents.ts @@ -0,0 +1,189 @@ +import type { HostApiContext } from '../context'; +import type { NormalizedHostApiRequest } from '../route-utils'; +import { fail, ok, parseJsonBody } from '../route-utils'; +import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync'; +import { + assignChannelToAgent, + clearChannelBinding, + createAgentConfig, + deleteAgentConfig, + listAgentsSnapshot, + updateAgentModelConfig, + updateAgentName, +} from '../../utils/agent-config'; + +function getProviderSnapshot(ctx: HostApiContext) { + const accounts = ctx.providerApiService + .getAccounts() + .filter((account) => account.enabled !== false); + const defaultAccountId = ctx.providerApiService.getDefault().accountId; + return { accounts, defaultAccountId }; +} + +function formatRuntimeWarning(warnings: string[]): string | null { + if (warnings.length === 0) return null; + return `Runtime sync warnings: ${warnings.join('; ')}`; +} + +export async function handleAgentRoutes( + request: NormalizedHostApiRequest, + ctx: HostApiContext, +) { + const { pathname, method } = request; + const { accounts, defaultAccountId } = getProviderSnapshot(ctx); + + if (pathname === '/api/agents' && method === 'GET') { + return ok({ + success: true, + ...listAgentsSnapshot(accounts, defaultAccountId), + }); + } + + if (pathname === '/api/agents' && method === 'POST') { + try { + const body = parseJsonBody<{ name?: string; inheritWorkspace?: boolean }>(request.body); + if (!body?.name || !String(body.name).trim()) { + return fail(400, 'name is required'); + } + + const snapshot = createAgentConfig(body.name, { inheritWorkspace: body.inheritWorkspace }, accounts, defaultAccountId); + const runtimeSync = syncProviderRuntimeSnapshot({ accounts, defaultAccountId, snapshot }); + ctx.gatewayManager.reloadProviders({ + topics: ['agents', 'providers', 'models'], + reason: 'agents:created', + warnings: runtimeSync.warnings, + }); + + return ok({ + success: true, + warning: formatRuntimeWarning(runtimeSync.warnings), + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (!pathname.startsWith('/api/agents/')) { + return null; + } + + const suffix = pathname.slice('/api/agents/'.length); + const parts = suffix.split('/').filter(Boolean).map((part) => decodeURIComponent(part)); + + if (parts.length === 0) { + return null; + } + + if (method === 'PUT' && parts.length === 1) { + try { + const body = parseJsonBody<{ name?: string }>(request.body); + if (!body?.name || !String(body.name).trim()) { + return fail(400, 'name is required'); + } + + const snapshot = updateAgentName(parts[0], body.name, accounts, defaultAccountId); + const runtimeSync = syncProviderRuntimeSnapshot({ accounts, defaultAccountId, snapshot }); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['agents'], + reason: 'agents:renamed', + warnings: runtimeSync.warnings, + }); + + return ok({ + success: true, + warning: formatRuntimeWarning(runtimeSync.warnings), + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (method === 'PUT' && parts.length === 2 && parts[1] === 'model') { + try { + const body = parseJsonBody<{ modelRef?: string | null; providerAccountId?: string | null }>(request.body); + const snapshot = updateAgentModelConfig( + parts[0], + body?.modelRef ?? null, + body?.providerAccountId ?? null, + accounts, + defaultAccountId, + ); + const runtimeSync = syncProviderRuntimeSnapshot({ accounts, defaultAccountId, snapshot }); + ctx.gatewayManager.reloadProviders({ + topics: ['agents', 'providers', 'models'], + reason: 'agents:model-updated', + warnings: runtimeSync.warnings, + }); + + return ok({ + success: true, + warning: formatRuntimeWarning(runtimeSync.warnings), + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (method === 'PUT' && parts.length === 3 && parts[1] === 'channels') { + try { + const body = parseJsonBody<{ accountId?: string | null }>(request.body); + const snapshot = assignChannelToAgent(parts[0], parts[2], body?.accountId ?? null, accounts, defaultAccountId); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['agents', 'channels', 'channel-targets'], + reason: 'agents:channel-assigned', + channelType: parts[2], + accountId: body?.accountId ?? undefined, + }); + return ok({ + success: true, + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (method === 'DELETE' && parts.length === 1) { + try { + const snapshot = deleteAgentConfig(parts[0], accounts, defaultAccountId); + const runtimeSync = syncProviderRuntimeSnapshot({ accounts, defaultAccountId, snapshot }); + await ctx.gatewayManager.restart({ + topics: ['agents', 'providers', 'models', 'channels', 'channel-targets'], + reason: 'agents:deleted', + warnings: runtimeSync.warnings, + }); + + return ok({ + success: true, + warning: formatRuntimeWarning(runtimeSync.warnings), + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (method === 'DELETE' && parts.length === 3 && parts[1] === 'channels') { + try { + const accountId = request.url.searchParams.get('accountId')?.trim() || null; + const snapshot = clearChannelBinding(parts[2], accountId, accounts, defaultAccountId); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['agents', 'channels', 'channel-targets'], + reason: 'agents:channel-cleared', + channelType: parts[2], + accountId: accountId ?? undefined, + }); + return ok({ + success: true, + ...snapshot, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + return null; +} diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts new file mode 100644 index 0000000..95db3dc --- /dev/null +++ b/electron/api/routes/channels.ts @@ -0,0 +1,133 @@ +import type { HostApiContext } from '../context'; +import type { NormalizedHostApiRequest } from '../route-utils'; +import { fail, ok, parseJsonBody } from '../route-utils'; +import { + assignChannelToAgent, + clearChannelBinding, + listAgentsSnapshot, +} from '../../utils/agent-config'; +import { + listSelectedChannelAccountGroups, + listSelectedChannelTargets, +} from '../../utils/channels'; + +function getProviderSnapshot(ctx: HostApiContext) { + const accounts = ctx.providerApiService + .getAccounts() + .filter((account) => account.enabled !== false); + const defaultAccountId = ctx.providerApiService.getDefault().accountId; + return { accounts, defaultAccountId }; +} + +export async function handleChannelRoutes( + request: NormalizedHostApiRequest, + ctx: HostApiContext, +) { + const { pathname, method } = request; + const { accounts, defaultAccountId } = getProviderSnapshot(ctx); + const snapshot = listAgentsSnapshot(accounts, defaultAccountId); + + if (pathname === '/api/channels/accounts' && method === 'GET') { + return ok({ + success: true, + channels: listSelectedChannelAccountGroups(snapshot), + }); + } + + if (pathname === '/api/channels/targets' && method === 'GET') { + const channelType = request.url.searchParams.get('channelType')?.trim() || ''; + const accountId = request.url.searchParams.get('accountId')?.trim() || null; + const query = request.url.searchParams.get('query')?.trim() || null; + + if (!channelType) { + return fail(400, 'channelType is required'); + } + + return ok({ + success: true, + targets: listSelectedChannelTargets(channelType, accountId, query), + }); + } + + if (pathname === '/api/channels/binding' && method === 'PUT') { + try { + const body = parseJsonBody<{ + channelType?: string; + accountId?: string | null; + agentId?: string; + }>(request.body); + const channelType = String(body?.channelType ?? '').trim(); + const accountId = String(body?.accountId ?? '').trim(); + const agentId = String(body?.agentId ?? '').trim(); + + if (!channelType) { + return fail(400, 'channelType is required'); + } + if (!accountId) { + return fail(400, 'accountId is required'); + } + if (!agentId) { + return fail(400, 'agentId is required'); + } + if (!snapshot.agents.some((agent) => agent.id === agentId)) { + return fail(404, `Agent "${agentId}" not found`); + } + + const groupedChannels = listSelectedChannelAccountGroups(snapshot); + const group = groupedChannels.find((entry) => entry.channelType === channelType); + if (!group || !group.accounts.some((entry) => entry.accountId === accountId)) { + return fail(404, `Channel account "${channelType}:${accountId}" not found`); + } + + const result = assignChannelToAgent(agentId, channelType, accountId, accounts, defaultAccountId); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['agents', 'channels', 'channel-targets'], + reason: 'channels:binding-updated', + channelType, + accountId, + }); + + return ok({ + success: true, + ...result, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + if (pathname === '/api/channels/binding' && method === 'DELETE') { + 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'); + } + + const result = clearChannelBinding(channelType, accountId, accounts, defaultAccountId); + ctx.gatewayManager.notifyRuntimeChanged({ + topics: ['agents', 'channels', 'channel-targets'], + reason: 'channels:binding-cleared', + channelType, + accountId, + }); + + return ok({ + success: true, + ...result, + }); + } catch (error) { + return fail(500, error instanceof Error ? error.message : String(error)); + } + } + + return null; +} diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts new file mode 100644 index 0000000..679866a --- /dev/null +++ b/electron/api/routes/cron.ts @@ -0,0 +1,86 @@ +import type { HostApiContext } from '../context'; +import type { NormalizedHostApiRequest } from '../route-utils'; +import { fail, ok, parseJsonBody } from '../route-utils'; +import { + createCronJob, + deleteCronJob, + listCronJobs, + toggleCronJob, + triggerCronJob, + updateCronJob, +} from '../../utils/cron-store'; +import type { CronJobCreateInput, CronJobUpdateInput } from '@src/lib/cron-types'; + +export async function handleCronRoutes( + request: NormalizedHostApiRequest, + _ctx: HostApiContext, +) { + const { pathname, method } = request; + + if (pathname === '/api/cron/jobs' && method === 'GET') { + return ok(listCronJobs()); + } + + if (pathname === '/api/cron/jobs' && method === 'POST') { + try { + const body = parseJsonBody(request.body); + return ok(createCronJob(body), 201); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error)); + } + } + + if (pathname === '/api/cron/toggle' && method === 'POST') { + try { + const body = parseJsonBody<{ id?: string; enabled?: boolean }>(request.body); + if (!body?.id) { + return fail(400, 'id is required'); + } + + return ok(toggleCronJob(body.id, body.enabled !== false)); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error)); + } + } + + if (pathname === '/api/cron/trigger' && method === 'POST') { + try { + const body = parseJsonBody<{ id?: string }>(request.body); + if (!body?.id) { + return fail(400, 'id is required'); + } + + return ok(triggerCronJob(body.id)); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error)); + } + } + + if (!pathname.startsWith('/api/cron/jobs/')) { + return null; + } + + const jobId = decodeURIComponent(pathname.slice('/api/cron/jobs/'.length)).trim(); + if (!jobId) { + return fail(400, 'id is required'); + } + + if (method === 'PUT') { + try { + const body = parseJsonBody(request.body); + return ok(updateCronJob(jobId, body)); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error)); + } + } + + if (method === 'DELETE') { + try { + return ok(deleteCronJob(jobId)); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error)); + } + } + + return null; +} diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts index 5371b62..ce25cc0 100644 --- a/electron/api/routes/files.ts +++ b/electron/api/routes/files.ts @@ -1,9 +1,10 @@ import crypto from 'node:crypto'; -import { app, nativeImage } from 'electron'; +import { nativeImage } from 'electron'; import { extname, join } from 'node:path'; import type { HostApiContext } from '../context'; import type { NormalizedHostApiRequest } from '../route-utils'; import { ok, parseJsonBody } from '../route-utils'; +import { getUserDataDir } from '@electron/utils/paths'; const EXT_MIME_MAP: Record = { '.png': 'image/png', @@ -29,7 +30,7 @@ function mimeToExt(mimeType: string): string { return ''; } -const OUTBOUND_DIR = join(app.getPath('userData'), 'openclaw-media', 'outbound'); +const OUTBOUND_DIR = join(getUserDataDir(), 'openclaw-media', 'outbound'); async function generateImagePreview(filePath: string, mimeType: string): Promise { try { diff --git a/electron/api/routes/models.ts b/electron/api/routes/models.ts index d8f7363..4134a97 100644 --- a/electron/api/routes/models.ts +++ b/electron/api/routes/models.ts @@ -62,7 +62,7 @@ export async function handleModelRoutes( ctx: HostApiContext, ) { const { pathname, method } = request; - if ((pathname !== '/api/models' && pathname !== '/api/agents') || method !== 'GET') { + if (pathname !== '/api/models' || method !== 'GET') { return null; } diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 040aef6..3470801 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -1,10 +1,18 @@ import { BrowserWindow } from 'electron'; import { windowManager } from '@electron/service/window-service'; import logManager from '@electron/service/logger'; -import type { GatewayEvent } from './types'; +import type { GatewayEvent, RuntimeRefreshTopic } from './types'; import * as chatHandlers from './handlers/chat'; import * as providerHandlers from './handlers/provider'; +type RuntimeChangeBroadcast = { + topics: RuntimeRefreshTopic[]; + reason?: string; + warnings?: string[]; + channelType?: string; + accountId?: string; +}; + class GatewayManager { private initialized = false; private status: 'connected' | 'disconnected' | 'reconnecting' = 'disconnected'; @@ -31,10 +39,13 @@ class GatewayManager { this.setStatus('disconnected'); } - async restart(): Promise { + async restart(options?: RuntimeChangeBroadcast): Promise { this.initialized = false; this.setStatus('reconnecting'); await this.init(); + if (options) { + this.notifyRuntimeChanged(options); + } } getStatus(): { @@ -97,10 +108,34 @@ class GatewayManager { } } - reloadProviders(): void { + notifyRuntimeChanged(options: RuntimeChangeBroadcast): void { + const topics = Array.from(new Set(options.topics.filter(Boolean))); + if (topics.length === 0) { + return; + } + + this.broadcast({ + type: 'runtime:changed', + topics, + reason: options.reason, + warnings: options.warnings && options.warnings.length > 0 ? options.warnings : undefined, + channelType: options.channelType, + accountId: options.accountId, + syncedAt: new Date().toISOString(), + }); + } + + reloadProviders(options?: RuntimeChangeBroadcast): void { logManager.info('GatewayManager reloading providers'); // For now, providers are resolved on each chat.send call, // so no in-memory cache to invalidate. Future: notify active sessions. + this.notifyRuntimeChanged({ + topics: options?.topics ?? ['providers', 'models'], + reason: options?.reason ?? 'providers:reload', + warnings: options?.warnings, + channelType: options?.channelType, + accountId: options?.accountId, + }); } } diff --git a/electron/gateway/session-store.ts b/electron/gateway/session-store.ts index 6e71afe..04846b9 100644 --- a/electron/gateway/session-store.ts +++ b/electron/gateway/session-store.ts @@ -1,15 +1,15 @@ import * as fs from 'fs'; import * as path from 'path'; -import { app } from 'electron'; import logManager from '@electron/service/logger'; import { normalizeAgentSessionKey } from '@runtime/lib/models'; import type { RawMessage } from '@runtime/shared/chat-model'; +import { getUserDataDir } from '@electron/utils/paths'; let sessionsFilePath: string | null = null; function getSessionsFilePath(): string { if (!sessionsFilePath) { - sessionsFilePath = path.join(app.getPath('userData'), 'chat-sessions.json'); + sessionsFilePath = path.join(getUserDataDir(), 'chat-sessions.json'); } return sessionsFilePath; } diff --git a/electron/gateway/types.ts b/electron/gateway/types.ts index 43e23d9..1086c56 100644 --- a/electron/gateway/types.ts +++ b/electron/gateway/types.ts @@ -1,5 +1,12 @@ import type { RawMessage } from '@runtime/shared/chat-model'; +export type RuntimeRefreshTopic = + | 'providers' + | 'models' + | 'agents' + | 'channels' + | 'channel-targets'; + /// Gateway 向 Renderer 推送的事件类型 export type GatewayEvent = | { @@ -28,6 +35,15 @@ export type GatewayEvent = | { type: 'gateway:status'; status: 'connected' | 'disconnected' | 'reconnecting'; + } + | { + type: 'runtime:changed'; + topics: RuntimeRefreshTopic[]; + reason?: string; + syncedAt: string; + warnings?: string[]; + channelType?: string; + accountId?: string; }; /// Gateway RPC 方法参数映射 diff --git a/electron/main.ts b/electron/main.ts index 84a75b9..1a271b5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,30 +13,39 @@ import axios from 'axios'; import { onProviderChange } from '@electron/service/provider-api-service'; import { gatewayManager } from '@electron/gateway/manager'; import { dispatchLocalHostApi } from '@electron/api/router'; +import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync'; // 初始化 updater,确保在 app ready 之前或者之中注册好 IPC appUpdater.init(); // 注册 hostapi:fetch IPC 代理 // 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端 -const HOST_API_BASE_URL = process.env.VITE_SERVICE_URL || 'http://8.138.234.141/ingress'; +const HOST_API_BASE_URL = process.env['ZN_AI_HOST_API_BASE_URL'] + || process.env['VITE_SERVICE_URL'] + || 'http://8.138.234.141/ingress'; -ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => { - // 1. 优先本地处理 Host API 路由(逐步对齐 ClawX) - const localResult = await dispatchLocalHostApi({ - path, - method: method || 'GET', - headers, - body, - }); - if (localResult) return localResult; +function refreshProviderRuntime(): { warnings: string[] } { + try { + return syncProviderRuntimeSnapshot(); + } catch (error) { + log.error('provider runtime sync failed', error); + return { + warnings: [error instanceof Error ? error.message : String(error)], + }; + } +} - // 2. 其余接口代理到远端后端 +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 { const response = await axios({ url, - method: method || 'GET', + method, headers: { 'Content-Type': 'application/json', ...headers, @@ -67,6 +76,29 @@ ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) error: error.message || 'Unknown error', }; } +} + +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, + method: normalizedMethod, + headers, + body, + }); + if (localResult) return localResult; + + // 2. 其余接口代理到远端后端 + return await requestUpstreamHostApi(path, normalizedMethod, headers, body); }); // Gateway RPC IPC handler @@ -95,9 +127,15 @@ app.whenReady().then(async () => { await themeManager.init(); gatewayManager.init(); + refreshProviderRuntime(); onProviderChange(() => { - gatewayManager.reloadProviders(); + const runtimeSync = refreshProviderRuntime(); + gatewayManager.reloadProviders({ + topics: ['providers', 'models', 'agents'], + reason: 'providers:changed', + warnings: runtimeSync.warnings, + }); }); setupMainWindow(); diff --git a/electron/service/config-service/index.ts b/electron/service/config-service/index.ts index f458b24..d6d6722 100644 --- a/electron/service/config-service/index.ts +++ b/electron/service/config-service/index.ts @@ -4,6 +4,7 @@ import { CONFIG_KEYS, IPC_EVENTS } from '@runtime/lib/constants' import { debounce } from '@runtime/lib/utils' import logManager from '@electron/service/logger' +import { getUserDataDir } from '@electron/utils/paths' const DEFAULT_CONFIG: IConfig = { [CONFIG_KEYS.THEME_MODE]: 'system', @@ -35,6 +36,7 @@ export class ConfigService { const { default: Store } = await import('electron-store'); this._store = new Store({ name: 'config', + cwd: getUserDataDir(), defaults: DEFAULT_CONFIG, }); this._setupIpcEvents(); diff --git a/electron/service/logger/index.ts b/electron/service/logger/index.ts index 1298cef..34f39dd 100644 --- a/electron/service/logger/index.ts +++ b/electron/service/logger/index.ts @@ -1,9 +1,10 @@ import { IPC_EVENTS } from '@runtime/lib/constants'; import { promisify } from 'util'; -import { app, ipcMain } from 'electron'; +import { ipcMain } from 'electron'; import log from 'electron-log'; import * as path from 'path'; import * as fs from 'fs'; +import { getUserDataDir } from '@electron/utils/paths'; // 转换为Promise形式的fs方法 const readdirAsync = promisify(fs.readdir); @@ -20,7 +21,7 @@ class LogService { private readonly CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; private constructor() { - const logPath = path.join(app.getPath('userData'), 'logs'); + const logPath = path.join(getUserDataDir(), 'logs'); // c:users/{username}/AppData/Roaming/{appName}/logs // 创建日志目录 @@ -81,7 +82,7 @@ class LogService { private async _cleanupOldLogs() { try { - const logPath = path.join(app.getPath('userData'), 'logs'); + const logPath = path.join(getUserDataDir(), 'logs'); if (!fs.existsSync(logPath)) return; diff --git a/electron/service/provider-api-service/index.ts b/electron/service/provider-api-service/index.ts index 3e3e61b..5863ef3 100644 --- a/electron/service/provider-api-service/index.ts +++ b/electron/service/provider-api-service/index.ts @@ -1,4 +1,3 @@ -import { app } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import logManager from '@electron/service/logger'; @@ -8,6 +7,7 @@ import type { ProviderVendorInfo, ProviderWithKeyInfo, } from '@runtime/lib/providers'; +import { getUserDataDir } from '@electron/utils/paths'; interface ProviderStore { accounts: ProviderAccount[]; @@ -19,8 +19,8 @@ const defaultStore: ProviderStore = { defaultAccountId: null, }; -const storePath = path.join(app.getPath('userData'), 'provider-accounts.json'); -const keysPath = path.join(app.getPath('userData'), 'provider-keys.json'); +const storePath = path.join(getUserDataDir(), 'provider-accounts.json'); +const keysPath = path.join(getUserDataDir(), 'provider-keys.json'); function readJson(filePath: string, defaultValue: T): T { try { diff --git a/electron/service/provider-runtime-sync/index.ts b/electron/service/provider-runtime-sync/index.ts new file mode 100644 index 0000000..a69aed4 --- /dev/null +++ b/electron/service/provider-runtime-sync/index.ts @@ -0,0 +1,231 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import logManager from '@electron/service/logger'; +import { providerApiService } from '@electron/service/provider-api-service'; +import { listAgentsSnapshot } from '@electron/utils/agent-config'; +import { ensureDir, ensureOpenClawRuntimeLayout, getOpenClawRuntimePaths } from '@electron/utils/paths'; +import type { AgentSummary, AgentsSnapshot } from '@runtime/lib/agents'; +import type { ProviderAccount } from '@runtime/lib/providers'; + +interface AgentRuntimeSyncMeta { + agentId: string; + providerAccountId: string | null; + modelRef: string | null; + inheritedModel: boolean; + runtimeDir: string; +} + +export interface ProviderRuntimeSyncResult { + syncedAt: string; + agentCount: number; + defaultAccountId: string | null; + globalRegistryPath: string; + agents: AgentRuntimeSyncMeta[]; + warnings: string[]; +} + +interface RuntimeProviderProfile { + id: string; + vendorId: string; + label: string; + authMode: string; + apiKey: string | null; + hasKey: boolean; + baseUrl?: string; + apiProtocol?: string; + headers?: Record; + metadata?: ProviderAccount['metadata']; +} + +const AGENT_RUNTIME_DIR_NAME = 'runtime'; +const AUTH_PROFILES_FILE_NAME = 'auth-profiles.json'; +const MODELS_FILE_NAME = 'models.json'; +const OPENCLAW_FILE_NAME = 'openclaw.json'; +const GLOBAL_REGISTRY_FILE_NAME = 'agents-runtime.json'; + +function writeJson(filePath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} + +function getEnabledAccounts(accounts?: ProviderAccount[]): ProviderAccount[] { + const source = Array.isArray(accounts) ? accounts : providerApiService.getAccounts(); + return source.filter((account) => account.enabled !== false); +} + +function resolveEffectiveAccount( + agent: AgentSummary, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): ProviderAccount | null { + if (agent.providerAccountId) { + return accounts.find((account) => account.id === agent.providerAccountId) ?? null; + } + + if (defaultAccountId) { + return accounts.find((account) => account.id === defaultAccountId) ?? null; + } + + return accounts[0] ?? null; +} + +function buildRuntimeProfile(account: ProviderAccount | null): RuntimeProviderProfile[] { + if (!account) return []; + + const apiKey = providerApiService.getApiKey(account.id).apiKey; + return [{ + id: account.id, + vendorId: account.vendorId, + label: account.label, + authMode: account.authMode, + apiKey: apiKey || null, + hasKey: Boolean(apiKey), + baseUrl: account.baseUrl, + apiProtocol: account.apiProtocol, + headers: account.headers, + metadata: account.metadata, + }]; +} + +function buildRuntimeModelEntry( + agent: AgentSummary, + effectiveAccount: ProviderAccount | null, + defaultModelRef: string | null, +) { + const effectiveModelRef = agent.modelRef ?? effectiveAccount?.model ?? defaultModelRef ?? null; + if (!effectiveModelRef) { + return []; + } + + return [{ + id: effectiveModelRef, + modelRef: effectiveModelRef, + providerAccountId: effectiveAccount?.id ?? null, + vendorId: effectiveAccount?.vendorId ?? agent.vendorId ?? null, + baseUrl: effectiveAccount?.baseUrl ?? null, + apiProtocol: effectiveAccount?.apiProtocol ?? null, + headers: effectiveAccount?.headers ?? {}, + fallbackModels: effectiveAccount?.fallbackModels ?? [], + fallbackAccountIds: effectiveAccount?.fallbackAccountIds ?? [], + inheritedModel: Boolean(agent.inheritedModel), + source: agent.overrideModelRef ? 'agent-override' : 'provider-default', + }]; +} + +function ensureAgentRuntimeDir(agent: AgentSummary): string { + const agentDir = agent.agentDir?.trim(); + if (!agentDir) { + throw new Error(`Agent "${agent.id}" is missing agentDir`); + } + + return ensureDir(path.join(agentDir, AGENT_RUNTIME_DIR_NAME)); +} + +function writeAgentRuntimeFiles( + agent: AgentSummary, + snapshot: AgentsSnapshot, + accounts: ProviderAccount[], + defaultAccountId: string | null, + syncedAt: string, + warnings: string[], +): AgentRuntimeSyncMeta { + const runtimeDir = ensureAgentRuntimeDir(agent); + const effectiveAccount = resolveEffectiveAccount(agent, accounts, defaultAccountId); + const profiles = buildRuntimeProfile(effectiveAccount); + const models = buildRuntimeModelEntry(agent, effectiveAccount, snapshot.defaultModelRef); + + if (agent.providerAccountId && !effectiveAccount) { + warnings.push(`Agent "${agent.id}" references missing provider account "${agent.providerAccountId}"`); + } + + if (!effectiveAccount) { + warnings.push(`Agent "${agent.id}" has no enabled provider account available`); + } + + if (models.length === 0) { + warnings.push(`Agent "${agent.id}" has no model configured after runtime sync`); + } + + writeJson(path.join(runtimeDir, AUTH_PROFILES_FILE_NAME), { + version: 1, + syncedAt, + agentId: agent.id, + defaultProfileId: effectiveAccount?.id ?? null, + profiles, + }); + + writeJson(path.join(runtimeDir, MODELS_FILE_NAME), { + version: 1, + syncedAt, + agentId: agent.id, + defaultModelRef: models[0]?.modelRef ?? null, + models, + }); + + writeJson(path.join(runtimeDir, OPENCLAW_FILE_NAME), { + version: 1, + syncedAt, + agentId: agent.id, + agentName: agent.name, + mainSessionKey: agent.mainSessionKey, + workspace: agent.workspace ?? null, + agentDir: agent.agentDir ?? null, + providerAccountId: effectiveAccount?.id ?? null, + defaultProviderAccountId: snapshot.defaultProviderAccountId, + modelRef: models[0]?.modelRef ?? null, + defaultModelRef: snapshot.defaultModelRef, + inheritedModel: Boolean(agent.inheritedModel), + }); + + return { + agentId: agent.id, + providerAccountId: effectiveAccount?.id ?? null, + modelRef: models[0]?.modelRef ?? null, + inheritedModel: Boolean(agent.inheritedModel), + runtimeDir, + }; +} + +export function syncProviderRuntimeSnapshot(options?: { + accounts?: ProviderAccount[]; + defaultAccountId?: string | null; + snapshot?: AgentsSnapshot; +}): ProviderRuntimeSyncResult { + const syncedAt = new Date().toISOString(); + const accounts = getEnabledAccounts(options?.accounts); + const defaultAccountId = options?.defaultAccountId ?? providerApiService.getDefault().accountId; + const snapshot = options?.snapshot ?? listAgentsSnapshot(accounts, defaultAccountId); + const warnings: string[] = []; + const runtimePaths = ensureOpenClawRuntimeLayout(getOpenClawRuntimePaths()); + + const agents = snapshot.agents.map((agent) => + writeAgentRuntimeFiles(agent, snapshot, accounts, defaultAccountId, syncedAt, warnings), + ); + + const globalRegistryPath = path.join(runtimePaths.runtimeDir, GLOBAL_REGISTRY_FILE_NAME); + writeJson(globalRegistryPath, { + version: 1, + syncedAt, + defaultAccountId, + defaultModelRef: snapshot.defaultModelRef, + agents, + }); + + if (warnings.length > 0) { + logManager.warn('Provider runtime sync completed with warnings', warnings); + } else { + logManager.info('Provider runtime sync completed', { + agentCount: agents.length, + globalRegistryPath, + }); + } + + return { + syncedAt, + agentCount: agents.length, + defaultAccountId, + globalRegistryPath, + agents, + warnings, + }; +} diff --git a/electron/service/tab-manager/index.ts b/electron/service/tab-manager/index.ts index 8351e56..1fd3392 100644 --- a/electron/service/tab-manager/index.ts +++ b/electron/service/tab-manager/index.ts @@ -9,6 +9,9 @@ type TabId = string type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean } const UI_HEIGHT = 88 +const preloadEntryPath = MAIN_WINDOW_VITE_DEV_SERVER_URL + ? path.join(process.cwd(), 'dist-electron', 'preload', 'preload.js') + : path.join(__dirname, '..', 'preload', 'preload.js') export class TabManager { private win: BrowserWindow @@ -99,9 +102,7 @@ export class TabManager { nodeIntegration: false, contextIsolation: true, sandbox: true, - preload: MAIN_WINDOW_VITE_DEV_SERVER_URL - ? path.join(process.cwd(), 'dist-electron/preload/preload.js') - : path.join(__dirname, 'preload.js'), + preload: preloadEntryPath, }, }) this.views.set(id, view) diff --git a/electron/service/window-service/index.ts b/electron/service/window-service/index.ts index 30b7a34..5ccbc04 100644 --- a/electron/service/window-service/index.ts +++ b/electron/service/window-service/index.ts @@ -30,6 +30,9 @@ interface SizeOptions { const isMac = process.platform === 'darwin'; const isWindows = process.platform === 'win32'; const useCustomTitleBar = isWindows; +const preloadEntryPath = MAIN_WINDOW_VITE_DEV_SERVER_URL + ? path.join(process.cwd(), 'dist-electron', 'preload', 'preload.js') + : path.join(__dirname, '..', 'preload', 'preload.js'); function getSharedWindowOptions(): BrowserWindowConstructorOptions { return { @@ -45,9 +48,7 @@ function getSharedWindowOptions(): BrowserWindowConstructorOptions { contextIsolation: true, // 启用上下文隔离,防止渲染进程访问主进程 API sandbox: true, // 启用沙箱模式,进一步增强安全性 backgroundThrottling: false, - preload: MAIN_WINDOW_VITE_DEV_SERVER_URL - ? path.join(process.cwd(), 'dist-electron/preload/preload.js') - : path.join(__dirname, 'preload.js'), + preload: preloadEntryPath, }, }; } diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts new file mode 100644 index 0000000..5f9c7d3 --- /dev/null +++ b/electron/utils/agent-config.ts @@ -0,0 +1,500 @@ +import * as fs from 'fs'; +import * as path from 'path'; +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'; + +interface StoredAgentEntry { + id: string; + name: string; + providerAccountId?: string | null; + modelRef?: string | null; + workspace?: string | null; + agentDir?: string | null; + channelTypes?: string[]; + createdAt?: string; + updatedAt?: string; +} + +interface StoredAgentsConfig { + agents: StoredAgentEntry[]; + channelOwners?: Record; + channelAccountOwners?: Record; + mainSessionSuffix?: string; +} + +const STORE_FILE_NAME = 'agents.json'; + +function getStorePath(): string { + return path.join(getUserDataDir(), STORE_FILE_NAME); +} + +function getAgentsRootDir(): string { + return path.join(getUserDataDir(), 'agents'); +} + +function getMainWorkspacePath(): string { + return path.join(getAgentsRootDir(), DEFAULT_AGENT_ID, 'workspace'); +} + +function getMainAgentDirPath(): string { + return path.join(getAgentsRootDir(), DEFAULT_AGENT_ID, 'agent'); +} + +function getAgentWorkspacePath(agentId: string): string { + return path.join(getAgentsRootDir(), agentId, 'workspace'); +} + +function getAgentDirPath(agentId: string): string { + return path.join(getAgentsRootDir(), agentId, 'agent'); +} + +function ensureDir(dirPath: string | null | undefined): void { + if (!dirPath) return; + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readStore(): StoredAgentsConfig { + try { + const filePath = getStorePath(); + if (fs.existsSync(filePath)) { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial; + return { + agents: Array.isArray(parsed.agents) ? parsed.agents : [], + channelOwners: parsed.channelOwners && typeof parsed.channelOwners === 'object' ? parsed.channelOwners : {}, + channelAccountOwners: parsed.channelAccountOwners && typeof parsed.channelAccountOwners === 'object' + ? parsed.channelAccountOwners + : {}, + mainSessionSuffix: typeof parsed.mainSessionSuffix === 'string' ? parsed.mainSessionSuffix : DEFAULT_MAIN_SESSION_SUFFIX, + }; + } + } catch { + // Fall back to an empty store on malformed JSON. + } + + return { + agents: [], + channelOwners: {}, + channelAccountOwners: {}, + mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX, + }; +} + +function writeStore(store: StoredAgentsConfig): void { + const filePath = getStorePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); +} + +function formatModelDisplay(modelRef: string | null | undefined, fallbackLabel: string): string { + const trimmed = String(modelRef ?? '').trim(); + if (!trimmed) return fallbackLabel; + + const parts = trimmed.split('/'); + return parts[parts.length - 1] || trimmed; +} + +function normalizeAgentName(name: string): string { + return name.trim() || 'Agent'; +} + +function slugifyAgentId(name: string): string { + const normalized = name + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + if (!normalized || /^\d+$/.test(normalized)) return 'agent'; + if (normalized === DEFAULT_AGENT_ID) return 'agent'; + return normalized; +} + +function buildUniqueAgentId(existingIds: Set, name: string): string { + const baseId = slugifyAgentId(name); + let nextId = baseId; + let index = 2; + + while (existingIds.has(nextId)) { + nextId = `${baseId}-${index}`; + index += 1; + } + + return nextId; +} + +function getDefaultAccount(accounts: ProviderAccount[], defaultAccountId: string | null): ProviderAccount | null { + return accounts.find((account) => account.id === defaultAccountId) ?? accounts[0] ?? null; +} + +function buildMainAgent(defaultAccount: ProviderAccount | null, mainSessionSuffix: string): AgentSummary { + ensureDir(getMainWorkspacePath()); + ensureDir(getMainAgentDirPath()); + + return { + id: DEFAULT_AGENT_ID, + name: 'Main Agent', + isDefault: true, + providerAccountId: defaultAccount?.id ?? null, + modelRef: defaultAccount?.model ?? null, + modelDisplay: formatModelDisplay(defaultAccount?.model, defaultAccount?.label || 'Unassigned'), + mainSessionKey: buildMainSessionKey(DEFAULT_AGENT_ID, mainSessionSuffix), + vendorId: defaultAccount?.vendorId ?? null, + source: 'synthetic-main', + overrideModelRef: null, + inheritedModel: true, + workspace: getMainWorkspacePath(), + agentDir: getMainAgentDirPath(), + channelTypes: [], + }; +} + +function collectChannelOwners( + store: StoredAgentsConfig, + entries: StoredAgentEntry[], +): { channelOwners: Record; channelAccountOwners: Record } { + const explicitChannelOwners = store.channelOwners && typeof store.channelOwners === 'object' + ? { ...store.channelOwners } + : {}; + const explicitChannelAccountOwners = store.channelAccountOwners && typeof store.channelAccountOwners === 'object' + ? { ...store.channelAccountOwners } + : {}; + + for (const entry of entries) { + const normalizedId = normalizeAgentId(entry.id); + const channels = Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : []; + for (const channelType of channels) { + if (!explicitChannelOwners[channelType]) { + explicitChannelOwners[channelType] = normalizedId; + } + } + } + + return { + channelOwners: explicitChannelOwners, + channelAccountOwners: explicitChannelAccountOwners, + }; +} + +function mapStoredAgentToSummary( + entry: StoredAgentEntry, + accounts: ProviderAccount[], + defaultAccount: ProviderAccount | null, + mainSessionSuffix: string, +): AgentSummary { + const normalizedId = normalizeAgentId(entry.id); + const configuredAccount = entry.providerAccountId + ? accounts.find((account) => account.id === entry.providerAccountId) ?? null + : null; + const effectiveAccount = configuredAccount ?? defaultAccount; + const effectiveModelRef = entry.modelRef ?? effectiveAccount?.model ?? defaultAccount?.model ?? null; + const workspace = entry.workspace || getAgentWorkspacePath(normalizedId); + const agentDir = entry.agentDir || getAgentDirPath(normalizedId); + + ensureDir(agentDir); + ensureDir(workspace); + + return { + id: normalizedId, + name: normalizeAgentName(entry.name), + isDefault: false, + providerAccountId: entry.providerAccountId ?? null, + modelRef: effectiveModelRef, + modelDisplay: formatModelDisplay(effectiveModelRef, normalizeAgentName(entry.name)), + mainSessionKey: buildMainSessionKey(normalizedId, mainSessionSuffix), + vendorId: configuredAccount?.vendorId ?? effectiveAccount?.vendorId ?? null, + source: 'agent-config', + overrideModelRef: entry.modelRef ?? null, + inheritedModel: !entry.modelRef, + workspace, + agentDir, + channelTypes: Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : [], + }; +} + +function buildSnapshotFromStore( + store: StoredAgentsConfig, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const defaultAccount = getDefaultAccount(accounts, defaultAccountId); + const mainSessionSuffix = store.mainSessionSuffix || DEFAULT_MAIN_SESSION_SUFFIX; + const normalizedEntries = Array.from( + new Map( + store.agents + .filter((entry) => Boolean(entry) && typeof entry.id === 'string' && entry.id.trim().length > 0) + .map((entry) => [normalizeAgentId(entry.id), { ...entry, id: normalizeAgentId(entry.id) }]), + ).values(), + ).filter((entry) => entry.id !== DEFAULT_AGENT_ID); + + const { channelOwners, channelAccountOwners } = collectChannelOwners(store, normalizedEntries); + const agents = [ + buildMainAgent(defaultAccount, mainSessionSuffix), + ...normalizedEntries.map((entry) => mapStoredAgentToSummary(entry, accounts, defaultAccount, mainSessionSuffix)), + ]; + + const configuredChannelTypes = Array.from(new Set([ + ...Object.keys(channelOwners), + ...Object.keys(channelAccountOwners).map((key) => key.split(':')[0]).filter(Boolean), + ])); + + return { + agents, + models: agents, + defaultAgentId: DEFAULT_AGENT_ID, + defaultProviderAccountId: defaultAccount?.id ?? null, + defaultModelRef: defaultAccount?.model ?? null, + mainSessionSuffix, + configuredChannelTypes, + channelOwners, + channelAccountOwners, + }; +} + +function syncAgentChannelMembership(store: StoredAgentsConfig, channelType: string): void { + const normalizedChannelType = String(channelType ?? '').trim(); + if (!normalizedChannelType) return; + + const ownerIds = new Set(); + const channelOwnerId = store.channelOwners?.[normalizedChannelType]; + if (channelOwnerId) { + ownerIds.add(normalizeAgentId(channelOwnerId)); + } + + for (const [key, ownerId] of Object.entries(store.channelAccountOwners ?? {})) { + if (!key.startsWith(`${normalizedChannelType}:`) || !ownerId) continue; + ownerIds.add(normalizeAgentId(ownerId)); + } + + store.agents = store.agents.map((entry) => { + const normalizedId = normalizeAgentId(entry.id); + const currentChannelTypes = Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : []; + const hasChannel = currentChannelTypes.includes(normalizedChannelType); + const shouldHaveChannel = ownerIds.has(normalizedId); + + if (hasChannel === shouldHaveChannel) { + return entry; + } + + return { + ...entry, + channelTypes: shouldHaveChannel + ? [...currentChannelTypes, normalizedChannelType] + : currentChannelTypes.filter((value) => value !== normalizedChannelType), + updatedAt: new Date().toISOString(), + }; + }); +} + +export function listAgentsSnapshot( + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + return buildSnapshotFromStore(readStore(), accounts, defaultAccountId); +} + +export function createAgentConfig( + name: string, + options: { inheritWorkspace?: boolean } | undefined, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const trimmedName = normalizeAgentName(name); + const store = readStore(); + const existingIds = new Set(store.agents.map((entry) => normalizeAgentId(entry.id))); + const agentId = buildUniqueAgentId(existingIds, trimmedName); + const now = new Date().toISOString(); + const workspace = options?.inheritWorkspace ? getMainWorkspacePath() : getAgentWorkspacePath(agentId); + const agentDir = getAgentDirPath(agentId); + + ensureDir(workspace); + ensureDir(agentDir); + + store.agents = [ + ...store.agents, + { + id: agentId, + name: trimmedName, + providerAccountId: null, + modelRef: null, + workspace, + agentDir, + channelTypes: [], + createdAt: now, + updatedAt: now, + }, + ]; + + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} + +export function updateAgentName( + agentId: string, + name: string, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedId = normalizeAgentId(agentId); + if (normalizedId === DEFAULT_AGENT_ID) { + throw new Error('Main Agent cannot be renamed'); + } + + const store = readStore(); + const index = store.agents.findIndex((entry) => normalizeAgentId(entry.id) === normalizedId); + if (index === -1) { + throw new Error(`Agent "${normalizedId}" not found`); + } + + store.agents[index] = { + ...store.agents[index], + name: normalizeAgentName(name), + updatedAt: new Date().toISOString(), + }; + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} + +export function updateAgentModelConfig( + agentId: string, + modelRef: string | null, + providerAccountId: string | null | undefined, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedId = normalizeAgentId(agentId); + if (normalizedId === DEFAULT_AGENT_ID) { + throw new Error('Main Agent model is managed from Models'); + } + + const store = readStore(); + const index = store.agents.findIndex((entry) => normalizeAgentId(entry.id) === normalizedId); + if (index === -1) { + throw new Error(`Agent "${normalizedId}" not found`); + } + + const trimmedModelRef = typeof modelRef === 'string' ? modelRef.trim() : ''; + const inferredAccountId = providerAccountId + ?? ( + trimmedModelRef + ? accounts.find((account) => account.model === trimmedModelRef)?.id ?? store.agents[index].providerAccountId ?? null + : null + ); + + store.agents[index] = { + ...store.agents[index], + providerAccountId: inferredAccountId ?? null, + modelRef: trimmedModelRef || null, + updatedAt: new Date().toISOString(), + }; + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} + +export function deleteAgentConfig( + agentId: string, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedId = normalizeAgentId(agentId); + if (normalizedId === DEFAULT_AGENT_ID) { + throw new Error('Main Agent cannot be deleted'); + } + + const store = readStore(); + const existingAgent = store.agents.find((entry) => normalizeAgentId(entry.id) === normalizedId); + if (!existingAgent) { + throw new Error(`Agent "${normalizedId}" not found`); + } + + store.agents = store.agents.filter((entry) => normalizeAgentId(entry.id) !== normalizedId); + store.channelOwners = Object.fromEntries( + Object.entries(store.channelOwners ?? {}).filter(([, ownerId]) => normalizeAgentId(ownerId) !== normalizedId), + ); + store.channelAccountOwners = Object.fromEntries( + Object.entries(store.channelAccountOwners ?? {}).filter(([, ownerId]) => normalizeAgentId(ownerId) !== normalizedId), + ); + writeStore(store); + + const agentRootDir = path.join(getAgentsRootDir(), normalizedId); + try { + fs.rmSync(agentRootDir, { recursive: true, force: true }); + } catch { + // Best effort cleanup only. + } + + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} + +export function assignChannelToAgent( + agentId: string, + channelType: string, + accountId: string | null | undefined, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedId = normalizeAgentId(agentId); + const normalizedChannelType = String(channelType ?? '').trim(); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const store = readStore(); + if (normalizedId !== DEFAULT_AGENT_ID && !store.agents.some((entry) => normalizeAgentId(entry.id) === normalizedId)) { + throw new Error(`Agent "${normalizedId}" not found`); + } + + store.channelOwners = { + ...(store.channelOwners ?? {}), + ...(accountId ? {} : { [normalizedChannelType]: normalizedId }), + }; + + if (accountId) { + store.channelAccountOwners = { + ...(store.channelAccountOwners ?? {}), + [`${normalizedChannelType}:${accountId}`]: normalizedId, + }; + } + + syncAgentChannelMembership(store, normalizedChannelType); + + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} + +export function clearChannelBinding( + channelType: string, + accountId: string | null | undefined, + accounts: ProviderAccount[], + defaultAccountId: string | null, +): AgentsSnapshot { + const normalizedChannelType = String(channelType ?? '').trim(); + if (!normalizedChannelType) { + throw new Error('channelType is required'); + } + + const store = readStore(); + const ownerId = accountId + ? store.channelAccountOwners?.[`${normalizedChannelType}:${accountId}`] + : store.channelOwners?.[normalizedChannelType]; + + if (accountId) { + const nextAccountOwners = { ...(store.channelAccountOwners ?? {}) }; + delete nextAccountOwners[`${normalizedChannelType}:${accountId}`]; + store.channelAccountOwners = nextAccountOwners; + } else { + const nextChannelOwners = { ...(store.channelOwners ?? {}) }; + delete nextChannelOwners[normalizedChannelType]; + store.channelOwners = nextChannelOwners; + } + + if (ownerId) { + syncAgentChannelMembership(store, normalizedChannelType); + } + + writeStore(store); + return buildSnapshotFromStore(store, accounts, defaultAccountId); +} diff --git a/electron/utils/channels.ts b/electron/utils/channels.ts new file mode 100644 index 0000000..3c37b96 --- /dev/null +++ b/electron/utils/channels.ts @@ -0,0 +1,436 @@ +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 type { + ChannelAccountCatalogGroup, + ChannelConnectionStatus, + ChannelTargetCatalogItem, + ChannelTargetKind, + ChannelTargetSource, +} from '@src/lib/channel-types'; + +interface SelectedChannelConfigItem { + id: string; + channelName: string; + channelUrl: string; +} + +export interface LocalChannelAccount { + id: string; + accountId: string; + channelType: string; + channelName: string; + channelUrl: string; + label: string; + ownerAgentId: string | null; + ownerAgentName: string | null; + bindingScope: 'account' | 'channel' | null; +} + +const CHANNEL_TYPE_ALIASES: Record = { + douyin: 'douyin', + '抖音': 'douyin', + fliggy: 'fliggy', + '飞猪': 'fliggy', + meituan: 'meituan', + '美团': 'meituan', +}; + +const CHANNEL_HOST_TYPE_ALIASES: Record = { + 'life.douyin.com': 'douyin', + 'douyin.com': 'douyin', + 'hotel.fliggy.com': 'fliggy', + 'fliggy.com': 'fliggy', + 'me.meituan.com': 'meituan', + 'meituan.com': 'meituan', +}; + +const TARGET_QUERY_PARAM_LABELS: Record = { + accountid: 'Account ID', + chatid: 'Chat ID', + conversationid: 'Conversation ID', + groupid: 'Group ID', + hotelid: 'Hotel ID', + merchantid: 'Merchant ID', + openconversationid: 'Open Conversation ID', + roomid: 'Room ID', + sellerid: 'Seller ID', + shopid: 'Shop ID', + threadid: 'Thread ID', + userid: 'User ID', +}; + +function normalizeTargetValue(value: string | null | undefined): string { + return String(value ?? '').trim(); +} + +function buildTargetCandidate( + value: string | null | undefined, + label: string, + options: { + description?: string; + kind?: ChannelTargetKind; + source?: ChannelTargetSource; + channelType?: string; + accountId?: string; + } = {}, +): ChannelTargetCatalogItem | null { + const normalizedValue = normalizeTargetValue(value); + if (!normalizedValue) return null; + + return { + value: normalizedValue, + label: label.trim() || normalizedValue, + description: options.description?.trim() || undefined, + kind: options.kind, + source: options.source, + channelType: options.channelType, + accountId: options.accountId, + }; +} + +function pushTargetCandidate( + targetMap: Map, + candidate: ChannelTargetCatalogItem | null, +): void { + if (!candidate) return; + + const existing = targetMap.get(candidate.value); + if (!existing) { + targetMap.set(candidate.value, candidate); + return; + } + + targetMap.set(candidate.value, { + ...existing, + label: existing.label || candidate.label, + description: existing.description || candidate.description, + kind: existing.kind || candidate.kind, + source: existing.source || candidate.source, + channelType: existing.channelType || candidate.channelType, + accountId: existing.accountId || candidate.accountId, + }); +} + +function appendUrlTargetCandidates( + account: Pick, + targetMap: Map, +): void { + const rawUrl = String(account.channelUrl ?? '').trim(); + if (!rawUrl) return; + + try { + const parsedUrl = new URL(rawUrl); + const queryEntries = [ + ...parsedUrl.searchParams.entries(), + ...new URLSearchParams(parsedUrl.hash.split('?')[1] ?? '').entries(), + ]; + + for (const [rawKey, rawValue] of queryEntries) { + const key = rawKey.trim().toLowerCase(); + const value = rawValue.trim(); + if (!value) continue; + + const labelBase = TARGET_QUERY_PARAM_LABELS[key] + || (/id$/.test(key) ? key.replace(/id$/, ' ID') : ''); + if (!labelBase) continue; + + pushTargetCandidate( + targetMap, + buildTargetCandidate(value, `${labelBase} · ${value}`, { + description: `${account.channelName || account.accountId} URL 中发现的候选标识`, + kind: 'identifier', + source: parsedUrl.searchParams.has(rawKey) ? 'query-param' : 'hash-param', + channelType: account.channelType, + accountId: account.accountId, + }), + ); + } + + if (/webhook|hook|bot|robot/i.test(parsedUrl.toString())) { + pushTargetCandidate( + targetMap, + buildTargetCandidate(parsedUrl.toString(), `Webhook · ${account.channelName || account.accountId}`, { + description: '渠道 URL 看起来像一个可直接发送的 webhook 目标', + kind: 'webhook', + source: 'channel-url', + channelType: account.channelType, + accountId: account.accountId, + }), + ); + } + } catch { + // Ignore malformed URLs and keep the other fallback targets. + } +} + +function appendHistoricalTargetCandidates( + channelType: string, + accountId: string | null | undefined, + targetMap: Map, +): void { + const normalizedChannelType = String(channelType ?? '').trim(); + const normalizedAccountId = String(accountId ?? '').trim(); + + for (const job of listCronJobs()) { + const delivery = job.delivery; + if (!delivery || delivery.mode !== 'announce') continue; + if (String(delivery.channel ?? '').trim() !== normalizedChannelType) continue; + + const deliveryAccountId = String(delivery.accountId ?? '').trim(); + if (normalizedAccountId && deliveryAccountId && deliveryAccountId !== normalizedAccountId) { + continue; + } + + const targetValue = String(delivery.to ?? '').trim(); + if (!targetValue) continue; + + pushTargetCandidate( + targetMap, + buildTargetCandidate(targetValue, targetValue, { + description: `来自历史定时任务「${job.name || job.id}」的投递目标`, + kind: /^https?:\/\//i.test(targetValue) ? 'webhook' : 'name', + source: 'fallback', + channelType: normalizedChannelType, + accountId: deliveryAccountId || normalizedAccountId || undefined, + }), + ); + } +} + +function formatChannelLabel(channelType: string): string { + const parts = String(channelType ?? '') + .split(/[-_]/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return channelType; + } + + return parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function normalizeSelectedChannel(item: Partial | null | undefined): SelectedChannelConfigItem | null { + const channelUrl = String(item?.channelUrl ?? '').trim(); + if (!channelUrl) return null; + + const channelName = String(item?.channelName ?? channelUrl).trim() || channelUrl; + const id = String(item?.id ?? channelUrl).trim() || channelUrl; + + return { + id, + channelName, + channelUrl, + }; +} + +function slugifyChannelType(value: string): string { + const normalized = value + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return normalized || 'channel'; +} + +function inferChannelType(item: SelectedChannelConfigItem): string { + const nameKey = item.channelName.trim(); + if (CHANNEL_TYPE_ALIASES[nameKey]) return CHANNEL_TYPE_ALIASES[nameKey]; + + const lowerNameKey = nameKey.toLowerCase(); + if (CHANNEL_TYPE_ALIASES[lowerNameKey]) return CHANNEL_TYPE_ALIASES[lowerNameKey]; + + try { + const hostname = new URL(item.channelUrl).hostname.toLowerCase(); + if (CHANNEL_HOST_TYPE_ALIASES[hostname]) return CHANNEL_HOST_TYPE_ALIASES[hostname]; + + const matchedHost = Object.entries(CHANNEL_HOST_TYPE_ALIASES) + .find(([host]) => hostname === host || hostname.endsWith(`.${host}`)); + if (matchedHost) return matchedHost[1]; + } catch { + // Fall through to a slugified channel name. + } + + return slugifyChannelType(lowerNameKey || item.id || item.channelUrl); +} + +export function getSelectedChannelsConfig(): SelectedChannelConfigItem[] { + const saved = configManager.get(CONFIG_KEYS.SELECTED_CHANNELS); + if (!Array.isArray(saved)) return []; + + const deduped = new Map(); + for (const item of saved) { + const normalized = normalizeSelectedChannel(item); + if (!normalized) continue; + + const dedupeKey = `${inferChannelType(normalized)}:${normalized.id}`; + if (!deduped.has(dedupeKey)) { + deduped.set(dedupeKey, normalized); + } + } + + return Array.from(deduped.values()); +} + +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)]) + : [], + ); + + return channels.map((item) => { + const channelType = inferChannelType(item); + 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; + + return { + id: item.id, + accountId: item.id, + channelType, + channelName: item.channelName, + channelUrl: item.channelUrl, + label: item.channelName, + ownerAgentId: normalizedOwnerId, + ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null, + bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null, + }; + }); +} + +export function listSelectedChannelAccountGroups( + snapshot?: Pick, +): ChannelAccountCatalogGroup[] { + const accounts = listSelectedChannelAccounts(snapshot); + const groups = new Map(); + + for (const account of accounts) { + const existing = groups.get(account.channelType) ?? { + channelType: account.channelType, + channelLabel: account.channelName || formatChannelLabel(account.channelType), + defaultAccountId: account.accountId, + status: 'connected' as ChannelConnectionStatus, + accounts: [], + }; + + existing.accounts.push({ + accountId: account.accountId, + name: account.label || account.channelName || account.accountId, + configured: true, + status: 'connected', + isDefault: false, + agentId: account.ownerAgentId ?? undefined, + bindingScope: account.bindingScope ?? undefined, + channelUrl: account.channelUrl, + }); + + groups.set(account.channelType, existing); + } + + return Array.from(groups.values()) + .map((group) => { + const sortedAccounts = [...group.accounts].sort((left, right) => left.name.localeCompare(right.name)); + const defaultAccountId = group.defaultAccountId || sortedAccounts[0]?.accountId || 'default'; + + return { + ...group, + defaultAccountId, + accounts: sortedAccounts.map((account) => ({ + ...account, + isDefault: account.accountId === defaultAccountId, + })), + }; + }) + .sort((left, right) => left.channelLabel.localeCompare(right.channelLabel)); +} + +export function listSelectedChannelTargets( + channelType: string, + accountId?: string | null, + query?: string | null, +): ChannelTargetCatalogItem[] { + const normalizedChannelType = String(channelType ?? '').trim(); + const normalizedAccountId = String(accountId ?? '').trim(); + const normalizedQuery = String(query ?? '').trim().toLowerCase(); + const accounts = listSelectedChannelAccounts().filter((entry) => { + if (entry.channelType !== normalizedChannelType) return false; + if (!normalizedAccountId) return true; + return entry.accountId === normalizedAccountId; + }); + + const targetMap = new Map(); + + for (const account of accounts) { + const displayName = account.channelName || account.label || account.accountId; + pushTargetCandidate( + targetMap, + buildTargetCandidate(displayName, displayName, { + description: '使用当前渠道账号名称作为默认发送目标', + kind: 'name', + source: 'channel-name', + channelType: account.channelType, + accountId: account.accountId, + }), + ); + + pushTargetCandidate( + targetMap, + buildTargetCandidate(account.accountId, `账号 ID · ${account.accountId}`, { + description: '使用当前渠道账号标识作为发送目标', + kind: 'identifier', + source: 'account-id', + channelType: account.channelType, + accountId: account.accountId, + }), + ); + + appendUrlTargetCandidates(account, targetMap); + } + + appendHistoricalTargetCandidates(normalizedChannelType, normalizedAccountId || null, targetMap); + + if (targetMap.size === 0 && normalizedAccountId) { + pushTargetCandidate( + targetMap, + buildTargetCandidate(normalizedAccountId, `账号 ID · ${normalizedAccountId}`, { + description: '当前账号没有更多可发现目标,保留账号标识作为兜底候选', + kind: 'identifier', + source: 'fallback', + channelType: normalizedChannelType, + accountId: normalizedAccountId, + }), + ); + } + + return Array.from(targetMap.values()).sort((left, right) => { + const kindOrder: Record = { + name: 0, + identifier: 1, + webhook: 2, + url: 3, + }; + const leftOrder = left.kind ? kindOrder[left.kind] : 99; + const rightOrder = right.kind ? kindOrder[right.kind] : 99; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.label.localeCompare(right.label, 'zh-CN'); + }).filter((entry) => { + if (!normalizedQuery) return true; + const haystacks = [ + entry.value, + entry.label, + entry.description ?? '', + ].map((value) => value.toLowerCase()); + return haystacks.some((value) => value.includes(normalizedQuery)); + }); +} diff --git a/electron/utils/chrome/getProfileDir.ts b/electron/utils/chrome/getProfileDir.ts index 58f0f66..289ab09 100644 --- a/electron/utils/chrome/getProfileDir.ts +++ b/electron/utils/chrome/getProfileDir.ts @@ -1,7 +1,7 @@ import path from "node:path"; -import { app } from 'electron'; +import { getUserDataDir } from '../paths'; // 多账号隔离 export function getProfileDir (accountId: string) { - return path.join(app.getPath('userData'), `profiles`, accountId); -} \ No newline at end of file + return path.join(getUserDataDir(), `profiles`, accountId); +} diff --git a/electron/utils/cron-store.ts b/electron/utils/cron-store.ts new file mode 100644 index 0000000..d084d3a --- /dev/null +++ b/electron/utils/cron-store.ts @@ -0,0 +1,417 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { + CronJob, + CronJobCreateInput, + CronJobDelivery, + CronJobLastRun, + CronJobUpdateInput, + CronSchedule, +} from '@src/lib/cron-types'; +import { getUserDataDir } from './paths'; + +interface StoredCronJob extends CronJob { + agentId?: string | null; +} + +interface CronStore { + jobs: StoredCronJob[]; +} + +const CRON_STORE_PATH = path.join(getUserDataDir(), 'cron', 'jobs.json'); +const MAX_CRON_LOOKAHEAD_MINUTES = 366 * 24 * 60; + +function readStore(): CronStore { + try { + if (fs.existsSync(CRON_STORE_PATH)) { + const parsed = JSON.parse(fs.readFileSync(CRON_STORE_PATH, 'utf-8')) as Partial; + return { + jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [], + }; + } + } catch { + // Fall back to an empty store on malformed JSON. + } + + return { jobs: [] }; +} + +function writeStore(store: CronStore): void { + fs.mkdirSync(path.dirname(CRON_STORE_PATH), { recursive: true }); + fs.writeFileSync(CRON_STORE_PATH, JSON.stringify(store, null, 2), 'utf-8'); +} + +function normalizeString(value: unknown): string { + return String(value ?? '').trim(); +} + +function normalizeIsoDate(value: unknown, fallback?: string): string { + const raw = normalizeString(value); + const ms = Date.parse(raw); + if (Number.isFinite(ms)) { + return new Date(ms).toISOString(); + } + + if (fallback) { + return fallback; + } + + return new Date().toISOString(); +} + +function normalizeDelivery(value: unknown): CronJobDelivery | undefined { + if (!value || typeof value !== 'object') { + return { mode: 'none' }; + } + + const input = value as Partial; + const mode = input.mode === 'announce' ? 'announce' : 'none'; + if (mode === 'announce') { + const channel = normalizeString(input.channel); + const to = normalizeString(input.to); + const accountId = normalizeString(input.accountId); + + return { + mode, + channel: channel || undefined, + to: to || undefined, + accountId: accountId || undefined, + }; + } + + return { mode: 'none' }; +} + +function normalizeLastRun(value: unknown): CronJobLastRun | undefined { + if (!value || typeof value !== 'object') return undefined; + + const input = value as Partial; + const time = normalizeString(input.time); + if (!time) return undefined; + + return { + time: normalizeIsoDate(time), + success: input.success !== false, + error: normalizeString(input.error) || undefined, + duration: typeof input.duration === 'number' && Number.isFinite(input.duration) ? input.duration : undefined, + }; +} + +function normalizeSchedule(value: unknown, fallback?: CronJob['schedule']): CronJob['schedule'] | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || fallback || null; + } + + if (!value || typeof value !== 'object') { + return fallback || null; + } + + const input = value as Partial; + if (input.kind === 'at') { + const at = normalizeString(input.at); + return at ? { kind: 'at', at: normalizeIsoDate(at) } : fallback || null; + } + + if (input.kind === 'every') { + const everyMs = typeof input.everyMs === 'number' && Number.isFinite(input.everyMs) ? input.everyMs : 0; + if (everyMs <= 0) return fallback || null; + + return { + kind: 'every', + everyMs, + anchorMs: typeof input.anchorMs === 'number' && Number.isFinite(input.anchorMs) ? input.anchorMs : undefined, + }; + } + + if (input.kind === 'cron') { + const expr = normalizeString(input.expr); + return expr + ? { + kind: 'cron', + expr, + tz: normalizeString(input.tz) || undefined, + } + : fallback || null; + } + + return fallback || null; +} + +function parseCronNumberToken(token: string, min: number, max: number, isDayOfWeek = false): number | null { + if (!/^\d+$/.test(token)) return null; + + const parsed = Number(token); + if (!Number.isFinite(parsed)) return null; + if (isDayOfWeek && parsed === 7) return 0; + if (parsed < min || parsed > max) return null; + return parsed; +} + +function matchesCronField(expression: string, value: number, min: number, max: number, isDayOfWeek = false): boolean { + const trimmed = expression.trim(); + if (!trimmed) return false; + if (trimmed === '*') return true; + + return trimmed.split(',').some((segment) => { + const part = segment.trim(); + if (!part) return false; + if (part === '*') return true; + + const [rangeExpression, stepExpression] = part.split('/'); + const step = stepExpression ? Number(stepExpression) : 1; + if (!Number.isFinite(step) || step <= 0) return false; + + if (rangeExpression === '*') { + return (value - min) % step === 0; + } + + if (rangeExpression.includes('-')) { + const [startRaw, endRaw] = rangeExpression.split('-'); + const start = parseCronNumberToken(startRaw.trim(), min, max, isDayOfWeek); + const end = parseCronNumberToken(endRaw.trim(), min, max, isDayOfWeek); + if (start == null || end == null || start > end) return false; + if (value < start || value > end) return false; + return (value - start) % step === 0; + } + + const literal = parseCronNumberToken(rangeExpression.trim(), min, max, isDayOfWeek); + if (literal == null) return false; + return value === literal; + }); +} + +function estimateNextCronRun(expr: string, now = new Date()): string | undefined { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return undefined; + + const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts; + const candidate = new Date(now); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + for (let index = 0; index < MAX_CRON_LOOKAHEAD_MINUTES; index += 1) { + const minute = candidate.getMinutes(); + const hour = candidate.getHours(); + const dayOfMonth = candidate.getDate(); + const month = candidate.getMonth() + 1; + const dayOfWeek = candidate.getDay(); + + if ( + matchesCronField(minuteExpr, minute, 0, 59) + && matchesCronField(hourExpr, hour, 0, 23) + && matchesCronField(dayOfMonthExpr, dayOfMonth, 1, 31) + && matchesCronField(monthExpr, month, 1, 12) + && matchesCronField(dayOfWeekExpr, dayOfWeek, 0, 7, true) + ) { + return candidate.toISOString(); + } + + candidate.setMinutes(candidate.getMinutes() + 1); + } + + return undefined; +} + +function estimateNextRun(schedule: CronJob['schedule'], enabled: boolean): string | undefined { + if (!enabled) return undefined; + + if (typeof schedule === 'string') { + return estimateNextCronRun(schedule); + } + + if (schedule.kind === 'at') { + const atMs = Date.parse(schedule.at); + if (!Number.isFinite(atMs) || atMs <= Date.now()) return undefined; + return new Date(atMs).toISOString(); + } + + if (schedule.kind === 'every') { + if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) return undefined; + const nowMs = Date.now(); + const anchorMs = typeof schedule.anchorMs === 'number' && Number.isFinite(schedule.anchorMs) + ? schedule.anchorMs + : nowMs; + const delta = Math.max(nowMs - anchorMs, 0); + const steps = Math.floor(delta / schedule.everyMs) + 1; + return new Date(anchorMs + steps * schedule.everyMs).toISOString(); + } + + return estimateNextCronRun(schedule.expr); +} + +function normalizeStoredJob(input: Partial | null | undefined): StoredCronJob | null { + if (!input || typeof input !== 'object') return null; + + const id = normalizeString(input.id); + const name = normalizeString(input.name); + const message = normalizeString(input.message); + const schedule = normalizeSchedule(input.schedule); + if (!id || !name || !message || !schedule) { + return null; + } + + const createdAt = normalizeIsoDate(input.createdAt); + const updatedAt = normalizeIsoDate(input.updatedAt, createdAt); + const enabled = input.enabled !== false; + + return { + id, + name, + message, + schedule, + delivery: normalizeDelivery(input.delivery), + enabled, + createdAt, + updatedAt, + lastRun: normalizeLastRun(input.lastRun), + nextRun: estimateNextRun(schedule, enabled), + agentId: normalizeString(input.agentId) || undefined, + }; +} + +function listNormalizedJobs(): StoredCronJob[] { + return readStore().jobs + .map((job) => normalizeStoredJob(job)) + .filter((job): job is StoredCronJob => Boolean(job)) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)); +} + +function writeJobs(jobs: StoredCronJob[]): StoredCronJob[] { + writeStore({ jobs }); + return jobs; +} + +function ensureCreateInput(input: CronJobCreateInput): void { + if (!normalizeString(input.name)) { + throw new Error('name is required'); + } + + if (!normalizeString(input.message)) { + throw new Error('message is required'); + } + + if (!normalizeString(input.schedule)) { + throw new Error('schedule is required'); + } +} + +export function listCronJobs(): CronJob[] { + return listNormalizedJobs(); +} + +export function createCronJob(input: CronJobCreateInput & { agentId?: string | null }): CronJob { + ensureCreateInput(input); + + const jobs = listNormalizedJobs(); + const now = new Date().toISOString(); + const schedule = normalizeSchedule(input.schedule); + if (!schedule) { + throw new Error('schedule is required'); + } + + const nextJob = normalizeStoredJob({ + id: `cron-${randomUUID()}`, + name: input.name, + message: input.message, + schedule, + delivery: input.delivery, + enabled: input.enabled !== false, + createdAt: now, + updatedAt: now, + agentId: input.agentId, + }); + + if (!nextJob) { + throw new Error('Failed to create cron job'); + } + + writeJobs([...jobs, nextJob]); + return nextJob; +} + +export function updateCronJob(jobId: string, input: CronJobUpdateInput & { agentId?: string | null }): CronJob { + const normalizedJobId = normalizeString(jobId); + if (!normalizedJobId) { + throw new Error('id is required'); + } + + const jobs = listNormalizedJobs(); + const index = jobs.findIndex((job) => job.id === normalizedJobId); + if (index === -1) { + throw new Error(`Cron job "${normalizedJobId}" not found`); + } + + const currentJob = jobs[index]; + const schedule = normalizeSchedule(input.schedule, currentJob.schedule); + const nextJob = normalizeStoredJob({ + ...currentJob, + name: normalizeString(input.name) || currentJob.name, + message: normalizeString(input.message) || currentJob.message, + schedule, + delivery: typeof input.delivery === 'undefined' ? currentJob.delivery : input.delivery, + enabled: typeof input.enabled === 'boolean' ? input.enabled : currentJob.enabled, + updatedAt: new Date().toISOString(), + agentId: typeof input.agentId === 'undefined' ? currentJob.agentId : input.agentId, + }); + + if (!nextJob) { + throw new Error(`Cron job "${normalizedJobId}" could not be normalized`); + } + + jobs[index] = nextJob; + writeJobs(jobs); + return nextJob; +} + +export function deleteCronJob(jobId: string): { id: string } { + const normalizedJobId = normalizeString(jobId); + if (!normalizedJobId) { + throw new Error('id is required'); + } + + const jobs = listNormalizedJobs(); + const nextJobs = jobs.filter((job) => job.id !== normalizedJobId); + if (nextJobs.length === jobs.length) { + throw new Error(`Cron job "${normalizedJobId}" not found`); + } + + writeJobs(nextJobs); + return { id: normalizedJobId }; +} + +export function toggleCronJob(jobId: string, enabled: boolean): CronJob { + return updateCronJob(jobId, { enabled }); +} + +export function triggerCronJob(jobId: string): CronJob { + const normalizedJobId = normalizeString(jobId); + if (!normalizedJobId) { + throw new Error('id is required'); + } + + const jobs = listNormalizedJobs(); + const index = jobs.findIndex((job) => job.id === normalizedJobId); + if (index === -1) { + throw new Error(`Cron job "${normalizedJobId}" not found`); + } + + const currentJob = jobs[index]; + const nextJob = normalizeStoredJob({ + ...currentJob, + updatedAt: new Date().toISOString(), + lastRun: { + time: new Date().toISOString(), + success: true, + }, + }); + + if (!nextJob) { + throw new Error(`Cron job "${normalizedJobId}" could not be normalized`); + } + + jobs[index] = nextJob; + writeJobs(jobs); + return nextJob; +} diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index 4d33fd1..c623509 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -7,6 +7,7 @@ export const OPENCLAW_CONFIG_DIR_NAME = '.openclaw'; export const OPENCLAW_RUNTIME_DIR_NAME = 'runtime'; export const OPENCLAW_PACKAGE_DIR_NAME = 'openclaw'; export const OPENCLAW_ENTRY_FILE_NAME = 'openclaw.mjs'; +export const USER_DATA_DIR_ENV_NAME = 'ZN_AI_USER_DATA_DIR'; export interface OpenClawRuntimePaths { configDir: string; @@ -20,6 +21,14 @@ export function getOpenClawConfigDir(): string { return join(homedir(), OPENCLAW_CONFIG_DIR_NAME); } +export function getUserDataDir(): string { + const override = process.env[USER_DATA_DIR_ENV_NAME]?.trim(); + if (override) { + return override; + } + return app.getPath('userData'); +} + export function getOpenClawRuntimeDir(): string { return join(getOpenClawConfigDir(), OPENCLAW_RUNTIME_DIR_NAME); } diff --git a/electron/utils/token-usage-writer.ts b/electron/utils/token-usage-writer.ts index 0cd8c1a..e15e54a 100644 --- a/electron/utils/token-usage-writer.ts +++ b/electron/utils/token-usage-writer.ts @@ -1,7 +1,7 @@ -import { app } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import { parseSessionKey } from '@runtime/lib/models'; +import { getUserDataDir } from './paths'; const PRIMARY_TRANSCRIPT_ROOT_DIR = 'models'; const LEGACY_TRANSCRIPT_ROOT_DIR = 'agents'; @@ -15,7 +15,7 @@ function buildTranscriptFilePath(sessionKey: string, rootDirName: string): strin sessionId = 'unknown'; } - const baseDir = path.join(app.getPath('userData'), rootDirName, agentId, 'sessions'); + const baseDir = path.join(getUserDataDir(), rootDirName, agentId, 'sessions'); return path.join(baseDir, `${sessionId}.jsonl`); } diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts index 212a64f..a33f3ad 100644 --- a/electron/utils/token-usage.ts +++ b/electron/utils/token-usage.ts @@ -1,7 +1,7 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join } from 'path'; -import { app } from 'electron'; import logManager from '@electron/service/logger'; +import { getUserDataDir } from './paths'; import { extractSessionIdFromTranscriptFileName, parseUsageEntriesFromJsonl, @@ -17,7 +17,7 @@ export { const TRANSCRIPT_ROOT_DIR_NAMES = ['models', 'agents'] as const; async function listAgentIdsWithSessionDirs(rootDirName: string): Promise { - const rootDir = join(app.getPath('userData'), rootDirName); + const rootDir = join(getUserDataDir(), rootDirName); const agentIds = new Set(); try { @@ -45,7 +45,7 @@ async function listRecentSessionFiles(): Promise; + channelAccountOwners: Record; +} + +export interface AgentChannelBinding { + channelType: string; + accountId: string; + agentId: string; +} + +export interface AgentChannelBindingInput { + channelType: string; + accountId: string; + agentId: string; +} + +export interface AgentChannelUnbindingInput { + channelType: string; + accountId: string; +} + +export function normalizeAgentId(value: string | null | undefined): string { + const normalized = String(value ?? '').trim().toLowerCase(); + return normalized || DEFAULT_AGENT_ID; +} + +export function normalizeSessionSuffix(value: string | null | undefined): string { + const normalized = String(value ?? '').trim().toLowerCase(); + return normalized || DEFAULT_MAIN_SESSION_SUFFIX; +} + +export function buildAgentSessionKey(agentId: string, sessionId: string): string { + return `agent:${normalizeAgentId(agentId)}:${normalizeSessionSuffix(sessionId)}`; +} + +export function buildMainSessionKey( + agentId: string, + sessionId = DEFAULT_MAIN_SESSION_SUFFIX, +): string { + return buildAgentSessionKey(agentId, sessionId); +} + +export function normalizeChannelType(value: string | null | undefined): string { + return String(value ?? '').trim().toLowerCase(); +} + +export function normalizeChannelAccountId(value: string | null | undefined): string { + const normalized = String(value ?? '').trim(); + return normalized || DEFAULT_CHANNEL_ACCOUNT_ID; +} + +export function buildChannelAccountOwnerKey( + channelType: string, + accountId: string | null | undefined = DEFAULT_CHANNEL_ACCOUNT_ID, +): string { + return `${normalizeChannelType(channelType)}:${normalizeChannelAccountId(accountId)}`; +} + +export function parseChannelAccountOwnerKey(key: string): { + channelType: string; + accountId: string; +} { + const trimmed = String(key ?? '').trim(); + const separatorIndex = trimmed.indexOf(':'); + if (separatorIndex === -1) { + return { + channelType: normalizeChannelType(trimmed), + accountId: DEFAULT_CHANNEL_ACCOUNT_ID, + }; + } + + return { + channelType: normalizeChannelType(trimmed.slice(0, separatorIndex)), + accountId: normalizeChannelAccountId(trimmed.slice(separatorIndex + 1)), + }; +} + +export function resolveChannelAccountOwner( + channelAccountOwners: Record | null | undefined, + channelType: string, + accountId?: string | null, +): string | null { + if (!channelAccountOwners) return null; + const key = buildChannelAccountOwnerKey(channelType, accountId); + const owner = channelAccountOwners[key]; + return typeof owner === 'string' && owner.trim() ? owner : null; +} + +export type AgentSummaryLike = AgentSummary; +export type AgentsSnapshotLike = AgentsSnapshot; diff --git a/runtime-shared/lib/models.ts b/runtime-shared/lib/models.ts index 87a0837..76e14b6 100644 --- a/runtime-shared/lib/models.ts +++ b/runtime-shared/lib/models.ts @@ -1,44 +1,26 @@ -export const DEFAULT_AGENT_ID = 'main'; -export const DEFAULT_MAIN_SESSION_SUFFIX = 'main'; -export const DEFAULT_MODEL_ID = DEFAULT_AGENT_ID; - -export interface AgentSummary { - id: string; - name: string; - isDefault: boolean; - providerAccountId: string | null; - modelRef: string | null; - modelDisplay: string; - mainSessionKey: string; - vendorId?: string | null; - source?: 'synthetic-main' | 'provider-account'; -} +import { + DEFAULT_AGENT_ID, + DEFAULT_CHANNEL_ACCOUNT_ID, + DEFAULT_MAIN_SESSION_SUFFIX, + buildChannelAccountOwnerKey, + normalizeChannelAccountId, + normalizeChannelType, + parseChannelAccountOwnerKey, + resolveChannelAccountOwner, + type AgentSummary, + type AgentChannelBinding, + type AgentChannelBindingInput, + type AgentChannelUnbindingInput, + type AgentsSnapshot, +} from './agents'; export type ModelSummary = AgentSummary; +export const DEFAULT_MODEL_ID = DEFAULT_AGENT_ID; -export interface ModelsSnapshot { +export type ModelsSnapshot = AgentsSnapshot & { models: ModelSummary[]; agents?: ModelSummary[]; - defaultAgentId: string; - defaultProviderAccountId: string | null; - defaultModelRef: string | null; - mainSessionSuffix: string; - configuredChannelTypes: string[]; - channelOwners: Record; - channelAccountOwners: Record; -} - -export interface AgentsSnapshot { - agents: AgentSummary[]; - models?: AgentSummary[]; - defaultAgentId: string; - defaultProviderAccountId: string | null; - defaultModelRef: string | null; - mainSessionSuffix: string; - configuredChannelTypes: string[]; - channelOwners: Record; - channelAccountOwners: Record; -} +}; export interface ParsedSessionKey { sessionKey: string; @@ -110,3 +92,22 @@ export function normalizeAgentSessionKey(sessionKey: string): string { export const normalizeModelId = normalizeAgentId; export const buildModelSessionKey = buildAgentSessionKey; export const normalizeModelSessionKey = normalizeAgentSessionKey; + +export { + DEFAULT_AGENT_ID, + DEFAULT_CHANNEL_ACCOUNT_ID, + DEFAULT_MAIN_SESSION_SUFFIX, + buildChannelAccountOwnerKey, + normalizeChannelAccountId, + normalizeChannelType, + parseChannelAccountOwnerKey, + resolveChannelAccountOwner, +}; + +export type { + AgentSummary, + AgentChannelBinding, + AgentChannelBindingInput, + AgentChannelUnbindingInput, + AgentsSnapshot, +}; diff --git a/scripts/agents-runtime-smoke.mjs b/scripts/agents-runtime-smoke.mjs new file mode 100644 index 0000000..6eaee6f --- /dev/null +++ b/scripts/agents-runtime-smoke.mjs @@ -0,0 +1,366 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const distMainEntry = path.join(repoRoot, 'dist-electron', 'main', 'main.js'); +const electronExecutable = require('electron'); + +async function ensureBuildArtifacts() { + await access(distMainEntry); +} + +async function startUpstreamServer() { + const requests = []; + const server = http.createServer((req, res) => { + const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + requests.push({ + method: req.method || 'GET', + pathname: requestUrl.pathname, + search: requestUrl.search, + }); + + if (req.method === 'GET' && requestUrl.pathname === '/ingress/api/channels/targets') { + const channelType = requestUrl.searchParams.get('channelType') || ''; + const accountId = requestUrl.searchParams.get('accountId') || ''; + + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ + success: true, + channelType, + accountId: accountId || null, + targets: [ + { + value: 'remote-ops-room', + label: 'Remote Ops Room', + description: 'Simulated upstream discovery target', + kind: 'name', + source: 'remote', + channelType, + accountId: accountId || undefined, + }, + ], + })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ success: false, error: 'Not found' })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind upstream smoke server'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}/ingress`, + requests, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function attachGatewayCollector(page) { + await page.evaluate(() => { + const scope = window; + if (scope.__smokeGatewayCollectorAttached) return; + + scope.__smokeGatewayCollectorAttached = true; + scope.__smokeGatewayEvents = []; + window.api.on('gateway:event', (event) => { + scope.__smokeGatewayEvents.push({ + ...event, + observedAt: new Date().toISOString(), + }); + }); + }); +} + +async function waitForIpcBridge(page, timeout = 15000) { + await page.waitForFunction( + () => Boolean(window.api && typeof window.api.invoke === 'function' && typeof window.api.on === 'function'), + { timeout }, + ); +} + +async function clearGatewayEvents(page) { + await page.evaluate(() => { + window.__smokeGatewayEvents = []; + }); +} + +async function waitForGatewayEvent(page, predicateSource, timeout = 15000) { + await page.waitForFunction( + (predicateBody) => { + const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : []; + const predicate = new Function('event', predicateBody); + return events.some((event) => { + try { + return Boolean(predicate(event)); + } catch { + return false; + } + }); + }, + predicateSource, + { timeout }, + ); + + return page.evaluate((predicateBody) => { + const events = Array.isArray(window.__smokeGatewayEvents) ? window.__smokeGatewayEvents : []; + const predicate = new Function('event', predicateBody); + return events.find((event) => { + try { + return Boolean(predicate(event)); + } catch { + return false; + } + }) ?? null; + }, predicateSource); +} + +async function invokeIpc(page, channel, ...args) { + return page.evaluate( + async ({ channel, args }) => window.api.invoke(channel, ...args), + { channel, args }, + ); +} + +async function hostApiFetch(page, pathName, init = {}) { + const method = init.method || 'GET'; + const headers = init.headers || {}; + const body = typeof init.body === 'undefined' + ? null + : typeof init.body === 'string' + ? init.body + : JSON.stringify(init.body); + + const response = await invokeIpc(page, 'hostapi:fetch', { + path: pathName, + method, + headers, + body, + }); + + if (response?.success === false || response?.ok === false) { + throw new Error(response?.error || response?.text || `Host API failed for ${method} ${pathName}`); + } + + return typeof response?.json !== 'undefined' + ? response.json + : typeof response?.data !== 'undefined' + ? response.data + : response; +} + +async function navigateTo(page, hashPath, headingText) { + await page.evaluate((nextHash) => { + window.location.hash = nextHash; + }, hashPath); + await page.waitForURL(new RegExp(`#${hashPath.replace('/', '\\/')}`), { timeout: 15000 }); + await page.getByText(headingText, { exact: false }).first().waitFor({ timeout: 15000 }); +} + +async function main() { + await ensureBuildArtifacts(); + + const tempHome = await mkdtemp(path.join(os.tmpdir(), 'zn-ai-agents-smoke-home-')); + const tempUserDataDir = path.join(tempHome, 'user-data'); + const upstreamServer = await startUpstreamServer(); + const launchEnv = { ...process.env }; + delete launchEnv.ELECTRON_RUN_AS_NODE; + + let electronApp; + + try { + console.log(`[smoke] using temporary HOME: ${tempHome}`); + console.log(`[smoke] using temporary userData: ${tempUserDataDir}`); + console.log(`[smoke] upstream targets server: ${upstreamServer.baseUrl}`); + console.log(`[smoke] electron executable: ${electronExecutable}`); + + console.log('[smoke] launching Electron...'); + electronApp = await electron.launch({ + executablePath: electronExecutable, + args: ['.'], + cwd: repoRoot, + timeout: 30000, + env: { + ...launchEnv, + HOME: tempHome, + ZN_AI_USER_DATA_DIR: tempUserDataDir, + ZN_AI_HOST_API_BASE_URL: upstreamServer.baseUrl, + VITE_SERVICE_URL: upstreamServer.baseUrl, + }, + }); + console.log('[smoke] Electron launched'); + + console.log('[smoke] waiting for first window...'); + const page = await electronApp.firstWindow(); + console.log('[smoke] first window acquired'); + await page.waitForLoadState('domcontentloaded'); + console.log('[smoke] first window DOM ready'); + + await page.evaluate(() => { + window.sessionStorage.setItem('token', JSON.stringify('smoke-token')); + window.location.hash = '/models'; + window.location.reload(); + }); + console.log('[smoke] login bypass injected'); + + await page.waitForURL(/#\/models/, { timeout: 15000 }); + await page.getByRole('heading', { name: 'AI Providers' }).waitFor({ timeout: 15000 }); + await waitForIpcBridge(page); + await attachGatewayCollector(page); + + const gatewayDefaultBefore = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {}); + assert.equal(gatewayDefaultBefore?.accountId ?? null, null, 'expected clean provider default state'); + + console.log('[smoke] models page loaded'); + + const providerAccountId = 'smoke-ollama-account'; + const providerLabel = 'Smoke Ollama'; + + await clearGatewayEvents(page); + await hostApiFetch(page, '/api/provider-accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { + account: { + id: providerAccountId, + vendorId: 'ollama', + label: providerLabel, + authMode: 'local', + baseUrl: 'http://127.0.0.1:11434', + model: 'llama3.2', + enabled: true, + isDefault: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + }); + await waitForGatewayEvent( + page, + "return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';", + ); + + await clearGatewayEvents(page); + await hostApiFetch(page, '/api/provider-accounts/default', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { accountId: providerAccountId }, + }); + await waitForGatewayEvent( + page, + "return event.type === 'runtime:changed' && event.topics.includes('providers') && event.reason === 'providers:changed';", + ); + await page.getByText(providerLabel, { exact: false }).waitFor({ timeout: 15000 }); + + const gatewayDefaultAfter = await invokeIpc(page, 'gateway:rpc', 'provider.getDefault', {}); + assert.equal(gatewayDefaultAfter?.accountId, providerAccountId, 'gateway default provider did not update'); + + console.log('[smoke] provider runtime sync and UI refresh verified'); + + await navigateTo(page, '/agents', 'Agents'); + await clearGatewayEvents(page); + await hostApiFetch(page, '/api/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { name: 'Smoke Agent' }, + }); + await waitForGatewayEvent( + page, + "return event.type === 'runtime:changed' && event.topics.includes('agents') && event.reason === 'agents:created';", + ); + await page.getByText('Smoke Agent', { exact: false }).first().waitFor({ timeout: 15000 }); + await page.getByText('Smoke Agent', { exact: false }).first().click(); + + const agentsSnapshot = await hostApiFetch(page, '/api/agents'); + const smokeAgent = Array.isArray(agentsSnapshot?.agents) + ? agentsSnapshot.agents.find((agent) => agent.name === 'Smoke Agent') + : null; + assert.ok(smokeAgent?.id, 'expected Smoke Agent to exist after creation'); + + console.log('[smoke] agents page auto refresh verified'); + + await invokeIpc(page, 'set-config', 'selectedChannels', [ + { + id: 'douyin-account-1', + channelName: '抖音门店号', + channelUrl: 'https://life.douyin.com/?openConversationId=local-fallback-room&accountId=douyin-account-1', + }, + ]); + + await clearGatewayEvents(page); + await hostApiFetch(page, '/api/channels/binding', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: { + channelType: 'douyin', + accountId: 'douyin-account-1', + agentId: smokeAgent.id, + }, + }); + await waitForGatewayEvent( + page, + "return event.type === 'runtime:changed' && event.topics.includes('channels') && event.topics.includes('channel-targets') && event.reason === 'channels:binding-updated';", + ); + await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 }); + + console.log('[smoke] agents-side binding summary refresh verified'); + + await navigateTo(page, '/channels', 'Channels'); + await page.getByText('抖音门店号', { exact: false }).first().waitFor({ timeout: 15000 }); + await page.getByText('当前归属:Smoke Agent', { exact: false }).waitFor({ timeout: 15000 }); + + console.log('[smoke] channels page binding refresh verified'); + + await navigateTo(page, '/cron', '定时任务'); + const directTargetCatalog = await hostApiFetch(page, '/api/channels/targets?channelType=douyin&accountId=douyin-account-1'); + console.log(`[smoke] direct target discovery: ${JSON.stringify(directTargetCatalog)}`); + await page.getByRole('button', { name: '新建任务' }).click(); + await page.getByText('新建定时任务').waitFor({ timeout: 15000 }); + await page.getByPlaceholder('例如:晨间播报').fill('Smoke Cron Broadcast'); + await page.getByPlaceholder('描述任务要执行或广播的内容').fill('Validate remote target discovery and delivery binding.'); + await page.getByRole('button', { name: '执行并发送' }).click(); + await page.locator('select').nth(1).selectOption('douyin'); + await page.locator('select').nth(2).selectOption('douyin-account-1'); + await page.getByRole('button', { name: 'Remote Ops Room' }).waitFor({ timeout: 15000 }); + await page.getByRole('button', { name: 'Remote Ops Room' }).click(); + await page.getByRole('button', { name: '创建任务' }).last().click(); + await page.getByText('定时任务已创建。', { exact: false }).waitFor({ timeout: 15000 }); + await page.getByText('Smoke Cron Broadcast', { exact: false }).waitFor({ timeout: 15000 }); + await page.getByText('remote-ops-room', { exact: false }).first().waitFor({ timeout: 15000 }); + + const targetRequests = upstreamServer.requests.filter((entry) => entry.pathname === '/ingress/api/channels/targets'); + assert.ok(targetRequests.length > 0, 'expected upstream /api/channels/targets to be called'); + + console.log('[smoke] cron remote target discovery verified'); + console.log('[smoke] all checks passed'); + } finally { + await electronApp?.close().catch(() => {}); + await upstreamServer.close().catch(() => {}); + await rm(tempHome, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((error) => { + console.error('[smoke] failed'); + console.error(error); + process.exitCode = 1; +}); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 704b78d..3f02070 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { useLocation, useNavigate } from 'react-router-dom'; -import { Book, Clock, Code, Cpu, House, Puzzle, Settings } from 'lucide-react'; +import { Book, Bot, Clock, Code, Cpu, House, Link2, Puzzle, Settings } from 'lucide-react'; import { useI18n } from '../../i18n'; import { NAV_ITEMS, normalizeWorkspacePath } from '../../router/routes'; @@ -8,6 +8,8 @@ import blueLogo from '../../assets/images/login/blue_logo.png'; const MENU_MARKS: Record = { '/home': House, '/knowledge': Book, + '/channels': Link2, + '/agents': Bot, '/models': Cpu, '/skills': Puzzle, '/cron': Clock, diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 812aaae..b4ba73e 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -43,12 +43,115 @@ export const messages: I18nMessages = { sidebar: { home: 'Home', knowledge: 'Knowledge', + channels: 'Channels', + agents: 'Agents', models: 'Models', skills: 'Skills', cron: 'Cron', scripts: 'Scripts', settings: 'Settings', }, + agents: { + title: 'Agents', + subtitle: 'Create new Agents to route specific channels into different personas or workspaces.', + refresh: 'Refresh', + addAgent: 'Add Agent', + addHint: 'Create a separate Agent for a new workspace, persona, or channel owner.', + warning: 'Changes from Providers, Channels, or runtime sync can take a moment to appear. Refresh if this view looks stale.', + emptyTitle: 'No Agents yet', + emptyDescription: 'Add your first Agent to split channels, accounts, or workspace context away from the Main Agent.', + errorPrefix: 'Failed to load agents: {error}', + defaultBadge: 'Default', + inherited: 'Inherited', + mainManagedHint: 'Main Agent', + none: 'None', + actions: { + rename: 'Rename', + model: 'Model', + settings: 'Settings', + delete: 'Delete', + }, + prompts: { + createName: 'Agent name', + inheritWorkspace: 'Inherit the Main Agent workspace bootstrap files for this Agent?', + renameName: 'Rename Agent', + modelRef: 'Set a provider/model ref for this Agent. Leave blank to inherit from its provider or the workspace default.', + deleteConfirm: 'Delete Agent "{name}"? Existing chats stay on disk, but this Agent will disappear from the Agents page.', + }, + createDialog: { + title: 'Add Agent', + description: 'Create a new Agent by name. You can optionally inherit the Main Agent workspace bootstrap files.', + nameLabel: 'Agent name', + namePlaceholder: 'Coding Helper', + inheritWorkspaceLabel: 'Inherit Main Agent workspace', + inheritWorkspaceDescription: 'Copy SOUL.md, AGENTS.md, and other bootstrap files from the Main Agent.', + saveLabel: 'Save', + savingLabel: 'Saving...', + }, + fields: { + model: 'Model', + workspace: 'Workspace', + agentDir: 'Agent folder', + mainSessionKey: 'Main session key', + channels: 'Channels', + providerAccount: 'Provider', + }, + card: { + modelLabel: 'Model', + channelCount: '{count} channels', + accountBindingCount: '{count} account bindings', + noChannels: 'No channels', + missingWorkspace: 'Workspace not configured', + missingModel: 'Model not configured', + missingAgentDir: 'Folder not created yet', + missingProviderAccount: 'No provider', + }, + settings: { + title: 'Agent settings', + description: 'Review identity, model routing, and channel ownership for {name}.', + identityTitle: 'Identity', + modelTitle: 'Model', + bindingTitle: 'Channel ownership', + bindingHelp: 'Read-only summary of which channels and accounts currently resolve to this Agent.', + bindingEmpty: 'No channels or account ownership are associated with this Agent yet.', + nameLabel: 'Agent name', + agentIdLabel: 'Agent ID', + providerAccountLabel: 'Provider account', + useDefaultProvider: 'Use workspace default provider', + modelRefLabel: 'Model override', + modelRefPlaceholder: 'provider/model-id', + effectiveProviderLabel: 'Effective provider', + effectiveModelLabel: 'Effective model', + modelHelp: 'Leave this empty to follow the selected provider model, or the workspace default when no provider is pinned.', + inheritModel: 'Use inherited model', + saveIdentity: 'Save name', + saveModel: 'Save model', + save: 'Save', + saving: 'Saving...', + managedFromModels: 'The Main Agent follows the provider and default model configured in Models.', + openModels: 'Open Models', + workspaceTitle: 'Workspace', + workspaceDescription: 'Review the current workspace path and whether this Agent inherits the Main Agent workspace.', + inheritedWorkspaceLabel: 'Inherit Main Agent workspace', + inheritedWorkspaceYes: 'Yes', + inheritedWorkspaceNo: 'No', + channelSummaryLabel: 'Channel routing summary', + accountBindingsLabel: 'Account binding count', + providerLoadErrorPrefix: 'Provider accounts could not be loaded: ', + refreshCatalog: 'Refresh data', + manageBindings: 'Open Channels', + boundToLabel: 'Owner: {owner}', + channelFallbackLabel: 'Channel fallback', + channelFallbackHelp: 'Used when an account under this channel has no explicit owner.', + channelBindingLabel: 'From channel', + accountBindingLabel: 'Account override', + unassigned: 'Unassigned', + noChannelAccounts: 'No accounts discovered for this channel yet.', + bindingReadonly: 'Ownership is read-only here. Change channel/account bindings in Channels.', + providerLoadError: 'Provider accounts could not be loaded: {error}', + channelLoadError: 'Channel accounts could not be loaded: {error}', + }, + }, settings: { menu: { systemSettings: 'System Settings', @@ -143,12 +246,115 @@ export const messages: I18nMessages = { sidebar: { home: '首页', knowledge: '知识库', + channels: '渠道', + agents: 'Agents', models: '模型', skills: '技能', cron: '定时任务', scripts: '脚本', settings: '设置', }, + agents: { + title: 'Agents', + subtitle: '创建新的 Agent,可以将特定频道路由到不同的人格配置或工作区。', + refresh: '刷新', + addAgent: '添加 Agent', + addHint: '为新的工作区、角色或频道归属创建独立 Agent。', + warning: 'Providers、Channels 或 runtime 刷新后,界面可能会有短暂延迟;如果看到旧数据,请手动刷新。', + emptyTitle: '还没有 Agent', + emptyDescription: '添加第一个 Agent,把频道、账号或工作区上下文从 Main Agent 中拆分出来。', + errorPrefix: '加载 Agents 失败:{error}', + defaultBadge: '默认', + inherited: '继承', + mainManagedHint: 'Main Agent', + none: '无', + actions: { + rename: '重命名', + model: '设置模型', + settings: '设置', + delete: '删除', + }, + prompts: { + createName: '请输入 Agent 名称', + inheritWorkspace: '是否让这个 Agent 继承 Main Agent 的工作区引导文件?', + renameName: '重命名 Agent', + modelRef: '为这个 Agent 设置 provider/model 标识。留空时会继承所选 Provider 或工作区默认模型。', + deleteConfirm: '确定删除 Agent “{name}”吗?现有聊天会保留在磁盘上,但这个 Agent 会从 Agents 页面中移除。', + }, + createDialog: { + title: '添加 Agent', + description: '输入名称即可创建新 Agent,可选择是否继承主 Agent 的工作区引导文件。', + nameLabel: 'Agent 名称', + namePlaceholder: 'Coding Helper', + inheritWorkspaceLabel: '继承 Main Agent 工作区', + inheritWorkspaceDescription: '从 Main Agent 复制 SOUL.md、AGENTS.md 等引导文件。', + saveLabel: '保存', + savingLabel: '保存中...', + }, + fields: { + model: '模型', + workspace: '工作区', + agentDir: 'Agent 目录', + mainSessionKey: '主会话 Key', + channels: '频道', + providerAccount: 'Provider', + }, + card: { + modelLabel: 'Model', + channelCount: '{count} 个频道', + accountBindingCount: '{count} 个账号绑定', + noChannels: '无频道', + missingWorkspace: '工作区未配置', + missingModel: '模型未配置', + missingAgentDir: '目录尚未创建', + missingProviderAccount: '未选择 Provider', + }, + settings: { + title: 'Agent 设置', + description: '查看并调整 {name} 的基础信息、模型路由和频道归属摘要。', + identityTitle: '基础信息', + modelTitle: '模型', + bindingTitle: '频道归属', + bindingHelp: '这里只展示这个 Agent 当前命中的频道和账号归属摘要。', + bindingEmpty: '这个 Agent 还没有任何频道或账号归属。', + nameLabel: 'Agent 名称', + agentIdLabel: 'Agent ID', + providerAccountLabel: 'Provider 账号', + useDefaultProvider: '使用工作区默认 Provider', + modelRefLabel: '模型覆盖', + modelRefPlaceholder: 'provider/model-id', + effectiveProviderLabel: '生效中的 Provider', + effectiveModelLabel: '生效中的模型', + modelHelp: '留空后会跟随所选 Provider 的模型;如果没有固定 Provider,则继续继承工作区默认模型。', + inheritModel: '改为继承模型', + saveIdentity: '保存名称', + saveModel: '保存模型', + save: '保存', + saving: '保存中...', + managedFromModels: 'Main Agent 使用 Models 页面里配置的 Provider 和默认模型。', + openModels: '前往 Models', + workspaceTitle: '工作区', + workspaceDescription: '查看当前 Agent 的工作区路径,以及是否继承主 Agent 工作区。', + inheritedWorkspaceLabel: '继承主 Agent 工作区', + inheritedWorkspaceYes: '是', + inheritedWorkspaceNo: '否', + channelSummaryLabel: '频道路由摘要', + accountBindingsLabel: '账号绑定数', + providerLoadErrorPrefix: '加载 Provider 账号失败:', + refreshCatalog: '刷新数据', + manageBindings: '前往 Channels', + boundToLabel: '归属:{owner}', + channelFallbackLabel: '频道级兜底归属', + channelFallbackHelp: '当该频道下的账号没有单独归属时,会使用这里的结果。', + channelBindingLabel: '继承频道', + accountBindingLabel: '账号覆盖', + unassigned: '未分配', + noChannelAccounts: '这个频道暂时还没有发现可展示的账号。', + bindingReadonly: '这里只读展示归属;修改 channel/account 绑定请前往 Channels。', + providerLoadError: '加载 Provider 账号失败:{error}', + channelLoadError: '加载频道账号失败:{error}', + }, + }, settings: { menu: { systemSettings: '系统设置', @@ -243,12 +449,115 @@ export const messages: I18nMessages = { sidebar: { home: 'ホーム', knowledge: 'ナレッジ', + channels: 'チャンネル', + agents: 'Agents', models: 'モデル', skills: 'スキル', cron: '定時タスク', scripts: 'スクリプト', settings: '設定', }, + agents: { + title: 'Agents', + subtitle: '新しい Agent を作成し、特定のチャンネルを別の人格設定やワークスペースへ振り分けます。', + refresh: '更新', + addAgent: 'Agent を追加', + addHint: '新しいワークスペース、役割、またはチャンネル担当のために別 Agent を作成します。', + warning: 'Providers、Channels、runtime の更新反映には少し時間がかかることがあります。表示が古い場合は更新してください。', + emptyTitle: 'まだ Agent はありません', + emptyDescription: '最初の Agent を追加して、チャンネル、アカウント、またはワークスペースの文脈を Main Agent から分けましょう。', + errorPrefix: 'Agents の読み込みに失敗しました: {error}', + defaultBadge: '既定', + inherited: '継承', + mainManagedHint: 'Main Agent', + none: 'なし', + actions: { + rename: '名前変更', + model: 'モデル設定', + settings: '設定', + delete: '削除', + }, + prompts: { + createName: 'Agent 名を入力してください', + inheritWorkspace: 'この Agent に Main Agent のワークスペース初期ファイルを引き継ぎますか?', + renameName: 'Agent 名を変更', + modelRef: 'この Agent の provider/model を設定します。空欄のままなら選択した Provider またはワークスペース既定モデルを継承します。', + deleteConfirm: 'Agent「{name}」を削除しますか?既存のチャットはディスクに残りますが、この Agent は Agents ページから消えます。', + }, + createDialog: { + title: 'Agent を追加', + description: '名前を指定して新しい Agent を作成します。必要なら Main Agent のワークスペース初期ファイルも引き継げます。', + nameLabel: 'Agent 名', + namePlaceholder: 'Coding Helper', + inheritWorkspaceLabel: 'Main Agent のワークスペースを引き継ぐ', + inheritWorkspaceDescription: 'Main Agent から SOUL.md、AGENTS.md などの初期ファイルをコピーします。', + saveLabel: '保存', + savingLabel: '保存中...', + }, + fields: { + model: 'モデル', + workspace: 'ワークスペース', + agentDir: 'Agent フォルダ', + mainSessionKey: 'メインセッション Key', + channels: 'チャンネル', + providerAccount: 'Provider', + }, + card: { + modelLabel: 'Model', + channelCount: '{count} チャンネル', + accountBindingCount: '{count} 件のアカウント紐付け', + noChannels: 'チャンネルなし', + missingWorkspace: 'ワークスペース未設定', + missingModel: 'モデル未設定', + missingAgentDir: 'フォルダ未作成', + missingProviderAccount: 'Provider 未選択', + }, + settings: { + title: 'Agent 設定', + description: '{name} の基本情報、モデルルーティング、チャンネル担当の要約を確認します。', + identityTitle: '基本情報', + modelTitle: 'モデル', + bindingTitle: 'チャンネル担当', + bindingHelp: 'この Agent に現在解決されるチャンネル / アカウント担当を読み取り専用で表示します。', + bindingEmpty: 'この Agent にはまだチャンネルまたはアカウントの担当がありません。', + nameLabel: 'Agent 名', + agentIdLabel: 'Agent ID', + providerAccountLabel: 'Provider アカウント', + useDefaultProvider: 'ワークスペース既定の Provider を使う', + modelRefLabel: 'モデル上書き', + modelRefPlaceholder: 'provider/model-id', + effectiveProviderLabel: '現在の Provider', + effectiveModelLabel: '現在のモデル', + modelHelp: '空欄のままなら選択した Provider のモデルを使い、Provider が固定されていなければワークスペース既定モデルを継承します。', + inheritModel: '継承モデルに戻す', + saveIdentity: '名前を保存', + saveModel: 'モデルを保存', + save: '保存', + saving: '保存中...', + managedFromModels: 'Main Agent は Models ページで設定した Provider と既定モデルに従います。', + openModels: 'Models を開く', + workspaceTitle: 'ワークスペース', + workspaceDescription: '現在のワークスペースパスと、Main Agent のワークスペースを継承しているかを確認します。', + inheritedWorkspaceLabel: 'Main Agent のワークスペースを継承', + inheritedWorkspaceYes: 'はい', + inheritedWorkspaceNo: 'いいえ', + channelSummaryLabel: 'チャンネルルーティング要約', + accountBindingsLabel: 'アカウント紐付け数', + providerLoadErrorPrefix: 'Provider アカウントの読み込みに失敗しました: ', + refreshCatalog: 'データを更新', + manageBindings: 'Channels を開く', + boundToLabel: '担当: {owner}', + channelFallbackLabel: 'チャンネル既定担当', + channelFallbackHelp: 'このチャンネル配下のアカウントに個別担当がない場合に使われます。', + channelBindingLabel: 'チャンネル継承', + accountBindingLabel: 'アカウント個別設定', + unassigned: '未割り当て', + noChannelAccounts: 'このチャンネルではまだ表示できるアカウントが見つかっていません。', + bindingReadonly: 'ここでは担当を読み取り専用で表示します。channel/account の変更は Channels ページで行ってください。', + providerLoadError: 'Provider アカウントの読み込みに失敗しました: {error}', + channelLoadError: 'チャンネルアカウントの読み込みに失敗しました: {error}', + }, + }, settings: { menu: { systemSettings: 'システム設定', diff --git a/src/lib/channel-types.ts b/src/lib/channel-types.ts new file mode 100644 index 0000000..9118ed0 --- /dev/null +++ b/src/lib/channel-types.ts @@ -0,0 +1,52 @@ +export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'; + +export interface ChannelAccountCatalogItem { + accountId: string; + name: string; + configured: boolean; + status: ChannelConnectionStatus; + lastError?: string; + isDefault: boolean; + agentId?: string; + bindingScope?: 'account' | 'channel'; + channelUrl?: string; +} + +export interface ChannelAccountCatalogGroup { + channelType: string; + channelLabel: string; + defaultAccountId: string; + status: ChannelConnectionStatus; + accounts: ChannelAccountCatalogItem[]; +} + +export interface ChannelAccountsCatalogResponse { + success?: boolean; + channels?: ChannelAccountCatalogGroup[]; +} + +export type ChannelTargetKind = 'name' | 'identifier' | 'webhook' | 'url'; + +export type ChannelTargetSource = + | 'channel-name' + | 'account-id' + | 'remote' + | 'query-param' + | 'hash-param' + | 'channel-url' + | 'fallback'; + +export interface ChannelTargetCatalogItem { + value: string; + label: string; + description?: string; + kind?: ChannelTargetKind; + source?: ChannelTargetSource; + channelType?: string; + accountId?: string; +} + +export interface ChannelTargetsCatalogResponse { + success?: boolean; + targets?: ChannelTargetCatalogItem[]; +} diff --git a/src/lib/cron-types.ts b/src/lib/cron-types.ts index f0fee49..fea93f1 100644 --- a/src/lib/cron-types.ts +++ b/src/lib/cron-types.ts @@ -7,6 +7,18 @@ export interface CronJobDelivery { accountId?: string; } +export interface CronDeliveryChannelAccount { + accountId: string; + name: string; + isDefault: boolean; +} + +export interface CronDeliveryChannelGroup { + channelType: string; + defaultAccountId: string; + accounts: CronDeliveryChannelAccount[]; +} + export interface CronJobLastRun { time: string; success: boolean; @@ -24,6 +36,7 @@ export interface CronJob { name: string; message: string; schedule: string | CronSchedule; + agentId?: string; delivery?: CronJobDelivery; enabled: boolean; createdAt: string; @@ -36,6 +49,7 @@ export interface CronJobCreateInput { name: string; message: string; schedule: string; + agentId?: string; delivery?: CronJobDelivery; enabled?: boolean; } @@ -44,6 +58,7 @@ export interface CronJobUpdateInput { name?: string; message?: string; schedule?: string; + agentId?: string; delivery?: CronJobDelivery; enabled?: boolean; } diff --git a/src/lib/runtime-events.ts b/src/lib/runtime-events.ts new file mode 100644 index 0000000..7d0b2c3 --- /dev/null +++ b/src/lib/runtime-events.ts @@ -0,0 +1,14 @@ +import type { GatewayEvent, RuntimeRefreshTopic } from '../types/runtime'; + +export type RuntimeChangedGatewayEvent = Extract; + +export function isRuntimeChangedGatewayEvent(event: GatewayEvent): event is RuntimeChangedGatewayEvent { + return event.type === 'runtime:changed'; +} + +export function runtimeEventHasTopic( + event: RuntimeChangedGatewayEvent, + ...topics: RuntimeRefreshTopic[] +): boolean { + return topics.some((topic) => event.topics.includes(topic)); +} diff --git a/src/pages/Agents/components/AddAgentDialog.tsx b/src/pages/Agents/components/AddAgentDialog.tsx new file mode 100644 index 0000000..e1296d4 --- /dev/null +++ b/src/pages/Agents/components/AddAgentDialog.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import AgentsDialogSurface from './AgentsDialogSurface'; + +const INPUT_CLASS_NAME = [ + 'h-[88px] w-full rounded-[26px] border border-black/10 bg-[#F8F4EC] px-7', + 'text-[22px] text-[#171717] outline-none transition-colors placeholder:text-[#9A958C]', + 'focus:border-black/20 dark:border-white/10 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500', +].join(' '); + +type AddAgentDialogCopy = { + title: string; + subtitle: string; + nameLabel: string; + namePlaceholder: string; + inheritWorkspaceLabel: string; + inheritWorkspaceDescription: string; + cancelLabel: string; + saveLabel: string; + savingLabel: string; +}; + +type AddAgentDialogProps = { + open: boolean; + saving: boolean; + copy: AddAgentDialogCopy; + onClose: () => void; + onSave: (input: { name: string; inheritWorkspace: boolean }) => Promise | void; +}; + +function Toggle({ + checked, + disabled, + onToggle, +}: { + checked: boolean; + disabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +export default function AddAgentDialog({ + open, + saving, + copy, + onClose, + onSave, +}: AddAgentDialogProps) { + const [name, setName] = useState(''); + const [inheritWorkspace, setInheritWorkspace] = useState(false); + + useEffect(() => { + if (!open) return; + setName(''); + setInheritWorkspace(false); + }, [open]); + + const canSave = name.trim().length > 0 && !saving; + + return ( + +
+
+ + setName(event.target.value)} + placeholder={copy.namePlaceholder} + className={INPUT_CLASS_NAME} + autoFocus + /> +
+ +
+
+
+ {copy.inheritWorkspaceLabel} +
+

+ {copy.inheritWorkspaceDescription} +

+
+ + setInheritWorkspace((current) => !current)} + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/Agents/components/AgentCard.tsx b/src/pages/Agents/components/AgentCard.tsx new file mode 100644 index 0000000..ae5fbd7 --- /dev/null +++ b/src/pages/Agents/components/AgentCard.tsx @@ -0,0 +1,65 @@ +import { Bot, Check, SlidersHorizontal } from 'lucide-react'; +import type { AgentSummary } from '@runtime/lib/agents'; + +type AgentCardProps = { + agent: AgentSummary; + defaultBadge: string; + modelLabel: string; + modelValue: string; + channelsLabel: string; + channelsValue: string; + settingsLabel: string; + disabled?: boolean; + onOpenSettings: (agent: AgentSummary) => void; +}; + +export default function AgentCard({ + agent, + defaultBadge, + modelLabel, + modelValue, + channelsLabel, + channelsValue, + settingsLabel, + disabled = false, + onOpenSettings, +}: AgentCardProps) { + return ( +
+
+
+ +
+ +
+
+

+ {agent.name} +

+ {agent.isDefault ? ( + + + {defaultBadge} + + ) : null} +
+ +
+
{`${modelLabel}: ${modelValue}`}
+
{`${channelsLabel}: ${channelsValue}`}
+
+
+ + +
+
+ ); +} diff --git a/src/pages/Agents/components/AgentSettingsDialog.tsx b/src/pages/Agents/components/AgentSettingsDialog.tsx new file mode 100644 index 0000000..812b849 --- /dev/null +++ b/src/pages/Agents/components/AgentSettingsDialog.tsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { AgentSummary } from '@runtime/lib/agents'; +import type { ProviderAccount } from '@runtime/lib/providers'; +import AgentsDialogSurface from './AgentsDialogSurface'; + +const FIELD_CLASS_NAME = [ + 'w-full rounded-[20px] border border-black/10 bg-[#F8F4EC] px-5 py-4 text-[16px] text-[#171717]', + 'outline-none transition-colors placeholder:text-[#99A0AE] focus:border-black/20', + 'disabled:cursor-not-allowed disabled:opacity-65 dark:border-white/10 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500 dark:focus:border-white/20', +].join(' '); + +type AgentSettingsDialogCopy = { + title: string; + subtitle: string; + identityTitle: string; + nameLabel: string; + agentIdLabel: string; + workspaceTitle: string; + workspaceDescription: string; + workspaceLabel: string; + inheritedWorkspaceLabel: string; + inheritedWorkspaceYes: string; + inheritedWorkspaceNo: string; + modelTitle: string; + providerAccountLabel: string; + useDefaultProvider: string; + modelRefLabel: string; + modelRefPlaceholder: string; + effectiveProviderLabel: string; + effectiveModelLabel: string; + modelHelp: string; + managedFromModels: string; + openModelsLabel: string; + channelsTitle: string; + channelSummaryLabel: string; + accountBindingsLabel: string; + noChannels: string; + openChannelsLabel: string; + providerLoadErrorPrefix: string; + cancelLabel: string; + saveLabel: string; + savingLabel: string; + deleteLabel: string; +}; + +type AgentSettingsDialogProps = { + open: boolean; + agent: AgentSummary | null; + providerAccounts: ProviderAccount[]; + providerLoading: boolean; + providerError: string | null; + defaultProviderAccountId: string | null; + defaultModelRef: string | null; + mainWorkspacePath: string | null; + channelSummary: string; + accountBindingCount: number; + saving: boolean; + deleting: boolean; + copy: AgentSettingsDialogCopy; + onClose: () => void; + onSave: (input: { name: string; providerAccountId: string | null; modelRef: string | null }) => Promise | void; + onDelete: (agent: AgentSummary) => Promise | void; + onOpenChannels: () => void; + onOpenModels: () => void; +}; + +export default function AgentSettingsDialog({ + open, + agent, + providerAccounts, + providerLoading, + providerError, + defaultProviderAccountId, + defaultModelRef, + mainWorkspacePath, + channelSummary, + accountBindingCount, + saving, + deleting, + copy, + onClose, + onSave, + onDelete, + onOpenChannels, + onOpenModels, +}: AgentSettingsDialogProps) { + const [name, setName] = useState(''); + const [providerAccountId, setProviderAccountId] = useState(''); + const [modelRef, setModelRef] = useState(''); + + useEffect(() => { + if (!agent || !open) return; + setName(agent.name); + setProviderAccountId(agent.providerAccountId ?? ''); + setModelRef(agent.overrideModelRef ?? ''); + }, [agent, open]); + + const selectedProvider = useMemo( + () => providerAccounts.find((account) => account.id === providerAccountId) ?? null, + [providerAccountId, providerAccounts], + ); + + const defaultProvider = useMemo( + () => providerAccounts.find((account) => account.id === defaultProviderAccountId) ?? null, + [defaultProviderAccountId, providerAccounts], + ); + + if (!agent) return null; + + const isDefault = agent.isDefault; + const inheritedWorkspace = Boolean( + !isDefault + && mainWorkspacePath + && agent.workspace + && agent.workspace === mainWorkspacePath, + ); + const effectiveProviderLabel = selectedProvider?.label || defaultProvider?.label || copy.useDefaultProvider; + const effectiveModelLabel = modelRef.trim() + || selectedProvider?.model + || defaultModelRef + || copy.modelRefPlaceholder; + const hasChanges = !isDefault && ( + name.trim() !== agent.name + || providerAccountId !== (agent.providerAccountId ?? '') + || modelRef !== (agent.overrideModelRef ?? '') + ); + + return ( + +
+
+
+
+ {copy.identityTitle} +
+
+ + +
+
+ {copy.agentIdLabel} +
+
+ {agent.id} +
+
+ +
+
+ {copy.workspaceTitle} +
+
+ {copy.workspaceDescription} +
+
+
+
+ {copy.workspaceLabel} +
+
+ {agent.workspace || '--'} +
+
+
+
+ {copy.inheritedWorkspaceLabel} +
+
+ {inheritedWorkspace ? copy.inheritedWorkspaceYes : copy.inheritedWorkspaceNo} +
+
+
+
+
+
+ +
+
+
+
+ {copy.channelsTitle} +
+
+ {copy.channelSummaryLabel} +
+
+ +
+ +
+
+
+ {copy.channelSummaryLabel} +
+
+ {channelSummary || copy.noChannels} +
+
+
+
+ {copy.accountBindingsLabel} +
+
+ {accountBindingCount} +
+
+
+
+
+ +
+
+
+
+ {copy.modelTitle} +
+
+ {isDefault ? copy.managedFromModels : copy.modelHelp} +
+
+ {isDefault ? ( + + ) : null} +
+ + {isDefault ? null : ( +
+ + + + +
+
+ + {copy.effectiveProviderLabel} + + {effectiveProviderLabel} +
+
+ + {copy.effectiveModelLabel} + + + {effectiveModelLabel} + +
+
+
+ )} + + {providerError && !isDefault ? ( +
+ {`${copy.providerLoadErrorPrefix}${providerError}`} +
+ ) : null} +
+ +
+ {!isDefault ? ( + + ) : null} + + + + {!isDefault ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/pages/Agents/components/AgentsDialogSurface.tsx b/src/pages/Agents/components/AgentsDialogSurface.tsx new file mode 100644 index 0000000..4a56486 --- /dev/null +++ b/src/pages/Agents/components/AgentsDialogSurface.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; + +type AgentsDialogSurfaceProps = { + open: boolean; + title: string; + subtitle?: string; + widthClassName?: string; + onClose: () => void; + children: ReactNode; +}; + +export default function AgentsDialogSurface({ + open, + title, + subtitle, + widthClassName = 'max-w-[860px]', + onClose, + children, +}: AgentsDialogSurfaceProps) { + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+

+ {title} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ + +
+ +
{children}
+
+
+ ); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx new file mode 100644 index 0000000..afb16b0 --- /dev/null +++ b/src/pages/Agents/index.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, Plus, RefreshCw } from 'lucide-react'; +import type { AgentSummary } from '@runtime/lib/agents'; +import type { ProviderAccount } from '@runtime/lib/providers'; +import { useNavigate } from 'react-router-dom'; +import { useI18n } from '../../i18n'; +import { onGatewayEvent } from '../../lib/gateway-client'; +import { hostApiFetch } from '../../lib/host-api'; +import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; +import { agentsStore, useAgentsStore } from '../../stores'; +import AddAgentDialog from './components/AddAgentDialog'; +import AgentCard from './components/AgentCard'; +import AgentSettingsDialog from './components/AgentSettingsDialog'; + +function interpolateFallback(template: string, params?: Record): string { + if (!params) return template; + + return template.replace(/\{(\w+)\}/g, (_match, token) => { + const value = params[token]; + return typeof value === 'undefined' ? `{${token}}` : String(value); + }); +} + +function formatChannelLabel(channelType: string): string { + const normalized = String(channelType ?? '') + .split(/[-_]/) + .map((part) => part.trim()) + .filter(Boolean); + + if (normalized.length === 0) return channelType; + + return normalized + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function countAccountBindings(agentId: string, channelAccountOwners: Record): number { + return Object.values(channelAccountOwners).filter((ownerId) => ownerId === agentId).length; +} + +function getAgentModelValue( + agent: AgentSummary, + defaultModelRef: string | null, + notConfiguredLabel: string, +): string { + const candidate = ( + agent.overrideModelRef + || agent.modelRef + || (agent.isDefault ? defaultModelRef : null) + || '' + ).trim(); + + return candidate || notConfiguredLabel; +} + +function getAgentChannelsValue(agent: AgentSummary, noneLabel: string): string { + const channels = Array.isArray(agent.channelTypes) + ? agent.channelTypes.map((channelType) => formatChannelLabel(channelType)).filter(Boolean) + : []; + + return channels.length > 0 ? channels.join(', ') : noneLabel; +} + +function Spinner() { + return ( +
+ ); +} + +export default function AgentsPage() { + const navigate = useNavigate(); + const { t, hasMessage } = useI18n(); + const initialized = useAgentsStore((state) => state.initialized); + const loading = useAgentsStore((state) => state.loading); + const error = useAgentsStore((state) => state.error); + const warning = useAgentsStore((state) => state.warning); + const agents = useAgentsStore((state) => state.agents); + const defaultProviderAccountId = useAgentsStore((state) => state.defaultProviderAccountId); + const defaultModelRef = useAgentsStore((state) => state.defaultModelRef); + const channelAccountOwners = useAgentsStore((state) => state.channelAccountOwners); + + const [busyAction, setBusyAction] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [settingsAgentId, setSettingsAgentId] = useState(null); + const [providerAccounts, setProviderAccounts] = useState([]); + const [providerLoading, setProviderLoading] = useState(false); + const [providerError, setProviderError] = useState(null); + + const message = (path: string, fallback: string, params?: Record) => ( + hasMessage(path) + ? t(path, params) + : interpolateFallback(fallback, params) + ); + + useEffect(() => { + void agentsStore.init(); + }, []); + + useEffect(() => { + void loadProviderAccounts(); + }, []); + + useEffect(() => ( + onGatewayEvent((event) => { + if (!isRuntimeChangedGatewayEvent(event)) return; + if (!runtimeEventHasTopic(event, 'providers', 'models', 'agents')) return; + void loadProviderAccounts(false); + }) + ), []); + + const sortedAgents = useMemo( + () => [...agents].sort((left, right) => { + if (left.isDefault && !right.isDefault) return -1; + if (!left.isDefault && right.isDefault) return 1; + return left.name.localeCompare(right.name, 'zh-CN'); + }), + [agents], + ); + + const settingsAgent = useMemo( + () => sortedAgents.find((agent) => agent.id === settingsAgentId) ?? null, + [settingsAgentId, sortedAgents], + ); + + const mainAgent = useMemo( + () => sortedAgents.find((agent) => agent.isDefault) ?? null, + [sortedAgents], + ); + + const isBusy = busyAction !== null; + const isInitialLoading = loading && !initialized; + + async function loadProviderAccounts(showLoading = true): Promise { + if (showLoading) { + setProviderLoading(true); + } + setProviderError(null); + + try { + const accounts = await hostApiFetch('/api/provider-accounts'); + setProviderAccounts(Array.isArray(accounts) ? accounts.filter((account) => account?.enabled !== false) : []); + } catch (requestError) { + setProviderAccounts([]); + setProviderError(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + if (showLoading) { + setProviderLoading(false); + } + } + } + + async function handleRefresh(): Promise { + await Promise.allSettled([ + agentsStore.refresh(), + loadProviderAccounts(), + ]); + } + + async function handleCreateAgent(input: { name: string; inheritWorkspace: boolean }): Promise { + setBusyAction('create'); + try { + await agentsStore.createAgent(input.name, { inheritWorkspace: input.inheritWorkspace }); + setAddDialogOpen(false); + } finally { + setBusyAction(null); + } + } + + async function handleSaveSettings(input: { name: string; providerAccountId: string | null; modelRef: string | null }): Promise { + if (!settingsAgent || settingsAgent.isDefault) { + setSettingsAgentId(null); + return; + } + + setBusyAction(`save:${settingsAgent.id}`); + try { + if (input.name.trim() && input.name.trim() !== settingsAgent.name) { + await agentsStore.updateAgent(settingsAgent.id, input.name.trim()); + } + + if ( + input.providerAccountId !== (settingsAgent.providerAccountId ?? null) + || input.modelRef !== (settingsAgent.overrideModelRef ?? null) + ) { + await agentsStore.updateAgentModel(settingsAgent.id, input.modelRef, { + providerAccountId: input.providerAccountId, + }); + } + + setSettingsAgentId(null); + } finally { + setBusyAction(null); + } + } + + async function handleDeleteAgent(agent: AgentSummary): Promise { + const confirmed = window.confirm( + message( + 'agents.prompts.deleteConfirm', + '确定删除 Agent “{name}”吗?已有会话记录会保留在磁盘上,但它会从 Agents 控制台中移除。', + { name: agent.name }, + ), + ); + if (!confirmed) return; + + setBusyAction(`delete:${agent.id}`); + try { + await agentsStore.deleteAgent(agent.id); + setSettingsAgentId((current) => (current === agent.id ? null : current)); + } finally { + setBusyAction(null); + } + } + + if (isInitialLoading) { + return ( +
+
+ +
+
+ ); + } + + const pageCopy = { + title: message('agents.title', 'Agents'), + subtitle: message('agents.subtitle', '创建新的 Agent,可以将特定频道路由到不同的人格配置或工作区。'), + refresh: message('agents.refresh', '刷新'), + addAgent: message('agents.addAgent', '添加 Agent'), + defaultBadge: message('agents.defaultBadge', '默认'), + none: message('agents.none', '无'), + modelLabel: message('agents.card.modelLabel', 'Model'), + channelsLabel: message('agents.fields.channels', '频道'), + settingsLabel: message('agents.actions.settings', '设置'), + emptyTitle: message('agents.emptyTitle', '暂无 Agent'), + emptyDescription: message('agents.emptyDescription', '创建新的 Agent 后,这里会显示对应的卡片摘要。'), + notConfigured: message('agents.card.missingModel', 'Not configured'), + addDialog: { + title: message('agents.createDialog.title', '添加 Agent'), + subtitle: message('agents.createDialog.description', '输入名称即可创建新 Agent,可选择是否继承主 Agent 的工作区引导文件。'), + nameLabel: message('agents.createDialog.nameLabel', 'Agent 名称'), + namePlaceholder: message('agents.createDialog.namePlaceholder', 'Coding Helper'), + inheritWorkspaceLabel: message('agents.createDialog.inheritWorkspaceLabel', '继承主 Agent 工作区'), + inheritWorkspaceDescription: message('agents.createDialog.inheritWorkspaceDescription', '从主 Agent 复制 SOUL.md、AGENTS.md 等引导文件。'), + cancelLabel: message('dialog.cancel', '取消'), + saveLabel: message('agents.createDialog.saveLabel', '保存'), + savingLabel: message('agents.createDialog.savingLabel', '保存中...'), + }, + settingsDialog: { + title: message('agents.settings.title', 'Agent 设置'), + subtitle: settingsAgent + ? message('agents.settings.description', '查看并调整 {name} 的基础信息、模型路由和频道归属摘要。', { name: settingsAgent.name }) + : '', + identityTitle: message('agents.settings.identityTitle', '基础信息'), + nameLabel: message('agents.settings.nameLabel', 'Agent 名称'), + agentIdLabel: message('agents.settings.agentIdLabel', 'Agent ID'), + workspaceTitle: message('agents.settings.workspaceTitle', '工作区'), + workspaceDescription: message('agents.settings.workspaceDescription', '查看当前 Agent 的工作区路径,以及是否继承主 Agent 工作区。'), + workspaceLabel: message('agents.fields.workspace', '工作区'), + inheritedWorkspaceLabel: message('agents.settings.inheritedWorkspaceLabel', '继承主 Agent 工作区'), + inheritedWorkspaceYes: message('agents.settings.inheritedWorkspaceYes', '是'), + inheritedWorkspaceNo: message('agents.settings.inheritedWorkspaceNo', '否'), + modelTitle: message('agents.settings.modelTitle', '模型'), + providerAccountLabel: message('agents.settings.providerAccountLabel', 'Provider 账号'), + useDefaultProvider: message('agents.settings.useDefaultProvider', '使用工作区默认 Provider'), + modelRefLabel: message('agents.settings.modelRefLabel', '模型覆盖'), + modelRefPlaceholder: message('agents.settings.modelRefPlaceholder', 'provider/model-id'), + effectiveProviderLabel: message('agents.settings.effectiveProviderLabel', '生效中的 Provider'), + effectiveModelLabel: message('agents.settings.effectiveModelLabel', '生效中的模型'), + modelHelp: message('agents.settings.modelHelp', '留空后会跟随所选 Provider 的模型;如果没有固定 Provider,则继续继承工作区默认模型。'), + managedFromModels: message('agents.settings.managedFromModels', 'Main Agent 使用 Models 页面里配置的 Provider 和默认模型。'), + openModelsLabel: message('agents.settings.openModels', '前往 Models'), + channelsTitle: message('agents.settings.bindingTitle', '频道归属'), + channelSummaryLabel: message('agents.settings.channelSummaryLabel', '频道路由摘要'), + accountBindingsLabel: message('agents.settings.accountBindingsLabel', '账号绑定数'), + noChannels: message('agents.card.noChannels', '无'), + openChannelsLabel: message('agents.settings.manageBindings', '前往 Channels'), + providerLoadErrorPrefix: message('agents.settings.providerLoadErrorPrefix', '加载 Provider 账号失败:'), + cancelLabel: message('dialog.cancel', '取消'), + saveLabel: message('agents.settings.save', '保存'), + savingLabel: message('agents.settings.saving', '保存中...'), + deleteLabel: message('agents.actions.delete', '删除'), + }, + }; + + return ( +
+
+
+
+

+ {pageCopy.title} +

+

+ {pageCopy.subtitle} +

+
+ +
+ + + +
+
+ +
+ {warning ? ( +
+
+ + {warning} +
+
+ ) : null} + + {error ? ( +
+
+ + {message('agents.errorPrefix', '加载 Agents 失败:{error}', { error })} +
+
+ ) : null} + + {providerError ? ( +
+
+ + {`${pageCopy.settingsDialog.providerLoadErrorPrefix}${providerError}`} +
+
+ ) : null} +
+ +
+ {sortedAgents.length === 0 ? ( +
+
+ {pageCopy.emptyTitle} +
+
+ {pageCopy.emptyDescription} +
+
+ ) : ( + sortedAgents.map((agent) => ( + setSettingsAgentId(targetAgent.id)} + /> + )) + )} +
+
+ + { + if (!isBusy) { + setAddDialogOpen(false); + } + }} + onSave={handleCreateAgent} + /> + + { + if (!isBusy) { + setSettingsAgentId(null); + } + }} + onSave={handleSaveSettings} + onDelete={handleDeleteAgent} + onOpenChannels={() => navigate('/channels')} + onOpenModels={() => navigate('/models')} + /> +
+ ); +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx new file mode 100644 index 0000000..f932562 --- /dev/null +++ b/src/pages/Channels/index.tsx @@ -0,0 +1,348 @@ +import { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, Bot, Link2, RefreshCw } from 'lucide-react'; +import type { ChannelAccountCatalogGroup, ChannelAccountsCatalogResponse } from '../../lib/channel-types'; +import { onGatewayEvent } from '../../lib/gateway-client'; +import { hostApiFetch } from '../../lib/host-api'; +import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; +import { agentsStore, useAgentsStore } from '../../stores'; + +function cn(...tokens: Array): string { + return tokens.filter(Boolean).join(' '); +} + +function formatChannelLabel(channelType: string, fallback?: string): string { + if (fallback) return fallback; + + const parts = String(channelType ?? '') + .split(/[-_]/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) return channelType; + + return parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeGroups(payload: unknown): ChannelAccountCatalogGroup[] { + const groups = Array.isArray(payload) + ? payload + : isRecord(payload) && Array.isArray(payload.channels) + ? payload.channels + : []; + + return groups + .filter((group): group is ChannelAccountCatalogGroup => isRecord(group)) + .map((group) => ({ + channelType: String(group.channelType ?? '').trim(), + channelLabel: formatChannelLabel(String(group.channelType ?? '').trim(), String(group.channelLabel ?? '').trim() || undefined), + defaultAccountId: String(group.defaultAccountId ?? '').trim(), + status: group.status ?? 'connected', + accounts: Array.isArray(group.accounts) + ? group.accounts + .filter((account) => isRecord(account)) + .map((account) => ({ + accountId: String(account.accountId ?? '').trim(), + name: String(account.name ?? account.accountId ?? '').trim(), + configured: account.configured !== false, + status: account.status ?? 'connected', + lastError: typeof account.lastError === 'string' ? account.lastError : undefined, + isDefault: Boolean(account.isDefault), + agentId: typeof account.agentId === 'string' ? account.agentId : undefined, + bindingScope: account.bindingScope === 'account' || account.bindingScope === 'channel' ? account.bindingScope : undefined, + channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined, + })) + .filter((account) => account.accountId) + : [], + })) + .filter((group) => group.channelType) + .sort((left, right) => left.channelLabel.localeCompare(right.channelLabel, 'zh-CN')); +} + +function resolveAgentName(agentId: string | null | undefined, agentNames: Map): string { + const resolvedId = String(agentId ?? '').trim(); + if (!resolvedId) return '未分配'; + return agentNames.get(resolvedId) || resolvedId; +} + +export default function ChannelsPage() { + const agents = useAgentsStore((state) => state.agents); + const agentsLoading = useAgentsStore((state) => state.loading); + const agentsError = useAgentsStore((state) => state.error); + const channelOwners = useAgentsStore((state) => state.channelOwners); + const channelAccountOwners = useAgentsStore((state) => state.channelAccountOwners); + + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [feedback, setFeedback] = useState(null); + const [pendingKey, setPendingKey] = useState(null); + + const agentNames = useMemo( + () => new Map(agents.map((agent) => [agent.id, agent.name])), + [agents], + ); + + async function loadChannels(): Promise { + setLoading(true); + setError(null); + + try { + const response = await hostApiFetch('/api/channels/accounts'); + setGroups(normalizeGroups(response)); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); + setGroups([]); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void Promise.all([agentsStore.init(), loadChannels()]); + }, []); + + useEffect(() => { + return onGatewayEvent((event) => { + if (!isRuntimeChangedGatewayEvent(event)) return; + if (!runtimeEventHasTopic(event, 'channels', 'agents', 'providers', 'channel-targets')) return; + void loadChannels(); + }); + }, []); + + async function handleRefresh(): Promise { + setFeedback(null); + await Promise.allSettled([agentsStore.refresh(), loadChannels()]); + } + + async function handleChannelOwnerChange(channelType: string, nextOwnerId: string): Promise { + const currentOwnerId = channelOwners[channelType] || ''; + if (currentOwnerId === nextOwnerId) return; + + setPendingKey(`channel:${channelType}`); + setFeedback(null); + try { + if (nextOwnerId) { + await agentsStore.assignChannel(nextOwnerId, channelType); + setFeedback(`${formatChannelLabel(channelType)} 的频道级归属已更新。`); + } else if (currentOwnerId) { + await agentsStore.removeChannel(currentOwnerId, channelType); + setFeedback(`${formatChannelLabel(channelType)} 的频道级归属已清除。`); + } + } catch (requestError) { + setFeedback(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + setPendingKey(null); + } + } + + async function handleAccountOwnerChange(channelType: string, accountId: string, nextOwnerId: string): Promise { + const bindingKey = `${channelType}:${accountId}`; + const currentOwnerId = channelAccountOwners[bindingKey] || ''; + if (currentOwnerId === nextOwnerId) return; + + setPendingKey(bindingKey); + setFeedback(null); + try { + if (nextOwnerId) { + await agentsStore.assignChannelAccount(nextOwnerId, channelType, accountId); + setFeedback(`${formatChannelLabel(channelType)} / ${accountId} 的账号归属已更新。`); + } else { + await agentsStore.clearChannelBinding(channelType, accountId); + setFeedback(`${formatChannelLabel(channelType)} / ${accountId} 的账号归属已清除。`); + } + } catch (requestError) { + setFeedback(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + setPendingKey(null); + } + } + + return ( +
+
+
+
+ + Channels + + + 在这里统一管理 channel/account 到 Agent 的归属,让 Agents 页面只负责模型与摘要展示,更接近 ClawX 的职责边界。 + +
+ + +
+ +
+ {feedback ? ( +
+ {feedback} +
+ ) : null} + + {error ? ( +
+
+ + {error} +
+
+ ) : null} + + {agentsError ? ( +
+
+ + {`Agents 数据加载失败:${agentsError}`} +
+
+ ) : null} + + {loading && groups.length === 0 ? ( +
+ 正在加载渠道目录... +
+ ) : groups.length === 0 ? ( +
+
暂无已配置渠道
+
+ 先在首页任务中心里配置可用渠道,这里就会出现对应的 channel/account 归属入口。 +
+
+ ) : ( +
+ {groups.map((group) => { + const channelOwnerId = channelOwners[group.channelType] || ''; + const channelPending = pendingKey === `channel:${group.channelType}`; + + return ( +
+
+
+
+
+ +
+
+
+ {group.channelLabel} +
+
+ {group.channelType} +
+
+
+
+ 频道级兜底归属会在账号没有单独指定 Agent 时生效。 +
+
+ +
+
频道级归属
+ +
+
+ +
+ {group.accounts.map((account) => { + const bindingKey = `${group.channelType}:${account.accountId}`; + const explicitOwnerId = channelAccountOwners[bindingKey] || ''; + const effectiveOwnerId = explicitOwnerId || channelOwnerId; + const bindingScope = explicitOwnerId ? '账号归属' : channelOwnerId ? '继承频道' : '未分配'; + const accountPending = pendingKey === bindingKey; + + return ( +
+
+
+
+
+ {account.name || account.accountId} +
+ {account.isDefault ? ( + + 默认账号 + + ) : null} + + {bindingScope} + +
+
+ {`当前归属:${resolveAgentName(effectiveOwnerId, agentNames)}`} + {account.channelUrl ? {account.channelUrl} : null} +
+
+ +
+
+ + 账号归属 +
+ +
+
+
+ ); + })} +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index b7b3a87..b2534f0 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -1,8 +1,29 @@ -import { useEffect, useMemo, useState, type SVGProps } from 'react'; +import { + useEffect, + useMemo, + useState, + type ReactNode, + type SVGProps, + type SelectHTMLAttributes, +} from 'react'; +import { DEFAULT_AGENT_ID, normalizeAgentId, normalizeChannelType, type AgentSummary } from '@runtime/lib/agents'; +import { onGatewayEvent } from '../../lib/gateway-client'; import { hostApiFetch } from '../../lib/host-api'; -import type { CronJob, CronJobCreateInput } from '../../lib/cron-types'; +import type { + ChannelTargetCatalogItem, + ChannelTargetsCatalogResponse, +} from '../../lib/channel-types'; +import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; +import type { + CronDeliveryChannelAccount, + CronDeliveryChannelGroup, + CronJob, + CronJobCreateInput, + CronJobDelivery, +} from '../../lib/cron-types'; +import { agentsStore, chatStore, useAgentsStore } from '../../stores'; -type FeedbackTone = 'success' | 'error' | 'info'; +type FeedbackTone = 'success' | 'error' | 'info' | 'warning'; type FeedbackState = { tone: FeedbackTone; @@ -13,12 +34,20 @@ type DialogProps = { open: boolean; job: CronJob | null; saving: boolean; + agents: AgentSummary[]; + defaultAgentId: string; + channelGroups: CronDeliveryChannelGroup[]; + channelsLoading: boolean; + channelsError: string | null; onClose: () => void; onSave: (input: CronJobCreateInput) => Promise; }; type JobCardProps = { job: CronJob; + agentLabel: string; + agentDetail: string; + deliverySummary: string; busyAction: string | null; onToggle: (enabled: boolean) => void; onEdit: () => void; @@ -39,6 +68,60 @@ type SchedulePreset = { value: string; }; +type CronJobsResponse = + | CronJob[] + | { + success?: boolean; + jobs?: unknown; + data?: unknown; + items?: unknown; + error?: string; + }; + +type ChannelAccountsResponse = + | unknown[] + | { + success?: boolean; + channels?: unknown; + groups?: unknown; + accounts?: unknown; + data?: unknown; + error?: string; + }; + +type ChannelTargetsResponse = + | unknown[] + | ChannelTargetsCatalogResponse + | { + success?: boolean; + targets?: unknown; + items?: unknown; + data?: unknown; + error?: string; + }; + +type DeliveryChannelGroupLike = Partial & { + name?: string; + label?: string; + channelName?: string; + accounts?: unknown; +}; + +type DeliveryChannelAccountLike = Partial & { + id?: string; + label?: string; + channelName?: string; + channelType?: string; + name?: string; + default?: boolean; +}; + +type DeliveryTargetOptionLike = Partial & { + name?: string; + title?: string; + desc?: string; +}; + const SCHEDULE_PRESETS: SchedulePreset[] = [ { key: 'every-minute', label: '每分钟', value: '* * * * *' }, { key: 'every-5-minutes', label: '每 5 分钟', value: '*/5 * * * *' }, @@ -50,12 +133,30 @@ const SCHEDULE_PRESETS: SchedulePreset[] = [ { key: 'monthly-first', label: '每月 1 日 09:00', value: '0 9 1 * *' }, ]; +const CHANNEL_DISPLAY_NAMES: Record = { + douyin: '抖音', + feishu: '飞书', + fliggy: '飞猪', + meituan: '美团', + qqbot: 'QQ 机器人', + telegram: 'Telegram', + wechat: '微信', + wecom: '企业微信', +}; + const FALLBACK_CRON_JOBS: CronJob[] = [ { id: 'cron-morning-briefing', name: '晨间营业播报', message: '每天开店前提醒值班同事检查渠道状态和当日房态。', schedule: '0 9 * * *', + agentId: 'main', + delivery: { + mode: 'announce', + channel: 'wecom', + accountId: 'default', + to: '早班值守群', + }, enabled: true, createdAt: '2026-04-10T09:00:00.000Z', updatedAt: '2026-04-16T09:00:00.000Z', @@ -70,6 +171,8 @@ const FALLBACK_CRON_JOBS: CronJob[] = [ name: '渠道异常巡检', message: '每 15 分钟轮询飞猪、美团和抖音渠道在线状态。', schedule: '*/15 * * * *', + agentId: 'main', + delivery: { mode: 'none' }, enabled: true, createdAt: '2026-04-11T08:30:00.000Z', updatedAt: '2026-04-16T08:30:00.000Z', @@ -84,6 +187,13 @@ const FALLBACK_CRON_JOBS: CronJob[] = [ name: '评论汇总提醒', message: '每日晚间生成评论待处理清单并推送给运营。', schedule: '0 18 * * *', + agentId: 'main', + delivery: { + mode: 'announce', + channel: 'feishu', + accountId: 'default', + to: '运营复盘群', + }, enabled: false, createdAt: '2026-04-09T18:00:00.000Z', updatedAt: '2026-04-15T18:00:00.000Z', @@ -99,11 +209,19 @@ function cn(...tokens: Array): string { return tokens.filter(Boolean).join(' '); } +function getString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + function IconBase({ children, className, ...props -}: SVGProps & { children: React.ReactNode }) { +}: SVGProps & { children: ReactNode }) { return ( ) { ); } +function BotIcon(props: SVGProps) { + return ( + + + + + + + + + ); +} + +function SendIcon(props: SVGProps) { + return ( + + + + + ); +} + +function ChevronDownIcon(props: SVGProps) { + return ( + + + + ); +} + function getScheduleExpression(schedule: CronJob['schedule']): string { if (typeof schedule === 'string') return schedule; if (schedule.kind === 'cron') return schedule.expr; @@ -277,59 +425,66 @@ function parseCronSchedule(schedule: CronJob['schedule']): string { return schedule; } -function estimateNextRun(scheduleExpr: string): string | null { +function estimateNextRunDate(scheduleExpr: string): Date | null { const now = new Date(); const next = new Date(now); if (scheduleExpr === '* * * * *') { next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + 1); - return formatDateTime(next.toISOString()); + return next; } if (scheduleExpr === '*/5 * * * *') { + const remainder = next.getMinutes() % 5; next.setSeconds(0, 0); - next.setMinutes(next.getMinutes() + (5 - (next.getMinutes() % 5 || 5))); - return formatDateTime(next.toISOString()); + next.setMinutes(next.getMinutes() + (remainder === 0 ? 5 : 5 - remainder)); + return next; } if (scheduleExpr === '*/15 * * * *') { + const remainder = next.getMinutes() % 15; next.setSeconds(0, 0); - next.setMinutes(next.getMinutes() + (15 - (next.getMinutes() % 15 || 15))); - return formatDateTime(next.toISOString()); + next.setMinutes(next.getMinutes() + (remainder === 0 ? 15 : 15 - remainder)); + return next; } if (scheduleExpr === '0 * * * *') { next.setMinutes(0, 0, 0); next.setHours(next.getHours() + 1); - return formatDateTime(next.toISOString()); + return next; } if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') { const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18; next.setHours(targetHour, 0, 0, 0); if (next <= now) next.setDate(next.getDate() + 1); - return formatDateTime(next.toISOString()); + return next; } if (scheduleExpr === '0 9 * * 1') { next.setHours(9, 0, 0, 0); - const day = next.getDay(); - const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7; + const weekday = next.getDay(); + const daysUntilMonday = weekday === 1 ? 7 : (8 - weekday) % 7; next.setDate(next.getDate() + daysUntilMonday); - return formatDateTime(next.toISOString()); + return next; } if (scheduleExpr === '0 9 1 * *') { next.setDate(1); next.setHours(9, 0, 0, 0); if (next <= now) next.setMonth(next.getMonth() + 1); - return formatDateTime(next.toISOString()); + return next; } return null; } +function estimateNextRun(scheduleExpr: string): string | null { + const next = estimateNextRunDate(scheduleExpr); + return next ? formatDateTime(next.toISOString()) : null; +} + function formatWeekday(weekday: string): string { const weekdayMap: Record = { '0': '周日', @@ -346,7 +501,10 @@ function formatWeekday(weekday: string): string { } function formatDateTime(value: string): string { - return new Date(value).toLocaleString('zh-CN', { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', @@ -356,6 +514,8 @@ function formatDateTime(value: string): string { function formatRelativeTime(value: string): string { const target = new Date(value).getTime(); + if (Number.isNaN(target)) return value; + const diff = Date.now() - target; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); @@ -370,23 +530,511 @@ function formatRelativeTime(value: string): string { return formatDateTime(value); } +function normalizeCronScheduleValue(value: unknown): CronJob['schedule'] { + if (typeof value === 'string' && value.trim()) return value.trim(); + + if (!isRecord(value)) return '0 9 * * *'; + + const kind = getString(value.kind); + if (kind === 'cron') { + const expr = getString(value.expr); + return { kind: 'cron', expr: expr || '0 9 * * *', tz: getString(value.tz) || undefined }; + } + + if (kind === 'at') { + const at = getString(value.at); + return { kind: 'at', at: at || new Date().toISOString() }; + } + + if (kind === 'every') { + const everyMs = typeof value.everyMs === 'number' && Number.isFinite(value.everyMs) ? value.everyMs : 60_000; + const anchorMs = typeof value.anchorMs === 'number' && Number.isFinite(value.anchorMs) ? value.anchorMs : undefined; + return { kind: 'every', everyMs, anchorMs }; + } + + return '0 9 * * *'; +} + +function normalizeCronDelivery(value: unknown): CronJobDelivery { + if (!isRecord(value)) return { mode: 'none' }; + + const mode = getString(value.mode) === 'announce' ? 'announce' : 'none'; + const channel = getString(value.channel) || undefined; + const to = getString(value.to) || undefined; + const accountId = getString(value.accountId) || undefined; + + if (mode === 'none') { + return { mode: 'none' }; + } + + return { + mode, + channel, + to, + accountId, + }; +} + +function normalizeCronJob(value: unknown): CronJob | null { + if (!isRecord(value)) return null; + + const id = getString(value.id); + if (!id) return null; + + const name = getString(value.name) || '未命名任务'; + const message = getString(value.message); + const schedule = normalizeCronScheduleValue(value.schedule); + const enabled = value.enabled !== false; + const createdAt = getString(value.createdAt) || new Date().toISOString(); + const updatedAt = getString(value.updatedAt) || createdAt; + const agentId = getString(value.agentId) ? normalizeAgentId(value.agentId) : undefined; + const nextRun = getString(value.nextRun) || undefined; + const lastRun = isRecord(value.lastRun) + ? { + time: getString(value.lastRun.time) || new Date().toISOString(), + success: value.lastRun.success !== false, + error: getString(value.lastRun.error) || undefined, + duration: typeof value.lastRun.duration === 'number' ? value.lastRun.duration : undefined, + } + : undefined; + + return { + id, + name, + message, + schedule, + agentId, + delivery: normalizeCronDelivery(value.delivery), + enabled, + createdAt, + updatedAt, + lastRun, + nextRun, + }; +} + function normalizeCronJobs(payload: unknown): CronJob[] { - return Array.isArray(payload) ? payload as CronJob[] : []; + if (Array.isArray(payload)) { + return payload.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job)); + } + + if (!isRecord(payload)) return []; + + const collection = Array.isArray(payload.jobs) + ? payload.jobs + : Array.isArray(payload.items) + ? payload.items + : Array.isArray(payload.data) + ? payload.data + : []; + + return collection.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job)); +} + +function normalizeCronJobResult(payload: unknown): CronJob | null { + if (isRecord(payload) && 'job' in payload) { + return normalizeCronJob(payload.job); + } + + if (isRecord(payload) && 'data' in payload && !Array.isArray(payload.data)) { + return normalizeCronJob(payload.data); + } + + return normalizeCronJob(payload); +} + +function normalizeDeliveryChannelAccount( + value: unknown, + fallbackDefaultAccountId?: string, +): CronDeliveryChannelAccount | null { + if (!isRecord(value)) return null; + + const account = value as DeliveryChannelAccountLike; + const accountId = getString(account.accountId) || getString(account.id) || 'default'; + const name = + getString(account.name) + || getString(account.label) + || getString(account.channelName) + || accountId; + + return { + accountId, + name, + isDefault: Boolean(account.isDefault || account.default || accountId === fallbackDefaultAccountId || accountId === 'default'), + }; +} + +function normalizeGroupedChannelEntry(value: unknown): CronDeliveryChannelGroup | null { + if (!isRecord(value)) return null; + + const group = value as DeliveryChannelGroupLike; + const channelType = normalizeChannelType(getString(group.channelType)); + if (!channelType) return null; + + const fallbackDefaultAccountId = getString(group.defaultAccountId) || 'default'; + const accounts = Array.isArray(group.accounts) + ? group.accounts + .map((account) => normalizeDeliveryChannelAccount(account, fallbackDefaultAccountId)) + .filter((account): account is CronDeliveryChannelAccount => Boolean(account)) + : []; + + const uniqueAccounts = dedupeDeliveryAccounts(accounts); + const defaultAccountId = + uniqueAccounts.find((account) => account.isDefault)?.accountId + || fallbackDefaultAccountId + || uniqueAccounts[0]?.accountId + || 'default'; + + return { + channelType, + defaultAccountId, + accounts: uniqueAccounts.map((account) => + account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, + ), + }; +} + +function dedupeDeliveryAccounts(accounts: CronDeliveryChannelAccount[]): CronDeliveryChannelAccount[] { + const byId = new Map(); + for (const account of accounts) { + const existing = byId.get(account.accountId); + if (!existing) { + byId.set(account.accountId, account); + continue; + } + + byId.set(account.accountId, { + accountId: account.accountId, + name: existing.name || account.name, + isDefault: existing.isDefault || account.isDefault, + }); + } + + return Array.from(byId.values()).sort((left, right) => { + if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1; + return left.name.localeCompare(right.name, 'zh-CN'); + }); +} + +function normalizeFlatChannelAccounts(payload: unknown[]): CronDeliveryChannelGroup[] { + const groupMap = new Map(); + + for (const entry of payload) { + if (!isRecord(entry)) continue; + + const account = entry as DeliveryChannelAccountLike; + const channelType = normalizeChannelType(getString(account.channelType)); + if (!channelType) continue; + + const normalizedAccount = normalizeDeliveryChannelAccount(entry); + if (!normalizedAccount) continue; + + const currentAccounts = groupMap.get(channelType) ?? []; + currentAccounts.push(normalizedAccount); + groupMap.set(channelType, currentAccounts); + } + + return Array.from(groupMap.entries()) + .map(([channelType, accounts]) => { + const uniqueAccounts = dedupeDeliveryAccounts(accounts); + const defaultAccountId = + uniqueAccounts.find((account) => account.isDefault)?.accountId + || uniqueAccounts[0]?.accountId + || 'default'; + + return { + channelType, + defaultAccountId, + accounts: uniqueAccounts.map((account) => + account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, + ), + }; + }) + .sort((left, right) => getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN')); +} + +function normalizeDeliveryChannelGroups(payload: unknown): CronDeliveryChannelGroup[] { + if (Array.isArray(payload)) { + const groupedEntries = payload + .map(normalizeGroupedChannelEntry) + .filter((entry): entry is CronDeliveryChannelGroup => Boolean(entry)); + + if (groupedEntries.length > 0) { + return groupedEntries.sort((left, right) => + getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN'), + ); + } + + return normalizeFlatChannelAccounts(payload); + } + + if (!isRecord(payload)) return []; + + if (Array.isArray(payload.channels)) { + return normalizeDeliveryChannelGroups(payload.channels); + } + + if (Array.isArray(payload.groups)) { + return normalizeDeliveryChannelGroups(payload.groups); + } + + if (Array.isArray(payload.accounts)) { + return normalizeDeliveryChannelGroups(payload.accounts); + } + + if (Array.isArray(payload.data)) { + return normalizeDeliveryChannelGroups(payload.data); + } + + return []; +} + +function normalizeDeliveryTargetOption(value: unknown): ChannelTargetCatalogItem | null { + if (!isRecord(value)) return null; + + const option = value as DeliveryTargetOptionLike; + const targetValue = getString(option.value); + if (!targetValue) return null; + + const kind = option.kind === 'name' + || option.kind === 'identifier' + || option.kind === 'webhook' + || option.kind === 'url' + ? option.kind + : undefined; + const source = option.source === 'channel-name' + || option.source === 'account-id' + || option.source === 'remote' + || option.source === 'query-param' + || option.source === 'hash-param' + || option.source === 'channel-url' + || option.source === 'fallback' + ? option.source + : undefined; + + return { + value: targetValue, + label: getString(option.label) || getString(option.title) || getString(option.name) || targetValue, + description: getString(option.description) || getString(option.desc) || undefined, + kind, + source, + channelType: getString(option.channelType) || undefined, + accountId: getString(option.accountId) || undefined, + }; +} + +function dedupeDeliveryTargetOptions(options: ChannelTargetCatalogItem[]): ChannelTargetCatalogItem[] { + const byValue = new Map(); + + for (const option of options) { + const existing = byValue.get(option.value); + if (!existing) { + byValue.set(option.value, option); + continue; + } + + byValue.set(option.value, { + ...existing, + label: existing.label || option.label, + description: existing.description || option.description, + kind: existing.kind || option.kind, + source: existing.source || option.source, + channelType: existing.channelType || option.channelType, + accountId: existing.accountId || option.accountId, + }); + } + + const kindOrder: Record, number> = { + name: 0, + identifier: 1, + webhook: 2, + url: 3, + }; + + return Array.from(byValue.values()).sort((left, right) => { + const leftOrder = left.kind ? kindOrder[left.kind] : 99; + const rightOrder = right.kind ? kindOrder[right.kind] : 99; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.label.localeCompare(right.label, 'zh-CN'); + }); +} + +function normalizeDeliveryTargetOptions(payload: unknown): ChannelTargetCatalogItem[] { + if (Array.isArray(payload)) { + return dedupeDeliveryTargetOptions( + payload + .map(normalizeDeliveryTargetOption) + .filter((option): option is ChannelTargetCatalogItem => Boolean(option)), + ); + } + + if (!isRecord(payload)) return []; + + if (Array.isArray(payload.targets)) { + return normalizeDeliveryTargetOptions(payload.targets); + } + + if (Array.isArray(payload.items)) { + return normalizeDeliveryTargetOptions(payload.items); + } + + if (Array.isArray(payload.data)) { + return normalizeDeliveryTargetOptions(payload.data); + } + + return []; +} + +function mergeDeliveryTargetOptions( + options: ChannelTargetCatalogItem[], + currentValue: string, +): ChannelTargetCatalogItem[] { + const trimmedValue = currentValue.trim(); + if (!trimmedValue) { + return options; + } + + if (options.some((option) => option.value === trimmedValue)) { + return options; + } + + return dedupeDeliveryTargetOptions([ + { + value: trimmedValue, + label: trimmedValue, + description: '当前任务里已经保存的自定义目标', + source: 'fallback', + }, + ...options, + ]); +} + +function ensureChannelGroupSelection( + groups: CronDeliveryChannelGroup[], + channelType: string, + accountId?: string, +): CronDeliveryChannelGroup[] { + const normalizedChannelType = normalizeChannelType(channelType); + if (!normalizedChannelType) return groups; + if (groups.some((group) => group.channelType === normalizedChannelType)) return groups; + + return [ + ...groups, + { + channelType: normalizedChannelType, + defaultAccountId: accountId || 'default', + accounts: accountId + ? [{ accountId, name: accountId, isDefault: true }] + : [], + }, + ]; +} + +function getChannelDisplayName(channelType: string): string { + const normalized = normalizeChannelType(channelType); + if (!normalized) return '未命名渠道'; + if (CHANNEL_DISPLAY_NAMES[normalized]) return CHANNEL_DISPLAY_NAMES[normalized]; + + return normalized + .split(/[-_]/) + .filter(Boolean) + .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1)) + .join(' '); +} + +function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined): string { + if (!account) return '主账号'; + if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return '主账号'; + return account.name; +} + +function getAgentLabel( + agentId: string | null | undefined, + agentsById: Map, + defaultAgentId: string, +): string { + const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID); + const resolvedAgent = agentsById.get(resolvedId); + if (resolvedAgent?.name) return resolvedAgent.name; + if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)) return '主 Agent'; + return resolvedId; +} + +function formatPathTail(value: string): string { + const normalized = value.replace(/\\/g, '/').trim(); + if (!normalized) return ''; + + const parts = normalized.split('/').filter(Boolean); + if (parts.length <= 2) return normalized; + return `.../${parts.slice(-2).join('/')}`; +} + +function getAgentDetail( + agentId: string | null | undefined, + agentsById: Map, + defaultAgentId: string, +): string { + const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID); + const resolvedAgent = agentsById.get(resolvedId); + + if (resolvedAgent?.workspace) { + return `工作区 ${formatPathTail(resolvedAgent.workspace)}`; + } + + if (resolvedAgent?.agentDir) { + return `目录 ${formatPathTail(resolvedAgent.agentDir)}`; + } + + if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID) || resolvedAgent?.isDefault) { + return '共享主工作区'; + } + + return '工作区待同步'; +} + +function describeDelivery( + delivery: CronJobDelivery | undefined, + channelGroups: CronDeliveryChannelGroup[], +): string { + if (!delivery || delivery.mode !== 'announce') { + return '仅执行任务,不额外发送'; + } + + const channelType = normalizeChannelType(delivery.channel); + if (!channelType) { + return '公告发送待配置'; + } + + const channelGroup = channelGroups.find((group) => group.channelType === channelType); + const accountId = delivery.accountId || channelGroup?.defaultAccountId; + const account = channelGroup?.accounts.find((item) => item.accountId === accountId) || channelGroup?.accounts[0]; + const target = getString(delivery.to); + + return [ + getChannelDisplayName(channelType), + account ? getDeliveryAccountDisplayName(account) : null, + target || '目标待填写', + ] + .filter(Boolean) + .join(' / '); } function buildLocalCronJob(input: CronJobCreateInput, current?: CronJob | null): CronJob { const schedule = input.schedule.trim(); + const nextRun = estimateNextRunDate(schedule)?.toISOString() ?? current?.nextRun; + return { id: current?.id ?? `cron-${Date.now()}`, name: input.name.trim(), message: input.message.trim(), schedule, + agentId: input.agentId ? normalizeAgentId(input.agentId) : current?.agentId, enabled: input.enabled ?? current?.enabled ?? true, - delivery: input.delivery, + delivery: input.delivery ?? current?.delivery ?? { mode: 'none' }, createdAt: current?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), lastRun: current?.lastRun, - nextRun: estimateNextRun(schedule) ? new Date().toISOString() : current?.nextRun, + nextRun, }; } @@ -399,9 +1047,45 @@ function toneClasses(tone: FeedbackTone): string { return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-900/20 dark:text-red-300'; } + if (tone === 'warning') { + return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-900/20 dark:text-amber-300'; + } + return 'border-[#dfeaf6] bg-[#f8fbff] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300'; } +function Notice({ + tone, + message, +}: { + tone: FeedbackTone; + message: string; +}) { + return ( +
+ + {message} +
+ ); +} + +function SelectField({ className, children, ...props }: SelectHTMLAttributes & { children: ReactNode }) { + return ( +
+ + +
+ ); +} + function StatCard({ label, value, tone, icon: Icon }: StatCardProps) { const iconWrapperClass = tone === 'green' @@ -427,8 +1111,19 @@ function StatCard({ label, value, tone, icon: Icon }: StatCardProps) { ); } -function CronJobCard({ job, busyAction, onToggle, onEdit, onDelete, onTrigger }: JobCardProps) { +function CronJobCard({ + job, + agentLabel, + agentDetail, + deliverySummary, + busyAction, + onToggle, + onEdit, + onDelete, + onTrigger, +}: JobCardProps) { const disabled = busyAction !== null; + const isAnnouncement = job.delivery?.mode === 'announce'; return (
+ + {isAnnouncement ? '执行并发送' : '仅执行'} +
@@ -488,6 +1193,24 @@ function CronJobCard({ job, busyAction, onToggle, onEdit, onDelete, onTrigger }:

{job.message}

+
+
+ + + Agent + {agentLabel} + {agentDetail} + +
+
+ + + 送达 + {deliverySummary} + +
+
+
{job.lastRun ? ( @@ -546,42 +1269,229 @@ function CronJobCard({ job, busyAction, onToggle, onEdit, onDelete, onTrigger }: ); } -function CronTaskDialog({ open, job, saving, onClose, onSave }: DialogProps) { - const initialSchedule = useMemo(() => { - if (!job) return '0 9 * * *'; - if (typeof job.schedule === 'string') return job.schedule; - if (job.schedule.kind === 'cron') return job.schedule.expr; - return '0 9 * * *'; - }, [job]); +function getSuggestedAgentId(agents: AgentSummary[], defaultAgentId: string, currentJob: CronJob | null): string { + const jobAgentId = getString(currentJob?.agentId); + if (jobAgentId) return normalizeAgentId(jobAgentId); + const currentChatAgentId = getString(chatStore.getState().currentAgentId); + const normalizedCurrentChatAgentId = normalizeAgentId(currentChatAgentId || defaultAgentId || DEFAULT_AGENT_ID); + + if (agents.some((agent) => agent.id === normalizedCurrentChatAgentId)) { + return normalizedCurrentChatAgentId; + } + + if (agents.some((agent) => agent.id === normalizeAgentId(defaultAgentId))) { + return normalizeAgentId(defaultAgentId); + } + + return agents[0]?.id || normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID); +} + +function CronTaskDialog({ + open, + job, + saving, + agents, + defaultAgentId, + channelGroups, + channelsLoading, + channelsError, + onClose, + onSave, +}: DialogProps) { const [name, setName] = useState(''); + const [selectedAgentId, setSelectedAgentId] = useState(normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)); const [message, setMessage] = useState(''); const [schedule, setSchedule] = useState('0 9 * * *'); const [enabled, setEnabled] = useState(true); const [useCustom, setUseCustom] = useState(false); const [customSchedule, setCustomSchedule] = useState(''); const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>('none'); + const [deliveryChannel, setDeliveryChannel] = useState(''); + const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(''); + const [deliveryTarget, setDeliveryTarget] = useState(''); + const [loadedDeliveryTargetOptions, setLoadedDeliveryTargetOptions] = useState([]); + const [targetsLoading, setTargetsLoading] = useState(false); + const [targetsError, setTargetsError] = useState(null); + const [deliveryTargetScopeKey, setDeliveryTargetScopeKey] = useState(''); const [validationError, setValidationError] = useState(null); useEffect(() => { if (!open) return; + const initialSchedule = job ? getScheduleExpression(job.schedule) || '0 9 * * *' : '0 9 * * *'; + const matchedPreset = SCHEDULE_PRESETS.some((item) => item.value === initialSchedule); + setName(job?.name ?? ''); setMessage(job?.message ?? ''); setSchedule(initialSchedule); setEnabled(job?.enabled ?? true); - setDeliveryMode(job?.delivery?.mode === 'announce' ? 'announce' : 'none'); - - const matchedPreset = SCHEDULE_PRESETS.some((item) => item.value === initialSchedule); setUseCustom(!matchedPreset); setCustomSchedule(matchedPreset ? '' : initialSchedule); + setDeliveryMode(job?.delivery?.mode === 'announce' ? 'announce' : 'none'); + setDeliveryChannel(getString(job?.delivery?.channel)); + setSelectedDeliveryAccountId(getString(job?.delivery?.accountId)); + setDeliveryTarget(getString(job?.delivery?.to)); + setLoadedDeliveryTargetOptions([]); + setTargetsLoading(false); + setTargetsError(null); + setDeliveryTargetScopeKey(''); + setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job)); setValidationError(null); - }, [initialSchedule, job, open]); + }, [agents, defaultAgentId, job, open]); + + const availableChannelGroups = useMemo( + () => ensureChannelGroupSelection(channelGroups, deliveryChannel, selectedDeliveryAccountId), + [channelGroups, deliveryChannel, selectedDeliveryAccountId], + ); + + const selectedChannelGroup = useMemo( + () => availableChannelGroups.find((group) => group.channelType === normalizeChannelType(deliveryChannel)), + [availableChannelGroups, deliveryChannel], + ); + + const deliveryTargetOptions = useMemo( + () => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget), + [deliveryTarget, loadedDeliveryTargetOptions], + ); + + const targetListId = useMemo( + () => `cron-delivery-targets-${job?.id ?? 'draft'}`, + [job?.id], + ); + + useEffect(() => { + if (!open || deliveryMode !== 'announce') return; + + if (!deliveryChannel && availableChannelGroups[0]) { + setDeliveryChannel(availableChannelGroups[0].channelType); + } + }, [availableChannelGroups, deliveryChannel, deliveryMode, open]); + + useEffect(() => { + if (!open || deliveryMode !== 'announce') return; + + const resolvedDefaultAccountId = + selectedChannelGroup?.defaultAccountId + || selectedChannelGroup?.accounts[0]?.accountId + || ''; + + if (!resolvedDefaultAccountId) { + setSelectedDeliveryAccountId(''); + return; + } + + const hasSelectedAccount = selectedChannelGroup?.accounts.some((account) => account.accountId === selectedDeliveryAccountId); + if (!selectedDeliveryAccountId || !hasSelectedAccount) { + setSelectedDeliveryAccountId(resolvedDefaultAccountId); + } + }, [deliveryMode, open, selectedChannelGroup, selectedDeliveryAccountId]); + + useEffect(() => { + if (!open) return; + if (agents.length === 0) return; + if (agents.some((agent) => agent.id === selectedAgentId)) return; + setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job)); + }, [agents, defaultAgentId, job, open, selectedAgentId]); + + useEffect(() => { + if (!open || deliveryMode !== 'announce') { + setDeliveryTargetScopeKey(''); + return; + } + + const scopeKey = `${normalizeChannelType(deliveryChannel)}:${getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId)}`; + if (!scopeKey || scopeKey === ':') { + setDeliveryTargetScopeKey(''); + return; + } + + setDeliveryTargetScopeKey((currentScopeKey) => { + if (currentScopeKey && currentScopeKey !== scopeKey) { + setDeliveryTarget(''); + } + return scopeKey; + }); + }, [ + deliveryChannel, + deliveryMode, + open, + selectedChannelGroup?.defaultAccountId, + selectedDeliveryAccountId, + ]); + + useEffect(() => { + if (!open || deliveryMode !== 'announce') { + setLoadedDeliveryTargetOptions([]); + setTargetsLoading(false); + setTargetsError(null); + return; + } + + const normalizedChannelType = normalizeChannelType(deliveryChannel); + const normalizedAccountId = getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId); + if (!normalizedChannelType) { + setLoadedDeliveryTargetOptions([]); + setTargetsLoading(false); + setTargetsError(null); + return; + } + + let cancelled = false; + const query = new URLSearchParams({ channelType: normalizedChannelType }); + if (normalizedAccountId) { + query.set('accountId', normalizedAccountId); + } + + setTargetsLoading(true); + setTargetsError(null); + + void hostApiFetch(`/api/channels/targets?${query.toString()}`) + .then((response) => { + if (cancelled) return; + setLoadedDeliveryTargetOptions(normalizeDeliveryTargetOptions(response)); + }) + .catch((requestError) => { + if (cancelled) return; + setLoadedDeliveryTargetOptions([]); + setTargetsError(requestError instanceof Error ? requestError.message : String(requestError)); + }) + .finally(() => { + if (!cancelled) { + setTargetsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + deliveryChannel, + deliveryMode, + open, + selectedChannelGroup?.defaultAccountId, + selectedDeliveryAccountId, + ]); if (!open) return null; const finalSchedule = useCustom ? customSchedule.trim() : schedule; const nextRunPreview = finalSchedule ? estimateNextRun(finalSchedule) : null; + const hasSelectableAgents = agents.length > 0; + const selectedAgent = agents.find((agent) => agent.id === selectedAgentId); + const selectedAccount = selectedChannelGroup?.accounts.find((account) => account.accountId === selectedDeliveryAccountId); + const deliveryPreview = + deliveryMode === 'announce' + ? describeDelivery( + { + mode: 'announce', + channel: deliveryChannel, + accountId: selectedDeliveryAccountId, + to: deliveryTarget, + }, + availableChannelGroups, + ) + : '仅执行任务,不额外发送'; async function handleSubmit(): Promise { if (!name.trim()) { @@ -589,6 +1499,11 @@ function CronTaskDialog({ open, job, saving, onClose, onSave }: DialogProps) { return; } + if (!selectedAgentId.trim()) { + setValidationError('请先选择要执行任务的 Agent。'); + return; + } + if (!message.trim()) { setValidationError('请填写提醒内容。'); return; @@ -599,20 +1514,41 @@ function CronTaskDialog({ open, job, saving, onClose, onSave }: DialogProps) { return; } + if (deliveryMode === 'announce') { + if (!deliveryChannel) { + setValidationError('请选择一个发送渠道。'); + return; + } + + if (!deliveryTarget.trim()) { + setValidationError('请填写发送目标,例如群组名、用户标识或 Webhook。'); + return; + } + } + setValidationError(null); await onSave({ name: name.trim(), + agentId: normalizeAgentId(selectedAgentId), message: message.trim(), schedule: finalSchedule, enabled, - delivery: deliveryMode === 'announce' ? { mode: 'announce', channel: '', to: '' } : { mode: 'none' }, + delivery: + deliveryMode === 'announce' + ? { + mode: 'announce', + channel: normalizeChannelType(deliveryChannel), + accountId: selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId || undefined, + to: deliveryTarget.trim(), + } + : { mode: 'none' }, }); } return (
-
+

{job ? '编辑定时任务' : '新建定时任务'}

-

延续现有 Vue 页面的字段组织与轻量提醒设置。

+

+ 现在可以为任务指定 Agent、发送渠道和目标收件人,和 ClawX 的调度职责保持一致。 +

+ ))} +
+
+ {targetsLoading + ? '正在为当前渠道账号加载推荐目标...' + : deliveryTargetOptions.length > 0 + ? '推荐目标已就绪,也可以继续手工输入群组、用户标识或 Webhook。' + : '暂未发现推荐目标,仍可手工输入群组、用户标识或 Webhook。'} +
+
+ 预览:{deliveryPreview} + {selectedAccount ? 当前账号:{getDeliveryAccountDisplayName(selectedAccount)} : null} +
+
+ + {channelsError ? : null} + {targetsError ? : null} + {!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? ( + + ) : null}
) : null}
@@ -789,7 +1843,7 @@ function CronTaskDialog({ open, job, saving, onClose, onSave }: DialogProps) { onClick={() => { void handleSubmit(); }} - disabled={saving} + disabled={saving || !hasSelectableAgents} > {saving ? : } {saving ? '保存中...' : job ? '保存修改' : '创建任务'} @@ -801,6 +1855,12 @@ function CronTaskDialog({ open, job, saving, onClose, onSave }: DialogProps) { } export default function CronPage() { + const agents = useAgentsStore((state) => state.agents); + const agentsLoading = useAgentsStore((state) => state.loading); + const agentsError = useAgentsStore((state) => state.error); + const agentsWarning = useAgentsStore((state) => state.warning); + const defaultAgentId = useAgentsStore((state) => state.defaultAgentId); + const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -810,18 +1870,28 @@ export default function CronPage() { const [dialogSaving, setDialogSaving] = useState(false); const [busyJobId, setBusyJobId] = useState(null); const [busyAction, setBusyAction] = useState(null); + const [channelGroups, setChannelGroups] = useState([]); + const [channelsLoading, setChannelsLoading] = useState(false); + const [channelsError, setChannelsError] = useState(null); const activeJobs = useMemo(() => jobs.filter((job) => job.enabled), [jobs]); const pausedJobs = useMemo(() => jobs.filter((job) => !job.enabled), [jobs]); const failedJobs = useMemo(() => jobs.filter((job) => job.lastRun && !job.lastRun.success), [jobs]); + const refreshing = loading || channelsLoading || agentsLoading; + + const agentsById = useMemo( + () => new Map(agents.map((agent) => [normalizeAgentId(agent.id), agent])), + [agents], + ); async function loadJobs(): Promise { setLoading(true); setError(null); try { - const response = await hostApiFetch('/api/cron/jobs'); - setJobs(normalizeCronJobs(response)); + const response = await hostApiFetch('/api/cron/jobs'); + const normalizedJobs = normalizeCronJobs(response); + setJobs(normalizedJobs); } catch (requestError) { setJobs((currentJobs) => (currentJobs.length > 0 ? currentJobs : FALLBACK_CRON_JOBS)); setError(requestError instanceof Error ? requestError.message : String(requestError)); @@ -830,24 +1900,54 @@ export default function CronPage() { } } + async function loadDeliveryChannels(): Promise { + setChannelsLoading(true); + setChannelsError(null); + + try { + const response = await hostApiFetch('/api/channels/accounts'); + setChannelGroups(normalizeDeliveryChannelGroups(response)); + } catch (requestError) { + setChannelsError(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + setChannelsLoading(false); + } + } + useEffect(() => { + void agentsStore.init(); void loadJobs(); + void loadDeliveryChannels(); }, []); + useEffect(() => { + return onGatewayEvent((event) => { + if (!isRuntimeChangedGatewayEvent(event)) return; + if (!runtimeEventHasTopic(event, 'channels', 'providers', 'agents', 'channel-targets')) return; + void Promise.allSettled([loadDeliveryChannels(), agentsStore.load()]); + }); + }, []); + + async function handleRefresh(): Promise { + setFeedback(null); + await Promise.allSettled([loadJobs(), loadDeliveryChannels(), agentsStore.load()]); + } + async function handleSave(input: CronJobCreateInput): Promise { setDialogSaving(true); try { if (editingJob) { try { - const response = await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(editingJob.id)}`, { + const response = await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(editingJob.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); + const normalizedJob = normalizeCronJobResult(response); setJobs((currentJobs) => - currentJobs.map((job) => (job.id === editingJob.id ? response : job)), + currentJobs.map((job) => (job.id === editingJob.id ? normalizedJob ?? buildLocalCronJob(input, editingJob) : job)), ); } catch { const fallback = buildLocalCronJob(input, editingJob); @@ -857,13 +1957,14 @@ export default function CronPage() { setFeedback({ tone: 'success', message: '定时任务已更新。' }); } else { try { - const response = await hostApiFetch('/api/cron/jobs', { + const response = await hostApiFetch('/api/cron/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); - setJobs((currentJobs) => [...currentJobs, response]); + const normalizedJob = normalizeCronJobResult(response); + setJobs((currentJobs) => [...currentJobs, normalizedJob ?? buildLocalCronJob(input)]); } catch { setJobs((currentJobs) => [...currentJobs, buildLocalCronJob(input)]); } @@ -997,7 +2098,7 @@ export default function CronPage() { 定时任务

- 延续现有调度页的标题、统计、卡片列表和创建入口。 + 为 Cron 任务绑定执行 Agent 与发送渠道,让调度、执行和投递信息在一个页面里闭环。

@@ -1006,10 +2107,10 @@ export default function CronPage() { type="button" className="flex h-9 shrink-0 items-center justify-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] dark:border-gray-700 dark:text-[#f3f4f6]/80 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]" onClick={() => { - void loadJobs(); + void handleRefresh(); }} > - + 刷新