From 781a216ca63ff236a16ff6fdcb9a298ae0fd7fca Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 19 May 2026 21:43:13 +0800 Subject: [PATCH] =?UTF-8?q?model:=20=E5=90=8C=20task=20=E5=86=85=E5=88=87?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B(c=20=E6=A8=A1=E5=BC=8F=20task=20=E7=BA=A7=20?= =?UTF-8?q?/=20A=20=E7=B2=92=E5=BA=A6)+=20usage=5Fevents=20v2=20=E8=A1=A8(?= =?UTF-8?q?0006);=20GET=20/v1/models;=20=E5=89=8D=E7=AB=AF=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E4=B8=8B=E6=8B=89=20+=20=E5=8E=86=E5=8F=B2=20model=20?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E7=82=B9=E5=B0=8F=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB(0006): messages 加 model_profile 列(assistant 行有值); 重建 usage_events 表 v2 形态(event_id/user_id/task_id/message_id/kind/model_profile/units jsonb/cost_usd + 三索引), 0004 删的旧 schema 字段不够多态; tasks.tokens_prompt/completion/cost_usd 保留作粗概览 - ModelCapabilities 加 display_name; deepseek_v4.yaml flash/pro 各填名 - GET /v1/models: 扫 config/models/*.yaml 列可选项(profile/display_name/family/thinking_mode/is_default); POST /v1/tasks + PATCH 接受 model_profile(不传 → cfg["default_model"]; 校验走 ModelCapabilities.load 失败 400) - build_agent: resume 时优先 task.model_profile 而非 cfg default; AgentLoop 加 user_id 透传, 每轮 assistant 入库后调 record_chat_usage(litellm cost map 算钱, 失败吞掉 emit warn 不阻 loop) - core/storage/usage.py 新文件: record_chat_usage(双写 messages.tokens_in/out + model_profile + insert usage_events 一行) - session.append() 返回 message_id(供 usage 关联) - 前端 dev.html: chat-meta 加模型下拉(切了 PATCH + running 中提示"跑完后生效"); 新建对话框 modal 加 nt-model select; renderMessages 按 model_profile 切换点画小标 "── DeepSeek V4 Pro ──" - CLAUDE.md: 加"开发测试期 / 不删现有数据 / DROP COLUMN 两种情况"规则 - DESIGN §7.4 schema 加 messages.model_profile + usage_events v2 段; PROGRESS 加 0006 条目 + 文件清单 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 9 ++- DESIGN.md | 18 ++++- PROGRESS.md | 9 ++- config/models/deepseek_v4.yaml | 2 + core/agent_builder.py | 18 ++++- core/capabilities.py | 1 + core/loop.py | 23 +++++- core/session.py | 17 ++-- core/storage/__init__.py | 2 + core/storage/models.py | 48 +++++++++++- core/storage/usage.py | 77 +++++++++++++++++++ ..._0006_usage_events_v2_and_message_model.py | 62 +++++++++++++++ web/app.py | 72 ++++++++++++++++- web/static/dev.html | 76 +++++++++++++++++- 14 files changed, 411 insertions(+), 23 deletions(-) create mode 100644 core/storage/usage.py create mode 100644 db/migrations/versions/20260519_1600_0006_usage_events_v2_and_message_model.py diff --git a/CLAUDE.md b/CLAUDE.md index 241d083..ebd41d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,13 +8,14 @@ ## 开发阶段心智 -当前处于开发阶段(尚未发布给真实用户)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**: -- DB schema 变 → 直接改 model + 写一条干净的 migration(必要时清空旧 row,不写双向兼容代码) -- 字段语义变 → 全量替换,不留 `legacy_xxx` / `*_v2` 并存 +当前处于**开发测试期**(开发自用 + 内部测试,DB 已有真实测试数据)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**,但**不删现有数据**: +- DB schema 变 → 直接改 model + 写一条干净的 migration:加列 / 改列结构 OK;**不要 truncate / DELETE FROM 现有表 —— 测试数据要保留** +- 删字段(DROP COLUMN)前:若该列是当前唯一持有该信息(如累计型 tokens 列),先 backfill 到新位置再删;若纯冗余(从其他列能推出)直接删 OK +- 字段语义变 → 全量替换 + migration 把旧值映射到新值(不留 `legacy_xxx` / `*_v2` 并存) - CLI / REPL 选项变 → 直接改,不留 deprecated 别名 - 只有当用户明确说"这条要保留兼容"时才写兼容代码 -理由:兼容层就是技术债,开发期写了之后忘记删反而拖累;真上线后再视情况补迁移路径。 +理由:兼容层是技术债;但测试数据是观察新代码行为的依据 —— 一次 truncate 后再回去查"上周那 task 烧了多少 token / 哪条消息触发的 bug",就只能瞎猜。 ## 文档维护 diff --git a/DESIGN.md b/DESIGN.md index 06de639..7440362 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -324,12 +324,26 @@ create index on tasks (user_id, working_dir); -- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255 messages(message_id uuid pk, task_id fk, idx int not null, - payload jsonb not null, tokens_in, tokens_out, created_at, + payload jsonb not null, tokens_in, tokens_out, + model_profile text null, -- 0006:只在 assistant 行有值,标产生该 msg 的模型 + created_at, unique (task_id, idx)); 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`;`usage_events` 是计费预付架构成本,真要计费再加。`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。 +**0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`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` 清。 **No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%` 或 `existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 diff --git a/PROGRESS.md b/PROGRESS.md index 4fef75a..01ba355 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — `users.email/password_hash` + UNIQUE + `main.py user add` CLI + 登录页两 tab) +最后更新:2026-05-19(0006 模型切换 + usage_events v2 表:task 级模型 PATCH / `GET /v1/models` / 前端顶栏下拉 + 历史小标 / chat usage 落 messages 双写 + usage_events 一行,A 粒度下条 send 生效) --- @@ -23,6 +23,7 @@ ### 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 加 ` + +
@@ -530,6 +532,8 @@ const state = { taskPage: 1, taskPageSize: 20, taskTotal: 0, + // 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用 + models: [], }; // ───── helpers ───── @@ -748,6 +752,16 @@ function enterApp() { $("hd-who").title = state.userId; loadTaskList(); 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() { @@ -924,8 +938,11 @@ function renderChatMeta() { ${t.task_id.slice(0, 8)} ${t.description ? `${escapeHtml(t.description)}` : ""} + ${renderModelDropdown(t)} ${t.n_messages || 0} 条 · ${t.tokens || 0} tok `; + const sel = $("chat-model-sel"); + if (sel) sel.onchange = onChangeModel; const active = t.status === "active"; $("chat-form").style.display = active ? "flex" : "none"; $("btn-done").disabled = !active; @@ -934,6 +951,35 @@ function renderChatMeta() { $("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 => + `` + ).join(""); + return `模型 `; +} + +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() { const data = await api("GET", `/v1/tasks/${state.taskId}/messages`); renderMessages(data.messages); @@ -946,10 +992,22 @@ function renderMessages(msgs) { wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`; return; } + // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔 + // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。 + let lastAsstModel = null; for (const m of msgs) { const p = m.payload || {}; const role = p.role || "?"; 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") { // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) const card = document.createElement("div"); @@ -1650,19 +1708,33 @@ $("hd-new").onclick = async () => { $("nt-err").textContent = ""; $("nt-wd-hint").textContent = ""; $("new-task-modal").classList.add("show"); - await Promise.all([loadFolderSuggestions(), loadSkillOptions()]); + await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]); + populateModelSelect(); $("nt-name").focus(); }; +function populateModelSelect() { + const sel = $("nt-model"); + const models = state.models || []; + if (models.length === 0) { + sel.innerHTML = ``; + return; + } + sel.innerHTML = models.map(m => + `` + ).join(""); +} $("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show"); $("nt-go").onclick = async () => { const name = $("nt-name").value.trim(); const working_dir = $("nt-wd").value.trim(); const desc = $("nt-desc").value.trim(); const skill = $("nt-skill").value; + const model_profile = $("nt-model").value; $("nt-err").textContent = ""; if (!name) { $("nt-err").textContent = "任务名为必填项"; return; } try { - const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill }); + const t = await api("POST", "/v1/tasks", + { name, working_dir, description: desc, skill, model_profile }); $("new-task-modal").classList.remove("show"); await loadTaskList(); selectTask(t.task_id);