core(0003): name + working_dir + skill schema 重构 + per-user .memory

- alembic 0003: TRUNCATE tasks CASCADE + task_dir→working_dir + mode→skill + 加 name TEXT NOT NULL
- name(必填,任务显示名,UI / docx 用)与 working_dir(可选,留空 fallback 用 name 作目录)解耦;
  同 working_dir 多 task 共享物理目录(§7.1)
- skill 字段对齐 skills/ 注册表语义,后续可下拉强校验
- POST /v1/tasks {name(req), working_dir?, description?, skill?};
  PATCH 支持改 name/skill;新增 GET /v1/folders(FS 列表 + n_tasks + last_used)
- DELETE /v1/tasks/{id} 硬删 DB(messages CASCADE)+ FS working_dir 保留;
  dev SPA 加 task delete 按钮 + file per-row 删按钮
- 工作目录改 eager mkdir(取代懒创建):用户给 name 即声明项目,目录立刻存在
- dev SPA modal 拆"任务名" + "工作目录"(<datalist> autocomplete 走 /v1/folders +
  输入实时提示"复用 / 新建 / fallback");renderTaskList 主行 = t.name,副行 = 📁 + skill + desc
- files 面板 UX:pane-head 显示项目名 + crumbs root 用项目名 + 修 root 处多渲 "." crumb 的 bug
- 顺手:memory 搬 workspace/users/<uid>/.memory/(per-user dotfile 隔离);
  CLI --mode → --skill,--name + --working-dir 分开
- DESIGN §3.1 / §3.6 / §7.2 / §7.4 + PROGRESS + RUN 全量同步

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-17 19:15:37 +08:00
parent 02a69058df
commit 4a6aaaf34d
22 changed files with 770 additions and 312 deletions

View File

