zcbot/PROGRESS.md

103 KiB
Raw Blame History

实施进度

配合 DESIGN.md。本文件只记 phase 状态、决策偏差、文件量、下一步。

最后更新:2026-05-19(dev SPA 登录从"邀请码/uuid5"撤回 邮箱+密码 — 复用 0001 schema 的 users.email/password_hash、加 UNIQUE(email)、加 main.py user add CLI、登录页两 tab 切换)


状态

Phase 标题 状态 备注
1-3 骨架 + Skill + run_python 三个 skill;CoreCoder 唯一匹配 edit;敏感 env 过滤
4 演化性能力 🟡 Model Profile + Probing ;版本化 prompt 未做
5 Eval Suite ⏸ 不做 dogfooding 替代,probe 覆盖健康检查
6 长任务工程化 🟡 task + 恢复 ;双层记忆 ;context 压缩未做
7 打磨 Docker 沙盒 / 更多 skill
§7 SaaS DESIGN §7 路线 🟡 A 事件流化 ;B 完工;D /v1 JSON API 完工 (原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ;同 task 单活 run 锁 ;task-level cancel + dev SPA stop 按钮 ;0004 schema 瘦身 (删 runs/usage_events);入口归位 (cli.pymain.py,装配 lib 挪 core/agent_builder.py,CLI REPL 删,§7 E 撤);真 OIDC 待;C(Executor)待。

已完成关键能力

  • 05-19 / dev SPA 登录撤回 邮箱+密码,删 invites 表 + 邀请码路径:接前两条 "邀请码 env → invites 表(0005)" 用了一天,用户复盘"还不如直接复用 users 表本来就有的 email/password_hash 列"。判定:invites + uuid5(NS, name) 推导是黑盒(同事不知道自己 user_id 从哪来、改 name 等于换身份),复用 users 列语义清晰、生产路径上也是 email+password 主流。dev 期不留兼容,直接 downgrade 0005 → 删 invites 文件 → 写干净的新 0005 加 UNIQUE(email)DB(db/migrations/.../20260519_1500_0005_users_email_unique.py):CREATE UNIQUE CONSTRAINT uq_users_email ON users(email);PG UNIQUE 对 NULL 不冲突,所以 platform_key 入口建的 user(email=NULL)不受影响。密码哈希 bcrypt>=4.1.0(Windows wheel bcrypt-5.0.0-cp39-abi3-win_amd64),bcrypt.hashpw + gensalt() 默认 cost=12,存 ASCII str $2b$12$...users.password_hash;bcrypt.checkpw 常数时间比对。web/auth.py:① 删 resolve_invite + _INVITE_NAMESPACE 常量 + from core.storage.models import Invite;② 新 hash_password(s) -> str(bcrypt hashpw → ASCII)+ verify_password(s, h) -> bool(checkpw,异常 fallback False 防 stored_hash 坏数据 500)+ resolve_user_by_email(email, password) -> Optional[(user_id, email)](SELECT 不到时跑一次 dummy bcrypt.checkpw(b"x", b"$2b$12$..."*53) 防 timing oracle);③ ensure_user_row docstring 改写(只 platform_key 入口走;邮箱密码登录走 main.py user add 已经写好 users 行)。web/app.py:① InviteLoginRequestPasswordLoginRequest {email, password};② /v1/auth/login_invite 路由 → /v1/auth/login_password(命中返 {token, expires_at, user_id, email, ttl_seconds};错邮箱 / 错密码 / 邮箱不存在统一 403 invalid email or password 不细分错因防探测用户存在性);③ 不调 ensure_user_row(行已由 user add 创);老 /v1/auth/login (platform 路径) 不动。core/storage/models.py:① 删 Invite class;② email 列加 unique=True;③ 文件 docstring 改"3 张表"(原 4)。新加 CLI main.py user add --email X --password Y [--user-id UUID]:bcrypt hash + INSERT users(email,password_hash[,user_id]);email lowercase + @ 简单校验,password ≥ 6 字符;email UNIQUE 撞 / user_id PK 撞 → IntegrityError 走 except 退 2(报清错因)。--user-id 可选用于把已有 user_id(platform_key 入口创的孤儿)接到邮箱密码路径。dev SPA web/static/dev.html:登录卡 1 格("邀请码")改 2 tab 切换(#tab-pw "邮箱密码" 默认 / #tab-key "UUID + PLATFORM_KEY" 备用);CSS 新加 .tabs / .tab-body + active 态;JS loginTab + LS_TAB localStorage 持久化 last-used tab(回登录页保留);switchLoginTab auto-focus 第一个 input;doLogin 按 tab 分派 url + body — pw → /v1/auth/login_password {email, password},key → /v1/auth/login {user_id, platform_key};header 显示 name 时优先用返回的 email(pw 路径),platform_key 路径无显示名只显 uid 前 8 位。任意 input 上回车均触发登录。Smoke(curl + web 起服)4 case 全绿:① user add dev@example.com / devpass123 200 OK;② POST /v1/auth/login_password 正密码 200 + 返 user_id+email+JWT;③ 错密码 403 invalid email or password;④ 不存在邮箱 403 同;⑤ 重复 add 同 email → IntegrityError 走 except 退 2 + 报 uq_users_email;⑥ 老路径 POST /v1/auth/login_invite 404 Not Found(路由已删)。没动:JWT 签发 / verify_token / require_user Depends(签出来的 JWT 同款,后续 /v1/* 路由层完全无感)/ /v1/auth/login platform 路径 / DB users 表本身列结构(0001 早就有 email/password_hash,0005 只加约束)。Migration 序号:旧 0005(invites)在本次开发期 downgrade + 删 file 整体抹除,新 0005 重新使用同号承接 users.email UNIQUE(down_revision: 0004)。文档同步:RUN.md(env 段去 invites / 加 user CLI 段 + 路由表 /v1/auth/login_invite/v1/auth/login_password + 故障兜底改邀请码相关 4 条 → 邮箱密码 4 条);PROGRESS 本条。改文件:db/migrations/.../20260519_1500_0005_users_email_unique.py(+33 行新文件,替代旧 0005 invites file)/ db/migrations/.../20260519_1100_0005_invites_table.py(删)/ core/storage/models.py(-15 行 Invite class,email 加 unique)/ web/auth.py(净 ~+10 行:删 ~40 行 invite,加 ~50 行 password helpers)/ web/app.py(login_invite → login_password 同尺寸)/ web/static/dev.html(+~80 行 JS/HTML/CSS 2 tab 登录)/ main.py(+~50 行 user CLI)/ requirements.txt(+1 行 bcrypt)/ RUN.md / PROGRESS.md净增量:~+90 行(2 tab UI + CLI 都是有功能价值的代码,前一条邀请码净 -20 行被这条吃掉)。
  • 05-19 / 邀请码后端从 env 升级到 invites 表(0005 migration) (已撤,见上条):接上一条 ZCBOT_INVITES env 落地后,用户复盘"用一张表还是 env 哪个更好",讨论 trade-off 后判定:≤5 人 + 一个人管 + 开发期这个体量下 env 完胜 — 0 migration / 0 CRUD / 1 秒重启;但用户又转念:"既然将来真发布要表,不如现在就薄薄一张,我直接 DB 里 INSERT 试用,后期再加 CLI 命令"。判定:env 升表是低成本前置(schema 极薄,签名几乎不变),省去"未来再写 migration + 同步 env 数据"。Schema(0005,db/migrations/.../20260519_1100_0005_invites_table.py):invites(token text pk, name text not null unique, created_at timestamptz default now())设计取舍:① token PK 而非自增 id — lookup 入口直接是 token,PK 自带索引,id 列纯死字段;② name UNIQUE — 同 name → 同 uuid5 → 两 token 共身份漏,DB schema 拦比应用层早一层;③ 不存 user_iduuid5(固定 NS, name) 推导,namespace 永远不动,存它等于冗余 + 偏离单一真相源(改 namespace 全员漂移这个风险一样存在,不靠存 user_id 解);④ 不存 revoked_at — 撤销直接 DELETE FROM invites WHERE name=...,软删的"revoked 是否签 JWT"分支判断 ≤5 人场景用不上;⑤ 不加管理 CLI 子命令 — 用户明确说"我到时候在数据库里直接添加",后期需要再加 python main.py invite {add|list|revoke} 薄包装。ORM(core/storage/models.py)新加 Invite class(token PK / name unique / created_at server_default)。web/auth.py 全面重写:① 删 _parse_invites 函数(~40 行 env 字符串解析 + 校验)+ 删 _INVITE_NAME_RE(改交给 DB UNIQUE 约束)+ 删 AuthConfig.invites 字段 + 删 AuthConfig.resolve_invite 方法 + 删 AuthConfig.from_env 里读 ZCBOT_INVITES 那段 + 删 AuthConfig.__init__ 多余 invites 参数;② 新增模块级 resolve_invite(token) -> Optional[(name, user_id)]:每次 login 走一次 SELECT name FROM invites WHERE token = ?,命中后 uuid5(NS, name) 算 user_id;不缓存,避免 DELETE 后还能登的不一致窗口(5 人级别用户开销可忽略,P99 也是 1ms 级);③ docstring 改写(ZCBOT_INVITESinvites 表,加发码 SQL 示例);④ __all__resolve_invite(模块函数,不是 AuthConfig 方法,刻意分开 — DB 查询的 callable 不绑 cfg 闭包,更清晰)。web/app.py::login_invite 路由:从 auth_cfg.invites / auth_cfg.resolve_invite 改为 from .auth import resolve_invite;if not auth_cfg.invites: 403 "invite login disabled" 那条早返判断直接删 — 空表跟未配 env 是同一语义,resolve_invite 自然返 None;docstring 加发码 / 撤销 SQL 速查。migration 跑通:db upgrade 0004 → 0005 一把过,db current0005 (head)Smoke 9 case 全绿(TestClient 起 app + 直 DB SQL):① AuthConfig 不再有 invites/resolve_invite 字段;② auth.py 源码 grep 完全无 ZCBOT_INVITES / _parse_invites 痕迹(env 即使设了也不被读);③ 空表 → resolve_invite 任何 token 返 None;④ 手动 INSERT INTO invites(token, name) VALUES('smoke_tok_alice','alice');⑤ resolve_invite('smoke_tok_alice') 命中 → ('alice', uuid5(NS,'alice'));⑥ POST /v1/auth/login_invite {token: 'smoke_tok_alice'} 200 + 正确 user_id + name;⑦ 错 / 空 token → 403 invalid invite token;⑧ JWT 调 /v1/tasks 200 + 老 /v1/auth/login (platform 路径) 仍工作;⑨ DELETE FROM invites WHERE name='alice' 后立刻 login 403(验证无缓存);⑩ 同 name 两行 INSERT 触发 IntegrityError(UNIQUE 生效);⑪ 同 token 两行 INSERT 触发 IntegrityError(PK 生效)。没动:POST /v1/auth/login(platform 机器对机器入口,与 invite 路径正交)、JWT 签发逻辑(mint_token / verify_token)、dev SPA(前端只调 /v1/auth/login_invite 路由 contract 没变)、SENTINEL 撤的所有清理(本次之前一步,见下条)。文档同步:RUN.md .env 示例段去 ZCBOT_INVITES 注释 + 加"邀请码不走 env 改 invites 表"指向 + Auth env 段重写邀请码段(指向表 + 发码 SQL + 撤销 SQL) + 日常命令段重写发码流程(INSERT INTO invites(...) + 不用重启 web,每次 login 都查 DB) + 路由表 /v1/auth/login_invite 说明改 "invites 表 token 未命中(含空表)→ 403" + 故障兜底重写四行 invite 相关 (含 INSERT 冲突时 DB 约束的诊断 + name 修改 = 换身份的语义清晰化) + DESIGN §7.4 schema 段加 invites 表声明 + 元描述;未动 PROGRESS 老 env 条目(历史记录,演进过程合理);新加本条作为升级里程碑。改文件:db/migrations/.../20260519_1100_0005_invites_table.py(+38 行新文件)/ core/storage/models.py(+15 行 Invite class + docstring)/ web/auth.py(净 -50 行,~30 行加 resolve_invite 函数 + docstring 重写,~80 行删 env 解析)/ web/app.py(净 -7 行 login_invite 路由简化)/ RUN.md(env / 邀请码 / 命令 / 路由 / 故障兜底五段)/ DESIGN.md(§7.4 schema +5 行)/ PROGRESS.md(+ 本条)。净增量:~-20 行(尽管加了一张表 + ORM,删 env 解析的逻辑更省)。后期 follow-up(用户提的):python main.py invite {add|list|revoke} 薄包装 —— 真要写时大概就是 3 条 click 子命令分别包 INSERT/SELECT/DELETE,留给真需要管理面的时候做。
  • 05-19 / SENTINEL user 彻底撤(数据 + 代码):接邀请码 login 落地后,SENTINEL_USER_ID = UUID('00000000-...') 这个本地 CLI 时代的兜底 user 已彻底无角色 —— CLI 早撤,web 必从 JWT 拿 user_id,agent_builder.py:190uid = user_id or SENTINEL_USER_ID fallback 永远不触发(web 永远显式传)。按 CLAUDE.md "不写兼容层" 心智一次连根拔。DB 数据:DELETE FROM users WHERE user_id=SENTINEL CASCADE 删 5 个 dev 期 task + 307 条 messages(经用户确认不保留);rm -rf workspace/users/00000000.../(14 个 smoke 残留目录 + 1 个真实任务,用户确认不留)。代码 8 处:① core/storage/models.pySENTINEL_USER_ID 常量 + 文件 docstring 改;② core/storage/engine.pyensure_local_sentinel 函数 + User/SENTINEL_USER_ID import + 文件 docstring 改(改写 "users 行由 web auth 入口按需 INSERT");③ core/storage/__init__.py 删两个导出 + docstring 改;④ core/storage/utils.py 三个函数 ensure_local_task_row / upsert_task / check_no_subtaskuser_id 默认参数(原 = SENTINEL_USER_ID)全改必填 + 删 import;⑤ core/agent_builder.py user_id: Optional[UUID] = Noneuser_id: UUID(KEYWORD_ONLY,前面加 *,),删 uid = user_id or SENTINEL_USER_IDuid = user_id,删 ensure_local_sentinel() 调用,删 import,docstring 改两处 — 一处文件头 (移除 "默认填 SENTINEL"),一处 build_agent 函数注释 ("None → SENTINEL" 改 "必填,web 入口从 JWT 拿");两个 TaskState(...) 构造点都加 user_id=uid;⑥ core/task.py::TaskState dataclass 加 user_id: UUID 字段(放 task_id 后,无默认值 = 必填),save() 透传给 upsert_task(user_id=self.user_id, ...),from_row()user_id=row.user_id;⑦ core/session.py::Session.append 删多余 ensure_local_task_row 调用块(8 行)+ 改 docstring(写明 "前置条件:tasks 行已由 web 入口写入") — Session 不持有 user_id,这调用本来就是历史 CLI 残留(REPL 时 task 可能尚未 INSERT),现在 web 入口 100% 先 INSERT,这条 ensure 是 ON CONFLICT DO NOTHING no-op,删了反而清爽;⑧ core/memory.py docstring 改 ("本地 CLI = SENTINEL user" → "user_id 由 web auth 入口 JWT sub 透传");⑨ web/auth.pySENTINEL_USER_ID import + __all__ 条目 + ensure_user_row docstring 提及 SENTINEL 改写;⑩ db/migrations/.../0001_initial_schema.py:12 注释改 (ensure_local_sentinelweb.auth.ensure_user_row)。关键判断:build_agent 参数顺序问题 — user_id 必填放在一堆带默认参数后面会 SyntaxError: non-default argument follows default,改 *, user_id: UUID, ... keyword-only;唯一 caller web/app.py:199 已 keyword 调用,无 break。Smoke 13 case 全绿:① 全模块 import 不炸;② TaskState 新字段;③ utils.py 三函数签名 inspect.Parameter.empty 必填;④ build_agent KEYWORD_ONLY 必填;⑤ DB 无 SENTINEL 行;⑥ /v1/auth/login_invite 200 + uuid5 推导;⑦ POST /v1/tasks 201(走 ensure_local_task_row(user_id=user_id));⑧ GET /v1/tasks count=1;⑨ PATCH 走 update_task 200;⑩ GET /v1/files + /v1/folders 200;⑪ DELETE 204;⑫ user 行存在并清理;⑬ python main.py db current 子进程跑通 + 输出 0004 (head)没动:DB schema(0004 已最终态,SENTINEL 是数据非 schema)、users 表本身、/v1/auth/login(platform 机器对机器入口仍需要)、邀请码登录路径。文档同步:① RUN.md 关键路径段 Workspace 行去 "本地 CLI sentinel = 00000000..." 表述;② DESIGN.md 三处 — §1 working_dir 路径解释(去 "dev SPA 默认填 SENTINEL")+ §7.0 共享差别表(working_dir / Memory / Auth 三行去 sentinel 措辞,Auth 行加邀请码 + platform_key 双路径)+ §7.9 取舍 (去 "dev SPA 默认填 SENTINEL");③ DESIGN.md §3.7 memory 段去 "本地 CLI 走 SENTINEL"。改文件:core/{storage/models.py,storage/engine.py,storage/__init__.py,storage/utils.py,agent_builder.py,task.py,session.py,memory.py} + web/auth.py + db/migrations/.../0001_initial_schema.py + RUN.md + DESIGN.md(共 12 个文件,净 ~-50 行)。bonus 价值:把"操作 user 数据的函数必须显式传 user_id"作为编译期约束(Python 必填参数)固化下来 — 以后再加多 user 相关函数 / caller 时,IDE / typechecker 会直接拦到。
  • 05-19 / dev SPA 邀请码登录(ZCBOT_INVITES env + POST /v1/auth/login_invite):用户要把 dev SPA 发给 ≤5 个同事试用,原登录页要求填 user_id (UUID) + platform_key 两件事 — 同事记不住 UUID、platform_key 也不该暴露(那是 platform 与 zcbot 间的机器对机器共享密钥,泄漏 = 任意伪造 user_id)。方案:加一条"邀请码"login 路径与原 /v1/auth/login 共存(签同款 JWT)。后端 web/auth.py:新增 ZCBOT_INVITES env 解析(name:token,name:token,... 格式;name 限 [A-Za-z0-9_-]{1,40} / token 非空 / 不含 ,: / ≤200;name 唯一、token 唯一 —— 重复 token 表两人同身份,默认拒);user_id 由 uuid5(_INVITE_NAMESPACE, name) 推导(固定 UUID namespace 9b5e7a2a-...,不能改,改了所有邀请用户身份全漂移),重启稳定、纯纯无状态;AuthConfig.invites{token: (name, uuid)} dict,启动时一次解析完丢内存。新路由 POST /v1/auth/login_invite {token} → 同款 JWT + {name, user_id} 返给前端展示用;ZCBOT_INVITES 未配 → 整个入口 403 invite login disabled(避免裸跑暴露);token 未命中 → 403 invalid invite token老路由 /v1/auth/login 不动 — platform 服务端机器对机器入口语义不一样(它能注入指定 user_id,邀请码只能登 env 里配过的人),OIDC 替换时也只动这条,留着合理。前端 web/static/dev.html:登录卡 2 格(uuid + key)→ 1 格"邀请码"输入(type=password 避免肩窥),去 SENTINEL 预填、去 LS_UID 自动填充;header 显示 name · uuid前8位(name 缺失老 token 升级前回落到 uuid full,title 攻略悬停看完整 uuid);加 LS_NAME localStorage 持久化;logout 清三件 LS。给同事发码流程(RUN.md 写了):python -c "import secrets;print(secrets.token_urlsafe(16))" 生 token,.envZCBOT_INVITES=alice:tok1,bob:tok2,...,重启 main.py web,把 URL + 各自 token 发给同事。撤销某人:从 env 里删那条;真要换身份:改 name(他之前的 task 在另一个 user 下访问不到)。Smoke 14 case 全绿:① 单元 _parse_invites 8 case(未配 / 正常 / uuid5 重启稳定 / 未命中或空 token / 非法 name 拒 / 重复 name 拒 / 重复 token 拒 / 缺 colon 拒 / token 含 colon 拒 / 多余分隔符空白容忍);② 路由 TestClient 6 case(命中 alice / 命中 bob 不同 uid / 错 token 403 / 空 token 403 / JWT 调 /v1/tasks 200 / 无 Authorization 401 / 老 /v1/auth/login 仍工作 / ZCBOT_INVITES 未配 invite 路径 403)。dev.html UI:grep 旧字段 SENTINEL / li-uid / li-key / platform_key 全清。没动:require_user Depends(签 JWT 是同款,后续路由层完全无感)/ ensure_user_row(两路径都调,幂等 INSERT)/ DB schema(用户行靠登录时按需建);DESIGN(纯过渡 auth 形态扩展,§7 D' 仍写"PLATFORM_KEY → JWT" 的本意 — invite 是同一条路的小扩展)。文档:RUN.md 同步(env 段加 ZCBOT_INVITES 说明 + 给同事发码流程 + 路由表加 /v1/auth/login_invite + 故障兜底 4 行 invite 相关条目)+ PROGRESS 本条。改动文件:web/auth.py(+~70 行 _parse_invites + AuthConfig.invites + resolve_invite)/web/app.py(+~25 行 InviteLoginRequest + 新路由)/web/static/dev.html(净 +~15 行 -~20 行,登录块精简 + LS_NAME)/RUN.md(env 段 / 路由表 / 故障兜底)。
  • 05-19 / dev SPA 任务/文件 下拉菜单 + 文件顶栏长名截断 + 聊天框上传按钮 + 工具调用返回 debounce 刷新右侧:用户提"左侧任务行加下拉菜单(删除/完成/废弃/导出 docx,不同颜色)、右侧文件同理、文件顶栏长项目名压'文件'换行不要、聊天框加跟文件 panel 一样的上传按钮;另:上传后右侧刷新、工具调用返回时右侧也刷新"。做法:① 单例浮层菜单(#floating-menu,position: fixed)避开 pane overflow:auto 裁剪 — showMenu(triggerEl, items) 算 trigger 右下展开,空间不足翻上;点 trigger 外 / resize / 任何 scroll 关菜单。② 任务行(renderTaskList):右侧加 trigger,菜单 4 项 complete/abandon/export/delete,颜色 act-complete #2e7d32 / act-abandon #c77800 / act-export #1565c0 / act-delete var(--accent);complete/abandon 在非 active 任务上 disabled,export 在 0 消息时 disabled;点击 trigger stopPropagation 不触发 row 选中;state.tasksById 缓存避免 menu 里再查。③ 文件行(renderFiles + fileMenuItems):删除原内联 改名 / × 两个按钮,统一改 菜单 — 重命名 / 下载(目录不出现) / 删除,同套颜色;state.entriesByRel 缓存 entry。④ 中间 pane-head 已有的 完成/废弃/导出/删除 4 个按钮保留(操作当前打开任务还是顺手),重构 setTaskStatus(tid, status, name) / deleteTask(tid, name, nMsg) / exportTask(tid) 接受 tid 参数,中间按钮与左侧菜单共用同一组函数。⑤ "文件"二字换行:.pane-head .labelwhite-space: nowrap; flex-shrink: 0;同时 #files-projflex: 0 1 auto + min-width: 0 + ellipsis + JS 端 projName.slice(0, 11) + "…" 截短(完整名留 title) — 双保险防长项目名挤爆顶栏。⑥ 聊天框上传(#chat-upload):与右侧 #btn-upload 都触发同一 <input type="file" id="upload-input">,uploadSelected 不变(上传到 state.filesPath 当前右侧目录),末尾 await loadFiles() 已有刷新。⑦ 工具调用刷新文件:handleSseEventtool_result 分支加 scheduleFilesRefresh(),debounce 500ms 避免每次 tool_result 都 hit /v1/files(SSE 一轮回复里 tool_call 经常一连串)。没动:后端(纯前端 UX 调整);DESIGN(不动 — 非架构);RUN(不动 — 无 CLI / env / 文件布局变化);中间 pane 已有按钮文案与 disabled 规则保持不变。文档:只动 PROGRESS(按 CLAUDE.md 三文档边界)。改文件:仅 web/static/dev.html(+~110 行 JS/CSS,-~10 行旧内联按钮代码)。
  • 05-19 / dev SPA /v1/files/downloadCache-Control: no-cache + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(┌─┐│)+ 裸 ... 围栏,从未用 mermaid + ![](),matplotlib 生成的 figures/fig*.png 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch /v1/files/download 命中浏览器启发式缓存(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。修法:① web/app.py::download_fileheaders={"Cache-Control": "no-cache"} —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② skills/proposal/scripts/quality_check.py::check_figures 新加(共 4 条):1) figures/ 有 png 但 sections 0 个 ![](...) 引用 → 图全没挂上,2) 任何 fenced 代码块里出现 box-drawing 字符(┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶)→ ASCII 字符画当图,3) mermaid 块必须有首行 %% caption: <题>,4) 同 task 内 mermaid caption 不能撞名;③ hash → caption 命名重构(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):render_diagrams.pymermaid_hash() + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 caption_to_stem() 清洗(保留 CJK/字母/数字,其它折 _,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);render_docx.pymermaid_hash() + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ SKILL.md 精简(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(quality_check 会拦)")。为什么这一波改这么散:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。端到端 smoke(/tmp/zcbot_repro 临时 task):mermaid 块 %% caption: 总体架构figures/fig_总体架构.png 落盘 → docx figures: 1 报告对、word/media/image1.png 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:figures/ 有 N 张 png 0 个 ![]() / [md:L] ASCII 字符画 ┌─┐│└─┘ / [md:L] mermaid 缺首行 %% caption / mermaid caption 撞名 X 出现在 md1:L1, md2:L2没动:render_docx.py 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(fig1_xxx.png 风格留着,反正不冲突,figures/ 同时存在 mermaid 的 fig_<caption>.png 与 matplotlib 的 fig<N>_<desc>.png 两种风格)/ templates/*.md 里 mermaid 示例首行 %% caption: 本来就有(只是历史可选,现在强制约定到位)。hash → caption 兼容性:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 fig_<caption>.png 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。文档:只动 PROGRESS + skills/proposal/SKILL.md(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 Cache-Control 改动是 /v1/files/download 行为微调,客户端无感、文档化为后续 follow-up 可选)。
  • 05-19 / dev SPA 文件预览弹框:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 downloadFile(走 /v1/files/download)落盘,不能在线看。方案:复用现有 /v1/files/download(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 #file-preview-modal(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。分派:① image(jpg/png/gif/webp/bmp/svg/ico)→ <img> blob URL;② pdf → <iframe> blob URL 强制 application/pdf mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ <pre> textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 renderMd(marked + DOMPurify + hljs);⑤ docx → 懒加载 /static/vendor/jszip.min.js + docx-preview.min.jswindow.docx.renderAsync(blob, host) 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 xlsx.full.min.js(SheetJS 社区版),多 sheet 出 tab 切换,sheet_to_html 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。机制:loadScript() 懒加载只在首次访问 office 文件才拉 1MB vendor;_trackBlobUrl + _flushBlobUrls 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。库选型:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,sheet_to_html 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。新增 web/static/vendor/(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。web/static/dev.html(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。没动:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。文档:只动 PROGRESS + 文件清单加 vendor/ 目录(按 CLAUDE.md 三文档边界)。
  • 05-18 / proposal skill 流程图/结构图管线:用户反馈"申报 skill 关于流程图、结构图等的生成有些问题,包括渲染到 docx 里"。诊断结果:① render_docx.py 整个脚本没有 add_picture / 没引 Inches,所谓"画流程图"只能走 add_code_block 的 ASCII 字符画(新宋体 + Consolas + box drawing),Word 里 CJK 与 ─ │ ┌ ┐ 不真等宽,中文标签一长就错位,评审看到字符画扣印象分;② 模板里写满 [图 2-2 关键技术关系架构] 裸占位,但 SKILL.md 零提及 mermaid / graphviz / matplotlib,模型只能瞎编 ASCII;③ 评审红线"图编号连续无遗漏"(references/review_redlines.md:96)无机制保证。方案:Mermaid 管线 + matplotlib 兜底 + 图编号自增。新增 scripts/render_diagrams.py(143 行):扫 sections/.md 的 mermaid 块 → 算 sha1 前 10 位作稳定 id → 落到 <task_dir>/figures/fig_<hash>.png;两阶 backend:① 本地 mmdc(npm i -g @mermaid-js/mermaid-cli;最高质量、离线)② mermaid.ink 公网 API(https://mermaid.ink/img/<url-safe-b64>,urlsafe_b64encode rstrip '=';不装东西、要联网);两个都失败留 WARN 退出 0(不阻塞流水线);%% caption: <图题> 行注释抽题文,mermaid 本身当注释跳过、render_docx 当题用;不改动 .md 文件(源是真相);幂等(png 存在跳过)。render_docx.py(+~70 行):① 加 ![caption](path) 单行识别 → add_picture(width=Cm(15)) 居中 + 五号宋体居中图题段落"图 N ",N 通过 ctx 字典({sections_dir, figures_dir, fig_no})在 render_md_block 调用链里递增,relative 路径以 .md 所在目录为锚;图片源缺失 → 留 [图片缺失: <src>] 占位段防 silent miss、文档不崩;② 围栏 lang == "mermaid" 特判:算同源 sha1 查 <sections_dir>/../figures/fig_<hash>.png,命中走插图 + 题(同样自增编号、复用 extract_mermaid_caption),未命中继续走原 add_code_block ASCII fallback 路径(mmdc 没装也能交差,只是不漂亮);③ A4 减页边距得正文宽 16cm,图宽 cap Cm(15) 留 1cm 安全垫;④ add_picture 失败 try/except 不让整 doc 崩,改占位文字。改 SKILL.md:资源 段加 render_diagrams.py 行;阶段三命令链插入 render_diagrams.py 前置(可选,无 mermaid 块直接跳过);新增"插图"段(类型选择表 / mermaid %% caption 约定 + 完整 flowchart 例子 / matplotlib figsize=(10,4) dpi=150 中文字体 SimHei 配色规范 / 不要手写"图 2-2"章节-序号);反模式加 3 条(ASCII 字符画当真图 / 手写图编号 / 裸 [图 N-N ...] 占位)。templates/key_rd.md:① §04_content (一) 主要研究内容里 [图 2-2 关键技术关系架构] 占位换成完整 mermaid flowchart LR 块(关键问题 Q1/Q2 → 技术 T1/T2/T3 → 平台,带 %% caption:);② §04 (三) 技术路线加"项目总体技术路线" mermaid flowchart TB 例子(需求→设计→突破→集成→示范 5 阶段 + 双向反馈虚线);③ §09_schedule 甘特图改"两种画法 A. mermaid gantt B. matplotlib barh"并给完整 mermaid gantt 示例。没动:major_project.md / nsfc_joint_fund.md 只是"配图"提示,不是裸占位,通过 SKILL.md 横向覆盖;scripts/word_count.py / quality_check.py(图不计字数,质量检查暂不涉及图占位)。Smoke 4 case 全绿(scripts/_smoke_proposal_diagrams.py,留作回归):① cached mermaid + direct image + ASCII fallback 混排(figures: 2 报告对、inline_shapes == 2、缓存命中走"图 1/图 2"、缺缓存 mermaid 走 ASCII 源保留 + 不申请图号"图 3");② 无插图回归(figures: 0 + table 完好);③ render_diagrams.py API 调用(find_mermaid_blocks 抽 2 块 / extract_caption 命中/未命中 / 预填 cache png 全走 cache backend 不走网络);④ 图片源缺失走占位文字,后续段落不丢。Windows GBK 子进程坑:smoke 跑 subprocess 拿不到 UTF-8 stdout(UnicodeDecodeError 0xd6),给子进程 env 加 PYTHONIOENCODING=utf-8 修;同 memory 里 emoji 编码教训同源。文档:只动 PROGRESS(skill 内部能力增强 ≠ 架构变化,不动 DESIGN;skill CLI 不是 zcbot 对外行为,不动 RUN —— 按 CLAUDE.md 三文档边界)。净增量:~213 行代码新增,5 行文档示例改写,sections/.md 不动(源永远是 mermaid 真相)。留给真用户的体验:模型不需要再瞎编 ASCII,直接写 mermaid 块就行;mmdc/网络都没的极端环境下 docx 仍能产(ASCII 退化,文字不丢);图编号永远连续不重不漏(自动),手工占位的旧坑彻底关上。
  • 05-18 / POST /v1/files/rename + 顶层目录 delete 加 task 引用闸:用户反复抠"文件夹改名 / 删除时怎么不破 DB 一致性"。架构最终落点:/v1/files/* 是唯一的目录树 mutation 命名空间,DB-FS 一致性作为服务端不变量内化(放弃曾经的"files API 永不进 DB"惯例 —— 那是当初没考虑顶层目录时形成的偶然,把它升格成铁律反而导出双命名空间代价);GET /v1/folders 保留,但定位为"项目聚合视图"(只读,带 n_tasks/last_used,新建任务 datalist 用),不做 mutation。判定:target.parent.resolve() == root.resolve() and target.is_dir() ⇒ 顶层目录(就是 task 的 working_dir)。POST /v1/files/rename:校验 validate_task_name(new_name) / target 存在 / 不能等于 user_root / sibling 不能已存在;顶层目录走 DB-aware 分支:session_scope() 事务内 SELECT task_id, run_status WHERE working_dir=old_db FOR UPDATE 锁所有关联 task,任一 run_status in ('running','cancelling') → 409;check_no_subtask(new_db, exclude_task_ids=tids) 防改名后与其它 task 形成嵌套(exclude 平移过去的自己);UPDATE tasks SET working_dir=new_dbos.rename(old_fs, new_fs) —— FS 失败 raise → session_scope 回滚 DB UPDATE。非顶层(子目录 / 文件)纯 FS rename,不动 DB。事务顺序考量:DB UPDATE 在 FS rename 之前(都在事务未提交期间),FS 失败可回滚 UPDATE;唯一不一致窗口是"FS 改完 + commit 失败"(PG 单事务 commit 极少失败,接受)。POST /v1/files/delete 收紧:同样的顶层目录判定,若顶层目录有任意 task 引用 → 409 "请先 DELETE 关联 task 再删目录",避免悬空指针。check_no_subtaskexclude_task_ids 参数:core/storage/utils.py 加可选 Iterable[UUID],循环里跳过这些 task_id;rename 场景刚需(否则被改名 task 与自己未来的 new_db 误判为嵌套);其它 caller 默认 None 行为不变。dev SPA 同步(web/static/dev.html):file row 加 改名 按钮,prompt 拿新名 → POST /v1/files/rename;rename 后:① 当前 state.filesPath 若在被改名子树内做前缀替换继续停留(rel === filesPathfilesPath.startsWith(rel + "/") → 替换前缀为 res.new);② loadFolderSuggestions() 刷 datalist;③ res.tasks_updated > 0loadTaskList() + selectTask(state.taskId)(task 卡片 / chat 头里展示的 working_dir 末段也跟着变)。delete confirm 文案补一句"顶层目录且仍被 task 引用需先删 task";删除完成也 loadFolderSuggestions()Smoke 5 case 全绿(in-process TestClient + PG):① 子目录 rename 纯 FS / tasks_updated=0;② 顶层目录 rename 同步 UPDATE / tasks_updated=N / FS 改完 + DB working_dir 跟着变;③ 顶层目录 rename 时有 running task → 409;④ 删顶层有 task 引用 → 409;⑤ rename 目标已存在 → 409。Smoke 文件(scripts/smoke_files_rename.py)跑完未删(留作回归用)。没动:GET /v1/folders 接口、DELETE /v1/tasks/{id} 行为(仍删 DB 行不动 FS,与新 delete 配对刚好覆盖"销毁项目"全链路);/v1/files/{list,upload,download} 路由签名;skill / chat / cancel 等其它路由。架构反思:此前一版我先提的双命名空间 /v1/folders/rename vs /v1/files/rename,内部 if path is top-level 切分支被自己视为"代码异味" —— 实际是反了,这种分支从数据状态派生(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。这条工程教训记 §7.9。
  • 05-18 / system prompt skill 机制改"可选辅助":接 GET /v1/skills + 下拉选择落地后,task 创建时 skill 字段允许留空成为常态。原 prompts/system/general_v1.md 第 14 行 "永远 load 一下。skill 数有限,加载成本很低" 在新形态下变得过激 —— 简单问答 / 通用编码 / 文件操作不该被强行匹配到 coding 等 skill。改为"Skill 是可选辅助"+ 明确列出"简单问答、读代码、改 bug、文件操作这类通用任务,直接用通用工具就够,不必为每个任务硬套 skill"。一旦决定要用仍要求 load 完整指引(原则不变)。未动:skill discovery block 内容(name + description 注入仍按 registry 顺序)、load_skill 工具协议、SKILL.md 内容。tradeoff:边缘场景(用户提"整理大纲"可能落 proposal 也可能不用)agent 现在会偏向不 load,可能漏掉好的模板;但比原来"什么都套 coding"的噪音更可接受。
  • 05-18 / GET /v1/skills + dev SPA skill 字段改下拉:原 nt-skill 是自由输入(用户得记住 coding / ppt / proposal 拼写),用户反馈"加 skill 接口给前端选"。后端 web/app.py lifespan 启动时 SkillRegistry(ROOT / cfg["skills_dir"]) 扫一次挂到 app.state.skill_registry(文件系统静态,运行中不变);新增 GET /v1/skillsrequire_user JWT 鉴权,返 {skills:[{name,description}]} 按 name 升序(registry 已 sorted)。dev SPA(web/static/dev.html):<input id=nt-skill><select>,首项固定 (默认 · 不限定) 空值;hd-new 打开 modal 时 loadSkillOptions()loadFolderSuggestions() 并发(Promise.all),首次拉到的列表缓存到 state.skills,失败时静默退化为只剩"默认"项不阻塞。option 文案 name — description,title 也带 description 鼠标悬停看长文。Smoke:TestClient 起 app → /v1/auth/login 拿 token → /v1/skills 返 3 项(coding/ppt/proposal)+ 描述;无 token 401。未动:_build_system_prompt 注入的 skill discovery block(name + description)和这里渲染的下拉项是同源 registry,改一处不影响另一处;POST /v1/tasks body 不校验 skill ∈ registry(留空 / 任意串都允许,与 schema 一致 — 真要拦在 UI 层早就拦了)。
  • 05-18 / dev SPA 全套 UI 中文化:用户反馈"web 页面菜单按钮啥的改为中文"。web/static/dev.html 静态部分(login overlay / header / 三栏 pane-head label / chat 操作按钮 / new task modal)+ JS 动态部分(状态文案 / role 标签 / confirm/alert 文案 / 状态 badge / SSE 流式提示)全面本地化。静态文案:zcbot dev login → zcbot 登录 / + new task → + 新建任务 / logout → 退出登录 / tasks/chat/files → 任务/对话/文件 / 状态 select (all)/active/completed/abandoned → (全部)/进行中/已完成/已废弃 / export .docx/done/abandon/delete → 导出 docx/完成/废弃/删除 / stop/send → 停止/发送 / ready/sending/streaming/cancelling → 就绪/发送中/接收中/停止中 / (no task selected) → (未选中任务) / select a task on the left → 请在左侧选一个任务 / loading… → 加载中… / load failed → 加载失败 / (no tasks) → (暂无任务) / (no messages yet) → (暂无消息 · 在下方输入开始对话) / (unnamed) → (未命名) / (user root) → (根目录)动态文案:renderTaskList / renderChatMetastatusLabels map(active→进行中等),task list 计数 msg → 条;消息卡 role 标签 user/assistant/error → 我/助手/错误,tool · name → 工具调用 · name,result (N chars) → 结果(N 字符),SSE 流式 tool_call:/tool_result → 工具调用:/工具结果;cancelled badge 已停止(stopped by user) → 已停止(更简洁)。弹窗 / 错文案:确认置为 status? → 确认置为「中文 label」? / delete failed → 删除失败: / download failed → 下载失败: / upload failed → 上传失败: / export failed → 导出失败: / 删 task confirm 文案改"任务「项目名」(N 条消息)" / 任务名 必填 → 任务名为必填项modal:新建 task → 新建任务 / 各 label "必填"/"可选" 加括号统一 / 留空 fallback 用任务名 → 留空则用任务名 / N 个 task → N 个任务Smoke(in-process TestClient 拉 /static/dev.html):assert 13 个中文标签全在 + 8 个原英文按钮文案全无残留。没动:技术字段(user_id / platform_key / UUID / tok token 简称)、CSS class(badge active 等仍是英文 class,但显示文本走 statusLabels)、SSE event 名(text/tool_call/tool_result/done/error/cancelled)、API 字段名 — 都是 schema 层,不影响 UI 中文。
  • 05-18 / 入口归位:cli.py 改名 main.py、原 main.pycore/agent_builder.py,删 CLI REPL chat/tasks/export,§7 E 双模式路线撤:接 0004 schema 大瘦身后又一轮架构清理。用户复盘"§7 E --remote 是不是可以移除""有 dev SPA 后 CLI REPL 还需要吗""统一到 main.py 是否合理"——一连串问题指向同一个底层:cli.py(CLI 入口)+ main.py(装配 lib)+ chat / tasks / export REPL 子命令是历史多形态共存遗留,在"UI 由 platform 实现 + dev SPA 是开发主路径"的新形态下都是冗余。架构判断:main.py 此前混三角色(装配 lib + 路径/验证 utility + 被 cli+web 共同 import 的事实入口),按 §5 Less Scaffolding + SoC 应该拆;直接答案是 cli.py 改名 main.py(入口),原 main.pycore/agent_builder.py(装配 lib),单一职责对齐 Python 社区惯例(入口叫 main.py,lib 在子模块)。改动:① git mv main.py core/agent_builder.py;② git mv cli.py main.py(覆盖);③ 5 处 web/app.py::from main import xxxfrom core.agent_builder import xxx(build_agent / sync_task_tokens / working_dir_from_name / resolve_workspace / user_root / InvalidTaskName / validate_task_name);④ 新 main.py 自指 from main importfrom core.agent_builder import;⑤ 删 chat / tasks / export 三个 click 命令 + REPL 内部 helpers(_cleanup_if_empty / _delete_task_db_row / _task_has_messages / _list_task_rows 共 ~110 行)+ REPL 主循环(/exit /reset /new /resume /id /status /done /abandon /desc /export 共 ~200 行)+ --name --working-dir --skill --desc --resume --model CLI 选项 + tasks 列表渲染 + export 命令 — 共 ~400 行;新 main.py ~180 行(db {upgrade,downgrade,current} + probe + web 三命令组);⑥ core/agent_builder.py 顺手清:删 _resolve_uuid_or_prefix 函数(web 端只传完整 UUID,前缀匹配无 caller)+ resolve_task_idtask_id_arg in (None, "", "last") 分支(web 不传 "last"),resume 直接 UUID(task_id_arg);模块 docstring "本地 CLI user_id = SENTINEL" → "所有入口走 web /v1 + JWT;dev SPA 默认填 SENTINEL 走同一条路径"。Smoke 6 case 全绿(in-process TestClient + 子进程跑 python main.py db current):① /healthz 200 ② POST /v1/tasks → GET → POST messages(返 events_url 无 run_id)→ cancel → DELETE 全链路 ③ /v1/folderscore.agent_builder.user_root 路径 ④ /v1/files_load_user_rootresolve_task_id 完整 UUID resume(去前缀匹配后用 UUID(...) 直接解析;非 UUID 字符串 ValueError;ghost UUID empty working_dir ValueError)⑥ subprocess.run([sys.executable, "main.py", "db", "current"]) 子进程跑通 + stdout 含 0004 (head)(验证 click 入口、alembic config 路径、ROOT 解析都没坏)。文档同步:DESIGN §1 形态兼容(删 --remote,讲"无 CLI / in-process 分叉")/ §2 目录树({main.py, cli.py}core/agent_builder.py + main.py)/ §3.3 cli.py probemain.py probe / §3.6 "REPL 内 task 切换"段改"Task 切换 / 软删 走 dev SPA + /v1" + "入口"段讲 python main.py web / §7.0 共享差别表入口列改 python main.py web + auth 行讲"dev SPA 填 sentinel + 本地 key" / §7.6 #8 标"已撤" / §7.7 E 阶段标"撤" / §7.8 风险表"CLI 双模式分叉"行融合进"过早抽象" / §7.9 新增"CLI REPL 撤,入口统一 main.py"取舍说明 + 删原"CLI 双模式共存"段;RUN 顶 / 一次性初始化 / 日常命令 / 故障兜底 / 关键路径全部 cli.pymain.py,且日常命令段重写"只剩 web / db / probe + 所有 task 交互走 main.py web 后浏览器或 /v1";PROGRESS 文件清单 / 状态表 / 下一步候选同步(去掉 E 路线)。**净效果**:总代码 -360 行(cli.py558 行删 →main.py180 行 +core/agent_builder.py~320 行 = ~500;原main.py337 +cli.py558 = 895;净减 -395);入口文件数 2 → 1;维护面 -1 套 task 切换语义(REPL/new /resume /done /abandon全归到/v1/tasks*`);测试面 -1 套(原 cli build_agent 调用链 smoke 全归到 web TestClient)。
  • 05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 — usage_events 全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);runstokens_p/c 写但从未被读(tokens 累计走 tasks 列),started_at / finished_at / error 也只写不读,run_id 唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定单活 run 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。alembic 0004:DROP TABLE usage_events / runs,tasksrun_status text not null default 'idle'(idle / running / cancelling / error)+ run_error text nullORM models.pyRun / UsageEvent 两 class + 删 BigInteger import;Task 加两列;storage/__init__.py 文档示例同步;Task.run_status 终态语义:ok / cancelled 收尾都回 idle(用户视角"跑完 / 停了"等价不留持久标记),只有 error 是持久终态,起新 run 时清。Broker(web/broker.py)全面 task_id 索引:_subs / _done / _cancel_flags 三个 dict key 从 run_id 换 task_id;加 start(task_id) 入口在新 run 起来前清 _done 标记(避免上一轮 done 让新订阅者立刻断流)。Sink(web/sinks.py)绑 task_id 替代 run_id。web/app.py:① _run_agent_bg(task_id, user_id, content) 去掉 run_id 参数;装 agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid);终态写 tasks.run_status = "idle"(原 Run.status = "ok"/"cancelled")或 "error"(run_error = err);finally broker.clear_cancel(tid) + broker.close(tid)。② POST /v1/tasks/{tid}/messages 改:SELECT Task.run_status … FOR UPDATE 替代 select(Run.run_id) … running/cancelling;同事务 UPDATE Task SET run_status='running', run_error=NULL(error 也算可重启视为清);commit 后 broker.start(tid) 清 done;返 {"events_url": "/v1/tasks/{tid}/events"} 去掉 run_id。③ POST /v1/tasks/{tid}/cancel 取代 POST /v1/tasks/{tid}/runs/{rid}/cancel,只校验 task 归属 user;run_status != 'running' → 409。④ GET /v1/tasks/{tid}/events 取代 /runs/{rid}/events,broker.subscribe(tid)。⑤ lifespan reaper UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling'),文案不变。⑥ _task_dict 暴露 run_status / run_error 字段给前端。dev SPA(web/static/dev.html):state.currentRunIdstate.streaming bool;POST /messages 拿到 events_url 直接订阅,不再保存 run_id;cancel 按钮 click → POST /v1/tasks/{tid}/cancel(去掉 /runs/{rid}/)。Migration 跑通:本地 PG db upgrade 0003 → 0004 (head) 一把过(用户授权清旧数据,无 backfill)。Smoke 18 case 全绿(in-process TestClient + BG mock):POST /messages 返 events_url 无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。净增量:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由 /runs/{rid}/{events,cancel}/{events,cancel},前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。文档同步:DESIGN §7.2 路由表 messages 路由返 events_url(去 run_id)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段 delta(实际就是 delta,文档原 content 笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。
  • 05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 POST /v1/tasks/{id}/runs/{rid}/cancelBroker(web/broker.py):加 request_cancel(rid) / is_cancelled(rid) / clear_cancel(rid) 三方法,内部 dict[UUID, threading.Event] per-run;setdefault 保证 BG 还没 register 也能 set。Loop(core/loop.py):AgentLoopcancel_check: Optional[Callable[[], bool]] 字段(CLI 路径不传 = None 永不 cancel),_is_cancelled() helper + _fill_cancelled_tool_results(remaining) 给未执行的 tool_call 全部 append [cancelled by user] tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit cancelled event + return [cancelled]LLM 同步 call 本身不可中断(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。Endpoint(web/app.py::cancel_run):校验 task 归属 user + run 归属 task(else 404),run.status 必须是 running(else 409 含具体 status);标 cancelling(过渡态)+ broker.request_cancel(rid);202。_run_agent_bg 装配时 agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid),run 完时判 broker.is_cancelled 写终态 cancelled vs ok;finally broker.clear_cancel + broker.closeGate 同步扩:post_message 单活 run 检查从 status == 'running'status in ('running', 'cancelling'),确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。Reaper 同步扩:lifespan 启动也扫 cancelling(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。dev SPA(web/static/dev.html):chat 表单加 <button id="chat-cancel" class="small danger">stop</button>(常态 hidden);state 加 currentRunId;sendMessage 拿到 run_id 后 show stop,fetchSse try/finally 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → POST /runs/{rid}/cancel;409 静默忽略(并发 done 不算错)。handleSseEventcancelled case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 .cancelled-badgeSmoke 15 case 全绿:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。没动 SSE handler 的 break list(("done", "error")):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。文档同步:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 cancelled{} 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。
  • 05-18 / POST /v1/tasks/{id}/messages 单活 run 锁 + 孤儿 reaper:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争 messages.idx(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。实现:web/app.py::post_message 把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个 session_scope() 事务,首行用 select(Task.task_id).where(...).with_for_update() 锁 task 行序列化并发 POST;事务内查 Run.status='running' 命中即 raise HTTPException(409, "task already has a running run ({rid}); wait for it to finish");无活跃则同事务 s.add(Run(...status="running")) —— 三步原子完成,避免 TOCTOU。lifespan 加 stale-run reaper:启动时 UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running',把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行 [startup] reaped N stale running run(s)。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。没动 Session.append:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。Smoke 10 case 全绿(in-process TestClient + _run_agent_bg mock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。采坑:@case 每个用 make_client() 起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用 fresh=True 开第二个。文档同步:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。未来 TODO:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。
  • 05-17 / files API 全面 user-rooted(去掉 task_id 前置):用户反馈"web 页应该能看到 user 的所有目录,现在只能选 task 后右侧才刷新"——根因是原 files API 用 task_id 拐杖间接拿 working_dir,迫使前端必须先选 task。语义上 files 操作只关心"路径 + user 边界",task_id 是多余的;同时 §7.1 心智模型早就把 task 和 dir 定义为正交副视图,API 不该混。后端:删 _load_working_dir(task_id, user_id),加 _load_user_root(user_id)(走 main.user_root(ws, uid) 自动 mkdir 拿 workspace/users/<uid>/);4 路由全换:GET /v1/files?path= / GET /v1/files/download?path= / POST /v1/files/upload / POST /v1/files/delete_safe_join 边界从 task_dir 改 user_root,安全性不降低;_enumerate_files 加 dotfile 过滤(if p.name.startswith(".") 跳过 .memory/ 等,同 /v1/folders 约定);_rel_toPath(".") 归一为空串(避免 root 时 current="." 这种 ugly 形态)。删 from_db_path import(只剩 to_db_path)。dev SPA:loadFiles() 不再 gate on state.taskId,enterApp 时直接调一次拉 user_root;selectTask 在拿到 task meta 后 state.filesPath = wdName(从 working_dir 末段抽出)再 loadFiles,选 task 自动跳到对应子目录但用户可点 crumb 回 root 看其他目录;crumbs root 标签 "/" → "我的"(user_root 直观);files-proj header 从"项目名(state.taskMeta 派生)"改"路径首段(数据驱动)",空时显示 (user root)新增 upload 按钮(原来藏在外部页面里没暴露给 SPA):pane-head 加 按钮 + 隐藏 <input type=file multiple>,onchange 走 FormData POST /v1/files/upload,path 取当前 state.filesPath(空 → user_root);上传完 loadFiles 刷新。deleteCurrentTask 不再重置 files 面板(task 删了但 FS 文件保留,继续浏览有意义),只 reload 当前路径。btn-refresh-files 移除 disabled 状态(任何时候可用)。Smoke 68 case 全绿(in-process TestClient,跑完即删 _smoke_files.py):列 user_root(包含 working_dir 目录,.memory 被过滤) / 列子目录 2 层 / 不存在路径 200+exists=False / 路径安全 6 case(../ / 绝对 / Windows 绝对 / \\ 起头)/ upload 单 / multi+nested mkdir / 上传到 root / 文件名攻击 4 case(../ .. / \\)/ download 文件 + 深度 + 目录 400 + ghost 404 + 越界 400 / delete 文件 / 空目录 / 非空 400 / user_root 拒 / ghost 404 / 越界 400 / 跨 user 隔离 4 case(A 不见 B,B 不见 A)/ 无 token 全 401(GET list / POST upload / POST delete / GET download)/ 子目录里 dotfile 也过滤 / 新 user 首访 user_root 自动 mkdir + 列表空。文档:DESIGN §7.2 路由表段 + lead-in 同步("Task 一等公民,files 是其副视图(经 task_dir 暴露)" → "Task 一等公民;files 与 task 正交,走 user-rooted /v1/files*,以 workspace/users// 为边界")。
  • Q1 → 05-06 / Phase 1-4:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。
  • 05-06 / Phase 6 部分:task + state.json + tokens 累计;CLI tasks + REPL /status /done /abandon /desc;移除 legacy workspace/sessions/
  • 05-07 / TUI + task_dir:rich Markdown 渲染;spinner 显实时耗时 + 累计 token;system prompt 注入 task_dir 绝对路径,产物收敛 workspace/tasks/<id>/;.gitignore 删 bandaid。
  • 05-08 / REPL 切换 + 懒创建:/resume [last|<id>];build_agent 不预占文件;_cleanup_if_empty 三条件守门。
  • 05-09 → 05-10 / §7 草案 + 导出:DESIGN §7 初版(05-12 重写);cli.py export <task_id> + core/export_docx.py
  • 05-11 / 原子写 + 双层记忆 + §7 A:atomic_write_text 接管 save;core/memory.py(core.md 入 prompt,extended/* 走索引);loop 事件流化(sink.emit)铺 SSE 路。
  • 05-12 / §7 改写:platform/core 多租户方案废弃,改 user-direct(folder-centric、task/messages 入 PG、no-subtask、hard cascade)。
  • 05-14 / §7.1 心智模型修正:Folder-centricTask 一等公民 + Dir 文件副视图(双视图正交,dir 不是 task 父容器);task_dir 留空=一次性对话 / 指定=项目化二分语义入文。
  • 05-14 / §7 B Step 1 基建:core/storage/{engine,models}.py SQLAlchemy 2.x ORM(users/tasks/messages/runs/usage_events 5 表)+ alembic(初版 migration 0001_initial_schema,GIN/复合索引)+ cli db {upgrade,downgrade,current} 子命令组 + 本地 sentinel user(00000000-...)+ ZCBOT_DB_URL 必填(未设给清晰报错,不引导 docker)。已在远端测试 PG 跑通 db upgrade head
  • 05-14 / §7 B Step 2 Session ORM:core/session.py 重写,messages 走 PG(append-only,jsonb,idx 严格递增);system prompt 不入库(每次 build_agent 重建);Session.load(task_id, system_prompt=...) resume 接口;ensure_local_task_row idempotent UPSERT(INSERT ... ON CONFLICT DO NOTHING)在首条非 system 消息前打底 tasks 行。task_id 切换为 UUID(原时间戳格式废弃,旧 workspace 不做兼容)。main.py/cli.py 适配:resolve_task_id(UUID 前缀解析)、_cleanup_if_empty 双检查(DB messages + FS 产物)、_list_task_rows 改读 PG。core/export_docx.py 改从 PG 读 messages。端到端 build/append/resume/cleanup smoke 全绿。取消 Step 5 migrate-from-fs(用户决定不兼容旧 workspace)。
  • 05-14 / §7 B Step 3 TaskState ORM:core/task.py 重写,TaskState dataclass 保留为内存 DTO 但落地走 PG —— save()upsert_task(INSERT ON CONFLICT DO UPDATE,显式 set updated_at=func.now()),load(task_id) 走 SELECT;字段去掉 cwd(改读 task_dir,§7 SaaS task_dir-as-identity)。state.json 文件全面废除,task_dir 只承担 skill 产物。core/storage/utils.pyupsert_task / update_task 工具。main.py::sync_task_tokensupdate_task(tokens_p,tokens_c) 单字段 UPDATE(ORM-level update 自带 onupdate=func.now())。core/session.py::Session.append 的 ensure 调用补传 mode/description/reasoning_effort,避免首次 INSERT 后 _list_task_rows 看到空 meta。cli.py 全字段从 ORM Task 列读;_cleanup_if_empty 去 state.json 特例(任何 FS 文件 / 子目录都算实质痕迹);/done /abandon /desc 走 PG。core/export_docx.py meta 改从 TaskState.load(tid) 读(asdict 拿到 dict),去 CWD 字段。端到端 smoke:storage UPSERT/UPDATE round-trip + build_agent 懒创建 + Session.append 自动 INSERT 完整 meta + sync_task_tokens 局部 UPDATE + task_state.save UPSERT 保留 task_dir/tokens + export → .docx 37KB 全绿。
  • 05-14 / §7 B Step 4 task_dir 双形态:CLI chat --task-dir <path> 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 workspace/tasks/<uuid>/,显式走用户路径(绝对或相对 cwd,Path.resolve())。main.py::resolve_task_idtask_dir_arg;resume 时从 PG tasks.task_dir 读(SELECT task_dir WHERE task_id=?),空则降级默认派生。新增 is_managed_task_dir(td, ws) 判断是否在 workspace/tasks/<uuid>/ 模板下,作 _cleanup_if_empty 保护开关 —— 用户自指定的项目目录绝不 rmtree(可能含用户已有文件);DB 行该删还是删。core/export_docx.py::export_chat_to_docx 重构:task_id 升一等参数(从 task_dir.name 提取改入参传入),task_dir 留空时自动从 PG 读,支持用户目录(非 UUID 命名)正常导出。cli /exportcli.py export 子命令均改走 _resolve_uuid_or_prefix + task_id 直传。Smoke 4 路径全绿:default-derived(managed=True, cleanup rmtree)/ --task-dir(managed=False, FS preserved)/ resume reads DB / export 自动 PG 查路径。
  • 05-14 / §7 B Step 6 no-subtask 校验:core/storage/utils.py::check_no_subtask(task_dir, user_id=SENTINEL) —— 同 user 下查 new LIKE existing||'/%' OR existing LIKE new||'/%'(task_dir != new 过滤掉同 task_dir 同项目多对话场景)。冲突抛 NoSubtaskError(ValueError 子类),消息带冲突 task 的 UUID 前 8 位 + 它的 task_dir。分隔符容差:SQL 里 replace(task_dir, :bs, '/') 把存的 Windows \ 在比较前归一,新值也 replace('\\', '/'),跨 OS / 历史数据混合分隔符不漏判;bs 通过 bind 参数传(绕开 SQL 字符串转义陷阱)。空 / whitespace task_dir 直接 return(legacy / 未绑项目)。main.py::build_agentresolve_task_id 后、TaskState 构造前调,if not resume 单层闸 —— resume 跳过(目录改名走未来 Folder API cascade,这里只拦新建)。cli.py 三处 build_agent 调用现有 try/except 直接接住 NoSubtaskError 并友好打印。Smoke 全绿:同 dir 允许 / child 拒 / parent 拒 / sibling 允许 / proj_a_other 不误中 proj_a(因为用 /% 而非 %)/ 空跳过 / Win \ 子目录拒 / 混合分隔符(\ 存 + / 查)仍拒 / build_agent 端到端三分支(child raise / same pass / resume bypass)。
  • 05-14 / §7 Phase G G1 Web UI 脚手架:新增 web/ 包(app.py FastAPI 工厂 + templates/{base,home}.html + static/style.css),cli.py web --host --port --reload 子命令(默认 127.0.0.1:8765,本地形态 sentinel user 无 auth,Phase D 才上 OIDC)。模板用 Jinja2 + HTMX/HTMX-SSE 走 CDN(无 node 链路),base.html{% block nav %} 让 G2+ 扩。Starlette 新版 TemplateResponse 签名:(request, name, context),旧式塞 context 里会让 jinja 用 dict 当 cache key 报 unhashable type,踩过修了。requirements 加 fastapi>=0.111 uvicorn[standard] jinja2>=3.1 python-multipart(后者为 G5 文件上传留)。Smoke 四路径全绿(in-process via Starlette TestClient):/healthz → "ok" / / → 1063B(title + static link + version) / /static/style.css → 1624B / /nonexistent → 404。Linux portability 顺手:模板里 path 显示约定用 Path.as_posix()(G3+ 模板落地);SSE 响应头 G4 上时带 X-Accel-Buffering: no(nginx 反代友好)。
  • 05-14 / §7 Phase G G2 task list 页:web/app.py::list_tasks(limit, status) 读 PG tasks + messages count(updated_at 降序),返回模板友好的 dict 列表;不复用 cli.py::_list_task_rows —— CLI 拿 tuple, Web 拿 dict,数据形状有别,等真有 schema 变更同步成本时再抽(避免预付抽象)。/ 路由换成 task 表渲染,filter via ?status=active|completed|abandoned(无效值静默降级为 all);/tasks/{task_id} 占位路由 UUID 校验 + DB 存在性校验,缺一则 404,有效则渲染 task_placeholder.html(G3 来填消息流)。Linux portability 落地:_norm_path() 把存的 backslash 在显示时全替成 forward slash(Path.as_posix() 在 Linux 读 Win backslash 串时不归一,所以直接 replace('\\','/'));Win Path.resolve() 存 D:\projects\...、Linux 存 /home/user/...,都能正确显示。template:home.html 表格(id/updated/status/mode/model/msgs/tokens/desc-dir),status 用 badge(status-active/completed/abandoned 配色),hover 高亮;空态文案。CSS:table 紧凑(.9rem)+ tabular-nums 对齐 + accent-soft placeholder note。Smoke 18 路径全绿(in-process):3 task seed(active/completed/abandoned)+ Win\Linux 双路径形态 → / 渲染对、status filter 正/反向、garbage status 静默 all、UUID 占位、notauuid 404、ghost UUID 404、limit 生效、/healthz 不退化。版本 0.1 → 0.2。
  • 05-15 / §7 Phase G G3 chat 只读页:web/app.py_get_md() 单例 MarkdownIt(gfm-like 预设 + linkify + breaks,html=False 禁内联 HTML 防 XSS),fenced code 走 pygments _pygments_highlight() 回调(codehilite cssclass)。load_chat_messages(tid) 读 PG idx asc;build_chat_blocks(messages) 聚合显示块 —— system / tool 不入 block(tool 内嵌进 assistant 的 tool_call.result),user / assistant text 走 markdown 渲染,assistant.tool_calls 配对 tool result(orphan tool_call → [no result])。_args_preview 60 字符截断,_pretty_json 解析失败 fallback 原串。/tasks/{id} 替换占位为 chat.html 渲染,删 task_placeholder.html。template:.msg 卡片(user 浅蓝 / assistant 白底),.body markdown 区(<pre> / <code> / <table> / <blockquote> / <s> 全 GFM 样式),tool_call 用 <details> 默认折叠(无 JS,浏览器原生开闭;summary 显示 tool 名 + args 前 60 字预览,展开看 args_pretty + result)。CSS 加 .codehilite 浅色 token 配色(keyword / string / comment / function / number / operator 6 类,余下黑色)。Smoke 28 路径全绿:4 display blocks(user/assistant×3,system/tool 跳过)+ markdown 特性(table / fence / autolink / strikethrough / bold)+ tool 配对(call_1 命中、orphan 走 [no result])+ HTML 含 <details>/tool-badge/codehilite/<s> + 空 task 文案 + invalid UUID 404 + util 单测(args_preview / pretty_json / render_md 边界)。版本 0.2 → 0.3。requirements 加 markdown-it-py[linkify] / mdit-py-plugins / pygments
  • 05-15 / §7 Phase G G6 部分:/new 入口(提前于 G5 落):用户反馈 Web 没"新建对话"入口 — 加 GET /new 表单页(description / mode / task_dir 三字段)+ POST /new 处理(strip 校验 + descriptiontask_dir 至少填一个否则 400 + check_no_subtask 同 CLI / build_agent 一致拦前缀嵌套 → 409 + ensure_local_task_row 写占位行 + 303 See Other 跳转 /tasks/{tid})。task_dir 空 → 默认派生 workspace/tasks/<uuid>/(同 _default_task_dir),显式 → Path.expanduser().resolve() 同 cli.py --task-dir。模板 new_task.html 加表单 + error 渲染(400/409 重渲带 form_state 不丢用户填的值);home.html 加 + new task 主按钮 + nav 加 new 链接;base.html 默认 nav 也带 tasks/new。CSS 加 .btn-primary / .new-task-form / .navlinks .active 配色。懒创建保留语义:Task 在 /new POST 时入库,后续 build_agent 走 resume 路径(已存在,不冲突);CLI REPL /new 仍走 build_agent 懒创建路径,不互相干扰。Smoke 21 路径全绿:GET 表单 200 + 三字段 / POST happy(description-only / custom task_dir)→ 303 + Location 正确 / DB 行字段对 + default-derived task_dir 含 uuid / 空描述空 task_dir → 400 重渲表单带 error / no-subtask 父子嵌套 → 409 + 错误文案 / home 页 + new task 按钮 + nav 链接 / /new nav 链接 active 标记。版本 0.4 → 0.5。
  • 05-15 / litellm 启动 cost map 网络警告兜底:litellm 启动会去 GitHub 拉 model_prices_and_context_window.json,墙内 SSL 握手常超时,虽然有本地 backup 不影响功能,但 stdout 一行 WARNING 噪声大。core/llm.pyimport litellm 之前 os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")(setdefault 不覆盖用户已显式设的值),走 litellm 的 LITELLM_LOCAL_MODEL_COST_MAP=True 路径直接用打包的本地 cost map,跳过 httpx.get。CLI / Web 都经 core.llm 走这条单点,不需要在多个入口分别设。冷启动从原来 ~5s SSL 超时降到 <1s。
  • 05-15 / task_dir 改相对存储:DB tasks.task_dir 原存绝对(D:\projects\zcbot\workspace\tasks\<uuid>),改为 ROOT 内→相对 posix(workspace/tasks/<uuid>)、ROOT 外→保留绝对(用户 --task-dir 指外部项目的场景)。新增 core/paths.py 提供 ROOT / to_db_path / from_db_path 三个出口,所有读写边界统一过这里。读端:resolve_task_id resume 分支 from_db_path(db_dir)(相对走 ROOT/.,绝对原样 resolve);export_chat_to_docx 自动从 PG 读时同样过 from_db_path。写端:build_agent 构造 metaTaskStateto_db_path(task_dir),web/app.py::/new 同步。check_no_subtask 抛掉原来 SQL 里 replace(task_dir, :bs, '/') 的拼接,改 Python 端 fetch + 双侧 from_db_path 归一到 absolute posix 后比前缀,逻辑更清晰且天然支持混合形态(老绝对 + 新相对 DB row 并存也对)。alembic 0002_task_dir_relative 一次 UPDATE 把现有 ROOT-prefix 行转相对(本机两条 active row 已 migrate 完);downgrade 反向用 _:% / /% LIKE 区分相对 vs 绝对。Smoke 四段全绿:round-trip(ROOT-内 / 外 / 空 / Windows backslash)/ check_no_subtask 混合形态 7 case(same / child / parent / sibling / outside-child / 绝对串新值 vs 相对串老 row 仍能拦 / 空跳过)/ resolve_task_id resume 还原一致 / build_agent 端到端写 DB 验证默认派生→相对、--task-dir 外部→绝对。CLAUDE.md 加"开发阶段不写兼容层"心智(用户指示)。
  • 05-15 / §7 D 阶段:/v1 JSON API 落地;Phase G Jinja2/HTMX 路线撤掉:用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML 就是双套 UI 浪费(DESIGN §7.9 新增取舍说明)。删除:web/templates/* 9 个模板 + web/static/* CSS 全去;requirements.txt 拿掉 jinja2 / markdown-it-py / mdit-py-plugins / pygments(python-multipart upload 还要用,保留)。重写 web/app.py/v1/ 前缀,JSON 响应:POST /v1/tasks(创建,Pydantic body)/ GET /v1/tasks?status=&limit=(列表)/ GET /v1/tasks/{id}(单 meta,不含 messages 走 /messages 拿)/ PATCH /v1/tasks/{id}({status?,description?,mode?} 部分更新,active 不让从 web 切回)/ GET /v1/tasks/{id}/messages(LiteLLM 原 payload 透传)/ POST /v1/tasks/{id}/messages(JSON {content},返 {run_id, events_url} + 起 BG run)/ GET /v1/tasks/{id}/runs/{rid}/events(SSE)/ files 4 路由全 /v1/ + JSON 返回 / GET /v1/tasks/{id}/export(.docx 下载不变)/ GET /healthz({"status":"ok"})/ GET / 302→/docs(Swagger UI)。SSE 事件 payload 由 HTML 片段切 JSON:每帧 event: <type> + data: <JSON dict>,前端自渲染;event types run_start / llm_start / text / tool_call / tool_result / llm_end / error / done(去掉 type 键的剩余字段进 data)。Pydantic 请求体 给 FastAPI auto-docs 自动出 schema。CORS allow_origins=["*"] 起步(部署 platform 时收紧)。没动:core/loop.py event shape(已是 dict)/ web/broker.py fan-out / web/sinks.py WebEventSink / 文件路径安全归一 / no-subtask 校验。Smoke 50+ case 全绿(in-process TestClient + 真实 HTTP):root 302、healthz JSON、docs/openapi 暴露、tasks CRUD 全分支(create happy + custom dir + 双空 400 + 嵌套 409 + 列表 + 单 get + ghost/非 UUID 404 + PATCH 多分支 + 空 PATCH 400)、messages list/post(payload 透传 + run_id 返 + events_url 拼对 + 空 content 400)、files list/upload/download/delete(攻击名 400、路径越界 400、root 拒、size raw int、mtime ISO)、export PK\x03\x04 magic、CORS preflight Access-Control-Allow-Origin: *。真实 HTTP cli.py web 起服务 → curl /healthz /v1/tasks /openapi.json 全 200 + 干净 JSON。版本 0.7 → 0.7(API surface 完工)。_smoke_api.py ad-hoc 跑完即删。沉淀的 Phase G 工作:sink 协议 / RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留 —— 删的只是 UI 层。
  • 05-15 / §7 Phase G G5 文件浏览 + 上传 / 下载 / 删:web/app.py 加四件套路由 — GET /tasks/{id}/files?path=<rel>(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ GET /tasks/{id}/files/download?path=<rel>(FileResponse + Content-Disposition)/ POST /tasks/{id}/files/upload(multipart list[UploadFile],?path= 指目标子目录,自动 mkdir(parents=True),303 回浏览页)/ POST /tasks/{id}/files/delete(form path=...,文件 / 空目录可删,非空目录 → 400,root → 400)。核心:_safe_join(root, rel) 路径安全归一——空 / "."→ root;/ \\ 起头 → 400(absolute-style 拒);Path.is_absolute → 400;(root / rel).resolve().relative_to(root.resolve()) 校验仍在 root 内(防 ../ / symlink 逃逸)。上传文件名 strict 拒带 path 痕迹(/ \\ .. parts)—— 现代浏览器只给 basename,异常 client 直接 400 不悄悄 sanitize。task_dir 不存在(skill 还没产物)→ 200 + 空文案,不报错。task_dir 空(legacy / 未绑)→ 400。_load_task_dir(task_id) 共用入口:404 if 非 UUID / task 不存在,400 if task_dir 空,否则返 (tid, abs_path)模板:新增 files.html(面包屑 nav + upload-form multipart/form-data + <table class="file-list"> 行渲染目录用蓝色 + / 后缀,文件用 download 链 + size + mtime + 删除按钮);chat.html 在 page-head 加 files 按钮(task_dir 非空时显示)。CSS:.crumbs / .upload-form(虚线红框 accent-soft 区)/ .file-list 表 / .btn-mini mini 按钮 + .btn-mini-danger 红 hover / .ico-dir .ico-file 文件类型标识。Smoke 50+ case 全绿:task_dir 不存在 200(2) / 列文件 + 子目录(12) / download 文件 + 子目录 + 404 + 目录-是非文件 400(7) / path 安全 6 case(../ 越界 + POSIX 绝对 + Win 绝对 + \\ 越界 + /tmp) / upload 单文件 + multi-file + nested mkdir + 攻击名 ../escape.txt / ../../boom / empty 全拒 + 目标 path 是文件 400 + 文件落 FS 内容一致(13) / delete 文件 + 空目录 + 非空 400 + ghost 404 + root 拒 + 越界拒(9) / chat.html files 链接 + ghost task_id 全 404(5) / task_dir 空 400(2)。版本 0.6 → 0.7。_smoke_g5.py ad-hoc 跑完即删。
  • 05-15 / §7 Phase G G6 三件套:/done /abandon 按钮 + /export 下载 + 全局 toast:① POST /tasks/{id}/status(status=completed|abandoned,active 不让从 web 切回 → 400)走 UPDATE tasks SET status,303 redirect 回 /tasks/{id} —— 浏览器全页刷新,聊天流不重发。chat.html active task 渲两个 <form method="post"> 按钮(原生 confirm() 防误操,无 HTMX 依赖),completed/abandoned 自动隐藏按钮只显 status badge。② GET /tasks/{id}/exporttempfile.mkstemp(suffix=.docx)export_chat_to_docx(tid, out_path=tmp)FileResponse(..., background=BackgroundTask(tmp.unlink, missing_ok=True)) 响应完成自动删 tmpfile;无 messages → 400 / ghost UUID → 404 / 失败 → 500 带错文。chat.html 在 n_messages > 0 时渲 <a class="btn">export .docx</a>(浏览器原生下载,无 HTMX 干预 Content-Disposition)。export_chat_to_docx 顺手修了一个 bug:task_dir is None 且 PG 也空时旧逻辑硬抛 ValueError,即便 out_path 已经显式传入 —— 现在 task_dir 改为可选(None 时 meta 段显示 (未绑)),只在 out_path 也 None 时才报错。③ base.html 末尾加 <div id="toast-region"> + inline JS 监听 htmx:responseError(4xx/5xx 抓 responseText 截 200 字)和 htmx:sendError(网络层挂),自动 5-6s dismiss + 手动 × 关。CSS .toast / .toast-error 右上角 fixed 区 + @keyframes toast-in/out 滑入滑出;#toast-region z-index 9999 + pointer-events: none(容器穿透,toast 自身可点)。Smoke 32 case 全绿:status 6 case(completed/abandoned 303 + DB UPDATE + GET 不再渲按钮、invalid status 400、active 400 拒切回、非 UUID 404、ghost 404)+ export 7 case(200 + Content-Disposition attachment + filename chat_<8>.docx + media-type docx + size > 8KB + magic PK\x03\x04 + no messages 400 + 404 双路径)+ toast 6 case(div / 两 listener / CSS)+ chat.html 7 case(active 渲 done/abandon/export + confirm 文案 + completed 不渲)。版本 0.5 → 0.6。_smoke_g6.py ad-hoc 跑完即删(不入 git)。TODO:并发同 task 多 run lock 还没做(留到 D 阶段或下次)。
  • 05-15 / §7 D' 过渡 auth + dev SPA:platform 联调前需要 auth,但完整 OIDC 还要等;落地 PLATFORM_KEY → JWT 兑换 过渡形态(web/auth.py),前后端走完全同一条流(platform 服务端 / dev 浏览器都持有 PLATFORM_KEY、调 /v1/auth/login 换 token、后续 Authorization: Bearer <jwt>)。实现:pyjwt HS256,AuthConfig.from_env() 启动校验 PLATFORM_KEY / JWT_SECRET 必填(任一缺失 fail-fast)、ZCBOT_JWT_TTL_SECONDS 默认 7d、mint_token / verify_token / ensure_user_row(任意 user_id 幂等 INSERT users 行避免 FK 失败)。HTTPBearer(auto_error=False) Depends 拿凭证 → verify_token → UUID;make_require_user(cfg) 工厂闭包持 cfg,FastAPI Depends 抽签到每个 /v1/tasks* 路由。数据隔离:所有 SELECT Task / UPDATE TaskTask.user_id == user_id 条件;_load_task_dir(task_id, user_id) 跨 user 视为 404(不暴露存在性);check_no_subtask(... user_id=user_id)ensure_local_task_row(... user_id=user_id) 同 user 隔离 no-subtask 校验。新增 _assert_owns_task(s, tid, user_id) helper 复用 messages / SSE / export 三处所有权校验。豁免://healthz/docs/openapi.json/v1/auth/login/static/* 不验 token。dev SPA(web/static/dev.html ~600 行单文件 vanilla JS):login overlay(user_id 默 SENTINEL 全 0 + platform_key) → localStorage 存 token → 3 栏布局(左 task 列表 + 状态 filter + 新建按钮;中 chat meta + 流式消息卡 + send 表单;右 file 浏览 + 面包屑 + 下载)+ 顶 bar(user 显示 + logout)+ new task modal。SSE 走 fetch + ReadableStream(不用 EventSource,因为 EventSource API 不支持自定义 header,token 没法塞;改用 fetch + 手解 SSE frame \n\n 切帧、event: data: 行解析、JSON.parse data 字段)。/ 302 → /static/dev.html(Swagger 仍在 /docs)。Smoke 32 case 全绿(TestClient + 真实 HTTP via uvicorn @8767):基本路由(/healthz / / 302 / dev.html 28KB / /docs 仍 200)+ 未带 token 8 路径全 401 + login 路径(bad key 403 / bad user_id 400 / happy 200 + token/expires_at/user_id 回显)+ 带 token CRUD 200/201 + 跨 user 隔离 4 case(other 看 sentinel 404 / 列表不串 / 各自创建独立 / sentinel 看 other 404)+ token 异常(garbled / Basic scheme / wrong-secret / expired 全 401)+ 真实 HTTP login + bearer call + dev.html 静态服务 29KB + root 302 Location 正确 + /docs 仍开放。版本 0.7 → 0.8。requirements 加 pyjwt>=2.8.0没动:core/*build_agentSession.append、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。TODO:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。
  • 05-17 / GET /v1/tasks + ordering 排序(DRF 风格):加 ordering query 参数,逗号分隔多字段,-field 倒序;allowlist created_at/updated_at/name/status;非法字段静默丢弃,全非法 fallback 默认。默认从 -updated_at-created_at(用户要求,创建时间倒序更稳定)。_parse_ordering(s) helper 返 sqlalchemy order_by 列表直接 *expand。dev SPA 加 ordering dropdown(7 个常用选项:创建/更新时间双向 + 名称双向 + 状态分组),默认值 -created_at 不发送 URL(参数干净);onchange 同 filter 一样 reset page=1。Smoke 7 case:default = -created_at(mu/alpha/zeta) / created_at asc 反向 / name asc(alpha/mu/zeta) / -name desc 反向 / 多字段 status,-created_at(状态 alpha 排序 abandoned→active→completed) / 非法字段 garbage → fallback default / 混合 garbage,-name → 仅 -name 生效。文档 DESIGN §7.2 / RUN 路由表同步。
  • 05-17 / GET /v1/tasks 分页 + 多维筛选 + dev SPA 翻页/搜索:用户反馈 list 接口缺分页和 status 等筛选。改 list_tasks_route:① 标准分页壳 {page, page_size, count, results}(响应键固定顺序,前端契约稳定);② 6 个 query 参数 —— page(default 1, ≥1 clamp)/ page_size(default 20,1100 clamp)/ status 单值 active|completed|abandoned(非法值静默忽略)/ skill 精确匹配 / working_dir 末段目录名(后端自动拼 workspace/users/<uid>/<name> 比对,客户端不用知道完整 db form)/ q 走 PG ILIKE '%q%' 同时打 name + description 两列;③ 实现 select 出 conditions list 一把过 + 单 COUNT(*) + 单 SELECT LIMIT/OFFSET,无 N+1。dev SPAloadTaskList:从老的"无分页 ?status=" 改成构造 URLSearchParams 传 page+filters;state 新增 taskPage / taskPageSize / taskTotal;新增 renderPager() 显示 fromto / count (第 P/L 页) + prev/next 按钮(disabled 边界态);筛选输入框(#filter-q #filter-wd)debounce 300ms 后 reset page=1 重拉;#filter-wd autocomplete 复用 <datalist id="folders-datalist">(focus 时 lazy 拉 /v1/folders)。task list pane 改成三段:① label + status select + 刷新 ② q 搜索 + 工作目录筛选 ③ pager(条件态显示)。Smoke 12 case 全绿:无 filter (count=25, page1=20条) / page=2 (count=25, 5条) / page_size clamp 500 → 100 / page=0 → 1 / status 单维 (10) / skill 单维 (10) / working_dir CJK '水泥申报' (10) / q 'AI' (10) / q CJK '废弃' (5) / status+skill 组合 (10) / status+skill 不命中 (0+[]) / 非法 status 静默 → 全集 25。新 envelope 验证:list(data.keys()) == ['page','page_size','count','results'] 顺序固定。文档 DESIGN §7.2 路由签名 + RUN 路由表同步。
  • 05-17 / 0003 schema:name + working_dir + skill 三件套(去掉 task_dir / mode 旧名):用户反馈"name 应该自动 / 可改;现在的 name 其实是工作目录(可建可选)"——也就是要把任务标识和工作目录解耦。同时观察到 mode 命名抽象,跟项目 skills/ 注册表对不上,顺手改 skillalembic 0003:用户授权清表(TRUNCATE tasks CASCADE)+ task_dir → working_dir + mode → skill + 加 name TEXT NOT NULL(空表上 NOT NULL 不需要 backfill)。ORM Task 三列同步;TaskStatename 字段、task_dir → working_dirmode → skill;ensure_local_task_row / upsert_task 签名重排(name 必传 INSERT 路径);check_no_subtask ORM 引用 + 形参 task_dir → working_dir;core/paths.py / export_docx.py / session.py 同步刷;main.py::build_agent 重构:new task 必传 name(任务名)+ 可选 working_dir(留空 → fallback name 作目录),两者都过 validate_task_name;working_dir_from_name 取代 task_dir_from_name(纯路径派生);resolve_task_id 形参 working_dir_name;mkdir 在新建分支后 + check_no_subtask 后立即落盘;meta dictname 字段、原 task_dir/modeworking_dir/skillCLI:chat --name <必填> + --working-dir <可选> + --skill <coding/ppt/...>;/new <name> 自动复用当前 working_dir(取上层 task_dir 末段);/new 无参 → 自动 gen 新任务_HH-MM-SS;/status 显示 name + skill + working_dir 全套;/resume 列表加 name + skill 两列。web /v1:TaskCreateRequest 字段 name(req)+ working_dir(opt) + description + skill;TaskPatchRequestname + skill(去 mode);create_task working_dir 留空 fallback 用 name + mkdir + check_no_subtask;新增 GET /v1/folders(列 user 下 FS 非 dotfile 子目录 + 关联 task 计数 + 最后使用时间,sort last_used desc, name asc);_load_task_dir → _load_working_dir;_task_dict 返回 dict 加 name 字段、task_dir → working_dirmode → skill;路径越界错文案 task_dir → working_dir。dev SPA modal:任务名 + 工作目录(配 <datalist> autocomplete 走 /v1/folders)+ skill + description 四字段;hd-new 打开 modal 时拉 folders;nt-wd 输入时实时提示"→ 复用已有目录 (N 个 task)" / "→ 新建目录 X" / "留空 → 用任务名 fallback";renderTaskList 主行从"working_dir 末段"改为 t.name(任务名优先),📁 工作目录名+ skill + description 走副行;renderChatMeta 同步把 name 顶头 + 📁 + skill + tid + desc + 计数。Smoke:9 case /v1/tasks POST 全绿(name+working_dir 双填 / 同 working_dir 二次共享 / 留空 fallback / name 缺 → 422 / name 非法 → 400 / working_dir 非法 → 400 / GET 列表含三新字段 / PATCH name+skill / /v1/folders 含 水泥申报 n_tasks=2 last_used 非空);CLI build_agent 4 case(new 双填 / append 后 reloaded 字段对 / resume 还原 / fallback)。文档:DESIGN §3.1 目录树注释 / §3.6 三件套字段语义 + 创建语义 / §7.2 POST + PATCH + DELETE + 新 GET /v1/folders / §7.4 schema 块 + index 同步;PROGRESS 单条记录;RUN 待刷。
  • 05-17 / files 面板 UX 让用户清楚"我在哪个项目里" + 修 root crumb bug:用户反馈"web 右侧 files 看不到文件夹"——实际场景是用户建了 task name=水泥申报,FS workspace/users/.../水泥申报/ 已建,但里面是空的,files 面板只显示"(空目录)"——用户混淆为"看不到 水泥申报 这个文件夹本身"。真因是 UI 没把"现在面板内部就是 水泥申报 的内容"说清楚。修两处:① 后端 _enumerate_files:cur_rel == "."(target == root)时不再追加一个无意义 "." crumb(原来 if cur_rel: 把 "." 当真值,会塞 {label: ".", rel: "."} 进 crumbs[1]);改为 if cur_rel and cur_rel != "."。② dev SPA renderFiles:pane-head 旁加 <span id="files-proj">(muted small 样式 + ellipsis),textContent = "· " + projName(取 task_dir.split('/').filter(Boolean).pop());crumbs 第一格 label 从 "/" 替换为项目名(projName),整条路径直观为 水泥申报 / 草稿 / draft.mddeleteCurrentTask 清面板时也 reset files-proj。Smoke:root 路径 crumbs 长度 == 1(原 == 2);进 水泥申报/草稿 子目录 crumbs == 2 且第二格 label == "草稿"(CJK 透传 OK);GBK 控制台显示乱码确认是 stdout encoding 而非 PG 存储问题(task_dir.encode('utf-8') 字节正确 + codepoints 是 [0x6c34, 0x6ce5, 0x7533, 0x62a5])。
  • 05-17 / task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删:用户反馈缺 task 删除入口(原本只有 PATCH status=abandoned 软态)。新增 DELETE /v1/tasks/{id}:user_id ownership 校验(跨 user → 404 不暴露存在性)+ DB 行 DELETE(messages / runs CASCADE)+ FS task_dir 不动(同 name 多 task 共享语义下"最后一个 task 删了顺便 rmtree"的判断有边界 case,易擦用户素材;让用户经 /files/delete 或文件管理器显式清更安全)。返 204 No Content。dev SPA:chat 面板 head 加 btn-delete-task(small danger 样式,title 说明"清 DB 行 + messages,FS 文件不动"),disabled 仅在没选 task 时 true —— 任何 status 都可删(active / completed / abandoned 不限,confirm 弹窗带项目名 + 消息条数二次确认)。点击后清空 chat 面板 + files 面板 + state reset + reload task list。file 面板 per-row 加红 × 按钮(del-file class),click stopPropagation 不触发行的下载/进目录;调原有 POST /v1/tasks/{id}/files/delete(API 没改,非空目录仍 400 拒,弹错文)。Smoke 6 case 全绿:happy 路径 204 + DB 行 gone + FS should_survive.txt 保留 / messages CASCADE 真生效(idx=1 user msg INSERT 后 DELETE task → messages count = 0)/ ghost UUID 404 / 非 UUID 字符串 404 / 跨 user delete 404 + 原 user 仍可删 + 原 task 行未被擦 / 无 token 401。文档:DESIGN §7.2 资源模型加 DELETE /v1/tasks/{id} 行 + 注释 "FS task_dir 保留"。
  • 05-17 / task_dir 改 eager mkdir + dev SPA 列表显示项目名:用户反馈"创建 task 给了名字也聊了天,文件夹没建出来"——原"懒 mkdir(skill 第一次写产物时建)"是 UUID-named 派生目录时代的设计,现在 task_dir 是用户给的项目名(workspace/users/<uid>/<name>/),name = 项目声明,目录就该在 task 创建时存在(用户可立刻往里塞素材文件,而非等 LLM 触发 skill)。改两处入口:main.py::build_agent 新建分支(not resume && no-subtask 校验后)+ web/app.py::create_task(fs_dir = task_dir_from_name(...) 之后),都加 mkdir(parents=True, exist_ok=True)。同 name 多 task 共享同目录(§7.1),exist_ok=True 无冲突 + 已有内容(其他 task 产物或用户素材)不被擦。task_dir_from_name 仍保持纯路径派生(docstring 同步)。cli.py::_cleanup_if_empty 注释里"未触发 lazy mkdir"过时表述修正。dev SPA dev.html:renderTaskList 主行原本 t.description || "(no desc)",description 空时一片"(no desc)"丑;改为 主行 = 项目名(task_dir.split('/').filter(Boolean).pop())+ description 移到副行(空则不渲)+ task_id[:8] 移到 badge 行末段,信息密度更高且每条都有标识。renderChatMeta 同步同样规则。DESIGN §3.6 同步:删"task_dir 在 skill 第一次落产物时 mkdir"+ 修过时的 _cleanup_if_empty 描述("DB 无 messages 且 FS 无产物 → DELETE + rmdir" → 实际现在 "无 user msg → DELETE DB 行;FS 一律不动")。Smoke:task_dir_from_name 纯路径不预 mkdir + mkdir idempotent + POST /v1/tasks 后 FS 真存在 + 同 name 二次 create reuse 不擦已落入文件(user_marker.txt 保留)+ DB 双行 task_id 不同 task_dir 同。
  • 05-17 / task = name-based 项目目录 + memory dotfile:废弃自动 UUID 派生 + tasks/ 中间层。新建 task 必须给 name(简单名,项目目录名),task_dir 派生为 workspace/users/<uid>/<name>/;同 name 多 task 自动共享同目录(§7.1 task-primary)。name 校验(main.py::validate_task_name):非空 / 不含 /\NUL / 不以 . 起头(挡 .memory 等系统区)/ ≤ 255 字符;允许 CJK 与其他 Unicode。memory 搬 dotfile:workspace/users/<uid>/.memory/{core.md, extended/},跟用户项目目录扁平共存不撞名;validate_task_name. 起头双向防呆。删函数:_default_task_dir / is_managed_task_dir / tasks_dir 全删,build_agent.task_dir_argname,cli.py --task-dir--name;web TaskCreateRequest.task_dirname(必填),POST /v1/tasks 缺 name → 422 (Pydantic) / 不合法 → 400(InvalidTaskName 文案)/ 同名共享 task_dir 不触发 no-subtask。_cleanup_if_empty 简化:FS 一律不动(项目目录跨 task 复用,绝不 rmtree),空 task 只删 DB 行;原"managed 派生模板"概念整个废弃。dev SPA:新建 task modal 字段 task_dir(留空)→ name(必填),task 列表行末段显示项目名(task_dir.split('/').pop())而非两段 UUID/path。清旧数据:workspace/users/*/tasks/ 上轮白建的中间层空目录 + users/<uid>/memory/(非 dotfile)全 rm,DB 已空。Smoke 全绿:validate_task_name 14 case 边界(简单名 / 中间含 . / 空白 strip / CJK / 含 /\NUL 拒 / . 起头四种拒 / 长度边界 255 vs 256)+ 路径派生 + memory dotfile 路径 + CLI build_agent name 必填强制 + web /v1/tasks 4 + name 不合法 400 全分支 + 同 name 多 task 共享同 task_dir + resume + dotfile memory 注入 + 跨 user 隔离。文档同步:DESIGN.md §3.1 目录树 / §3.7 memory 路径 + dotfile 说明 / §7.0 表 / §7.1 留空派生改"必给 name" / §7.2 /v1/tasks POST 入参 / §7.4 schema 注释 + 文件系统块 / §7.6 Step 3 描述 全部刷新。未来形态:外部绝对路径(项目目录在 workspace 外的场景)暂未保留,日后真需要再开 --external-path 单独通道。
  • 05-15 / workspace 布局统一 per-user:DESIGN §7.0 / §7.4 落地补 —— 原默认 task_dir workspace/tasks/<uuid>/ + 全局 workspace/memory/ 改为 workspace/users/<user_id>/{tasks/<uuid>,memory/}/(本地 CLI user_id = SENTINEL,web/JWT user_id = JWT sub)。main.py::_default_task_dir(workspace, tid, user_id) / tasks_dir(workspace, user_id) / 新增 user_root(workspace, user_id) / is_managed_task_dir(td, ws, user_id) 都接 user_id;resolve_task_id / build_agent / _build_system_prompt 透传(build_agent.user_id 留 Optional 默认 SENTINEL,CLI 不需要显式传)。core/memory.py::memory_block(workspace, user_id) per-user 子树读 users/<uid>/memory/,prompt 段从"workspace 级"改"user 级"。web/app.py::create_task 把 Depends 拿到的 JWT user_id 喂进 _default_task_dir;_run_agent_bg(task_id, run_id, user_id, msg) 加 user_id 参数透传给 build_agent(resume=True, user_id=...) —— 确保 web resume 时 memory_block 读对 per-user 子树。cli.py::_cleanup_if_emptyis_managed_task_dir(..., SENTINEL_USER_ID);tasks_dir(ws, SENTINEL_USER_ID) 显示路径。没动 session.py —— Session.append → ensure_local_task_row(user_id=SENTINEL) 默认值对 CLI 正确;web 路径 create_task 已提前预 INSERT 真 user_id 的占位行,后续 ON CONFLICT DO NOTHING 不会落 SENTINEL 默认值。开发期心态(CLAUDE.md):清旧数据不留兼容 —— DELETE FROM tasks(CASCADE messages/runs)+ usage_events;rm -rf workspace/tasks/;保留 users 表 2 行(sentinel + 已登录 web user)避免 JWT FK 失败。文档同步:DESIGN.md §3.1 目录树 / §3.7 memory 路径 / §7.0 task_dir & memory 默认值表 / §7.1 留空派生路径 / §7.2 /v1/tasks POST task_dir 默认值 / §7.4 task_dir 存储约定注释 / §7.4 文件系统块布局 / §7.6 Step 3 描述 全部刷新。
  • 05-15 / §7 Phase G G4 chat 发送 + SSE 流式:新增 web/broker.py::RunBroker(in-process pub/sub,subscribe/emit/close/unsubscribe)+ web/sinks.py::WebEventSink 实现 §7 A 的 sink 协议,把 AgentLoop._emit 桥到 broker。异步策略 = asyncio.to_thread(不改 core):POST /tasks/{tid}/messages async handler → 校验 task + INSERT runs 行 + asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...)),_run_agent_bg 在工作线程跑 build_agent(resume=True) + agent.run,sink 通过 loop.call_soon_threadsafe(q.put_nowait, ev) 跨线程桥事件回 asyncio queue。多访问策略 = fan-out:每订阅一个独立 asyncio.Queue,同 run 多 tab / 刷新 / 桌面+移动都看得到流;_done 集合让晚到订阅者立即收 done(不挂)。GET /tasks/{tid}/runs/{rid}/eventsStreamingResponse async gen,响应头带 text/event-stream / Cache-Control: no-cache / X-Accel-Buffering: no(nginx 反代友好);第一帧发 : connected\nretry: 3000\n\n 让 EventSource 立即建立,30s 无 event 发 : ping 注释心跳。SSE multi-line data:HTML 片段含换行,每行加 data: 前缀(SSE spec),EventSource API 还原成 \n 拼接的 HTML 字符串。Event → HTML 片段:_render_event_fragment 渲染 text/tool_call/tool_result/error 四种,run_start/llm_start/llm_end/done 发空 data(只让客户端识别 event type)。新 fragment 模板 _frag_text.html / _frag_tool_call.html / _frag_tool_result.html / _frag_error.html + _send_response.html(POST 响应:user msg 卡 + msg-assistant streaming 容器带 sse-connect/sse-swap/sse-close)。chat.html 加 send 表单(Enter 发送、Shift+Enter 换行,HTMX hx-post / hx-target=#chat-stream / hx-swap=beforeend / hx-on::after-request reset);chat section 改 id="chat-stream" 让 SSE 追加进同一容器;非 active task 隐藏表单。CSS 加 .streaming .run-indicator 红点脉冲 / .send-form 表单样式 / .tool-result-inline 追加式样式 / .msg-error 错误卡。Run 状态写 PG runs:POST 时 status=running,正常完结 status=ok + tokens_p/c,异常 status=error + error 文本;DB 写失败不放大噪声(已 emit error 给前端)。lifespan bind_loop(asyncio.get_running_loop()) 让 broker 拿到 asyncio loop 引用。Smoke 双层全绿:broker 单元 8 case(subscribe/emit/get、fan-out 双订阅、跨 run_id 隔离、close 派 done、late subscribe 立刻收 done、unsubscribe 后失联、WebEventSink 桥、unbinded loop silent drop);端到端 24 case(POST 200 + HTML 含 sse-connect + run_id 抽出 + SSE stream content-type/x-accel-buffering/cache-control 头对、event types 序列 run_start/llm_start/text/tool_call/tool_result/llm_end/done、text fragment 含 <strong> markdown、tool_call 含 <details>、tool_result 含 preview、empty body 400、invalid/ghost UUID 404、late subscribe 立刻 done、PG runs 行 INSERT)。版本 0.3 → 0.4。TODO:并发同 task 多 run 互锁(messages idx UniqueConstraint 在并发 POST 下会冲突 — 用户连续点 send 暂时不会触发,但需要在 G6 或 D 阶段加 lock_for_update);event log 持久化(刷新继续看流式)留到未来。

关键决策与偏差

决策 备注
工具基目录 cwd(读)+ task_dir(写) system prompt 同时注入两者绝对路径
Workspace 布局 workspace/users/<user_id>/{.memory/, <name>/} per-user 隔离;memory dotfile 防撞;<name> 用户起项目名,同 name 多 task 共享;CLI sentinel = 00000000-...
Eval Suite 不做 个人工具 dogfooding
版本化 prompt 直接 general_v1.md Windows 软链接麻烦,真要切再做
run_python 沙盒 subprocess + env 过滤 Docker 在 §7 C 阶段

文件清单

core/capabilities.py        71
core/llm.py                 93   ← +litellm 离线 cost map env
core/loop.py               182   ← §7 A sink.emit + cancel_check 协作式 cancel
core/sinks.py              101   ← §7 A
core/ui.py                  38
core/paths.py               50   ← task_dir db form 归一(to_db_path / from_db_path)
core/probe.py              243
core/session.py            153   ← §7 B Step 2-3: ORM + ensure 补 meta
core/skills.py              81
core/task.py                82   ← §7 B Step 3: PG-backed TaskState,去 cwd
core/memory.py              81   ← per-user `.memory/` dotfile
core/export_docx.py        383   ← §7 B Step 2-4 + from_db_path 还原 + task_dir Optional
core/storage/__init__.py    27   ← §7 B Step 1-3
core/storage/engine.py      80   ← §7 B Step 1
core/storage/models.py      99   ← 3 表(0004 删 runs/usage_events;Task + run_status/run_error)
core/storage/utils.py      136   ← check_no_subtask 改 Python 端归一
tools/base.py               34
tools/fs.py                182
tools/shell.py              94
tools/run_python.py         84
tools/skill_tool.py         45
main.py                    164   ← 入口: web / db / probe 三 click 命令(05-18 改名归位)
core/agent_builder.py      307   ← 装配 lib: build_agent / system prompt / validate_task_name(原 main.py 内容)
db/migrations/env.py        61   ← §7 B Step 1
db/migrations/versions/
  0001_initial_schema.py   125   ← §7 B Step 1
  0002_task_dir_relative.py 61   ← 现有 ROOT-prefix 绝对 → 相对
  0003_task_name_and_working_dir.py
                             51   ← name 必填 + task_dir→working_dir + mode→skill
  0004_drop_runs_usage_events.py
                             77   ← 删 runs/usage_events + tasks 加 run_status/run_error
web/__init__.py              5   ← Phase G G1
web/app.py                 889   ← /v1/ JSON API + user_id 隔离 + run lock + task-level cancel
web/auth.py                115   ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py              121   ← in-process pub/sub + cancel signal,全 task_id 索引(0004)
web/sinks.py                21   ← WebEventSink 绑 task_id(0004)
web/static/dev.html       1516   ← D' dev SPA + 文件预览弹框(image/pdf/text/md/docx/xlsx)
web/static/vendor/        ~1 MB  ← jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5(office 预览 vendor)
─────────────────────────────────
Python 合计              ~3400 行(+ dev.html 1516 静态 + vendor 1MB)— 05-18 入口归位净减 ~400 行 REPL/CLI

加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。


下一步候选(性价比排序)

  1. 真 OIDC 接入 + CORS 收紧(~1 天)—— 把 /v1/auth/login 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。真发布给真实用户前必做
  2. §7 C Executor + sandbox(~2-3 天)—— run_python/shellExecutor.run(...),本地保留 subprocess、SaaS 走 docker;api_key_envKeyProvider 运行时注入。多用户在线跑代码前置。
  3. Phase 6 context 三层压缩(~1 天)—— 兜底,V4 长上下文一般用不到。

§7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 主体已完工。剩余路线:真 OIDC → C(Executor)→ F(deploy / billing)。§7 E CLI 双模式撤(2026-05-18 §7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 删,无 --remote 双 transport 维护税。原 Phase G Web UI 路线撤(§7.9),UI 改 platform 端实现;web/static/dev.html 是开发期单文件 SPA,跟 platform UI 并存不冲突。