Compare commits

..

No commits in common. "094f4b0cd95f7f8a993fbceb348b85cef93b55e0" and "48924d0d561570bdc3b7d21f740897798c15bb25" have entirely different histories.

15 changed files with 24 additions and 509 deletions

View File

@ -8,14 +8,13 @@
## 开发阶段心智 ## 开发阶段心智
当前处于**开发测试期**(开发自用 + 内部测试,DB 已有真实测试数据)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**,但**不删现有数据**: 当前处于开发阶段(尚未发布给真实用户)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**:
- DB schema 变 → 直接改 model + 写一条干净的 migration:加列 / 改列结构 OK;**不要 truncate / DELETE FROM 现有表 —— 测试数据要保留** - DB schema 变 → 直接改 model + 写一条干净的 migration(必要时清空旧 row,不写双向兼容代码)
- 删字段(DROP COLUMN)前:若该列是当前唯一持有该信息(如累计型 tokens 列),先 backfill 到新位置再删;若纯冗余(从其他列能推出)直接删 OK - 字段语义变 → 全量替换,不留 `legacy_xxx` / `*_v2` 并存
- 字段语义变 → 全量替换 + migration 把旧值映射到新值(不留 `legacy_xxx` / `*_v2` 并存)
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名 - CLI / REPL 选项变 → 直接改,不留 deprecated 别名
- 只有当用户明确说"这条要保留兼容"时才写兼容代码 - 只有当用户明确说"这条要保留兼容"时才写兼容代码
理由:兼容层是技术债;但测试数据是观察新代码行为的依据 —— 一次 truncate 后再回去查"上周那 task 烧了多少 token / 哪条消息触发的 bug",就只能瞎猜 理由:兼容层就是技术债,开发期写了之后忘记删反而拖累;真上线后再视情况补迁移路径
## 文档维护 ## 文档维护

View File