@ -40,12 +40,13 @@ zcbot/
├── prompts/system/general_v1.md ├── prompts/system/general_v1.md
├── config/{agent.yaml, models/deepseek_v4.yaml} ├── config/{agent.yaml, models/deepseek_v4.yaml}
├── workspace/ ├── workspace/
│ ├── memory/{core.md, extended/*.md} # 跨 task 共享记忆 │ └── users/<user_id>/
│ └── tasks/<task_id>/ # task_dir:仅 skill 产物,state/messages 在 PG │ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离)
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物
└── {main.py, cli.py} └── {main.py, cli.py}
``` ```
**task_dir = `workspace/tasks/<task_id>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent。写错位置(cwd / `skills/` / repo 根)git status 立刻报红,不再用无锚 .gitignore 通配盖污染 **工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent(prompt 里仍叫 `task_dir` 占位符,跟 SKILL.md DSL 一致)。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。本地 CLI user_id 固定为 SENTINEL(`00000000-...`);web/JWT 路径用 `sub`。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变
**启动**:读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 task_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + task_dir 绝对路径)→ 装配工具 → REPL。新建路径**懒创建**,不预占文件(§3.6)。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。 **启动**:读 `agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 解析 task_dir → 拼 system prompt(general_v1.md + skill discovery + cwd + task_dir 绝对路径)→ 装配工具 → REPL。新建路径**懒创建**,不预占文件(§3.6)。`ZCBOT_DB_URL` 指 PG(本地 docker compose / 远端 dev / 生产)。
@ -81,19 +82,24 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
### 3.6 Session 与 Task ### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。 **Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
**Task**(`core/task.py`)= Session 上层,含 mode / description / status / model / reasoning_effort / task_dir / 时间戳 / tokens。**直接 ORM 写 PG `tasks` 表**。task_dir FS 目录只存 skill 产物,无 `state.json` / `messages.json`。本地 + SaaS **同一份 schema 和 ORM**,差别只在 `ZCBOT_DB_URL` **Task**(`core/task.py`)= Session 上层,含 name / working_dir / skill / description / status / model / reasoning_effort / 时间戳 / tokens。**直接 ORM 写 PG `tasks` 表**。working_dir FS 目录只存 skill 产物,无 `state.json` / `messages.json`。本地 + SaaS **同一份 schema 和 ORM**,差别只在 `ZCBOT_DB_URL`
**懒创建** —— `build_agent` 不立刻 INSERT,Task / Session 在第一条 user 消息触发 `append` 时 INSERT;task_dir 目录在 skill 第一次落产物时 `mkdir(parents=True)`。启动 REPL 后立刻 `/exit` 不留 DB 行 + 不留目录。 **字段三件套语义**:
- `name`(NOT NULL) = 任务显示名,UI 列表 / 标题 / docx 导出文件名用;独立于工作目录
- `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录
- `skill` = 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐 `skills/` 注册表强校验)
**REPL 内 task 切换** —— `/new` / `/resume [last|<id>]`(无参列最近 10 个)/ `/done /abandon` / `/desc`。切走前 `_cleanup_if_empty` 守门:DB 无 messages **且** FS task_dir 无产物 → DELETE + rmdir;任一痕迹存在则保留。 **创建语义** —— working_dir 目录在 task 创建入口立即 `mkdir(parents=True, exist_ok=True)`(`name` 必填代表"显式声明项目";`working_dir` 留空 → fallback 用 name 作目录名)。`Task` 行在 web `/v1/tasks` POST 时即写;CLI 内仍走 `Session.append` 首条 user 消息触发的占位 INSERT(`ensure_local_task_row` idempotent,`name` 透传给 NOT NULL 列)—— REPL 启动后立刻 `/exit` 不留 DB 行(目录留着无害,跨 task 复用)。
**REPL 内 task 切换** —— `/new` / `/resume [last|<id>]`(无参列最近 10 个)/ `/done /abandon` / `/desc`。切走前 `_cleanup_if_empty` 守门:无 user message → DELETE DB 行;**FS 一律不动**(同 name 跨 task 共享,绝不 rmtree)。
**原子性** —— PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。 **原子性** —— PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。
CLI:`chat --mode coding --desc "..." [--resume last|<id>] [--remote <url>]`;`tasks [--status ...]`。 CLI:`chat --name "<任务名>" [--working-dir <目录名>] [--skill coding] [--desc "..."] [--resume last|<id>] [--remote <url>]`;`tasks [--status ...]`。
### 3.7 双层记忆(`core/memory.py`) ### 3.7 双层记忆(`core/memory.py`)
跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/memory/`: 跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/users/<user_id>/.memory/`(per-user,dotfile 隔离):
| 层 | 文件 | 加载 | 适合 | | 层 | 文件 | 加载 | 适合 |
|---|---|---|---| |---|---|---|---|
@ -104,7 +110,7 @@ CLI:`chat --mode coding --desc "..." [--resume last|<id>] [--remote <url>]`;`tas
memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。
**memory 永远在 FS,不入 DB**:本地 `workspace/memory/`,SaaS `<storage_root>/users/<user_id>/memory/`(bind mount 进容器)。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 **memory 永远在 FS,不入 DB**:统一 `<workspace_or_storage_root>/users/<user_id>/.memory/`(本地直接是 `workspace/`,SaaS 是 `<storage_root>/`,bind mount 进容器)。本地 CLI 走 SENTINEL user;web/JWT 走 `sub`。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免项目名取 `memory` 时撞名;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
--- ---
@ -188,14 +194,14 @@ SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个
|---|---|---| |---|---|---|
| 入口 | `cli.py chat` 直调 core | HTTP `/v1/...` + SSE | | 入口 | `cli.py chat` 直调 core | HTTP `/v1/...` + SSE |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| task_dir 默认值 | `workspace/tasks/<task_id>/`(留空时派生) | `<storage_root>/users/<user_id>/tasks/<task_id>/`(留空时派生);用户指定时走 `<storage_root>/users/<user_id>/<user-path>/` | | task_dir 派生 | `workspace/users/<sentinel>/<name>/`(`name` 必填,简单名) | `<storage_root>/users/<user_id>/<name>/`(`name` 必填,简单名) |
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) | | Memory | `workspace/users/<sentinel>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
| Sandbox | subprocess + env 过滤 | per-task docker exec | | Sandbox | subprocess + env 过滤 | per-task docker exec |
| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC | | Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC |
**CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。 **CLI 长期双模式**:本地直跑(默认,in-process,直连 PG,适合调内部状态)/ `--remote https://...`(HTTP 走 `/v1`,等价真实用户路径)。两模式共用 `cli.py`,差别只在 transport 层。
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 差别只在 task_dir 根路径,不在 storage 形态。 `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录(`workspace/` vs `<storage_root>/`),不在 storage 形态。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 ### 7.1 心智模型:Task 一等公民 + Dir 文件副视图
@ -209,7 +215,7 @@ SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。 类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。
- **Task** = DB 一行,一等公民,自带 `task_dir text` 字段: - **Task** = DB 一行,一等公民,自带 `task_dir text` 字段:
- **留空 → 默认派生路径**(`workspace/tasks/<task_id>/`),等价"一次性对话"(ChatGPT thread 体验) - **新建必给 `name`**(简单名),`task_dir = workspace/users/<user_id>/<name>/`。同 name 多 task 共享 → "同一项目多对话"语义;不再支持空 task_dir / 自动 UUID 派生(原 ChatGPT thread 模式取消,纯对话也得起个项目名)
- **指定 → 项目化 task**,同 task_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体) - **指定 → 项目化 task**,同 task_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体)
- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走 prefix cascade(§7.4) - **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走 prefix cascade(§7.4)
- **No-subtask**:同 task_dir 允许(同项目多对话),前缀嵌套拒 - **No-subtask**:同 task_dir 允许(同项目多对话),前缀嵌套拒
@ -227,11 +233,16 @@ Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder
``` ```
Tasks Tasks
POST /v1/tasks 创建 {description?, mode?, task_dir?}; POST /v1/tasks 创建 {name(必填), working_dir?, description?, skill?};
task_dir 留空 → 默认派生 workspace/tasks/<uuid>/ 留空 working_dir → 用 name 作目录名;
working_dir 派生 workspace/users/<user_id>/<working_dir>/;
name/working_dir 不合法 → 400
GET /v1/tasks?status=&limit= 列表(updated_at 降序,?status=active|completed|abandoned) GET /v1/tasks?status=&limit= 列表(updated_at 降序,?status=active|completed|abandoned)
GET /v1/tasks/{id} 单 task meta + 完整 messages GET /v1/tasks/{id} 单 task meta + 完整 messages
PATCH /v1/tasks/{id} {status?,description?,mode?};status 从 web 不让切回 active(走 CLI) PATCH /v1/tasks/{id} {status?,description?,name?,skill?};status 从 web 不让切回 active(走 CLI)
DELETE /v1/tasks/{id} 硬删:DB 行 + messages(CASCADE);**FS working_dir 保留**
(同 working_dir 多 task 共享,文件由用户经 /files/delete 单独清)
GET /v1/folders 列当前 user 的 working_dir(FS 是 source of truth + 关联 task 计数 + 最后使用时间)
GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector) GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {run_id} POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {run_id}
GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下) GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下)
@ -284,13 +295,16 @@ done {}
users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at) users(user_id uuid pk, email null, password_hash | oidc_subject null, plan null, created_at)
-- 本地形态固定 INSERT sentinel: user_id = '00000000-...',email/auth/plan 全 NULL -- 本地形态固定 INSERT sentinel: user_id = '00000000-...',email/auth/plan 全 NULL
tasks(task_id uuid pk, user_id fk, task_dir text not null, mode, description, tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd, status, model_profile, tokens_prompt, tokens_completion, cost_usd,
created_at, updated_at); created_at, updated_at);
create index on tasks (user_id, task_dir); create index on tasks (user_id, working_dir);
-- task_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串(`workspace/tasks/<uuid>`); -- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串
-- ROOT 外 → 绝对 str(用户自指定项目目录);空串 → 未绑项目。SaaS 阶段同理(基础是 -- (`workspace/users/<user_id>/<name>`,name 是简单名,无 /\..);
-- <storage_root>/users/<uid>/)。读写边界统一过 core/paths.py::{to_db_path,from_db_path}。 -- 新建强制 `name` 必填,空串只可能在 legacy 数据(开发期已 wipe)。
-- SaaS 阶段同理(基础是 <storage_root>/users/<uid>/)。
-- 读写边界统一过 core/paths.py::{to_db_path,from_db_path}。
-- 入口校验 main.py::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, created_at, payload jsonb not null, tokens_in, tokens_out, created_at,
@ -309,11 +323,12 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
**Folder delete**:hard cascade,前端 modal 列影响面 + 输入 folder 名二确认。先 DELETE messages → DELETE tasks → FS 递归删;DB 成功 FS 失败由后台 GC 兜底清孤儿目录。`usage_events` 不参与 cascade。 **Folder delete**:hard cascade,前端 modal 列影响面 + 输入 folder 名二确认。先 DELETE messages → DELETE tasks → FS 递归删;DB 成功 FS 失败由后台 GC 兜底清孤儿目录。`usage_events` 不参与 cascade。
**文件系统**: **文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根,布局不变):
``` ```
<storage_root>/users/<user_id>/ <storage_root>/users/<user_id>/
memory/{core.md, extended/} # per-user,不入 DB .memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
<user-given-paths>/... # task_dir 散落其下 <name>/ # 项目目录,name 用户起(必填),task_dir 直接落这
<name>/... # 同 name 多 task 共享同目录(§7.1)
``` ```
本地优先 S3(部署简化 / 低延迟),storage 抽象层留好后续可换。 本地优先 S3(部署简化 / 低延迟),storage 抽象层留好后续可换。
@ -338,7 +353,7 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
|---|---|---| |---|---|---|
| 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done | | 1 | ~~事件流化 `loop.py`~~(commit `375bb29`) | done |
| 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 | | 2 | **Storage 落 PG**:`Session` / `TaskState` 改 SQLAlchemy 写 PG;alembic;`cli migrate-from-fs`;`docker-compose.yml` 起本地 PG | 3 天 |
| 3 | **task_dir 字段语义**:留空走默认派生(本地 `workspace/tasks/<task_id>/` / SaaS `<storage_root>/users/<uid>/tasks/<task_id>/`)或显式指定(走用户给路径);`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入两形态共用 | 1 天 | | 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `<storage_root>/users/<user_id>/<name>/`(本地 `<storage_root>` = `workspace/`,sentinel user);同 name 多 task 共享同目录;`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入 | 1 天 |
| 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 | | 4 | **Folder API**:list / create / rename(cascade + 锁 running) / delete(hard cascade) / upload / download | 2 天 |
| 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 | | 5 | **No-subtask 校验**:`create_task` 入口跑 §7.4 SQL | 0.5 天 |
| 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 | | 6 | **Executor + sandbox**:`run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入 | 2-3 天 |

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-15(D 阶段:/v1 JSON API 落地 + PLATFORM_KEY → JWT auth + dev SPA;Phase G Jinja2 路线撤掉) 最后更新:2026-05-17(schema 重构:`name`(必填,显示名)+ `working_dir`(可选,留空 fallback name)解耦;`task_dir → working_dir` + `mode → skill` 列重命名;新增 `GET /v1/folders`(已有目录 autocomplete);dev SPA 任务名 / 工作目录字段拆开 + datalist 选已有目录;files 面板 UX:pane-head 显示项目名 + crumbs root 用项目名 + 修 root 处多渲 "." crumb 的 bug;`DELETE /v1/tasks/{id}` 硬删 + dev SPA delete 按钮 + 文件 per-row 删按钮;task 创建立即建项目目录(从懒创建改 eager))
--- ---
@ -44,6 +44,12 @@
- **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 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}/export``tempfile.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 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}/export``tempfile.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 Task``Task.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_agent`、`Session.append`、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。 - **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 Task``Task.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_agent`、`Session.append`、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。**TODO**:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。
- **05-17 / 0003 schema:name + working_dir + skill 三件套(去掉 task_dir / mode 旧名)**:用户反馈"name 应该自动 / 可改;现在的 name 其实是工作目录(可建可选)"——也就是要把任务标识和工作目录解耦。同时观察到 `mode` 命名抽象,跟项目 `skills/` 注册表对不上,顺手改 `skill`。**alembic 0003**:用户授权清表(`TRUNCATE tasks CASCADE`)+ `task_dir → working_dir` + `mode → skill` + 加 `name TEXT NOT NULL`(空表上 NOT NULL 不需要 backfill)。**ORM** `Task` 三列同步;**TaskState** 加 `name` 字段、`task_dir → working_dir`、`mode → 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 dict** 多 `name` 字段、原 `task_dir`/`mode` 改 `working_dir`/`skill`。**CLI**:`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`;`TaskPatchRequest` 加 `name` + `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_dir`、`mode → 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.md`。`deleteCurrentTask` 清面板时也 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_arg` 改 `name`,`cli.py --task-dir` 改 `--name`;web `TaskCreateRequest.task_dir``name`(必填),`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_empty` 调 `is_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}/events``StreamingResponse` 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 持久化(刷新继续看流式)留到未来。 - **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}/events``StreamingResponse` 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 持久化(刷新继续看流式)留到未来。
--- ---
@ -53,7 +59,7 @@
| 项 | 决策 | 备注 | | 项 | 决策 | 备注 |
|---|---|---| |---|---|---|
| 工具基目录 | cwd(读)+ task_dir(写) | system prompt 同时注入两者绝对路径 | | 工具基目录 | cwd(读)+ task_dir(写) | system prompt 同时注入两者绝对路径 |
| Workspace 布局 | `tasks/<id>/` + `memory/{core.md, extended/}` | memory 跨 task 共享 | | Workspace 布局 | `workspace/users/<user_id>/{.memory/, <name>/}` | per-user 隔离;memory dotfile 防撞;`<name>` 用户起项目名,同 name 多 task 共享;CLI sentinel = `00000000-...` |
| Eval Suite | 不做 | 个人工具 dogfooding | | Eval Suite | 不做 | 个人工具 dogfooding |
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 | | 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 | | run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
@ -73,7 +79,7 @@ core/probe.py 243
core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta
core/skills.py 81 core/skills.py 81
core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd
core/memory.py 76 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/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/__init__.py 27 ← §7 B Step 1-3
core/storage/engine.py 80 ← §7 B Step 1 core/storage/engine.py 80 ← §7 B Step 1
@ -84,7 +90,7 @@ tools/fs.py 182
tools/shell.py 94 tools/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 tools/skill_tool.py 45
main.py 285 ← +to_db_path 写 / from_db_path 读 main.py 285 ← user_root / task_dir_from_name / validate_task_name(删 auto-derive 三件套)
cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令 cli.py 558 ← §7 B Step 4 / Phase G G1: --task-dir / web 子命令
db/migrations/env.py 61 ← §7 B Step 1 db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/ db/migrations/versions/

39
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-15(D' 过渡 auth + dev SPA 落地) 最后更新:2026-05-17(task 拆 `--name`(必填,任务名)+ `--working-dir`(可选,目录名);`--mode → --skill`;`/v1/folders` 列已有目录;0003 migration)
--- ---
@ -37,7 +37,7 @@ python -m venv .venv
# 3) DB schema 上车 # 3) DB schema 上车
.venv/Scripts/python.exe cli.py db upgrade head .venv/Scripts/python.exe cli.py db upgrade head
.venv/Scripts/python.exe cli.py db current # 应输出 0002 (head) .venv/Scripts/python.exe cli.py db current # 应输出 0003 (head)
``` ```
--- ---
@ -47,26 +47,29 @@ python -m venv .venv
### 聊天 / 任务 ### 聊天 / 任务
```bash ```bash
# 新建 task,默认派生 workspace/tasks/<uuid>/ # 新建 task —— `--name` 必填(任务显示名),`--working-dir` 可选(目录名,留空 → 用 --name)
.venv/Scripts/python.exe cli.py chat .venv/Scripts/python.exe cli.py chat --name "初稿大纲" --working-dir proposal_v3
# 带模式 + 描述(便于后续 list 识别) # 只给 name → working_dir fallback 用 name
.venv/Scripts/python.exe cli.py chat --mode coding --desc "修 X 的 Y" .venv/Scripts/python.exe cli.py chat --name proposal_v3
# 项目化 task —— 产物落到指定目录(§7.1 task-primary + dir 副视图) # 带 skill + 描述(便于后续 list 识别)
.venv/Scripts/python.exe cli.py chat --task-dir /path/to/proj --mode proposal .venv/Scripts/python.exe cli.py chat --name "修登录 401" --working-dir fix_login_bug --skill coding --desc "登录返回 401 排查"
# 恢复最近一个 task # 同 working_dir 多 task(共享 workspace/users/<sentinel>/proposal_v3/ 目录,name 各不同)
.venv/Scripts/python.exe cli.py chat --name "补充资料" --working-dir proposal_v3
# 恢复最近一个 task(resume 时 --name / --working-dir 都忽略)
.venv/Scripts/python.exe cli.py chat --resume last .venv/Scripts/python.exe cli.py chat --resume last
# 恢复指定 task(UUID 完整或 ≥8 字符前缀) # 恢复指定 task(UUID 完整或 ≥8 字符前缀)
.venv/Scripts/python.exe cli.py chat --resume 76c6bd25 .venv/Scripts/python.exe cli.py chat --resume 76c6bd25
# 切模型 # 切模型
.venv/Scripts/python.exe cli.py chat --model deepseek_v4.pro .venv/Scripts/python.exe cli.py chat --name x --model deepseek_v4.pro
``` ```
REPL 内命令:`/exit /reset /new /resume [last|<id>] /id /status /done /abandon /desc <文本> /export [<id>]` REPL 内命令:`/exit /reset /new [<name>] /resume [last|<id>] /id /status /done /abandon /desc <文本> /export [<id>]`(`/new <name>` 用新任务名 + 沿用当前 working_dir;`/new` 无参 → 自动 gen `新任务_HH-MM-SS`)
### 列表 / 导出 ### 列表 / 导出
@ -134,10 +137,12 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 | | `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 | | `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 | | `POST /v1/auth/login` | body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/tasks` | 创建 task,body `{description?, mode?, task_dir?}`;同 cli `chat --task-dir` | 必填 | | `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
| `GET /v1/tasks?status=&limit=` | 列当前 user 的任务,`updated_at` 降序 | 必填 | | `GET /v1/tasks?status=&limit=` | 列当前 user 的任务,`updated_at` 降序 | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 | | `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,mode?}` 部分更新;active 走 CLI 切回 | 必填 | | `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}` 部分更新;active 走 CLI 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used(供创建 task 自动补全用) | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}` | 必填 | | `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}` | 必填 |
| `GET /v1/tasks/{id}/runs/{rid}/events` | SSE 流(`event: <type>` + `data: <json>`) | 必填 | | `GET /v1/tasks/{id}/runs/{rid}/events` | SSE 流(`event: <type>` + `data: <json>`) | 必填 |
@ -164,9 +169,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) | | Windows 控制台 emoji 崩 | Python stdout 是 GBK,emoji 不能直 print。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| `db upgrade``column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 | | `db upgrade``column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID | | Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID |
| `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--working-dir` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
| `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) | | `NoSubtaskError: working_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--working-dir`;否则改路径成 sibling(平级) |
| `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` | | `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)| | platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
@ -188,7 +193,9 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS) - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}`(FastAPI + /v1 JSON API + SSE + PLATFORM_KEY→JWT)+ `web/static/dev.html`(dev SPA,单文件 vanilla JS)
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile) - **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**:`workspace/memory/{core.md, extended/}`(跨 task 记忆,FS 永久)/ `workspace/tasks/<uuid>/`(默认派生 task_dir,只放 skill 产物) - **Workspace**(per-user 子树,本地 CLI sentinel = `00000000-0000-0000-0000-000000000000`,web/JWT 用 sub):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/<working_dir>/` —— 工作目录,用户起的目录名(`cli chat --working-dir` 或留空 fallback `--name` / API `POST /v1/tasks {working_dir?}`),同 working_dir 多 task 共享
--- ---

134
cli.py
View File

@ -11,23 +11,26 @@
""" """
from __future__ import annotations from __future__ import annotations
import shutil
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
import click import click
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.table import Table from rich.table import Table
from core.storage import SENTINEL_USER_ID
from core.ui import make_console from core.ui import make_console
from main import ( from main import (
ROOT, ROOT,
InvalidTaskName,
_resolve_uuid_or_prefix, _resolve_uuid_or_prefix,
build_agent, build_agent,
load_config, load_config,
resolve_workspace, resolve_workspace,
sync_task_tokens, sync_task_tokens,
tasks_dir, user_root,
validate_task_name,
) )
@ -81,37 +84,23 @@ def db_current() -> None:
_run_alembic(command.current) _run_alembic(command.current)
def _cleanup_if_empty(task_dir, session, workspace_dir, console=None) -> bool: def _cleanup_if_empty(working_dir, session, workspace_dir, console=None) -> bool:
"""切走前清理空 task。 """切走前清理空 task。
DB 行无条件删除(若存在且 session 内存无 user 消息) DB 行无条件删除(若存在且 session 内存无 user 消息)
FS rmtree **仅在 task_dir workspace/tasks/<uuid>/ 默认派生路径**且无产物时执行 FS **绝不 rmtree** working_dir 是用户起的项目目录名, working_dir task 复用,
用户用 `--task-dir` 指定的项目目录绝不 rmtree(可能含用户已有文件) 可能里面已有别的产物; task 只清 DB
""" """
from main import is_managed_task_dir _ = workspace_dir # 不再用,签名保留向后兼容
_ = working_dir # FS 不动,只清 DB
if session.n_user_msgs() > 0: if session.n_user_msgs() > 0:
return False return False
managed = is_managed_task_dir(task_dir, workspace_dir)
try:
entries = list(task_dir.iterdir())
except FileNotFoundError:
# 目录都没建,只清 DB 占位行
_delete_task_db_row(session.task_id)
return False
meaningful = [
p for p in entries
if not (p.is_file() and p.name.endswith(".tmp"))
]
if meaningful:
return False
if managed:
shutil.rmtree(task_dir, ignore_errors=True)
_delete_task_db_row(session.task_id) _delete_task_db_row(session.task_id)
if console is not None: if console is not None:
tag = "empty" if managed else "empty (kept user dir)" console.print(
console.print(f"[muted]cleaned {tag} task {str(session.task_id)[:8]}[/muted]") f"[muted]cleaned empty task {str(session.task_id)[:8]} (kept FS dir)[/muted]"
)
return True return True
@ -142,7 +131,7 @@ def _task_has_messages(task_id_str: str) -> bool:
def _list_task_rows(workspace_dir, limit=20, status=None): def _list_task_rows(workspace_dir, limit=20, status=None):
"""返回 [(updated_at, task_id_str, status, mode, model, tokens, n_msgs, desc), ...] 时间降序。 """返回 [(updated_at, task_id_str, status, name, skill, model, tokens, n_msgs, desc), ...] 时间降序。
Step 3 :全字段从 PG tasks 表读,messages 数从 PG ;workspace_dir 仅用于 Step 3 :全字段从 PG tasks 表读,messages 数从 PG ;workspace_dir 仅用于
保持签名向后兼容(不再读 state.json)status 过滤走 SQL WHERE 保持签名向后兼容(不再读 state.json)status 过滤走 SQL WHERE
@ -154,7 +143,7 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
_ = workspace_dir # 签名占位,Step 3 后已不需要 _ = workspace_dir # 签名占位,Step 3 后已不需要
with session_scope() as s: with session_scope() as s:
q = select( q = select(
Task.task_id, Task.updated_at, Task.status, Task.mode, Task.task_id, Task.updated_at, Task.status, Task.name, Task.skill,
Task.model, Task.model_profile, Task.tokens_prompt, Task.model, Task.model_profile, Task.tokens_prompt,
Task.tokens_completion, Task.description, Task.tokens_completion, Task.description,
).order_by(Task.updated_at.desc()) ).order_by(Task.updated_at.desc())
@ -166,10 +155,10 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
).all()) ).all())
rows = [] rows = []
for tid, updated_at, st_, md, mdl, prof, tp, tc, desc in rows_db: for tid, updated_at, st_, nm, sk, mdl, prof, tp, tc, desc in rows_db:
n = msg_counts.get(tid, 0) n = msg_counts.get(tid, 0)
rows.append(( rows.append((
updated_at, str(tid), st_, md, updated_at, str(tid), st_, nm, sk,
prof or mdl, (tp or 0) + (tc or 0), n, desc, prof or mdl, (tp or 0) + (tc or 0), n, desc,
)) ))
return rows return rows
@ -177,17 +166,36 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
@cli.command() @cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro") @click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--workspace", default=None, help="工作目录(存 tasks/ 和 sessions/)") @click.option("--workspace", default=None, help="工作目录根(默认 ./workspace)")
@click.option("--resume", default=None, help="恢复 task: 'last' 或 task_id") @click.option("--resume", default=None, help="恢复 task: 'last' 或 task_id")
@click.option("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)") @click.option("--skill", default="", help="智能体类型标签(coding/ppt/proposal/...自由形式,对齐 skills/)")
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别") @click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别")
@click.option("--task-dir", "task_dir_arg", default=None, @click.option("--name", default=None,
help="项目化 task:把产物落到指定目录(绝对或相对当前 cwd);留空走默认派生 workspace/tasks/<uuid>/") help="任务名(必填,DB 存,UI 显示用)。resume 时忽略。")
def chat(model: str, workspace: str, resume: str, mode: str, desc: str, @click.option("--working-dir", default=None,
task_dir_arg: str) -> None: help="工作目录名(简单名,不含 / \\ .. 也不能以 . 起头);留空 → 用 --name。"
"""启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。""" "工作目录落 workspace/users/<sentinel>/<working_dir>/,同名多 task 共享。"
"resume 时忽略。")
def chat(model: str, workspace: str, resume: str, skill: str, desc: str,
name: str, working_dir: str) -> None:
"""启动交互式 REPL。新建必填 `--name`,可选 `--working-dir`;用 --resume 接老的。"""
console = make_console() console = make_console()
ws_dir = resolve_workspace(workspace) ws_dir = resolve_workspace(workspace)
if not resume:
if not name:
console.print("[err]新建 task 需要 --name <任务名>[/err]")
sys.exit(1)
try:
name = validate_task_name(name)
except InvalidTaskName as e:
console.print(f"[err]name 不合法:[/err] {e}")
sys.exit(1)
if working_dir:
try:
working_dir = validate_task_name(working_dir)
except InvalidTaskName as e:
console.print(f"[err]working_dir 不合法:[/err] {e}")
sys.exit(1)
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, model_name=model,
@ -195,9 +203,10 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
console=console, console=console,
session_id=resume, session_id=resume,
resume=bool(resume), resume=bool(resume),
mode=mode, skill=skill,
description=desc, description=desc,
task_dir_arg=task_dir_arg, name=name if not resume else None,
working_dir=working_dir if not resume else None,
) )
except Exception as e: except Exception as e:
console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}") console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}")
@ -206,14 +215,15 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
if resume: if resume:
console.print( console.print(
f"[ok]恢复 task[/ok] [bold]{sid[:8]}[/bold] ({len(session.messages)} 条消息) " f"[ok]恢复 task[/ok] [bold]{sid[:8]}[/bold] ({len(session.messages)} 条消息) "
f"name: [accent]{task_state.name}[/accent] "
f"model: [accent]{agent.caps.model_id}[/accent]" f"model: [accent]{agent.caps.model_id}[/accent]"
) )
else: else:
meta_tail = "" meta_tail = ""
if task_state.mode or task_state.description: if task_state.skill or task_state.description:
meta_tail = f" mode={task_state.mode!r} desc={task_state.description!r}" meta_tail = f" skill={task_state.skill!r} desc={task_state.description!r}"
console.print( console.print(
f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold] " f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold] name=[accent]{task_state.name}[/accent] "
f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}" f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}"
) )
console.print( console.print(
@ -239,18 +249,32 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
session.reset(keep_system=True) session.reset(keep_system=True)
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]") console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue continue
if cmd == "/new": if cmd.startswith("/new"):
_cleanup_if_empty(task_dir, session, ws_dir, console) _cleanup_if_empty(task_dir, session, ws_dir, console)
# `/new <name>` → 新 task,name = 参数;`/new` 无参 → 自动生成名(时间戳)
arg = cmd[len("/new"):].strip()
new_name = arg or f"新任务_{datetime.now().strftime('%H-%M-%S')}"
try:
new_name = validate_task_name(new_name)
except InvalidTaskName as e:
console.print(f"[err]name 不合法:[/err] {e}")
continue
# 沿用当前 task 的 working_dir(同项目多对话);取上层 task_dir 末段作为 dir name
current_wd = task_dir.name # 例如 `水泥申报` 或 `proposal_v3`
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console, model_name=model, workspace=workspace, console=console,
mode=mode, description=desc, skill=skill, description=desc,
task_dir_arg=task_dir_arg, name=new_name, working_dir=current_wd,
) )
except Exception as e: except Exception as e:
console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}") console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}")
continue continue
console.print(f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold]") name = new_name # 更新当前 name
console.print(
f"[ok]新 task[/ok] [bold]{sid[:8]}[/bold] name=[accent]{name}[/accent] "
f"working_dir=[accent]{current_wd}[/accent]"
)
continue continue
if cmd.startswith("/resume"): if cmd.startswith("/resume"):
arg = cmd[len("/resume"):].strip() arg = cmd[len("/resume"):].strip()
@ -272,14 +296,15 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
tbl.add_column("#", style="bold") tbl.add_column("#", style="bold")
tbl.add_column("task id") tbl.add_column("task id")
tbl.add_column("status") tbl.add_column("status")
tbl.add_column("mode") tbl.add_column("name")
tbl.add_column("skill")
tbl.add_column("msgs", justify="right") tbl.add_column("msgs", justify="right")
tbl.add_column("desc") tbl.add_column("desc")
sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"} sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for i, (_, tid, st, md, _mdl, _tok, n, dsc) in enumerate(rs, 1): for i, (_, tid, st, nm, sk, _mdl, _tok, n, dsc) in enumerate(rs, 1):
c = sc.get(st, "info") c = sc.get(st, "info")
d_show = dsc if len(dsc) <= 50 else dsc[:47] + "..." d_show = dsc if len(dsc) <= 50 else dsc[:47] + "..."
tbl.add_row(str(i), tid[:8], f"[{c}]{st}[/{c}]", md, str(n), d_show) tbl.add_row(str(i), tid[:8], f"[{c}]{st}[/{c}]", nm, sk, str(n), d_show)
console.print(tbl) console.print(tbl)
try: try:
sel = Prompt.ask("[user]选编号或输入 task_id (回车取消)[/user]", console=console, default="") sel = Prompt.ask("[user]选编号或输入 task_id (回车取消)[/user]", console=console, default="")
@ -321,8 +346,10 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
continue continue
if cmd == "/status": if cmd == "/status":
console.print( console.print(
f"[info]task {task_state.task_id} status={task_state.status} " f"[info]task {task_state.task_id} name={task_state.name!r} "
f"mode={task_state.mode!r} desc={task_state.description!r}\n" f"status={task_state.status} skill={task_state.skill!r} "
f"desc={task_state.description!r}\n"
f" working_dir={task_state.working_dir}\n"
f" model={task_state.model} tokens={task_state.tokens_total} " f" model={task_state.model} tokens={task_state.tokens_total} "
f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) " f"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) "
f"created={task_state.created_at} updated={task_state.updated_at}[/info]" f"created={task_state.created_at} updated={task_state.updated_at}[/info]"
@ -400,21 +427,22 @@ def tasks(workspace: str, limit: int, status: str) -> None:
rows = _list_task_rows(ws, limit=limit, status=status) rows = _list_task_rows(ws, limit=limit, status=status)
if not rows: if not rows:
click.echo(f"(no tasks in {tasks_dir(ws)})") click.echo(f"(no tasks under {user_root(ws, SENTINEL_USER_ID)})")
return return
tbl = Table(show_lines=False) tbl = Table(show_lines=False)
tbl.add_column("task id", style="bold") tbl.add_column("task id", style="bold")
tbl.add_column("status") tbl.add_column("status")
tbl.add_column("mode") tbl.add_column("name")
tbl.add_column("skill")
tbl.add_column("model") tbl.add_column("model")
tbl.add_column("msgs", justify="right") tbl.add_column("msgs", justify="right")
tbl.add_column("tokens", justify="right") tbl.add_column("tokens", justify="right")
tbl.add_column("desc") tbl.add_column("desc")
sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"} sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for _, tid, st, mode, model, tok, n, desc in rows: for _, tid, st, nm, sk, model, tok, n, desc in rows:
c = sc.get(st, "info") c = sc.get(st, "info")
d_show = desc if len(desc) <= 50 else desc[:47] + "..." d_show = desc if len(desc) <= 50 else desc[:47] + "..."
tbl.add_row(tid[:8], f"[{c}]{st}[/{c}]", mode, model, str(n), str(tok), d_show) tbl.add_row(tid[:8], f"[{c}]{st}[/{c}]", nm, sk, model, str(n), str(tok), d_show)
make_console().print(tbl) make_console().print(tbl)

View File

@ -168,7 +168,7 @@ def _format_args(args_str: str) -> str:
# ───────────────────────── Meta 区块 ───────────────────────── # ───────────────────────── Meta 区块 ─────────────────────────
def _add_meta_block( def _add_meta_block(
doc: Document, meta: dict, task_state: dict, n_msgs: int, task_dir: Optional[Path] doc: Document, meta: dict, task_state: dict, n_msgs: int, working_dir: Optional[Path]
) -> None: ) -> None:
p = doc.add_paragraph() p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT p.alignment = WD_ALIGN_PARAGRAPH.LEFT
@ -181,8 +181,9 @@ def _add_meta_block(
run.font.bold = True run.font.bold = True
_set_run_fonts(run, cn_font="黑体", en_font="Consolas") _set_run_fonts(run, cn_font="黑体", en_font="Consolas")
name = task_state.get("name") or ""
desc = task_state.get("description") or "" desc = task_state.get("description") or ""
mode = task_state.get("mode") or "" skill = task_state.get("skill") or ""
status = task_state.get("status") or "" status = task_state.get("status") or ""
model = meta.get("model") or task_state.get("model") or "" model = meta.get("model") or task_state.get("model") or ""
profile = meta.get("model_profile") or task_state.get("model_profile") or "" profile = meta.get("model_profile") or task_state.get("model_profile") or ""
@ -193,7 +194,8 @@ def _add_meta_block(
rows = [ rows = [
("Task ID", meta.get("id") or task_state.get("task_id") or "?"), ("Task ID", meta.get("id") or task_state.get("task_id") or "?"),
("模式", mode), ("任务名", name),
("Skill", skill),
("描述", desc), ("描述", desc),
("状态", status), ("状态", status),
("模型", model), ("模型", model),
@ -202,7 +204,7 @@ def _add_meta_block(
("更新时间", updated), ("更新时间", updated),
("消息数", str(n_msgs)), ("消息数", str(n_msgs)),
("Tokens", f"{tp} prompt / {tc} completion / {tp + tc} total"), ("Tokens", f"{tp} prompt / {tc} completion / {tp + tc} total"),
("Task dir", str(task_dir) if task_dir else "(未绑)"), ("工作目录", str(working_dir) if working_dir else "(未绑)"),
("导出时间", datetime.now().isoformat(timespec="seconds")), ("导出时间", datetime.now().isoformat(timespec="seconds")),
] ]
@ -316,7 +318,7 @@ def _render_message(
def export_chat_to_docx( def export_chat_to_docx(
task_id: UUID, task_id: UUID,
task_dir: Optional[Path] = None, working_dir: Optional[Path] = None,
out_path: Optional[Path] = None, out_path: Optional[Path] = None,
*, *,
include_system: bool = False, include_system: bool = False,
@ -327,8 +329,8 @@ def export_chat_to_docx(
"""渲染 task 对话为 .docx,返回写入路径。 """渲染 task 对话为 .docx,返回写入路径。
task_id 是主标识( PG messages + 元数据) task_id 是主标识( PG messages + 元数据)
task_dir 留空 PG tasks.task_dir(用户指定模式可能不在 workspace/tasks/<uuid>/); working_dir 留空 PG tasks.working_dir(用户指定模式可能不在默认派生路径下);
DB 也空 报错(无处放产物)out_path 留空 task_dir / chat_<uuid>.docx DB 也空 报错(无处放产物)out_path 留空 working_dir / chat_<uuid>.docx
""" """
from dataclasses import asdict from dataclasses import asdict
from sqlalchemy import select from sqlalchemy import select
@ -344,18 +346,18 @@ def export_chat_to_docx(
st = TaskState.load(task_id) st = TaskState.load(task_id)
task_state: dict = asdict(st) if st is not None else {} task_state: dict = asdict(st) if st is not None else {}
if task_dir is None: if working_dir is None:
td_str = task_state.get("task_dir", "") wd_str = task_state.get("working_dir", "")
if td_str: if wd_str:
# td_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path # wd_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path
from core.paths import from_db_path from core.paths import from_db_path
task_dir = from_db_path(td_str) working_dir = from_db_path(wd_str)
# else: task_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx) # else: working_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx)
if out_path is None: if out_path is None:
if task_dir is None: if working_dir is None:
raise ValueError(f"task {task_id}task_dir 且未指定 out_path —— 无处放 .docx") raise ValueError(f"task {task_id}working_dir 且未指定 out_path —— 无处放 .docx")
out_path = task_dir / f"chat_{task_id}.docx" out_path = working_dir / f"chat_{task_id}.docx"
meta = { meta = {
"id": str(task_id), "id": str(task_id),
@ -365,7 +367,7 @@ def export_chat_to_docx(
} }
doc = _init_doc() doc = _init_doc()
_add_meta_block(doc, meta, task_state, len(messages), task_dir) _add_meta_block(doc, meta, task_state, len(messages), working_dir)
doc.add_paragraph() # 与 meta 表保持一行间距 doc.add_paragraph() # 与 meta 表保持一行间距
for msg in messages: for msg in messages:

View File

@ -1,4 +1,4 @@
"""双层记忆: `workspace/memory/`。 """双层记忆: `workspace/users/<user_id>/.memory/` (§3.7 / §7.4)
core.md system prompt,每次都看到装稳定事实 core.md system prompt,每次都看到装稳定事实
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等) (用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
@ -9,17 +9,21 @@
core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容 core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容
extended 索引只占几行,内容按需付费 适合大量低频专题 extended 索引只占几行,内容按需付费 适合大量低频专题
memory workspace 级别(不是 task 级别)同一 workspace 的所有 task 共享 memory per-user(同一 workspace 内按 user_id 隔离), user 的所有 task 共享
SaaS (§7)后会按 tenant 隔离 接口不变,只换 storage backend **dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` )区分,避免
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆
本地 CLI = SENTINEL user;web/JWT subSaaS 化时 `<storage_root>` 替换
`workspace`,布局不变(§7.0)
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple
from uuid import UUID
def _memory_dir(workspace_dir: Path) -> Path: def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "memory" return workspace_dir / "users" / str(user_id) / ".memory"
def _read_first_title(p: Path) -> str: def _read_first_title(p: Path) -> str:
@ -36,8 +40,8 @@ def _read_first_title(p: Path) -> str:
return p.stem return p.stem
def _load_core(workspace_dir: Path) -> str: def _load_core(workspace_dir: Path, user_id: UUID) -> str:
p = _memory_dir(workspace_dir) / "core.md" p = _memory_dir(workspace_dir, user_id) / "core.md"
if not p.is_file(): if not p.is_file():
return "" return ""
try: try:
@ -46,9 +50,9 @@ def _load_core(workspace_dir: Path) -> str:
return "" return ""
def _extended_index(workspace_dir: Path) -> List[Tuple[str, Path]]: def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, Path]]:
"""返回 [(title, abs_path), ...],按文件名排序。""" """返回 [(title, abs_path), ...],按文件名排序。"""
ext_dir = _memory_dir(workspace_dir) / "extended" ext_dir = _memory_dir(workspace_dir, user_id) / "extended"
if not ext_dir.is_dir(): if not ext_dir.is_dir():
return [] return []
items: List[Tuple[str, Path]] = [] items: List[Tuple[str, Path]] = []
@ -58,14 +62,14 @@ def _extended_index(workspace_dir: Path) -> List[Tuple[str, Path]]:
return items return items
def memory_block(workspace_dir: Path) -> str: def memory_block(workspace_dir: Path, user_id: UUID) -> str:
"""构造注入 system prompt 的记忆段;两块都空就返回空串。""" """构造注入 system prompt 的记忆段;两块都空就返回空串。"""
core = _load_core(workspace_dir) core = _load_core(workspace_dir, user_id)
ext = _extended_index(workspace_dir) ext = _extended_index(workspace_dir, user_id)
if not core and not ext: if not core and not ext:
return "" return ""
parts = ["\n\n## 记忆 (workspace 级,跨 task 共享)"] parts = ["\n\n## 记忆 (user 级,跨 task 共享)"]
if core: if core:
parts.append("\n### Core (常驻 prompt)\n") parts.append("\n### Core (常驻 prompt)\n")
parts.append(core) parts.append(core)

View File

@ -1,16 +1,16 @@
"""task_dir 在 DB 与文件系统两种形态之间的归一。 """working_dir 在 DB 与文件系统两种形态之间的归一(原 `task_dir` 已改名)
存储约定(DESIGN §7.4): 存储约定(DESIGN §7.4):
- task_dir ROOT 相对 ROOT posix ( `workspace/tasks/abc-...`) - working_dir ROOT 相对 ROOT posix ( `workspace/users/<uid>/<name>`)
- task_dir ROOT 绝对 str( `D:\\projects\\other\\proj` `/home/u/proj`) - working_dir ROOT 绝对 str( `D:\\projects\\other\\proj` `/home/u/proj`)
- 空串 空串(legacy / 未绑项目) - 空串 空串(legacy / 未绑项目)
跨机器迁移 / OS / repo ,ROOT-内路径仍能 resolve;ROOT-外仍存绝对是务实选择 跨机器迁移 / OS / repo ,ROOT-内路径仍能 resolve;ROOT-外仍存绝对是务实选择
用户自指定的项目目录没有更好的归一基 用户自指定的项目目录没有更好的归一基
Read 端两种来源走两个入口: Read 端两种来源走两个入口:
- DB tasks.task_dir `from_db_path(s)` absolute Path - DB tasks.working_dir `from_db_path(s)` absolute Path
- 用户 CLI `--task-dir` / Web `/new` 表单 `Path(arg).expanduser().resolve()`(原行为不变) - 用户 CLI `--working-dir` / Web `/v1/tasks` 表单 `Path(arg).expanduser().resolve()`
Write 端只通过 `to_db_path(absolute Path)` DB Write 端只通过 `to_db_path(absolute Path)` DB
""" """
@ -26,7 +26,7 @@ def to_db_path(p: Union[Path, str, None]) -> str:
"""absolute Path / str → DB 串。 """absolute Path / str → DB 串。
输入应已是绝对路径(build_agent / web 路由那一层都 .resolve() ) 输入应已是绝对路径(build_agent / web 路由那一层都 .resolve() )
ROOT 相对 posix(`workspace/tasks/abc`) ROOT 相对 posix(`workspace/users/<uid>/<name>`)
ROOT str(Path)(保留 OS 原生分隔符) ROOT str(Path)(保留 OS 原生分隔符)
"" ""
""" """

View File

@ -77,13 +77,15 @@ class Session:
return return
# 首次写入前,让 tasks 行就位。`ensure_local_task_row` 在 storage 层 idempotent。 # 首次写入前,让 tasks 行就位。`ensure_local_task_row` 在 storage 层 idempotent。
# meta 字段(mode/description/reasoning_effort)走 INSERT 一次性带入,避免 # meta 字段(name/working_dir/skill/description/reasoning_effort)走 INSERT 一次性带入,
# 首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。 # 避免首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。
# name 是 NOT NULL,build_agent 必须放进 meta(新建 / resume 都已就位)。
from .storage.utils import ensure_local_task_row from .storage.utils import ensure_local_task_row
ensure_local_task_row( ensure_local_task_row(
task_id=self.task_id, task_id=self.task_id,
task_dir=self.meta.get("task_dir", ""), name=self.meta.get("name", ""),
mode=self.meta.get("mode", ""), working_dir=self.meta.get("working_dir", ""),
skill=self.meta.get("skill", ""),
description=self.meta.get("description", ""), description=self.meta.get("description", ""),
model=self.meta.get("model", ""), model=self.meta.get("model", ""),
model_profile=self.meta.get("model_profile", ""), model_profile=self.meta.get("model_profile", ""),

View File

@ -54,8 +54,9 @@ class Task(Base):
user_id: Mapped[UUID] = mapped_column( user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False
) )
task_dir: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
mode: Mapped[str] = mapped_column(Text, nullable=False, default="") working_dir: Mapped[str] = mapped_column(Text, nullable=False)
skill: Mapped[str] = mapped_column(Text, nullable=False, default="")
description: Mapped[str] = mapped_column(Text, nullable=False, default="") description: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(Text, nullable=False, default="active") status: Mapped[str] = mapped_column(Text, nullable=False, default="active")
model: Mapped[str] = mapped_column(Text, nullable=False, default="") model: Mapped[str] = mapped_column(Text, nullable=False, default="")

View File

@ -12,13 +12,14 @@ from .models import SENTINEL_USER_ID, Task
class NoSubtaskError(ValueError): class NoSubtaskError(ValueError):
"""task_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。""" """working_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。"""
def ensure_local_task_row( def ensure_local_task_row(
task_id: UUID, task_id: UUID,
task_dir: str = "", name: str,
mode: str = "", working_dir: str = "",
skill: str = "",
description: str = "", description: str = "",
model: str = "", model: str = "",
model_profile: str = "", model_profile: str = "",
@ -29,15 +30,17 @@ def ensure_local_task_row(
用于 `Session.append` 在首条非 system 消息前打底 tasks ,避免 messages 用于 `Session.append` 在首条非 system 消息前打底 tasks ,避免 messages
FK 违反字段是 build_agent 阶段已知的最小集;TaskState.save 之后会通过 FK 违反字段是 build_agent 阶段已知的最小集;TaskState.save 之后会通过
`upsert_task` 把真实字段(desc/status/tokens )写进去 `upsert_task` 把真实字段(desc/status/tokens )写进去`name` 必填( NOT NULL),
调用方应已 validate
""" """
stmt = ( stmt = (
insert(Task) insert(Task)
.values( .values(
task_id=task_id, task_id=task_id,
user_id=user_id, user_id=user_id,
task_dir=task_dir, name=name,
mode=mode, working_dir=working_dir,
skill=skill,
description=description, description=description,
model=model, model=model,
model_profile=model_profile, model_profile=model_profile,
@ -57,9 +60,10 @@ def upsert_task(
) -> None: ) -> None:
"""INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。 """INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。
fields 可包含 tasks 表任意可写列(task_dir/mode/description/status/model/ fields 可包含 tasks 表任意可写列(name/working_dir/skill/description/status/model/
model_profile/reasoning_effort/tokens_prompt/tokens_completion/cost_usd) model_profile/reasoning_effort/tokens_prompt/tokens_completion/cost_usd)
不传的字段在 INSERT 时走 ORM 默认值,UPDATE 时不动 不传的字段在 INSERT 时走 ORM 默认值,UPDATE 时不动
INSERT 路径需要 name(NOT NULL)+ working_dir; UPDATE 路径(行已存在)不强制
""" """
values = {"task_id": task_id, "user_id": user_id, **fields} values = {"task_id": task_id, "user_id": user_id, **fields}
stmt = insert(Task).values(**values) stmt = insert(Task).values(**values)
@ -100,30 +104,30 @@ def get_task(task_id: UUID) -> Optional[Task]:
def check_no_subtask( def check_no_subtask(
task_dir: str, working_dir: str,
user_id: UUID = SENTINEL_USER_ID, user_id: UUID = SENTINEL_USER_ID,
) -> None: ) -> None:
"""§7.4 no-subtask:同 user 下校验 task_dir 不能与已有 task_dir 形成前缀嵌套。 """§7.4 no-subtask:同 user 下校验 working_dir 不能与已有 working_dir 形成前缀嵌套。
允许: task_dir(同项目多对话)完全无关路径(平级或不相关) 允许: working_dir(同项目多对话)完全无关路径(平级或不相关)
拒绝:new existing 的子目录existing new 的子目录 拒绝:new existing 的子目录existing new 的子目录
task_dir / whitespace 跳过(legacy / 未绑项目) working_dir / whitespace 跳过(legacy / 未绑项目)
`task_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用 `working_dir` 入参既可以是 db 形态(相对 ROOT)也可以是 absolute str,内部统一用
`from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一 `from_db_path` 归一到 absolute posix 后再比前缀;DB 里行的两种形态同样归一
数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀 数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀
""" """
if not task_dir or not task_dir.strip(): if not working_dir or not working_dir.strip():
return return
from core.paths import from_db_path from core.paths import from_db_path
new_abs = from_db_path(task_dir).as_posix() new_abs = from_db_path(working_dir).as_posix()
if not new_abs: if not new_abs:
return return
with session_scope() as s: with session_scope() as s:
rows = s.execute( rows = s.execute(
select(Task.task_id, Task.task_dir) select(Task.task_id, Task.working_dir)
.where(Task.user_id == user_id, Task.task_dir != "") .where(Task.user_id == user_id, Task.working_dir != "")
).all() ).all()
for existing_id, existing_dir in rows: for existing_id, existing_dir in rows:
existing_abs = from_db_path(existing_dir).as_posix() existing_abs = from_db_path(existing_dir).as_posix()
@ -131,6 +135,6 @@ def check_no_subtask(
continue continue
if new_abs.startswith(existing_abs + "/") or existing_abs.startswith(new_abs + "/"): if new_abs.startswith(existing_abs + "/") or existing_abs.startswith(new_abs + "/"):
raise NoSubtaskError( raise NoSubtaskError(
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]}" f"working_dir {working_dir!r} 与已有 task {str(existing_id)[:8]}"
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir" f"working_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 working_dir"
) )

View File

@ -25,8 +25,9 @@ def _iso(dt: Optional[datetime]) -> str:
@dataclass @dataclass
class TaskState: class TaskState:
task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID) task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID)
task_dir: str = "" # 绝对路径或留空(留空= ChatGPT thread 默认派生,§7.1) name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读)
mode: str = "" # coding / ppt / proposal / general / 自由形式 working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑)
skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表)
description: str = "" # 一句话描述,便于列表识别 description: str = "" # 一句话描述,便于列表识别
status: str = "active" # active / completed / abandoned status: str = "active" # active / completed / abandoned
model: str = "" # caps.model_id model: str = "" # caps.model_id
@ -46,8 +47,9 @@ class TaskState:
"""UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。""" """UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。"""
upsert_task( upsert_task(
UUID(self.task_id), UUID(self.task_id),
task_dir=self.task_dir, name=self.name,
mode=self.mode, working_dir=self.working_dir,
skill=self.skill,
description=self.description, description=self.description,
status=self.status, status=self.status,
model=self.model, model=self.model,
@ -61,8 +63,9 @@ class TaskState:
def from_row(cls, row: TaskRow) -> "TaskState": def from_row(cls, row: TaskRow) -> "TaskState":
return cls( return cls(
task_id=str(row.task_id), task_id=str(row.task_id),
task_dir=row.task_dir, name=row.name,
mode=row.mode, working_dir=row.working_dir,
skill=row.skill,
description=row.description, description=row.description,
status=row.status, status=row.status,
model=row.model, model=row.model,

View File

@ -0,0 +1,50 @@
"""task name + rename task_dir → working_dir + rename mode → skill.
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-17
三件事一把改:
- `name` (必填,任务显示名,与工作目录解耦 working_dir 可有多个 task)
- `task_dir` `working_dir`(同目录跨 task 共享,语义就是"工作目录",DESIGN §7.1)
- `mode` `skill`(跟项目 `skills/` 注册表对齐,语义"选用哪个智能体")
开发期 + 用户授权清表:TRUNCATE tasks CASCADE(messages / runs 跟着清)
新加 `name` NOT NULL,空表上加 NOT NULL 不需要 server_default + backfill 两步
downgrade 反向同样 TRUNCATE + 删列 + 改名;数据不可恢复
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 清掉旧数据 —— 用户授权(开发期,新 name 列 NOT NULL 不写 backfill)
op.execute("TRUNCATE TABLE tasks CASCADE")
# task_dir → working_dir + 索引同步
op.drop_index("ix_tasks_user_task_dir", table_name="tasks")
op.alter_column("tasks", "task_dir", new_column_name="working_dir")
op.create_index("ix_tasks_user_working_dir", "tasks", ["user_id", "working_dir"])
# mode → skill(对齐 skills/ 注册表语义)
op.alter_column("tasks", "mode", new_column_name="skill")
# name 列必填,空表上 NOT NULL 加列不需要 default + UPDATE 两步
op.add_column("tasks", sa.Column("name", sa.Text(), nullable=False))
def downgrade() -> None:
op.execute("TRUNCATE TABLE tasks CASCADE")
op.drop_column("tasks", "name")
op.alter_column("tasks", "skill", new_column_name="mode")
op.drop_index("ix_tasks_user_working_dir", table_name="tasks")
op.alter_column("tasks", "working_dir", new_column_name="task_dir")
op.create_index("ix_tasks_user_task_dir", "tasks", ["user_id", "task_dir"])

200
main.py
View File

@ -1,9 +1,18 @@
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。 """装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。
存储布局(§7 B Step 3 ): 存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` ,只差 user_id:
PG tasks / messages Task 元数据 + Session 消息
workspace/tasks/<task_id>/ task_dir,只承担 skill 产物 PG tasks / messages 元数据 + 消息
task_id UUID,state.json 已删除(元数据全在 PG) workspace/users/<user_id>/<working_dir>/ 工作目录(用户起名,可多 task 共享)
workspace/users/<user_id>/.memory/{core.md, extended/} per-user 记忆(dotfile 隔离)
本地 CLI user_id = SENTINEL(`00000000-...`),web/JWT user_id = sub
task_id / user_id UUID;state.json 已删除(元数据全在 PG)
**新建 task 必须给 `name`**(任务显示名,DB NOT NULL);**`working_dir` 可选**
(留空 name 作目录名; working_dir task 自动共享 §7.1)name working_dir
都过同一份 `validate_task_name` 校验(简单名,不含 `/\\..`不以 `.` 起头)
`_cleanup_if_empty` rmtree FS working_dir task 复用, task 只删 DB
""" """
from __future__ import annotations from __future__ import annotations
@ -23,7 +32,7 @@ from core.paths import ROOT, from_db_path, to_db_path
from core.session import Session from core.session import Session
from core.sinks import ConsoleEventSink from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
from core.storage import check_no_subtask, ensure_local_sentinel from core.storage import SENTINEL_USER_ID, check_no_subtask, ensure_local_sentinel
from core.task import TaskState from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
@ -42,49 +51,59 @@ def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> P
return p return p
def tasks_dir(workspace_dir: Path) -> Path: def user_root(workspace_dir: Path, user_id: UUID) -> Path:
d = workspace_dir / "tasks" """per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。"""
d = workspace_dir / "users" / str(user_id)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
def _default_task_dir(workspace_dir: Path, task_id: UUID) -> Path: class InvalidTaskName(ValueError):
return tasks_dir(workspace_dir) / str(task_id) """task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
def is_managed_task_dir(task_dir: Path, workspace_dir: Path) -> bool: def validate_task_name(name: str) -> str:
"""task_dir 是否在 workspace/tasks/<uuid>/ 默认派生模板下 """返回 stripped name;非法抛 InvalidTaskName
用作 _cleanup_if_empty 的保护开关 用户自指定的项目目录绝不 rmtree name working_dir 共用一份规则:非空 / 不含 `/\\` NUL / 不以 `.` 起头
( `.memory` 等系统区)/ 255 字符允许 CJK 与其他 Unicode 字符
""" """
try: n = (name or "").strip()
rel = task_dir.resolve().relative_to(tasks_dir(workspace_dir).resolve()) if not n:
except (ValueError, OSError): raise InvalidTaskName("name 不能为空")
return False if len(n) > 255:
parts = rel.parts raise InvalidTaskName(f"name 超长(>255 字符): {n[:40]!r}...")
if len(parts) != 1: if any(c in n for c in ("/", "\\", "\x00")):
return False raise InvalidTaskName(f"name 不能含 `/` `\\` 或 NUL: {n!r}")
try: if n.startswith("."):
UUID(parts[0]) raise InvalidTaskName(
except ValueError: f"name 不能以 `.` 起头(保留给 .memory 等系统区): {n!r}"
return False )
return True return n
def working_dir_from_name(workspace_dir: Path, user_id: UUID, dir_name: str) -> Path:
"""`<workspace>/users/<user_id>/<dir_name>` 绝对路径。
入参 dir_name `validate_task_name` 在入口校验过;本函数只拼路径, mkdir
(目录创建放在 task 创建入口 build_agent / web `/v1/tasks`,函数保持纯)
"""
return user_root(workspace_dir, user_id) / dir_name
def resolve_task_id( def resolve_task_id(
workspace_dir: Path, workspace_dir: Path,
task_id_arg: Optional[str], task_id_arg: Optional[str],
resume: bool, resume: bool,
task_dir_arg: Optional[str] = None, user_id: UUID,
working_dir_name: Optional[str] = None,
) -> Tuple[UUID, Path]: ) -> Tuple[UUID, Path]:
"""返回 (task_id, task_dir 绝对路径)。 """返回 (task_id, working_dir 绝对路径)。
新建: 新建:`working_dir_name` 必填(调用方应已 fallback name + 校验过),
- UUID + (task_dir_arg 显式 用户路径绝对化;否则默认派生 workspace/tasks/<uuid>/) 工作目录 = `<workspace>/users/<uid>/<working_dir_name>/`
Resume: Resume:`task_id` 从前缀/UUID/'last' 解析,working_dir PG `tasks.working_dir`
- task_id 从前缀/UUID/'last' 解析;task_dir PG tasks.task_dir 读还原;`working_dir_name` resume 时被忽略
- DB task_dir 为空表示"该 task 创建时未显式指定" 仍用默认派生(老数据 / Step 3 )
- task_dir_arg resume 时若传入 覆盖 DB (允许用户改绑路径,但调用方需自行 UPSERT)
""" """
if resume: if resume:
from sqlalchemy import select from sqlalchemy import select
@ -94,7 +113,7 @@ def resolve_task_id(
if task_id_arg in (None, "", "last"): if task_id_arg in (None, "", "last"):
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.task_id, Task.task_dir) select(Task.task_id, Task.working_dir)
.order_by(Task.updated_at.desc()).limit(1) .order_by(Task.updated_at.desc()).limit(1)
).first() ).first()
if row is None: if row is None:
@ -104,25 +123,22 @@ def resolve_task_id(
tid = _resolve_uuid_or_prefix(task_id_arg) tid = _resolve_uuid_or_prefix(task_id_arg)
with session_scope() as s: with session_scope() as s:
db_dir = s.execute( db_dir = s.execute(
select(Task.task_dir).where(Task.task_id == tid) select(Task.working_dir).where(Task.task_id == tid)
).scalar_one_or_none() or "" ).scalar_one_or_none() or ""
if task_dir_arg and task_dir_arg.strip(): if not db_dir:
# 用户显式覆盖(允许 resume 时改绑路径,调用方需自行 UPSERT 持久化) raise ValueError(
fs_dir = Path(task_dir_arg).expanduser().resolve() f"task {tid} has empty working_dir in DB — should not happen "
elif db_dir: "(new tasks require name + working_dir; legacy empty data was wiped)"
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对 )
fs_dir = from_db_path(db_dir) # DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
else: fs_dir = from_db_path(db_dir)
fs_dir = _default_task_dir(workspace_dir, tid)
return tid, fs_dir return tid, fs_dir
tid = uuid4() if not working_dir_name:
if task_dir_arg and task_dir_arg.strip(): raise InvalidTaskName("new task 必须指定 working_dir(或留空 fallback 用 name)")
fs_dir = Path(task_dir_arg).expanduser().resolve() safe = validate_task_name(working_dir_name)
else: return uuid4(), working_dir_from_name(workspace_dir, user_id, safe)
fs_dir = _default_task_dir(workspace_dir, tid)
return tid, fs_dir
def _resolve_uuid_or_prefix(s: str) -> UUID: def _resolve_uuid_or_prefix(s: str) -> UUID:
@ -151,25 +167,26 @@ def _build_system_prompt(
skills: SkillRegistry, skills: SkillRegistry,
workspace_dir: Path, workspace_dir: Path,
tool_base: Path, tool_base: Path,
task_dir: Path, working_dir: Path,
user_id: UUID,
) -> str: ) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。 """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段。
new task resume task 都走这里,memory 演化即时生效 new task resume task 都走这里,memory 演化即时生效memory user_id 隔离
""" """
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8") prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills: if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}" prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir) prompt += memory_block(workspace_dir, user_id)
task_dir_abs = task_dir.resolve() wd_abs = working_dir.resolve()
prompt += ( prompt += (
f"\n\n## 工作目录\n" f"\n\n## 工作目录\n"
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n" f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{task_dir_abs}`\n\n" f"- **task_dir(所有产物写到这里)**: `{wd_abs}`\n\n"
f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。" f"SKILL 文档里出现的 `<task_dir>` 占位符,一律指上面这个绝对路径。"
f"产物示例: `{task_dir_abs}/spec_lock.md`、" f"产物示例: `{wd_abs}/spec_lock.md`、"
f"`{task_dir_abs}/sections/01_summary.md`、" f"`{wd_abs}/sections/01_summary.md`、"
f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n" f"`{wd_abs}/slides/`、最终 .docx/.pptx。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
) )
return prompt return prompt
@ -182,13 +199,25 @@ def build_agent(
session_id: Optional[str] = None, session_id: Optional[str] = None,
resume: bool = False, resume: bool = False,
tool_base: Optional[Path] = None, tool_base: Optional[Path] = None,
mode: str = "", skill: str = "",
description: str = "", description: str = "",
task_dir_arg: Optional[str] = None, name: Optional[str] = None,
working_dir: Optional[str] = None,
user_id: Optional[UUID] = None,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]: ) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, task_dir)。""" """返回 (agent, session, task_id_str, task_state, working_dir_path)。
新建 task:
- `name` 必填(任务显示名,DB NOT NULL, validate_task_name)
- `working_dir` 可选(留空 fallback name 作目录名;非空也走 validate_task_name)
Resume:name / working_dir 都忽略( DB )
`user_id` 决定 working_dir memory 子树no-subtask 校验作用域
None SENTINEL(本地 CLI)web 入口必须显式传入 JWT user_id
"""
cfg = load_config() cfg = load_config()
model = model_name or cfg["default_model"] model = model_name or cfg["default_model"]
uid = user_id or SENTINEL_USER_ID
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口 # 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口
ensure_local_sentinel() ensure_local_sentinel()
@ -197,32 +226,52 @@ def build_agent(
llm = LLM(caps) llm = LLM(caps)
workspace_dir = resolve_workspace(workspace, cfg) workspace_dir = resolve_workspace(workspace, cfg)
task_id, task_dir = resolve_task_id(workspace_dir, session_id, resume, task_dir_arg)
# 新建时校验 name + 解析 working_dir(留空 fallback 用 name);resume 跳过
task_name_safe = ""
wd_name_for_resolve: Optional[str] = None
if not resume:
if not name:
raise InvalidTaskName("new task 必须指定 name(任务显示名)")
task_name_safe = validate_task_name(name)
wd_raw = (working_dir or "").strip()
wd_name = wd_raw if wd_raw else task_name_safe
wd_name_for_resolve = validate_task_name(wd_name)
task_id, working_dir_path = resolve_task_id(
workspace_dir, session_id, resume, uid, wd_name_for_resolve
)
sid = str(task_id) sid = str(task_id)
# §7.4 no-subtask:新建 task 时校验 task_dir 不与同 user 已有 task 形成前缀嵌套 # §7.4 no-subtask:新建 task 时校验 working_dir 不与同 user 已有 task 形成前缀嵌套
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade) # (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
if not resume: if not resume:
check_no_subtask(str(task_dir)) check_no_subtask(str(working_dir_path), user_id=uid)
# 新建 task 立刻建工作目录 —— 用户已声明项目,目录就该存在
# (同 working_dir 多 task 共享,exist_ok=True 不冲突)
working_dir_path.mkdir(parents=True, exist_ok=True)
tool_base = Path(tool_base) if tool_base else Path.cwd() tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
system_prompt = _build_system_prompt(cfg, skills, workspace_dir, tool_base, task_dir) system_prompt = _build_system_prompt(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid
)
now_iso = datetime.now().isoformat(timespec="seconds") now_iso = datetime.now().isoformat(timespec="seconds")
# meta["task_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row # meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
# 把它直接落 PG tasks.task_dir,所以这里就转好。文件系统操作仍用上面的 task_dir(absolute)。 # 把它直接落 PG tasks.working_dir,所以这里就转好。文件系统操作仍用 working_dir_path(absolute)。
task_dir_db = to_db_path(task_dir) wd_db = to_db_path(working_dir_path)
meta = { meta = {
"id": sid, "id": sid,
"created_at": now_iso, "created_at": now_iso,
"cwd": str(tool_base), "cwd": str(tool_base),
"task_dir": task_dir_db, "name": task_name_safe, # resume 时空字符串(Session.load 会从 DB 拿不到 -- 不要紧,ensure 走 ON CONFLICT DO NOTHING)
"working_dir": wd_db,
"model": caps.model_id, "model": caps.model_id,
"model_profile": model, "model_profile": model,
"mode": mode, "skill": skill,
"description": description, "description": description,
"reasoning_effort": caps.default_reasoning_effort or "", "reasoning_effort": caps.default_reasoning_effort or "",
} }
@ -234,17 +283,20 @@ def build_agent(
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里 # tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令) # 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
task_state = TaskState( task_state = TaskState(
task_id=sid, task_dir=task_dir_db, task_id=sid, name="", working_dir=wd_db,
mode=mode, description=description, status="active", skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
) )
# resume 时 meta name 用 DB 里读出来的真值(给 Session.append → ensure 用,避免落空串)
meta["name"] = task_state.name
else: else:
session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta) session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta)
# 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由 # 懒创建:TaskState 仅内存。tasks 行在首条 user 消息 append 时由
# ensure_local_task_row 占位 INSERT;首次 sync_task_tokens 或 /done /desc 走 upsert 覆盖。 # ensure_local_task_row 占位 INSERT(name 已就位);首次 sync_task_tokens
# 或 /done /desc 走 upsert 覆盖完整字段。
task_state = TaskState( task_state = TaskState(
task_id=sid, task_dir=task_dir_db, task_id=sid, name=task_name_safe, working_dir=wd_db,
mode=mode, description=description, status="active", skill=skill, description=description, status="active",
model=caps.model_id, model_profile=model, model=caps.model_id, model_profile=model,
reasoning_effort=caps.default_reasoning_effort or "", reasoning_effort=caps.default_reasoning_effort or "",
) )
@ -264,7 +316,7 @@ def build_agent(
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, sink=sink) agent = AgentLoop(llm, tools, session, caps, sink=sink)
return agent, session, sid, task_state, task_dir return agent, session, sid, task_state, working_dir_path
def sync_task_tokens(task_state: TaskState, llm: LLM) -> None: def sync_task_tokens(task_state: TaskState, llm: LLM) -> None:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -62,10 +62,11 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
"""Task ORM row → API JSON dict。""" """Task ORM row → API JSON dict。"""
d = { d = {
"task_id": str(row.task_id), "task_id": str(row.task_id),
"name": row.name or "",
"description": row.description or "", "description": row.description or "",
"task_dir": _norm_path(row.task_dir or ""), "working_dir": _norm_path(row.working_dir or ""),
"status": row.status, "status": row.status,
"mode": row.mode or "", "skill": row.skill or "",
"model": row.model or "", "model": row.model or "",
"model_profile": row.model_profile or "", "model_profile": row.model_profile or "",
"tokens_prompt": row.tokens_prompt or 0, "tokens_prompt": row.tokens_prompt or 0,
@ -81,9 +82,9 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
# ─────────────────────── files helpers ─────────────────────── # ─────────────────────── files helpers ───────────────────────
def _load_task_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]: def _load_working_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]:
"""task_id 解析 + 查 PG 拿 task_dir db form + 还原 absolute Path。 """task_id 解析 + 查 PG 拿 working_dir db form + 还原 absolute Path。
404 / 400 if UUID / task 不存在 / 不属于 user / task_dir 404 / 400 if UUID / task 不存在 / 不属于 user / working_dir
user 视为 not found(不暴露 task 存在性) user 视为 not found(不暴露 task 存在性)
""" """
try: try:
@ -92,16 +93,16 @@ def _load_task_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]:
raise HTTPException(404, f"invalid task id: {task_id!r}") raise HTTPException(404, f"invalid task id: {task_id!r}")
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.task_dir).where( select(Task.working_dir).where(
Task.task_id == tid, Task.user_id == user_id Task.task_id == tid, Task.user_id == user_id
) )
).first() ).first()
if row is None: if row is None:
raise HTTPException(404, f"task not found: {tid}") raise HTTPException(404, f"task not found: {tid}")
td = row[0] or "" wd = row[0] or ""
if not td: if not wd:
raise HTTPException(400, f"task {tid} has no task_dir, files browsing unavailable") raise HTTPException(400, f"task {tid} has no working_dir, files browsing unavailable")
return tid, from_db_path(td) return tid, from_db_path(wd)
def _safe_join(root: Path, rel: str) -> Path: def _safe_join(root: Path, rel: str) -> Path:
@ -117,7 +118,7 @@ def _safe_join(root: Path, rel: str) -> Path:
try: try:
target.relative_to(root.resolve()) target.relative_to(root.resolve())
except ValueError: except ValueError:
raise HTTPException(400, f"path escapes task_dir: {rel!r}") raise HTTPException(400, f"path escapes working_dir: {rel!r}")
return target return target
@ -151,7 +152,9 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
}) })
cur_rel = _rel_to(root, current) cur_rel = _rel_to(root, current)
crumbs = [{"label": "/", "rel": ""}] crumbs = [{"label": "/", "rel": ""}]
if cur_rel: # cur_rel == "." 表示当前就在 root(target.relative_to(root) 返 Path(".")),
# 不该再追加一个无意义的 "." crumb
if cur_rel and cur_rel != ".":
acc = "" acc = ""
for part in cur_rel.split("/"): for part in cur_rel.split("/"):
acc = f"{acc}/{part}" if acc else part acc = f"{acc}/{part}" if acc else part
@ -161,16 +164,17 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
# ─────────────────── Run 启动 + SSE 帧格式 ─────────────────── # ─────────────────── Run 启动 + SSE 帧格式 ───────────────────
def _run_agent_bg(task_id: UUID, run_id: UUID, user_message: str) -> None: def _run_agent_bg(task_id: UUID, run_id: UUID, user_id: UUID, user_message: str) -> None:
"""工作线程:`build_agent(resume=True)` → 装 WebEventSink → `agent.run` → 写 runs 状态。 """工作线程:`build_agent(resume=True)` → 装 WebEventSink → `agent.run` → 写 runs 状态。
sink 通过 broker.emit 桥事件回 asyncio loop;agent.run sync,所以在 to_thread sink 通过 broker.emit 桥事件回 asyncio loop;agent.run sync,所以在 to_thread
user_id 必须从 JWT 那侧透传过来 决定 memory_block 读哪个 per-user 子树
""" """
from main import build_agent, sync_task_tokens from main import build_agent, sync_task_tokens
try: try:
broker.emit(run_id, {"type": "run_start"}) broker.emit(run_id, {"type": "run_start"})
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
session_id=str(task_id), resume=True, session_id=str(task_id), resume=True, user_id=user_id,
) )
agent.sink = WebEventSink(broker, run_id) agent.sink = WebEventSink(broker, run_id)
agent.run(user_message) agent.run(user_message)
@ -209,15 +213,17 @@ def _sse_event(event_type: str, payload: dict) -> bytes:
# ────────────────────── Pydantic 请求体 ────────────────────── # ────────────────────── Pydantic 请求体 ──────────────────────
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
name: str # 任务显示名(必填,DB 列 NOT NULL)
working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名)
description: str = "" description: str = ""
mode: str = "" skill: str = ""
task_dir: str = ""
class TaskPatchRequest(BaseModel): class TaskPatchRequest(BaseModel):
status: Optional[str] = None status: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
mode: Optional[str] = None name: Optional[str] = None
skill: Optional[str] = None
class MessageRequest(BaseModel): class MessageRequest(BaseModel):
@ -308,23 +314,31 @@ def create_app() -> FastAPI:
@app.post("/v1/tasks", status_code=201, tags=["tasks"]) @app.post("/v1/tasks", status_code=201, tags=["tasks"])
def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)): def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)):
"""新建 task。`task_dir` 留空 → 默认派生 `workspace/tasks/<uuid>/`。 """新建 task。
`description` `task_dir` 至少给一个否则 400
前缀嵌套(no-subtask, user ) 409 - `name` 必填(任务显示名,DB NOT NULL,UI 列表 / 标题用)
- `working_dir` 可选(留空 name 作目录名); working_dir task 共享同目录(§7.1)
- name / working_dir 都过 validate_task_name(简单名, `/\\..`, `.` 起头,255)
- 前缀嵌套(no-subtask, user ) 409
""" """
from main import InvalidTaskName, resolve_workspace, validate_task_name, working_dir_from_name
try:
name = validate_task_name(body.name)
except InvalidTaskName as e:
raise HTTPException(400, f"name 不合法: {e}")
# working_dir 留空 → fallback 用 name
wd_raw = (body.working_dir or "").strip()
wd_name = wd_raw if wd_raw else name
try:
wd_name = validate_task_name(wd_name)
except InvalidTaskName as e:
raise HTTPException(400, f"working_dir 不合法: {e}")
description = body.description.strip() description = body.description.strip()
mode = body.mode.strip() skill = body.skill.strip()
task_dir_raw = body.task_dir.strip()
if not description and not task_dir_raw:
raise HTTPException(400, "either description or task_dir must be provided")
tid = uuid4() tid = uuid4()
from main import _default_task_dir, resolve_workspace
ws = resolve_workspace(None) ws = resolve_workspace(None)
if task_dir_raw: fs_dir = working_dir_from_name(ws, user_id, wd_name)
fs_dir = Path(task_dir_raw).expanduser().resolve()
else:
fs_dir = _default_task_dir(ws, tid)
fs_dir_db = to_db_path(fs_dir) fs_dir_db = to_db_path(fs_dir)
try: try:
@ -332,8 +346,11 @@ def create_app() -> FastAPI:
except NoSubtaskError as e: except NoSubtaskError as e:
raise HTTPException(409, str(e)) raise HTTPException(409, str(e))
# 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True)
fs_dir.mkdir(parents=True, exist_ok=True)
ensure_local_task_row( ensure_local_task_row(
task_id=tid, task_dir=fs_dir_db, mode=mode, task_id=tid, name=name, working_dir=fs_dir_db, skill=skill,
description=description, user_id=user_id, description=description, user_id=user_id,
) )
with session_scope() as s: with session_scope() as s:
@ -388,6 +405,62 @@ def create_app() -> FastAPI:
).scalar_one() ).scalar_one()
return _task_dict(row, n_messages=n) return _task_dict(row, n_messages=n)
@app.get("/v1/folders", tags=["folders"])
def list_folders(user_id: UUID = Depends(require_user)):
"""列出当前 user 的工作目录(`workspace/users/<uid>/` 下非 dotfile 子目录)。
供新建 task 时自动补全 / 选已有目录用FS source of truth(也含手动创建
但还无关联 task 的目录)每项带 n_tasks(关联 task )+ last_used(最近使用 ISO)
排序: last_used 的按降序, last_used 的排最后,同列 by name asc
"""
from main import resolve_workspace, user_root
ws = resolve_workspace(None)
root = user_root(ws, user_id)
folder_names: list[str] = []
if root.is_dir():
for p in sorted(root.iterdir(), key=lambda x: x.name.lower()):
if p.is_dir() and not p.name.startswith("."):
folder_names.append(p.name)
folders: list[dict] = []
if folder_names:
with session_scope() as s:
for name in folder_names:
db_form = f"workspace/users/{user_id}/{name}"
stat = s.execute(
select(func.count(), func.max(Task.updated_at))
.where(Task.user_id == user_id, Task.working_dir == db_form)
).first()
n = int((stat[0] if stat else 0) or 0)
lu = stat[1] if stat else None
folders.append({
"name": name,
"n_tasks": n,
"last_used": _iso(lu),
})
folders.sort(key=lambda f: f["name"])
folders.sort(key=lambda f: f["last_used"] or "", reverse=True)
return {"folders": folders}
@app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""硬删除:DELETE DB 行(messages / runs CASCADE)。**FS task_dir 不动**
( name task 共享,文件由用户经 /files/delete 单独清) user 404
"""
try:
tid = UUID(task_id)
except ValueError:
raise HTTPException(404, f"invalid task id: {task_id!r}")
from sqlalchemy import delete as _delete
with session_scope() as s:
result = s.execute(
_delete(Task).where(Task.task_id == tid, Task.user_id == user_id)
)
if result.rowcount == 0:
raise HTTPException(404, f"task not found: {tid}")
return None # 204
@app.patch("/v1/tasks/{task_id}", tags=["tasks"]) @app.patch("/v1/tasks/{task_id}", tags=["tasks"])
def patch_task( def patch_task(
task_id: str, task_id: str,
@ -408,8 +481,14 @@ def create_app() -> FastAPI:
updates["status"] = body.status updates["status"] = body.status
if body.description is not None: if body.description is not None:
updates["description"] = body.description updates["description"] = body.description
if body.mode is not None: if body.skill is not None:
updates["mode"] = body.mode updates["skill"] = body.skill
if body.name is not None:
from main import InvalidTaskName, validate_task_name
try:
updates["name"] = validate_task_name(body.name)
except InvalidTaskName as e:
raise HTTPException(400, f"name 不合法: {e}")
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:
@ -484,7 +563,7 @@ def create_app() -> FastAPI:
with session_scope() as s: with session_scope() as s:
s.add(Run(run_id=run_id, task_id=tid, status="running", started_at=func.now())) s.add(Run(run_id=run_id, task_id=tid, status="running", started_at=func.now()))
# to_thread 跑 sync agent.run;sink 通过 broker 把 event 桥回 asyncio # to_thread 跑 sync agent.run;sink 通过 broker 把 event 桥回 asyncio
asyncio.create_task(asyncio.to_thread(_run_agent_bg, tid, run_id, content)) asyncio.create_task(asyncio.to_thread(_run_agent_bg, tid, run_id, user_id, content))
return { return {
"run_id": str(run_id), "run_id": str(run_id),
"events_url": f"/v1/tasks/{tid}/runs/{run_id}/events", "events_url": f"/v1/tasks/{tid}/runs/{run_id}/events",
@ -549,7 +628,7 @@ def create_app() -> FastAPI:
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""列子目录条目 + 面包屑。`path` 留空 → root;`../` / 绝对 → 400。""" """列子目录条目 + 面包屑。`path` 留空 → root;`../` / 绝对 → 400。"""
tid, root = _load_task_dir(task_id, user_id) tid, root = _load_working_dir(task_id, user_id)
current = _safe_join(root, path) current = _safe_join(root, path)
entries, crumbs, exists = _enumerate_files(root, current) entries, crumbs, exists = _enumerate_files(root, current)
return { return {
@ -568,7 +647,7 @@ def create_app() -> FastAPI:
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""下载单个 regular file(目录 → 400 / 不存在 → 404)。""" """下载单个 regular file(目录 → 400 / 不存在 → 404)。"""
tid, root = _load_task_dir(task_id, user_id) tid, root = _load_working_dir(task_id, user_id)
target = _safe_join(root, path) target = _safe_join(root, path)
if not target.exists(): if not target.exists():
raise HTTPException(404, f"file not found: {path}") raise HTTPException(404, f"file not found: {path}")
@ -587,7 +666,7 @@ def create_app() -> FastAPI:
路径不存在自动 mkdir(parents=True);重名直接覆盖 路径不存在自动 mkdir(parents=True);重名直接覆盖
文件名严格校验( `/ \\ ..` 或为空 400) 文件名严格校验( `/ \\ ..` 或为空 400)
""" """
tid, root = _load_task_dir(task_id, user_id) tid, root = _load_working_dir(task_id, user_id)
dest_dir = _safe_join(root, path) dest_dir = _safe_join(root, path)
if dest_dir.exists() and not dest_dir.is_dir(): if dest_dir.exists() and not dest_dir.is_dir():
raise HTTPException(400, f"upload target is a file, not a directory: {path}") raise HTTPException(400, f"upload target is a file, not a directory: {path}")
@ -622,7 +701,7 @@ def create_app() -> FastAPI:
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""删 task_dir 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。""" """删 task_dir 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。"""
tid, root = _load_task_dir(task_id, user_id) tid, root = _load_working_dir(task_id, user_id)
target = _safe_join(root, body.path) target = _safe_join(root, body.path)
if target.resolve() == root.resolve(): if target.resolve() == root.resolve():
raise HTTPException(400, "cannot delete task_dir root") raise HTTPException(400, "cannot delete task_dir root")

View File

@ -4,6 +4,13 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>zcbot dev</title> <title>zcbot dev</title>
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" />
<style> <style>
:root { :root {
--bg: #f7f7f7; --bg: #f7f7f7;
@ -23,6 +30,7 @@
body { body {
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg); color: var(--text); background: var(--bg);
overflow: hidden; /* 视窗锁死,所有滚动在 pane 内 */
} }
button, input, textarea, select { button, input, textarea, select {
font: inherit; color: inherit; font: inherit; color: inherit;
@ -69,10 +77,11 @@
header .who { color: var(--muted); font-size: 12px; font-family: monospace; } header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
header .spacer { flex: 1; } header .spacer { flex: 1; }
.pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; } .pane { border-right: 1px solid var(--border); background: var(--panel); overflow: auto; min-height: 0; }
#pane-left { grid-area: left; } #pane-left { grid-area: left; }
#pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); } /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); } #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; overflow: hidden; }
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; }
.pane-head { .pane-head {
padding: 8px 12px; border-bottom: 1px solid var(--border); padding: 8px 12px; border-bottom: 1px solid var(--border);
@ -106,7 +115,9 @@
#chat-meta .tid { font-family: monospace; color: var(--text); } #chat-meta .tid { font-family: monospace; color: var(--text); }
#chat-meta .spacer { flex: 1; } #chat-meta .spacer { flex: 1; }
#chat-stream { #chat-stream {
flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px;
display: flex; flex-direction: column; gap: 8px;
min-height: 0; /* 同上,允许在 flex 容器里收缩 + 触发自身滚动 */
} }
.msg { .msg {
border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px;
@ -116,9 +127,50 @@
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; } .msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } .msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; } .msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; }
.msg .body { white-space: pre-wrap; word-wrap: break-word; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; } .msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; } .msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } } @keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
/* markdown 输出元素紧凑化 */
.msg .body > :first-child { margin-top: 0; }
.msg .body > :last-child { margin-bottom: 0; }
.msg .body p { margin: 0.4em 0; }
.msg .body h1, .msg .body h2, .msg .body h3, .msg .body h4 {
margin: 0.8em 0 0.3em; line-height: 1.3;
}
.msg .body h1 { font-size: 1.4em; }
.msg .body h2 { font-size: 1.25em; }
.msg .body h3 { font-size: 1.1em; }
.msg .body h4 { font-size: 1em; font-weight: 600; }
.msg .body ul, .msg .body ol { margin: 0.4em 0; padding-left: 1.6em; }
.msg .body li { margin: 0.15em 0; }
.msg .body li > p { margin: 0.15em 0; }
.msg .body blockquote {
margin: 0.4em 0; padding: 4px 12px; border-left: 3px solid var(--accent);
background: var(--accent-soft); color: #555;
}
.msg .body code:not(pre code) {
background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.92em;
}
.msg .body pre {
margin: 0.5em 0; padding: 10px; background: #f6f8fa; border-radius: 4px;
overflow-x: auto; font-size: 12.5px; line-height: 1.4;
}
.msg .body pre code {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
background: transparent; padding: 0;
}
.msg .body table {
border-collapse: collapse; margin: 0.5em 0; font-size: 13px;
}
.msg .body th, .msg .body td {
border: 1px solid var(--border); padding: 4px 8px;
}
.msg .body th { background: #fafafa; font-weight: 600; }
.msg .body a { color: var(--accent); }
.msg .body img { max-width: 100%; }
.msg .body hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.tool-call { .tool-call {
margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
} }
@ -135,6 +187,7 @@
#chat-form { #chat-form {
border-top: 1px solid var(--border); padding: 10px; background: #fafafa; border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
display: flex; flex-direction: column; gap: 6px; display: flex; flex-direction: column; gap: 6px;
flex-shrink: 0; /* 输入区固定在底,不被消息挤压 */
} }
#chat-form .row { display: flex; gap: 8px; } #chat-form .row { display: flex; gap: 8px; }
#chat-form textarea { flex: 1; } #chat-form textarea { flex: 1; }
@ -226,6 +279,7 @@
<button id="btn-export" class="small" disabled>export .docx</button> <button id="btn-export" class="small" disabled>export .docx</button>
<button id="btn-done" class="small" disabled>done</button> <button id="btn-done" class="small" disabled>done</button>
<button id="btn-abandon" class="small danger" disabled>abandon</button> <button id="btn-abandon" class="small danger" disabled>abandon</button>
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">delete</button>
</div> </div>
<div id="chat-meta"><span class="muted">(no task selected)</span></div> <div id="chat-meta"><span class="muted">(no task selected)</span></div>
<div id="chat-stream"><div class="empty">select a task on the left</div></div> <div id="chat-stream"><div class="empty">select a task on the left</div></div>
@ -243,6 +297,7 @@
<div id="pane-right"> <div id="pane-right">
<div class="pane-head"> <div class="pane-head">
<span class="label">files</span> <span class="label">files</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="btn-refresh-files" class="small" disabled></button> <button id="btn-refresh-files" class="small" disabled></button>
</div> </div>
@ -255,12 +310,16 @@
<div id="new-task-modal"> <div id="new-task-modal">
<div class="card"> <div class="card">
<h3>新建 task</h3> <h3>新建 task</h3>
<label for="nt-desc">description</label> <label for="nt-name">任务名 (必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录 (可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空 fallback 用任务名" />
<datalist id="folders-datalist"></datalist>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述 (可选,task 长描述)</label>
<input id="nt-desc" /> <input id="nt-desc" />
<label for="nt-mode">mode (可选,如 coding / writing)</label> <label for="nt-skill">skill (可选,智能体类型,如 coding / ppt / proposal)</label>
<input id="nt-mode" /> <input id="nt-skill" />
<label for="nt-dir">task_dir (可选,绑项目目录;留空 → 默认派生)</label>
<input id="nt-dir" placeholder="例如 D:/projects/foo 或 留空" />
<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>
@ -329,6 +388,30 @@ function escapeHtml(s) {
)); ));
} }
// ───── markdown 渲染 ─────
// 三个 CDN 库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(plain text wrap)
if (window.marked && window.marked.setOptions) {
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
}
function renderMd(text) {
const raw = String(text || "");
if (!window.marked || !window.marked.parse) {
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
}
let html = window.marked.parse(raw);
if (window.DOMPurify) {
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
}
return html;
}
function highlightIn(container) {
if (!window.hljs || !container) return;
container.querySelectorAll("pre code").forEach((b) => {
if (b.dataset.hl === "1") return;
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
});
}
// ───── login ───── // ───── login ─────
$("li-uid").value = state.userId || SENTINEL; $("li-uid").value = state.userId || SENTINEL;
@ -400,18 +483,21 @@ function renderTaskList(tasks) {
} }
const html = tasks.map((t) => { const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : ""; const active = state.taskId === t.task_id ? " active" : "";
const desc = t.description || "(no desc)"; // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const dir = t.task_dir ? (" · " + t.task_dir.split("/").slice(-2).join("/")) : ""; const taskName = t.name || "(unnamed)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || "";
return ` return `
<div class="task-row${active}" data-tid="${t.task_id}"> <div class="task-row${active}" data-tid="${t.task_id}">
<div class="desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</div> <div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
<div class="meta"> <div class="meta">
<span class="badge ${t.status}">${t.status}</span> <span class="badge ${t.status}">${t.status}</span>
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} msg</span> <span>${t.n_messages || 0} msg</span>
<span>${t.tokens || 0} tok</span> <span>${t.tokens || 0} tok</span>
</div> <span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
<div class="meta muted" title="${escapeHtml(t.task_dir || "")}">
${t.task_id.slice(0, 8)}${escapeHtml(dir)}
</div> </div>
</div> </div>
`; `;
@ -448,10 +534,15 @@ async function selectTask(tid) {
function renderChatMeta() { function renderChatMeta() {
const t = state.taskMeta; const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; } if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(unnamed)";
$("chat-meta").innerHTML = ` $("chat-meta").innerHTML = `
<span class="tid">${t.task_id.slice(0, 8)}</span> <span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${t.status}</span> <span class="badge ${t.status}">${t.status}</span>
<span class="muted">${escapeHtml(t.description || "(no desc)")}</span> ${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span> <span class="spacer"></span>
<span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span> <span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span>
`; `;
@ -459,6 +550,7 @@ function renderChatMeta() {
$("chat-form").style.display = active ? "flex" : "none"; $("chat-form").style.display = active ? "flex" : "none";
$("btn-done").disabled = !active; $("btn-done").disabled = !active;
$("btn-abandon").disabled = !active; $("btn-abandon").disabled = !active;
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
$("btn-export").disabled = (t.n_messages || 0) === 0; $("btn-export").disabled = (t.n_messages || 0) === 0;
$("btn-refresh-files").disabled = false; $("btn-refresh-files").disabled = false;
} }
@ -495,7 +587,7 @@ function renderMessages(msgs) {
card.className = "msg " + role; card.className = "msg " + role;
let html = `<div class="role">${role}</div>`; let html = `<div class="role">${role}</div>`;
if (typeof p.content === "string" && p.content) { if (typeof p.content === "string" && p.content) {
html += `<div class="body">${escapeHtml(p.content)}</div>`; html += `<div class="body">${renderMd(p.content)}</div>`;
} }
if (Array.isArray(p.tool_calls) && p.tool_calls.length) { if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
for (const tc of p.tool_calls) { for (const tc of p.tool_calls) {
@ -510,6 +602,7 @@ function renderMessages(msgs) {
} }
} }
card.innerHTML = html; card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card); wrap.appendChild(card);
} }
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
@ -569,7 +662,8 @@ async function fetchSse(url, asstCard) {
const reader = r.body.getReader(); const reader = r.body.getReader();
const dec = new TextDecoder(); const dec = new TextDecoder();
let buf = ""; let buf = "";
let acc = ""; // 流式 markdown:累积 raw 文本 → rAF 节流重渲染整段 body
const ctx = { acc: "", body, pending: false };
$("chat-hint").textContent = "streaming…"; $("chat-hint").textContent = "streaming…";
while (true) { while (true) {
@ -583,15 +677,17 @@ async function fetchSse(url, asstCard) {
buf = buf.slice(idx + 2); buf = buf.slice(idx + 2);
const ev = parseSseFrame(frame); const ev = parseSseFrame(frame);
if (!ev) continue; if (!ev) continue;
handleSseEvent(ev, body, asstCard); handleSseEvent(ev, asstCard, ctx);
if (ev.event === "text" && ev.data && ev.data.delta) acc += ev.data.delta;
if (ev.event === "done" || ev.event === "error") break; if (ev.event === "done" || ev.event === "error") break;
} }
} }
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc);
body.classList.remove("streaming"); body.classList.remove("streaming");
highlightIn(asstCard);
$("chat-send").disabled = false; $("chat-send").disabled = false;
$("chat-hint").textContent = "ready"; $("chat-hint").textContent = "ready";
// 最后刷新 task meta + messages(拿真实持久化的) // 刷新 task meta + messages(拿真实持久化的)
loadTaskList(); loadTaskList();
await loadMessages(); await loadMessages();
} }
@ -611,11 +707,22 @@ function parseSseFrame(frame) {
return { event, data }; return { event, data };
} }
function handleSseEvent(ev, body, asstCard) { function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event; const t = ev.event;
const stream = $("chat-stream");
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
if (t === "text" && ev.data && ev.data.delta) { if (t === "text" && ev.data && ev.data.delta) {
body.textContent += ev.data.delta; ctx.acc += ev.data.delta;
$("chat-stream").scrollTop = $("chat-stream").scrollHeight; // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
if (!ctx.pending) {
ctx.pending = true;
requestAnimationFrame(() => {
ctx.body.innerHTML = renderMd(ctx.acc);
ctx.pending = false;
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "tool_call") { } else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?"; const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.arguments) || ""; const args = (ev.data && ev.data.arguments) || "";
@ -633,6 +740,7 @@ function handleSseEvent(ev, body, asstCard) {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data); const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg); appendErrorCard(msg);
} }
if (nearBottom) stream.scrollTop = stream.scrollHeight;
} }
function appendErrorCard(msg) { function appendErrorCard(msg) {
@ -643,9 +751,10 @@ function appendErrorCard(msg) {
$("chat-stream").scrollTop = $("chat-stream").scrollHeight; $("chat-stream").scrollTop = $("chat-stream").scrollHeight;
} }
// ───── done / abandon / export ───── // ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => patchStatus("completed"); $("btn-done").onclick = () => patchStatus("completed");
$("btn-abandon").onclick = () => patchStatus("abandoned"); $("btn-abandon").onclick = () => patchStatus("abandoned");
$("btn-delete-task").onclick = deleteCurrentTask;
async function patchStatus(status) { async function patchStatus(status) {
if (!state.taskId) return; if (!state.taskId) return;
@ -660,6 +769,37 @@ async function patchStatus(status) {
} }
} }
async function deleteCurrentTask() {
if (!state.taskId) return;
const t = state.taskMeta;
const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8);
const nMsg = (t && t.n_messages) || 0;
if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try {
await api("DELETE", "/v1/tasks/" + state.taskId);
// 清空 chat / files 面板,回到初始态
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null;
state.taskMeta = null;
state.filesPath = "";
$("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`;
$("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`;
$("chat-form").style.display = "none";
$("file-crumbs").innerHTML = `<span class="muted">(no task selected)</span>`;
$("file-list").innerHTML = "";
$("files-proj").textContent = "";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true;
$("btn-export").disabled = true;
$("btn-refresh-files").disabled = true;
loadTaskList();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message);
}
}
$("btn-export").onclick = () => { $("btn-export").onclick = () => {
if (!state.taskId) return; if (!state.taskId) return;
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载 // 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
@ -688,7 +828,7 @@ async function loadFiles() {
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
if (e.status === 400) { if (e.status === 400) {
$("file-crumbs").innerHTML = `<span class="muted">(no task_dir bound)</span>`; $("file-crumbs").innerHTML = `<span class="muted">(no working_dir bound)</span>`;
$("file-list").innerHTML = ""; $("file-list").innerHTML = "";
} else { } else {
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`; $("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
@ -698,10 +838,17 @@ async function loadFiles() {
} }
function renderFiles(data) { function renderFiles(data) {
// pane-head 显示项目名(working_dir 末段),让用户清楚"现在看的是哪个项目里的文件"
const projName = (state.taskMeta && state.taskMeta.working_dir)
? state.taskMeta.working_dir.split("/").filter(Boolean).pop() : "";
$("files-proj").textContent = projName ? "· " + projName : "";
$("files-proj").title = (state.taskMeta && state.taskMeta.working_dir) || "";
// crumbs root 用项目名替代 "/",更直观
const cr = data.crumbs.map((c, i) => { const cr = data.crumbs.map((c, i) => {
const label = (i === 0 && projName) ? projName : c.label;
const isLast = i === data.crumbs.length - 1; const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(c.label)}</span>`; if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(c.label)}</a> /`; return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" "); }).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`; $("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => { $("file-crumbs").querySelectorAll("a").forEach((a) => {
@ -723,6 +870,7 @@ function renderFiles(data) {
${escapeHtml(e.name)} ${escapeHtml(e.name)}
</span> </span>
<span class="size">${humanSize(e.size)}</span> <span class="size">${humanSize(e.size)}</span>
<button class="small danger del-file" data-rel="${escapeHtml(e.rel)}" data-name="${escapeHtml(e.name)}" data-isdir="${e.is_dir}" title="删(非空目录会失败)">×</button>
</div> </div>
`; `;
}).join(""); }).join("");
@ -734,6 +882,21 @@ function renderFiles(data) {
else { downloadFile(rel); } else { downloadFile(rel); }
}; };
}); });
$("file-list").querySelectorAll(".del-file").forEach((btn) => {
btn.onclick = (ev) => { ev.stopPropagation(); deleteFile(btn.dataset.rel, btn.dataset.name, btn.dataset.isdir === "true"); };
});
}
async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return;
try {
await api("POST", `/v1/tasks/${state.taskId}/files/delete`, { path: rel });
await loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message);
}
} }
function downloadFile(rel) { function downloadFile(rel) {
@ -751,21 +914,25 @@ function downloadFile(rel) {
} }
// ───── new task ───── // ───── new task ─────
$("hd-new").onclick = () => { $("hd-new").onclick = async () => {
$("nt-desc").value = ""; $("nt-mode").value = ""; $("nt-dir").value = ""; $("nt-name").value = ""; $("nt-wd").value = "";
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = ""; $("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
$("new-task-modal").classList.add("show"); $("new-task-modal").classList.add("show");
$("nt-desc").focus(); await loadFolderSuggestions(); // 拉已有目录填 datalist
$("nt-name").focus();
}; };
$("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 working_dir = $("nt-wd").value.trim();
const desc = $("nt-desc").value.trim(); const desc = $("nt-desc").value.trim();
const mode = $("nt-mode").value.trim(); const skill = $("nt-skill").value.trim();
const dir = $("nt-dir").value.trim();
$("nt-err").textContent = ""; $("nt-err").textContent = "";
if (!desc && !dir) { $("nt-err").textContent = "description 与 task_dir 至少填一个"; return; } if (!name) { $("nt-err").textContent = "任务名 必填"; return; }
try { try {
const t = await api("POST", "/v1/tasks", { description: desc, mode, task_dir: dir }); const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
$("new-task-modal").classList.remove("show"); $("new-task-modal").classList.remove("show");
await loadTaskList(); await loadTaskList();
selectTask(t.task_id); selectTask(t.task_id);
@ -775,6 +942,44 @@ $("nt-go").onclick = async () => {
} }
}; };
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
const dl = $("folders-datalist");
dl.innerHTML = (data.folders || []).map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个 task` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
} catch (e) {
// 静默 — datalist 留空不影响用户输入
}
}
$("nt-wd").addEventListener("input", () => {
const v = $("nt-wd").value.trim();
const hint = $("nt-wd-hint");
if (!v) {
const fallback = $("nt-name").value.trim();
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
return;
}
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
if (opt) {
const n = parseInt(opt.dataset.n) || 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个 task`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
}
});
$("nt-name").addEventListener("input", () => {
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
if (!$("nt-wd").value.trim()) {
const fallback = $("nt-name").value.trim();
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
}
});
// ───── boot ───── // ───── boot ─────
if (state.token) { if (state.token) {
// 已有 token:试探一下,失败回登录页 // 已有 token:试探一下,失败回登录页