Compare commits

...

26 Commits

Author SHA1 Message Date
caoqianming 7b541a2256 feat(sandbox): raise per-user execution capacity 2026-09-02 16:21:33 +08:00
caoqianming 11108f31c2 feat(admin): manage provider API credentials 2026-09-02 16:18:35 +08:00
caoqianming aa8ccec738 feat(web): improve Mermaid diagram styling 2026-09-02 10:13:02 +08:00
caoqianming a1068f0ec1 feat(web): optimize admin operations overview 2026-09-02 09:56:52 +08:00
caoqianming f50bfc1bcf release: 发布 0.70.0 2026-09-02 08:48:54 +08:00
caoqianming 572da9a365 feat(web): 收敛管理页并分页加载更新日志 2026-09-02 08:48:30 +08:00
caoqianming 605e4eecfe feat(sandbox): 增加共享执行容量与依赖观测 2026-09-02 08:15:31 +08:00
caoqianming e47ff1f942 fix(web): stabilize conversation rail width 2026-09-01 10:16:19 +08:00
caoqianming 7917331e5a feat(web): consolidate conversation navigation 2026-09-01 10:04:00 +08:00
caoqianming 37aa83f8fb feat(web): improve conversation side rails 2026-09-01 08:54:03 +08:00
caoqianming 38deea7d1d feat(windows-node): complete WPF host migration 2026-08-31 17:22:11 +08:00
caoqianming 0d5cbb8407 fix(llm): stream DeepSeek reasoning immediately 2026-08-31 16:03:27 +08:00
caoqianming e05de152ad fix(models): restrict DeepSeek Pro to pro tier 2026-08-31 15:40:18 +08:00
caoqianming 08754be1a4 fix(pricing): correct domestic model cost accounting 2026-08-30 11:37:12 +08:00
caoqianming 7ffa9eb645 feat(admin): consolidate dashboard overview cards 2026-08-28 17:04:07 +08:00
caoqianming 3bed132c30 feat(admin): add operations summary cards 2026-08-28 16:08:00 +08:00
caoqianming 510033d06f style(admin): compact dashboard layout 2026-08-28 15:11:18 +08:00
caoqianming 7f439a91d5 fix(web): hide idle software job indicators 2026-08-28 14:00:05 +08:00
caoqianming 0bc6d695ef feat(admin): show active Windows Node jobs 2026-08-28 13:40:44 +08:00
caoqianming a7c3ee8b16 style(admin): polish dashboard layout and visuals 2026-08-28 13:40:02 +08:00
caoqianming b6d0e34b51 fix(web): 让 embed 模式可访问管理入口 2026-08-28 13:37:15 +08:00
caoqianming bff6125cd1 fix(storage): 删除文件后释放用户配额 2026-08-28 13:23:28 +08:00
caoqianming 9728fc3dd2 feat(models): add GLM-5.3 Flash multimodal support 2026-08-27 14:55:17 +08:00
caoqianming a475207781 release: 0.69.1 2026-08-27 13:26:38 +08:00
caoqianming b0a7a86aa8 feat(web): add software result rail 2026-08-27 13:25:13 +08:00
caoqianming a219f8a0c0 fix(windows-node): 显示专业软件任务标题 2026-08-27 09:14:00 +08:00
183 changed files with 13777 additions and 3549 deletions

View File

@ -6,6 +6,32 @@
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## Unreleased
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。
- 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据并查看来源、脱敏尾号和可用状态数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。
## 0.70.0 — 2026-09-02
- 容器长任务会在整机繁忙时安全排队,后台任务可查看或取消排队状态;管理员可在“容器状态”查看实时容量,并通过“容器依赖”分析用户临时安装的 Python 依赖及占用。
- 软件作业与轮次导航统一收在对话右侧,并增加醒目的数量徽章;既可悬停速览,也可固定展开,进行中或未读作业会直接通过数字提醒。
- 模型体验进一步升级:思考过程恢复实时显示,新版 GLM 向默认档位开放,并支持直接理解上传图片和延续长工具任务的分析状态。
- 专业软件节点启用完整的新管理界面节点状态、首次注册、软件环境、本机任务、ANSYS 验收和数据目录迁移均可直接管理;应用图标、导航、状态提示和窄窗口布局也更清晰,关闭窗口后仍会继续在托盘运行。
## 0.69.1 — 2026-08-27
- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。
- 修复专业软件已经出图后,对话偶尔仍持续显示“思考中”的问题。
## 0.69.0 — 2026-08-26
- Origin 新增受控科研分析,可进行数据体检、归一化、平滑、求导、积分和线性拟合,并交付结果表、诊断信息及可继续检查的 Origin 工程。

View File

@ -136,7 +136,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。
模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度,`reasoning_replay` 只表示历史 reasoning 的 provider 回传策略(`none` / `tool_calls` / `all``core/llm_params.py` 是请求参数构造唯一入口,`core/context.py` 是历史消息清洗唯一入口。原始 assistant 响应仍完整落库供展示与导出,发模型前才按 profile 裁剪且上下文统计、压缩与折叠都使用裁剪后的请求视图。DeepSeek、GLM、方舟当前共享 `extra_body` 协议DeepSeek V4 仅为带工具调用的 assistant 消息保留 reasoning未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`
模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度,`thinking_clear` 表示 provider 是否清除历史思考,`reasoning_replay` 表示状态生命周期(`none` / `tool_turn` / `conversation` / `provider_managed``core/llm_params.py` 是请求参数构造唯一入口,`core/context.py` 是历史消息清洗唯一入口。原始 assistant 响应完整落库provider-bound 副本只向相同生产模型回放未改写的 reasoningDeepSeek V4 仅保留当前用户轮次的工具链状态GLM-5.3 Flash 在同模型会话内保留完整状态,未验证网关明确用 `none`,未来签名/加密 block 走 `provider_managed`。模型切换、上下文折叠和普通压缩都在同一入口应用隔离;上下文统计使用裁剪后的请求视图,原生图片 token 不反向污染 chars/token 校准
---
@ -184,7 +184,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
**对话产物与生命周期(0025/0028/0033)**:真实文件仍是内容事实源;`artifacts` 表记录已发布产物的稳定身份和生命周期,包含 user-root 相对当前路径、来源 task、复制来源、可空的 `software_job_id`、哈希/大小及 active/deleted、回收路径。`software_job_id` 不设外键,非空即表示该正式产物由对应专业软件 Job 生成复制品保留该来源Job 或 task 生命周期结束也不抹除来源标识。新 `messages.artifact_refs` 使用 `{version:2, artifact_id, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}``path` 是兼容快照,预览/下载优先按 `artifact_id` 找当前路径因此移动或重命名后历史卡片仍有效。version 1 和 `NULL` 旧消息继续走原 task-scoped 兼容链。普通源码树、中间文件、`.meta/` 技术信息和配套资源不登记agent 仅用 `publish_artifacts` 显式提升少量最终文件。移动保持身份,复制为每个副本创建新身份并记录直接来源;删除将文件移入 `.zcbot_artifact_trash/` 并软删记录,普通文件仍物理删除。
**用户消息附件(0031)**:`messages.attachment_refs` 与助手产物分开表达输入附件,元素为 `{version:1, scope:"working_dir", path, label, kind, media_type, size_bytes}`;文件仍是事实源,不登记为已发布 artifact也不承诺独立生命周期。`payload.content` 只保存用户自然语言,模型上下文在内存中按附件类型补兼容提示,避免 UI 协议污染正文。`NULL` 表示旧客户端/旧历史,前端继续解析正文标记;`[]` 表示新消息明确无附件。独立 attachment 表暂缓,只有出现跨消息复用、稳定身份、版本快照或附件级审计需求时再抽象 `file_assets + message_attachments`
**用户消息附件(0031)**:`messages.attachment_refs` 与助手产物分开表达输入附件,元素为 `{version:1, scope:"working_dir", path, label, kind, media_type, size_bytes}`;文件仍是事实源,不登记为已发布 artifact也不承诺独立生命周期。`payload.content` 只保存用户自然语言,Session 内存补兼容路径提示并保留私有附件引用;发模型前按 `input_modalities` 决定纯文本退化或把图片物化为 OpenAI-compatible Base64 `image_url` blockBase64 永不落库、物化仍经过 working_dir/user_root 越界与大小校验。原生看图模型不注册 `look_at_image``read_document` 继续承担扫描 PDF 等文档摄取。`NULL` 表示旧客户端/旧历史,`[]` 表示新消息明确无附件;独立 attachment 表仍待跨消息复用、稳定身份、版本快照或附件级审计需求出现后再抽象
### 7.2 资源模型(/v1)
@ -208,7 +208,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/
Export GET /v1/tasks/{id}/export(docx)
```
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ progress_snapshot{run_id,steps,waiting}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。进度 dock 是运行状态而非历史消息:活跃时紧凑显示,正常完成后隐藏,`ask_user` 等待确认/取消/异常时折叠保留。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ reasoning_reset(传输层放弃当前响应并重试时清除已直播推理)/ progress_snapshot{run_id,steps,waiting}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。进度 dock 是运行状态而非历史消息:活跃时紧凑显示,正常完成后隐藏,`ask_user` 等待确认/取消/异常时折叠保留。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
### 7.3 认证
@ -244,6 +244,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
- `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false快速入口首发后消费人工改名优先清闸。
- **0004 简化**:runs 表只写不读、独立 run 实体在单活形态下冗余 → 合并 `run_status/run_error` 入 tasks需要前端关联本轮时直接复用该轮 user message UUID 为 `run_id`,不恢复 runs 表。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
- **版本化模型计费**(2026-08-30):provider 响应的 usage token 是用量事实源,直连模型 YAML 中带生效时间/币种/来源的 `pricing.periods` 是价格事实源LiteLLM price map 仅给没有本地价目的网关模型兜底,避免第三方价目滞后覆盖已核对的国内厂商价格。`core/pricing.py` 按调用时间选择价格版本和 DeepSeek 峰谷时段,统一拆缓存命中/未命中/输出成本;主对话、标题、折叠、知识库摘要与 prompt 润色共用 `record_chat_usage`。每行 `usage_events.units` 快照 revision/tier/原币单价/汇率/分项成本,无 schema migration公开按量价是产品估算口径赠送额度、套餐和商务折扣以 provider 账单为最终财务口径。历史调价只通过默认 dry-run、显式 apply 且保存旧值的重算工具修正,不让新配置静默改写旧账。
- **0029 消息序号**:`tasks.next_message_idx` 在 task 行锁下统一分配 `messages.idx`Web、agent 与渠道追加不再各自维护序号或依赖冲突重试;分配时仍与 `max(idx)` 校准,允许蓝绿发布窗口内旧实例继续写入。清空消息与计数器在同一事务归零。
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
- **文件面板先备料**:user_root 与普通目录都可显式新建直接子目录;创建成功后前端进入该目录,用户可先上传/选入资料,再把顶层空目录选作新对话 working_dir。目录 leaf 复用 `validate_task_name`,不允许借 UI 创建点目录或路径式名称。
@ -252,11 +253,11 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 7.5 沙盒:Per-user 容器 + Per-tool exec
选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 空闲 5 分钟回收 + bind mount user root→`/workspace`。
选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 无执行活动 10 分钟回收 + bind mount user root→`/workspace`。
**边界划分**:Control plane 留宿主(auth/DB/files 校验/SSE/LLM/受控 web 工具/配额审计),Execution plane 进容器(shell/run_python/任意生成代码)。目标不是"所有操作进容器",是"所有不可信执行不能在宿主"——否则凭据反被带进执行面。
**硬限制**:cgroup CPU/mem、pids-limit、exec timeout、并发数、read-only rootfs、tmpfs /tmp、no-new-privileges、drop ALL caps、非 root、`--shm-size`。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量/并发),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。
**硬限制**:单容器 4 GiB/2 CPU、pids 1024、`/dev/shm` 512 MiB、`/tmp` 1 GiB、exec timeout、read-only rootfs、no-new-privileges、drop ALL caps、非 root。整机重型执行由宿主共享文件账本 + advisory lock 统一准入:前后台合计 6、后台最多 4、单用户合计 3蓝绿/多 Web 实例共享同一账本,前台排队不计 timeout 且可取消,并通过 SSE 明示单用户、整机、内存压力或队列顺序等等待原因;宿主 MemAvailable 低于阈值时暂停新放行、不杀存量。轻量 fs 工具不占重型槽。普通容器显式跟踪 active execreaper 只回收无 active exec 且超 TTL 的容器。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。
**落地清单(Stage C 硬协议,实施按此对账)**:
1. **网络 blocklist 硬编码段**(任一缺失=未完成):`169.254/16`(metadata)、内网三段、CGNAT `100.64/10`;**PG 实际 IP 单独再 block**(belt-and-suspenders)。**容器自身 loopback(`-o lo`)显式放行**——netns 隔离下容器内 127.0.0.1 到不了宿主,DROP 它无安全收益且误伤容器内 IPC(2026-07 实锤:puppeteer↔chromium DevTools 走 127.0.0.1,被 DROP 导致 mermaid 渲染 90 天 0 成功)。
@ -275,7 +276,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
| gVisor → Firecracker/e2b | 合规客户 / 单机 100+ user / 兼容墙 | 每 VM 100MB+ 不划算;e2b 与 storage_root 自持冲突 |
| docker exec → 容器内 tool-runner RPC | exec 开销 >30% 持续两周 / 长驻服务 / 单轮 >20 次调用 | 自管清理+观测损失 >> 200ms×N;美学统一 ≠ 理由 |
**Image 体积 / 多 user 资源 / 加包**(2026-05-28):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆全在运行时限制;③ 新增依赖 = base 收敛 + **per-user venv**(`<user_root>/.venv/`,bind mount 回收不丢;不放共享 volume——install 脚本是任意代码,破坏隔离)+ 使用频次沉淀进 base
**Image 体积 / 多 user 资源 / 加包**(2026-09-01 修订):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆在共享准入与 cgroup;③ requirements 保持全局 site-packages非 root + readonly rootfs + `HOME=/tmp` 使普通 pip 临时落到 `/tmp/.local`、cache 落 `/tmp/.cache`。不建 venv、不持久化用户包、不加额外包目录容器删除前由镜像内可信扫描器只解析 `.dist-info`,与构建期基础清单比较 added/override/reinstall空扫描不入库、失败不阻塞删除。Admin 只分析依赖会话,不自动改基础镜像
### 7.6 / 7.7 改造项与阶段
@ -298,7 +299,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact其他文件照常删除回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact其他文件照常删除回收内容不占用户逻辑配额,但后台扫描在 `user_disk_usage` 中单列其字节数和文件数Admin 展示“配额占用 / 回收站 / 物理合计”,避免释放用户额度后失去服务器真实容量可见性。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
@ -316,7 +317,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 8.1 图像理解 + Seedream i2i(✅ 2026-06-16)
缺口:主模型纯文本;t2i 无法"改已生成图"或"读上传图"。**选 E+C 组合**:seedream 加 `reference_images` 走 i2i + 新增 `look_at_image`(Doubao Seed 2.0 Lite,一次读图 <¥0.01)让 DeepSeek 自决何时"借眼睛"。**不选 A**(主模型换多模态:V4 code/tool-calling 是核心,换=降能力+改 loop 引 multimodal,工程 5×);**不选 B**(每条消息隐式 vision 路由:烧 token+失 agentic 控制权)。关键实测:ARK 接受 base64 data URL → 内网无需对象存储。**升级到 A 的信号**:用户要"贴图直接对话读图"成高频——当前假设"图是工具调用对象"而非"对话内容"
历史基线是纯文本主模型配 `look_at_image` 借眼睛,生图改图独立走 `reference_images`。2026-08-27 随 GLM-5.3 Flash 原生多模态进入默认档位,升级信号已满足:附件输入改为 capability-driven原生图片模型直接收到用户上传图片纯文本模型继续使用路径提示与辅助视觉工具不会把所有文件或工具产物隐式送入模型避免无关 token、越界读取和缓存污染。生图/改图仍是显式外部动作,原生理解不替代图像生成工具;运行中产生的新图片若未作为后续用户附件加入,不自动注入下一轮
### 8.2 Token 优化与上下文治理(✅ 2026-06-04 起)
@ -406,15 +407,15 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
**决策**:把长进程从 zcbot 进程树上摘下来,OS 就是"任务组件",不引 Celery/RQ/队列。`shell`/`run_python` 加 `background=true`(**模型判断**:预计 >~1min 走后台;前台超时报错里提示改后台——判断错了纠错路径只有一步;用户显式指令永远优先)。状态协议**纯文件**:`<user_root>/.zcbot_procs/<task_id>/<proc_id>/{proc.json, output.log, exit_code}`——dotfile 用户不可见(同 `.zcbot_tmp` 惯例),`exit_code` 文件出现是唯一终态信号,状态判定全靠文件 + 现场探测(pid / 容器 running),**无常驻登记,天然扛重启**。查询/终止走配套 `check_process` 工具(host in-process,两种 backend 通吃)。
- **host backend**:detach 独立 wrapper(`core/proc_wrapper.py`,stdlib-only,sys.executable 直跑不依赖 PYTHONPATH):限时(默认 7200s / cap 86400s)、超时杀进程树记 124、日志截尾 10MB、最后写 exit_code。
- **docker backend**:**专用容器** `zcbot-proc-<id>`(pool.run_proc_container,同款硬化 + iptables init),`product=proc` + 无 instance label —— 与 sandbox 容器的 idle reaper / shutdown_all 生命周期**解耦**,dockerd 托管,蓝绿切换/实例重启不中断。不用 `docker exec -d` 进 sandbox 容器:idle 5min reaper + 启动 shutdown_all 会把长进程随容器带走
- **回收**:check_process 见终态顺手 rm 容器;web lifespan 每小时 `procs.sweep`(终态目录 7d TTL / exited 孤儿容器),幂等,蓝绿双实例同时跑无害
- **docker backend**:**专用容器** `zcbot-proc-<id>`(pool.run_proc_container,同款硬化 + iptables init),`product=proc` + 无 instance label —— 与普通 sandbox 的 idle reaper / shutdown_all 生命周期**解耦**,dockerd 托管,蓝绿切换/实例重启不中断。无容量时先写 `state=queued`,调度器获得共享前后台槽后才启动,故排队时间不计运行 timeout`check_process` 兼容 queued/running/finished/lost并可取消 queued
- **回收**:后台仍运行即有活动,不适用普通容器 idle TTLcheck_process 见终态顺手扫描临时包并 rm 容器web lifespan 约 30 秒 sweep/dispatch终态目录仍保留 7d幂等蓝绿双实例以 proc 目录锁避免重复启动。运行中容器由 Docker 托管,服务重启恢复 queued 并保留 running
- **通知/可视**:不做服务端推送 —— 前端轮询 `GET /v1/procs`(用户级,纯文件读取,仅有 running proc 时 5s 一拉):`[Background]` 工具结果卡本身活化(spinner+跳秒+停止按钮,与前台工具卡同体验,历史重渲同样恢复;`POST .../procs/<id>/kill`)、running→终态弹 toast(跨 task 也提醒,点击跳转)。proc 完成时刻往往没有活跃 run,SSE 通道根本不在,轮询是诚实的选型。
### 8.13 扫描件 PDF 直读:方舟文档理解,不接外部 OCR(✅ 2026-07-21)
缺口:markitdown 只抽 PDF 文本层,扫描件(老标准/检测报告/红头指南,建材院高频)转出为空=死路。**选复用 seed-2.0-lite 的方舟文档理解**(chat file 内容块,PDF 整本 base64 内联)新增 `read_document`:零新供应商(敏感文档不出已有豆包面)、零新基础设施、记账复用 vision 通道;实测 ~1300 输入 token/页(约 1 厘/页)、100 页全覆盖、17MB 内联可用。**不选专用解析 API**(MinerU/Textin:版面还原最好,但申报书/专利底稿要上传新第三方 + 免费额度政策不稳);**不选本地 OCR**(PaddleOCR 类:镜像塞推理依赖,需求未量化前过度投资);**不选 file_url/file_id 传址**(前者要给用户文件开免认证公网直链=新安全面、开发机 NAT 后还跑不通;后者要接 TOS 多落一份存储;base64 是零新增面的唯一形态,行业惯例 chat 端点也不收 multipart)。防上下文爆:多页 OCR 强制 `save_md` 落盘只返预览。**升级信号**:>100 页/>30MB 巨件成高频 → 接 TOS 走 file_id;要高保真版面/公式还原 → 再评 MinerU。probe/smoke 留仓(`scripts/probe_ark_doc.py` / `smoke_read_document.py`)。
- **对话锁(前端)**:bg proc 运行期间该 task 的 composer 锁定(发送→停止,Enter 拦截),观感与前台执行完全一致 —— 后台化的收益定位为「进程扛超时/服务重启」,**不改变"一个任务同时只做一件事"的对话心智**;完成的那次轮询解锁 + toast「可继续对话」。锁只在前端,服务端不 409:「停止」入口必须可达,且多设备/渠道绕过前端锁属可接受边缘(等的是同一个进程,发了消息也不冲突)。
- **防失控**:每用户并发 running 上限(`ZCBOT_MAX_BG_PROCS` 默 3);前台默认超时不放大(它是逼模型做前台/后台选择的杠杆)。
- **防失控**:前后台统一使用整机 6 / 后台 4 / 单用户 3 的共享准入;前台默认超时不放大(它是逼模型做前台/后台选择的杠杆)。
**边界(防滑坡)**:只覆盖「单个本地长进程」。①**外部异步作业**(seedance 等 submit/poll 形态)不进这里——工具内轮询 + `resume_task_id` 续查已够;②**job 链/依赖/自动重试**不做——那是 workflow 引擎,编排的唯一归属是 agent loop(模型 check 后自己决定下一步),同 §6 拒绝编排的理由;③**完成后自动续跑 run**不做——zcbot 的长任务产物多为终点交付物(与 Claude Code"build 是中间步骤"不同),自动续跑=无人在场烧 token,通知给人、下一步由人/下次对话决定。
@ -473,8 +474,12 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtimeUI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 和执行门健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。
Core 在 Job offer 中附带由 capability 合同生成的 `request_summary`供本机任务列表统一显示标题Host 不解析各软件私有请求结构,旧落盘 Job 只做通用兼容读取。
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schemaNode 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。
桌面宿主采用 WPF `Application` 持有唯一 GUI 消息循环WPF 主窗口只通过 ViewModel/Command 消费应用服务与控制器状态WinForms 被限制在 `NotifyIcon`、文件/目录选择、确认框和剪贴板互操作,不再存在第二套配置窗口或 Host 生命周期。托盘宿主负责把后台状态经 Dispatcher 投递到 WPF、控制窗口显隐并复用应用控制器完成注册、重连、清身份、数据目录迁移与安全退出。CLI 参数在 WPF 初始化前分流,因此 `run --headless` 不创建窗口或托盘。节点概览、首次注册、登录自启动、专业软件/runtime、本机任务、ANSYS 固定验收和数据目录迁移均已进入 WPF`SoftwareManagementService`、`JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService` 分别封装固定业务边界ViewModel 不解析 Job 落盘格式、不访问注册表、不创建 Worker也不直接持有应用控制器。
Node 通过 `Authorization: Bearer``X-Node-Id` 建立 `/v1/software-nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。
第二阶段已增加 `software_jobs`(专业软件任务)账本与 `origin.plot@v2` 的 offer/accept 骨架。用户只能在本人 task 下以幂等键提交固定 schema云端规范化请求并记录 SHA-256按当前进程真实在线、能力匹配、健康且有空闲 slot 的 Node 创建短期 offer。Node 再次校验 schema、图形类型和输出格式使用 write-through、flush 与原子 rename 先落本机任务目录,再回 `job_accept`;重复 job 只有 digest 一致才接受。过期或发送失败的 offer 回到队列lease、Node 和 digest 不匹配的响应被拒绝。Node 接收后云端进入 `dispatched` 而非 `running`,并将 slot 降为 0只有固定 Worker 真正启动后才进入软件无关的 `software_running`,具体软件和操作由 capability/request 表达。
@ -503,7 +508,7 @@ Workspace capability 的默认完成协议不发布正式产物。Node 只自动
第六阶段增加用户级 Job 中心与 Agent typed tools。`software_capability_list` 只暴露固定能力及当前在线空闲节点数,`software_job_submit/status/cancel` 在构造时绑定当前 user/task模型不能跨用户或跨对话指定归属。`software_job_submit` 的唯一入口为通用 `inputs[] + operation + outputs[]`;输入和输出使用任务内稳定 key具体 selector、type、format 和 options 由 capability 校验。`register_artifact` 是普通文件获得输入身份的唯一入口,并明确返回 UUID。右下角 Job 中心按用户聚合各对话任务,活动期短轮询、空闲期降频;终态变化通知用户,成功任务可回到原对话发起分析。取消采用协作协议:未派发任务直接终止,已派发任务先进入 `cancelling`,云端通过 WebSocket 发送并在心跳时重放 `job_cancel`Node 杀死固定 Worker 进程树后回报 `cancelled`;终态写入仍由云端账本裁决。
Job 完成后的对话闭环复用 `software_jobs` 账本,不增加通用事件表。提交时用 `completion_action=report|analyze` 区分“只报告产物”和“报告并分析”,未明确分析时默认 `report`成功终态将 `followup_status` 置为 `pending`分发器只在原 task 空闲时领取:`report` 直接写入带 artifact refs 的固定 assistant 消息,不产生模型费用;`analyze` 写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复回复Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。
Job 完成后的交互闭环复用 `software_jobs` 账本,不增加通用事件表,也不把每个软件结果复制为新的聊天消息。提交时用 `completion_action=report|analyze` 区分“只展示结果”和“结果完成后继续分析”,未明确分析时默认 `report`;终态将 `followup_status` 置为 `pending``report` 不依赖原 task 是否空闲,分发器只完成回调账本;当前 task 的左侧结果轨按 Job 独立堆叠运行状态和结果,成功预览可直接打开,移动端收敛为结果入口。右侧 Job 中心仍是跨 task 的完整管理面,主消息流只保留用户与 Agent 的叙事;历史 `software_job_report` 消息继续兼容读取,但不再新增。只有 `analyze` 需要等待原 task 空闲,随后写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复分析Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。
ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2revision 242并以共享 GeoData 层拆成检查和求解两个声明式入口。`ansys.geometry.inspect@v1` 只导入一份已登记 STEP/Parasolid/IGES枚举 assembly/part/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection并输出 JSON manifest、整体预览和溯源它不划分网格或求解。`ansys.mechanical.static_structural@v2` 接受显式单位、内置 Structural Steel、全局网格、固定支撑、力/压力和三类结果,边界区域可引用导入的 Named Selection也可用全局轴极值平面或带法向/偏置/容差的平面查询;查询只在 Mechanical 导入后的真实 GeoData 上解析,按实体 ID 创建项目内 Named Selection可用 `expected_count` 硬断言选择数量,并把最终面、来源和选区预览写入结果。两者都不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;静力输出固定为 MECHDAT、汇总/结果表、选区及应力/变形图、solver log 和 `.meta` 溯源。
@ -519,10 +524,20 @@ ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2revision 242
当前网关与 zcbot 共用 host但 HTML 响应同时用 CSP `sandbox` 和 iframe sandbox 强制 opaque origin且不授予 `allow-same-origin`;这一约束在签名 URL 被顶层打开时同样生效,项目代码不能读取主站 localStorage/JWT。`Referrer-Policy: no-referrer` 防能力 URL 随外链泄露,资源 URL 自带同一签名所以 ES module 不依赖认证 Cookie。将来需要 LocalStorage、Service Worker 或全栈服务器时,升级条件是独立 wildcard preview origin + 专用只挂载当前项目目录的容器 + HTTP/WebSocket/SSE 代理、健康检查和空闲回收,而不是放宽当前同源 sandbox。
### 8.18 Provider API 凭据动态控制面implementation2026-09-02
模型、媒体、平台数据与语音 Provider 采用代码内静态可信注册表Provider id、显示名、凭据字段组、固定测试协议、余额能力和巡检频率均由代码定义Admin 只能提交凭据值,不能编辑目标 URL、`api_base`、模型、协议或请求模板。这一控制面与可编辑的用户外部系统 definition 分离,避免把平台根凭据送往管理员可变目标。
`provider_credentials` 每个 Provider 只保存一套当前凭据。各字段以 `provider + field` 为 AES-GCM AAD 独立加密,未配置 `ZCBOT_CREDENTIAL_MASTER_KEY` 时拒绝写数据库,不存在明文降级。数据库覆盖优先;没有完整覆盖时沿用既有 env模型能力、价格、`model_id` 和 `api_base` 仍以 `config/models` / `config/media` 为事实源。
凭据生命周期采用请求级解析LLM 在每次构造新的 provider 请求时解析一次,媒体、检索和语音在每次新 HTTP/WebSocket 会话开始时解析;因此更新后下一次请求立即生效,已经发出的 HTTP 或流式响应保持启动时的 Key。候选凭据先在内存中测试认证成功含低余额告警后才以 revision 乐观并发原子替换;失败不改旧密文。
Provider 测试将 402/明确额度不足、认证失败、网络不可达和普通 429 分开分类。DeepSeek 读取官方 CNY 余额,`< ¥30` 为低余额30 分钟巡检;其他 Provider 没有官方余额字段时只展示认证/连通状态。业务调用的明确 402、额度耗尽或认证失败进入同一状态与提醒入口。后台一轮以 PostgreSQL advisory lock 在蓝绿实例间选主,首次异常立即邮件,持续异常最多每日一次,恢复后重置提醒状态。
---
## 附录:DeepSeek V4 关键事实(2026-04-24)
- V4-Pro:1.6T/49B 激活,1M context,SWE-Bench 80.6;V4-Flash:284B/13B 激活,1M context
- 推理:non-thinking / thinking / thinking-max;价格 in ~$0.145/M、out ~$1.74/M(约 Opus 1/6)
- 推理:non-thinking / thinking / thinking-max2026-08-16 起按工作日 UTC 峰谷计费,具体单价以官方价格页与版本化模型档案为准
- 旧 `deepseek-chat/reasoner` 已于 2026-07 下线,全库仅存 `deepseek-v4-flash/pro`

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-26(Origin 科研分析与平台来源升级bump 0.69.0)
最后更新:2026-09-020.70.0 正式发布:容器共享容量、专业软件节点、模型与交互更新)
---
@ -20,6 +20,26 @@
---
## 已完成关键能力
- **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4单用户上限由 2 提升到 3前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。
- **09-02 / Unreleased / Admin Provider API 凭据管理与余额提醒**:新增代码内静态可信 Provider 注册表和单行当前凭据控制面0039 以 AES-GCM 字段级 AAD 密文保存Admin 可查看 DB/env 来源与尾号、先测后原子替换、手动测试和删除覆盖LLM/媒体/平台来源/语音均按新请求热解析。DeepSeek 每 30 分钟检查官方 CNY 余额,低于 ¥30 及明确 402/认证失败进入统一状态与邮件提醒,蓝绿实例以 PostgreSQL advisory lock 单轮选主;持续异常每日最多一次、恢复重置。未连接生产数据库、未调用真实第三方 API。
- **09-02 / Unreleased / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
- **09-02 / 0.70.0 / 管理与更新日志界面收敛**Admin 顶部“容器状态”直接承载前后台执行、排队、回收、宿主资源与单用户占用,移除重复的独立容量区块,并统一采用“容器依赖”“专业软件节点”用户文案;用户版更新日志只保留可感知变化,公开接口新增向后兼容的 offset 分页元数据,前端每页加载 5 个版本并按需继续加载。
- **09-01 / 0.70.0 / Sandbox 执行容量与临时依赖观测**:默认单容器资源提升到 4 GiB/2 CPU、pids 1024、shm 512 MiB、tmp 1 GiB普通容器无活动 10 分钟回收;新增跨蓝绿/多 Web 实例共享的整机 6、后台 4、单用户 2 重型槽位,前台可取消排队且排队不计 timeout活动计数防长命令被 reaper 误删内存压力只暂停新放行。Docker 后台 proc 无槽时持久化 queued、约 30 秒恢复调度,运行中仍由 Docker 托管,终态目录继续保留 7 天。镜像构建基础 Python 包清单,容器删除前可信扫描 `/tmp/.local``.dist-info` 并以单会话表幂等记录非空差异Admin 顶部“容器状态”集中展示实时容量,“容器依赖”提供 7/30 天/全部依赖会话统计。新增 0038 migration未连接或迁移生产数据库、未启动真实专业软件。
- **08-31 / 0.70.0 / Windows Node 完整 WPF 管理界面**本机任务、ANSYS 固定验收、诊断复制和数据目录迁移全部进入 WPF任务页每秒刷新本地持久化记录并保持选择验收支持进度、停止、通过报告校验与机器级执行门数据迁移继续经过活动任务检查、停连、复制、SHA-256 校验、失败回滚和重启。新增 `JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService`,删除经典 `ConfigurationForm` 和旧 `TrayApplicationContext`WinForms 只保留托盘及受控系统对话框互操作117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过,未启动专业软件、连接生产服务或读写真实节点数据。
- **08-31 / 0.70.0 / Windows Node Host/WPF 重构阶段 06 第二切片**WPF 在节点概览、首次注册和运行设置基础上新增专业软件页Origin、ANSYS、Blender 卡片通过 `SoftwareManagementService` 统一获取位置、runtime 和 probe 快照,可选择或恢复自动检测位置、安装/更新独立 Python 3.12 环境并取消安装。选择对话框仍限制在 Tray WinForms 互操作层ViewModel 不访问注册表或启动进程;全机执行门会禁用安装,`ManagedRuntimeInstaller` 入口继续二次拒绝活动任务并保持临时验证、原子替换和失败 rollback。同步完成 WPF 视觉打磨:主题 token、圆角控件模板、导航选中态、状态色带/徽标、软件可用性标识、忙碌进度和窄窗口操作换行统一落地。ANSYS 验收、本机任务和数据目录迁移仍从经典配置进入。73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、Release 发布包及隔离 GUI/headless 烟测通过,未安装 runtime、启动专业软件、连接生产服务或读写真实节点数据。
- **08-30 / 0.70.0 / 国内模型版本化计费与历史矫正工具**:新增本地价格目录解析,直连 DeepSeek、豆包、智谱不再被 LiteLLM 滞后价目覆盖DeepSeek 按 2026-08-16 官方新价及工作日 UTC 峰谷窗口计费豆包使用人民币固定价GLM 绑定国内 BigModel 标准按量价。主对话、Prompt 润色、自动标题、上下文折叠和知识库摘要统一提取缓存 usage 并保存价格 revision/tier/币种/汇率/分项快照;新增默认 dry-run、显式确认 apply/rollback 的历史重算脚本,可针对调价后 DeepSeek 事件审计并矫正成本。无 schema/migration未连接或写入生产数据库。
- **08-27 / 0.70.0 / GLM-5.3 Flash + 通用 reasoning/多模态能力**:下架 GLM-5.1/5.2 的可选入口并以隐藏别名将存量 `glm.pro/pro52` 统一解析到 `glm.flash53`Flash53 加入默认与专业档位;模型能力新增原生输入模态和 `none/tool_turn/conversation/provider_managed` reasoning 生命周期历史状态仅向同一生产模型回放DeepSeek 限当前工具轮、GLM 保留同模型会话并发送 `clear_thinking=false`。结构化图片附件仍以文件引用为事实源,只在 provider 请求边界安全物化为 Base64 `image_url`GLM 不再注册 `look_at_image`,文档读取与生图/视频工具保持独立;无 schema/migration/依赖变化,未调用真实模型或连接生产数据库。
- **08-27 / 0.69.1 / 专业软件结果轨与终态收敛**:当前对话的专业软件 Job 按独立状态点堆叠在聊天左侧,悬停展示名称与进度,成功的 Workspace 任务可直接打开多预览画廊,移动端收敛为“软件结果”入口;主消息流只保留用户与 Agent 叙事,默认 `report` 完成后只更新 Job 账本和视图,显式 `analyze` 才重新进入 Agent 对话。Web SSE 终态统一延后到任务状态落库之后发送,结果刷新会清理不再活动的 live card避免软件已出图但“思考中”被重新点亮。既有历史软件报告消息继续兼容显示无 schema、migration 或 HTTP API 变化。
- **08-26 / 0.69.0 / Origin 科研分析 MVP**:新增非默认的 `origin.analysis@v1` / adapter 0.1.0,与稳定的 `origin.plot@v2` 分离;首批提供数据体检、最大值/面积/参考点归一化、固定局部多项式平滑、一/二阶导数、区间/累计梯形积分和带 95% 置信区间的线性拟合,固定输出可编辑 OPJU、CSV、诊断、分析规格与溯源可选 XLSX线性拟合工程同时保留 Origin 原生报告。六个合成真值用例在 Origin 2024 / originpro 1.1.15 / numpy 2.2.6 上完成生产 Worker、数值 oracle、OPJU 重开和进程释放门;前三例来自组合执行的逐例通过记录,后三例分别通过,目标机 COM 完整退出约需 2 分钟。Origin/合同/Job/Node 专项 159 项 unittest、Release .NET build、完整 Node publish 与独立 adapter ZIP 校验通过,未连接或写入数据库。
- **08-26 / 0.69.0 / platform_sources bounded context**:将 paper_server、内部材料库和 Materials Project 收敛为独立顶层包,以显式可信 Provider 列表统一可用性与生命周期,单来源配置/装配失败不阻断其他来源;`core/tool_registry.py` 只保留一个构建入口,与用户连接 `external_systems` 完全独立。模型工具统一改为来源明确的 `paper_server_*`、`materials_library_*`、`materials_project_*`保留材料库批量检索、MP 专用 SDK/CIF/entries 语义及 paper_server 安全下载边界;部署环境变量仍是事实源,无数据库迁移、无版本提升。
@ -124,6 +144,7 @@
- **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。
- **08-12 / 0.64.0 / artifact 稳定身份 + 隐藏回收**:新增 0028 `artifacts` 生命周期表并回填存量结构化引用;新发布消息写带 `artifact_id` 的 v2 引用,移动/重命名保持身份,复制创建独立身份并记录来源,历史卡片按身份解析最新路径。删除已发布产物时移动到用户隐藏目录 `.zcbot_artifact_trash/` 并软删记录普通文件仍物理删除Python 35 项(测试库门控 1 skip、Node 前端 11 项、mypy、Alembic 单 head、编译、Ruff 致命规则及 diff 检查通过,未连接或写入生产 DB。
- **08-28 / 0.70.0 / 回收站与用户配额分离**:0037 为 `user_disk_usage` 增加回收站字节与文件数快照;后台扫描从用户配额中排除 `.zcbot_artifact_trash`同时保留其物理占用统计Admin 分列展示配额占用、回收站和物理合计。相关 pytest 48 项与 41 个子测试、Python 编译、JavaScript 语法、Alembic 单 head、Ruff 致命规则及 diff 检查通过;本机 Docker daemon 未运行DB 集成测试按安全门控跳过,未连接或写入生产 DB。
- **08-12 / 0.63.11 / PDF/PPT 连续预览滚动布局修复**:补齐 PDF 预览根容器的纵向 Flex 布局与可收缩高度约束,使内部连续页 viewport 获得实际可用高度和独立滚动区域,不再被外层 `overflow:hidden` 裁断回归测试同步锁定根容器、viewport 与页列表三层布局契约。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。
@ -428,6 +449,7 @@ core/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留
core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate)
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector
core/provider_credentials/*.py ← 静态平台 Provider 目录、DB/env resolver、候选测试、状态提醒与蓝绿巡检
core/software_nodes.py ← Windows Node 注册码、身份认证与运行状态
core/sinks.py 101
core/paths.py 50 ← task_dir db form 归一
@ -445,10 +467,10 @@ core/ark_client.py 105 ← 火山方舟 HTTP 客户端
core/asr_xfyun.py 170 ← 讯飞语音听写 IAT wss 客户端(整段 PCM→文本;web 语音输入用,diag: scripts/diag_asr.py)
core/asr_lfasr.py 250 ← 讯飞录音文件转写 LFASR 客户端(异步订单 + 说话人分离;transcribe_audio 工具底座,diag: scripts/diag_lfasr.py)
core/agent_builder.py 649 ← 装配 lib:build_agent/system prompt(工具注册块已迁 tool_registry)
core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
core/executor.py / sandbox/{network,pool,capacity,package_scans}.py / executor_docker.py / procs.py ← Executor ABC + Docker per-user 容器池 + 宿主共享执行槽 + bg proc 文件队列 + 临时依赖扫描
tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio,office_to_pdf,external_systems}.py ← media_common=媒体五工具共享原语;external_systems=host-side 外部系统元工具
main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0030
db/migrations/versions/ 0001-0039
web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等)
web/routers/*.py ← 含 external_systems 用户连接与 software_nodes 节点路由
web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出

62
RUN.md
View File

@ -93,9 +93,8 @@
# 单次 LLM 请求超时(秒),默 600(与 litellm 默认一致,显式化 + 可调)。长思考模型
# 被掐("600s 无字节 → run 标 error")再调大;流式正常出 chunk 不触发。
# ZCBOT_LLM_TIMEOUT_S=600
# 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3
# 后台进程与前台重型执行统一受 sandbox.max_* / ZCBOT_MAX_* 共享容量控制
# 状态锚 <user_root>/.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。
# ZCBOT_MAX_BG_PROCS=3
# 静态多文件 Web 项目预览会话 TTL(秒),默认 7 天,平台限制 5 分钟30 天。
# 改后重启 web 生效;只影响新建/再次发布的预览。
# ZCBOT_WEB_PREVIEW_TTL_SECONDS=604800
@ -141,7 +140,8 @@
# ZCBOT_CREDENTIAL_MASTER_KEY=<至少 32 字符随机串>
```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **平台托管来源配置**:`PAPER_SERVER_*`、`DOCUMENT_SEARCH_*`、`MP_API_KEY` 由宿主部署环境提供,修改后重启 web 生效不进数据库、用户连接、prompt、tool result、`run_python` 或 Docker sandbox。若需求升级为用户级凭据/per-user grant接入 `external_systems`;若需要在线动态 definition、revision/reverify 或 OAuth先设计独立 `platform_sources` 控制面,不直接扩展当前静态 env registry。
- **平台 Provider 凭据**`DEEPSEEK_API_KEY`、`ZHIPUAI_API_KEY`、`ARK_API_KEY`、`UNIFYLLM_API_KEY`、`LOCAL_LLM_API_KEY`、`BOCHA_API_KEY`、`PAPER_SERVER_API_KEY`、`DOCUMENT_SEARCH_API_KEY`、`MP_API_KEY` 和两组 `XFYUN_*` 可继续由宿主 env 提供,也可在 0039 migration 后由 Admin「API 凭据」加密覆盖。数据库覆盖在下一次新外部请求生效;删除覆盖立即回退当前 env。URL、模型和协议继续由代码/YAML 管理Admin 不可编辑。凭据不进入 prompt、tool result、`run_python` 或 Docker sandbox。
- **Provider 凭据部署**:先配置至少 32 字符的 `ZCBOT_CREDENTIAL_MASTER_KEY`(轮换沿用 `ZCBOT_CREDENTIAL_KEY_ID` / `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`),再执行 `.venv/Scripts/python.exe main.py db upgrade head` 创建 `provider_credentials`。蓝绿部署须先迁移、后启动新代码。未配置 master key 时 env 调用保持可用,但 Admin 保存会明确拒绝master key 丢失或 AAD 不匹配时已有 DB 覆盖不会静默回退 env应恢复正确 keyring 或在可解密后删除覆盖。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。
- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env``ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。**支持语音消息**(voice_item SILK v3 → pilk 解码 → 讯飞 IAT 转写进对话,回执「🎤 已识别:…」;需 `XFYUN_*` 三件套 + ffmpeg + pilk,pilk 随 requirements 装)。
- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET`(+ 可见范围含目标用户);② `main.py db upgrade head`。**绑定两条路,任选**:
@ -156,7 +156,7 @@
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **OpenAPI / MCP 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`。③ admin 进入管理后台「外部系统」,选择通用 OpenAPI 或通用 MCP具体 MES/ERP/LIMS 都作为数据库 definition 配置,不新增专用 provider。MCP 填写与登录 Base URL 同源的 Streamable HTTP URL可选填写期望 Server 名称;连接后以 `tools/list` 为事实源。④ 普通用户点击左栏 **「外部」**,页面按 definition 动态显示用户名密码、API Key 或 Bearer Token目标、Server 身份、登录、认证绑定或 TLS 变化后保留密文并暂停调用重新测试成功后恢复。OpenAPI spec 和 MCP tool catalog 只在进程内按连接身份有界缓存,登录与业务响应均限长,普通用户和模型不能传任意 URL。
- **Artifact 生命周期(0028)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理且继续计入磁盘配额。普通文件删除语义不变。
- **Artifact 生命周期(0028/0037)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理、不计用户配额,但其物理占用由后台扫描单列并展示在 Admin。普通文件删除语义不变。
- **用户消息结构化附件(0031)**:部署新版前执行 `.venv/Scripts/python.exe main.py db upgrade head`,为 `messages` 增加 nullable `attachment_refs`。migration 不回写或改动存量正文;旧客户端仍可只传 `content`,新版会发送结构化 `attachments`
- **旧 `factory_mes` definition 一次性转换**:新版代码不再识别 `factory_mes`;部署时保持旧服务进程运行,先从新代码目录执行数据脚本,转换成功后再重启到新版。脚本不加载 `.env`、不读取 `ZCBOT_DB_URL`,只认显式的 `ZCBOT_MIGRATION_DB_URL`;默认 dry-run检查同名冲突与配置合法性。确认输出后加 `--apply`,脚本把 definition 转为 `generic_openapi + query`、物化 JWT/提示/只读 POST 配置并同步 active connection revision不解密或改写用户凭据。
```powershell
@ -215,7 +215,7 @@ python -m venv .venv
```bash
# 模型能力对账(费 token)
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
.venv/Scripts/python.exe main.py probe --model glm.pro # 智谱 GLM-5.1(走 litellm zai provider + 国内站 bigmodel.cn)
.venv/Scripts/python.exe main.py probe --model glm.flash53 # 智谱 GLM-5.3 Flash(原生多模态,国内站 bigmodel.cn)
.venv/Scripts/python.exe main.py probe --model local.r1 # 内网 DeepSeek-R1(满血,128K),涉密任务用;需 .env 设 LOCAL_LLM_API_KEY
.venv/Scripts/python.exe main.py probe --model local.qwen3 # 内网 Qwen3-30B-A3B(MoE,原生 32K);共享 LOCAL_LLM_API_KEY
@ -361,6 +361,10 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/external-system-providers` | 列管理员已启用的外部系统目录和安全的动态凭据字段声明;不返回完整配置或密钥 | 必填 |
| `GET /v1/admin/provider-credentials` | Admin 查看静态 Provider 目录、来源、脱敏尾号、测试/余额/提醒状态;不返回密文或明文 | admin |
| `PUT /v1/admin/provider-credentials/{provider_id}` | Admin 提交完整凭据组与 `expected_revision`;候选先测试,成功后原子替换 | admin |
| `POST /v1/admin/provider-credentials/{provider_id}/test` | Admin 测试当前 DB/env 凭据;标记 billable 的 Provider 前端会二次确认 | admin |
| `DELETE /v1/admin/provider-credentials/{provider_id}` | Admin 按 revision 删除数据库覆盖并回退 env | admin |
| `GET/POST /v1/external-systems` | 列当前用户连接 / 新建并在线验证连接;创建 body `{definition_id,name,credentials}`,旧 Factory `{username,password}` 请求继续兼容 | 必填 |
| `PUT /v1/external-systems/{id}/credentials` | 用 `{credentials}` 重新提交并在线验证当前用户连接;凭据不提供读取接口,旧用户名/密码格式继续兼容 | 必填 |
| `POST /v1/external-systems/{id}/test` | 用已保存密文凭据测试登录和 Swagger 可读性,并更新连接状态 | 必填 |
@ -392,7 +396,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):建连时若当前 run 已发布计划,先补 `progress_snapshot{run_id,steps,waiting}``run_start{}``llm_start{}``text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。`task_progress` 新协议每次携带完整 `steps`,客户端整体替换;消息分页响应也附加 `progress_snapshot`,刷新不依赖当前 30 条窗口。`waiting=true` 表示本轮已调用 `ask_user` 等待确认;正常完成回看时隐藏进度,等待/取消/异常则折叠保留。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):建连时若当前 run 已发布计划,先补 `progress_snapshot{run_id,steps,waiting}``run_start{}``llm_start{}``text{delta}` / `tool_call{name,args,args_preview}` / `execution_queue{state,reason,...}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}``done{}``execution_queue` 仅在前台重型工具确实等待共享容量时发送 `waiting`,放行后发送 `admitted`cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。`task_progress` 新协议每次携带完整 `steps`,客户端整体替换;消息分页响应也附加 `progress_snapshot`,刷新不依赖当前 30 条窗口。`waiting=true` 表示本轮已调用 `ask_user` 等待确认;正常完成回看时隐藏进度,等待/取消/异常则折叠保留。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query(目前不支持,避免 token 进 access log)。
@ -678,12 +682,18 @@ sudo -u zcbot docker network create zcbot-sandbox-net
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
# ZCBOT_SANDBOX_RUNTIME=
# 空闲多少秒回收(默 300)
# ZCBOT_SANDBOX_IDLE_TTL=300
# ZCBOT_SANDBOX_IDLE_TTL=600
# 资源限制(优先级 env > yaml `sandbox.*` > 默);改后重启 web 新起容器生效
# ZCBOT_SANDBOX_MEMORY=2g
# ZCBOT_SANDBOX_CPUS=1.0
# ZCBOT_SANDBOX_PIDS_LIMIT=256
# ZCBOT_SANDBOX_MEMORY=4g
# ZCBOT_SANDBOX_CPUS=2.0
# ZCBOT_SANDBOX_PIDS_LIMIT=1024
# ZCBOT_SANDBOX_SHM_SIZE=512m # chromium/mmdc 渲 mermaid 的 /dev/shm(docker 默 64MB 不够会挂超时)
# ZCBOT_SANDBOX_TMP_SIZE=1g
# ZCBOT_MAX_ACTIVE_EXECS=6
# ZCBOT_MAX_BACKGROUND_EXECS=4
# ZCBOT_MAX_ACTIVE_EXECS_PER_USER=3
# 三个并发 env 只用于向下收紧,代码硬上限固定为 6 / 4 / 3。
# ZCBOT_MIN_MEM_AVAILABLE=1g # 低于阈值暂停新放行,不杀正在执行的任务
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
@ -705,7 +715,7 @@ ZCBOT_SANDBOX_BACKEND=docker .venv/bin/python main.py web
# 触发任一 shell / run_python 消息后,容器应已起
sudo -u zcbot docker ps --filter label=zcbot.product=sandbox
# 应看到 zcbot-sandbox-<your-uid>,STATUS = Up ...
# 5 分钟无新消息后 reaper 自动 rm
# 10 分钟无执行活动后 reaper 自动 rm
```
也可直接起一个测试容器单验 hardening(不依赖 web 进程):
@ -717,10 +727,10 @@ sudo -u zcbot docker run -d \
--label zcbot.product=sandbox \
--label zcbot.user_id=$USER_ID \
--network zcbot-sandbox-net \
--read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
--read-only --tmpfs /tmp:exec,size=1g,mode=1777 --shm-size=512m \
--cap-drop=ALL --cap-add=NET_ADMIN \
--security-opt=no-new-privileges \
--pids-limit=256 --memory=2g --cpus=1.0 \
--pids-limit=1024 --memory=4g --cpus=2.0 \
-v /opt/zcbot/workspace/users/$USER_ID:/workspace \
-e ZCBOT_PG_IPS=10.1.2.3 \
zcbot-sandbox:latest
@ -823,23 +833,29 @@ docker rm -f zcbot-sandbox-<user-uuid>
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
```
磁盘配额查看(0008 表):
磁盘配额与回收站查看(0008/0037 表):
```bash
# 当前所有 user 用量
# 当前所有 user 的配额占用、回收站与文件物理合计
psql "$ZCBOT_DB_URL" -c "
SELECT user_id, bytes_used/1024/1024 AS mb, file_count, scanned_at
SELECT user_id,
bytes_used/1024/1024 AS quota_mb,
trash_bytes/1024/1024 AS trash_mb,
(bytes_used+trash_bytes)/1024/1024 AS physical_mb,
file_count, trash_file_count, scanned_at
FROM user_disk_usage ORDER BY bytes_used DESC;"
# 强制扫描某 user(改 yaml interval 太久不耐烦时)
.venv/bin/python -c "
from pathlib import Path
from uuid import UUID
from core.storage.disk_quota import scan_user_dir, upsert_user_usage
from core.storage.disk_quota import scan_user_dir, scan_user_trash, upsert_user_usage
uid = UUID('<your-user-uuid>')
b, c = scan_user_dir(Path('/home/lighthouse/zcbot/workspace/users') / str(uid))
upsert_user_usage(uid, b, c)
print(f'{b/1024/1024:.1f} MB / {c} files')"
root = Path('/home/lighthouse/zcbot/workspace/users') / str(uid)
b, c = scan_user_dir(root)
tb, tc = scan_user_trash(root)
upsert_user_usage(uid, b, c, tb, tc)
print(f'quota={b/1024/1024:.1f} MB, trash={tb/1024/1024:.1f} MB')"
```
DNS / resolv.conf 文件位置(host 侧):
@ -979,7 +995,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| prod 想把 workspace 落独立数据盘 | **别用 env / 别指 ROOT 外绝对路径**(workspace 锚定 ROOT,ROOT 外会让文件面板 / agent / 新建 task 三家分叉)。用 **bind mount**`/data/...` 接到 `ROOT/workspace`,逻辑路径不变,DB 不用改。详「workspace 落独立数据盘」段 |
| 文件面板"目录尚未创建"但文件确实在 / agent 写的文件面板看不到 | workspace 被指到了 ROOT 外(旧 `ZCBOT_WORKSPACE_DIR` 绝对路径残留)→ 文件面板走 `resolve_workspace` 看一处、agent 走 DB `from_db_path`(锚 ROOT)看另一处。删掉 env、改用 bind mount(见上段),三家归一 |
| `docker run zcbot-sandbox:latest``Unable to find image` | 镜像没 build。`sudo -u zcbot docker build -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) -t zcbot-sandbox:latest .` |
| 后台进程(bg proc)疑似残留 / 想手工排查 | docker 模式:`docker ps --filter label=zcbot.product=proc` 列在跑的 proc 容器,`docker rm -f zcbot-proc-<id>` 手杀;host 模式:状态锚在 `<user_root>/.zcbot_procs/<task_id>/<proc_id>/`(proc.json 有 pid,exit_code 文件在 = 已结束)。web 进程每小时 sweep 自动回收(终态目录 7 天 TTL);正常终止走前端停止按钮 / `check_process(action="kill")` |
| 后台进程(bg proc)疑似残留 / 想手工排查 | docker 模式:`docker ps --filter label=zcbot.product=proc` 列在跑的 proc 容器;状态锚在 `<user_root>/.zcbot_procs/<task_id>/<proc_id>/``proc.json` 的 `state=queued` 表示等待共享容量,`exit_code` 存在表示已结束。web 约每 30 秒调度 queued 并回收终态容器,终态目录仍保留 7 天;正常终止/取消排队走前端停止按钮或 `check_process(action="kill")`。若手工 `docker rm -f`,状态会收敛为 lost。 |
| 后台进程状态显示 lost | 进程没留退出码就没了 —— 宿主重启 / OOM killer / docker daemon 重启把它带走(bg proc 扛 zcbot 重启和蓝绿,但不扛宿主级重启)。output.log 保留到中断为止,需重新 background=true 发起 |
| 镜像 build pip 报 `THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE`(本仓 requirements 未钉 hash) | **不是被篡改、也不是 require-hashes**:镜像 index 声明的 wheel hash 与它实际吐出的文件字节不符 = 该镜像存的文件损坏 / 截断(2026-06-03 腾讯源就这么坏过 litellm-1.87.0)。换源重 build:`PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ sudo -E bash deploy/update.sh`。验真伪:`https://pypi.org/pypi/<pkg>/<ver>/json` 看官方 sha256 是哪边对。与下面"版本滞后(Could not find)"是两回事 |
| 镜像 build pip 报 `ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...)` | 境内访问 PyPI 抖动。加 `--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/`(清华,现默认)或腾讯 / 阿里源,详 RUN.md「镜像构建」段。Dockerfile 已把 pip timeout 拉到 60s,主因仍是源不通而非超时 |
@ -1015,6 +1031,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/<stem>.<hash>.pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |
| `materials_project_*` tool 没出现在对话里 | `.env` 没设 `MP_API_KEY`,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 |
| 豆包调价了 | 改 `config/media/doubao.yaml``price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
| 文本模型调价了 | 在 `config/models/<family>.yaml``pricing.periods` 追加带 `revision/effective_from/source_url` 的新价格版本并重启 web直连模型以本地版本化价格为准LiteLLM 只兜底无本地价格的网关。新事件会在 `usage_events.units` 快照版本、时段、币种、汇率和分项成本,历史记录不会被配置静默改写。 |
| DeepSeek 调价后的历史成本不准 | 先显式设置目标库 `ZCBOT_DB_URL`,运行 `.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z` 查看 dry-run核对脱敏 DB 目标、行数和差额后,才追加 `--apply --confirm APPLY_DEEPSEEK_REPRICE`。脚本单事务、按 revision 幂等,并保存 `previous_cost_cny`;回退用相同参数加 `--rollback --confirm APPLY_DEEPSEEK_REPRICE`。缺缓存明细的旧辅助调用按未命中估算并标记。 |
| `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |
@ -1170,7 +1188,7 @@ Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开 WPF 主窗口,可完成首次注册、重连、清除身份、复制诊断信息和登录自启动设置。“专业软件”页可配置 Origin、ANSYS、Blender 位置及各自 runtime并运行或停止 ANSYS 固定验收、在报告通过后开启机器级执行门;“本机任务”页每秒刷新本地持久化记录;“运行设置”页可打开或安全迁移完整数据目录。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`
若执行中的 Job 遇到 Node 重连,客户端会先等待已接收的本地执行管线收尾,再建立新连接,避免同一 Worker 被新旧连接同时恢复。若云端已将 Job 判为失败或取消、但本地 Worker 随后仍生成了结果,本机任务会显示“云端已终止”并保留工作区文件,不再永久停在 90% 重传;这些本地结果不会反向覆盖云端终态。

View File

@ -12,7 +12,7 @@ models_dir: config/models
model_tiers:
default: # 基线:所有未分配档位的用户(= 公测期默认可用)
- deepseek_v4.flash
- deepseek_v4.pro
- glm.flash53
- local.r1 # 内网模型(涉密任务)
- local.qwen3
- seedream_5 # 图(config/media/doubao.yaml image 段)
@ -26,8 +26,7 @@ model_tiers:
- doubao.turbo
- doubao.pro
- doubao.evolving
- glm.pro
- glm.pro52
- glm.flash53
- unifyllm.fable5 # unifyllm 网关(海外旗舰,走代理出口)
- unifyllm.opus48
- unifyllm.sonnet46
@ -67,14 +66,20 @@ shutdown:
cancel_grace_seconds: 15 # 超时转 cancel 后再给的退场宽限
# Sandbox 容器资源限制(docker run flag,env 可 override);改后重启 web 生效,
# 新起的容器用新值,已 running 的不变(idle 5min 回收后下次起)。
# 新起的容器用新值,已 running 的不变(idle 10min 回收后下次起)。
sandbox:
memory: 2g # --memory (env: ZCBOT_SANDBOX_MEMORY)
cpus: 1.0 # --cpus (env: ZCBOT_SANDBOX_CPUS)
memory: 4g # --memory (env: ZCBOT_SANDBOX_MEMORY)
cpus: 2.0 # --cpus (env: ZCBOT_SANDBOX_CPUS)
pids_limit: 1024 # --pids-limit (env: ZCBOT_SANDBOX_PIDS_LIMIT);线程也计数,
# chromium headless 一次 ~150-200 线程 + 超时残留进程,256 会被
# 打满致 mmdc 渲 mermaid 必崩(pthread_create EAGAIN)
shm_size: 512m # --shm-size (env: ZCBOT_SANDBOX_SHM_SIZE);chromium/mmdc 渲 mermaid 的 /dev/shm,默 64MB 不够会挂
tmp_size: 1g # /tmp tmpfs (env: ZCBOT_SANDBOX_TMP_SIZE)
idle_ttl_seconds: 600 # 普通容器无 exec 活动 10 分钟回收
max_active_execs: 6 # 宿主硬上限;前后台统一计数
max_background_execs: 4
max_active_execs_per_user: 3
min_mem_available: 1g # 低于阈值暂停新放行,不杀已运行任务
# 容器 DNS server 显式配置(docker run --dns,容器 /etc/resolv.conf 直接写,
# 绕过 docker daemon 上游 DNS 探测路径;腾讯云轻量 / 部分云上 daemon 探测
# systemd-resolved 上游会失败,导致 embedded DNS 127.0.0.11 forward 出去也跪)。

View File

@ -16,17 +16,39 @@ variants:
thinking_transport: extra_body
reasoning_effort_levels: [low, high, max]
default_reasoning_effort: high
reasoning_replay: tool_calls # 普通跨轮剥离;工具调用 reasoning 按官方协议完整回传
reasoning_replay: tool_turn # 只在当前用户轮次的工具链内原样回传 reasoning
code_quality: good
enable_run_python: true
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# Flash-0731 官方美元价按 1 USD ~= 7.2 CNY 折算;峰谷价正式生效后再同步。
input_cny_per_mtoken: 1.0
output_cny_per_mtoken: 2.0
cache_hit_cny_per_mtoken: 0.02 # 官方 $0.0028 / M tokens
# 扁平价保留给旧调用方,取当前谷时折算值;正式记账使用下面的版本化 pricing。
input_cny_per_mtoken: 1.584
output_cny_per_mtoken: 4.752
cache_hit_cny_per_mtoken: 0.0504
pricing:
periods:
- revision: deepseek-v4-20260816
currency: USD
fx_to_cny: 7.2
source_url: https://api-docs.deepseek.com/quick_start/pricing/
checked_at: 2026-08-30
effective_from: 2026-08-16T16:00:00Z
default: # 谷时(官方峰时之外)
input_per_mtoken: 0.22
cache_hit_per_mtoken: 0.007
output_per_mtoken: 0.66
time_tiers:
- name: peak
timezone: UTC
weekdays: [mon, tue, wed, thu, fri]
windows:
- ["01:00", "04:00"]
- ["06:00", "10:00"]
input_per_mtoken: 0.44
cache_hit_per_mtoken: 0.014
output_per_mtoken: 1.32
pro:
display_name: DeepSeek V4 Pro
@ -42,13 +64,35 @@ variants:
thinking_transport: extra_body
reasoning_effort_levels: [low, medium, high, max]
default_reasoning_effort: medium
reasoning_replay: tool_calls
reasoning_replay: tool_turn
code_quality: excellent
enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.2
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 2.0
output_cny_per_mtoken: 20.0
cache_hit_cny_per_mtoken: 0.2 # DeepSeek 前缀缓存命中价(input 的 ~0.1x)
input_cny_per_mtoken: 4.752
output_cny_per_mtoken: 14.256
cache_hit_cny_per_mtoken: 0.1584
pricing:
periods:
- revision: deepseek-v4-20260816
currency: USD
fx_to_cny: 7.2
source_url: https://api-docs.deepseek.com/quick_start/pricing/
checked_at: 2026-08-30
effective_from: 2026-08-16T16:00:00Z
default:
input_per_mtoken: 0.66
cache_hit_per_mtoken: 0.022
output_per_mtoken: 1.98
time_tiers:
- name: peak
timezone: UTC
weekdays: [mon, tue, wed, thu, fri]
windows:
- ["01:00", "04:00"]
- ["06:00", "10:00"]
input_per_mtoken: 1.32
cache_hit_per_mtoken: 0.044
output_per_mtoken: 3.96

View File

@ -32,6 +32,17 @@ variants:
input_cny_per_mtoken: 3.0
output_cny_per_mtoken: 15.0
cache_hit_cny_per_mtoken: 0.6
pricing:
periods:
- revision: doubao-seed21-20260628
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-06-28T00:00:00Z
default:
input_per_mtoken: 3.0
cache_hit_per_mtoken: 0.6
output_per_mtoken: 15.0
pro:
display_name: 豆包 Seed 2.1 Pro
@ -56,6 +67,17 @@ variants:
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
pricing:
periods:
- revision: doubao-seed21-20260628
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-06-28T00:00:00Z
default:
input_per_mtoken: 6.0
cache_hit_per_mtoken: 1.2
output_per_mtoken: 30.0
evolving:
# 自进化版:统一 model_id `doubao-seed-evolving`,每周至少迭代一次,始终指向最新版。
@ -83,3 +105,14 @@ variants:
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
pricing:
periods:
- revision: doubao-seed-evolving-202607
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-07-01T00:00:00Z
default:
input_per_mtoken: 6.0
cache_hit_per_mtoken: 1.2
output_per_mtoken: 30.0

View File

@ -1,55 +1,55 @@
# 智谱 GLM 模型档案
# 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai;
# 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。
# thinking 走统一 extra_body 协议,由本档 `thinking_enabled` 决定开关。当前均 false=禁用 ——
# 因网关侧默认开 thinking,重任务上会把输出预算烧在 reasoning_content 上撞满 65536 上限
# 被截断→空响应(task 35744bea 案);禁用后线上探针实测 reasoning_content 归零、正文照常。
# 走 LiteLLM 原生 zai provider。国内站 bigmodel.cn 与国际站 API key 不通用。
# GLM-5.3-Flash 强制 thinkingzcbot 保留同模型完整 reasoning 以维持长工具链连续性。
family: glm
variants:
pro:
display_name: GLM 5.1
model_id: zai/glm-5.1
flash53:
display_name: GLM-5.3 Flash
model_id: zai/glm-5.3-flash
api_base: https://open.bigmodel.cn/api/paas/v4
api_key_env: ZHIPUAI_API_KEY
max_context: 200000
reliable_context: 100000
max_output: 8192
parallel_tools: false
tool_calling_quality: good
thinking_enabled: false
thinking_transport: extra_body
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good
enable_run_python: true
max_iterations: 50
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# GLM 5.2:与 5.1 并存(新增 variant,不动 glm.pro,线上 task 仍引 5.1 不受影响)。
# 旗舰基座,真正可用的 1M 上下文,适合大仓库/长链路工程任务。thinking 同 pro 留 false(协议同 5.1)。
pro52:
display_name: GLM 5.2
model_id: zai/glm-5.2
api_base: https://open.bigmodel.cn/api/paas/v4
api_key_env: ZHIPUAI_API_KEY
max_context: 1000000 # 真 1M
max_context: 1000000
reliable_context: 262144
max_output: 8192
max_output: 131072
input_modalities: [text, image]
parallel_tools: false
tool_calling_quality: good
thinking_enabled: false
tool_calling_quality: excellent
thinking_enabled: true
thinking_transport: extra_body
reasoning_effort_levels: []
default_reasoning_effort: ""
thinking_clear: false
reasoning_effort_levels: [low, high, max]
default_reasoning_effort: high
reasoning_replay: conversation
code_quality: excellent
enable_run_python: true
max_iterations: 50
optimal_temperature: 0.3
max_iterations: 120
optimal_temperature: 1.0
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 8.0
output_cny_per_mtoken: 28.0
cache_hit_cny_per_mtoken: 2.0
# 当前 endpoint 是国内 BigModel使用国内按量标准价限时活动/套餐抵扣不进入
# 长期公开价估算,实际财务扣费仍以账号费用中心账单为准。
input_cny_per_mtoken: 0.8
output_cny_per_mtoken: 2.8
cache_hit_cny_per_mtoken: 0.23
pricing:
periods:
- revision: bigmodel-glm53-flash-20260826
currency: CNY
source_url: https://bigmodel.cn/pricing
checked_at: 2026-08-30
effective_from: 2026-08-26T00:00:00Z
default:
input_per_mtoken: 0.8
cache_hit_per_mtoken: 0.23
output_per_mtoken: 2.8
# 隐藏兼容别名:真实调用、能力和记账身份全部归 glm.flash53。
# 待一个兼容版本确认无旧 profile 活跃后删除。
pro:
hidden: true
alias_of: flash53
pro52:
hidden: true
alias_of: flash53

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.69.0"
__version__ = "0.70.0"

View File

@ -56,10 +56,12 @@ _MEDIA_LOOK_SEG = """\
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答("这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)**实际内容**做事(不是改图,改图走 seedream) `image=<路径>`并且**必须在 `question` 里一次写清完成当前任务所需的具体信息**别分多次看
- **问题范围**:只问完成用户任务所需内容例如问仪表读数就只读数问某列数据就只提取该列仅当用户明确要求"全文 OCR / 转文字 / 完整识别整张图"才要求逐字识别全部文字不要无条件同时要求画面描述全文 OCR 和图表解析
- **何时不调**:用户只是要改图( seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)/ 图的内容对当前任务可有可无**绝不对同一张图反复看**(每看一次都是几十秒等待)"""
_MEDIA_NATIVE_LOOK_SEG = """\
- **原生看图**:你可以直接理解用户随消息上传的图片图片已作为多模态内容随请求送入直接据图分析**不要调用辅助看图工具**消息中的 `[用户上传的参考图]` 路径仅用于定位同一附件或交给改图工具"""
_MEDIA_READDOC_SEG = """\
- `read_document` PDF(豆包 Seed 2.0 Lite 文档理解),**专治扫描件**:markitdown 对某 PDF 转出** / 近空**(纯图页无文本层) 用它逐页 OCR markdown每页约 1-2 厘钱,单次上限 100 (更长先拆分卷)
- **多页整本 OCR 必传 `save_md`**( `save_md='source/xxx.md'`):全文落文件只返回预览,后续直接 read 那个 md;只问局部内容("第3章指标是什么") `question` 即可不落盘
- **何时不调**:PDF 有文本层且 markitdown 已转出正文(直接用那份 md,别重复花钱);DOCX/PPTX/XLSX( markitdown);单张图片( look_at_image)"""
- **何时不调**:PDF 有文本层且 markitdown 已转出正文(直接用那份 md,别重复花钱);DOCX/PPTX/XLSX( markitdown);单张图片由主模型原生读取或走本轮实际提供的看图能力"""
# PDF 摄取是跨 skill 的基础入口,始终注入,不能依赖 agent 是否正确加载 paper / proposal。
# read_document 只在配了 ARK 时挂载;没挂时扫描件明确停在“需 OCR”,不诱导现场装包或
@ -87,14 +89,19 @@ _MEDIA_SEEDANCE_SEG = """\
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 = ¥8+2min);固定开场图传 `image`,多张主体/产品/场景参考图传 `reference_images`当前不支持 video-to-video"""
def _media_tools_block(has_ark: bool, image_tool: str) -> str:
def _media_tools_block(
has_ark: bool, image_tool: str, native_image_input: bool = False
) -> str:
"""按本 run 实际挂载的媒体工具拼指引段;一个都没挂 → 返 ""(prompt 不出现媒体段)。
image_tool: "seedream" / "gpt_image" / ""(无图像工具)look_at_image seedance
只跟豆包(ARK_API_KEY);图像工具二选一,prompt 只描述实际存在的那个
"""
segs: list[str] = []
if native_image_input:
segs.append(_MEDIA_NATIVE_LOOK_SEG)
if has_ark:
if not native_image_input:
segs.append(_MEDIA_LOOK_SEG)
segs.append(_MEDIA_READDOC_SEG)
if image_tool == "seedream":
@ -107,7 +114,7 @@ def _media_tools_block(has_ark: bool, image_tool: str) -> str:
segs.append(_MEDIA_SEEDANCE_SEG)
if not segs:
return ""
return "## 媒体工具(图像生成 / 视频生成 / 看图)\n" + "\n".join(segs)
return "## 媒体能力(图像生成 / 视频生成 / 看图)\n" + "\n".join(segs)
def _choose_image_variant(
@ -154,6 +161,14 @@ _HOST_ENV_BLOCK = """\
## 运行环境(本地 host)
`shell` 走的是 **Windows cmd.exe**( bash):避免 unix-only flag,`mkdir -p` 不识别 `run_python` `os.makedirs(..., exist_ok=True)` 建目录;复杂管道/重定向用 run_python 更稳"""
_MERMAID_STYLE_BLOCK = """\
## Mermaid 视觉规范
生成 Mermaid 图时默认采用**克制的语义配色**,不要输出全灰单色图,也不要随机彩虹色:
- 流程图 / 架构图用 `classDef` 按角色分组,通常 35 色足够:输入/数据用蓝色,处理/模块用青绿色,判断/风险用橙色,结果/成功用绿色,异常用红色;同类节点保持同色
- 使用浅色填充较深描边与高对比深色文字,线条颜色统一;分组框比节点更淡,避免大面积高饱和背景
- 颜色服务于信息层级;若用户指定品牌色黑白打印或已有 `classDef` / `style` 规则,优先遵循用户设置,不要覆盖"""
def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
@ -356,6 +371,7 @@ def _build_system_prompt(
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
# 运行环境段紧跟模板(平台/网络是基础事实,放前面);general_v1 的「平台」段指向这里。
prompt += _CONTAINER_ENV_BLOCK if is_docker else _HOST_ENV_BLOCK
prompt += _MERMAID_STYLE_BLOCK
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
# .memory/ 在 agent 视角下的可写路径:docker 给容器路径,host 给宿主绝对路径。
@ -525,6 +541,7 @@ def build_agent(
model = cfg["default_model"]
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
model = caps.profile
llm = LLM(caps)
workspace_dir = resolve_workspace(workspace, cfg)
@ -603,7 +620,9 @@ def build_agent(
system_prompt = _build_system_prompt(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
task_id, task_state.name, task_state.skill,
media_block=_media_tools_block(ark_cfg is not None, image_tool_name),
media_block=_media_tools_block(
ark_cfg is not None, image_tool_name, caps.native_image_input
),
allow_working_dir_rename=not scheduled_run,
office_to_pdf_available=office_pdf_available,
)
@ -671,6 +690,7 @@ def build_agent(
if current_profile.split(".", 1)[0] == "unifyllm":
return None
new_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
profile = new_caps.profile
new_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换
from sqlalchemy import update as _update
from core.storage import session_scope as _scope

View File

@ -5,7 +5,6 @@ litellm 不覆盖豆包的图像/视频生成端点,这里自己用 httpx 直调
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@ -35,6 +34,11 @@ class ArkConfig:
api_key: str
base_url: str
raw: dict # 完整 yaml 内容(便于 caller 按 image/video 子键再取)
api_key_env: str = ""
def request_api_key(self) -> str:
from core.provider_credentials.runtime import resolve_env_secret
return resolve_env_secret(self.api_key_env) if self.api_key_env else self.api_key
@classmethod
def load(cls, path: Optional[Path] = None) -> Optional["ArkConfig"]:
@ -50,7 +54,8 @@ class ArkConfig:
return None
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
env = data.get("api_key_env") or data.get("ark_api_key_env") or "ARK_API_KEY"
key = os.environ.get(env, "").strip()
from core.provider_credentials.runtime import resolve_env_secret
key = resolve_env_secret(env)
if not key:
return None
base = (
@ -58,7 +63,7 @@ class ArkConfig:
or data.get("ark_base_url")
or "https://ark.cn-beijing.volces.com/api/v3"
)
return cls(api_key=key, base_url=str(base).rstrip("/"), raw=data)
return cls(api_key=key, base_url=str(base).rstrip("/"), raw=data, api_key_env=env)
class ArkClient:
@ -70,10 +75,11 @@ class ArkClient:
def __init__(self, cfg: ArkConfig, timeout_s: float = 60.0) -> None:
self.cfg = cfg
self.timeout_s = timeout_s
self._api_key = cfg.request_api_key()
self._client = httpx.Client(
base_url=cfg.base_url,
headers={
"Authorization": f"Bearer {cfg.api_key}",
"Authorization": f"Bearer {self._api_key}",
},
timeout=timeout_s,
)
@ -118,17 +124,32 @@ class ArkClient:
raise ArkTimeoutError(f"network error calling GET {path}: {e}") from e
return self._parse(resp, f"GET {path}")
@staticmethod
def _parse(resp: httpx.Response, label: str) -> dict:
def _parse(self, resp: httpx.Response, label: str) -> dict:
if resp.status_code >= 400:
try:
from core.provider_credentials.registry import BY_ENV
from core.provider_credentials.service import record_business_failure
binding = BY_ENV.get(self.cfg.api_key_env)
if binding:
record_business_failure(
binding[0], status_code=resp.status_code, detail=resp.text[:300]
)
except Exception:
pass
# ark 错误 body 一般是 {"error": {"code": ..., "message": ...}};能解就解
try:
err = resp.json().get("error") or {}
msg = err.get("message") or resp.text[:300]
key = self._api_key
msg = str(msg).replace(key, "***") if key else str(msg)
code = err.get("code") or resp.status_code
raise ArkError(f"{label} → HTTP {resp.status_code} ({code}): {msg}")
except ValueError:
raise ArkError(f"{label} → HTTP {resp.status_code}: {resp.text[:300]}")
detail = resp.text[:300]
key = self._api_key
if key:
detail = detail.replace(key, "***")
raise ArkError(f"{label} → HTTP {resp.status_code}: {detail}")
try:
return resp.json()
except ValueError as e:

View File

@ -18,7 +18,6 @@ import base64
import hashlib
import hmac
import json
import os
import time
from typing import Callable, Optional
@ -57,15 +56,15 @@ class LfasrCancelled(LfasrError):
def is_configured() -> bool:
"""LFASR 凭据是否齐 —— agent_builder 据此决定挂不挂 transcribe_audio tool。"""
return all(
(os.getenv(k) or "").strip()
for k in ("XFYUN_APPID", "XFYUN_LFASR_SECRET_KEY")
)
from core.provider_credentials.runtime import provider_available
return provider_available("xfyun_lfasr")
def _load_credentials() -> tuple[str, str]:
appid = (os.getenv("XFYUN_APPID") or "").strip()
secret = (os.getenv("XFYUN_LFASR_SECRET_KEY") or "").strip()
from core.provider_credentials.runtime import resolve_credentials
values = resolve_credentials("xfyun_lfasr").values
appid = values.get("appid", "")
secret = values.get("secret_key", "")
if not (appid and secret):
raise LfasrNotConfigured(
"录音转写未配置:需在 .env 设 XFYUN_APPID / XFYUN_LFASR_SECRET_KEY"
@ -99,6 +98,15 @@ def _post(path: str, params: dict, content: Optional[bytes] = None,
headers={"Content-Type": "application/octet-stream"} if content else None,
timeout=timeout_s,
)
if resp.status_code >= 400:
try:
from core.provider_credentials.service import record_business_failure
record_business_failure(
"xfyun_lfasr", status_code=resp.status_code,
detail=resp.text[:200],
)
except Exception:
pass
resp.raise_for_status()
body = resp.json()
except LfasrError:
@ -108,6 +116,14 @@ def _post(path: str, params: dict, content: Optional[bytes] = None,
code = str(body.get("code") or "")
if code != "000000":
desc = body.get("descInfo") or "未知错误"
if code in {"10105", "10106", "10107", "11200"}:
try:
from core.provider_credentials.service import record_business_failure
record_business_failure(
"xfyun_lfasr", status_code=401, detail="讯飞认证失败"
)
except Exception:
pass
raise LfasrError(f"讯飞录音转写失败({code}):{desc}", code=code)
return body.get("content") or {}

View File

@ -18,7 +18,6 @@ import base64
import hashlib
import hmac
import json
import os
import time
from contextlib import suppress
from typing import Any
@ -84,9 +83,11 @@ def build_auth_url(api_key: str, api_secret: str) -> str:
def _load_credentials() -> tuple[str, str, str]:
appid = (os.getenv("XFYUN_APPID") or "").strip()
api_key = (os.getenv("XFYUN_API_KEY") or "").strip()
api_secret = (os.getenv("XFYUN_API_SECRET") or "").strip()
from core.provider_credentials.runtime import resolve_credentials
values = resolve_credentials("xfyun_iat").values
appid = values.get("appid", "")
api_key = values.get("api_key", "")
api_secret = values.get("api_secret", "")
if not (appid and api_key and api_secret):
raise XfyunASRNotConfigured(
"语音识别未配置:需在 .env 设 XFYUN_APPID / XFYUN_API_KEY / XFYUN_API_SECRET"
@ -94,6 +95,16 @@ def _load_credentials() -> tuple[str, str, str]:
return appid, api_key, api_secret
def _report_auth_error(code: object) -> None:
if str(code) not in {"10105", "10106", "10107", "11200"}:
return
try:
from core.provider_credentials.service import record_business_failure
record_business_failure("xfyun_iat", status_code=401, detail="讯飞认证失败")
except Exception:
pass
class XfyunStream:
"""流式会话:实时喂 PCM 分片,partial 全文经 on_text 异步回调推出(wpgs 已合并)。
@ -151,6 +162,7 @@ class XfyunStream:
msg = json.loads(await self._ws.recv())
code = msg.get("code")
if code:
_report_auth_error(code)
hint = _ERR_HINTS.get(code, msg.get("message") or "未知错误")
raise XfyunASRError(f"讯飞识别失败({code}):{hint}", code=code)
data = msg.get("data") or {}
@ -272,6 +284,7 @@ async def transcribe(pcm: bytes, *, language: str = "zh_cn") -> str:
msg = json.loads(raw)
code = msg.get("code")
if code:
_report_auth_error(code)
hint = _ERR_HINTS.get(code, msg.get("message") or "未知错误")
raise XfyunASRError(f"讯飞识别失败({code}):{hint}", code=code)
data = msg.get("data") or {}

View File

@ -1,7 +1,9 @@
"""Structured user-message attachments and model-context compatibility rendering."""
from __future__ import annotations
import base64
import mimetypes
from copy import deepcopy
from collections.abc import Iterable
from pathlib import Path
from typing import Any
@ -10,6 +12,14 @@ from .artifacts import ArtifactPathError, resolve_artifact_path
ATTACHMENT_REF_VERSION = 1
MAX_ATTACHMENTS_PER_MESSAGE = 10
MAX_NATIVE_IMAGE_BYTES = 10 * 1024 * 1024
_NATIVE_IMAGE_MIME = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
_IMAGE_EXTENSIONS = {
".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg",
".png", ".svg", ".tif", ".tiff", ".webp",
@ -74,3 +84,60 @@ def content_for_model(content: str, refs: Iterable[dict], working_dir_name: str)
return text
suffix = "\n".join(lines)
return f"{text}\n\n{suffix}" if text else suffix
def materialize_native_images(
messages: Iterable[dict],
*,
enabled: bool,
working_dir: Path,
user_root: Path,
) -> tuple[list[dict], int]:
"""把内存附件引用按需物化为 OpenAI-compatible image_url blocks。
Base64 只存在于本次 provider-bound 副本不写数据库历史文件已删除越界
格式不受支持或超过上限时保留原有文字路径提示并跳过图片块不能让旧附件阻断续聊
所有 ``_`` 内部字段在返回前剥离
"""
output: list[dict] = []
image_count = 0
for message in messages:
new_msg = deepcopy(message)
refs = list(new_msg.pop("_attachment_refs", []) or [])
new_msg.pop("_model_profile", None)
if not enabled or new_msg.get("role") != "user" or not refs:
output.append(new_msg)
continue
blocks: list[dict] = []
text = new_msg.get("content")
if isinstance(text, str) and text:
blocks.append({"type": "text", "text": text})
message_image_count = 0
for ref in refs:
if not isinstance(ref, dict) or ref.get("kind") != "image":
continue
raw_path = str(ref.get("path") or "")
try:
path, _ = resolve_artifact_path(
raw_path,
working_dir=working_dir,
user_root=user_root,
require_file=True,
)
mime = _NATIVE_IMAGE_MIME.get(path.suffix.lower())
if mime is None or path.stat().st_size > MAX_NATIVE_IMAGE_BYTES:
continue
data = base64.b64encode(path.read_bytes()).decode("ascii")
except (ArtifactPathError, OSError):
continue
blocks.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
})
image_count += 1
message_image_count += 1
if message_image_count:
new_msg["content"] = blocks
output.append(new_msg)
return output, image_count

View File

@ -1,7 +1,6 @@
"""博查 (Bocha AI) Web Search API 客户端,共享给 web_search tool。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@ -22,6 +21,11 @@ class BochaError(RuntimeError):
class BochaConfig:
api_key: str
base_url: str
api_key_env: str = ""
def request_api_key(self) -> str:
from core.provider_credentials.runtime import resolve_env_secret
return resolve_env_secret(self.api_key_env) if self.api_key_env else self.api_key
@classmethod
def load(cls, path: Optional[Path] = None) -> Optional["BochaConfig"]:
@ -35,12 +39,14 @@ class BochaConfig:
return None
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
env = data.get("bocha_api_key_env") or "BOCHA_API_KEY"
key = os.environ.get(env, "").strip()
from core.provider_credentials.runtime import resolve_env_secret
key = resolve_env_secret(env)
if not key:
return None
return cls(
api_key=key,
base_url=str(data.get("bocha_base_url") or "https://api.bochaai.com/v1").rstrip("/"),
api_key_env=env,
)
@ -49,10 +55,11 @@ class BochaClient:
def __init__(self, cfg: BochaConfig, timeout_s: float = 15.0) -> None:
self.cfg = cfg
self._api_key = cfg.request_api_key()
self._client = httpx.Client(
base_url=cfg.base_url,
headers={
"Authorization": f"Bearer {cfg.api_key}",
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
timeout=timeout_s,
@ -73,13 +80,21 @@ class BochaClient:
raise BochaError(f"博查网络错误: {e}") from e
return self._parse(resp)
@staticmethod
def _parse(resp: httpx.Response) -> dict:
def _parse(self, resp: httpx.Response) -> dict:
if resp.status_code >= 400:
try:
from core.provider_credentials.service import record_business_failure
record_business_failure(
"bocha", status_code=resp.status_code, detail=resp.text[:300]
)
except Exception:
pass
try:
msg = resp.json().get("message", resp.text[:300])
except ValueError:
msg = resp.text[:300]
key = self._api_key
msg = str(msg).replace(key, "***") if key else str(msg)
raise BochaError(f"博查 API → HTTP {resp.status_code}: {msg}")
try:
return resp.json()

View File

@ -3,13 +3,26 @@ from __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import List
from typing import Any, Dict, List, Optional
import yaml
from .llm_params import THINKING_TRANSPORTS
REASONING_REPLAY_POLICIES = {"none", "tool_calls", "all"}
REASONING_REPLAY_POLICIES = {
"none",
"tool_turn",
"conversation",
"provider_managed",
}
def model_profile_of(caps: object) -> str:
"""返回能力对象的 canonical profile兼容测试和旧调用方的鸭子类型。"""
profile = getattr(caps, "profile", "")
if profile:
return str(profile)
return f"{getattr(caps, 'family', '')}.{getattr(caps, 'variant', '')}".strip(".")
@dataclass
@ -34,10 +47,16 @@ class ModelCapabilities:
thinking_transport: str = "none"
reasoning_effort_levels: List[str] = field(default_factory=list)
default_reasoning_effort: str = ""
# 是否清除历史 thinking。None=不发送该 provider 可选字段False=保留历史推理。
thinking_clear: Optional[bool] = None
# 历史 reasoning 发回模型的策略。持久化原始响应与 provider-bound 输入分离:
# none=全部剥离tool_calls=仅工具调用 assistant 保留all=完整原样回传。
# none=剥离tool_turn=仅当前工具链conversation=同模型会话;
# provider_managed=同模型不透明签名/block。
reasoning_replay: str = "none"
# 原生输入模态。附件仍以文件引用落库,发模型前才物化成 provider content blocks。
input_modalities: List[str] = field(default_factory=lambda: ["text"])
# 代码 / 沙盒
code_quality: str = "good"
enable_run_python: bool = False
@ -56,6 +75,9 @@ class ModelCapabilities:
# 前缀缓存命中价(DeepSeek 等自动缓存 prompt 前缀,命中部分按此价,通常 ~0.1x input)。
# 0 = 不区分,缓存命中按 input 全价记(安全兜底,不会少记)。
cache_hit_cny_per_mtoken: float = 0.0
# 版本化价格配置。存在时由 core.pricing 作为本地事实源;上面三个扁平字段仅作
# 旧档案/旧调用方兼容与无版本价格兜底。
pricing: Dict[str, Any] = field(default_factory=dict)
# API 接入
api_base: str = ""
@ -81,6 +103,16 @@ class ModelCapabilities:
)
var = dict(variants[variant])
# 已下架 profile 可保留为一版隐藏别名,确保存量 task / scheduled job 能续跑;
# 真正能力和记账身份统一归目标 variant不维持旧模型双轨。
seen_aliases = {variant}
while var.get("alias_of"):
target = str(var["alias_of"]).strip()
if not target or target in seen_aliases or target not in variants:
raise ValueError(f"档案 {path} 的 variant={variant} alias_of 无效")
seen_aliases.add(target)
variant = target
var = dict(variants[target])
valid_keys = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in var.items() if k in valid_keys}
kwargs["family"] = data.get("family", family)
@ -109,3 +141,11 @@ class ModelCapabilities:
f"{caps.default_reasoning_effort!r} 不在 reasoning_effort_levels 中"
)
return caps
@property
def profile(self) -> str:
return f"{self.family}.{self.variant}"
@property
def native_image_input(self) -> bool:
return "image" in self.input_modalities

View File

@ -14,6 +14,8 @@ from typing import Any, List
import json
import re
from .capabilities import REASONING_REPLAY_POLICIES
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token)与压缩触发比例。
# loop(50% 压缩门槛)与 context_fold(85% 折叠门槛)共用,单一事实源;压缩/折叠是
# 成本/安全优化、非正确性关键,估算粗糙无妨。
@ -40,30 +42,42 @@ _REASONING_FIELDS = {
def filter_reasoning_for_replay(
messages: List[dict[str, Any]],
policy: str = "none",
target_model_profile: str = "",
) -> tuple[List[dict[str, Any]], dict[str, int]]:
"""构造 provider-bound 消息副本,按能力档案裁剪历史 reasoning。
原始 Session/DB payload 不动继续供 UI导出和排障使用工具调用需要 reasoning
连续性的 provider 可用 ``tool_calls``未声明能力的 provider 默认 ``none``避免
跨模型发送私有状态保留时把 LiteLLM 可能放在 provider_specific_fields 里的
reasoning_content 提升到 DeepSeek/OpenAI-compatible 所需的顶层字段
``tool_turn`` 只保留当前用户轮次内工具链的状态``conversation`` 保留同模型
会话状态``provider_managed`` 保留同模型的不透明签名/block未声明能力默认
``none``只要历史消息的生产模型与目标模型不同就无条件剥离私有状态
"""
if policy not in {"none", "tool_calls", "all"}:
if policy not in REASONING_REPLAY_POLICIES:
raise ValueError(f"unsupported reasoning replay policy: {policy!r}")
last_user = max(
(i for i, msg in enumerate(messages) if msg.get("role") == "user"),
default=-1,
)
filtered: List[dict[str, Any]] = []
stripped_messages = 0
stripped_chars = 0
for message in messages:
for idx, message in enumerate(messages):
new_msg = deepcopy(message)
if policy == "all":
filtered.append(new_msg)
continue
source_profile = str(new_msg.pop("_model_profile", "") or "")
compatible = not target_model_profile or source_profile == target_model_profile
preserve = (
policy == "tool_calls"
compatible
and new_msg.get("role") == "assistant"
and (
policy in {"conversation", "provider_managed"}
or (
policy == "tool_turn"
and idx > last_user
and bool(new_msg.get("tool_calls"))
)
)
)
if preserve:
provider_fields = new_msg.get("provider_specific_fields")
if (
@ -242,6 +256,7 @@ def prepare_messages_for_llm(
old_tool_chars: int = 2_000,
compact_threshold_chars: int = 0,
reasoning_replay: str = "none",
target_model_profile: str = "",
) -> List[dict[str, Any]]:
"""返回发给 LLM 的 messages 副本。
@ -257,6 +272,7 @@ def prepare_messages_for_llm(
old_tool_chars=old_tool_chars,
compact_threshold_chars=compact_threshold_chars,
reasoning_replay=reasoning_replay,
target_model_profile=target_model_profile,
)
return prepared
@ -268,6 +284,7 @@ def prepare_messages_with_stats(
old_tool_chars: int = 2_000,
compact_threshold_chars: int = 0,
reasoning_replay: str = "none",
target_model_profile: str = "",
) -> tuple[List[dict[str, Any]], dict[str, int]]:
"""返回发给 LLM 的 messages 副本和压缩统计。
@ -279,7 +296,9 @@ def prepare_messages_with_stats(
"""
if keep_recent < 0:
keep_recent = 0
messages, reasoning_stats = filter_reasoning_for_replay(messages, reasoning_replay)
messages, reasoning_stats = filter_reasoning_for_replay(
messages, reasoning_replay, target_model_profile
)
# 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。
messages, repaired_tool_calls = _repair_dangling_tool_calls(messages)
original_chars = sum(_message_chars(m) for m in messages)

View File

@ -25,6 +25,7 @@ from uuid import UUID
from sqlalchemy import update
from .capabilities import model_profile_of
from .context import (
CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO,
@ -33,6 +34,7 @@ from .context import (
filter_reasoning_for_replay,
prepare_messages_with_stats,
)
from .llm_transport import extract_usage_details
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
@ -144,7 +146,10 @@ def maybe_fold(
"""
msgs = session.messages
reasoning_replay = getattr(caps, "reasoning_replay", "none")
provider_msgs, _ = filter_reasoning_for_replay(msgs, reasoning_replay)
target_profile = model_profile_of(caps)
provider_msgs, _ = filter_reasoning_for_replay(
msgs, reasoning_replay, target_profile
)
before_chars = sum(_message_chars(m) for m in provider_msgs)
est_tokens = estimate_window_tokens(provider_msgs, session.last_measured_usage())
if est_tokens < caps.reliable_context * FOLD_TRIGGER_RATIO:
@ -164,6 +169,7 @@ def maybe_fold(
msgs,
compact_threshold_chars=compact_threshold,
reasoning_replay=reasoning_replay,
target_model_profile=target_profile,
)
p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff))
if p_cut is None: # 理论不可达,防御
@ -177,17 +183,24 @@ def maybe_fold(
emit({"type": "warn", "msg": "context fold: 摘要调用返回空,本轮跳过折叠"})
return None
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=session.task_id,
user_id=user_id,
message_id=None, # 摘要不入 messages,event 挂 task 维度
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
extra_units={
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
# 独立 kind:按 kind 对账"折叠烧多少钱",且折叠次数 = count(该 kind)
# (任务详情 context_folds 字段的数据源,免加计数列)
@ -200,7 +213,7 @@ def maybe_fold(
persist_fold(session.task_id, new_base, summary)
session.apply_fold(cutoff, summary)
after_provider_msgs, _ = filter_reasoning_for_replay(
session.messages, reasoning_replay
session.messages, reasoning_replay, target_profile
)
after_chars = sum(_message_chars(m) for m in after_provider_msgs)
stats = {

View File

@ -35,6 +35,7 @@ class ExecCtx:
task_id: UUID
working_dir: Path
cancel_check: Optional[Callable[[], bool]] = None
event_emit: Optional[Callable[[dict], None]] = None
@dataclass

View File

@ -25,7 +25,7 @@ run_python tmp .py 落 host 侧 `<user_root>/.zcbot_tmp/<task_id>/<rand>.py`(bin
Cancel limitation(第一版接受):
- docker exec 客户端断开后,容器内 server 端进程**不会**因此终止 这是 docker 设计
- 第一版只杀 docker CLI(Popen.kill());容器内残留进程靠 idle 5min reaper / 下次
- 第一版只杀 docker CLI(Popen.kill());容器内残留进程靠 idle 10min reaper / 下次
ensure rm -f 兜底
- 升级触发(§7.5 #3 PGID 协议):用户反馈"取消了但还在烧 CPU" / 多次 cancel 后
容器内进程堆积 启用ZCBOT_EXEC_ID env + PGID 写文件 + 二次 exec kill协议
@ -145,8 +145,12 @@ class DockerExecutor(Executor):
return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2)
try:
if name == "shell":
if not args.get("background"):
return self._call_heavy_foreground(name, args, ctx)
return self._exec_shell(args, ctx)
if name == "run_python":
if not args.get("background"):
return self._call_heavy_foreground(name, args, ctx)
return self._exec_python(args, ctx)
if name in FS_TOOLS:
return self._exec_fs_tool(name, args, ctx)
@ -157,6 +161,23 @@ class DockerExecutor(Executor):
)
return ToolResult(content=f"[Error] unhandled container tool: {name}", exit_code=2)
def _call_heavy_foreground(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
"""前台重型工具先排共享槽;排队不进入命令 timeout。"""
def notify(payload: dict) -> None:
if ctx.event_emit is not None:
ctx.event_emit({"type": "execution_queue", **payload})
with self.pool.capacity.foreground(
str(self.user_id), ctx.cancel_check, notify
) as admitted:
if not admitted:
return ToolResult(content="[Error] command cancelled by user while queued", exit_code=130)
self.pool.exec_started(self.user_id)
try:
return self._exec_shell(args, ctx) if name == "shell" else self._exec_python(args, ctx)
finally:
self.pool.exec_finished(self.user_id)
# ── shell ────────────────────────────────────────────────
def _exec_shell(self, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
@ -248,7 +269,7 @@ class DockerExecutor(Executor):
def _exec_background(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
"""background=true:专用容器跑长进程,立即返回 proc_id。
为什么不是 `docker exec -d` sandbox 容器:sandbox idle 5min reaper +
为什么不是 `docker exec -d` sandbox 容器:sandbox idle 10min reaper +
启动时 shutdown_all,长进程会随容器陪葬专用容器 product=proc 与这两条
生命周期解耦(pool.run_proc_container),状态协议同 host 模式(core/procs.py):
runner.sh 结束时写 exit_code,check_process/sweep 负责回收容器
@ -256,15 +277,6 @@ class DockerExecutor(Executor):
from core import procs
anchor = self.user_root
if procs.count_running(anchor) >= procs.MAX_RUNNING_PER_USER:
return ToolResult(
content=(
f"[Error] 已有 {procs.MAX_RUNNING_PER_USER} 个后台进程在跑(上限)。"
f"用 check_process 查看,等待完成或 kill 掉不需要的再启动。"
),
exit_code=2,
)
raw_timeout = args.get("timeout")
fg_default = 60 if name == "shell" else 120
try:
@ -334,29 +346,16 @@ class DockerExecutor(Executor):
"timeout_s": timeout_s,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"created_ts": time.time(),
"user_id": str(self.user_id),
"exec_user": self.exec_user,
"state": "queued",
}
procs.write_meta(d, meta)
try:
container = self.pool.run_proc_container(self.user_id, proc_id, str(d))
except Exception as e:
return ToolResult(content=f"[Error] 后台容器启动失败: {e}", exit_code=1)
meta["container"] = container
procs.write_meta(d, meta)
argv = self._docker_exec_argv(
container, extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}), detach=True
) + ["bash", f"{cdir}/runner.sh"]
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if r.returncode != 0:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
return ToolResult(
content=f"[Error] 后台进程启动失败: {(r.stderr or '').strip()[:300]}",
exit_code=1,
)
started = procs.start_queued_docker(meta, d, self.pool)
return ToolResult(
content=(
f"[Background] 已启动后台进程 proc_id={proc_id}({display[:150]}),"
f"[Background] 已{'启动' if started else '排队'}后台进程 proc_id={proc_id}({display[:150]}),"
f"最长运行 {timeout_s}s。\n"
f"用 check_process(proc_id=\"{proc_id}\") 查进度和日志。进程独立于本轮对话运行,"
f"服务重启也不中断。现在可以继续其他工作;若无事可做,结束回合并告知用户稍后询问进度。"

View File

@ -30,6 +30,7 @@ from core.kb import format_index_line, kb_dir, parse_index, pending_sources
from core.file_store import atomic_write_text
from core.kb_lock import KbBusyError, kb_is_locked, kb_mutation_lock
from core.llm import LLM
from core.llm_transport import extract_usage_details
from core.storage.usage import record_chat_usage, record_vision_usage
# 摘要模型固定走最便宜档(与 web FALLBACK_MODEL_PROFILE 同值;单次几千 token,不随任务模型)
@ -184,20 +185,30 @@ def _summarize(
except Exception as e:
print(f"[kb_ingest] summary llm failed ({filename}): {type(e).__name__}: {e}", flush=True)
return fallback
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=None,
user_id=user_id,
message_id=None,
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
response=response,
kind="kb_ingest",
extra_units={"kb": kb_name, "source": filename},
extra_units={
"kb": kb_name,
"source": filename,
**{
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
},
)
except Exception:
pass

View File

@ -37,16 +37,39 @@ _REQUEST_TIMEOUT_S = int(os.getenv("ZCBOT_LLM_TIMEOUT_S", "600"))
class LLM:
def __init__(self, capabilities: ModelCapabilities) -> None:
def __init__(
self,
capabilities: ModelCapabilities,
*,
credential_resolver: Callable[[str], str] | None = None,
) -> None:
self.caps = capabilities
env_name = capabilities.api_key_env or "DEEPSEEK_API_KEY"
self.api_key = os.environ.get(env_name)
self.env_name = capabilities.api_key_env or "DEEPSEEK_API_KEY"
if credential_resolver is None:
from .provider_credentials.runtime import resolve_env_secret
credential_resolver = resolve_env_secret
self._credential_resolver = credential_resolver
self.api_key = self._credential_resolver(self.env_name)
self.api_base = capabilities.api_base or None
if not self.api_key:
raise RuntimeError(
f"环境变量 {env_name} 未设置,无法调用 {capabilities.model_id}"
f"Provider 凭据 {self.env_name} 未设置,无法调用 {capabilities.model_id}"
)
def _request_api_key(self) -> str:
key = self._credential_resolver(self.env_name)
if not key:
# 非注册表内的测试/私有模型仍保持构造期 env 快照兼容;正式受管
# Provider 不回退快照,删除 DB 覆盖后应立即回到当前 env 或报未配置。
from .provider_credentials.registry import BY_ENV
if self.env_name not in BY_ENV:
key = self.api_key
if not key:
raise RuntimeError(
f"Provider 凭据 {self.env_name} 未设置,无法调用 {self.caps.model_id}"
)
return key
def _build_kwargs(
self,
messages: List[dict],
@ -54,11 +77,19 @@ class LLM:
parallel_tool_calls: Optional[bool],
reasoning_effort: Optional[str],
) -> dict:
# Session 私有元数据永不进入 provider 请求。原生图片由主 loop 在此之前
# 物化;折叠/标题等旁路则安全退化为纯文本路径提示。
provider_messages = [
{k: v for k, v in message.items() if not str(k).startswith("_")}
for message in messages
]
kwargs: dict = {
"model": self.caps.model_id,
"messages": messages,
"messages": provider_messages,
"temperature": self.caps.optimal_temperature,
"api_key": self.api_key,
# 请求级解析:已经创建的 LLM 在下一次请求热切新 Key本次 kwargs
# 构建后保持局部快照,流式迭代期间不会中途换 Key。
"api_key": self._request_api_key(),
"timeout": _REQUEST_TIMEOUT_S,
}
if self.api_base:
@ -72,12 +103,29 @@ class LLM:
enabled=self.caps.thinking_enabled,
transport=self.caps.thinking_transport,
reasoning_effort=reasoning_effort,
clear_thinking=self.caps.thinking_clear,
)
)
if self.caps.prompt_caching:
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
return kwargs
def _report_provider_error(self, error: Exception) -> None:
try:
from .provider_credentials.registry import BY_ENV
from .provider_credentials.service import record_business_failure
binding = BY_ENV.get(self.env_name)
if binding is None:
return
raw_status = getattr(error, "status_code", 0)
if hasattr(raw_status, "value"):
raw_status = raw_status.value
record_business_failure(
binding[0], status_code=int(raw_status or 0), detail=str(error)
)
except Exception:
pass
def chat(
self,
messages: List[dict],
@ -94,6 +142,7 @@ class LLM:
return response
except (RateLimitError, APIConnectionError, ServiceUnavailableError, Timeout, APIError) as e:
last_err = e
self._report_provider_error(e)
if attempt == max_retries - 1:
break
time.sleep(2 ** attempt)
@ -130,8 +179,7 @@ class LLM:
yield from self._chat_stream_direct(kwargs, max_retries=max_retries)
@staticmethod
def _chat_stream_direct(kwargs: dict, *, max_retries: int) -> Iterator[Any]:
def _chat_stream_direct(self, kwargs: dict, *, max_retries: int) -> Iterator[Any]:
"""无取消调用方的直连路径probe 等保持原有同步语义)。"""
last_err: Optional[Exception] = None
@ -141,6 +189,7 @@ class LLM:
break
except (RateLimitError, APIConnectionError, ServiceUnavailableError, Timeout, APIError) as e:
last_err = e
self._report_provider_error(e)
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
@ -159,8 +208,8 @@ class LLM:
except Exception:
pass
@staticmethod
def _chat_stream_interruptible(
self,
kwargs: dict,
*,
max_retries: int,
@ -199,6 +248,7 @@ class LLM:
APIError,
) as e:
last_err = e
self._report_provider_error(e)
if attempt == max_retries - 1:
raise
if stop.wait(2 ** attempt):

View File

@ -7,7 +7,11 @@ THINKING_TRANSPORTS = {"none", "extra_body"}
def build_thinking_kwargs(
*, enabled: bool, transport: str, reasoning_effort: str | None
*,
enabled: bool,
transport: str,
reasoning_effort: str | None,
clear_thinking: bool | None = None,
) -> dict[str, Any]:
"""把统一的 thinking 配置转换为 LiteLLM 调用参数。
@ -22,9 +26,12 @@ def build_thinking_kwargs(
f"不支持的 thinking_transport={transport!r};可选: {sorted(THINKING_TRANSPORTS)}"
)
body: dict[str, Any] = {
"thinking": {"type": "enabled" if enabled else "disabled"}
thinking: dict[str, Any] = {
"type": "enabled" if enabled else "disabled"
}
if enabled and clear_thinking is not None:
thinking["clear_thinking"] = clear_thinking
body: dict[str, Any] = {"thinking": thinking}
if enabled and reasoning_effort:
body["reasoning_effort"] = reasoning_effort
return {"extra_body": body}

View File

@ -22,7 +22,8 @@ import litellm
from . import pptx_guard
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .capabilities import ModelCapabilities
from .attachments import materialize_native_images
from .capabilities import ModelCapabilities, model_profile_of
from .context import (
CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO,
@ -312,20 +313,21 @@ class AgentLoop:
assert response is not None
msg = response.choices[0].message
tool_calls = getattr(msg, "tool_calls", None) or []
model_profile = model_profile_of(self.caps)
asst_msg_id = self.session.append(
msg,
artifact_refs=(list(self._pending_artifact_refs) if not tool_calls else None),
source_model_profile=model_profile,
)
usage_details = extract_usage_details(getattr(response, "usage", None))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
# 用本轮实报 prompt_tokens 刷新 chars/token 校准比值(下一轮门槛/占用环即用)。
if pt > 0 and self._last_sent_chars > 0:
if pt > 0 and self._last_sent_chars > 0 and not self._last_had_native_images:
self._ctx_chars_per_token = clamp_ratio(self._last_sent_chars / pt)
# 记账(0006):一行 usage_event + 回填 messages.tokens_in/out + model_profile。
# 任何失败都吞掉(litellm cost map miss / DB 异常),不阻塞主 loop;
# message 仍在 session/DB 里,后续重启不影响。
model_profile = f"{self.caps.family}.{self.caps.variant}"
try:
record_chat_usage(
task_id=self.session.task_id,
@ -338,6 +340,7 @@ class AgentLoop:
output_cny_per_mtoken=self.caps.output_cny_per_mtoken,
cache_hit_tokens=usage_details["cache_hit_tokens"],
cache_hit_cny_per_mtoken=self.caps.cache_hit_cny_per_mtoken,
pricing=self.caps.pricing,
extra_units={
k: v for k, v in usage_details.items()
if k not in ("tokens_in", "tokens_out") and v
@ -456,6 +459,7 @@ class AgentLoop:
# 无实测时的回退(详 context.py 常数注释)。
_ctx_chars_per_token: Optional[float] = None
_last_sent_chars: int = 0
_last_had_native_images: bool = False
def _context_ratio(self) -> float:
"""当前窗口的 chars/token 换算比值(校准态)。
@ -470,6 +474,7 @@ class AgentLoop:
provider_messages, _ = filter_reasoning_for_replay(
self.session.messages,
getattr(self.caps, "reasoning_replay", "none"),
model_profile_of(self.caps),
)
self._ctx_chars_per_token = calibrated_chars_per_token(
provider_messages, self.session.last_measured_usage()
@ -514,7 +519,18 @@ class AgentLoop:
self.session.messages,
compact_threshold_chars=compact_threshold,
reasoning_replay=getattr(self.caps, "reasoning_replay", "none"),
target_model_profile=model_profile_of(self.caps),
)
native_images = 0
if self.user_root is not None:
llm_messages, native_images = materialize_native_images(
llm_messages,
enabled=self.caps.native_image_input,
working_dir=self.working_dir,
user_root=self.user_root,
)
self._last_had_native_images = native_images > 0
context_stats["native_images"] = native_images
self._last_sent_chars = context_stats.get("sent_chars", 0)
llm_start_event = {
"type": "llm_start",
@ -623,9 +639,8 @@ class AgentLoop:
cancel_check=self._is_cancelled,
)
cancelled = False
pending_events: List[dict] = []
may_reroute = self.caps.family == "deepseek_v4"
output_route_known = not may_reroute
reasoning_emitted = False
try:
for chunk in stream:
if self._is_cancelled():
@ -637,7 +652,10 @@ class AgentLoop:
may_reroute
and any(name in self._DEEPSEEK_NONSTREAM_TOOLS for name in tool_names)
):
# 推理 delta 先暂存,避免切换后非流式完整 reasoning 再发一次造成重复。
# 当前流会被放弃并以非流式完整重发。已实时展示的推理属于被放弃
# 的响应,先通知前端清掉,避免随后展示最终响应时重复或与历史不一致。
if reasoning_emitted:
self._emit({"type": "reasoning_reset"})
raise PreferNonstreamToolCall(next(
name for name in tool_names
if name in self._DEEPSEEK_NONSTREAM_TOOLS
@ -647,20 +665,13 @@ class AgentLoop:
# _execute_tool_call 时机发更直观)。
delta_text = extract_delta_content(chunk)
if delta_text:
pending_events.append({"type": "text", "delta": delta_text})
self._emit({"type": "text", "delta": delta_text})
# thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达
# 分钟级,不发的话前端全程静止"思考中",用户以为卡死。
delta_reasoning = extract_delta_reasoning(chunk)
if delta_reasoning:
pending_events.append({"type": "reasoning", "delta": delta_reasoning})
# 首个正文或任意工具名已确定本轮不会再切换;释放之前暂存的推理片段,
# 后续 chunk 也继续即时释放。只有尚未看到输出类型时才短暂缓冲。
if delta_text or tool_names:
output_route_known = True
if output_route_known and pending_events:
for event in pending_events:
self._emit(event)
pending_events.clear()
self._emit({"type": "reasoning", "delta": delta_reasoning})
reasoning_emitted = True
# interruptible stream 会在无新 chunk 的等待期直接因 cancel 结束迭代;
# 循环体没有机会执行上面的检查,故在正常耗尽处再判一次。
if self._is_cancelled():
@ -674,10 +685,6 @@ class AgentLoop:
if cancelled:
return None, True
# 极少数 provider 只有 reasoning/空收尾、始终没有正文或工具名,不能吞掉已收内容。
for event in pending_events:
self._emit(event)
# 用 litellm 官方 helper 拼回完整 response(包括 tool_calls 拼接 + usage)。
# messages 参数仅用于失败时回填 prompt token 估算,正常路径 stream_options.include_usage
# 已让最后一个 chunk 带准确 usage。
@ -765,6 +772,7 @@ class AgentLoop:
task_id=self.session.task_id,
working_dir=self.working_dir,
cancel_check=self.cancel_check,
event_emit=self._emit,
)
tool_started_at = time.time()
tool_result = self.executor.call_tool(name, args, ctx)

View File

@ -6,7 +6,7 @@
- 某档成员里出现 `"*"` 该档全开(含未来新增模型),给内部档用
模型 id 约定( list 端点 / resolve 校验一致):
- 文本模型 = `family.variant`(config/models/<family>.yaml), `doubao.pro``glm.pro52`
- 文本模型 = `family.variant`(config/models/<family>.yaml), `doubao.pro``glm.flash53`
- / 视频模型 = variant key(config/media/doubao.yaml), `seedream_5``seedance_2_fast`
两者命名不冲突(文本带点媒体 variant 不带点),同一档集合里混放即可

209
core/pricing.py Normal file
View File

@ -0,0 +1,209 @@
"""版本化 chat 价格解析与成本计算。
模型 API 通常只返回 token usage不返回实际金额这里以模型 YAML 中经过核对的
provider 价格为事实源并把命中的价格版本时段和汇率快照交给 usage_events 留痕
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, time, timezone
from decimal import Decimal
from typing import Any, Mapping, Optional
_MTOKEN = Decimal("1000000")
_WEEKDAYS = {
"mon": 0,
"tue": 1,
"wed": 2,
"thu": 3,
"fri": 4,
"sat": 5,
"sun": 6,
}
def _decimal(value: Any, default: str = "0") -> Decimal:
if value is None or value == "":
return Decimal(default)
return Decimal(str(value))
def _parse_datetime(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
dt = value
else:
raw = str(value).strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _parse_time(value: str) -> time:
hour, minute = (int(part) for part in value.split(":", 1))
return time(hour=hour, minute=minute)
def _in_window(now: time, start: time, end: time) -> bool:
if start <= end:
return start <= now < end
return now >= start or now < end
@dataclass(frozen=True)
class PriceQuote:
revision: str
source_url: str
currency: str
fx_to_cny: Decimal
tier: str
input_per_mtoken: Decimal
output_per_mtoken: Decimal
cache_hit_per_mtoken: Decimal
@dataclass(frozen=True)
class CostBreakdown:
total_cny: Decimal
input_cny: Decimal
cache_hit_cny: Decimal
output_cny: Decimal
cache_hit_tokens: int
cache_miss_tokens: int
def _periods(pricing: Mapping[str, Any]) -> list[Mapping[str, Any]]:
periods = pricing.get("periods")
if isinstance(periods, list):
return [p for p in periods if isinstance(p, Mapping)]
# 兼容一个价格版本直接写在 pricing 顶层的简写。
return [pricing]
def _select_period(
pricing: Mapping[str, Any], occurred_at: datetime
) -> Optional[Mapping[str, Any]]:
candidates: list[tuple[datetime, Mapping[str, Any]]] = []
for period in _periods(pricing):
start = _parse_datetime(period.get("effective_from")) or datetime.min.replace(
tzinfo=timezone.utc
)
end = _parse_datetime(period.get("effective_to"))
if start <= occurred_at and (end is None or occurred_at < end):
candidates.append((start, period))
if not candidates:
return None
return max(candidates, key=lambda item: item[0])[1]
def _tier_matches(tier: Mapping[str, Any], occurred_at: datetime) -> bool:
tz_name = str(tier.get("timezone") or "UTC").upper()
if tz_name != "UTC":
raise ValueError(f"pricing time tier 暂只支持 UTC收到 {tz_name!r}")
now = occurred_at.astimezone(timezone.utc)
weekdays = tier.get("weekdays") or []
if weekdays:
allowed = {_WEEKDAYS[str(day).lower()] for day in weekdays}
if now.weekday() not in allowed:
return False
windows = tier.get("windows") or []
if not windows:
return True
current = now.time().replace(tzinfo=None)
return any(
_in_window(current, _parse_time(window[0]), _parse_time(window[1]))
for window in windows
if isinstance(window, (list, tuple)) and len(window) == 2
)
def resolve_chat_price(
pricing: Mapping[str, Any] | None,
*,
occurred_at: Optional[datetime] = None,
) -> Optional[PriceQuote]:
"""解析调用时刻适用的价格;无配置或尚未生效时返回 ``None``。"""
if not pricing:
return None
when = occurred_at or datetime.now(timezone.utc)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
when = when.astimezone(timezone.utc)
period = _select_period(pricing, when)
if period is None:
return None
rates = period.get("default") or {}
tier_name = "default"
for tier in period.get("time_tiers") or []:
if isinstance(tier, Mapping) and _tier_matches(tier, when):
rates = tier
tier_name = str(tier.get("name") or "time_tier")
break
currency = str(period.get("currency") or "CNY").upper()
if currency not in {"CNY", "USD"}:
raise ValueError(f"不支持的 pricing currency: {currency!r}")
fx = _decimal(period.get("fx_to_cny"), "1") if currency == "USD" else Decimal("1")
return PriceQuote(
revision=str(period.get("revision") or "unversioned"),
source_url=str(period.get("source_url") or ""),
currency=currency,
fx_to_cny=fx,
tier=tier_name,
input_per_mtoken=_decimal(rates.get("input_per_mtoken")),
output_per_mtoken=_decimal(rates.get("output_per_mtoken")),
cache_hit_per_mtoken=_decimal(
rates.get("cache_hit_per_mtoken"),
str(rates.get("input_per_mtoken") or 0),
),
)
def calculate_chat_cost(
quote: PriceQuote,
*,
prompt_tokens: int,
completion_tokens: int,
cache_hit_tokens: int = 0,
) -> CostBreakdown:
"""按价格快照拆分缓存命中、未命中和输出成本,返回人民币金额。"""
tokens_in = max(0, int(prompt_tokens))
hit = max(0, min(int(cache_hit_tokens), tokens_in))
miss = tokens_in - hit
tokens_out = max(0, int(completion_tokens))
input_cost = Decimal(miss) * quote.input_per_mtoken * quote.fx_to_cny / _MTOKEN
hit_cost = Decimal(hit) * quote.cache_hit_per_mtoken * quote.fx_to_cny / _MTOKEN
output_cost = Decimal(tokens_out) * quote.output_per_mtoken * quote.fx_to_cny / _MTOKEN
quant = Decimal("0.000001")
return CostBreakdown(
total_cny=(input_cost + hit_cost + output_cost).quantize(quant),
input_cny=input_cost.quantize(quant),
cache_hit_cny=hit_cost.quantize(quant),
output_cny=output_cost.quantize(quant),
cache_hit_tokens=hit,
cache_miss_tokens=miss,
)
def pricing_snapshot(quote: PriceQuote, breakdown: CostBreakdown) -> dict[str, Any]:
"""生成可直接合并进 usage_events.units 的价格快照。"""
return {
"pricing_revision": quote.revision,
"pricing_source": "local_catalog",
"pricing_source_url": quote.source_url,
"pricing_currency": quote.currency,
"price_tier": quote.tier,
"fx_to_cny": float(quote.fx_to_cny),
"input_price_per_mtoken": float(quote.input_per_mtoken),
"cache_hit_price_per_mtoken": float(quote.cache_hit_per_mtoken),
"output_price_per_mtoken": float(quote.output_per_mtoken),
"cache_hit_tokens": breakdown.cache_hit_tokens,
"cache_miss_tokens": breakdown.cache_miss_tokens,
"input_cost_cny": float(breakdown.input_cny),
"cache_hit_cost_cny": float(breakdown.cache_hit_cny),
"output_cost_cny": float(breakdown.output_cny),
}

View File

@ -45,9 +45,6 @@ PROCS_SUBDIR = ".zcbot_procs"
DEFAULT_TIMEOUT_S = 7200
MAX_TIMEOUT_S = 86400
# 每用户并发 bg 进程上限(跨 task 合计);防模型失控起一堆
MAX_RUNNING_PER_USER = int(os.getenv("ZCBOT_MAX_BG_PROCS", "3"))
# 终态 proc 目录保留时长,sweep 超期删除
FINISHED_TTL_S = 7 * 86400
@ -151,12 +148,14 @@ def _container_running(name: str) -> bool:
def status_of(meta: Dict[str, Any], d: Path) -> Tuple[str, Optional[int]]:
"""("running"|"finished"|"lost", exit_code|None)。exit_code 文件是唯一终态锚。"""
"""queued/running/finished/lost。exit_code 文件是唯一终态锚。"""
try:
raw = (d / "exit_code").read_text(encoding="utf-8").strip()
return "finished", int(raw)
except (OSError, ValueError):
pass
if meta.get("state") == "queued":
return "queued", None
if meta.get("backend") == "docker":
if _container_running(str(meta.get("container") or "")):
return "running", None
@ -304,10 +303,24 @@ def kill_proc(meta: Dict[str, Any], d: Path) -> str:
st, _ = status_of(meta, d)
if st == "finished":
return "进程已结束,无需终止"
if st == "queued":
meta["state"] = "finished"
meta["killed"] = True
write_meta(d, meta)
(d / "exit_code").write_text("137", encoding="utf-8")
return "已取消排队"
if meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name:
_scan_proc_container(meta, d, name)
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30)
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
except Exception:
pass
else:
wrapper_pid = int(meta.get("wrapper_pid") or 0)
child_pid = int(meta.get("child_pid") or 0)
@ -338,6 +351,69 @@ def kill_proc(meta: Dict[str, Any], d: Path) -> str:
return "已终止"
def start_queued_docker(meta: Dict[str, Any], d: Path, pool) -> bool:
"""尝试为 queued proc 获取共享槽并启动;无槽返回 False保持排队。"""
from core.file_store import interprocess_file_lock
with interprocess_file_lock(d / "dispatch.lock", timeout_seconds=None):
meta = read_meta(d) or meta
if status_of(meta, d)[0] != "queued":
return False
uid = str(meta.get("user_id") or "")
lease_id = f"bg-{meta.get('proc_id')}"
lease = pool.capacity.try_acquire(uid, "background", lease_id=lease_id,
proc_id=str(meta.get("proc_id")))
if not lease:
return False
try:
from uuid import UUID
container = pool.run_proc_container(UUID(uid), str(meta["proc_id"]), str(d))
cdir = f"/workspace/{PROCS_SUBDIR}/{meta['task_id']}/{meta['proc_id']}"
argv = ["docker", "exec", "--user", str(meta.get("exec_user") or "zcbot"),
"--workdir", str(meta.get("cwd") or "/workspace"), "-d",
"-e", "PYTHONPATH=/sandbox:/workspace", "-e", "HOME=/tmp",
"-e", "PYTHONIOENCODING=utf-8", container, "bash", f"{cdir}/runner.sh"]
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if r.returncode != 0:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
raise RuntimeError((r.stderr or "").strip()[:300])
meta.update({"container": container, "capacity_lease_id": lease,
"state": "running", "started_ts": time.time()})
write_meta(d, meta)
return True
except Exception as exc:
pool.capacity.release(lease)
meta["last_start_error"] = f"{type(exc).__name__}: {exc}"
write_meta(d, meta)
return False
def dispatch_queued(user_root_base: Path, pool) -> int:
started = 0
base = Path(user_root_base)
if not base.is_dir():
return 0
candidates = []
running_ids: set[str] = set()
for uroot in base.iterdir():
root = uroot / PROCS_SUBDIR
if not root.is_dir():
continue
for meta_file in root.glob("*/*/proc.json"):
d = meta_file.parent
meta = read_meta(d)
if meta:
status = status_of(meta, d)[0]
if status == "queued":
candidates.append((float(meta.get("created_ts") or 0), meta, d))
elif status == "running":
running_ids.add(str(meta.get("proc_id") or d.name))
pool.capacity.reconcile_background(running_ids)
for _, meta, d in sorted(candidates, key=lambda x: x[0]):
if start_queued_docker(meta, d, pool):
started += 1
return started
# ───────────── 清扫(web lifespan 周期调用) ─────────────
def sweep(user_root_base: Path, ttl_s: int = FINISHED_TTL_S) -> Dict[str, int]:
@ -365,16 +441,27 @@ def sweep(user_root_base: Path, ttl_s: int = FINISHED_TTL_S) -> Dict[str, int]:
if not meta:
continue
st, _ = status_of(meta, d)
if st != "running" and meta.get("backend") == "docker":
if st in {"finished", "lost"} and meta.get("capacity_lease_id"):
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
meta.pop("capacity_lease_id", None)
write_meta(d, meta)
except Exception:
pass
if st not in {"running", "queued"} and meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name and _container_exists(name):
_scan_proc_container(meta, d, name)
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, timeout=30,
)
reaped_containers += 1
age = now - float(meta.get("created_ts") or now)
if st != "running" and age > ttl_s:
if st not in {"running", "queued"} and age > ttl_s:
shutil.rmtree(d, ignore_errors=True)
removed_dirs += 1
try:
@ -410,6 +497,16 @@ def _container_exists(name: str) -> bool:
return False
def _scan_proc_container(meta: Dict[str, Any], d: Path, name: str) -> None:
try:
from uuid import UUID
from core.sandbox.package_scans import scan_and_persist
uid = UUID(str(meta.get("user_id")))
scan_and_persist(name, uid, "background", str(meta.get("proc_id") or d.name))
except Exception as exc:
print(f"[sandbox-package-scan] proc finalizer failed container={name}: {type(exc).__name__}: {exc}")
# ───────────── LLM 面向的描述格式化(check_process / 启动返回共用) ─────────────
def format_proc_line(meta: Dict[str, Any]) -> str:
@ -418,7 +515,7 @@ def format_proc_line(meta: Dict[str, Any]) -> str:
elapsed = ""
ts = meta.get("created_ts")
if ts:
elapsed = f" · 已运行 {_fmt_elapsed(time.time() - float(ts))}" if st == "running" else ""
elapsed = f" · 已运行 {_fmt_elapsed(time.time() - float(meta.get('started_ts') or ts))}" if st == "running" else ""
tail = f"(exit {ec})" if st == "finished" else ""
return (
f"- {meta.get('proc_id')} [{st}{tail}] {meta.get('kind')}: "

View File

@ -0,0 +1,5 @@
"""Admin 管理的平台 Provider 凭据控制面。"""
from .runtime import resolve_credentials, resolve_secret
__all__ = ["resolve_credentials", "resolve_secret"]

View File

@ -0,0 +1,50 @@
"""蓝绿单轮选主的 Provider 巡检与开发者邮件。"""
from __future__ import annotations
import os
from core.storage import get_engine
from .registry import get_provider
from .service import due_provider_ids, test_current_credentials
from .testing import TestResult
_LOCK_SQL = "SELECT pg_try_advisory_lock(31331, 2)"
_UNLOCK_SQL = "SELECT pg_advisory_unlock(31331, 2)"
def send_notification(provider_id: str, event: str, result: TestResult) -> None:
provider = get_provider(provider_id)
label = "恢复正常" if event == "recovered" else result.detail
print(f"[provider] {provider_id} {event}: {label}")
email = os.getenv("ZCBOT_DEVELOPER_EMAIL", "").strip()
if not email:
return
try:
from tools.send_email import send_email_smtp, smtp_configured
if smtp_configured():
send_email_smtp(
email, f"[zcbot] {provider.display_name} {label}",
f"Provider: {provider.display_name}\n状态: {event}\n详情: {result.detail}",
)
except Exception as exc: # noqa: BLE001 - 告警失败不得影响业务状态
print(f"[provider] notification failed: {type(exc).__name__}")
def run_due_checks() -> int:
engine = get_engine()
with engine.connect() as connection:
claimed = bool(connection.exec_driver_sql(_LOCK_SQL).scalar())
if not claimed:
return 0
try:
count = 0
for provider_id in due_provider_ids():
try:
test_current_credentials(provider_id, notify=send_notification)
count += 1
except Exception as exc: # noqa: BLE001 - 单 Provider 隔离
print(f"[provider] {provider_id} check failed: {type(exc).__name__}")
return count
finally:
connection.exec_driver_sql(_UNLOCK_SQL)

View File

@ -0,0 +1,83 @@
"""静态可信 Provider 注册表;网络目标和协议不可由 Admin 修改。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class CredentialField:
name: str
label: str
env: str
secret: bool = True
@dataclass(frozen=True)
class ProviderDefinition:
provider_id: str
display_name: str
category: str
fields: tuple[CredentialField, ...]
test_kind: str
test_url: str = ""
test_url_env: str = ""
billable: bool = False
balance_supported: bool = False
check_interval_seconds: int = 86400
low_balance_threshold: float | None = None
def _key(env: str, label: str = "API Key") -> CredentialField:
return CredentialField("api_key", label, env)
PROVIDERS: tuple[ProviderDefinition, ...] = (
ProviderDefinition("deepseek", "DeepSeek", "模型", (_key("DEEPSEEK_API_KEY"),),
"deepseek_balance", "https://api.deepseek.com/user/balance",
balance_supported=True, check_interval_seconds=1800,
low_balance_threshold=30.0),
ProviderDefinition("zhipuai", "智谱开放平台", "模型", (_key("ZHIPUAI_API_KEY"),),
"bearer_get", "https://open.bigmodel.cn/api/paas/v4/models"),
ProviderDefinition("ark", "火山方舟", "模型与媒体", (_key("ARK_API_KEY"),),
"bearer_get", "https://ark.cn-beijing.volces.com/api/v3/models"),
ProviderDefinition("unifyllm", "国际旗舰模型网关", "模型与媒体", (_key("UNIFYLLM_API_KEY"),),
"bearer_get", "https://unifyllm.ai/v1/models"),
ProviderDefinition("local_llm", "内网本地模型", "模型", (_key("LOCAL_LLM_API_KEY"),),
"bearer_get", "http://182.54.21.126:9000/v1/models"),
ProviderDefinition("bocha", "博查搜索", "搜索与平台数据", (_key("BOCHA_API_KEY"),),
"bocha_search", "https://api.bochaai.com/v1/web-search", billable=True),
ProviderDefinition("document_search", "内部材料库", "搜索与平台数据", (_key("DOCUMENT_SEARCH_API_KEY"),),
"bearer_get", "https://ai.ctc-zc.com:8100/api/document_search/list_knowledge_bases",
test_url_env="DOCUMENT_SEARCH_URL"),
ProviderDefinition("paper_server", "论文服务", "搜索与平台数据", (_key("PAPER_SERVER_API_KEY"),),
"query_get", "http://paper.xxhhcty.xyz:8080/api/resm/paper/",
test_url_env="PAPER_SERVER_URL"),
ProviderDefinition("materials_project", "Materials Project", "搜索与平台数据", (_key("MP_API_KEY"),),
"mp_get", "https://api.materialsproject.org/materials/summary/?_limit=1"),
ProviderDefinition(
"xfyun_iat", "讯飞语音听写 IAT", "语音",
(CredentialField("appid", "APPID", "XFYUN_APPID"),
CredentialField("api_key", "API Key", "XFYUN_API_KEY"),
CredentialField("api_secret", "API Secret", "XFYUN_API_SECRET")),
"xfyun_iat", billable=True,
),
ProviderDefinition(
"xfyun_lfasr", "讯飞录音转写 LFASR", "语音",
(CredentialField("appid", "APPID", "XFYUN_APPID"),
CredentialField("secret_key", "Secret Key", "XFYUN_LFASR_SECRET_KEY")),
"xfyun_lfasr", "https://raasr.xfyun.cn/v2/api/getResult", billable=False,
),
)
BY_ID = {provider.provider_id: provider for provider in PROVIDERS}
BY_ENV = {
field.env: (provider.provider_id, field.name)
for provider in PROVIDERS for field in provider.fields
}
def get_provider(provider_id: str) -> ProviderDefinition:
try:
return BY_ID[provider_id]
except KeyError as exc:
raise ValueError("unknown provider") from exc

View File

@ -0,0 +1,73 @@
"""每个新外部请求调用一次的数据库优先/env fallback 凭据解析器。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from core.external_systems.crypto import decrypt_secret
from .registry import BY_ENV, get_provider
@dataclass(frozen=True)
class ResolvedCredentials:
provider_id: str
source: str
values: dict[str, str]
def _env_values(provider_id: str) -> dict[str, str]:
provider = get_provider(provider_id)
return {
field.name: value
for field in provider.fields
if (value := (os.getenv(field.env) or "").strip())
}
def resolve_credentials(provider_id: str) -> ResolvedCredentials:
provider = get_provider(provider_id)
row = None
try:
from core.storage import session_scope
from core.storage.models import ProviderCredential
with session_scope() as session:
row = session.execute(
select(ProviderCredential).where(
ProviderCredential.provider_id == provider_id
)
).scalar_one_or_none()
except (RuntimeError, SQLAlchemyError):
# CLI、migration 前部署或 DB 短暂不可用时保持历史 env 行为。
pass
if row is not None and all(field.name in row.credentials for field in provider.fields):
# 已存在完整 DB 覆盖时master key/AAD 错误必须显式失败,不能静默绕回 env。
values = {
field.name: decrypt_secret(
row.credentials[field.name], aad=f"provider:{provider_id}:{field.name}"
)
for field in provider.fields
}
return ResolvedCredentials(provider_id, "database", values)
values = _env_values(provider_id)
source = "env" if len(values) == len(provider.fields) else "missing"
return ResolvedCredentials(provider_id, source, values)
def resolve_secret(provider_id: str, field: str = "api_key") -> str:
return resolve_credentials(provider_id).values.get(field, "")
def resolve_env_secret(env_name: str) -> str:
binding = BY_ENV.get(env_name)
if binding is None:
return (os.getenv(env_name) or "").strip()
return resolve_secret(*binding)
def provider_available(provider_id: str) -> bool:
provider = get_provider(provider_id)
return len(resolve_credentials(provider_id).values) == len(provider.fields)

View File

@ -0,0 +1,263 @@
"""Provider 凭据 CRUD、乐观替换、状态持久化与定时检查。"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from core.external_systems.crypto import configured as crypto_configured
from core.external_systems.crypto import encrypt_secret
from core.storage import session_scope
from core.storage.models import ProviderCredential
from .registry import PROVIDERS, get_provider
from .runtime import _env_values, resolve_credentials
from .testing import TestResult, classify_response, test_provider
_BAD = {"low_balance", "exhausted", "auth_error", "unreachable"}
_NOTIFY_COOLDOWN = timedelta(hours=24)
class ProviderCredentialError(RuntimeError):
pass
class RevisionConflict(ProviderCredentialError):
pass
def _hint(value: str) -> str:
value = str(value or "")
return f"***{value[-4:]}" if len(value) >= 4 else "***"
def _row_payload(provider, row: ProviderCredential | None) -> dict:
database = bool(row and all(field.name in row.credentials for field in provider.fields))
env_values = _env_values(provider.provider_id)
env_source = "env" if len(env_values) == len(provider.fields) else "missing"
source = "database" if database else env_source
hints = row.credential_hint if database else {
field.name: _hint(env_values.get(field.name, ""))
for field in provider.fields if env_values.get(field.name)
}
return {
"provider_id": provider.provider_id,
"display_name": provider.display_name,
"category": provider.category,
"fields": [
{"name": field.name, "label": field.label, "required": True,
"hint": hints.get(field.name, "")}
for field in provider.fields
],
"configured": source != "missing",
"source": source,
"revision": row.revision if row else 0,
"test_status": row.test_status if row else "untested",
"test_detail": row.test_detail if row else None,
"last_tested_at": row.last_tested_at.isoformat() if row and row.last_tested_at else None,
"balance": (
{"amount": str(row.balance_amount), "currency": row.balance_currency}
if row and row.balance_amount is not None else None
),
"balance_supported": provider.balance_supported,
"billable_test": provider.billable,
"alerting": bool(row and row.test_status in _BAD),
}
def list_providers() -> list[dict]:
with session_scope() as session:
rows = {
row.provider_id: row
for row in session.execute(select(ProviderCredential)).scalars()
}
return [_row_payload(provider, rows.get(provider.provider_id)) for provider in PROVIDERS]
def _validate_values(provider_id: str, values: dict[str, str]) -> dict[str, str]:
provider = get_provider(provider_id)
expected = {field.name for field in provider.fields}
cleaned = {str(k): str(v).strip() for k, v in values.items()}
if set(cleaned) != expected or any(not value for value in cleaned.values()):
raise ProviderCredentialError("凭据字段不完整或包含未知字段")
return cleaned
def _notification_transition(
old_status: str, old_notified_at: datetime | None, result: TestResult, now: datetime
) -> str | None:
if result.status == "normal" and old_status in _BAD:
return "recovered"
if result.status not in _BAD:
return None
if old_status not in _BAD or old_status != result.status:
return result.status
if old_notified_at is None or now - old_notified_at >= _NOTIFY_COOLDOWN:
return result.status
return None
def _apply_result(
provider_id: str,
result: TestResult,
*,
notify: Callable[[str, str, TestResult], None] | None = None,
) -> None:
now = datetime.now(timezone.utc)
event: str | None = None
with session_scope() as session:
row = session.execute(
select(ProviderCredential).where(ProviderCredential.provider_id == provider_id)
).scalar_one_or_none()
if row is None:
row = ProviderCredential(
provider_id=provider_id, credentials={}, credential_hint={}, revision=1
)
session.add(row)
session.flush()
old_status = row.test_status
event = _notification_transition(old_status, row.last_notified_at, result, now)
row.test_status = result.status
row.test_detail = result.detail[:500]
row.balance_amount = result.balance_amount
row.balance_currency = result.balance_currency
row.last_tested_at = now
if event == "recovered":
row.last_notified_at = None
row.last_notified_status = None
elif event:
row.last_notified_at = now
row.last_notified_status = result.status
if event and notify:
notify(provider_id, event, result)
def replace_credentials(
provider_id: str,
values: dict[str, str],
*,
expected_revision: int,
updated_by: UUID,
request=None,
notify: Callable[[str, str, TestResult], None] | None = None,
) -> dict:
if not crypto_configured():
raise ProviderCredentialError(
"未配置 ZCBOT_CREDENTIAL_MASTER_KEY禁止保存数据库凭据"
)
provider = get_provider(provider_id)
cleaned = _validate_values(provider_id, values)
kwargs = {"request": request} if request is not None else {}
result = test_provider(provider_id, cleaned, **kwargs)
if not result.accepted:
raise ProviderCredentialError(f"候选凭据测试失败:{result.detail}")
encrypted = {
field.name: encrypt_secret(
cleaned[field.name], aad=f"provider:{provider_id}:{field.name}"
)
for field in provider.fields
}
hints = {field.name: _hint(cleaned[field.name]) for field in provider.fields}
now = datetime.now(timezone.utc)
try:
with session_scope() as session:
if expected_revision == 0:
session.add(ProviderCredential(
provider_id=provider_id, credentials=encrypted,
credential_hint=hints, revision=1, test_status=result.status,
test_detail=result.detail, balance_amount=result.balance_amount,
balance_currency=result.balance_currency, last_tested_at=now,
updated_by=updated_by,
))
revision = 1
else:
changed = session.execute(
update(ProviderCredential)
.where(ProviderCredential.provider_id == provider_id)
.where(ProviderCredential.revision == expected_revision)
.values(
credentials=encrypted, credential_hint=hints,
revision=expected_revision + 1, test_status=result.status,
test_detail=result.detail, balance_amount=result.balance_amount,
balance_currency=result.balance_currency, last_tested_at=now,
updated_by=updated_by, updated_at=now,
)
)
if int(changed.rowcount or 0) != 1:
raise RevisionConflict("凭据已被其他管理员更新,请刷新后重试")
revision = expected_revision + 1
except IntegrityError as exc:
raise RevisionConflict("凭据已被其他管理员更新,请刷新后重试") from exc
if result.status == "low_balance" and notify:
notify(provider_id, result.status, result)
with session_scope() as session:
session.execute(
update(ProviderCredential)
.where(ProviderCredential.provider_id == provider_id)
.where(ProviderCredential.revision == revision)
.values(last_notified_at=now, last_notified_status=result.status)
)
return {"provider_id": provider_id, "revision": revision,
"test_status": result.status, "test_detail": result.detail}
def test_current_credentials(
provider_id: str, *, request=None,
notify: Callable[[str, str, TestResult], None] | None = None,
) -> TestResult:
resolved = resolve_credentials(provider_id)
provider = get_provider(provider_id)
if len(resolved.values) != len(provider.fields):
raise ProviderCredentialError("Provider 凭据未完整配置")
kwargs = {"request": request} if request is not None else {}
result = test_provider(provider_id, resolved.values, **kwargs)
_apply_result(provider_id, result, notify=notify)
return result
def delete_override(provider_id: str, *, expected_revision: int) -> None:
get_provider(provider_id)
with session_scope() as session:
changed = session.execute(
delete(ProviderCredential)
.where(ProviderCredential.provider_id == provider_id)
.where(ProviderCredential.revision == expected_revision)
)
if int(changed.rowcount or 0) != 1:
raise RevisionConflict("凭据已被更新或不存在,请刷新后重试")
def record_business_failure(
provider_id: str, *, status_code: int = 0, detail: str = "",
notify: Callable[[str, str, TestResult], None] | None = None,
) -> str | None:
result = classify_response(status_code, detail)
if result.status not in {"auth_error", "exhausted"}:
return None
if notify is None:
from .monitor import send_notification
notify = send_notification
_apply_result(provider_id, result, notify=notify)
return result.status
def due_provider_ids(now: datetime | None = None) -> list[str]:
now = now or datetime.now(timezone.utc)
with session_scope() as session:
rows = {
row.provider_id: row for row in session.execute(select(ProviderCredential)).scalars()
}
due = []
for provider in PROVIDERS:
resolved = resolve_credentials(provider.provider_id)
if len(resolved.values) != len(provider.fields):
continue
row = rows.get(provider.provider_id)
if row is None or row.last_tested_at is None or (
now - row.last_tested_at >= timedelta(seconds=provider.check_interval_seconds)
):
due.append(provider.provider_id)
return due

View File

@ -0,0 +1,172 @@
"""Provider 候选凭据测试与统一、保守的错误分类。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import time
from collections.abc import Callable
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
import httpx
from .registry import ProviderDefinition, get_provider
_EXHAUSTED_RE = re.compile(
r"insufficient[ _-]?(?:balance|quota|credit)|balance[ _-]?not[ _-]?enough|余额不足|额度不足|quota exhausted",
re.IGNORECASE,
)
_AUTH_RE = re.compile(
r"invalid.*(?:key|token)|authentication|unauthori[sz]ed|鉴权|认证失败",
re.IGNORECASE,
)
@dataclass(frozen=True)
class TestResult:
status: str
detail: str
balance_amount: Decimal | None = None
balance_currency: str | None = None
@property
def accepted(self) -> bool:
return self.status in {"normal", "low_balance"}
def classify_response(status_code: int, text: str = "") -> TestResult:
safe = re.sub(r"\s+", " ", text or "").strip()[:300]
if status_code in {401, 403} or _AUTH_RE.search(safe):
return TestResult("auth_error", f"认证失败HTTP {status_code}")
if status_code == 402 or _EXHAUSTED_RE.search(safe):
return TestResult("exhausted", f"余额或额度已耗尽HTTP {status_code}")
if status_code == 429:
return TestResult("unreachable", "服务限流HTTP 429未判定为余额耗尽")
if status_code >= 400:
return TestResult("unreachable", f"服务返回 HTTP {status_code}")
return TestResult("normal", "认证与连通性正常")
def _deepseek(response: httpx.Response, provider: ProviderDefinition) -> TestResult:
base = classify_response(response.status_code, response.text)
if base.status != "normal":
return base
try:
body = response.json()
infos = body.get("balance_infos") or []
cny = next(x for x in infos if str(x.get("currency", "")).upper() == "CNY")
amount = Decimal(str(cny.get("total_balance")))
except (ValueError, KeyError, StopIteration, InvalidOperation, TypeError):
return TestResult("unreachable", "余额响应格式异常")
if body.get("is_available") is False or amount <= 0:
return TestResult("exhausted", "余额已耗尽", amount, "CNY")
threshold = Decimal(str(provider.low_balance_threshold or 0))
status = "low_balance" if amount < threshold else "normal"
detail = f"CNY 可用余额 ¥{amount:.2f}"
return TestResult(status, detail, amount, "CNY")
def _xfyun_sign(credentials: dict[str, str]) -> dict[str, str]:
ts = str(int(time.time()))
md5hex = hashlib.md5(
(credentials["appid"] + ts).encode(), usedforsecurity=False
).hexdigest()
signa = base64.b64encode(
hmac.new(credentials["secret_key"].encode(), md5hex.encode(), hashlib.sha1).digest()
).decode()
return {"appId": credentials["appid"], "ts": ts, "signa": signa,
"orderId": "zcbot-credential-check", "resultType": "transfer"}
def test_provider(
provider_id: str,
credentials: dict[str, str],
*,
request: Callable[..., httpx.Response] = httpx.request,
) -> TestResult:
provider = get_provider(provider_id)
expected = {field.name for field in provider.fields}
if set(credentials) != expected or any(not str(v).strip() for v in credentials.values()):
return TestResult("auth_error", "凭据字段不完整")
headers: dict[str, str] = {}
params: dict[str, str] = {}
json_body: dict[str, Any] | None = None
method = "GET"
test_url = provider.test_url
if provider.test_url_env:
base = (os.getenv(provider.test_url_env) or "").strip().rstrip("/")
if base and provider_id == "document_search":
test_url = f"{base}/document_search/list_knowledge_bases"
elif base and provider_id == "paper_server":
test_url = f"{base}/api/resm/paper/"
if provider.test_kind in {"deepseek_balance", "bearer_get", "bocha_search"}:
headers["Authorization"] = f"Bearer {credentials['api_key']}"
if provider.test_kind == "query_get":
params["api_key"] = credentials["api_key"]
params["page_size"] = "1"
elif provider.test_kind == "mp_get":
headers["X-API-KEY"] = credentials["api_key"]
elif provider.test_kind == "bocha_search":
method = "POST"
json_body = {"query": "水泥", "count": 1, "freshness": "noLimit"}
elif provider.test_kind == "xfyun_lfasr":
method = "POST"
params.update(_xfyun_sign(credentials))
elif provider.test_kind == "xfyun_iat":
# IAT 鉴权只存在于 WebSocket upgrade复用官方签名函数并允许测试替换
# requester。200/101 均视为握手成功,真实 handler 不发送音频、不计费。
from core.asr_xfyun import build_auth_url
url = build_auth_url(credentials["api_key"], credentials["api_secret"])
if request is httpx.request:
try:
from websockets.sync.client import connect
with connect(url, open_timeout=8) as websocket:
websocket.send(json.dumps({
"common": {"app_id": credentials["appid"]},
"business": {"language": "zh_cn", "domain": "iat", "accent": "mandarin"},
"data": {"status": 2, "format": "audio/L16;rate=16000",
"encoding": "raw", "audio": ""},
}))
body = json.loads(websocket.recv(timeout=8))
if int(body.get("code") or 0) == 0:
return TestResult("normal", "WebSocket 凭据组鉴权正常")
return TestResult("auth_error", "WebSocket 凭据组认证失败")
except Exception as exc: # noqa: BLE001 - WebSocket 库异常族随版本变化
text = str(exc)
status = int(getattr(exc, "status_code", 0) or 0)
return classify_response(status, text) if status else TestResult(
"unreachable", "WebSocket 连接失败"
)
response = request("GET", url, headers={"X-Appid": credentials["appid"]}, timeout=8)
return classify_response(response.status_code, response.text)
try:
response = request(
method, test_url, headers=headers, params=params,
json=json_body, timeout=8,
)
except httpx.HTTPError:
return TestResult("unreachable", "网络连接失败")
if provider.test_kind == "deepseek_balance":
return _deepseek(response, provider)
if provider.test_kind == "xfyun_lfasr" and response.status_code < 400:
try:
body = response.json()
code = str(body.get("code") or "")
description = str(body.get("descInfo") or "")
if code in {"10105", "10106", "10107"}:
return TestResult("auth_error", "认证失败")
# 虚拟订单的“订单不存在”说明签名已通过。
if code and re.search(
r"order|订单.*(?:不存在|无效)", description, re.IGNORECASE
):
return TestResult("normal", "认证正常(未创建计费订单)")
if code:
return TestResult("unreachable", "服务未确认凭据有效")
except (ValueError, AttributeError):
pass
return classify_response(response.status_code, response.text)

268
core/sandbox/capacity.py Normal file
View File

@ -0,0 +1,268 @@
"""宿主级 Sandbox 重型执行容量。
状态保存在 workspace/.sandbox 下并由 advisory file lock 串行化因此蓝绿和多
Web 进程共享同一组槽位业务 DB 不承载实时状态后台排队事实源仍是
``.zcbot_procs``这里只记录已经获得槽位的租约和短暂的前台候选
"""
from __future__ import annotations
import json
import os
import time
import uuid
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Callable, Dict, Iterator, Optional
from core.file_store import atomic_write_text, interprocess_file_lock
DEFAULT_MAX_ACTIVE_EXECS = 6
DEFAULT_MAX_BACKGROUND_EXECS = 4
DEFAULT_MAX_ACTIVE_EXECS_PER_USER = 3
DEFAULT_MIN_MEM_AVAILABLE_BYTES = 1024 ** 3
def _parse_bytes(value: object, default: int) -> int:
text = str(value or "").strip().lower()
if not text:
return default
units = {"k": 1024, "kb": 1024, "m": 1024**2, "mb": 1024**2,
"g": 1024**3, "gb": 1024**3}
for suffix, factor in sorted(units.items(), key=lambda x: -len(x[0])):
if text.endswith(suffix):
return int(float(text[:-len(suffix)]) * factor)
return int(text)
def mem_available_bytes() -> Optional[int]:
try:
for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines():
if line.startswith("MemAvailable:"):
return int(line.split()[1]) * 1024
except (OSError, ValueError, IndexError):
return None
return None
class ExecCapacity:
def __init__(self, state_dir: Path, cfg: Optional[dict] = None) -> None:
cfg = cfg or {}
self.state_dir = Path(state_dir)
self.state_path = self.state_dir / "exec-capacity.json"
self.lock_path = self.state_dir / "exec-capacity.lock"
self.max_active = max(1, min(DEFAULT_MAX_ACTIVE_EXECS, int(os.getenv("ZCBOT_MAX_ACTIVE_EXECS") or cfg.get("max_active_execs") or DEFAULT_MAX_ACTIVE_EXECS)))
self.max_background = max(1, min(DEFAULT_MAX_BACKGROUND_EXECS, int(os.getenv("ZCBOT_MAX_BACKGROUND_EXECS") or cfg.get("max_background_execs") or DEFAULT_MAX_BACKGROUND_EXECS)))
self.max_per_user = max(1, min(DEFAULT_MAX_ACTIVE_EXECS_PER_USER, int(os.getenv("ZCBOT_MAX_ACTIVE_EXECS_PER_USER") or cfg.get("max_active_execs_per_user") or DEFAULT_MAX_ACTIVE_EXECS_PER_USER)))
self.min_mem_available = _parse_bytes(
os.getenv("ZCBOT_MIN_MEM_AVAILABLE") or cfg.get("min_mem_available"),
DEFAULT_MIN_MEM_AVAILABLE_BYTES,
)
def _read(self) -> dict:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
data.setdefault("leases", {})
data.setdefault("foreground_queue", [])
data.setdefault("containers", {})
return data
except (OSError, ValueError):
pass
return {"leases": {}, "foreground_queue": [], "containers": {}}
def _write(self, state: dict) -> None:
atomic_write_text(self.state_path, json.dumps(state, ensure_ascii=False, sort_keys=True))
def _prune(self, state: dict) -> None:
leases = state["leases"]
for key, lease in list(leases.items()):
if lease.get("kind") == "foreground":
pid = int(lease.get("owner_pid") or 0)
if pid and not self._pid_alive(pid):
leases.pop(key, None)
state["foreground_queue"] = [
q for q in state["foreground_queue"]
if self._pid_alive(int(q.get("owner_pid") or 0))
]
@staticmethod
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except PermissionError:
return True
except OSError:
return False
def _can_admit(self, state: dict, user_id: str, kind: str) -> bool:
leases = list(state["leases"].values())
if len(leases) >= self.max_active:
return False
if sum(1 for x in leases if x.get("user_id") == user_id) >= self.max_per_user:
return False
if kind == "background" and sum(1 for x in leases if x.get("kind") == "background") >= self.max_background:
return False
available = mem_available_bytes()
return available is None or available >= self.min_mem_available
def try_acquire(self, user_id: str, kind: str, *, lease_id: Optional[str] = None, proc_id: Optional[str] = None) -> Optional[str]:
lease_id = lease_id or uuid.uuid4().hex
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
self._prune(state)
if lease_id in state["leases"]:
return None
if not self._can_admit(state, str(user_id), kind):
self._write(state)
return None
state["leases"][lease_id] = {
"lease_id": lease_id, "user_id": str(user_id), "kind": kind,
"proc_id": proc_id, "owner_pid": os.getpid(), "started_ts": time.time(),
}
self._write(state)
return lease_id
def acquire_foreground(
self,
user_id: str,
cancel_check: Optional[Callable[[], bool]] = None,
wait_notify: Optional[Callable[[dict], None]] = None,
) -> Optional[str]:
ticket = uuid.uuid4().hex
wait_notified = False
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state)
state["foreground_queue"].append({"ticket": ticket, "user_id": str(user_id), "created_ts": time.time(), "owner_pid": os.getpid()})
self._write(state)
while True:
admitted = False
notification = None
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state)
queue = state["foreground_queue"]
eligible = next((q for q in queue if q.get("user_id") == str(user_id)), None)
if eligible and eligible.get("ticket") == ticket and self._can_admit(state, str(user_id), "foreground"):
queue[:] = [q for q in queue if q.get("ticket") != ticket]
state["leases"][ticket] = {"lease_id": ticket, "user_id": str(user_id), "kind": "foreground", "owner_pid": os.getpid(), "started_ts": time.time()}
self._write(state)
admitted = True
else:
self._write(state)
if not admitted and not wait_notified and wait_notify is not None:
leases = list(state["leases"].values())
user_running = sum(
1 for lease in leases
if lease.get("user_id") == str(user_id)
)
available = mem_available_bytes()
if user_running >= self.max_per_user:
reason = "per_user_limit"
elif len(leases) >= self.max_active:
reason = "global_limit"
elif available is not None and available < self.min_mem_available:
reason = "memory_pressure"
else:
reason = "queue_order"
notification = {
"state": "waiting",
"reason": reason,
"user_running": user_running,
"user_limit": self.max_per_user,
"global_running": len(leases),
"global_limit": self.max_active,
}
wait_notified = True
if admitted:
if wait_notified and wait_notify is not None:
with suppress(Exception):
wait_notify({"state": "admitted"})
return ticket
if notification is not None and wait_notify is not None:
with suppress(Exception):
wait_notify(notification)
if cancel_check is not None and cancel_check():
self.cancel_waiter(ticket)
return None
time.sleep(0.1)
def cancel_waiter(self, ticket: str) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
state["foreground_queue"] = [q for q in state["foreground_queue"] if q.get("ticket") != ticket]
self._write(state)
def release(self, lease_id: Optional[str]) -> None:
if not lease_id:
return
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); state["leases"].pop(lease_id, None); self._write(state)
def reconcile_background(self, running_proc_ids: set[str]) -> None:
"""服务重启后保留 Docker 仍在跑的租约,清启动窗口遗留的孤儿租约。"""
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
for key, lease in list(state["leases"].items()):
if (lease.get("kind") == "background"
and str(lease.get("proc_id") or "") not in running_proc_ids
and not self._pid_alive(int(lease.get("owner_pid") or 0))):
state["leases"].pop(key, None)
self._write(state)
def touch_container(self, name: str, *, active_delta: int = 0) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
row = state["containers"].setdefault(name, {"active_execs": 0})
row["active_execs"] = max(0, int(row.get("active_execs") or 0) + active_delta)
row["last_active_ts"] = time.time()
self._write(state)
def remove_container(self, name: str) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); state["containers"].pop(name, None); self._write(state)
def reconcile_containers(self, running_names: set[str]) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
for name in list(state["containers"]):
if name not in running_names:
state["containers"].pop(name, None)
self._write(state)
@contextmanager
def foreground(
self,
user_id: str,
cancel_check: Optional[Callable[[], bool]] = None,
wait_notify: Optional[Callable[[dict], None]] = None,
) -> Iterator[bool]:
lease = self.acquire_foreground(user_id, cancel_check, wait_notify)
try:
yield lease is not None
finally:
self.release(lease)
def snapshot(self) -> dict:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state); self._write(state)
leases = list(state["leases"].values())
fg = sum(x.get("kind") == "foreground" for x in leases)
bg = sum(x.get("kind") == "background" for x in leases)
by_user: Dict[str, int] = {}
for x in leases:
by_user[x["user_id"]] = by_user.get(x["user_id"], 0) + 1
available_mem = mem_available_bytes()
memory_paused = available_mem is not None and available_mem < self.min_mem_available
containers = state.get("containers", {})
now = time.time()
return {
"limits": {"active": self.max_active, "background": self.max_background, "per_user": self.max_per_user},
"foreground_running": fg, "foreground_queued": len(state["foreground_queue"]),
"background_running": bg, "per_user": by_user,
"admit_available": 0 if memory_paused else max(0, self.max_active - len(leases)),
"memory_paused": memory_paused,
"mem_available_bytes": available_mem,
"cpu_load": list(os.getloadavg()) if hasattr(os, "getloadavg") else None,
"active_sandbox_containers": len(containers),
"idle_reap_candidates": sum(int(x.get("active_execs") or 0) == 0 and float(x.get("last_active_ts") or now) < now - getattr(self, "idle_ttl_seconds", 600) for x in containers.values()),
}

View File

@ -0,0 +1,57 @@
"""Sandbox 临时 Python 包扫描与 best-effort 持久化。"""
from __future__ import annotations
import json
import subprocess
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
def scan_container(container: str) -> Optional[dict]:
try:
scan = subprocess.run(["docker", "exec", "--user", "zcbot", "--workdir", "/sandbox", container,
"python", "-I", "-S", "/sandbox/package_scan.py"], capture_output=True,
text=True, timeout=30)
if scan.returncode != 0:
raise RuntimeError(scan.stderr.strip())
packages = json.loads(scan.stdout)
if not isinstance(packages, list):
raise ValueError("scanner output is not a package list")
inspect = subprocess.run(["docker", "inspect", "--format={{.Image}}", container],
capture_output=True, text=True, timeout=15)
py = subprocess.run(["docker", "exec", "--workdir", "/sandbox", container, "python", "-I", "-S", "-c",
"import sys;print('.'.join(map(str,sys.version_info[:3])))"],
capture_output=True, text=True, timeout=15)
if inspect.returncode != 0 or py.returncode != 0:
raise RuntimeError("failed to inspect image or Python version")
return {"packages": packages, "image_digest": inspect.stdout.strip(),
"python_version": py.stdout.strip(),
"total_installed_bytes": sum(int(x.get("installed_bytes") or 0) for x in packages)}
except Exception as exc:
print(f"[sandbox-package-scan] failed container={container}: {type(exc).__name__}: {exc}")
return None
def persist_scan(*, container_session_id: str, user_id: UUID, execution_kind: str,
result: Optional[dict]) -> None:
if not result or not result.get("packages"):
return
try:
from core.storage import session_scope
from core.storage.models import SandboxPackageScan
with session_scope() as s:
s.merge(SandboxPackageScan(
container_session_id=container_session_id, user_id=user_id,
execution_kind=execution_kind, image_digest=result["image_digest"],
python_version=result["python_version"], packages=result["packages"],
package_count=len(result["packages"]),
total_installed_bytes=result["total_installed_bytes"],
finished_at=datetime.now(timezone.utc),
))
except Exception as exc:
print(f"[sandbox-package-scan] persist failed session={container_session_id}: {type(exc).__name__}: {exc}")
def scan_and_persist(container: str, user_id: UUID, kind: str, session_id: str) -> None:
persist_scan(container_session_id=session_id, user_id=user_id,
execution_kind=kind, result=scan_container(container))

View File

@ -56,11 +56,11 @@ LABEL_INSTANCE_KEY = "zcbot.instance"
INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
DEFAULT_IMAGE = "zcbot-sandbox:latest"
DEFAULT_IDLE_TTL_SECONDS = 300
DEFAULT_IDLE_TTL_SECONDS = 600
# 容器资源限制默认值(可被 yaml `sandbox.*` / env override,详 SandboxPool ctor)
DEFAULT_MEMORY = "2g"
DEFAULT_CPUS = "1.0"
DEFAULT_MEMORY = "4g"
DEFAULT_CPUS = "2.0"
# pids-limit 把**线程**也计数:chromium headless 一次启动(browser+GPU+network+renderer
# 多进程)就 ~150-200 线程,256 本来就贴边;shell 超时又只杀 host 侧 docker CLI,容器内
# mmdc+chromium 树留着(见 executor_docker.py 头注 Cancel limitation),残留几棵就把配额
@ -70,8 +70,9 @@ DEFAULT_PIDS_LIMIT = 1024
# chromium(mmdc 渲 mermaid / puppeteer)默认走 /dev/shm,docker 不传 --shm-size 时
# 只给 64MB,起不来就一直挂到 timeout。镜像备的 puppeteer-config 有 --disable-dev-shm-usage,
# 但模型不一定用那份;这里从根上把 /dev/shm 撑到够用,任何 chromium 路径都不再挂。
# 从 --memory(默 2g)里切,512m 是上限非占用(tmpfs 按需用)。
# 从 --memory(默 4g)里切,512m 是上限非占用(tmpfs 按需用)。
DEFAULT_SHM_SIZE = "512m"
DEFAULT_TMP_SIZE = "1g"
def container_name(user_id: UUID) -> str:
@ -102,6 +103,11 @@ def _container_running(name: str) -> bool:
return r.returncode == 0 and r.stdout.strip() == "true"
def _container_session_id(name: str) -> str:
r = subprocess.run(["docker", "inspect", "--format={{.Id}}", name], capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else name
class SandboxPool:
def __init__(
self,
@ -115,7 +121,9 @@ class SandboxPool:
cpus: Optional[str] = None,
pids_limit: Optional[int] = None,
shm_size: Optional[str] = None,
tmp_size: Optional[str] = None,
dns: Optional[List[str]] = None,
capacity_cfg: Optional[Dict[str, object]] = None,
) -> None:
"""
user_root_base: per-user 子树父目录,典型 `<workspace>/users`bind mount
@ -134,9 +142,9 @@ class SandboxPool:
pg_ips: 逗号分隔的 PG IP ,塞容器 `ZCBOT_PG_IPS` env,init.sh DROP 规则
(env `ZCBOT_PG_IPS`)defense-in-depth 即便落内网三段
memory/cpus/pids_limit/shm_size:
容器资源限制, 2g/1.0/256/512m;env(`ZCBOT_SANDBOX_MEMORY` )
容器资源限制, 4g/2.0/1024/512m;env(`ZCBOT_SANDBOX_MEMORY` )
override caller 参数 override 默认改后重启 web 生效,新起的
容器用新值; running 不变(idle 5min 回收后下次起按新值)
容器用新值; running 不变(idle 10min 回收后下次起按新值)
shm_size chromium /dev/shm( 64MB 不够,mmdc 渲图会挂)
"""
self.user_root_base = user_root_base
@ -153,6 +161,7 @@ class SandboxPool:
self.memory = os.getenv("ZCBOT_SANDBOX_MEMORY") or memory or DEFAULT_MEMORY
self.cpus = os.getenv("ZCBOT_SANDBOX_CPUS") or cpus or DEFAULT_CPUS
self.shm_size = os.getenv("ZCBOT_SANDBOX_SHM_SIZE") or shm_size or DEFAULT_SHM_SIZE
self.tmp_size = os.getenv("ZCBOT_SANDBOX_TMP_SIZE") or tmp_size or DEFAULT_TMP_SIZE
self.pids_limit = int(
os.getenv("ZCBOT_SANDBOX_PIDS_LIMIT")
or (pids_limit if pids_limit is not None else DEFAULT_PIDS_LIMIT)
@ -166,6 +175,10 @@ class SandboxPool:
self._dict_lock = threading.Lock() # 保护 _locks / _last_active 的字典级 race
self._locks: Dict[UUID, threading.Lock] = {}
self._last_active: Dict[UUID, int] = {}
self._active_execs: Dict[UUID, int] = {}
from .capacity import ExecCapacity
self.capacity = ExecCapacity(self.user_root_base.parent / ".sandbox", capacity_cfg)
self.capacity.idle_ttl_seconds = self.idle_ttl
def _lock_for(self, user_id: UUID) -> threading.Lock:
with self._dict_lock:
@ -179,15 +192,18 @@ class SandboxPool:
name = container_name(user_id)
if _container_running(name):
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
if _container_exists(name):
# stopped / crashed ── rm 重起。iptables 规则随容器生命周期重新 apply。
self._scan_before_remove(name, user_id, "foreground")
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, check=False,
)
self._docker_run(user_id, name)
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
def _ensure_resolv_conf_file(self) -> Optional[Path]:
@ -255,7 +271,7 @@ class SandboxPool:
"--network", NETWORK_NAME,
# §7.5 硬限制(任一缺失视为 hardening 未完成)
"--read-only", # rootfs read-only
"--tmpfs", "/tmp:exec,size=512m,mode=1777", # 可写临时区,exec 允许 (run_python 写脚本)
"--tmpfs", f"/tmp:exec,size={self.tmp_size},mode=1777",
f"--shm-size={self.shm_size}", # chromium/mmdc 的 /dev/shm,默 64MB 不够会挂(DEFAULT_SHM_SIZE)
"--cap-drop=ALL", # 默全丢
"--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到
@ -305,20 +321,48 @@ class SandboxPool:
def mark_active(self, user_id: UUID) -> None:
"""每次 `docker exec` 完调一次,刷新 idle 计时。"""
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id))
def exec_started(self, user_id: UUID) -> None:
"""普通容器内 exec 开始;显式计数防止 idle reaper 误删长命令。"""
with self._dict_lock:
self._active_execs[user_id] = self._active_execs.get(user_id, 0) + 1
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=1)
def exec_finished(self, user_id: UUID) -> None:
with self._dict_lock:
n = max(0, self._active_execs.get(user_id, 0) - 1)
if n:
self._active_execs[user_id] = n
else:
self._active_execs.pop(user_id, None)
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=-1)
def _scan_before_remove(self, name: str, user_id: UUID, kind: str, session_id: Optional[str] = None) -> None:
"""扫描/落库均 best-effort调用方无论如何继续删除容器。"""
try:
from .package_scans import scan_and_persist
scan_and_persist(name, user_id, kind, session_id or _container_session_id(name))
except Exception as exc:
print(f"[sandbox-package-scan] finalizer failed container={name}: {type(exc).__name__}: {exc}")
def reap_idle(self) -> List[str]:
"""杀超过 idle_ttl 没活跃的容器。返回已杀容器名列表(供日志 / 审计)。"""
removed: List[str] = []
cutoff = _now() - self.idle_ttl
for uid, ts in list(self._last_active.items()):
if ts < cutoff:
if ts < cutoff and self._active_execs.get(uid, 0) == 0:
name = container_name(uid)
self._scan_before_remove(name, uid, "foreground")
r = subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, text=True,
)
if r.returncode == 0:
removed.append(name)
self.capacity.remove_container(name)
# 无论 rm 成功与否,从 dict 移除 ── 失败则下次启动靠 shutdown_all 兜底
del self._last_active[uid]
return removed
@ -341,6 +385,21 @@ class SandboxPool:
if list_r.returncode != 0 or not list_r.stdout.strip():
return []
ids = list_r.stdout.strip().splitlines()
for cid in ids:
info = subprocess.run(
["docker", "inspect", "--format={{index .Config.Labels \"zcbot.user_id\"}}", cid],
capture_output=True, text=True,
)
try:
uid = UUID(info.stdout.strip())
except (ValueError, AttributeError):
continue
self._scan_before_remove(cid, uid, "foreground", cid)
try:
name_r = subprocess.run(["docker", "inspect", "--format={{.Name}}", cid], capture_output=True, text=True)
self.capacity.remove_container(name_r.stdout.strip().lstrip("/") or cid)
except Exception:
pass
subprocess.run(
["docker", "rm", "-f", *ids],
capture_output=True, text=True,
@ -349,6 +408,31 @@ class SandboxPool:
self._last_active.clear()
return ids
def runtime_snapshot(self) -> dict:
try:
listed = subprocess.run(
["docker", "ps", "--format={{.Names}}", "--filter", f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"],
capture_output=True, text=True, timeout=15,
)
if listed.returncode == 0:
self.capacity.reconcile_containers(set(listed.stdout.split()))
except (OSError, subprocess.TimeoutExpired):
pass
snap = self.capacity.snapshot()
now = _now()
snap["idle_ttl_seconds"] = self.idle_ttl
try:
from core import procs
items = []
for uroot in self.user_root_base.iterdir():
items.extend(procs.list_all_procs(uroot))
snap["background_queued"] = sum(x.get("_status") == "queued" for x in items)
snap["running_proc_containers"] = sum(x.get("_status") == "running" and x.get("backend") == "docker" for x in items)
except OSError:
snap["background_queued"] = 0
snap["running_proc_containers"] = 0
return snap
def setup_pool(
user_root_base: Path,
@ -376,12 +460,16 @@ def setup_pool(
cpus = cfg.get("cpus")
pids_limit = cfg.get("pids_limit")
shm_size = cfg.get("shm_size")
tmp_size = cfg.get("tmp_size")
return SandboxPool(
user_root_base=user_root_base,
repo_root=repo_root,
idle_ttl=int(str(cfg.get("idle_ttl_seconds"))) if cfg.get("idle_ttl_seconds") is not None else None,
memory=memory if isinstance(memory, str) else None,
cpus=str(cpus) if cpus is not None else None,
pids_limit=int(str(pids_limit)) if pids_limit is not None else None,
shm_size=shm_size if isinstance(shm_size, str) else None,
tmp_size=tmp_size if isinstance(tmp_size, str) else None,
dns=[str(x) for x in dns_cfg],
capacity_cfg=cfg,
)

View File

@ -65,6 +65,7 @@ class Session:
msg: Any,
*,
artifact_refs: Optional[list[dict]] = None,
source_model_profile: str = "",
) -> Optional[UUID]:
"""追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。
@ -91,7 +92,10 @@ class Session:
f"[markdown:fence-warning] task={self.task_id} unclosed=1",
flush=True,
)
self.messages.append(msg_dict)
memory_msg = dict(msg_dict)
if source_model_profile and memory_msg.get("role") == "assistant":
memory_msg["_model_profile"] = source_model_profile
self.messages.append(memory_msg)
if msg_dict.get("role") == "system":
return None
@ -236,6 +240,9 @@ class Session:
attachment_refs,
wd_name,
)
payload["_attachment_refs"] = list(attachment_refs)
if payload.get("role") == "assistant" and row.model_profile:
payload["_model_profile"] = row.model_profile
if payload.get("role") == "assistant" and isinstance(payload.get("content"), str):
# 历史行不回写生产库;只在重建 LLM 上下文时应用同一窄修复,
# 与 Web 展示层保持一致,避免旧坏围栏继续污染后续轮次。

View File

@ -7,6 +7,7 @@ Loop 不直接 print,改 emit({type, ...})。Sink 决定怎么呈现。
llm_end {type, prompt_tokens, completion_tokens, elapsed}
text {type, content} assistant 文字段(整段,非流式)
tool_call {type, name, args, args_preview}
execution_queue {type, state, reason, ...} 前台重型工具等待/放行
tool_result {type, name, result, preview, truncated}
done {type} 一次 run 全部结束

View File

@ -841,6 +841,9 @@ def offer_next_job(node_ids: set[UUID]) -> dict | None:
"capability": job.capability,
"request_digest": job.request_digest,
"request": job.request,
# Node 的本机任务列表只消费合同生成的通用摘要,不解析各软件
# 私有 request 结构。旧 Node 会忽略这个新增字段。
"request_summary": _request_summary(job),
"workspace": (
{
"workspace_id": str(job.workspace_id),

View File

@ -8,13 +8,19 @@ from hashlib import sha256
from uuid import UUID, uuid4
import bcrypt
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from core.software_contracts import supported_capabilities
from core.storage.engine import session_scope
from core.storage.models import SoftwareNode, SoftwareNodeEnrollment, SoftwareWorkspace
from core.storage.models import (
SoftwareJob,
SoftwareNode,
SoftwareNodeEnrollment,
SoftwareWorkspace,
)
MAX_ENROLLMENT_FAILURES = 5
ACTIVE_JOB_STATUSES = {"offered", "dispatched", "running", "disconnected", "cancelling"}
class SoftwareNodeError(Exception):
@ -208,6 +214,16 @@ def list_nodes() -> list[dict]:
.scalars()
.all()
)
active_job_counts = dict(
session.execute(
select(SoftwareJob.node_id, func.count(SoftwareJob.job_id))
.where(
SoftwareJob.node_id.is_not(None),
SoftwareJob.status.in_(ACTIVE_JOB_STATUSES),
)
.group_by(SoftwareJob.node_id)
).all()
)
return [
{
"node_id": str(row.node_id),
@ -217,6 +233,7 @@ def list_nodes() -> list[dict]:
"node_version": row.node_version,
"os_version": row.os_version,
"runtime": row.runtime,
"active_job_count": int(active_job_counts.get(row.node_id, 0)),
"last_seen_at": row.last_seen_at.isoformat()
if row.last_seen_at
else None,

View File

@ -1,8 +1,9 @@
"""Per-user 工作目录配额(§7.5 #4 软配额,应用层 gate)。
调用入口:
- `scan_user_dir(user_root) -> (bytes, count)` os.walk 累加, dotfile / 损坏 stat
- `upsert_user_usage(user_id, bytes, count)` user_disk_usage
- `scan_user_dir(user_root) -> (bytes, count)` 累加计入用户配额的文件
- `scan_user_trash(user_root) -> (bytes, count)` 单独统计隐藏回收站物理占用
- `upsert_user_usage(...)` 同时落配额占用与回收站快照
- `check_disk_quota(user_id, limit_bytes) -> Optional[str]` 写前查, None=放行 /
str=拒绝原因`limit_bytes <= 0` 短路放行(不限)
- `scan_all_users(user_root_base, limit_bytes)` lifespan 后台 task 周期跑,
@ -64,15 +65,18 @@ def parse_bytes(value) -> Optional[int]:
_SKIP_TOPLEVEL = frozenset({
".zcbot_cache",
".zcbot_external_results",
".zcbot_artifact_trash",
".zcbot_tmp",
".memory",
})
_ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash"
def scan_user_dir(user_root: Path) -> Tuple[int, int]:
"""os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。
跳过顶层平台缓存/临时区与 .memory(均有独立生命周期,不算入产品配额);
跳过顶层平台缓存/临时区隐藏回收站与 .memory(均有独立生命周期,
不算入产品配额);
follow_symlinks=False symlink 循环爆
"""
if not user_root.exists() or not user_root.is_dir():
@ -102,6 +106,14 @@ def scan_user_dir(user_root: Path) -> Tuple[int, int]:
return total_bytes, total_count
def scan_user_trash(user_root: Path) -> Tuple[int, int]:
"""统计隐藏 artifact 回收站的物理占用,不计入用户配额。"""
trash = user_root / _ARTIFACT_TRASH_DIR
if not trash.is_dir():
return 0, 0
return _walk_dir(trash)
def _walk_dir(d: Path) -> Tuple[int, int]:
total_b, total_c = 0, 0
for root, dirs, files in os.walk(d, followlinks=False, onerror=lambda _e: None):
@ -115,19 +127,29 @@ def _walk_dir(d: Path) -> Tuple[int, int]:
return total_b, total_c
def upsert_user_usage(user_id: UUID, bytes_used: int, file_count: int) -> None:
"""落 user_disk_usage 单行;首次 INSERT,后续 UPDATE。"""
def upsert_user_usage(
user_id: UUID,
bytes_used: int,
file_count: int,
trash_bytes: int = 0,
trash_file_count: int = 0,
) -> None:
"""落 user_disk_usage 单行;首次 INSERT后续 UPDATE。"""
from sqlalchemy import func
with session_scope() as s:
stmt = pg_insert(UserDiskUsage).values(
user_id=user_id,
bytes_used=bytes_used,
file_count=file_count,
trash_bytes=trash_bytes,
trash_file_count=trash_file_count,
).on_conflict_do_update(
index_elements=["user_id"],
set_={
"bytes_used": bytes_used,
"file_count": file_count,
"trash_bytes": trash_bytes,
"trash_file_count": trash_file_count,
"scanned_at": func.now(),
},
)
@ -202,8 +224,10 @@ def scan_all_users(user_root_base: Path) -> int:
count = 0
for uid in list_user_ids_with_root(user_root_base):
try:
b, c = scan_user_dir(user_root_base / str(uid))
upsert_user_usage(uid, b, c)
user_root = user_root_base / str(uid)
b, c = scan_user_dir(user_root)
trash_b, trash_c = scan_user_trash(user_root)
upsert_user_usage(uid, b, c, trash_b, trash_c)
count += 1
except Exception:
# 单 user 扫挂不阻塞其他 user;下次周期重试。日志靠 caller 注入。

View File

@ -258,6 +258,48 @@ class WebPreview(Base):
)
class ProviderCredential(Base):
"""平台级可信 Provider 当前凭据与健康状态credentials 只保存字段密文。"""
__tablename__ = "provider_credentials"
provider_id: Mapped[str] = mapped_column(Text, primary_key=True)
credentials: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
credential_hint: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default="1"
)
test_status: Mapped[str] = mapped_column(
Text, nullable=False, default="untested", server_default="untested"
)
test_detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
balance_amount: Mapped[Optional[Decimal]] = mapped_column(
Numeric(18, 6), nullable=True
)
balance_currency: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_tested_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_notified_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_notified_status: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
updated_by: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.user_id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False,
)
class Artifact(Base):
"""Stable identity and lifecycle metadata for a published workspace file."""
@ -355,8 +397,9 @@ class UserDiskUsage(Base):
"""per-user 工作目录字节使用快照(0008,§7.5 #4 软配额表)。
每个 user_id 单行 upsert,lifespan 后台 task 周期( 15min)扫描 user_root 落库;
write gate(DockerExecutor / /v1/files/upload)查这表对比 yaml `quotas.disk_bytes_per_user`,
超额返 [Error] 硬阻
write gate(DockerExecutor / /v1/files/upload) bytes_used 对比 yaml
`quotas.disk_bytes_per_user`,超额返 [Error] 硬阻trash_* 只记录隐藏回收站
的物理占用不参与用户配额判断
扫描间隙写入会突破上限一点(race-tolerant, image/video 配额一致接受);外部用户
开放前 OS xfs prjquota 兜底真上限 DESIGN §7.5 #4 / PROGRESS。
@ -371,6 +414,8 @@ class UserDiskUsage(Base):
)
bytes_used: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
trash_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
trash_file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
scanned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@ -801,3 +846,27 @@ class ExternalSystemAudit(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class SandboxPackageScan(Base):
"""一个发现临时 Python 包的容器会话一行(空扫描不入库)。"""
__tablename__ = "sandbox_package_scans"
__table_args__ = (
Index("ix_sandbox_package_scans_finished_at", "finished_at"),
Index("ix_sandbox_package_scans_user_finished", "user_id", "finished_at"),
)
container_session_id: Mapped[str] = mapped_column(Text, primary_key=True)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
execution_kind: Mapped[str] = mapped_column(Text, nullable=False)
image_digest: Mapped[str] = mapped_column(Text, nullable=False)
python_version: Mapped[str] = mapped_column(Text, nullable=False)
packages: Mapped[list[Any]] = mapped_column(JSONB, nullable=False)
package_count: Mapped[int] = mapped_column(Integer, nullable=False)
total_installed_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -3,19 +3,20 @@
chat 类型的入口由 loop.py assistant message 入库后调用;媒体工具(image/video/audio)
tool execute 完后由 tool 直接调用对应入口(record_image_usage )
币种(0007):全表统一 CNY(`cost_cny` )chat 路径走 litellm USD cost_map 内部
×USD_TO_CNY 折算落库;媒体路径价格本身就是 CNY,直接落units jsonb snapshot 当时
的关键价格参数(chat 没有,media price_cny_per_image ),便于跨调价对账
币种(0007):全表统一 CNY(`cost_cny` )有版本化本地价格的直连模型以该配置为
事实源未配置的网关模型才走 LiteLLM USD cost map再退到扁平人民币价格
units jsonb 快照当时价格版本时段币种汇率和分项成本便于跨调价对账
"""
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Mapping, Optional
from uuid import UUID
from sqlalchemy import func, select, update
from ..pricing import calculate_chat_cost, pricing_snapshot, resolve_chat_price
from .engine import session_scope
from .models import Message, UsageEvent
@ -88,6 +89,8 @@ def record_chat_usage(
output_cny_per_mtoken: float = 0.0,
cache_hit_tokens: int = 0,
cache_hit_cny_per_mtoken: float = 0.0,
pricing: Optional[Mapping[str, Any]] = None,
occurred_at: Optional[datetime] = None,
extra_units: Optional[Mapping[str, Any]] = None,
response: Any = None,
kind: str = "chat",
@ -103,9 +106,32 @@ def record_chat_usage(
可挂,溯源靠 kind + extra_units(kb_ingest {"kb": 库名, "source": 原件名})
返回算出的 cost_cny(已落库),调用方可用作 SSE 显示
"""
# 直连 provider 的版本化本地价格是事实源,避免 LiteLLM 内置价目滞后覆盖已核对
# 的厂商价格。无本地适用价格的旧档案/网关模型才沿用 LiteLLM → 扁平价兜底。
when = occurred_at
if when is None:
created = getattr(response, "created", None)
try:
when = datetime.fromtimestamp(float(created), tz=timezone.utc) if created else None
except (TypeError, ValueError, OSError):
when = None
when = when or datetime.now(timezone.utc)
quote = resolve_chat_price(pricing, occurred_at=when)
price_units: dict[str, Any] = {}
if quote is not None:
breakdown = calculate_chat_cost(
quote,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cache_hit_tokens=cache_hit_tokens,
)
cost_cny = breakdown.total_cny
price_units = pricing_snapshot(quote, breakdown)
price_units["pricing_at"] = when.astimezone(timezone.utc).isoformat()
else:
cost_usd = _safe_chat_cost_usd(response)
cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001"))
if cost_cny == 0 and (input_cny_per_mtoken or output_cny_per_mtoken):
if quote is None and cost_cny == 0 and (input_cny_per_mtoken or output_cny_per_mtoken):
cost_cny = _fallback_chat_cost_cny(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
@ -114,19 +140,23 @@ def record_chat_usage(
cache_hit_tokens=cache_hit_tokens,
cache_hit_cny_per_mtoken=cache_hit_cny_per_mtoken,
)
units = {
units: dict[str, Any] = {
"tokens_in": int(prompt_tokens),
"tokens_out": int(completion_tokens),
# snapshot 折算系数,便于历史对账(汇率/价格涨跌后仍能还原当时折算逻辑)
"usd_to_cny": float(USD_TO_CNY),
"cache_hit_tokens": max(0, min(int(cache_hit_tokens), int(prompt_tokens))),
}
if quote is None:
# LiteLLM cost map 是 USD快照折算系数供旧路径历史对账。
units["usd_to_cny"] = float(USD_TO_CNY)
if input_cny_per_mtoken or output_cny_per_mtoken:
units.update({
"input_cny_per_mtoken": float(input_cny_per_mtoken or 0),
"output_cny_per_mtoken": float(output_cny_per_mtoken or 0),
"cache_hit_cny_per_mtoken": float(cache_hit_cny_per_mtoken or 0),
})
if extra_units:
units.update(extra_units)
units.update(price_units)
with session_scope() as s:
s.add(UsageEvent(

View File

@ -15,6 +15,7 @@ from sqlalchemy import select, update
from .agent_builder import ROOT, load_config
from .capabilities import ModelCapabilities
from .llm import LLM
from .llm_transport import extract_usage_details
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
@ -127,17 +128,24 @@ def generate_task_title(
)
applied = bool(getattr(result, "rowcount", 0))
if response is not None and caps is not None:
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=task_id,
user_id=user_id,
message_id=None,
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
extra_units={
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
kind="task_title",
)

View File

@ -269,17 +269,24 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
cancel_check=ctx.cancel_check, daily_limit=videos_per_day, **base,
)]
def _vision() -> list:
def _vision_kwargs() -> Optional[dict]:
# 看图 + 文档理解共用 vision variant(读图不分档,固定第一个)。
key, v = _pick_variant(ctx.ark_cfg.raw.get("vision") or {})
if v is None:
return []
kw = dict(
return None
return dict(
ark_cfg=ctx.ark_cfg, vision_variant_cfg=v, variant_key=key,
working_dir=ctx.working_dir_path, task_id=ctx.task_id, user_id=ctx.uid,
**base,
)
return [LookAtImageTool(**kw), ReadDocumentTool(**kw)]
def _look_at_image() -> list:
kw = _vision_kwargs()
return [LookAtImageTool(**kw)] if kw is not None else []
def _read_document() -> list:
kw = _vision_kwargs()
return [ReadDocumentTool(**kw)] if kw is not None else []
def _transcribe() -> list:
# 录音文件转写(讯飞 LFASR,与 IAT 听写是两个服务两套 key)。
@ -316,7 +323,13 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
("office_to_pdf", lambda: ctx.office_to_pdf_available, _office_to_pdf),
("image", lambda: ctx.img_cfg is not None, _image),
("video", lambda: ctx.ark_cfg is not None, _video),
("vision", lambda: ctx.ark_cfg is not None, _vision),
(
"look_at_image",
lambda: ctx.ark_cfg is not None
and not getattr(ctx.caps, "native_image_input", False),
_look_at_image,
),
("read_document", lambda: ctx.ark_cfg is not None, _read_document),
("transcribe_audio", lfasr_configured, _transcribe),
("web_search", lambda: BochaConfig.load() is not None, _web_search),
]

View File

@ -0,0 +1,31 @@
"""Track artifact trash separately from quota usage.
Revision ID: 0037
Revises: 0036
Create Date: 2026-08-28
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0037"
down_revision: str | None = "0036"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"user_disk_usage",
sa.Column("trash_bytes", sa.BigInteger(), nullable=False, server_default="0"),
)
op.add_column(
"user_disk_usage",
sa.Column("trash_file_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("user_disk_usage", "trash_file_count")
op.drop_column("user_disk_usage", "trash_bytes")

View File

@ -0,0 +1,37 @@
"""Add sandbox package scan sessions.
Revision ID: 0038
Revises: 0037
Create Date: 2026-09-01
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0038"
down_revision: str | None = "0037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"sandbox_package_scans",
sa.Column("container_session_id", sa.Text(), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False),
sa.Column("execution_kind", sa.Text(), nullable=False),
sa.Column("image_digest", sa.Text(), nullable=False),
sa.Column("python_version", sa.Text(), nullable=False),
sa.Column("packages", postgresql.JSONB(), nullable=False),
sa.Column("package_count", sa.Integer(), nullable=False),
sa.Column("total_installed_bytes", sa.BigInteger(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sandbox_package_scans_finished_at", "sandbox_package_scans", ["finished_at"])
op.create_index("ix_sandbox_package_scans_user_finished", "sandbox_package_scans", ["user_id", "finished_at"])
def downgrade() -> None:
op.drop_table("sandbox_package_scans")

View File

@ -0,0 +1,46 @@
"""Add encrypted provider credentials control plane.
Revision ID: 0039
Revises: 0038
Create Date: 2026-09-02
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0039"
down_revision: str | None = "0038"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"provider_credentials",
sa.Column("provider_id", sa.Text(), primary_key=True),
sa.Column("credentials", postgresql.JSONB(), nullable=False),
sa.Column("credential_hint", postgresql.JSONB(), nullable=False),
sa.Column("revision", sa.Integer(), server_default="1", nullable=False),
sa.Column("test_status", sa.Text(), server_default="untested", nullable=False),
sa.Column("test_detail", sa.Text(), nullable=True),
sa.Column("balance_amount", sa.Numeric(18, 6), nullable=True),
sa.Column("balance_currency", sa.Text(), nullable=True),
sa.Column("last_tested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_notified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_notified_status", sa.Text(), nullable=True),
sa.Column(
"updated_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint("revision > 0", name="ck_provider_credentials_revision_positive"),
)
def downgrade() -> None:
op.drop_table("provider_credentials")

View File

@ -64,6 +64,9 @@ RUN grep -v '\[host-only\]' /tmp/requirements.txt > /tmp/requirements.sandbox.tx
-r /tmp/requirements.sandbox.txt \
&& rm /tmp/requirements.txt /tmp/requirements.sandbox.txt
# 构建期基础包清单;运行时扫描只与它比较,不 import/执行用户包。
RUN mkdir -p /sandbox && python -c "import importlib.metadata as m,json; c=lambda s:s.lower().replace('_','-').replace('.','-'); print(json.dumps({c(d.metadata['Name']):d.version for d in m.distributions() if d.metadata.get('Name')},sort_keys=True))" > /sandbox/base-python-packages.json
# 持久化 pip 源到 /etc/pip.conf ── 让运行时模型用 `pip install foo` 也走 mirror,
# 不只 build 时。zcbot user / root 都吃这个 global 配置。
RUN printf '[global]\nindex-url = %s\ntimeout = 60\n%s\n' \
@ -176,6 +179,7 @@ COPY core/__init__.py core/file_store.py core/kb_lock.py /sandbox/core/
COPY core/sandbox/tool_runner.py /sandbox/tool_runner.py
COPY deploy/sandbox/init.sh /init.sh
COPY deploy/sandbox/package_scan.py /sandbox/package_scan.py
RUN chmod +x /init.sh
# 默认 cwd /workspace ── 但每次 `docker exec --workdir /workspace/<wd>` 会覆盖

View File

@ -0,0 +1,84 @@
"""可信临时包扫描器;只解析 dist-info 元数据,不 import 用户代码。"""
from __future__ import annotations
import csv
import email.parser
import json
from pathlib import Path
BASE = Path("/sandbox/base-python-packages.json")
ROOT = Path("/tmp/.local/lib")
def safe_file(root: Path, path: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
current = root
for part in path.relative_to(root).parts:
current = current / part
if current.is_symlink():
return False
return path.is_file()
def safe_dir(root: Path, path: Path) -> bool:
try:
rel = path.relative_to(root)
except ValueError:
return False
current = root
for part in rel.parts:
current = current / part
if current.is_symlink():
return False
return path.is_dir()
def canonical(name: str) -> str:
return name.lower().replace("_", "-").replace(".", "-")
def scan_packages(root: Path, base: dict) -> list[dict]:
out = []
if root.is_dir():
for site in sorted(root.glob("python*/site-packages")):
if not safe_dir(root, site):
continue
for dist in sorted(site.glob("*.dist-info")):
if len(out) >= 5000:
return out
meta_path = dist / "METADATA"
if not safe_file(site, meta_path) or meta_path.stat().st_size > 1024 * 1024:
continue
msg = email.parser.Parser().parsestr(meta_path.read_text(encoding="utf-8", errors="replace"), headersonly=True)
name, version = msg.get("Name", "").strip(), msg.get("Version", "").strip()
if not name:
continue
total = 0
record = dist / "RECORD"
if safe_file(site, record) and record.stat().st_size <= 32 * 1024 * 1024:
for index, row in enumerate(csv.reader(record.read_text(encoding="utf-8", errors="replace").splitlines())):
if index >= 200_000:
break
if not row:
continue
candidate = site / row[0]
if safe_file(site, candidate):
try: total += candidate.stat().st_size
except OSError: pass
key = canonical(name)
old = base.get(key)
change = "added" if old is None else ("reinstall" if old == version else "override")
out.append({"name": name, "version": version, "base_version": old,
"change": change, "direct_requested": safe_file(site, dist / "REQUESTED") or safe_file(site, dist / "direct_url.json"),
"installed_bytes": total})
return out
def main() -> None:
base = json.loads(BASE.read_text(encoding="utf-8"))
print(json.dumps(scan_packages(ROOT, base), ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -39,7 +39,8 @@ _LIST_FIELDS = (
def _api_key() -> str:
key = os.environ.get("DOCUMENT_SEARCH_API_KEY", "").strip()
from core.provider_credentials.runtime import resolve_secret
key = resolve_secret("document_search")
if not key:
raise RuntimeError(
"DOCUMENT_SEARCH_API_KEY env 未设置 —— 配置后再使用 literature skill 的内部材料库来源"
@ -54,6 +55,19 @@ def _auth_headers(extra: Optional[dict] = None) -> dict:
return h
def _check_response(response: httpx.Response) -> None:
if response.status_code >= 400:
try:
from core.provider_credentials.service import record_business_failure
record_business_failure(
"document_search", status_code=response.status_code,
detail=response.text[:300],
)
except Exception:
pass
response.raise_for_status()
def _safe_name(name: str) -> str:
# 防目录穿越;保留扩展名
return name.replace("/", "_").replace("\\", "_").replace("..", "_")
@ -66,7 +80,7 @@ def list_kb() -> list[dict]:
/ create_time / file_count只返回 ID 映射里有效的(分类 1-7)
"""
r = httpx.get(f"{_API}/list_knowledge_bases", headers=_auth_headers(), timeout=_TIMEOUT)
r.raise_for_status()
_check_response(r)
payload = r.json()
data = payload.get("data") or {}
return list(data.get("knowledge_bases") or [])
@ -104,7 +118,7 @@ def search(
json=body,
timeout=_TIMEOUT,
)
r.raise_for_status()
_check_response(r)
payload = r.json()
data = payload.get("data") or {}
docs = data.get("documents") or []
@ -144,7 +158,7 @@ def download(
params=params,
timeout=_DOWNLOAD_TIMEOUT,
) as resp:
resp.raise_for_status()
_check_response(resp)
with open(dest, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
f.write(chunk)

View File

@ -30,7 +30,8 @@ _DEFAULT_SUMMARY_FIELDS = [
def _mp_key() -> str:
key = os.environ.get("MP_API_KEY", "").strip()
from core.provider_credentials.runtime import resolve_secret
key = resolve_secret("materials_project")
if not key:
raise RuntimeError("MP_API_KEY env 未设置,无法查询 Materials Project")
return key
@ -61,6 +62,17 @@ def _mpr():
return MPRester(_mp_key())
def _report_provider_error(error: Exception) -> None:
try:
from core.provider_credentials.service import record_business_failure
status = int(getattr(error, "status_code", 0) or 0)
record_business_failure(
"materials_project", status_code=status, detail=str(error)
)
except Exception:
pass
class MaterialsProjectSearchTool(Tool):
name = "materials_project_search"
description = (
@ -128,6 +140,7 @@ class MaterialsProjectSearchTool(Tool):
num_chunks=1, chunk_size=limit, **kwargs
)
except Exception as e:
_report_provider_error(e)
detail = safe_error_text(e, ("MP_API_KEY",))
return f"[Error] materials_project_search failed: {type(e).__name__}: {detail}"
plain = [_to_plain(d) for d in list(docs)[:limit]]
@ -152,6 +165,7 @@ class MaterialsProjectSearchTool(Tool):
try:
session = _mpr()
except Exception as e:
_report_provider_error(e)
detail = safe_error_text(e, ("MP_API_KEY",))
return f"[Error] materials_project_search batch failed: {type(e).__name__}: {detail}"
agg: list[dict[str, Any]] = []
@ -169,6 +183,7 @@ class MaterialsProjectSearchTool(Tool):
n_ok += 1
agg.append({"formula": f, "n": len(results), "results": results})
except Exception as e:
_report_provider_error(e)
detail = safe_error_text(e, ("MP_API_KEY",))
agg.append({"formula": f, "error": f"{type(e).__name__}: {detail}"})
return self._render_batch(agg, fields, n_ok)
@ -251,6 +266,7 @@ class MaterialsProjectGetStructureTool(Tool):
dest.parent.mkdir(parents=True, exist_ok=True)
struct.to(filename=str(dest))
except Exception as e:
_report_provider_error(e)
detail = safe_error_text(e, ("MP_API_KEY",))
return f"[Error] materials_project_get_structure failed: {type(e).__name__}: {detail}"
return f"saved: {self._display(dest)}"

View File

@ -47,7 +47,8 @@ _LIST_FIELDS = (
def _config() -> tuple[str, str, str]:
base_url = os.environ.get("PAPER_SERVER_URL", _DEFAULT_BASE_URL).strip().rstrip("/")
api_key = os.environ.get("PAPER_SERVER_API_KEY", "").strip()
from core.provider_credentials.runtime import resolve_secret
api_key = resolve_secret("paper_server")
if not api_key:
raise RuntimeError("PAPER_SERVER_API_KEY env 未设置,无法查询 paper_server")
return base_url, f"{base_url}/api/resm/paper", api_key
@ -70,6 +71,14 @@ def _raise_response_error(response: httpx.Response) -> None:
except Exception:
pass
if response.status_code in (401, 403) or err_code in _AUTH_ERR_CODES:
try:
from core.provider_credentials.service import record_business_failure
record_business_failure(
"paper_server", status_code=response.status_code or 401,
detail=err_code,
)
except Exception:
pass
raise RuntimeError(
f"paper_server auth failed (HTTP {response.status_code}, "
f"{err_code or 'no err_code'}):请管理员检查平台 PAPER_SERVER_API_KEY"

View File

@ -54,7 +54,8 @@ class PlatformSourceProvider(Protocol):
def _env_set(name: str) -> bool:
return bool(os.environ.get(name, "").strip())
from core.provider_credentials.runtime import resolve_env_secret
return bool(resolve_env_secret(name))
def _require_valid_http_url(name: str, default: str) -> None:

225
scripts/reprice_usage.py Normal file
View File

@ -0,0 +1,225 @@
"""按版本化模型价格重算历史 usage_events默认只输出 dry-run 汇总。
示例
.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z
.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z \
--apply --confirm APPLY_DEEPSEEK_REPRICE
脚本不加载 .env调用方必须显式提供 ZCBOT_DB_URL生产写入前先核对 dry-run 输出和
打印的脱敏目标缺少 cache_hit_tokens 的旧辅助调用按全部未命中估算并留标记
"""
from __future__ import annotations
import argparse
import os
from collections import defaultdict
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import sys
from typing import Any
from sqlalchemy import select
from sqlalchemy.engine import make_url
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from core.capabilities import ModelCapabilities # noqa: E402
from core.pricing import calculate_chat_cost, pricing_snapshot, resolve_chat_price # noqa: E402
from core.storage import session_scope # noqa: E402
from core.storage.models import UsageEvent # noqa: E402
DEFAULT_PROFILES = ("deepseek_v4.flash", "deepseek_v4.pro")
COST_KINDS = {"chat", "prompt_optimize", "context_fold", "task_title", "kb_ingest"}
CONFIRM_TEXT = "APPLY_DEEPSEEK_REPRICE"
PRICE_SNAPSHOT_KEYS = {
"pricing_revision",
"pricing_source",
"pricing_source_url",
"pricing_currency",
"pricing_at",
"price_tier",
"fx_to_cny",
"input_price_per_mtoken",
"cache_hit_price_per_mtoken",
"output_price_per_mtoken",
"input_cost_cny",
"cache_hit_cost_cny",
"output_cost_cny",
}
def _parse_datetime(value: str) -> datetime:
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="重算历史模型成本(默认 dry-run")
p.add_argument("--from", dest="from_at", required=True, type=_parse_datetime)
p.add_argument("--profile", action="append", dest="profiles")
p.add_argument("--apply", action="store_true", help="事务内写回;默认只预览")
p.add_argument("--rollback", action="store_true", help="恢复本 revision 保存的旧成本")
p.add_argument("--confirm", default="", help=f"写入确认串:{CONFIRM_TEXT}")
return p.parse_args()
def _target() -> str:
raw = os.environ.get("ZCBOT_DB_URL", "").strip()
if not raw:
raise RuntimeError("ZCBOT_DB_URL is not set")
return make_url(raw).render_as_string(hide_password=True)
def main() -> int:
args = _args()
if args.apply and args.rollback:
raise SystemExit("[ERR] --apply 与 --rollback 不能同时使用")
if (args.apply or args.rollback) and args.confirm != CONFIRM_TEXT:
raise SystemExit(f"[ERR] 写入必须传 --confirm {CONFIRM_TEXT}")
profiles = tuple(args.profiles or DEFAULT_PROFILES)
caps_by_profile = {
profile: ModelCapabilities.load(profile, ROOT / "config" / "models")
for profile in profiles
}
revisions = {
period.get("revision")
for caps in caps_by_profile.values()
for period in caps.pricing.get("periods", [])
if period.get("revision")
}
target = _target()
mode = "ROLLBACK" if args.rollback else ("APPLY" if args.apply else "DRY-RUN")
print(f"[INFO] mode={mode} target={target} from={args.from_at.isoformat()}")
print(f"[INFO] profiles={','.join(profiles)} revisions={','.join(sorted(revisions))}")
summary: dict[tuple[str, str], dict[str, Any]] = defaultdict(
lambda: {
"rows": 0,
"estimated": 0,
"old": Decimal("0"),
"new": Decimal("0"),
"estimated_old": Decimal("0"),
"estimated_new": Decimal("0"),
}
)
estimated_by_kind: dict[tuple[str, str], dict[str, Any]] = defaultdict(
lambda: {"rows": 0, "old": Decimal("0"), "new": Decimal("0")}
)
changed = 0
with session_scope() as s:
events = s.execute(
select(UsageEvent).where(
UsageEvent.model_profile.in_(profiles),
UsageEvent.kind.in_(COST_KINDS),
UsageEvent.created_at >= args.from_at,
).order_by(UsageEvent.created_at, UsageEvent.event_id)
).scalars().all()
for event in events:
units = dict(event.units or {})
caps = caps_by_profile[event.model_profile]
if args.rollback:
if units.get("repricing_revision") not in revisions:
continue
previous = units.get("previous_cost_cny")
if previous is None:
continue
old = Decimal(str(event.cost_cny or 0))
new = Decimal(str(previous))
previous_pricing = units.get("previous_pricing_units") or {}
for price_key in PRICE_SNAPSHOT_KEYS:
units.pop(price_key, None)
if isinstance(previous_pricing, dict):
units.update(previous_pricing)
for key in list(units):
if (
key.startswith("repricing_")
or key in {"previous_cost_cny", "previous_pricing_units"}
):
units.pop(key, None)
key = (event.model_profile, "rollback")
else:
quote = resolve_chat_price(caps.pricing, occurred_at=event.created_at)
if quote is None or units.get("repricing_revision") == quote.revision:
continue
tokens_in = int(units.get("tokens_in") or 0)
tokens_out = int(units.get("tokens_out") or 0)
# 旧主循环一直提取 DeepSeek cache usage但 record_chat_usage 的
# extra_units 会省略值为 0 的字段,所以 chat 缺 key 可判定为零命中;
# 旧辅助调用没有提取缓存明细,缺 key 才是真正的不确定。
has_cache = "cache_hit_tokens" in units or event.kind == "chat"
cache_hit = int(units.get("cache_hit_tokens") or 0)
breakdown = calculate_chat_cost(
quote,
prompt_tokens=tokens_in,
completion_tokens=tokens_out,
cache_hit_tokens=cache_hit,
)
old = Decimal(str(event.cost_cny or 0))
new = breakdown.total_cny
previous_pricing = {
key: units[key] for key in PRICE_SNAPSHOT_KEYS if key in units
}
units.update(pricing_snapshot(quote, breakdown))
units.update({
"repricing_revision": quote.revision,
"previous_cost_cny": float(old),
"repriced_at": datetime.now(timezone.utc).isoformat(),
"repricing_time_basis": "usage_event.created_at",
"repricing_estimated": not has_cache,
"previous_pricing_units": previous_pricing,
})
key = (event.model_profile, quote.tier)
if not has_cache:
summary[key]["estimated"] += 1
summary[key]["estimated_old"] += old
summary[key]["estimated_new"] += new
uncertain = estimated_by_kind[(event.model_profile, event.kind)]
uncertain["rows"] += 1
uncertain["old"] += old
uncertain["new"] += new
bucket = summary[key]
bucket["rows"] += 1
bucket["old"] += old
bucket["new"] += new
changed += 1
if args.apply or args.rollback:
event.cost_cny = new.quantize(Decimal("0.000001"))
event.units = units
if not (args.apply or args.rollback):
s.rollback()
total_old = Decimal("0")
total_new = Decimal("0")
for (profile, tier), item in sorted(summary.items()):
total_old += item["old"]
total_new += item["new"]
print(
f"[INFO] {profile} tier={tier} rows={item['rows']} "
f"estimated={item['estimated']} old={item['old']:.6f} "
f"new={item['new']:.6f} delta={(item['new'] - item['old']):.6f} "
f"estimated_delta={(item['estimated_new'] - item['estimated_old']):.6f}"
)
for (profile, kind), item in sorted(estimated_by_kind.items()):
print(
f"[WARN] estimated profile={profile} kind={kind} rows={item['rows']} "
f"old={item['old']:.6f} new={item['new']:.6f} "
f"delta={(item['new'] - item['old']):.6f}"
)
print(
f"[OK] rows={changed} old={total_old:.6f} new={total_new:.6f} "
f"delta={(total_new - total_old):.6f} mode={mode}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -67,6 +67,15 @@ test("composer uses one Codex-style action button for stop and queued send", ()
assert.match(chat, /removeQueuedMessage\([\s\S]*streamSse\(r\.events_url, run\)/);
});
test("heavy execution queue exposes the blocking reason", () => {
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
assert.match(chat, /t === "execution_queue"/);
assert.match(chat, /等待执行容量(当前用户/);
assert.match(chat, /等待执行容量(整机/);
assert.match(chat, /等待执行容量(宿主内存压力)/);
assert.match(chat, /dataset\.runningLabel/);
});
test("automatic title polling starts before a long-running response finishes", () => {
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
const sendMessage = chat.indexOf("async function sendMessage(");

View File

@ -5,6 +5,7 @@ import test from "node:test";
const filesJs = fs.readFileSync("web/static/js/files.js", "utf8");
const layoutJs = fs.readFileSync("web/static/js/layout.js", "utf8");
const jobsJs = fs.readFileSync("web/static/js/software_jobs.js", "utf8");
const previewJs = fs.readFileSync("web/static/js/preview.js", "utf8");
const pageHtml = fs.readFileSync("web/static/dev.html", "utf8");
test("file menu exposes an explicit confirmed replacement action", () => {
@ -48,6 +49,22 @@ test("software jobs share the embed-safe right workspace", () => {
assert.match(jobsJs, /作业列表加载失败,请点击刷新重试/);
});
test("current task software jobs render in a dedicated result rail", () => {
assert.match(pageHtml, /id="software-result-rail"/);
assert.match(pageHtml, /id="mobile-software-results"/);
assert.match(jobsJs, /task_id: taskJobsTaskId/);
assert.match(jobsJs, /renderTaskResultRail/);
assert.match(jobsJs, /task-selection-changed/);
assert.match(jobsJs, /openSoftwarePreviewGallery/);
});
test("software preview gallery uses authenticated fetch and hides artifact download", () => {
assert.match(previewJs, /export async function openSoftwarePreviewGallery/);
assert.match(previewJs, /"Authorization": "Bearer " \+ state\.token/);
assert.match(previewJs, /\$\("fp-download"\)\.hidden = true/);
assert.match(pageHtml, /software-preview-gallery/);
});
test("mobile navigation distinguishes list, files, and software jobs", () => {
assert.match(pageHtml, /id="mv-tab-left"[^>]*>\s*列表/);
assert.match(pageHtml, /id="mv-tab-right"[^>]*data-right-tab="files"/);

View File

@ -78,6 +78,11 @@ test("renders completed mermaid blocks with strict local configuration", async (
assert.equal(calls.initialize.length, 1);
assert.equal(calls.initialize[0].securityLevel, "strict");
assert.equal(calls.initialize[0].startOnLoad, false);
assert.equal(calls.initialize[0].theme, "base");
assert.equal(calls.initialize[0].themeVariables.primaryColor, "#eaf2ff");
assert.equal(calls.initialize[0].themeVariables.secondaryColor, "#e8f7f3");
assert.equal(calls.initialize[0].themeVariables.tertiaryColor, "#fff4df");
assert.equal(calls.initialize[0].themeVariables.pie5, "#d96c75");
assert.equal(calls.render[0].source, "flowchart LR\nA --> B");
assert.equal(fixture.pre.replacement.className, "mermaid-diagram");
assert.equal(fixture.pre.replacement.children[0].innerHTML, "<svg viewBox='0 0 10 10'></svg>");

View File

@ -0,0 +1,48 @@
"""Admin 存储快照的配额、回收站与物理合计口径。"""
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from web.admin import _storage_page
class AdminStorageTests(unittest.TestCase):
def test_storage_page_exposes_trash_and_physical_total(self) -> None:
uid = uuid4()
scanned = datetime(2026, 8, 28, tzinfo=timezone.utc)
totals_result = MagicMock()
totals_result.one.return_value = (1, 100, 40, 2, 1)
rows_result = MagicMock()
rows_result.all.return_value = [
(uid, "user@example.com", "测试用户", "tester", 100, 2, 40, 1, scanned)
]
session = MagicMock()
session.execute.side_effect = [totals_result, rows_result]
with patch("core.agent_builder.load_config", return_value={"quotas": {}}):
page = _storage_page(session, 0, 20)
row = page["rows"][0]
self.assertEqual(row["bytes_used"], 100)
self.assertEqual(row["trash_bytes"], 40)
self.assertEqual(row["trash_file_count"], 1)
self.assertEqual(row["physical_bytes"], 140)
self.assertEqual(page["total"], 1)
self.assertEqual(page["totals"], {
"bytes_used": 100,
"trash_bytes": 40,
"physical_bytes": 140,
"file_count": 2,
"trash_file_count": 1,
})
if __name__ == "__main__":
unittest.main()

View File

@ -3,7 +3,11 @@ import unittest
from pathlib import Path
from core.artifacts import ArtifactPathError
from core.attachments import content_for_model, normalize_attachment_refs
from core.attachments import (
content_for_model,
materialize_native_images,
normalize_attachment_refs,
)
class AttachmentRefTests(unittest.TestCase):
@ -47,3 +51,27 @@ class AttachmentRefTests(unittest.TestCase):
content_for_model("", refs, "实验分析"),
"[用户上传的参考图] 实验分析/figures/a.png",
)
def test_materializes_native_image_without_persisting_internal_fields(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
wd = root / "任务"
wd.mkdir()
(wd / "a.png").write_bytes(b"png")
messages = [{
"role": "user",
"content": "请分析\n\n[用户上传的参考图] 任务/a.png",
"_attachment_refs": [{"path": "a.png", "kind": "image"}],
}]
prepared, count = materialize_native_images(
messages, enabled=True, working_dir=wd, user_root=root,
)
self.assertEqual(count, 1)
self.assertNotIn("_attachment_refs", prepared[0])
self.assertEqual(prepared[0]["content"][0]["type"], "text")
self.assertEqual(prepared[0]["content"][1]["type"], "image_url")
self.assertTrue(
prepared[0]["content"][1]["image_url"]["url"].startswith(
"data:image/png;base64,"
)
)

View File

@ -138,6 +138,10 @@ class BlenderContractTests(unittest.TestCase):
tuple(f"preview_{index:02d}" for index in range(1, 9)),
)
self.assertEqual(contract.required_adapter_version(normalized), "1.0.0")
self.assertEqual(
contract.summarize(normalized)["title"],
"水泥实验室设备布置",
)
self.assertEqual(
set(contract.expected_outputs(normalized)),
{

View File

@ -58,7 +58,7 @@ class ContextCompactionTests(unittest.TestCase):
prepared, stats = prepare_messages_with_stats(
messages,
reasoning_replay="tool_calls",
reasoning_replay="tool_turn",
compact_threshold_chars=10_000,
)
@ -78,7 +78,7 @@ class ContextCompactionTests(unittest.TestCase):
}, {"role": "tool", "tool_call_id": "tc1", "content": "ok"}]
prepared = prepare_messages_for_llm(
messages, reasoning_replay="tool_calls",
messages, reasoning_replay="tool_turn",
)
self.assertEqual(prepared[0]["reasoning_content"], "nested reasoning")
@ -91,10 +91,54 @@ class ContextCompactionTests(unittest.TestCase):
"provider_specific_fields": {"signature": "opaque"},
}
prepared = prepare_messages_for_llm([message], reasoning_replay="all")
prepared = prepare_messages_for_llm(
[message], reasoning_replay="conversation"
)
self.assertEqual(prepared[0], message)
def test_conversation_policy_strips_reasoning_from_other_model(self) -> None:
messages = [{
"role": "assistant",
"content": "answer",
"reasoning_content": "deepseek reasoning",
"_model_profile": "deepseek_v4.flash",
}]
prepared = prepare_messages_for_llm(
messages,
reasoning_replay="conversation",
target_model_profile="glm.flash53",
)
self.assertNotIn("reasoning_content", prepared[0])
self.assertNotIn("_model_profile", prepared[0])
def test_tool_turn_keeps_only_reasoning_after_latest_user(self) -> None:
call = lambda cid, reasoning: {
"role": "assistant", "content": None,
"reasoning_content": reasoning,
"_model_profile": "deepseek_v4.flash",
"tool_calls": [{
"id": cid, "type": "function",
"function": {"name": "shell", "arguments": "{}"},
}],
}
messages = [
{"role": "user", "content": "old"},
call("old", "old reasoning"),
{"role": "tool", "tool_call_id": "old", "content": "old result"},
{"role": "assistant", "content": "done"},
{"role": "user", "content": "new"},
call("new", "new reasoning"),
{"role": "tool", "tool_call_id": "new", "content": "new result"},
]
prepared = prepare_messages_for_llm(
messages,
reasoning_replay="tool_turn",
target_model_profile="deepseek_v4.flash",
)
self.assertNotIn("reasoning_content", prepared[1])
self.assertEqual(prepared[5]["reasoning_content"], "new reasoning")
def test_preserves_system_and_recent_messages(self) -> None:
messages = [
{"role": "system", "content": "rules"},

View File

@ -146,7 +146,7 @@ class _FakeLLM:
_FAKE_CAPS = SimpleNamespace(
reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符
family="deepseek_v4", variant="flash",
reasoning_replay="tool_calls",
reasoning_replay="tool_turn",
input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0,
)
@ -227,7 +227,7 @@ class MaybeFoldTests(unittest.TestCase):
self.assertEqual(sess.messages, before) # 内存零污染
self.assertTrue(any(e.get("type") == "warn" for e in events))
def test_fold_input_strips_final_reasoning_but_keeps_tool_reasoning(self) -> None:
def test_fold_input_strips_reasoning_from_completed_turns(self) -> None:
sess = self._session()
sess.messages[2]["reasoning_content"] = "final reasoning"
sess.messages[4] = {
@ -254,7 +254,7 @@ class MaybeFoldTests(unittest.TestCase):
final_msg = next(m for m in sent if m.get("content", "").startswith("a000"))
tool_msg = next(m for m in sent if m.get("tool_calls"))
self.assertNotIn("reasoning_content", final_msg)
self.assertEqual(tool_msg["reasoning_content"], "tool reasoning")
self.assertNotIn("reasoning_content", tool_msg)
if __name__ == "__main__":

View File

@ -12,7 +12,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.storage.disk_quota import parse_bytes, scan_user_dir
from core.storage.disk_quota import parse_bytes, scan_user_dir, scan_user_trash
class TestParseBytes(unittest.TestCase):
@ -75,6 +75,7 @@ class TestScanUserDir(unittest.TestCase):
for hidden in (
".zcbot_cache",
".zcbot_external_results",
".zcbot_artifact_trash",
".zcbot_tmp",
".memory",
):
@ -84,6 +85,17 @@ class TestScanUserDir(unittest.TestCase):
self.assertEqual(b, 7)
self.assertEqual(c, 1)
def test_trash_is_excluded_from_quota_and_counted_separately(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "active.bin").write_bytes(b"active")
trash = root / ".zcbot_artifact_trash" / "2026" / "08"
trash.mkdir(parents=True)
(trash / "deleted.bin").write_bytes(b"deleted")
self.assertEqual(scan_user_dir(root), (6, 1))
self.assertEqual(scan_user_trash(root), (7, 1))
if __name__ == "__main__":
unittest.main()

View File

@ -18,6 +18,7 @@ import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
@ -35,6 +36,22 @@ class FakePool:
def __init__(self):
self.ensure_calls = []
self.mark_active_calls = []
self.active = 0
self.capacity = self
self.capacity_notices = []
@contextmanager
def foreground(self, user_id, cancel_check=None, wait_notify=None):
if wait_notify is not None:
for notice in self.capacity_notices:
wait_notify(notice)
yield True
def exec_started(self, user_id):
self.active += 1
def exec_finished(self, user_id):
self.active -= 1
def ensure(self, user_id):
name = f"zcbot-sandbox-{user_id}"
@ -161,6 +178,27 @@ class TestShellExec(unittest.TestCase):
self.assertEqual(pool.ensure_calls, [executor.user_id])
self.assertEqual(pool.mark_active_calls, [executor.user_id])
def test_shell_forwards_capacity_queue_events(self):
executor, pool, _ = make_executor()
pool.capacity_notices = [
{"state": "waiting", "reason": "per_user_limit"},
{"state": "admitted"},
]
events = []
ctx = make_ctx(executor)
ctx.event_emit = events.append
proc = MagicMock()
proc.communicate.return_value = ("ok\n", "")
proc.returncode = 0
with patch("core.executor_docker.subprocess.Popen", return_value=proc):
executor.call_tool("shell", {"command": "echo ok"}, ctx)
self.assertEqual(events, [
{"type": "execution_queue", "state": "waiting", "reason": "per_user_limit"},
{"type": "execution_queue", "state": "admitted"},
])
def test_shell_bad_args(self):
executor, _, _ = make_executor()
ctx = make_ctx(executor)

View File

@ -75,6 +75,22 @@ class LLMKwargsTests(unittest.TestCase):
kwargs["extra_body"], {"thinking": {"type": "enabled"}}
)
def test_glm_preserved_thinking_is_nested_in_thinking_body(self) -> None:
caps = ModelCapabilities(
family="glm", variant="flash53", model_id="zai/glm-5.3-flash",
api_key_env="TEST_LLM_API_KEY", thinking_enabled=True,
thinking_transport="extra_body", thinking_clear=False,
default_reasoning_effort="high",
)
with patch.dict(os.environ, {"TEST_LLM_API_KEY": "test-key"}):
kwargs = LLM(caps)._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, "high"
)
self.assertEqual(kwargs["extra_body"], {
"thinking": {"type": "enabled", "clear_thinking": False},
"reasoning_effort": "high",
})
def test_invalid_transport_is_rejected_before_request(self) -> None:
llm = self._llm(
family="test", thinking_enabled=True, thinking_transport="unknown"
@ -94,10 +110,13 @@ class LLMKwargsTests(unittest.TestCase):
self.assertEqual(caps.reasoning_effort_levels, ["low", "high", "max"])
self.assertEqual(caps.default_reasoning_effort, "high")
self.assertEqual(caps.max_output, 8192)
self.assertEqual(caps.output_cny_per_mtoken, 2.0)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02)
self.assertEqual(caps.output_cny_per_mtoken, 4.752)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.0504)
self.assertEqual(
caps.pricing["periods"][0]["revision"], "deepseek-v4-20260816"
)
self.assertEqual(caps.thinking_transport, "extra_body")
self.assertEqual(caps.reasoning_replay, "tool_calls")
self.assertEqual(caps.reasoning_replay, "tool_turn")
def test_other_controllable_profiles_declare_transport(self) -> None:
models_dir = Path(__file__).resolve().parents[1] / "config" / "models"
@ -105,8 +124,12 @@ class LLMKwargsTests(unittest.TestCase):
glm = ModelCapabilities.load("glm.pro52", models_dir)
doubao = ModelCapabilities.load("doubao.turbo", models_dir)
self.assertFalse(glm.thinking_enabled)
self.assertEqual(glm.profile, "glm.flash53")
self.assertTrue(glm.thinking_enabled)
self.assertEqual(glm.thinking_transport, "extra_body")
self.assertFalse(glm.thinking_clear)
self.assertEqual(glm.reasoning_replay, "conversation")
self.assertTrue(glm.native_image_input)
self.assertTrue(doubao.thinking_enabled)
self.assertEqual(doubao.thinking_transport, "extra_body")
self.assertEqual(doubao.default_reasoning_effort, "")

View File

@ -1,9 +1,12 @@
"""look_at_image 的问题收敛与兼容兜底测试(不碰网络和数据库)。"""
from __future__ import annotations
import tempfile
import unittest
import uuid
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from core.ark_client import ArkConfig
@ -27,6 +30,69 @@ class LookAtImageQuestionTests(unittest.TestCase):
user_root=Path("."),
)
def test_native_vision_model_omits_look_tool_but_keeps_document_ocr(self):
from core.tool_registry import ToolContext, build_tools
ark = ArkConfig(
api_key="test",
base_url="https://example.invalid",
raw={"vision": {"test": {"model_id": "vision-test"}}},
)
def build(native_image_input: bool) -> dict:
with tempfile.TemporaryDirectory() as tmp, ExitStack() as stack:
root = Path(tmp)
stack.enter_context(mock.patch(
"core.tool_registry._external_system_status_available",
return_value=False,
))
stack.enter_context(mock.patch(
"core.tool_registry._external_systems_available",
return_value=False,
))
stack.enter_context(mock.patch(
"core.tool_registry.smtp_configured", return_value=False,
))
stack.enter_context(mock.patch(
"core.tool_registry.wechat_push_available", return_value=False,
))
stack.enter_context(mock.patch(
"core.tool_registry.lfasr_configured", return_value=False,
))
stack.enter_context(mock.patch(
"core.tool_registry.BochaConfig.load", return_value=None,
))
return build_tools(ToolContext(
tool_base=root,
ur_path=root,
working_dir_path=root,
task_id=uuid.uuid4(),
uid=uuid.uuid4(),
cfg={},
caps=SimpleNamespace(
enable_run_python=False,
native_image_input=native_image_input,
),
skills=SimpleNamespace(skills={}),
cancel_check=None,
scheduled_run=True,
deferred_actions=SimpleNamespace(),
ark_cfg=ark,
img_provider="",
img_key="",
img_cfg=None,
img_provider_cfg=None,
video_variant="",
office_to_pdf_available=False,
))
text_only = build(False)
native = build(True)
self.assertIn("look_at_image", text_only)
self.assertIn("read_document", text_only)
self.assertNotIn("look_at_image", native)
self.assertIn("read_document", native)
def _execute_and_question(self, question=None) -> str:
captured = {}

View File

@ -35,6 +35,7 @@ def _make_loop(stream_results, nonstream_results):
loop.caps = SimpleNamespace(reliable_context=64_000, family="test", variant="t")
loop.session = SimpleNamespace(messages=[], task_id="test-task")
loop.user_id = "test-user" # 无 DB 环境:_log_malformed_args 的落库路径应静默跳过
loop.user_root = None
# salvage(0.58.24)接在畸形重试前:空 schemas → 未知工具无白名单 → _try_salvage 返 False,
# 这些用例本就测「salvage 救不了 → 落非流式重试」,给个空 executor 即可走到该分支。
loop.executor = SimpleNamespace(schemas=lambda: [])
@ -56,8 +57,8 @@ def _make_loop(stream_results, nonstream_results):
class TestMalformedRetry(unittest.TestCase):
def test_deepseek_write_name_aborts_before_buffered_reasoning_is_emitted(self):
"""真实 collect 路径见到 write 名即关流,之前的 reasoning 不重复上屏"""
def test_deepseek_write_name_resets_streamed_reasoning_before_reroute(self):
"""真实 collect 路径实时发 reasoning见到 write 后重置再改走非流式"""
reasoning = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content="thinking", content=None, tool_calls=None,
@ -86,7 +87,44 @@ class TestMalformedRetry(unittest.TestCase):
with self.assertRaises(PreferNonstreamToolCall):
loop._collect_stream_once([])
self.assertEqual(loop.events, [])
self.assertEqual(loop.events, [
{"type": "reasoning", "delta": "thinking"},
{"type": "reasoning_reset"},
])
def test_deepseek_reasoning_is_emitted_before_output_route_is_known(self):
"""普通 DeepSeek 回答不能等首个正文 token 才释放已经到达的推理。"""
reasoning = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content="thinking", content=None, tool_calls=None,
))]
)
text = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content=None, content="answer", tool_calls=None,
))]
)
loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(
family="deepseek_v4", default_reasoning_effort=None,
)
loop.executor = SimpleNamespace(schemas=lambda: [])
loop.cancel_check = None
loop.events = []
loop._emit = loop.events.append
loop.llm = SimpleNamespace(
chat_stream=lambda **_kwargs: iter([reasoning, text]),
)
with unittest.mock.patch(
"core.loop.litellm.stream_chunk_builder", return_value=object()
):
loop._collect_stream_once([])
self.assertEqual(loop.events, [
{"type": "reasoning", "delta": "thinking"},
{"type": "text", "delta": "answer"},
])
def test_preferred_tool_reroutes_before_arguments_stream(self):
"""首包识别 write/edit 后直接非流式,不把它算作一次畸形失败。"""

98
tests/test_pricing.py Normal file
View File

@ -0,0 +1,98 @@
from contextlib import contextmanager
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from uuid import uuid4
from core.capabilities import ModelCapabilities
from core.pricing import calculate_chat_cost, resolve_chat_price
from core.storage.usage import record_chat_usage
MODELS_DIR = Path(__file__).resolve().parents[1] / "config" / "models"
class PricingTests(unittest.TestCase):
def test_deepseek_flash_selects_peak_and_off_peak(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
# 2026-08-17 是周一01:00 UTC 为峰时起点04:00 已回到谷时。
peak = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 1, 0, tzinfo=timezone.utc),
)
off_peak = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 4, 0, tzinfo=timezone.utc),
)
self.assertIsNotNone(peak)
self.assertIsNotNone(off_peak)
assert peak is not None and off_peak is not None
self.assertEqual(peak.tier, "peak")
self.assertEqual(peak.input_per_mtoken, Decimal("0.44"))
self.assertEqual(off_peak.tier, "default")
self.assertEqual(off_peak.output_per_mtoken, Decimal("0.66"))
def test_deepseek_weekend_is_off_peak(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.pro", MODELS_DIR)
quote = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 22, 2, 0, tzinfo=timezone.utc),
)
assert quote is not None
self.assertEqual(quote.tier, "default")
def test_usd_quote_calculates_cache_split_in_cny(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
quote = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 1, 0, tzinfo=timezone.utc),
)
assert quote is not None
result = calculate_chat_cost(
quote,
prompt_tokens=1_000_000,
completion_tokens=500_000,
cache_hit_tokens=800_000,
)
# miss:0.2*0.44*7.2 + hit:0.8*0.014*7.2 + out:0.5*1.32*7.2
self.assertEqual(result.total_cny, Decimal("5.466240"))
self.assertEqual(result.cache_miss_tokens, 200_000)
def test_local_catalog_precedes_litellm_cost_map(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
added = []
class FakeSession:
def add(self, row):
added.append(row)
@contextmanager
def fake_scope():
yield FakeSession()
with (
patch("core.storage.usage.session_scope", fake_scope),
patch("core.storage.usage._safe_chat_cost_usd", return_value=Decimal("999")),
):
cost = record_chat_usage(
task_id=None,
user_id=uuid4(),
message_id=None,
model_profile="deepseek_v4.flash",
prompt_tokens=1_000_000,
completion_tokens=0,
pricing=caps.pricing,
occurred_at=datetime(2026, 8, 17, 4, 0, tzinfo=timezone.utc),
response=SimpleNamespace(created=None),
)
self.assertEqual(cost, Decimal("1.584000"))
self.assertEqual(added[0].units["pricing_revision"], "deepseek-v4-20260816")
self.assertEqual(added[0].units["price_tier"], "default")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,274 @@
import json
import os
import unittest
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from importlib import util
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
import httpx
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.provider_credentials.registry import BY_ID
from core.provider_credentials.runtime import resolve_credentials
from core.provider_credentials.service import (
ProviderCredentialError,
RevisionConflict,
_hint,
_notification_transition,
_row_payload,
replace_credentials,
)
from core.provider_credentials.testing import (
TestResult,
classify_response,
test_provider,
)
def response(status, body):
content = json.dumps(body).encode() if isinstance(body, dict) else str(body).encode()
return httpx.Response(status, content=content)
class ProviderRegistryTests(unittest.TestCase):
def test_credential_groups_are_explicit(self):
self.assertEqual(
[f.name for f in BY_ID["xfyun_iat"].fields],
["appid", "api_key", "api_secret"],
)
self.assertEqual(
[f.name for f in BY_ID["xfyun_lfasr"].fields],
["appid", "secret_key"],
)
self.assertNotIn("ZCBOT_DB_URL", [f.env for p in BY_ID.values() for f in p.fields])
def test_hint_only_reveals_last_four(self):
self.assertEqual(_hint("sk-abcdefgh"), "***efgh")
self.assertNotIn("abcdef", _hint("sk-abcdefgh"))
class ProviderClassificationTests(unittest.TestCase):
def test_http_402_and_429_are_not_conflated(self):
self.assertEqual(classify_response(402, "").status, "exhausted")
self.assertEqual(classify_response(429, "rate limit").status, "unreachable")
def test_deepseek_30_yuan_boundary(self):
def request_at(amount):
return lambda *a, **k: response(200, {
"is_available": True,
"balance_infos": [{"currency": "CNY", "total_balance": str(amount)}],
})
creds = {"api_key": "candidate-secret"}
self.assertEqual(
test_provider("deepseek", creds, request=request_at("29.99")).status,
"low_balance",
)
self.assertEqual(
test_provider("deepseek", creds, request=request_at("30.00")).status,
"normal",
)
def test_candidate_secret_is_not_returned_in_detail(self):
secret = "candidate-super-secret"
result = test_provider(
"deepseek", {"api_key": secret},
request=lambda *a, **k: response(401, {"message": secret}),
)
self.assertEqual(result.status, "auth_error")
self.assertNotIn(secret, result.detail)
class ProviderResolutionTests(unittest.TestCase):
def test_database_precedes_environment(self):
row = SimpleNamespace(credentials={"api_key": "cipher"})
class Result:
def scalar_one_or_none(self):
return row
class Session:
def execute(self, _statement):
return Result()
@contextmanager
def scope():
yield Session()
with (
patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-key"}),
patch("core.storage.session_scope", scope),
patch("core.provider_credentials.runtime.decrypt_secret", return_value="db-key") as decrypt,
):
resolved = resolve_credentials("deepseek")
self.assertEqual((resolved.source, resolved.values["api_key"]), ("database", "db-key"))
decrypt.assert_called_once_with("cipher", aad="provider:deepseek:api_key")
def test_environment_fallback_without_database(self):
with (
patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-key"}),
patch("core.storage.session_scope", side_effect=RuntimeError("no db")),
):
resolved = resolve_credentials("deepseek")
self.assertEqual((resolved.source, resolved.values), ("env", {"api_key": "env-key"}))
def test_each_xfyun_group_resolves_its_declared_fields(self):
env = {
"XFYUN_APPID": "app", "XFYUN_API_KEY": "iat-key",
"XFYUN_API_SECRET": "iat-secret", "XFYUN_LFASR_SECRET_KEY": "lf-key",
}
with patch.dict(os.environ, env), patch("core.storage.session_scope", side_effect=RuntimeError("no db")):
self.assertEqual(set(resolve_credentials("xfyun_iat").values), {"appid", "api_key", "api_secret"})
self.assertEqual(set(resolve_credentials("xfyun_lfasr").values), {"appid", "secret_key"})
class LLMCredentialLifecycleTests(unittest.TestCase):
def _caps(self):
return ModelCapabilities(
family="deepseek_v4", model_id="deepseek/model",
api_key_env="DEEPSEEK_API_KEY", thinking_transport="none",
)
def test_created_llm_uses_new_key_on_next_request(self):
state = {"key": "old-key"}
llm = LLM(self._caps(), credential_resolver=lambda _env: state["key"])
first = llm._build_kwargs([], None, None, None)
state["key"] = "new-key"
second = llm._build_kwargs([], None, None, None)
self.assertEqual(first["api_key"], "old-key")
self.assertEqual(second["api_key"], "new-key")
def test_built_stream_request_keeps_key_snapshot(self):
state = {"key": "old-key"}
calls = []
llm = LLM(self._caps(), credential_resolver=lambda _env: state["key"])
def completion(**kwargs):
calls.append(kwargs["api_key"])
state["key"] = "new-key"
return iter([{"chunk": 1}, {"chunk": 2}])
with patch("core.llm.litellm.completion", side_effect=completion):
self.assertEqual(len(list(llm.chat_stream([]))), 2)
self.assertEqual(calls, ["old-key"])
class ProviderMutationTests(unittest.TestCase):
def test_missing_master_key_refuses_database_save(self):
with (
patch("core.provider_credentials.service.crypto_configured", return_value=False),
self.assertRaisesRegex(ProviderCredentialError, "MASTER_KEY"),
):
replace_credentials(
"deepseek", {"api_key": "candidate"}, expected_revision=0,
updated_by=uuid4(),
)
def test_failed_candidate_never_opens_database_transaction(self):
with (
patch("core.provider_credentials.service.crypto_configured", return_value=True),
patch("core.provider_credentials.service.test_provider",
return_value=TestResult("auth_error", "认证失败")),
patch("core.provider_credentials.service.session_scope") as scope,
self.assertRaises(ProviderCredentialError),
):
replace_credentials(
"deepseek", {"api_key": "bad-key"}, expected_revision=0,
updated_by=uuid4(),
)
scope.assert_not_called()
def test_revision_conflict_does_not_overwrite(self):
class Session:
def execute(self, _statement):
return SimpleNamespace(rowcount=0)
@contextmanager
def scope():
yield Session()
with (
patch("core.provider_credentials.service.crypto_configured", return_value=True),
patch("core.provider_credentials.service.test_provider",
return_value=TestResult("normal", "正常")),
patch("core.provider_credentials.service.encrypt_secret", return_value="cipher"),
patch("core.provider_credentials.service.session_scope", scope),
self.assertRaises(RevisionConflict),
):
replace_credentials(
"deepseek", {"api_key": "new-key"}, expected_revision=7,
updated_by=uuid4(),
)
def test_alert_cooldown_and_recovery(self):
now = datetime.now(timezone.utc)
low = TestResult("low_balance", "余额低")
self.assertEqual(_notification_transition("normal", None, low, now), "low_balance")
self.assertIsNone(_notification_transition("low_balance", now, low, now))
self.assertEqual(
_notification_transition("low_balance", now - timedelta(days=1), low, now),
"low_balance",
)
self.assertEqual(
_notification_transition("auth_error", now, TestResult("normal", "正常"), now),
"recovered",
)
def test_admin_payload_contains_no_ciphertext(self):
provider = BY_ID["deepseek"]
row = SimpleNamespace(
credentials={"api_key": "ciphertext-secret"},
credential_hint={"api_key": "***1234"}, revision=2,
test_status="normal", test_detail="正常", last_tested_at=None,
balance_amount=None, balance_currency=None,
)
with patch("core.provider_credentials.service.resolve_credentials") as resolve:
resolve.return_value = SimpleNamespace(source="database", values={"api_key": "plain-secret"})
payload = _row_payload(provider, row)
encoded = json.dumps(payload, ensure_ascii=False)
self.assertNotIn("ciphertext-secret", encoded)
self.assertNotIn("plain-secret", encoded)
self.assertIn("***1234", encoded)
class ProviderLeaderTests(unittest.TestCase):
def test_unclaimed_round_does_not_test(self):
connection = Mock()
connection.exec_driver_sql.return_value.scalar.return_value = False
engine = MagicMock()
engine.connect.return_value.__enter__.return_value = connection
with (
patch("core.provider_credentials.monitor.get_engine", return_value=engine),
patch("core.provider_credentials.monitor.due_provider_ids") as due,
):
from core.provider_credentials.monitor import run_due_checks
self.assertEqual(run_due_checks(), 0)
due.assert_not_called()
class ProviderMigrationTests(unittest.TestCase):
def test_0039_creates_single_encrypted_credentials_table(self):
path = Path(__file__).resolve().parents[1] / "db" / "migrations" / "versions" / "20260902_1000_0039_provider_credentials.py"
spec = util.spec_from_file_location("migration_0039_test", path)
module = util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
captured = {}
def create_table(name, *items):
captured["name"] = name
captured["columns"] = {item.name for item in items if hasattr(item, "name")}
with patch.object(module.op, "create_table", side_effect=create_table):
module.upgrade()
self.assertEqual(captured["name"], "provider_credentials")
self.assertTrue({"provider_id", "credentials", "credential_hint", "revision"} <= captured["columns"])
self.assertNotIn("api_key", captured["columns"])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,213 @@
from __future__ import annotations
import json
import os
import tempfile
import threading
import time
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
from core.sandbox.capacity import ExecCapacity
from core.sandbox.pool import SandboxPool
from core.sandbox.package_scans import persist_scan
from deploy.sandbox.package_scan import scan_packages
from core import procs
from web.admin import _sandbox_package_stats
class CapacityTests(unittest.TestCase):
def test_env_overrides_capacity_config(self):
with tempfile.TemporaryDirectory() as td, patch.dict(os.environ, {
"ZCBOT_MAX_ACTIVE_EXECS": "5", "ZCBOT_MAX_BACKGROUND_EXECS": "3",
"ZCBOT_MAX_ACTIVE_EXECS_PER_USER": "1", "ZCBOT_MIN_MEM_AVAILABLE": "2g",
}, clear=False):
cap = ExecCapacity(Path(td), {"max_active_execs": 9, "max_background_execs": 8,
"max_active_execs_per_user": 7, "min_mem_available": "4g"})
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user, cap.min_mem_available),
(5, 3, 1, 2 * 1024**3))
def test_config_cannot_raise_hard_limits(self):
with tempfile.TemporaryDirectory() as td:
cap = ExecCapacity(Path(td), {"max_active_execs": 99, "max_background_execs": 99,
"max_active_execs_per_user": 99})
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user), (6, 4, 3))
def test_global_per_user_and_background_limits(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
cap = ExecCapacity(Path(td), {"max_active_execs": 3, "max_background_execs": 1, "max_active_execs_per_user": 2})
a = cap.try_acquire("u1", "background", lease_id="a")
b = cap.try_acquire("u1", "foreground", lease_id="b")
self.assertIsNotNone(a); self.assertIsNotNone(b)
self.assertIsNone(cap.try_acquire("u1", "foreground", lease_id="c"))
self.assertIsNone(cap.try_acquire("u2", "background", lease_id="d"))
self.assertIsNotNone(cap.try_acquire("u2", "foreground", lease_id="e"))
self.assertIsNone(cap.try_acquire("u3", "foreground", lease_id="f"))
def test_cross_instance_state_and_cancel_queue(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
one = ExecCapacity(Path(td), {"max_active_execs": 1})
two = ExecCapacity(Path(td), {"max_active_execs": 1})
lease = one.try_acquire("u1", "foreground", lease_id="held")
cancelled = threading.Event()
result = []
t = threading.Thread(target=lambda: result.append(two.acquire_foreground("u2", cancelled.is_set)))
t.start(); time.sleep(.15); cancelled.set(); t.join(2)
self.assertEqual(result, [None])
self.assertEqual(two.snapshot()["foreground_queued"], 0)
one.release(lease)
def test_foreground_queue_reports_limit_and_admission(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
cap = ExecCapacity(Path(td), {"max_active_execs": 2, "max_active_execs_per_user": 1})
held = cap.try_acquire("u1", "background", lease_id="held")
notices = []
result = []
t = threading.Thread(
target=lambda: result.append(cap.acquire_foreground("u1", wait_notify=notices.append))
)
t.start(); time.sleep(.15)
self.assertEqual(notices, [{
"state": "waiting", "reason": "per_user_limit",
"user_running": 1, "user_limit": 1,
"global_running": 1, "global_limit": 2,
}])
cap.release(held); t.join(2)
self.assertEqual(len(result), 1)
self.assertIsNotNone(result[0])
self.assertEqual(notices[-1], {"state": "admitted"})
cap.release(result[0])
def test_immediate_foreground_admission_does_not_report_queue(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
cap = ExecCapacity(Path(td), {"max_active_execs": 1})
notices = []
lease = cap.acquire_foreground("u1", wait_notify=notices.append)
self.assertIsNotNone(lease)
self.assertEqual(notices, [])
cap.release(lease)
def test_memory_pressure_pauses_new_admission(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=100):
cap = ExecCapacity(Path(td), {"min_mem_available": "1g"})
self.assertIsNone(cap.try_acquire("u", "foreground"))
self.assertTrue(cap.snapshot()["memory_paused"])
class PoolTests(unittest.TestCase):
def test_defaults_and_docker_tmpfs(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.pool.subprocess.run") as run:
run.return_value = MagicMock(returncode=0, stdout="")
pool = SandboxPool(Path(td) / "users")
self.assertEqual((pool.memory, pool.cpus, pool.idle_ttl, pool.tmp_size), ("4g", "2.0", 600, "1g"))
pool._docker_run(uuid4(), "box")
argv = run.call_args_list[-1].args[0]
self.assertIn("/tmp:exec,size=1g,mode=1777", argv)
def test_active_exec_not_reaped(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.pool._container_running", return_value=True), patch("core.sandbox.pool.subprocess.run") as run:
pool = SandboxPool(Path(td) / "users", idle_ttl=1)
uid = uuid4(); pool._last_active[uid] = int(time.time()) - 10
pool.exec_started(uid)
self.assertEqual(pool.reap_idle(), [])
run.assert_not_called()
def test_scan_failure_does_not_block_idle_removal(self):
with tempfile.TemporaryDirectory() as td, \
patch("core.sandbox.package_scans.scan_and_persist", side_effect=RuntimeError("db down")), \
patch("core.sandbox.pool.subprocess.run", return_value=MagicMock(returncode=0)):
pool = SandboxPool(Path(td) / "users", idle_ttl=1)
uid = uuid4(); pool._last_active[uid] = int(time.time()) - 10
self.assertEqual(pool.reap_idle(), [f"zcbot-sandbox-{uid}"])
def test_finalizer_helper_swallows_scanner_error(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.package_scans.scan_and_persist", side_effect=RuntimeError("db down")):
pool = SandboxPool(Path(td) / "users")
pool._scan_before_remove("box", uuid4(), "foreground")
class PackageScannerTests(unittest.TestCase):
def test_empty_scan_does_not_touch_database(self):
persist_scan(container_session_id="empty", user_id=uuid4(), execution_kind="foreground",
result={"packages": [], "image_digest": "sha256:x", "python_version": "3.12", "total_installed_bytes": 0})
def test_added_override_bytes_and_symlink_escape(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td) / "lib"; site = root / "python3.12" / "site-packages"; site.mkdir(parents=True)
pkg = site / "demo.py"; pkg.write_text("abc", encoding="utf-8")
dist = site / "Demo-2.dist-info"; dist.mkdir()
(dist / "METADATA").write_text("Name: Demo\nVersion: 2\n", encoding="utf-8")
(dist / "REQUESTED").write_text("", encoding="utf-8")
(dist / "RECORD").write_text("demo.py,,3\n", encoding="utf-8")
rows = scan_packages(root, {"demo": "1"})
self.assertEqual(rows[0]["change"], "override")
self.assertEqual(rows[0]["installed_bytes"], 3)
self.assertTrue(rows[0]["direct_requested"])
self.assertEqual(scan_packages(root, {"demo": "2"})[0]["change"], "reinstall")
self.assertEqual(scan_packages(root, {})[0]["change"], "added")
def test_metadata_symlink_is_ignored(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td) / "lib"; site = root / "python3.12" / "site-packages"; site.mkdir(parents=True)
outside = Path(td) / "outside"; outside.write_text("Name: Bad\nVersion: 1\n")
dist = site / "Bad.dist-info"; dist.mkdir()
try:
(dist / "METADATA").symlink_to(outside)
except OSError:
self.skipTest("symlink unavailable")
self.assertEqual(scan_packages(root, {}), [])
class ProcQueueTests(unittest.TestCase):
def test_queued_status_and_cancel(self):
with tempfile.TemporaryDirectory() as td:
d = Path(td) / "p"; d.mkdir()
meta = {"proc_id": "p1", "backend": "docker", "state": "queued"}
procs.write_meta(d, meta)
self.assertEqual(procs.status_of(meta, d)[0], "queued")
self.assertEqual(procs.kill_proc(meta, d), "已取消排队")
self.assertEqual(procs.status_of(procs.read_meta(d), d), ("finished", 137))
def test_start_queued_respects_capacity_then_runs(self):
with tempfile.TemporaryDirectory() as td:
d = Path(td) / "p"; d.mkdir(); (d / "runner.sh").write_text("true")
uid = uuid4()
meta = {"proc_id": "p1", "task_id": "t1", "backend": "docker", "state": "queued",
"user_id": str(uid), "cwd": "/workspace/demo"}
procs.write_meta(d, meta)
pool = MagicMock(); pool.capacity.try_acquire.return_value = None
self.assertFalse(procs.start_queued_docker(meta, d, pool))
pool.capacity.try_acquire.return_value = "bg-p1"
pool.run_proc_container.return_value = "zcbot-proc-p1"
with patch("core.procs.subprocess.run", return_value=MagicMock(returncode=0, stderr="")):
self.assertTrue(procs.start_queued_docker(meta, d, pool))
saved = procs.read_meta(d)
self.assertEqual(saved["state"], "running")
self.assertEqual(saved["capacity_lease_id"], "bg-p1")
class AdminAggregationTests(unittest.TestCase):
def test_package_sessions_are_aggregated_by_name_version(self):
scans = [
SimpleNamespace(container_session_id="s1", user_id=uuid4(), execution_kind="foreground",
packages=[{"name":"Demo","version":"1","direct_requested":True,"installed_bytes":100,"change":"added","base_version":None}],
finished_at=datetime.now(timezone.utc)),
SimpleNamespace(container_session_id="s2", user_id=uuid4(), execution_kind="background",
packages=[{"name":"Demo","version":"1","direct_requested":False,"installed_bytes":300,"change":"added","base_version":None}],
finished_at=datetime.now(timezone.utc)),
]
session = MagicMock(); session.execute.return_value.scalars.return_value.all.return_value = scans
row = _sandbox_package_stats(session, None)["rows"][0]
self.assertEqual(row["session_count"], 2)
self.assertEqual(row["user_count"], 2)
self.assertEqual(row["foreground_sessions"], 1)
self.assertEqual(row["background_sessions"], 1)
self.assertEqual(row["direct_sessions"], 1)
self.assertEqual(row["average_installed_bytes"], 200)
if __name__ == "__main__":
unittest.main()

View File

@ -59,7 +59,7 @@ def _job(action: str, *, status: str = "succeeded"):
class SoftwareFollowupTests(unittest.TestCase):
def test_report_claim_persists_fixed_assistant_message_with_artifacts(self):
def test_report_claim_completes_without_persisting_chat_message(self):
job = _job("report")
task = SimpleNamespace(task_id=job.task_id, run_status="idle")
session = _Session([job.task_id, task, job])
@ -68,20 +68,12 @@ class SoftwareFollowupTests(unittest.TestCase):
def scope():
yield session
with (
patch("web.software_followups.session_scope", scope),
patch("web.software_followups.allocate_message_idx", return_value=7),
):
with patch("web.software_followups.session_scope", scope):
claim = claim_followup(job.job_id)
self.assertEqual(claim.action, "report")
self.assertEqual(job.followup_status, "completed")
self.assertEqual(len(session.added), 1)
message = session.added[0]
self.assertEqual(message.payload["role"], "assistant")
self.assertIn(str(job.job_id), message.payload["content"])
self.assertEqual(len(message.artifact_refs), 1)
self.assertEqual(message.artifact_refs[0]["label"], "figure.png")
self.assertEqual(session.added, [])
def test_analyze_claim_persists_internal_turn_and_locks_task(self):
job = _job("analyze")
@ -120,7 +112,7 @@ class SoftwareFollowupTests(unittest.TestCase):
self.assertEqual(job.followup_status, "pending")
self.assertFalse(session.added)
def test_failed_job_persists_fixed_assistant_message_without_artifacts(self):
def test_failed_job_completes_without_persisting_chat_message(self):
job = _job("analyze", status="failed")
job.error = {"code": "COLUMN_MISSING", "detail": " Y2\n does not exist "}
task = SimpleNamespace(task_id=job.task_id, run_status="idle")
@ -130,21 +122,14 @@ class SoftwareFollowupTests(unittest.TestCase):
def scope():
yield session
with (
patch("web.software_followups.session_scope", scope),
patch("web.software_followups.allocate_message_idx", return_value=9),
):
with patch("web.software_followups.session_scope", scope):
claim = claim_followup(job.job_id)
self.assertEqual(claim.action, "report")
self.assertEqual(job.followup_status, "completed")
message = session.added[0]
self.assertEqual(message.payload["role"], "assistant")
self.assertIn("专业软件任务执行失败", message.payload["content"])
self.assertIn("Y2 does not exist", message.payload["content"])
self.assertEqual(message.artifact_refs, [])
self.assertEqual(session.added, [])
def test_cancelled_job_persists_fixed_assistant_message(self):
def test_cancelled_job_completes_without_persisting_chat_message(self):
job = _job("analyze", status="cancelled")
task = SimpleNamespace(task_id=job.task_id, run_status="idle")
session = _Session([job.task_id, task, job])
@ -153,19 +138,17 @@ class SoftwareFollowupTests(unittest.TestCase):
def scope():
yield session
with (
patch("web.software_followups.session_scope", scope),
patch("web.software_followups.allocate_message_idx", return_value=10),
):
with patch("web.software_followups.session_scope", scope):
claim = claim_followup(job.job_id)
self.assertEqual(claim.action, "report")
self.assertIn("专业软件任务已取消", session.added[0].payload["content"])
self.assertEqual(job.followup_status, "completed")
self.assertEqual(session.added, [])
def test_busy_task_leaves_failed_notification_pending(self):
def test_busy_task_does_not_delay_non_chat_result(self):
job = _job("report", status="failed")
task = SimpleNamespace(task_id=job.task_id, run_status="running")
session = _Session([job.task_id, task])
session = _Session([job.task_id, task, job])
@contextmanager
def scope():
@ -174,8 +157,8 @@ class SoftwareFollowupTests(unittest.TestCase):
with patch("web.software_followups.session_scope", scope):
claim = claim_followup(job.job_id)
self.assertIsNone(claim)
self.assertEqual(job.followup_status, "pending")
self.assertEqual(claim.action, "report")
self.assertEqual(job.followup_status, "completed")
self.assertFalse(session.added)

View File

@ -34,6 +34,7 @@ from core.software_nodes import (
_verify_secret,
create_enrollment,
delete_node,
list_nodes,
update_node_runtime,
)
from web.routers.software_nodes import NodeConnectionManager, _bearer
@ -122,6 +123,30 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
)
self.assertIsNotNone(node.last_seen_at)
@patch("core.software_nodes.session_scope")
def test_admin_node_list_includes_active_job_count(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value
node_id = uuid4()
node = type("Node", (), {
"node_id": node_id,
"name": "绘图节点",
"status": "online",
"capabilities": ["origin.plot@v2"],
"node_version": "0.2.0",
"os_version": "Windows",
"runtime": {},
"last_seen_at": datetime.now(timezone.utc),
})()
node_result = MagicMock()
node_result.scalars.return_value.all.return_value = [node]
count_result = MagicMock()
count_result.all.return_value = [(node_id, 1)]
session.execute.side_effect = [node_result, count_result]
result = list_nodes()
self.assertEqual(result[0]["active_job_count"], 1)
class SoftwareNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_new_connection_replaces_old_without_removing_new(self) -> None:
@ -799,6 +824,11 @@ class SoftwareJobProtocolTests(unittest.TestCase):
self.assertEqual(offer["node_id"], node.node_id)
self.assertEqual(offer["payload"]["job_id"], str(jobs[1].job_id))
self.assertEqual(offer["payload"]["request"]["operation"]["plot"]["type"], "line")
self.assertEqual(
offer["payload"]["request_summary"]["display_name"],
"Origin 科研绘图",
)
self.assertIn("title", offer["payload"]["request_summary"])
def test_input_download_rechecks_file_digest(self) -> None:
source = (

View File

@ -33,6 +33,8 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('id="right-tab-jobs"', html)
self.assertIn('id="right-jobs-panel"', html)
self.assertIn('id="software-job-badge"', html)
self.assertIn(".workspace-badge[hidden]", html)
self.assertIn(".sj-entry-dot.succeeded, .sj-entry-dot.failed", html)
self.assertIn(".right-workspace-panel {", html)
self.assertLess(
html.index('id="right-tab-jobs"'),
@ -50,6 +52,7 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("续接自 Job ${sourceJobId.slice(0, 8)}", source)
self.assertIn("<dt>来源 Job</dt>", source)
self.assertIn("refreshCurrentTaskAfterSoftwareJob(job.task_id)", source)
self.assertNotIn('else if (active.length) entryDot.classList.add("active")', source)
self.assertIn("job.execution_runtime", source)
self.assertIn("执行版本未记录", source)
self.assertIn("执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}", source)
@ -69,7 +72,7 @@ class StaticVendorTests(unittest.TestCase):
admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")
self.assertIn('id="node-enrollment-modal" class="modal"', html)
self.assertIn("生成 Windows Node 注册码", html)
self.assertIn("生成专业软件节点注册码", html)
self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn("节点能力由客户端自动发现并上报", html)
@ -87,10 +90,65 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn("最近心跳", admin_js)
self.assertIn("活动任务", admin_js)
self.assertIn("node.active_job_count", admin_js)
self.assertIn("重新启用", admin_js)
self.assertIn("永久删除", admin_js)
self.assertNotIn("localStorage.setItem", admin_js)
def test_admin_consolidates_overview_into_eight_summary_cards(self) -> None:
html = ADMIN_HTML.read_text(encoding="utf-8")
admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")
self.assertIn('id="ops-summary" class="ops-summary"', admin_js)
self.assertIn(".ops-summary {", html)
labels = (
"任务运行",
"执行容量",
"专业软件节点",
"工具健康",
"活跃用户",
"Token 与缓存",
"成本",
"存储",
)
positions = []
for label in labels:
self.assertIn(f'label: "{label}"', admin_js)
positions.append(admin_js.index(f'label: "{label}"'))
self.assertEqual(positions, sorted(positions))
self.assertNotIn("function renderRuntime", admin_js)
self.assertNotIn("function renderTasks", admin_js)
self.assertNotIn("function renderUsersAndUsage", admin_js)
self.assertIn('["s-overview", "总览"]', admin_js)
self.assertIn('["s-usage", "用量趋势"]', admin_js)
self.assertIn('unit: softwareNodesLoaded ? "在线 / 总数"', admin_js)
self.assertIn('value: fmtTokens(tokens7d), unit: "近 7 天"', admin_js)
self.assertIn("storageTotals.physical_bytes", admin_js)
self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html)
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
self.assertIn('target: "s-windows-node"', admin_js)
self.assertIn('target: "s-toolfail"', admin_js)
self.assertNotIn('"s-sandbox-capacity"', admin_js)
self.assertNotIn("function renderSandboxCapacity", admin_js)
self.assertIn('["s-sandbox-packages", "容器依赖"]', admin_js)
self.assertIn('sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity")', admin_js)
self.assertNotIn("containerStatusHTML(capacity)", admin_js)
self.assertIn('data-summary-action="${escapeHtml(action)}"', admin_js)
self.assertIn("function renderCapacityDrawer()", admin_js)
self.assertIn("function openCapacityDrawer()", admin_js)
self.assertIn('id="capacity-drawer" class="capacity-drawer"', html)
self.assertIn(".capacity-drawer-panel {", html)
def test_changelog_loads_incremental_pages(self) -> None:
source = (JS_DIR / "changelog.js").read_text(encoding="utf-8")
self.assertIn("const PAGE_SIZE = 5", source)
self.assertIn("offset=${_nextOffset}", source)
self.assertIn("d.has_more", source)
self.assertIn("加载更多", source)
self.assertNotIn("/v1/changelog?limit=50", source)
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
@ -140,6 +198,17 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn(":focus-visible", css)
self.assertIn("@media (max-width: 640px)", css)
def test_admin_entry_lives_in_main_conversation_panel(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
header = html.split("<header>", 1)[1].split("</header>", 1)[0]
conversation_head = html.split('id="pane-mid"', 1)[1].split(
'id="chat-meta"', 1
)[0]
self.assertNotIn('id="hd-admin"', header)
self.assertIn('id="hd-admin"', conversation_head)
self.assertIn('href="/static/admin.html"', conversation_head)
def test_file_toolbar_keeps_hidden_toggle_and_groups_add_actions(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
files_js = (JS_DIR / "files.js").read_text(encoding="utf-8")
@ -191,6 +260,14 @@ class StaticVendorTests(unittest.TestCase):
self.assertNotIn('<div class="role">科研助手</div>', frontend)
self.assertIn(".tool-call.activity > summary", css)
self.assertIn("#task-progress-dock .task-progress", css)
self.assertIn(".conversation-rail-caption", html)
self.assertIn('id="conversation-side-rail"', html)
self.assertLess(html.index('id="msg-outline-rail"'), html.index('id="software-result-rail"'))
self.assertIn(".rail-count.attention", html)
self.assertIn("toggleConversationRailPinned", frontend)
self.assertIn("syncConversationSideRail", frontend)
self.assertIn("点击固定展开或收起导航", frontend)
self.assertIn("个新结果", frontend)
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source()
@ -211,6 +288,13 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("await yieldToStreamPaint()", chat_js)
self.assertIn('document.visibilityState === "hidden"', chat_js)
def test_sse_consumer_resets_abandoned_reasoning_stream(self) -> None:
chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
self.assertIn("function resetReasonSeg(run)", chat_js)
self.assertIn('t === "reasoning_reset"', chat_js)
self.assertIn("resetReasonSeg(ctx)", chat_js)
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8")

View File

@ -75,6 +75,15 @@ class TestSystemPromptPaths(unittest.TestCase):
self.assertIn(str(wd), prompt)
self.assertNotIn("/workspace/数据资源展示", prompt)
def test_mermaid_visual_guidance_is_backend_independent(self):
for backend in ("docker", "host"):
with self.subTest(backend=backend):
prompt, _, _ = _build(backend)
self.assertIn("## Mermaid 视觉规范", prompt)
self.assertIn("`classDef` 按角色分组", prompt)
self.assertIn("不要输出全灰单色图", prompt)
self.assertIn("用户指定品牌色", prompt)
def test_rename_guard_only_when_tool_is_available(self):
interactive, _, _ = _build("docker", allow_rename=True)
scheduled, _, _ = _build("docker", allow_rename=False)

View File

@ -73,11 +73,17 @@ class PublicEndpointTests(unittest.TestCase):
self.assertIn("brand", body)
def test_changelog_public(self):
r = _client.get("/v1/changelog?limit=2")
r = _client.get("/v1/changelog?limit=2&offset=0")
self.assertEqual(r.status_code, 200)
self.assertIn("entries", r.json())
first = r.json()
self.assertEqual(len(first["entries"]), 2)
self.assertGreaterEqual(first["total"], 2)
self.assertEqual(first["next_offset"], 2)
second = _client.get("/v1/changelog?limit=2&offset=2").json()
self.assertNotEqual(first["entries"][0]["version"], second["entries"][0]["version"])
# limit 越界 clamp,不 500
self.assertEqual(_client.get("/v1/changelog?limit=9999").status_code, 200)
self.assertEqual(_client.get("/v1/changelog?offset=-1").status_code, 200)
def test_root_redirects_to_dev_spa(self):
r = _client.get("/", follow_redirects=False)
@ -111,6 +117,12 @@ class AuthGateTests(unittest.TestCase):
("POST", "/v1/tasks"),
("POST", "/v1/asr/transcribe"),
("GET", "/v1/admin/overview"),
("GET", "/v1/admin/provider-credentials"),
("PUT", "/v1/admin/provider-credentials/deepseek"),
("POST", "/v1/admin/provider-credentials/deepseek/test"),
("DELETE", "/v1/admin/provider-credentials/deepseek"),
("GET", "/v1/admin/sandbox/capacity"),
("GET", "/v1/admin/sandbox/packages"),
("GET", "/v1/admin/software-nodes"),
("GET", "/v1/software-jobs"),
("POST", "/v1/software-jobs/00000000-0000-0000-0000-000000000000/cancel"),

30
tests/test_web_sinks.py Normal file
View File

@ -0,0 +1,30 @@
from __future__ import annotations
import unittest
from uuid import uuid4
from web.sinks import WebEventSink
class _Broker:
def __init__(self) -> None:
self.events: list[tuple] = []
def emit(self, task_id, event) -> None:
self.events.append((task_id, event))
class WebEventSinkTests(unittest.TestCase):
def test_done_is_deferred_to_worker_close(self) -> None:
broker = _Broker()
task_id = uuid4()
sink = WebEventSink(broker, task_id)
sink.emit({"type": "text", "delta": "ok"})
sink.emit({"type": "done"})
self.assertEqual(broker.events, [(task_id, {"type": "text", "delta": "ok"})])
if __name__ == "__main__":
unittest.main()

View File

@ -9,17 +9,28 @@ PROJECT = ROOT / "Zcbot.WindowsNode"
class WindowsNodeSourceTests(unittest.TestCase):
def test_project_targets_net10_windows_forms_with_json_schema_validator(self) -> None:
def test_project_targets_net10_wpf_with_windows_forms_interop(self) -> None:
tree = ET.parse(PROJECT / "Zcbot.WindowsNode.csproj")
root = tree.getroot()
self.assertEqual(root.findtext("./PropertyGroup/TargetFramework"), "net10.0-windows")
self.assertEqual(root.findtext("./PropertyGroup/UseWindowsForms"), "true")
self.assertEqual(root.findtext("./PropertyGroup/UseWPF"), "true")
self.assertEqual(root.findtext("./PropertyGroup/OutputType"), "WinExe")
self.assertEqual(
root.findtext("./PropertyGroup/ApplicationIcon"), "Assets\\zcbot.ico"
)
self.assertEqual(
[item.attrib["Include"] for item in root.findall("./ItemGroup/Resource")],
["Assets\\zcbot.ico"],
)
icon = PROJECT / "Assets" / "zcbot.ico"
self.assertTrue(icon.is_file())
self.assertEqual(icon.read_bytes()[:6], b"\x00\x00\x01\x00\x07\x00")
packages = root.findall("./ItemGroup/PackageReference")
self.assertEqual([item.attrib["Include"] for item in packages], ["JsonSchema.Net"])
def test_node_protocol_and_secret_storage_markers_are_present(self) -> None:
source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs"))
source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.rglob("*.cs"))
for marker in (
"v1/software-nodes/enroll",
"v1/software-nodes/connect",
@ -29,16 +40,129 @@ class WindowsNodeSourceTests(unittest.TestCase):
"SetAccessRuleProtection(isProtected: true",
'"adapter.json"',
"NotifyIcon",
"ConfigurationForm",
"MainWindow",
"TrayIconFactory.Create",
'"--headless"',
):
self.assertIn(marker, source)
def test_gui_and_headless_share_the_composition_root(self) -> None:
program = (PROJECT / "Program.cs").read_text(encoding="utf-8")
composition = (
PROJECT / "Bootstrap" / "NodeCompositionRoot.cs"
).read_text(encoding="utf-8")
controller = (
PROJECT / "Application" / "NodeApplicationController.cs"
).read_text(encoding="utf-8")
self.assertEqual(program.count("new NodeCompositionRoot"), 2)
self.assertIn("CreateApplicationController()", program)
self.assertIn("RunHeadlessAsync(shutdown.Token)", program)
self.assertIn("new App(compositionRoot.CreateApplicationController())", program)
self.assertIn("return app.Run()", program)
self.assertNotIn("new TrayApplicationContext", program)
self.assertIn("new NodeConnectionLoop(config, Paths", composition)
self.assertIn("stopTask ??= StopOnceAsync(mode)", controller)
self.assertIn("await StopConnectionAsync()", controller)
def test_wpf_overview_registration_and_settings_use_view_model_commands(self) -> None:
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
view_model = (
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
).read_text(encoding="utf-8")
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
for binding in (
"ShowOverviewCommand",
"ShowSettingsCommand",
"RegisterCommand",
"ReconnectCommand",
"ResetIdentityCommand",
"IsStartupEnabled",
"DataRootPage.CurrentRoot",
):
self.assertIn(f"{{Binding {binding}", xaml)
self.assertIn('PasswordChanged="EnrollmentCodeBox_OnPasswordChanged"', xaml)
self.assertIn("CopyDiagnosticsCommand", xaml)
self.assertNotIn("OpenLegacyCommand", xaml + view_model)
self.assertNotIn("NodeApplicationController", xaml + view_model)
self.assertIn("interface IStartupRegistration", startup)
self.assertIn("RegisterRequested += RegisterFromWpfAsync", tray)
self.assertIn("ReconnectRequested += ReconnectFromWpfAsync", tray)
self.assertIn("ResetIdentityRequested += ResetIdentityFromWpfAsync", tray)
def test_wpf_software_page_uses_runtime_services_and_tray_dialogs(self) -> None:
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
page = (
PROJECT / "Presentation" / "ViewModels" / "SoftwarePageViewModel.cs"
).read_text(encoding="utf-8")
service = (
PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs"
).read_text(encoding="utf-8")
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
for binding in (
"ShowSoftwareCommand",
"SoftwarePage.RefreshCommand",
"SoftwarePage.CancelInstallCommand",
"SoftwarePage.Cards",
"ChooseLocationCommand",
"ClearLocationCommand",
"InstallRuntimeCommand",
):
self.assertIn(f"{{Binding {binding}", xaml)
self.assertIn("ISoftwareManagementService", page + service)
self.assertIn("SoftwareLocationService.SaveConfiguredPath", service)
self.assertIn("SoftwareLocationService.ClearConfiguredPath", service)
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
self.assertIn("AdapterCatalog.HasActiveExecution", service)
self.assertIn("ChooseLocationRequested += ChooseSoftwareLocation", tray)
self.assertIn("ConfirmInstallRequested += ConfirmRuntimeInstall", tray)
self.assertNotIn("OpenFileDialog", page + service)
def test_wpf_theme_has_selected_navigation_status_and_responsive_actions(self) -> None:
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
theme = (
PROJECT / "Presentation" / "Themes" / "LightTheme.xaml"
).read_text(encoding="utf-8")
self.assertGreaterEqual(xaml.count("<ToggleButton"), 3)
self.assertIn('IsChecked="{Binding IsOverviewPage, Mode=OneWay}"', xaml)
self.assertIn('IsChecked="{Binding IsSoftwarePage, Mode=OneWay}"', xaml)
self.assertIn('IsChecked="{Binding IsSettingsPage, Mode=OneWay}"', xaml)
self.assertIn('Binding="{Binding StatusTone}"', xaml)
self.assertIn("SoftwarePage.IsBusy", xaml)
self.assertGreaterEqual(xaml.count("<WrapPanel"), 3)
self.assertIn("HasOperationMessage", xaml)
for token in (
"SidebarSelectedBrush",
"SuccessSurfaceBrush",
"WarningSurfaceBrush",
"ErrorSurfaceBrush",
"CardBorderStyle",
"SectionTitleStyle",
):
self.assertIn(token, theme)
self.assertIn('<ControlTemplate TargetType="Button">', theme)
self.assertIn('<ControlTemplate TargetType="ToggleButton">', theme)
def test_workspace_storage_is_global_by_workspace_not_user_directory(self) -> None:
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
store = (PROJECT / "WorkspaceStore.cs").read_text(encoding="utf-8")
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
encoding="utf-8"
)
self.assertIn('Path.Combine(root, "workspaces")', models)
self.assertIn('Binding(job).WorkspaceId.ToString("D")', store)
self.assertNotIn("UserId", store)
@ -48,7 +172,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("workspaceStore.Restore", runner)
def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None:
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8")
self.assertIn(
"var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray()",
@ -62,27 +186,42 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None:
source = "\n".join(
path.read_text(encoding="utf-8")
for path in PROJECT.glob("*.cs")
for path in PROJECT.rglob("*.cs")
if path.name not in {
"AdapterProcessRunner.cs",
"AdapterExecutionService.cs",
"ProcessSupervisor.cs",
"ConfigurationForm.cs",
"SoftwareRuntimeManager.cs",
"TrayHost.cs",
"SoftwareLocationService.cs",
"ManagedRuntimeInstaller.cs",
}
)
for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"):
self.assertNotIn(forbidden, source)
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn('"explorer.exe"', form)
self.assertIn("startInfo.ArgumentList.Add(dataRoot.Text)", form)
self.assertNotIn("UseShellExecute = true", form)
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
self.assertIn('"explorer.exe"', tray)
self.assertIn("startInfo.ArgumentList.Add(path)", tray)
self.assertNotIn("UseShellExecute = true", tray)
def test_data_root_is_configurable_and_migrated_before_switching(self) -> None:
settings = (PROJECT / "NodeDataRoot.cs").read_text(encoding="utf-8")
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
page = (
PROJECT / "Presentation" / "ViewModels" / "DataRootPageViewModel.cs"
).read_text(encoding="utf-8")
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
controller = (
PROJECT / "Application" / "NodeApplicationController.cs"
).read_text(encoding="utf-8")
composition = (
PROJECT / "Bootstrap" / "NodeCompositionRoot.cs"
).read_text(encoding="utf-8")
self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings)
self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings)
@ -99,15 +238,13 @@ class WindowsNodeSourceTests(unittest.TestCase):
settings.index("VerifyTree(source, staging"),
settings.index("Directory.Move(staging, target)"),
)
self.assertIn("DataRootMigrationRequested", form)
self.assertIn("jobInbox.HasPendingJobs", form)
self.assertIn(
"NodeDataRootSettings.SaveUserRoot(result.TargetDirectory)", tray
)
self.assertIn("MigrationRequested", page)
self.assertIn("service.HasPendingJobs", page)
self.assertIn("saveDataRoot(result.TargetDirectory)", controller)
self.assertIn("NodeDataRootSettings.SaveUserRoot", composition)
self.assertIn("controller.MigrateDataRootAsync(target)", tray)
self.assertIn("jobPipelines.Values.Concat(exportPipelines.Values)", connection)
self.assertIn(
"Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory)", connection
)
self.assertIn("Path.GetPathRoot(paths.RootDirectory)", connection)
def test_config_field_names_do_not_serialize_plain_node_token(self) -> None:
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
@ -116,28 +253,46 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertNotIn("string NodeToken", stored_record)
def test_configuration_ui_never_displays_or_copies_token(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("CreateTextBox(usePassword: true)", form)
self.assertIn("UseSystemPasswordChar = usePassword", form)
self.assertNotIn("NodeToken", form)
self.assertIn("Clipboard.SetText(BuildDiagnosticText(currentConfig))", form)
diagnostics = form.split("private string BuildDiagnosticText", 1)[1].split(
"private async Task RunAnsysAcceptanceAsync", 1
)[0]
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
view_model = (
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
).read_text(encoding="utf-8")
diagnostic_service = (
PROJECT / "Application" / "DiagnosticService.cs"
).read_text(encoding="utf-8")
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
self.assertIn("<PasswordBox", xaml)
self.assertNotIn("NodeToken", xaml + view_model + diagnostic_service)
self.assertIn("Clipboard.SetText(text)", tray)
diagnostics = diagnostic_service.split("public string Build", 1)[1]
self.assertNotIn("NodeToken", diagnostics)
self.assertIn("内容不包含 Node Token", form)
self.assertIn("清除本机身份", form)
self.assertIn("管理后台删除或禁用云端旧节点", form)
self.assertIn("内容不包含 Node Token", view_model)
self.assertIn("清除本机身份", xaml)
self.assertIn("管理后台删除或禁用云端旧节点", tray)
def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
view_model = (
PROJECT / "Presentation" / "ViewModels" / "AnsysAcceptanceViewModel.cs"
).read_text(encoding="utf-8")
service = (
PROJECT / "Application" / "AnsysAcceptanceService.cs"
).read_text(encoding="utf-8")
adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
self.assertIn("ANSYS Mechanical 真机验收", form)
self.assertIn("运行内置基准验收", form)
self.assertIn("AcceptanceReportPassed", form)
self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form)
self.assertIn("EnvironmentVariableTarget.Machine", form)
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
encoding="utf-8"
)
self.assertIn("ANSYS Mechanical 真机验收", xaml)
self.assertIn("运行内置基准验收", xaml)
self.assertIn("ReportPassed", service)
self.assertIn('GateEnvironmentVariable,\n "1"', service)
self.assertIn("EnvironmentVariableTarget.Machine", service)
self.assertIn("SupportsLocalAcceptance", adapters)
self.assertIn('Path.Combine(descriptor.DirectoryPath, "acceptance.py")', runner)
self.assertIn(
@ -145,114 +300,100 @@ class WindowsNodeSourceTests(unittest.TestCase):
)
self.assertIn('["--work-root", root, "--repeat", "20"', runner)
self.assertIn("Adapter acceptance cannot run while a dispatched job is active", runner)
acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split(
"private void EnableAnsysGate", 1
)[0]
self.assertNotIn("new OpenFileDialog", acceptance)
self.assertNotIn("Process.Start", acceptance)
self.assertIn("service.RunAsync(workRoot", view_model)
self.assertNotIn("new OpenFileDialog", view_model + service)
self.assertNotIn("Process.Start", view_model + service)
def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
service = (
PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs"
).read_text(encoding="utf-8")
catalog = (PROJECT / "Runtime" / "SoftwareCatalog.cs").read_text(encoding="utf-8")
manager = (PROJECT / "Runtime" / "SoftwareLocationService.cs").read_text(
encoding="utf-8"
)
installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text(
encoding="utf-8"
)
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
encoding="utf-8"
)
self.assertIn('CreateSectionTitle("专业软件")', form)
self.assertIn('CreateButton("自动检测", 92)', form)
self.assertIn('CreateButton("选择位置", 92)', form)
self.assertIn('CreateButton("安装 / 更新环境", 150', form)
self.assertIn("panel.SetColumnSpan(path, 2)", form)
self.assertIn("path.ReadOnly = true", form)
software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split(
"private void RefreshSoftwareCards", 1
)[0]
self.assertIn("panel.SetColumnSpan(status, 2)", software_panel)
self.assertIn("panel.SetColumnSpan(actions, 2)", software_panel)
self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form)
self.assertIn("item.HasActiveJobs", form)
self.assertIn('Text="本机专业软件"', xaml)
self.assertIn('Content="自动检测"', xaml)
self.assertIn('Content="选择位置"', xaml)
self.assertIn('Content="安装 / 更新环境"', xaml)
self.assertIn('IsReadOnly="True"', xaml)
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
self.assertIn("item.HasActiveJobs", service)
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
self.assertIn(runtime_id, manager)
self.assertIn(runtime_id, catalog)
self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
self.assertIn("RegistryHive.LocalMachine", manager)
self.assertIn("RegistryHive.CurrentUser", manager)
self.assertIn('"ZCBOT_BLENDER_EXE"', manager)
self.assertIn('"AWP_ROOT242"', manager)
self.assertIn('"ZCBOT_ORIGIN_EXE"', manager)
self.assertIn('"Origin*.exe"', manager)
self.assertIn('"ZCBOT_BLENDER_EXE"', catalog)
self.assertIn('"AWP_ROOT242"', catalog)
self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog)
self.assertIn('"Origin*.exe"', catalog)
self.assertIn("OriginExecutables", manager)
self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager)
self.assertIn("ArgumentList.Add(argument)", manager)
self.assertNotIn('"cmd.exe"', manager.lower())
self.assertNotIn('"powershell.exe"', manager.lower())
self.assertIn("SoftwareRuntimeManager.ApplyProcessEnvironment", runner)
self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', installer)
self.assertIn("ArgumentList.Add(argument)", installer)
self.assertNotIn('"cmd.exe"', installer.lower())
self.assertNotIn('"powershell.exe"', installer.lower())
self.assertIn("SoftwareLocationService.ApplyProcessEnvironment", runner)
self.assertIn("AdapterCatalog.HasActiveExecution", installer)
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("ClientSize = new Size(1080, 1050)", form)
self.assertIn("MinimumSize = new Size(760, 560)", form)
self.assertIn("FormBorderStyle.Sizable", form)
self.assertIn("AutoScaleMode.Dpi", form)
self.assertIn("AutoScroll = true", form)
self.assertNotIn("MaximumSize = new Size(410", form)
self.assertIn("注册码默认 10 分钟有效", form)
self.assertIn("成功注册一次后立即失效", form)
self.assertIn("CreateSection", form)
self.assertIn("注册并连接", form)
self.assertNotIn("ContentWidth", form)
self.assertIn("new Padding(24, 20, 24, 20)", form)
self.assertIn("new ColumnStyle(SizeType.Percent, 100)", form)
self.assertIn('AddTab(tabs, "节点概览")', form)
self.assertIn('AddTab(tabs, "专业软件")', form)
self.assertIn('AddTab(tabs, "本机任务")', form)
self.assertIn('AddTab(tabs, "运行设置")', form)
self.assertIn("SizeMode = TabSizeMode.FillToRight", form)
self.assertIn("new NavigationTabControl", form)
self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form)
self.assertIn("Appearance = TabAppearance.FlatButtons", form)
self.assertNotIn("protected override void OnResize", form)
self.assertIn("tab.Controls.Add(content)", form)
self.assertIn("CreateSectionTitle(\"节点信息\")", form)
self.assertIn("CreateSubsectionTitle(\"可用软件\")", form)
self.assertIn("heading.Controls.Add(state, 1, 0)", form)
self.assertNotIn("headingHint", form)
self.assertIn("panel.SetColumnSpan(path, 2)", form)
self.assertIn("panel.SetColumnSpan(status, 2)", form)
self.assertIn("panel.SetColumnSpan(actions, 2)", form)
self.assertIn("BorderedTableLayoutPanel", form)
self.assertIn(
'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
self.assertIn("FormatCapabilitySummary(adapters.All)", form)
self.assertIn("GroupBy(entry => entry.SoftwareName", form)
self.assertIn("GroupBy(entry => entry.CapabilityName", form)
self.assertIn("ShortCapabilityName(adapter.DisplayName, softwareName)", form)
self.assertIn('CreateButton("立即重连", 112, primary: true)', form)
self.assertIn("ReconnectRequested?.Invoke()", form)
self.assertIn("registrationCard.Visible = !registered", form)
self.assertIn("reconnect.Visible = registered", form)
self.assertIn("resetIdentity.Visible = registered", form)
self.assertIn('CreateSectionTitle("本机任务")', form)
self.assertIn("DataGridView", form)
self.assertIn("jobInbox.ReadJobSnapshots()", form)
self.assertIn('"software_running" => "软件执行中"', form)
self.assertNotIn("HttpClient", form)
self.assertIn('Width="1040"', xaml)
self.assertIn('MinWidth="760"', xaml)
self.assertIn('MinHeight="560"', xaml)
self.assertIn("<ScrollViewer", xaml)
self.assertIn('Content="注册并连接"', xaml)
self.assertIn('Content="节点概览"', xaml)
self.assertIn('Content="专业软件"', xaml)
self.assertIn('Content="本机任务"', xaml)
self.assertIn('Content="运行设置"', xaml)
self.assertIn("<DataGrid", xaml)
self.assertFalse((PROJECT / "ConfigurationForm.cs").exists())
self.assertFalse((PROJECT / "TrayApplicationContext.cs").exists())
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("form.ReconnectRequested += RestartConnection", tray)
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
self.assertNotIn("legacyForm", tray)
self.assertIn("statusItem.Text = CompactMenuText(status.Message, 24)", tray)
self.assertIn('singleLine[..(length - 1)] + ""', tray)
def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> None:
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
host_loop = (PROJECT / "Host" / "NodeConnectionLoop.cs").read_text(
encoding="utf-8"
)
controller = (
PROJECT / "Application" / "NodeApplicationController.cs"
).read_text(encoding="utf-8")
self.assertIn("private async Task ExitNodeAsync()", tray)
self.assertIn("if (exiting || config is null", tray)
self.assertIn("ReadJobSnapshots()", tray)
self.assertIn("if (exiting)", tray)
self.assertIn("controller.ActiveJobCount", tray)
self.assertIn("MessageBoxButtons.YesNoCancel", tray)
self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray)
self.assertIn("NodeShutdownMode.CancelJobs", tray)
self.assertIn("tray.ContextMenuStrip.Enabled = false", tray)
self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();"))
self.assertIn("internal void CancelActiveJobsForExit()", connection)
self.assertLess(tray.index("await controller.StopAsync"), tray.index("shutdown();"))
self.assertIn("stopTask ??= StopOnceAsync(mode)", controller)
self.assertIn("connection?.CancelActiveJobsForExit()", controller)
self.assertIn("await StopConnectionAsync()", controller)
self.assertIn("public void CancelActiveJobsForExit()", host_loop)
self.assertIn("coordinator.CancelActiveJobsForExit()", host_loop)
self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection)
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
self.assertIn('"NODE_EXIT_CANCELLED"', connection)
@ -260,11 +401,20 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None:
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
monitor = (
PROJECT / "Application" / "JobMonitorService.cs"
).read_text(encoding="utf-8")
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
models = (PROJECT / "JobMonitorModels.cs").read_text(encoding="utf-8")
self.assertIn('"state.json"', inbox)
self.assertIn("ReadJobSnapshots", inbox)
self.assertIn("AtomicWrite(path, content, overwrite: true)", inbox)
repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text(
encoding="utf-8"
)
state_machine = (PROJECT / "Jobs" / "JobStateMachine.cs").read_text(
encoding="utf-8"
)
self.assertIn('"state.json"', monitor)
self.assertIn("ReadSnapshots", monitor)
self.assertIn("AtomicFile.Write(path, content, overwrite: true)", repository)
for stage in (
"accepted",
"downloading_inputs",
@ -275,26 +425,36 @@ class WindowsNodeSourceTests(unittest.TestCase):
"failed",
"cancelled",
):
self.assertIn(f'"{stage}"', inbox + connection)
self.assertIn(
f'"{stage}"',
inbox + monitor + repository + state_machine + connection,
)
self.assertIn("JobDisplaySnapshot", models)
self.assertNotIn("origin_running", inbox + connection + models)
self.assertNotIn("origin_running", inbox + monitor + connection + models)
def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None:
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
encoding="utf-8"
)
view_model = (
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
).read_text(encoding="utf-8")
installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text(
encoding="utf-8"
)
self.assertFalse((ROOT / "install-windows-node.bat").exists())
self.assertIn('Text = "登录 Windows 后自动启动节点"', form)
self.assertIn("StartupRegistration.SetEnabled", form)
self.assertIn('Content="登录 Windows 后自动启动节点"', xaml)
self.assertIn("startupRegistration.SetEnabled", view_model)
self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup)
self.assertIn("Environment.ProcessPath", startup)
self.assertIn("FindPython312Async", manager)
self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', manager)
self.assertIn('new BootstrapPython("python.exe", [])', manager)
self.assertIn('"ZCBOT_PIP_INDEX_URL"', manager)
self.assertIn("FindPython312Async", installer)
self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', installer)
self.assertIn('new BootstrapPython("python.exe", [])', installer)
self.assertIn('"ZCBOT_PIP_INDEX_URL"', installer)
self.assertIn(
'"https://pypi.tuna.tsinghua.edu.cn/simple/"', manager
'"https://pypi.tuna.tsinghua.edu.cn/simple/"', installer
)
def test_publish_output_contains_the_complete_node_payload(self) -> None:
@ -354,30 +514,36 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertFalse((ROOT / "package-windows-node.ps1").exists())
def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None:
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', connection)
self.assertIn('or HttpStatusCode.Forbidden', connection)
self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", connection)
self.assertIn("catch (NodeEndpointException exception)", connection)
self.assertIn("throw new NodeEndpointException", connection)
self.assertIn("(int?)result.CloseStatus == 4003", connection)
self.assertIn("节点身份已被服务端拒绝", connection)
self.assertNotIn("Node credentials were rejected", connection)
client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text(
encoding="utf-8"
)
session = (PROJECT / "Host" / "NodeSession.cs").read_text(encoding="utf-8")
self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', client)
self.assertIn('or HttpStatusCode.Forbidden', client)
self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", session)
self.assertIn("catch (NodeEndpointException exception)", session)
self.assertIn("throw new NodeEndpointException", client)
self.assertIn("(int?)result.CloseStatus == 4003", client)
self.assertIn("节点身份已被服务端拒绝", client)
self.assertNotIn("Node credentials were rejected", client + session)
def test_adapter_runtime_probe_is_worker_owned_and_reported(self) -> None:
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
encoding="utf-8"
)
worker = (
ROOT / "adapters" / "origin.plot@v2" / "worker.py"
).read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
self.assertIn('["--probe"]', runner)
self.assertIn('root.GetProperty("adapter_version")', runner)
self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form)
self.assertIn('$"{group.Key} · 版本未知"', form)
diagnostics = (
PROJECT / "Application" / "DiagnosticService.cs"
).read_text(encoding="utf-8")
self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', diagnostics)
self.assertIn(
"&& !jobInbox.HasPendingJobs",
connection,
@ -385,7 +551,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("originAdapter?.HasActiveJobs", connection)
self.assertIn(
"ReadRecoverableJobs().Any(item => item.Terminal is null)",
(PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8"),
(PROJECT / "Jobs" / "JobRepository.cs").read_text(encoding="utf-8"),
)
self.assertNotIn("CreateInstance", worker)
for marker in (
@ -398,7 +564,22 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_job_offer_is_persisted_before_acceptance(self) -> None:
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
monitor = (
PROJECT / "Application" / "JobMonitorService.cs"
).read_text(encoding="utf-8")
repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text(
encoding="utf-8"
)
recovery = (PROJECT / "Jobs" / "JobRecoveryService.cs").read_text(
encoding="utf-8"
)
atomic_file = (PROJECT / "Persistence" / "AtomicFile.cs").read_text(
encoding="utf-8"
)
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
protocol_client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text(
encoding="utf-8"
)
adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
self.assertIn("adapters.Find(capability)", inbox)
self.assertIn("adapter.ValidateRequest(request)", inbox)
@ -411,44 +592,51 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertNotIn('AppContext.BaseDirectory, "software-contracts"', adapters)
self.assertNotIn("IsValidOriginRequest", inbox)
self.assertNotIn("IsValidPlot", inbox)
self.assertIn('root.TryGetProperty("input_transfers"', inbox)
self.assertIn('root.TryGetProperty("input_transfers"', inbox + monitor)
self.assertIn('root.TryGetProperty("request_summary"', inbox + monitor)
self.assertIn("ReadDisplayTitle(root)", monitor)
self.assertIn("foreach (var action in operation.EnumerateObject())", monitor)
self.assertIn("transfers.GetArrayLength() > 16", inbox)
self.assertNotIn("transfers.GetArrayLength() is < 1 or > 16", inbox)
self.assertIn('"input", key, filename', inbox)
self.assertIn("FileOptions.WriteThrough", inbox)
self.assertIn("stream.Flush(flushToDisk: true)", inbox)
self.assertIn('"input", key, filename', repository)
self.assertIn("FileOptions.WriteThrough", atomic_file)
self.assertIn("stream.Flush(flushToDisk: true)", atomic_file)
new_record = inbox.split("var record =", 1)[1].split("private static JsonElement?", 1)[0]
self.assertLess(
new_record.index("AtomicWrite(requestPath, record"),
new_record.index("AtomicFile.Write(requestPath, record"),
new_record.index("JobOfferResult.Accept"),
)
self.assertIn('offerResult.Accepted ? "job_accept" : "job_reject"', connection)
self.assertIn("sendLock.WaitAsync", connection)
self.assertIn("sendLock.WaitAsync", protocol_client)
self.assertIn("!jobInbox.HasPendingJobs", connection)
self.assertIn("capability_runtime = capabilityRuntime", connection)
self.assertIn("adapter.RunAsync(job)", connection)
self.assertIn("ReportRecoverableJobsAsync", connection)
self.assertIn("ConcurrentDictionary<Guid, Task> jobPipelines", connection)
self.assertIn("StartJobPipeline(socket, acceptedJob)", connection)
self.assertIn("StartJobPipeline(client, acceptedJob)", connection)
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection)
self.assertIn('stage = "uploading_outputs"', connection)
self.assertIn('stage = "software_running"', connection)
self.assertNotIn('stage = "origin_running"', connection)
self.assertIn("&& !job.UploadComplete", connection)
self.assertIn("&& !job.CloudTerminal", connection)
self.assertIn("JobRecoveryAction.ResumeOutputUpload", connection)
self.assertIn("job.UploadComplete || job.CloudTerminal", recovery)
self.assertIn(
"await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray())",
connection,
)
self.assertIn("StartJobPipeline(socket, job)", connection)
self.assertIn("StartJobPipeline(client, job, recovery: true)", connection)
self.assertIn('stage = "downloading_inputs"', connection)
self.assertIn('Path.Combine(jobDirectory, "terminal.json")', inbox)
self.assertIn("AtomicWrite(requestPath, updated, overwrite: true)", inbox)
self.assertIn('Path.Combine(jobDirectory, "terminal.json")', repository)
self.assertIn("AtomicFile.Write(requestPath, updated, overwrite: true)", inbox)
downloader = (PROJECT / "JobInputDownloader.cs").read_text(encoding="utf-8")
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', downloader)
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', downloader)
http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text(
encoding="utf-8"
)
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client)
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client)
self.assertIn("SendWithRetryAsync", downloader)
self.assertIn("HttpCompletionOption.ResponseHeadersRead", downloader)
self.assertIn("IncrementalHash.CreateHash", downloader)
self.assertIn("total > expectedSize", downloader)
@ -456,8 +644,13 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertNotIn("Process.Start", downloader)
def test_adapter_worker_launch_is_manifest_driven_and_terminal_driven(self) -> None:
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
encoding="utf-8"
)
supervisor = (PROJECT / "Adapters" / "ProcessSupervisor.cs").read_text(
encoding="utf-8"
)
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
worker = (
ROOT / "adapters" / "origin.plot@v2" / "worker.py"
@ -473,8 +666,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('Path.Combine(jobDirectory, "terminal.json")', runner)
self.assertIn('"NODE_RESTARTED_DURING_JOB"', runner)
self.assertIn("CancellationTokenSource.CreateLinkedTokenSource", runner)
self.assertIn("process.Kill(entireProcessTree: true)", runner)
self.assertIn('type.GetString() == "job_cancel"', connection)
self.assertIn("process.Kill(entireProcessTree: true)", supervisor)
self.assertIn('message.Type == "job_cancel"', connection)
self.assertIn('"cancelled", "USER_CANCELLED"', connection)
self.assertIn("..\\adapters\\**\\*", project)
self.assertIn("descriptor.Manifest.WorkerTimeoutMinutes", runner)
@ -495,24 +688,29 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_origin_adapter_can_be_packaged_without_building_node(self) -> None:
script = (ROOT / "package-origin-adapter.bat").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script)
self.assertIn('"adapters\\origin.plot@v2\\acceptance.py"', script)
self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
self.assertNotIn("dotnet", script.lower())
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("Contract SHA-256: {adapter.ContractSha256}", form)
self.assertIn("Workspace protocol:", form)
self.assertIn("Workspace state:", form)
diagnostics = (
PROJECT / "Application" / "DiagnosticService.cs"
).read_text(encoding="utf-8")
self.assertIn("Contract SHA-256: {adapter.ContractSha256}", diagnostics)
self.assertIn("Workspace protocol:", diagnostics)
self.assertIn("Workspace state:", diagnostics)
self.assertIn("workspace_protocol_version", connection)
self.assertIn("contract_sha256", connection)
uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8")
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', uploader)
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', uploader)
self.assertIn('DefaultRequestHeaders.Add("X-Lease-Id"', uploader)
http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text(
encoding="utf-8"
)
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client)
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client)
self.assertIn('request.Headers.Add("X-Lease-Id"', http_client)
self.assertIn("SHA256.HashDataAsync", uploader)
self.assertIn("upload-complete.json", connection + uploader)
self.assertIn("adapter.PreviewOutputIds", connection)
@ -536,11 +734,17 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertNotIn('".tmp-" + Guid.NewGuid()', uploader)
self.assertNotIn("Process.Start", uploader)
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("_ = form.Handle", tray)
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
encoding="utf-8"
)
controller = (
PROJECT / "Application" / "NodeApplicationController.cs"
).read_text(encoding="utf-8")
self.assertNotIn("legacyForm", tray)
self.assertIn("window.ShowAndActivate()", tray)
self.assertIn(
'UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))',
tray,
'Publish(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))',
controller,
)
def test_origin_analysis_adapter_can_be_packaged_without_building_node(self) -> None:
@ -573,8 +777,10 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertNotIn("dotnet", script.lower())
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
self.assertIn("state.UpdatedAt > acceptedAt", inbox)
monitor = (
PROJECT / "Application" / "JobMonitorService.cs"
).read_text(encoding="utf-8")
self.assertIn("state.UpdatedAt > acceptedAt", monitor)
if __name__ == "__main__":

View File

@ -90,18 +90,28 @@ class CheckProcessTool(Tool):
tail = procs.tail_log(d, max_bytes=tail_bytes)
# docker 模式:已结束但专用容器还在空转 → 顺手回收(幂等,sweep 也会兜底)
if st != "running" and meta.get("backend") == "docker":
if st not in {"running", "queued"} and meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name:
try:
procs._scan_proc_container(meta, d, name)
subprocess.run(["docker", "rm", "-f", name],
capture_output=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
pass
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
except Exception:
pass
header = f"[check_process] {proc_id} · {meta.get('kind')} · {meta.get('command', '')[:150]}"
if st == "running":
elapsed = procs._fmt_elapsed(time.time() - float(meta.get("created_ts") or time.time()))
if st == "queued":
body = "状态: queued(等待 Sandbox 执行容量;排队时间不计运行超时)\n提示:可用 action='kill' 取消排队。"
elif st == "running":
elapsed = procs._fmt_elapsed(time.time() - float(meta.get("started_ts") or meta.get("created_ts") or time.time()))
body = (
f"状态: running(已运行 {elapsed},上限 {meta.get('timeout_s')}s)\n"
f"--- 日志尾部 ---\n{tail}\n"

View File

@ -24,7 +24,7 @@ from sqlalchemy import func, select, update
from core.storage import session_scope
from core.storage import usage_report
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
from core.storage.models import SandboxPackageScan, Task, UsageEvent, User, UserDiskUsage
from .broker import broker
@ -51,6 +51,37 @@ def _range_cutoff(now: datetime, range_key: str):
return None # all / 未知 → 不筛
def _sandbox_package_stats(s: Any, cutoff: datetime | None) -> dict:
stmt = select(SandboxPackageScan)
if cutoff is not None:
stmt = stmt.where(SandboxPackageScan.finished_at >= cutoff)
scans = s.execute(stmt).scalars().all()
grouped: dict[tuple[str, str], dict] = {}
for scan in scans:
for package in scan.packages:
key = (str(package.get("name") or ""), str(package.get("version") or ""))
row = grouped.setdefault(key, {"name": key[0], "version": key[1], "sessions": set(),
"users": set(), "foreground_sessions": 0, "background_sessions": 0,
"direct_sessions": 0, "total_installed_bytes": 0, "latest_at": None,
"base_versions": set(), "changes": set()})
row["sessions"].add(scan.container_session_id); row["users"].add(str(scan.user_id))
row[f"{scan.execution_kind}_sessions"] += 1
row["direct_sessions"] += int(bool(package.get("direct_requested")))
row["total_installed_bytes"] += int(package.get("installed_bytes") or 0)
row["base_versions"].add(package.get("base_version")); row["changes"].add(package.get("change"))
if row["latest_at"] is None or scan.finished_at > row["latest_at"]:
row["latest_at"] = scan.finished_at
rows = []
for row in grouped.values():
sessions = len(row.pop("sessions")); row["session_count"] = sessions
row["user_count"] = len(row.pop("users")); row["average_installed_bytes"] = row["total_installed_bytes"] // max(1, sessions)
row["base_versions"] = sorted(str(x) for x in row["base_versions"] if x is not None)
row["changes"] = sorted(row["changes"]); row["latest_at"] = row["latest_at"].isoformat()
rows.append(row)
rows.sort(key=lambda x: (-x["session_count"], x["name"].lower(), x["version"]))
return {"rows": rows, "scan_sessions": len(scans)}
def _runtime_section(app: FastAPI) -> dict:
"""实时运行态:从 app.state 读内存,无 DB。
@ -97,15 +128,24 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict:
def _storage_page(s: Any, page: int, page_size: int) -> dict:
"""分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。
"""分页的各用户磁盘用量,并附全站聚合与 per-user 配额。
bytes_used 是用户配额占用trash_bytes 是不计配额但仍占磁盘的隐藏回收站
数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数
"""
from core.agent_builder import load_config
from core.storage.disk_quota import parse_bytes
quota = parse_bytes((load_config().get("quotas") or {}).get("disk_bytes_per_user"))
total = s.execute(select(func.count()).select_from(UserDiskUsage)).scalar_one()
total, bytes_used, trash_bytes, file_count, trash_file_count = s.execute(
select(
func.count(),
func.coalesce(func.sum(UserDiskUsage.bytes_used), 0),
func.coalesce(func.sum(UserDiskUsage.trash_bytes), 0),
func.coalesce(func.sum(UserDiskUsage.file_count), 0),
func.coalesce(func.sum(UserDiskUsage.trash_file_count), 0),
).select_from(UserDiskUsage)
).one()
rows = [
{
"user_id": str(uid),
@ -114,9 +154,12 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
"user_name": uname or "",
"bytes_used": int(b or 0),
"file_count": int(fc or 0),
"trash_bytes": int(tb or 0),
"trash_file_count": int(tfc or 0),
"physical_bytes": int(b or 0) + int(tb or 0),
"scanned_at": scanned.isoformat() if scanned else None,
}
for uid, email, name, uname, b, fc, scanned in s.execute(
for uid, email, name, uname, b, fc, tb, tfc, scanned in s.execute(
select(
UserDiskUsage.user_id,
User.email,
@ -124,6 +167,8 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
User.user_name,
UserDiskUsage.bytes_used,
UserDiskUsage.file_count,
UserDiskUsage.trash_bytes,
UserDiskUsage.trash_file_count,
UserDiskUsage.scanned_at,
)
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True)
@ -134,7 +179,15 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
]
return {
"page": page, "page_size": page_size, "total": total,
"quota_bytes": quota, "rows": rows,
"quota_bytes": quota,
"totals": {
"bytes_used": int(bytes_used or 0),
"trash_bytes": int(trash_bytes or 0),
"physical_bytes": int(bytes_used or 0) + int(trash_bytes or 0),
"file_count": int(file_count or 0),
"trash_file_count": int(trash_file_count or 0),
},
"rows": rows,
}
@ -155,7 +208,9 @@ def _model_catalog() -> list[dict]:
except Exception:
continue
family = data.get("family") or path.stem
for variant in (data.get("variants") or {}).keys():
for variant, variant_cfg in (data.get("variants") or {}).items():
if isinstance(variant_cfg, dict) and variant_cfg.get("hidden"):
continue
profile = f"{family}.{variant}"
try:
caps = ModelCapabilities.load(profile, models_dir)
@ -182,6 +237,15 @@ class SetPlanRequest(BaseModel):
plan: str = "" # 档位名(config/agent.yaml model_tiers 的 key);空串 = 清空 → 落 default 档
class ProviderCredentialRequest(BaseModel):
credentials: dict[str, str]
expected_revision: int = Field(ge=0)
class ProviderCredentialDeleteRequest(BaseModel):
expected_revision: int = Field(ge=1)
class ExternalSystemDefinitionRequest(BaseModel):
provider: str = "generic_openapi"
name: str
@ -257,6 +321,88 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
"usage": usage_report.usage_overview(s, cutoff_7d),
}
@app.get("/v1/admin/provider-credentials", tags=["admin"])
def admin_provider_credentials(user_id: UUID = Depends(require_admin)):
from core.provider_credentials.service import list_providers
return {"results": list_providers()}
@app.put("/v1/admin/provider-credentials/{provider_id}", tags=["admin"])
def admin_replace_provider_credentials(
provider_id: str,
body: ProviderCredentialRequest,
user_id: UUID = Depends(require_admin),
):
from core.provider_credentials.monitor import send_notification
from core.provider_credentials.service import (
ProviderCredentialError, RevisionConflict, replace_credentials,
)
try:
return replace_credentials(
provider_id, body.credentials,
expected_revision=body.expected_revision,
updated_by=user_id, notify=send_notification,
)
except ValueError as exc:
raise HTTPException(404, "unknown provider") from exc
except RevisionConflict as exc:
raise HTTPException(409, str(exc)) from exc
except ProviderCredentialError as exc:
raise HTTPException(400, str(exc)) from exc
@app.post("/v1/admin/provider-credentials/{provider_id}/test", tags=["admin"])
def admin_test_provider_credentials(
provider_id: str, user_id: UUID = Depends(require_admin),
):
from core.provider_credentials.monitor import send_notification
from core.provider_credentials.service import (
ProviderCredentialError, test_current_credentials,
)
try:
result = test_current_credentials(provider_id, notify=send_notification)
return {
"status": result.status, "detail": result.detail,
"balance": (
{"amount": str(result.balance_amount),
"currency": result.balance_currency}
if result.balance_amount is not None else None
),
}
except ValueError as exc:
raise HTTPException(404, "unknown provider") from exc
except ProviderCredentialError as exc:
raise HTTPException(400, str(exc)) from exc
@app.delete("/v1/admin/provider-credentials/{provider_id}", tags=["admin"])
def admin_delete_provider_credentials(
provider_id: str,
body: ProviderCredentialDeleteRequest,
user_id: UUID = Depends(require_admin),
):
from core.provider_credentials.service import RevisionConflict, delete_override
try:
delete_override(provider_id, expected_revision=body.expected_revision)
except ValueError as exc:
raise HTTPException(404, "unknown provider") from exc
except RevisionConflict as exc:
raise HTTPException(409, str(exc)) from exc
return {"deleted": True}
@app.get("/v1/admin/sandbox/capacity", tags=["admin"])
def admin_sandbox_capacity(user_id: UUID = Depends(require_admin)):
"""宿主共享实时容量;只读文件/Docker 状态,不写 DB。"""
pool = getattr(app.state, "sandbox_pool", None)
if pool is None:
return {"enabled": False}
return {"enabled": True, **pool.runtime_snapshot()}
@app.get("/v1/admin/sandbox/packages", tags=["admin"])
def admin_sandbox_packages(range: str = "30d", user_id: UUID = Depends(require_admin)):
now = datetime.now(timezone.utc)
with session_scope() as s:
result = _sandbox_package_stats(s, _range_cutoff(now, range))
result["range"] = range
return result
@app.get("/v1/admin/external-system-definitions", tags=["admin"])
def admin_external_system_definitions(user_id: UUID = Depends(require_admin)):
from core.external_systems.service import list_external_system_definitions

View File

@ -43,6 +43,7 @@ from .background import (
reap_stale_runs,
start_disk_scanner,
start_proc_sweeper,
start_provider_scanner,
start_stats_logger,
start_toolfail_scanner,
)
@ -124,6 +125,7 @@ def create_app() -> FastAPI:
disk_scanner_task = start_disk_scanner(_cfg)
stats_logger_task = start_stats_logger(app, run_max_workers)
toolfail_task = start_toolfail_scanner()
provider_task = start_provider_scanner()
scheduler_task = start_scheduler(app, _cfg)
wechat_task, wechat_stop = start_wechat_inbound(app)
sandbox_reaper_task = init_sandbox(app, _cfg)
@ -141,6 +143,7 @@ def create_app() -> FastAPI:
await cancel_and_wait(disk_scanner_task)
await cancel_and_wait(stats_logger_task)
await cancel_and_wait(toolfail_task)
await cancel_and_wait(provider_task)
await cancel_and_wait(scheduler_task)
if wechat_task is not None:
wechat_stop.set()

View File

@ -183,6 +183,26 @@ def start_toolfail_scanner() -> Optional[asyncio.Task]:
return asyncio.create_task(_toolfail_scanner(), name="toolfail-scanner")
def start_provider_scanner() -> asyncio.Task:
"""每分钟寻找到期 Provider实际一轮由 PostgreSQL advisory lock 单实例执行。"""
async def _scanner() -> None:
from core.provider_credentials.monitor import run_due_checks
loop = asyncio.get_running_loop()
while True:
try:
await asyncio.sleep(5)
checked = await loop.run_in_executor(None, run_due_checks)
if checked:
print(f"[provider] checked {checked} provider(s)")
await asyncio.sleep(55)
except asyncio.CancelledError:
raise
except Exception as exc:
print(f"[provider] scanner error: {type(exc).__name__}")
await asyncio.sleep(60)
return asyncio.create_task(_scanner(), name="provider-scanner")
def init_sandbox(app, cfg: dict) -> Optional[asyncio.Task]:
"""Sandbox pool(§7.5):仅当 ZCBOT_SANDBOX_BACKEND=docker 时启用。
@ -248,7 +268,7 @@ def start_proc_sweeper(cfg: dict) -> asyncio.Task:
已结束容器/过期目录收掉
"""
from core.agent_builder import resolve_workspace
from core.procs import sweep as _procs_sweep
from core.procs import dispatch_queued, sweep as _procs_sweep
users_base = resolve_workspace(None, cfg) / "users"
async def _proc_sweeper() -> None:
@ -258,14 +278,19 @@ def start_proc_sweeper(cfg: dict) -> asyncio.Task:
stats = await loop.run_in_executor(
None, _procs_sweep, users_base
)
from core.sandbox import get_pool
pool = get_pool()
started = await loop.run_in_executor(None, dispatch_queued, users_base, pool) if pool is not None else 0
if stats["removed_dirs"] or stats["reaped_containers"]:
print(f"[proc-sweep] dirs={stats['removed_dirs']} "
f"containers={stats['reaped_containers']}")
if started:
print(f"[proc-sweep] started queued={started}")
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[proc-sweep] error: {type(e).__name__}: {e}")
await asyncio.sleep(3600)
await asyncio.sleep(30)
return asyncio.create_task(_proc_sweeper(), name="proc-sweeper")

View File

@ -70,8 +70,9 @@ def resolve_model_profile(profile: str, user_id: Optional[UUID] = None) -> tuple
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, f"invalid model_profile {name!r}: {e}")
assert_model_allowed(name, user_id, "text")
return name, caps.model_id
canonical = caps.profile
assert_model_allowed(canonical, user_id, "text")
return canonical, caps.model_id
def skill_pinned_profiles() -> set:

View File

@ -261,6 +261,16 @@ def register_message_routes(app, *, require_user) -> None:
cur_profile = task.model_profile or ""
if cur_profile:
from core.model_access import is_allowed
# 旧模型 profile 先解析成隐藏兼容别名的正式目标,避免被误判成档外
# 后降到 DeepSeek本次持久化 canonical profile后续直接走新模型。
try:
canonical_profile, canonical_model = resolve_model_profile(cur_profile)
except HTTPException:
canonical_profile, canonical_model = cur_profile, task.model
if canonical_profile != cur_profile:
values["model_profile"] = canonical_profile
values["model"] = canonical_model
cur_profile = canonical_profile
urow = s.execute(
select(User.plan, User.role).where(User.user_id == user_id)
).first()
@ -482,13 +492,12 @@ def register_message_routes(app, *, require_user) -> None:
回复期间也可润色下一条草稿
- image_model 影响 meta-prompt 里给 LLM 的下游 tool 提示;不动 DB
"""
from decimal import Decimal
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.llm_transport import extract_usage_details
from core.paths import ROOT
from core.storage.models import UsageEvent
from core.storage.usage import USD_TO_CNY
from core.storage.usage import record_chat_usage
try:
tid = UUID(task_id)
@ -511,6 +520,10 @@ def register_message_routes(app, *, require_user) -> None:
cfg = load_config()
chosen_profile = task_model_profile or cfg["default_model"]
try:
chosen_profile, _ = resolve_model_profile(chosen_profile)
except HTTPException:
pass
# 档位门控:task 存量模型已不在用户档位内 → 润色也落回 flash(与 send 路径一致,
# 不持久改 task,仅本次润色调用降级)。
if chosen_profile and not model_allowed_for_user(chosen_profile, user_id):
@ -576,37 +589,38 @@ def register_message_routes(app, *, require_user) -> None:
if not optimized:
raise HTTPException(502, "llm returned empty optimization")
usage = getattr(response, "usage", None)
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
try:
from litellm import completion_cost
cost_usd_raw = completion_cost(completion_response=response)
cost_usd = Decimal(str(cost_usd_raw)) if cost_usd_raw else Decimal("0")
except Exception:
cost_usd = Decimal("0")
cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001"))
usage = extract_usage_details(getattr(response, "usage", None))
prompt_tokens = usage["tokens_in"]
completion_tokens = usage["tokens_out"]
try:
with session_scope() as s:
s.add(UsageEvent(
cost_cny = record_chat_usage(
user_id=user_id,
task_id=tid,
message_id=None,
kind="prompt_optimize",
model_profile=chosen_profile,
units={
"tokens_in": prompt_tokens,
"tokens_out": completion_tokens,
"usd_to_cny": float(USD_TO_CNY),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=caps.cache_hit_cny_per_mtoken,
pricing=caps.pricing,
response=response,
extra_units={
"image_model_hint": img_variant or "",
"video_model_hint": vid_variant or "",
**{
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
cost_cny=cost_cny,
))
},
)
except Exception as e:
# 记账失败不阻塞返结果 — 用户拿到润色文本要紧,事后人工补
print(f"[optimize_prompt] usage record failed: {type(e).__name__}: {e}", flush=True)
cost_cny = 0
return {
"optimized": optimized,
@ -622,7 +636,7 @@ def register_message_routes(app, *, require_user) -> None:
user_id: UUID = Depends(require_user),
):
"""SSE 流。订阅当前 task 的活动 event(单活 run 形态下无歧义)。
事件类型:run_start / llm_start / text / reasoning / tool_call /
事件类型:run_start / llm_start / text / reasoning / reasoning_reset / tool_call /
tool_result / llm_end / cancelled / error / donedata JSON dict
(已剔除 `type` 字段,移到 event )
"""

View File

@ -53,7 +53,7 @@ def register_misc_routes(app, *, require_user) -> None:
"brand": BRAND}
@app.get("/v1/changelog", tags=["misc"])
def changelog(limit: int = 20):
def changelog(limit: int = 20, offset: int = 0):
"""用户版更新日志(仓库根 CHANGELOG.md 解析,新在前)。
公开端点(无鉴权):内容本就是写给用户看的,登录页脚也展示版本号
@ -61,15 +61,26 @@ def register_misc_routes(app, *, require_user) -> None:
前端:点左栏底部版本号弹层展示, localStorage 红点提示新版本
"""
limit = max(1, min(limit, 100))
offset = max(0, offset)
try:
mtime = _CHANGELOG_PATH.stat().st_mtime
except OSError:
return {"current": __version__, "entries": []}
return {"current": __version__, "entries": [], "total": 0,
"has_more": False, "next_offset": offset}
if _changelog_cache["mtime"] != mtime:
_changelog_cache["entries"] = _parse_changelog(
_CHANGELOG_PATH.read_text(encoding="utf-8"))
_changelog_cache["mtime"] = mtime
return {"current": __version__, "entries": _changelog_cache["entries"][:limit]}
entries = _changelog_cache["entries"]
page = entries[offset:offset + limit]
next_offset = offset + len(page)
return {
"current": __version__,
"entries": page,
"total": len(entries),
"has_more": next_offset < len(entries),
"next_offset": next_offset,
}
@app.get("/WW_verify_{token}.txt", include_in_schema=False)
def wecom_domain_verify(token: str):

View File

@ -41,7 +41,9 @@ def register_model_routes(app, *, require_user) -> None:
except Exception:
continue
family = data.get("family") or path.stem
for variant in (data.get("variants") or {}).keys():
for variant, variant_cfg in (data.get("variants") or {}).items():
if isinstance(variant_cfg, dict) and variant_cfg.get("hidden"):
continue
profile = f"{family}.{variant}"
if allowed is not None and profile not in allowed:
continue

View File

@ -322,6 +322,7 @@ def register_task_routes(app, *, require_user) -> None:
window_messages,
compact_threshold_chars=compact_threshold,
reasoning_replay=caps.reasoning_replay,
target_model_profile=caps.profile,
)
window_chars = context_stats["original_chars"]
except Exception:

View File

@ -4,11 +4,13 @@
broker.emit(task_id, event)sink 实例由 web 层在启 run 时创建,传进 AgentLoop;
loop 完全不知 web 存在(§5 Less Scaffolding)
"""
from __future__ import annotations
from typing import Any
from uuid import UUID
class WebEventSink:
def __init__(self, broker, task_id: UUID) -> None:
# broker: LocalRunBroker | RedisRunBroker(鸭子类型同接口,web/broker.py)
@ -16,4 +18,10 @@ class WebEventSink:
self._task_id = task_id
def emit(self, event: dict[str, Any]) -> None:
# AgentLoop 的 done 早于 web worker 把 tasks.run_status 落回 idle。若把这个
# 早到终态直接发给浏览器,客户端收尾回读可能仍看到 running继而重建一张
# 永不该存在的“思考中”卡。Web run 的唯一终态由 run_agent_bg finally 中的
# broker.close() 在 DB 状态写完后发送CLI 等非 Web sink 不受影响。
if event.get("type") == "done":
return
self._broker.emit(self._task_id, event)

View File

@ -1,4 +1,4 @@
"""Software Job 终态后的固定报告与 Agent 自动续跑。"""
"""Software Job 终态后的结果账本收尾与 Agent 自动续跑。"""
from __future__ import annotations
import asyncio
@ -27,89 +27,6 @@ class FollowupClaim:
prompt: str = ""
def _artifact_refs(manifest: list) -> list[dict]:
refs: list[dict] = []
for item in manifest:
if not isinstance(item, dict) or not item.get("artifact_id") or not item.get("path"):
continue
refs.append({
"path": item["path"],
"label": item.get("filename") or item["path"].rsplit("/", 1)[-1],
"artifact_id": item["artifact_id"],
"version": 2,
})
return refs
def _preview_refs(manifest: list) -> list[dict]:
return [
{
"path": item.get("path") or item["filename"],
"label": item["filename"],
"preview_url": item["url"],
"kind": "software_preview",
}
for item in manifest
if isinstance(item, dict)
and item.get("filename")
and item.get("url")
]
def _report_text(job: SoftwareJob) -> str:
contract = get_contract(job.capability)
if job.status == "failed":
error = job.error if isinstance(job.error, dict) else {}
code = str(error.get("code") or "SOFTWARE_JOB_FAILED")[:100]
detail = " ".join(str(error.get("detail") or "未提供具体错误信息").split())[:500]
return (
f"专业软件任务执行失败:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- 错误代码:`{code}`\n"
f"- 原因:{detail}\n"
"- 结果:任务未生成正式产物;调整输入或运行环境后可重新提交"
)
if job.status == "cancelled":
return (
f"专业软件任务已取消:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
"- 结果:任务已停止,未完成的中间输出不会作为正式产物发布"
)
if contract.workspace is not None and getattr(job, "workspace_id", None) is not None:
exported = [
str(item.get("filename"))
for item in job.artifact_manifest
if isinstance(item, dict) and item.get("artifact_id") and item.get("filename")
]
if exported:
return (
f"专业软件文件已导出:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- 导出文件:{''.join(exported)}\n"
"- Workspace 工程状态仍保留在 Windows Node可继续加工"
)
return (
f"专业软件任务已完成:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- Workspace ID`{job.workspace_id}`\n"
"- 工程状态:已保存在 Windows Node可继续加工\n"
"- 交付方式:需要源文件或最终格式时请明确提出导出"
)
output_dir = f"{contract.output_namespace}/{job.job_id}"
names = [
str(item.get("filename"))
for item in job.artifact_manifest
if isinstance(item, dict) and item.get("artifact_id") and item.get("filename")
]
files = "".join(names) if names else "无可发布文件"
return (
f"专业软件任务已完成:{contract.display_name}\n\n"
f"- Job ID`{job.job_id}`\n"
f"- 输出目录:`{output_dir}`\n"
f"- 结果文件:{files}"
)
def _analysis_prompt(job: SoftwareJob) -> str:
contract = get_contract(job.capability)
if contract.workspace is not None and getattr(job, "workspace_id", None) is not None:
@ -164,7 +81,7 @@ def requeue_stale_analysis_followups() -> int:
def claim_followup(job_id: UUID) -> FollowupClaim | None:
"""task 空闲时原子领取回调report 在事务内直接落固定 assistant 消息"""
"""原子领取回调report 只完成账本analyze 等 task 空闲后进入对话"""
with session_scope() as session:
candidate = session.execute(
select(SoftwareJob.task_id).where(SoftwareJob.job_id == job_id)
@ -174,7 +91,7 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None:
task = session.execute(
select(Task).where(Task.task_id == candidate).with_for_update()
).scalar_one_or_none()
if task is None or task.run_status in {"running", "cancelling"}:
if task is None:
return None
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
@ -186,23 +103,7 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None:
):
return None
next_idx = allocate_message_idx(session, task.task_id, locked_task=task)
if job.status != "succeeded" or job.completion_action == "report":
session.add(Message(
task_id=task.task_id,
idx=next_idx,
payload={"role": "assistant", "content": _report_text(job)},
artifact_refs=(
[]
if job.status != "succeeded"
else (
_artifact_refs(job.artifact_manifest)
if job.artifact_manifest
else _preview_refs(job.preview_manifest)
)
),
kind="software_job_report",
))
job.followup_status = "completed"
return FollowupClaim(
job_id=job.job_id,
@ -211,7 +112,11 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None:
action="report",
)
if task.run_status in {"running", "cancelling"}:
return None
prompt = _analysis_prompt(job)
next_idx = allocate_message_idx(session, task.task_id, locked_task=task)
session.add(Message(
task_id=task.task_id,
idx=next_idx,

View File

@ -6,84 +6,148 @@
<title>zcbot 管理后台</title>
<style>
:root {
--bg: #f7f7f7; --panel: #ffffff; --border: #e3e3e3; --border-soft: #ececec;
--text: #222; --muted: #888; --accent: #c0392b; --accent-soft: #fde9e7;
--ok: #2e7d32; --warn: #c87f0a; --danger: #c0392b;
--r-md: 4px; --r-lg: 8px;
color-scheme: light;
--bg: #f5f6f7; --panel: #ffffff; --panel-soft: #f7f8f9;
--border: #dfe3e6; --border-soft: #eceff1;
--text: #202428; --text-strong: #121619; --muted: #70777f;
--accent: #b83a31; --accent-hover: #a9322a; --accent-soft: #fbecea;
--accent-ring: rgba(184,58,49,.16);
--ok: #238653; --warn: #b66a16; --danger: #b83a31;
--r-md: 5px; --r-lg: 8px;
--shadow-soft: 0 1px 2px rgba(18,24,30,.035);
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg); font-size: 13px; line-height: 1.5;
color: var(--text); background: var(--bg); font-size: 13px; line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
header {
position: sticky; top: 0; z-index: 10; background: #fff;
border-bottom: 1px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,.03);
display: flex; align-items: center; gap: 12px; padding: 10px 16px; flex-wrap: wrap;
position: sticky; top: 0; z-index: 10; min-height: 54px;
background: rgba(255,255,255,.94); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border); box-shadow: 0 1px 0 rgba(18,24,30,.02);
display: flex; align-items: center; gap: 12px; padding: 8px max(18px, calc((100vw - 1480px) / 2));
}
header .brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
header .logo {
width: 24px; height: 24px; border-radius: var(--r-md);
width: 30px; height: 30px; flex: 0 0 auto; border-radius: 7px;
background: linear-gradient(135deg, var(--accent), #8e2a20);
color: #fff; font-weight: 700; font-size: 13px;
box-shadow: 0 3px 9px rgba(184,58,49,.18);
color: #fff; font-weight: 750; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
header .title { font-weight: 600; font-size: 15px; }
header .brand-copy { min-width: 0; }
header .title { color: var(--text-strong); font-weight: 650; font-size: 14px; line-height: 1.2; }
header .eyebrow { margin-top: 1px; color: var(--muted); font-size: 10px; letter-spacing: .7px; text-transform: uppercase; }
header .spacer { flex: 1; }
header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); }
header .actions { display: flex; align-items: center; gap: 6px; }
header a, header button {
font-size: 12px; color: var(--accent); text-decoration: none;
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
background: #fff; cursor: pointer;
min-height: 30px; display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 550; color: var(--text); text-decoration: none;
padding: 4px 10px; border: 1px solid var(--border); border-radius: var(--r-md);
background: #fff; cursor: pointer; transition: .15s ease;
}
header a:hover, header button:hover { background: var(--accent-soft); }
header label.auto { color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
header a:hover, header button:hover { border-color: #c8cdd2; background: var(--panel-soft); color: var(--accent); }
header a { color: var(--accent); border-color: #edcfcc; }
header label.auto { color: var(--muted); display: flex; align-items: center; gap: 6px; padding: 0 4px; cursor: pointer; white-space: nowrap; }
input[type="checkbox"] { accent-color: var(--accent); }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
.msg { padding: 40px 16px; text-align: center; color: var(--muted); }
main { padding: 16px 18px 30px; max-width: 1480px; margin: 0 auto; }
.msg { padding: 28px 14px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); }
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */
#layout { display: grid; grid-template-columns: 132px 1fr; gap: 18px; align-items: start; }
#toc { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; }
#toc a {
color: var(--muted); text-decoration: none; font-size: 13px; padding: 6px 10px;
border-radius: var(--r-md); border-left: 2px solid transparent;
#layout { display: grid; grid-template-columns: 158px minmax(0, 1fr); gap: 14px; align-items: start; }
#toc {
position: sticky; top: 70px; display: flex; flex-direction: column; gap: 3px;
padding: 8px; background: rgba(255,255,255,.88); border: 1px solid var(--border);
border-radius: var(--r-lg); box-shadow: var(--shadow-soft);
}
#toc a:hover { background: var(--accent-soft); color: var(--accent); }
#toc a.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.anchor { scroll-margin-top: 64px; } /* 滚动定位避开 sticky 顶栏 */
#toc .toc-label { padding: 4px 9px 7px; color: #8a9299; font-size: 10px; font-weight: 700; letter-spacing: .8px; }
#toc a {
position: relative; display: flex; align-items: center; gap: 8px; min-height: 34px;
color: var(--muted); text-decoration: none; font-size: 12.5px; padding: 6px 8px;
border-radius: 6px; border: 1px solid transparent; transition: .15s ease;
}
#toc .nav-index { width: 20px; flex: 0 0 auto; color: #9aa2a9; font: 10px/1 var(--mono); }
#toc a > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#toc a:hover { background: var(--panel-soft); color: var(--text-strong); }
#toc a.active { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); font-weight: 650; }
#toc a.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
#toc a.active .nav-index { color: var(--accent); }
#content { min-width: 0; }
.page-intro {
position: relative; overflow: hidden; margin-bottom: 10px; padding: 13px 16px;
border: 1px solid #e6d9d7; border-radius: var(--r-lg);
background: linear-gradient(120deg, #fff 0%, #fff 54%, #fdf4f2 100%); box-shadow: var(--shadow-soft);
}
.page-intro::after {
content: ""; position: absolute; right: -55px; top: -75px; width: 170px; height: 170px;
border: 32px solid rgba(184,58,49,.045); border-radius: 50%; pointer-events: none;
}
.page-intro h1 { position: relative; margin: 0; color: var(--text-strong); font-size: 17px; line-height: 1.3; }
.page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.anchor { scroll-margin-top: 68px; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
/* 首屏运营摘要:第一排业务运行,第二排基础设施与质量。 */
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.summary-card {
min-width: 0; min-height: 142px; display: flex; flex-direction: column; padding: 13px 14px 11px;
color: inherit; text-decoration: none; background: var(--panel); border: 1px solid var(--border);
border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease; text-align: left;
}
button.summary-card { width: 100%; appearance: none; font: inherit; cursor: pointer; }
.summary-card[href]:hover, button.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
.summary-card[href]:focus-visible, button.summary-card:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; border-color: var(--accent); }
.summary-card .label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.summary-card .label { min-width: 0; color: #626b73; font-size: 13px; font-weight: 650; }
.summary-card-actions { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.summary-card .jump { flex: 0 0 auto; color: #929aa1; font-size: 14px; }
.summary-detail { color: #899198; font-size: 10px; white-space: nowrap; }
.summary-badge {
display: inline-flex; align-items: center; min-height: 20px; padding: 2px 7px; border-radius: 999px;
color: #606970; background: #f0f2f3; font-size: 10px; font-weight: 700; white-space: nowrap;
}
.summary-badge.ok { color: var(--ok); background: #e8f5e9; }
.summary-badge.warn { color: var(--warn); background: #fff0d8; }
.summary-badge.danger { color: var(--danger); background: var(--accent-soft); }
.summary-card .summary-value { margin-top: 6px; color: var(--text-strong); font: 680 28px/1.15 var(--mono); letter-spacing: -.7px; }
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0; white-space: nowrap; }
.summary-card .summary-sub { margin-top: 5px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.summary-card .summary-meta { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 8px; color: var(--muted); font-size: 11px; }
.summary-card .summary-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary-card .summary-meta span:last-child { text-align: right; }
.summary-card .summary-meter { height: 4px; margin-top: 6px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
.summary-card .summary-meter > i { display: block; width: var(--value); height: 100%; border-radius: inherit; background: #89939d; }
.summary-card.warn { border-color: #efd8b7; background: #fffaf2; }
.summary-card.warn .summary-value { color: var(--warn); }
.summary-card.warn .summary-meter > i { background: var(--warn); }
.summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); }
.summary-card.danger .summary-value { color: var(--danger); }
.summary-card.danger .summary-meter > i { background: var(--danger); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
.card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; }
.ctrl select {
font-size: 12px; padding: 3px 6px; border: 1px solid var(--border);
min-height: 30px; font-size: 12px; padding: 3px 26px 3px 8px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text);
}
.sublabel { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.sublabel { color: var(--muted); font-size: 10px; margin-bottom: 3px; font-weight: 600; letter-spacing: .2px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
/* auto-fit 会折叠空轨道,让不足一整行的指标卡平分剩余宽度。 */
.grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
padding: 14px 16px; margin-bottom: 14px;
padding: 12px 14px; margin-bottom: 10px; box-shadow: var(--shadow-soft);
}
.card h2 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .3px; }
/* 大数字 stat 块 */
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 12px 14px; }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 22px; font-weight: 600; font-family: var(--mono); margin-top: 4px; }
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
.stat.warn { border-color: var(--warn); background: #fff8ec; }
.stat.warn .v { color: var(--warn); }
.stat.danger { border-color: var(--danger); background: var(--accent-soft); }
.stat.danger .v { color: var(--danger); }
.card h2 { margin: 0 0 8px; font-size: 13px; color: var(--text-strong); font-weight: 650; letter-spacing: .1px; }
/* chips(状态分布) */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
font-size: 12px; padding: 3px 9px; border-radius: 999px;
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: #f1f1f1; color: var(--text); font-family: var(--mono);
}
.chip b { font-weight: 700; }
@ -91,10 +155,15 @@
.chip.run { background: #e7f3ff; color: #1565c0; }
.chip.ok { background: #e8f5e9; color: var(--ok); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
th, td { padding: 7px 9px; text-align: right; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 600; }
th { color: #626a72; background: var(--panel-soft); font-size: 11px; font-weight: 650; letter-spacing: .1px; }
thead th:first-child { border-radius: 7px 0 0 7px; }
thead th:last-child { border-radius: 0 7px 7px 0; }
tbody tr { transition: background-color .12s ease; }
tbody tr:hover { background: #fafbfc; }
tbody tr:last-child td { border-bottom-color: transparent; }
td.num { font-family: var(--mono); }
td.num.hot { color: var(--danger); font-weight: 700; }
td.trend { font-family: var(--mono); letter-spacing: 1px; color: var(--accent); }
@ -102,7 +171,7 @@
td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
.bar-cell { position: relative; }
tfoot .total-row td { border-top: 2px solid var(--border); border-bottom: none; font-weight: 700; }
.scroll-x { overflow-x: auto; }
.scroll-x { overflow-x: auto; scrollbar-color: #cbd1d6 transparent; scrollbar-width: thin; }
.empty { color: var(--muted); padding: 8px; text-align: center; }
#ext-admin-form label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; }
#ext-admin-form [hidden] { display: none; }
@ -160,6 +229,65 @@
#app-dialog button { padding: 5px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; cursor: pointer; }
#app-dialog button.primary { color: #fff; border-color: var(--accent); background: var(--accent); }
/* 实时执行容量使用右侧抽屉,避免在摘要卡内堆叠监控明细。 */
html.drawer-open, body.drawer-open { overflow: hidden; }
.capacity-drawer {
display: none; position: fixed; inset: 0; z-index: 140; justify-content: flex-end;
background: rgba(18,24,30,.28); backdrop-filter: blur(1px);
}
#s-provider-credentials button {
font-size: 12px; padding: 4px 8px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; cursor: pointer;
}
#s-provider-credentials button:disabled { opacity: .45; cursor: default; }
.capacity-drawer.show { display: flex; }
.capacity-drawer-panel {
width: min(460px, calc(100vw - 32px)); height: 100%; display: flex; flex-direction: column;
background: var(--panel); border-left: 1px solid var(--border); box-shadow: -12px 0 34px rgba(18,24,30,.14);
}
.capacity-drawer-head {
min-height: 58px; display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-bottom: 1px solid var(--border); background: rgba(255,255,255,.96);
}
.capacity-drawer-head > div { min-width: 0; flex: 1; }
.capacity-drawer-head h2 { margin: 0; color: var(--text-strong); font-size: 15px; }
.capacity-drawer-head p { margin: 2px 0 0; color: var(--muted); font-size: 10px; }
.capacity-drawer-close {
width: 30px; height: 30px; flex: 0 0 auto; padding: 0; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--muted); background: #fff; font-size: 20px; line-height: 1; cursor: pointer;
}
.capacity-drawer-close:hover { color: var(--text-strong); background: var(--panel-soft); }
.capacity-drawer-body { min-height: 0; padding: 16px; overflow-y: auto; overscroll-behavior: contain; }
.capacity-alert {
display: grid; gap: 3px; margin-bottom: 12px; padding: 10px 12px; border: 1px solid #edc1bd;
border-radius: var(--r-lg); color: var(--danger); background: var(--accent-soft);
}
.capacity-alert span { font-size: 11px; }
.capacity-hero { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 8px; }
.capacity-hero > div { min-width: 0; padding: 11px; border: 1px solid var(--border-soft); border-radius: var(--r-lg); background: var(--panel-soft); }
.capacity-hero strong { display: block; overflow: hidden; color: var(--text-strong); font: 700 21px/1.2 var(--mono); text-overflow: ellipsis; }
.capacity-hero span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
.capacity-detail-section { margin-top: 18px; }
.capacity-detail-section h3 { margin: 0 0 7px; color: #5f6870; font-size: 11px; font-weight: 700; }
.capacity-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.capacity-detail-item { min-width: 0; padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-detail-item > span, .capacity-detail-item small { display: block; color: var(--muted); font-size: 10px; }
.capacity-detail-item strong { display: block; margin: 2px 0; color: var(--text-strong); font: 700 18px/1.2 var(--mono); }
.capacity-detail-item.warn { border-color: #efd8b7; background: #fffaf2; }
.capacity-detail-item.warn strong { color: var(--warn); }
.capacity-user-list { overflow: hidden; border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-user-list > div, .capacity-resource-list > div {
display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px;
padding: 7px 10px; border-bottom: 1px solid var(--border-soft);
}
.capacity-user-list > div:last-child, .capacity-resource-list > div:last-child { border-bottom: 0; }
.capacity-user-list code { overflow: hidden; color: var(--text); text-overflow: ellipsis; }
.capacity-user-list span, .capacity-resource-list span { color: var(--muted); font-size: 11px; }
.capacity-resource-list { border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-resource-list strong { color: var(--text-strong); font: 650 12px/1.3 var(--mono); text-align: right; }
.capacity-empty { padding: 24px 12px; color: var(--muted); text-align: center; }
.capacity-empty.compact { padding: 14px 10px; border: 1px dashed var(--border); border-radius: var(--r-md); font-size: 11px; }
#s-windows-node button, #node-enrollment-modal button {
font-size: 12px; padding: 5px 11px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text); cursor: pointer;
@ -186,13 +314,20 @@
.node-table th:nth-child(1), .node-table td:nth-child(1) { width: 260px; }
.node-table th:nth-child(2), .node-table td:nth-child(2) { width: 80px; text-align: left; }
.node-table th:nth-child(3), .node-table td:nth-child(3) { text-align: left; }
.node-table th:nth-child(4), .node-table td:nth-child(4) { width: 90px; }
.node-table th:nth-child(5), .node-table td:nth-child(5) { width: 150px; }
.node-table th:nth-child(4), .node-table td:nth-child(4) { width: 92px; text-align: center; }
.node-table th:nth-child(5), .node-table td:nth-child(5) { width: 90px; }
.node-table th:nth-child(6), .node-table td:nth-child(6) { width: 150px; }
.node-capabilities { min-width: 360px; }
.node-capability { display: flex; align-items: center; gap: 8px; min-width: max-content; }
.node-capability + .node-capability { margin-top: 5px; }
.node-capability-version { color: var(--muted); font: 11px/1.4 var(--mono); }
.node-empty-capability { color: var(--muted); }
.node-task-count {
display: inline-flex; align-items: center; justify-content: center; min-width: 28px;
padding: 2px 8px; border-radius: 999px; background: #f1f3f4; color: var(--muted);
font: 600 12px/1.5 var(--mono);
}
.node-task-count.busy { background: #fff0de; color: var(--warn); }
.node-actions { text-align: right; }
#node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; }
.node-modal-head, .node-modal-actions {
@ -234,24 +369,38 @@
.app-msg.success { border-color: #b8ddb9; background: #f1faf1; color: var(--ok); }
.app-msg.error { border-color: #edc1bd; background: var(--accent-soft); color: var(--danger); }
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; }
.pager { display: flex; align-items: center; gap: 8px; justify-content: flex-end; margin-top: 7px; }
.pager button {
font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md);
min-height: 30px; font-size: 12px; padding: 3px 10px; border: 1px solid var(--border); border-radius: var(--r-md);
background: #fff; cursor: pointer; color: var(--text);
}
.pager button:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.pager button:disabled { opacity: .45; cursor: default; }
.pager .pginfo { color: var(--muted); font-size: 12px; font-family: var(--mono); }
@media (max-width: 640px) {
button, select, input, textarea { font: inherit; }
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
select:focus, input:focus, textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-ring); outline: none; }
@media (min-width: 821px) and (max-width: 1240px) {
#layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; }
#toc .nav-index { display: none; }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 820px) {
main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.stat .v { font-size: 18px; }
.grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
/* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */
header { padding: 8px 10px; gap: 8px; }
header { padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
header .title { font-size: 14px; }
header .eyebrow { display: none; }
header .meta { font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header a, header button { padding: 4px 8px; }
header .actions { order: 3; width: 100%; overflow-x: auto; padding-bottom: 1px; }
header .actions > * { flex: 0 0 auto; }
/* card-head 允许换行,标题长 + 下拉不再撑出横向溢出 */
.card-head { flex-wrap: wrap; }
.ctrl { flex-wrap: wrap; }
@ -262,12 +411,35 @@
/* 窄屏:目录变顶部横向 chip 条 */
#layout { grid-template-columns: 1fr; gap: 10px; }
#toc {
flex-direction: row; overflow-x: auto; gap: 6px; top: 52px;
background: var(--bg); padding: 6px 0; z-index: 5;
flex-direction: row; overflow-x: auto; gap: 6px; top: 109px;
background: rgba(244,246,248,.94); backdrop-filter: blur(8px); padding: 6px 0; z-index: 5;
border: 0; border-radius: 0; box-shadow: none;
}
#toc a { white-space: nowrap; border-left: none; border: 1px solid var(--border); }
#toc .toc-label, #toc .nav-index { display: none; }
#toc a { min-height: 32px; white-space: nowrap; border: 1px solid var(--border); background: #fff; }
#toc a.active { border-color: var(--accent); }
.anchor { scroll-margin-top: 96px; }
.page-intro { padding: 12px 14px; }
.page-intro h1 { font-size: 17px; }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.summary-card { min-height: 140px; }
.summary-card .summary-value { font-size: 26px; }
.anchor { scroll-margin-top: 150px; }
.card { padding: 12px; }
}
@media (max-width: 480px) {
.ops-summary { grid-template-columns: 1fr; }
.summary-card { min-height: 138px; }
.capacity-drawer { align-items: flex-end; }
.capacity-drawer-panel { width: 100%; height: min(90vh, 760px); border: 0; border-radius: 14px 14px 0 0; }
.capacity-hero { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.capacity-hero > div { padding: 9px 8px; }
.capacity-hero strong { font-size: 18px; }
.capacity-detail-grid { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
/* 屏幕上隐藏打印报告;打印时只显它 */
@ -290,14 +462,21 @@
</head>
<body>
<header>
<div class="brand">
<div class="logo">Z</div>
<div class="brand-copy">
<div class="title">zcbot 管理后台</div>
<div class="eyebrow">Operations Console</div>
</div>
</div>
<span class="meta" id="gen-at"></span>
<div class="spacer"></div>
<div class="actions">
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新</button>
<button id="refresh">刷新数据</button>
<button id="export">导出 PDF</button>
<a href="/static/dev.html">← 返回控制台</a>
<a href="/static/dev.html">返回控制台</a>
</div>
</header>
<main id="main">
<div class="msg" id="boot">加载中…</div>
@ -305,10 +484,23 @@
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
<div id="print-report"></div>
<div id="app-dialog" class="modal"></div>
<div id="capacity-drawer" class="capacity-drawer" aria-hidden="true">
<aside class="capacity-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="capacity-drawer-title">
<div class="capacity-drawer-head">
<div>
<h2 id="capacity-drawer-title">执行容量详情</h2>
<p>自动刷新前后台队列、容器生命周期与宿主资源</p>
</div>
<span id="capacity-drawer-status" class="summary-badge"></span>
<button id="capacity-drawer-close" class="capacity-drawer-close" type="button" aria-label="关闭执行容量详情">×</button>
</div>
<div id="capacity-drawer-body" class="capacity-drawer-body"></div>
</aside>
</div>
<div id="node-enrollment-modal" class="modal" aria-hidden="true">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="node-enrollment-title">
<div class="node-modal-head">
<h3 id="node-enrollment-title">生成 Windows Node 注册码</h3>
<h3 id="node-enrollment-title">生成专业软件节点注册码</h3>
<button id="node-enrollment-close" type="button" aria-label="关闭">×</button>
</div>
<form id="node-enrollment-form">

View File

@ -272,6 +272,14 @@
.cl-md { font-size: 13px; }
.cl-md ul { margin: 4px 0; padding-left: 18px; }
.cl-md li { margin: 2px 0; }
.cl-more { display: flex; justify-content: center; padding: 14px 0 4px; }
.cl-more button {
border: 1px solid var(--border); border-radius: 8px; padding: 6px 18px;
background: var(--surface); color: var(--text); cursor: pointer;
}
.cl-more button:hover { border-color: var(--accent); color: var(--accent); }
.cl-more button:disabled { cursor: default; opacity: .55; }
.cl-load-error { color: var(--danger); font-size: 12px; text-align: center; padding-top: 10px; }
@media (max-width: 760px) {
#changelog-modal .card { width: 96vw; max-height: 88vh; }
}
@ -843,36 +851,105 @@
0%, 100% { box-shadow: none; }
18% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 6px var(--accent-soft); }
}
/* 消息目录:悬浮在对话右缘的圆点轨道。容器 pointer-events:none 让圆点间隙漏给
滚动条/正文(不挡滚动),只有圆点本身可点;hover 圆点整列展开标题(ChatGPT 式)。 */
#msg-outline-rail {
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
max-height: 72%; display: flex; flex-direction: column; align-items: flex-end;
gap: 7px; padding: 8px 4px; overflow-y: auto; overflow-x: hidden; z-index: 5;
pointer-events: none; scrollbar-width: none;
/* 对话辅助导航统一收在正文右缘:轮次在上、作业在下。默认只显示带数量的
文字入口;悬停可速览,点击后固定展开,且两组固定态互斥。 */
#conversation-side-rail {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
z-index: 6; display: flex; flex-direction: column;
width: min(176px, calc(100% - 24px)); max-height: 72%; padding: 4px;
overflow-y: auto; overflow-x: hidden; border: 1px solid rgba(225, 227, 229, .86);
border-radius: 11px; background: rgba(255, 255, 255, .92);
box-shadow: 0 4px 16px rgba(24, 32, 41, .09); backdrop-filter: blur(8px);
scrollbar-width: none; transition: border-color .15s ease, box-shadow .15s ease;
}
#msg-outline-rail::-webkit-scrollbar { display: none; }
#conversation-side-rail[hidden], .conversation-rail-section[hidden],
#conversation-side-divider[hidden] { display: none !important; }
#conversation-side-rail:hover, #conversation-side-rail:focus-within {
border-color: var(--border); box-shadow: 0 7px 22px rgba(24, 32, 41, .12);
}
#conversation-side-rail::-webkit-scrollbar { display: none; }
#conversation-side-divider { height: 1px; margin: 4px 3px; background: var(--border-soft); }
.conversation-rail-section {
display: flex; flex-direction: column; align-items: stretch; gap: 2px; min-width: 0;
}
.conversation-rail-caption {
display: grid; grid-template-columns: 1fr auto 8px; align-items: center; gap: 4px;
width: 100%; min-height: 28px; padding: 3px 5px; border: 0; border-radius: 7px;
background: var(--panel-muted); color: var(--muted); font-size: 11px;
font-weight: 600; line-height: 1; white-space: nowrap;
}
.rail-caption-label { text-align: left; }
.rail-count {
display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px;
padding: 0 4px;
border-radius: 7px; background: #eceeef; color: #60656b; font-size: 12px;
font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums;
}
#msg-outline-rail > .conversation-rail-caption,
.conversation-rail-section.pinned > .conversation-rail-caption {
color: var(--accent); background: var(--accent-soft);
}
#msg-outline-rail > .conversation-rail-caption .rail-count,
.conversation-rail-section.pinned > .conversation-rail-caption .rail-count:not(.attention) {
color: var(--accent); background: #fff; box-shadow: 0 0 0 1px rgba(184, 58, 49, .16);
}
.rail-count.attention {
color: #fff; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.rail-chevron { color: currentColor; font-size: 9px; text-align: right; transition: transform .15s ease; }
.conversation-rail-section.pinned > .conversation-rail-caption .rail-chevron { transform: rotate(180deg); }
.ol-dot, .sr-item { display: none; }
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:hover > .ol-dot,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:focus-within > .ol-dot,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:hover > .sr-item,
#conversation-side-rail:not(.has-pinned) .conversation-rail-section:focus-within > .sr-item,
.conversation-rail-section.pinned > .ol-dot,
.conversation-rail-section.pinned > .sr-item { display: flex; }
.ol-dot {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
background: transparent; border: none; padding: 0; cursor: pointer;
max-width: 22px; pointer-events: auto; transition: max-width .18s ease;
align-items: center; justify-content: flex-end; gap: 6px; min-height: 20px;
max-width: 246px; padding: 2px 5px; border: 0; border-radius: 5px;
background: transparent; cursor: pointer;
}
#msg-outline-rail:hover .ol-dot { max-width: 240px; }
.ol-dot .ol-num { display: none; }
.ol-dot .ol-label {
font-size: 12px; line-height: 1.3; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 0; opacity: 0;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 2px 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
transition: max-width .18s ease, opacity .18s ease;
max-width: 210px; overflow: hidden; color: var(--muted); font-size: 11px;
line-height: 1.3; text-overflow: ellipsis; white-space: nowrap;
}
#msg-outline-rail:hover .ol-label { max-width: 210px; opacity: 1; }
.ol-dot::after {
content: ""; flex: none; width: 16px; height: 4px; border-radius: 3px;
content: ""; flex: none; width: 12px; height: 3px; border-radius: 3px;
background: var(--border); transition: background .15s ease, width .15s ease;
}
.ol-dot:hover::after { background: var(--muted); }
.ol-dot.active::after { background: var(--accent); width: 20px; }
.ol-dot:hover, .ol-dot:focus-visible, .sr-item:hover, .sr-item:focus-visible {
background: var(--panel-muted);
}
.ol-dot:hover::after { background: #9aa0a6; }
.ol-dot.active .ol-label { color: var(--text-strong); font-weight: 600; }
.ol-dot.active::after { width: 16px; background: var(--accent); }
#mobile-software-results { display: none; }
.sr-item {
align-items: center; gap: 6px; min-height: 20px; max-width: 278px; padding: 2px 5px;
border: 0; border-radius: 5px; background: transparent; color: var(--text); cursor: pointer;
}
.sr-item::before {
content: ""; flex: none; width: 8px; height: 8px; border: 1.5px solid var(--panel);
border-radius: 50%; background: var(--border); box-shadow: 0 0 0 1px var(--border);
}
.sr-item.running::before, .sr-item.queued::before, .sr-item.dispatched::before,
.sr-item.offered::before, .sr-item.disconnected::before, .sr-item.cancelling::before {
background: var(--accent); box-shadow: 0 0 0 1px var(--accent);
animation: sr-pulse 1.4s ease-in-out infinite;
}
.sr-item.succeeded::before { background: #2f8f62; box-shadow: 0 0 0 1px #2f8f62; }
.sr-item.failed::before { background: #c43d35; box-shadow: 0 0 0 1px #c43d35; }
.sr-item.cancelled::before { background: #8a929b; box-shadow: 0 0 0 1px #8a929b; }
@keyframes sr-pulse { 50% { opacity: .45; transform: scale(.82); } }
.sr-label {
display: flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 250px;
overflow: hidden; white-space: nowrap; text-align: left;
}
.sr-label strong, .sr-label small { overflow: hidden; text-overflow: ellipsis; }
.sr-label strong { max-width: 142px; font-size: 11px; font-weight: 600; }
.sr-label small { max-width: 100px; color: var(--muted); font-size: 10px; }
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */
.msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); scroll-margin-top: 16px; }
@ -1103,10 +1180,11 @@
}
.workspace-badge {
min-width: 16px; height: 16px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center;
border-radius: 999px; background: var(--accent); color: #fff; font-size: 10px; font-style: normal; line-height: 1;
border-radius: 999px; background: var(--c-blue); color: #fff; font-size: 10px; font-style: normal; line-height: 1;
}
.sj-entry-dot { position: absolute; right: 2px; top: 1px; width: 8px; height: 8px; border: 2px solid #fafafa; border-radius: 50%; background: transparent; }
.sj-entry-dot.active { background: #e28a2b; box-shadow: 0 0 0 2px rgba(226,138,43,.14); }
.workspace-badge[hidden] { display: none; }
.sj-entry-dot { display: none; position: absolute; right: 2px; top: 1px; width: 8px; height: 8px; border: 2px solid #fafafa; border-radius: 50%; }
.sj-entry-dot.succeeded, .sj-entry-dot.failed { display: block; }
.sj-entry-dot.succeeded { background: #35a164; }
.sj-entry-dot.failed { background: var(--danger); }
.software-job-summary {
@ -1516,6 +1594,20 @@
}
.zoom-badge.show { opacity: 1; }
#file-preview-modal .body.center { display: flex; align-items: safe center; justify-content: safe center; }
#file-preview-modal .body.software-preview-gallery {
display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
align-content: start; gap: 14px; background: #f7f8fa;
}
.software-preview-item {
min-width: 0; margin: 0; padding: 10px; border: 1px solid var(--border);
border-radius: 10px; background: var(--panel);
}
.software-preview-item .ph { min-height: 160px; display: grid; place-items: center; }
.software-preview-item img.preview-img { width: 100%; max-height: 68vh; object-fit: contain; }
.software-preview-item figcaption {
margin-top: 7px; color: var(--muted); font-size: 11px; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#file-preview-modal .body .ph { color: var(--muted); font-size: 13px; text-align: center; }
.preview-spinner {
width: 22px; height: 22px; border-radius: 50%; margin: 0 auto 10px;
@ -1672,14 +1764,12 @@
header .who { display: none; }
header .brand { min-width: 0; flex-shrink: 1; }
header .title { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* tab 行:order 让 tabs 换行到 header 末行,「管理」(order 更大)跟到 tabs 右侧
成小按钮 —— 首行只留 brand + 改密码/退出。flex-basis 70% 是换行开关:
首行剩余空间塞不下 70% → tabs 必换行;换行后 70% + 管理(~15%)同行放得下 */
/* tab 行换到 header 末行。flex-basis 70% 是换行开关:
首行剩余空间塞不下 70% → tabs 必换行。 */
.mobile-tabs { display: flex; order: 99; flex: 1 1 70%; }
.mobile-tabs button { flex: 1; }
#hd-admin { order: 100; flex-shrink: 0; padding: 5px 10px; font-size: 12px; }
#hd-chpw, #hd-logout { padding: 4px 8px; font-size: 12px; }
/* embed(企业微信免登)时 brand/改密码/退出已藏 → tabs +「管理」自然收成单行 */
/* embed(企业微信免登)时 brand/改密码/退出已藏 → tabs 自然收成单行 */
body.embed-mode header .spacer { display: none; }
/* 浮层菜单:加大触控目标(桌面 6px 高度在手机上难点准) */
@ -1695,8 +1785,8 @@
/* chat / 文件 微调 */
.msg { max-width: 96%; }
/* 手机端窄屏:目录轨道会挡正文 / 滚动,直接藏(覆盖 JS 的 inline display) */
#msg-outline-rail { display: none !important; }
/* 手机端窄屏:统一辅助导航会挡正文,直接藏;软件结果仍从“作业”页查看。 */
#conversation-side-rail { display: none !important; }
#chat-meta { padding: 6px 10px; gap: 6px; font-size: 11px; }
#chat-meta .tid { display: none; }
#chat-meta .desc {
@ -1713,6 +1803,7 @@
#pane-mid > .pane-head > .label,
#pane-mid > .pane-head > .spacer { display: none; }
#pane-mid > .pane-head > button { white-space: nowrap; padding: 3px 8px; }
#mobile-software-results:not([hidden]) { display: inline-flex; }
#chat-form { padding: 8px; }
.art-media img, .art-media video { max-width: 100%; }
.file-row { padding: 8px 12px; }
@ -2071,8 +2162,6 @@
</div>
<div class="who" id="hd-who"></div>
<div class="spacer"></div>
<a id="hd-admin" href="/static/admin.html" target="_blank" rel="noopener"
title="管理后台(仅管理员)" style="display:none;">管理</a>
<button id="hd-chpw" title="修改登录密码">改密码</button>
<button id="hd-logout">退出登录</button>
<!-- 手机 tab(桌面 display:none):对话列表 / 对话 / 文件 / 软件作业 -->
@ -2157,6 +2246,9 @@
<div class="pane-head">
<span class="label">对话</span>
<span class="spacer"></span>
<a id="hd-admin" href="/static/admin.html" target="_blank" rel="noopener"
title="打开管理后台(仅管理员)" style="display:none;">管理</a>
<button id="mobile-software-results" class="small" type="button" hidden>软件结果</button>
<button id="btn-done" class="small" disabled>完成</button>
<button id="btn-task-menu" class="small dd-toggle" disabled title="更多任务操作(导出 / 清空 / 废弃 / 删除)"></button>
</div>
@ -2164,8 +2256,12 @@
<div id="wd-concurrent-warn" style="display:none;"></div>
<div id="task-progress-dock"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
<div id="msg-outline-rail" style="display:none;"></div>
<!-- 当前对话右缘的统一辅助导航:对话轮次在上、软件作业在下;两组互斥固定展开。 -->
<div id="conversation-side-rail" aria-label="当前对话辅助导航" hidden>
<div id="msg-outline-rail" class="conversation-rail-section" aria-label="对话导航" hidden></div>
<div id="conversation-side-divider" hidden></div>
<div id="software-result-rail" class="conversation-rail-section" aria-label="当前对话的软件作业" hidden></div>
</div>
<form id="chat-form" style="display:none;">
<div id="voice-rec-panel">
<div class="vr-row">

View File

@ -12,9 +12,11 @@ const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
["s-windows-node", "Windows Node"],
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
["s-users", "各用户"], ["s-storage", "存储"],
["s-sandbox-packages", "容器依赖"],
["s-windows-node", "专业软件节点"],
["s-provider-credentials", "API 凭据"],
["s-external", "外部系统"],
["s-toolfail", "工具失败"],
];
@ -50,12 +52,21 @@ let timer = null;
let modelRange = "7d", modelSort = "cost";
let userRange = "7d", userSort = "cost", userPage = 0;
let storagePage = 0;
let packageRange = "30d";
let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)
let externalDefinitions = [];
let providerCredentials = [];
let externalUsers = [];
let externalDefinitionsLoaded = false;
let externalEditingId = "";
let softwareNodes = [];
let softwareNodesLoaded = false;
let overviewData = null;
let sandboxCapacityData = null;
let storageSummaryData = null;
let toolFailuresData = null;
let toolWireData = null;
let capacityDrawerTrigger = null;
// ───── 格式化 ─────
function fmtCNY(n) {
@ -87,51 +98,255 @@ function ctrlHTML(prefix, range, sort) {
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ─────
function statCard(k, v, sub, cls) {
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
+ `<div class="v">${v}</div>`
+ (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
function summaryCard({ target = "", action = "", label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) {
const meter = progress == null ? "" : `<div class="summary-meter" aria-hidden="true"><i style="--value:${Math.max(0, Math.min(100, progress))}%"></i></div>`;
const metaHTML = meta.length
? `<div class="summary-meta"><span>${escapeHtml(meta[0] || "")}</span><span>${escapeHtml(meta[1] || "")}</span></div>`
: "";
const tag = target ? "a" : (action ? "button" : "article");
const attrs = target
? ` href="#${target}"`
: (action ? ` type="button" data-summary-action="${escapeHtml(action)}" aria-controls="capacity-drawer"` : "");
const badgeHTML = badge && badge.text
? `<span class="summary-badge ${escapeHtml(badge.tone || "")}">${escapeHtml(badge.text)}</span>`
: "";
const affordance = target ? `<span class="jump" aria-hidden="true"></span>`
: (action ? `<span class="summary-detail">查看详情</span>` : "");
return `<${tag} class="summary-card ${tone} ${extraClass}"${attrs}>`
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span><span class="summary-card-actions">${badgeHTML}${affordance}</span></div>`
+ (value ? `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>` : "")
+ (sub ? `<div class="summary-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : "")
+ metaHTML + meter + `</${tag}>`;
}
function renderRuntime(r) {
const active = r.active_runs || 0;
const max = r.max_workers || 0;
const ratio = max ? active / max : 0;
const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : "";
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
return `<div class="card"><h2>实时运行态</h2><div class="grid">`
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio))
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
+ statCard("内存峰值", rss, "进程 RSS high-water")
+ `</div></div>`;
function renderOpsSummary() {
const root = $("ops-summary");
if (!root) return;
const d = overviewData || {};
const runtime = d.runtime || {};
const tasks = d.tasks || {};
const users = d.users || {};
const usage = (d.usage || {}).total || {};
const byDay = (d.usage || {}).by_day_7d || [];
const capacity = sandboxCapacityData;
const capacityEnabled = capacity && capacity.enabled !== false;
const capacityLimits = (capacity && capacity.limits) || {};
const activeExecs = capacityEnabled
? (Number(capacity.foreground_running) || 0) + (Number(capacity.background_running) || 0)
: 0;
const activeLimit = Number(capacityLimits.active) || 0;
const foregroundQueued = Number((capacity || {}).foreground_queued) || 0;
const backgroundQueued = Number((capacity || {}).background_queued) || 0;
const queuedExecs = foregroundQueued + backgroundQueued;
const availableExecs = capacityEnabled ? Math.max(0, Number(capacity.admit_available) || 0) : 0;
const maxUserUsage = capacityEnabled
? Math.max(0, ...Object.values(capacity.per_user || {}).map(value => Number(value) || 0))
: 0;
const capacityPct = activeLimit ? Math.round(activeExecs / activeLimit * 100) : 0;
const capacityTone = capacityEnabled && capacity.memory_paused
? "danger"
: levelClass(activeLimit ? activeExecs / activeLimit : 0);
const nodeTotal = softwareNodes.length;
const nodeOnline = softwareNodes.filter(node => node.status === "online").length;
const nodeOffline = softwareNodes.filter(node => node.status === "offline").length;
const nodeDisabled = softwareNodes.filter(node => node.status === "disabled").length;
const nodeJobs = softwareNodes.reduce((sum, node) => sum + (Number(node.active_job_count) || 0), 0);
const nodeTone = softwareNodesLoaded && nodeTotal && !nodeOnline ? "danger" : (nodeOffline || nodeDisabled ? "warn" : "");
const runStatuses = tasks.by_run_status || {};
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
const errorTasks = Number(runStatuses.error) || 0;
const cancellingTasks = Number(runStatuses.cancelling) || 0;
const activeTasks = Number((tasks.by_status || {}).active) || 0;
const userTotal = Number(users.total) || 0;
const activeUsers = Number(users.active_7d) || 0;
const activeRate = userTotal ? Math.round(activeUsers / userTotal * 100) : 0;
const tokensIn = Number(usage.tokens_in) || 0;
const tokensIn7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_in) || 0), 0);
const tokensOut7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_out) || 0), 0);
const tokens7d = tokensIn7d + tokensOut7d;
const cacheHit = Number(usage.tokens_cache_hit) || 0;
const hitRate = tokensIn ? Math.round(cacheHit / tokensIn * 100) : 0;
const storage = storageSummaryData;
const storageTotals = (storage && storage.totals) || {};
const scannedUsers = storage ? Number(storage.total) || 0 : 0;
const storageValue = storage ? humanSize(storageTotals.physical_bytes || 0) : "汇总中";
const storageSub = storage
? `配额内 ${humanSize(storageTotals.bytes_used || 0)} · 回收站 ${humanSize(storageTotals.trash_bytes || 0)}`
: "正在读取存储快照";
const storageMeta = storage
? [`${storageTotals.file_count || 0} 个文件`, `已扫描 ${scannedUsers}`]
: ["加载中", ""];
const failureRows = (toolFailuresData && toolFailuresData.clusters) || [];
const activeFailures = failureRows.filter(item => (item.count_24h || 0) > 0);
const operationalFailures = activeFailures.filter(item => item.category !== "quality_gate");
const failureCount = operationalFailures.reduce((sum, item) => sum + (Number(item.count_24h) || 0), 0);
const systemicCount = activeFailures.filter(item => item.category !== "quality_gate" && item.task_count >= 2).length;
const localCount = activeFailures.filter(item => item.category !== "quality_gate" && item.task_count < 2).length;
const gateCount = activeFailures.filter(item => item.category === "quality_gate")
.reduce((sum, item) => sum + (Number(item.count_24h) || 0), 0);
const malformed = Number(((toolWireData || {}).total || {}).malformed_24h) || 0;
const toolTone = systemicCount || malformed ? "danger" : (localCount ? "warn" : "");
const cost = Number(usage.cost_cny) || 0;
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
root.innerHTML = [
summaryCard({
label: "任务运行", value: String(runningTasks), unit: "正在运行",
sub: cancellingTasks ? `取消中 ${cancellingTasks}` : (errorTasks ? "存在执行错误记录" : "当前无执行异常"),
meta: [`活动任务 ${activeTasks}`, `累计 ${tasks.total || 0}`],
badge: errorTasks ? { text: `错误 ${errorTasks}`, tone: "danger" } : { text: "正常", tone: "ok" },
}),
summaryCard({
action: capacityEnabled ? "open-capacity" : "", label: "执行容量",
value: !capacity ? "汇总中" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
unit: capacityEnabled ? "使用中 / 上限" : "",
sub: !capacity ? "正在读取容器状态" : (capacityEnabled
? `可用 ${availableExecs} · 排队 ${queuedExecs}`
: "Docker Sandbox 未启用"),
meta: capacityEnabled
? [`后台 ${Number(capacity.background_running) || 0} / ${capacityLimits.background || 0}`, `单用户最高 ${maxUserUsage} / ${capacityLimits.per_user || 0}`]
: ["", ""],
badge: capacityEnabled
? (capacity.memory_paused ? { text: "暂停放行", tone: "danger" } : (queuedExecs ? { text: `排队 ${queuedExecs}`, tone: "warn" } : { text: "正常", tone: "ok" }))
: null,
tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null,
}),
summaryCard({
target: "s-windows-node", label: "专业软件节点",
value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "汇总中", unit: softwareNodesLoaded ? "在线 / 总数" : "",
sub: softwareNodesLoaded ? `活动任务 ${nodeJobs}` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : [],
badge: softwareNodesLoaded
? (!nodeTotal ? { text: "未配置" } : (nodeTone ? { text: nodeOnline ? "部分异常" : "不可用", tone: nodeTone } : { text: "正常", tone: "ok" }))
: null,
}),
summaryCard({
target: "s-toolfail", label: "工具健康",
value: toolFailuresData ? (failureCount ? `${failureCount} 次失败` : "正常") : "汇总中",
unit: toolFailuresData ? "近 24 小时" : "",
sub: toolFailuresData
? (systemicCount || malformed ? `系统性聚集 ${systemicCount} · 链路残余 ${malformed}` : "未发现系统性工具异常")
: "正在汇总工具调用状态",
meta: toolFailuresData ? [`单任务 ${localCount}`, `质量门 ${gateCount}`] : [],
badge: toolFailuresData
? (toolTone ? { text: systemicCount || malformed ? "需处理" : "有记录", tone: toolTone } : { text: "正常", tone: "ok" })
: null,
}),
summaryCard({
target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃",
sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`,
meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
}),
summaryCard({
target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天",
sub: `缓存命中 ${hitRate}%`,
meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate,
}),
summaryCard({
target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天",
sub: `累计 ${fmtCNY(cost)}`,
}),
summaryCard({
target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "",
sub: storageSub, meta: storageMeta,
}),
].join("");
const capacityCard = root.querySelector('[data-summary-action="open-capacity"]');
if (capacityCard) capacityCard.onclick = openCapacityDrawer;
if ($("capacity-drawer").classList.contains("show")) renderCapacityDrawer();
}
function renderTasks(t) {
const order = ["active", "completed", "abandoned"];
const statusChips = Object.entries(t.by_status || {})
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
.map(([k, n]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
.join("") || `<span class="empty">无</span>`;
const runChips = Object.entries(t.by_run_status || {})
.map(([k, n]) => {
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
}).join("") || `<span class="empty">无</span>`;
return `<div class="card"><h2>任务(共 ${t.total || 0}</h2>`
+ `<div style="margin-bottom:10px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
+ `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
+ `</div>`;
function capacityDetailItem(label, value, hint = "", tone = "") {
return `<div class="capacity-detail-item ${tone}">`
+ `<span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong>`
+ (hint ? `<small>${escapeHtml(hint)}</small>` : "") + `</div>`;
}
function renderUsersAndUsage(users, usage) {
const u = usage.total || {};
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
return `<div class="card"><h2>用户与用量总览all-time</h2><div class="grid">`
+ statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`)
+ statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`)
+ statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`)
+ statCard("输出 token", fmtTokens(u.tokens_out), "")
+ `</div></div>`;
function renderCapacityDrawer() {
const capacity = sandboxCapacityData;
const body = $("capacity-drawer-body");
const status = $("capacity-drawer-status");
if (!capacity || !body || !status) return;
if (capacity.enabled === false) {
status.className = "summary-badge";
status.textContent = "未启用";
body.innerHTML = `<div class="capacity-empty">Docker Sandbox 未启用,当前没有可展示的执行容量。</div>`;
return;
}
const limits = capacity.limits || {};
const foregroundRunning = Number(capacity.foreground_running) || 0;
const backgroundRunning = Number(capacity.background_running) || 0;
const foregroundQueued = Number(capacity.foreground_queued) || 0;
const backgroundQueued = Number(capacity.background_queued) || 0;
const active = foregroundRunning + backgroundRunning;
const limit = Number(limits.active) || 0;
const queued = foregroundQueued + backgroundQueued;
const available = Math.max(0, Number(capacity.admit_available) || 0);
const users = Object.entries(capacity.per_user || {}).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0));
const cpuLoad = (capacity.cpu_load || []).map(value => Number(value).toFixed(2)).join(" / ") || "—";
status.className = `summary-badge ${capacity.memory_paused ? "danger" : (queued ? "warn" : "ok")}`;
status.textContent = capacity.memory_paused ? "暂停放行" : (queued ? `排队 ${queued}` : "运行正常");
body.innerHTML = (capacity.memory_paused
? `<div class="capacity-alert"><strong>内存压力已暂停新任务放行</strong><span>运行中的任务不受影响;资源恢复后会继续调度。</span></div>`
: "")
+ `<section class="capacity-hero" aria-label="容量摘要">`
+ `<div><strong>${active} / ${limit || "—"}</strong><span>使用中 / 上限</span></div>`
+ `<div><strong>${available}</strong><span>当前可放行</span></div>`
+ `<div><strong>${queued}</strong><span>等待调度</span></div></section>`
+ `<section class="capacity-detail-section"><h3>执行队列</h3><div class="capacity-detail-grid">`
+ capacityDetailItem("前台执行", foregroundRunning, `排队 ${foregroundQueued}`, foregroundQueued ? "warn" : "")
+ capacityDetailItem("后台执行", `${backgroundRunning} / ${Number(limits.background) || 0}`, `排队 ${backgroundQueued}`, backgroundQueued ? "warn" : "")
+ capacityDetailItem("单用户上限", Number(limits.per_user) || 0, "重型任务槽位")
+ `</div></section>`
+ `<section class="capacity-detail-section"><h3>容器生命周期</h3><div class="capacity-detail-grid">`
+ capacityDetailItem("活跃容器", Number(capacity.active_sandbox_containers) || 0)
+ capacityDetailItem("待回收", Number(capacity.idle_reap_candidates) || 0, "空闲候选", Number(capacity.idle_reap_candidates) ? "warn" : "")
+ capacityDetailItem("后台 proc", Number(capacity.running_proc_containers) || 0)
+ `</div></section>`
+ `<section class="capacity-detail-section"><h3>单用户占用</h3>`
+ (users.length
? `<div class="capacity-user-list">${users.map(([userId, count]) => `<div><code title="${escapeHtml(userId)}">${escapeHtml(userId.slice(0, 8))}</code><span>${Number(count) || 0} / ${Number(limits.per_user) || 0}</span></div>`).join("")}</div>`
: `<div class="capacity-empty compact">当前没有用户占用执行槽位</div>`)
+ `</section>`
+ `<section class="capacity-detail-section"><h3>宿主资源</h3><div class="capacity-resource-list">`
+ `<div><span>可用内存</span><strong>${humanSize(capacity.mem_available_bytes || 0)}</strong></div>`
+ `<div><span>CPU Load</span><strong>${escapeHtml(cpuLoad)}</strong></div>`
+ `</div></section>`;
}
function openCapacityDrawer() {
if (!sandboxCapacityData || sandboxCapacityData.enabled === false) return;
capacityDrawerTrigger = document.activeElement;
renderCapacityDrawer();
const drawer = $("capacity-drawer");
drawer.classList.add("show");
drawer.setAttribute("aria-hidden", "false");
document.documentElement.classList.add("drawer-open");
document.body.classList.add("drawer-open");
$("capacity-drawer-close").focus();
}
function closeCapacityDrawer() {
const drawer = $("capacity-drawer");
if (!drawer.classList.contains("show")) return;
drawer.classList.remove("show");
drawer.setAttribute("aria-hidden", "true");
document.documentElement.classList.remove("drawer-open");
document.body.classList.remove("drawer-open");
const nextTrigger = capacityDrawerTrigger && document.contains(capacityDrawerTrigger)
? capacityDrawerTrigger
: document.querySelector('[data-summary-action="open-capacity"]');
if (nextTrigger) nextTrigger.focus();
capacityDrawerTrigger = null;
}
function renderByDay(rows) {
@ -208,24 +423,26 @@ function renderWindowsNodes() {
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
: "—";
const disabled = node.status === "disabled";
const activeJobCount = Math.max(0, Number(node.active_job_count) || 0);
return `<tr data-node-id="${escapeHtml(node.node_id)}">`
+ `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</div>`
+ `<div class="node-id">${escapeHtml(node.node_id)}</div>`
+ `<div class="node-version">${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-capabilities">${capabilityHTML || `<span class="node-empty-capability">暂无可用能力</span>`}</td>`
+ `<td><span class="node-task-count ${activeJobCount ? "busy" : ""}" title="占用该节点的活动任务数">${activeJobCount}</span></td>`
+ `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
+ `<button type="button" data-node-delete class="danger">删除</button></td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="5" class="empty">尚无已注册的 Windows Node</td></tr>`;
}).join("") || `<tr><td colspan="6" class="empty">尚无已注册的专业软件节点</td></tr>`;
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
+ `<div><h2>Windows Node${softwareNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
+ `<div><h2>专业软件节点${softwareNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成专业软件节点注册码</button>`
+ `</div><div class="scroll-x"><table class="node-table"><thead><tr><th>节点 / Node 版本</th><th>状态</th>`
+ `<th>可用能力 / 软件版本</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
+ `<th>可用能力 / 软件版本</th><th>活动任务</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
button.onclick = () => toggleSoftwareNode(button);
@ -241,7 +458,7 @@ async function toggleSoftwareNode(button) {
if (!node) return;
const disabling = button.dataset.nodeToggle === "disable";
const confirmed = await dialogConfirm({
title: disabling ? "禁用 Windows Node" : "重新启用 Windows Node",
title: disabling ? "禁用专业软件节点" : "重新启用专业软件节点",
message: disabling
? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。`
: `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`,
@ -267,7 +484,7 @@ async function deleteSoftwareNode(button) {
const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return;
const confirmed = await dialogConfirm({
title: "删除 Windows Node",
title: "删除专业软件节点",
message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`,
okText: "永久删除",
danger: true,
@ -744,7 +961,7 @@ function tierLegendHTML() {
return `<div style="margin:2px 0;"><b>${escapeHtml(name)}${name === def ? "(默认)" : ""}</b>`
+ `<span style="color:var(--muted);">${members.map(escapeHtml).join("、") || "(空)"}</span></div>`;
}).join("");
return `<div class="tier-legend" style="font-size:.85em;margin:0 0 10px;padding:8px 10px;`
return `<div class="tier-legend" style="font-size:.85em;margin:0 0 7px;padding:6px 8px;`
+ `background:var(--bg-soft,#f6f6f6);border-radius:6px;">`
+ `<div style="color:var(--muted);margin-bottom:4px;">档位说明(改 config/agent.yaml model_tiersadmin 始终全开)</div>`
+ rows + `</div>`;
@ -775,12 +992,14 @@ function renderStorage(d) {
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
+ `<td class="num">${pctTxt}</td>`
+ `<td class="num">${r.file_count || 0}</td>`
+ `<td class="num" title="${r.trash_file_count || 0} 个文件">${humanSize(r.trash_bytes || 0)}</td>`
+ `<td class="num">${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}</td>`
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
+ `<thead><tr><th>用户</th><th>配额占用</th><th>占配额</th><th>文件数</th><th>回收站</th><th>物理合计</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>`
+ pagerHTML("st", page, maxPage, from, to, total)
+ `</div>`;
@ -897,11 +1116,14 @@ function wirePager(prefix, page, maxPage, go) {
function ensureSkeleton() {
if ($("layout")) return;
$("main").innerHTML = `<div id="layout">`
+ `<nav id="toc">` + SECTIONS.map(([id, label]) =>
`<a href="#${id}" data-target="${id}">${label}</a>`).join("") + `</nav>`
+ `<div id="content">` + SECTIONS.map(([id]) =>
+ `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">页面导航</div>` + SECTIONS.map(([id, label], index) =>
`<a href="#${id}" data-target="${id}"><span class="nav-index">${String(index + 1).padStart(2, "0")}</span><span>${label}</span></a>`).join("") + `</nav>`
+ `<div id="content"><section id="s-overview" class="page-intro anchor"><h1>系统运行概览</h1>`
+ `<p>集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。</p></section>`
+ `<section id="ops-summary" class="ops-summary" aria-label="系统运营摘要"></section>` + SECTIONS.slice(1).map(([id]) =>
`<div id="${id}" class="anchor"></div>`).join("") + `</div>`
+ `</div>`;
renderOpsSummary();
document.querySelectorAll("#toc a").forEach(a => {
a.onclick = (e) => {
e.preventDefault();
@ -927,13 +1149,106 @@ function setupScrollSpy() {
}
function renderMetrics(d) {
overviewData = d;
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("s-runtime").innerHTML = renderRuntime(d.runtime || {});
$("s-tasks").innerHTML = renderTasks(d.tasks || {});
$("s-usage").innerHTML =
renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d);
$("s-usage").innerHTML = renderByDay((d.usage || {}).by_day_7d);
renderWindowsNodes();
renderOpsSummary();
}
function providerStatusLabel(status) {
return ({ normal: "正常", low_balance: "余额偏低", exhausted: "已耗尽",
auth_error: "认证失败", unreachable: "不可达", untested: "未测试" })[status] || status;
}
function renderProviderCredentials() {
const rows = providerCredentials.map(row => {
const fields = (row.fields || []).map(f => `${escapeHtml(f.label)} ${escapeHtml(f.hint || "未配置")}`).join(" · ");
const balance = row.balance
? `${escapeHtml(row.balance.currency || "")} ${escapeHtml(row.balance.amount || "")}` : "—";
const tone = ["low_balance", "exhausted", "auth_error"].includes(row.test_status) ? "err" :
(row.test_status === "normal" ? "ok" : "");
return `<tr data-provider-id="${escapeHtml(row.provider_id)}"><td>${escapeHtml(row.display_name)}`
+ `<br/><span class="muted">${escapeHtml(row.category)}</span></td>`
+ `<td><span class="chip">${escapeHtml(row.source)}</span><br/><span class="muted">${fields}</span></td>`
+ `<td><span class="chip ${tone}">${escapeHtml(providerStatusLabel(row.test_status))}</span>`
+ `<br/><span class="muted" title="${escapeHtml(row.test_detail || "")}">${escapeHtml(row.test_detail || "—")}</span></td>`
+ `<td>${balance}<br/><span class="muted">${row.last_tested_at ? fmtTimeAgo(row.last_tested_at) : "未测试"}</span></td>`
+ `<td><button data-provider-replace>录入/更换</button> <button data-provider-test ${row.configured ? "" : "disabled"}>测试</button> `
+ `<button data-provider-delete ${row.source === "database" ? "" : "disabled"}>删除覆盖</button></td></tr>`;
}).join("") || `<tr><td colspan="5" class="empty">暂无 Provider</td></tr>`;
$("s-provider-credentials").innerHTML = `<div class="card"><div class="card-head"><h2>API 凭据</h2>`
+ `<span class="sublabel">数据库密文优先;删除覆盖后回退环境变量。测试候选成功后才原子替换。</span></div>`
+ `<div class="scroll-x"><table><thead><tr><th>Provider</th><th>来源与尾号</th><th>状态</th><th>余额 / 最近测试</th><th>操作</th></tr></thead>`
+ `<tbody>${rows}</tbody></table></div></div>`;
$("s-provider-credentials").onclick = async event => {
const tr = event.target.closest("tr[data-provider-id]");
if (!tr) return;
const row = providerCredentials.find(item => item.provider_id === tr.dataset.providerId);
if (!row) return;
if (event.target.closest("[data-provider-replace]")) await replaceProviderCredentials(row);
else if (event.target.closest("[data-provider-test]")) await testProviderCredentials(row);
else if (event.target.closest("[data-provider-delete]")) await deleteProviderCredentials(row);
};
}
async function replaceProviderCredentials(row) {
if (row.billable_test && !confirm(`${row.display_name} 没有免费鉴权端点,本次测试会发起一次最小真实调用。继续?`)) return;
const credentials = {};
for (const field of row.fields || []) {
const value = await dialogPrompt({
title: `录入 ${row.display_name}`,
label: `${field.label}(当前 ${field.hint || "未配置"};不会回显原值)`,
placeholder: `输入新的 ${field.label}`,
value: "", maxLength: 4096, okText: "下一步",
});
if (value === null) return;
if (!value.trim()) { message(`${field.label} 不可为空`, "error"); return; }
credentials[field.name] = value.trim();
}
if (!await dialogConfirm({ title: `更换 ${row.display_name} 凭据?`,
message: "系统会先在内存中测试候选凭据;仅测试成功后才替换当前密文。",
okText: "测试并启用", danger: false })) return;
try {
const result = await apiSend("PUT", `/v1/admin/provider-credentials/${row.provider_id}`,
{ credentials, expected_revision: row.revision || 0 });
message(result.test_detail || "凭据已启用", result.test_status === "low_balance" ? "info" : "success");
await loadProviderCredentials();
} catch (err) { message("保存失败:" + (err.message || String(err)), "error", 6000); }
}
async function testProviderCredentials(row) {
if (row.billable_test && !confirm(`${row.display_name} 测试会发起一次最小真实调用。继续?`)) return;
try {
const result = await apiSend("POST", `/v1/admin/provider-credentials/${row.provider_id}/test`, {});
message(result.detail || providerStatusLabel(result.status), result.status === "normal" ? "success" : "info", 5000);
await loadProviderCredentials();
} catch (err) { message("测试失败:" + (err.message || String(err)), "error", 6000); }
}
async function deleteProviderCredentials(row) {
if (!await dialogConfirm({ title: `删除 ${row.display_name} 数据库覆盖?`,
message: "删除后立即回退到部署环境变量;若环境变量也未配置,该能力将不可用。",
okText: "删除覆盖", danger: true })) return;
try {
await apiSend("DELETE", `/v1/admin/provider-credentials/${row.provider_id}`,
{ expected_revision: row.revision });
await loadProviderCredentials();
} catch (err) { message("删除失败:" + (err.message || String(err)), "error", 6000); }
}
function renderSandboxPackages(d) {
const rows = d.rows || [];
const opts = RANGE_OPTS.map(([v,l]) => `<option value="${v}" ${v===packageRange?"selected":""}>${l}</option>`).join("");
const body = rows.map(r => `<tr><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.version)}</td>`
+ `<td>${r.session_count}</td><td>${r.user_count}</td><td>${r.foreground_sessions}/${r.background_sessions}</td>`
+ `<td>${r.direct_sessions}</td><td>${humanSize(r.average_installed_bytes)}</td><td>${humanSize(r.total_installed_bytes)}</td>`
+ `<td>${escapeHtml((r.changes || []).join("/"))}<br/><span class="muted">${fmtTimeAgo(r.latest_at)}</span></td></tr>`).join("");
$("s-sandbox-packages").innerHTML = `<div class="card"><div class="card-head"><h2>容器依赖统计</h2>`
+ `<select id="sandbox-package-range">${opts}</select></div><p class="muted">只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。</p>`
+ `<div class="table-wrap"><table><thead><tr><th>包名</th><th>版本</th><th>安装会话</th><th>独立用户</th><th>前台/后台</th><th>直接安装会话</th><th>平均占用</th><th>累计占用</th><th>基础镜像状态 / 最近</th></tr></thead><tbody>${body || `<tr><td colspan="9">暂无临时依赖记录</td></tr>`}</tbody></table></div></div>`;
const select = $("sandbox-package-range");
if (select) select.onchange = () => { packageRange = select.value; loadSandboxPackages(); };
}
function showMsg(html) {
@ -1027,10 +1342,21 @@ async function loadStorage(page) {
try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0;
if (storagePage === 0) {
storageSummaryData = d;
renderOpsSummary();
}
renderStorage(d);
} catch (e) { /* 同上 */ }
}
async function loadStorageSummary() {
try {
storageSummaryData = await apiGet("/v1/admin/storage/users?page=0&page_size=1");
renderOpsSummary();
} catch (e) { /* 保留最近一次成功的摘要 */ }
}
async function loadToolFailures() {
try {
const [failures, wire] = await Promise.allSettled([
@ -1038,10 +1364,13 @@ async function loadToolFailures() {
apiGet("/v1/admin/tool-wire-health?days=7"),
]);
if (failures.status !== "fulfilled") throw failures.reason;
toolFailuresData = failures.value;
toolWireData = wire.status === "fulfilled" ? wire.value : null;
renderToolFailures(
failures.value,
wire.status === "fulfilled" ? wire.value : null,
toolWireData,
);
renderOpsSummary();
} catch (e) { /* 同上 */ }
}
@ -1063,10 +1392,30 @@ async function loadSoftwareNodes() {
try {
const result = await apiGet("/v1/admin/software-nodes");
softwareNodes = result.results || [];
softwareNodesLoaded = true;
renderWindowsNodes();
renderOpsSummary();
} catch (e) { /* overview 统一处理鉴权 */ }
}
async function loadProviderCredentials() {
try {
const result = await apiGet("/v1/admin/provider-credentials");
providerCredentials = result.results || [];
renderProviderCredentials();
} catch (e) { /* overview 统一处理鉴权 */ }
}
async function loadSandboxCapacity() {
try {
sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity");
renderOpsSummary();
} catch (e) { /* overview 统一处理 */ }
}
async function loadSandboxPackages() {
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态)
async function refresh() {
try {
@ -1076,9 +1425,13 @@ async function refresh() {
loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
if (storagePage !== 0) loadStorageSummary();
loadSoftwareNodes();
loadProviderCredentials();
loadExternalDefinitions();
loadToolFailures();
loadSandboxCapacity();
loadSandboxPackages();
} catch (e) {
if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
@ -1126,8 +1479,9 @@ function buildReport(ov, models, users, storage, health) {
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
+ `<td>${pct}</td><td>${r.file_count || 0}</td><td>${humanSize(r.trash_bytes || 0)}</td>`
+ `<td>${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}</td></tr>`;
}).join("") || `<tr><td colspan="6">无数据</td></tr>`;
$("print-report").innerHTML =
`<h1>zcbot 管理后台报告</h1>`
@ -1148,7 +1502,7 @@ function buildReport(ov, models, users, storage, health) {
+ `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `<h2>各用户用量all-timeTop 10</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10</h2>`
+ tbl(["用户", "用", "占配额", "文件数"], stBody);
+ tbl(["用户", "配额占用", "占配额", "文件数", "回收站", "物理合计"], stBody);
}
// ───── 自动刷新 ─────
@ -1167,13 +1521,21 @@ $("node-enrollment-form").onsubmit = createNodeEnrollment;
$("node-enrollment-close").onclick = closeNodeEnrollmentModal;
$("node-enrollment-cancel").onclick = closeNodeEnrollmentModal;
$("node-enrollment-copy").onclick = copyNodeEnrollmentCode;
$("capacity-drawer-close").onclick = closeCapacityDrawer;
$("capacity-drawer").onclick = (e) => {
if (e.target.id === "capacity-drawer") closeCapacityDrawer();
};
$("node-enrollment-modal").onclick = (e) => {
if (e.target.id === "node-enrollment-modal") closeNodeEnrollmentModal();
};
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) {
closeNodeEnrollmentModal();
if ($("capacity-drawer").classList.contains("show") && e.key === "Tab") {
e.preventDefault();
$("capacity-drawer-close").focus();
return;
}
if (e.key === "Escape" && $("capacity-drawer").classList.contains("show")) closeCapacityDrawer();
else if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) closeNodeEnrollmentModal();
});
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {

View File

@ -6,7 +6,13 @@ import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
const SEEN_KEY = "zcbot_seen_version";
const PAGE_SIZE = 5;
let _current = ""; // /healthz 返回的当前版本(main.js loadVersion 灌入)
let _entries = [];
let _nextOffset = 0;
let _hasMore = false;
let _loading = false;
let _loadError = "";
// main.js loadVersion 拿到版本号后调:决定红点亮不亮(localStorage 不可用时静默不亮)
export function markVersion(v) {
@ -22,31 +28,21 @@ export function closeChangelogModal() {
$("changelog-modal").classList.remove("show");
}
async function openChangelogModal() {
function renderChangelog() {
const body = $("cl-body");
$("changelog-modal").classList.add("show");
if (!_entries.length && _loading) {
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
// 打开即视为"看过":记住当前版本 + 清红点
if (_current) {
try { localStorage.setItem(SEEN_KEY, _current); } catch (e) {}
$("app-version").classList.remove("hasnew");
}
let d;
try {
const r = await fetch("/v1/changelog?limit=50");
if (!r.ok) throw new Error("HTTP " + r.status);
d = await r.json();
} catch (e) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
$("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
const entries = d.entries || [];
if (!entries.length) {
if (!_entries.length && _loadError) {
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(_loadError)}</div>`;
return;
}
if (!_entries.length) {
body.innerHTML = '<div class="sk-empty">暂无更新说明。</div>';
return;
}
body.innerHTML = entries.map((e, i) => `
body.innerHTML = _entries.map((e, i) => `
<div class="cl-entry">
<div class="cl-head">
<span class="cl-ver">v${escapeHtml(e.version)}</span>
@ -54,8 +50,50 @@ async function openChangelogModal() {
${e.date ? `<span class="cl-date">${escapeHtml(e.date)}</span>` : ""}
</div>
<div class="cl-md">${renderMd(e.body_md)}</div>
</div>`).join("");
</div>`).join("")
+ (_loadError ? `<div class="cl-load-error">加载失败: ${escapeHtml(_loadError)}</div>` : "")
+ (_hasMore ? `<div class="cl-more"><button id="cl-more" type="button" ${_loading ? "disabled" : ""}>${_loading ? "加载中…" : "加载更多"}</button></div>` : "");
highlightIn(body);
const more = $("cl-more");
if (more) more.onclick = () => loadChangelogPage();
}
async function loadChangelogPage(reset = false) {
if (_loading) return;
if (reset) {
_entries = [];
_nextOffset = 0;
_hasMore = false;
}
_loading = true;
_loadError = "";
renderChangelog();
try {
const r = await fetch(`/v1/changelog?limit=${PAGE_SIZE}&offset=${_nextOffset}`);
if (!r.ok) throw new Error("HTTP " + r.status);
const d = await r.json();
$("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
const page = d.entries || [];
_entries = _entries.concat(page);
_nextOffset = Number.isInteger(d.next_offset) ? d.next_offset : _nextOffset + page.length;
_hasMore = !!d.has_more;
} catch (e) {
_loadError = e.message || String(e);
_hasMore = _entries.length > 0;
} finally {
_loading = false;
renderChangelog();
}
}
function openChangelogModal() {
$("changelog-modal").classList.add("show");
// 打开即视为"看过":记住当前版本 + 清红点
if (_current) {
try { localStorage.setItem(SEEN_KEY, _current); } catch (e) {}
$("app-version").classList.remove("hasnew");
}
loadChangelogPage(true);
}
// ───── 顶层绑定 ─────

View File

@ -5,7 +5,7 @@
// 各入口模块顶层自绑;对外导出 loadTaskList / loadModels / selectTask
// (供 main enterApp、embed、files、newtask)。
import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu, showMenuAtPoint } from "./dom.js";
import { $, showMenu, showMenuAtPoint, syncConversationSideRail, toggleConversationRailPinned } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
import { renderMd, highlightIn, renderMermaidIn } from "./markdown.js";
@ -730,6 +730,7 @@ export async function showNewConversationDraft() {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
if (state.taskId) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = null;
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: "" } }));
state.taskMeta = null;
state.concurrentWarnings = [];
state.loadedMessages = [];
@ -806,6 +807,7 @@ export async function selectTask(tid) {
if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = tid;
restoreComposerDraft(tid);
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: tid } }));
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
@ -948,7 +950,7 @@ function renderModelDropdown(t) {
let opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
// 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项,
// 当前模型不在该用户档位清单里(skill 定向模型)→ 补一项,
// 下拉如实显示;用户仍可切回档内模型。
if (cur && !state.models.some(m => m.profile === cur)) {
opts += `<option value="${escapeHtml(cur)}" selected>${escapeHtml(cur)}</option>`;
@ -1253,17 +1255,26 @@ function renderOutlineRail() {
if (!rail) return; // embed 等精简页无此元素 → no-op
const items = state.outline || [];
if (items.length < 2) { // 0/1 轮没必要显示目录
rail.style.display = "none";
rail.hidden = true;
rail.innerHTML = "";
rail.classList.remove("pinned");
syncConversationSideRail();
return;
}
rail.style.display = "";
rail.innerHTML = items.map((it, i) => {
rail.hidden = false;
const pinned = rail.classList.contains("pinned");
const caption = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="${items.length} 轮对话 · 点击固定展开或收起导航">`
+ `<span class="rail-caption-label">导航</span><b class="rail-count">${items.length}</b>`
+ `<span class="rail-chevron" aria-hidden="true">⌄</span></button>`;
rail.innerHTML = caption + items.map((it, i) => {
const label = it.snippet || `${i + 1}`;
return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}">`
return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}"`
+ ` aria-label="跳转到第 ${i + 1} 轮:${escapeHtml(label)}">`
+ `<span class="ol-num">${i + 1}</span>`
+ `<span class="ol-label">${escapeHtml(label)}</span></button>`;
}).join("");
syncConversationSideRail();
updateActiveOutlineDot();
}
@ -1271,7 +1282,10 @@ function setActiveOutlineIdx(idx) {
const rail = $("msg-outline-rail");
if (!rail) return;
rail.querySelectorAll(".ol-dot").forEach((d) => {
d.classList.toggle("active", Number(d.dataset.idx) === Number(idx));
const active = Number(d.dataset.idx) === Number(idx);
d.classList.toggle("active", active);
if (active) d.setAttribute("aria-current", "step");
else d.removeAttribute("aria-current");
});
}
@ -1293,7 +1307,7 @@ function lockOutlineDuringJump() {
// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
function updateActiveOutlineDot() {
const rail = $("msg-outline-rail");
if (!rail || rail.style.display === "none") return;
if (!rail || rail.hidden) return;
if (_outlineJumpLock) return; // 显式跳转动画期间不抢
const wrap = $("chat-stream");
const items = state.outline || [];
@ -1333,6 +1347,12 @@ $("chat-stream").addEventListener("scroll", () => {
const _outlineRailEl = $("msg-outline-rail");
if (_outlineRailEl) {
_outlineRailEl.addEventListener("click", (e) => {
const caption = e.target.closest(".conversation-rail-caption");
if (caption) {
const pinned = toggleConversationRailPinned(_outlineRailEl);
if (!pinned) caption.blur();
return;
}
const dot = e.target.closest(".ol-dot");
if (dot) jumpToMessage(Number(dot.dataset.idx));
});
@ -1401,6 +1421,15 @@ function closeReasonSeg(run) {
run.curReason = null;
}
// provider 传输层放弃当前流并重试时,移除这次响应已经直播出来的推理。
// 重试返回的最终 reasoning 会重新建立一段,确保直播态与最终持久化消息一致。
function resetReasonSeg(run) {
const rs = run.curReason;
if (!rs) return;
rs.el.remove();
run.curReason = null;
}
// ───── run 活跃状态指示(阶段 + 已耗时) ─────
// 长耗时阶段(TTFT / 深度推理 / 工具执行)原本页面全静止,和卡死无法区分。
// 每秒把「阶段 · Ns」写进空占位段的 data-status(CSS :empty::before 渲染),
@ -2017,9 +2046,9 @@ export function refreshCurrentTaskFiles(taskId) {
if (taskId && state.taskId === taskId) scheduleFilesRefresh();
}
// Software Job 终态可能新增一条固定报告,也可能启动自动分析 run。刷新当前 task
// 并在后者场景主动接上 SSE用户无需切走再切回来才能看到回复
export async function refreshCurrentTaskAfterSoftwareJob(taskId) {
// Software Job 本身只更新左侧结果轨;只有 analyze 才会启动对话 run。这里用刚拉取
// 的 DB 状态清掉历史上可能残留的 live card避免 Job 结果刷新把“思考中”复活
export async function refreshCurrentTaskAfterSoftwareJob(taskId, reloadMessages = false) {
if (!taskId || state.taskId !== taskId) return;
try {
const meta = await api("GET", "/v1/tasks/" + taskId);
@ -2029,9 +2058,12 @@ export async function refreshCurrentTaskAfterSoftwareJob(taskId) {
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(taskId, `/v1/tasks/${taskId}/events`, meta);
} else {
discardInactiveLiveRun(taskId);
if (reloadMessages) {
await loadMessages();
refreshOutline();
}
}
scheduleFilesRefresh();
} catch (_) { /* Job 中心下一轮轮询或切换 task 时会恢复 */ }
}
@ -3012,6 +3044,7 @@ async function createTaskFromDraft() {
model_profile: state.draftModelProfile || "",
});
state.taskId = t.task_id;
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: t.task_id } }));
state.taskMeta = t;
// task 创建与首条消息是两个请求;先把新对话草稿迁到真实 task确保第二步
// 发送失败或此时刷新页面仍能在该对话恢复内容。
@ -3150,11 +3183,28 @@ function streamSse(url, run) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
if (!run.abortController) run.abortController = new AbortController();
fetchSse(url, run).catch((e) => {
if (e.name === "AbortError" || run.terminal) return;
appendRunError(run, "sse: " + e.message);
});
}
function discardInactiveLiveRun(taskId) {
const run = getLiveRun(taskId);
if (!run) return;
run.terminal = true;
if (run.abortController) run.abortController.abort();
if (run.card) run.card.remove();
state.liveRuns.delete(taskId);
state.streaming = state.liveRuns.size > 0;
syncTaskRowRunIndicator(taskId);
if (state.taskId === taskId) {
setActionMode("idle");
$("chat-hint").textContent = "";
}
}
function appendRunError(run, msg) {
if (state.taskId === run.taskId) {
appendErrorCard(msg);
@ -3265,6 +3315,7 @@ async function pollAutoTitle(taskId, attempts = 6) {
async function consumeSseStream(url, asstCard, ctx) {
const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
signal: ctx.abortController?.signal,
});
if (!r.ok) throw new Error(r.status + " " + r.statusText);
const reader = r.body.getReader();
@ -3409,6 +3460,8 @@ function handleSseEvent(ev, asstCard, ctx) {
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "reasoning_reset") {
resetReasonSeg(ctx);
} else if (t === "text" && ev.data && ev.data.delta) {
closeReasonSeg(ctx); // 正文开始 → 思考过程定稿折叠
ctx.acc += ev.data.delta;
@ -3430,6 +3483,27 @@ function handleSseEvent(ev, asstCard, ctx) {
if (!ctx.runId && snapshot.run_id) ctx.runId = snapshot.run_id;
ctx.progressSteps = cloneProgressSteps(snapshot.steps);
setTaskProgress(ctx.taskId, ctx.progressSteps);
} else if (t === "execution_queue") {
const det = ctx.runningToolEl;
if (!det) return;
const labelEl = det.querySelector(".tool-label");
if (!labelEl) return;
const queue = ev.data || {};
if (queue.state === "waiting") {
det.classList.add("waiting");
if (queue.reason === "per_user_limit") {
labelEl.textContent = `等待执行容量(当前用户 ${Number(queue.user_running) || 0}/${Number(queue.user_limit) || 0}`;
} else if (queue.reason === "global_limit") {
labelEl.textContent = `等待执行容量(整机 ${Number(queue.global_running) || 0}/${Number(queue.global_limit) || 0}`;
} else if (queue.reason === "memory_pressure") {
labelEl.textContent = "等待执行容量(宿主内存压力)";
} else {
labelEl.textContent = "等待执行容量(前方任务优先)";
}
} else if (queue.state === "admitted") {
det.classList.remove("waiting");
labelEl.textContent = det.dataset.runningLabel || labelEl.textContent;
}
} else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.args) || "";
@ -3453,6 +3527,7 @@ function handleSseEvent(ev, asstCard, ctx) {
const label = toolActivityLabel(fn, args);
const det = document.createElement("details");
det.className = "tool-call activity running";
det.dataset.runningLabel = label;
det.innerHTML = `<summary><span class="tool-state" aria-hidden="true">…</span><span class="tool-label">${escapeHtml(label)}</span><span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")

View File

@ -3,6 +3,36 @@ import { escapeHtml } from "./format.js";
export const $ = (id) => document.getElementById(id);
// 对话区右缘的「导航 / 作业」共用一只容器。两个业务模块各自渲染内容,
// 这里仅负责可见性与互斥固定展开,避免 chat ↔ software_jobs 形成新的循环依赖。
export function syncConversationSideRail() {
const wrap = $("conversation-side-rail");
if (!wrap) return;
const sections = Array.from(wrap.querySelectorAll(".conversation-rail-section"));
const visible = sections.filter((section) => !section.hidden);
wrap.hidden = visible.length === 0;
const divider = $("conversation-side-divider");
if (divider) divider.hidden = visible.length < 2;
sections.filter((section) => section.hidden).forEach((section) => section.classList.remove("pinned"));
wrap.classList.toggle("has-pinned", visible.some((section) => section.classList.contains("pinned")));
}
export function toggleConversationRailPinned(section) {
const wrap = $("conversation-side-rail");
if (!wrap || !section) return false;
const wasPinned = section.classList.contains("pinned");
wrap.querySelectorAll(".conversation-rail-section").forEach((item) => {
item.classList.remove("pinned");
item.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "false");
});
if (!wasPinned) {
section.classList.add("pinned");
section.querySelector(".conversation-rail-caption")?.setAttribute("aria-expanded", "true");
}
wrap.classList.toggle("has-pinned", !wasPinned);
return !wasPinned;
}
// ───── floating dropdown menu (single instance) ─────
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
let _menuItems = null;

View File

@ -39,7 +39,7 @@ export function enterApp(initialTaskId = "") {
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项)
loadStorage(); // 顶栏存储用量(后台扫描快照,非实时)
loadRole(); // 拉 /v1/me,admin 才显「管理」入口(/static/admin.html)
loadRole(); // 拉 /v1/me,admin 才显对话面板的「管理」入口(/static/admin.html)
refreshProcs(); // 后台进程(bg proc):上个会话遗留的 running proc 恢复轮询/展示
initSoftwareJobs();
}
@ -53,7 +53,7 @@ function renderWho() {
}
// 当前用户身份 + 角色:/v1/me 返 {user_id, role, name, user_name, email}。
// admin → 显顶栏「管理」链接;并用服务端权威值校准顶栏用户名(platform_key 登录 / 老 token
// admin → 显对话面板「管理」链接;并用服务端权威值校准顶栏用户名(platform_key 登录 / 老 token
// 升级后,name/user_name 这一刻才到齐)。失败静默(增量功能,拉不到就维持登录时的兜底值)。
async function loadRole() {
const link = $("hd-admin");

View File

@ -11,7 +11,53 @@ const MERMAID_CONFIG = Object.freeze({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "neutral",
// A restrained scientific palette. Author-defined classDef/style rules still
// take precedence, while unstyled diagrams get a consistent visual baseline.
theme: "base",
themeVariables: Object.freeze({
background: "#ffffff",
primaryColor: "#eaf2ff",
primaryTextColor: "#17324d",
primaryBorderColor: "#5b8fc9",
secondaryColor: "#e8f7f3",
secondaryTextColor: "#163c35",
secondaryBorderColor: "#4f9f8d",
tertiaryColor: "#fff4df",
tertiaryTextColor: "#594018",
tertiaryBorderColor: "#d79a3b",
lineColor: "#60758a",
textColor: "#25384a",
mainBkg: "#eaf2ff",
secondBkg: "#e8f7f3",
border1: "#5b8fc9",
border2: "#4f9f8d",
arrowheadColor: "#60758a",
clusterBkg: "#f7f9fc",
clusterBorder: "#b9c8d8",
actorBkg: "#eaf2ff",
actorBorder: "#5b8fc9",
actorTextColor: "#17324d",
actorLineColor: "#8ca0b3",
signalColor: "#536b82",
signalTextColor: "#25384a",
labelBoxBkgColor: "#f7f9fc",
labelBoxBorderColor: "#b9c8d8",
labelTextColor: "#25384a",
loopTextColor: "#25384a",
noteBkgColor: "#fff4df",
noteBorderColor: "#d79a3b",
noteTextColor: "#594018",
activationBkgColor: "#d9eee9",
activationBorderColor: "#4f9f8d",
pie1: "#4f7cac",
pie2: "#5ca89a",
pie3: "#e4a64a",
pie4: "#a879b2",
pie5: "#d96c75",
pie6: "#6d9dc5",
pie7: "#84b59f",
pie8: "#d8b365",
}),
maxTextSize: 50000,
maxEdges: 500,
fontFamily: '-apple-system, "Segoe UI", "Microsoft YaHei", sans-serif',

View File

@ -60,6 +60,7 @@ export async function openWebPreview(preview) {
_fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = previewId;
$("fp-name").textContent = current.name || "网页项目预览";
$("fp-download").hidden = false;
$("fp-meta").textContent = `${Number(current.file_count || 0)} 个文件 · ${humanSize(Number(current.size_bytes || 0))}`;
_resetTextModes("fp");
const body = $("fp-body");
@ -347,6 +348,7 @@ export async function openFilePreview(rel, taskId = "", legacy = false, artifact
_fpCurrentArtifactId = artifactId;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-download").hidden = false;
$("fp-meta").textContent = "";
const body = $("fp-body");
_resetTextModes("fp");
@ -398,6 +400,63 @@ export async function openFilePreview(rel, taskId = "", legacy = false, artifact
}
}
// 专业软件 Workspace 预览保存在受保护的 Job 缓存,不一定已经导出为 task 文件。
// 结果轨与 Job 中心共用这个画廊入口URL 仍用 Bearer fetchblob URL 只活在当前
// 预览弹框,关闭时统一回收。预览不是正式 Artifact因此不提供“下载原文件”按钮。
export async function openSoftwarePreviewGallery(title, previews) {
const refs = (Array.isArray(previews) ? previews : []).filter(
(item) => item && item.url && (item.filename || item.path),
);
if (!refs.length) throw new Error("这个任务没有可查看的预览");
_fpCurrentWebPreviewId = "";
_fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
_fpCurrentArtifactId = "";
_flushBlobUrls();
_resetTextModes("fp");
const body = $("fp-body");
_clearZoom(body);
_clearPdfPreview(body);
$("fp-name").textContent = title || "专业软件结果";
$("fp-meta").textContent = `${refs.length} 个预览`;
$("fp-download").hidden = true;
body.className = "body software-preview-gallery";
body.innerHTML = refs.map((item, index) => (
`<figure class="software-preview-item" data-preview-index="${index}">`
+ `<div class="ph">${escapeHtml(item.filename || item.path)} 加载中…</div>`
+ `<figcaption>${escapeHtml(item.filename || item.path)}</figcaption></figure>`
)).join("");
const cf = $("chat-form");
const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
$("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
$("file-preview-modal").classList.add("show");
document.body.classList.add("fp-open");
await Promise.all(refs.map(async (item, index) => {
const host = body.querySelector(`[data-preview-index="${index}"]`);
try {
const response = await fetch(item.url, {
headers: { "Authorization": "Bearer " + state.token },
});
if (!response.ok) throw new Error("HTTP " + response.status);
const blob = await response.blob();
if (!host || !host.isConnected) return;
const media = document.createElement("img");
media.className = "preview-img";
media.alt = item.filename || item.path;
media.src = _trackBlobUrl(
blob,
/\.svg$/i.test(item.filename || item.path) ? "image/svg+xml" : "",
);
host.querySelector(".ph").replaceWith(media);
} catch (error) {
if (!host || !host.isConnected) return;
host.querySelector(".ph").textContent = `加载失败:${error.message || error}`;
}
}));
}
function _showImage(blob) {
// SVG 在 <img> 里必须 Content-Type=image/svg+xml 才渲染;下载接口靠服务端
// mimetypes 猜类型,部分部署环境(Linux)未注册 .svg → octet-stream → 不显示。
@ -790,6 +849,7 @@ export function closeFilePreview() {
_fpCurrentLegacy = false;
_fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = "";
$("fp-download").hidden = false;
}
let _mpCurrentRel = null;

View File

@ -1,7 +1,7 @@
// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。
import { api } from "./api.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { $, syncConversationSideRail, toggleConversationRailPinned } from "./dom.js";
import { escapeHtml } from "./format.js";
import {
openSoftwareJobResults,
@ -10,6 +10,7 @@ import {
} from "./chat.js";
import { dialogConfirm, message } from "./dialog.js";
import { mqPhone, setMobileView, setRightPanelTab } from "./layout.js";
import { openSoftwarePreviewGallery } from "./preview.js";
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
@ -23,6 +24,9 @@ let nextCursor = null;
let loadingMore = false;
let initialized = false;
let loadError = "";
let taskJobs = [];
let taskJobsTaskId = "";
let taskJobsLoadSeq = 0;
const unreadTerminal = new Set();
const statusLabel = {
@ -62,8 +66,21 @@ export function initSoftwareJobs() {
if (initialized || !$("software-job-list")) return;
initialized = true;
$("software-job-refresh").onclick = refreshSoftwareJobs;
const mobileResults = $("mobile-software-results");
if (mobileResults) {
mobileResults.onclick = () => {
setRightPanelTab("jobs");
setMobileView("mv-right", "jobs");
};
}
$("software-job-list").addEventListener("scroll", onListScroll);
document.addEventListener("software-job-submitted", refreshSoftwareJobs);
document.addEventListener("software-job-submitted", () => {
if (state.taskId) void refreshTaskSoftwareResults(state.taskId);
});
document.addEventListener("task-selection-changed", (event) => {
void refreshTaskSoftwareResults(event.detail?.taskId || "");
});
document.addEventListener("right-panel-changed", (event) => {
if (event.detail?.tab === "jobs") {
unreadTerminal.clear();
@ -71,6 +88,143 @@ export function initSoftwareJobs() {
}
});
refreshSoftwareJobs();
void refreshTaskSoftwareResults(state.taskId || "");
}
async function refreshTaskSoftwareResults(taskId) {
const seq = ++taskJobsLoadSeq;
taskJobsTaskId = taskId || "";
taskJobs = [];
renderTaskResultRail();
if (!taskJobsTaskId) {
return;
}
try {
const params = new URLSearchParams({ task_id: taskJobsTaskId, limit: "50" });
const data = await api("GET", `/v1/software-jobs?${params}`);
if (seq !== taskJobsLoadSeq || state.taskId !== taskId) return;
taskJobs = data.results || [];
} catch (_) {
if (seq !== taskJobsLoadSeq) return;
taskJobs = [];
}
renderTaskResultRail();
}
function mergeTaskJobs(currentPage) {
const taskId = state.taskId || "";
if (!taskId || taskJobsTaskId !== taskId) return;
const updates = new Map(
(currentPage || []).filter((job) => job.task_id === taskId).map((job) => [job.job_id, job]),
);
if (!updates.size) return;
const seen = new Set();
taskJobs = taskJobs.map((job) => {
const updated = updates.get(job.job_id);
if (updated) seen.add(job.job_id);
return updated || job;
});
for (const [jobId, job] of updates) {
if (!seen.has(jobId)) taskJobs.unshift(job);
}
taskJobs.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
renderTaskResultRail();
}
function taskResultLabel(job) {
const summary = job.request_summary || {};
return summary.title || summary.display_name || job.capability || "专业软件任务";
}
function taskResultMeta(job) {
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest.length : 0;
if (job.status === "succeeded") {
return previews ? `${previews} 个预览 · 点击查看` : "已完成 · 点击查看";
}
if (job.status === "failed") return "执行失败 · 点击查看详情";
if (job.status === "cancelled") return "已取消 · 点击查看详情";
return `${statusLabel[job.status] || "处理中"}${job.progress ? ` · ${job.progress}%` : ""}`;
}
function renderTaskResultRail() {
const rail = $("software-result-rail");
const mobileResults = $("mobile-software-results");
if (!rail) return;
if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) {
rail.innerHTML = "";
rail.hidden = true;
rail.classList.remove("pinned");
syncConversationSideRail();
if (mobileResults) mobileResults.hidden = true;
return;
}
const activeCount = taskJobs.filter((job) => ACTIVE.has(job.status)).length;
const unreadCount = taskJobs.filter((job) => unreadTerminal.has(job.job_id)).length;
const attention = activeCount > 0 || unreadCount > 0;
const attentionText = [
activeCount ? `${activeCount} 个进行中` : "",
unreadCount ? `${unreadCount} 个新结果` : "",
].filter(Boolean).join("");
const pinned = rail.classList.contains("pinned");
rail.innerHTML = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="${taskJobs.length} 个作业${attentionText ? `${attentionText}` : ""} · 点击固定展开或收起">`
+ `<span class="rail-caption-label">作业</span>`
+ `<b class="rail-count${attention ? " attention" : ""}">${taskJobs.length}</b>`
+ `<span class="rail-chevron" aria-hidden="true">⌄</span></button>`
+ taskJobs.map((job) => `
<button type="button" class="sr-item ${escapeHtml(job.status)}"
data-result-job-id="${escapeHtml(job.job_id)}"
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}"
aria-label="打开软件作业:${escapeHtml(taskResultLabel(job))}${escapeHtml(taskResultMeta(job))}">
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
<small>${escapeHtml(taskResultMeta(job))}</small></span>
</button>`).join("");
rail.hidden = false;
syncConversationSideRail();
if (mobileResults) {
mobileResults.hidden = false;
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
}
const caption = rail.querySelector(".conversation-rail-caption");
if (caption) {
caption.onclick = () => {
const pinned = toggleConversationRailPinned(rail);
if (!pinned) caption.blur();
};
}
rail.querySelectorAll("[data-result-job-id]").forEach((button) => {
button.onclick = () => void openTaskResult(button.dataset.resultJobId);
});
}
async function openTaskResult(jobId) {
const job = taskJobs.find((item) => item.job_id === jobId)
|| jobs.find((item) => item.job_id === jobId);
if (!job) return;
if (unreadTerminal.delete(jobId)) renderTaskResultRail();
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
if (job.status === "succeeded" && previews.length) {
try {
await openSoftwarePreviewGallery(taskResultLabel(job), previews);
} catch (error) {
message(error.message || "预览打开失败", "error");
}
return;
}
if (job.status === "succeeded" && !job.workspace_id) {
await openSoftwareJobResults(job.task_id, job.output_dir || "");
setRightPanelTab("files");
if (mqPhone.matches) setMobileView("mv-right", "files");
return;
}
setRightPanelTab("jobs");
if (mqPhone.matches) setMobileView("mv-right", "jobs");
requestAnimationFrame(() => {
const card = document.querySelector(
`#software-job-list [data-job-id="${CSS.escape(jobId)}"]`,
);
if (card) card.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function pagePath(cursor = null) {
@ -97,7 +251,9 @@ export async function refreshSoftwareJobs() {
&& previous.followup_status !== job.followup_status
&& ["running", "completed", "failed"].includes(job.followup_status)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
const reloadMessages = job.completion_action === "analyze"
&& job.followup_status === "completed";
void refreshCurrentTaskAfterSoftwareJob(job.task_id, reloadMessages);
}
if (
job.followup_status === "running"
@ -115,6 +271,7 @@ export async function refreshSoftwareJobs() {
const hadLoadedMore = previousJobs.length > PAGE_SIZE;
const firstPageIds = new Set(next.map((job) => job.job_id));
jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))];
mergeTaskJobs(next);
if (!hadLoadedMore) nextCursor = data.next_cursor;
render();
} catch (_) {
@ -161,7 +318,6 @@ function render() {
entryDot.className = "sj-entry-dot";
if (unreadJob?.status === "failed") entryDot.classList.add("failed");
else if (unreadJob) entryDot.classList.add("succeeded");
else if (active.length) entryDot.classList.add("active");
for (const id of ["software-job-badge", "software-job-mobile-badge"]) {
const badge = $(id);
badge.hidden = active.length === 0;
@ -173,6 +329,7 @@ function render() {
? `${active.length} 个进行中 · ${statusLabel[active[0].status]}`
: (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业");
renderList();
renderTaskResultRail();
}
function renderList() {
@ -229,7 +386,7 @@ function followupLabel(job) {
if (job.followup_status === "failed") return "自动分析失败";
return "报告并分析";
}
return job.followup_status === "completed" ? "已报告结果" : "仅报告结果";
return job.followup_status === "completed" ? "结果已就绪" : "仅展示结果";
}
function jobActions(job, active) {
@ -325,7 +482,13 @@ async function handleAction(event, button) {
if (action === "results") {
const selected = jobs.find((item) => item.job_id === jobId);
if (selected?.workspace_id) {
const previews = Array.isArray(selected.preview_manifest) ? selected.preview_manifest : [];
if (previews.length) {
try { await openSoftwarePreviewGallery(taskResultLabel(selected), previews); }
catch (error) { message(error.message || "预览打开失败", "error"); }
} else {
await selectTask(taskId);
}
} else {
await openSoftwareJobResults(taskId, selected?.output_dir || "");
setRightPanelTab("files");
@ -356,5 +519,6 @@ function notifyTerminal(job) {
const summary = job.request_summary || {};
if (!document.body.classList.contains("right-tab-jobs")) unreadTerminal.add(job.job_id);
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
if (state.taskId === job.task_id) void refreshTaskSoftwareResults(job.task_id);
void refreshCurrentTaskAfterSoftwareJob(job.task_id, false);
}

View File

@ -442,11 +442,12 @@ header .who {
.mermaid-diagram {
margin: 14px 0;
padding: 14px;
padding: 18px;
overflow-x: auto;
border: 1px solid var(--border-soft);
border-radius: var(--r-lg);
background: #ffffff;
border: 1px solid #dce6f2;
border-radius: var(--r-xl);
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 6px 22px rgba(50, 79, 108, 0.08);
}
.mermaid-viewport {
@ -458,6 +459,7 @@ header .who {
max-width: 100%;
height: auto;
margin: 0 auto;
filter: drop-shadow(0 2px 3px rgba(38, 61, 82, 0.06));
}
.mermaid-notice {

Some files were not shown because too many files have changed in this diff Show More