@ -324,26 +324,12 @@ create index on tasks (user_id, working_dir);
-- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255 -- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255
messages(message_id uuid pk, task_id fk, idx int not null, messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out, payload jsonb not null, tokens_in, tokens_out, created_at,
model_profile text null, -- 0006:只在 assistant 行有值,标产生该 msg 的模型
created_at,
unique (task_id, idx)); unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops); create index on messages using gin (payload jsonb_path_ops);
usage_events(event_id uuid pk, user_id fk, task_id fk on delete cascade,
message_id fk on delete set null,
kind text not null, -- chat / image / video / audio / ...(0006 起只 chat,媒体扩展位)
model_profile text not null,
units jsonb not null, -- chat: {tokens_in, tokens_out};image: {count, size};...
cost_usd numeric(12,6) not null default 0,
created_at);
create index on usage_events (user_id, created_at); -- 用户级聚合走这条,JOIN-free
create index on usage_events (task_id);
create index on usage_events (model_profile, created_at);
``` ```
**0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。 **0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`usage_events` 是计费预付架构成本,真要计费再加。`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。
**0006 模型切换 + 用量统计**:`tasks.model_profile` 从 0001 起就有,本次开始真用 —— task 创建时 UI 选 / PATCH 切;`build_agent` resume 读它而非 `cfg["default_model"]`(A 粒度:下条 send 才生效,当前 run 不受影响)。`messages.model_profile` 新增,assistant 行落实际用的模型,前端按 model 切换点画小标。`usage_events` 表 0004 删掉的简陋版形态(id/user_id/task_id/run_id/kind/value/ts)字段不够多态,本次重建 v2 形态:per-event 一行,`units` JSONB 装多态用量(token / 张数 / 秒数),`cost_usd` 用 litellm cost map 算;chat 已接入(`core/loop.py` 在 assistant message 入库后调 `record_chat_usage`),媒体工具未来加 image/video kind 不动 schema。**`tasks.tokens_prompt/completion/cost_usd` 三列保留作粗 task 级概览**,继续由 `sync_task_tokens` 维护;`messages.tokens_in/out` 同时双写,查 message 详情不需 JOIN。统计真实 source-of-truth 走 `usage_events`,跨用户 / 跨模型 / 跨时间维度都按 `(user_id, created_at)` 索引直查。
**run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。 **run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。
**No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%``existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 **No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%``existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`
最后更新:2026-05-19(0006 模型切换 + usage_events v2 表:task 级模型 PATCH / `GET /v1/models` / 前端顶栏下拉 + 历史小标 / chat usage 落 messages 双写 + usage_events 一行,A 粒度下条 send 生效) 最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — `users.email/password_hash` + UNIQUE + `main.py user add` CLI + 登录页两 tab)
--- ---
@ -23,7 +23,6 @@
### 2026-05-19 ### 2026-05-19
- **0006 模型切换(c 模式 task 级 A 粒度)+ usage_events v2 表**:`tasks.model_profile` 从死字段变 source-of-truth,顶栏下拉 → `PATCH /v1/tasks/{id}` 即换,**A 粒度下条 send 生效**(当前 run 不受影响;running 中切 UI 提示"跑完后生效")。`build_agent` resume 时优先 task.model_profile,新建 task POST body 加可选 `model_profile`(留空 → `cfg["default_model"]`)。`GET /v1/models` 扫 `config/models/*.yaml` 列可选项(含 display_name / thinking_mode / is_default),`ModelCapabilities` 加 `display_name` 字段,deepseek_v4.yaml 两 variant 各填名。**前端**:chat-meta 加下拉(切了 PATCH+提示)、新建对话框 modal 加 `<select id="nt-model">`、message 历史按 `messages.model_profile` 切换点画小标(`── DeepSeek V4 Pro ──`,连续同 model 不重复)。**统计 schema**:0004 删掉的简陋 usage_events 字段不够多态,本次重建 v2 形态(`event_id/user_id/task_id/message_id/kind/model_profile/units jsonb/cost_usd`),chat 已接入(`core/storage/usage.py::record_chat_usage`,`loop.py` 在 assistant message 入库后调,litellm cost map 算钱),媒体扩展位(image/video/audio kind)预留不动 schema。**双写**:同时回填 `messages.tokens_in/out/model_profile`,查 message 详情时不需 JOIN。**索引**:`(user_id, created_at)` / `(task_id)` / `(model_profile, created_at)`,用户级配额 query JOIN-free。**没动**:CLI / RUN.md(无 env / 命令变化)、`tasks.tokens_prompt/completion/cost_usd` 保留作 task 级粗概览。
- **dev SPA 登录撤回 邮箱+密码,删 invites 表**:前两条"邀请码 env → invites 表(0005)"一日游撤回,复用 users 表本来就有的 email/password_hash 列(0001 schema)+ 0005 加 UNIQUE(email)。`bcrypt` 哈希,新 `/v1/auth/login_password` 路由,新 `main.py user add --email --password` CLI 发用户。dev SPA 登录两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化 LS)。判定:邀请码 uuid5(NS,name) 推导对外是黑盒(改 name = 换身份),复用 users 列语义清晰也对齐生产路径。**没动**:JWT 签发 / platform_key 路径 / DB users 表列结构。 - **dev SPA 登录撤回 邮箱+密码,删 invites 表**:前两条"邀请码 env → invites 表(0005)"一日游撤回,复用 users 表本来就有的 email/password_hash 列(0001 schema)+ 0005 加 UNIQUE(email)。`bcrypt` 哈希,新 `/v1/auth/login_password` 路由,新 `main.py user add --email --password` CLI 发用户。dev SPA 登录两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化 LS)。判定:邀请码 uuid5(NS,name) 推导对外是黑盒(改 name = 换身份),复用 users 列语义清晰也对齐生产路径。**没动**:JWT 签发 / platform_key 路径 / DB users 表列结构。
- **邀请码后端 env → invites 表(0005)** _(已撤,见上条;原条目已删,有需要看 git history)_ - **邀请码后端 env → invites 表(0005)** _(已撤,见上条;原条目已删,有需要看 git history)_
- **SENTINEL user 彻底撤(数据 + 代码)**:`SENTINEL_USER_ID = UUID('00000000-...')` 在 web 必从 JWT 拿 user_id 后已无角色,按 CLAUDE.md "不写兼容层" 连根拔。DB CASCADE 删 sentinel user + workspace dotfile 目录;代码 10 处删 import / 默认参数 / fallback,`utils.py` 三函数和 `build_agent``user_id` 从可选变必填(`build_agent` 加 `*,` 转 KEYWORD_ONLY 规避默认参数顺序)。**Bonus**:把"操作 user 数据的函数必须显式传 user_id"作为 Python 必填参数固化,以后多 user 函数 typechecker 会拦到。 - **SENTINEL user 彻底撤(数据 + 代码)**:`SENTINEL_USER_ID = UUID('00000000-...')` 在 web 必从 JWT 拿 user_id 后已无角色,按 CLAUDE.md "不写兼容层" 连根拔。DB CASCADE 删 sentinel user + workspace dotfile 目录;代码 10 处删 import / 默认参数 / fallback,`utils.py` 三函数和 `build_agent``user_id` 从可选变必填(`build_agent` 加 `*,` 转 KEYWORD_ONLY 规避默认参数顺序)。**Bonus**:把"操作 user 数据的函数必须显式传 user_id"作为 Python 必填参数固化,以后多 user 函数 typechecker 会拦到。
@ -114,10 +113,9 @@ core/skills.py 81
core/task.py 82 ← §7 B Step 3: PG-backed TaskState core/task.py 82 ← §7 B Step 3: PG-backed TaskState
core/memory.py 81 ← per-user `.memory/` dotfile core/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383 core/export_docx.py 383
core/storage/__init__.py 29 ← record_chat_usage 出口(0006) core/storage/__init__.py 27
core/storage/engine.py 80 core/storage/engine.py 80
core/storage/models.py 130 ← 4 表(0004 删 runs;0005 email UNIQUE;0006 加 usage_events v2 + messages.model_profile) core/storage/models.py 98 ← 3 表(0004 删 runs/usage_events;0005 email UNIQUE)
core/storage/usage.py 70 ← 0006:record_chat_usage(litellm cost map + 双写 messages + insert usage_events)
core/storage/utils.py 136 core/storage/utils.py 136
core/agent_builder.py 307 ← 装配 lib(原 main.py 内容,05-18 改名归位) core/agent_builder.py 307 ← 装配 lib(原 main.py 内容,05-18 改名归位)
tools/{base,fs,shell,run_python,skill_tool}.py ~440 行 tools/{base,fs,shell,run_python,skill_tool}.py ~440 行
@ -129,7 +127,6 @@ db/migrations/versions/
0003_task_name_and_working_dir.py 51 0003_task_name_and_working_dir.py 51
0004_drop_runs_usage_events.py 77 0004_drop_runs_usage_events.py 77
0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE 0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE
0006_usage_events_v2_and_message_model.py 60 ← messages.model_profile 列 + usage_events v2 表(多态 units jsonb)
web/__init__.py 5 web/__init__.py 5
web/app.py ~890 ← /v1 JSON API + user_id 隔离 + run lock + task-level cancel web/app.py ~890 ← /v1 JSON API + user_id 隔离 + run lock + task-level cancel
web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT

