From 445a21df117db0921869c59c3781fea0c0985bc0 Mon Sep 17 00:00:00 2001 From: empty610 Date: Thu, 29 Oct 2026 23:06:34 +0800 Subject: [PATCH 1/2] Update public source to 0.5.1 --- AGENTS.md | 9 +- CHANGELOG.md | 47 +- CONTRIBUTING.md | 2 + README.md | 28 +- desktop/geometry.cjs | 14 +- desktop/main.cjs | 298 +----- desktop/runtime.cjs | 326 +++++++ docs/README.md | 5 + docs/manager-responsibilities.md | 32 + docs/message-contracts.md | 48 + docs/project-evaluation.md | 2 +- docs/runtime-lifecycle.md | 55 ++ docs/shared-interface.md | 37 + docs/task-models.md | 35 + docs/usage.md | 20 +- package-lock.json | 4 +- package.json | 2 +- plugin.json | 2 +- plugin/control.js | 13 +- plugin/lifecycle.js | 139 +++ plugin/local-client.js | 39 + plugin/runtime.js | 101 +- plugin/shutdown.js | 7 +- plugin/tools.js | 2 +- public/access.js | 3 +- public/api-client.js | 23 + public/app.js | 408 ++++---- public/html.js | 5 + public/i18n.js | 76 ++ public/icons.js | 22 + public/index.html | 26 +- public/message.js | 71 ++ public/messages.js | 1359 +++++++++++++++++++++++++++ public/quota-display.js | 7 +- public/rhine-signal-loop.svg | 10 +- public/storage-status.js | 7 +- public/style.css | 6 + public/system-messages.js | 587 ++++++++++++ public/task-details.js | 5 +- public/task-forms.js | 55 ++ public/task-mode.js | 22 +- public/task-view.js | 176 ++++ public/widget.css | 13 +- public/widget.html | 52 +- public/widget.js | 611 ++++++------ server/demo.js | 11 +- server/direct-task.js | 91 ++ server/http-body.js | 13 +- server/http-policy.js | 12 +- server/http-routes.js | 11 +- server/index.js | 19 +- server/lease.js | 9 +- server/manager.js | 503 ++-------- server/monitor-task.js | 131 +++ server/policy.js | 4 +- server/project-path.js | 7 +- server/queue-sender.js | 24 +- server/quota-wake.js | 68 +- server/rpc.js | 13 +- server/state-lifecycle.js | 49 + server/state-persistence.js | 64 ++ server/static-assets.js | 8 +- server/store.js | 67 +- server/summary.js | 15 +- server/task-model.js | 22 + server/thread-reply.js | 35 + server/turn-controller.js | 246 +++++ tests/access.test.js | 6 +- tests/continuation-message.test.js | 201 ++++ tests/desktop-layout.test.js | 69 +- tests/drag.test.js | 11 +- tests/fixtures/shutdown-passive.mjs | 2 +- tests/fixtures/thread-history.js | 8 + tests/fixtures/widget-animation.cjs | 14 +- tests/fixtures/widget-view.js | 55 +- tests/fixtures/window-expansion.cjs | 50 + tests/i18n.test.js | 157 ++++ tests/import-http.test.js | 21 +- tests/import.test.js | 3 +- tests/local-client.test.js | 41 + tests/manager-components.test.js | 119 +++ tests/manager.test.js | 1 + tests/message-contracts.test.js | 156 +++ tests/mode-views.test.js | 106 ++- tests/monitor-policy.test.js | 17 +- tests/persistence.test.js | 3 +- tests/quota-wake.test.js | 3 +- tests/reliability.test.js | 23 + tests/reply-guard.test.js | 178 ++++ tests/round2.test.js | 3 +- tests/round3.test.js | 3 +- tests/runtime-lifecycle.test.js | 217 +++++ tests/service-integration.test.js | 3 +- tests/shared-ui.test.js | 131 +++ tests/task-models.test.js | 140 +++ tests/widget-animation.test.js | 14 +- tests/window-expansion.test.js | 28 + 97 files changed, 6481 insertions(+), 1535 deletions(-) create mode 100644 desktop/runtime.cjs create mode 100644 docs/manager-responsibilities.md create mode 100644 docs/message-contracts.md create mode 100644 docs/runtime-lifecycle.md create mode 100644 docs/shared-interface.md create mode 100644 docs/task-models.md create mode 100644 plugin/lifecycle.js create mode 100644 plugin/local-client.js create mode 100644 public/api-client.js create mode 100644 public/html.js create mode 100644 public/i18n.js create mode 100644 public/icons.js create mode 100644 public/message.js create mode 100644 public/messages.js create mode 100644 public/system-messages.js create mode 100644 public/task-forms.js create mode 100644 public/task-view.js create mode 100644 server/direct-task.js create mode 100644 server/monitor-task.js create mode 100644 server/state-lifecycle.js create mode 100644 server/state-persistence.js create mode 100644 server/task-model.js create mode 100644 server/thread-reply.js create mode 100644 server/turn-controller.js create mode 100644 tests/continuation-message.test.js create mode 100644 tests/fixtures/thread-history.js create mode 100644 tests/fixtures/window-expansion.cjs create mode 100644 tests/i18n.test.js create mode 100644 tests/local-client.test.js create mode 100644 tests/manager-components.test.js create mode 100644 tests/message-contracts.test.js create mode 100644 tests/reply-guard.test.js create mode 100644 tests/runtime-lifecycle.test.js create mode 100644 tests/shared-ui.test.js create mode 100644 tests/task-models.test.js create mode 100644 tests/window-expansion.test.js diff --git a/AGENTS.md b/AGENTS.md index 18c4a1c..5b11717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,12 @@ # 本对话的版本约定 -- 用户指定:本对话的后续更新统一保存为 `0.4.1-preview`,在 `preview/0.4.1` 分支开发;不要自动递增版本号或添加新的 preview 序号。 +- 用户于 2026-10-29 更新约定:后续更改统一记录为 `0.5.1`,在 `preview/0.5.1` 分支开发;不要自动递增版本号或添加 preview 序号。此前 `0.5.0-preview` 已推送并保留。 +- 用户于 2026-10-29 本轮明确授权:将 `0.5.1` 源码和用户编写的 README 原样推送并合入公开仓库 `main`;仍通过脱敏独立仓库发布。该次授权不包含安装、切换正在使用的插件或发布 Release;完成后后续改动默认仍只保存在本地,除非收到新指令。 - 当前继续使用的 `0.4.0-preview.1` 是保留版本。不要覆盖其安装目录、已生成的 0.4.0 构建、运行配置、任务数据或 GitHub `main` 分支。 - 新版本改动保存在源码和 Git 提交中。没有新的明确安装指令时,不运行安装器、不切换用户正在使用的插件、不发布 Release 或合并到 `main`。 -- 2026-10-26 用户已明确授权:本轮“退出停止所有后台”完成后,正常停止 0.4.0,并安装、打开 0.4.1 供预览。保留 0.4.0 的安装文件和构建备份;后续更新仍沿用上述默认规则。 -- 需要构建时,在独立的 0.4.1 源码导出目录构建,避免默认构建路径覆盖现有 0.4.0 产物。 +- 用户于 2026-10-28 明确要求“别给我推送”:本轮及后续改动只保存在本地;不推送远程仓库、不发布版本,除非收到新的明确指令。 +- 用户于 2026-10-28 授权推送“卡片展开窗口漂移”和备注清理修复的 `0.5.0-preview`,已完成;README 保留用户修改。该次推送授权已履行,0.5.1 默认只保存源码和本地提交,未经新指令不推送、不安装、不发布 Release、不合并 `main`。 +- 2026-10-26 用户曾明确授权:当轮“退出停止所有后台”完成后,正常停止 0.4.0,并安装、打开 0.4.1 供预览。该授权仅适用于当轮;0.5.0 未获新安装指令,仍只保存源码和提交。保留 0.4.0 的安装文件和构建备份。 +- 需要构建时,在独立的 0.5.1 源码导出目录构建,避免默认构建路径覆盖现有 0.4.0 / 0.4.1 / 0.5.0 产物。 - 测试使用独立数据目录和模拟 RPC/消息通道,不启动真实任务,不发送聊天消息,不更改 Electron 沙箱或系统 ACL。 - 当前工作目录的旧 Git 历史包含私有开发记录。公开提交仍通过脱敏独立仓库 `dist/public-source/codex-continuer`,不得把原开发仓库的历史推送出去。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 571462a..5a7b85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # 更新记录 +## 0.5.1 — 开发中 + +- 后续版本统一记录为 0.5.1,在 preview/0.5.1 分支开发。 +- 插件、命令行和桌面共用启动与退出接口;本机配置、固定证书请求和生命周期协调明确分工,统一服务构建及目录核验,保留查询冷启动与完整退出规则。 +- 并发查询、打开与完整退出各自合并;启动等待受总时间预算约束,创建进程失败及时返回;退出与重开使用独立启动代次,旧响应和等待不会影响新一轮。新增 23 项生命周期与本地客户端回归。 +- 运行链路重构验证:固定源码以两个测试文件并发运行,354 项回归全部通过,包含真实服务冷启动与隐藏窗口启动、退出和反复展开;语法和公开源码检查通过。 +- 界面和服务提示改用固定消息标识及显式参数;任务活动、日志、连接、存储和 HTTP 错误保留文本并附带翻译字段,支持重启后和跨窗口切换语言。旧文本与外部输出按原文显示。 +- 页面、挂件、旧凭据清理及桌面运行时提供正式模块入口,测试直接调用;移除为执行测试而截取、改写和拼接生产 JavaScript 的做法。 +- 消息与测试入口重构验证:新增 10 项契约检查,固定源码中 331 项回归全部通过;中英文页面与挂件共 4 组隐藏窗口验收通过,语法和公开源码检查通过。 +- 完整页面和挂件共用请求客户端、任务状态与按钮规则、卡片与详情内容,以及新建和导入表单;保留各自布局,将长模板按内容段落展开。 +- 完整页面补齐挂件已有的 35 秒请求超时;两处均在工作运行或暂停过程中隐藏结束按钮,完整详情加载后才开放续作消息编辑。 +- 界面重构验证:固定源码以两个测试文件并发运行,321 项回归全部通过;实际隐藏窗口的中英文共 4 组验收通过,覆盖布局、详情编辑和语言切换时草稿与光标保留。语法和公开源码检查通过,未安装或发送真实消息。 +- 多轮工作与原聊天额度监测各自定义创建、状态、计数、操作、校验和恢复规则;Manager 按模式分发,工作轮次入口明确拒绝监测记录。 +- 新建工作明确记录 direct 模式;兼容迁移旧模式和计数,清理监测记录中遗留的执行字段。重启中的不确定发送立即保存,备份恢复也按模式归一化后暂停。 +- 模式重构验证:固定源码副本中 311 项回归全部通过,新增 7 项模式、计数、执行隔离及迁移恢复检查;语法和公开源码检查通过,未启动真实任务或发送真实消息。 +- 额度恢复时逐条检查原聊天最新指令的回复:已正常结束并形成最终回复则跳过本次续作;较早的完整回复不阻止新指令的恢复,失败、中断和中途说明不误判为完成。 +- 原聊天仍在回复或读取失败时保留待发送记录并稍后重试;队列能力检查后、真正发送前再次核对,避免准备期间完成回复仍被追加消息。跳过不增加次数,不删除监测备注或自定义消息。 +- 完整回复检查支持分页历史和旧服务,读取到的聊天正文不写入任务和日志;界面、英文提示、MCP 工具说明和使用文档同步更新。 +- 回复检查验证:固定源码副本中 300 项回归全部通过,覆盖多对话筛选、新指令与旧回复、重启、分页读取、发送前补查、读取失败及暂停/额度过期/上限变更;语法和公开源码检查通过,全部使用独立数据和模拟消息通道。 +- 已加入的原聊天可在详情中编辑并保存续作消息;挂件和完整页面均支持,清空后按当前语言使用默认消息。 +- 编辑不重置发送次数、额度耗尽记录或监测状态;正在发送的消息保留原内容,新内容用于后续发送。独立编辑表单在后台刷新和语言切换时保留输入。 +- 完整详情加载后才开放编辑,避免用尚未加载或过期的内容覆盖已保存消息。 +- 验证:288 项回归通过,覆盖编辑后的本地持久化和重启、等待恢复时修改、发送中修改、清空后的中英文默认消息及非法输入;独立模拟界面验收保存、取消、草稿和光标保留,未发送真实消息。 + +## 0.5.0-preview — 开发中 + +- 修复卡片展开时跳回旧面板位置的问题:以当前卡片和所在屏幕定位,空间不足时换向展开;跟随偏移同步更新,收起恢复卡片原位。已提交的模式状态取代固定尺寸误差阈值,兼容 DPI 读回差异。 +- 删除工作记录时同步从自动备份及可解析的恢复副本中移除对应记录、监测备注和自定义消息,避免恢复备份时重新带回;其他任务和设置继续保留。 +- 验证:281 项回归通过,包含 3 个原生窗口位置各 12 次展开收起、模拟 DPI 读回偏差下的反复切换,以及删除后的主文件、备份、恢复副本和再次恢复检查。 +- 修复切换英文后已缓存的聊天下拉框仍显示中文的问题;加载中、无会话、读取失败等状态同步切换语言,保留已选聊天,避免重新读取或改写聊天标题。 +- 新建、导入弹窗与缓存会话列表支持跨窗口语言同步,保留输入内容、勾选状态、焦点、光标和滚动位置;旧弹窗延迟触发的关闭事件不再清除新弹窗状态,过期列表响应不再覆盖新表单。 +- 修复聊天读取错误被后台刷新覆盖的问题,导入失败提供重试;补齐英文空列表、日志和范围错误提示,并统一英文界面的标点。 +- 英文修复验证:275 项回归通过,语法和公开源码检查通过;真实隐藏窗口复现并验收下拉框即时切换、跨窗口弹窗同步、表单内容与光标保留及快速关闭重开。 +- 飞行演示期间隐藏上层额度字样:第 12–12.6 秒淡出,第 18.3–18.9 秒恢复;文字与背景动画共用 60 秒周期和可见性暂停状态。 +- 航天器抵达中心后,摩斯电码从第 18.6 秒开始淡入、第 19.2 秒完全显示,比原来提前约 3.2 秒;同步提前星球淡出和逐行揭示,并将电码下移 24 个 SVG 像素,扫描线收拢到电码区域。 +- 动画验证覆盖真实 Electron 的飞行遮字、抵达后显码、上下文字间距、减少动态效果,以及保留的一秒额度切换渐变。 +- 新增中文 / English 切换,覆盖挂件、完整页面、额度倒计时、表单、设置和托盘菜单;语言保存在本地并同步到其他窗口,旧数据默认中文。 +- 空白或仅空白的续作消息在发送时使用当前语言的默认消息:中文“继续”、英文“Continue”;自定义消息保持原文。切换语言保留未提交的设置,不会开启调度。 +- 双语验证:269 项回归全部通过;独立数据、模拟 RPC 与桌面桥验收英文表单、首页卡片、详情、完整页面与中英文往返切换,未发送真实消息,未改动现有安装。 +- 本对话后续改动统一记为 0.5.0 preview,在 preview/0.5.0 分支开发。 +- 点击“加入我的工作”后可填写续作消息;开启监测并确认额度耗尽、恢复后,向原聊天发送该消息。留空或仅空白时发送“继续”,旧记录沿用默认行为。 +- 监测备注随记录保存在本地,并展示在首页工作卡片中;长备注显示预览,详情保留完整内容,备注不发送给聊天。 +- 挂件、完整页面、HTTP 导入及 MCP 导入使用同一消息字段;保留暂停导入、发送上限和结果不确定时防重复发送的规则。 +- 验证:265 项回归通过,语法和公开源码检查通过;退出及 Electron 检查在常规权限环境重跑通过,未修改 Electron 沙箱或系统 ACL。另用独立数据、模拟 RPC 和桌面桥验收真实表单提交、本地保存、首页备注卡片与详情展示,未发送真实聊天消息。 + ## 0.4.1-preview — 开发中 - MCP 限制为查询和收紧执行范围的操作;创建、恢复、开启及策略修改改由用户在挂件中操作。 @@ -11,7 +56,7 @@ - 关闭按钮、托盘退出和命令行退出统一结束后台服务、跟随及自启动监听器;正常保存停止后仍未退出时,只清理路径核验通过的所属服务进程树。 - 主动退出后,查询和自动启动不再重建后台服务;手动打开才恢复。演示模式退出与真实服务隔离。 - 本轮用户明确授权停止 0.4.0 并切换预览 0.4.1;旧版安装文件和 main 分支保留。 -- 本对话的后续更新固定保存到此版本;保留现有安装版 0.4.0-preview.1。 +- 当时约定后续更新固定保存到此版本;2026-10-28 起由 0.5.0-preview 约定取代,保留 0.4.0-preview.1 安装及构建备份。 - 五小时/七天额度的切换间隔由 5 秒延长到 12 秒,淡出换值和淡入的完整过渡由 480 毫秒延长到 1 秒。 - 额度文字与背景装饰动画的隐藏阶段解耦,在可见的紧凑仪表中持续显示;背景的 60 秒动画保留。 - 移除切换时强制读取布局的操作;隐藏、展开面板时仍停止轮播和相关定时器,减少动态效果模式仍直接换值。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daf73d2..145cd24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,8 @@ 请先提交带最小复现的 Issue 或小范围 PR。调度、审批、发送、存储和访问控制改动应有对应行为回归。测试必须使用临时数据与模拟 RPC/消息通道;不得读取贡献者真实任务、发送真实消息或为了通过测试关闭 Electron 沙箱、修改系统 ACL。 +服务组件的职责和协作边界见 [Manager 职责拆分](docs/manager-responsibilities.md)。迁移、持久化和轮次协议分别维护,新增功能通过明确的组件接口接入。 + 提交前检查日志、截图和 Git diff 是否包含本机路径、聊天 ID、凭据或任务内容。不要提交 data、缓存、运行时或分发包。素材与依赖必须记录来源、许可证和修改日期。贡献按 GPL-3.0-only 提交;第三方原件保留各自版权和许可声明。 公开仓库 main 要求通过 PR 合入、通过 verify 检查并满足签名提交规则,禁止强推和删除;单人维护不强制第二位审批者。不要为绕过签名要求重写旧公开历史。可使用 GitHub 验证签名的网页合并流程,或维护者自行配置并登记签名密钥;自动化不会替维护者生成身份密钥。 diff --git a/README.md b/README.md index 8b8dbc4..3a8743c 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,32 @@ 监测 Codex 账户额度,在额度恢复后接续指定工作,并通过跟随 Codex 窗口的桌面挂件查看状态。 -**开发版本:0.4.1-preview · Windows x64 预览版 · GPL-3.0-only。** +**开发版本:0.5.1 · Windows x64 预览版 · GPL-3.0-only。** ## 功能 -- 显示短期、长期额度与重置倒计时;数据未知或超过 90 秒时等待新样本。 -- 导入原聊天,确认额度耗尽并恢复后,向原聊天排队发送一次固定“继续”。 -- 新建带目标和验收标准的工作,按额度、轮次上限和审批状态推进。 -- 提供紧凑仪表、任务面板、托盘、窗口跟随与独立显示;隐藏时暂停界面订阅和动画。 -- 本地保存任务与备份,提供存储恢复;所有本地 API 都要求访问凭据。 - +- 显示短期、长期定量与重置倒计时;数据未知或超过 90 秒等待新样本 +- 导入原聊天,确认前期并恢复后,向原聊天发送一次约定消息 +- 新建带目标并验收标准的工作,按限额、轮次上限和中间状态推进 +- 提供简洁的仪表、任务面板、托盘、窗口紧随独立显示;隐藏时暂停界面订阅和动画 +- 本地保存任务与备份,提供存储恢复;所有本地 API 都需要访问凭据 + | 紧凑仪表 | 任务面板 | | --- | --- | | 紧凑额度仪表 | 任务面板 | 截图使用演示数据,拍摄于旧版,仅供界面参考。 +## 相对上版本的改动 + +- 你现在可以随意编辑它在额度耗尽后的发送内容 +- 解决了即使当前任务已经结束,仍然会对被设置监测的对话发送约定消息的问题 +- 拆分了事件处理器的职责,减轻了核心管理器manager.js的负担 +- 明确了不同工作的边界,加入了跨模式校验 +- 减轻了重复逻辑的使用,稍微优化了石山 +- 给依赖文字和源码形状的实现添加了新的协议 +- 给英文版装了新的依赖 + ## 安装 运行需要 Windows x64、Node.js 22.12+(推荐 24),以及已安装、登录并支持插件的 Codex CLI。原聊天接续还需要支持 `queue --thread --message` 的桌面 CLI。源码编译额外需要 Visual Studio Build Tools 的 MSBuild/Roslyn;便携包不需要编译工具。 @@ -54,7 +64,7 @@ cd codex-continuer | 模式 | 接续行为 | 停止条件 | | --- | --- | --- | -| 导入原聊天 | 一次确认的耗尽恢复周期,向原聊天发送一次“继续”;备注仅本地保存 | 暂停、发送次数到限或发送结果不确定;不自动识别原聊天目标完成 | +| 导入原聊天 | 一次确认的耗尽恢复周期,向原聊天发送一次自定义续作消息,留空发送“继续”;备注保存在本地并展示在首页卡片 | 暂停、发送次数到限或发送结果不确定;不自动识别原聊天目标完成 | | 新建工作 | 按原始目标启动执行轮次,要求结构化完成结果 | 完成、阻塞、无工具活动或轮次到限;模型报告完成仍需核对验收 | `Start-Continuer.ps1` 打开原生挂件;浏览器凭据链接已停用。本地客户端通过固定证书校验服务身份后才发送凭据。隐藏到托盘仍保留运行;点击关闭按钮或选择“退出 Continuer”会保存状态、停止后台服务和监听器,并结束进程。停止超时后仅清理经安装路径核验的所属服务进程树。退出后,普通状态查询和自动启动不会重新拉起;手动打开挂件可恢复。 @@ -87,7 +97,7 @@ npm run build:release ## 范围与隐私 - 当前主要验证 Windows x64、Node.js 24、Codex CLI 协议基线 0.153.2;外部接口升级可能需要适配。 -- 目前仅支持中文,在工作恢复时也发送且只发送“继续”,对于需求新阶段新引导词的对话可能不适配。 +- 界面支持中文和英文;续作消息可在加入工作时自定义,每次额度恢复使用同一条消息,仍需在原聊天中确认工作进展和结果。 - 电脑、服务和原聊天执行端必须可用。排队成功不等于任务完成;已进入 Codex 队列的消息不能由本工具撤回。 - 跨机器、完整真实额度耗尽恢复和长期无人值守仍然需要验收;原生监听器尚未代码签名。 - 凭据由 Codex 管理;本工具的任务、输出、路径、备注和日志仍可能包含私人信息。不要上传 `data/`、`.cache/`、`runtime.json`、令牌或未脱敏诊断以及一切可能涉及安全问题的信息。 diff --git a/desktop/geometry.cjs b/desktop/geometry.cjs index 272f274..d308d62 100644 --- a/desktop/geometry.cjs +++ b/desktop/geometry.cjs @@ -17,6 +17,18 @@ function placeIndependent(saved, current, workArea, size) { const y = saved?.y ?? (Math.abs(current.height-size.height)<=1 ? current.y : current.y+current.height-size.height); return place({ x, y, ...size },workArea,size,{ right: 0, bottom: 0 }); } +function expandFromCard(card, workArea, size) { + const width = Math.min(size.width, workArea.width), height = Math.min(size.height, workArea.height); + const align = (start, length, minimum, available, expanded) => { + const endAligned = start + length - expanded, maximum = minimum + available - expanded; + // Prefer the shared bottom/right edge; near the top/left edge, grow in + // the other direction instead of sliding the card's corner to the edge. + if (endAligned >= minimum && endAligned <= maximum) return Math.round(endAligned); + if (start >= minimum && start <= maximum) return Math.round(start); + return Math.round(Math.max(minimum, Math.min(endAligned, maximum))); + }; + return { x: align(card.x, card.width, workArea.x, workArea.width, width), y: align(card.y, card.height, workArea.y, workArea.height, height), width, height }; +} function compactSize(value) { return Number.isFinite(value) ? Math.min(420,Math.max(160,Math.round(value))) : 256; } function samePlacementContext(a,b) { return !!a && a.mode===b.mode && a.follow===b.follow && a.compactSize===b.compactSize && @@ -31,4 +43,4 @@ function restoreModePosition(fallback,saved,workArea) { ? {...fallback,x,y} : fallback; } const sizes = { compact: { width: 256, height: 256 }, expanded: { width: 432, height: 720 } }; -module.exports = { place, placeIndependent, offsetFrom, sizes, compactSize, samePlacementContext, restoreModePosition }; +module.exports = { place, placeIndependent, expandFromCard, offsetFrom, sizes, compactSize, samePlacementContext, restoreModePosition }; diff --git a/desktop/main.cjs b/desktop/main.cjs index f70b526..3a97a12 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,297 +1 @@ -const { app, BrowserWindow, ipcMain, screen, Tray, Menu, nativeImage, globalShortcut, dialog } = require('electron'); -const { spawn } = require('node:child_process'); -const { createInterface } = require('node:readline'); -const { join } = require('node:path'); -const { readFileSync, writeFileSync, mkdirSync, existsSync } = require('node:fs'); -const { place, placeIndependent, offsetFrom, sizes, compactSize, samePlacementContext, restoreModePosition } = require('./geometry.cjs'); -const { createPointerDrag } = require('./pointer-drag.cjs'); -const runtimeConfig = require('../server/config.cjs'); -const logging = require('../server/logging.cjs'); -const localTls = require('../server/local-tls.cjs'); - -app.setName('Codex Continuer'); -const dataDir = runtimeConfig.dataDir; -mkdirSync(dataDir, { recursive: true }); -logging.installLogs(dataDir, 'widget'); -app.setPath('userData', join(dataDir, 'desktop-profile')); -const preferenceFile = join(dataDir, 'widget-preferences.json'); -let prefs = { follow: true, expanded: false, hidden: false, compactSize: 256, offsets: {}, positions: {} }; -try { prefs = { ...prefs, ...JSON.parse(readFileSync(preferenceFile, 'utf8')) }; } catch {} -let preferenceError = null; -const save = () => { try { writeFileSync(preferenceFile, JSON.stringify(prefs)); preferenceError = null; } catch (error) { preferenceError = error.message; console.error(`挂件设置未保存:${error.message}`); } }; -const port = runtimeConfig.port; -const origin = `https://127.0.0.1:${port}`; -let win, tray, widgetMenu, tracker, anchor, pointerDrag, isReady = false, targetVisible = false, closing = false, lastDiagnostic, lastSentState, menuOpen = false, dragging = false, boundsTimer; -let panelSize = { width: sizes.expanded.width - 18, height: sizes.expanded.height - 18 }; -let settledPlacement, restoringMode=false; -let pendingTransition, transitionSequence=0, renderedExpanded=prefs.expanded; -const mode = () => prefs.expanded ? 'expanded' : 'compact'; -const placementContext = () => ({ mode:mode(),follow:prefs.follow,compactSize:compactSize(prefs.compactSize),anchor:anchor ? {...anchor} : null }); -if (process.argv.includes('--auto')) prefs.hidden = false; -function diagnostics() { - if (!win || win.isDestroyed() || dragging || pointerDrag?.active) return; - const next = JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: isReady, visible: win.isVisible(), ...state(), bounds: win.getBounds() }); - if (next !== lastDiagnostic) { lastDiagnostic = next; try { writeFileSync(join(dataDir, 'widget-status.json'), next); } catch (error) { console.error(`挂件诊断未保存:${error.message}`); } } -} -const ownsInstance = app.requestSingleInstanceLock(); -let quitting, shutdownComplete = false; -if (!ownsInstance) { app.quit(); } -else if (process.argv.includes('--quit')) { app.whenReady().then(quitAll); } -else { - app.on('second-instance', (_event, argv) => { if (argv.includes('--quit')) { void quitAll(); return; } if (closing) return; prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); - app.whenReady().then(start).catch(error => { dialog.showErrorBox('Continuer 启动失败', error.message); app.quit(); }); -} -function quitAll() { - if (quitting) return quitting; - closing = true; isReady = false; - pointerDrag?.finish(); cancelTransition(); tracker?.kill(); win?.hide(); - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: false, exitReason: 'quitting', updatedAt: Date.now() })); } catch {} - quitting = (async () => { - try { - const runtime = await import('../plugin/runtime.js'); - const { stopBackground } = await import('../plugin/shutdown.js'); - await stopBackground({ root: runtime.root, dataDir, request: runtime.request, demo: process.env.CONTINUER_DEMO === '1' }); - } catch (error) { console.error('退出后台进程失败:' + error.message); } - finally { shutdownComplete = true; app.quit(); } - })(); - return quitting; -} -function state() { return { follow: prefs.follow, expanded: prefs.expanded, hidden: prefs.hidden, preferenceError, tracking: !!anchor, visible: !!win?.isVisible(), compactSize: compactSize(prefs.compactSize), transitioning: !!boundsTimer, transition: pendingTransition ? {id:pendingTransition.id,fromExpanded:pendingTransition.fromExpanded} : null, panelSize, platform: process.platform }; } -function cancelTransition() { - clearTimeout(boundsTimer); boundsTimer=null; pendingTransition=null; -} -function finishTransition(id=pendingTransition?.id) { - if (!pendingTransition || pendingTransition.id!==id || !win || win.isDestroyed()) return; - const transition=pendingTransition; - cancelTransition(); - // Change native geometry only after the outgoing view has faded away. - win.setBounds(transition.next,false); - renderedExpanded=prefs.expanded; - transition.settle(); sendState(); diagnostics(); -} -function sendState() { - if (!isReady || win.isDestroyed()) return; - const next = state(), serialized = JSON.stringify(next); - if (serialized !== lastSentState) { lastSentState = serialized; win.webContents.send('continuer:state', next); } -} -function position(animate = false) { - if (!win || win.isDestroyed() || dragging || pointerDrag?.active || (boundsTimer && !animate)) return; - // A user drop is authoritative. Do not snap it back to the work area, resize - // it, or reinterpret it on an unchanged tracker tick. - if (samePlacementContext(settledPlacement,placementContext())) return; - const positionedContext=placementContext(); - const settle = () => { restoringMode=false; settledPlacement=positionedContext; }; - const current = win.getBounds(); - const saved = prefs.positions[mode()]; - const following = prefs.follow && anchor; - const target = following ? anchor : { ...(saved || current), width: (saved || current).width + 16, height: (saved || current).height + 16 }; - const size = prefs.expanded ? sizes.expanded : { width: compactSize(prefs.compactSize), height: compactSize(prefs.compactSize) }; - const offset = prefs.follow && anchor ? (prefs.offsets[mode()] || prefs.offsets[prefs.expanded ? 'compact' : 'expanded']) : undefined; - const desired = { x: target.x + target.width - size.width - (offset?.right ?? 16), y: target.y + target.height - size.height - (offset?.bottom ?? 16), ...size }; - const display = screen.getDisplayMatching(desired); - panelSize = { width: Math.min(sizes.expanded.width,display.workArea.width)-18, height: Math.min(sizes.expanded.height,display.workArea.height)-18 }; - if (!prefs.expanded) size.width=size.height=Math.min(size.width,display.workArea.width,display.workArea.height); - let next = following ? place(target, display.workArea, size, offset) : placeIndependent(saved,current,display.workArea,size); - if (restoringMode) { - const remembered=following ? (prefs.offsets[mode()] ? {x:target.x+target.width-next.width-offset.right,y:target.y+target.height-next.height-offset.bottom} : null) : saved; - next=restoreModePosition(next,remembered,display.workArea); - } - // Windows fractional DPI can round a 432 DIP window to 433 on readback. - if (Object.keys(next).some(key => Math.abs(current[key] - next[key]) > 1)) { - cancelTransition(); - if (!animate || !win.isVisible()) { win.setBounds(next,false); renderedExpanded=prefs.expanded; settle(); } - else { - const id=++transitionSequence; - pendingTransition={id,next,settle,fromExpanded:renderedExpanded}; - // Renderer acknowledges opacity=0; the timeout also handles hidden, - // reloading, or reduced-motion renderers without leaving a stuck view. - boundsTimer=setTimeout(()=>finishTransition(id),240); - } - } else { cancelTransition(); renderedExpanded=prefs.expanded; settle(); } -} -function show(force = false, animate = false) { - if (!win || !isReady) return; - // Tracking still records the latest anchor, but must not query/mutate the - // window, send renderer state, or write diagnostics during a user drag. - if (!force && !prefs.hidden && (dragging || pointerDrag?.active)) return; - position(animate); - if (!prefs.hidden && (force || menuOpen || !prefs.follow || targetVisible || win.isFocused())) { - if (!win.isVisible()) win.showInactive(); - if (force) win.focus(); - } else win.hide(); - sendState(); diagnostics(); -} -function beginDrag() { - cancelTransition(); - dragging = true; -} -function savePosition() { - const bounds = win.getBounds(); - prefs.positions[mode()] = bounds; - if (prefs.follow && anchor) prefs.offsets[mode()] = offsetFrom(anchor,bounds); - settledPlacement=placementContext(); - save(); diagnostics(); -} -function finishDrag() { - if (!dragging) return; - dragging = false; savePosition(); -} -function changeExpanded(expanded) { - if (prefs.expanded === expanded) return; - pointerDrag?.finish(); - // Record the departing mode only after its layout has settled. Never save - // an intermediate animation rectangle as a compact or expanded position. - if (isReady && win && !win.isDestroyed() && !boundsTimer && !dragging) { - const bounds=win.getBounds(); - const size=prefs.expanded ? sizes.expanded : {width:compactSize(prefs.compactSize),height:compactSize(prefs.compactSize)}; - if (bounds.width<=size.width+2 && bounds.height<=size.height+2) { - prefs.positions[mode()]=bounds; - if (prefs.follow && anchor) prefs.offsets[mode()]=offsetFrom(anchor,bounds); - } - } - // A remembered compact drop must not suppress a later expanded -> compact - // layout, nor may a cancelled animation resume after a rapid reverse toggle. - cancelTransition(); - settledPlacement=null; - restoringMode=true; - dragging=false; - prefs.expanded=expanded; -} -function syncTracker() { - if (process.platform !== 'win32') { prefs.follow = false; return; } - if (!prefs.follow) { - const previous = tracker; tracker = null; - previous?.kill(); - anchor = null; targetVisible = false; - return; - } - if (tracker) return; - const native = join(__dirname, 'continuer-monitor.exe'); - const command = existsSync(native) ? native : 'powershell.exe'; - const args = command === native ? ['--track', String(process.pid)] : ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'follow-window.ps1'), '-WidgetPid', String(process.pid)]; - const child = tracker = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); - child.stderr.resume(); - const lines = createInterface({ input: child.stdout }); - lines.on('line', line => { - if (tracker !== child || closing) return; - try { - const result = JSON.parse(line); - targetVisible = !!result.visible; - anchor = result.found ? screen.screenToDipRect(null, { x: result.x, y: result.y, width: result.width, height: result.height }) : null; - show(); - } catch {} - }); - const ended = () => { - lines.close(); - if (tracker !== child) return; - tracker = null; - if (!closing) { prefs.follow = false; anchor = null; targetVisible = false; show(true); } - }; - child.on('error', ended); - child.on('exit', ended); -} -async function start() { - const runtime = await import('../plugin/runtime.js'); - await runtime.ensureService(); - const display = screen.getPrimaryDisplay().workArea; - win = new BrowserWindow({ x: display.x + display.width - sizes.compact.width - 16, y: display.y + display.height - sizes.compact.height - 16, ...sizes.compact, show: false, alwaysOnTop: false, frame: false, thickFrame: false, roundedCorners: false, transparent: true, backgroundColor: '#00000000', resizable: false, skipTaskbar: true, hasShadow: false, autoHideMenuBar: true, title: 'Codex Continuer Widget', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, devTools: false } }); - win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - win.webContents.on('console-message', details => { if (details.level === 'error') console.error('挂件页面错误:' + details.message.slice(0, 2000)); }); - for (const name of ['will-navigate', 'will-redirect']) win.webContents.on(name, (event, url) => { if (url !== origin + '/widget') event.preventDefault(); }); - win.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); - const pinned = localTls.readIdentity(dataDir, process.env.CONTINUER_DEMO === '1').certificate; - win.webContents.session.setCertificateVerifyProc((request, callback) => { - callback(request.hostname === '127.0.0.1' && localTls.matchesCertificate(request.certificate.data, pinned) ? 0 : -2); - }); - // Keep the native credential in the main process and never attach it to - // another window, origin, static asset or redirected request. - win.webContents.session.webRequest.onBeforeSendHeaders({ urls: [origin + '/api/*'] }, (details, callback) => { - try { - const token = runtime.readAccessToken(); - if (details.webContentsId === win.webContents.id && new URL(details.url).origin === origin && token) details.requestHeaders.Authorization = 'Bearer ' + token; - callback({ requestHeaders: details.requestHeaders }); - } catch { callback({ cancel: true }); } - }); - win.on('close', event => { if (!shutdownComplete) { event.preventDefault(); void quitAll(); } }); - // will-move is emitted for native user dragging, not our setBounds calls. - win.on('will-move', () => { if (!dragging && !pointerDrag.active) beginDrag(); }); - win.on('moved', () => { if (dragging && !pointerDrag.active) finishDrag(); }); - pointerDrag = createPointerDrag({ - getCursor: () => screen.getCursorScreenPoint(), - getBounds: () => win.getBounds(), - // setPosition can accumulate fractional-DPI size rounding on every move. - setPosition: (x,y,bounds) => win.setBounds({ x,y,width:bounds.width,height:bounds.height },false), - onMove: cancelTransition, - onFinish: moved => { if (moved) savePosition(); }, - }); - win.on('blur', () => pointerDrag.finish()); - win.on('hide', () => { pointerDrag.finish(); finishTransition(); }); - let rendererRestarts = 0; - win.webContents.on('render-process-gone', (_event, detail) => { - pointerDrag.finish(); isReady = false; lastDiagnostic = null; lastSentState = null; - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: false, exitReason: `renderer-${detail.reason}` })); } catch {} - if (!closing && rendererRestarts++ < 3) setTimeout(() => { if (!closing && !win.isDestroyed()) win.reload(); }, 1000 * rendererRestarts); - }); - win.webContents.on('did-finish-load', () => { if (isReady) show(); }); - const allowed = event => event.sender === win.webContents && event.senderFrame.url === origin + '/widget'; - ipcMain.handle('continuer:control', (event, action, value) => { - if (!allowed(event)) throw new Error('Untrusted widget request'); - if (action === 'renderer-ready') { isReady = true; show(); return state(); } - if (action === 'quit') { setImmediate(() => { void quitAll(); }); return { quitting: true }; } - if (action === 'transition-ready') { finishTransition(value); return state(); } - if (action === 'drag-start') { - if (!Number.isInteger(value) || prefs.expanded || boundsTimer || dragging) return { ok:false }; - return pointerDrag.start(value); - } - if (action === 'drag-end') return pointerDrag.finish(value?.id); - if (action !== 'state') pointerDrag.finish(); - if (action === 'expand' && typeof value === 'boolean') changeExpanded(value); - else if (action === 'follow' && typeof value === 'boolean') { prefs.follow = value; syncTracker(); } - else if (action === 'hide') prefs.hidden = true; - else if (action === 'reset-position') { settledPlacement=null; prefs.offsets = {}; prefs.positions = {}; } - else if (action === 'compact-size' && Number.isFinite(value) && value >= 160 && value <= 420) prefs.compactSize=compactSize(value); - else if (action === 'state') return state(); - else throw new Error('Unsupported action'); - save(); show(action === 'expand',action === 'expand' || action === 'compact-size'); return state(); - }); - ipcMain.handle('continuer:pick-directory', async event => { - if (!allowed(event)) throw new Error('Untrusted widget request'); - const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'], title: '选择工作项目目录' }); - return result.canceled ? null : result.filePaths[0]; - }); - tray = new Tray(nativeImage.createFromPath(join(__dirname, 'icon.png'))); - tray.setToolTip('Codex Continuer · 额度与持续工作'); - const reveal = () => { prefs.hidden = false; changeExpanded(true); save(); show(true,true); }; - widgetMenu = Menu.buildFromTemplate([ - { label: '打开额度与工作面板', click: reveal }, - { label: '跟随 Codex 窗口', click: () => { prefs.follow = true; prefs.hidden = false; syncTracker(); save(); show(); } }, - { label: '独立显示面板', click: () => { prefs.follow = false; syncTracker(); reveal(); } }, - { label: '重置挂件位置', click: () => { settledPlacement=null; prefs.offsets = {}; prefs.positions = {}; save(); show(true); } }, - { type: 'separator' }, - { label: '隐藏到托盘', click: () => { prefs.hidden = true; save(); show(); } }, - { label: '退出 Continuer', click: () => { void quitAll(); } }, - ]); - const popupMenu = () => { - if (menuOpen) return; - pointerDrag.finish(); - menuOpen = true; - widgetMenu.popup({ window: win, callback: () => { menuOpen = false; } }); - }; - tray.setContextMenu(widgetMenu); - win.webContents.on('context-menu', (_event,params) => { if (!params.isEditable) popupMenu(); }); - win.on('system-context-menu', event => { event.preventDefault(); popupMenu(); }); - tray.on('click', reveal); - globalShortcut.register('CommandOrControl+Alt+C', reveal); - const displayChanged = () => { settledPlacement=null; show(); }; - screen.on('display-removed',displayChanged); - screen.on('display-metrics-changed',displayChanged); - syncTracker(); - await win.loadURL(origin + '/widget'); -} -app.on('before-quit', event => { - if (!ownsInstance) return; - if (!shutdownComplete) { event.preventDefault(); void quitAll(); return; } - closing = true; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); globalShortcut.unregisterAll(); tray?.destroy(); - try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: false, exitReason: 'user-quit', updatedAt: Date.now() })); } catch {} -}); -app.on('window-all-closed', () => app.quit()); +require('./runtime.cjs').createDesktopRuntime(); diff --git a/desktop/runtime.cjs b/desktop/runtime.cjs new file mode 100644 index 0000000..c1139be --- /dev/null +++ b/desktop/runtime.cjs @@ -0,0 +1,326 @@ +// Constructing a runtime makes its native dependencies and lifecycle explicit. +function createDesktopRuntime({ load = require, processInfo = process, timing = globalThis, window } = {}) { + const { setTimeout, clearTimeout } = timing; + const { app, BrowserWindow, ipcMain, screen, Tray, Menu, nativeImage, globalShortcut, dialog } = load('electron'); + const { spawn } = load('node:child_process'); + const { createInterface } = load('node:readline'); + const { join } = load('node:path'); + const { readFileSync, writeFileSync, mkdirSync, existsSync } = load('node:fs'); + const { place, placeIndependent, expandFromCard, offsetFrom, sizes, compactSize, samePlacementContext, restoreModePosition } = load('./geometry.cjs'); + const { createPointerDrag } = load('./pointer-drag.cjs'); + const runtimeConfig = load('../server/config.cjs'); + const logging = load('../server/logging.cjs'); + const localTls = load('../server/local-tls.cjs'); + + app.setName('Codex Continuer'); + const dataDir = runtimeConfig.dataDir; + mkdirSync(dataDir, { recursive: true }); + logging.installLogs(dataDir, 'widget'); + app.setPath('userData', join(dataDir, 'desktop-profile')); + const preferenceFile = join(dataDir, 'widget-preferences.json'); + let prefs = { follow: true, expanded: false, hidden: false, compactSize: 256, offsets: {}, positions: {} }; + try { prefs = { ...prefs, ...JSON.parse(readFileSync(preferenceFile, 'utf8')) }; } catch {} + let preferenceError = null; + let nativeText = () => 'Codex Continuer', refreshMenu = () => {}; + const save = () => { try { writeFileSync(preferenceFile, JSON.stringify(prefs)); preferenceError = null; } catch (error) { preferenceError = error.message; console.error(`挂件设置未保存:${error.message}`); } }; + const port = runtimeConfig.port; + const origin = `https://127.0.0.1:${port}`; + let win = window, tray, widgetMenu, tracker, anchor, pointerDrag, isReady = !!window, targetVisible = false, closing = false, lastDiagnostic, lastSentState, menuOpen = false, dragging = false, boundsTimer; + let panelSize = { width: sizes.expanded.width - 18, height: sizes.expanded.height - 18 }; + let settledPlacement, restoringMode=false, expansionOrigin=null; + let pendingTransition, transitionSequence=0, renderedExpanded=prefs.expanded; + const mode = () => prefs.expanded ? 'expanded' : 'compact'; + const placementContext = () => ({ mode:mode(),follow:prefs.follow,compactSize:compactSize(prefs.compactSize),anchor:anchor ? {...anchor} : null }); + if (processInfo.argv.includes('--auto')) prefs.hidden = false; + function diagnostics() { + if (!win || win.isDestroyed() || dragging || pointerDrag?.active) return; + const next = JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: isReady, visible: win.isVisible(), ...state(), bounds: win.getBounds() }); + if (next !== lastDiagnostic) { lastDiagnostic = next; try { writeFileSync(join(dataDir, 'widget-status.json'), next); } catch (error) { console.error(`挂件诊断未保存:${error.message}`); } } + } + const ownsInstance = app.requestSingleInstanceLock(); + let quitting, shutdownComplete = false; + if (!ownsInstance) { app.quit(); } + else if (processInfo.argv.includes('--quit')) { app.whenReady().then(quitAll); } + else { + app.on('second-instance', (_event, argv) => { if (argv.includes('--quit')) { void quitAll(); return; } if (closing) return; prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); + app.whenReady().then(start).catch(error => { dialog.showErrorBox(nativeText("native.continuer_could_not_start"), error.message); app.quit(); }); + } + function quitAll() { + if (quitting) return quitting; + closing = true; isReady = false; + pointerDrag?.finish(); cancelTransition(); tracker?.kill(); win?.hide(); + try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: 'quitting', updatedAt: Date.now() })); } catch {} + quitting = (async () => { + try { + const runtime = await import('../plugin/runtime.js'); + await runtime.stopBackground(); + } catch (error) { console.error('退出后台进程失败:' + error.message); } + finally { shutdownComplete = true; app.quit(); } + })(); + return quitting; + } + function state() { return { follow: prefs.follow, expanded: prefs.expanded, hidden: prefs.hidden, preferenceError, tracking: !!anchor, visible: !!win?.isVisible(), compactSize: compactSize(prefs.compactSize), transitioning: !!boundsTimer, transition: pendingTransition ? {id:pendingTransition.id,fromExpanded:pendingTransition.fromExpanded} : null, panelSize, platform: processInfo.platform }; } + function cancelTransition() { + clearTimeout(boundsTimer); boundsTimer=null; pendingTransition=null; + } + function finishTransition(id=pendingTransition?.id) { + if (!pendingTransition || pendingTransition.id!==id || !win || win.isDestroyed()) return; + const transition=pendingTransition; + cancelTransition(); + // Change native geometry only after the outgoing view has faded away. + win.setBounds(transition.next,false); + renderedExpanded=prefs.expanded; + transition.settle(); sendState(); diagnostics(); + } + function sendState() { + if (!isReady || win.isDestroyed()) return; + const next = state(), serialized = JSON.stringify(next); + if (serialized !== lastSentState) { lastSentState = serialized; win.webContents.send('continuer:state', next); } + } + function position(animate = false) { + if (!win || win.isDestroyed() || dragging || pointerDrag?.active || (boundsTimer && !animate)) return; + // A user drop is authoritative. Do not snap it back to the work area, resize + // it, or reinterpret it on an unchanged tracker tick. + if (samePlacementContext(settledPlacement,placementContext())) return; + const positionedContext=placementContext(); + const fromCard = restoringMode && prefs.expanded && expansionOrigin; + const settle = () => { + if (fromCard) { + const bounds = win.getBounds(); + prefs.positions.expanded = bounds; + if (prefs.follow && anchor) prefs.offsets.expanded = offsetFrom(anchor,bounds); + expansionOrigin = null; + save(); + } + restoringMode=false; settledPlacement=fromCard ? placementContext() : positionedContext; + }; + const current = win.getBounds(); + const saved = prefs.positions[mode()]; + const following = prefs.follow && anchor; + const target = following ? anchor : { ...(saved || current), width: (saved || current).width + 16, height: (saved || current).height + 16 }; + const size = prefs.expanded ? sizes.expanded : { width: compactSize(prefs.compactSize), height: compactSize(prefs.compactSize) }; + const offset = prefs.follow && anchor ? (prefs.offsets[mode()] || prefs.offsets[prefs.expanded ? 'compact' : 'expanded']) : undefined; + const desired = { x: target.x + target.width - size.width - (offset?.right ?? 16), y: target.y + target.height - size.height - (offset?.bottom ?? 16), ...size }; + const display = screen.getDisplayMatching(fromCard || desired); + panelSize = { width: Math.min(sizes.expanded.width,display.workArea.width)-18, height: Math.min(sizes.expanded.height,display.workArea.height)-18 }; + if (!prefs.expanded) size.width=size.height=Math.min(size.width,display.workArea.width,display.workArea.height); + let next = fromCard ? expandFromCard(fromCard,display.workArea,size) : following ? place(target, display.workArea, size, offset) : placeIndependent(saved,current,display.workArea,size); + if (restoringMode && !fromCard) { + const remembered=following ? (prefs.offsets[mode()] ? {x:target.x+target.width-next.width-offset.right,y:target.y+target.height-next.height-offset.bottom} : null) : saved; + next=restoreModePosition(next,remembered,display.workArea); + } + // Windows fractional DPI can round a 432 DIP window to 433 on readback. + if (Object.keys(next).some(key => Math.abs(current[key] - next[key]) > 1)) { + cancelTransition(); + if (!animate || !win.isVisible()) { win.setBounds(next,false); renderedExpanded=prefs.expanded; settle(); } + else { + const id=++transitionSequence; + pendingTransition={id,next,settle,fromExpanded:renderedExpanded}; + // Renderer acknowledges opacity=0; the timeout also handles hidden, + // reloading, or reduced-motion renderers without leaving a stuck view. + boundsTimer=setTimeout(()=>finishTransition(id),240); + } + } else { cancelTransition(); renderedExpanded=prefs.expanded; settle(); } + } + function show(force = false, animate = false) { + if (!win || !isReady) return; + // Tracking still records the latest anchor, but must not query/mutate the + // window, send renderer state, or write diagnostics during a user drag. + if (!force && !prefs.hidden && (dragging || pointerDrag?.active)) return; + position(animate); + if (!prefs.hidden && (force || menuOpen || !prefs.follow || targetVisible || win.isFocused())) { + if (!win.isVisible()) win.showInactive(); + if (force) win.focus(); + } else win.hide(); + sendState(); diagnostics(); + } + function beginDrag() { + cancelTransition(); + dragging = true; + } + function savePosition() { + const bounds = win.getBounds(); + prefs.positions[mode()] = bounds; + if (prefs.follow && anchor) prefs.offsets[mode()] = offsetFrom(anchor,bounds); + settledPlacement=placementContext(); + save(); diagnostics(); + } + function finishDrag() { + if (!dragging) return; + dragging = false; savePosition(); + } + function changeExpanded(expanded) { + if (prefs.expanded === expanded) return; + pointerDrag?.finish(); + let origin = null; + // Record the departing mode only after its layout has settled. Never save + // an intermediate animation rectangle as a compact or expanded position. + if (isReady && win && !win.isDestroyed() && !boundsTimer && !dragging) { + const bounds=win.getBounds(); + // Native dimensions can differ by several DIP during a display/DPI change. + // The committed view mode, rather than a size tolerance, tells us whether + // these are the departing mode's settled bounds. + if (renderedExpanded === prefs.expanded) { + prefs.positions[mode()]=bounds; + if (prefs.follow && anchor) prefs.offsets[mode()]=offsetFrom(anchor,bounds); + if (!prefs.expanded && !renderedExpanded) origin = bounds; + } + } + // A remembered compact drop must not suppress a later expanded -> compact + // layout, nor may a cancelled animation resume after a rapid reverse toggle. + cancelTransition(); + settledPlacement=null; + restoringMode=true; + expansionOrigin=expanded ? origin : null; + dragging=false; + prefs.expanded=expanded; + } + function setFollow(value) { prefs.follow = value; syncTracker(); } + function syncTracker() { + if (processInfo.platform !== 'win32') { prefs.follow = false; return; } + if (!prefs.follow) { + const previous = tracker; tracker = null; + previous?.kill(); + anchor = null; targetVisible = false; + return; + } + if (tracker) return; + const native = join(__dirname, 'continuer-monitor.exe'); + const command = existsSync(native) ? native : 'powershell.exe'; + const args = command === native ? ['--track', String(processInfo.pid)] : ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'follow-window.ps1'), '-WidgetPid', String(processInfo.pid)]; + const child = tracker = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stderr.resume(); + const lines = createInterface({ input: child.stdout }); + lines.on('line', line => { + if (tracker !== child || closing) return; + try { + const result = JSON.parse(line); + targetVisible = !!result.visible; + anchor = result.found ? screen.screenToDipRect(null, { x: result.x, y: result.y, width: result.width, height: result.height }) : null; + show(); + } catch {} + }); + const ended = () => { + lines.close(); + if (tracker !== child) return; + tracker = null; + if (!closing) { prefs.follow = false; anchor = null; targetVisible = false; show(true); } + }; + child.on('error', ended); + child.on('exit', ended); + } + async function start() { + const { interfaceText } = await import('../public/i18n.js'); + nativeText = text => interfaceText(text, prefs.language || 'zh'); + const runtime = await import('../plugin/runtime.js'); + await runtime.ensureService(); + const display = screen.getPrimaryDisplay().workArea; + win = new BrowserWindow({ x: display.x + display.width - sizes.compact.width - 16, y: display.y + display.height - sizes.compact.height - 16, ...sizes.compact, show: false, alwaysOnTop: false, frame: false, thickFrame: false, roundedCorners: false, transparent: true, backgroundColor: '#00000000', resizable: false, skipTaskbar: true, hasShadow: false, autoHideMenuBar: true, title: 'Codex Continuer Widget', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, devTools: false } }); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + win.webContents.on('console-message', details => { if (details.level === 'error') console.error('挂件页面错误:' + details.message.slice(0, 2000)); }); + for (const name of ['will-navigate', 'will-redirect']) win.webContents.on(name, (event, url) => { if (url !== origin + '/widget') event.preventDefault(); }); + win.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + const pinned = localTls.readIdentity(dataDir, processInfo.env.CONTINUER_DEMO === '1').certificate; + win.webContents.session.setCertificateVerifyProc((request, callback) => { + callback(request.hostname === '127.0.0.1' && localTls.matchesCertificate(request.certificate.data, pinned) ? 0 : -2); + }); + // Keep the native credential in the main process and never attach it to + // another window, origin, static asset or redirected request. + win.webContents.session.webRequest.onBeforeSendHeaders({ urls: [origin + '/api/*'] }, (details, callback) => { + try { + const token = runtime.readAccessToken(); + if (details.webContentsId === win.webContents.id && new URL(details.url).origin === origin && token) details.requestHeaders.Authorization = 'Bearer ' + token; + callback({ requestHeaders: details.requestHeaders }); + } catch { callback({ cancel: true }); } + }); + win.on('close', event => { if (!shutdownComplete) { event.preventDefault(); void quitAll(); } }); + // will-move is emitted for native user dragging, not our setBounds calls. + win.on('will-move', () => { if (!dragging && !pointerDrag.active) beginDrag(); }); + win.on('moved', () => { if (dragging && !pointerDrag.active) finishDrag(); }); + pointerDrag = createPointerDrag({ + getCursor: () => screen.getCursorScreenPoint(), + getBounds: () => win.getBounds(), + // setPosition can accumulate fractional-DPI size rounding on every move. + setPosition: (x,y,bounds) => win.setBounds({ x,y,width:bounds.width,height:bounds.height },false), + onMove: cancelTransition, + onFinish: moved => { if (moved) savePosition(); }, + }); + win.on('blur', () => pointerDrag.finish()); + win.on('hide', () => { pointerDrag.finish(); finishTransition(); }); + let rendererRestarts = 0; + win.webContents.on('render-process-gone', (_event, detail) => { + pointerDrag.finish(); isReady = false; lastDiagnostic = null; lastSentState = null; + try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: `renderer-${detail.reason}` })); } catch {} + if (!closing && rendererRestarts++ < 3) setTimeout(() => { if (!closing && !win.isDestroyed()) win.reload(); }, 1000 * rendererRestarts); + }); + win.webContents.on('did-finish-load', () => { if (isReady) show(); }); + const allowed = event => event.sender === win.webContents && event.senderFrame.url === origin + '/widget'; + ipcMain.handle('continuer:control', (event, action, value) => { + if (!allowed(event)) throw new Error('Untrusted widget request'); + if (action === 'language' && ['zh', 'en'].includes(value)) { if (prefs.language !== value) { prefs.language = value; save(); refreshMenu(); } return state(); } + if (action === 'renderer-ready') { isReady = true; show(); return state(); } + if (action === 'quit') { setImmediate(() => { void quitAll(); }); return { quitting: true }; } + if (action === 'transition-ready') { finishTransition(value); return state(); } + if (action === 'drag-start') { + if (!Number.isInteger(value) || prefs.expanded || boundsTimer || dragging) return { ok:false }; + return pointerDrag.start(value); + } + if (action === 'drag-end') return pointerDrag.finish(value?.id); + if (action !== 'state') pointerDrag.finish(); + if (action === 'expand' && typeof value === 'boolean') changeExpanded(value); + else if (action === 'follow' && typeof value === 'boolean') setFollow(value) + else if (action === 'hide') prefs.hidden = true; + else if (action === 'reset-position') { settledPlacement=null; prefs.offsets = {}; prefs.positions = {}; } + else if (action === 'compact-size' && Number.isFinite(value) && value >= 160 && value <= 420) prefs.compactSize=compactSize(value); + else if (action === 'state') return state(); + else throw new Error('Unsupported action'); + save(); show(action === 'expand',action === 'expand' || action === 'compact-size'); return state(); + }); + ipcMain.handle('continuer:pick-directory', async event => { + if (!allowed(event)) throw new Error('Untrusted widget request'); + const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'], title: nativeText("native.select_project_directory") }); + return result.canceled ? null : result.filePaths[0]; + }); + tray = new Tray(nativeImage.createFromPath(join(__dirname, 'icon.png'))); + const reveal = () => { prefs.hidden = false; changeExpanded(true); save(); show(true,true); }; + refreshMenu = () => { + tray.setToolTip(nativeText("native.codex_continuer_quota_and_continuous_work")); + widgetMenu = Menu.buildFromTemplate([ + { label: nativeText("native.open_quota_and_work_panel"), click: reveal }, + { label: nativeText("native.follow_codex_window"), click: () => { prefs.hidden = false; setFollow(true); save(); show(); } }, + { label: nativeText("native.show_independently"), click: () => { setFollow(false); reveal(); } }, + { label: nativeText("ui.reset_widget_position"), click: () => { settledPlacement=null; prefs.offsets = {}; prefs.positions = {}; save(); show(true); } }, + { type: 'separator' }, + { label: nativeText("native.hide_to_tray"), click: () => { prefs.hidden = true; save(); show(); } }, + { label: nativeText("ui.quit_continuer"), click: () => { void quitAll(); } }, + ]); + tray.setContextMenu(widgetMenu); + }; + refreshMenu(); + const popupMenu = () => { + if (menuOpen) return; + pointerDrag.finish(); + menuOpen = true; + widgetMenu.popup({ window: win, callback: () => { menuOpen = false; } }); + }; + win.webContents.on('context-menu', (_event,params) => { if (!params.isEditable) popupMenu(); }); + win.on('system-context-menu', event => { event.preventDefault(); popupMenu(); }); + tray.on('click', reveal); + globalShortcut.register('CommandOrControl+Alt+C', reveal); + const displayChanged = () => { settledPlacement=null; show(); }; + screen.on('display-removed',displayChanged); + screen.on('display-metrics-changed',displayChanged); + syncTracker(); + await win.loadURL(origin + '/widget'); + } + app.on('before-quit', event => { + if (!ownsInstance) return; + if (!shutdownComplete) { event.preventDefault(); void quitAll(); return; } + closing = true; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); globalShortcut.unregisterAll(); tray?.destroy(); + try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: processInfo.pid, ready: false, exitReason: 'user-quit', updatedAt: Date.now() })); } catch {} + }); + app.on('window-all-closed', () => app.quit()); + return { changeExpanded, finishTransition, show, savePosition, state, setFollow, get preferences() { return prefs; } }; +} + +module.exports = { createDesktopRuntime }; diff --git a/docs/README.md b/docs/README.md index 07b0bf2..39f48de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,11 @@ | [支持范围](compatibility.md) | Windows x64 预览版、只读诊断、外部版本与尚未完成的验收 | | [便携包安装](portable.md) | 二进制包安装、卸载及源码配套要求 | | [按需加载与代码结构](loading-and-structure.md) | 隐藏视图停订阅、HTTP 模块拆分、冗余代码与一次性脚本清理;226 项回归 | +| [Manager 职责拆分](manager-responsibilities.md) | 调度协调、状态保存及 Codex 轮次协议的组件边界 | +| [两种任务的状态模型](task-models.md) | 多轮工作与额度监测的独立状态、计数、操作和旧数据迁移 | +| [完整页面与挂件的共享界面](shared-interface.md) | 共用请求、任务状态、卡片、详情和表单,以及界面行为回归 | +| [消息标识与模块测试入口](message-contracts.md) | 固定消息标识、兼容字段、旧文本处理和正式模块入口测试 | +| [启动与退出的运行边界](runtime-lifecycle.md) | 共享身份核验、并发启动、就绪等待、退出与手动重开 | | [第三轮审查与修复](project-evaluation-round3.md) | 7 类问题已修复,213 项回归通过;授权、在途发送、异步轮次及中文输入 | | [第二轮评估](project-evaluation-round2.md) | 6 项缺陷及详情缓存优化已修复,195 项回归通过;保留修复前证据 | | [项目评估](project-evaluation.md) | 功能、架构、12 项评估与修复进展、测试及优化路线 | diff --git a/docs/manager-responsibilities.md b/docs/manager-responsibilities.md new file mode 100644 index 0000000..7f7f84c --- /dev/null +++ b/docs/manager-responsibilities.md @@ -0,0 +1,32 @@ +# Manager 职责拆分 + +版本:0.5.1。日期:2026-10-29。 + +`server/manager.js` 保留服务对外入口,负责额度采样、调度准入、任务操作和组件协调。原先集中在 Manager 中的数据迁移、保存与 Codex 轮次协议分别交给以下模块。 + +| 模块 | 负责内容 | 协作边界 | +| --- | --- | --- | +| `server/state-lifecycle.js` | 默认状态、完成记录清理,以及按模式分发迁移和恢复转换 | 只转换传入的数据,不调用 RPC、保存文件或开启调度 | +| `server/direct-task.js`、`server/monitor-task.js` | 各模式的创建、状态、计数、操作、校验和恢复规则 | 通过 `server/task-model.js` 选择规则;模式说明见 [两种任务的状态模型](task-models.md) | +| `server/state-persistence.js` | 当前状态、保存健康信息、250 毫秒输出合并、立即提交、保存重试与备份替换 | 通过 Store 完成文件操作;保存失败先关闭调度,再通知协调层使旧调度失效 | +| `server/turn-controller.js` | 轮次启动、启动前事件缓存、轮次 ID 校验、输出与完成事件、授权和中断 | 自行管理执行与授权映射;使用明确注入的调度、额度、保存和通知接口,不接收 Manager 对象 | +| `server/manager.js` | 用户操作、额度采样、调度周期和恢复前的运行检查 | 组装组件,保持 HTTP、MCP、健康检查及状态快照使用的现有接口 | + +状态由 StatePersistence 持有。恢复备份会替换状态对象,轮次处理每次读取当前对象,避免继续修改已替换的旧任务。恢复时同时取消旧的延迟保存定时器。 + +保存失败后,未保存标记仍然保留。授权操作先立即提交,再发送决定;额度恢复消息仍由原有发送路径在发送前保存记录。重启后正在执行的工作等待人工确认,发送结果不确定的记录继续阻止自动重复发送。两种任务模式、额度阈值及发送策略沿用原有规则。 + +## 验证 + +在独立的 0.5.1 源码目录中,以本次改动前的提交加上职责拆分源码运行完整回归,避免工作区同时进行的其他功能改动影响基线:**292 项通过,0 失败、0 跳过**。语法检查和公开源码检查通过。 + +新增 `tests/manager-components.test.js`,在不实例化 Manager 的情况下验证: + +- 旧 writer 冲突记录的迁移、计数保留与重复迁移稳定性。 +- 启动迁移保存失败时,错误可见且调度被关闭。 +- 备份替换取消待执行保存,轮次事件只更新恢复后的当前状态。 +- 独立轮次组件的授权隔离、保存失败时保留请求,以及先保存后发送决定的顺序。 + +既有回归继续覆盖暂停期间的并发事件、过期轮次、启动事件溢出、断线重连、授权范围、发送防重、输出合并、HTTP 集成及真实隐藏 Electron 窗口。测试使用独立数据和模拟 RPC/消息通道。 + +验证源码保存在本地 `.cache/manager-refactor-0.5.1/`;不构建或安装新版本。 diff --git a/docs/message-contracts.md b/docs/message-contracts.md new file mode 100644 index 0000000..16c60f8 --- /dev/null +++ b/docs/message-contracts.md @@ -0,0 +1,48 @@ +# 消息标识与模块测试入口 + +版本:0.5.1。日期:2026-10-29。 + +界面和服务提示通过固定消息标识取译文,不再根据中文字符串、前后缀或正则猜测含义。修改中文文案只需修改词条内容;调用处的标识和英文译文不受影响。 + +| 模块 | 职责 | +| --- | --- | +| `public/messages.js` | 界面文案的中英文词条 | +| `public/system-messages.js` | 服务状态、日志和错误的中英文词条 | +| `public/message.js` | 构造、校验、格式化及限制消息参数;生成兼容的活动、日志和错误字段 | +| `public/i18n.js` | 当前语言、显式静态标记、表单错误与语言切换 | +| `public/api-client.js` | 将 HTTP 错误的消息标识保留到界面错误对象 | + +## 字段与兼容 + +消息采用 `{ id, params }`。例如 `message('error.setting_range', { name: 'maxWakeSends', min: 1, max: 100 })`。参数只能包含声明的名称,值可以是文本、有限数字、布尔值、null 或有深度限制的嵌套消息。用户提供的名称、备注、输出和错误详情作为参数时保持原文。 + +现有文本字段继续保留中文文本,供旧调用方和直接检查数据使用;新增可选字段负责翻译: + +| 数据 | 文本字段 | 消息字段 | +| --- | --- | --- | +| 任务活动 | `activity` | `activityMessage` | +| 日志、连接、存储状态 | `message` | `messageInfo` | +| HTTP 错误 | `error` | `errorInfo` | +| 精简列表提示 | `summaryNotice` | `summaryNoticeInfo` | + +标识和参数随任务和日志一起保存。列表摘要限制消息参数长度,完整详情仍走原有接口;消息字段变化会更新详情修订号。内部错误继续通过 HTTP 错误策略隐藏,只有允许的提示和参数能够返回客户端。 + +旧数据、未知标识及外部诊断没有有效消息标识时显示原文,不根据中文反推类别。新模型输出替换系统活动时会清除旧消息字段,避免套用上一次活动的译文。语言切换不会改写任务名称、备注、自定义续作消息或模型输出,也不会重新发送消息。 + +静态 HTML 使用 `data-i18n` 和 `data-i18n-title`、`data-i18n-aria-label`、`data-i18n-placeholder` 标记。动态组件直接调用 `t(id, params)`;组件中的 HTML 结构不进入翻译词条。 + +## 测试入口 + +`mountPage(environment)`、`mountWidget(environment)` 接收 DOM、时间、请求和消息源,浏览器加载时仍自动挂载。测试直接导入并调用相同入口。`discardLegacyAccess()` 独立处理旧凭据清理;`desktop/runtime.cjs` 的 `createDesktopRuntime()` 明确接收原生依赖、运行环境、计时器和已有窗口,由 `desktop/main.cjs` 负责启动。 + +页面、访问清理、桌面布局以及真实窗口展开测试均不再读取、改写或拼接生产 JavaScript 来暴露私有函数。原生窗口验收继续使用真实 Electron 几何计算;测试窗口和数据独立,沙箱保持开启。 + +`tests/message-contracts.test.js` 覆盖词条参数一致性、中文文案变化、用户文本保留、无效消息、嵌套参数限制、HTTP 错误往返、显式静态标记、真实存储重启、摘要限制和详情修订。现有语言测试和回复检查测试改为检查消息契约及实际运行结果。 + +固定源码验证目录:`.cache/message-contract-refactor-0.5.1/`。双语界面验收报告与截图:`.cache/message-contract-ui-qa-0.5.1/`。本轮只保存源码和本地提交。 + +## 验证结果 + +新增 10 项消息契约回归。最终固定源码以两个测试文件并发运行,**331 项通过,0 失败、0 跳过**;语法、公开源码和差异空白检查通过。工作区与验证副本的修改文件已逐项比对哈希。 + +完整页面、挂件各自的中文和英文共 4 组真实隐藏 Electron 窗口验收通过,检查系统活动按标识翻译、卡片与表单布局、详情编辑,以及语言往返切换时草稿、焦点和光标保留。截图已核对;桌面启动、退出和原生窗口反复展开也通过隔离回归。未安装新版本,未启动真实任务或发送聊天消息。 diff --git a/docs/project-evaluation.md b/docs/project-evaluation.md index 9e64f44..e674859 100644 --- a/docs/project-evaluation.md +++ b/docs/project-evaluation.md @@ -46,7 +46,7 @@ Continuer 已经具备个人日常使用的完整功能闭环:查看额度、 | --- | --- | --- | | 创建方式 | 新建工作 | 导入已有聊天 | | 执行端 | Continuer 自己的 `codex app-server` | 原聊天的桌面执行端 | -| 接续方式 | `thread/start/resume`、`turn/start` | CLI 队列发送固定消息“继续” | +| 接续方式 | `thread/start/resume`、`turn/start` | CLI 队列发送自定义续作消息,留空发送“继续”(0.5.0-preview) | | 启动条件 | 开启调度、任务排队、真实额度达标 | 先观察到额度耗尽,再观察到恢复且达标 | | 用户输入 | 目标进入实际执行提示词 | 可选本地监测备注,不发送 | | 上限 | maxRounds,直接执行轮次 | maxWakeSends,每条记录累计确认排队次数 | diff --git a/docs/runtime-lifecycle.md b/docs/runtime-lifecycle.md new file mode 100644 index 0000000..52e3a38 --- /dev/null +++ b/docs/runtime-lifecycle.md @@ -0,0 +1,55 @@ +# 启动与退出的运行边界 + +版本:0.5.1。日期:2026-10-29。 + +插件命令、MCP 与 Electron 通过同一个运行时接口启动和停止。入口不再各自解释端口身份、构建、目录与就绪状态;生命周期流程集中管理,主机依赖由适配器提供。 + +| 模块 | 职责 | +| --- | --- | +| `plugin/runtime.js` | 绑定本机配置、凭据、证书、日志文件、进程创建与退出标记;对外保留原有接口并提供明确启动和停止方法 | +| `plugin/lifecycle.js` | 组织服务启动、挂件就绪、监听器恢复、显式重开和完整退出;合并同一进程中的并发操作 | +| `plugin/local-client.js` | 固定本地 TLS 请求、消息错误、目录规范化及服务身份校验 | +| `plugin/shutdown.js` | 写入退出标记,停止监听器,请求正常关闭,最后按所属路径核验后清理进程 | + +```mermaid +flowchart LR + MCP[MCP 查询] --> Runtime[运行时接口] + CLI[命令行入口] --> Runtime + Desktop[Electron 入口] --> Runtime + Runtime --> Lifecycle[生命周期协调] + Lifecycle --> Client[本地请求与身份校验] + Client --> Service[后台 HTTPS 服务] + Lifecycle --> Host[主机进程适配] + Host --> Widget[Electron 挂件] + Host --> Watcher[原生监听器] +``` + +后台服务负责无窗口时的额度与任务处理;Electron 负责挂件;原生监听器负责桌面跟随和自动打开。RPC 与队列仍属于任务执行层,不参与挂件就绪判定。本次收敛入口和生命周期契约,不改变任务调度与发送路径。 + +## 入口契约 + +- `ensureService()` 是被动查询:检查退出标记,复用当前服务,必要时以 `--observe-only` 冷启动。不会清除退出标记或开启保存的调度。 +- `startService()` 是明确启动:等待正在进行的完整退出,清除当前模式的退出标记,再进入查询模式。 +- `openWidget({ automatic })` 自动打开时遵守退出标记;手动打开时等待退出完成并清除标记。先确认服务,再启动挂件并等待真实就绪状态;手动打开按既有偏好恢复监听器。 +- `stopService()` 只请求当前数据目录的服务关闭,保留命令行停止入口既有含义;服务不在时不启动它。 +- `stopBackground()` 是挂件完整退出:沿用退出标记、监听器清理、正常关闭和所属进程核验流程。同一运行时中的并发退出共用一次流程。 + +并发服务查询共用一次启动。并发打开共用一个挂件启动与就绪等待;手动打开加入自动打开时只恢复一次监听器。完整退出会使旧启动代次失效,重开创建新的启动代次;旧服务响应和挂件等待不能被新请求复用,也不能清除新一轮的等待状态。 + +## 身份、时间与故障 + +服务查询和冷启动就绪检查均核验应用标识、协议、构建和规范化数据目录。命令行停止允许关闭同数据目录的旧构建,便于升级;挂件完整退出额外要求源码或安装根目录一致。进程强制清理继续使用既有 PowerShell 路径核验,不依赖单个端口或 PID。 + +只有未创建本地 TLS 身份或明确连接拒绝才触发启动;身份不匹配、TLS 错误、访问拒绝和其他网络错误直接返回。请求继续使用固定证书,每次请求重新读取当前凭据及 TLS 身份,不将凭据发送到其他来源。 + +常规 API 请求保留 35 秒超时。启动探测单次最多 2 秒;冷启动后的就绪等待使用 18 秒总预算,每次探测受剩余预算约束。挂件保留原来的 60 次、间隔 150 ms 检查,需满足当前构建、真实存活进程和首屏就绪条件。创建进程失败直接返回错误,失败后后续明确请求可重试。 + +构造生命周期不读写文件、不启动进程;执行时所有主机操作由注入的适配器完成。测试通过正式构造函数注入请求、启动器、诊断、时钟和退出标记。运行时错误提供稳定错误代码及消息标识,调用方不依靠中文文本分类。 + +## 验证 + +新增 `tests/runtime-lifecycle.test.js` 与 `tests/local-client.test.js` 共 23 项检查,覆盖并发启动与打开、启动与退出交叉、重新打开、旧代次响应和等待隔离、主机同步停机通知、身份及构建不符、就绪超时、进程创建失败、监听器归属与回退、旧构建停止、凭据更新和消息参数传递。 + +完整回归使用固定源码目录 `.cache/runtime-lifecycle-refactor-0.5.1/`。真实服务冷启动继续采用独立数据与模拟 RPC/消息通道,桌面验收使用隐藏 Electron 窗口。未运行安装器或更换用户正在使用的插件。 + +最终固定源码以两个测试文件并发运行:**354 项通过,0 失败、0 跳过**。语法检查、公开源码检查及差异空白检查通过,工作区与验证副本的 13 个修改文件已逐项核对哈希。完整回归包含真实 MCP 冷启动、跨数据目录拒绝、退出标记、所属进程清理、隐藏窗口就绪与退出,以及原生窗口反复展开;测试未启动真实任务或发送聊天消息。 diff --git a/docs/shared-interface.md b/docs/shared-interface.md new file mode 100644 index 0000000..899a978 --- /dev/null +++ b/docs/shared-interface.md @@ -0,0 +1,37 @@ +# 完整页面与挂件的共享界面 + +版本:0.5.1。日期:2026-10-29。 + +完整页面和挂件继续使用各自的布局、导航和窗口交互。请求策略、任务状态与操作判断、任务内容和工作表单集中在共享模块中维护。 + +| 模块 | 职责 | +| --- | --- | +| `public/api-client.js` | 创建 JSON 请求客户端;统一凭据策略、重定向限制、写请求标记、服务错误和 35 秒超时 | +| `public/task-view.js` | 任务状态文字、暂停或继续按钮、结束操作条件、进度值;两种布局的卡片和详情,以及共用的备注、续作消息和输出内容 | +| `public/task-forms.js` | 新建工作与导入监测表单;共用字段、限制和提示,保留布局、目录选择和按钮样式的差异 | +| `public/html.js` | 统一转义用户文本与属性值 | +| `public/icons.js` | 现有 SVG 图标集合 | +| `public/app.js`、`public/widget.js` | 页面和窗口生命周期、局部布局、事件处理,以及调用共享组件 | + +任务卡片和详情通过 `taskPresentation()` 取得当前语言的状态、操作和进度信息。共用的业务内容只定义一次,布局差异由独立的小模板表达。模板按内容段落展开,页面事件处理不再嵌入完整的新建或导入表单。 + +两种界面都在完整详情加载后才允许编辑续作消息;运行、启动、授权和暂停中的工作隐藏结束按钮,继续通过暂停流程停止。监测详情展示备注和消息,工作详情展示计划和输出。用户标题、备注、消息、计划和输出始终按原文转义后显示。 + +请求客户端由各界面创建,详情缓存和窗口状态仍分别持有。两处都使用既有的 `credentials: omit`、`redirect: error` 和写请求标记;完整页面补齐挂件已有的 35 秒超时。实时流仍由 `VisibleFeed` 管理,隐藏视图订阅和动画的生命周期继续独立维护。 + +## 验证方式 + +- `tests/shared-ui.test.js` 检查请求与错误处理、超时、两种布局的状态和操作一致性、转义、表单字段,以及实际页面脚本中的创建、导入和消息编辑提交。 +- `tests/mode-views.test.js` 通过页面和挂件入口检查卡片、详情及设置,移除对生产函数源码形状的截取。 +- `tests/fixtures/widget-view.js` 使用同一套模拟 DOM、请求与事件环境加载两种页面脚本;测试不启动真实任务或发送聊天消息。 +- HTTP 集成检查遍历页面模块的实际导入,确认所有共享模块均包含在静态资源白名单中。 + +固定源码回归目录为 `.cache/shared-ui-refactor-0.5.1/`。运行数据和界面验收数据使用独立目录;本轮未构建或安装新版本。 + +## 验证结果 + +新增 10 项共享界面回归,相关界面、语言、加载与 HTTP 集成共 44 项通过。固定源码的完整回归以两个测试文件并发执行,**321 项通过,0 失败、0 跳过**;语法和公开源码检查通过。 + +首轮默认并发中出现服务初始化读取、进程与窗口等待及目录清理失败;随后保持同一源码和原有超时设置,以两个测试文件并发复跑,全量通过。未为通过检查修改 Electron 沙箱或系统 ACL。 + +另以模拟桌面桥接和模拟 HTTP 数据,在真实隐藏 Electron 窗口中验收完整页面、挂件各自的中文和英文,共 4 组。检查卡片、新建表单和监测详情;语言切换时输入、焦点与光标保留,完整详情加载后开放消息编辑。截图已核对,报告与截图保存在 `.cache/shared-ui-qa-0.5.1/`。 diff --git a/docs/task-models.md b/docs/task-models.md new file mode 100644 index 0000000..7353620 --- /dev/null +++ b/docs/task-models.md @@ -0,0 +1,35 @@ +# 两种任务的状态模型 + +版本:0.5.1。日期:2026-10-29。 + +任务列表继续通过原有 HTTP、MCP 和快照接口读取。后台以 `mode` 区分两种记录,各自定义创建、状态、操作、校验和恢复规则;Manager 只分发操作并协调保存、授权取消及中断。 + +| 约定 | 多轮工作 | 原聊天额度监测 | +| --- | --- | --- | +| 模块 | `server/direct-task.js` | `server/monitor-task.js` | +| `mode` | `direct`,新建时明确记录 | `quota-wake` | +| 主要数据 | 原始目标 `prompt`、计划、输出和当前轮次 | 本地备注 `note`、续作消息和额度恢复发送记录 `wake` | +| 计数 | `rounds`,按 `maxRounds` 限制 | `sentCount`,按 `maxWakeSends` 限制 | +| 手动继续 | 重置执行轮次并等待调度 | 保留累计发送次数,重新开启监测 | +| 允许状态 | 排队、等待、启动、执行、授权、暂停中、暂停、阻塞、失败、完成 | 监测、等待、暂停、阻塞、结束并移除 | +| 执行通道 | Codex 轮次与授权协议 | 额度恢复后的消息队列;发送前检查原聊天回复 | + +`server/task-model.js` 按模式选择规则,并保留共用字段的基本结构检查。Store 使用各模式的状态和计数校验;工作记录不能保存监测发送字段,监测记录不能使用执行、授权或启动等工作状态。轮次启动入口也会拒绝监测记录,其外部轮次通知不会写入计划、输出或授权映射。 + +两种模式仍使用现有的 `continuous` 字段以兼容界面和持久化格式,含义分别是“允许继续下一轮”和“开启额度监测”。状态及计数的规则由各自模块维护,调用方不再通过同一段分支处理两类任务的暂停、继续和完成。 + +## 旧数据与恢复 + +- 读取没有 `mode` 的旧工作记录时补为 `direct`,保留目标和执行次数。明确标为 `direct` 的 writer 等待继续按工作模式重试。 +- 只有没有模式、满足历史 writer 冲突条件的旧记录才迁为监测;旧直接执行轮次不能当作已排队消息,其发送次数从 0 开始。 +- 旧监测记录的 `prompt` 转为备注,缺少 `sentCount` 时按原有兼容规则读取旧计数;已有发送次数、备注和自定义消息继续保留。之后移除监测记录中的旧目标、轮次、计划、输出及执行轮次 ID。 +- 缺少耗尽证据的待发送记录继续阻塞;重启时正在发送的记录转为结果不确定并保存,不自动重发。 +- 恢复备份也先执行模式迁移,再关闭两种任务的自动推进。监测恢复不会新增工作执行字段。 + +数据 schema 继续为 1,使用现有字段与模式标记。迁移只在新源码运行时作用于其指定的数据目录;本轮测试使用独立数据,未安装或切换正在使用的版本。 + +## 验证 + +固定源码副本 `.cache/task-model-refactor-0.5.1/` 的完整回归:**311 项通过,0 失败、0 跳过**,包括真实隐藏 Electron 检查。语法检查与公开源码检查通过。 + +新增 `tests/task-models.test.js` 的 7 项回归覆盖模式字段、跨模式非法状态、计数与执行隔离、外部通知和授权隔离、混合旧数据的一次性迁移、在途发送重启,以及备份恢复。既有暂停竞态、发送上限、回复检查、保存失败、HTTP/MCP 和界面回归继续通过。 diff --git a/docs/usage.md b/docs/usage.md index 889e09e..afc6a82 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ # 功能与使用细节 -**0.4.1-preview · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 +**0.5.1 · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 一个可运行的本地 Codex 插件:监测账户额度、查看任务计划,并在额度恢复后继续指定工作。v0.3.3 基于 [Rhine 仓库的动画 SVG](https://github.com/empty610/rhine_ui_clash-verge/blob/a298150dbbd13a217bf48fe574f8fd27bfec2b80/src/assets/image/rhine-signal-loop.svg),保留 60 秒的轮廓、飞行、行星、轨道形变动画。本地版本精简重复关键帧与叠加滤镜,将 48 个粒子动画合并为 3 组,并移除方形外框;不使用图片序列缓存或额外合成层。界面使用仓库中的暖灰、琥珀配色,支持明暗主题。 @@ -8,7 +8,7 @@ 收起后显示动态仪表:默认先显示**五小时额度及其重置倒计时**,每 12 秒切换一次,使用 1 秒的淡出、换值、淡入过渡;额度文字不再随装饰动画长时间隐藏,原版 SVG 的 60 秒动画时间线保持连续。倒计时每秒更新,数据未知或过期时显示「待同步」,重置时间已到则显示「待刷新」。这仅切换显示,续作调度仍需所有窗口的真实新额度样本达标。系统开启「减少动态效果」时关闭装饰动画与转场,但保留额度轮播。 -桌面卡片默认 256 px,可在「工作策略与显示」中用滑块调为 160–420 px,也可按住 Ctrl 在卡片上滚动鼠标滚轮调整;大小会保存。**点击整张卡片展开,拖动任意位置移动**;桌面光标移动超过 6 个逻辑像素视为拖动,不触发展开。静止按住或轻微抖动不会移动卡片或保存新位置;失焦、取消或丢失指针捕获时结束拖动。展开/收起先淡出,在不可见时一次切换窗口尺寸和位置,再淡入;两种模式分别记住位置。 +桌面卡片默认 256 px,可在「工作策略与显示」中用滑块调为 160–420 px,也可按住 Ctrl 在卡片上滚动鼠标滚轮调整;大小会保存。**点击整张卡片展开,拖动任意位置移动**;桌面光标移动超过 6 个逻辑像素视为拖动,不触发展开。静止按住或轻微抖动不会移动卡片或保存新位置;失焦、取消或丢失指针捕获时结束拖动。展开/收起先淡出,在不可见时一次切换窗口尺寸和位置,再淡入;收起会恢复卡片原位,从卡片展开时以当前卡片为锚,屏幕边缘空间不足时换向展开,不跳回旧面板位置。 **右键卡片**可打开与系统托盘相同的菜单:打开面板、跟随 Codex、独立显示、重置位置、隐藏挂件或退出挂件。隐藏到托盘只隐藏界面;关闭按钮和“退出 Continuer”会保存状态、停止本工具的执行轮次与服务、结束跟随和自启动监听进程。已进入原聊天队列的消息仍不能撤回。 @@ -16,9 +16,9 @@ 源码构建使用 Visual Studio Build Tools 的 Roslyn 编译器确定性生成 `desktop/continuer-monitor.exe`,可通过 `CONTINUER_CSC` 指定编译器。自启动与跟随监测优先使用此隐藏程序,PowerShell 启动入口在交接后退出。便携包已经包含监听器,无需安装编译工具。源码环境仍保留 PowerShell 监测回退。 -导入原聊天后,开启「额度监测」即开始等待耗尽与恢复。只有后台确认额度耗尽(某窗口已用 ≥100% 或服务端返回限制标记),随后获取所有窗口达标的新样本,才通过官方 `codex queue` 向原聊天排队发送一次「继续」。开启时额度正常、仅低于阈值、倒计时到零、额度未知或部分更新事件都不会直接触发。每次耗尽恢复周期只发送一次;暂停监测可取消尚未发送的消息。已进入 Codex 队列的消息不能由本工具撤回。 +导入原聊天后,开启「额度监测」即开始等待耗尽与恢复。只有后台确认额度耗尽(某窗口已用 ≥100% 或服务端返回限制标记),随后获取所有窗口达标的新样本,才准备通过官方 `codex queue` 向原聊天排队发送一次续作消息。发送前逐条检查原聊天:最新指令对应的轮次已正常结束且已有最终回复,则跳过本次发送,不增加发送次数;仍在回复或无法确认状态时保留待发送记录,稍后重试。只检查最新轮次,较早的完整回复不会阻止新指令在额度中断后续作。开启时额度正常、仅低于阈值、倒计时到零、额度未知或部分更新事件都不会直接触发。每次耗尽恢复周期最多发送一次;暂停监测可取消尚未发送的消息。已进入 Codex 队列的消息不能由本工具撤回。 -原聊天的「监测备注」仅本地保存,可留空,不作为新目标发送。每条记录受独立的「原聊天发送次数上限」约束(1–100 次,默认 20 次),累计次数在暂停、重新开启和重启后保留;到限自动停止,提高上限后需手动开启。原聊天进度和完成状态由原聊天负责,本工具不自动识别目标完成;「结束监测并移除」只移除监测记录。新建工作仍使用自己的执行目标、轮次上限和结构化完成结果。 +原聊天的「监测备注」仅本地保存,可留空,不作为新目标发送。每条记录受独立的「原聊天发送次数上限」约束(1–100 次,默认 20 次),累计次数在暂停、重新开启和重启后保留;到限自动停止,提高上限后需手动开启。完整回复检查使用轮次结束状态、最终消息及消息与最新指令的顺序;中途说明、失败或中断的轮次不算完整回复。兼容未提供消息 phase 的旧模型,在轮次正常结束且有非空回复时视为完整回复。此检查不验证项目验收结果,也不保存读取到的聊天正文。跳过后继续监测并保留备注和自定义消息,以便新指令遇到额度中断时再次接续;「结束监测并移除」只移除监测记录。新建工作仍使用自己的执行目标、轮次上限和结构化完成结果。 此模式使用新版桌面端附带的 CLI 队列,不通过 `thread/resume` 接管原聊天,不需要模型定时检查额度,也不依赖活跃助手调用上下文。可用 `CODEX_QUEUE_BINARY` 指定支持 `queue` 的 CLI。原聊天及其执行端必须可用;队列接收成功不等于项目已完成。发送超时或重启时结果不明将停止自动重发,请检查原聊天后重新开启监测。当前旧版「等待会话占用」任务会迁移到监测模式,迁移不会把历史占用错误当作新额度耗尽。 @@ -37,7 +37,7 @@ cd .\codex-continuer 安装器下载锁定版本的 Electron 运行时,构建本地插件包,注册 `continuer-local` 市场并安装 `codex-continuer`,随后打开挂件。使用官方本地插件机制,不修改 Codex 安装文件。首次安装后,在 Codex 中重新加载插件或打开新聊天,再让 Codex「打开 Continuer 挂件」或「查看额度和任务进度」。 -也可以双击 `Open-Widget.cmd` 直接打开,或运行 `npm run widget`。**Ctrl+Alt+C** 展开面板,点击减号收起成圆形仪表。收起后点击任意位置展开、拖动移动;展开后拖动顶部标题栏移动。两种状态分别记住位置,跟随模式保存相对 Codex 的偏移。系统托盘和工作策略中提供「重置挂件位置」。跟随模式在 Codex 位于前台时显示,挂件获得焦点时保持显示;其他应用在前台或 Codex 最小化时隐藏。 +也可以双击 `Open-Widget.cmd` 直接打开,或运行 `npm run widget`。**Ctrl+Alt+C** 展开面板,点击减号收起成圆形仪表。收起后点击任意位置展开、拖动移动;展开后拖动顶部标题栏移动。卡片位置独立保留,点击展开时优先贴着当前卡片;跟随模式同步保存相对 Codex 的新偏移。系统托盘和工作策略中提供「重置挂件位置」。跟随模式在 Codex 位于前台时显示,挂件获得焦点时保持显示;其他应用在前台或 Codex 最小化时隐藏。 这是与 Codex 窗口联动的独立原生挂件,不是向 Codex 内部侧栏注入 UI。插件工具与挂件共用本地服务。隐藏到托盘仍保持运行;退出会停止后台调度和服务。退出标记会阻止普通 MCP 查询或自动启动重新拉起,需手动打开挂件恢复。Codex 托管的 MCP 工具连接可保持空闲,不执行后台调度。 @@ -108,12 +108,16 @@ CLI 已登录时不必重复登录。普通 Windows 用户环境即可运行, ## 使用 1. 新建工作:填写名称、现有项目的绝对目录、目标和验收标准。创建后即排队执行。 -2. 或导入已有本地 Codex 会话:填写可选备注,导入后保持暂停;开启额度监测,确认耗尽并恢复后向原聊天发送固定「继续」。备注不发送,原聊天中的最新要求继续有效。 +2. 或导入已有本地 Codex 会话:点击「加入我的工作」后,可填写续作消息和监测备注。续作消息留空(含仅空白)时,根据发送时的界面语言发送中文「继续」或英文「Continue」;填写时每次恢复都发送原文。备注保存到首页工作卡片,仅本地展示,不发送。导入后保持暂停;开启额度监测并确认耗尽、恢复后才发送。详情可查看完整备注和实际续作消息。 + +挂件与完整页面顶部的语言选择器可切换「中文 / English」。设置保存到本地,重新打开后保留,并同步到其他窗口;旧数据默认中文。语言切换不会开启自动调度、清空输入或翻译工作名称、备注和自定义续作消息。 + +已加入的原聊天可以在「详情 → 编辑续作消息」中修改消息,点击「保存消息」后本地持久保存。清空后恢复当前语言的默认消息,取消编辑不保存。修改不重置发送次数或监测状态;新内容用于后续发送,正在发送或已排队的消息保持原内容。 3. 为需要长期推进的任务开启「持续工作」。等待额度的任务在所有返回窗口恢复到阈值后自动继续。 4. 新建工作的进度来自 Codex 步骤计划;没有计划显示「无计划」。原聊天显示「进度请在原聊天查看」,不推断完成度。 5. 新建工作详情显示目标、计划、输出和执行轮次;原聊天详情显示备注、已排队发送次数和监测状态。 6. 设置中调整轮询周期(10–90 秒)、恢复阈值(1–99%)、直接执行上限(1–100 轮)和原聊天发送上限(每条记录累计 1–100 次)。 -7. 新建工作完成后清除记录;原聊天需选择「结束监测并移除」,该操作不确认原聊天工作已完成。原始 Codex 对话和项目文件保留。 +7. 新建工作完成后清除记录;原聊天需选择「结束监测并移除」,该操作不确认原聊天工作已完成,并同步清理当前状态、自动备份及可解析恢复副本中的对应记录、备注和自定义消息。原始 Codex 对话和项目文件保留。 全局调度开关控制新轮次和新的续作消息。新建工作的「暂停」请求中断本工具当前轮次;原聊天的「暂停监测」不打断原聊天执行,也不能撤回已排队的消息。修改上限时,已经开始排队的消息可能完成;已确认排队次数仍会计入累计。 @@ -137,7 +141,7 @@ CLI 已登录时不必重复登录。普通 Windows 用户环境即可运行, - 额度样本超过 90 秒、未知或连接失败时不运行。重置时间已到仍需新样本确认,不能推断额度恢复。 - 轮询周期最多 90 秒;旧版本超过上限的配置会在加载时收紧至 90 秒。旧监测目标转存为本地备注,旧发送计数迁移为 sentCount;新增发送上限首次沿用原配置 maxRounds,之后两个上限独立。读取或迁移不会开启监测。 - 额度类失败按协议错误码判断,至少退避 60 秒;其他失败需人工处理。 -- 新建工作每轮要求结构化 `done / continue / blocked` 结果;`continue` 且存在工具活动才可自动开始下一轮。结果不明确、需要输入、没有工具活动或达到轮次上限均停止。原聊天只监测额度并发送消息,不读取这类完成结果。 +- 新建工作每轮要求结构化 `done / continue / blocked` 结果;`continue` 且存在工具活动才可自动开始下一轮。结果不明确、需要输入、没有工具活动或达到轮次上限均停止。原聊天在额度恢复后读取最新轮次以检查完整回复,不解析这类结构化项目结果;支持分页历史的服务只读取最新一轮,旧服务通过 `thread/read` 兼容读取。 - `done` 由模型报告,不是独立验证器。验收要求应写入目标,用户可检查输出后手动标记完成。 - 使用 `workspace-write` 沙箱和 `on-request` 审批策略。支持可完整审阅的命令审批;文件变更、空命令、未展示的权限字段及其他交互会拒绝并暂停,需用户到原 Codex 手工处理。 - 只主动管理本工具创建或导入的任务;不会接管未导入的聊天。其他 Codex 客户端的实时进度不保证跨进程同步;不应在两个客户端同时执行同一会话。 diff --git a/package-lock.json b/package-lock.json index 1572a9f..84ec807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-continuer", - "version": "0.4.1-preview", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-continuer", - "version": "0.4.1-preview", + "version": "0.5.1", "devDependencies": { "electron": "45.0.0" }, diff --git a/package.json b/package.json index 3cfde7a..ecfe77c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-continuer", - "version": "0.4.1-preview", + "version": "0.5.1", "private": true, "type": "module", "description": "Local Codex quota monitor and resumable task manager", diff --git a/plugin.json b/plugin.json index 387635f..b35a6bb 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "codex-continuer", - "version": "0.4.1-preview", + "version": "0.5.1", "description": "Windows 预览版:Codex 额度监测、原聊天接续与任务管理", "extensions": { "com.openai": { diff --git a/plugin/control.js b/plugin/control.js index 058ad58..e444d82 100644 --- a/plugin/control.js +++ b/plugin/control.js @@ -3,20 +3,13 @@ const demo = process.argv.includes('--demo'); process.env.CONTINUER_PORT = process.env.PORT || (demo ? '4318' : process.env.CONTINUER_PORT || '4317'); process.env.CONTINUER_DEMO = demo ? '1' : ''; const runtime = await import('./runtime.js'); -const { default: exitState } = await import('../server/exit-state.cjs'); try { if (process.argv[2] === 'stop') { - let state; - try { state = await runtime.request('/api/state'); } - catch (error) { if (error.code === 'TLS_IDENTITY_MISSING' || error.cause?.code === 'ECONNREFUSED') { console.log('Continuer is not running.'); process.exit(0); } throw error; } - const { resolve } = await import('node:path'); - const normalize = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); - if (state.app !== 'codex-continuer' || typeof state.diagnostics?.dataDir !== 'string' || normalize(state.diagnostics.dataDir) !== normalize(runtime.dataDir)) throw Error('端口服务不属于当前 Continuer 数据目录'); - await runtime.request('/api/shutdown', {}); console.log('Continuer stopped.'); + const result = await runtime.stopService(); + console.log(result.stopped ? 'Continuer stopped.' : 'Continuer is not running.'); } else if (process.argv[2] === 'open' || process.argv[2] === 'start') { - exitState.clear(runtime.dataDir, demo); if (process.argv[2] === 'open') console.log(JSON.stringify(await runtime.openWidget())); - else { await runtime.ensureService(); console.log('Continuer started in observation mode.'); } + else { await runtime.startService(); console.log('Continuer started in observation mode.'); } } else if (process.argv[2] === 'url') throw Error('浏览器凭据链接已停用,请运行 node plugin/open.js 打开原生挂件。'); else throw Error('Usage: node plugin/control.js open|start|stop [--demo]'); } catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/plugin/lifecycle.js b/plugin/lifecycle.js new file mode 100644 index 0000000..439511f --- /dev/null +++ b/plugin/lifecycle.js @@ -0,0 +1,139 @@ +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { assertServiceIdentity, runtimeError, samePath, serviceUnavailable } from './local-client.js'; +import { message, messageFields } from '../public/message.js'; + +// The lifecycle knows process roles and readiness. Host I/O is supplied by the +// runtime adapter; constructing it never reads state or starts a process. +export function createLifecycle({ root, dataDir, port, demo = false, platform = process.platform, serviceNode, widgetNode, + appInfo, request, launch, exists, diagnostic, alive, quit, shutdown, + wait = delay, now = () => performance.now(), serviceTimeoutMs = 18000, widgetAttempts = 60 }) { + let starting, opening, stopping, generation = 0; + const assertOpen = (expected = generation) => { + if (expected !== generation || stopping || quit.requested()) throw runtimeError('EXIT_REQUESTED', 'runtime.exited'); + }; + const verify = state => assertServiceIdentity(state, { dataDir, appInfo: appInfo(), platform }); + + async function start(expected) { + assertOpen(expected); + try { + const state = await request('/api/state', undefined, { timeoutMs: 2000 }); + assertOpen(expected); return verify(state); + } catch (error) { if (!serviceUnavailable(error)) throw error; } + assertOpen(expected); + if (!serviceNode) throw runtimeError('NODE_MISSING', 'runtime.node_missing'); + // A passive query/open must not restore the saved scheduler automatically. + const child = launch(serviceNode, [join(root, 'server/index.js'), '--observe-only', ...(demo ? ['--demo'] : [])], 'service', { PORT: String(port) }); + let spawnError; + const failed = error => { spawnError = error; }; + child.once('error', failed); + const deadline = now() + serviceTimeoutMs; + try { + while (now() < deadline) { + assertOpen(expected); if (spawnError) throw spawnError; + await wait(Math.min(150, Math.max(0, deadline - now()))); + assertOpen(expected); if (spawnError) throw spawnError; + const remaining = Math.ceil(deadline - now()); + if (remaining <= 0) break; + try { + const state = await request('/api/state', undefined, { timeoutMs: Math.min(2000, remaining) }); + assertOpen(expected); if (spawnError) throw spawnError; + return verify(state); + } catch (error) { if (!serviceUnavailable(error)) throw error; } + } + throw runtimeError('SERVICE_START_TIMEOUT', 'runtime.service_timeout'); + } finally { child.removeListener('error', failed); } + } + + function ensureService() { + try { assertOpen(); } catch (error) { return Promise.reject(error); } + const job = starting || { generation }; + if (!starting) { + starting = job; + job.promise = start(job.generation).finally(() => { if (starting === job) starting = null; }); + } + return job.promise; + } + + async function startService() { + const expected = generation; + if (stopping) await stopping; + assertOpenGeneration(expected); + quit.clear(); return ensureService(); + } + + function assertOpenGeneration(expected) { + if (expected !== generation) throw runtimeError('EXIT_REQUESTED', 'runtime.exited'); + } + + function ensureWatcher() { + if (demo || !diagnostic(join(dataDir, 'autostart-preferences.json'))?.enabled) return; + const watcher = diagnostic(join(dataDir, 'autostart-status.json')); + if (samePath(watcher?.pluginRoot, root, platform) && alive(watcher?.pid)) return; + const native = join(root, 'desktop/continuer-monitor.exe'); + if (exists(native)) launch(native, ['--watch', widgetNode, root, dataDir], 'watcher'); + else launch('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'desktop/watch-codex.ps1'), '-NodePath', widgetNode, '-PluginRoot', root, '-DataDir', dataDir], 'watcher'); + } + + async function open(automatic, expected) { + assertOpen(expected); await ensureService(); assertOpen(expected); + const executable = join(root, 'node_modules/electron/dist', platform === 'win32' ? 'electron.exe' : 'electron'); + if (!exists(executable)) throw runtimeError('DESKTOP_MISSING', 'runtime.desktop_missing'); + const child = launch(executable, [join(root, 'desktop/main.cjs'), ...(automatic ? ['--auto'] : [])], 'widget', { ELECTRON_RUN_AS_NODE: undefined, CONTINUER_NODE: widgetNode, CONTINUER_PORT: String(port) }); + let spawnError; + const failed = error => { spawnError = error; }; + child.once('error', failed); + try { + for (let attempt = 0; attempt < widgetAttempts; attempt++) { + assertOpen(expected); if (spawnError) throw spawnError; + const widget = diagnostic(join(dataDir, 'widget-status.json')); + if (widget?.ready && widget.buildId === appInfo().buildId && alive(widget.pid)) { + return { launched: true, ready: true, mode: widget.follow ? 'follow-codex' : 'independent', ...messageFields(message('runtime.widget_ready')) }; + } + await wait(150); + } + assertOpen(expected); if (spawnError) throw spawnError; + throw runtimeError('WIDGET_START_TIMEOUT', 'runtime.widget_timeout'); + } finally { child.removeListener('error', failed); } + } + + async function openWidget({ automatic = false } = {}) { + if (automatic) assertOpen(); + else { + const expected = generation; + if (stopping) await stopping; + assertOpenGeneration(expected); quit.clear(); + } + const job = opening || { manual: !automatic, generation }; + if (!opening) { + opening = job; + job.promise = open(automatic, job.generation).then(result => { + assertOpen(job.generation); + // Manual callers joining automatic startup restore one watcher. + if (job.manual) ensureWatcher(); + return result; + }).finally(() => { if (opening === job) opening = null; }); + } else if (!automatic) job.manual = true; + return job.promise; + } + + async function stopService() { + let state; + try { state = await request('/api/state', undefined, { timeoutMs: 2000 }); } + catch (error) { if (serviceUnavailable(error)) return { running: false }; throw error; } + assertServiceIdentity(state, { dataDir, platform }); + await request('/api/shutdown', {}, { timeoutMs: 5000 }); + return { running: false, stopped: true }; + } + + function stopBackground() { + if (!stopping) { + generation++; starting = null; opening = null; + // Publish the stopping state before calling host I/O, which may emit events. + stopping = Promise.resolve().then(shutdown).finally(() => { stopping = null; }); + } + return stopping; + } + + return { ensureService, startService, openWidget, stopService, stopBackground }; +} diff --git a/plugin/local-client.js b/plugin/local-client.js new file mode 100644 index 0000000..d531f2f --- /dev/null +++ b/plugin/local-client.js @@ -0,0 +1,39 @@ +import { resolve } from 'node:path'; +import { isMessage, message, MessageError } from '../public/message.js'; + +export function runtimeError(code, id) { return Object.assign(new MessageError(message(id)), { code }); } +export const serviceUnavailable = error => error?.code === 'TLS_IDENTITY_MISSING' || error?.cause?.code === 'ECONNREFUSED'; +export function samePath(left, right, platform = process.platform) { + if (typeof left !== 'string' || typeof right !== 'string') return false; + const normalize = value => platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); + return normalize(left) === normalize(right); +} + +// Reads require the current build; shutdown may deliberately address an older +// build. Both require the expected data directory, and full exit also its root. +export function assertServiceIdentity(state, { dataDir, root, appInfo, platform = process.platform }) { + if (state?.app !== 'codex-continuer') throw runtimeError('SERVICE_OWNER_MISMATCH', 'runtime.port_in_use'); + if (appInfo && (state.protocolVersion !== appInfo.protocolVersion || state.buildId !== appInfo.buildId)) { + throw runtimeError('SERVICE_BUILD_MISMATCH', 'runtime.build_mismatch'); + } + if (!samePath(state.diagnostics?.dataDir, dataDir, platform)) { + throw runtimeError('SERVICE_OWNER_MISMATCH', 'runtime.directory_mismatch'); + } + if (root !== undefined && !samePath(state.diagnostics?.projectRoot, root, platform)) throw runtimeError('SERVICE_OWNER_MISMATCH', 'runtime.root_mismatch'); + return state; +} + +export function createLocalClient({ origin, readAccessToken, identityOptions, fetchLocal }) { + return async function request(path, payload, { timeoutMs = 35000 } = {}) { + if (typeof path !== 'string' || !path.startsWith('/api/') || path.includes('\\') || new URL(origin + path).origin !== origin) throw Error('Invalid API path'); + const token = readAccessToken(); + const response = await fetchLocal(origin + path, { + signal: AbortSignal.timeout(timeoutMs), + headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, + ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }), + }, identityOptions()); + const result = await response.json(); + if (!response.ok) throw new MessageError(isMessage(result.errorInfo) ? result.errorInfo : null, result.error || `HTTP ${response.status}`); + return result; + }; +} diff --git a/plugin/runtime.js b/plugin/runtime.js index ef5ad51..f06cbb6 100644 --- a/plugin/runtime.js +++ b/plugin/runtime.js @@ -1,92 +1,45 @@ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, openSync, closeSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { join } from 'node:path'; import settings from '../server/config.cjs'; import logging from '../server/logging.cjs'; import { diagnostic, alive } from '../server/health.js'; import access from '../server/access.cjs'; import exitState from '../server/exit-state.cjs'; import localTls from '../server/local-tls.cjs'; +import { createLocalClient } from './local-client.js'; +import { createLifecycle } from './lifecycle.js'; +import { stopBackground as stopOwnedBackground } from './shutdown.js'; -export const root = settings.root; -const config = settings.config; -export const dataDir = settings.dataDir; -export const port = settings.port; +// This adapter is the only place where the lifecycle binds to this host. +export const { root, dataDir, port } = settings; export const origin = `https://127.0.0.1:${port}`; -export const readAccessToken = () => access.readToken(dataDir, process.env.CONTINUER_DEMO === '1'); -export async function request(path, payload, { timeoutMs = 35000 } = {}) { - const token = readAccessToken(); - if (typeof path !== 'string' || !path.startsWith('/api/') || path.includes('\\') || new URL(origin + path).origin !== origin) throw Error('Invalid API path'); - const response = await localTls.fetchLocal(origin + path, { signal: AbortSignal.timeout(timeoutMs), headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }) }, localTls.requestOptions(dataDir, process.env.CONTINUER_DEMO === '1')); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); - return result; -} +const demo = process.env.CONTINUER_DEMO === '1'; +export const readAccessToken = () => access.readToken(dataDir, demo); +export const request = createLocalClient({ + origin, readAccessToken, + identityOptions: () => localTls.requestOptions(dataDir, demo), + fetchLocal: (...args) => localTls.fetchLocal(...args), +}); + export function launch(command, args, name, extraEnv = {}) { mkdirSync(dataDir, { recursive: true }); logging.rotateLogs(dataDir); const out = openSync(join(dataDir, `${name}-stdout.log`), 'a'); - const err = openSync(join(dataDir, `${name}-stderr.log`), 'a'); + let err; try { + err = openSync(join(dataDir, `${name}-stderr.log`), 'a'); const child = spawn(command, args, { cwd: root, detached: true, windowsHide: true, stdio: ['ignore', out, err], env: { ...process.env, ...extraEnv, CONTINUER_DATA_DIR: dataDir } }); child.on('error', () => {}); child.unref(); return child; - } finally { closeSync(out); closeSync(err); } -} -let starting; -function verifiedService(state) { - if (state.app !== 'codex-continuer') throw new Error('Port belongs to another application'); - if (state.protocolVersion !== settings.appInfo().protocolVersion || state.buildId !== settings.appInfo().buildId) throw new Error('运行服务与当前插件构建不一致,请先停止并重新启动 Continuer'); - const directory = path => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path); - if (typeof state.diagnostics?.dataDir !== 'string' || directory(state.diagnostics.dataDir) !== directory(dataDir)) throw new Error('该端口服务使用不同的数据目录,请检查路径后重新启动 Continuer'); - return state; -} -export async function ensureService() { - if (exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,请先手动打开挂件。'); - if (starting) return starting; - starting = (async () => { - try { - const state = await request('/api/state'); - return verifiedService(state); - } catch (error) { - if (error.code !== 'TLS_IDENTITY_MISSING' && error.cause?.code !== 'ECONNREFUSED') throw error; - } - const node = config.nodePath || (process.versions.electron ? process.env.CONTINUER_NODE || 'node' : process.execPath); - if (!node) throw new Error('缺少 Node.js 路径,请重新运行插件安装器'); - if (exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,请先手动打开挂件。'); - // Opening a widget or reading MCP state never restores saved scheduling by itself. - const child = launch(node, [join(root, 'server/index.js'), '--observe-only', ...(process.env.CONTINUER_DEMO === '1' ? ['--demo'] : [])], 'service', { PORT: String(port) }); - let spawnError; child.once('error', e => { spawnError = e; }); - for (let i = 0; i < 120; i++) { - if (spawnError) throw spawnError; - await new Promise(resolve => setTimeout(resolve, 150)); - let state; - try { state = await request('/api/state'); } catch (error) { if (error.code === 'TLS_IDENTITY_MISSING' || error.cause?.code === 'ECONNREFUSED') continue; throw error; } - return verifiedService(state); - } - throw new Error('本地服务启动失败,请查看 data/service-stderr.log'); - })().finally(() => { starting = null; }); - return starting; -} -export async function openWidget({ automatic = false } = {}) { - if (automatic && exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,自动启动已暂停。'); - if (!automatic) exitState.clear(dataDir, process.env.CONTINUER_DEMO === '1'); - await ensureService(); - const executable = join(root, 'node_modules/electron/dist', process.platform === 'win32' ? 'electron.exe' : 'electron'); - if (!existsSync(executable)) throw new Error('缺少桌面运行时,请在项目目录运行 npm install 后重新构建插件'); - launch(executable, [join(root, 'desktop/main.cjs'), ...(automatic ? ['--auto'] : [])], 'widget', { ELECTRON_RUN_AS_NODE: undefined, CONTINUER_NODE: config.nodePath || process.execPath, CONTINUER_PORT: String(port) }); - for (let attempt = 0; attempt < 60; attempt++) { - const widget = diagnostic(join(dataDir, 'widget-status.json')); - if (widget?.ready && widget.buildId === settings.appInfo().buildId && alive(widget.pid)) { - const watcher = diagnostic(join(dataDir, 'autostart-status.json')); - const currentWatcher = typeof watcher?.pluginRoot === 'string' && resolve(watcher.pluginRoot).toLowerCase() === resolve(root).toLowerCase() && alive(watcher.pid); - if (!automatic && process.env.CONTINUER_DEMO !== '1' && diagnostic(join(dataDir, 'autostart-preferences.json'))?.enabled && !currentWatcher) { - const node = config.nodePath || process.env.CONTINUER_NODE || process.execPath, native = join(root, 'desktop/continuer-monitor.exe'); - if (existsSync(native)) launch(native, ['--watch', node, root, dataDir], 'watcher'); - else launch('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'desktop/watch-codex.ps1'), '-NodePath', node, '-PluginRoot', root, '-DataDir', dataDir], 'watcher'); - } - return { launched: true, ready: true, mode: widget.follow ? 'follow-codex' : 'independent', message: '挂件已就绪。可使用 Ctrl+Alt+C 展开。' }; - } - await new Promise(resolve => setTimeout(resolve, 150)); - } - throw new Error('挂件未在时限内就绪,请查看 /api/health 和 widget-stderr.log'); + } finally { closeSync(out); if (err !== undefined) closeSync(err); } } + +const lifecycle = createLifecycle({ + root, dataDir, port, demo, + serviceNode: settings.config.nodePath || (process.versions.electron ? process.env.CONTINUER_NODE || 'node' : process.execPath), + widgetNode: settings.config.nodePath || process.env.CONTINUER_NODE || process.execPath, + appInfo: settings.appInfo, request, launch, exists: existsSync, diagnostic, alive, + quit: { requested: () => exitState.requested(dataDir, demo), clear: () => exitState.clear(dataDir, demo) }, + shutdown: () => stopOwnedBackground({ root, dataDir, request, demo }), +}); +export const { ensureService, startService, openWidget, stopService, stopBackground } = lifecycle; diff --git a/plugin/shutdown.js b/plugin/shutdown.js index 2c7bc41..1d41010 100644 --- a/plugin/shutdown.js +++ b/plugin/shutdown.js @@ -1,12 +1,12 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { join, resolve } from 'node:path'; +import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import exitState from '../server/exit-state.cjs'; import { alive, diagnostic } from '../server/health.js'; +import { assertServiceIdentity } from './local-client.js'; const execute = promisify(execFile); -const canonical = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); export async function stopBackground({ root, dataDir, request, demo = false, cleanup = async (phase, pid = 0) => { await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'plugin/stop-owned.ps1'), '-PluginRoot', root, '-DataDir', dataDir, '-Phase', phase, '-ServicePid', String(pid)], { windowsHide: true, timeout: 10000 }); }, isAlive = alive, wait = delay, graceMs = 2500 }) { @@ -17,8 +17,7 @@ export async function stopBackground({ root, dataDir, request, demo = false, cle let pid = diagnostic(join(dataDir, demo ? 'demo-service.lock' : 'service.lock'))?.pid; try { const state = await request('/api/state', undefined, { timeoutMs: 2000 }); - if (state.app !== 'codex-continuer' || typeof state.diagnostics?.projectRoot !== 'string' || typeof state.diagnostics?.dataDir !== 'string' - || canonical(state.diagnostics.projectRoot) !== canonical(root) || canonical(state.diagnostics.dataDir) !== canonical(dataDir)) throw Error('Refusing to shut down another application or data directory.'); + assertServiceIdentity(state, { root, dataDir }); pid = state.diagnostics.pid; await request('/api/shutdown', {}, { timeoutMs: 5000 }); } catch (error) { diff --git a/plugin/tools.js b/plugin/tools.js index bd59b5f..cd4bcb0 100644 --- a/plugin/tools.js +++ b/plugin/tools.js @@ -4,7 +4,7 @@ export const tools = [ { name: 'continuer_open_widget', description: '打开跟随 Codex 窗口的 Continuer 额度和任务挂件。只显示界面,不新建或继续任务。', inputSchema: object({}), annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, { name: 'continuer_status', description: '读取 Codex 额度、已管理任务进度、持续工作开关及连接状态。未知额度不是零。', inputSchema: object({}), annotations: { readOnlyHint: true, openWorldHint: false } }, { name: 'continuer_list_sessions', description: '分页读取可导入的 Codex 本机会话;读取不会自动接管会话。', inputSchema: object({ cursor: { type: 'string' } }), annotations: { readOnlyHint: true, openWorldHint: false } }, - { name: 'continuer_import_session', description: '将原聊天加入额度恢复监测,导入后暂停。note 为可选本地备注,不发送给聊天;旧 prompt 字段仅作为备注兼容。每次确认耗尽并恢复后只发送固定“继续”,累计受 maxWakeSends 限制。不读取原聊天完成状态,不承诺完成目标。', inputSchema: { ...object({ threadId: text, note: { type: 'string', minLength: 0 }, prompt: { ...text, description: '旧版兼容备注;不发送给聊天,不可与 note 同时提供' } }), required: ['threadId'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, + { name: 'continuer_import_session', description: '将原聊天加入额度恢复监测,导入后暂停。note 为可选本地备注,不发送给聊天;旧 prompt 字段仅作为备注兼容。continuationMessage 为可选续作消息,留空发送“继续”。每次确认耗尽并恢复后先检查最新回复,上次指令已有完整回复则跳过;仍在回复或状态未知时等待重试。实际发送累计受 maxWakeSends 限制,不承诺完成项目目标。', inputSchema: { ...object({ threadId: text, note: { type: 'string', minLength: 0 }, continuationMessage: { type: 'string', minLength: 0, description: '额度恢复时发送给原聊天的消息;留空发送继续' }, prompt: { ...text, description: '旧版兼容备注;不发送给聊天,不可与 note 同时提供' } }), required: ['threadId'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, { name: 'continuer_task_action', description: '暂停任务、关闭持续工作或结束并移除记录。原聊天 complete 仅结束监测,不确认工作完成。恢复执行、开启监测和持续工作必须由用户在挂件中操作。', inputSchema: { ...object({ id: text, action: { enum: ['pause', 'complete', 'continuous'], type: 'string' }, value: { type: 'boolean', enum: [false] } }), required: ['id', 'action'] }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false } }, { name: 'continuer_update_settings', description: '仅允许关闭后台调度。开启调度、调整额度阈值及执行上限必须由用户在挂件中操作。', inputSchema: { ...object({ enabled: { type: 'boolean', enum: [false] } }), required: ['enabled'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, ]; diff --git a/public/access.js b/public/access.js index 863f876..4053fb0 100644 --- a/public/access.js +++ b/public/access.js @@ -1,9 +1,10 @@ // Browser credential links are retired. Only the pinned native window receives // API authorization from its main process; discard legacy browser credentials. -if (typeof window !== 'undefined' && typeof location !== 'undefined') { +export function discardLegacyAccess({ location, history, sessionStorage }) { if (new URLSearchParams(location.hash.slice(1)).has('access')) history.replaceState(null, '', location.pathname + location.search); sessionStorage.removeItem('continuer-access'); } +if (typeof window !== 'undefined' && typeof location !== 'undefined') discardLegacyAccess(globalThis); export function accessHeaders() { return {}; } // Native EventSource cannot send authorization headers. Preserve its small diff --git a/public/api-client.js b/public/api-client.js new file mode 100644 index 0000000..1d213d5 --- /dev/null +++ b/public/api-client.js @@ -0,0 +1,23 @@ +import { accessHeaders } from './access.js'; +import { t as tr } from './i18n.js'; +import { isMessage, message, MessageError } from './message.js'; + +// Both interfaces use the same transport policy and bounded request lifetime. +export function createApiClient({ request = (...args) => fetch(...args), timeoutMs = 35000 } = {}) { + return async function api(path, body) { + const write = body !== undefined; + const response = await request(path, { + signal: AbortSignal.timeout(timeoutMs), + credentials: 'omit', + redirect: 'error', + headers: { + ...accessHeaders(), + ...(write ? { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' } : {}), + }, + ...(write ? { method: 'POST', body: JSON.stringify(body) } : {}), + }); + const data = await response.json(); + if (!response.ok) throw new MessageError(isMessage(data.errorInfo) ? data.errorInfo : data.error ? null : message('ui.request_failed'), data.error || tr("ui.request_failed")); + return data; + }; +} diff --git a/public/app.js b/public/app.js index cf1ec8c..b16d749 100644 --- a/public/app.js +++ b/public/app.js @@ -1,184 +1,236 @@ +import { newTaskForm, monitorImportForm } from './task-forms.js'; +import { t as tr, systemText, setFormError, locale, setLanguage, staticInterface, refreshLocalizedContent } from './i18n.js'; import { TaskDetails } from './task-details.js'; -import { accessHeaders } from './access.js'; +import { createApiClient } from './api-client.js'; +import { escapeHtml as esc } from './html.js'; +import { icon } from './icons.js'; +import { taskCardHtml, taskDetailHtml, isTaskActive, toggle } from './task-view.js'; import { VisibleFeed, setHtml } from './view-lifecycle.js'; import { ApprovalDetails } from './approval-details.js'; import { storageNotice } from './storage-status.js'; -import { taskMode, wakeExplanation } from './task-mode.js'; -const paths = { - overview: '', - tasks: '', - chart: '', - logs: '', - settings: '', - shield: '', - sun: '', - refresh: '', - plus: '', - arrow: '', - play: '', - pause: '', - clock: '', - bolt: '', - folder: '', - check: '', - close: '', - info: '', - import: '', -}; -const icon = name => ``; -const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); -const $ = selector => document.querySelector(selector); -document.querySelectorAll('[data-icon]').forEach(el => { el.innerHTML = icon(el.dataset.icon); }); -const labels = { overview: '概览', tasks: '工作任务', quota: '额度监测', logs: '活动日志', settings: '设置' }; -const statusLabels = { queued: '排队中', waiting: '等待额度', starting: '准备中', running: '进行中', pausing: '暂停中', paused: '已暂停', blocked: '需要关注', failed: '执行失败', completed: '已完成', approval: '等待授权' }; -const taskLabel=t=>t.status==="monitoring" ? "监测中" : t.waitReason==="desktop" ? "等待发送通道" : t.status==='waiting' && ['writer','thread'].includes(t.waitReason) ? '等待会话' : statusLabels[t.status] || t.status; -const active = ['running', 'starting', 'approval', 'pausing']; -const taskDetails = new TaskDetails(api, () => { if (!document.hidden && selectedTask) renderDetail(selectedTask); }, error => toast(error.message)); -const approvalDetails = new ApprovalDetails(api, () => render()); -let state, page = 'overview', filter = 'all', selectedTask, toastTimer, streamAlive = true; -const main = $('#main'); -const modal = $('#modal'); -const time = value => value ? new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—'; -function relative(value) { const sec = Math.max(0, Math.round((Date.now() - value) / 1000)); return sec < 60 ? '刚刚' : sec < 3600 ? `${Math.floor(sec / 60)} 分钟前` : `${Math.floor(sec / 3600)} 小时前`; } -function resetLabel(value) { if (!value) return '恢复时间未知'; const diff = Math.max(0, value * 1000 - Date.now()); if (!diff) return '等待额度刷新确认'; const h = Math.floor(diff / 3600000), m = Math.ceil(diff % 3600000 / 60000); return `${h >= 24 ? `${Math.floor(h / 24)} 天 ${h % 24} 小时` : h ? `${h} 小时 ${m} 分钟` : `${m} 分钟`}后重置`; } -function allBuckets() { const values = Object.values(state.quota?.rateLimitsByLimitId || {}).filter(Boolean); return values.length ? values : state.quota?.rateLimits ? [state.quota.rateLimits] : []; } -function toast(message) { $('#toast').textContent = message; $('#toast').classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => $('#toast').classList.remove('show'), 3500); } -async function api(path, body) { - const response = await fetch(path, { credentials: 'omit', redirect: 'error', headers: { ...accessHeaders(), ...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) }); - const data = await response.json(); if (!response.ok) throw new Error(data.error || '请求失败'); return data; -} -function toggle(on, action, label, id = '') { return ``; } -function badge(task) { return `${esc(taskLabel(task))}`; } -function heading(title, description, actions = '') { return `
YOUR WORK, CONTINUED.

${title}

${description}

${actions}
`; } -const newButton = ``; -const refreshButton = ``; -function empty(title, text, button = false) { return `
${icon('folder')}

${title}

${text}${button ? `
${newButton}
` : ''}
`; } -function quotaCard(window, title, color = '') { - const valid = Number.isFinite(window?.usedPercent); - const remaining = valid ? Math.max(0, Math.min(100, 100 - window.usedPercent)) : null; - return `
${esc(title)}${icon('clock')}
${remaining === null ? '—' : Math.round(remaining)}${remaining === null ? '数据未知' : '% 剩余'}
${remaining === null ? '等待账户数据' : resetLabel(window.resetsAt)}${remaining === null ? '' : `已用 ${Math.round(window.usedPercent)}%`}
`; -} -function stats() { - const bucket = allBuckets().find(b => b.limitId === 'codex') || allBuckets()[0]; - const running = state.tasks.filter(t => active.includes(t.status)).length; - const continuous = state.tasks.filter(t => t.continuous && t.status !== 'completed').length; - const windowName = (w, fallback) => w?.windowDurationMins ? (w.windowDurationMins >= 1440 ? `${w.windowDurationMins / 1440} 天额度` : `${w.windowDurationMins / 60} 小时额度`) : fallback; - return `
${quotaCard(bucket?.primary, windowName(bucket?.primary, '短期额度'), 'purple')}${quotaCard(bucket?.secondary, windowName(bucket?.secondary, '长期额度'), 'green')}
进行中的工作${icon('tasks')}
${running}/ ${state.tasks.filter(t => t.status !== 'completed').length} 个工作
${Array.from({ length: 10 }, (_, i) => ``).join('')}
${running ? '正在推进项目目标' : '准备好继续下一步'}串行执行
持续工作已开启${icon('bolt')}
${continuous}个工作
${Array.from({ length: 10 }, (_, i) => ``).join('')}
额度恢复后自动接续${icon('arrow')}
`; -} -function banner() { return ``; } -function tasksPanel(full = false) { - const list = state.tasks.filter(t => filter !== 'continuous' || t.continuous); - return `
${[['all', '全部工作'], ['continuous', '持续工作']].map(([value, label]) => ``).join('')}
${list.length ? `
${list.map(taskCard).join('')}
` : empty('这里还没有工作', '添加项目目标,让每一次额度都用在进展上。', true)}
${icon('shield')}已完成的工作自动清除;需授权时等待你的决定
`; -} -function taskCard(t) { - const mode = taskMode(t, state.settings); - return `
${icon('folder')}
${esc(t.cwd)}
${badge(t)}
${mode.progress || (t.progress === null ? '无计划' : `${t.progress}%`)}
${esc(mode.counter)}
${esc(t.activity)}
${toggle(t.continuous, 'continuous', `${t.name}${mode.continuous}`, t.id)}${mode.continuous}
${t.status === 'completed' ? '' : ``}
`; -} -function flowPanel() { return `

持续工作策略

${state.settings.enabled ? '已启用' : '已暂停'}
${icon('chart')}
持续监测额度

每 ${state.settings.pollSeconds} 秒同步,实时接收更新

${icon('clock')}
额度不足,保留现场

保存会话,等待所有额度窗口恢复

${icon('play')}
恢复后,按模式接续

剩余额度 ≥ ${state.settings.minimumRemaining}% 时自动续作

直接执行:完成、阻塞或达到 ${state.settings.maxRounds} 轮时停止。原聊天:${esc(wakeExplanation)}每条记录累计最多发送 ${state.settings.maxWakeSends} 次。

`; } -function recentPanel() { return `

最近活动

${state.logs.slice(0, 3).map(log => `

${esc(log.message)}

`).join('') || '

开始第一项工作后,活动会记录在这里。

'}
`; } -function chart() { - const data = state.history.map(h => ({ time: h.time, value: h.values.find(v => v.id === 'codex')?.used ?? h.values[0]?.used })).filter(h => Number.isFinite(h.value)); - if (data.length < 2) return `

额度使用趋势

收集到至少两个额度样本后显示趋势
`; - const width = 900, start = data[0].time, duration = data.at(-1).time - start || 1; - const points = data.map(d => `${((d.time - start) / duration * width).toFixed(2)},${(104 - Math.max(0, Math.min(100, d.value)) * .92).toFixed(2)}`).join(' '); - return `

额度使用趋势

短期额度 · 已使用比例
${[12, 58, 104].map((y, i) => `${100 - i * 50}%`).join('')}
${time(start)}${time(start + duration / 2)}${time(data.at(-1).time)}
`; -} -function alerts() { - let html = storageNotice(state, esc); - if (state.demo) html += `
${icon('info')}演示模式 · 模拟额度与任务,不执行真实项目
`; - if (state.connection.status !== 'connected') html += `
${icon('info')} ${esc(state.connection.message)}。请先在终端运行 codex login,确认 CLI 可用后刷新。额度未知时不会自动执行。
`; - for (const a of approvalDetails.readAll(state.approvals)) html += `

操作等待授权

${esc(a.reason)}

${esc(a.command || a.method)}\n${esc(a.cwd)}
${a.requiresDetails ? '

完整授权内容尚未加载,请刷新重试;加载完成后可允许操作。

' : ''}
`; - return html; -} -function settings() { const s = state.settings; return `${heading('设置', '按照你的工作节奏,安排每一次继续。')}

自动调度

关闭后不再启动新轮次,正在进行的轮次会继续。

${toggle(s.enabled, 'scheduler', '自动调度')}

额度同步间隔

10–90 秒,接收实时事件并定期校验;额度有效期仍为 90 秒。

恢复阈值

所有返回的额度窗口剩余比例达到此值,才允许启动。

%

直接执行轮次上限

仅用于新建工作的自动执行;到限等待手动继续。

轮

原聊天发送次数上限

每条监测记录累计计数,暂停或重新开启不清零。到限停止;提高上限后手动开启监测。

次

本地连接

使用本机 Codex 登录状态与默认模型,凭据由 Codex CLI 管理。
工作在项目目录沙箱内进行;请求额外权限时需单独批准。

${state.connection.status === 'connected' ? '已连接' : '未连接'}

本地服务需持续运行,关机或休眠期间不执行。关闭页面不影响后台服务;原生挂件提供系统托盘;随 Codex 启动:${state.diagnostics?.autostart.watcherRunning ? '监听器运行中' : state.diagnostics?.autostart.enabled === false ? '已关闭' : '监听器未运行或状态未知'}。
构建 ${esc(state.buildId || '')} · 协议 ${state.protocolVersion} ·

`; } -function render() { - if (!state || document.hidden) return; - $('#app-version').textContent = `v${state.version} · ${state.buildId.slice(0, 8)}`; - $('#nav-count').textContent = state.tasks.filter(t => t.status !== 'completed').length; - $('#breadcrumb-current').textContent = labels[page]; - document.querySelectorAll('#nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page)); - const connected = state.connection.status === 'connected' && streamAlive; - $('#connection').textContent = !streamAlive ? '本地服务断开' : state.demo ? '演示连接' : connected ? '本地服务已连接' : '等待 Codex 连接'; - $('#connection').classList.toggle('off', !connected); - $('#footer-status').textContent = !streamAlive ? '本地服务断开,正在重连' : state.demo ? '演示模式 · 模拟数据' : connected ? 'Codex App Server 已连接' : '等待连接 Codex CLI'; - $('#footer-update').textContent = `每 ${state.settings.pollSeconds} 秒同步 · ${state.sampledAt ? `更新于 ${time(state.sampledAt)}` : '额度数据未知'}`; - // Preserve form input while background events arrive. - if (page === 'settings' && $('#settings-form')) { - const control = $('#settings-form .switch'); - control.classList.toggle('on', state.settings.enabled); - control.setAttribute('aria-checked', String(state.settings.enabled)); - return; +import { wakeExplanation, continuationEditor } from './task-mode.js'; + +export function mountPage(environment = globalThis) { + const { document, window, localStorage, Date, FormData, AbortSignal, DOMParser, getComputedStyle, setTimeout, clearTimeout, setInterval, clearInterval, fetch, Blob, URL } = environment; + const api = createApiClient({ request: (...args) => fetch(...args) }); + const applyStaticLanguage = staticInterface(document); + const $ = selector => document.querySelector(selector); + document.querySelectorAll('[data-icon]').forEach(el => { el.innerHTML = icon(el.dataset.icon); }); + const labels = () => ({ overview: tr("ui.overview"), tasks: tr("ui.work"), quota: tr("ui.quota_monitor"), logs: tr("ui.activity_log"), settings: tr("ui.settings") }); + const taskDetails = new TaskDetails(api, () => { if (!document.hidden && selectedTask) renderDetail(selectedTask); }, error => toast(systemText(error))); + const approvalDetails = new ApprovalDetails(api, () => render()); + let modalRenderer = null, importList = null; + let state, page = 'overview', filter = 'all', selectedTask, toastTimer, streamAlive = true; + const main = $('#main'); + const modal = $('#modal'); + const time = value => value ? new Date(value).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit', hour12: false }) : '—'; + function relative(value) { const sec = Math.max(0, Math.round((Date.now() - value) / 1000)); return sec < 60 ? tr("ui.just_now") : sec < 3600 ? tr("ui.min_ago", { p0: Math.floor(sec / 60) }) : tr("ui.h_ago", { p0: Math.floor(sec / 3600) }); } + function resetLabel(value) { if (!value) return tr("ui.reset_time_unknown"); const diff = Math.max(0, value * 1000 - Date.now()); if (!diff) return tr("ui.waiting_for_fresh_quota_confirmation"); const h = Math.floor(diff / 3600000), m = Math.ceil(diff % 3600000 / 60000); return tr("ui.until_reset", { p0: h >= 24 ? tr("ui.d_h", { p0: Math.floor(h / 24), p1: h % 24 }) : h ? tr("ui.h_min", { p0: h, p1: m }) : tr("ui.min", { p0: m }) }); } + function allBuckets() { const values = Object.values(state.quota?.rateLimitsByLimitId || {}).filter(Boolean); return values.length ? values : state.quota?.rateLimits ? [state.quota.rateLimits] : []; } + function toast(message) { $('#toast').textContent = message; $('#toast').classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => $('#toast').classList.remove('show'), 3500); } + function heading(title, description, actions = '') { return `
YOUR WORK, CONTINUED.

${title}

${description}

${actions}
`; } + const newButton = () => ``; + const refreshButton = () => ``; + function empty(title, text, button = false) { return `
${icon('folder')}

${title}

${text}${button ? `
${newButton()}
` : ''}
`; } + function quotaCard(window, title, color = '') { + const valid = Number.isFinite(window?.usedPercent); + const remaining = valid ? Math.max(0, Math.min(100, 100 - window.usedPercent)) : null; + return `
${esc(title)}${icon('clock')}
${remaining === null ? '—' : Math.round(remaining)}${remaining === null ? tr("ui.unknown") : tr("ui.remaining")}
${remaining === null ? tr("ui.waiting_for_account_data") : resetLabel(window.resetsAt)}${remaining === null ? '' : tr("ui.used", { p0: Math.round(window.usedPercent) })}
`; } - let html; - if (page === 'overview') html = `${heading('工作概览', '掌握每一份额度,让每一个项目稳步向前。', refreshButton + newButton)}${alerts()}${banner()}${stats()}

我的工作 ${state.tasks.length}

${tasksPanel()}${chart()}
`; - else if (page === 'tasks') html = `${heading('工作任务', '目标、进度、下一步,在这里井然有序。', `${newButton}`)}${alerts()}${tasksPanel(true)}`; - else if (page === 'quota') html = `${heading('额度监测', '同步账户可用额度,准确掌握下一次出发的时间。', refreshButton)}${alerts()}${allBuckets().length ? allBuckets().map(b => `

${esc(b.limitName || b.limitId || 'Codex')} ${esc(b.planType || '账户额度')}

${quotaCard(b.primary, '主要额度窗口', 'purple')}${quotaCard(b.secondary, '次要额度窗口', 'green')}
`).join('
') : empty('额度暂不可用', '连接已登录的 Codex CLI 后显示真实额度。')}${chart()}

重置时间来自服务端,仅作参考。系统会读取恢复后的新样本,再决定是否继续工作。

`; - else if (page === 'logs') html = `${heading('活动日志', '显示最近 50 条活动;导出可获取最多 300 条。', '')}
${state.logs.length ? `${state.logs.map(l => ``).join('')}
时间类型活动
${new Date(l.time).toLocaleDateString('zh-CN')} ${time(l.time)}${({ success: '成功', warning: '关注', info: '信息' })[l.type]}${esc(l.message)}
` : empty('暂无活动记录', '添加工作后,调度记录将显示在这里。')}
`; - else html = settings(); - setHtml(main, html); - if (modal.open && selectedTask) renderDetail(selectedTask); -} -function openModal(html) { selectedTask = null; $('#modal-body').innerHTML = html; if (!modal.open) modal.showModal(); } -function modalHead(title) { return ``; } -function newTask() { openModal(`${modalHead('新建工作')}

给项目一个明确目标,Continuer 帮你接续进展。

创建后加入队列;额度充足且调度开启时开始执行。

`); } -function renderDetail(id) { - const summary = state.tasks.find(t => t.id === id); if (!summary) { modal.close(); selectedTask = null; return; } - const t = taskDetails.read(summary); - const mode = taskMode(t, state.settings); - $('#modal-body').innerHTML = `${modalHead(esc(t.name))}${badge(t)}

${esc(t.cwd)}

${esc(mode.description)}

${mode.wake ? `

${esc(wakeExplanation)}

` : `

计划进度 ${t.progress === null ? '· 暂无计划' : `· ${t.progress}%`}

    ${(t.plan || []).map(s => `
  • ${icon(s.status === 'completed' ? 'check' : s.status === 'inProgress' ? 'play' : 'clock')}${esc(s.step)}
  • `).join('')}

计划百分比仅表示已完成步骤比例。

`}

当前状态:${esc(t.activity)} · ${esc(mode.counter)}

${t.threadId ? `

会话:${esc(t.threadId)}

` : ''}${!mode.wake ? `

本轮输出

${t.outputTruncated ? '

输出较长,仅保留末尾预览;完整内容请在原 Codex 会话查看。

' : ''}
${esc(t.output || '尚无输出')}
` : ''}`; -} -async function importModal(cursor = null, append = false) { - if (!append) openModal(`${modalHead('导入 Codex 会话')}

选择需要管理的本机会话。导入后先保持暂停。

正在读取会话…
`); - try { - const result = await api(`/api/threads${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`); - const remaining = 300 - (append ? $('#thread-list').querySelectorAll('.thread-option').length : 0); - const list = result.data.slice(0, remaining).map(t => `
${esc(t.name || t.preview || '未命名会话')}${esc(t.cwd || t.id)}
`).join('') || '

没有可导入的会话

'; - if (append) $('#thread-list').insertAdjacentHTML('beforeend', list); else $('#thread-list').innerHTML = list; - $('#thread-more').innerHTML = result.nextCursor && remaining > result.data.length ? `` : ''; - } catch (e) { if ($('#modal-error')) $('#modal-error').textContent = e.message; } + function stats() { + const bucket = allBuckets().find(b => b.limitId === 'codex') || allBuckets()[0]; + const running = state.tasks.filter(isTaskActive).length; + const continuous = state.tasks.filter(t => t.continuous && t.status !== 'completed').length; + const windowName = (w, fallback) => w?.windowDurationMins ? (w.windowDurationMins >= 1440 ? tr("ui.day_quota", { p0: w.windowDurationMins / 1440 }) : tr("ui.hour_quota", { p0: w.windowDurationMins / 60 })) : fallback; + return `
${quotaCard(bucket?.primary, windowName(bucket?.primary, tr("ui.short_term_quota")), 'purple')}${quotaCard(bucket?.secondary, windowName(bucket?.secondary, tr("ui.long_term_quota")), 'green')}
${tr("ui.active_work")}${icon('tasks')}
${running}/ ${state.tasks.filter(t => t.status !== 'completed').length}${tr("ui.work_items")}
${Array.from({ length: 10 }, (_, i) => ``).join('')}
${running ? tr("ui.working_toward_project_goals") : tr("ui.ready_for_the_next_step")}${tr("ui.runs_one_at_a_time")}
${tr("ui.continuous_work_enabled")}${icon('bolt')}
${continuous}${tr("ui.work_items_2")}
${Array.from({ length: 10 }, (_, i) => ``).join('')}
${tr("ui.resumes_after_quota_recovery")}${icon('arrow')}
`; + } + function banner() { return ``; } + function tasksPanel(full = false) { + const list = state.tasks.filter(t => filter !== 'continuous' || t.continuous); + return `
${[['all', tr("ui.all_work")], ['continuous', tr("ui.continuous_work")]].map(([value, label]) => ``).join('')}
${list.length ? `
${list.map(taskCard).join('')}
` : empty(tr("ui.no_work_here_yet"), tr("ui.add_a_project_goal_to_make_progress"), true)}
${icon('shield')}${tr("ui.completed_work_is_removed_automatically_approvals_wait")}
`; + } + function taskCard(task) { + return taskCardHtml(task, state.settings); + } + function flowPanel() { return `

${tr("ui.continuation_policy")}

${state.settings.enabled ? tr("ui.enabled") : tr("ui.paused")}
${icon('chart')}
${tr("ui.monitor_quota")}

${tr("ui.every")}${state.settings.pollSeconds}${tr("ui.sec_with_live_updates")}

${icon('clock')}
${tr("ui.preserve_work_when_quota_is_low")}

${tr("ui.save_the_chat_and_wait_for_all")}

${icon('play')}
${tr("ui.resume_according_to_work_mode")}

${tr("ui.resume_at")}${state.settings.minimumRemaining}${tr("ui.resume_percent_suffix")}

${tr("ui.direct_work_stops_when_completed_blocked_or")}${state.settings.maxRounds}${tr("ui.rounds_original_chats")}${esc(wakeExplanation)}${tr("ui.each_record_can_send_up_to")}${state.settings.maxWakeSends}${tr("ui.messages")}

`; } + function recentPanel() { return `

${tr("ui.recent_activity")}

${state.logs.slice(0, 3).map(log => `

${esc(systemText(log))}

`).join('') || `

${tr("ui.activity_will_appear_here_after_your_first")}

`}
`; } + function chart() { + const data = state.history.map(h => ({ time: h.time, value: h.values.find(v => v.id === 'codex')?.used ?? h.values[0]?.used })).filter(h => Number.isFinite(h.value)); + if (data.length < 2) return `

${tr("ui.quota_usage_trend")}

${tr("ui.the_trend_appears_after_at_least_two")}
`; + const width = 900, start = data[0].time, duration = data.at(-1).time - start || 1; + const points = data.map(d => `${((d.time - start) / duration * width).toFixed(2)},${(104 - Math.max(0, Math.min(100, d.value)) * .92).toFixed(2)}`).join(' '); + return `

${tr("ui.quota_usage_trend")}

${tr("ui.short_term_quota_usage")}
${[12, 58, 104].map((y, i) => `${100 - i * 50}%`).join('')}
${time(start)}${time(start + duration / 2)}${time(data.at(-1).time)}
`; + } + function alerts() { + let html = storageNotice(state, esc); + if (state.demo) html += `
${icon('info')}${tr("ui.demo_simulated_quota_and_work_no_real")}
`; + if (state.connection.status !== 'connected') html += `
${icon('info')} ${esc(systemText(state.connection))}${tr("ui.run_codex_login_confirm_cli_is_available")}
`; + for (const a of approvalDetails.readAll(state.approvals)) html += `

${tr("ui.approval_required")}

${esc(a.reason)}

${esc(a.command || a.method)}
+${esc(a.cwd)}
${a.requiresDetails ? `

${tr("ui.full_approval_details_are_not_loaded_refresh")}

` : ''}
`; + return html; + } + function settings() { + const s = state.settings; + const intervals = [...new Set([10, 15, 30, 60, 90, s.pollSeconds])].sort((a, b) => a - b); + const intervalOptions = intervals.map(n => ``).join(''); + const connected = state.connection.status === 'connected'; + const autostart = state.diagnostics?.autostart; + const watcher = autostart?.watcherRunning ? tr("ui.watcher_running") : autostart?.enabled === false ? tr("ui.off") : tr("ui.watcher_stopped_or_status_unknown"); + return `${heading(tr("ui.settings"), tr("ui.set_when_and_how_your_work_resumes"))} +
+
+
+

${tr("ui.scheduling")}

${tr("ui.when_off_no_new_turns_start_current")}

+ ${toggle(s.enabled, 'scheduler', tr("ui.scheduling"))} +
+
+

${tr("ui.quota_sync_interval")}

${tr("ui.seconds_with_live_updates_and_periodic_checks")}

+ +
+
+

${tr("ui.resume_threshold")}

${tr("ui.all_returned_quota_windows_must_meet_this")}

+
%
+
+
+

${tr("ui.direct_work_turn_limit")}

${tr("ui.applies_to_new_work_resume_manually_after")}

+
${tr("ui.turns")}
+
+
+

${tr("ui.original_chat_message_limit")}

${tr("ui.counts_are_cumulative_per_record_and_survive")}

+
${tr("ui.messages_2")}
+
+
+

${tr("ui.local_connection")}

${tr("ui.uses_local_codex_sign_in_and_the")}
${tr("ui.work_runs_in_the_project_directory_sandbox")}

+ ${connected ? tr("ui.connected") : tr("ui.disconnected")} +
+
+
+
+

${tr("ui.the_local_service_must_stay_running_work")}${watcher}。
${tr("ui.build")}${esc(state.buildId || '')}${tr("ui.protocol")}${state.protocolVersion} ·

`; + } + function render() { + if (!state || document.hidden) return; + const languageChanged = setLanguage(state.settings.language || 'zh'); applyStaticLanguage(); + if (languageChanged && modalRenderer && modal.open) refreshLocalizedContent($('#modal-body'), modalRenderer, true); + const draft = languageChanged && page === 'settings' && $('#settings-form') ? new FormData($('#settings-form')) : null; + $('#app-version').textContent = `v${state.version} · ${state.buildId.slice(0, 8)}`; + $('#nav-count').textContent = state.tasks.filter(t => t.status !== 'completed').length; + $('#breadcrumb-current').textContent = labels()[page]; + document.querySelectorAll('#nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page)); + const connected = state.connection.status === 'connected' && streamAlive; + $('#connection').textContent = !streamAlive ? tr("ui.local_service_disconnected") : state.demo ? tr("ui.demo_connection") : connected ? tr("ui.local_service_connected") : tr("ui.waiting_for_codex_connection"); + $('#connection').classList.toggle('off', !connected); + $('#footer-status').textContent = !streamAlive ? tr("ui.local_service_disconnected_reconnecting") : state.demo ? tr("ui.demo_simulated_data") : connected ? tr("ui.codex_app_server_connected") : tr("ui.waiting_for_codex_cli_connection"); + $('#footer-update').textContent = tr("ui.every_sec", { p0: state.settings.pollSeconds, p1: state.sampledAt ? tr("ui.updated_at", { p0: time(state.sampledAt) }) : tr("ui.quota_data_unknown") }); + // Preserve form input while background events arrive. + if (!languageChanged && page === 'settings' && $('#settings-form')) { + const control = $('#settings-form .switch'); + control.classList.toggle('on', state.settings.enabled); + control.setAttribute('aria-checked', String(state.settings.enabled)); + return; + } + let html; + if (page === 'overview') html = `${heading(tr("ui.work_overview"), tr("ui.track_your_quota_and_keep_projects_moving"), refreshButton() + newButton())}${alerts()}${banner()}${stats()}

${tr("ui.my_work")}${state.tasks.length}

${tasksPanel()}${chart()}
`; + else if (page === 'tasks') html = `${heading(tr("ui.work"), tr("ui.your_goals_progress_and_next_steps_in"), `${newButton()}`)}${alerts()}${tasksPanel(true)}`; + else if (page === 'quota') html = `${heading(tr("ui.quota_monitor"), tr("ui.track_account_quota_and_when_work_can"), refreshButton())}${alerts()}${allBuckets().length ? allBuckets().map(b => `

${esc(b.limitName || b.limitId || 'Codex')} ${esc(b.planType || tr("ui.account_quota"))}

${quotaCard(b.primary, tr("ui.primary_quota_window"), 'purple')}${quotaCard(b.secondary, tr("ui.secondary_quota_window"), 'green')}
`).join('
') : empty(tr("ui.quota_unavailable"), tr("ui.connect_a_signed_in_codex_cli_to"))}${chart()}

${tr("ui.reset_times_come_from_the_server_and")}

`; + else if (page === 'logs') html = `${heading(tr("ui.activity_log"), tr("ui.shows_the_latest_events_export_up_to"), ``)}
${state.logs.length ? `${state.logs.map(l => ``).join('')}
${tr("ui.time")}${tr("ui.type")}${tr("ui.activity")}
${new Date(l.time).toLocaleDateString(locale())} ${time(l.time)}${({ success: tr("ui.success"), warning: tr("ui.attention"), info: tr("ui.info") })[l.type]}${esc(systemText(l))}
` : empty(tr("ui.no_activity_yet"), tr("ui.scheduling_activity_appears_here_after_you_add"))}
`; + else html = settings(); + setHtml(main, html); + if (draft) for (const [name, value] of draft) { const input = $('#settings-form').elements.namedItem(name); if (input) input.value = value; } + if (modal.open && selectedTask) renderDetail(selectedTask); + } + function openModal(html) { modalRenderer = null; importList = null; selectedTask = null; $('#modal-body').innerHTML = html; if (!modal.open) modal.showModal(); } + function openLocalized(render) { openModal(''); modalRenderer = render; refreshLocalizedContent($('#modal-body'), render); } + function modalHead(title) { return ``; } + function newTask() { + openLocalized(() => modalHead(tr("ui.new_work")) + newTaskForm()); + } + function renderDetail(id) { + const summary = state.tasks.find(task => task.id === id); + if (!summary) { modal.close(); selectedTask = null; return; } + const task = taskDetails.read(summary); + $('#modal-body').innerHTML = modalHead(esc(task.name)) + taskDetailHtml(task, state.settings, { + canEdit: taskDetails.ready(summary), + }); + } + async function importModal(cursor = null, append = false) { + if (!append) { + const list = { data: [], nextCursor: null, loading: true, error: null }; + const render = () => `${modalHead(tr("ui.import_codex_chat"))}

${tr("ui.choose_a_local_chat_to_manage_monitoring")}

${list.loading && !list.data.length ? `
${tr("ui.loading_chats")}
` : list.data.map(t => `
${esc(t.name || t.preview || tr("ui.untitled_chat"))}${esc(t.cwd || t.id)}
`).join('') || `

${tr("ui.no_chats_available_to_import")}

`}
${list.nextCursor ? `` : ''}
`; + openLocalized(render); importList = { list, render }; + } + const current = importList; + if (!current) return; + current.list.loading = true; current.list.error = null; + refreshLocalizedContent($('#modal-body'), current.render); + try { + const result = await api(`/api/threads${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`); + if (modalRenderer !== current.render || !modal.open) return; + const remaining = 300 - (append ? current.list.data.length : 0); + current.list.data = [...(append ? current.list.data : []), ...result.data.slice(0, remaining)]; + current.list.nextCursor = result.nextCursor && remaining > result.data.length ? result.nextCursor : null; + } catch (error) { current.list.error = error; } + finally { + current.list.loading = false; + if (modalRenderer === current.render && modal.open) refreshLocalizedContent($('#modal-body'), current.render); + } + } + document.addEventListener('click', async event => { + const button = event.target.closest('button'); if (!button) return; + if (button.dataset.page) { page = button.dataset.page; render(); return; } + if (button.dataset.filter) { filter = button.dataset.filter; render(); return; } + const { action, id, token } = button.dataset; if (!action) return; + try { + if (action === 'close') { modal.close(); selectedTask = null; return; } + if (action === 'new') { newTask(); return; } + if (action === 'health') { const health = await api('/api/health'); openLocalized(() => `${modalHead(tr("ui.diagnostics"))}
${esc(JSON.stringify(health, null, 2))}
`); return; } + if (action === 'detail') { openModal(''); selectedTask = id; renderDetail(id); return; } + if (action === 'edit-message') { + const summary = state.tasks.find(task => task.id === id), task = summary && taskDetails.read(summary); + if (!task || task.mode !== 'quota-wake') throw Error(tr("ui.only_monitored_chats_support_editing_continuation_messages")); + if (!taskDetails.ready(summary)) throw Error(tr("ui.wait_for_the_full_work_details_to")); + openLocalized(() => `${modalHead(tr("ui.edit_continuation_message"))}${continuationEditor(task, esc)}`); return; + } + if (action === 'import') { await importModal(); return; } + if (action === 'more-threads') { await importModal(button.dataset.cursor, true); return; } + if (action === 'choose-thread') { + openLocalized(() => modalHead(tr("ui.monitor_original_chat_quota")) + monitorImportForm(id)); + return; + } + if (action === 'export') { const logs = await api('/api/logs'); const blob = new Blob([JSON.stringify(logs.logs, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'continuer-logs.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; } + button.disabled = true; + if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } + else if (action === 'refresh') { state = await api('/api/refresh', {}); toast(state.connection.status === 'connected' ? tr("ui.quota_synced") : tr("ui.connection_failed_check_the_notice")); } + else if (action === 'scheduler') { state = await api('/api/settings', { enabled: !state.settings.enabled }); toast(state.settings.enabled ? tr("ui.scheduling_enabled") : tr("ui.scheduling_paused")); } + else if (action === 'continuous') { state = await api(`/api/tasks/${id}`, { action, value: !state.tasks.find(t => t.id === id).continuous }); } + else if (['pause', 'resume', 'complete'].includes(action)) { const wake = state.tasks.find(t => t.id === id)?.mode === 'quota-wake'; state = await api(`/api/tasks/${id}`, { action }); toast(wake ? (action === 'resume' ? tr("ui.monitoring_enabled") : action === 'complete' ? tr("ui.monitoring_ended_and_removed") : tr("ui.monitoring_paused")) : (action === 'resume' ? tr("ui.work_queued") : action === 'complete' ? tr("ui.marked_complete") : tr("ui.pause_requested"))); } + else if (action === 'approve' || action === 'decline') { if (action === 'approve' && !approvalDetails.ready(state.approvals.find(item => String(item.id) === id && item.detailToken === token))) throw Error(tr("ui.wait_for_the_full_approval_details_to")); state = await api('/api/approval', { id, token, decision: action === 'approve' ? 'accept' : 'decline' }); } + else if (action === 'exhaust' || action === 'recover') { state = await api('/api/demo/quota', { exhausted: action === 'exhaust' }); toast(action === 'exhaust' ? tr("ui.simulated_quota_exhausted") : tr("ui.simulated_quota_recovered")); } + render(); + } catch (error) { toast(systemText(error)); } finally { button.disabled = false; } + }); + document.addEventListener('submit', async event => { + const form = event.target; event.preventDefault(); + const values = Object.fromEntries(new FormData(form)); + const submit = form.querySelector('[type="submit"],button.primary'); submit.disabled = true; + try { + if (form.id === 'task-form') { state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); modal.close(); toast(tr("ui.work_created")); } + else if (form.id === 'import-form') { state = await api('/api/import', { ...values, threadId: form.dataset.id }); modal.close(); toast(tr("ui.chat_imported_monitoring_stays_paused")); } + else if (form.id === 'message-form') { state = await api(`/api/tasks/${encodeURIComponent(form.dataset.id)}`, { action: 'message', value: values.continuationMessage }); taskDetails.clear(); modal.close(); toast(tr("ui.continuation_message_saved")); } + else if (form.id === 'settings-form') { state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); document.activeElement?.blur(); toast(tr("ui.settings_saved")); } + render(); + } catch (error) { const errorEl = form.querySelector('.form-error'); if (errorEl) setFormError(errorEl, error); else toast(systemText(error)); } + finally { submit.disabled = false; } + }); + $('#language').addEventListener('change', async event => { const input = event.target; input.disabled = true; try { state = await api('/api/settings', { language: input.value }); render(); } catch (error) { toast(systemText(error)); applyStaticLanguage(); } finally { input.disabled = false; } }); + $('#theme').addEventListener('click', () => { document.body.classList.toggle('dark'); localStorage.setItem('continuer-theme', document.body.classList.contains('dark') ? 'dark' : 'light'); }); + if (localStorage.getItem('continuer-theme') === 'dark') document.body.classList.add('dark'); + modal.addEventListener('close', () => { if (modal.open) return; modalRenderer = null; importList = null; selectedTask = null; taskDetails.clear(); }); + const stream = new VisibleFeed({ ...(environment.createSource ? { create: environment.createSource } : {}), visible: () => !document.hidden, receive: data => { streamAlive = true; state = data; render(); }, error: () => { streamAlive = false; if (state) render(); else setHtml(main, `
${tr("ui.could_not_connect_to_the_local_service")}
`); } }); + document.addEventListener('visibilitychange', () => { stream.sync(); if (!document.hidden) render(); }); + window.addEventListener('pagehide', () => stream.close()); + window.addEventListener('pageshow', () => stream.sync()); + stream.sync(); } -document.addEventListener('click', async event => { - const button = event.target.closest('button'); if (!button) return; - if (button.dataset.page) { page = button.dataset.page; render(); return; } - if (button.dataset.filter) { filter = button.dataset.filter; render(); return; } - const { action, id, token } = button.dataset; if (!action) return; - try { - if (action === 'close') { modal.close(); selectedTask = null; return; } - if (action === 'new') { newTask(); return; } - if (action === 'health') { const health = await api('/api/health'); openModal(`${modalHead('运行诊断')}
${esc(JSON.stringify(health, null, 2))}
`); return; } - if (action === 'detail') { openModal(''); selectedTask = id; renderDetail(id); return; } - if (action === 'import') { await importModal(); return; } - if (action === 'more-threads') { await importModal(button.dataset.cursor, true); return; } - if (action === 'choose-thread') { openModal(`${modalHead('监测原聊天额度')}

导入后保持暂停。${esc(wakeExplanation)}

`); return; } - if (action === 'export') { const logs = await api('/api/logs'); const blob = new Blob([JSON.stringify(logs.logs, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'continuer-logs.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; } - button.disabled = true; - if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast('恢复完成,调度保持关闭'); } - else if (action === 'refresh') { state = await api('/api/refresh', {}); toast(state.connection.status === 'connected' ? '额度已同步' : '连接未成功,请查看提示'); } - else if (action === 'scheduler') { state = await api('/api/settings', { enabled: !state.settings.enabled }); toast(state.settings.enabled ? '自动调度已开启' : '自动调度已暂停'); } - else if (action === 'continuous') { state = await api(`/api/tasks/${id}`, { action, value: !state.tasks.find(t => t.id === id).continuous }); } - else if (['pause', 'resume', 'complete'].includes(action)) { const wake = state.tasks.find(t => t.id === id)?.mode === 'quota-wake'; state = await api(`/api/tasks/${id}`, { action }); toast(wake ? (action === 'resume' ? '额度监测已开启' : action === 'complete' ? '监测已结束并移除' : '额度监测已暂停') : (action === 'resume' ? '工作已加入队列' : action === 'complete' ? '已标记完成' : '已请求暂停')); } - else if (action === 'approve' || action === 'decline') { if (action === 'approve' && !approvalDetails.ready(state.approvals.find(item => String(item.id) === id && item.detailToken === token))) throw Error('请先等待完整授权内容加载'); state = await api('/api/approval', { id, token, decision: action === 'approve' ? 'accept' : 'decline' }); } - else if (action === 'exhaust' || action === 'recover') { state = await api('/api/demo/quota', { exhausted: action === 'exhaust' }); toast(action === 'exhaust' ? '模拟额度已耗尽' : '模拟额度已恢复'); } - render(); - } catch (error) { toast(error.message); } finally { button.disabled = false; } -}); -document.addEventListener('submit', async event => { - const form = event.target; event.preventDefault(); - const values = Object.fromEntries(new FormData(form)); - const submit = form.querySelector('[type="submit"],button.primary'); submit.disabled = true; - try { - if (form.id === 'task-form') { state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); modal.close(); toast('工作已创建'); } - else if (form.id === 'import-form') { state = await api('/api/import', { ...values, threadId: form.dataset.id }); modal.close(); toast('会话已导入,监测保持暂停'); } - else if (form.id === 'settings-form') { state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); document.activeElement?.blur(); toast('设置已保存'); } - render(); - } catch (error) { const errorEl = form.querySelector('.form-error'); if (errorEl) errorEl.textContent = error.message; else toast(error.message); } - finally { submit.disabled = false; } -}); -$('#theme').addEventListener('click', () => { document.body.classList.toggle('dark'); localStorage.setItem('continuer-theme', document.body.classList.contains('dark') ? 'dark' : 'light'); }); -if (localStorage.getItem('continuer-theme') === 'dark') document.body.classList.add('dark'); -modal.addEventListener('close', () => { selectedTask = null; taskDetails.clear(); }); -const stream = new VisibleFeed({ visible: () => !document.hidden, receive: data => { streamAlive = true; state = data; render(); }, error: () => { streamAlive = false; if (state) render(); else setHtml(main, '
本地服务连接失败,正在自动重连。请确认 npm start 仍在运行。
'); } }); -document.addEventListener('visibilitychange', () => { stream.sync(); if (!document.hidden) render(); }); -window.addEventListener('pagehide', () => stream.close()); -window.addEventListener('pageshow', () => stream.sync()); -stream.sync(); + +if (typeof document !== 'undefined' && typeof window !== 'undefined') mountPage(); diff --git a/public/html.js b/public/html.js new file mode 100644 index 0000000..1bcb5e2 --- /dev/null +++ b/public/html.js @@ -0,0 +1,5 @@ +export function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]); +} diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..dbd8827 --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,76 @@ +import { message, formatMessage, isMessage, MessageError } from './message.js'; + +let language = 'zh'; +const listeners = new Set(); +export const getLanguage = () => language; +export const locale = () => language === 'en' ? 'en-US' : 'zh-CN'; +export const defaultContinuation = (value = 'zh') => value === 'en' ? 'Continue' : '继续'; +export function setLanguage(value) { + const next = value === 'en' ? 'en' : 'zh'; + if (next === language) return false; + language = next; + for (const listener of listeners) listener(next); + return true; +} +export function onLanguageChange(listener) { listeners.add(listener); return () => listeners.delete(listener); } +export function t(id, params = {}) { return formatMessage(message(id, params), language); } +export function interfaceText(id, selected = language) { return formatMessage(message(id), selected); } +export function uiError(id, params = {}) { return new MessageError(message(id, params), t(id, params)); } + +// Message ownership is explicit. User output and legacy strings stay untouched. +export function systemText(value, descriptor) { + const fallback = typeof value === 'string' ? value : value?.message ?? ''; + const info = descriptor || value?.messageInfo; + return isMessage(info) ? formatMessage(info, language) : fallback; +} +export function setFormError(element, value) { + const source = typeof value === 'string' ? { message: value } : value; + element.errorSource = source; + element.dataset.sourceMessage = source?.message || ''; + element.textContent = systemText(source); +} +export function refreshLocalizedContent(element, render, preserveErrors = false) { + const document = element.ownerDocument; + const draft = [...element.querySelectorAll('input[name],textarea[name],select[name]')].map(control => ({ + name: control.name, value: control.value, checked: control.checked, + focused: document?.activeElement === control, + start: control.selectionStart, end: control.selectionEnd, + })); + const container = element.closest('dialog') || element, scroll = container.scrollTop; + const errors = preserveErrors && draft.length ? [...element.querySelectorAll('.form-error')].map(node => node.errorSource || node.dataset.sourceMessage || node.textContent) : []; + element.innerHTML = render(); + for (const control of element.querySelectorAll('input[name],textarea[name],select[name]')) { + const saved = draft.find(item => item.name === control.name); + if (!saved) continue; + control.value = saved.value; control.checked = saved.checked; + if (saved.focused) { + control.focus({ preventScroll: true }); + if (Number.isInteger(saved.start)) control.setSelectionRange(saved.start, saved.end); + } + } + [...element.querySelectorAll('.form-error')].forEach((node, index) => { if (errors[index]) setFormError(node, errors[index]); }); + container.scrollTop = scroll; +} +// Only explicitly marked shell text and attributes participate in localization. +export function staticInterface(document) { + const entries = []; + for (const element of document.querySelectorAll('[data-i18n]')) { + const node = [...element.childNodes].find(child => child.nodeType === 3 && child.nodeValue.trim()); + if (node) entries.push({ node, id: element.getAttribute('data-i18n') }); + } + for (const attribute of ['title', 'aria-label', 'placeholder']) { + for (const node of document.querySelectorAll('[data-i18n-' + attribute + ']')) { + entries.push({ node, attribute, id: node.getAttribute('data-i18n-' + attribute) }); + } + } + return () => { + document.documentElement.lang = locale(); + for (const { node, attribute, id } of entries) { + if (!node.isConnected) continue; + if (attribute) node.setAttribute(attribute, t(id)); + else node.nodeValue = t(id); + } + const select = document.querySelector('#language'); + if (select) select.value = language; + }; +} diff --git a/public/icons.js b/public/icons.js new file mode 100644 index 0000000..8195c4c --- /dev/null +++ b/public/icons.js @@ -0,0 +1,22 @@ +const paths = { + overview: '', + tasks: '', + chart: '', + logs: '', + settings: '', + shield: '', + sun: '', + refresh: '', + plus: '', + arrow: '', + play: '', + pause: '', + clock: '', + bolt: '', + folder: '', + check: '', + close: '', + info: '', + import: '', +}; +export const icon = name => ``; diff --git a/public/index.html b/public/index.html index ff69b93..e71af2e 100644 --- a/public/index.html +++ b/public/index.html @@ -1,24 +1,24 @@ -Codex Continuer · 让工作持续向前 +Codex Continuer · 让工作持续向前
-
正在连接
-
正在载入工作空间…
-
正在连接本地服务额度数据正在同步
+
正在连接
+
正在载入工作空间…
+
正在连接本地服务额度数据正在同步
diff --git a/public/message.js b/public/message.js new file mode 100644 index 0000000..eb89dee --- /dev/null +++ b/public/message.js @@ -0,0 +1,71 @@ +import { messages } from './messages.js'; +import { systemMessages } from './system-messages.js'; + +export const messageCatalog = Object.freeze({ ...messages, ...systemMessages }); +const slots = template => [...template.matchAll(/\{([A-Za-z]\w*)\}/g)].map(match => match[1]); +const parameters = new Map(Object.entries(messageCatalog).map(([id, entry]) => [id, new Set(slots(entry.zh))])); + +export function isMessage(value, depth = 0) { + if (!value || typeof value !== 'object' || Array.isArray(value) || depth > 3 || !parameters.has(value.id)) return false; + const params = value.params === undefined ? {} : value.params, required = parameters.get(value.id); + return !!params && typeof params === 'object' && !Array.isArray(params) + && [...required].every(key => Object.hasOwn(params, key)) + && Object.keys(params).every(key => required.has(key)) + && Object.values(params).every(item => item === null || ['string', 'boolean'].includes(typeof item) + || (typeof item === 'number' && Number.isFinite(item)) || isMessage(item, depth + 1)); +} + +export function message(id, params = {}) { + const value = { id, params: Object.fromEntries(Object.entries(params).map(([key, item]) => [key, item === undefined ? 'undefined' : item])) }; + if (!isMessage(value)) throw new Error('Invalid message contract: ' + id); + return value; +} + +export function formatMessage(value, language = 'zh') { + if (!isMessage(value)) throw new Error('Invalid message contract'); + const entry = messageCatalog[value.id], template = language === 'en' ? entry.en : entry.zh; + return template.replace(/\{([A-Za-z]\w*)\}/g, (_slot, key) => { + const part = value.params?.[key]; + return isMessage(part) ? formatMessage(part, language) : String(part); + }); +} + +// Only declared parameters survive serialization; text limits apply recursively. +export function boundedMessage(value, limit = 2600) { + if (!isMessage(value)) return undefined; + return { id: value.id, params: Object.fromEntries(Object.entries(value.params || {}).map(([key, item]) => [key, + typeof item === 'string' ? item.slice(0, limit) : isMessage(item) ? boundedMessage(item, limit) : item, + ])) }; +} + +export class MessageError extends Error { + constructor(value, fallback) { + const info = boundedMessage(value); + super(fallback ?? (info ? formatMessage(info) : String(value ?? ''))); + if (info) this.messageInfo = info; + } +} + +export function messageText(value) { + return isMessage(value) ? formatMessage(value) : String(value?.message ?? value ?? ''); +} + +export function setActivity(task, value) { + const info = boundedMessage(isMessage(value) ? value : value?.messageInfo); + task.activity = messageText(value).slice(0, 2400); + if (info) task.activityMessage = info; + else delete task.activityMessage; +} + +export function activityFields(value) { const fields = {}; setActivity(fields, value); return fields; } +export function messageFields(value) { + const info = boundedMessage(isMessage(value) ? value : value?.messageInfo); + return { message: messageText(value), ...(info ? { messageInfo: info } : {}) }; +} +export function errorResponse(value) { + const fields = messageFields(value); + return { error: fields.message, ...(fields.messageInfo ? { errorInfo: fields.messageInfo } : {}) }; +} +export function taskActivityMessage(task) { + return message('log.task_activity', { name: task.name, activity: isMessage(task.activityMessage) ? task.activityMessage : task.activity }); +} diff --git a/public/messages.js b/public/messages.js new file mode 100644 index 0000000..2812804 --- /dev/null +++ b/public/messages.js @@ -0,0 +1,1359 @@ +// Stable IDs own both translations; literal source text is never a lookup key. +export const messages = Object.freeze({ + "ui.request_failed": { + "zh": "请求失败", + "en": "Request failed" + }, + "ui.overview": { + "zh": "概览", + "en": "Overview" + }, + "ui.work": { + "zh": "工作任务", + "en": "Work" + }, + "ui.quota_monitor": { + "zh": "额度监测", + "en": "Quota monitor" + }, + "ui.activity_log": { + "zh": "活动日志", + "en": "Activity log" + }, + "ui.settings": { + "zh": "设置", + "en": "Settings" + }, + "ui.just_now": { + "zh": "刚刚", + "en": "Just now" + }, + "ui.min_ago": { + "zh": "{p0} 分钟前", + "en": "{p0} min ago" + }, + "ui.h_ago": { + "zh": "{p0} 小时前", + "en": "{p0} h ago" + }, + "ui.reset_time_unknown": { + "zh": "恢复时间未知", + "en": "Reset time unknown" + }, + "ui.waiting_for_fresh_quota_confirmation": { + "zh": "等待额度刷新确认", + "en": "Waiting for fresh quota confirmation" + }, + "ui.until_reset": { + "zh": "{p0}后重置", + "en": "{p0} until reset" + }, + "ui.d_h": { + "zh": "{p0} 天 {p1} 小时", + "en": "{p0} d {p1} h" + }, + "ui.h_min": { + "zh": "{p0} 小时 {p1} 分钟", + "en": "{p0} h {p1} min" + }, + "ui.min": { + "zh": "{p0} 分钟", + "en": "{p0} min" + }, + "ui.new_work": { + "zh": "新建工作", + "en": "New work" + }, + "ui.refresh_quota": { + "zh": "刷新额度", + "en": "Refresh quota" + }, + "ui.unknown": { + "zh": "数据未知", + "en": "Unknown" + }, + "ui.remaining": { + "zh": "% 剩余", + "en": "% remaining" + }, + "ui.waiting_for_account_data": { + "zh": "等待账户数据", + "en": "Waiting for account data" + }, + "ui.used": { + "zh": "已用 {p0}%", + "en": "Used {p0}%" + }, + "ui.day_quota": { + "zh": "{p0} 天额度", + "en": "{p0} day quota" + }, + "ui.hour_quota": { + "zh": "{p0} 小时额度", + "en": "{p0} hour quota" + }, + "ui.short_term_quota": { + "zh": "短期额度", + "en": "Short-term quota" + }, + "ui.long_term_quota": { + "zh": "长期额度", + "en": "Long-term quota" + }, + "ui.active_work": { + "zh": "进行中的工作", + "en": "Active work" + }, + "ui.work_items": { + "zh": " 个工作", + "en": " work items" + }, + "ui.working_toward_project_goals": { + "zh": "正在推进项目目标", + "en": "Working toward project goals" + }, + "ui.ready_for_the_next_step": { + "zh": "准备好继续下一步", + "en": "Ready for the next step" + }, + "ui.runs_one_at_a_time": { + "zh": "串行执行", + "en": "Runs one at a time" + }, + "ui.continuous_work_enabled": { + "zh": "持续工作已开启", + "en": "Continuous work enabled" + }, + "ui.work_items_2": { + "zh": "个工作", + "en": "work items" + }, + "ui.resumes_after_quota_recovery": { + "zh": "额度恢复后自动接续", + "en": "Resumes after quota recovery" + }, + "ui.keep_work_moving": { + "zh": "让工作持续向前", + "en": "Keep work moving" + }, + "ui.scheduling_paused": { + "zh": "自动调度已暂停", + "en": "Scheduling paused" + }, + "ui.monitor_available_quota_and_resume_work_when": { + "zh": "监测可用额度,在合适的时刻接续每一项工作。", + "en": "Monitor available quota and resume work when ready." + }, + "ui.current_turns_continue_new_turns_wait_until": { + "zh": "当前轮次继续执行,新轮次将等待你重新开启调度。", + "en": "Current turns continue. New turns wait until scheduling is enabled." + }, + "ui.scheduling": { + "zh": "自动调度", + "en": "Scheduling" + }, + "ui.all_work": { + "zh": "全部工作", + "en": "All work" + }, + "ui.continuous_work": { + "zh": "持续工作", + "en": "Continuous work" + }, + "ui.no_work_here_yet": { + "zh": "这里还没有工作", + "en": "No work here yet" + }, + "ui.add_a_project_goal_to_make_progress": { + "zh": "添加项目目标,让每一次额度都用在进展上。", + "en": "Add a project goal to make progress with available quota." + }, + "ui.completed_work_is_removed_automatically_approvals_wait": { + "zh": "已完成的工作自动清除;需授权时等待你的决定", + "en": "Completed work is removed automatically; approvals wait for you" + }, + "ui.continuation_policy": { + "zh": "持续工作策略", + "en": "Continuation policy" + }, + "ui.enabled": { + "zh": "已启用", + "en": "Enabled" + }, + "ui.paused": { + "zh": "已暂停", + "en": "Paused" + }, + "ui.monitor_quota": { + "zh": "持续监测额度", + "en": "Monitor quota" + }, + "ui.every": { + "zh": "每 ", + "en": "Every " + }, + "ui.sec_with_live_updates": { + "zh": " 秒同步,实时接收更新", + "en": " sec, with live updates" + }, + "ui.preserve_work_when_quota_is_low": { + "zh": "额度不足,保留现场", + "en": "Preserve work when quota is low" + }, + "ui.save_the_chat_and_wait_for_all": { + "zh": "保存会话,等待所有额度窗口恢复", + "en": "Save the chat and wait for all quota windows to recover" + }, + "ui.resume_according_to_work_mode": { + "zh": "恢复后,按模式接续", + "en": "Resume according to work mode" + }, + "ui.resume_at": { + "zh": "剩余额度 ≥ ", + "en": "Resume at ≥ " + }, + "ui.resume_percent_suffix": { + "zh": "% 时自动续作", + "en": "% " + }, + "ui.direct_work_stops_when_completed_blocked_or": { + "zh": "直接执行:完成、阻塞或达到 ", + "en": "Direct work stops when completed, blocked, or after " + }, + "ui.rounds_original_chats": { + "zh": " 轮时停止。原聊天:", + "en": " rounds. Original chats: " + }, + "ui.each_record_can_send_up_to": { + "zh": "每条记录累计最多发送 ", + "en": "Each record can send up to " + }, + "ui.messages": { + "zh": " 次。", + "en": " messages." + }, + "ui.adjust_policy": { + "zh": "调整工作策略 ", + "en": "Adjust policy " + }, + "ui.recent_activity": { + "zh": "最近活动", + "en": "Recent activity" + }, + "ui.all": { + "zh": "全部 ", + "en": "All " + }, + "ui.activity_will_appear_here_after_your_first": { + "zh": "开始第一项工作后,活动会记录在这里。", + "en": "Activity will appear here after your first work item." + }, + "ui.quota_usage_trend": { + "zh": "额度使用趋势", + "en": "Quota usage trend" + }, + "ui.the_trend_appears_after_at_least_two": { + "zh": "收集到至少两个额度样本后显示趋势", + "en": "The trend appears after at least two samples" + }, + "ui.short_term_quota_usage": { + "zh": "短期额度 · 已使用比例", + "en": "Short-term quota · Usage" + }, + "ui.recent_quota_usage_trend": { + "zh": "近期已使用额度趋势", + "en": "Recent quota usage trend" + }, + "ui.demo_simulated_quota_and_work_no_real": { + "zh": "演示模式 · 模拟额度与任务,不执行真实项目", + "en": "Demo · Simulated quota and work; no real projects run" + }, + "ui.simulate_exhaustion": { + "zh": "模拟耗尽", + "en": "Simulate exhaustion" + }, + "ui.simulate_recovery": { + "zh": "模拟恢复", + "en": "Simulate recovery" + }, + "ui.run_codex_login_confirm_cli_is_available": { + "zh": "。请先在终端运行 codex login,确认 CLI 可用后刷新。额度未知时不会自动执行。", + "en": ". Run codex login, confirm CLI is available, then refresh. Unknown quota prevents automatic execution." + }, + "ui.approval_required": { + "zh": "操作等待授权", + "en": "Approval required" + }, + "ui.full_approval_details_are_not_loaded_refresh": { + "zh": "完整授权内容尚未加载,请刷新重试;加载完成后可允许操作。", + "en": "Full approval details are not loaded. Refresh and review them before allowing the operation." + }, + "ui.allow_once": { + "zh": "允许本次", + "en": "Allow once" + }, + "ui.decline": { + "zh": "拒绝", + "en": "Decline" + }, + "ui.sec": { + "zh": " 秒", + "en": " sec" + }, + "ui.watcher_running": { + "zh": "监听器运行中", + "en": "Watcher running" + }, + "ui.off": { + "zh": "已关闭", + "en": "Off" + }, + "ui.watcher_stopped_or_status_unknown": { + "zh": "监听器未运行或状态未知", + "en": "Watcher stopped or status unknown" + }, + "ui.set_when_and_how_your_work_resumes": { + "zh": "按照你的工作节奏,安排每一次继续。", + "en": "Set when and how your work resumes." + }, + "ui.when_off_no_new_turns_start_current": { + "zh": "关闭后不再启动新轮次,正在进行的轮次会继续。", + "en": "When off, no new turns start. Current turns continue." + }, + "ui.quota_sync_interval": { + "zh": "额度同步间隔", + "en": "Quota sync interval" + }, + "ui.seconds_with_live_updates_and_periodic_checks": { + "zh": "10–90 秒,接收实时事件并定期校验;额度有效期仍为 90 秒。", + "en": "10–90 seconds, with live updates and periodic checks. Quota samples expire after 90 seconds." + }, + "ui.resume_threshold": { + "zh": "恢复阈值", + "en": "Resume threshold" + }, + "ui.all_returned_quota_windows_must_meet_this": { + "zh": "所有返回的额度窗口剩余比例达到此值,才允许启动。", + "en": "All returned quota windows must meet this remaining percentage before starting." + }, + "ui.direct_work_turn_limit": { + "zh": "直接执行轮次上限", + "en": "Direct-work turn limit" + }, + "ui.applies_to_new_work_resume_manually_after": { + "zh": "仅用于新建工作的自动执行;到限等待手动继续。", + "en": "Applies to new work. Resume manually after reaching the limit." + }, + "ui.turns": { + "zh": " 轮", + "en": " turns" + }, + "ui.original_chat_message_limit": { + "zh": "原聊天发送次数上限", + "en": "Original-chat message limit" + }, + "ui.counts_are_cumulative_per_record_and_survive": { + "zh": "每条监测记录累计计数,暂停或重新开启不清零。到限停止;提高上限后手动开启监测。", + "en": "Counts are cumulative per record and survive pausing. Monitoring stops at the limit; raise it and enable monitoring manually to resume." + }, + "ui.messages_2": { + "zh": " 次", + "en": " messages" + }, + "ui.local_connection": { + "zh": "本地连接", + "en": "Local connection" + }, + "ui.uses_local_codex_sign_in_and_the": { + "zh": "使用本机 Codex 登录状态与默认模型,凭据由 Codex CLI 管理。", + "en": "Uses local Codex sign-in and the default model. Credentials are managed by Codex CLI." + }, + "ui.work_runs_in_the_project_directory_sandbox": { + "zh": "工作在项目目录沙箱内进行;请求额外权限时需单独批准。", + "en": "Work runs in the project directory sandbox; extra permissions require approval." + }, + "ui.connected": { + "zh": "已连接", + "en": "Connected" + }, + "ui.disconnected": { + "zh": "未连接", + "en": "Disconnected" + }, + "ui.save_settings": { + "zh": "保存设置", + "en": "Save settings" + }, + "ui.the_local_service_must_stay_running_work": { + "zh": "本地服务需持续运行,关机或休眠期间不执行。关闭页面不影响后台服务;原生挂件提供系统托盘;随 Codex 启动:", + "en": "The local service must stay running; work pauses during shutdown or sleep. Closing the page keeps the service running. The native widget provides a system tray. Start with Codex : " + }, + "ui.build": { + "zh": "构建 ", + "en": "Build " + }, + "ui.protocol": { + "zh": " · 协议 ", + "en": " · Protocol " + }, + "ui.diagnostics": { + "zh": "运行诊断", + "en": "Diagnostics" + }, + "ui.local_service_disconnected": { + "zh": "本地服务断开", + "en": "Local service disconnected" + }, + "ui.demo_connection": { + "zh": "演示连接", + "en": "Demo connection" + }, + "ui.local_service_connected": { + "zh": "本地服务已连接", + "en": "Local service connected" + }, + "ui.waiting_for_codex_connection": { + "zh": "等待 Codex 连接", + "en": "Waiting for Codex connection" + }, + "ui.local_service_disconnected_reconnecting": { + "zh": "本地服务断开,正在重连", + "en": "Local service disconnected; reconnecting" + }, + "ui.demo_simulated_data": { + "zh": "演示模式 · 模拟数据", + "en": "Demo · Simulated data" + }, + "ui.codex_app_server_connected": { + "zh": "Codex App Server 已连接", + "en": "Codex App Server connected" + }, + "ui.waiting_for_codex_cli_connection": { + "zh": "等待连接 Codex CLI", + "en": "Waiting for Codex CLI connection" + }, + "ui.every_sec": { + "zh": "每 {p0} 秒同步 · {p1}", + "en": "Every {p0} sec · {p1}" + }, + "ui.updated_at": { + "zh": "更新于 {p0}", + "en": "Updated at {p0}" + }, + "ui.quota_data_unknown": { + "zh": "额度数据未知", + "en": "Quota data unknown" + }, + "ui.work_overview": { + "zh": "工作概览", + "en": "Work overview" + }, + "ui.track_your_quota_and_keep_projects_moving": { + "zh": "掌握每一份额度,让每一个项目稳步向前。", + "en": "Track your quota and keep projects moving." + }, + "ui.my_work": { + "zh": "我的工作 ", + "en": "My work " + }, + "ui.import_chat": { + "zh": "导入会话", + "en": "Import chat" + }, + "ui.your_goals_progress_and_next_steps_in": { + "zh": "目标、进度、下一步,在这里井然有序。", + "en": "Your goals, progress, and next steps in one place." + }, + "ui.track_account_quota_and_when_work_can": { + "zh": "同步账户可用额度,准确掌握下一次出发的时间。", + "en": "Track account quota and when work can resume." + }, + "ui.account_quota": { + "zh": "账户额度", + "en": "Account quota" + }, + "ui.primary_quota_window": { + "zh": "主要额度窗口", + "en": "Primary quota window" + }, + "ui.secondary_quota_window": { + "zh": "次要额度窗口", + "en": "Secondary quota window" + }, + "ui.quota_unavailable": { + "zh": "额度暂不可用", + "en": "Quota unavailable" + }, + "ui.connect_a_signed_in_codex_cli_to": { + "zh": "连接已登录的 Codex CLI 后显示真实额度。", + "en": "Connect a signed-in Codex CLI to see your quota." + }, + "ui.reset_times_come_from_the_server_and": { + "zh": "重置时间来自服务端,仅作参考。系统会读取恢复后的新样本,再决定是否继续工作。", + "en": "Reset times come from the server and are estimates. Fresh quota samples determine when work resumes." + }, + "ui.shows_the_latest_events_export_up_to": { + "zh": "显示最近 50 条活动;导出可获取最多 300 条。", + "en": "Shows the latest 50 events. Export up to 300." + }, + "ui.export_logs": { + "zh": "导出日志", + "en": "Export logs" + }, + "ui.time": { + "zh": "时间", + "en": "Time" + }, + "ui.type": { + "zh": "类型", + "en": "Type" + }, + "ui.activity": { + "zh": "活动", + "en": "Activity" + }, + "ui.success": { + "zh": "成功", + "en": "Success" + }, + "ui.attention": { + "zh": "关注", + "en": "Attention" + }, + "ui.info": { + "zh": "信息", + "en": "Info" + }, + "ui.no_activity_yet": { + "zh": "暂无活动记录", + "en": "No activity yet" + }, + "ui.scheduling_activity_appears_here_after_you_add": { + "zh": "添加工作后,调度记录将显示在这里。", + "en": "Scheduling activity appears here after you add work." + }, + "ui.close": { + "zh": "关闭", + "en": "Close" + }, + "ui.import_codex_chat": { + "zh": "导入 Codex 会话", + "en": "Import Codex chat" + }, + "ui.choose_a_local_chat_to_manage_monitoring": { + "zh": "选择需要管理的本机会话。导入后先保持暂停。", + "en": "Choose a local chat to manage. Monitoring stays paused after import." + }, + "ui.loading_chats": { + "zh": "正在读取会话…", + "en": "Loading chats…" + }, + "ui.untitled_chat": { + "zh": "未命名会话", + "en": "Untitled chat" + }, + "ui.select": { + "zh": "选择", + "en": "Select" + }, + "ui.no_chats_available_to_import": { + "zh": "没有可导入的会话", + "en": "No chats available to import" + }, + "ui.load_more": { + "zh": "加载更多", + "en": "Load more" + }, + "ui.only_monitored_chats_support_editing_continuation_messages": { + "zh": "仅原聊天监测支持编辑续作消息", + "en": "Only monitored chats support editing continuation messages." + }, + "ui.wait_for_the_full_work_details_to": { + "zh": "请等待完整工作详情加载后再编辑", + "en": "Wait for the full work details to load before editing." + }, + "ui.edit_continuation_message": { + "zh": "编辑续作消息", + "en": "Edit continuation message" + }, + "ui.monitor_original_chat_quota": { + "zh": "监测原聊天额度", + "en": "Monitor original chat quota" + }, + "ui.storage_recovered_scheduling_remains_off": { + "zh": "恢复完成,调度保持关闭", + "en": "Storage recovered; scheduling remains off" + }, + "ui.quota_synced": { + "zh": "额度已同步", + "en": "Quota synced" + }, + "ui.connection_failed_check_the_notice": { + "zh": "连接未成功,请查看提示", + "en": "Connection failed; check the notice" + }, + "ui.scheduling_enabled": { + "zh": "自动调度已开启", + "en": "Scheduling enabled" + }, + "ui.monitoring_enabled": { + "zh": "额度监测已开启", + "en": "Monitoring enabled" + }, + "ui.monitoring_ended_and_removed": { + "zh": "监测已结束并移除", + "en": "Monitoring ended and removed" + }, + "ui.monitoring_paused": { + "zh": "额度监测已暂停", + "en": "Monitoring paused" + }, + "ui.work_queued": { + "zh": "工作已加入队列", + "en": "Work queued" + }, + "ui.marked_complete": { + "zh": "已标记完成", + "en": "Marked complete" + }, + "ui.pause_requested": { + "zh": "已请求暂停", + "en": "Pause requested" + }, + "ui.wait_for_the_full_approval_details_to": { + "zh": "请先等待完整授权内容加载", + "en": "Wait for the full approval details to load" + }, + "ui.simulated_quota_exhausted": { + "zh": "模拟额度已耗尽", + "en": "Simulated quota exhausted" + }, + "ui.simulated_quota_recovered": { + "zh": "模拟额度已恢复", + "en": "Simulated quota recovered" + }, + "ui.work_created": { + "zh": "工作已创建", + "en": "Work created" + }, + "ui.chat_imported_monitoring_stays_paused": { + "zh": "会话已导入,监测保持暂停", + "en": "Chat imported; monitoring stays paused" + }, + "ui.continuation_message_saved": { + "zh": "续作消息已保存", + "en": "Continuation message saved" + }, + "ui.settings_saved": { + "zh": "设置已保存", + "en": "Settings saved" + }, + "ui.could_not_connect_to_the_local_service": { + "zh": "本地服务连接失败,正在自动重连。请确认 npm start 仍在运行。", + "en": "Could not connect to the local service. Reconnecting. Check that npm start is still running." + }, + "ui.waiting_to_sync": { + "zh": "待同步", + "en": "Waiting to sync" + }, + "ui.awaiting_refresh": { + "zh": "待刷新", + "en": "Awaiting refresh" + }, + "ui.d": { + "zh": "{p0}天 ", + "en": "{p0}d " + }, + "ui.data_could_not_be_saved_or_loaded": { + "zh": "数据保存或读取失败,自动调度已停止:", + "en": "Data could not be saved or loaded. Scheduling stopped: " + }, + "ui.retry_save_scheduling_stays_off": { + "zh": "重试保存(调度保持关闭)", + "en": "Retry save (scheduling stays off)" + }, + "ui.restore_backup_pause_all_records": { + "zh": "恢复上次备份(暂停全部记录)", + "en": "Restore backup (pause all records)" + }, + "ui.a_copy_of_the_current_file_is": { + "zh": "恢复会保留当前主文件副本;恢复后请先核对原聊天与项目状态。", + "en": "A copy of the current file is kept. Check your original chats and projects after restoring." + }, + "ui.read_only_mode_work_starts_only_after": { + "zh": "当前为查询模式,尚未恢复自动调度。开启调度或明确继续工作后才会执行。", + "en": "Read-only mode. Work starts only after you enable scheduling or explicitly resume work." + }, + "ui.enter_an_absolute_path_to_an_existing": { + "zh": "填写本机现有目录的绝对路径,Codex 将在此目录工作。", + "en": "Enter an absolute path to an existing local directory; Codex will work there." + }, + "ui.create_work": { + "zh": "创建工作 {p0}", + "en": "Create work {p0}" + }, + "ui.create_and_queue": { + "zh": "创建并加入队列", + "en": "Create and queue" + }, + "ui.cancel": { + "zh": "取消", + "en": "Cancel" + }, + "ui.give_your_project_a_clear_goal_continuer": { + "zh": "给项目一个明确目标,Continuer 帮你接续进展。", + "en": "Give your project a clear goal; Continuer helps you keep going." + }, + "ui.work_name": { + "zh": "工作名称\n ", + "en": "Work name\n " + }, + "ui.example_improve_knowledge_base_search": { + "zh": "例如:完善个人知识库搜索", + "en": "Example: Improve knowledge base search" + }, + "ui.example_finish_knowledge_base_search": { + "zh": "例如:完成个人知识库搜索", + "en": "Example: Finish knowledge base search" + }, + "ui.project_directory": { + "zh": "项目目录", + "en": "Project directory" + }, + "ui.work_goal": { + "zh": "工作目标", + "en": "Work goal" + }, + "ui.goal_and_acceptance_criteria": { + "zh": "目标与验收标准", + "en": "Goal and acceptance criteria" + }, + "ui.describe_the_desired_features_and_acceptance_criteria": { + "zh": "描述需要完成的功能与验收标准…", + "en": "Describe the desired features and acceptance criteria…" + }, + "ui.describe_the_desired_changes_and_how_to": { + "zh": "描述需要完成的功能和验证方式…", + "en": "Describe the desired changes and how to verify them…" + }, + "ui.keep_working_after_quota_recovers": { + "zh": "持续工作,额度恢复后自动继续\n ", + "en": "Keep working after quota recovers\n " + }, + "ui.queued_after_creation_work_starts_when_quota": { + "zh": "创建后加入队列;额度充足且调度开启时开始执行。", + "en": "Queued after creation. Work starts when quota is sufficient and scheduling is enabled." + }, + "ui.work_is_queued_after_creation_extra_permissions": { + "zh": "创建后进入执行队列。需要额外权限时会等待你确认。", + "en": "Work is queued after creation. Extra permissions require your approval." + }, + "ui.codex_chat": { + "zh": "Codex 对话", + "en": "Codex chat" + }, + "ui.add_to_my_work": { + "zh": "加入我的工作", + "en": "Add to my work" + }, + "ui.continuation_message_optional": { + "zh": "续作消息(可选)\n ", + "en": "Continuation message (optional)\n " + }, + "ui.leave_blank_to_send_continue_example_run": { + "zh": "留空发送:继续;例如:优先完成测试,遇到阻塞时说明原因", + "en": "Leave blank to send: Continue. Example: Run tests first; explain any blockers." + }, + "ui.sent_to_the_original_chat_after_each": { + "zh": "额度恢复时发送给原聊天;每次恢复使用此消息。", + "en": "Sent to the original chat after each quota recovery." + }, + "ui.monitoring_note_optional_local_only": { + "zh": "监测备注(可选,仅本地保存)\n ", + "en": "Monitoring note (optional, local only)\n " + }, + "ui.placeholder_example_project_to_resume": { + "zh": "placeholder=\"例如:待继续的项目名称\"", + "en": "placeholder=\"Example: Project to resume\"" + }, + "ui.monitoring_stays_paused_after_adding": { + "zh": "加入后保持暂停。", + "en": "Monitoring stays paused after adding. " + }, + "ui.monitoring_stays_paused_after_import": { + "zh": "导入后保持暂停。", + "en": "Monitoring stays paused after import. " + }, + "ui.after_confirmed_quota_exhaustion_and_recovery_the": { + "zh": "确认额度耗尽并恢复后,先检查原聊天最新回复;上次指令后已有完整回复则跳过,否则发送一次自定义续作消息(留空发送“继续”)。仍在回复或无法确认状态时等待重试;备注不发送,项目结果请在原聊天确认。", + "en": "After confirmed quota exhaustion and recovery, the latest reply is checked first. If the last instruction already received a complete reply, sending is skipped; otherwise one custom continuation message is sent (blank sends “Continue”). Active or unknown replies wait for a retry. Notes are not sent; check project results in the original chat." + }, + "ui.monitoring_note_local_only": { + "zh": "监测备注:{p0}(仅本地保存)", + "en": "Monitoring note: {p0} (local only)" + }, + "ui.not_provided": { + "zh": "未填写", + "en": "Not provided" + }, + "ui.queued_messages": { + "zh": "已排队发送 {p0} / {p1} 次", + "en": "Queued {p0} / {p1} messages" + }, + "ui.executed_turns": { + "zh": "已执行 {p0} / {p1} 轮", + "en": "Executed {p0} / {p1} turns" + }, + "ui.check_progress_in_the_original_chat": { + "zh": "进度请在原聊天查看", + "en": "Check progress in the original chat" + }, + "ui.pause_monitoring": { + "zh": "暂停监测", + "en": "Pause monitoring" + }, + "ui.pause": { + "zh": "暂停", + "en": "Pause" + }, + "ui.enable_monitoring": { + "zh": "开启监测", + "en": "Enable monitoring" + }, + "ui.continue": { + "zh": "继续", + "en": "Continue" + }, + "ui.end_monitoring_and_remove": { + "zh": "结束监测并移除", + "en": "End monitoring and remove" + }, + "ui.mark_complete": { + "zh": "标记完成", + "en": "Mark complete" + }, + "ui.continuation_message_optional_2": { + "zh": "续作消息(可选)", + "en": "Continuation message (optional)" + }, + "ui.leave_blank_to_use_the_default_message": { + "zh": "留空时按当前语言发送“继续”或“Continue”。", + "en": "Leave blank to use the default message for the current language." + }, + "ui.the_saved_message_is_used_for_future": { + "zh": "从下次发送起使用保存后的消息;已排队的消息不会被更改。", + "en": "The saved message is used for future sends. Already queued messages stay unchanged." + }, + "ui.save_message": { + "zh": "保存消息", + "en": "Save message" + }, + "ui.queued": { + "zh": "排队中", + "en": "Queued" + }, + "ui.waiting_for_quota": { + "zh": "等待额度", + "en": "Waiting for quota" + }, + "ui.starting": { + "zh": "准备中", + "en": "Starting" + }, + "ui.running": { + "zh": "进行中", + "en": "Running" + }, + "ui.pausing": { + "zh": "暂停中", + "en": "Pausing" + }, + "ui.needs_attention": { + "zh": "需要关注", + "en": "Needs attention" + }, + "ui.failed": { + "zh": "执行失败", + "en": "Failed" + }, + "ui.completed": { + "zh": "已完成", + "en": "Completed" + }, + "ui.awaiting_approval": { + "zh": "等待授权", + "en": "Awaiting approval" + }, + "ui.monitoring": { + "zh": "监测中", + "en": "Monitoring" + }, + "ui.waiting_for_delivery": { + "zh": "等待发送通道", + "en": "Waiting for delivery" + }, + "ui.waiting_for_chat": { + "zh": "等待会话", + "en": "Waiting for chat" + }, + "ui.no_plan": { + "zh": "无计划", + "en": "No plan" + }, + "ui.monitoring_note": { + "zh": "监测备注", + "en": "Monitoring note" + }, + "ui.see_details_for_the_full_note": { + "zh": "更多内容见详情", + "en": "See details for the full note" + }, + "ui.view_details": { + "zh": "查看详情", + "en": "View details" + }, + "ui.view_in_original_chat": { + "zh": "原聊天中查看", + "en": "View in original chat" + }, + "ui.details": { + "zh": "详情", + "en": "Details" + }, + "ui.continuation_message": { + "zh": "续作消息", + "en": "Continuation message" + }, + "ui.no_plan_yet": { + "zh": "· 暂无计划", + "en": "· No plan yet" + }, + "ui.plan_progress": { + "zh": "计划进度 ", + "en": "Plan progress " + }, + "ui.the_percentage_shows_completed_plan_steps_only": { + "zh": "计划百分比仅表示已完成步骤比例。", + "en": "The percentage shows completed plan steps only." + }, + "ui.only_the_end_of_long_output_is": { + "zh": "输出较长,仅保留末尾预览;完整内容请在原 Codex 会话查看。", + "en": "Only the end of long output is shown. View the full output in the original Codex chat." + }, + "ui.turn_output": { + "zh": "本轮输出", + "en": "Turn output" + }, + "ui.no_output_yet": { + "zh": "尚无输出", + "en": "No output yet" + }, + "ui.current_status": { + "zh": "当前状态:", + "en": "Current status: " + }, + "ui.chat": { + "zh": "会话:", + "en": "Chat: " + }, + "ui.rhine_could_not_load_animation": { + "zh": "Rhine 动画资源读取失败", + "en": "Rhine Could not load animation" + }, + "ui.rhine_invalid_animation": { + "zh": "Rhine 动画资源无效", + "en": "Rhine Invalid animation" + }, + "ui.chats_unavailable": { + "zh": "会话暂不可用", + "en": "Chats unavailable" + }, + "ui.choose_a_chat_to_manage": { + "zh": "选择当前要管理的对话…", + "en": "Choose a chat to manage…" + }, + "ui.loading_chats_2": { + "zh": "正在读取已有对话…", + "en": "Loading chats…" + }, + "ui.no_local_codex_chats": { + "zh": "暂无本地 Codex 对话", + "en": "No local Codex chats" + }, + "ui.view_added_work": { + "zh": "查看已加入的工作", + "en": "View added work" + }, + "ui.could_not_load_click_chats_to_retry": { + "zh": "读取失败:{p0},请点击「↻ 对话」重试。", + "en": "Could not load: {p0}, click “↻ Chats” to retry." + }, + "ui.no_project_directory": { + "zh": "未提供项目目录", + "en": "No project directory" + }, + "ui.updated": { + "zh": " · {p0} 更新", + "en": " · {p0} updated" + }, + "ui.choose_a_chat_sorted_by_most_recently": { + "zh": "按最近更新排序,选择你正在进行的对话。", + "en": "Choose a chat, sorted by most recently updated." + }, + "ui.waiting_for_recovery_confirmation": { + "zh": "等待恢复确认", + "en": "Waiting for recovery confirmation" + }, + "ui.d_h_until_reset": { + "zh": "{p0} 天 {p1} 小时后重置", + "en": "{p0} d {p1} h until reset" + }, + "ui.h_min_until_reset": { + "zh": "{p0} 小时 {p1} 分钟后重置", + "en": "{p0} h {p1} min until reset" + }, + "ui.min_until_reset": { + "zh": "{p0} 分钟后重置", + "en": "{p0} min until reset" + }, + "ui.unknown_2": { + "zh": "未知", + "en": "Unknown" + }, + "ui.d_2": { + "zh": "天", + "en": "d" + }, + "ui.hour": { + "zh": "五小时", + "en": "5-hour" + }, + "ui.day": { + "zh": "七天", + "en": "7-day" + }, + "ui.available_quota": { + "zh": "{p0}可用额度", + "en": "{p0} available quota" + }, + "ui.until_reset_2": { + "zh": "距离重置", + "en": "UNTIL RESET" + }, + "ui.click_to_expand_drag_to_move_ctrl": { + "zh": "{p0}。点击展开,拖动移动;Ctrl + 滚轮调整大小。", + "en": "{p0}. Click to expand; drag to move; Ctrl + scroll to resize." + }, + "ui.available_quota_time_until_reset": { + "zh": "{p0}可用额度 {p1}%,重置倒计时 {p2}", + "en": "{p0} available quota {p1}%, time until reset {p2}" + }, + "ui.waiting_for_quota_data": { + "zh": "{p0}额度数据待同步", + "en": "{p0}Waiting for quota data" + }, + "ui.synced": { + "zh": "{p0} 同步", + "en": "{p0} synced" + }, + "ui.waiting_to_sync_2": { + "zh": "等待同步", + "en": "Waiting to sync" + }, + "ui.widget_settings_were_not_saved": { + "zh": "挂件设置未保存:{p0}", + "en": "Widget settings were not saved: {p0}" + }, + "ui.local_service_disconnected_reconnecting_2": { + "zh": "本地服务断开,正在重连。", + "en": "Local service disconnected. Reconnecting." + }, + "ui.quota_data_is_stale_scheduling_waits_for": { + "zh": "额度样本已过期,等待刷新后再调度。", + "en": "Quota data is stale. Scheduling waits for fresh data." + }, + "ui.connected_codex_live_updates_periodic_sync": { + "zh": "已连接 Codex · 实时事件 + 定期同步", + "en": "Connected Codex · Live updates + periodic sync" + }, + "ui.resume_at_2": { + "zh": "剩余额度 ≥ {p0}% 时自动接续", + "en": "Resume at ≥ {p0}% " + }, + "ui.scheduling_paused_current_turn_continues": { + "zh": "调度已暂停 · 当前轮次仍会继续", + "en": "Scheduling paused · Current turn continues" + }, + "ui.no_work_yet": { + "zh": "还没有工作", + "en": "No work yet" + }, + "ui.add_a_chat_above_or_create_new": { + "zh": "选择上方对话加入管理,或新建工作。", + "en": "Add a chat above, or create new work." + }, + "ui.full_approval_details_are_not_loaded_refresh_2": { + "zh": "完整授权内容尚未加载,请刷新重试。", + "en": "Full approval details are not loaded. Refresh to retry." + }, + "ui.no_activity_yet_2": { + "zh": "暂无活动", + "en": "No activity yet" + }, + "ui.loading_local_chats": { + "zh": "正在读取本机会话…", + "en": "Loading local chats…" + }, + "ui.retry": { + "zh": "重试", + "en": "Retry" + }, + "ui.import_an_existing_chat": { + "zh": "导入已有会话", + "en": "Import an existing chat" + }, + "ui.work_details": { + "zh": "工作详情", + "en": "Work details" + }, + "ui.quota_sync_requested": { + "zh": "额度同步请求已完成", + "en": "Quota sync requested" + }, + "ui.saved": { + "zh": "已保存", + "en": "Saved" + }, + "ui.widget_position_reset": { + "zh": "挂件位置已重置", + "en": "Widget position reset" + }, + "ui.could_not_connect_to_the_local_service_2": { + "zh": "本地服务连接失败,正在重连…", + "en": "Could not connect to the local service. Reconnecting…" + }, + "ui.codex_continuer_keep_work_moving": { + "zh": "Codex Continuer · 让工作持续向前", + "en": "Codex Continuer · Keep work moving" + }, + "ui.codex_continuer_home": { + "zh": "Codex Continuer 首页", + "en": "Codex Continuer Home" + }, + "ui.workspace": { + "zh": "工作空间", + "en": "Workspace" + }, + "ui.preferences": { + "zh": "偏好设置", + "en": "Preferences" + }, + "ui.local_workspace": { + "zh": "本地工作空间", + "en": "Local workspace" + }, + "ui.data_stays_on_this_device": { + "zh": "数据保存在此设备", + "en": "Data stays on this device" + }, + "ui.loading_version": { + "zh": "读取版本…", + "en": "Loading version…" + }, + "ui.connecting": { + "zh": "正在连接", + "en": "Connecting" + }, + "ui.switch_language": { + "zh": "切换语言", + "en": "Switch language" + }, + "ui.language_chinese": { + "zh": "中文", + "en": "中文" + }, + "ui.switch_light_dark_theme": { + "zh": "切换深浅主题", + "en": "Switch light/dark theme" + }, + "ui.switch_theme": { + "zh": "切换主题", + "en": "Switch theme" + }, + "ui.loading_workspace": { + "zh": "正在载入工作空间…", + "en": "Loading workspace…" + }, + "ui.connecting_to_local_service": { + "zh": "正在连接本地服务", + "en": "Connecting to local service" + }, + "ui.syncing_quota_data": { + "zh": "额度数据正在同步", + "en": "Syncing quota data" + }, + "ui.continuer_codex_widget": { + "zh": "Continuer · Codex 挂件", + "en": "Continuer · Codex Widget" + }, + "ui.expand_quota_and_work_panel": { + "zh": "展开额度和工作面板", + "en": "Expand quota and work panel" + }, + "ui.click_to_expand_drag_to_move_ctrl_2": { + "zh": "点击卡片展开,拖动移动;Ctrl + 滚轮调整大小", + "en": "Click to expand; drag to move; Ctrl + scroll to resize" + }, + "ui.quota_and_reset_countdown": { + "zh": "额度与重置倒计时", + "en": "Quota and reset countdown" + }, + "ui.hour_available_quota": { + "zh": "五小时可用额度", + "en": "5-hour available quota" + }, + "ui.quota_monitor_continuous_work": { + "zh": "额度监测 · 持续工作", + "en": "Quota monitor · Continuous work" + }, + "ui.collapse_to_gauge": { + "zh": "收起为仪表", + "en": "Collapse to gauge" + }, + "ui.collapse": { + "zh": "收起", + "en": "Collapse" + }, + "ui.quit_continuer_and_stop_background_services": { + "zh": "退出 Continuer 并停止后台", + "en": "Quit Continuer and stop background services" + }, + "ui.quit_continuer": { + "zh": "退出 Continuer", + "en": "Quit Continuer" + }, + "ui.account_quota_2": { + "zh": " 账户额度", + "en": " Account quota" + }, + "ui.connecting_to_local_service_2": { + "zh": "正在连接本地服务…", + "en": "Connecting to local service…" + }, + "ui.auto_continue": { + "zh": "自动续作 ", + "en": "Auto continue " + }, + "ui.resume_work_after_quota_recovery": { + "zh": "额度恢复后接续工作", + "en": "Resume work after quota recovery" + }, + "ui.my_work_2": { + "zh": " 我的工作 ", + "en": " My work " + }, + "ui.chats": { + "zh": "↻ 对话", + "en": "↻ chats" + }, + "ui.refresh_chats": { + "zh": "刷新对话列表", + "en": "Refresh chats" + }, + "ui.new": { + "zh": "+ 新建", + "en": "+ New" + }, + "ui.select_a_codex_chat": { + "zh": "选择 Codex 对话", + "en": "Select a Codex chat" + }, + "ui.more_chats": { + "zh": "更多对话", + "en": "More chats" + }, + "ui.loading_work": { + "zh": "正在读取工作记录…", + "en": "Loading work…" + }, + "ui.policy_and_display": { + "zh": "工作策略与显示", + "en": "Policy and display" + }, + "ui.resume_threshold_2": { + "zh": "恢复阈值 ", + "en": "Resume threshold " + }, + "ui.sync_interval": { + "zh": "同步周期 ", + "en": "Sync interval " + }, + "ui.direct_work_turn_limit_2": { + "zh": "直接执行轮次上限 ", + "en": "Direct-work turn limit " + }, + "ui.original_chat_message_limit_2": { + "zh": "原聊天发送上限 ", + "en": "Original-chat message limit " + }, + "ui.follow_codex_window": { + "zh": "跟随 Codex 窗口 ", + "en": "Follow Codex window " + }, + "ui.widget_size": { + "zh": "桌面卡片大小 ", + "en": "Widget size " + }, + "ui.widget_size_2": { + "zh": "桌面卡片大小", + "en": "Widget size" + }, + "ui.reset_widget_position": { + "zh": "重置挂件位置", + "en": "Reset widget position" + }, + "ui.sync_every_seconds_direct_work_has_a": { + "zh": "同步周期为 10–90 秒。直接执行受轮次上限约束;原聊天按每条监测记录累计发送次数,到限停止。暂停、重新开启不会清零;提高上限后需手动开启。原聊天工作是否完成需自行确认。", + "en": "Sync every 10–90 seconds. Direct work has a turn limit. Original-chat messages are counted per record; monitoring stops at the limit. Pausing does not reset counts. Raise the limit and enable monitoring manually to resume. Check completion in the original chat." + }, + "ui.save_policy": { + "zh": "保存策略", + "en": "Save policy" + }, + "ui.saved_locally_runs_in_background": { + "zh": "本机保存 · 后台持续工作", + "en": "Saved locally · Runs in background" + }, + "ui.close_dialog": { + "zh": "关闭对话框", + "en": "Close dialog" + }, + "native.continuer_could_not_start": { + "zh": "Continuer 启动失败", + "en": "Continuer could not start" + }, + "native.select_project_directory": { + "zh": "选择工作项目目录", + "en": "Select project directory" + }, + "native.codex_continuer_quota_and_continuous_work": { + "zh": "Codex Continuer · 额度与持续工作", + "en": "Codex Continuer · Quota and continuous work" + }, + "native.open_quota_and_work_panel": { + "zh": "打开额度与工作面板", + "en": "Open quota and work panel" + }, + "native.follow_codex_window": { + "zh": "跟随 Codex 窗口", + "en": "Follow Codex window" + }, + "native.show_independently": { + "zh": "独立显示面板", + "en": "Show independently" + }, + "native.hide_to_tray": { + "zh": "隐藏到托盘", + "en": "Hide to tray" + } +}); diff --git a/public/quota-display.js b/public/quota-display.js index f89d0a9..6431968 100644 --- a/public/quota-display.js +++ b/public/quota-display.js @@ -1,3 +1,4 @@ +import { t as tr } from './i18n.js'; // Display only. A countdown reaching zero never grants permission to run work. export function quotaDisplay(state, connected = true, now = Date.now()) { if (!connected || state?.connection?.status !== 'connected' || !state?.sampledAt || now - state.sampledAt > 90000) return { remaining: null, resetsAt: null, windowMinutes: null }; @@ -9,12 +10,12 @@ export function quotaDisplay(state, connected = true, now = Date.now()) { return ordered[0]; } export function resetCountdown(resetsAt, now = Date.now()) { - if (!Number.isFinite(resetsAt) || resetsAt <= 0) return '待同步'; + if (!Number.isFinite(resetsAt) || resetsAt <= 0) return tr("ui.waiting_to_sync"); const seconds = Math.ceil((resetsAt * 1000 - now) / 1000); - if (seconds <= 0) return '待刷新'; + if (seconds <= 0) return tr("ui.awaiting_refresh"); const pad = n => String(n).padStart(2, '0'); const days = Math.floor(seconds / 86400); - return `${days ? `${days}天 ` : ''}${pad(Math.floor(seconds / 3600) % 24)}:${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`; + return `${days ? tr("ui.d", { p0: days }) : ''}${pad(Math.floor(seconds / 3600) % 24)}:${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`; } export function quotaWindow(state, slot = 'primary', connected = true, now = Date.now()) { if (!connected || state?.connection?.status !== 'connected' || !state?.sampledAt || now - state.sampledAt > 90000) return { remaining: null, resetsAt: null, windowMinutes: slot === 'primary' ? 300 : 10080 }; diff --git a/public/rhine-signal-loop.svg b/public/rhine-signal-loop.svg index d59b243..05f6b80 100644 --- a/public/rhine-signal-loop.svg +++ b/public/rhine-signal-loop.svg @@ -33,7 +33,7 @@ @keyframes rhine-signal-tail-travel{from{stroke-dashoffset:3}to{stroke-dashoffset:-97}} @keyframes rhine-signal-planet-arrival{0.0000%{transform:translate(39px,25px) scale(.02)}29.0000%{transform:translate(39px,25px) scale(.02)}30.1667%{transform:translate(39px,25px) scale(1)}100.0000%{transform:translate(39px,25px) scale(1)}} @keyframes rhine-signal-glimmer{0%,100%{transform:scale(.85);opacity:.7}50%{transform:scale(1.05);opacity:1}} -@keyframes rhine-signal-scan-cycle{0.000%,37.000%{opacity:0;transform:translateY(166px)}42.000%{opacity:.25}53.000%,100.000%{opacity:0;transform:translateY(95px)}} +@keyframes rhine-signal-scan-cycle{0.000%,31.6667%{opacity:0;transform:translateY(168px)}36.6667%{opacity:.25}47.6667%,100.000%{opacity:0;transform:translateY(142px)}} @keyframes rhine-signal-stars-cycle{0.000%,17.000%,65.000%,100.000%{opacity:.12}25.333%,54.500%{opacity:.35}} @media(prefers-reduced-motion:reduce){.rhine-signal .rhine-signal-motion{animation:none}} .rhine-signal-rim{filter:url(#rhine-signal-edge-glow)} @@ -47,8 +47,8 @@ .rhine-signal-data-line{clip-path:inset(0 100% 0 0);animation:rhine-signal-data-line 60s steps(18,end) infinite} .rhine-signal-exhaust,.rhine-signal-focus-light,.rhine-signal-glint{animation:rhine-signal-glint-reveal 60s linear infinite} .rhine-signal-launch{animation:rhine-signal-launch-core 60s linear infinite;filter:url(#rhine-signal-point-glow)} -@keyframes rhine-signal-data{0.0000%{opacity:0}36.3333%{opacity:0}37.3333%{opacity:1}48.5000%{opacity:1}49.6667%{opacity:0}100.0000%{opacity:0}} -@keyframes rhine-signal-data-line{0.0000%{clip-path:inset(0 100% 0 0)}36.5000%{clip-path:inset(0 100% 0 0)}38.5000%{clip-path:inset(0 0 66% 0)}41.6667%{clip-path:inset(0 0 33% 0)}45.0000%{clip-path:inset(0 0 0 0)}100.0000%{clip-path:inset(0 0 0 0)}} +@keyframes rhine-signal-data{0.0000%{opacity:0}31.0000%{opacity:0}32.0000%{opacity:1}48.5000%{opacity:1}49.6667%{opacity:0}100.0000%{opacity:0}} +@keyframes rhine-signal-data-line{0.0000%{clip-path:inset(0 100% 0 0)}31.1667%{clip-path:inset(0 100% 0 0)}33.1667%{clip-path:inset(0 0 66% 0)}36.3333%{clip-path:inset(0 0 33% 0)}39.6667%{clip-path:inset(0 0 0 0)}100.0000%{clip-path:inset(0 0 0 0)}} @keyframes rhine-signal-reflection{0%,100%{opacity:.62;transform:rotate(-3deg)}50%{opacity:1;transform:rotate(3deg)}} @keyframes rhine-signal-dust{0%,100%{opacity:.12}45%{opacity:.65}60%{opacity:.3}} @media(prefers-reduced-motion:reduce){.rhine-signal .rhine-signal-motion{animation:none}} @@ -59,7 +59,7 @@ .rhine-signal-planet-stage{opacity:0;animation:rhine-signal-planet-stage 60s linear infinite} @keyframes rhine-signal-wave-stage{0.0000%{opacity:1}21.6667%{opacity:1}23.6667%{opacity:0}63.5833%{opacity:0}64.2500%{opacity:1}100.0000%{opacity:1}} @keyframes rhine-signal-launch-stage{0.0000%{opacity:0}21.1667%{opacity:0}21.7500%{opacity:1}29.4167%{opacity:1}29.6667%{opacity:0}100.0000%{opacity:0}} -@keyframes rhine-signal-planet-stage{0.0000%{opacity:0}27.5000%{opacity:0}28.3333%{opacity:1}36.3333%{opacity:1}37.6667%{opacity:0}48.5000%{opacity:0}49.6667%{opacity:1}63.5000%{opacity:1}64.1667%{opacity:0}100.0000%{opacity:0}} +@keyframes rhine-signal-planet-stage{0.0000%{opacity:0}27.5000%{opacity:0}28.3333%{opacity:1}31.0000%{opacity:1}32.3333%{opacity:0}48.5000%{opacity:0}49.6667%{opacity:1}63.5000%{opacity:1}64.1667%{opacity:0}100.0000%{opacity:0}} @media(prefers-reduced-motion:reduce){.rhine-signal .rhine-signal-motion{animation:none}} .rhine-signal-instruments{animation:rhine-signal-instruments 60s linear infinite} @@ -132,7 +132,7 @@ - + diff --git a/public/storage-status.js b/public/storage-status.js index 5feaff4..c1710ef 100644 --- a/public/storage-status.js +++ b/public/storage-status.js @@ -1,5 +1,6 @@ +import { t as tr, systemText } from './i18n.js'; export function storageNotice(state, esc) { - const summary = state.summaryNotice ? `

${esc(state.summaryNotice)}

` : ''; - if (state.persistence?.status === 'error') return summary + `

数据保存或读取失败,自动调度已停止:${esc(state.persistence.message)}

${!state.persistence.needsRestore ? '' : ''}${state.persistence.backupAvailable ? '

恢复会保留当前主文件副本;恢复后请先核对原聊天与项目状态。

' : ''}
`; - return summary + (state.observationOnly ? '

当前为查询模式,尚未恢复自动调度。开启调度或明确继续工作后才会执行。

' : ''); + const summary = state.summaryNotice ? `

${esc(systemText(state.summaryNotice, state.summaryNoticeInfo))}

` : ''; + if (state.persistence?.status === 'error') return summary + `

${tr("ui.data_could_not_be_saved_or_loaded")}${esc(systemText(state.persistence))}

${!state.persistence.needsRestore ? `` : ''}${state.persistence.backupAvailable ? `

${tr("ui.a_copy_of_the_current_file_is")}

` : ''}
`; + return summary + (state.observationOnly ? `

${tr("ui.read_only_mode_work_starts_only_after")}

` : ''); } diff --git a/public/style.css b/public/style.css index 407324b..f306c36 100644 --- a/public/style.css +++ b/public/style.css @@ -16,3 +16,9 @@ body.dark{--bg:#20251f;--surface:#293027;--side:#242a21;--line:#3b4435;--muted:# .banner,body.dark .banner{background:var(--tint);border-color:var(--line)}.banner h3,body.dark .banner h3{color:var(--purple)}.banner p,body.dark .banner p{color:var(--muted)} nav>button.active,body.dark nav>button.active{background:var(--tint);color:var(--purple)}.banner-icon,body.dark .banner-icon{background:var(--surface);border-color:var(--line);color:var(--purple)}.card-bottom,body.dark .card-bottom{background:var(--side)}.count{background:var(--tint);color:var(--purple)} + +.monitor-note{margin:12px 0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font-size:11px;line-height:1.7;overflow-wrap:anywhere}.monitor-note strong{display:block;font-size:10px;opacity:.7;margin-bottom:4px}.monitor-note span{display:block;white-space:pre-wrap;max-height:10em;overflow:auto}.monitor-note small{display:block;margin-top:4px;opacity:.7} + +.detail-summary{white-space:pre-wrap} +#language{padding:6px 8px;font-size:12px} +.setting-row>div:last-child{flex-shrink:0;white-space:nowrap} diff --git a/public/system-messages.js b/public/system-messages.js new file mode 100644 index 0000000..3d196c7 --- /dev/null +++ b/public/system-messages.js @@ -0,0 +1,587 @@ +// Service-owned messages use stable IDs and explicit parameters. +export const systemMessages = Object.freeze({ + "runtime.widget_ready": { "zh": "挂件已就绪。可使用 Ctrl+Alt+C 展开。", "en": "The widget is ready. Press Ctrl+Alt+C to expand it." }, + "runtime.port_in_use": { "zh": "端口属于其他应用", "en": "Port belongs to another application" }, + "runtime.build_mismatch": { "zh": "运行服务与当前插件构建不一致,请先停止并重新启动 Continuer", "en": "The service and plugin builds differ. Stop and restart Continuer." }, + "runtime.directory_mismatch": { "zh": "该端口服务使用不同的数据目录,请检查路径后重新启动 Continuer", "en": "The service uses a different data directory. Check the paths before restarting Continuer." }, + "runtime.root_mismatch": { "zh": "端口服务使用不同的安装目录,请检查路径后重新启动 Continuer", "en": "The service uses a different installation directory. Check the paths before restarting Continuer." }, + "runtime.exited": { "zh": "Continuer 已退出,请先手动打开挂件。", "en": "Continuer has exited. Open the widget manually first." }, + "runtime.node_missing": { "zh": "缺少 Node.js 路径,请重新运行插件安装器", "en": "Node.js path is missing. Run the plugin installer again." }, + "runtime.service_timeout": { "zh": "本地服务启动失败,请查看数据目录中的 service-stderr.log", "en": "The local service did not become ready. Check service-stderr.log in the data directory." }, + "runtime.desktop_missing": { "zh": "缺少桌面运行时,请在项目目录运行 npm install 后重新构建插件", "en": "Desktop runtime is missing. Run npm install in the project directory and rebuild the plugin." }, + "runtime.widget_timeout": { "zh": "挂件未在时限内就绪,请查看 /api/health 和 widget-stderr.log", "en": "The widget did not become ready in time. Check /api/health and widget-stderr.log." }, + "connection.demo": { "zh": "演示数据 · 不执行真实任务", "en": "Demo data · No real tasks are run" }, + "connection.codex_connected": { "zh": "Codex App Server 已连接", "en": "Codex App Server connected" }, + "task.outcome_missing": { "zh": "本轮已结束,未收到明确的完成状态,请检查输出后继续。", "en": "This turn ended without a clear completion status. Check the output before continuing." }, + "error.project_missing": { "zh": "项目目录不存在", "en": "Project directory does not exist" }, + "error.operation_failed": { "zh": "操作失败,请检查本地服务状态后重试", "en": "Operation failed. Check the local service and retry." }, + "system.summary_truncated": { "zh": "列表内容较多,已精简历史和授权预览;完整内容请打开详情。", "en": "History and approval previews were shortened. Open details for the full content." }, + "log.task_activity": { + "zh": "{name}:{activity}", + "en": "{name}: {activity}" + }, + "task.no_tool_activity": { + "zh": "{summary}(本轮无工具活动,已停止自动续作)", + "en": "{summary} (No tool activity this turn; automatic continuation stopped)" + }, + "error.setting_range": { + "zh": "{name} 必须为 {min}–{max} 的整数", + "en": "{name} must be an integer between {min} and {max}" + }, + "monitor.send_limit": { + "zh": "已达到原聊天发送上限({sent}/{limit} 次),监测已停止;请查看原聊天确认工作结果", + "en": "Message limit reached ({sent}/{limit}). Monitoring stopped; check results in the original chat." + }, + "task.wait_writer_retry": { + "zh": "原会话被其他 Codex 客户端占用,30 秒后自动重试", + "en": "The original chat is in use by another Codex client. Retrying in 30 seconds." + }, + "task.wait_writer_manual": { + "zh": "原会话被其他 Codex 客户端占用,请等待原会话结束后再继续", + "en": "The original chat is in use by another Codex client. Wait for it to finish before continuing." + }, + "task.wait_thread_retry": { + "zh": "原会话仍在运行,30 秒后自动重试", + "en": "The original chat is still running. Retrying in 30 seconds." + }, + "task.wait_thread_manual": { + "zh": "原会话仍在运行,请等待原会话结束后再继续", + "en": "The original chat is still running. Wait for it to finish before continuing." + }, + "demo.search_ready": { + "zh": "全文索引已建立,等待继续验证搜索结果", + "en": "Full-text index built; continue to verify search results" + }, + "demo.components_ready": { + "zh": "已完成 Button 和 Input,等待继续", + "en": "Button and Input completed; waiting to continue" + }, + "demo.tests_ready": { + "zh": "演示记录:测试用例已补齐", + "en": "Demo record: test cases added" + }, + "demo.started": { + "zh": "演示模式已启动,所有额度与工作记录均为模拟数据", + "en": "Demo mode started. All quota and work records are simulated." + }, + "demo.work_complete": { + "zh": "API 服务 · 单元测试:模拟工作已完成", + "en": "API service · Unit tests: simulated work completed" + }, + "error.enter_a_name_project_directory_and_work_goal": { + "zh": "请填写名称、项目目录和工作目标", + "en": "Enter a name, project directory, and work goal." + }, + "error.name_or_goal_is_too_long": { + "zh": "名称或目标过长", + "en": "Name or goal is too long" + }, + "task.waiting_for_scheduling": { + "zh": "等待调度", + "en": "Waiting for scheduling" + }, + "task.service_restarted_confirm_the_original_chat_has_stopped": { + "zh": "服务重启,请确认原会话已停止后手动继续", + "en": "Service restarted. Confirm the original chat has stopped before resuming manually." + }, + "error.direct_state": { + "zh": "工作任务状态无效", + "en": "Invalid direct work state" + }, + "error.direct_rounds": { + "zh": "工作执行轮次无效", + "en": "Invalid execution round count" + }, + "error.direct_monitor_fields": { + "zh": "工作任务不能包含监测发送状态", + "en": "Direct work cannot contain monitoring delivery fields" + }, + "error.invalid_switch_value": { + "zh": "开关值无效", + "en": "Invalid switch value" + }, + "task.continuous_work_disabled": { + "zh": "持续工作已关闭", + "en": "Continuous work disabled" + }, + "task.pausing": { + "zh": "正在暂停", + "en": "Pausing" + }, + "task.work_paused": { + "zh": "工作已暂停", + "en": "Work paused" + }, + "error.work_is_still_running_or_stopping": { + "zh": "任务尚在运行或停止中", + "en": "Work is still running or stopping" + }, + "task.waiting_for_available_quota": { + "zh": "等待可用额度", + "en": "Waiting for available quota" + }, + "error.pause_the_work_first": { + "zh": "请先暂停任务", + "en": "Pause the work first" + }, + "task.user_marked_complete": { + "zh": "用户确认完成", + "en": "User marked complete" + }, + "error.unsupported_action": { + "zh": "不支持的操作", + "en": "Unsupported action" + }, + "task.backup_restored_check_the_original_chats_and_projects": { + "zh": "已恢复备份,请检查原聊天和项目状态后手动继续", + "en": "Backup restored. Check the original chats and projects before resuming manually." + }, + "error.invalid_utf8": { + "zh": "请求正文不是有效 UTF-8", + "en": "Request body is not valid UTF-8" + }, + "error.request_too_large": { + "zh": "请求过大", + "en": "Request is too large" + }, + "error.invalid_json": { + "zh": "请求正文不是有效 JSON", + "en": "Request body is not valid JSON" + }, + "error.json_object_required": { + "zh": "请求正文必须为 JSON 对象", + "en": "Request body must be a JSON object" + }, + "error.note_alias_conflict": { + "zh": "请仅提供监测备注 note;prompt 为旧版兼容字段", + "en": "Provide only note; prompt is a legacy compatibility field" + }, + "error.work_item_not_found": { + "zh": "任务不存在", + "en": "Work item not found" + }, + "error.stream_limit": { + "zh": "实时连接已达上限,请关闭多余页面", + "en": "Live connection limit reached. Close extra pages." + }, + "error.approval_expired": { + "zh": "授权请求已失效", + "en": "Approval request has expired" + }, + "error.loopback_required": { + "zh": "请使用 127.0.0.1 访问", + "en": "Use 127.0.0.1 to access this service" + }, + "error.cross_origin": { + "zh": "跨站请求被拒绝", + "en": "Cross-origin request rejected" + }, + "error.invalid_origin": { + "zh": "请求来源无效", + "en": "Invalid request origin" + }, + "error.request_rate": { + "zh": "请求过于频繁,请稍后重试", + "en": "Too many requests. Retry later." + }, + "error.api_credentials": { + "zh": "请用启动脚本重新打开 Continuer;本地 API 需要访问凭据", + "en": "Reopen Continuer with the launcher. Local API access requires credentials." + }, + "error.not_found": { + "zh": "接口或页面不存在", + "en": "API route or page not found" + }, + "error.lease_platform": { + "zh": "此平台不支持 Continuer 数据目录互斥锁", + "en": "Continuer data directory locking is unavailable on this platform" + }, + "error.lease_conflict": { + "zh": "同一数据目录已有服务运行,请勿重复启动", + "en": "A service is already running for this data directory" + }, + "error.lease_corrupt": { + "zh": "服务锁损坏,请检查进程后处理 service.lock", + "en": "Service lock is damaged. Check running processes before handling service.lock." + }, + "connection.connecting_to_local_codex": { + "zh": "正在连接本机 Codex…", + "en": "Connecting to local Codex…" + }, + "task.connection_interrupted_check_the_original_work_before_resuming": { + "zh": "连接中断,请检查原任务后手动继续", + "en": "Connection interrupted. Check the original work before resuming manually." + }, + "error.data_could_not_be_saved_recover_storage_before": { + "zh": "数据保存失败,恢复存储后再开启调度", + "en": "Data could not be saved. Recover storage before enabling scheduling." + }, + "error.a_continuation_message_is_still_being_sent_wait": { + "zh": "续作消息仍在发送,请等待发送结果确认后再恢复存储", + "en": "A continuation message is still being sent. Wait for confirmation before recovering storage." + }, + "error.confirm_current_work_has_stopped_before_restoring_a": { + "zh": "请先确认当前工作已停止,再恢复备份", + "en": "Confirm current work has stopped before restoring a backup." + }, + "error.the_main_state_file_is_corrupt_choose_restore": { + "zh": "主状态文件损坏,请明确选择恢复备份", + "en": "The main state file is corrupt. Choose Restore backup to proceed." + }, + "error.invalid_recovery_method": { + "zh": "恢复方式无效", + "en": "Invalid recovery method" + }, + "task.quota_is_low_or_unknown_waiting_for_fresh": { + "zh": "可用额度不足或未知,等待新额度样本", + "en": "Quota is low or unknown. Waiting for fresh data." + }, + "error.up_to_records_are_supported_end_unused_work": { + "zh": "最多管理 100 条记录,请先结束不再需要的工作", + "en": "Up to 100 records are supported. End unused work first." + }, + "log.work_added": { + "zh": "添加工作:{name}", + "en": "Work added: {name}" + }, + "log.scheduling_settings_updated": { + "zh": "调度设置已更新", + "en": "Scheduling settings updated" + }, + "error.this_chat_has_already_been_added": { + "zh": "该会话已添加", + "en": "This chat has already been added" + }, + "error.this_chat_is_being_added_do_not_import": { + "zh": "该会话正在添加,请勿重复导入", + "en": "This chat is being added. Do not import it again." + }, + "log.chat_added": { + "zh": "已添加会话:{name}", + "en": "Chat added: {name}" + }, + "task.monitoring_quota_a_continuation_message_will_be_sent": { + "zh": "正在监测额度;确认耗尽后,恢复时向原聊天发送续作消息", + "en": "Monitoring quota. A continuation message will be sent after confirmed exhaustion and recovery." + }, + "task.quota_recovery_monitoring_paused": { + "zh": "额度恢复续作已暂停", + "en": "Quota recovery monitoring paused" + }, + "error.monitoring_notes_must_be_text_of_up_to": { + "zh": "监测备注必须为不超过 12000 字符的文本", + "en": "Monitoring notes must be text of up to 12,000 characters." + }, + "error.continuation_messages_must_be_text_of_up_to": { + "zh": "续作消息必须为不超过 12000 字符的文本", + "en": "Continuation messages must be text of up to 12,000 characters." + }, + "task.chat_added_enable_monitoring_to_send_a_message": { + "zh": "会话已添加,开启额度监测后在恢复时发送续作消息;工作结果请在原聊天确认", + "en": "Chat added. Enable monitoring to send a message after quota recovery. Check results in the original chat." + }, + "task.this_legacy_record_lacks_proof_of_quota_exhaustion": { + "zh": "旧监测记录缺少额度耗尽依据,请检查原聊天后重新开启监测", + "en": "This legacy record lacks proof of quota exhaustion. Check the original chat before enabling monitoring again." + }, + "task.the_previous_delivery_is_unconfirmed_check_the_original": { + "zh": "上次发送结果未确认,请检查原聊天后重新开启监测", + "en": "The previous delivery is unconfirmed. Check the original chat before enabling monitoring again." + }, + "error.monitor_state": { + "zh": "额度监测状态无效", + "en": "Invalid monitoring state" + }, + "error.monitor_count": { + "zh": "额度监测发送次数或旧计数无效", + "en": "Invalid monitoring delivery count or legacy counter" + }, + "error.monitor_delivery": { + "zh": "额度监测发送状态无效", + "en": "Invalid monitoring delivery state" + }, + "log.continuation_message_updated": { + "zh": "续作消息已更新", + "en": "Continuation message updated" + }, + "task.monitoring_paused_delivery_is_still_unconfirmed_check_the": { + "zh": "监测已暂停,发送结果仍需确认;请核对原聊天,已发出的消息无法撤回", + "en": "Monitoring paused. Delivery is still unconfirmed; check the original chat. Sent messages cannot be recalled." + }, + "error.delivery_is_still_being_confirmed_wait_before_enabling": { + "zh": "续作消息发送结果仍在确认,请等待后再开启或结束监测", + "en": "Delivery is still being confirmed. Wait before enabling or ending monitoring." + }, + "error.message_limit_reached_raise_the_limit_then_enable": { + "zh": "已达到原聊天发送次数上限,请提高发送上限后再开启监测;累计次数不会重置", + "en": "Message limit reached. Raise the limit, then enable monitoring. Counts do not reset." + }, + "log.monitoring_ended_check_results_in_the_original_chat": { + "zh": "已结束监测:{name};工作结果请在原聊天确认", + "en": "Monitoring ended: {name}; check results in the original chat" + }, + "task.the_delivery_record_could_not_be_saved_check": { + "zh": "发送记录保存失败,请检查原聊天后手动继续", + "en": "The delivery record could not be saved. Check the original chat before resuming manually." + }, + "error.project_path": { + "zh": "项目目录必须是本地盘符绝对路径", + "en": "Project directory must be an absolute local drive path" + }, + "error.project_path_kind": { + "zh": "项目目录必须是本地盘符绝对路径,不支持网络或设备路径", + "en": "Project directory must be an absolute local drive path; network and device paths are unsupported" + }, + "error.project_directory": { + "zh": "项目目录必须是存在的本地目录", + "en": "Project directory must be an existing local directory" + }, + "error.queue_timeout": { + "zh": "Codex 队列请求超时", + "en": "Codex queue request timed out" + }, + "error.queue_failed": { + "zh": "Codex 队列请求失败 ({code})", + "en": "Codex queue request failed ({code})" + }, + "error.invalid_original_chat_id": { + "zh": "原聊天 ID 无效", + "en": "Invalid original chat ID" + }, + "error.codex_message_queue_unavailable_update_the_desktop_app": { + "zh": "Codex 消息队列不可用,请更新桌面端或设置 CODEX_QUEUE_BINARY", + "en": "Codex message queue unavailable. Update the desktop app or set CODEX_QUEUE_BINARY." + }, + "error.paused_no_continuation_message_sent": { + "zh": "已暂停,未发送续作消息", + "en": "Paused; no continuation message sent" + }, + "error.delivery_unconfirmed_check_the_original_chat": { + "zh": "发送结果未确认,请检查原聊天:{reason}", + "en": "Delivery unconfirmed. Check the original chat: {reason}" + }, + "task.quota_exhaustion_confirmed_one_continuation_message_will_be": { + "zh": "已确认额度耗尽,恢复后向原聊天发送一次续作消息", + "en": "Quota exhaustion confirmed. One continuation message will be sent after recovery." + }, + "task.quota_recovered_waiting_for_message_delivery_to_become": { + "zh": "额度已恢复,等待消息发送通道", + "en": "Quota recovered. Waiting for message delivery to become available." + }, + "error.the_last_instruction_already_received_a_complete_reply": { + "zh": "原聊天自上次指令后已形成完整回复,已跳过本次续作消息", + "en": "The last instruction already received a complete reply. This continuation message was skipped." + }, + "error.the_original_chat_is_still_replying_no_continuation": { + "zh": "原聊天仍在回复,暂不发送续作消息", + "en": "The original chat is still replying. No continuation message sent yet." + }, + "error.reply_status_could_not_be_confirmed_no_continuation": { + "zh": "无法确认原聊天回复状态,暂不发送续作消息,稍后重试", + "en": "Reply status could not be confirmed. No continuation message sent; retrying later." + }, + "task.quota_recovered_sending_a_continuation_message_to_the": { + "zh": "额度已恢复,正在向原聊天发送续作消息", + "en": "Quota recovered. Sending a continuation message to the original chat." + }, + "task.continuation_message_queued_monitoring_continues_check_results_in": { + "zh": "已向原聊天排队发送续作消息,继续监测;工作结果请在原聊天确认", + "en": "Continuation message queued. Monitoring continues; check results in the original chat." + }, + "task.continuation_message_queued_monitoring_paused_queued_messages_can": { + "zh": "已向原聊天排队发送续作消息,监测已暂停;已排队消息无法撤回", + "en": "Continuation message queued. Monitoring paused; queued messages cannot be recalled." + }, + "error.codex_exit": { + "zh": "Codex 进程已退出 ({code})", + "en": "Codex process exited ({code})" + }, + "error.codex_connection": { + "zh": "Codex 连接已失效", + "en": "Codex connection is no longer valid" + }, + "error.codex_disconnected": { + "zh": "Codex 未连接", + "en": "Codex is not connected" + }, + "error.rpc_timeout": { + "zh": "{method} 请求超时,请检查连接", + "en": "{method} request timed out. Check the connection." + }, + "error.codex_connection_closed": { + "zh": "Codex 连接已关闭", + "en": "Codex connectionOff" + }, + "error.storage_shape": { + "zh": "状态文件结构无效", + "en": "Invalid state file structure" + }, + "error.storage_version": { + "zh": "状态文件版本不兼容", + "en": "Incompatible state file version" + }, + "error.storage_history": { + "zh": "活动或额度历史格式无效", + "en": "Invalid activity or quota history" + }, + "error.storage_settings": { + "zh": "状态文件设置无效", + "en": "Invalid state file settings" + }, + "error.storage_send_limit": { + "zh": "状态文件发送上限无效", + "en": "Invalid state file delivery limit" + }, + "error.task_duplicate": { + "zh": "任务记录无效或 ID 重复", + "en": "Invalid task record or duplicate ID" + }, + "error.thread_duplicate": { + "zh": "状态中存在重复会话,请检查备份后恢复", + "en": "Duplicate chats in state. Check the backup before restoring." + }, + "error.storage_size": { + "zh": "状态文件超过 64 MiB,请检查后恢复备份", + "en": "State file exceeds 64 MiB. Inspect it before restoring a backup." + }, + "error.storage_missing": { + "zh": "主状态文件缺失,但存在备份,请明确恢复", + "en": "Primary state file is missing but a backup exists. Restore it explicitly." + }, + "error.storage_unrecovered": { + "zh": "状态未恢复,禁止覆盖原文件:{reason}", + "en": "State has not been restored; the original file cannot be overwritten: {reason}" + }, + "error.task_mode": { + "zh": "任务模式无效", + "en": "Invalid task mode" + }, + "error.task_record": { + "zh": "任务记录无效", + "en": "Invalid task record" + }, + "error.task_switch": { + "zh": "任务开关无效", + "en": "Invalid task switch" + }, + "error.task_fields": { + "zh": "任务目标、备注、续作消息或输出字段无效", + "en": "Invalid task goal, note, continuation message, or output fields" + }, + "error.monitor_execution": { + "zh": "额度监测记录不能启动执行轮次", + "en": "A monitoring record cannot start an execution turn" + }, + "task.start_cancelled_waiting_for_active_scheduling_and_fresh": { + "zh": "启动已取消,等待有效调度和新鲜额度", + "en": "Start cancelled. Waiting for active scheduling and fresh quota." + }, + "task.resuming_chat": { + "zh": "正在恢复会话…", + "en": "Resuming chat…" + }, + "task.automatic_turn_limit_reached_resume_manually_to_continue": { + "zh": "已达到自动执行轮次上限,请手动继续", + "en": "Automatic turn limit reached. Resume manually to continue." + }, + "error.no_valid_turn_id_received_check_the_original": { + "zh": "未收到有效的轮次 ID,请检查原会话后手动继续", + "en": "No valid turn ID received. Check the original chat before resuming manually." + }, + "log.work_started": { + "zh": "开始工作:{name}", + "en": "Work started: {name}" + }, + "task.startup_events_exceeded_the_buffer_limit_interrupting_check": { + "zh": "启动期间通知超过缓存上限,正在中断,请检查原会话后手动继续", + "en": "Startup events exceeded the buffer limit. Interrupting; check the original chat before resuming." + }, + "task.interruption_unconfirmed": { + "zh": "未确认中断:{reason}", + "en": "Interruption unconfirmed: {reason}" + }, + "task.codex_is_working_on_the_goal": { + "zh": "Codex 正在处理目标", + "en": "Codex is working on the goal" + }, + "task.plan_updated": { + "zh": "计划已更新", + "en": "Plan updated" + }, + "task.running_a_command": { + "zh": "正在执行命令", + "en": "Running a command" + }, + "task.editing_files": { + "zh": "正在修改文件", + "en": "Editing files" + }, + "task.analyzing": { + "zh": "正在分析", + "en": "Analyzing" + }, + "task.generating_a_response": { + "zh": "正在生成回复", + "en": "Generating a response" + }, + "task.calling_a_tool": { + "zh": "正在调用工具", + "en": "Calling a tool" + }, + "task.working": { + "zh": "正在工作", + "en": "Working" + }, + "task.this_turn_failed": { + "zh": "本轮执行失败", + "en": "This turn failed" + }, + "task.waiting_for_your_approval": { + "zh": "等待你批准操作", + "en": "Waiting for your approval" + }, + "log.is_awaiting_approval": { + "zh": "{name} 等待操作授权", + "en": "{name} is awaiting approval" + }, + "task.file_changes_cannot_be_fully_reviewed_and_were": { + "zh": "文件修改范围无法完整审阅,已拒绝;请在原 Codex 中核对后手动继续", + "en": "File changes cannot be fully reviewed and were declined. Review them in the original Codex chat before resuming." + }, + "task.user_input_is_required_respond_in_codex_before": { + "zh": "需要用户输入,请在 Codex 中处理后继续", + "en": "User input is required. Respond in Codex before resuming." + }, + "task.approval_details_cannot_be_fully_reviewed_and_were": { + "zh": "授权内容超出可审阅范围,已拒绝,请在原 Codex 会话核对后继续", + "en": "Approval details cannot be fully reviewed and were declined. Review them in the original Codex chat before resuming." + }, + "error.recover_data_storage_before_handling_approvals": { + "zh": "请先恢复数据保存,再处理授权", + "en": "Recover data storage before handling approvals." + }, + "error.approval_expired_or_decision_invalid": { + "zh": "授权请求已过期或决定无效", + "en": "Approval expired or decision invalid" + }, + "error.approval_details_cannot_be_fully_reviewed_handle_this": { + "zh": "授权内容无法完整审阅,请在原 Codex 中处理", + "en": "Approval details cannot be fully reviewed. Handle this in the original Codex chat." + }, + "error.the_approval_turn_is_no_longer_active_or": { + "zh": "授权所属轮次已失效或正在暂停", + "en": "The approval turn is no longer active or is pausing" + }, + "task.more_operations_await_approval": { + "zh": "仍有操作等待授权", + "en": "More operations await approval" + }, + "task.approved_continuing": { + "zh": "已批准,继续执行", + "en": "Approved; continuing" + }, + "task.operation_declined": { + "zh": "已拒绝操作", + "en": "Operation declined" + } +}); diff --git a/public/task-details.js b/public/task-details.js index b55b5d9..1d9b40a 100644 --- a/public/task-details.js +++ b/public/task-details.js @@ -8,12 +8,13 @@ export class TaskDetails { slot.pending = true; slot.revision = revision; this.request(`/api/tasks/${encodeURIComponent(summary.id)}`).then(result => { if (this.slot !== slot) return; - slot.task = result.task; slot.pending = false; this.changed(); + slot.task = result.task; slot.taskRevision = revision; slot.pending = false; this.changed(); }).catch(error => { if (this.slot === slot) { slot.pending = false; slot.revision = undefined; slot.retryAt = Date.now() + 2000; this.error(error); } }); } const result = { ...slot.task, ...summary }; - if (slot.task && slot.revision === revision) for (const key of ['cwd', 'activity', 'name']) if (key in slot.task) result[key] = slot.task[key]; + if (slot.task && slot.taskRevision === revision) for (const key of ['cwd', 'activity', 'activityMessage', 'name']) if (key in slot.task) result[key] = slot.task[key]; return result; } + ready(summary) { return !!this.slot?.task && this.slot.id === summary.id && !this.slot.pending && this.slot.taskRevision === (summary.detailRevision ?? 0); } clear() { this.slot = null; } } diff --git a/public/task-forms.js b/public/task-forms.js new file mode 100644 index 0000000..ef15c6b --- /dev/null +++ b/public/task-forms.js @@ -0,0 +1,55 @@ +import { t as tr } from './i18n.js'; +import { wakeExplanation } from './task-mode.js'; +import { escapeHtml as esc } from './html.js'; +import { icon } from './icons.js'; + +export function newTaskForm({ layout = 'page', pickDirectory = false } = {}) { + const page = layout === 'page', fieldClass = page ? 'form-field' : ''; + const directory = ``; + const directoryControl = page + ? `${directory}${tr("ui.enter_an_absolute_path_to_an_existing")}` + : `${directory}${pickDirectory ? `` : ''}`; + const submit = page ? tr("ui.create_work", { p0: icon('arrow') }) : tr("ui.create_and_queue"); + const actions = page + ? `` + : ``; + return `${page ? `

${tr("ui.give_your_project_a_clear_goal_continuer")}

` : ''} +
+ + + + +

${page + ? tr("ui.queued_after_creation_work_starts_when_quota") + : tr("ui.work_is_queued_after_creation_extra_permissions")}

+ ${page ? '' : ''} + ${actions} +
`; +} + +export function monitorImportForm(threadId, { layout = 'page', thread = null } = {}) { + const page = layout === 'page', fieldClass = page ? 'form-field' : '', hint = page ? 'subtitle' : 'hint'; + const preview = thread ? `${esc(thread.name || thread.preview || tr("ui.codex_chat"))}

${esc(thread.cwd)}

` : ''; + const label = thread ? tr("ui.add_to_my_work") : tr("ui.import_chat"); + const button = ``; + const actions = page + ? `` + : button; + return `
+ ${preview} + + +

${thread ? tr("ui.monitoring_stays_paused_after_adding") : tr("ui.monitoring_stays_paused_after_import")}${esc(wakeExplanation)}

+ ${actions} +
`; +} diff --git a/public/task-mode.js b/public/task-mode.js index 35f3cfa..9805b2e 100644 --- a/public/task-mode.js +++ b/public/task-mode.js @@ -1,14 +1,20 @@ -export const wakeExplanation = '确认额度耗尽并恢复后,仅向原聊天发送固定“继续”;备注不发送。不读取原聊天进度或完成状态,工作结果请在原聊天确认。'; +import { t as tr, onLanguageChange } from './i18n.js'; +const wakeMessageId = "ui.after_confirmed_quota_exhaustion_and_recovery_the"; +export let wakeExplanation = tr(wakeMessageId); +onLanguageChange(() => { wakeExplanation = tr(wakeMessageId); }); export function taskMode(task, settings) { const wake = task.mode === 'quota-wake'; return { wake, - description: wake ? `监测备注:${task.note || '未填写'}(仅本地保存)` : task.prompt, - counter: wake ? `已排队发送 ${task.sentCount || 0} / ${settings.maxWakeSends} 次` : `已执行 ${task.rounds} / ${settings.maxRounds} 轮`, - progress: wake ? '进度请在原聊天查看' : null, - continuous: wake ? '额度监测' : '持续工作', - pause: wake ? '暂停监测' : '暂停', - resume: wake ? '开启监测' : '继续', - complete: wake ? '结束监测并移除' : '标记完成', + description: wake ? tr("ui.monitoring_note_local_only", { p0: task.note || tr("ui.not_provided") }) : task.prompt, + counter: wake ? tr("ui.queued_messages", { p0: task.sentCount || 0, p1: settings.maxWakeSends }) : tr("ui.executed_turns", { p0: task.rounds, p1: settings.maxRounds }), + progress: wake ? tr("ui.check_progress_in_the_original_chat") : null, + continuous: wake ? tr("ui.quota_monitor") : tr("ui.continuous_work"), + pause: wake ? tr("ui.pause_monitoring") : tr("ui.pause"), + resume: wake ? tr("ui.enable_monitoring") : tr("ui.continue"), + complete: wake ? tr("ui.end_monitoring_and_remove") : tr("ui.mark_complete"), }; } +export function continuationEditor(task, esc) { + return `

${tr("ui.leave_blank_to_use_the_default_message")}

${tr("ui.the_saved_message_is_used_for_future")}

`; +} diff --git a/public/task-view.js b/public/task-view.js new file mode 100644 index 0000000..054c5ce --- /dev/null +++ b/public/task-view.js @@ -0,0 +1,176 @@ +import { t as tr, systemText, defaultContinuation } from './i18n.js'; +import { taskMode, wakeExplanation } from './task-mode.js'; +import { escapeHtml as esc } from './html.js'; +import { icon } from './icons.js'; + +const activeStatuses = new Set(['running', 'starting', 'approval', 'pausing']); +const pausableStatuses = new Set([...activeStatuses, 'monitoring', 'waiting', 'queued']); +const statusText = { + queued: "ui.queued", + waiting: "ui.waiting_for_quota", + starting: "ui.starting", + running: "ui.running", + pausing: "ui.pausing", + paused: "ui.paused", + blocked: "ui.needs_attention", + failed: "ui.failed", + completed: "ui.completed", + approval: "ui.awaiting_approval", + monitoring: "ui.monitoring" +}; + +export const isTaskActive = task => activeStatuses.has(task.status); + +export function taskStatusLabel(task) { + if (task.status === 'monitoring') return tr("ui.monitoring"); + if (task.waitReason === 'desktop') return tr("ui.waiting_for_delivery"); + if (task.status === 'waiting' && ['writer', 'thread'].includes(task.waitReason)) return tr("ui.waiting_for_chat"); + return statusText[task.status] ? tr(statusText[task.status]) : task.status || ''; +} + +export function taskPresentation(task, settings) { + const mode = taskMode(task, settings); + const pause = pausableStatuses.has(task.status); + const progress = Number.isFinite(task.progress) ? Math.max(0, Math.min(100, task.progress)) : 0; + return { + task, mode, progress, + label: taskStatusLabel(task), + activity: systemText(task.activity, task.activityMessage), + progressText: task.progress == null ? tr("ui.no_plan") : `${progress}%`, + control: task.status === 'completed' ? null : { action: pause ? 'pause' : 'resume', label: pause ? mode.pause : mode.resume }, + canComplete: !isTaskActive(task) && task.status !== 'completed', + }; +} + +export function toggle(on, action, label, id = '') { + return ``; +} + +function badge(view) { + return `${esc(view.label)}`; +} + +function monitorNote(view) { + if (!view.mode.wake) return ''; + const task = view.task, note = task.notePreview ?? task.note ?? tr("ui.not_provided"); + return `

+ ${tr("ui.monitoring_note")}${esc(note) || tr("ui.not_provided")} + ${task.noteTruncated ? `${tr("ui.see_details_for_the_full_note")}` : ''} +

`; +} + +function controlButton(view, layout) { + if (!view.control) return ''; + const { action, label } = view.control; + const buttonIcon = layout === 'page' ? icon(isTaskActive(view.task) ? 'pause' : 'play') : ''; + return ``; +} + +function pageTaskCard(view) { + const { task, mode, progress } = view; + return `
+
+
${icon('folder')}
+
+ +
${esc(task.cwd)}
+
+ ${badge(view)} +
+ ${monitorNote(view)} +
+
+ ${esc(mode.progress || view.progressText)} +
+
${esc(mode.counter)}
+
${esc(view.activity)}
+
+
${toggle(task.continuous, 'continuous', `${task.name}${mode.continuous}`, task.id)}${mode.continuous}
+
+ + ${controlButton(view, 'page')} +
+
+
`; +} + +function widgetTaskCard(view) { + const { task, mode } = view; + return `
+
+ + ${badge(view)} +
+
${esc(task.cwd)}
+ ${monitorNote(view)} +
+
+ ${mode.wake ? tr("ui.view_in_original_chat") : esc(view.progressText)} +
+

${esc(view.activity)}

+

${esc(mode.counter)}

+
+ +
+ + ${controlButton(view, 'widget')} +
+
+
`; +} + +export function taskCardHtml(task, settings, layout = 'page') { + const view = taskPresentation(task, settings); + return layout === 'widget' ? widgetTaskCard(view) : pageTaskCard(view); +} + +function monitorDetails(view, settings, canEdit, hint) { + const task = view.task; + return `

${tr("ui.continuation_message")}
${esc(task.continuationMessage?.trim() || defaultContinuation(settings.language))}

+ +

${esc(wakeExplanation)}

`; +} + +function planDetails(view, layout) { + const steps = view.task.plan || []; + if (layout === 'widget') { + return `
    ${steps.map(step => `
  1. ${step.status === 'completed' ? '✓' : step.status === 'inProgress' ? '◉' : '○'} ${esc(step.step)}
  2. `).join('')}
`; + } + const progress = view.task.progress == null ? tr("ui.no_plan_yet") : `· ${view.progress}%`; + const rows = steps.map(step => `
  • + ${icon(step.status === 'completed' ? 'check' : step.status === 'inProgress' ? 'play' : 'clock')}${esc(step.step)} +
  • `).join(''); + return `

    ${tr("ui.plan_progress")}${progress}

    +
      ${rows}
    +

    ${tr("ui.the_percentage_shows_completed_plan_steps_only")}

    `; +} + +function outputDetails(view, layout, hint) { + if (view.mode.wake) return ''; + const task = view.task; + const notice = task.outputTruncated + ? `

    ${tr("ui.only_the_end_of_long_output_is")}

    ` : ''; + if (layout === 'widget') return notice + (task.output ? `
    ${esc(task.output)}
    ` : ''); + return `

    ${tr("ui.turn_output")}

    ${notice}
    ${esc(task.output || tr("ui.no_output_yet"))}
    `; +} + +export function taskDetailHtml(task, settings, { layout = 'page', canEdit = false } = {}) { + const view = taskPresentation(task, settings), page = layout === 'page'; + const hint = page ? 'subtitle' : 'hint'; + const complete = view.canComplete ? `` : ''; + const context = page + ? `${badge(view)}

    ${esc(task.cwd)}

    ` + : `

    ${esc(task.cwd)}
    ${esc(view.label)} · ${esc(view.mode.counter)}

    `; + const activity = page + ? `

    ${tr("ui.current_status")}${esc(view.activity)} · ${esc(view.mode.counter)}

    ` + : `

    ${esc(view.activity)}

    `; + const thread = page && task.threadId ? `

    ${tr("ui.chat")}${esc(task.threadId)}

    ` : ''; + const actions = page + ? `` : complete; + const description = `

    ${esc(view.mode.description)}

    `; + const details = view.mode.wake ? monitorDetails(view, settings, canEdit, hint) : planDetails(view, layout); + return `${page ? context + description : description + context}${details}${activity}${thread}${outputDetails(view, layout, hint)}${actions}`; +} diff --git a/public/widget.css b/public/widget.css index adad780..b3f923a 100644 --- a/public/widget.css +++ b/public/widget.css @@ -4,8 +4,11 @@ #mini.has-art #mini-open{animation:readout-art-cycle 60s ease-in-out infinite;animation-play-state:paused} #mini.has-art[data-motion=running] #mini-open{animation-play-state:running} @keyframes readout-art-cycle{0%,20%{opacity:1}21.1667%,64.1667%{opacity:0}65.5%,100%{opacity:1}} -/* Keep quota information visible throughout the decorative artwork cycle. */ +/* Hide quota text during the spacecraft flight, then restore it above the lower signal. */ #gauge-readout{opacity:1} +#mini.has-art #gauge-readout{animation:readout-flight 60s linear infinite;animation-play-state:paused} +#mini.has-art[data-motion=running] #gauge-readout{animation-play-state:running} +@keyframes readout-flight{0%,20%{opacity:1}21%,30.5%{opacity:0}31.5%,100%{opacity:1}} #mini.is-refreshing #quota-readout{animation:quota-reload 1s cubic-bezier(.37,0,.63,1) both} @keyframes quota-reload{0%,100%{opacity:1}45%,55%{opacity:0}} @keyframes beacon{0%,100%{opacity:.5}50%{opacity:1}}.size-setting>span{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.size-setting output{font:10px var(--mono);color:var(--accent)}#compact-size{width:130px;padding:0;accent-color:var(--accent);border:0;background:transparent}.browser{min-height:100dvh;background:var(--bg)}.browser .shell{width:min(416px,100%);height:min(704px,calc(100dvh - 16px));margin:auto;transition:width .28s ease,height .28s ease,border-radius .28s ease,background-color .28s ease}.browser.collapsed .shell{width:var(--disc-size);height:var(--disc-size)}.browser #quit,.browser #follow-label,.browser #reset-position{display:none}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(max-width:340px){.scroll{padding:4px 11px 10px}header{padding:13px 10px;gap:3px}.brand strong{font-size:13px;letter-spacing:.7px}.brand small{letter-spacing:1px}.quota{padding:11px}.quota strong{font-size:32px}.scheduler strong span{display:none}.section-label em{padding-right:5px;margin-right:3px}} @@ -39,3 +42,11 @@ body:not(.browser).is-switching:not(.collapsed) #panel{opacity:1;visibility:visi body:not(.browser).is-switching header{-webkit-app-region:no-drag} /* Storage diagnostics share the existing compact panel palette. */ .storage-warning{margin:10px 0;padding:10px;border:1px solid var(--line);border-radius:8px;font-size:11px;line-height:1.6}.storage-warning p{margin:4px 0}.storage-warning button{margin:4px 4px 4px 0} + +.monitor-note{margin:12px 0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font-size:11px;line-height:1.7;overflow-wrap:anywhere}.monitor-note strong{display:block;font-size:10px;opacity:.7;margin-bottom:4px}.monitor-note span{display:block;white-space:pre-wrap;max-height:10em;overflow:auto}.monitor-note small{display:block;margin-top:4px;opacity:.7} + +.detail-summary{white-space:pre-wrap} +.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}.form-error{color:var(--orange);font-size:11px;line-height:1.5} +#language{background:var(--card);color:var(--text);border:1px solid var(--line);border-radius:4px;padding:3px 1px;font-size:10px;max-width:69px;-webkit-app-region:no-drag} +html[lang=en-US] .brand small{font-size:8px;letter-spacing:0}html[lang=en-US] .brand strong{font-size:12px}html[lang=en-US] .scheduler strong>span{display:none} +html[lang=en-US] #mini-window{font-size:7px;letter-spacing:0}html[lang=en-US] .task-bottom{gap:6px;flex-wrap:wrap}html[lang=en-US] .task-bottom>div{margin-left:auto} diff --git a/public/widget.html b/public/widget.html index 751dbc5..3726e57 100644 --- a/public/widget.html +++ b/public/widget.html @@ -1,48 +1,48 @@ -Continuer · Codex 挂件 +Continuer · Codex 挂件
    -
    - +
    diff --git a/public/widget.js b/public/widget.js index 6b34cd7..029097c 100644 --- a/public/widget.js +++ b/public/widget.js @@ -1,280 +1,359 @@ +import { newTaskForm, monitorImportForm } from './task-forms.js'; +import { t as tr, systemText, setFormError, locale, setLanguage, staticInterface, refreshLocalizedContent } from './i18n.js'; import { TaskDetails } from './task-details.js'; -import { accessHeaders } from './access.js'; +import { createApiClient } from './api-client.js'; +import { escapeHtml as esc } from './html.js'; +import { taskCardHtml, taskDetailHtml } from './task-view.js'; import { VisibleFeed, LazyResource, setHtml } from './view-lifecycle.js'; import { ApprovalDetails } from './approval-details.js'; import { storageNotice } from './storage-status.js'; -import { taskMode, wakeExplanation } from './task-mode.js'; +import { wakeExplanation, continuationEditor } from './task-mode.js'; import { quotaWindow, resetCountdown } from './quota-display.js'; import { pixelText } from './rhine-readout.js'; -const $ = s => document.querySelector(s); -const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); -const bridge = window.continuerDesktop; -const labels = { queued: '排队中', waiting: '等待额度', starting: '准备中', running: '进行中', pausing: '暂停中', paused: '已暂停', blocked: '需要关注', failed: '执行失败', completed: '已完成', approval: '等待授权' }; -const taskLabel=t=>t.status==="monitoring" ? "监测中" : t.waitReason==="desktop" ? "等待发送通道" : t.status==='waiting' && ['writer','thread'].includes(t.waitReason) ? '等待会话' : labels[t.status] || t.status; -const busy = ["monitoring", 'queued', 'waiting', 'starting', 'running', 'approval', 'pausing']; -const QUOTA_CYCLE_MS = 12000, QUOTA_TRANSITION_MS = 1000; -const taskDetails = new TaskDetails(api, () => { if (viewVisible() && detailId && $('#modal').open) detail(detailId); }, error => toast(error.message)); -const approvalDetails = new ApprovalDetails(api, () => render()); -let desktopPreferenceError = null, rendererAnnounced = false, rendererReady = false; -let state, connected = true, settingsInitialized = false, lastPixelCountdown, timer, detailId; -let sessionItems = [], sessionCursor = null, sessionLoading = false, lastSessionRead = 0, lastSessionAttempt = 0; -let windowSlot='primary', carouselTimer, swapTimer, refreshTimer, gaugeTimer, panelTimer, nativeVisible=!bridge, compactPixels=256; -const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)'); -const animation = new LazyResource(loadRhineAnimation); -function viewVisible() { return !document.hidden && nativeVisible; } -async function loadRhineAnimation() { - const response=await fetch('/rhine-signal-loop.svg'); - if(!response.ok) throw Error('Rhine 动画资源读取失败'); - const source=new DOMParser().parseFromString(await response.text(),'image/svg+xml'); - if(source.querySelector('parsererror')) throw Error('Rhine 动画资源无效'); - $('#rhine-art').replaceChildren(document.importNode(source.documentElement,true)); - // Start the overlay on the same render frame as the SVG's 60-second cycle. - $('#mini').classList.add('has-art'); - updateMotion(); -} -function updateMotion() { - const motion = viewVisible() && document.body.classList.contains('collapsed') ? 'running' : 'paused'; - if ($('#mini').dataset.motion !== motion) $('#mini').dataset.motion = motion; -} -function syncActivity() { - const compact = document.body.classList.contains('collapsed'), visible = viewVisible(); - const active=compact && visible && !!state?.sampledAt; - if (compact && visible && state) { - if (!gaugeTimer) gaugeTimer = setInterval(renderGauge, 1000); - if (!animation.loaded) void animation.ensure().catch(error => toast(error.message)); - } else { clearInterval(gaugeTimer); gaugeTimer = null; } - if (!compact && visible && state) { - if (!panelTimer) panelTimer = setInterval(() => { render(); refreshSessionsIfNeeded(); }, 30000); - } else { clearInterval(panelTimer); panelTimer = null; } - if(active && !carouselTimer) { - windowSlot='primary'; renderGauge(); - carouselTimer=setInterval(() => { - clearTimeout(swapTimer); clearTimeout(refreshTimer); - if(reducedMotion.matches) { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); return; } + +export function mountWidget(environment = globalThis) { + const { document, window, localStorage, Date, FormData, AbortSignal, DOMParser, getComputedStyle, setTimeout, clearTimeout, setInterval, clearInterval, fetch, Blob, URL } = environment; + const api = createApiClient({ request: (...args) => fetch(...args) }); + const applyStaticLanguage = staticInterface(document); + const $ = s => document.querySelector(s); + const bridge = window.continuerDesktop; + const QUOTA_CYCLE_MS = 12000, QUOTA_TRANSITION_MS = 1000; + const taskDetails = new TaskDetails(api, () => { if (viewVisible() && detailId && $('#modal').open) detail(detailId); }, error => toast(systemText(error))); + const approvalDetails = new ApprovalDetails(api, () => render()); + let lastDesktopLanguage, desktopPreferenceError = null, rendererAnnounced = false, rendererReady = false; + let modalRenderer = null, sessionModal = null; + let state, connected = true, settingsInitialized = false, lastPixelCountdown, timer, detailId; + let sessionItems = [], sessionCursor = null, sessionLoading = false, sessionError = null, lastSessionRead = 0, lastSessionAttempt = 0; + let windowSlot='primary', carouselTimer, swapTimer, refreshTimer, gaugeTimer, panelTimer, nativeVisible=!bridge, compactPixels=256; + const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)'); + const animation = new LazyResource(loadRhineAnimation); + function viewVisible() { return !document.hidden && nativeVisible; } + async function loadRhineAnimation() { + const response=await fetch('/rhine-signal-loop.svg'); + if(!response.ok) throw Error(tr("ui.rhine_could_not_load_animation")); + const source=new DOMParser().parseFromString(await response.text(),'image/svg+xml'); + if(source.querySelector('parsererror')) throw Error(tr("ui.rhine_invalid_animation")); + $('#rhine-art').replaceChildren(document.importNode(source.documentElement,true)); + // Start the overlay on the same render frame as the SVG's 60-second cycle. + $('#mini').classList.add('has-art'); + updateMotion(); + } + function updateMotion() { + const motion = viewVisible() && document.body.classList.contains('collapsed') ? 'running' : 'paused'; + if ($('#mini').dataset.motion !== motion) $('#mini').dataset.motion = motion; + } + function syncActivity() { + const compact = document.body.classList.contains('collapsed'), visible = viewVisible(); + const active=compact && visible && !!state?.sampledAt; + if (compact && visible && state) { + if (!gaugeTimer) gaugeTimer = setInterval(renderGauge, 1000); + if (!animation.loaded) void animation.ensure().catch(error => toast(systemText(error))); + } else { clearInterval(gaugeTimer); gaugeTimer = null; } + if (!compact && visible && state) { + if (!panelTimer) panelTimer = setInterval(() => { render(); refreshSessionsIfNeeded(); }, 30000); + } else { clearInterval(panelTimer); panelTimer = null; } + if(active && !carouselTimer) { + windowSlot='primary'; renderGauge(); + carouselTimer=setInterval(() => { + clearTimeout(swapTimer); clearTimeout(refreshTimer); + if(reducedMotion.matches) { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); return; } + $('#mini').classList.remove('is-refreshing'); + $('#mini').classList.add('is-refreshing'); + // Change the quota, label and reset together at the opacity minimum. + swapTimer=setTimeout(() => { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); },QUOTA_TRANSITION_MS / 2); + refreshTimer=setTimeout(() => $('#mini').classList.remove('is-refreshing'),QUOTA_TRANSITION_MS); + },QUOTA_CYCLE_MS); + } else if(!active && carouselTimer) { + clearInterval(carouselTimer); clearTimeout(swapTimer); clearTimeout(refreshTimer); carouselTimer=null; $('#mini').classList.remove('is-refreshing'); - $('#mini').classList.add('is-refreshing'); - // Change the quota, label and reset together at the opacity minimum. - swapTimer=setTimeout(() => { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); },QUOTA_TRANSITION_MS / 2); - refreshTimer=setTimeout(() => $('#mini').classList.remove('is-refreshing'),QUOTA_TRANSITION_MS); - },QUOTA_CYCLE_MS); - } else if(!active && carouselTimer) { - clearInterval(carouselTimer); clearTimeout(swapTimer); clearTimeout(refreshTimer); carouselTimer=null; - $('#mini').classList.remove('is-refreshing'); + } + updateMotion(); } - updateMotion(); -} -function refreshSessionsIfNeeded() { - if (viewVisible() && !document.body.classList.contains('collapsed') && document.activeElement !== $('#session-select') && !$('#modal').open - && Date.now() - Math.max(lastSessionRead, lastSessionAttempt) >= 60000) void readSessions(); -} -function syncView() { - stream.sync(); - if (state && viewVisible()) render(); else syncActivity(); -} -function selectedSession() { return sessionItems.find(t => t.id === $('#session-select').value); } -function updateSessionSelection() { - const t = selectedSession(); - const managed = t && state?.tasks.find(task => task.threadId === t.id); - $('#add-session').disabled = !t; - $('#add-session').textContent = managed ? '查看已加入的工作' : '加入我的工作'; - $('#session-preview').textContent = t ? `${t.cwd || '未提供项目目录'}${t.updatedAt ? ` · ${new Date(t.updatedAt * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} 更新` : ''}` : '按最近更新排序,选择你正在进行的对话。'; -} -async function readSessions(more = false) { - if (sessionLoading) return; - sessionLoading = true; - lastSessionAttempt = Date.now(); - const chosen = $('#session-select').value || localStorage.getItem('continuer-selected-session') || ''; - try { - const result = await api('/api/threads' + (more && sessionCursor ? `?cursor=${encodeURIComponent(sessionCursor)}` : '')); - const retained = sessionItems.find(t => t.id === chosen); - sessionItems = [...new Map([...(more ? sessionItems : []), ...(result.data || []), ...(!more && retained && !(result.data || []).some(t => t.id === retained.id) ? [retained] : [])].map(t => [t.id, t])).values()].slice(0, 300); - sessionCursor = sessionItems.length < 300 ? result.nextCursor : null; - setHtml($('#session-select'), `` + sessionItems.map(t => ``).join('')); - $('#session-select').value = sessionItems.some(t => t.id === chosen) ? chosen : ''; + function refreshSessionsIfNeeded() { + if (viewVisible() && !document.body.classList.contains('collapsed') && document.activeElement !== $('#session-select') && !$('#modal').open + && Date.now() - Math.max(lastSessionRead, lastSessionAttempt) >= 60000) void readSessions(); + } + function syncView() { + stream.sync(); + if (state && viewVisible()) render(); else syncActivity(); + } + function selectedSession() { return sessionItems.find(t => t.id === $('#session-select').value); } + function renderSessionOptions() { + const select = $('#session-select'), chosen = select.value || localStorage.getItem('continuer-selected-session') || ''; + const placeholder = sessionError && !sessionItems.length ? tr("ui.chats_unavailable") : sessionItems.length ? tr("ui.choose_a_chat_to_manage") : !lastSessionRead && !sessionError ? tr("ui.loading_chats_2") : tr("ui.no_local_codex_chats"); + setHtml(select, `` + sessionItems.map(t => ``).join('')); + select.value = sessionItems.some(t => t.id === chosen) ? chosen : ''; $('#session-more').hidden = !sessionCursor; - lastSessionRead = Date.now(); updateSessionSelection(); - } catch (error) { $('#session-preview').textContent = `读取失败:${error.message},请点击「↻ 对话」重试。`; } - finally { sessionLoading = false; } -} -const clock = t => t ? new Date(t).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '—'; -const percent = w => Number.isFinite(w?.usedPercent) ? Math.max(0, Math.min(100, 100 - w.usedPercent)) : null; -function reset(t) { if (!t) return '恢复时间未知'; const m = Math.ceil((t * 1000 - Date.now()) / 60000); return m <= 0 ? '等待恢复确认' : m >= 1440 ? `${Math.floor(m / 1440)} 天 ${Math.floor(m % 1440 / 60)} 小时后重置` : m >= 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟后重置` : `${m} 分钟后重置`; } -function windowName(w, fallback) { const m = w?.windowDurationMins; return m ? m >= 1440 ? `${m / 1440} 天额度` : `${m / 60} 小时额度` : fallback; } -function toast(text) { $('#toast').textContent = text; $('#toast').classList.add('show'); clearTimeout(timer); timer = setTimeout(() => $('#toast').classList.remove('show'), 3500); } -async function api(path, body) { const r = await fetch(path, { signal: AbortSignal.timeout(35000), credentials: 'omit', redirect: 'error', headers: { ...accessHeaders(), ...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }) }); const data = await r.json(); if (!r.ok) throw Error(data.error || '请求失败'); return data; } -function card(w, title) { const p = percent(w); return `
    ${esc(title)}
    ${p === null ? '—' : Math.round(p)}${p === null ? '未知' : '% 剩余'}
    ${reset(w?.resetsAt)}
    `; } -function renderGauge() { - if (!state || document.hidden || !nativeVisible || !document.body.classList.contains('collapsed')) return; - const reading = quotaWindow(state, windowSlot, connected); - const available = reading.remaining !== null; - const setText=(selector,value) => { const element=$(selector),text=String(value); if(element.textContent!==text) element.textContent=text; }; - setText('#mini-quota',available ? Math.round(reading.remaining) : '—'); - setText('#mini-reset',resetCountdown(reading.resetsAt)); - const countdown=$('#mini-reset').textContent; - const numeric=/^\d/.test(countdown); - $('#mini-reset').classList.toggle('visually-empty',numeric); - if(numeric && countdown!==lastPixelCountdown) { - const pixels=pixelText(countdown.replace('天','d')); - const scale=Math.min(1.9,104/pixels.width); - $('#mini-reset-pixels').setAttribute('d',pixels.path); - $('#mini-reset-pixels').setAttribute('transform',`translate(${128-pixels.width*scale/2} 176) scale(${scale})`); - } else if(!numeric && countdown!==lastPixelCountdown) $('#mini-reset-pixels').setAttribute('d',''); - lastPixelCountdown=countdown; - const windowLabel=windowSlot==='primary'?'五小时':'七天'; - setText('#mini-window',`${windowLabel}可用额度`); - setText('#mini-reset-label','距离重置'); - $('#mini').dataset.window=windowSlot; - $('#quota-dot-primary').classList.toggle('selected',windowSlot==='primary'); - $('#quota-dot-secondary').classList.toggle('selected',windowSlot==='secondary'); - $('#mini').classList.toggle('offline', !available); - $('#mini').title = `${available ? `${windowLabel}可用额度 ${Math.round(reading.remaining)}%,重置倒计时 ${resetCountdown(reading.resetsAt)}` : `${windowLabel}额度数据待同步`}。点击展开,拖动移动;Ctrl + 滚轮调整大小。`; - $('#gauge-readout').setAttribute('aria-label',$('#mini').title); -} -function render() { - if (!state) return; - if (bridge && !rendererAnnounced) { - rendererAnnounced = true; - bridge.control('renderer-ready').then(result => { rendererReady = true; applyDesktop(result); stream.sync(); }).catch(error => { rendererAnnounced = false; toast(error.message); }); + $('#session-more').disabled = sessionLoading; + updateSessionSelection(); } - renderGauge(); - if(!viewVisible() || document.body.classList.contains('collapsed')) { syncActivity(); return; } - const buckets = Object.values(state.quota?.rateLimitsByLimitId || {}).filter(Boolean); - if (!buckets.length && state.quota?.rateLimits) buckets.push(state.quota.rateLimits); - const stale = !state.sampledAt || Date.now() - state.sampledAt > 90000; - setHtml($('#quotas'), (buckets.length ? buckets : [{}]).map(b => card(b.primary, (buckets.length > 1 ? `${b.limitName || b.limitId} · ` : '') + windowName(b.primary, '短期额度')) + card(b.secondary, windowName(b.secondary, '长期额度'))).join('')); - $('#updated').textContent = state.sampledAt ? `${clock(state.sampledAt)} 同步` : '等待同步'; - $('#health').textContent = desktopPreferenceError ? `挂件设置未保存:${desktopPreferenceError}` : !connected ? '本地服务断开,正在重连。' : state.demo ? '演示模式 · 模拟额度与任务,不执行真实项目' : state.connection.status !== 'connected' ? state.connection.message : stale ? '额度样本已过期,等待刷新后再调度。' : '已连接 Codex · 实时事件 + 定期同步'; - setHtml($('#storage-status'), storageNotice(state, esc)); - $('#scheduler').checked = state.settings.enabled; - $('.scheduler').classList.toggle('paused', !state.settings.enabled); - $('#scheduler-note').textContent = state.settings.enabled ? `剩余额度 ≥ ${state.settings.minimumRemaining}% 时自动接续` : '调度已暂停 · 当前轮次仍会继续'; - $('#count').textContent = state.tasks.length; - setHtml($('#tasks'), state.tasks.map(t => `
    ${esc(taskLabel(t))}
    ${esc(t.cwd)}
    ${taskMode(t, state.settings).wake ? '原聊天中查看' : t.progress == null ? '无计划' : `${t.progress}%`}

    ${esc(t.activity)}

    ${esc(taskMode(t, state.settings).counter)}

    ${t.status !== 'completed' ? `` : ''}
    `).join('') || '

    还没有工作
    选择上方对话加入管理,或新建工作。

    '); - setHtml($('#approvals'), approvalDetails.readAll(state.approvals).map(a => `
    操作等待授权

    ${esc(a.reason)}

    ${esc(a.command || a.method)}\n${esc(a.cwd)}
    ${a.requiresDetails ? '

    完整授权内容尚未加载,请刷新重试。

    ' : ''}
    `).join('')); - setHtml($('#logs'), state.logs.slice(0, 10).map(l => `
    ${esc(l.message)}
    `).join('') || '

    暂无活动

    '); - if (!settingsInitialized) { for (const key of ['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends']) $(`#settings-form [name=${key}]`).value = state.settings[key]; settingsInitialized = true; } - refreshSessionsIfNeeded(); - updateSessionSelection(); - syncActivity(); - if ($('#modal').open && detailId) { - if (state.tasks.some(t => t.id === detailId)) detail(detailId); - else { $('#modal').close(); detailId = null; } + function updateSessionSelection() { + const t = selectedSession(); + const managed = t && state?.tasks.find(task => task.threadId === t.id); + $('#add-session').disabled = !t; + $('#add-session').textContent = managed ? tr("ui.view_added_work") : tr("ui.add_to_my_work"); + $('#session-preview').textContent = sessionError ? tr("ui.could_not_load_click_chats_to_retry", { p0: systemText(sessionError) }) : t ? `${t.cwd || tr("ui.no_project_directory")}${t.updatedAt ? tr("ui.updated", { p0: new Date(t.updatedAt * 1000).toLocaleString(locale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }) : ''}` : tr("ui.choose_a_chat_sorted_by_most_recently"); } -} -function open(title, html) { detailId = null; $('#modal-title').textContent = title; $('#modal-body').innerHTML = html; $('#error').textContent = ''; if (!$('#modal').open) $('#modal').showModal(); } -function detail(id) { - const summary = state.tasks.find(t => t.id === id); if (!summary) return; - const t = taskDetails.read(summary); - const mode = taskMode(t, state.settings); - $('#modal-title').textContent = t.name; - $('#modal-body').innerHTML = `

    ${esc(mode.description)}

    ${esc(t.cwd)}
    ${esc(taskLabel(t))} · ${esc(mode.counter)}

    ${mode.wake ? `

    ${esc(wakeExplanation)}

    ` : `
      ${(t.plan || []).map(s => `
    1. ${s.status === 'completed' ? '✓' : s.status === 'inProgress' ? '◉' : '○'} ${esc(s.step)}
    2. `).join('')}
    `}

    ${esc(t.activity)}

    ${!mode.wake && t.outputTruncated ? '

    输出较长,仅保留末尾预览;完整内容请在原 Codex 会话查看。

    ' : ''}${!mode.wake && t.output ? `
    ${esc(t.output)}
    ` : ''}${t.status !== 'completed' ? `` : ''}`; -} -async function sessions(cursor, append = false) { if (!append) open('导入已有会话', '

    正在读取本机会话…

    '); const data = await api('/api/threads' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : '')); const remaining = 300 - (append ? $('#modal-body').querySelectorAll('.session').length : 0); const html = (data.data || []).slice(0, remaining).map(t => ``).join(''); if (append) { $('#more')?.remove(); $('#modal-body').insertAdjacentHTML('beforeend', html); } else $('#modal-body').innerHTML = html || '

    没有可导入的会话

    '; if (data.nextCursor && remaining > (data.data || []).length) $('#modal-body').insertAdjacentHTML('beforeend', ``); } -document.addEventListener('click', async e => { const b = e.target.closest('[data-action]'); if (!b) return; const { action, id, token } = b.dataset; try { - if (action === 'sessions-refresh' || action === 'sessions-more') { await readSessions(action === 'sessions-more'); return; } - if (action === 'add-session') { - const t = selectedSession(); if (!t) return; - const managed = state.tasks.find(task => task.threadId === t.id); - if (managed) { open('工作详情', ''); detailId = managed.id; detail(managed.id); return; } - open('监测原聊天额度', `
    ${esc(t.name || t.preview || 'Codex 对话')}

    ${esc(t.cwd)}

    加入后保持暂停。${esc(wakeExplanation)}

    `); return; + async function readSessions(more = false) { + if (sessionLoading) return; + sessionLoading = true; + sessionError = null; + lastSessionAttempt = Date.now(); + const chosen = $('#session-select').value || localStorage.getItem('continuer-selected-session') || ''; + renderSessionOptions(); + try { + const result = await api('/api/threads' + (more && sessionCursor ? `?cursor=${encodeURIComponent(sessionCursor)}` : '')); + const retained = sessionItems.find(t => t.id === ($('#session-select').value || chosen)); + sessionItems = [...new Map([...(more ? sessionItems : []), ...(result.data || []), ...(!more && retained && !(result.data || []).some(t => t.id === retained.id) ? [retained] : [])].map(t => [t.id, t])).values()].slice(0, 300); + sessionCursor = sessionItems.length < 300 ? result.nextCursor : null; + lastSessionRead = Date.now(); + } catch (error) { sessionError = error; } + finally { sessionLoading = false; renderSessionOptions(); } } - if (action === 'new') { open('新建工作', `

    创建后进入执行队列。需要额外权限时会等待你确认。

    `); return; } - if (action === 'detail') { open('工作详情', ''); detailId = id; detail(id); return; } - if (action === 'pick') { const path = await bridge.pickDirectory(); if (path) $('#new-form [name=cwd]').value = path; return; } - if (action === 'import' || action === 'more') { await sessions(b.dataset.cursor, action === 'more'); return; } - if (action === 'select-session') { open('监测原聊天额度', `

    导入后保持暂停。${esc(wakeExplanation)}

    `); return; } - b.disabled = true; - if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast('恢复完成,调度保持关闭'); } - else if (action === 'refresh') { state = await api('/api/refresh', {}); toast('额度同步请求已完成'); } - else if (['pause', 'resume', 'complete'].includes(action)) state = await api(`/api/tasks/${encodeURIComponent(id)}`, { action }); - else if (action === 'approve' || action === 'decline') { if (action === 'approve' && !approvalDetails.ready(state.approvals.find(item => String(item.id) === id && item.detailToken === token))) throw Error('请先等待完整授权内容加载'); state = await api('/api/approval', { id, token, decision: action === 'approve' ? 'accept' : 'decline' }); } - render(); -} catch (error) { if ($('#modal').open) $('#error').textContent = error.message; else toast(error.message); } finally { b.disabled = false; } }); -document.addEventListener('change', async e => { const input = e.target; try { if (input.id === 'scheduler') state = await api('/api/settings', { enabled: input.checked }); else if (input.dataset.continuous) state = await api(`/api/tasks/${encodeURIComponent(input.dataset.continuous)}`, { action: 'continuous', value: input.checked }); else if (input.id === 'follow') { applyDesktop(await bridge.control('follow', input.checked)); return; } else return; render(); } catch (error) { toast(error.message); render(); } }); -document.addEventListener('submit', async e => { e.preventDefault(); const form = e.target, values = Object.fromEntries(new FormData(form)), button = form.querySelector('[type=submit]'); button.disabled = true; $('#error').textContent = ''; try { if (form.id === 'new-form') state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); else if (form.id === 'import-form') state = await api('/api/import', { threadId: form.dataset.id, note: values.note }); else if (form.id === 'settings-form') state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); if (form.id !== 'settings-form') $('#modal').close(); render(); toast('已保存'); } catch (error) { if ($('#modal').open) $('#error').textContent = error.message; else toast(error.message); } finally { button.disabled = false; } }); -function setSize(value) { - compactPixels=Math.max(160,Math.min(420,Math.round(Number(value)||256))); - document.documentElement.style.setProperty('--compact-size',`${compactPixels}px`); - $('#compact-size').value=compactPixels; $('#compact-size-value').textContent=`${compactPixels} px`; -} -let nativeTransition,transitionAcknowledged; -function acknowledgeTransition() { - if(!bridge || !nativeTransition || transitionAcknowledged===nativeTransition.id) return; - const id=nativeTransition.id; - transitionAcknowledged=id; - void bridge.control('transition-ready',id).then(result=>{if(nativeTransition?.id===id) applyDesktop(result);}).catch(error=>toast(error.message)); -} -function applyDesktop(s) { - desktopPreferenceError = s.preferenceError || null; - if (desktopPreferenceError) toast(`挂件设置未保存:${desktopPreferenceError}`); - nativeTransition=s.transition; - const expanded=nativeTransition ? nativeTransition.fromExpanded : s.expanded; - if(s.panelSize) { - document.documentElement.style.setProperty('--panel-width',`${s.panelSize.width}px`); - document.documentElement.style.setProperty('--panel-height',`${s.panelSize.height}px`); + const clock = t => t ? new Date(t).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit', hour12: false }) : '—'; + const percent = w => Number.isFinite(w?.usedPercent) ? Math.max(0, Math.min(100, 100 - w.usedPercent)) : null; + function reset(t) { if (!t) return tr("ui.reset_time_unknown"); const m = Math.ceil((t * 1000 - Date.now()) / 60000); return m <= 0 ? tr("ui.waiting_for_recovery_confirmation") : m >= 1440 ? tr("ui.d_h_until_reset", { p0: Math.floor(m / 1440), p1: Math.floor(m % 1440 / 60) }) : m >= 60 ? tr("ui.h_min_until_reset", { p0: Math.floor(m / 60), p1: m % 60 }) : tr("ui.min_until_reset", { p0: m }); } + function windowName(w, fallback) { const m = w?.windowDurationMins; return m ? m >= 1440 ? tr("ui.day_quota", { p0: m / 1440 }) : tr("ui.hour_quota", { p0: m / 60 }) : fallback; } + function toast(text) { $('#toast').textContent = text; $('#toast').classList.add('show'); clearTimeout(timer); timer = setTimeout(() => $('#toast').classList.remove('show'), 3500); } + function card(w, title) { const p = percent(w); return `
    ${esc(title)}
    ${p === null ? '—' : Math.round(p)}${p === null ? tr("ui.unknown_2") : tr("ui.remaining")}
    ${reset(w?.resetsAt)}
    `; } + function renderGauge() { + if (!state || document.hidden || !nativeVisible || !document.body.classList.contains('collapsed')) return; + const reading = quotaWindow(state, windowSlot, connected, Date.now()); + const available = reading.remaining !== null; + const setText=(selector,value) => { const element=$(selector),text=String(value); if(element.textContent!==text) element.textContent=text; }; + setText('#mini-quota',available ? Math.round(reading.remaining) : '—'); + setText('#mini-reset',resetCountdown(reading.resetsAt, Date.now())); + const countdown=$('#mini-reset').textContent; + const numeric=/^\d/.test(countdown); + $('#mini-reset').classList.toggle('visually-empty',numeric); + if(numeric && countdown!==lastPixelCountdown) { + const pixels=pixelText(countdown.replace(tr("ui.d_2"),'d')); + const scale=Math.min(1.9,104/pixels.width); + $('#mini-reset-pixels').setAttribute('d',pixels.path); + $('#mini-reset-pixels').setAttribute('transform',`translate(${128-pixels.width*scale/2} 176) scale(${scale})`); + } else if(!numeric && countdown!==lastPixelCountdown) $('#mini-reset-pixels').setAttribute('d',''); + lastPixelCountdown=countdown; + const windowLabel=windowSlot==='primary'?tr("ui.hour"):tr("ui.day"); + setText('#mini-window',tr("ui.available_quota", { p0: windowLabel })); + setText('#mini-reset-label',tr("ui.until_reset_2")); + $('#mini').dataset.window=windowSlot; + $('#quota-dot-primary').classList.toggle('selected',windowSlot==='primary'); + $('#quota-dot-secondary').classList.toggle('selected',windowSlot==='secondary'); + $('#mini').classList.toggle('offline', !available); + $('#mini').title = tr("ui.click_to_expand_drag_to_move_ctrl", { p0: available ? tr("ui.available_quota_time_until_reset", { p0: windowLabel, p1: Math.round(reading.remaining), p2: resetCountdown(reading.resetsAt, Date.now()) }) : tr("ui.waiting_for_quota_data", { p0: windowLabel }) }); + $('#gauge-readout').setAttribute('aria-label',$('#mini').title); } - document.body.classList.toggle('collapsed',!expanded); - $('#mini').setAttribute('aria-expanded',String(expanded)); - document.body.classList.toggle('is-switching',!!s.transitioning); - $('#follow').checked=s.follow; nativeVisible=!!s.visible; setSize(s.compactSize); - syncView(); - if(nativeTransition && (reducedMotion.matches || Number(getComputedStyle($('.shell')).opacity)<=.001)) acknowledgeTransition(); -} -$('.shell').addEventListener('transitionend',event=>{ - if(event.target===$('.shell') && event.propertyName==='opacity' && Number(getComputedStyle($('.shell')).opacity)<=.001) acknowledgeTransition(); -}); -async function setExpanded(expanded) { - $('#modal').close(); - if(bridge) { try { applyDesktop(await bridge.control('expand',expanded)); } catch(error) { toast(error.message); } } - else { document.body.classList.toggle('collapsed',!expanded); $('#mini').setAttribute('aria-expanded',String(expanded)); syncView(); } -} -let pointerDrag=null,dragSequence=0; -let pointerResult=Promise.resolve({ moved:false }); -$('#mini').addEventListener('pointerdown',e => { - if(e.button!==0 || !e.isPrimary || pointerDrag) return; - const drag={id:e.pointerId,session:++dragSequence,x:e.screenX,y:e.screenY,moved:false}; - pointerDrag=drag; - $('#mini').setPointerCapture(e.pointerId); - drag.started=bridge ? bridge.control('drag-start',drag.session).catch(error => { toast(error.message); return {ok:false}; }) : Promise.resolve({ok:true}); -}); -$('#mini').addEventListener('pointermove',e => { - if(bridge || !pointerDrag || pointerDrag.id!==e.pointerId) return; - if(Math.hypot(e.screenX-pointerDrag.x,e.screenY-pointerDrag.y)>6) pointerDrag.moved=true; -}); -function endPointer(e,cancelled=false) { - if(!pointerDrag || (e && pointerDrag.id!==e.pointerId)) return; - const drag=pointerDrag; pointerDrag=null; - if($('#mini').hasPointerCapture(drag.id)) $('#mini').releasePointerCapture(drag.id); - pointerResult=drag.started.then(async started => { - const result=bridge && started.ok ? await bridge.control('drag-end',{id:drag.session,cancelled}) : {moved:drag.moved}; - return {moved:result.moved || cancelled}; - }).catch(error => { toast(error.message); return {moved:true}; }); -} -$('#mini').addEventListener('pointerup',e => endPointer(e)); -$('#mini').addEventListener('pointercancel',e => endPointer(e,true)); -$('#mini').addEventListener('lostpointercapture',e => endPointer(e,true)); -window.addEventListener('blur',() => endPointer(null,true)); -$('#mini').onclick=async e => { e.preventDefault(); const result=await pointerResult; pointerResult=Promise.resolve({moved:false}); if(!result.moved) void setExpanded(true); }; -$('#mini').addEventListener('keydown',e => { if(e.key==='Enter'||e.key===' ') { e.preventDefault(); void setExpanded(true); } }); -$('#mini').addEventListener('wheel',e => { if(!e.ctrlKey) return; e.preventDefault(); if(!pointerDrag) void changeSize(compactPixels+(e.deltaY<0?16:-16)); },{passive:false}); -async function changeSize(value) { - setSize(value); - if(bridge) { try { applyDesktop(await bridge.control('compact-size',compactPixels)); } catch(error) { toast(error.message); } } - else localStorage.setItem('continuer-compact-size',String(compactPixels)); + function render() { + if (!state) return; + const languageChanged = setLanguage(state.settings.language || 'zh'); applyStaticLanguage(); + if ($('#error').dataset.sourceMessage) setFormError($('#error'), $('#error').errorSource || $('#error').dataset.sourceMessage); + if (languageChanged && modalRenderer && $('#modal').open) refreshLocalizedContent($('#modal-body'), modalRenderer, true); + if (bridge && !rendererAnnounced) { + rendererAnnounced = true; + bridge.control('renderer-ready').then(result => { rendererReady = true; applyDesktop(result); stream.sync(); }).catch(error => { rendererAnnounced = false; toast(systemText(error)); }); + } + if (bridge && rendererReady && lastDesktopLanguage !== (state.settings.language || 'zh')) { + const value = state.settings.language || 'zh'; lastDesktopLanguage = value; + void bridge.control('language', value).catch(error => { lastDesktopLanguage = undefined; toast(systemText(error)); }); + } + renderGauge(); + if(!viewVisible() || document.body.classList.contains('collapsed')) { syncActivity(); return; } + const buckets = Object.values(state.quota?.rateLimitsByLimitId || {}).filter(Boolean); + if (!buckets.length && state.quota?.rateLimits) buckets.push(state.quota.rateLimits); + const stale = !state.sampledAt || Date.now() - state.sampledAt > 90000; + setHtml($('#quotas'), (buckets.length ? buckets : [{}]).map(b => card(b.primary, (buckets.length > 1 ? `${b.limitName || b.limitId} · ` : '') + windowName(b.primary, tr("ui.short_term_quota"))) + card(b.secondary, windowName(b.secondary, tr("ui.long_term_quota")))).join('')); + $('#updated').textContent = state.sampledAt ? tr("ui.synced", { p0: clock(state.sampledAt) }) : tr("ui.waiting_to_sync_2"); + $('#health').textContent = desktopPreferenceError ? tr("ui.widget_settings_were_not_saved", { p0: desktopPreferenceError }) : !connected ? tr("ui.local_service_disconnected_reconnecting_2") : state.demo ? tr("ui.demo_simulated_quota_and_work_no_real") : state.connection.status !== 'connected' ? systemText(state.connection) : stale ? tr("ui.quota_data_is_stale_scheduling_waits_for") : tr("ui.connected_codex_live_updates_periodic_sync"); + setHtml($('#storage-status'), storageNotice(state, esc)); + $('#scheduler').checked = state.settings.enabled; + $('.scheduler').classList.toggle('paused', !state.settings.enabled); + $('#scheduler-note').textContent = state.settings.enabled ? tr("ui.resume_at_2", { p0: state.settings.minimumRemaining }) : tr("ui.scheduling_paused_current_turn_continues"); + $('#count').textContent = state.tasks.length; + const taskCards = state.tasks.map(task => taskCardHtml(task, state.settings, 'widget')).join(''); + setHtml($('#tasks'), taskCards || `

    ${tr("ui.no_work_yet")}
    ${tr("ui.add_a_chat_above_or_create_new")}

    `); + setHtml($('#approvals'), approvalDetails.readAll(state.approvals).map(a => `
    ${tr("ui.approval_required")}

    ${esc(a.reason)}

    ${esc(a.command || a.method)}
    +${esc(a.cwd)}
    ${a.requiresDetails ? `

    ${tr("ui.full_approval_details_are_not_loaded_refresh_2")}

    ` : ''}
    `).join('')); + setHtml($('#logs'), state.logs.slice(0, 10).map(l => `
    ${esc(systemText(l))}
    `).join('') || `

    ${tr("ui.no_activity_yet_2")}

    `); + if (!settingsInitialized) { for (const key of ['minimumRemaining', 'pollSeconds', 'maxRounds', 'maxWakeSends']) $(`#settings-form [name=${key}]`).value = state.settings[key]; settingsInitialized = true; } + refreshSessionsIfNeeded(); + renderSessionOptions(); + syncActivity(); + if ($('#modal').open && detailId) { + if (state.tasks.some(t => t.id === detailId)) detail(detailId); + else { $('#modal').close(); detailId = null; } + } + } + function open(title, html) { modalRenderer = null; sessionModal = null; detailId = null; $('#modal-title').textContent = title; $('#modal-body').innerHTML = html; setFormError($('#error'), ''); if (!$('#modal').open) $('#modal').showModal(); } + function openLocalized(render) { + open('', ''); + modalRenderer = () => { const view = render(); $('#modal-title').textContent = view.title; return view.html; }; + refreshLocalizedContent($('#modal-body'), modalRenderer); + } + + function detail(id) { + const summary = state.tasks.find(task => task.id === id); + if (!summary) return; + const task = taskDetails.read(summary); + $('#modal-title').textContent = task.name; + $('#modal-body').innerHTML = taskDetailHtml(task, state.settings, { + layout: 'widget', canEdit: taskDetails.ready(summary), + }); + } + async function sessions(cursor, append = false) { + if (!append) { + const list = { data: [], nextCursor: null, loading: true, error: null }; + openLocalized(() => { + const options = list.data.map(t => ``).join(''); + const empty = list.loading ? `

    ${tr("ui.loading_local_chats")}

    ` : list.error ? `

    ${tr("ui.chats_unavailable")}

    ` : `

    ${tr("ui.no_chats_available_to_import")}

    `; + const more = list.nextCursor ? `` : ''; + const retry = list.error ? `` : ''; + return { title: tr("ui.import_an_existing_chat"), html: (options || empty) + more + retry }; + }); + sessionModal = { list, render: modalRenderer }; + } + const current = sessionModal; + if (!current) return; + current.list.loading = true; current.list.error = null; setFormError($('#error'), ''); + refreshLocalizedContent($('#modal-body'), current.render); + try { + const result = await api('/api/threads' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : '')); + if (modalRenderer !== current.render || !$('#modal').open) return; + const remaining = 300 - (append ? current.list.data.length : 0); + current.list.data = [...(append ? current.list.data : []), ...(result.data || []).slice(0, remaining)]; + current.list.nextCursor = remaining > (result.data || []).length ? result.nextCursor : null; + } catch (error) { + current.list.error = error; + if (modalRenderer === current.render && $('#modal').open) setFormError($('#error'), error); + } finally { + current.list.loading = false; + if (modalRenderer === current.render && $('#modal').open) refreshLocalizedContent($('#modal-body'), current.render); + } + } + + + document.addEventListener('click', async e => { const b = e.target.closest('[data-action]'); if (!b) return; const { action, id, token } = b.dataset; try { + if (action === 'sessions-refresh' || action === 'sessions-more') { await readSessions(action === 'sessions-more'); return; } + if (action === 'add-session') { + const t = selectedSession(); if (!t) return; + const managed = state.tasks.find(task => task.threadId === t.id); + if (managed) { open(tr("ui.work_details"), ''); detailId = managed.id; detail(managed.id); return; } + openLocalized(() => ({ + title: tr("ui.monitor_original_chat_quota"), html: monitorImportForm(t.id, { layout: 'widget', thread: t }), + })); + return; + } + if (action === 'new') { + openLocalized(() => ({ title: tr("ui.new_work"), html: newTaskForm({ layout: 'widget', pickDirectory: !!bridge }) })); + return; + } + if (action === 'detail') { open(tr("ui.work_details"), ''); detailId = id; detail(id); return; } + if (action === 'edit-message') { + const summary = state.tasks.find(task => task.id === id), task = summary && taskDetails.read(summary); + if (!task || task.mode !== 'quota-wake') throw Error(tr("ui.only_monitored_chats_support_editing_continuation_messages")); + if (!taskDetails.ready(summary)) throw Error(tr("ui.wait_for_the_full_work_details_to")); + openLocalized(() => ({ title: tr("ui.edit_continuation_message"), html: continuationEditor(task, esc) })); return; + } + if (action === 'pick') { const path = await bridge.pickDirectory(); if (path) $('#new-form [name=cwd]').value = path; return; } + if (action === 'import' || action === 'more') { await sessions(b.dataset.cursor, action === 'more'); return; } + if (action === 'select-session') { + openLocalized(() => ({ title: tr("ui.monitor_original_chat_quota"), html: monitorImportForm(id, { layout: 'widget' }) })); + return; + } + b.disabled = true; + if (action === 'storage-retry' || action === 'storage-backup') { state = await api('/api/storage/recover', { source: action === 'storage-backup' ? 'backup' : 'retry' }); toast(tr("ui.storage_recovered_scheduling_remains_off")); } + else if (action === 'refresh') { state = await api('/api/refresh', {}); toast(tr("ui.quota_sync_requested")); } + else if (['pause', 'resume', 'complete'].includes(action)) state = await api(`/api/tasks/${encodeURIComponent(id)}`, { action }); + else if (action === 'approve' || action === 'decline') { if (action === 'approve' && !approvalDetails.ready(state.approvals.find(item => String(item.id) === id && item.detailToken === token))) throw Error(tr("ui.wait_for_the_full_approval_details_to")); state = await api('/api/approval', { id, token, decision: action === 'approve' ? 'accept' : 'decline' }); } + render(); + } catch (error) { if ($('#modal').open) setFormError($('#error'), error); else toast(systemText(error)); } finally { b.disabled = false; } }); + document.addEventListener('change', async e => { const input = e.target; try { if (input.id === 'language') { input.disabled = true; try { state = await api('/api/settings', { language: input.value }); } finally { input.disabled = false; } } else if (input.id === 'scheduler') state = await api('/api/settings', { enabled: input.checked }); else if (input.dataset.continuous) state = await api(`/api/tasks/${encodeURIComponent(input.dataset.continuous)}`, { action: 'continuous', value: input.checked }); else if (input.id === 'follow') { applyDesktop(await bridge.control('follow', input.checked)); return; } else return; render(); } catch (error) { toast(systemText(error)); render(); } }); + document.addEventListener('submit', async e => { e.preventDefault(); const form = e.target, values = Object.fromEntries(new FormData(form)), button = form.querySelector('[type=submit]'); button.disabled = true; setFormError($('#error'), ''); try { if (form.id === 'new-form') state = await api('/api/tasks', { ...values, continuous: values.continuous === 'on' }); else if (form.id === 'import-form') state = await api('/api/import', { threadId: form.dataset.id, note: values.note, continuationMessage: values.continuationMessage }); else if (form.id === 'message-form') { state = await api(`/api/tasks/${encodeURIComponent(form.dataset.id)}`, { action: 'message', value: values.continuationMessage }); taskDetails.clear(); } else if (form.id === 'settings-form') state = await api('/api/settings', Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Number(v)]))); if (form.id !== 'settings-form') $('#modal').close(); render(); toast(form.id === 'message-form' ? tr("ui.continuation_message_saved") : tr("ui.saved")); } catch (error) { if ($('#modal').open) setFormError($('#error'), error); else toast(systemText(error)); } finally { button.disabled = false; } }); + function setSize(value) { + compactPixels=Math.max(160,Math.min(420,Math.round(Number(value)||256))); + document.documentElement.style.setProperty('--compact-size',`${compactPixels}px`); + $('#compact-size').value=compactPixels; $('#compact-size-value').textContent=`${compactPixels} px`; + } + let nativeTransition,transitionAcknowledged; + function acknowledgeTransition() { + if(!bridge || !nativeTransition || transitionAcknowledged===nativeTransition.id) return; + const id=nativeTransition.id; + transitionAcknowledged=id; + void bridge.control('transition-ready',id).then(result=>{if(nativeTransition?.id===id) applyDesktop(result);}).catch(error=>toast(systemText(error))); + } + function applyDesktop(s) { + desktopPreferenceError = s.preferenceError || null; + if (desktopPreferenceError) toast(tr("ui.widget_settings_were_not_saved", { p0: desktopPreferenceError })); + nativeTransition=s.transition; + const expanded=nativeTransition ? nativeTransition.fromExpanded : s.expanded; + if(s.panelSize) { + document.documentElement.style.setProperty('--panel-width',`${s.panelSize.width}px`); + document.documentElement.style.setProperty('--panel-height',`${s.panelSize.height}px`); + } + document.body.classList.toggle('collapsed',!expanded); + $('#mini').setAttribute('aria-expanded',String(expanded)); + document.body.classList.toggle('is-switching',!!s.transitioning); + $('#follow').checked=s.follow; nativeVisible=!!s.visible; setSize(s.compactSize); + syncView(); + if(nativeTransition && (reducedMotion.matches || Number(getComputedStyle($('.shell')).opacity)<=.001)) acknowledgeTransition(); + } + $('.shell').addEventListener('transitionend',event=>{ + if(event.target===$('.shell') && event.propertyName==='opacity' && Number(getComputedStyle($('.shell')).opacity)<=.001) acknowledgeTransition(); + }); + async function setExpanded(expanded) { + $('#modal').close(); + if(bridge) { try { applyDesktop(await bridge.control('expand',expanded)); } catch(error) { toast(systemText(error)); } } + else { document.body.classList.toggle('collapsed',!expanded); $('#mini').setAttribute('aria-expanded',String(expanded)); syncView(); } + } + let pointerDrag=null,dragSequence=0; + let pointerResult=Promise.resolve({ moved:false }); + $('#mini').addEventListener('pointerdown',e => { + if(e.button!==0 || !e.isPrimary || pointerDrag) return; + const drag={id:e.pointerId,session:++dragSequence,x:e.screenX,y:e.screenY,moved:false}; + pointerDrag=drag; + $('#mini').setPointerCapture(e.pointerId); + drag.started=bridge ? bridge.control('drag-start',drag.session).catch(error => { toast(systemText(error)); return {ok:false}; }) : Promise.resolve({ok:true}); + }); + $('#mini').addEventListener('pointermove',e => { + if(bridge || !pointerDrag || pointerDrag.id!==e.pointerId) return; + if(Math.hypot(e.screenX-pointerDrag.x,e.screenY-pointerDrag.y)>6) pointerDrag.moved=true; + }); + function endPointer(e,cancelled=false) { + if(!pointerDrag || (e && pointerDrag.id!==e.pointerId)) return; + const drag=pointerDrag; pointerDrag=null; + if($('#mini').hasPointerCapture(drag.id)) $('#mini').releasePointerCapture(drag.id); + pointerResult=drag.started.then(async started => { + const result=bridge && started.ok ? await bridge.control('drag-end',{id:drag.session,cancelled}) : {moved:drag.moved}; + return {moved:result.moved || cancelled}; + }).catch(error => { toast(systemText(error)); return {moved:true}; }); + } + $('#mini').addEventListener('pointerup',e => endPointer(e)); + $('#mini').addEventListener('pointercancel',e => endPointer(e,true)); + $('#mini').addEventListener('lostpointercapture',e => endPointer(e,true)); + window.addEventListener('blur',() => endPointer(null,true)); + $('#mini').onclick=async e => { e.preventDefault(); const result=await pointerResult; pointerResult=Promise.resolve({moved:false}); if(!result.moved) void setExpanded(true); }; + $('#mini').addEventListener('keydown',e => { if(e.key==='Enter'||e.key===' ') { e.preventDefault(); void setExpanded(true); } }); + $('#mini').addEventListener('wheel',e => { if(!e.ctrlKey) return; e.preventDefault(); if(!pointerDrag) void changeSize(compactPixels+(e.deltaY<0?16:-16)); },{passive:false}); + async function changeSize(value) { + setSize(value); + if(bridge) { try { applyDesktop(await bridge.control('compact-size',compactPixels)); } catch(error) { toast(systemText(error)); } } + else localStorage.setItem('continuer-compact-size',String(compactPixels)); + } + $('#compact-size').addEventListener('input',e => { void changeSize(e.target.value); }); + $('#collapse').onclick=() => { void setExpanded(false); }; + if(bridge) { + bridge.onState(applyDesktop); bridge.control('state').then(applyDesktop).catch(e=>toast(e.message)); + $('#quit').onclick=()=>bridge.control('quit'); + $('#reset-position').onclick=()=>bridge.control('reset-position').then(()=>toast(tr("ui.widget_position_reset"))); + } else { document.body.classList.add('browser'); setSize(Number(localStorage.getItem('continuer-compact-size'))||256); } + document.addEventListener('visibilitychange',syncView); + $('#session-select').onchange = () => { localStorage.setItem('continuer-selected-session', $('#session-select').value); updateSessionSelection(); }; + $('#close').onclick = () => $('#modal').close(); $('#modal').addEventListener('close', () => { if ($('#modal').open) return; modalRenderer = null; sessionModal = null; detailId = null; taskDetails.clear(); }); + if (localStorage.getItem('continuer-rhine-theme') === 'light') document.body.classList.add('light'); + $('#theme').onclick = () => { document.body.classList.toggle('light'); localStorage.setItem('continuer-rhine-theme', document.body.classList.contains('light') ? 'light' : 'dark'); }; + const stream = new VisibleFeed({ ...(environment.createSource ? { create: environment.createSource } : {}), visible: viewVisible, bootstrap: () => !!bridge && !rendererReady, receive: data => { connected = true; state = data; render(); }, error: () => { connected = false; if (state) render(); else $('#health').textContent = tr("ui.could_not_connect_to_the_local_service_2"); } }); + window.addEventListener('pagehide', () => { stream.close(); nativeVisible = false; syncActivity(); }); + window.addEventListener('pageshow', () => { if (!bridge) nativeVisible = true; syncView(); }); + stream.sync(); } -$('#compact-size').addEventListener('input',e => { void changeSize(e.target.value); }); -$('#collapse').onclick=() => { void setExpanded(false); }; -if(bridge) { - bridge.onState(applyDesktop); bridge.control('state').then(applyDesktop).catch(e=>toast(e.message)); - $('#quit').onclick=()=>bridge.control('quit'); - $('#reset-position').onclick=()=>bridge.control('reset-position').then(()=>toast('挂件位置已重置')); -} else { document.body.classList.add('browser'); setSize(Number(localStorage.getItem('continuer-compact-size'))||256); } -document.addEventListener('visibilitychange',syncView); -$('#session-select').onchange = () => { localStorage.setItem('continuer-selected-session', $('#session-select').value); updateSessionSelection(); }; -$('#close').onclick = () => $('#modal').close(); $('#modal').addEventListener('close', () => { detailId = null; taskDetails.clear(); }); -if (localStorage.getItem('continuer-rhine-theme') === 'light') document.body.classList.add('light'); -$('#theme').onclick = () => { document.body.classList.toggle('light'); localStorage.setItem('continuer-rhine-theme', document.body.classList.contains('light') ? 'light' : 'dark'); }; -const stream = new VisibleFeed({ visible: viewVisible, bootstrap: () => !!bridge && !rendererReady, receive: data => { connected = true; state = data; render(); }, error: () => { connected = false; if (state) render(); else $('#health').textContent = '本地服务连接失败,正在重连…'; } }); -window.addEventListener('pagehide', () => { stream.close(); nativeVisible = false; syncActivity(); }); -window.addEventListener('pageshow', () => { if (!bridge) nativeVisible = true; syncView(); }); -stream.sync(); + +if (typeof document !== 'undefined' && typeof window !== 'undefined') mountWidget(); diff --git a/server/demo.js b/server/demo.js index 32e7a50..766f7ea 100644 --- a/server/demo.js +++ b/server/demo.js @@ -1,3 +1,4 @@ +import { activityFields, messageFields, message as textMessage } from '../public/message.js'; import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; @@ -9,7 +10,7 @@ export class DemoRpc extends EventEmitter { async call(method, p = {}) { if (method === 'account/rateLimits/read') return { rateLimitsByLimitId: { codex: { limitId: 'codex', planType: 'plus', primary: { usedPercent: this.used, windowDurationMins: 300, resetsAt: Math.floor(Date.now() / 1000) + 7380 }, secondary: { usedPercent: 42, windowDurationMins: 10080, resetsAt: Math.floor(Date.now() / 1000) + 192600 } } } }; if (method === 'thread/start') return { thread: { id: randomUUID() } }; - if (method === 'thread/read' || method === 'thread/resume') return { thread: { id: p.threadId, name: '示例会话', cwd: 'E:\\projects\\demo', status: { type: 'idle' } } }; + if (method === 'thread/read' || method === 'thread/resume') return { thread: { id: p.threadId, name: '示例会话', cwd: 'E:\\projects\\demo', status: { type: 'idle' }, turns: p.includeTurns ? [{ id: 'demo-quota-turn', status: 'failed', items: [{ type: 'userMessage', id: 'demo-instruction', content: [{ type: 'text', text: '演示续作' }] }], error: { codexErrorInfo: 'usageLimitExceeded' } }] : [] } }; if (method === 'thread/list') return { data: [{ id: 'demo-existing-thread', name: '个人知识库', cwd: 'E:\\projects\\knowledge', preview: '为个人知识库添加全文搜索' }], nextCursor: null }; if (method === 'turn/interrupt') { for (const timer of this.timers.get(p.threadId) || []) clearTimeout(timer); @@ -44,11 +45,11 @@ export function demoState() { return { settings: { enabled: true, pollSeconds: 30, minimumRemaining: 5, maxRounds: 20, maxWakeSends: 20 }, tasks: [ - { id: 'demo-1', name: '个人知识库', cwd: 'E:\\Projects\\knowledge-hub', prompt: '完成个人知识库的全文搜索,并验证搜索结果。', continuous: true, status: 'paused', activity: '全文索引已建立,等待继续验证搜索结果', rounds: 2, plan: [{ step: '建立全文索引', status: 'completed' }, { step: '接入搜索界面', status: 'completed' }, { step: '验证搜索结果', status: 'pending' }], output: '', createdAt: now, updatedAt: now }, - { id: 'demo-2', name: '设计系统 · 组件文档', cwd: 'E:\\Projects\\design-system', prompt: '为组件库补充使用文档与示例。', continuous: false, status: 'paused', activity: '已完成 Button 和 Input,等待继续', rounds: 1, plan: [{ step: '基础组件文档', status: 'completed' }, { step: '布局组件文档', status: 'pending' }, { step: '交互示例', status: 'pending' }, { step: '校验文档', status: 'pending' }], output: '', createdAt: now, updatedAt: now }, - { id: 'demo-3', name: 'API 服务 · 单元测试', cwd: 'E:\\Projects\\api-service', prompt: '完善 API 服务的单元测试。', continuous: false, status: 'completed', activity: '演示记录:测试用例已补齐', rounds: 3, plan: [{ step: '编写用例', status: 'completed' }, { step: '运行验证', status: 'completed' }], output: '', createdAt: now, updatedAt: now }, + { id: 'demo-1', name: '个人知识库', cwd: 'E:\\Projects\\knowledge-hub', prompt: '完成个人知识库的全文搜索,并验证搜索结果。', continuous: true, status: 'paused', ...activityFields(textMessage("demo.search_ready")), rounds: 2, plan: [{ step: '建立全文索引', status: 'completed' }, { step: '接入搜索界面', status: 'completed' }, { step: '验证搜索结果', status: 'pending' }], output: '', createdAt: now, updatedAt: now }, + { id: 'demo-2', name: '设计系统 · 组件文档', cwd: 'E:\\Projects\\design-system', prompt: '为组件库补充使用文档与示例。', continuous: false, status: 'paused', ...activityFields(textMessage("demo.components_ready")), rounds: 1, plan: [{ step: '基础组件文档', status: 'completed' }, { step: '布局组件文档', status: 'pending' }, { step: '交互示例', status: 'pending' }, { step: '校验文档', status: 'pending' }], output: '', createdAt: now, updatedAt: now }, + { id: 'demo-3', name: 'API 服务 · 单元测试', cwd: 'E:\\Projects\\api-service', prompt: '完善 API 服务的单元测试。', continuous: false, status: 'completed', ...activityFields(textMessage("demo.tests_ready")), rounds: 3, plan: [{ step: '编写用例', status: 'completed' }, { step: '运行验证', status: 'completed' }], output: '', createdAt: now, updatedAt: now }, ], - logs: [{ id: 'l1', time: now, type: 'info', message: '演示模式已启动,所有额度与工作记录均为模拟数据' }, { id: 'l2', time: now - 180000, type: 'success', message: 'API 服务 · 单元测试:模拟工作已完成' }], + logs: [{ id: 'l1', time: now, type: 'info', ...messageFields(textMessage("demo.started")) }, { id: 'l2', time: now - 180000, type: 'success', ...messageFields(textMessage("demo.work_complete")) }], history: Array.from({ length: 24 }, (_, i) => ({ time: now - (23 - i) * 300000, values: [{ id: 'codex', used: 18 + Math.floor(i * 2.17) }] })), }; } diff --git a/server/direct-task.js b/server/direct-task.js new file mode 100644 index 0000000..74734f0 --- /dev/null +++ b/server/direct-task.js @@ -0,0 +1,91 @@ +import { MessageError, activityFields, setActivity, message as textMessage } from '../public/message.js'; +import { randomUUID } from 'node:crypto'; +import { ClientError } from './http-policy.js'; +import { checkedProjectPath } from './project-path.js'; +import { isWriterConflict, parseOutcome } from './policy.js'; +import { OUTPUT_LIMIT, retainOutput, outcomeSummary } from './output.js'; + +/** + * @typedef {object} DirectTask + * @property {'direct'} mode + * @property {string} prompt Original goal; never a monitoring note. + * @property {number} rounds Execution rounds; manual resume resets this counter. + * @property {boolean} continuous Whether another round may be scheduled. + * @property {string} status A member of DIRECT_TASK_STATUSES. + */ +export const DIRECT_TASK_STATUSES = Object.freeze(['queued', 'waiting', 'starting', 'running', 'approval', 'pausing', 'paused', 'blocked', 'failed', 'completed']); +export const ACTIVE_TURN_STATUSES = Object.freeze(['running', 'approval', 'pausing']); +const inFlight = ['starting', ...ACTIVE_TURN_STATUSES]; +export const isDirectTask = task => task?.mode === 'direct' || (task != null && task.mode === undefined); +export const isActiveDirectTask = task => isDirectTask(task) && inFlight.includes(task.status); +export const isRunnableDirectTask = (task, now = Date.now()) => isDirectTask(task) + && (task.status === 'queued' || (task.status === 'waiting' && task.continuous)) && (!task.retryAt || now >= task.retryAt); + +export function createDirectTask(input, { demo = false } = {}) { + for (const key of ['name', 'cwd', 'prompt']) if (typeof input[key] !== 'string' || !input[key].trim()) throw new ClientError(textMessage("error.enter_a_name_project_directory_and_work_goal")); + if (input.name.length > 120 || input.prompt.length > 12000) throw new ClientError(textMessage("error.name_or_goal_is_too_long")); + const cwd = checkedProjectPath(input.cwd, { demo }), now = Date.now(); + return { id: randomUUID(), mode: 'direct', name: input.name.trim(), cwd, prompt: input.prompt.trim(), threadId: null, continuous: input.continuous === true, status: 'queued', ...activityFields(textMessage("task.waiting_for_scheduling")), rounds: 0, plan: [], output: '', createdAt: now, updatedAt: now }; +} + +export function waitForThread(task, reason) { + task.status = task.continuous ? 'waiting' : 'blocked'; + task.waitReason = task.continuous ? reason : null; + task.retryAt = task.continuous ? Date.now() + 30000 : 0; + const id = reason === 'writer' ? (task.continuous ? 'task.wait_writer_retry' : 'task.wait_writer_manual') + : (task.continuous ? 'task.wait_thread_retry' : 'task.wait_thread_manual'); + setActivity(task, textMessage(id)); +} + +function prepare(task) { + let changed = false; + if (task.mode === undefined) { task.mode = 'direct'; changed = true; } + if (task.rounds === undefined) { task.rounds = 0; changed = true; } + if (task.plan?.length > 100) { task.plan = task.plan.slice(0, 100); changed = true; } + for (const step of task.plan || []) if (typeof step.step === 'string' && step.step.length > 2000) { step.step = outcomeSummary(step.step); changed = true; } + if (typeof task.output === 'string' && task.output.length > OUTPUT_LIMIT) { + task.outcome = parseOutcome(task.output); retainOutput(task, task.output); changed = true; + } + if (task.status === 'blocked' && task.continuous && isWriterConflict({ message: task.activity })) { + task.lastError = task.activity; waitForThread(task, 'writer'); changed = true; + } + if (isActiveDirectTask(task)) { + task.status = 'blocked'; setActivity(task, textMessage("task.service_restarted_confirm_the_original_chat_has_stopped")); changed = true; + } + return changed; +} + +function validate(task) { + if (!DIRECT_TASK_STATUSES.includes(task.status)) throw new MessageError(textMessage("error.direct_state")); + if (task.rounds !== undefined && (!Number.isSafeInteger(task.rounds) || task.rounds < 0)) throw new MessageError(textMessage("error.direct_rounds")); + if (task.wake !== undefined || task.sentCount !== undefined || task.continuationMessage !== undefined) throw new MessageError(textMessage("error.direct_monitor_fields")); +} + +function action(task, action, value) { + if (action === 'continuous') { + if (typeof value !== 'boolean') throw new ClientError(textMessage("error.invalid_switch_value")); + task.continuous = value; + if (!value && ['waiting', 'queued'].includes(task.status)) { task.status = 'paused'; setActivity(task, textMessage("task.continuous_work_disabled")); } + if (value && task.status === 'blocked' && isWriterConflict({ message: task.activity })) waitForThread(task, 'writer'); + } else if (action === 'pause') { + const active = ACTIVE_TURN_STATUSES.includes(task.status); + task.status = active ? 'pausing' : 'paused'; setActivity(task, active ? textMessage("task.pausing") : textMessage("task.work_paused")); + task.waitReason = null; task.retryAt = 0; + return { kind: 'pause-turn', interrupt: active && !!task.turnId }; + } else if (action === 'resume') { + if (isActiveDirectTask(task)) throw new ClientError(textMessage("error.work_is_still_running_or_stopping")); + task.status = 'queued'; task.rounds = 0; task.retryAt = 0; setActivity(task, textMessage("task.waiting_for_available_quota")); + } else if (action === 'complete') { + if (isActiveDirectTask(task)) throw new ClientError(textMessage("error.pause_the_work_first")); + task.status = 'completed'; setActivity(task, textMessage("task.user_marked_complete")); + } else throw new ClientError(textMessage("error.unsupported_action")); + if (task.status !== 'waiting') { task.waitReason = null; if (task.status === 'paused') task.retryAt = 0; } + return { kind: 'changed', activate: action === 'resume' || (action === 'continuous' && value === true) }; +} + +function restoreBackup(task) { + task.continuous = false; task.status = 'blocked'; task.turnId = null; + setActivity(task, textMessage("task.backup_restored_check_the_original_chats_and_projects")); +} + +export const directTaskModel = Object.freeze({ prepare, validate, action, restoreBackup, retryStorage() {} }); diff --git a/server/http-body.js b/server/http-body.js index 036924e..d755c22 100644 --- a/server/http-body.js +++ b/server/http-body.js @@ -1,19 +1,20 @@ +import { message as textMessage } from '../public/message.js'; // Decode across network chunk boundaries; never replace a split UTF-8 codepoint. import { ClientError } from './http-policy.js'; export async function readJsonBody(req) { const decoder = new TextDecoder('utf-8', { fatal: true }); - const decode = (chunk, options) => { try { return decoder.decode(chunk, options); } catch { throw new ClientError('请求正文不是有效 UTF-8'); } }; + const decode = (chunk, options) => { try { return decoder.decode(chunk, options); } catch { throw new ClientError(textMessage("error.invalid_utf8")); } }; let content = '', bytes = 0; for await (const chunk of req) { bytes += chunk.length; - if (bytes > 160000) throw new ClientError('请求过大'); + if (bytes > 160000) throw new ClientError(textMessage("error.request_too_large")); content += decode(chunk, { stream: true }); - if (content.length > 40000) throw new ClientError('请求过大'); + if (content.length > 40000) throw new ClientError(textMessage("error.request_too_large")); } content += decode(); - if (content.length > 40000) throw new ClientError('请求过大'); + if (content.length > 40000) throw new ClientError(textMessage("error.request_too_large")); let result; - try { result = content ? JSON.parse(content) : {}; } catch { throw new ClientError('请求正文不是有效 JSON'); } - if (!result || typeof result !== 'object' || Array.isArray(result)) throw new ClientError('请求正文必须为 JSON 对象'); + try { result = content ? JSON.parse(content) : {}; } catch { throw new ClientError(textMessage("error.invalid_json")); } + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new ClientError(textMessage("error.json_object_required")); return result; } diff --git a/server/http-policy.js b/server/http-policy.js index f69456c..b47cc5e 100644 --- a/server/http-policy.js +++ b/server/http-policy.js @@ -1,9 +1,13 @@ // Only deliberate local validation messages may cross the HTTP boundary. -export class ClientError extends Error {} +import { MessageError, message, errorResponse } from '../public/message.js'; + +export class ClientError extends MessageError {} export function clientErrorMessage(error) { - if (error instanceof ClientError) return error.message; - if (error?.code === 'ENOENT') return '项目目录不存在'; - return '操作失败,请检查本地服务状态后重试'; + return clientErrorResponse(error).error; +} +export function clientErrorResponse(error) { + if (error instanceof ClientError) return errorResponse(error); + return errorResponse(message(error?.code === 'ENOENT' ? 'error.project_missing' : 'error.operation_failed')); } // Fixed-size buckets: unauthenticated traffic cannot spend a client's budget. diff --git a/server/http-routes.js b/server/http-routes.js index 68dda05..99f07a6 100644 --- a/server/http-routes.js +++ b/server/http-routes.js @@ -1,3 +1,4 @@ +import { errorResponse, message as textMessage } from '../public/message.js'; import { readJsonBody } from './http-body.js'; import { ClientError } from './http-policy.js'; @@ -14,8 +15,8 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s ['/api/storage/recover', input => manager.recoverStorage(input.source)], ['/api/tasks', input => manager.create(input)], ['/api/import', input => { - if (input.note !== undefined && input.prompt !== undefined) throw new ClientError('请仅提供监测备注 note;prompt 为旧版兼容字段'); - return manager.importThread(input.threadId, input.note ?? input.prompt); + if (input.note !== undefined && input.prompt !== undefined) throw new ClientError(textMessage("error.note_alias_conflict")); + return manager.importThread(input.threadId, input.note ?? input.prompt, input.continuationMessage); }], ['/api/approval', input => manager.approve(input.id, input.decision, input.token)], ]); @@ -29,9 +30,9 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s else if (path === '/api/logs') json(res, 200, { logs: manager.state.logs }); else if (/^\/api\/tasks\/[^/]+$/.test(path)) { const task = manager.taskSnapshot(decodeURIComponent(path.split('/').pop())); - json(res, task ? 200 : 404, task ? { task } : { error: '任务不存在' }); + json(res, task ? 200 : 404, task ? { task } : errorResponse(textMessage("error.work_item_not_found"))); } else if (path === '/api/events') { - if (!stream.add(req, res, summary())) json(res, 429, { error: '实时连接已达上限,请关闭多余页面' }); + if (!stream.add(req, res, summary())) json(res, 429, errorResponse(textMessage("error.stream_limit"))); } else if (path === '/api/threads') json(res, 200, await manager.listThreads(url.searchParams.get('cursor'))); else return false; return true; @@ -40,7 +41,7 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s if (/^\/api\/approvals\/[^/]+$/.test(path)) { const input = await readJsonBody(req), approval = manager.approvals.get(decodeURIComponent(path.split('/').pop())); if (typeof input.token === 'string' && approval && approval.detailToken === input.token) json(res, 200, { approval }); - else json(res, 404, { error: '授权请求已失效' }); + else json(res, 404, errorResponse(textMessage("error.approval_expired"))); return true; } const taskId = /^\/api\/tasks\/[^/]+$/.test(path) ? decodeURIComponent(path.split('/').pop()) : null; diff --git a/server/index.js b/server/index.js index fe2d3a2..b94055a 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import { errorResponse, message as textMessage } from '../public/message.js'; import { createServer } from 'node:https'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -16,7 +17,7 @@ import { createStaticAssets } from './static-assets.js'; import access from './access.cjs'; import exitState from './exit-state.cjs'; import localTls from './local-tls.cjs'; -import { clientErrorMessage, createRequestLimiter } from './http-policy.js'; +import { clientErrorResponse, createRequestLimiter } from './http-policy.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const demo = process.argv.includes('--demo'); @@ -58,21 +59,21 @@ const server = createServer(secureOptions, async (req, res) => { res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'"); // Loopback binding + exact Host + same-origin mutations prevent DNS rebinding/CSRF. - if (req.headers.host !== `127.0.0.1:${port}`) return json(res, 403, { error: '请使用 127.0.0.1 访问' }); - if (req.headers.origin && req.headers.origin !== origin) return json(res, 403, { error: '跨站请求被拒绝' }); - if (req.method !== 'GET' && (req.headers['x-continuer-client'] !== '1' || !req.headers['content-type']?.startsWith('application/json'))) return json(res, 403, { error: '请求来源无效' }); + if (req.headers.host !== `127.0.0.1:${port}`) return json(res, 403, errorResponse(textMessage("error.loopback_required"))); + if (req.headers.origin && req.headers.origin !== origin) return json(res, 403, errorResponse(textMessage("error.cross_origin"))); + if (req.method !== 'GET' && (req.headers['x-continuer-client'] !== '1' || !req.headers['content-type']?.startsWith('application/json'))) return json(res, 403, errorResponse(textMessage("error.invalid_origin"))); try { const url = new URL(req.url, origin); - if (url.origin !== origin) return json(res, 403, { error: '请求来源无效' }); + if (url.origin !== origin) return json(res, 403, errorResponse(textMessage("error.invalid_origin"))); if (url.pathname.startsWith('/api/')) { const authorized = access.authorized(req, accessToken); - if (!allowRequest(authorized, req.method)) { res.setHeader('Retry-After', '1'); return json(res, 429, { error: '请求过于频繁,请稍后重试' }); } - if (!authorized) return json(res, 401, { error: '请用启动脚本重新打开 Continuer;本地 API 需要访问凭据' }); + if (!allowRequest(authorized, req.method)) { res.setHeader('Retry-After', '1'); return json(res, 429, errorResponse(textMessage("error.request_rate"))); } + if (!authorized) return json(res, 401, errorResponse(textMessage("error.api_credentials"))); } if (await routeApi(req, res, url)) return; if (await serveStatic(req, res, url)) return; - json(res, 404, { error: '接口或页面不存在' }); - } catch (error) { json(res, 400, { error: clientErrorMessage(error) }); } + json(res, 404, errorResponse(textMessage("error.not_found"))); + } catch (error) { json(res, 400, clientErrorResponse(error)); } }); server.listen(port, '127.0.0.1', () => { health.record('running'); console.log(`Codex Continuer ${demo ? '(DEMO)' : ''}: ${origin}`); manager.start(); }); server.on('error', error => { console.error(error.message); process.exitCode = 1; health.record('failed', 'listen-error'); manager.safely(() => manager.stop()); }); diff --git a/server/lease.js b/server/lease.js index 4bb41e1..12e594d 100644 --- a/server/lease.js +++ b/server/lease.js @@ -1,3 +1,4 @@ +import { MessageError, message as textMessage } from '../public/message.js'; import { writeFileSync, readFileSync, unlinkSync, mkdirSync, realpathSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; @@ -12,10 +13,10 @@ export async function acquireLease(directory, name) { // libuv uses FILE_FLAG_FIRST_PIPE_INSTANCE on Windows. The OS closes the // exclusive handle after a crash; no stale-path deletion owns exclusion. const address = process.platform === 'win32' ? `\\\\.\\pipe\\continuer-${key}` : process.platform === 'linux' ? `\0continuer-${key}` : null; - if (!address) throw new Error('此平台不支持 Continuer 数据目录互斥锁'); + if (!address) throw new MessageError(textMessage("error.lease_platform")); const guard = createServer(socket => socket.destroy()); await new Promise((resolve, reject) => { - const failed = error => reject(error.code === 'EADDRINUSE' ? new Error('同一数据目录已有服务运行,请勿重复启动') : error); + const failed = error => reject(error.code === 'EADDRINUSE' ? new MessageError(textMessage("error.lease_conflict")) : error); guard.once('error', failed); guard.listen(address, () => { guard.off('error', failed); resolve(); }); }); @@ -24,8 +25,8 @@ export async function acquireLease(directory, name) { // Respect a running pre-upgrade service that does not own the pipe yet. if (existsSync(file)) { let previous; - try { previous = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new Error('服务锁损坏,请检查进程后处理 service.lock'); } - if (!Number.isInteger(previous.pid) || alive(previous.pid)) throw new Error('同一数据目录已有服务运行,请勿重复启动'); + try { previous = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new MessageError(textMessage("error.lease_corrupt")); } + if (!Number.isInteger(previous.pid) || alive(previous.pid)) throw new MessageError(textMessage("error.lease_conflict")); } writeFileSync(file, JSON.stringify({ pid: process.pid, leaseId, startedAt: Date.now() }), { mode: 0o600 }); } catch (error) { await new Promise(resolve => guard.close(resolve)); throw error; } diff --git a/server/manager.js b/server/manager.js index 00a34c3..e8542b5 100644 --- a/server/manager.js +++ b/server/manager.js @@ -1,21 +1,18 @@ +import { boundedMessage, isMessage, messageText, messageFields, setActivity, message as textMessage } from '../public/message.js'; import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; import { ClientError } from './http-policy.js'; -import { checkedProjectPath, localProjectPath } from './project-path.js'; -import { quotaAvailable, isQuotaError, isWriterConflict, parseOutcome, progress, buckets, MAX_POLL_SECONDS } from './policy.js'; -import { resetWake, tickQuotaWake, enforceWakeLimit } from './quota-wake.js'; -import { OUTPUT_LIMIT, retainOutput, outcomeSummary } from './output.js'; +import { quotaAvailable, progress, buckets, MAX_POLL_SECONDS } from './policy.js'; +import { tickQuotaWake } from './quota-wake.js'; +import { removeCompletedTasks } from './state-lifecycle.js'; +import { createDirectTask, isActiveDirectTask, isRunnableDirectTask } from './direct-task.js'; +import { createMonitorTask, validateMonitorInput, isMonitorTask, enforceWakeLimit } from './monitor-task.js'; +import { taskModel } from './task-model.js'; +import { StatePersistence } from './state-persistence.js'; +import { TurnController } from './turn-controller.js'; import config from './config.cjs'; import { compactSummary } from './summary.js'; -const outcomeSchema = { type: 'object', properties: { status: { type: 'string', enum: ['done', 'continue', 'blocked'] }, summary: { type: 'string' } }, required: ['status', 'summary'], additionalProperties: false }; -const instructions = '\n请维护可验证的步骤计划。仅在原始目标全部完成并验证后返回 status=done;还有可独立推进的工作则返回 continue;需要用户输入或权限则返回 blocked。summary 简述证据或阻塞原因。不要因为额度不足声称完成。'; -const OUTPUT_SAVE_INTERVAL_MS = 250; -const TURN_EVENTS = new Set(['turn/started', 'turn/completed', 'turn/plan/updated', 'item/agentMessage/delta', 'item/started', 'item/completed']); -const ACTIVE_TURN_STATUSES = ['running', 'approval', 'pausing']; -const START_EVENT_LIMIT = 256; -const START_EVENT_BYTES = 4 * 1024 * 1024; - export class Manager extends EventEmitter { constructor(rpc, store, { demo = false, wakeSender = null, observeOnly = false } = {}) { super(); @@ -24,92 +21,48 @@ export class Manager extends EventEmitter { this.demo = demo; this.wakeSender = wakeSender; this.wakeQuota = null; this.wakeSampledAt = null; - this.persistence = { status: 'ok', message: null }; this.observationOnly = observeOnly; this.scheduleEpoch = 0; this.stopped = false; this.wakeExecutions = new Map(); this.detailVersions = new WeakMap(); - try { this.state = store.load(); } - catch (error) { this.persistence = { status: 'error', message: error.message, needsRestore: true }; } - this.state ||= { settings: { enabled: !this.persistence.needsRestore, pollSeconds: 30, minimumRemaining: 5, maxRounds: 20, maxWakeSends: 20 }, tasks: [], logs: [], history: [] }; - this.connection = { status: 'disconnected', message: '正在连接本机 Codex…' }; + this.connection = { status: 'disconnected', ...messageFields(textMessage("connection.connecting_to_local_codex")) }; this.quota = null; this.sampledAt = null; - this.approvals = new Map(); this.importingThreads = new Set(); - this.executions = new Map(); - this.pendingSave = false; - this.saveTimer = null; - let migrated = this.clearCompletedTasks(); - if (this.state.logs.length > 300) { this.state.logs = this.state.logs.slice(0, 300); migrated = true; } - if (this.state.history.length > 240) { this.state.history = this.state.history.slice(-240); migrated = true; } - for (const entry of this.state.logs) if (typeof entry.message === 'string' && entry.message.length > 2600) { entry.message = entry.message.slice(0, 2600); migrated = true; } - const settings = this.state.settings; - const pollSeconds = Number.isInteger(settings.pollSeconds) ? Math.max(10, Math.min(MAX_POLL_SECONDS, settings.pollSeconds)) : 30; - if (settings.pollSeconds !== pollSeconds) { settings.pollSeconds = pollSeconds; migrated = true; } - if (!Number.isInteger(settings.maxWakeSends) || settings.maxWakeSends < 1 || settings.maxWakeSends > 100) { - settings.maxWakeSends = Number.isInteger(settings.maxRounds) ? Math.max(1, Math.min(100, settings.maxRounds)) : 20; - migrated = true; - } - for (const task of this.state.tasks) { - if (task.continuous === undefined) { task.continuous = false; migrated = true; } - if (task.mode !== 'quota-wake' && task.rounds === undefined) { task.rounds = 0; migrated = true; } - if (typeof task.activity === 'string' && task.activity.length > 2400) { task.activity = task.activity.slice(0, 2400); migrated = true; } - if (task.plan?.length > 100) { task.plan = task.plan.slice(0, 100); migrated = true; } - for (const step of task.plan || []) if (typeof step.step === 'string' && step.step.length > 2000) { step.step = outcomeSummary(step.step); migrated = true; } - if (typeof task.output === 'string' && task.output.length > OUTPUT_LIMIT) { - task.outcome = parseOutcome(task.output); - retainOutput(task, task.output); - migrated = true; - } - } - for (const task of this.state.tasks) { - if (!task.mode && ['waiting', 'blocked'].includes(task.status) && task.threadId && task.continuous && (task.waitReason === 'writer' || isWriterConflict({ message: task.activity }))) { - task.mode = 'quota-wake'; resetWake(task); - // Legacy writer waits counted direct turns, never queued messages. - task.sentCount = 0; migrated = true; - } - if (task.mode === 'quota-wake') { - if (!task.wake) { task.wake = { armed: false, delivery: 'idle' }; migrated = true; } - if (task.wake.delivery === 'pending' && (!task.wake.episodeId || !Number.isFinite(task.wake.exhaustedAt))) { - task.wake.delivery = 'uncertain'; task.continuous = false; task.status = 'blocked'; - task.activity = '旧监测记录缺少额度耗尽依据,请检查原聊天后重新开启监测'; migrated = true; - } - if (typeof task.note !== 'string') { task.note = task.prompt || ''; migrated = true; } - if (!Number.isInteger(task.sentCount) || task.sentCount < 0) { task.sentCount = Math.max(0, Number.isInteger(task.rounds) ? task.rounds : 0); migrated = true; } - if ('prompt' in task || 'rounds' in task) { delete task.prompt; delete task.rounds; migrated = true; } - if (task.wake?.delivery === 'sending') { - task.wake.delivery = 'uncertain'; task.status = 'blocked'; task.activity = '上次发送结果未确认,请检查原聊天后重新开启监测'; - } - if (enforceWakeLimit(task, settings.maxWakeSends)) migrated = true; - continue; - } - if (task.status === 'blocked' && task.continuous && isWriterConflict({message:task.activity})) { - task.lastError=task.activity; - this.waitForThread(task,'writer'); - } - if (['running', 'starting', 'approval', 'pausing'].includes(task.status)) { - task.status = 'blocked'; - task.activity = '服务重启,请确认原会话已停止后手动继续'; - } - } - if (migrated) { - try { this.store.save(this.state); } catch (error) { this.storageFailure(error); } - } + this.storage = new StatePersistence(store, error => { + this.scheduleEpoch++; + this.emit('change'); this.emit('persistence-error', error); + }); + this.turns = new TurnController(rpc, { + persistence: this.storage, + canSchedule: epoch => this.canSchedule(epoch), + hasExecutionQuota: () => this.hasExecutionQuota(), + isStopped: () => this.stopped, + changed: options => this.changed(options), + log: (...args) => this.log(...args), + invalidateQuota: () => { this.sampledAt = null; }, + onError: error => this.emit('operation-error', error), + }); + this.storage.load(); rpc.on('notification', msg => this.safely(() => this.event(msg))); rpc.on('request', msg => this.safely(() => this.request(msg))); - rpc.on('disconnect', message => { + rpc.on('disconnect', (message, messageInfo) => { this.scheduleEpoch++; - this.connection = { status: 'disconnected', message }; + this.connection = { status: 'disconnected', ...messageFields({ message, messageInfo }) }; this.sampledAt = null; this.wakeSampledAt = null; this.approvals.clear(); this.executions.clear(); - for (const t of this.state.tasks) if (['running', 'starting', 'approval', 'pausing'].includes(t.status)) { - t.status = 'blocked'; t.activity = '连接中断,请检查原任务后手动继续'; + for (const t of this.state.tasks) if (isActiveDirectTask(t)) { + t.status = 'blocked'; setActivity(t, textMessage("task.connection_interrupted_check_the_original_work_before_resuming")); } this.safely(() => this.changed()); }); } + get state() { return this.storage.state; } + get persistence() { return this.storage.status; } + get pendingSave() { return this.storage.pending; } + get approvals() { return this.turns.approvals; } + get executions() { return this.turns.executions; } snapshot({ details = true } = {}) { const snapshot = { ...this.state, ...config.appInfo(), settings: { ...this.state.settings, enabled: this.state.settings.enabled && !this.observationOnly }, observationOnly: this.observationOnly, persistence: { ...this.persistence, ...this.store.status?.(), pending: this.pendingSave }, @@ -119,11 +72,11 @@ export class Manager extends EventEmitter { taskSnapshot(id) { const task = typeof id === 'object' ? id : this.state.tasks.find(t => t.id === id); if (!task) return null; - const fields = ['name', 'prompt', 'note', 'output', 'outputTruncated', 'plan', 'outcome', 'cwd', 'activity', 'toolCount', 'lastError', 'turnId']; + const fields = ['name', 'prompt', 'note', 'continuationMessage', 'output', 'outputTruncated', 'plan', 'outcome', 'cwd', 'activity', 'activityMessage', 'toolCount', 'lastError', 'turnId']; const values = fields.map(key => task[key]); const previous = this.detailVersions.get(task); if (!previous || values.some((value, index) => value !== previous.values[index])) this.detailVersions.set(task, { values, revision: (previous?.revision || 0) + 1 }); - return { ...task, progress: task.mode === 'quota-wake' ? null : progress(task.plan), detailRevision: this.detailVersions.get(task).revision }; + return { ...task, progress: isMonitorTask(task) ? null : progress(task.plan), detailRevision: this.detailVersions.get(task).revision }; } canSchedule(epoch = this.scheduleEpoch) { return epoch === this.scheduleEpoch && !this.stopped && !this.observationOnly && this.persistence.status === 'ok' && this.state.settings.enabled && this.rpc.ready && this.connection.status === 'connected'; @@ -138,70 +91,40 @@ export class Manager extends EventEmitter { async safelyAsync(action) { try { return await action(); } catch (error) { this.emit('operation-error', error); } } - storageFailure(error) { - this.scheduleEpoch++; - this.persistence = { ...this.persistence, status: 'error', message: error.message, failedAt: Date.now() }; - this.state.settings.enabled = false; - this.emit('change'); this.emit('persistence-error', error); - } + storageFailure(error) { this.storage.fail(error); } activate() { - if (this.persistence.status !== 'ok') throw new ClientError('数据保存失败,恢复存储后再开启调度'); + if (this.persistence.status !== 'ok') throw new ClientError(textMessage("error.data_could_not_be_saved_recover_storage_before")); this.observationOnly = false; } recoverStorage(source = 'retry') { - if (this.wakeExecutions.size) throw new ClientError('续作消息仍在发送,请等待发送结果确认后再恢复存储'); + if (this.wakeExecutions.size) throw new ClientError(textMessage("error.a_continuation_message_is_still_being_sent_wait")); if (source === 'backup') { - if (this.scheduling || this.executions.size || this.state.tasks.some(t => ['starting', ...ACTIVE_TURN_STATUSES].includes(t.status))) throw new ClientError('请先确认当前工作已停止,再恢复备份'); + if (this.scheduling || this.executions.size || this.state.tasks.some(isActiveDirectTask)) throw new ClientError(textMessage("error.confirm_current_work_has_stopped_before_restoring_a")); this.scheduleEpoch++; - this.state = this.store.restoreBackup(state => { - state.settings.enabled = false; - for (const task of state.tasks) { - task.continuous = false; task.status = 'blocked'; task.turnId = null; - task.activity = '已恢复备份,请检查原聊天和项目状态后手动继续'; - if (task.wake?.armed || task.wake?.delivery === 'sending') task.wake.delivery = 'uncertain'; - } - }); - this.approvals.clear(); this.persistence = { status: 'ok', message: null }; this.pendingSave = false; - this.emit('change'); + this.storage.restoreBackup(); + this.approvals.clear(); } else if (source === 'retry') { - if (this.persistence.needsRestore) throw new ClientError('主状态文件损坏,请明确选择恢复备份'); + if (this.persistence.needsRestore) throw new ClientError(textMessage("error.the_main_state_file_is_corrupt_choose_restore")); this.scheduleEpoch++; - for (const task of this.state.tasks) if (task.wake?.delivery === 'sending') { task.wake.delivery = 'uncertain'; task.status = 'blocked'; task.activity = '发送记录保存失败,请检查原聊天后手动继续'; } - this.state.settings.enabled = false; this.pendingSave = true; this.flush(); this.emit('change'); - } else throw new ClientError('恢复方式无效'); + this.storage.retry(); + } else throw new ClientError(textMessage("error.invalid_recovery_method")); + this.emit('change'); } clearCompletedTasks() { - const completedIds = new Set(this.state.tasks.filter(t => t.status === 'completed').map(t => t.id)); - if (!completedIds.size) return false; - this.state.tasks = this.state.tasks.filter(t => !completedIds.has(t.id)); + const completedIds = removeCompletedTasks(this.state); for (const [id, request] of this.approvals) if (completedIds.has(request.taskId)) this.approvals.delete(id); - return true; + return completedIds.size > 0; } - changed({ deferSave = false } = {}) { - if (!deferSave) this.clearCompletedTasks(); - this.pendingSave = true; - if (deferSave) { - // One deadline from the first fragment; continuous output cannot postpone it. - if (!this.saveTimer) this.saveTimer = setTimeout(() => { - this.saveTimer = null; - try { this.flush(); } - catch { /* flush recorded the failure; keep the service available. */ } - }, OUTPUT_SAVE_INTERVAL_MS); - } else this.flush(); + changed(options = {}) { + if (!options.deferSave) this.clearCompletedTasks(); + this.storage.changed(options); // Consumers coalesce this invalidation signal and request the latest snapshot. this.emit('change'); } - flush() { - clearTimeout(this.saveTimer); this.saveTimer = null; - if (!this.pendingSave) return; - try { this.store.save(this.state); } - catch (error) { this.storageFailure(error); throw error; } - // Keep the pending flag on failure so a later change/flush can retry. - this.pendingSave = false; - this.persistence = { status: 'ok', message: null }; - } - log(message, type = 'info', taskId = null) { - this.state.logs.unshift({ id: randomUUID(), time: Date.now(), message: String(message).slice(0, 2600), type, taskId }); + flush() { this.storage.flush(); } + log(value, type = 'info', taskId = null) { + const info = boundedMessage(isMessage(value) ? value : value?.messageInfo); + this.state.logs.unshift({ id: randomUUID(), time: Date.now(), message: messageText(value).slice(0, 2600), ...(info ? { messageInfo: info } : {}), type, taskId }); this.state.logs = this.state.logs.slice(0, 300); } async refresh() { @@ -211,12 +134,12 @@ export class Manager extends EventEmitter { await this.rpc.connect(); const result = await this.rpc.call('account/rateLimits/read'); this.setQuota(result); - this.connection = { status: 'connected', message: this.demo ? '演示数据 · 不执行真实任务' : 'Codex App Server 已连接' }; + this.connection = { status: 'connected', ...messageFields(textMessage(this.demo ? 'connection.demo' : 'connection.codex_connected')) }; this.changed(); } catch (error) { this.sampledAt = null; this.wakeSampledAt = null; - this.connection = { status: 'disconnected', message: String(error.message).slice(0, 2400) }; + this.connection = { status: 'disconnected', ...messageFields(error), message: String(error.message).slice(0, 2400) }; this.changed(); } finally { this.refreshing = false; } await this.tick(); @@ -247,7 +170,7 @@ export class Manager extends EventEmitter { clearInterval(this.timer); this.state.settings.enabled = false; this.safely(() => this.changed()); - await Promise.all(this.state.tasks.filter(t => ['running', 'starting', 'approval', 'pausing'].includes(t.status)).map(t => this.action(t.id, 'pause').catch(() => {}))); + await Promise.all(this.state.tasks.filter(isActiveDirectTask).map(t => this.action(t.id, 'pause').catch(() => {}))); this.executions.clear(); try { this.flush(); } finally { this.rpc.close(); } } @@ -258,122 +181,17 @@ export class Manager extends EventEmitter { try { await tickQuotaWake(this, epoch); if (!this.canSchedule(epoch)) return; - if (this.state.tasks.some(t => ['starting', 'running', 'approval', 'pausing'].includes(t.status))) return; - const task = this.state.tasks.find(t => t.mode !== 'quota-wake' && (t.status === 'queued' || (t.status === 'waiting' && t.continuous)) && (!t.retryAt || Date.now() >= t.retryAt)); + if (this.state.tasks.some(isActiveDirectTask)) return; + const task = this.state.tasks.find(task => isRunnableDirectTask(task)); if (!task) return; if (!this.hasExecutionQuota()) { - if (task.status !== 'waiting' || task.waitReason !== 'quota') { task.status = 'waiting'; task.waitReason='quota'; task.activity = '可用额度不足或未知,等待新额度样本'; this.changed(); } + if (task.status !== 'waiting' || task.waitReason !== 'quota') { task.status = 'waiting'; task.waitReason='quota'; setActivity(task, textMessage("task.quota_is_low_or_unknown_waiting_for_fresh")); this.changed(); } return; } await this.run(task, epoch); } finally { this.scheduling = false; } } - waitForThread(task,reason) { - task.status=task.continuous ? 'waiting' : 'blocked'; - task.waitReason=task.continuous ? reason : null; - task.retryAt=task.continuous ? Date.now()+30000 : 0; - const message=reason==='writer' ? '原会话被其他 Codex 客户端占用' : '原会话仍在运行'; - task.activity=message+(task.continuous ? ',30 秒后自动重试' : ',请等待原会话结束后再继续'); - } - async run(task, epoch = this.scheduleEpoch) { - if (!this.canSchedule(epoch)) return; - const execution = { pending: false, events: [], bytes: 0, overflow: false }; - this.executions.set(task.id, execution); - const current = () => this.executions.get(task.id) === execution; - const previousRounds=task.rounds; - const permitted = () => { - if (!current() || task.status !== 'starting') return false; - if (this.canSchedule(epoch) && this.hasExecutionQuota()) return true; - task.status = 'queued'; task.activity = '启动已取消,等待有效调度和新鲜额度'; this.changed(); return false; - }; - try { - task.cwd = localProjectPath(task.cwd); - task.turnId = null; - task.status = 'starting'; task.waitReason=null; task.activity = '正在恢复会话…'; this.changed(); - if (task.rounds >= this.state.settings.maxRounds) { - task.status = 'blocked'; task.activity = '已达到自动执行轮次上限,请手动继续'; this.changed(); return; - } - if (task.threadId) { - const read = await this.rpc.call('thread/read', { threadId: task.threadId, includeTurns: false }); - if (!permitted()) return; - if (read.thread.status?.type === 'active') { this.waitForThread(task,'thread'); this.changed(); return; } - await this.rpc.call('thread/resume', { threadId: task.threadId, cwd: task.cwd, excludeTurns: true, approvalPolicy: 'on-request', sandbox: 'workspace-write' }); - } else { - const result = await this.rpc.call('thread/start', { cwd: task.cwd, approvalPolicy: 'on-request', sandbox: 'workspace-write' }); - if (!current()) return; - task.threadId = result.thread.id; - } - if (!current()) return; - if (task.status !== 'starting') { this.changed(); return; } - if (!permitted()) return; - task.output = ''; task.outputTruncated = false; task.outcome = null; - task.toolCount = 0; task.rounds++; task.status = 'running'; task.updatedAt = Date.now(); - this.changed(); - execution.pending = true; - const result = await this.rpc.call('turn/start', { - threadId: task.threadId, - input: [{ type: 'text', text: `${task.rounds > 1 ? '继续原目标,先检查已有成果,避免重复操作。\n' : ''}${task.prompt}${instructions}`, text_elements: [] }], - outputSchema: outcomeSchema, - }); - if (!current()) return; - if (typeof result.turn?.id !== 'string' || !result.turn.id) throw new ClientError('未收到有效的轮次 ID,请检查原会话后手动继续'); - execution.pending = false; - if (ACTIVE_TURN_STATUSES.includes(task.status)) task.turnId = result.turn.id; - this.log(`开始工作:${task.name}`, 'success', task.id); - if (execution.overflow) { - task.status = 'pausing'; task.activity = '启动期间通知超过缓存上限,正在中断,请检查原会话后手动继续'; - this.log(`${task.name}:${task.activity}`, 'warning', task.id); - } - const events = execution.events; execution.events = []; execution.bytes = 0; - for (const event of events) event.request ? this.request(event.message) : this.event(event.message); - this.changed(); - if (task.status === 'pausing' && task.turnId === result.turn.id) { - try { await this.rpc.call('turn/interrupt', { threadId: task.threadId, turnId: task.turnId }); } - catch (error) { - if (!current() || task.status !== 'pausing' || task.turnId !== result.turn.id) return; - task.status = 'blocked'; task.activity = `未确认中断:${error.message}`; - this.log(`${task.name}:${task.activity}`, 'warning', task.id); this.changed(); - } - } - } catch (error) { - if (!current()) return; - if (['paused', 'pausing'].includes(task.status)) task.status = 'paused'; - else if (isWriterConflict(error)) { - task.rounds=previousRounds; task.turnId=null; task.lastError=error.message; - this.waitForThread(task,'writer'); - } - else { task.status = 'blocked'; task.activity = String(error.message).slice(0, 2400); } - this.log(`${task.name}:${task.activity}`, 'warning', task.id); this.changed(); - } finally { - if (current()) { - this.executions.delete(task.id); - for (const event of execution.events) if (event.request) this.rejectRequest(event.message); - } - } - } - matchesTurn(task, params) { - const turnId = params.turnId ?? params.turn?.id; - return task.mode !== 'quota-wake' && ACTIVE_TURN_STATUSES.includes(task.status) - && task.threadId === params.threadId && typeof task.turnId === 'string' && !!task.turnId - && turnId === task.turnId && (params.turnId == null || params.turn?.id == null || params.turnId === params.turn.id); - } - rejectRequest(msg) { - this.rpc.send({ id: msg.id, error: { code: -32601, message: 'This client requires manual handling for this request.' } }); - } - deferTurnEvent(task, message, request = false) { - const execution = this.executions.get(task.id); - if (!execution?.pending) return false; - // Notifications may precede the turn/start response. Its ID is authoritative. - const bytes = execution.overflow ? 0 : Buffer.byteLength(JSON.stringify(message)); - if (execution.overflow || execution.events.length >= START_EVENT_LIMIT || execution.bytes + bytes > START_EVENT_BYTES) { - for (const event of execution.events) if (event.request) this.rejectRequest(event.message); - execution.events = []; execution.bytes = 0; execution.overflow = true; - if (request) this.rejectRequest(message); - } else { - execution.events.push({ message, request }); execution.bytes += bytes; - } - return true; - } + run(task, epoch = this.scheduleEpoch) { return this.turns.run(task, epoch); } event({ method, params: p = {} }) { if (method === 'account/rateLimits/updated') { const merged = p.rateLimitsByLimitId ? p : p.rateLimits?.limitId && this.quota?.rateLimitsByLimitId @@ -385,196 +203,61 @@ export class Manager extends EventEmitter { if (merged !== p && Object.keys(merged.rateLimitsByLimitId).length > 1) this.sampledAt = previousSample; this.changed(); return; } - if (!TURN_EVENTS.has(method)) return; - const task = this.state.tasks.find(t => t.threadId === p.threadId); - if (!task) return; - if (task.mode === 'quota-wake') return; - if (this.deferTurnEvent(task, { method, params: p })) return; - if (!this.matchesTurn(task, p)) return; - if (method === 'turn/completed' && !['completed', 'failed', 'interrupted'].includes(p.turn?.status)) return; - task.updatedAt = Date.now(); - if (method === 'turn/started') { task.activity = 'Codex 正在处理目标'; } - else if (method === 'turn/plan/updated') { task.plan = p.plan.slice(0, 100).map(step => ({ ...step, step: outcomeSummary(step.step) })); task.activity = outcomeSummary(p.explanation || task.plan.find(s => s.status === 'inProgress')?.step || '计划已更新'); } - else if (method === 'item/agentMessage/delta') { - retainOutput(task, p.delta, true); - task.outcome = null; - } - else if (method === 'item/started') { - const item = p.item; - if (['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch'].includes(item.type)) task.toolCount++; - task.activity = ({ commandExecution: '正在执行命令', fileChange: '正在修改文件', reasoning: '正在分析', agentMessage: '正在生成回复', mcpToolCall: '正在调用工具' })[item.type] || '正在工作'; - } else if (method === 'item/completed' && p.item.type === 'agentMessage') { - const text = p.item.text ?? task.output; - // Parse the complete JSON before retaining only its display tail. - task.outcome = parseOutcome(text); - if (p.item.text != null) retainOutput(task, text); - } - else if (method === 'turn/completed') { - for (const [id, request] of this.approvals) if (request.taskId === task.id) this.approvals.delete(id); - task.turnId = null; - if (p.turn.status === 'interrupted' || task.status === 'pausing') { - task.status = 'paused'; task.activity = '工作已暂停'; - } else if (p.turn.status === 'failed') { - const limited = isQuotaError(p.turn.error); - task.status = limited && task.continuous ? 'waiting' : 'failed'; - task.waitReason=task.status==='waiting' ? 'quota' : null; - task.activity = String(p.turn.error?.message || '本轮执行失败').slice(0, 2400); - task.retryAt = Date.now() + 60000; - if (limited) this.sampledAt = null; - } else { - const outcome = task.outcome || parseOutcome(task.output); - task.activity = outcome.summary; - task.status = outcome.status === 'done' ? 'completed' : outcome.status === 'continue' && task.continuous && task.toolCount > 0 ? 'queued' : 'blocked'; - if (outcome.status === 'continue' && task.toolCount === 0) task.activity += '(本轮无工具活动,已停止自动续作)'; - task.retryAt = Date.now() + 5000; - } - this.log(`${task.name}:${task.activity}`, task.status === 'completed' ? 'success' : 'info', task.id); - } else return; - this.changed({ deferSave: method === 'item/agentMessage/delta' }); - } - request(msg) { - const task = this.state.tasks.find(t => t.threadId === msg.params?.threadId); - if (task && task.mode !== 'quota-wake' && this.deferTurnEvent(task, msg, true)) return; - if (!task || !this.matchesTurn(task, msg.params || {})) { this.rejectRequest(msg); return; } - if (task.status === 'pausing') { this.rejectRequest(msg); return; } - // File changes need a complete patch/scope view. Until that exists, leave - // these requests to the original Codex client instead of approving blindly. - const supported = msg.method === 'item/commandExecution/requestApproval'; - const commandFields = ['threadId', 'turnId', 'itemId', 'approvalId', 'command', 'reason', 'cwd', 'commandActions', 'kind', 'startedAtMs']; - // CLI 0.153.2 permits null optional scope fields. A non-null scope needs - // its own review UI; unknown fields still fail closed, even when null. - const absentScopeFields = ['additionalPermissions', 'availableDecisions', 'environmentId', 'networkApprovalContext', 'proposedExecpolicyAmendment', 'proposedNetworkPolicyAmendments']; - const reviewable = typeof msg.params.command === 'string' && !!msg.params.command.trim() && msg.params.command.length <= 12000 - && Object.keys(msg.params).every(key => commandFields.includes(key) || (absentScopeFields.includes(key) && msg.params[key] === null)) - && (msg.params.kind === undefined || msg.params.kind === 'command') - && (msg.params.startedAtMs === undefined || Number.isSafeInteger(msg.params.startedAtMs)) - && (msg.params.reason == null || (typeof msg.params.reason === 'string' && msg.params.reason.length <= 2000)) - && (msg.params.cwd == null || typeof msg.params.cwd === 'string'); - if (supported && reviewable) { - if (this.approvals.size >= 50) { this.rejectRequest(msg); return; } - this.approvals.set(String(msg.id), { id: msg.id, detailToken: randomUUID(), taskId: task.id, threadId: task.threadId, turnId: task.turnId, method: msg.method, reason: msg.params.reason || '操作需要授权', command: msg.params.command || '', cwd: msg.params.cwd || task.cwd }); - task.status = 'approval'; task.activity = '等待你批准操作'; - this.log(`${task.name} 等待操作授权`, 'warning', task.id); - } else { - this.rejectRequest(msg); - this.cancelApprovals(task); - task.status = 'pausing'; task.activity = msg.method === 'item/fileChange/requestApproval' ? '文件修改范围无法完整审阅,已拒绝;请在原 Codex 中核对后手动继续' : reviewable ? '需要用户输入,请在 Codex 中处理后继续' : '授权内容超出可审阅范围,已拒绝,请在原 Codex 会话核对后继续'; - if (task.turnId) void this.safelyAsync(() => this.interruptTask(task)); - } - this.changed(); - } - cancelApprovals(task) { - for (const [id, request] of this.approvals) if (request.taskId === task.id) { - this.approvals.delete(id); - this.safely(() => this.rpc.send({ id: request.id, result: { decision: 'decline' } })); - } - } - async interruptTask(task) { - const threadId = task.threadId, turnId = task.turnId; - try { await this.rpc.call('turn/interrupt', { threadId, turnId }); } - catch (error) { - if (!this.state.tasks.includes(task) || task.status !== 'pausing' || task.threadId !== threadId || task.turnId !== turnId) return; - task.status = 'blocked'; task.activity = `未确认中断:${error.message}`; this.changed(); throw error; - } - } - approve(id, decision, token) { - if (this.persistence.status !== 'ok') throw new ClientError('请先恢复数据保存,再处理授权'); - const request = this.approvals.get(String(id)); - if (!request || typeof token !== 'string' || token !== request.detailToken || !['accept', 'decline'].includes(decision)) throw new ClientError('授权请求已过期或决定无效'); - if (request.method !== 'item/commandExecution/requestApproval' || typeof request.command !== 'string' || !request.command.trim()) throw new ClientError('授权内容无法完整审阅,请在原 Codex 中处理'); - const task = this.state.tasks.find(t => t.id === request.taskId); - if (this.stopped || !this.rpc.ready || !task || task.status !== 'approval' || !this.matchesTurn(task, request)) throw new ClientError('授权所属轮次已失效或正在暂停'); - // Confirm storage before granting an operation permission. - this.pendingSave = true; this.flush(); - this.rpc.send({ id: request.id, result: { decision } }); - this.approvals.delete(String(id)); - const remaining = [...this.approvals.values()].some(other => other.taskId === task.id); - task.status = remaining ? 'approval' : 'running'; task.activity = remaining ? '仍有操作等待授权' : decision === 'accept' ? '已批准,继续执行' : '已拒绝操作'; - this.changed(); + this.turns.event({ method, params: p }); } + request(message) { return this.turns.request(message); } + approve(id, decision, token) { return this.turns.approve(id, decision, token); } create(input) { - if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); - for (const key of ['name', 'cwd', 'prompt']) if (typeof input[key] !== 'string' || !input[key].trim()) throw new ClientError('请填写名称、项目目录和工作目标'); - if (input.name.length > 120 || input.prompt.length > 12000) throw new ClientError('名称或目标过长'); - const cwd = checkedProjectPath(input.cwd, { demo: this.demo }); - const task = { id: randomUUID(), name: input.name.trim(), cwd, prompt: input.prompt.trim(), threadId: null, continuous: input.continuous === true, status: 'queued', activity: '等待调度', rounds: 0, plan: [], output: '', createdAt: Date.now(), updatedAt: Date.now() }; - this.state.tasks.unshift(task); this.log(`添加工作:${task.name}`); this.changed(); this.activate(); return task; + if (this.state.tasks.length >= 100) throw new ClientError(textMessage("error.up_to_records_are_supported_end_unused_work")); + const task = createDirectTask(input, { demo: this.demo }); + this.state.tasks.unshift(task); this.log(textMessage("log.work_added", { name: task.name })); this.changed(); this.activate(); return task; } async action(id, action, value) { const task = this.state.tasks.find(t => t.id === id); - if (!task) throw new ClientError('任务不存在'); - if (task.mode === 'quota-wake') { - if ((this.wakeExecutions.has(task.id) || task.wake?.delivery === 'uncertain') && (action === 'pause' || (action === 'continuous' && value === false))) { - task.continuous = false; task.status = 'paused'; task.waitReason = null; task.retryAt = 0; - task.activity = '监测已暂停,发送结果仍需确认;请核对原聊天,已发出的消息无法撤回'; this.changed(); return; - } - if (this.wakeExecutions.has(task.id)) { - throw new ClientError('续作消息发送结果仍在确认,请等待后再开启或结束监测'); - } - if ((action === 'resume' || (action === 'continuous' && value === true)) && (task.sentCount || 0) >= this.state.settings.maxWakeSends) throw new ClientError('已达到原聊天发送次数上限,请提高发送上限后再开启监测;累计次数不会重置'); - if (action === 'continuous') { - if (typeof value !== 'boolean') throw new ClientError('开关值无效'); - if (task.continuous !== value) { task.continuous = value; resetWake(task); } - } else if (action === 'pause') { task.continuous = false; resetWake(task); } - else if (action === 'resume') { task.continuous = true; resetWake(task); } - else if (action === 'complete') { task.continuous = false; task.status = 'completed'; this.log(`已结束监测:${task.name};工作结果请在原聊天确认`, 'info', task.id); } - else throw new ClientError('不支持的操作'); - this.changed(); if (action === 'resume' || (action === 'continuous' && value === true)) this.activate(); return; - } - if (action === 'continuous') { - if (typeof value !== 'boolean') throw new ClientError('开关值无效'); - task.continuous = value; - if (!value && ['waiting', 'queued'].includes(task.status)) { task.status = 'paused'; task.activity = '持续工作已关闭'; } - if (value && task.status==='blocked' && isWriterConflict({message:task.activity})) this.waitForThread(task,'writer'); - } else if (action === 'pause') { - const active = ['running', 'approval', 'pausing'].includes(task.status); - task.status = active ? 'pausing' : 'paused'; task.activity = active ? '正在暂停' : '工作已暂停'; - task.waitReason = null; task.retryAt = 0; - this.cancelApprovals(task); + if (!task) throw new ClientError(textMessage("error.work_item_not_found")); + const transition = taskModel(task).action(task, action, value, { + sending: this.wakeExecutions.has(task.id), maxWakeSends: this.state.settings.maxWakeSends, + }); + if (transition.logMessage) this.log(transition.logMessage, 'info', task.id); + if (transition.kind === 'pause-turn') { + this.turns.cancelApprovals(task); this.safely(() => this.changed()); - if (task.turnId && active) { - await this.interruptTask(task); - } + if (transition.interrupt) await this.turns.interruptTask(task); return; - } else if (action === 'resume') { - if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new ClientError('任务尚在运行或停止中'); - task.status = 'queued'; task.rounds = 0; task.retryAt = 0; task.activity = '等待可用额度'; - } else if (action === 'complete') { - if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new ClientError('请先暂停任务'); - task.status = 'completed'; task.activity = '用户确认完成'; - } else throw new ClientError('不支持的操作'); - if (task.status!=='waiting') { task.waitReason=null; if (task.status==='paused') task.retryAt=0; } + } this.changed(); - if (action === 'resume' || (action === 'continuous' && value === true)) this.activate(); + if (transition.activate) this.activate(); } updateSettings(input) { const s = this.state.settings; - if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw new ClientError('开关值无效'); + if (input.language !== undefined && !['zh', 'en'].includes(input.language)) throw new ClientError('Invalid language: use zh or en'); + if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw new ClientError(textMessage("error.invalid_switch_value")); for (const [key, min, max] of [['pollSeconds', 10, MAX_POLL_SECONDS], ['minimumRemaining', 1, 99], ['maxRounds', 1, 100], ['maxWakeSends', 1, 100]]) { - if (input[key] !== undefined) { if (!Number.isInteger(input[key]) || input[key] < min || input[key] > max) throw new ClientError(`${key} 必须为 ${min}–${max} 的整数`); } + if (input[key] !== undefined) { if (!Number.isInteger(input[key]) || input[key] < min || input[key] > max) throw new ClientError(textMessage("error.setting_range", { name: key, min: min, max: max })); } } for (const key of ['pollSeconds', 'minimumRemaining', 'maxRounds', 'maxWakeSends']) if (input[key] !== undefined) s[key] = input[key]; if (input.enabled !== undefined) s.enabled = input.enabled; + if (input.language !== undefined) s.language = input.language; if (input.enabled === false) this.scheduleEpoch++; - for (const task of this.state.tasks) if (task.mode === 'quota-wake') enforceWakeLimit(task, s.maxWakeSends); - this.log('调度设置已更新'); this.changed(); + for (const task of this.state.tasks) if (isMonitorTask(task)) enforceWakeLimit(task, s.maxWakeSends); + this.log(textMessage("log.scheduling_settings_updated")); this.changed(); if (input.enabled === true) this.activate(); } async listThreads(cursor = null) { await this.rpc.connect?.(); return this.rpc.call('thread/list', { limit: 30, sortKey: 'updated_at', cursor }); } - async importThread(id, note = '') { - if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError('该会话已添加'); - if (this.importingThreads.has(id)) throw new ClientError('该会话正在添加,请勿重复导入'); - if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); - if (typeof note !== 'string' || note.length > 12000) throw new ClientError('监测备注必须为不超过 12000 字符的文本'); + async importThread(id, note = '', continuationMessage = '') { + if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError(textMessage("error.this_chat_has_already_been_added")); + if (this.importingThreads.has(id)) throw new ClientError(textMessage("error.this_chat_is_being_added_do_not_import")); + if (this.state.tasks.length >= 100) throw new ClientError(textMessage("error.up_to_records_are_supported_end_unused_work")); + validateMonitorInput(note, continuationMessage); // Reserve before yielding: HTTP and MCP imports share this manager. this.importingThreads.add(id); try { const { thread } = await this.rpc.call('thread/read', { threadId: id, includeTurns: false }); // Another task path may have claimed the thread while the read was pending. - if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError('该会话已添加'); - if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); - const task = { id: randomUUID(), mode: 'quota-wake', threadId: id, name: (thread.name || thread.preview || 'Codex 会话').slice(0, 80), cwd: thread.cwd, note: note.trim(), continuous: false, status: 'paused', activity: '会话已添加,开启额度监测后在恢复时发送“继续”;工作结果请在原聊天确认', sentCount: 0, wake: { armed: false, delivery: 'idle' }, plan: [], output: '', createdAt: Date.now(), updatedAt: Date.now() }; - this.state.tasks.unshift(task); this.log(`已添加会话:${task.name}`); this.changed(); return task; + if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError(textMessage("error.this_chat_has_already_been_added")); + if (this.state.tasks.length >= 100) throw new ClientError(textMessage("error.up_to_records_are_supported_end_unused_work")); + const task = createMonitorTask(id, thread, note, continuationMessage); + this.state.tasks.unshift(task); this.log(textMessage("log.chat_added", { name: task.name })); this.changed(); return task; } finally { this.importingThreads.delete(id); } } } diff --git a/server/monitor-task.js b/server/monitor-task.js new file mode 100644 index 0000000..5fd64e0 --- /dev/null +++ b/server/monitor-task.js @@ -0,0 +1,131 @@ +import { MessageError, activityFields, setActivity, message as textMessage } from '../public/message.js'; +import { randomUUID } from 'node:crypto'; +import { ClientError } from './http-policy.js'; +import { isWriterConflict } from './policy.js'; + +/** + * @typedef {object} MonitorTask + * @property {'quota-wake'} mode + * @property {string} note Local note; never an execution goal. + * @property {string} continuationMessage Message for future recovery deliveries. + * @property {number} sentCount Confirmed queue deliveries; resume never resets it. + * @property {boolean} continuous Whether quota monitoring is enabled. + * @property {object} wake The recovery episode and delivery claim. + * @property {string} status A member of MONITOR_TASK_STATUSES. + */ +export const MONITOR_TASK_STATUSES = Object.freeze(['monitoring', 'waiting', 'paused', 'blocked', 'completed']); +export const isMonitorTask = task => task?.mode === 'quota-wake'; +export const isEnabledMonitorTask = task => isMonitorTask(task) && task.continuous === true && ['monitoring', 'waiting'].includes(task.status); +const executionFields = ['prompt', 'rounds', 'plan', 'output', 'outputTruncated', 'outcome', 'toolCount', 'turnId', 'lastError']; + +export function resetWake(task) { + task.wake = { armed: false, delivery: 'idle' }; + task.status = task.continuous ? 'monitoring' : 'paused'; + task.waitReason = null; task.retryAt = 0; + setActivity(task, task.continuous ? textMessage("task.monitoring_quota_a_continuation_message_will_be_sent") : textMessage("task.quota_recovery_monitoring_paused")); +} + +export function enforceWakeLimit(task, limit) { + if ((task.sentCount || 0) < limit) return false; + task.continuous = false; + if (task.wake?.delivery !== 'uncertain') { + task.status = 'paused'; task.waitReason = 'send-limit'; task.retryAt = 0; + if (task.wake && task.wake.delivery !== 'sending') { + task.wake.armed = false; + if (task.wake.delivery === 'pending') task.wake.delivery = 'idle'; + } + setActivity(task, textMessage("monitor.send_limit", { sent: task.sentCount, limit: limit })); + } + return true; +} + +export function validateMonitorInput(note, continuationMessage) { + if (typeof note !== 'string' || note.length > 12000) throw new ClientError(textMessage("error.monitoring_notes_must_be_text_of_up_to")); + if (typeof continuationMessage !== 'string' || continuationMessage.length > 12000) throw new ClientError(textMessage("error.continuation_messages_must_be_text_of_up_to")); +} + +export function createMonitorTask(threadId, thread, note = '', continuationMessage = '') { + validateMonitorInput(note, continuationMessage); + const now = Date.now(); + return { id: randomUUID(), mode: 'quota-wake', threadId, name: (thread.name || thread.preview || 'Codex 会话').slice(0, 80), cwd: thread.cwd, note: note.trim(), continuationMessage: continuationMessage.trim(), continuous: false, status: 'paused', ...activityFields(textMessage("task.chat_added_enable_monitoring_to_send_a_message")), sentCount: 0, wake: { armed: false, delivery: 'idle' }, createdAt: now, updatedAt: now }; +} + +export function migrateLegacyWriter(task) { + if (task.mode !== undefined || !['waiting', 'blocked'].includes(task.status) || !task.threadId || !task.continuous + || !(task.waitReason === 'writer' || isWriterConflict({ message: task.activity }))) return false; + task.mode = 'quota-wake'; resetWake(task); + // Direct rounds were never confirmed queued messages. + task.sentCount = 0; + return true; +} + +function prepare(task, settings) { + let changed = false; + if (!task.wake) { task.wake = { armed: false, delivery: 'idle' }; changed = true; } + if (task.wake.delivery === 'pending' && (!task.wake.episodeId || !Number.isFinite(task.wake.exhaustedAt))) { + task.wake.delivery = 'uncertain'; task.continuous = false; task.status = 'blocked'; + setActivity(task, textMessage("task.this_legacy_record_lacks_proof_of_quota_exhaustion")); changed = true; + } + if (typeof task.note !== 'string') { task.note = task.prompt || ''; changed = true; } + if (!Number.isInteger(task.sentCount) || task.sentCount < 0) { task.sentCount = Math.max(0, Number.isInteger(task.rounds) ? task.rounds : 0); changed = true; } + for (const key of executionFields) if (key in task) { delete task[key]; changed = true; } + if (task.wake.delivery === 'sending') { + task.wake.delivery = 'uncertain'; task.status = 'blocked'; setActivity(task, textMessage("task.the_previous_delivery_is_unconfirmed_check_the_original")); changed = true; + } + if (enforceWakeLimit(task, settings.maxWakeSends)) changed = true; + return changed; +} + +function validate(task) { + if (!MONITOR_TASK_STATUSES.includes(task.status)) throw new MessageError(textMessage("error.monitor_state")); + if ((task.sentCount !== undefined && (!Number.isSafeInteger(task.sentCount) || task.sentCount < 0)) + || (task.rounds !== undefined && (!Number.isSafeInteger(task.rounds) || task.rounds < 0))) throw new MessageError(textMessage("error.monitor_count")); + // Old records may contain direct fields; prepare migrates their note/count + // before removing them. Delivery evidence is checked without inventing it. + if (task.wake === undefined) return; + const wake = task.wake; + if (!wake || typeof wake !== 'object' || Array.isArray(wake) || typeof wake.armed !== 'boolean' + || !['idle', 'pending', 'sending', 'sent', 'uncertain'].includes(wake.delivery) + || (['pending', 'sending'].includes(wake.delivery) && wake.armed !== true) + || (['idle', 'sent'].includes(wake.delivery) && wake.armed !== false) + || (wake.episodeId !== undefined && (typeof wake.episodeId !== 'string' || !wake.episodeId)) + || (wake.exhaustedAt !== undefined && (!Number.isFinite(wake.exhaustedAt) || wake.exhaustedAt <= 0))) throw new MessageError(textMessage("error.monitor_delivery")); +} + +function action(task, action, value, { sending, maxWakeSends }) { + if (action === 'message') { + if (typeof value !== 'string' || value.length > 12000) throw new ClientError(textMessage("error.continuation_messages_must_be_text_of_up_to")); + task.continuationMessage = value.trim(); task.updatedAt = Date.now(); + return { kind: 'changed', logMessage: textMessage("log.continuation_message_updated") }; + } + if ((sending || task.wake?.delivery === 'uncertain') && (action === 'pause' || (action === 'continuous' && value === false))) { + task.continuous = false; task.status = 'paused'; task.waitReason = null; task.retryAt = 0; + setActivity(task, textMessage("task.monitoring_paused_delivery_is_still_unconfirmed_check_the")); + return { kind: 'changed' }; + } + if (sending) throw new ClientError(textMessage("error.delivery_is_still_being_confirmed_wait_before_enabling")); + if ((action === 'resume' || (action === 'continuous' && value === true)) && (task.sentCount || 0) >= maxWakeSends) throw new ClientError(textMessage("error.message_limit_reached_raise_the_limit_then_enable")); + if (action === 'continuous') { + if (typeof value !== 'boolean') throw new ClientError(textMessage("error.invalid_switch_value")); + if (task.continuous !== value) { task.continuous = value; resetWake(task); } + } else if (action === 'pause') { task.continuous = false; resetWake(task); } + else if (action === 'resume') { task.continuous = true; resetWake(task); } + else if (action === 'complete') { + task.continuous = false; task.status = 'completed'; + return { kind: 'changed', logMessage: textMessage("log.monitoring_ended_check_results_in_the_original_chat", { name: task.name }) }; + } else throw new ClientError(textMessage("error.unsupported_action")); + return { kind: 'changed', activate: action === 'resume' || (action === 'continuous' && value === true) }; +} + +function restoreBackup(task) { + task.continuous = false; task.status = 'blocked'; + setActivity(task, textMessage("task.backup_restored_check_the_original_chats_and_projects")); + if (task.wake?.armed || task.wake?.delivery === 'sending') task.wake.delivery = 'uncertain'; +} + +function retryStorage(task) { + if (task.wake?.delivery !== 'sending') return; + task.wake.delivery = 'uncertain'; task.status = 'blocked'; setActivity(task, textMessage("task.the_delivery_record_could_not_be_saved_check")); +} + +export const monitorTaskModel = Object.freeze({ prepare, validate, action, restoreBackup, retryStorage }); diff --git a/server/policy.js b/server/policy.js index 7554d3d..823eaff 100644 --- a/server/policy.js +++ b/server/policy.js @@ -1,4 +1,5 @@ import { outcomeSummary } from './output.js'; +import { message, formatMessage } from '../public/message.js'; export const QUOTA_TTL_MS = 90000; export const MAX_POLL_SECONDS = QUOTA_TTL_MS / 1000; @@ -37,5 +38,6 @@ export function parseOutcome(text) { const value = JSON.parse(text); if (['done', 'continue', 'blocked'].includes(value?.status) && typeof value.summary === 'string') return { status: value.status, summary: outcomeSummary(value.summary) }; } catch { /* Missing structured outcome must not trigger an endless loop. */ } - return { status: 'blocked', summary: '本轮已结束,未收到明确的完成状态,请检查输出后继续。' }; + const summaryMessage = message('task.outcome_missing'); + return { status: 'blocked', summary: formatMessage(summaryMessage), summaryMessage }; } diff --git a/server/project-path.js b/server/project-path.js index dc2ba69..87cf82a 100644 --- a/server/project-path.js +++ b/server/project-path.js @@ -1,3 +1,4 @@ +import { message as textMessage } from '../public/message.js'; import { win32 } from 'node:path'; import { statSync } from 'node:fs'; import { ClientError } from './http-policy.js'; @@ -5,13 +6,13 @@ import { ClientError } from './http-policy.js'; // Check the string before touching the filesystem: UNC/device stat can send SMB // authentication. Accept drive-qualified Windows paths only, including in demo. export function localProjectPath(value) { - if (typeof value !== 'string') throw new ClientError('项目目录必须是本地盘符绝对路径'); + if (typeof value !== 'string') throw new ClientError(textMessage("error.project_path")); const path = value.trim().replaceAll('/', '\\'); - if (!/^[a-z]:\\(?!\\)/i.test(path) || /[\x00-\x1f]/.test(path) || path.slice(2).includes(':')) throw new ClientError('项目目录必须是本地盘符绝对路径,不支持网络或设备路径'); + if (!/^[a-z]:\\(?!\\)/i.test(path) || /[\x00-\x1f]/.test(path) || path.slice(2).includes(':')) throw new ClientError(textMessage("error.project_path_kind")); return win32.normalize(path); } export function checkedProjectPath(value, { demo = false, stat = statSync } = {}) { const path = localProjectPath(value); - if (!demo && !stat(path).isDirectory()) throw new ClientError('项目目录必须是存在的本地目录'); + if (!demo && !stat(path).isDirectory()) throw new ClientError(textMessage("error.project_directory")); return path; } diff --git a/server/queue-sender.js b/server/queue-sender.js index 409995c..8674574 100644 --- a/server/queue-sender.js +++ b/server/queue-sender.js @@ -1,6 +1,8 @@ +import { MessageError, message as textMessage } from '../public/message.js'; import { execFile } from 'node:child_process'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; +import { defaultContinuation } from '../public/i18n.js'; export function queueBinary(env = process.env) { if (env.CODEX_QUEUE_BINARY) return env.CODEX_QUEUE_BINARY; @@ -19,7 +21,7 @@ function execute(binary, args) { const env = { ...process.env }; for (const key of ['CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CODEX_TOOL_CALL_ID', 'CODEX_APP_TOOLS_PIPE_PATH', 'CODEX_APP_TOOLS_CALLER_HOST_ID']) delete env[key]; execFile(binary, args, { env, windowsHide: true, timeout: 20000, maxBuffer: 65536, shell: false }, (error, stdout) => { - if (error) reject(new Error(error.killed ? 'Codex 队列请求超时' : `Codex 队列请求失败 (${error.code || 'unknown'})`)); + if (error) reject(new MessageError(error.killed ? textMessage("error.queue_timeout") : textMessage("error.queue_failed", { code: error.code || 'unknown' }))); else resolve(String(stdout)); }); }); @@ -28,20 +30,28 @@ function execute(binary, args) { // Official queue command: no thread/resume, model override or approval override. export class QueueSender { constructor({ binary, run = execute } = {}) { this.binary = binary || queueBinary(); this.run = run; } - async send(threadId, { shouldSend = () => true } = {}) { + async send(threadId, { shouldSend = () => true, beforeSend, message = '', language = 'zh' } = {}) { + if (typeof message !== 'string' || message.length > 12000) { + throw Object.assign(new MessageError(textMessage("error.continuation_messages_must_be_text_of_up_to")), { safeToRetry: false }); + } + message = message.trim() || defaultContinuation(language); if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(threadId)) { - throw Object.assign(new Error('原聊天 ID 无效'), { safeToRetry: false }); + throw Object.assign(new MessageError(textMessage("error.invalid_original_chat_id")), { safeToRetry: false }); } try { await this.run(this.binary, ['queue', '--help']); } - catch { throw Object.assign(new Error('Codex 消息队列不可用,请更新桌面端或设置 CODEX_QUEUE_BINARY'), { safeToRetry: true }); } - if (!shouldSend()) throw Object.assign(new Error('已暂停,未发送续作消息'), { cancelled: true, safeToRetry: true }); + catch { throw Object.assign(new MessageError(textMessage("error.codex_message_queue_unavailable_update_the_desktop_app")), { safeToRetry: true }); } + if (!shouldSend()) throw Object.assign(new MessageError(textMessage("error.paused_no_continuation_message_sent")), { cancelled: true, safeToRetry: true }); + // Re-read after the capability probe, immediately before enqueueing. This + // hook has no message side effect, so its errors must not become uncertain. + await beforeSend?.(); + if (!shouldSend()) throw Object.assign(new MessageError(textMessage("error.paused_no_continuation_message_sent")), { cancelled: true, safeToRetry: true }); try { - const output = await this.run(this.binary, ['queue', '--thread', threadId, '--message', '继续']); + const output = await this.run(this.binary, ['queue', '--thread', threadId, '--message', message]); const queueId = /Queued message ([0-9a-f-]{36})/i.exec(String(output))?.[1]; return { queueId: queueId || null }; } catch (error) { // A timeout/exit can occur after enqueueing. Never silently send twice. - throw Object.assign(new Error(`发送结果未确认,请检查原聊天:${error.message}`), { safeToRetry: false }); + throw Object.assign(new MessageError(textMessage("error.delivery_unconfirmed_check_the_original_chat", { reason: error.messageInfo || error.message })), { safeToRetry: false }); } } } diff --git a/server/quota-wake.js b/server/quota-wake.js index 3281239..966c41b 100644 --- a/server/quota-wake.js +++ b/server/quota-wake.js @@ -1,73 +1,77 @@ +import { MessageError, setActivity, taskActivityMessage, message as textMessage } from '../public/message.js'; import { randomUUID } from 'node:crypto'; import { buckets, quotaAvailable, QUOTA_TTL_MS } from './policy.js'; +import { defaultContinuation } from '../public/i18n.js'; +import { readThreadReply } from './thread-reply.js'; +import { enforceWakeLimit, isEnabledMonitorTask } from './monitor-task.js'; -export function enforceWakeLimit(task, limit) { - if ((task.sentCount || 0) < limit) return false; - task.continuous = false; - if (task.wake?.delivery !== 'uncertain') { - task.status = 'paused'; task.waitReason = 'send-limit'; task.retryAt = 0; - if (task.wake && task.wake.delivery !== 'sending') { - task.wake.armed = false; - if (task.wake.delivery === 'pending') task.wake.delivery = 'idle'; - } - task.activity = `已达到原聊天发送上限(${task.sentCount}/${limit} 次),监测已停止;请查看原聊天确认工作结果`; - } - return true; -} +export { resetWake, enforceWakeLimit } from './monitor-task.js'; export function quotaExhausted(payload) { return buckets(payload).some(b => Boolean(b.rateLimitReachedType) || [b.primary, b.secondary].some(w => Number.isFinite(w?.usedPercent) && w.usedPercent >= 100)); } -export function resetWake(task) { - task.wake = { armed: false, delivery: 'idle' }; - task.status = task.continuous ? 'monitoring' : 'paused'; - task.waitReason = null; task.retryAt = 0; - task.activity = task.continuous ? '正在监测额度;确认耗尽后,恢复时向原聊天发送“继续”' : '额度恢复续作已暂停'; -} - export async function tickQuotaWake(manager, epoch = manager.scheduleEpoch) { const now = Date.now(); if (!manager.wakeSampledAt || now - manager.wakeSampledAt > QUOTA_TTL_MS) return; for (const task of manager.state.tasks) { if (!manager.canSchedule(epoch)) break; - if (task.mode !== 'quota-wake' || task.continuous !== true || !['monitoring', 'waiting'].includes(task.status)) continue; + if (!isEnabledMonitorTask(task)) continue; if (enforceWakeLimit(task, manager.state.settings.maxWakeSends)) { manager.changed(); continue; } const wake = task.wake ||= { armed: false, delivery: 'idle' }; if (quotaExhausted(manager.wakeQuota)) { if (!wake.armed) { Object.assign(wake, { armed: true, episodeId: randomUUID(), exhaustedAt: now, delivery: 'pending' }); task.status = 'waiting'; task.waitReason = 'quota'; task.retryAt = 0; - task.activity = '已确认额度耗尽,恢复后向原聊天发送一次“继续”'; - manager.log(`${task.name}:${task.activity}`, 'info', task.id); manager.changed(); + setActivity(task, textMessage("task.quota_exhaustion_confirmed_one_continuation_message_will_be")); + manager.log(taskActivityMessage(task), 'info', task.id); manager.changed(); } continue; } if (wake.armed !== true || wake.delivery !== 'pending' || (task.retryAt || 0) > now || !quotaAvailable(manager.wakeQuota, manager.wakeSampledAt, manager.state.settings.minimumRemaining)) continue; if (!manager.wakeSender) { - if (task.waitReason !== 'desktop') { task.waitReason = 'desktop'; task.activity = '额度已恢复,等待消息发送通道'; manager.changed(); } + if (task.waitReason !== 'desktop') { task.waitReason = 'desktop'; setActivity(task, textMessage("task.quota_recovered_waiting_for_message_delivery_to_become")); manager.changed(); } continue; } manager.wakeExecutions.set(task.id, wake); try { - // Durable outbox claim before the side effect. A restart leaves this uncertain. - wake.delivery = 'sending'; task.activity = '额度已恢复,正在向原聊天发送“继续”'; manager.changed(); + const permitted = () => manager.canSchedule(epoch) && isEnabledMonitorTask(task) + && task.wake === wake && manager.state.tasks.includes(task) && (task.sentCount || 0) < manager.state.settings.maxWakeSends + && quotaAvailable(manager.wakeQuota, manager.wakeSampledAt, manager.state.settings.minimumRemaining); + const inspectReply = async () => { + if (!permitted()) throw Object.assign(new MessageError(textMessage("error.paused_no_continuation_message_sent")), { cancelled: true }); + let reply; + try { reply = await readThreadReply(manager.rpc, task.threadId); } + catch { reply = 'unknown'; } + if (!permitted()) throw Object.assign(new MessageError(textMessage("error.paused_no_continuation_message_sent")), { cancelled: true }); + if (reply === 'complete') throw Object.assign(new MessageError(textMessage("error.the_last_instruction_already_received_a_complete_reply")), { replyComplete: true }); + if (reply !== 'incomplete') throw Object.assign(new MessageError(reply === 'active' ? textMessage("error.the_original_chat_is_still_replying_no_continuation") : textMessage("error.reply_status_could_not_be_confirmed_no_continuation")), { waitForReply: true }); + }; try { - const result = await manager.wakeSender.send(task.threadId, { shouldSend: () => manager.canSchedule(epoch) && task.continuous === true && task.status !== 'paused' && task.wake === wake && manager.state.tasks.includes(task) && (task.sentCount || 0) < manager.state.settings.maxWakeSends && quotaAvailable(manager.wakeQuota, manager.wakeSampledAt, manager.state.settings.minimumRemaining) }); + await inspectReply(); + // Durable outbox claim before the side effect. A restart leaves this uncertain. + wake.delivery = 'sending'; setActivity(task, textMessage("task.quota_recovered_sending_a_continuation_message_to_the")); manager.changed(); + const result = await manager.wakeSender.send(task.threadId, { message: task.continuationMessage?.trim() || defaultContinuation(manager.state.settings.language), shouldSend: permitted, beforeSend: inspectReply }); Object.assign(wake, { armed: false, delivery: 'sent', sentAt: Date.now(), queueId: result?.queueId || null }); task.sentCount = (task.sentCount || 0) + 1; // A user may pause while the queue command is in flight; don't undo it. if (task.continuous && task.status !== 'paused') { task.status = 'monitoring'; task.waitReason = null; } - task.activity = task.continuous ? '已向原聊天排队发送“继续”,继续监测;工作结果请在原聊天确认' : '已向原聊天排队发送“继续”,监测已暂停;已排队消息无法撤回'; + setActivity(task, task.continuous ? textMessage("task.continuation_message_queued_monitoring_continues_check_results_in") : textMessage("task.continuation_message_queued_monitoring_paused_queued_messages_can")); enforceWakeLimit(task, manager.state.settings.maxWakeSends); - manager.log(`${task.name}:${task.activity}`, 'success', task.id); + manager.log(taskActivityMessage(task), 'success', task.id); } catch (error) { - if (error.cancelled) { wake.delivery = 'pending'; } + if (manager.persistence.status !== 'ok') throw error; + if (error.replyComplete) { + wake.armed = false; wake.delivery = 'idle'; task.retryAt = 0; task.waitReason = null; + task.status = task.continuous ? 'monitoring' : 'paused'; + } else if (error.waitForReply) { + wake.delivery = 'pending'; task.waitReason = 'thread'; task.retryAt = Date.now() + manager.state.settings.pollSeconds * 1000; + } else if (error.cancelled) { if (wake.delivery === 'sending') wake.delivery = 'pending'; } else if (error.safeToRetry && task.continuous && task.status !== 'paused') { wake.delivery = 'pending'; task.waitReason = 'desktop'; task.retryAt = Date.now() + 60000; } else { wake.delivery = 'uncertain'; if (task.status !== 'paused') task.status = 'blocked'; } - task.activity = error.message; - manager.log(`${task.name}:${error.message}`, 'warning', task.id); + setActivity(task, error); + manager.log(taskActivityMessage(task), error.replyComplete ? 'info' : 'warning', task.id); } manager.changed(); } finally { manager.wakeExecutions.delete(task.id); } diff --git a/server/rpc.js b/server/rpc.js index 5962de5..36bb772 100644 --- a/server/rpc.js +++ b/server/rpc.js @@ -1,3 +1,4 @@ +import { MessageError, message as textMessage } from '../public/message.js'; import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { createInterface } from 'node:readline'; @@ -27,10 +28,10 @@ export class CodexRpc extends EventEmitter { this.child = null; for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); } this.pending.clear(); - this.emit('disconnect', error.message); + this.emit('disconnect', error.message, error.messageInfo); }; child.on('error', fail); - child.on('exit', (code) => fail(new Error(`Codex 进程已退出 (${code})`))); + child.on('exit', (code) => fail(new MessageError(textMessage("error.codex_exit", { code: code })))); child.stdin.on('error', fail); // Do not expose stderr: extensions can log credential-bearing diagnostics. child.stderr.resume(); @@ -50,19 +51,19 @@ export class CodexRpc extends EventEmitter { }); try { await this.call('initialize', { clientInfo: { name: 'codex_continuer', title: 'Codex Continuer', version: config.version }, capabilities: { experimentalApi: false } }); - if (this.child !== child) throw new Error('Codex 连接已失效'); + if (this.child !== child) throw new MessageError(textMessage("error.codex_connection")); this.send({ method: 'initialized' }); this.ready = true; } catch (error) { child.kill(); throw error; } } send(message) { - if (!this.child?.stdin.writable) throw new Error('Codex 未连接'); + if (!this.child?.stdin.writable) throw new MessageError(textMessage("error.codex_disconnected")); this.child.stdin.write(JSON.stringify(message) + '\n'); } call(method, params = {}) { const id = ++this.sequence; return new Promise((resolve, reject) => { - const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} 请求超时,请检查连接`)); }, 30000); + const timer = setTimeout(() => { this.pending.delete(id); reject(new MessageError(textMessage("error.rpc_timeout", { method: method }))); }, 30000); this.pending.set(id, { resolve, reject, timer }); try { this.send({ id, method, params }); } catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); } }); @@ -71,7 +72,7 @@ export class CodexRpc extends EventEmitter { const child = this.child; if (!child) return; this.child = null; this.ready = false; - for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new Error('Codex 连接已关闭')); } + for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new MessageError(textMessage("error.codex_connection_closed"))); } this.pending.clear(); // EOF permits app-server to close its sessions before the bounded fallback. child.stdin.end(); diff --git a/server/state-lifecycle.js b/server/state-lifecycle.js new file mode 100644 index 0000000..c6a06fb --- /dev/null +++ b/server/state-lifecycle.js @@ -0,0 +1,49 @@ +import { setActivity } from '../public/message.js'; +import { MAX_POLL_SECONDS } from './policy.js'; +import { taskModel } from './task-model.js'; +import { migrateLegacyWriter } from './monitor-task.js'; + +export { ACTIVE_TURN_STATUSES, waitForThread } from './direct-task.js'; + +export function initialState(enabled = true) { + return { settings: { enabled, language: 'zh', pollSeconds: 30, minimumRemaining: 5, maxRounds: 20, maxWakeSends: 20 }, tasks: [], logs: [], history: [] }; +} + +export function removeCompletedTasks(state) { + const ids = new Set(state.tasks.filter(task => task.status === 'completed').map(task => task.id)); + if (ids.size) state.tasks = state.tasks.filter(task => !ids.has(task.id)); + return ids; +} + +// Startup transformations only touch persisted data; they never contact Codex. +export function prepareState(state) { + let migrated = removeCompletedTasks(state).size > 0; + if (state.logs.length > 300) { state.logs = state.logs.slice(0, 300); migrated = true; } + if (state.history.length > 240) { state.history = state.history.slice(-240); migrated = true; } + for (const entry of state.logs) if (typeof entry.message === 'string' && entry.message.length > 2600) { entry.message = entry.message.slice(0, 2600); migrated = true; } + const settings = state.settings; + const pollSeconds = Number.isInteger(settings.pollSeconds) ? Math.max(10, Math.min(MAX_POLL_SECONDS, settings.pollSeconds)) : 30; + if (settings.pollSeconds !== pollSeconds) { settings.pollSeconds = pollSeconds; migrated = true; } + if (!Number.isInteger(settings.maxWakeSends) || settings.maxWakeSends < 1 || settings.maxWakeSends > 100) { + settings.maxWakeSends = Number.isInteger(settings.maxRounds) ? Math.max(1, Math.min(100, settings.maxRounds)) : 20; + migrated = true; + } + for (const task of state.tasks) { + if (task.continuous === undefined) { task.continuous = false; migrated = true; } + if (typeof task.activity === 'string' && task.activity.length > 2400) { setActivity(task, task.activity.slice(0, 2400)); migrated = true; } + if (migrateLegacyWriter(task)) migrated = true; + if (taskModel(task).prepare(task, settings)) migrated = true; + } + return migrated; +} + +export function prepareBackup(state) { + prepareState(state); + state.settings.enabled = false; + for (const task of state.tasks) taskModel(task).restoreBackup(task); +} + +export function prepareStorageRetry(state) { + for (const task of state.tasks) taskModel(task).retryStorage(task); + state.settings.enabled = false; +} diff --git a/server/state-persistence.js b/server/state-persistence.js new file mode 100644 index 0000000..3050ae5 --- /dev/null +++ b/server/state-persistence.js @@ -0,0 +1,64 @@ +import { messageFields, message as textMessage } from '../public/message.js'; +import { ClientError } from './http-policy.js'; +import { initialState, prepareState, prepareBackup, prepareStorageRetry } from './state-lifecycle.js'; + +const OUTPUT_SAVE_INTERVAL_MS = 250; + +// Owns the in-memory state and its durable-save lifecycle. Store owns file I/O. +export class StatePersistence { + constructor(store, onFailure) { + this.store = store; + this.onFailure = onFailure; + this.status = { status: 'ok', message: null }; + this.pending = false; + this.timer = null; + } + get healthy() { return this.status.status === 'ok'; } + load() { + try { this.state = this.store.load(); } + catch (error) { this.status = { status: 'error', ...messageFields(error), needsRestore: true }; } + this.state ||= initialState(!this.status.needsRestore); + if (prepareState(this.state)) { + try { this.store.save(this.state); } + catch (error) { this.fail(error); } + } + } + fail(error) { + this.status = { ...this.status, status: 'error', messageInfo: undefined, ...messageFields(error), failedAt: Date.now() }; + this.state.settings.enabled = false; + this.onFailure(error); + } + changed({ deferSave = false } = {}) { + this.pending = true; + if (!deferSave) return this.flush(); + // One deadline from the first fragment; continuous output cannot postpone it. + if (!this.timer) this.timer = setTimeout(() => { + this.timer = null; + try { this.flush(); } + catch { /* fail recorded the error; retain pending output for retry. */ } + }, OUTPUT_SAVE_INTERVAL_MS); + } + flush() { + clearTimeout(this.timer); this.timer = null; + if (!this.pending) return; + try { this.store.save(this.state); } + catch (error) { this.fail(error); throw error; } + this.pending = false; + this.status = { status: 'ok', message: null }; + } + commit() { + this.pending = true; + this.flush(); + } + restoreBackup() { + this.state = this.store.restoreBackup(prepareBackup); + clearTimeout(this.timer); this.timer = null; + this.status = { status: 'ok', message: null }; + this.pending = false; + } + retry() { + if (this.status.needsRestore) throw new ClientError(textMessage("error.the_main_state_file_is_corrupt_choose_restore")); + prepareStorageRetry(this.state); + this.commit(); + } +} diff --git a/server/static-assets.js b/server/static-assets.js index 4ecc117..4a461c2 100644 --- a/server/static-assets.js +++ b/server/static-assets.js @@ -4,7 +4,13 @@ import { createHash } from 'node:crypto'; const files = new Map([ ['/', 'index.html'], ['/widget', 'widget.html'], - ...['app.js', 'widget.js', 'access.js', 'view-lifecycle.js', 'task-mode.js', 'task-details.js', 'approval-details.js', 'storage-status.js', 'quota-display.js', 'rhine-readout.js', 'style.css', 'widget.css', 'favicon.svg', 'rhine-signal-loop.svg'].map(file => ['/' + file, file]), + ...[ + 'app.js', 'widget.js', 'i18n.js', 'messages.js', 'message.js', 'system-messages.js', 'access.js', 'api-client.js', + 'html.js', 'icons.js', 'task-view.js', 'task-forms.js', 'view-lifecycle.js', + 'task-mode.js', 'task-details.js', 'approval-details.js', 'storage-status.js', + 'quota-display.js', 'rhine-readout.js', 'style.css', 'widget.css', + 'favicon.svg', 'rhine-signal-loop.svg', + ].map(file => ['/' + file, file]), ]); const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }; diff --git a/server/store.js b/server/store.js index cbfd8bb..16d51e4 100644 --- a/server/store.js +++ b/server/store.js @@ -1,53 +1,56 @@ +import { MessageError, message as textMessage } from '../public/message.js'; import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, statSync } from 'node:fs'; import { dirname } from 'node:path'; import config from './config.cjs'; +import { validateTask } from './task-model.js'; export function validateState(state) { if (!state || typeof state !== 'object' || Array.isArray(state) || !state.settings || typeof state.settings.enabled !== 'boolean' - || !['tasks', 'logs', 'history'].every(key => Array.isArray(state[key]))) throw new Error('状态文件结构无效'); - if (state.schemaVersion != null && state.schemaVersion !== config.schemaVersion) throw new Error('状态文件版本不兼容'); - if (!state.logs.every(entry => entry && typeof entry.message === 'string') || !state.history.every(entry => entry && Array.isArray(entry.values))) throw new Error('活动或额度历史格式无效'); + || !['tasks', 'logs', 'history'].every(key => Array.isArray(state[key]))) throw new MessageError(textMessage("error.storage_shape")); + if (state.schemaVersion != null && state.schemaVersion !== config.schemaVersion) throw new MessageError(textMessage("error.storage_version")); + if (!state.logs.every(entry => entry && typeof entry.message === 'string') || !state.history.every(entry => entry && Array.isArray(entry.values))) throw new MessageError(textMessage("error.storage_history")); if (!Number.isInteger(state.settings.pollSeconds) || !Number.isInteger(state.settings.maxRounds) || state.settings.maxRounds < 1 || state.settings.maxRounds > 100 - || !Number.isInteger(state.settings.minimumRemaining) || state.settings.minimumRemaining < 1 || state.settings.minimumRemaining > 99) throw new Error('状态文件设置无效'); - if (state.settings.maxWakeSends !== undefined && (!Number.isInteger(state.settings.maxWakeSends) || state.settings.maxWakeSends < 1 || state.settings.maxWakeSends > 100)) throw new Error('状态文件发送上限无效'); + || !Number.isInteger(state.settings.minimumRemaining) || state.settings.minimumRemaining < 1 || state.settings.minimumRemaining > 99) throw new MessageError(textMessage("error.storage_settings")); + if (state.settings.maxWakeSends !== undefined && (!Number.isInteger(state.settings.maxWakeSends) || state.settings.maxWakeSends < 1 || state.settings.maxWakeSends > 100)) throw new MessageError(textMessage("error.storage_send_limit")); + if (state.settings.language !== undefined && !['zh', 'en'].includes(state.settings.language)) throw new Error('Invalid saved language'); const ids = new Set(), threads = new Set(); - const statuses = ['queued', 'waiting', 'starting', 'running', 'approval', 'pausing', 'paused', 'blocked', 'failed', 'completed', 'monitoring']; for (const task of state.tasks) { - if (!task || typeof task.id !== 'string' || !statuses.includes(task.status) || ids.has(task.id)) throw new Error('任务记录无效或 ID 重复'); + validateTask(task); + if (ids.has(task.id)) throw new MessageError(textMessage("error.task_duplicate")); ids.add(task.id); - if ((task.continuous !== undefined && typeof task.continuous !== 'boolean') - || (task.rounds !== undefined && (!Number.isSafeInteger(task.rounds) || task.rounds < 0)) - || (task.sentCount !== undefined && (!Number.isSafeInteger(task.sentCount) || task.sentCount < 0)) - || (task.mode !== undefined && !['direct', 'quota-wake'].includes(task.mode))) throw new Error('任务模式、开关或次数无效'); - if (task.wake !== undefined) { - const wake = task.wake; - if (!wake || typeof wake !== 'object' || Array.isArray(wake) || typeof wake.armed !== 'boolean' - || !['idle', 'pending', 'sending', 'sent', 'uncertain'].includes(wake.delivery) - || (['pending', 'sending'].includes(wake.delivery) && wake.armed !== true) - || (['idle', 'sent'].includes(wake.delivery) && wake.armed !== false) - || (wake.episodeId !== undefined && (typeof wake.episodeId !== 'string' || !wake.episodeId)) - || (wake.exhaustedAt !== undefined && (!Number.isFinite(wake.exhaustedAt) || wake.exhaustedAt <= 0))) throw new Error('额度监测发送状态无效'); - } - if ((task.prompt != null && (typeof task.prompt !== 'string' || task.prompt.length > 12000)) || (task.note != null && (typeof task.note !== 'string' || task.note.length > 12000)) - || (task.output != null && typeof task.output !== 'string') || (task.plan != null && (!Array.isArray(task.plan) || !task.plan.every(step => step && typeof step.step === 'string')))) throw new Error('任务目标、备注或输出字段无效'); if (task.threadId && task.status !== 'completed') { - if (typeof task.threadId !== 'string' || threads.has(task.threadId)) throw new Error('状态中存在重复会话,请检查备份后恢复'); + if (typeof task.threadId !== 'string' || threads.has(task.threadId)) throw new MessageError(textMessage("error.thread_duplicate")); threads.add(task.threadId); } } return state; } function readState(file) { - if (statSync(file).size > 64 * 1024 * 1024) throw new Error('状态文件超过 64 MiB,请检查后恢复备份'); + if (statSync(file).size > 64 * 1024 * 1024) throw new MessageError(textMessage("error.storage_size")); return readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); } +function removeArchivedTasks(file, removedIds) { + if (!existsSync(file)) return; + let archived; + try { archived = validateState(JSON.parse(readState(file))); } + catch (error) { + if (error.code) throw error; + // A damaged recovery snapshot is diagnostic evidence, not a writable state. + return; + } + const tasks = archived.tasks.filter(task => !removedIds.has(task.id)); + if (tasks.length === archived.tasks.length) return; + archived.tasks = tasks; + writeFileSync(file + '.tmp', JSON.stringify(archived, null, 2), { mode: 0o600, flush: true }); + renameSync(file + '.tmp', file); +} export class Store { constructor(file) { this.file = file; this.loadError = null; } load() { try { if (!existsSync(this.file)) { - if (existsSync(this.file + '.bak')) throw new Error('主状态文件缺失,但存在备份,请明确恢复'); + if (existsSync(this.file + '.bak')) throw new MessageError(textMessage("error.storage_missing")); return null; } const value = validateState(JSON.parse(readState(this.file))); @@ -55,14 +58,20 @@ export class Store { } catch (error) { this.loadError = error; throw error; } } save(state) { - if (this.loadError) throw new Error(`状态未恢复,禁止覆盖原文件:${this.loadError.message}`); + if (this.loadError) throw new MessageError(textMessage("error.storage_unrecovered", { reason: this.loadError.messageInfo || this.loadError.message })); validateState(state); state.schemaVersion = config.schemaVersion; mkdirSync(dirname(this.file), { recursive: true }); writeFileSync(this.file + '.tmp', JSON.stringify(state, null, 2), { mode: 0o600, flush: true }); if (existsSync(this.file)) { - const previous = readState(this.file); - validateState(JSON.parse(previous)); - writeFileSync(this.file + '.bak.tmp', previous, { mode: 0o600, flush: true }); + const previous = validateState(JSON.parse(readState(this.file))); + const remainingIds = new Set(state.tasks.map(task => task.id)); + const removedIds = new Set(previous.tasks.filter(task => !remainingIds.has(task.id)).map(task => task.id)); + if (removedIds.size) { + previous.tasks = previous.tasks.filter(task => !removedIds.has(task.id)); + for (const suffix of ['.before-restore', '.restore.tmp']) removeArchivedTasks(this.file + suffix, removedIds); + } + // Backups retain prior values for surviving records, never deleted tasks. + writeFileSync(this.file + '.bak.tmp', JSON.stringify(previous, null, 2), { mode: 0o600, flush: true }); renameSync(this.file + '.bak.tmp', this.file + '.bak'); } renameSync(this.file + '.tmp', this.file); diff --git a/server/summary.js b/server/summary.js index b65554a..6f1af10 100644 --- a/server/summary.js +++ b/server/summary.js @@ -1,4 +1,5 @@ import { buckets } from './policy.js'; +import { boundedMessage, formatMessage, message } from '../public/message.js'; export const SUMMARY_BUDGET = 768 * 1024; const text = (value, limit) => typeof value === 'string' ? value.length > limit ? value.slice(0, limit - 1) + '…' : value : typeof value === 'object' ? null : value; @@ -10,23 +11,25 @@ const windowSummary = value => value && pick(value, ['usedPercent', 'windowDurat export function compactSummary(snapshot) { const result = { ...pick(snapshot, ['app', 'version', 'buildId', 'protocolVersion', 'schemaVersion', 'sampledAt', 'demo', 'observationOnly']), - settings: pick(snapshot.settings, ['enabled', 'pollSeconds', 'minimumRemaining', 'maxRounds', 'maxWakeSends']), - persistence: { ...pick(snapshot.persistence, ['status', 'needsRestore', 'pending', 'failedAt', 'backupAvailable']), message: text(snapshot.persistence?.message, 2000), loadError: text(snapshot.persistence?.loadError, 2000) }, - connection: { status: snapshot.connection?.status, message: text(snapshot.connection?.message, 2000) }, + settings: pick(snapshot.settings, ['enabled', 'language', 'pollSeconds', 'minimumRemaining', 'maxRounds', 'maxWakeSends']), + persistence: { ...pick(snapshot.persistence, ['status', 'needsRestore', 'pending', 'failedAt', 'backupAvailable']), message: text(snapshot.persistence?.message, 2000), messageInfo: boundedMessage(snapshot.persistence?.messageInfo, 2000), loadError: text(snapshot.persistence?.loadError, 2000) }, + connection: { status: snapshot.connection?.status, message: text(snapshot.connection?.message, 2000), messageInfo: boundedMessage(snapshot.connection?.messageInfo, 2000) }, tasks: snapshot.tasks.map(task => ({ ...pick(task, ['id', 'mode', 'name', 'threadId', 'status', 'continuous', 'rounds', 'sentCount', 'waitReason', 'retryAt', 'createdAt', 'updatedAt', 'progress', 'detailRevision']), - cwd: text(task.cwd, 256), activity: text(task.activity, 240), + cwd: text(task.cwd, 256), activity: text(task.activity, 240), activityMessage: boundedMessage(task.activityMessage, 240), + ...(task.mode === 'quota-wake' ? { notePreview: text(task.note || '', 500), noteTruncated: (task.note?.length || 0) > 500 } : {}), ...(task.wake ? { wake: pick(task.wake, ['armed', 'delivery', 'episodeId', 'exhaustedAt', 'sentAt', 'queueId']) } : {}), })), approvals: snapshot.approvals.map(item => ({ ...pick(item, ['id', 'taskId', 'method', 'detailToken']), requiresDetails: (item.reason?.length || 0) > 240 || (item.command?.length || 0) > 1000 || (item.cwd?.length || 0) > 256, reason: text(item.reason, 240), command: text(item.command, 1000), cwd: text(item.cwd, 256) })), quota: snapshot.quota ? { rateLimitsByLimitId: Object.fromEntries(buckets(snapshot.quota).slice(0, 16).map((bucket, index) => [String(bucket.limitId || index).slice(0, 80), { ...pick(bucket, ['limitId', 'limitName', 'planType', 'rateLimitReachedType'], 80), primary: windowSummary(bucket.primary), secondary: windowSummary(bucket.secondary) }])) } : null, history: snapshot.history.slice(-240).map(entry => ({ time: Number.isFinite(entry.time) ? entry.time : 0, values: entry.values.slice(0, 16).map(value => ({ id: text(value?.id, 40), used: Number.isFinite(value?.used) ? value.used : null })) })), - logs: snapshot.logs.slice(0, 50).map(entry => ({ ...pick(entry, ['id', 'time', 'type', 'taskId']), message: text(entry.message, 180) })), + logs: snapshot.logs.slice(0, 50).map(entry => ({ ...pick(entry, ['id', 'time', 'type', 'taskId']), message: text(entry.message, 180), messageInfo: boundedMessage(entry.messageInfo, 180) })), }; if (Buffer.byteLength(JSON.stringify(result)) > SUMMARY_BUDGET) { result.history = []; result.logs = []; result.approvals = result.approvals.map(item => ({ ...item, requiresDetails: true, command: '', reason: text(item.reason, 80) })); - result.summaryNotice = '列表内容较多,已精简历史和授权预览;完整内容请打开详情。'; + result.summaryNoticeInfo = message('system.summary_truncated'); + result.summaryNotice = formatMessage(result.summaryNoticeInfo); } return result; } diff --git a/server/task-model.js b/server/task-model.js new file mode 100644 index 0000000..cc5ac9d --- /dev/null +++ b/server/task-model.js @@ -0,0 +1,22 @@ +import { MessageError, message as textMessage } from '../public/message.js'; +import { directTaskModel, isDirectTask } from './direct-task.js'; +import { monitorTaskModel, isMonitorTask } from './monitor-task.js'; + +/** @typedef {import('./direct-task.js').DirectTask | import('./monitor-task.js').MonitorTask} Task */ + +// Omitted mode is accepted only for compatibility with persisted direct tasks. +// Startup resolves the legacy writer migration before assigning an explicit mode. +export function taskModel(task) { + if (isMonitorTask(task)) return monitorTaskModel; + if (isDirectTask(task)) return directTaskModel; + throw new MessageError(textMessage("error.task_mode")); +} + +export function validateTask(task) { + if (!task || typeof task.id !== 'string') throw new MessageError(textMessage("error.task_record")); + if (task.continuous !== undefined && typeof task.continuous !== 'boolean') throw new MessageError(textMessage("error.task_switch")); + if ((task.prompt != null && (typeof task.prompt !== 'string' || task.prompt.length > 12000)) || (task.note != null && (typeof task.note !== 'string' || task.note.length > 12000)) + || (task.continuationMessage !== undefined && (typeof task.continuationMessage !== 'string' || task.continuationMessage.length > 12000)) + || (task.output != null && typeof task.output !== 'string') || (task.plan != null && (!Array.isArray(task.plan) || !task.plan.every(step => step && typeof step.step === 'string')))) throw new MessageError(textMessage("error.task_fields")); + taskModel(task).validate(task); +} diff --git a/server/thread-reply.js b/server/thread-reply.js new file mode 100644 index 0000000..8b1af92 --- /dev/null +++ b/server/thread-reply.js @@ -0,0 +1,35 @@ +// Inspect protocol state only: a final reply ends the latest instruction, but +// does not independently prove that the project's acceptance criteria passed. +export function turnReplyState(turn) { + if (!turn || typeof turn.id !== 'string' || !turn.id) return 'unknown'; + if (turn.status === 'inProgress') return 'active'; + if (!['completed', 'failed', 'interrupted'].includes(turn.status) + || !Array.isArray(turn.items) || turn.items.some(item => !item || typeof item.type !== 'string') + || (turn.itemsView != null && turn.itemsView !== 'full')) return 'unknown'; + if (turn.status !== 'completed') return 'incomplete'; + const instruction = turn.items.findLastIndex(item => item.type === 'userMessage'); + const reply = turn.items.slice(instruction + 1).findLast(item => item.type === 'agentMessage'); + if (reply && (typeof reply.text !== 'string' || (reply.phase != null && !['commentary', 'final_answer'].includes(reply.phase)))) return 'unknown'; + // Older providers omit phase. A successfully completed turn and nonempty + // assistant reply retain compatibility; commentary alone is not a final reply. + return typeof reply?.text === 'string' && reply.text.trim() + && (reply.phase == null || reply.phase === 'final_answer') ? 'complete' : 'incomplete'; +} + +const runtimeState = thread => thread?.status?.type === 'active' ? 'active' + : ['idle', 'notLoaded'].includes(thread?.status?.type) ? null : 'unknown'; + +export async function readThreadReply(rpc, threadId) { + const { thread } = await rpc.call('thread/read', { threadId, includeTurns: false }); + if (thread?.id !== threadId) return 'unknown'; + const runtime = runtimeState(thread); + if (runtime) return runtime; + if (thread.historyMode === 'paginated') { + const page = await rpc.call('thread/turns/list', { threadId, limit: 1, sortDirection: 'desc', itemsView: 'full' }); + return Array.isArray(page?.data) && page.data.length === 1 ? turnReplyState(page.data[0]) : 'unknown'; + } + // Legacy app-servers expose full chronological history through thread/read. + const { thread: history } = await rpc.call('thread/read', { threadId, includeTurns: true }); + if (history?.id !== threadId) return 'unknown'; + return runtimeState(history) || (Array.isArray(history.turns) ? turnReplyState(history.turns.at(-1)) : 'unknown'); +} diff --git a/server/turn-controller.js b/server/turn-controller.js new file mode 100644 index 0000000..5c36526 --- /dev/null +++ b/server/turn-controller.js @@ -0,0 +1,246 @@ +import { setActivity, taskActivityMessage, message as textMessage } from '../public/message.js'; +import { randomUUID } from 'node:crypto'; +import { ClientError } from './http-policy.js'; +import { localProjectPath } from './project-path.js'; +import { isQuotaError, isWriterConflict, parseOutcome } from './policy.js'; +import { retainOutput, outcomeSummary } from './output.js'; +import { ACTIVE_TURN_STATUSES, waitForThread, isDirectTask } from './direct-task.js'; + +const outcomeSchema = { type: 'object', properties: { status: { type: 'string', enum: ['done', 'continue', 'blocked'] }, summary: { type: 'string' } }, required: ['status', 'summary'], additionalProperties: false }; +const instructions = '\n请维护可验证的步骤计划。仅在原始目标全部完成并验证后返回 status=done;还有可独立推进的工作则返回 continue;需要用户输入或权限则返回 blocked。summary 简述证据或阻塞原因。不要因为额度不足声称完成。'; +const TURN_EVENTS = new Set(['turn/started', 'turn/completed', 'turn/plan/updated', 'item/agentMessage/delta', 'item/started', 'item/completed']); +const START_EVENT_LIMIT = 256; +const START_EVENT_BYTES = 4 * 1024 * 1024; + +// Owns Codex turn execution, early-event correlation and manual approvals. +// Scheduling and persistence policy are supplied through explicit collaborators. +export class TurnController { + constructor(rpc, { persistence, canSchedule, hasExecutionQuota, isStopped, changed, log, invalidateQuota, onError }) { + this.rpc = rpc; + this.persistence = persistence; + this.canSchedule = canSchedule; + this.hasExecutionQuota = hasExecutionQuota; + this.isStopped = isStopped; + this.changed = changed; + this.log = log; + this.invalidateQuota = invalidateQuota; + this.onError = onError; + this.executions = new Map(); + this.approvals = new Map(); + } + get state() { return this.persistence.state; } + safely(action) { + try { return action(); } catch (error) { this.onError(error); } + } + async safelyAsync(action) { + try { return await action(); } catch (error) { this.onError(error); } + } + async run(task, epoch) { + if (!isDirectTask(task)) throw new ClientError(textMessage("error.monitor_execution")); + if (!this.canSchedule(epoch)) return; + const execution = { pending: false, events: [], bytes: 0, overflow: false }; + this.executions.set(task.id, execution); + const current = () => this.executions.get(task.id) === execution; + const previousRounds=task.rounds; + const permitted = () => { + if (!current() || task.status !== 'starting') return false; + if (this.canSchedule(epoch) && this.hasExecutionQuota()) return true; + task.status = 'queued'; setActivity(task, textMessage("task.start_cancelled_waiting_for_active_scheduling_and_fresh")); this.changed(); return false; + }; + try { + task.cwd = localProjectPath(task.cwd); + task.turnId = null; + task.status = 'starting'; task.waitReason=null; setActivity(task, textMessage("task.resuming_chat")); this.changed(); + if (task.rounds >= this.state.settings.maxRounds) { + task.status = 'blocked'; setActivity(task, textMessage("task.automatic_turn_limit_reached_resume_manually_to_continue")); this.changed(); return; + } + if (task.threadId) { + const read = await this.rpc.call('thread/read', { threadId: task.threadId, includeTurns: false }); + if (!permitted()) return; + if (read.thread.status?.type === 'active') { waitForThread(task,'thread'); this.changed(); return; } + await this.rpc.call('thread/resume', { threadId: task.threadId, cwd: task.cwd, excludeTurns: true, approvalPolicy: 'on-request', sandbox: 'workspace-write' }); + } else { + const result = await this.rpc.call('thread/start', { cwd: task.cwd, approvalPolicy: 'on-request', sandbox: 'workspace-write' }); + if (!current()) return; + task.threadId = result.thread.id; + } + if (!current()) return; + if (task.status !== 'starting') { this.changed(); return; } + if (!permitted()) return; + task.output = ''; task.outputTruncated = false; task.outcome = null; + task.toolCount = 0; task.rounds++; task.status = 'running'; task.updatedAt = Date.now(); + this.changed(); + execution.pending = true; + const result = await this.rpc.call('turn/start', { + threadId: task.threadId, + input: [{ type: 'text', text: `${task.rounds > 1 ? '继续原目标,先检查已有成果,避免重复操作。\n' : ''}${task.prompt}${instructions}`, text_elements: [] }], + outputSchema: outcomeSchema, + }); + if (!current()) return; + if (typeof result.turn?.id !== 'string' || !result.turn.id) throw new ClientError(textMessage("error.no_valid_turn_id_received_check_the_original")); + execution.pending = false; + if (ACTIVE_TURN_STATUSES.includes(task.status)) task.turnId = result.turn.id; + this.log(textMessage("log.work_started", { name: task.name }), 'success', task.id); + if (execution.overflow) { + task.status = 'pausing'; setActivity(task, textMessage("task.startup_events_exceeded_the_buffer_limit_interrupting_check")); + this.log(taskActivityMessage(task), 'warning', task.id); + } + const events = execution.events; execution.events = []; execution.bytes = 0; + for (const event of events) event.request ? this.request(event.message) : this.event(event.message); + this.changed(); + if (task.status === 'pausing' && task.turnId === result.turn.id) { + try { await this.rpc.call('turn/interrupt', { threadId: task.threadId, turnId: task.turnId }); } + catch (error) { + if (!current() || task.status !== 'pausing' || task.turnId !== result.turn.id) return; + task.status = 'blocked'; setActivity(task, textMessage("task.interruption_unconfirmed", { reason: error.messageInfo || error.message })); + this.log(taskActivityMessage(task), 'warning', task.id); this.changed(); + } + } + } catch (error) { + if (!current()) return; + if (['paused', 'pausing'].includes(task.status)) task.status = 'paused'; + else if (isWriterConflict(error)) { + task.rounds=previousRounds; task.turnId=null; task.lastError=error.message; + waitForThread(task,'writer'); + } + else { task.status = 'blocked'; setActivity(task, error); } + this.log(taskActivityMessage(task), 'warning', task.id); this.changed(); + } finally { + if (current()) { + this.executions.delete(task.id); + for (const event of execution.events) if (event.request) this.rejectRequest(event.message); + } + } + } + matchesTurn(task, params) { + const turnId = params.turnId ?? params.turn?.id; + return isDirectTask(task) && ACTIVE_TURN_STATUSES.includes(task.status) + && task.threadId === params.threadId && typeof task.turnId === 'string' && !!task.turnId + && turnId === task.turnId && (params.turnId == null || params.turn?.id == null || params.turnId === params.turn.id); + } + rejectRequest(msg) { + this.rpc.send({ id: msg.id, error: { code: -32601, message: 'This client requires manual handling for this request.' } }); + } + deferTurnEvent(task, message, request = false) { + const execution = this.executions.get(task.id); + if (!execution?.pending) return false; + // Notifications may precede the turn/start response. Its ID is authoritative. + const bytes = execution.overflow ? 0 : Buffer.byteLength(JSON.stringify(message)); + if (execution.overflow || execution.events.length >= START_EVENT_LIMIT || execution.bytes + bytes > START_EVENT_BYTES) { + for (const event of execution.events) if (event.request) this.rejectRequest(event.message); + execution.events = []; execution.bytes = 0; execution.overflow = true; + if (request) this.rejectRequest(message); + } else { + execution.events.push({ message, request }); execution.bytes += bytes; + } + return true; + } + event({ method, params: p = {} }) { + if (!TURN_EVENTS.has(method)) return; + const task = this.state.tasks.find(t => t.threadId === p.threadId); + if (!task) return; + if (!isDirectTask(task)) return; + if (this.deferTurnEvent(task, { method, params: p })) return; + if (!this.matchesTurn(task, p)) return; + if (method === 'turn/completed' && !['completed', 'failed', 'interrupted'].includes(p.turn?.status)) return; + task.updatedAt = Date.now(); + if (method === 'turn/started') { setActivity(task, textMessage("task.codex_is_working_on_the_goal")); } + else if (method === 'turn/plan/updated') { task.plan = p.plan.slice(0, 100).map(step => ({ ...step, step: outcomeSummary(step.step) })); setActivity(task, (p.explanation || task.plan.find(s => s.status === 'inProgress')?.step) ? outcomeSummary(p.explanation || task.plan.find(s => s.status === 'inProgress')?.step) : textMessage("task.plan_updated")); } + else if (method === 'item/agentMessage/delta') { + retainOutput(task, p.delta, true); + task.outcome = null; + } + else if (method === 'item/started') { + const item = p.item; + if (['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch'].includes(item.type)) task.toolCount++; + setActivity(task, ({ commandExecution: textMessage("task.running_a_command"), fileChange: textMessage("task.editing_files"), reasoning: textMessage("task.analyzing"), agentMessage: textMessage("task.generating_a_response"), mcpToolCall: textMessage("task.calling_a_tool") })[item.type] || textMessage("task.working")); + } else if (method === 'item/completed' && p.item.type === 'agentMessage') { + const text = p.item.text ?? task.output; + // Parse the complete JSON before retaining only its display tail. + task.outcome = parseOutcome(text); + if (p.item.text != null) retainOutput(task, text); + } + else if (method === 'turn/completed') { + for (const [id, request] of this.approvals) if (request.taskId === task.id) this.approvals.delete(id); + task.turnId = null; + if (p.turn.status === 'interrupted' || task.status === 'pausing') { + task.status = 'paused'; setActivity(task, textMessage("task.work_paused")); + } else if (p.turn.status === 'failed') { + const limited = isQuotaError(p.turn.error); + task.status = limited && task.continuous ? 'waiting' : 'failed'; + task.waitReason=task.status==='waiting' ? 'quota' : null; + setActivity(task, p.turn.error?.message || textMessage("task.this_turn_failed")); + task.retryAt = Date.now() + 60000; + if (limited) this.invalidateQuota(); + } else { + const outcome = task.outcome || parseOutcome(task.output); + setActivity(task, { message: outcome.summary, messageInfo: outcome.summaryMessage }); + task.status = outcome.status === 'done' ? 'completed' : outcome.status === 'continue' && task.continuous && task.toolCount > 0 ? 'queued' : 'blocked'; + if (outcome.status === 'continue' && task.toolCount === 0) setActivity(task, textMessage("task.no_tool_activity", { summary: task.activityMessage || task.activity })); + task.retryAt = Date.now() + 5000; + } + this.log(taskActivityMessage(task), task.status === 'completed' ? 'success' : 'info', task.id); + } else return; + this.changed({ deferSave: method === 'item/agentMessage/delta' }); + } + request(msg) { + const task = this.state.tasks.find(t => t.threadId === msg.params?.threadId); + if (task && isDirectTask(task) && this.deferTurnEvent(task, msg, true)) return; + if (!task || !this.matchesTurn(task, msg.params || {})) { this.rejectRequest(msg); return; } + if (task.status === 'pausing') { this.rejectRequest(msg); return; } + // File changes need a complete patch/scope view. Until that exists, leave + // these requests to the original Codex client instead of approving blindly. + const supported = msg.method === 'item/commandExecution/requestApproval'; + const commandFields = ['threadId', 'turnId', 'itemId', 'approvalId', 'command', 'reason', 'cwd', 'commandActions', 'kind', 'startedAtMs']; + // CLI 0.153.2 permits null optional scope fields. A non-null scope needs + // its own review UI; unknown fields still fail closed, even when null. + const absentScopeFields = ['additionalPermissions', 'availableDecisions', 'environmentId', 'networkApprovalContext', 'proposedExecpolicyAmendment', 'proposedNetworkPolicyAmendments']; + const reviewable = typeof msg.params.command === 'string' && !!msg.params.command.trim() && msg.params.command.length <= 12000 + && Object.keys(msg.params).every(key => commandFields.includes(key) || (absentScopeFields.includes(key) && msg.params[key] === null)) + && (msg.params.kind === undefined || msg.params.kind === 'command') + && (msg.params.startedAtMs === undefined || Number.isSafeInteger(msg.params.startedAtMs)) + && (msg.params.reason == null || (typeof msg.params.reason === 'string' && msg.params.reason.length <= 2000)) + && (msg.params.cwd == null || typeof msg.params.cwd === 'string'); + if (supported && reviewable) { + if (this.approvals.size >= 50) { this.rejectRequest(msg); return; } + this.approvals.set(String(msg.id), { id: msg.id, detailToken: randomUUID(), taskId: task.id, threadId: task.threadId, turnId: task.turnId, method: msg.method, reason: msg.params.reason || '操作需要授权', command: msg.params.command || '', cwd: msg.params.cwd || task.cwd }); + task.status = 'approval'; setActivity(task, textMessage("task.waiting_for_your_approval")); + this.log(textMessage("log.is_awaiting_approval", { name: task.name }), 'warning', task.id); + } else { + this.rejectRequest(msg); + this.cancelApprovals(task); + task.status = 'pausing'; setActivity(task, msg.method === 'item/fileChange/requestApproval' ? textMessage("task.file_changes_cannot_be_fully_reviewed_and_were") : reviewable ? textMessage("task.user_input_is_required_respond_in_codex_before") : textMessage("task.approval_details_cannot_be_fully_reviewed_and_were")); + if (task.turnId) void this.safelyAsync(() => this.interruptTask(task)); + } + this.changed(); + } + cancelApprovals(task) { + for (const [id, request] of this.approvals) if (request.taskId === task.id) { + this.approvals.delete(id); + this.safely(() => this.rpc.send({ id: request.id, result: { decision: 'decline' } })); + } + } + async interruptTask(task) { + const threadId = task.threadId, turnId = task.turnId; + try { await this.rpc.call('turn/interrupt', { threadId, turnId }); } + catch (error) { + if (!this.state.tasks.includes(task) || task.status !== 'pausing' || task.threadId !== threadId || task.turnId !== turnId) return; + task.status = 'blocked'; setActivity(task, textMessage("task.interruption_unconfirmed", { reason: error.messageInfo || error.message })); this.changed(); throw error; + } + } + approve(id, decision, token) { + if (!this.persistence.healthy) throw new ClientError(textMessage("error.recover_data_storage_before_handling_approvals")); + const request = this.approvals.get(String(id)); + if (!request || typeof token !== 'string' || token !== request.detailToken || !['accept', 'decline'].includes(decision)) throw new ClientError(textMessage("error.approval_expired_or_decision_invalid")); + if (request.method !== 'item/commandExecution/requestApproval' || typeof request.command !== 'string' || !request.command.trim()) throw new ClientError(textMessage("error.approval_details_cannot_be_fully_reviewed_handle_this")); + const task = this.state.tasks.find(t => t.id === request.taskId); + if (this.isStopped() || !this.rpc.ready || !task || task.status !== 'approval' || !this.matchesTurn(task, request)) throw new ClientError(textMessage("error.the_approval_turn_is_no_longer_active_or")); + // Confirm storage before granting an operation permission. + this.persistence.commit(); + this.rpc.send({ id: request.id, result: { decision } }); + this.approvals.delete(String(id)); + const remaining = [...this.approvals.values()].some(other => other.taskId === task.id); + task.status = remaining ? 'approval' : 'running'; setActivity(task, remaining ? textMessage("task.more_operations_await_approval") : decision === 'accept' ? textMessage("task.approved_continuing") : textMessage("task.operation_declined")); + this.changed(); + } +} diff --git a/tests/access.test.js b/tests/access.test.js index 212849b..12bab2d 100644 --- a/tests/access.test.js +++ b/tests/access.test.js @@ -3,9 +3,8 @@ import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync, realpathSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; -import { runInNewContext } from 'node:vm'; import access from '../server/access.cjs'; -import { AuthenticatedSource } from '../public/access.js'; +import { AuthenticatedSource, discardLegacyAccess, accessHeaders } from '../public/access.js'; import { doctor } from '../scripts/doctor.js'; test('credentials are random, persistent, mode-separated and malformed files fail closed', t => { @@ -23,10 +22,9 @@ test('credentials are random, persistent, mode-separated and malformed files fai test('retired browser bootstrap discards legacy credentials and never authorizes requests', () => { const token = 'a'.repeat(64), saved = new Map(); let replaced; - const source = readFileSync(new URL('../public/access.js', import.meta.url), 'utf8').replace(/^export /gm, '') + '\nthis.headers = accessHeaders();'; saved.set('continuer-access', token); const context = { window: {}, location: { hash: '#access=' + token, pathname: '/', search: '' }, history: { replaceState(_state, _title, url) { replaced = url; } }, sessionStorage: { removeItem: key => saved.delete(key), getItem: key => saved.get(key) }, URLSearchParams }; - runInNewContext(source, context); assert.equal(replaced, '/'); assert.equal(context.headers.Authorization, undefined); + discardLegacyAccess(context); assert.equal(replaced, '/'); assert.equal(accessHeaders().Authorization, undefined); assert.equal(saved.size, 0); }); diff --git a/tests/continuation-message.test.js b/tests/continuation-message.test.js new file mode 100644 index 0000000..66dc9e6 --- /dev/null +++ b/tests/continuation-message.test.js @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Manager } from '../server/manager.js'; +import { QueueSender } from '../server/queue-sender.js'; +import { Store, validateState } from '../server/store.js'; +import { dispatch, validate } from '../plugin/tools.js'; +import { TaskDetails } from '../public/task-details.js'; +import { interruptedThread } from './fixtures/thread-history.js'; + +const threadId = '33333333-3333-4333-8333-333333333333'; +const quota = usedPercent => ({ rateLimits: { primary: { usedPercent } } }); +function manager(store = { load: () => null, save() {} }) { + const rpc = new EventEmitter(), calls = []; + rpc.ready = true; rpc.close = () => {}; + rpc.call = async (_method, params) => ({ thread: interruptedThread(params?.threadId) }); + const sender = new QueueSender({ binary: 'mock-codex', run: async (_binary, args) => { calls.push(args); return `Queued message ${threadId}`; } }); + const m = new Manager(rpc, store, { demo: true, wakeSender: sender }); + m.connection.status = 'connected'; + return { m, calls, async cycle() { m.setQuota(quota(100)); await m.tick(); m.setQuota(quota(0)); await m.tick(); } }; +} + +test('custom multiline message survives a real isolated store restart and reaches only the selected chat once per recovery', async t => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-message-')); + t.after(() => { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); + const store = new Store(join(directory, 'state.json')); + const custom = '优先完成测试,遇到阻塞时说明原因。\n保留 "引号"、`代码`、$() 和 <标签>。'; + const original = manager(store), task = await original.m.importThread(threadId, '只在首页显示的备注', custom); + await original.cycle(); assert.equal(original.calls.length, 0, 'Import stays paused'); + original.m.stop(); + const restored = manager(store), current = restored.m.state.tasks[0]; + t.after(() => restored.m.stop()); + assert.equal(current.note, task.note); assert.equal(current.continuationMessage, custom); + await restored.m.action(current.id, 'resume'); + restored.m.setQuota(quota(0)); await restored.m.tick(); assert.equal(restored.calls.length, 0); + await restored.cycle(); await restored.m.tick(); + assert.deepEqual(restored.calls, [['queue', '--help'], ['queue', '--thread', threadId, '--message', custom]]); + assert.equal(current.sentCount, 1); + await restored.cycle(); assert.equal(current.sentCount, 2); assert.equal(restored.calls.at(-1).at(-1), custom); + assert.ok(restored.calls.every(args => !args.includes(task.note)), 'Notes never enter the queue'); +}); + +test('empty, whitespace and legacy records all send the default continuation', async () => { + for (const message of ['', ' \r\n\t ', undefined]) { + const f = manager(), task = await f.m.importThread(threadId, 'Local only', message); + if (message === undefined) delete task.continuationMessage; + await f.m.action(task.id, 'resume'); await f.cycle(); + assert.equal(f.calls.at(-1).at(-1), '继续'); assert.equal(task.sentCount, 1); f.m.stop(); + } +}); + +test('malformed or overlong messages are rejected before import or queue effects and cannot load from storage', async () => { + const f = manager(); + for (const message of [null, 1, {}, 'x'.repeat(12001)]) { + await assert.rejects(f.m.importThread(threadId, '', message), /续作消息/); + await assert.rejects(f.m.wakeSender.send(threadId, { message }), /续作消息/); + assert.throws(() => validate('continuer_import_session', { threadId, continuationMessage: message })); + } + assert.equal(f.calls.length, 0); assert.equal(f.m.state.tasks.length, 0); + const task = await f.m.importThread(threadId, '', 'x'.repeat(12000)); + validateState(f.m.state); + task.continuationMessage = null; assert.throws(() => validateState(f.m.state), /续作消息/); + f.m.stop(); +}); + +test('authenticated homepage summaries include bounded notes while full message remains in details', async () => { + const f = manager(), note = '\n' + '备注'.repeat(6000); + const task = await f.m.importThread(threadId, note.slice(0, 12000), 'Custom private message'); + const summary = f.m.snapshot({ details: false }).tasks[0]; + assert.ok(summary.notePreview.startsWith('\n')); + assert.equal(summary.notePreview.length, 500); assert.equal(summary.noteTruncated, true); + assert.equal(summary.continuationMessage, undefined); assert.equal(summary.note, undefined); + assert.equal(f.m.taskSnapshot(task.id).note, task.note); + assert.equal(f.m.taskSnapshot(task.id).continuationMessage, 'Custom private message'); + f.m.stop(); +}); + +test('MCP forwards custom messages without including private notes or messages in status', async () => { + let forwarded; + await dispatch('continuer_import_session', { threadId, note: 'Local note', continuationMessage: 'Custom instruction' }, { + ensureService: async () => {}, request: async (path, body) => { forwarded = { path, body }; return { tasks: [] }; }, + }); + assert.deepEqual(forwarded, { path: '/api/import', body: { threadId, note: 'Local note', continuationMessage: 'Custom instruction' } }); + const result = await dispatch('continuer_status', {}, { ensureService: async () => ({ tasks: [{ notePreview: 'Local note', continuationMessage: 'Custom instruction' }], approvals: [] }) }); + assert.equal(result.tasks[0].notePreview, undefined); assert.equal(result.tasks[0].continuationMessage, undefined); +}); + +test('blank continuation follows the current saved language, including changes during an armed recovery', async () => { + const f = manager(), task = await f.m.importThread(threadId, '本地备注', ' \n\t '); + await f.m.action(task.id, 'resume'); + f.m.setQuota(quota(100)); await f.m.tick(); + f.m.updateSettings({ language: 'en' }); + f.m.setQuota(quota(0)); await f.m.tick(); + assert.equal(f.calls.at(-1).at(-1), 'Continue'); + f.m.updateSettings({ language: 'zh' }); await f.cycle(); + assert.equal(f.calls.at(-1).at(-1), '继续'); + task.continuationMessage = '继续处理测试;请保留这条自定义消息。'; + f.m.updateSettings({ language: 'en' }); await f.cycle(); + assert.equal(f.calls.at(-1).at(-1), task.continuationMessage); + assert.equal(task.note, '本地备注'); assert.equal(task.sentCount, 3); f.m.stop(); +}); + +test('language persists independently of scheduling and invalid updates cannot modify policies', async () => { + let saved; + const f = manager({ load: () => null, save: state => { saved = structuredClone(state); } }); + f.m.updateSettings({ enabled: false }); f.m.updateSettings({ language: 'en' }); + assert.equal(saved.settings.language, 'en'); assert.equal(saved.settings.enabled, false); + const restored = manager({ load: () => structuredClone(saved), save() {} }); + assert.equal(restored.m.snapshot({ details: false }).settings.language, 'en'); + assert.equal(restored.m.state.settings.enabled, false); + const before = structuredClone(restored.m.state.settings); + for (const language of ['fr', '', null, 1, {}]) { + assert.throws(() => restored.m.updateSettings({ language, enabled: true, maxWakeSends: 100 })); + assert.deepEqual(restored.m.state.settings, before); + assert.throws(() => validateState({ ...saved, settings: { ...saved.settings, language } })); + } + delete saved.settings.language; validateState(saved); + const sender = new QueueSender({ run: async (_binary, args) => { if (args.includes('--message')) assert.equal(args.at(-1), 'Continue'); return ''; } }); + return sender.send(threadId, { message: ' \n ', language: 'en' }).finally(() => { f.m.stop(); restored.m.stop(); }); +}); + +test('editing a saved message survives restart without resetting monitoring, notes or counters', async t => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-edit-message-')); + const store = new Store(join(directory, 'state.json')), f = manager(store); + let restored; + t.after(() => { f.m.stop(); restored?.m.stop(); assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); + const task = await f.m.importThread(threadId, 'Keep local note', 'Old message'); + task.sentCount = 2; + const revision = f.m.taskSnapshot(task.id).detailRevision, wake = structuredClone(task.wake); + const message = '先修复测试,再继续。\n保留 <标签>、"引号" 和 `代码`。'; + await f.m.action(task.id, 'message', message); + assert.equal(task.continuationMessage, message); assert.equal(task.note, 'Keep local note'); + assert.equal(task.status, 'paused'); assert.equal(task.continuous, false); assert.equal(task.sentCount, 2); + assert.deepEqual(task.wake, wake); assert.deepEqual(f.calls, []); + assert.ok(f.m.taskSnapshot(task.id).detailRevision > revision); + assert.equal(f.m.snapshot({ details: false }).tasks[0].continuationMessage, undefined); + assert.ok(f.m.state.logs.every(log => !log.message.includes(message))); + f.m.stop(); restored = manager(store); + const current = restored.m.state.tasks[0]; assert.equal(current.continuationMessage, message); + await restored.m.action(current.id, 'resume'); await restored.cycle(); + assert.equal(restored.calls.at(-1).at(-1), message); assert.equal(current.sentCount, 3); +}); + +test('editing an armed recovery uses the new message without losing the exhaustion record', async () => { + const f = manager(), task = await f.m.importThread(threadId, '', 'Before'); + await f.m.action(task.id, 'resume'); f.m.setQuota(quota(100)); await f.m.tick(); + const wake = structuredClone(task.wake); + await f.m.action(task.id, 'message', 'After'); + assert.deepEqual(task.wake, wake); assert.equal(task.status, 'waiting'); assert.equal(task.continuous, true); + f.m.setQuota(quota(0)); await f.m.tick(); + assert.equal(f.calls.at(-1).at(-1), 'After'); assert.equal(task.sentCount, 1); f.m.stop(); +}); + +test('editing during delivery leaves the in-flight message unchanged and applies to the next recovery', async () => { + const f = manager(), task = await f.m.importThread(threadId, '', 'In flight'); + let release, entered; + const gate = new Promise(resolve => { release = resolve; }), started = new Promise(resolve => { entered = resolve; }); + const sent = []; + f.m.wakeSender = new QueueSender({ run: async (_binary, args) => { + if (args.includes('--help')) { entered(); await gate; } else sent.push(args.at(-1)); + return ''; + } }); + await f.m.action(task.id, 'resume'); f.m.setQuota(quota(100)); await f.m.tick(); + f.m.setQuota(quota(0)); const pending = f.m.tick(); await started; + await f.m.action(task.id, 'message', 'Next recovery'); + assert.equal(task.wake.delivery, 'sending'); assert.equal(task.sentCount, 0); + release(); await pending; assert.deepEqual(sent, ['In flight']); + await f.cycle(); assert.deepEqual(sent, ['In flight', 'Next recovery']); assert.equal(task.sentCount, 2); f.m.stop(); +}); + +test('clearing an edited message restores localized defaults and invalid edits leave the task unchanged', async () => { + const f = manager(), task = await f.m.importThread(threadId, '', 'Custom'); + for (const value of [undefined, null, 7, {}, 'x'.repeat(12001)]) { + const before = structuredClone(task); + await assert.rejects(f.m.action(task.id, 'message', value), /续作消息/); + assert.deepEqual(task, before); + } + await f.m.action(task.id, 'message', ' \r\n\t '); assert.equal(task.continuationMessage, ''); + await f.m.action(task.id, 'resume'); + for (const [language, message] of [['en', 'Continue'], ['zh', '继续']]) { + f.m.updateSettings({ language }); await f.cycle(); assert.equal(f.calls.at(-1).at(-1), message); + } + await assert.rejects(f.m.action('missing', 'message', 'Invalid')); + assert.throws(() => validate('continuer_task_action', { id: task.id, action: 'message', value: 'Not allowed via MCP' })); + f.m.stop(); +}); + +test('edit readiness requires complete details from the current revision, not an older response', async () => { + const pending = [], details = new TaskDetails(() => new Promise(resolve => pending.push(resolve)), () => {}); + const first = { id: 'task', detailRevision: 1 }, second = { ...first, detailRevision: 2 }; + details.read(first); assert.equal(details.ready(first), false); + pending.shift()({ task: { id: 'task', continuationMessage: 'Old' } }); await Promise.resolve(); + assert.equal(details.ready(first), true); + details.read(second); assert.equal(details.ready(second), false); assert.equal(details.ready(first), false); + pending.shift()({ task: { id: 'task', continuationMessage: 'New' } }); await Promise.resolve(); + assert.equal(details.ready(second), true); assert.equal(details.read(second).continuationMessage, 'New'); + details.clear(); assert.equal(details.ready(second), false); +}); diff --git a/tests/desktop-layout.test.js b/tests/desktop-layout.test.js index c604b63..34d18d0 100644 --- a/tests/desktop-layout.test.js +++ b/tests/desktop-layout.test.js @@ -1,33 +1,32 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import vm from 'node:vm'; import { EventEmitter } from 'node:events'; const require=createRequire(import.meta.url); -const source=readFileSync(new URL('../desktop/main.cjs',import.meta.url),'utf8'); +const { createDesktopRuntime } = require('../desktop/runtime.cjs'); -function fixture(x=700,y=300,nativeMonitor=true) { - let now=0,serial=0,bounds={x,y,width:160,height:160},top=false; +function fixture(x=700,y=300,nativeMonitor=true,rounding=0) { + let now=0,serial=0,bounds={x,y,width:160+rounding,height:160+rounding},top=false; const timers=new Map(),updates=[],children=[]; const prefs={follow:false,expanded:false,hidden:false,compactSize:160,offsets:{},positions:{}}; - const window={getBounds:()=>({...bounds}),setBounds:next=>{bounds={...next};updates.push({...next});}, + const window={getBounds:()=>({...bounds}),setBounds:next=>{bounds={...next,width:next.width+rounding,height:next.height+rounding};updates.push({...next});}, isDestroyed:()=>false,isVisible:()=>true,isFocused:()=>true,isAlwaysOnTop:()=>top, setAlwaysOnTop:value=>{top=value;},focus:()=>{},hide:()=>{},webContents:{send:()=>{}}}; const app={setName:()=>{},setPath:()=>{},requestSingleInstanceLock:()=>true,on:()=>{}, whenReady:()=>({then:()=>({catch:()=>{}})})}; - const context=vm.createContext({ - require:name=>name==='electron'?{app,screen:{getDisplayMatching:()=>({workArea:{x:0,y:0,width:1920,height:1080}}),screenToDipRect:(_window,rect)=>rect}}: + const layout=createDesktopRuntime({ + window, + load:name=>name==='electron'?{app,screen:{getDisplayMatching:()=>({workArea:{x:0,y:0,width:1920,height:1080}}),screenToDipRect:(_window,rect)=>rect}}: + name==='../server/config.cjs'?{dataDir:'fixture-data',port:4317,appInfo:()=>({})}: + name==='../server/logging.cjs'?{installLogs(){}}: name==='node:fs'?{mkdirSync:()=>{},readFileSync:()=>JSON.stringify(prefs),writeFileSync:()=>{},existsSync:()=>nativeMonitor}: name==='node:child_process'?{spawn:(command,args,options)=>{const child=new EventEmitter();Object.assign(child,{command,args,options});child.stdout={};child.stderr={resume:()=>{}};child.kill=()=>{child.killed=true;};children.push(child);return child;}}: name==='node:readline'?{createInterface:({input})=>{const lines=new EventEmitter();lines.close=()=>{lines.closed=true;};input.lines=lines;return lines;}}: name.startsWith('./')?require('../desktop/'+name.slice(2)):require(name), - __dirname:'E:/codex_continuer/desktop',process:{env:{},argv:[],pid:1,platform:'win32'}, - performance:{now:()=>now},setTimeout:(callback,delay)=>{const id=++serial;timers.set(id,{callback,at:now+delay});return id;},clearTimeout:id=>timers.delete(id), + processInfo:{env:{},argv:[],pid:1,platform:'win32'}, + timing:{setTimeout:(callback,delay)=>{const id=++serial;timers.set(id,{callback,at:now+delay});return id;},clearTimeout:id=>timers.delete(id)}, }); - vm.runInContext(source+'\nglobalThis.layout={init:w=>{win=w;isReady=true},changeExpanded,finishTransition,show,savePosition,state,getPrefs:()=>prefs,setFollow:value=>{prefs.follow=value;syncTracker();}};',context); - const layout=context.layout;layout.init(window); function advance(ms=450){for(let elapsed=0;elapsed=timer.at){timers.delete(id);timer.callback();}}} function toggle(expanded){layout.changeExpanded(expanded);layout.show(true,true);} return {layout,toggle,advance,updates,children,get top(){return top;},get bounds(){return {...bounds};},move:next=>{bounds={...bounds,...next};},get timers(){return timers.size;}}; @@ -47,15 +46,15 @@ test('independent mode has no tracker and disabling follow stops it without chan f.layout.setFollow(false);assert.equal(child.killed,true);assert.equal(f.layout.state().tracking,false); child.stdout.lines.emit('line',JSON.stringify({found:true,visible:true,x:0,y:0,width:1000,height:800})); child.emit('exit',0); - assert.equal(child.stdout.lines.closed,true);assert.equal(f.layout.getPrefs().follow,false); + assert.equal(child.stdout.lines.closed,true);assert.equal(f.layout.preferences.follow,false); assert.equal(f.layout.state().tracking,false); f.layout.setFollow(true);assert.equal(f.children.length,2); - child.emit('error',Error('late error'));assert.equal(f.layout.getPrefs().follow,true); + child.emit('error',Error('late error'));assert.equal(f.layout.preferences.follow,true); }); test('a tracker failure falls back to independent mode and permits a later retry',()=>{ const f=fixture();f.layout.setFollow(true);f.children[0].emit('error',Error('spawn failed')); - assert.equal(f.layout.getPrefs().follow,false);assert.equal(f.layout.state().tracking,false); + assert.equal(f.layout.preferences.follow,false);assert.equal(f.layout.state().tracking,false); f.layout.setFollow(true);assert.equal(f.children.length,2); }); @@ -82,7 +81,31 @@ test('moving the detailed panel never overwrites the compact position',()=>{ const f=fixture();f.layout.savePosition();f.toggle(true);f.advance(); f.move({x:250,y:60});f.layout.savePosition(); f.toggle(false);f.advance();assert.deepEqual(f.bounds,{x:700,y:300,width:160,height:160}); - f.toggle(true);f.advance();assert.deepEqual(f.bounds,{x:250,y:60,width:432,height:720}); + f.toggle(true);f.advance(); + assert.equal(f.bounds.x+f.bounds.width,860); + assert.equal(f.bounds.y,300); +}); + +test('expanding from a moved card ignores an expanded position saved elsewhere',()=>{ + const f=fixture();f.layout.savePosition();f.toggle(true);f.advance(); + f.move({x:180,y:20});f.layout.savePosition(); + f.toggle(false);f.advance();f.move({x:1300,y:100});f.layout.savePosition(); + f.toggle(true);f.advance(); + assert.deepEqual(f.bounds,{x:1028,y:100,width:432,height:720}); + f.toggle(false);f.advance();assert.deepEqual(f.bounds,{x:1300,y:100,width:160,height:160}); +}); + +test('following expansion uses the card location and persists the new offset for later tracker updates',()=>{ + const f=fixture(1300,100);f.layout.setFollow(true); + const tracker=f.children[0]; + tracker.stdout.lines.emit('line',JSON.stringify({found:true,visible:true,x:0,y:0,width:1000,height:900})); + f.move({x:1300,y:100});f.layout.savePosition(); + f.layout.preferences.offsets.expanded={right:500,bottom:300}; + f.layout.preferences.positions.expanded={x:68,y:0,width:432,height:720}; + f.toggle(true);f.advance(); + assert.deepEqual(f.bounds,{x:1028,y:100,width:432,height:720}); + tracker.stdout.lines.emit('line',JSON.stringify({found:true,visible:true,x:10,y:20,width:1000,height:900})); + assert.deepEqual(f.bounds,{x:1038,y:120,width:432,height:720}); }); test('rapid reversal cancels the old timer without storing intermediate geometry',()=>{ @@ -90,7 +113,7 @@ test('rapid reversal cancels the old timer without storing intermediate geometry for(let i=0;i<5;i++) {f.toggle(true);f.advance(80);f.toggle(false);f.advance(32);} f.advance(); assert.deepEqual(f.bounds,{x:700,y:300,width:160,height:160}); - const compact=f.layout.getPrefs().positions.compact; + const compact=f.layout.preferences.positions.compact; assert.equal(compact.x,700);assert.equal(compact.y,300);assert.equal(compact.width,160); assert.equal(f.timers,0); }); @@ -121,3 +144,15 @@ test('a stale fade acknowledgement cannot commit a reversed transition',()=>{ assert.deepEqual(f.bounds,{x:700,y:300,width:160,height:160}); assert.equal(f.updates.length,0);assert.equal(f.timers,0); }); + +test('repeated expansion does not accumulate fractional-DPI rounding or overwrite the compact drop',()=>{ + for (const rounding of [1,4]) { + const f=fixture(1300,100,true,rounding);f.layout.savePosition(); + for(let cycle=0;cycle<50;cycle++) { + f.toggle(true);f.advance(); + assert.equal(f.bounds.x,1028+rounding);assert.equal(f.bounds.y,100); + f.toggle(false);f.advance(); + assert.deepEqual(f.bounds,{x:1300,y:100,width:160+rounding,height:160+rounding}); + } + } +}); diff --git a/tests/drag.test.js b/tests/drag.test.js index 9ea7445..8361820 100644 --- a/tests/drag.test.js +++ b/tests/drag.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createPointerDrag } from '../desktop/pointer-drag.cjs'; -import { placeIndependent, samePlacementContext } from '../desktop/geometry.cjs'; +import { placeIndependent, samePlacementContext, expandFromCard } from '../desktop/geometry.cjs'; function fixture() { let cursor={x:800,y:400}, bounds={x:720,y:320,width:161,height:160}, tick; @@ -137,3 +137,12 @@ test('manual drop stays authoritative until an actual placement context change', assert.equal(samePlacementContext(following,{...following,anchor:{...following.anchor}}),true); assert.equal(samePlacementContext(following,{...following,anchor:{...following.anchor,x:1}}),false); }); + +test('panel expansion preserves the card corner that has room on its current display',()=>{ + const work={x:0,y:0,width:1920,height:1080},size={width:432,height:720}; + assert.deepEqual(expandFromCard({x:1300,y:100,width:160,height:160},work,size),{x:1028,y:100,...size}); + assert.deepEqual(expandFromCard({x:50,y:850,width:160,height:160},work,size),{x:50,y:290,...size}); + assert.deepEqual(expandFromCard({x:1300,y:850,width:160,height:160},work,size),{x:1028,y:290,...size}); + assert.deepEqual(expandFromCard({x:-1250,y:80,width:160,height:160},{x:-1280,y:0,width:1280,height:800},size),{x:-1250,y:80,...size}); + assert.deepEqual(expandFromCard({x:100,y:30,width:160,height:160},{x:0,y:0,width:360,height:600},size),{x:0,y:0,width:360,height:600}); +}); diff --git a/tests/fixtures/shutdown-passive.mjs b/tests/fixtures/shutdown-passive.mjs index fd17555..e850142 100644 --- a/tests/fixtures/shutdown-passive.mjs +++ b/tests/fixtures/shutdown-passive.mjs @@ -7,7 +7,7 @@ localTls.fetchLocal = async () => { calls++; throw Error('fixture network failur const runtime = await import('../../plugin/runtime.js'); let blocked = 0; for (const action of [() => runtime.ensureService(), () => runtime.openWidget({ automatic: true })]) { - try { await action(); } catch (error) { if (error.message.includes('已退出')) blocked++; } + try { await action(); } catch (error) { if (error.code === 'EXIT_REQUESTED') blocked++; } } const before = calls; try { await runtime.openWidget(); } catch {} diff --git a/tests/fixtures/thread-history.js b/tests/fixtures/thread-history.js new file mode 100644 index 0000000..cd84b49 --- /dev/null +++ b/tests/fixtures/thread-history.js @@ -0,0 +1,8 @@ +export function interruptedThread(id, overrides = {}) { + return { id, name: 'Original', cwd: 'E:\\fixture', status: { type: 'idle' }, turns: [{ + id: 'quota-interrupted-turn', status: 'failed', itemsView: 'full', + error: { codexErrorInfo: 'usageLimitExceeded' }, + items: [{ type: 'userMessage', id: 'instruction', content: [{ type: 'text', text: 'Fixture instruction' }] }, + { type: 'agentMessage', id: 'partial', phase: 'commentary', text: 'Still working' }], + }], ...overrides }; +} diff --git a/tests/fixtures/widget-animation.cjs b/tests/fixtures/widget-animation.cjs index 4f12bd0..0d6e8d5 100644 --- a/tests/fixtures/widget-animation.cjs +++ b/tests/fixtures/widget-animation.cjs @@ -34,7 +34,15 @@ app.whenReady().then(async () => { await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); mark('initialized'); const phases = []; - for (const at of [0, 15000, 30000, 45000, 59000]) phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=" + at + ");({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); + for (const at of [0, 12000, 12300, 12600, 13000, 15000, 17000, 18000, 18300, 18600, 18900, 19200, 21000, 24000, 30000, 45000, 59000]) { + phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=" + at + ");({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName,flight:Number(getComputedStyle(document.querySelector('.rhine-signal-launch-stage')).opacity),signal:Number(getComputedStyle(document.querySelector('.rhine-signal-data')).opacity)})")); + if (captureDir && [0, 13000, 15000, 18000, 19200, 21000, 24000, 30000].includes(at)) { + fs.mkdirSync(captureDir, { recursive: true }); + await new Promise(resolve => setTimeout(resolve, 40)); + fs.writeFileSync(path.join(captureDir, 'phase-' + at + '.png'), (await win.webContents.capturePage()).toPNG()); + } + } + const position = await win.webContents.executeJavaScript("(() => {const node=document.querySelector('.rhine-signal-data-line'),box=node.getBoundingClientRect(),label=document.querySelector('#mini-window').getBoundingClientRect(),reset=document.querySelector('#mini-reset').getBoundingClientRect();return {offset:node.transform.baseVal.consolidate().matrix.f,top:box.top,bottom:box.bottom,labelBottom:label.bottom,resetTop:reset.top};})()"); mark('phases-checked'); await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); const duration = await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); @@ -47,6 +55,8 @@ app.whenReady().then(async () => { fs.writeFileSync(path.join(captureDir, 'quota-' + at + '.png'), (await win.webContents.capturePage()).toPNG()); } } - fs.writeFileSync(output, JSON.stringify({ ...result.value, phases, frames, duration })); + await command('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + const reduced = await win.webContents.executeJavaScript("({opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})"); + fs.writeFileSync(output, JSON.stringify({ ...result.value, phases, frames, duration, position, reduced })); win.destroy(); app.quit(); }).catch(error => { fs.writeFileSync(output, JSON.stringify({ error: error.stack })); app.exit(1); }); diff --git a/tests/fixtures/widget-view.js b/tests/fixtures/widget-view.js index abac21d..3ef02f7 100644 --- a/tests/fixtures/widget-view.js +++ b/tests/fixtures/widget-view.js @@ -1,15 +1,11 @@ -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import { VisibleFeed, LazyResource, setHtml } from '../../public/view-lifecycle.js'; -import { TaskDetails } from '../../public/task-details.js'; -import { ApprovalDetails } from '../../public/approval-details.js'; -import { storageNotice } from '../../public/storage-status.js'; -import { taskMode, wakeExplanation } from '../../public/task-mode.js'; -import { quotaWindow, resetCountdown } from '../../public/quota-display.js'; -import { pixelText } from '../../public/rhine-readout.js'; +import { mountPage } from '../../public/app.js'; +import { mountWidget } from '../../public/widget.js'; -export function widgetView({ reducedMotion = true } = {}) { - const elements = new Map(), requests = [], sources = [], intervals = new Map(), timeouts = new Map(); +export function widgetView(options = {}) { return interfaceView('widget', options); } +export function pageView(options = {}) { return interfaceView('page', options); } + +function interfaceView(surface, { reducedMotion = true, threads = async () => ({ data: [], nextCursor: null }), task = async () => ({ task: null }), request = null } = {}) { + const elements = new Map(), requests = [], apiCalls = [], sources = [], intervals = new Map(), timeouts = new Map(), events = new Map(); let now = Date.now(); let serial = 0, desktopListener; const classes = initial => { const values = new Set(initial); return { contains: key => values.has(key), add: key => values.add(key), remove: key => values.delete(key), toggle(key, on = !values.has(key)) { on ? values.add(key) : values.delete(key); } }; }; @@ -17,26 +13,41 @@ export function widgetView({ reducedMotion = true } = {}) { if (!elements.has(selector)) elements.set(selector, { value: '', textContent: '', open: false, writes: 0, dataset: {}, classList: classes([]), style: { setProperty() {} }, set innerHTML(value) { this.html = value; this.writes++; }, get innerHTML() { return this.html || ''; }, - addEventListener() {}, setAttribute() {}, replaceChildren() {}, close() { this.open = false; }, showModal() { this.open = true; }, querySelectorAll: () => [], + addEventListener(name, callback) { (this.events ||= new Map()).set(name, callback); }, emit(name) { this.events?.get(name)?.(); }, setAttribute() {}, replaceChildren() {}, close() { this.open = false; }, showModal() { this.open = true; }, querySelectorAll: () => [], closest: () => null, + querySelector: query => element(`${selector} ${query}`), + elements: { namedItem: name => element(`${selector} [name=${name}]`) }, }); return elements.get(selector); }; const desktop = { expanded: false, visible: false, follow: false, compactSize: 256, panelSize: { width: 414, height: 702 } }; const bridge = { onState: callback => { desktopListener = callback; }, async control(action) { if (action === 'renderer-ready') desktop.visible = true; return { ...desktop }; } }; - class Feed extends VisibleFeed { constructor(options) { super({ ...options, create: () => { const source = { close() { this.closed = true; } }; sources.push(source); return source; } }); } } - const document = { hidden: false, activeElement: null, body: { classList: classes(['collapsed']) }, documentElement: { style: { setProperty() {} } }, querySelector: element, importNode: value => value, addEventListener() {} }; + const querySelector = selector => surface === 'page' && selector.startsWith('#settings-form') && !element('#main').innerHTML.includes('id="settings-form"') ? null : element(selector); + const document = { hidden: false, activeElement: null, body: { classList: classes(surface === 'widget' ? ['collapsed'] : []) }, documentElement: { style: { setProperty() {} } }, querySelector, querySelectorAll: () => [], importNode: value => value, addEventListener(name, callback) { events.set(name, callback); } }; class ViewDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } } - const context = { document, window: { continuerDesktop: bridge, matchMedia: () => ({ matches: reducedMotion }), addEventListener() {} }, localStorage: { getItem: () => null, setItem() {} }, Date: ViewDate, - TaskDetails, ApprovalDetails, VisibleFeed: Feed, LazyResource, setHtml, storageNotice, taskMode, wakeExplanation, - quotaWindow: (state, slot, connected) => quotaWindow(state, slot, connected, now), resetCountdown: value => resetCountdown(value, now), pixelText, accessHeaders: () => ({}), + class ViewFormData { + constructor(form) { this.values = form.values || {}; } + [Symbol.iterator]() { return Object.entries(this.values)[Symbol.iterator](); } + } + const context = { document, window: { continuerDesktop: surface === 'widget' ? bridge : undefined, matchMedia: () => ({ matches: reducedMotion }), addEventListener() {} }, localStorage: { getItem: () => null, setItem() {} }, Date: ViewDate, FormData: ViewFormData, + createSource() { const source = { close() { this.closed = true; } }; sources.push(source); return source; }, AbortSignal, getComputedStyle: () => ({ opacity: '1' }), DOMParser: class { parseFromString() { return { querySelector: () => null, documentElement: {} }; } }, setInterval(callback, period) { const id = ++serial; intervals.set(id, { callback, period, at: now + period }); return id; }, clearInterval: id => intervals.delete(id), setTimeout(callback, period) { const id = ++serial; timeouts.set(id, { callback, at: now + period }); return id; }, clearTimeout: id => timeouts.delete(id), - async fetch(path) { requests.push(path); return { ok: true, json: async () => ({ data: [], nextCursor: null }), text: async () => '' }; }, + async fetch(path, options) { + requests.push(path); apiCalls.push({ path, options }); + const data = request ? await request(path, options) : path.startsWith('/api/threads') ? await threads(path, options) : path.startsWith('/api/tasks/') ? await task(path, options) : {}; + return { ok: true, json: async () => data, text: async () => '' }; + }, }; - const source = readFileSync(new URL('../../public/widget.js', import.meta.url), 'utf8').replace(/^import .*\n/gm, ''); - runInNewContext(source, context); - return { requests, sources, intervals, timeouts, element, now: () => now, + (surface === 'widget' ? mountWidget : mountPage)(context); + return { requests, apiCalls, sources, intervals, timeouts, element, now: () => now, + action(action, id) { return events.get('click')({ target: { closest: () => ({ dataset: { action, id } }) } }); }, + page(page) { return events.get('click')({ target: { closest: () => ({ dataset: { page } }) } }); }, + submit(id, values, dataset = {}) { + const form = element('#' + id); Object.assign(form, { id, values, dataset }); + return events.get('submit')({ target: form, preventDefault() {} }); + }, + visible(value) { document.hidden = !value; events.get('visibilitychange')?.(); }, advance(duration) { const end = now + duration; while (true) { @@ -49,7 +60,7 @@ export function widgetView({ reducedMotion = true } = {}) { now = end; }, desktop(change) { Object.assign(desktop, change); desktopListener({ ...desktop }); }, - message(index, state) { sources[index].onmessage({ data: JSON.stringify(state) }); }, + message(index, state) { sources[index].onmessage({ data: JSON.stringify({ version: '0.5.1', buildId: 'fixture', history: [], ...state }) }); }, flush: () => new Promise(resolve => setImmediate(resolve)), }; } diff --git a/tests/fixtures/window-expansion.cjs b/tests/fixtures/window-expansion.cjs new file mode 100644 index 0000000..c536adc --- /dev/null +++ b/tests/fixtures/window-expansion.cjs @@ -0,0 +1,50 @@ +const { app, BrowserWindow, screen } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const assert = require('node:assert/strict'); + +const directory = process.env.CONTINUER_TEST_EXPANSION_DIR; +const root = path.resolve(__dirname, '../..'), main = path.join(root, 'desktop/main.cjs'); +const localRequire = createRequire(main), { expandFromCard } = localRequire('./geometry.cjs'); +const { createDesktopRuntime } = localRequire('./runtime.cjs'); +app.setPath('userData', path.join(directory, 'profile')); +app.on('window-all-closed', () => {}); +const delay = () => new Promise(resolve => setTimeout(resolve, 30)); +const closeTo = (actual, expected) => assert.ok(Math.abs(actual - expected) <= 1, `${actual} should stay within one DIP of ${expected}`); + +app.whenReady().then(async () => { + const work = screen.getPrimaryDisplay().workArea, cases = []; + for (const point of [ + { x: work.x + work.width - 200, y: work.y + 40 }, + { x: work.x + 30, y: work.y + work.height - 190 }, + { x: work.x + Math.floor(work.width / 2), y: work.y + 80 }, + ]) { + const native = new BrowserWindow({ ...point, width: 160, height: 160, show: false, frame: false, thickFrame: false, transparent: true, resizable: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }); + const window = { getBounds: () => native.getBounds(), setBounds: (bounds, animate) => native.setBounds(bounds, animate), isDestroyed: () => native.isDestroyed(), isVisible: () => false, isFocused: () => true, showInactive() {}, focus() {}, hide() {}, webContents: { send() {} } }; + const preferences = { follow: false, expanded: false, hidden: false, compactSize: 160, offsets: {}, positions: { expanded: { x: work.x + 5, y: work.y + 5, width: 432, height: 720 } } }; + const appStub = { setName() {}, setPath() {}, requestSingleInstanceLock: () => true, on() {}, whenReady: () => ({ then: () => ({ catch() {} }) }) }; + const layout = createDesktopRuntime({ window, load: name => name === 'electron' ? { app: appStub, screen } : name === '../server/config.cjs' ? { dataDir: directory, port: 4317, appInfo: () => ({}) } : name === '../server/logging.cjs' ? { installLogs() {} } : name === 'node:fs' ? { ...fs, readFileSync: file => file === path.join(directory, 'widget-preferences.json') ? JSON.stringify(preferences) : fs.readFileSync(file, 'utf8'), writeFileSync() {} } : localRequire(name), + processInfo: { env: {}, argv: [], platform: process.platform, pid: process.pid } }); + const getDebug = () => ({ preferences: layout.preferences, state: layout.state() }); + layout.show(); await delay(); + const origin = native.getBounds(); layout.savePosition(); + let firstExpanded; + for (let cycle = 0; cycle < 12; cycle++) { + const before = structuredClone(getDebug()); + layout.changeExpanded(true); layout.show(true, true); await delay(); + const expanded = native.getBounds(), expected = expandFromCard(origin, screen.getDisplayMatching(origin).workArea, { width: 432, height: 720 }); + fs.writeFileSync(path.join(directory, 'latest.json'), JSON.stringify({point,cycle,origin,before,expanded,expected,after:getDebug()})); + closeTo(expanded.x, expected.x); closeTo(expanded.y, expected.y); + if (!firstExpanded) firstExpanded = expanded; + closeTo(expanded.x, firstExpanded.x); closeTo(expanded.y, firstExpanded.y); + if (cycle === 0) { native.setBounds({ ...expanded, x: work.x + 5, y: work.y + 5 }); layout.savePosition(); } + layout.changeExpanded(false); layout.show(true, true); await delay(); + const compact = native.getBounds(); closeTo(compact.x, origin.x); closeTo(compact.y, origin.y); + } + cases.push({ compact: origin, expanded: firstExpanded, cycles: 12 }); + native.destroy(); + } + fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify({ passed: true, cases })); + app.quit(); +}).catch(error => { fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify({ error: error.stack })); app.exit(1); }); diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..e5842c5 --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { t, setLanguage, locale, defaultContinuation, systemText, refreshLocalizedContent, uiError } from '../public/i18n.js'; +import { message } from '../public/message.js'; +import { taskMode, wakeExplanation } from '../public/task-mode.js'; +import { resetCountdown } from '../public/quota-display.js'; +import { widgetView } from './fixtures/widget-view.js'; + +test('translations change interface literals while preserving interpolated custom content', tcase => { + tcase.after(() => setLanguage('zh')); + setLanguage('en'); assert.equal(locale(), 'en-US'); + assert.equal(t('ui.add_to_my_work'), 'Add to my work'); + const custom = '继续 / 工作名称 / '; + assert.equal(t('ui.monitoring_note_local_only', {p0:custom}), `Monitoring note: ${custom} (local only)`); + assert.equal(systemText('正在执行命令', message('task.running_a_command')), 'Running a command'); + assert.equal(systemText('已添加会话:继续', message('log.chat_added',{name:'继续'})), 'Chat added: 继续'); + assert.equal(systemText('User output: 继续'), 'User output: 继续'); + assert.equal(systemText('', message('log.monitoring_ended_check_results_in_the_original_chat',{name:'中文项目'})), 'Monitoring ended: 中文项目; check results in the original chat'); + assert.equal(systemText('', message('log.task_activity',{name:'中文项目',activity:message('task.running_a_command')})), '中文项目: Running a command'); + assert.equal(systemText('', message('error.setting_range',{name:'maxWakeSends',min:1,max:100})), 'maxWakeSends must be an integer between 1 and 100'); + assert.equal(`${t('ui.current_status')}${custom}。`, `Current status: ${custom}。`); + assert.match(wakeExplanation, /blank sends “Continue”/); + assert.equal(taskMode({ mode: 'quota-wake', note: custom }, { maxWakeSends: 5 }).description, `Monitoring note: ${custom} (local only)`); + assert.equal(defaultContinuation('en'), 'Continue'); assert.equal(defaultContinuation('zh'), '继续'); + assert.equal(defaultContinuation(), '继续', 'Legacy backend calls always default to Chinese'); + assert.equal(resetCountdown(90001, 0), '1d 01:00:01'); + setLanguage('zh'); assert.equal(t('ui.add_to_my_work'), '加入我的工作'); assert.match(wakeExplanation, /留空发送“继续”/); +}); + +test('incoming language settings update visible widget cards and gauge without changing task content', async tcase => { + tcase.after(() => setLanguage('zh')); + const view = widgetView(); await view.flush(); + const task = { id: 'fixture', mode: 'quota-wake', status: 'paused', name: '继续', notePreview: '监测备注:保留中文', sentCount: 0 }; + const state = { connection: { status: 'connected' }, sampledAt: Date.now(), settings: { language: 'zh', enabled: false, minimumRemaining: 5, maxWakeSends: 5 }, tasks: [task], approvals: [], logs: [] }; + view.message(0, state); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + assert.match(view.element('#tasks').innerHTML, /开启监测/); + view.message(0, { ...state, settings: { ...state.settings, language: 'en' } }); + assert.match(view.element('#tasks').innerHTML, /Enable monitoring/); + assert.match(view.element('#tasks').innerHTML, /Monitoring note/); + assert.match(view.element('#tasks').innerHTML, /监测备注:保留中文/); + assert.match(view.element('#tasks').innerHTML, />继续<\/button>/); + assert.equal(view.element('#language').value, 'en'); + view.desktop({ expanded: false }); + assert.equal(view.element('#mini-reset-label').textContent, 'UNTIL RESET'); + view.message(0, state); view.desktop({ expanded: true }); + assert.match(view.element('#tasks').innerHTML, /开启监测/); + view.desktop({ visible: false }); +}); + +function sessionState(language = 'zh') { + return { connection: { status: 'connected' }, settings: { language, enabled: false, minimumRemaining: 5, maxWakeSends: 5 }, tasks: [], approvals: [], logs: [] }; +} + +test('cached session choices change language immediately without refetching or translating chat names', async tcase => { + tcase.after(() => setLanguage('zh')); + const view = widgetView({ threads: async () => ({ data: [{ id: 'chat', name: '继续处理中文项目' }], nextCursor: 'more' }) }); + await view.flush(); view.message(0, sessionState()); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + assert.match(view.element('#session-select').innerHTML, /选择当前要管理的对话/); + const reads = view.requests.filter(path => path.startsWith('/api/threads')).length; + view.message(0, sessionState('en')); + assert.match(view.element('#session-select').innerHTML, /Choose a chat to manage/); + assert.doesNotMatch(view.element('#session-select').innerHTML, /选择当前/); + assert.match(view.element('#session-select').innerHTML, /继续处理中文项目/); + view.element('#session-select').value = 'chat'; view.element('#session-select').onchange(); + view.message(0, sessionState()); + assert.equal(view.element('#session-select').value, 'chat'); + assert.match(view.element('#session-select').innerHTML, /选择当前要管理的对话/); + assert.equal(view.requests.filter(path => path.startsWith('/api/threads')).length, reads); + const writes = view.element('#session-select').writes; view.message(0, sessionState()); + assert.equal(view.element('#session-select').writes, writes, 'Unrelated updates must preserve option nodes'); + view.desktop({ visible: false }); +}); + +test('loading, empty and failed session states remain localized through background updates', async tcase => { + tcase.after(() => setLanguage('zh')); + let resolve, fail = false; + const view = widgetView({ threads: () => fail ? Promise.reject(uiError('ui.request_failed')) : new Promise(done => { resolve = done; }) }); + await view.flush(); view.message(0, sessionState()); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + view.message(0, sessionState('en')); + assert.match(view.element('#session-select').innerHTML, /Loading chats/); + resolve({ data: [], nextCursor: null }); await view.flush(); + assert.match(view.element('#session-select').innerHTML, /No local Codex chats/); + fail = true; view.advance(60001); await view.flush(); + assert.match(view.element('#session-preview').textContent, /Request failed/); + view.message(0, sessionState('en')); + assert.match(view.element('#session-preview').textContent, /Request failed/); + view.message(0, sessionState()); + assert.match(view.element('#session-preview').textContent, /读取失败:请求失败/); + view.desktop({ visible: false }); +}); + +test('open forms and cached import lists switch language when another window updates settings', async tcase => { + tcase.after(() => setLanguage('zh')); + const view = widgetView({ threads: async () => ({ data: [{ id: 'chat', name: '我的中文对话' }] }) }); + await view.flush(); view.message(0, sessionState()); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + await view.action('select-session', 'chat'); + assert.match(view.element('#modal-body').innerHTML, /续作消息(可选)/); + view.message(0, sessionState('en')); + assert.equal(view.element('#modal-title').textContent, 'Monitor original chat quota'); + assert.match(view.element('#modal-body').innerHTML, /Leave blank to send: Continue/); + assert.doesNotMatch(view.element('#modal-body').innerHTML, /续作消息|监测备注/); + await view.action('import'); + view.message(0, sessionState()); + assert.equal(view.element('#modal-title').textContent, '导入已有会话'); + assert.match(view.element('#modal-body').innerHTML, /我的中文对话/); + view.message(0, sessionState('en')); + assert.equal(view.element('#modal-title').textContent, 'Import an existing chat'); + assert.match(view.element('#modal-body').innerHTML, /我的中文对话/); + view.desktop({ visible: false }); +}); + +test('localized form redraw preserves values, checkboxes, focus, selection and scroll', () => { + const controls = () => [ + { name: 'continuationMessage', value: '', selectionStart: 0, selectionEnd: 0, focus() { this.focused = true; }, setSelectionRange(start, end) { this.range = [start, end]; } }, + { name: 'continuous', value: 'on', checked: true }, + ]; + let inputs = controls(); inputs[0].value = '继续,保留自定义消息'; inputs[0].selectionStart = 2; inputs[0].selectionEnd = 5; inputs[1].checked = false; + const document = { activeElement: inputs[0] }, container = { scrollTop: 87 }; + const element = { ownerDocument: document, closest: () => container, + querySelectorAll: selector => selector === '.form-error' ? [] : inputs, + set innerHTML(value) { this.html = value; inputs = controls(); }, + }; + refreshLocalizedContent(element, () => '
    New labels
    '); + assert.equal(inputs[0].value, '继续,保留自定义消息'); assert.equal(inputs[1].checked, false); + assert.equal(inputs[0].focused, true); assert.deepEqual(inputs[0].range, [2, 5]); + assert.equal(container.scrollTop, 87); +}); + +test('a delayed close event from the previous dialog does not disable localization of a newly opened form', async tcase => { + tcase.after(() => setLanguage('zh')); + const view = widgetView(); await view.flush(); view.message(0, sessionState()); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + await view.action('new'); view.element('#close').onclick(); + await view.action('new'); view.element('#modal').emit('close'); + view.message(0, sessionState('en')); + assert.equal(view.element('#modal-title').textContent, 'New work'); + assert.match(view.element('#modal-body').innerHTML, /Goal and acceptance criteria/); + view.desktop({ visible: false }); +}); + +test('failed imports can retry and a late session response cannot overwrite a different dialog', async tcase => { + tcase.after(() => setLanguage('zh')); + let fail = false, pending, release; + const view = widgetView({ threads: () => fail ? Promise.reject(uiError('ui.request_failed')) : pending ? new Promise(resolve => { release = resolve; }) : Promise.resolve({ data: [{ id: 'chat', name: '中文项目' }] }) }); + await view.flush(); view.message(0, sessionState('en')); await view.flush(); view.desktop({ expanded: true }); await view.flush(); + fail = true; await view.action('import'); + assert.match(view.element('#modal-body').innerHTML, />Retry<\/button>/); + assert.equal(view.element('#error').textContent, 'Request failed'); + fail = false; await view.action('import'); + assert.equal(view.element('#error').textContent, ''); + assert.match(view.element('#modal-body').innerHTML, /中文项目/); + pending = true; const request = view.action('import'); + await view.action('new'); release({ data: [{ id: 'late', name: 'Late response' }] }); await request; + assert.equal(view.element('#modal-title').textContent, 'New work'); + assert.match(view.element('#modal-body').innerHTML, /id="new-form"/); + assert.doesNotMatch(view.element('#modal-body').innerHTML, /Late response/); + view.desktop({ visible: false }); +}); diff --git a/tests/import-http.test.js b/tests/import-http.test.js index 5cc5da7..8b8193d 100644 --- a/tests/import-http.test.js +++ b/tests/import-http.test.js @@ -78,10 +78,29 @@ test('HTTP and MCP imports share one reservation and persist one recovery delive assert.equal(capped.status, 'paused'); assert.equal(capped.continuous, false); await assert.rejects(request(`/api/tasks/${capped.id}`, { action: 'resume' }), /上限/); const another = '22222222-2222-4222-8222-222222222222'; - await dispatch('continuer_import_session', { threadId: another, note: 'Local monitoring note' }, runtime); + await dispatch('continuer_import_session', { threadId: another, note: 'Local monitoring note', continuationMessage: '先验证测试,再继续实现。' }, runtime); const noted = (await request('/api/state')).tasks.find(task => task.threadId === another); const detail = await request(`/api/tasks/${noted.id}`); assert.equal(detail.task.note, 'Local monitoring note'); assert.equal(noted.note, undefined); assert.equal(noted.prompt, undefined); + assert.equal(noted.notePreview, 'Local monitoring note'); assert.equal(noted.noteTruncated, false); + assert.equal(detail.task.continuationMessage, '先验证测试,再继续实现。'); + assert.equal(noted.continuationMessage, undefined); + const stored = JSON.parse(readFileSync(join(dataDir, 'demo-state.json'), 'utf8')).tasks.find(task => task.id === noted.id); + assert.equal(stored.continuationMessage, '先验证测试,再继续实现。'); assert.equal(stored.note, 'Local monitoring note'); + const edited = await request(`/api/tasks/${noted.id}`, { action: 'message', value: '改为先检查阻塞,再继续。\n保留多行指令。' }); + const editedSummary = edited.tasks.find(task => task.id === noted.id); + assert.ok(editedSummary.detailRevision > noted.detailRevision); + assert.equal(editedSummary.status, 'paused'); assert.equal(editedSummary.sentCount, 0); + assert.equal((await request(`/api/tasks/${noted.id}`)).task.continuationMessage, '改为先检查阻塞,再继续。\n保留多行指令。'); + assert.equal(JSON.parse(readFileSync(join(dataDir, 'demo-state.json'), 'utf8')).tasks.find(task => task.id === noted.id).note, 'Local monitoring note'); + await assert.rejects(request(`/api/tasks/${noted.id}`, { action: 'message', value: null }), /续作消息/); + await request(`/api/tasks/${noted.id}`, { action: 'message', value: ' \n ' }); + assert.equal((await request(`/api/tasks/${noted.id}`)).task.continuationMessage, ''); + const localized = await request('/api/settings', { language: 'en' }); + assert.equal(localized.settings.language, 'en'); + assert.equal(JSON.parse(readFileSync(join(dataDir, 'demo-state.json'), 'utf8')).settings.language, 'en'); + await assert.rejects(request('/api/settings', { language: 'en-US' }), /language/); + await assert.rejects(request('/api/import', { threadId: 'unused', continuationMessage: 123 }), /续作消息/); assert.equal(noted.sentCount, 0); assert.equal(noted.progress, null); await assert.rejects(request('/api/import', { threadId: 'unused', prompt: 'Goal', note: 'Note' }), /note/); await request(`/api/tasks/${capped.id}`, { action: 'complete' }); diff --git a/tests/import.test.js b/tests/import.test.js index cf2c945..8ec3bba 100644 --- a/tests/import.test.js +++ b/tests/import.test.js @@ -2,10 +2,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { Manager } from '../server/manager.js'; +import { interruptedThread } from './fixtures/thread-history.js'; const threadId = '11111111-1111-4111-8111-111111111111'; const otherId = '22222222-2222-4222-8222-222222222222'; -const thread = id => ({ thread: { id, name: 'Original', cwd: 'E:\\project' } }); +const thread = id => ({ thread: interruptedThread(id, { cwd: 'E:\\project' }) }); const quota = usedPercent => ({ rateLimits: { primary: { usedPercent }, secondary: { usedPercent: 10 } } }); function deferred() { let resolve, reject; diff --git a/tests/local-client.test.js b/tests/local-client.test.js new file mode 100644 index 0000000..cee4c03 --- /dev/null +++ b/tests/local-client.test.js @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { createLocalClient, assertServiceIdentity, samePath } from '../plugin/local-client.js'; +import { message, formatMessage, MessageError } from '../public/message.js'; + +test('local transport reads current credentials and TLS identity for each request and retains payloads', async () => { + const calls = []; let generation = 0; + const request = createLocalClient({ origin: 'https://127.0.0.1:4317', readAccessToken: () => 'fixture-' + generation, + identityOptions: () => ({ generation }), fetchLocal: async (...args) => { calls.push(args); return { ok: true, json: async () => ({ saved: true }) }; } }); + assert.deepEqual(await request('/api/state'), { saved: true }); generation++; + const payload = { value: '继续\n"text"' }; await request('/api/tasks/fixture', payload, { timeoutMs: 150 }); + assert.equal(calls[0][1].headers.Authorization, 'Bearer fixture-0'); assert.equal(calls[0][1].method, undefined); + assert.equal(calls[1][1].headers.Authorization, 'Bearer fixture-1'); assert.equal(calls[1][1].headers['X-Continuer-Client'], '1'); + assert.deepEqual(JSON.parse(calls[1][1].body), payload); assert.deepEqual(calls[1][2], { generation: 1 }); + assert.ok(calls[1][1].signal instanceof AbortSignal); +}); + +test('invalid paths are rejected before reading credentials or sending a request', async () => { + const request = createLocalClient({ origin: 'https://127.0.0.1:4317', readAccessToken: () => { throw Error('Credential read'); }, fetchLocal: () => { throw Error('Network call'); } }); + for (const path of ['https://other.invalid/api/state', '//other.invalid/api/state', '/widget', '/api/\\other', null]) await assert.rejects(request(path), /Invalid API path/); +}); + +test('local transport preserves structured service errors and leaves untyped diagnostics unchanged', async () => { + const info = message('runtime.build_mismatch'); let response = { error: formatMessage(info), errorInfo: info }; + const request = createLocalClient({ origin: 'https://127.0.0.1:4317', readAccessToken: () => null, identityOptions: () => ({}), fetchLocal: async () => ({ ok: false, json: async () => response }) }); + await assert.rejects(request('/api/state'), error => error instanceof MessageError && error.messageInfo.id === info.id); + response = { error: 'External diagnostic' }; await assert.rejects(request('/api/state'), error => error.message === response.error && !error.messageInfo); +}); + +test('service identity distinguishes build compatibility from shutdown ownership and normalizes paths consistently', () => { + const dataDir = resolve('fixture-data'), root = resolve('fixture-root'), appInfo = { protocolVersion: 4, buildId: 'current' }; + const state = { app: 'codex-continuer', ...appInfo, diagnostics: { dataDir, projectRoot: root } }; + assert.equal(assertServiceIdentity(state, { dataDir, appInfo }), state); + state.buildId = 'old'; assert.throws(() => assertServiceIdentity(state, { dataDir, appInfo }), { code: 'SERVICE_BUILD_MISMATCH' }); + assert.equal(assertServiceIdentity(state, { root, dataDir }), state); + state.diagnostics.projectRoot = 'foreign'; assert.throws(() => assertServiceIdentity(state, { root, dataDir }), { code: 'SERVICE_OWNER_MISMATCH' }); + assert.equal(samePath(root.toUpperCase(), root.toLowerCase(), 'win32'), true); + assert.equal(samePath(root.toUpperCase(), root.toLowerCase(), 'linux'), false); + assert.equal(samePath(null, root), false); +}); diff --git a/tests/manager-components.test.js b/tests/manager-components.test.js new file mode 100644 index 0000000..3f1d28a --- /dev/null +++ b/tests/manager-components.test.js @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { initialState, prepareState } from '../server/state-lifecycle.js'; +import { StatePersistence } from '../server/state-persistence.js'; +import { TurnController } from '../server/turn-controller.js'; + +function fixture(saved = initialState()) { + const writes = [], sent = [], errors = [], trace = []; + let fail = false, backup = initialState(false); + const persistence = new StatePersistence({ + load: () => structuredClone(saved), + save: state => { + trace.push('save'); + if (fail) throw Error('fixture disk unavailable'); + writes.push(structuredClone(state)); + }, + restoreBackup: transform => { + const state = structuredClone(backup); + transform(state); + return state; + }, + }, error => errors.push(error)); + persistence.load(); + const rpc = { ready: true, send: message => { trace.push('send'); sent.push(message); }, call: async () => ({}) }; + const turns = new TurnController(rpc, { + persistence, + canSchedule: () => persistence.healthy && persistence.state.settings.enabled, + hasExecutionQuota: () => true, + isStopped: () => false, + changed: options => persistence.changed(options), + log: () => {}, + invalidateQuota: () => {}, + onError: error => errors.push(error), + }); + return { persistence, turns, writes, sent, errors, trace, fail: value => { fail = value; }, backup: value => { backup = value; } }; +} + +test('startup migration is independent of RPC and preserves legacy monitoring semantics', () => { + const state = initialState(false); + state.settings.pollSeconds = 120; delete state.settings.maxWakeSends; + state.tasks = [ + { id: 'old', threadId: 'external', status: 'blocked', activity: 'thread external already has an active writer', continuous: true, prompt: 'Local note', rounds: 12 }, + { id: 'done', status: 'completed' }, + { id: 'active', status: 'running', continuous: true, rounds: 1 }, + ]; + assert.equal(prepareState(state), true); + assert.equal(state.settings.enabled, false); + assert.equal(state.settings.pollSeconds, 90); + assert.equal(state.settings.maxWakeSends, 20); + assert.deepEqual(state.tasks.map(task => task.id), ['old', 'active']); + assert.equal(state.tasks[0].mode, 'quota-wake'); + assert.equal(state.tasks[0].note, 'Local note'); + assert.equal(state.tasks[0].sentCount, 0); + assert.equal(state.tasks[0].status, 'monitoring'); + assert.equal('rounds' in state.tasks[0], false); + assert.equal('prompt' in state.tasks[0], false); + assert.equal(state.tasks[1].status, 'blocked'); + const migrated = structuredClone(state); + assert.equal(prepareState(state), false); + assert.deepEqual(state, migrated); +}); + +test('migration save failures report the error and disable scheduling before use', () => { + const state = initialState(); delete state.settings.maxWakeSends; + const errors = []; + const persistence = new StatePersistence({ load: () => state, save: () => { throw Error('fixture disk unavailable'); } }, error => errors.push(error)); + assert.doesNotThrow(() => persistence.load()); + assert.equal(errors.length, 1); + assert.equal(persistence.healthy, false); + assert.equal(persistence.state.settings.enabled, false); + assert.match(persistence.status.message, /disk unavailable/); +}); + +test('backup replacement cancels deferred writes and turn handling reads the restored state', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = fixture(), oldState = f.persistence.state; + const oldTask = { id: 'old', threadId: 'old-chat', turnId: 'old-turn', status: 'running', output: '', rounds: 1 }; + oldState.tasks.push(oldTask); + f.persistence.changed({ deferSave: true }); + const backup = initialState(); + backup.tasks.push({ id: 'restored', threadId: 'restored-chat', status: 'paused', continuous: true, rounds: 1, output: '', plan: [] }); + f.backup(backup); f.persistence.restoreBackup(); + t.mock.timers.tick(1000); + assert.equal(f.writes.length, 0); + assert.equal(f.persistence.pending, false); + assert.notEqual(f.persistence.state, oldState); + const restored = f.persistence.state.tasks[0]; + assert.equal(restored.status, 'blocked'); + assert.equal(restored.continuous, false); + Object.assign(restored, { status: 'running', turnId: 'restored-turn' }); + const delta = (threadId, turnId, text) => f.turns.event({ method: 'item/agentMessage/delta', params: { threadId, turnId, delta: text } }); + delta('old-chat', 'old-turn', 'obsolete output'); + delta('restored-chat', 'restored-turn', 'current output'); + t.mock.timers.tick(250); + assert.equal(oldTask.output, ''); + assert.equal(restored.output, 'current output'); + assert.equal(f.writes.length, 1); + assert.deepEqual(f.writes[0].tasks.map(task => task.id), ['restored']); +}); + +test('standalone turn approvals commit before sending and keep requests after a failed save', () => { + const f = fixture(), other = fixture(); + const task = { id: 'work', threadId: 'chat', turnId: 'turn', status: 'running', cwd: 'E:\\fixture', rounds: 1 }; + f.persistence.state.tasks.push(task); + f.turns.request({ id: 1, method: 'item/commandExecution/requestApproval', params: { threadId: 'chat', turnId: 'turn', command: 'echo fixture' } }); + assert.equal(task.status, 'approval'); + assert.equal(other.turns.approvals.size, 0); + const token = f.turns.approvals.get('1').detailToken; + f.fail(true); f.trace.length = 0; + assert.throws(() => f.turns.approve(1, 'accept', token), /disk unavailable/); + assert.deepEqual(f.trace, ['save']); + assert.equal(f.sent.length, 0); + assert.equal(f.turns.approvals.size, 1); + f.fail(false); f.persistence.retry(); f.trace.length = 0; + f.turns.approve(1, 'decline', token); + assert.deepEqual(f.trace, ['save', 'send', 'save']); + assert.equal(f.turns.approvals.size, 0); + assert.equal(task.status, 'running'); +}); diff --git a/tests/manager.test.js b/tests/manager.test.js index 059b984..ad0e018 100644 --- a/tests/manager.test.js +++ b/tests/manager.test.js @@ -129,6 +129,7 @@ test('active-thread wait retries once idle without starting while it is active', test('restart migrates only continuous tasks blocked by the exact legacy writer-conflict error to quota monitoring',()=>{ const {m}=setup();const t=m.create(task);t.threadId='external'; + delete t.mode; // A persisted record from before explicit direct task modes. t.status='blocked';t.activity='thread external already has an active writer'; const restored=setup(m.state).m.state.tasks[0]; assert.equal(restored.status,'monitoring');assert.equal(restored.mode,'quota-wake');assert.equal(restored.wake.armed,false);assert.equal(restored.sentCount,0); diff --git a/tests/message-contracts.test.js b/tests/message-contracts.test.js new file mode 100644 index 0000000..35ea3df --- /dev/null +++ b/tests/message-contracts.test.js @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { messageCatalog, message, isMessage, formatMessage, boundedMessage, MessageError, setActivity, taskActivityMessage } from '../public/message.js'; +import { systemText, staticInterface, setLanguage, setFormError } from '../public/i18n.js'; +import { createApiClient } from '../public/api-client.js'; +import { TaskDetails } from '../public/task-details.js'; +import { ClientError, clientErrorResponse } from '../server/http-policy.js'; +import { Manager } from '../server/manager.js'; +import { Store } from '../server/store.js'; +import { StatePersistence } from '../server/state-persistence.js'; +import { compactSummary } from '../server/summary.js'; +import { parseOutcome } from '../server/policy.js'; + +const slots = text => [...new Set([...text.matchAll(/\{([A-Za-z]\w*)\}/g)].map(match => match[1]))].sort(); + +test('every catalog message has matching parameters and complete translations', () => { + for (const [id, entry] of Object.entries(messageCatalog)) { + assert.match(id, /^[a-z]+\.[a-z][a-z0-9_]*$/); + assert.equal(typeof entry.zh, 'string', id); assert.equal(typeof entry.en, 'string', id); + assert.ok(entry.zh && entry.en, id); assert.deepEqual(slots(entry.zh), slots(entry.en), id); + if (id !== 'ui.language_chinese') assert.doesNotMatch(entry.en, /\p{Script=Han}/u, id); + const params = Object.fromEntries(slots(entry.zh).map(key => [key, ''])); + assert.ok(isMessage(message(id, params)), id); + assert.ok(formatMessage(message(id, params), 'en'), id); + } +}); + +test('stable IDs do not depend on Chinese wording or reinterpret interpolated user text', t => { + t.after(() => setLanguage('zh')); setLanguage('en'); + const entry = messageCatalog['task.running_a_command'], previous = entry.zh; + t.after(() => { entry.zh = previous; }); entry.zh = '修改过的命令提示'; + assert.equal(formatMessage(message('task.running_a_command'), 'en'), 'Running a command'); + for (const raw of ['正在执行命令', '已添加会话:继续', '中文项目:正在执行命令']) assert.equal(systemText(raw), raw); + const task = { name: '正在执行命令' }; setActivity(task, message('task.running_a_command')); + assert.equal(formatMessage(taskActivityMessage(task), 'en'), '正在执行命令: Running a command'); + setActivity(task, '正在执行命令'); assert.equal(task.activityMessage, undefined); + assert.equal(formatMessage(taskActivityMessage(task), 'en'), '正在执行命令: 正在执行命令'); + task.activityMessage = { id: 'future.message', params: {} }; + assert.equal(formatMessage(taskActivityMessage(task), 'en'), '正在执行命令: 正在执行命令'); +}); + +test('message contracts reject missing, extra and nonserializable parameters and bound nested text', () => { + for (const info of [ + { id: 'future.message', params: {} }, { id: 'log.chat_added', params: {} }, + { id: 'task.running_a_command', params: null }, { id: 'task.running_a_command', params: false }, + { id: 'log.chat_added', params: { name: 'name', secret: 'unlisted' } }, + { id: 'log.chat_added', params: { name: undefined } }, { id: 'log.chat_added', params: { name: Infinity } }, + { id: 'log.chat_added', params: { name: { raw: 'untyped' } } }, + ]) { assert.equal(isMessage(info), false); assert.equal(boundedMessage(info), undefined); } + assert.throws(() => message('log.chat_added'), /Invalid message contract/); + assert.throws(() => message('future.message'), /Invalid message contract/); + const info = message('log.task_activity', { name: 'n'.repeat(5000), activity: message('task.no_tool_activity', { summary: 's'.repeat(5000) }) }); + const bounded = boundedMessage(info, 180); + assert.equal(bounded.params.name.length, 180); assert.equal(bounded.params.activity.params.summary.length, 180); + assert.deepEqual(JSON.parse(JSON.stringify(bounded)), bounded); + assert.equal(isMessage(JSON.parse(JSON.stringify(message('log.chat_added', { name: undefined })))), true); + assert.equal(systemText({ message: 'External diagnostic', messageInfo: { id: 'future.message' } }), 'External diagnostic'); +}); + +test('HTTP and shared API preserve error identity across serialization and language changes', async t => { + t.after(() => setLanguage('zh')); setLanguage('zh'); + const info = message('error.setting_range', { name: 'maxWakeSends', min: 1, max: 100 }); + const response = JSON.parse(JSON.stringify(clientErrorResponse(new ClientError(info)))); + assert.equal(response.error, 'maxWakeSends 必须为 1–100 的整数'); assert.deepEqual(response.errorInfo, info); + assert.doesNotMatch(JSON.stringify(clientErrorResponse(new Error('private diagnostic'))), /private diagnostic/); + const api = createApiClient({ request: async () => ({ ok: false, json: async () => response }) }); + const error = await api('/fixture').catch(error => error), node = { dataset: {} }; + assert.ok(error instanceof MessageError); setFormError(node, error); assert.equal(node.textContent, response.error); + setLanguage('en'); setFormError(node, node.errorSource); + assert.equal(node.textContent, 'maxWakeSends must be an integer between 1 and 100'); + const legacy = createApiClient({ request: async () => ({ ok: false, json: async () => ({ error: '正在执行命令' }) }) }); + assert.equal(systemText(await legacy('/fixture').catch(error => error)), '正在执行命令'); +}); + +test('static localization follows explicit markers while leaving unmarked content alone', t => { + t.after(() => setLanguage('zh')); setLanguage('en'); + const text = { nodeType: 3, nodeValue: '文案已修改', isConnected: true }, user = { nodeValue: '加入我的工作' }; + const attributes = new Map([['data-i18n', 'ui.add_to_my_work'], ['data-i18n-title', 'ui.add_to_my_work']]); + const element = { childNodes: [text], isConnected: true, getAttribute: name => attributes.get(name), setAttribute: (name, value) => attributes.set(name, value) }; + const document = { documentElement: {}, querySelector: () => null, querySelectorAll: selector => ['[data-i18n]', '[data-i18n-title]'].includes(selector) ? [element] : [] }; + const refresh = staticInterface(document); refresh(); + assert.equal(text.nodeValue, 'Add to my work'); assert.equal(attributes.get('title'), 'Add to my work'); + assert.equal(user.nodeValue, '加入我的工作'); assert.equal(document.documentElement.lang, 'en-US'); + setLanguage('zh'); refresh(); assert.equal(text.nodeValue, '加入我的工作'); +}); + +function manager(t, store) { + const rpc = new EventEmitter(); rpc.ready = true; rpc.close = () => {}; + const result = new Manager(rpc, store, { demo: true }); t.after(() => result.stop()); return result; +} + +test('real state storage retains message identity and preserves plain legacy logs after restart', t => { + const directory = mkdtempSync(join(tmpdir(), 'continuer-message-')); t.after(() => rmSync(directory, { recursive: true, force: true })); + const store = new Store(join(directory, 'state.json')), original = manager(t, store); + const task = original.create({ name: '中文任务', cwd: 'E:\\fixture', prompt: 'Local test', continuous: false }); + task.status = 'paused'; setActivity(task, message('task.running_a_command')); task.rounds = 3; + original.log(taskActivityMessage(task), 'info', task.id); original.log('已添加会话:旧记录'); original.changed(); + original.stop(); const restored = manager(t, new Store(store.file)), saved = restored.state.tasks[0]; + assert.equal(saved.rounds, 3); assert.equal(saved.activity, '正在执行命令'); + assert.deepEqual(saved.activityMessage, task.activityMessage); + setLanguage('en'); t.after(() => setLanguage('zh')); + assert.equal(systemText(saved.activity, saved.activityMessage), 'Running a command'); + assert.equal(systemText(restored.state.logs[0]), '已添加会话:旧记录'); + assert.equal(systemText(restored.state.logs[1]), '中文任务: Running a command'); +}); + +test('summary metadata is bounded, complete details retain full parameters and descriptor changes invalidate them', async t => { + const m = manager(t, { load: () => null, save() {} }); + const task = m.create({ name: 'fixture', cwd: 'E:\\fixture', prompt: 'Local test', continuous: false }); + setActivity(task, message('task.no_tool_activity', { summary: 'x'.repeat(10000) })); + m.log(taskActivityMessage(task)); + const summary = compactSummary(m.snapshot()); + assert.equal(summary.tasks[0].activityMessage.params.summary.length, 240); + assert.equal(summary.logs[0].messageInfo.params.activity.params.summary.length, 180); + assert.ok(!JSON.stringify(summary).includes('x'.repeat(1000))); + const details = new TaskDetails(async () => ({ task: m.taskSnapshot(task) }), () => {}); + details.read(summary.tasks[0]); await new Promise(resolve => setImmediate(resolve)); + assert.equal(details.read(summary.tasks[0]).activityMessage.params.summary.length, 2600); + const revision = m.taskSnapshot(task).detailRevision; + task.activityMessage = message('task.plan_updated'); + assert.ok(m.taskSnapshot(task).detailRevision > revision); +}); + +test('a later untyped persistence error clears earlier message metadata', () => { + const storage = new StatePersistence({ load: () => null, save() {} }, () => {}); storage.load(); + storage.fail(new MessageError(message('error.storage_missing'))); + assert.ok(storage.status.messageInfo); + storage.fail(new Error('External filesystem diagnostic')); + assert.equal(storage.status.messageInfo, undefined); assert.equal(systemText(storage.status), 'External filesystem diagnostic'); +}); + +test('connection refresh retains owned statuses and RPC errors but preserves external diagnostics', async t => { + t.after(() => setLanguage('zh')); setLanguage('en'); + const m = manager(t, { load: () => null, save() {} }); m.state.settings.enabled = false; + m.rpc.connect = async () => {}; m.rpc.call = async () => ({}); + await m.refresh(); assert.equal(systemText(m.connection), 'Demo data · No real tasks are run'); + m.demo = false; await m.refresh(); assert.equal(systemText(m.connection), 'Codex App Server connected'); + m.rpc.call = async () => { throw new MessageError(message('error.codex_disconnected')); }; + await m.refresh(); assert.ok(m.connection.messageInfo); assert.equal(systemText(m.connection), 'Codex is not connected'); + m.rpc.call = async () => { throw new Error('Codex 未连接'); }; + await m.refresh(); assert.equal(m.connection.messageInfo, undefined); assert.equal(systemText(m.connection), 'Codex 未连接'); +}); + +test('missing outcomes localize the service fallback while valid model summaries retain their original text', t => { + t.after(() => setLanguage('zh')); setLanguage('en'); + const fallback = parseOutcome('Incomplete JSON'), task = {}; + setActivity(task, fallback.summaryMessage || fallback.summary); + assert.equal(task.activityMessage.id, 'task.outcome_missing'); assert.doesNotMatch(systemText(task.activity, task.activityMessage), /\p{Script=Han}/u); + const model = parseOutcome(JSON.stringify({ status: 'blocked', summary: fallback.summary })); + setActivity(task, model.summaryMessage || model.summary); + assert.equal(task.activityMessage, undefined); assert.equal(systemText(task.activity), fallback.summary); +}); diff --git a/tests/mode-views.test.js b/tests/mode-views.test.js index 3bd0656..5c5d7b7 100644 --- a/tests/mode-views.test.js +++ b/tests/mode-views.test.js @@ -1,39 +1,93 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import { taskMode, wakeExplanation } from '../public/task-mode.js'; +import { continuationEditor } from '../public/task-mode.js'; +import { widgetView, pageView } from './fixtures/widget-view.js'; +import { setLanguage } from '../public/i18n.js'; const settings = { enabled: true, pollSeconds: 90, minimumRemaining: 5, maxRounds: 2, maxWakeSends: 5 }; const escape = value => String(value ?? '').replaceAll('<', '<').replaceAll('>', '>'); -function productionFunction(file, name) { - const source = readFileSync(new URL(file, import.meta.url), 'utf8'); - const start = source.indexOf(`function ${name}(`); - assert.notEqual(start, -1); - const firstLine = source.indexOf('\n', start), multi = source.slice(start, firstLine).trim().endsWith('{'); - const end = multi ? source.indexOf('\n}', firstLine) + 2 : firstLine; - assert.ok(end > start); - return source.slice(start, end); -} - -test('browser settings render both limits and expose no polling option over 90 seconds', () => { - const code = productionFunction('../public/app.js', 'settings'); - const html = runInNewContext(`${code}; settings()`, { state: { settings, connection: { status: 'connected' } }, esc: escape, heading: () => '', toggle: () => '' }); +test('browser settings render both limits and expose no polling option over 90 seconds', async () => { + const view = pageView(); await view.flush(); + view.message(0, { settings, tasks: [], connection: { status: 'connected' }, approvals: [], logs: [] }); + await view.page('settings'); + const html = view.element('#main').innerHTML; assert.match(html, /name="maxWakeSends"[^>]*value="5"/); assert.match(html, /name="maxRounds"[^>]*value="2"/); const options = [...html.matchAll(/