99
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-19(dev SPA 登录改 邮箱+密码;`POST /v1/auth/login_password`;`main.py user add` CLI;Ubuntu systemd 部署 + 无感更新指引) 最后更新:2026-05-19(dev SPA 登录改 邮箱+密码;`POST /v1/auth/login_password`;`main.py user add` CLI)
--- ---
@ -136,101 +136,6 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
--- ---
## 部署(Ubuntu / systemd)
> 单机最小拓扑:`/opt/zcbot/`(代码 + `.venv` + `.env`)+ systemd unit。按需改路径 / user / port。
### 一次性
```bash
sudo useradd -r -s /sbin/nologin -d /opt/zcbot zcbot # 跑服务的非 root 用户
sudo chown -R zcbot:zcbot /opt/zcbot
# 把 .env 权限收紧(含 JWT_SECRET / PLATFORM_KEY)
sudo chmod 600 /opt/zcbot/.env
sudo chown zcbot:zcbot /opt/zcbot/.env
```
### unit 文件 `/etc/systemd/system/zcbot.service`
```ini
[Unit]
Description=zcbot web (FastAPI/uvicorn)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=zcbot
WorkingDirectory=/opt/zcbot
# 显式让 systemd 装载 .env(KEY=value 行,不展开 ${...},不留 shell 引号)
EnvironmentFile=/opt/zcbot/.env
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
# uvicorn graceful shutdown 会等 in-flight 请求(含 SSE 长连接);
# 10s 后 systemd 兜底 SIGKILL,避免 SSE 拖住 restart 卡死
TimeoutStopSec=10
KillMode=mixed
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
启用 + 日常:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -f # 实时日志
sudo systemctl restart zcbot # 重启(REST 抖动 ~2s,SSE 连接断)
sudo systemctl stop zcbot
```
> **不要再用 `kill -HUP`**:uvicorn 不响应 SIGHUP(没装 handler,落 Python 默认),也不会 reload 代码。Ubuntu 上要么 `systemctl restart`,要么用下面 `--reload` 自动模式。
### 无感更新(对 SSE 也尽量不抖)
zcbot 现在 5 人级 + SSE 长连接,**严格"零中断"**(蓝绿 + nginx + SSE 客户端 reconnect 设计)代价高,不值得。有性价比的两挡:
**A. 简易档:`--reload`**(推荐当前规模)
ExecStart 加 `--reload`,`git pull` 后 uvicorn 监听到文件变动自动重起子进程,REST 抖动 < 1s。**代价**:SSE 连接被切断(浏览器看到 "load failed",dev.html 自动跳登录页或同事重发一次消息;DB 里被切的 task 走启动 reaper `run_status=error`)。
```ini
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765 --reload
```
`sudo systemctl restart zcbot` 一次生效。之后**只 `git pull` 即可**,不用再 restart;改 unit 文件本身才需 daemon-reload + restart。
**B. 真无感档:nginx + 蓝绿双实例**(将来流量上来再上)
两个 systemd 实例 `zcbot@blue` / `zcbot@green`(模板 unit,`--port 8765` / `--port 8766`),nginx upstream 在中间切。流程:
1. 部署到空闲实例(假设 green):`sudo systemctl restart zcbot@green`
2. `curl 127.0.0.1:8766/healthz` 验新版起来
3. 改 nginx upstream 指向 green,`nginx -s reload` — **新 REST 走 green,旧 SSE 还连在 blue 不断**
4. 等 blue SSE 自然清空(`ss -tnp | grep :8765` 为空)再关 blue
**zcbot 端额外要做的事**:消息 broker 当前在 task 进程内(`web/broker.py`),蓝绿期间同 task 不同进程会丢事件。nginx 侧用 `hash $arg_task_id consistent` 保同 task 落同实例可以缓解,但 task 创建分布是另一回事。要做这条得先把 broker 改成 Redis pub/sub。10 人内**不推**,留到真有需要再上。
### 部署 SOP(目前推荐:方案 A `--reload`)
```bash
# 在服务器上
cd /opt/zcbot
sudo -u zcbot git pull --ff-only # 拉新代码
sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt # 依赖有变才需要
sudo -u zcbot .venv/bin/python main.py db upgrade head # migration 有新版才需要
# 这一步通常不用做:--reload 监听到 .py 文件变动会自动重起
# 但 .env / unit 改了 → 手动:
# sudo systemctl restart zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -n 50 # 看新进程起没起干净
```
---
## 故障兜底 ## 故障兜底
| 现象 | 原因 / 处理 | | 现象 | 原因 / 处理 |
@ -250,8 +155,6 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 | | `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步 call 不可中断,最坏等当前一轮跑完(几十秒);loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop | | 点 stop 后流式没立刻停 | LLM 同步 call 不可中断,最坏等当前一轮跑完(几十秒);loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop |
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `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` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` |
| `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完关联 task 再删目录。子目录不受此限 | | `POST /v1/files/delete` 返 409 `folder ... 仍被 N 个 task 引用` | 顶层目录被 task 引用 working_dir;先 `DELETE /v1/tasks/{id}` 删完关联 task 再删目录。子目录不受此限 |
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename | | `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name | | `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |

View File

@ -4,7 +4,6 @@ family: deepseek_v4
variants: variants:
flash: flash:
display_name: DeepSeek V4 Flash
model_id: deepseek/deepseek-v4-flash model_id: deepseek/deepseek-v4-flash
api_base: https://api.deepseek.com/v1 api_base: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY
@ -24,7 +23,6 @@ variants:
extended_thinking: false extended_thinking: false
pro: pro:
display_name: DeepSeek V4 Pro
model_id: deepseek/deepseek-v4-pro model_id: deepseek/deepseek-v4-pro
api_base: https://api.deepseek.com/v1 api_base: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY

View File

@ -187,23 +187,9 @@ def build_agent(
web 入口从 JWT 拿到后透传;不允许无 user 的调用路径 web 入口从 JWT 拿到后透传;不允许无 user 的调用路径
""" """
cfg = load_config() cfg = load_config()
model = model_name or cfg["default_model"]
uid = user_id uid = user_id
# model 选择优先级:caller 传参 > resume 时 task.model_profile > cfg["default_model"]。
# caller 传参为新建 task 时 web POST /v1/tasks 接收的 model_profile 字段;resume
# 不传时读 tasks 表(由顶栏下拉切换 PATCH 维护)。整体满足 grill A 粒度:下条 send 生效。
model = model_name
if model is None and resume and session_id:
from sqlalchemy import select as _select
from core.storage import session_scope as _scope
from core.storage.models import Task as _Task
with _scope() as _s:
model = _s.execute(
_select(_Task.model_profile).where(_Task.task_id == UUID(session_id))
).scalar_one_or_none() or None
if not model:
model = cfg["default_model"]
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"]) caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
llm = LLM(caps) llm = LLM(caps)
@ -297,7 +283,7 @@ def build_agent(
tools[rp.name] = rp tools[rp.name] = rp
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
agent = AgentLoop(llm, tools, session, caps, user_id=uid, sink=sink) agent = AgentLoop(llm, tools, session, caps, sink=sink)
return agent, session, sid, task_state, working_dir_path return agent, session, sid, task_state, working_dir_path

View File

@ -13,7 +13,6 @@ class ModelCapabilities:
model_id: str = "" model_id: str = ""
family: str = "" family: str = ""
variant: str = "" variant: str = ""
display_name: str = "" # UI 展示用,如 "DeepSeek V4 Flash";空时前端 fallback 拼 family.variant
# 上下文 # 上下文
max_context: int = 128_000 max_context: int = 128_000

View File

@ -9,12 +9,9 @@ import json
import time import time
from typing import Any, Callable, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
from uuid import UUID
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
from .storage import record_chat_usage
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]" _CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
@ -40,7 +37,6 @@ class AgentLoop:
tools: Dict[str, Any], tools: Dict[str, Any],
session: Session, session: Session,
capabilities: ModelCapabilities, capabilities: ModelCapabilities,
user_id: UUID,
sink: Optional[Any] = None, sink: Optional[Any] = None,
max_iterations: Optional[int] = None, max_iterations: Optional[int] = None,
cancel_check: Optional[Callable[[], bool]] = None, cancel_check: Optional[Callable[[], bool]] = None,
@ -49,7 +45,6 @@ class AgentLoop:
self.tools = tools self.tools = tools
self.session = session self.session = session
self.caps = capabilities self.caps = capabilities
self.user_id = user_id # usage_events 写入时按 user 维度聚合
self.max_iterations = max_iterations or capabilities.max_iterations self.max_iterations = max_iterations or capabilities.max_iterations
self.sink = sink self.sink = sink
# 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(run_id)`; # 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(run_id)`;
@ -92,25 +87,9 @@ class AgentLoop:
) )
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
msg = response.choices[0].message msg = response.choices[0].message
asst_msg_id = self.session.append(msg) self.session.append(msg)
pt, ct = _extract_usage(getattr(response, "usage", None)) pt, ct = _extract_usage(getattr(response, "usage", None))
# 记账(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,
user_id=self.user_id,
message_id=asst_msg_id,
model_profile=model_profile,
prompt_tokens=pt,
completion_tokens=ct,
response=response,
)
except Exception as e:
self._emit({"type": "warn", "msg": f"record_usage failed: {type(e).__name__}: {e}"})
self._emit({ self._emit({
"type": "llm_end", "type": "llm_end",
"prompt_tokens": pt, "prompt_tokens": pt,

View File

@ -69,31 +69,24 @@ class Session:
if system_prompt: if system_prompt:
self.messages.append({"role": "system", "content": system_prompt}) self.messages.append({"role": "system", "content": system_prompt})
def append(self, msg: Any) -> Optional[UUID]: def append(self, msg: Any) -> None:
"""追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。 """追加消息;非 system 落 DB,system 仅内存。
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入; 前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入;
Session 不再做 idempotent ensure( user 上下文, task 必先在,多余) Session 不再做 idempotent ensure( user 上下文, task 必先在,多余)
返回值: system row message_id( loop usage_events 关联用);
system 消息不入库, None旧调用方忽略返回值不影响行为
""" """
msg_dict = _to_dict(msg) msg_dict = _to_dict(msg)
self.messages.append(msg_dict) self.messages.append(msg_dict)
if msg_dict.get("role") == "system": if msg_dict.get("role") == "system":
return None return
with session_scope() as s: with session_scope() as s:
row = Message( s.add(Message(
task_id=self.task_id, task_id=self.task_id,
idx=self._db_idx, idx=self._db_idx,
payload=msg_dict, payload=msg_dict,
) ))
s.add(row)
s.flush() # 触发 INSERT 拿到 server-default 生成的 message_id
msg_id = row.message_id
self._db_idx += 1 self._db_idx += 1
return msg_id
def reset(self, keep_system: bool = True) -> None: def reset(self, keep_system: bool = True) -> None:
"""清空消息。keep_system 仅影响内存(system 本来就不在 DB)。""" """清空消息。keep_system 仅影响内存(system 本来就不在 DB)。"""

View File

@ -12,7 +12,6 @@ from .engine import (
get_engine, get_engine,
session_scope, session_scope,
) )
from .usage import record_chat_usage
from .utils import ( from .utils import (
NoSubtaskError, NoSubtaskError,
check_no_subtask, check_no_subtask,
@ -28,7 +27,6 @@ __all__ = [
"ensure_local_task_row", "ensure_local_task_row",
"get_engine", "get_engine",
"get_task", "get_task",
"record_chat_usage",
"session_scope", "session_scope",
"update_task", "update_task",
"upsert_task", "upsert_task",

View File

@ -1,16 +1,12 @@
"""SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。 """SQLAlchemy 2.x ORM models,对应 DESIGN.md §7.4 schema。
4 张表:users / tasks / messages / usage_events 3 张表:users / tasks / messages
- users 行在 web 入口按需 INSERT(`/v1/auth/login_password` 实际创行 / `/v1/auth/login` - users 行在 web 入口按需 INSERT(`/v1/auth/login_password` 实际创行 / `/v1/auth/login`
platform_key 入口 ensure_user_row);email UNIQUE(0005) login lookup , platform_key 入口 ensure_user_row);email UNIQUE(0005) login lookup ,
password_hash bcrypt(`bcrypt.hashpw`),只在邮箱密码登录时有值 password_hash bcrypt(`bcrypt.hashpw`),只在邮箱密码登录时有值
- messages.payload jsonb,GIN 索引在 migration 里建;messages.model_profile - messages.payload jsonb,GIN 索引在 migration 里建
(0006)只在 assistant 行有值,标注产生该条 message 的模型
- run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs ); - run 状态承载在 tasks.run_status / run_error 两列(0004 合并 runs );
runs / usage_events 0004 DESIGN §7.4 取舍 / PROGRESS 05-18 runs / usage_events 0004 DESIGN §7.4 取舍 / PROGRESS 05-18
- usage_events(0006 v2 形态):per-event 多态用量行,kind=chat/image/video/...,
units JSONB(chat tokens_in/out,image count/size )用户级聚合
(user_id, created_at) 复合索引,JOIN-free DESIGN §7.4 / PROGRESS 05-19
""" """
from __future__ import annotations from __future__ import annotations
@ -94,44 +90,6 @@ class Message(Base):
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。
# 跟 usage_events.model_profile 写入一致,JOIN-free 时按 message 直查也能拿到。
model_profile: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class UsageEvent(Base):
"""per-event 用量记账(0006 v2 形态)。
一行 = 一次产生成本的调用chat 类型由 loop assistant message 入库后写入;
未来的媒体工具(image/video/audio) tool execute 完后由 loop 顺手记账
units polymorphic JSONB chat: {"tokens_in": N, "tokens_out": M};
image: {"count": K, "size": "1024x1024"}; kind 约定
user 聚合的统计 query (user_id, created_at) 索引, JOIN tasks
"""
__tablename__ = "usage_events"
event_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False
)
task_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("tasks.task_id", ondelete="CASCADE"),
nullable=False,
)
message_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("messages.message_id", ondelete="SET NULL"),
nullable=True,
)
kind: Mapped[str] = mapped_column(Text, nullable=False) # chat / image / video / audio / ...
model_profile: Mapped[str] = mapped_column(Text, nullable=False) # deepseek_v4.pro / dall-e-3 / ...
units: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
cost_usd: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -1,77 +0,0 @@
"""用量记账(0006):一次产生成本的调用 = 一行 usage_events + 双写 messages 列。
chat 类型的入口由 loop.py assistant message 入库后调用;未来的媒体工具
(image/video/audio) tool execute 后由 loop 顺手记账
成本计算依赖 litellm cost map(litellm.cost_calculator.completion_cost)
未知 model map 缺失时 cost=0(不阻塞主流程),emit warn sink
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import update
from .engine import session_scope
from .models import Message, UsageEvent
def _safe_chat_cost(response: Any) -> Decimal:
"""litellm.completion_cost(response) 包一层:任何异常都吞掉返 0。
未知 model / cost map 没收录 / response 结构变都不影响主流程 usage_events
仍写入,只是 cost_usd=0,后续人工补算 OK
"""
try:
from litellm import completion_cost # type: ignore[import-not-found]
cost = completion_cost(completion_response=response)
if cost is None:
return Decimal("0")
return Decimal(str(cost))
except Exception:
return Decimal("0")
def record_chat_usage(
*,
task_id: UUID,
user_id: UUID,
message_id: Optional[UUID],
model_profile: str,
prompt_tokens: int,
completion_tokens: int,
response: Any = None,
) -> Decimal:
"""记一次 chat 调用:写 usage_events 行 + 回填 messages.model_profile/tokens_in/out。
`message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到)
usage_events 仍写但 message_id=NULL,messages 列不回填
`model_profile` 形如 `"deepseek_v4.pro"`(family.variant)
返回算出的 cost_usd(已落库),调用方可用作 SSE 显示
"""
cost = _safe_chat_cost(response)
units = {"tokens_in": int(prompt_tokens), "tokens_out": int(completion_tokens)}
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=message_id,
kind="chat",
model_profile=model_profile,
units=units,
cost_usd=cost,
))
if message_id is not None:
s.execute(
update(Message)
.where(Message.message_id == message_id)
.values(
tokens_in=int(prompt_tokens),
tokens_out=int(completion_tokens),
model_profile=model_profile,
)
)
return cost

View File

@ -1,62 +0,0 @@
"""usage_events 表(v2 形态)+ messages.model_profile 列。
Revision ID: 0006
Revises: 0005
Create Date: 2026-05-19
模型切换 + 用量统计(grill 共识):
- task 默认模型走 tasks.model_profile(0001 起就存,本次开始真用)
- 每条 assistant message 标注实际用的模型 messages.model_profile(本次加列)
- 用量按 message 记成一行 usage_event(JSONB polymorphic units),前期只 chat kind;
未来扩 image/video/audio 不动 schema0004 删掉的 v1 形态(id/user_id/task_id/run_id/
kind/value/ts)字段不够,本次按 grill Q6 设计重建一张 v2 形态
- task_id ondelete=CASCADE(简单, messages 一致);message_id ondelete=SET NULL
(单条 message 不会主动删,留作未来防御);user_id 仍是 NOT NULL 计费 / 限额查询
user 维度直出,不靠 JOIN
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "0006"
down_revision: Union[str, None] = "0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"messages",
sa.Column("model_profile", sa.Text(), nullable=True),
)
op.create_table(
"usage_events",
sa.Column("event_id", postgresql.UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.user_id"), nullable=False),
sa.Column("task_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="CASCADE"), nullable=False),
sa.Column("message_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("messages.message_id", ondelete="SET NULL"), nullable=True),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("model_profile", sa.Text(), nullable=False),
sa.Column("units", postgresql.JSONB(), nullable=False),
sa.Column("cost_usd", sa.Numeric(12, 6), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_usage_user_created", "usage_events", ["user_id", "created_at"])
op.create_index("ix_usage_task", "usage_events", ["task_id"])
op.create_index("ix_usage_model_created", "usage_events", ["model_profile", "created_at"])
def downgrade() -> None:
op.drop_index("ix_usage_model_created", table_name="usage_events")
op.drop_index("ix_usage_task", table_name="usage_events")
op.drop_index("ix_usage_user_created", table_name="usage_events")
op.drop_table("usage_events")
op.drop_column("messages", "model_profile")

View File

@ -233,26 +233,6 @@ def _sse_event(event_type: str, payload: dict) -> bytes:
return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8") return f"event: {event_type}\ndata: {body}\n\n".encode("utf-8")
def _resolve_model_profile(profile: str) -> tuple[str, str]:
"""校验 model_profile 并返回 (profile, model_id)。
传空 cfg["default_model"]profile ModelCapabilities.load:
格式或文件错误一律 400 (profile_str, caps.model_id) ensure_local_task_row
model_profile / model 两列一起填,保持现有 schema 双列约定
"""
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.paths import ROOT
cfg = load_config()
name = (profile or "").strip() or cfg["default_model"]
try:
caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, f"invalid model_profile {name!r}: {e}")
return name, caps.model_id
# ────────────────────── Pydantic 请求体 ────────────────────── # ────────────────────── Pydantic 请求体 ──────────────────────
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
@ -260,7 +240,6 @@ class TaskCreateRequest(BaseModel):
working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名) working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名)
description: str = "" description: str = ""
skill: str = "" skill: str = ""
model_profile: str = "" # `family.variant`,留空 → cfg["default_model"];必须存在于 config/models/
class TaskPatchRequest(BaseModel): class TaskPatchRequest(BaseModel):
@ -268,7 +247,6 @@ class TaskPatchRequest(BaseModel):
description: Optional[str] = None description: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
skill: Optional[str] = None skill: Optional[str] = None
model_profile: Optional[str] = None # 切模型(c 模式 task 层 / A 粒度 — 下条 send 生效)
class MessageRequest(BaseModel): class MessageRequest(BaseModel):
@ -361,45 +339,6 @@ def create_app() -> FastAPI:
def healthz(): def healthz():
return {"status": "ok"} return {"status": "ok"}
@app.get("/v1/models", tags=["misc"])
def list_models(user_id: UUID = Depends(require_user)):
"""列出所有可用 LLM 模型(扫 config/models/*.yaml)。
前端顶栏 / 新建对话框的模型下拉拉这个is_default 标记 cfg["default_model"]
命中项开发期不缓存,每次扫一遍(几个文件 IO); YAML 立即生效
"""
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.paths import ROOT
import yaml as _yaml
cfg = load_config()
default = cfg["default_model"]
models_dir = ROOT / cfg["models_dir"]
out: list[dict] = []
if models_dir.is_dir():
for path in sorted(models_dir.glob("*.yaml")):
try:
data = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception:
continue
family = data.get("family") or path.stem
for variant in (data.get("variants") or {}).keys():
profile = f"{family}.{variant}"
try:
caps = ModelCapabilities.load(profile, models_dir)
except (ValueError, FileNotFoundError):
continue
out.append({
"profile": profile,
"display_name": caps.display_name or profile,
"family": caps.family,
"variant": caps.variant,
"thinking_mode": caps.thinking_mode,
"is_default": profile == default,
})
return {"models": out}
# ───────────── Auth ───────────── # ───────────── Auth ─────────────
@app.post("/v1/auth/login", tags=["auth"]) @app.post("/v1/auth/login", tags=["auth"])
@ -487,11 +426,9 @@ def create_app() -> FastAPI:
# 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True) # 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True)
fs_dir.mkdir(parents=True, exist_ok=True) fs_dir.mkdir(parents=True, exist_ok=True)
profile, model_id = _resolve_model_profile(body.model_profile)
ensure_local_task_row( ensure_local_task_row(
task_id=tid, name=name, working_dir=fs_dir_db, skill=skill, task_id=tid, name=name, working_dir=fs_dir_db, skill=skill,
description=description, user_id=user_id, description=description, user_id=user_id,
model=model_id, model_profile=profile,
) )
with session_scope() as s: with session_scope() as s:
row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()
@ -694,12 +631,6 @@ def create_app() -> FastAPI:
updates["name"] = validate_task_name(body.name) updates["name"] = validate_task_name(body.name)
except InvalidTaskName as e: except InvalidTaskName as e:
raise HTTPException(400, f"name 不合法: {e}") raise HTTPException(400, f"name 不合法: {e}")
if body.model_profile is not None:
# 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前
# in-flight run 不受影响(build_agent resume 时下次重读)。
profile, model_id = _resolve_model_profile(body.model_profile)
updates["model_profile"] = profile
updates["model"] = model_id
if not updates: if not updates:
raise HTTPException(400, "no fields to update") raise HTTPException(400, "no fields to update")
with session_scope() as s: with session_scope() as s:
@ -737,7 +668,7 @@ def create_app() -> FastAPI:
rows = s.execute( rows = s.execute(
select( select(
Message.idx, Message.payload, Message.tokens_in, Message.idx, Message.payload, Message.tokens_in,
Message.tokens_out, Message.model_profile, Message.created_at, Message.tokens_out, Message.created_at,
).where(Message.task_id == tid).order_by(Message.idx) ).where(Message.task_id == tid).order_by(Message.idx)
).all() ).all()
return { return {
@ -747,7 +678,6 @@ def create_app() -> FastAPI:
"payload": dict(r.payload), "payload": dict(r.payload),
"tokens_in": r.tokens_in, "tokens_in": r.tokens_in,
"tokens_out": r.tokens_out, "tokens_out": r.tokens_out,
"model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型
"created_at": _iso(r.created_at), "created_at": _iso(r.created_at),
} }
for r in rows for r in rows

View File

@ -491,8 +491,6 @@
<select id="nt-skill"> <select id="nt-skill">
<option value="">(默认 · 不限定)</option> <option value="">(默认 · 不限定)</option>
</select> </select>
<label for="nt-model">模型</label>
<select id="nt-model"></select>
<div class="err" id="nt-err"></div> <div class="err" id="nt-err"></div>
<div class="actions"> <div class="actions">
<button id="nt-cancel">取消</button> <button id="nt-cancel">取消</button>
@ -532,8 +530,6 @@ const state = {
taskPage: 1, taskPage: 1,
taskPageSize: 20, taskPageSize: 20,
taskTotal: 0, taskTotal: 0,
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
models: [],
}; };
// ───── helpers ───── // ───── helpers ─────
@ -752,16 +748,6 @@ function enterApp() {
$("hd-who").title = state.userId; $("hd-who").title = state.userId;
loadTaskList(); loadTaskList();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
}
async function loadModels() {
try {
const data = await api("GET", "/v1/models");
state.models = data.models || [];
} catch (e) {
state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程
}
} }
async function loadTaskList() { async function loadTaskList() {
@ -938,11 +924,8 @@ function renderChatMeta() {
<span class="tid">${t.task_id.slice(0, 8)}</span> <span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""} ${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span> <span class="spacer"></span>
${renderModelDropdown(t)}
<span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span> <span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span>
`; `;
const sel = $("chat-model-sel");
if (sel) sel.onchange = onChangeModel;
const active = t.status === "active"; const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none"; $("chat-form").style.display = active ? "flex" : "none";
$("btn-done").disabled = !active; $("btn-done").disabled = !active;
@ -951,35 +934,6 @@ function renderChatMeta() {
$("btn-export").disabled = (t.n_messages || 0) === 0; $("btn-export").disabled = (t.n_messages || 0) === 0;
} }
function renderModelDropdown(t) {
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
if (!state.models || state.models.length === 0) return "";
const cur = t.model_profile || "";
const opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">模型 <select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}
async function onChangeModel(ev) {
const sel = ev.target;
const newProfile = sel.value;
const t = state.taskMeta;
if (!t || !newProfile || newProfile === t.model_profile) return;
const oldProfile = t.model_profile || "";
try {
const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
state.taskMeta = updated;
const running = updated.run_status === "running" || updated.run_status === "cancelling";
$("chat-hint").textContent = running
? `已切到 ${newProfile} · 当前 run 跑完后生效`
: `已切到 ${newProfile}`;
} catch (e) {
sel.value = oldProfile; // PATCH 失败 UI 回滚
$("chat-hint").textContent = `切换失败:${e.message}`;
}
}
async function loadMessages() { async function loadMessages() {
const data = await api("GET", `/v1/tasks/${state.taskId}/messages`); const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
renderMessages(data.messages); renderMessages(data.messages);
@ -992,22 +946,10 @@ function renderMessages(msgs) {
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`; wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
return; return;
} }
// 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
// (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。
let lastAsstModel = null;
for (const m of msgs) { for (const m of msgs) {
const p = m.payload || {}; const p = m.payload || {};
const role = p.role || "?"; const role = p.role || "?";
if (role === "system") continue; // 不显示 system if (role === "system") continue; // 不显示 system
if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
const sep = document.createElement("div");
sep.className = "model-switch muted";
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
sep.textContent = `── ${dn} ──`;
wrap.appendChild(sep);
lastAsstModel = m.model_profile;
}
if (role === "tool") { if (role === "tool") {
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
const card = document.createElement("div"); const card = document.createElement("div");
@ -1708,33 +1650,19 @@ $("hd-new").onclick = async () => {
$("nt-err").textContent = ""; $("nt-err").textContent = "";
$("nt-wd-hint").textContent = ""; $("nt-wd-hint").textContent = "";
$("new-task-modal").classList.add("show"); $("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]); await Promise.all([loadFolderSuggestions(), loadSkillOptions()]);
populateModelSelect();
$("nt-name").focus(); $("nt-name").focus();
}; };
function populateModelSelect() {
const sel = $("nt-model");
const models = state.models || [];
if (models.length === 0) {
sel.innerHTML = `<option value="">(默认)</option>`;
return;
}
sel.innerHTML = models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show"); $("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => { $("nt-go").onclick = async () => {
const name = $("nt-name").value.trim(); const name = $("nt-name").value.trim();
const working_dir = $("nt-wd").value.trim(); const working_dir = $("nt-wd").value.trim();
const desc = $("nt-desc").value.trim(); const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value; const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
$("nt-err").textContent = ""; $("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; } if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
try { try {
const t = await api("POST", "/v1/tasks", const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
{ name, working_dir, description: desc, skill, model_profile });
$("new-task-modal").classList.remove("show"); $("new-task-modal").classList.remove("show");
await loadTaskList(); await loadTaskList();
selectTask(t.task_id); selectTask(t.task_id);