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>
65
DESIGN.md
|
|
@ -40,12 +40,13 @@ zcbot/
|
|||
├── prompts/system/general_v1.md
|
||||
├── config/{agent.yaml, models/deepseek_v4.yaml}
|
||||
├── workspace/
|
||||
│ ├── memory/{core.md, extended/*.md} # 跨 task 共享记忆
|
||||
│ └── tasks/<task_id>/ # task_dir:仅 skill 产物,state/messages 在 PG
|
||||
│ └── users/<user_id>/
|
||||
│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离)
|
||||
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物
|
||||
└── {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 / 生产)。
|
||||
|
||||
|
|
@ -81,19 +82,24 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
|
|||
### 3.6 Session 与 Task
|
||||
|
||||
**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)。
|
||||
|
||||
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`)
|
||||
|
||||
跨 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 永远在 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 |
|
||||
| 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>/` |
|
||||
| Memory | `workspace/memory/`(FS) | `<storage_root>/users/<user_id>/memory/`(仍是 FS) |
|
||||
| task_dir 派生 | `workspace/users/<sentinel>/<name>/`(`name` 必填,简单名) | `<storage_root>/users/<user_id>/<name>/`(`name` 必填,简单名) |
|
||||
| Memory | `workspace/users/<sentinel>/.memory/`(FS,dotfile) | `<storage_root>/users/<user_id>/.memory/`(仍是 FS,dotfile) |
|
||||
| Sandbox | subprocess + env 过滤 | per-task docker exec |
|
||||
| Auth | 无(`user_id='local'`) | PLATFORM_KEY → JWT(过渡)→ OIDC |
|
||||
|
||||
**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 文件副视图
|
||||
|
||||
|
|
@ -209,7 +215,7 @@ SaaS 化不是"重写"也不是"取代 CLI",而是**给同一份 core 加一个
|
|||
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。
|
||||
|
||||
- **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/` / 终稿(无需建"项目"实体)
|
||||
- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走 prefix cascade(§7.4)
|
||||
- **No-subtask**:同 task_dir 允许(同项目多对话),前缀嵌套拒
|
||||
|
|
@ -227,11 +233,16 @@ Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder
|
|||
|
||||
```
|
||||
Tasks
|
||||
POST /v1/tasks 创建 {description?, mode?, task_dir?};
|
||||
task_dir 留空 → 默认派生 workspace/tasks/<uuid>/
|
||||
POST /v1/tasks 创建 {name(必填), working_dir?, description?, skill?};
|
||||
留空 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/{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)
|
||||
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {run_id}
|
||||
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)
|
||||
-- 本地形态固定 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,
|
||||
created_at, updated_at);
|
||||
create index on tasks (user_id, task_dir);
|
||||
-- task_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串(`workspace/tasks/<uuid>`);
|
||||
-- ROOT 外 → 绝对 str(用户自指定项目目录);空串 → 未绑项目。SaaS 阶段同理(基础是
|
||||
-- <storage_root>/users/<uid>/)。读写边界统一过 core/paths.py::{to_db_path,from_db_path}。
|
||||
create index on tasks (user_id, working_dir);
|
||||
-- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串
|
||||
-- (`workspace/users/<user_id>/<name>`,name 是简单名,无 /\..);
|
||||
-- 新建强制 `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,
|
||||
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。
|
||||
|
||||
**文件系统**:
|
||||
**文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根,布局不变):
|
||||
```
|
||||
<storage_root>/users/<user_id>/
|
||||
memory/{core.md, extended/} # per-user,不入 DB
|
||||
<user-given-paths>/... # task_dir 散落其下
|
||||
.memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
|
||||
<name>/ # 项目目录,name 用户起(必填),task_dir 直接落这
|
||||
<name>/... # 同 name 多 task 共享同目录(§7.1)
|
||||
```
|
||||
本地优先 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 |
|
||||
| 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 天 |
|
||||
| 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 天 |
|
||||
|
|
|
|||
14
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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 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-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 持久化(刷新继续看流式)留到未来。
|
||||
|
||||
---
|
||||
|
|
@ -53,7 +59,7 @@
|
|||
| 项 | 决策 | 备注 |
|
||||
|---|---|---|
|
||||
| 工具基目录 | 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 |
|
||||
| 版本化 prompt | 直接 `general_v1.md` | Windows 软链接麻烦,真要切再做 |
|
||||
| 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/skills.py 81
|
||||
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/storage/__init__.py 27 ← §7 B Step 1-3
|
||||
core/storage/engine.py 80 ← §7 B Step 1
|
||||
|
|
@ -84,7 +90,7 @@ tools/fs.py 182
|
|||
tools/shell.py 94
|
||||
tools/run_python.py 84
|
||||
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 子命令
|
||||
db/migrations/env.py 61 ← §7 B Step 1
|
||||
db/migrations/versions/
|
||||
|
|
|
|||
39
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 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 上车
|
||||
.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
|
||||
# 新建 task,默认派生 workspace/tasks/<uuid>/
|
||||
.venv/Scripts/python.exe cli.py chat
|
||||
# 新建 task —— `--name` 必填(任务显示名),`--working-dir` 可选(目录名,留空 → 用 --name)
|
||||
.venv/Scripts/python.exe cli.py chat --name "初稿大纲" --working-dir proposal_v3
|
||||
|
||||
# 带模式 + 描述(便于后续 list 识别)
|
||||
.venv/Scripts/python.exe cli.py chat --mode coding --desc "修 X 的 Y"
|
||||
# 只给 name → working_dir fallback 用 name
|
||||
.venv/Scripts/python.exe cli.py chat --name proposal_v3
|
||||
|
||||
# 项目化 task —— 产物落到指定目录(§7.1 task-primary + dir 副视图)
|
||||
.venv/Scripts/python.exe cli.py chat --task-dir /path/to/proj --mode proposal
|
||||
# 带 skill + 描述(便于后续 list 识别)
|
||||
.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
|
||||
|
||||
# 恢复指定 task(UUID 完整或 ≥8 字符前缀)
|
||||
.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 /static/*` | dev.html 等静态文件 | 豁免 |
|
||||
| `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/{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 透传 | 必填 |
|
||||
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}` | 必填 |
|
||||
| `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) |
|
||||
| `db upgrade` 报 `column already exists` | DB 已被改过,先 `db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
|
||||
| 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 |
|
||||
| `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"}` |
|
||||
| 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 域名 或 `*`)|
|
||||
|
|
@ -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)
|
||||
- **配置**:`config/agent.yaml`(全局)/ `config/models/*.yaml`(模型档案,§3.2 Model Profile)
|
||||
- **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
|
|
@ -11,23 +11,26 @@
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
|
||||
from core.storage import SENTINEL_USER_ID
|
||||
from core.ui import make_console
|
||||
from main import (
|
||||
ROOT,
|
||||
InvalidTaskName,
|
||||
_resolve_uuid_or_prefix,
|
||||
build_agent,
|
||||
load_config,
|
||||
resolve_workspace,
|
||||
sync_task_tokens,
|
||||
tasks_dir,
|
||||
user_root,
|
||||
validate_task_name,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -81,37 +84,23 @@ def db_current() -> None:
|
|||
_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。
|
||||
|
||||
DB 行无条件删除(若存在且 session 内存无 user 消息)。
|
||||
FS rmtree **仅在 task_dir 是 workspace/tasks/<uuid>/ 默认派生路径**且无产物时执行 ——
|
||||
用户用 `--task-dir` 指定的项目目录绝不 rmtree(可能含用户已有文件)。
|
||||
FS **绝不 rmtree** —— working_dir 是用户起的项目目录名,同 working_dir 跨 task 复用,
|
||||
可能里面已有别的产物;空 task 只清 DB 行。
|
||||
"""
|
||||
from main import is_managed_task_dir
|
||||
|
||||
_ = workspace_dir # 不再用,签名保留向后兼容
|
||||
_ = working_dir # FS 不动,只清 DB
|
||||
if session.n_user_msgs() > 0:
|
||||
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)
|
||||
if console is not None:
|
||||
tag = "empty" if managed else "empty (kept user dir)"
|
||||
console.print(f"[muted]cleaned {tag} task {str(session.task_id)[:8]}[/muted]")
|
||||
console.print(
|
||||
f"[muted]cleaned empty task {str(session.task_id)[:8]} (kept FS dir)[/muted]"
|
||||
)
|
||||
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):
|
||||
"""返回 [(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 仅用于
|
||||
保持签名向后兼容(不再读 state.json)。status 过滤走 SQL WHERE。
|
||||
|
|
@ -154,7 +143,7 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
|
|||
_ = workspace_dir # 签名占位,Step 3 后已不需要
|
||||
with session_scope() as s:
|
||||
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.tokens_completion, Task.description,
|
||||
).order_by(Task.updated_at.desc())
|
||||
|
|
@ -166,10 +155,10 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
|
|||
).all())
|
||||
|
||||
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)
|
||||
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,
|
||||
))
|
||||
return rows
|
||||
|
|
@ -177,17 +166,36 @@ def _list_task_rows(workspace_dir, limit=20, status=None):
|
|||
|
||||
@cli.command()
|
||||
@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("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)")
|
||||
@click.option("--skill", default="", help="智能体类型标签(coding/ppt/proposal/...自由形式,对齐 skills/)")
|
||||
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别")
|
||||
@click.option("--task-dir", "task_dir_arg", default=None,
|
||||
help="项目化 task:把产物落到指定目录(绝对或相对当前 cwd);留空走默认派生 workspace/tasks/<uuid>/")
|
||||
def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
|
||||
task_dir_arg: str) -> None:
|
||||
"""启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。"""
|
||||
@click.option("--name", default=None,
|
||||
help="任务名(必填,DB 存,UI 显示用)。resume 时忽略。")
|
||||
@click.option("--working-dir", default=None,
|
||||
help="工作目录名(简单名,不含 / \\ .. 也不能以 . 起头);留空 → 用 --name。"
|
||||
"工作目录落 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()
|
||||
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:
|
||||
agent, session, sid, task_state, task_dir = build_agent(
|
||||
model_name=model,
|
||||
|
|
@ -195,9 +203,10 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
|
|||
console=console,
|
||||
session_id=resume,
|
||||
resume=bool(resume),
|
||||
mode=mode,
|
||||
skill=skill,
|
||||
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:
|
||||
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:
|
||||
console.print(
|
||||
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]"
|
||||
)
|
||||
else:
|
||||
meta_tail = ""
|
||||
if task_state.mode or task_state.description:
|
||||
meta_tail = f" mode={task_state.mode!r} desc={task_state.description!r}"
|
||||
if task_state.skill or task_state.description:
|
||||
meta_tail = f" skill={task_state.skill!r} desc={task_state.description!r}"
|
||||
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}"
|
||||
)
|
||||
console.print(
|
||||
|
|
@ -239,18 +249,32 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
|
|||
session.reset(keep_system=True)
|
||||
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
|
||||
continue
|
||||
if cmd == "/new":
|
||||
if cmd.startswith("/new"):
|
||||
_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:
|
||||
agent, session, sid, task_state, task_dir = build_agent(
|
||||
model_name=model, workspace=workspace, console=console,
|
||||
mode=mode, description=desc,
|
||||
task_dir_arg=task_dir_arg,
|
||||
skill=skill, description=desc,
|
||||
name=new_name, working_dir=current_wd,
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}")
|
||||
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
|
||||
if cmd.startswith("/resume"):
|
||||
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("task id")
|
||||
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("desc")
|
||||
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")
|
||||
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)
|
||||
try:
|
||||
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
|
||||
if cmd == "/status":
|
||||
console.print(
|
||||
f"[info]task {task_state.task_id} status={task_state.status} "
|
||||
f"mode={task_state.mode!r} desc={task_state.description!r}\n"
|
||||
f"[info]task {task_state.task_id} name={task_state.name!r} "
|
||||
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"(p={task_state.tokens_prompt}/c={task_state.tokens_completion}) "
|
||||
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)
|
||||
|
||||
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
|
||||
tbl = Table(show_lines=False)
|
||||
tbl.add_column("task id", style="bold")
|
||||
tbl.add_column("status")
|
||||
tbl.add_column("mode")
|
||||
tbl.add_column("name")
|
||||
tbl.add_column("skill")
|
||||
tbl.add_column("model")
|
||||
tbl.add_column("msgs", justify="right")
|
||||
tbl.add_column("tokens", justify="right")
|
||||
tbl.add_column("desc")
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ def _format_args(args_str: str) -> str:
|
|||
# ───────────────────────── Meta 区块 ─────────────────────────
|
||||
|
||||
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:
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
|
@ -181,8 +181,9 @@ def _add_meta_block(
|
|||
run.font.bold = True
|
||||
_set_run_fonts(run, cn_font="黑体", en_font="Consolas")
|
||||
|
||||
name = task_state.get("name") 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 ""
|
||||
model = meta.get("model") or task_state.get("model") or ""
|
||||
profile = meta.get("model_profile") or task_state.get("model_profile") or ""
|
||||
|
|
@ -193,7 +194,8 @@ def _add_meta_block(
|
|||
|
||||
rows = [
|
||||
("Task ID", meta.get("id") or task_state.get("task_id") or "?"),
|
||||
("模式", mode),
|
||||
("任务名", name),
|
||||
("Skill", skill),
|
||||
("描述", desc),
|
||||
("状态", status),
|
||||
("模型", model),
|
||||
|
|
@ -202,7 +204,7 @@ def _add_meta_block(
|
|||
("更新时间", updated),
|
||||
("消息数", str(n_msgs)),
|
||||
("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")),
|
||||
]
|
||||
|
||||
|
|
@ -316,7 +318,7 @@ def _render_message(
|
|||
|
||||
def export_chat_to_docx(
|
||||
task_id: UUID,
|
||||
task_dir: Optional[Path] = None,
|
||||
working_dir: Optional[Path] = None,
|
||||
out_path: Optional[Path] = None,
|
||||
*,
|
||||
include_system: bool = False,
|
||||
|
|
@ -327,8 +329,8 @@ def export_chat_to_docx(
|
|||
"""渲染 task 对话为 .docx,返回写入路径。
|
||||
|
||||
task_id 是主标识(从 PG 读 messages + 元数据)。
|
||||
task_dir 留空 → 用 PG tasks.task_dir(用户指定模式可能不在 workspace/tasks/<uuid>/);
|
||||
DB 也空 → 报错(无处放产物)。out_path 留空 → task_dir / chat_<uuid>.docx。
|
||||
working_dir 留空 → 用 PG tasks.working_dir(用户指定模式可能不在默认派生路径下);
|
||||
DB 也空 → 报错(无处放产物)。out_path 留空 → working_dir / chat_<uuid>.docx。
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
from sqlalchemy import select
|
||||
|
|
@ -344,18 +346,18 @@ def export_chat_to_docx(
|
|||
st = TaskState.load(task_id)
|
||||
task_state: dict = asdict(st) if st is not None else {}
|
||||
|
||||
if task_dir is None:
|
||||
td_str = task_state.get("task_dir", "")
|
||||
if td_str:
|
||||
# td_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path
|
||||
if working_dir is None:
|
||||
wd_str = task_state.get("working_dir", "")
|
||||
if wd_str:
|
||||
# wd_str 是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原 absolute Path
|
||||
from core.paths import from_db_path
|
||||
task_dir = from_db_path(td_str)
|
||||
# else: task_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx)
|
||||
working_dir = from_db_path(wd_str)
|
||||
# else: working_dir 留 None,只在 out_path 也 None 时报错(不能没地方落 .docx)
|
||||
|
||||
if out_path is None:
|
||||
if task_dir is None:
|
||||
raise ValueError(f"task {task_id} 无 task_dir 且未指定 out_path —— 无处放 .docx")
|
||||
out_path = task_dir / f"chat_{task_id}.docx"
|
||||
if working_dir is None:
|
||||
raise ValueError(f"task {task_id} 无 working_dir 且未指定 out_path —— 无处放 .docx")
|
||||
out_path = working_dir / f"chat_{task_id}.docx"
|
||||
|
||||
meta = {
|
||||
"id": str(task_id),
|
||||
|
|
@ -365,7 +367,7 @@ def export_chat_to_docx(
|
|||
}
|
||||
|
||||
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 表保持一行间距
|
||||
|
||||
for msg in messages:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""双层记忆: `workspace/memory/`。
|
||||
"""双层记忆: `workspace/users/<user_id>/.memory/` (§3.7 / §7.4)。
|
||||
|
||||
core.md —— 注 system prompt,每次都看到。装稳定事实
|
||||
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
|
||||
|
|
@ -9,17 +9,21 @@
|
|||
core 一直挂在上下文里,token 成本固定 ⇒ 只放跨任务高频用的精炼内容
|
||||
extended 索引只占几行,内容按需付费 ⇒ 适合大量低频专题
|
||||
|
||||
memory 是 workspace 级别(不是 task 级别)。同一 workspace 的所有 task 共享。
|
||||
SaaS 化(§7)后会按 tenant 隔离 —— 接口不变,只换 storage backend。
|
||||
memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 task 共享。
|
||||
**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `<uid>/` 下)区分,避免
|
||||
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。
|
||||
本地 CLI = SENTINEL user;web/JWT 用 sub。SaaS 化时 `<storage_root>` 替换
|
||||
`workspace`,布局不变(§7.0)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _memory_dir(workspace_dir: Path) -> Path:
|
||||
return workspace_dir / "memory"
|
||||
def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path:
|
||||
return workspace_dir / "users" / str(user_id) / ".memory"
|
||||
|
||||
|
||||
def _read_first_title(p: Path) -> str:
|
||||
|
|
@ -36,8 +40,8 @@ def _read_first_title(p: Path) -> str:
|
|||
return p.stem
|
||||
|
||||
|
||||
def _load_core(workspace_dir: Path) -> str:
|
||||
p = _memory_dir(workspace_dir) / "core.md"
|
||||
def _load_core(workspace_dir: Path, user_id: UUID) -> str:
|
||||
p = _memory_dir(workspace_dir, user_id) / "core.md"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
try:
|
||||
|
|
@ -46,9 +50,9 @@ def _load_core(workspace_dir: Path) -> str:
|
|||
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), ...],按文件名排序。"""
|
||||
ext_dir = _memory_dir(workspace_dir) / "extended"
|
||||
ext_dir = _memory_dir(workspace_dir, user_id) / "extended"
|
||||
if not ext_dir.is_dir():
|
||||
return []
|
||||
items: List[Tuple[str, Path]] = []
|
||||
|
|
@ -58,14 +62,14 @@ def _extended_index(workspace_dir: Path) -> List[Tuple[str, Path]]:
|
|||
return items
|
||||
|
||||
|
||||
def memory_block(workspace_dir: Path) -> str:
|
||||
def memory_block(workspace_dir: Path, user_id: UUID) -> str:
|
||||
"""构造注入 system prompt 的记忆段;两块都空就返回空串。"""
|
||||
core = _load_core(workspace_dir)
|
||||
ext = _extended_index(workspace_dir)
|
||||
core = _load_core(workspace_dir, user_id)
|
||||
ext = _extended_index(workspace_dir, user_id)
|
||||
if not core and not ext:
|
||||
return ""
|
||||
|
||||
parts = ["\n\n## 记忆 (workspace 级,跨 task 共享)"]
|
||||
parts = ["\n\n## 记忆 (user 级,跨 task 共享)"]
|
||||
if core:
|
||||
parts.append("\n### Core (常驻 prompt)\n")
|
||||
parts.append(core)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
"""task_dir 在 DB 与文件系统两种形态之间的归一。
|
||||
"""working_dir 在 DB 与文件系统两种形态之间的归一(原 `task_dir` 已改名)。
|
||||
|
||||
存储约定(DESIGN §7.4):
|
||||
- task_dir 在 ROOT 内 → 相对 ROOT 的 posix 串(如 `workspace/tasks/abc-...`)
|
||||
- task_dir 在 ROOT 外 → 绝对 str(如 `D:\\projects\\other\\proj` 或 `/home/u/proj`)
|
||||
- working_dir 在 ROOT 内 → 相对 ROOT 的 posix 串(如 `workspace/users/<uid>/<name>`)
|
||||
- working_dir 在 ROOT 外 → 绝对 str(如 `D:\\projects\\other\\proj` 或 `/home/u/proj`)
|
||||
- 空串 → 空串(legacy / 未绑项目)
|
||||
|
||||
跨机器迁移 / 切 OS / 移 repo 后,ROOT-内路径仍能 resolve;ROOT-外仍存绝对是务实选择
|
||||
—— 用户自指定的项目目录没有更好的归一基。
|
||||
|
||||
Read 端两种来源走两个入口:
|
||||
- DB tasks.task_dir → `from_db_path(s)` → absolute Path
|
||||
- 用户 CLI `--task-dir` / Web `/new` 表单 → `Path(arg).expanduser().resolve()`(原行为不变)
|
||||
- DB tasks.working_dir → `from_db_path(s)` → absolute Path
|
||||
- 用户 CLI `--working-dir` / Web `/v1/tasks` 表单 → `Path(arg).expanduser().resolve()`
|
||||
|
||||
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 串。
|
||||
|
||||
输入应已是绝对路径(build_agent / web 路由那一层都 .resolve() 过)。
|
||||
ROOT 内 → 相对 posix(`workspace/tasks/abc`)
|
||||
ROOT 内 → 相对 posix(`workspace/users/<uid>/<name>`)
|
||||
ROOT 外 → str(Path)(保留 OS 原生分隔符)
|
||||
空 → ""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -77,13 +77,15 @@ class Session:
|
|||
return
|
||||
|
||||
# 首次写入前,让 tasks 行就位。`ensure_local_task_row` 在 storage 层 idempotent。
|
||||
# meta 字段(mode/description/reasoning_effort)走 INSERT 一次性带入,避免
|
||||
# 首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。
|
||||
# meta 字段(name/working_dir/skill/description/reasoning_effort)走 INSERT 一次性带入,
|
||||
# 避免首次 append 后 _list_task_rows 看到空 meta;后续 task_state.save() 走 UPSERT 覆盖。
|
||||
# name 是 NOT NULL,build_agent 必须放进 meta(新建 / resume 都已就位)。
|
||||
from .storage.utils import ensure_local_task_row
|
||||
ensure_local_task_row(
|
||||
task_id=self.task_id,
|
||||
task_dir=self.meta.get("task_dir", ""),
|
||||
mode=self.meta.get("mode", ""),
|
||||
name=self.meta.get("name", ""),
|
||||
working_dir=self.meta.get("working_dir", ""),
|
||||
skill=self.meta.get("skill", ""),
|
||||
description=self.meta.get("description", ""),
|
||||
model=self.meta.get("model", ""),
|
||||
model_profile=self.meta.get("model_profile", ""),
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ class Task(Base):
|
|||
user_id: Mapped[UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False
|
||||
)
|
||||
task_dir: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
mode: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
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="")
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="active")
|
||||
model: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ from .models import SENTINEL_USER_ID, Task
|
|||
|
||||
|
||||
class NoSubtaskError(ValueError):
|
||||
"""task_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。"""
|
||||
"""working_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。"""
|
||||
|
||||
|
||||
def ensure_local_task_row(
|
||||
task_id: UUID,
|
||||
task_dir: str = "",
|
||||
mode: str = "",
|
||||
name: str,
|
||||
working_dir: str = "",
|
||||
skill: str = "",
|
||||
description: str = "",
|
||||
model: str = "",
|
||||
model_profile: str = "",
|
||||
|
|
@ -29,15 +30,17 @@ def ensure_local_task_row(
|
|||
|
||||
用于 `Session.append` 在首条非 system 消息前打底 tasks 行,避免 messages
|
||||
FK 违反。字段是 build_agent 阶段已知的最小集;TaskState.save 之后会通过
|
||||
`upsert_task` 把真实字段(desc/status/tokens 等)写进去。
|
||||
`upsert_task` 把真实字段(desc/status/tokens 等)写进去。`name` 必填(列 NOT NULL),
|
||||
调用方应已 validate。
|
||||
"""
|
||||
stmt = (
|
||||
insert(Task)
|
||||
.values(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
task_dir=task_dir,
|
||||
mode=mode,
|
||||
name=name,
|
||||
working_dir=working_dir,
|
||||
skill=skill,
|
||||
description=description,
|
||||
model=model,
|
||||
model_profile=model_profile,
|
||||
|
|
@ -57,9 +60,10 @@ def upsert_task(
|
|||
) -> None:
|
||||
"""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)。
|
||||
不传的字段在 INSERT 时走 ORM 默认值,UPDATE 时不动。
|
||||
INSERT 路径需要 name(NOT NULL)+ working_dir;纯 UPDATE 路径(行已存在)不强制。
|
||||
"""
|
||||
values = {"task_id": task_id, "user_id": user_id, **fields}
|
||||
stmt = insert(Task).values(**values)
|
||||
|
|
@ -100,30 +104,30 @@ def get_task(task_id: UUID) -> Optional[Task]:
|
|||
|
||||
|
||||
def check_no_subtask(
|
||||
task_dir: str,
|
||||
working_dir: str,
|
||||
user_id: UUID = SENTINEL_USER_ID,
|
||||
) -> 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 的子目录。
|
||||
空 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 里行的两种形态同样归一。
|
||||
数量小(per user 几十量级),全量拉到 Python 端比对,不在 SQL 里拼分隔符 / 前缀。
|
||||
"""
|
||||
if not task_dir or not task_dir.strip():
|
||||
if not working_dir or not working_dir.strip():
|
||||
return
|
||||
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:
|
||||
return
|
||||
with session_scope() as s:
|
||||
rows = s.execute(
|
||||
select(Task.task_id, Task.task_dir)
|
||||
.where(Task.user_id == user_id, Task.task_dir != "")
|
||||
select(Task.task_id, Task.working_dir)
|
||||
.where(Task.user_id == user_id, Task.working_dir != "")
|
||||
).all()
|
||||
for existing_id, existing_dir in rows:
|
||||
existing_abs = from_db_path(existing_dir).as_posix()
|
||||
|
|
@ -131,6 +135,6 @@ def check_no_subtask(
|
|||
continue
|
||||
if new_abs.startswith(existing_abs + "/") or existing_abs.startswith(new_abs + "/"):
|
||||
raise NoSubtaskError(
|
||||
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]} 的 "
|
||||
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir"
|
||||
f"working_dir {working_dir!r} 与已有 task {str(existing_id)[:8]} 的 "
|
||||
f"working_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 working_dir"
|
||||
)
|
||||
|
|
|
|||
15
core/task.py
|
|
@ -25,8 +25,9 @@ def _iso(dt: Optional[datetime]) -> str:
|
|||
@dataclass
|
||||
class TaskState:
|
||||
task_id: str # UUID 字符串形式(对外展示用,DB 仍是 UUID)
|
||||
task_dir: str = "" # 绝对路径或留空(留空= ChatGPT thread 默认派生,§7.1)
|
||||
mode: str = "" # coding / ppt / proposal / general / 自由形式
|
||||
name: str = "" # 任务显示名(列 NOT NULL,新建必填;resume 时从 DB 读)
|
||||
working_dir: str = "" # 工作目录(db 形态:ROOT 内相对 / ROOT 外绝对;空=未绑)
|
||||
skill: str = "" # 智能体类型(coding / ppt / proposal / 自由形式,后续可对齐 skills/ 注册表)
|
||||
description: str = "" # 一句话描述,便于列表识别
|
||||
status: str = "active" # active / completed / abandoned
|
||||
model: str = "" # caps.model_id
|
||||
|
|
@ -46,8 +47,9 @@ class TaskState:
|
|||
"""UPSERT 到 PG。created_at / updated_at 不参与写入(PG 自动管)。"""
|
||||
upsert_task(
|
||||
UUID(self.task_id),
|
||||
task_dir=self.task_dir,
|
||||
mode=self.mode,
|
||||
name=self.name,
|
||||
working_dir=self.working_dir,
|
||||
skill=self.skill,
|
||||
description=self.description,
|
||||
status=self.status,
|
||||
model=self.model,
|
||||
|
|
@ -61,8 +63,9 @@ class TaskState:
|
|||
def from_row(cls, row: TaskRow) -> "TaskState":
|
||||
return cls(
|
||||
task_id=str(row.task_id),
|
||||
task_dir=row.task_dir,
|
||||
mode=row.mode,
|
||||
name=row.name,
|
||||
working_dir=row.working_dir,
|
||||
skill=row.skill,
|
||||
description=row.description,
|
||||
status=row.status,
|
||||
model=row.model,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,9 +1,18 @@
|
|||
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。
|
||||
|
||||
存储布局(§7 B Step 3 后):
|
||||
PG tasks / messages ← Task 元数据 + Session 消息
|
||||
workspace/tasks/<task_id>/ ← task_dir,只承担 skill 产物
|
||||
task_id 用 UUID,state.json 已删除(元数据全在 PG)。
|
||||
存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` 根,只差 user_id:
|
||||
|
||||
PG tasks / messages ← 元数据 + 消息
|
||||
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
|
||||
|
||||
|
|
@ -23,7 +32,7 @@ from core.paths import ROOT, from_db_path, to_db_path
|
|||
from core.session import Session
|
||||
from core.sinks import ConsoleEventSink
|
||||
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 tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
||||
from tools.run_python import RunPythonTool
|
||||
|
|
@ -42,49 +51,59 @@ def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> P
|
|||
return p
|
||||
|
||||
|
||||
def tasks_dir(workspace_dir: Path) -> Path:
|
||||
d = workspace_dir / "tasks"
|
||||
def user_root(workspace_dir: Path, user_id: UUID) -> Path:
|
||||
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。"""
|
||||
d = workspace_dir / "users" / str(user_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _default_task_dir(workspace_dir: Path, task_id: UUID) -> Path:
|
||||
return tasks_dir(workspace_dir) / str(task_id)
|
||||
class InvalidTaskName(ValueError):
|
||||
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
|
||||
|
||||
|
||||
def is_managed_task_dir(task_dir: Path, workspace_dir: Path) -> bool:
|
||||
"""task_dir 是否在 workspace/tasks/<uuid>/ 默认派生模板下。
|
||||
def validate_task_name(name: str) -> str:
|
||||
"""返回 stripped name;非法抛 InvalidTaskName。
|
||||
|
||||
用作 _cleanup_if_empty 的保护开关 —— 用户自指定的项目目录绝不 rmtree。
|
||||
name 和 working_dir 共用一份规则:非空 / 不含 `/\\` 和 NUL / 不以 `.` 起头
|
||||
(挡 `.memory` 等系统区)/ ≤ 255 字符。允许 CJK 与其他 Unicode 字符。
|
||||
"""
|
||||
try:
|
||||
rel = task_dir.resolve().relative_to(tasks_dir(workspace_dir).resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
parts = rel.parts
|
||||
if len(parts) != 1:
|
||||
return False
|
||||
try:
|
||||
UUID(parts[0])
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
raise InvalidTaskName("name 不能为空")
|
||||
if len(n) > 255:
|
||||
raise InvalidTaskName(f"name 超长(>255 字符): {n[:40]!r}...")
|
||||
if any(c in n for c in ("/", "\\", "\x00")):
|
||||
raise InvalidTaskName(f"name 不能含 `/` `\\` 或 NUL: {n!r}")
|
||||
if n.startswith("."):
|
||||
raise InvalidTaskName(
|
||||
f"name 不能以 `.` 起头(保留给 .memory 等系统区): {n!r}"
|
||||
)
|
||||
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(
|
||||
workspace_dir: Path,
|
||||
task_id_arg: Optional[str],
|
||||
resume: bool,
|
||||
task_dir_arg: Optional[str] = None,
|
||||
user_id: UUID,
|
||||
working_dir_name: Optional[str] = None,
|
||||
) -> Tuple[UUID, Path]:
|
||||
"""返回 (task_id, task_dir 绝对路径)。
|
||||
"""返回 (task_id, working_dir 绝对路径)。
|
||||
|
||||
新建:
|
||||
- UUID + (task_dir_arg 显式 → 用户路径绝对化;否则默认派生 workspace/tasks/<uuid>/)
|
||||
Resume:
|
||||
- task_id 从前缀/UUID/'last' 解析;task_dir 从 PG tasks.task_dir 读
|
||||
- DB task_dir 为空表示"该 task 创建时未显式指定" → 仍用默认派生(老数据 / Step 3 前)
|
||||
- task_dir_arg 在 resume 时若传入 → 覆盖 DB 值(允许用户改绑路径,但调用方需自行 UPSERT)
|
||||
新建:`working_dir_name` 必填(调用方应已 fallback 到 name + 校验过),
|
||||
工作目录 = `<workspace>/users/<uid>/<working_dir_name>/`。
|
||||
Resume:`task_id` 从前缀/UUID/'last' 解析,working_dir 从 PG `tasks.working_dir`
|
||||
读还原;`working_dir_name` 在 resume 时被忽略。
|
||||
"""
|
||||
if resume:
|
||||
from sqlalchemy import select
|
||||
|
|
@ -94,7 +113,7 @@ def resolve_task_id(
|
|||
if task_id_arg in (None, "", "last"):
|
||||
with session_scope() as s:
|
||||
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)
|
||||
).first()
|
||||
if row is None:
|
||||
|
|
@ -104,25 +123,22 @@ def resolve_task_id(
|
|||
tid = _resolve_uuid_or_prefix(task_id_arg)
|
||||
with session_scope() as s:
|
||||
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 ""
|
||||
|
||||
if task_dir_arg and task_dir_arg.strip():
|
||||
# 用户显式覆盖(允许 resume 时改绑路径,调用方需自行 UPSERT 持久化)
|
||||
fs_dir = Path(task_dir_arg).expanduser().resolve()
|
||||
elif db_dir:
|
||||
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
|
||||
fs_dir = from_db_path(db_dir)
|
||||
else:
|
||||
fs_dir = _default_task_dir(workspace_dir, tid)
|
||||
if not db_dir:
|
||||
raise ValueError(
|
||||
f"task {tid} has empty working_dir in DB — should not happen "
|
||||
"(new tasks require name + working_dir; legacy empty data was wiped)"
|
||||
)
|
||||
# DB 存的是 db 形态(相对 ROOT 或绝对),走 from_db_path 还原绝对
|
||||
fs_dir = from_db_path(db_dir)
|
||||
return tid, fs_dir
|
||||
|
||||
tid = uuid4()
|
||||
if task_dir_arg and task_dir_arg.strip():
|
||||
fs_dir = Path(task_dir_arg).expanduser().resolve()
|
||||
else:
|
||||
fs_dir = _default_task_dir(workspace_dir, tid)
|
||||
return tid, fs_dir
|
||||
if not working_dir_name:
|
||||
raise InvalidTaskName("new task 必须指定 working_dir(或留空 fallback 用 name)")
|
||||
safe = validate_task_name(working_dir_name)
|
||||
return uuid4(), working_dir_from_name(workspace_dir, user_id, safe)
|
||||
|
||||
|
||||
def _resolve_uuid_or_prefix(s: str) -> UUID:
|
||||
|
|
@ -151,25 +167,26 @@ def _build_system_prompt(
|
|||
skills: SkillRegistry,
|
||||
workspace_dir: Path,
|
||||
tool_base: Path,
|
||||
task_dir: Path,
|
||||
working_dir: Path,
|
||||
user_id: UUID,
|
||||
) -> str:
|
||||
"""拼 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")
|
||||
if skills.skills:
|
||||
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
|
||||
prompt += memory_block(workspace_dir)
|
||||
task_dir_abs = task_dir.resolve()
|
||||
prompt += memory_block(workspace_dir, user_id)
|
||||
wd_abs = working_dir.resolve()
|
||||
prompt += (
|
||||
f"\n\n## 工作目录\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"产物示例: `{task_dir_abs}/spec_lock.md`、"
|
||||
f"`{task_dir_abs}/sections/01_summary.md`、"
|
||||
f"`{task_dir_abs}/slides/`、最终 .docx/.pptx。\n"
|
||||
f"产物示例: `{wd_abs}/spec_lock.md`、"
|
||||
f"`{wd_abs}/sections/01_summary.md`、"
|
||||
f"`{wd_abs}/slides/`、最终 .docx/.pptx。\n"
|
||||
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。"
|
||||
)
|
||||
return prompt
|
||||
|
|
@ -182,13 +199,25 @@ def build_agent(
|
|||
session_id: Optional[str] = None,
|
||||
resume: bool = False,
|
||||
tool_base: Optional[Path] = None,
|
||||
mode: str = "",
|
||||
skill: 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]:
|
||||
"""返回 (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()
|
||||
model = model_name or cfg["default_model"]
|
||||
uid = user_id or SENTINEL_USER_ID
|
||||
|
||||
# 本地 sentinel user 入库(idempotent);build_agent 是所有 task 操作的入口
|
||||
ensure_local_sentinel()
|
||||
|
|
@ -197,32 +226,52 @@ def build_agent(
|
|||
llm = LLM(caps)
|
||||
|
||||
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)
|
||||
|
||||
# §7.4 no-subtask:新建 task 时校验 task_dir 不与同 user 已有 task 形成前缀嵌套
|
||||
# §7.4 no-subtask:新建 task 时校验 working_dir 不与同 user 已有 task 形成前缀嵌套
|
||||
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
|
||||
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()
|
||||
|
||||
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")
|
||||
# meta["task_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
||||
# 把它直接落 PG tasks.task_dir,所以这里就转好。文件系统操作仍用上面的 task_dir(absolute)。
|
||||
task_dir_db = to_db_path(task_dir)
|
||||
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
||||
# 把它直接落 PG tasks.working_dir,所以这里就转好。文件系统操作仍用 working_dir_path(absolute)。
|
||||
wd_db = to_db_path(working_dir_path)
|
||||
meta = {
|
||||
"id": sid,
|
||||
"created_at": now_iso,
|
||||
"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_profile": model,
|
||||
"mode": mode,
|
||||
"skill": skill,
|
||||
"description": description,
|
||||
"reasoning_effort": caps.default_reasoning_effort or "",
|
||||
}
|
||||
|
|
@ -234,17 +283,20 @@ def build_agent(
|
|||
# tasks 行不存在 —— 理论上 resolve_task_id 已经定位到 DB 行了,走到这里
|
||||
# 说明被并发删了,兜底构造空 state(不主动 save,等下条 append / 命令)
|
||||
task_state = TaskState(
|
||||
task_id=sid, task_dir=task_dir_db,
|
||||
mode=mode, description=description, status="active",
|
||||
task_id=sid, name="", working_dir=wd_db,
|
||||
skill=skill, description=description, status="active",
|
||||
model=caps.model_id, model_profile=model,
|
||||
)
|
||||
# resume 时 meta name 用 DB 里读出来的真值(给 Session.append → ensure 用,避免落空串)
|
||||
meta["name"] = task_state.name
|
||||
else:
|
||||
session = Session(task_id=task_id, system_prompt=system_prompt, meta=meta)
|
||||
# 懒创建: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_id=sid, task_dir=task_dir_db,
|
||||
mode=mode, description=description, status="active",
|
||||
task_id=sid, name=task_name_safe, working_dir=wd_db,
|
||||
skill=skill, description=description, status="active",
|
||||
model=caps.model_id, model_profile=model,
|
||||
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
|
||||
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:
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
153
web/app.py
|
|
@ -62,10 +62,11 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
|
|||
"""Task ORM row → API JSON dict。"""
|
||||
d = {
|
||||
"task_id": str(row.task_id),
|
||||
"name": row.name or "",
|
||||
"description": row.description or "",
|
||||
"task_dir": _norm_path(row.task_dir or ""),
|
||||
"working_dir": _norm_path(row.working_dir or ""),
|
||||
"status": row.status,
|
||||
"mode": row.mode or "",
|
||||
"skill": row.skill or "",
|
||||
"model": row.model or "",
|
||||
"model_profile": row.model_profile or "",
|
||||
"tokens_prompt": row.tokens_prompt or 0,
|
||||
|
|
@ -81,9 +82,9 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
|
|||
|
||||
# ─────────────────────── files helpers ───────────────────────
|
||||
|
||||
def _load_task_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]:
|
||||
"""task_id 解析 + 查 PG 拿 task_dir db form + 还原 absolute Path。
|
||||
404 / 400 if 非 UUID / task 不存在 / 不属于 user / task_dir 空。
|
||||
def _load_working_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]:
|
||||
"""task_id 解析 + 查 PG 拿 working_dir db form + 还原 absolute Path。
|
||||
404 / 400 if 非 UUID / task 不存在 / 不属于 user / working_dir 空。
|
||||
跨 user 视为 not found(不暴露 task 存在性)。
|
||||
"""
|
||||
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}")
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
select(Task.task_dir).where(
|
||||
select(Task.working_dir).where(
|
||||
Task.task_id == tid, Task.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(404, f"task not found: {tid}")
|
||||
td = row[0] or ""
|
||||
if not td:
|
||||
raise HTTPException(400, f"task {tid} has no task_dir, files browsing unavailable")
|
||||
return tid, from_db_path(td)
|
||||
wd = row[0] or ""
|
||||
if not wd:
|
||||
raise HTTPException(400, f"task {tid} has no working_dir, files browsing unavailable")
|
||||
return tid, from_db_path(wd)
|
||||
|
||||
|
||||
def _safe_join(root: Path, rel: str) -> Path:
|
||||
|
|
@ -117,7 +118,7 @@ def _safe_join(root: Path, rel: str) -> Path:
|
|||
try:
|
||||
target.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"path escapes task_dir: {rel!r}")
|
||||
raise HTTPException(400, f"path escapes working_dir: {rel!r}")
|
||||
return target
|
||||
|
||||
|
||||
|
|
@ -151,7 +152,9 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
|
|||
})
|
||||
cur_rel = _rel_to(root, current)
|
||||
crumbs = [{"label": "/", "rel": ""}]
|
||||
if cur_rel:
|
||||
# cur_rel == "." 表示当前就在 root(target.relative_to(root) 返 Path(".")),
|
||||
# 不该再追加一个无意义的 "." crumb
|
||||
if cur_rel and cur_rel != ".":
|
||||
acc = ""
|
||||
for part in cur_rel.split("/"):
|
||||
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 帧格式 ───────────────────
|
||||
|
||||
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 状态。
|
||||
|
||||
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
|
||||
try:
|
||||
broker.emit(run_id, {"type": "run_start"})
|
||||
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.run(user_message)
|
||||
|
|
@ -209,15 +213,17 @@ def _sse_event(event_type: str, payload: dict) -> bytes:
|
|||
# ────────────────────── Pydantic 请求体 ──────────────────────
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
name: str # 任务显示名(必填,DB 列 NOT NULL)
|
||||
working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名)
|
||||
description: str = ""
|
||||
mode: str = ""
|
||||
task_dir: str = ""
|
||||
skill: str = ""
|
||||
|
||||
|
||||
class TaskPatchRequest(BaseModel):
|
||||
status: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
skill: Optional[str] = None
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
|
|
@ -308,23 +314,31 @@ def create_app() -> FastAPI:
|
|||
|
||||
@app.post("/v1/tasks", status_code=201, tags=["tasks"])
|
||||
def create_task(body: TaskCreateRequest, user_id: UUID = Depends(require_user)):
|
||||
"""新建 task。`task_dir` 留空 → 默认派生 `workspace/tasks/<uuid>/`。
|
||||
`description` 与 `task_dir` 至少给一个否则 400。
|
||||
前缀嵌套(no-subtask,同 user 内)→ 409。
|
||||
"""新建 task。
|
||||
|
||||
- `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()
|
||||
mode = body.mode.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")
|
||||
skill = body.skill.strip()
|
||||
|
||||
tid = uuid4()
|
||||
from main import _default_task_dir, resolve_workspace
|
||||
ws = resolve_workspace(None)
|
||||
if task_dir_raw:
|
||||
fs_dir = Path(task_dir_raw).expanduser().resolve()
|
||||
else:
|
||||
fs_dir = _default_task_dir(ws, tid)
|
||||
fs_dir = working_dir_from_name(ws, user_id, wd_name)
|
||||
fs_dir_db = to_db_path(fs_dir)
|
||||
|
||||
try:
|
||||
|
|
@ -332,8 +346,11 @@ def create_app() -> FastAPI:
|
|||
except NoSubtaskError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
|
||||
# 工作目录立刻建出(同 working_dir 多 task 共享,exist_ok=True)
|
||||
fs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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,
|
||||
)
|
||||
with session_scope() as s:
|
||||
|
|
@ -388,6 +405,62 @@ def create_app() -> FastAPI:
|
|||
).scalar_one()
|
||||
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"])
|
||||
def patch_task(
|
||||
task_id: str,
|
||||
|
|
@ -408,8 +481,14 @@ def create_app() -> FastAPI:
|
|||
updates["status"] = body.status
|
||||
if body.description is not None:
|
||||
updates["description"] = body.description
|
||||
if body.mode is not None:
|
||||
updates["mode"] = body.mode
|
||||
if body.skill is not None:
|
||||
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:
|
||||
raise HTTPException(400, "no fields to update")
|
||||
with session_scope() as s:
|
||||
|
|
@ -484,7 +563,7 @@ def create_app() -> FastAPI:
|
|||
with session_scope() as s:
|
||||
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
|
||||
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 {
|
||||
"run_id": str(run_id),
|
||||
"events_url": f"/v1/tasks/{tid}/runs/{run_id}/events",
|
||||
|
|
@ -549,7 +628,7 @@ def create_app() -> FastAPI:
|
|||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""列子目录条目 + 面包屑。`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)
|
||||
entries, crumbs, exists = _enumerate_files(root, current)
|
||||
return {
|
||||
|
|
@ -568,7 +647,7 @@ def create_app() -> FastAPI:
|
|||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""下载单个 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)
|
||||
if not target.exists():
|
||||
raise HTTPException(404, f"file not found: {path}")
|
||||
|
|
@ -587,7 +666,7 @@ def create_app() -> FastAPI:
|
|||
路径不存在自动 mkdir(parents=True);重名直接覆盖。
|
||||
文件名严格校验(含 `/ \\ ..` 或为空 → 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)
|
||||
if dest_dir.exists() and not dest_dir.is_dir():
|
||||
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),
|
||||
):
|
||||
"""删 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)
|
||||
if target.resolve() == root.resolve():
|
||||
raise HTTPException(400, "cannot delete task_dir root")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
<meta charset="utf-8" />
|
||||
<title>zcbot dev</title>
|
||||
<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>
|
||||
:root {
|
||||
--bg: #f7f7f7;
|
||||
|
|
@ -23,6 +30,7 @@
|
|||
body {
|
||||
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text); background: var(--bg);
|
||||
overflow: hidden; /* 视窗锁死,所有滚动在 pane 内 */
|
||||
}
|
||||
button, input, textarea, select {
|
||||
font: inherit; color: inherit;
|
||||
|
|
@ -69,10 +77,11 @@
|
|||
header .who { color: var(--muted); font-size: 12px; font-family: monospace; }
|
||||
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-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); }
|
||||
#pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); }
|
||||
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */
|
||||
#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 {
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
|
|
@ -106,7 +115,9 @@
|
|||
#chat-meta .tid { font-family: monospace; color: var(--text); }
|
||||
#chat-meta .spacer { flex: 1; }
|
||||
#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 {
|
||||
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.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 .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; }
|
||||
@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 {
|
||||
margin-top: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
|
|
@ -135,6 +187,7 @@
|
|||
#chat-form {
|
||||
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
flex-shrink: 0; /* 输入区固定在底,不被消息挤压 */
|
||||
}
|
||||
#chat-form .row { display: flex; gap: 8px; }
|
||||
#chat-form textarea { flex: 1; }
|
||||
|
|
@ -226,6 +279,7 @@
|
|||
<button id="btn-export" class="small" disabled>export .docx</button>
|
||||
<button id="btn-done" class="small" disabled>done</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 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>
|
||||
|
|
@ -243,6 +297,7 @@
|
|||
<div id="pane-right">
|
||||
<div class="pane-head">
|
||||
<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>
|
||||
<button id="btn-refresh-files" class="small" disabled>↻</button>
|
||||
</div>
|
||||
|
|
@ -255,12 +310,16 @@
|
|||
<div id="new-task-modal">
|
||||
<div class="card">
|
||||
<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" />
|
||||
<label for="nt-mode">mode (可选,如 coding / writing)</label>
|
||||
<input id="nt-mode" />
|
||||
<label for="nt-dir">task_dir (可选,绑项目目录;留空 → 默认派生)</label>
|
||||
<input id="nt-dir" placeholder="例如 D:/projects/foo 或 留空" />
|
||||
<label for="nt-skill">skill (可选,智能体类型,如 coding / ppt / proposal)</label>
|
||||
<input id="nt-skill" />
|
||||
<div class="err" id="nt-err"></div>
|
||||
<div class="actions">
|
||||
<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 ─────
|
||||
$("li-uid").value = state.userId || SENTINEL;
|
||||
|
||||
|
|
@ -400,18 +483,21 @@ function renderTaskList(tasks) {
|
|||
}
|
||||
const html = tasks.map((t) => {
|
||||
const active = state.taskId === t.task_id ? " active" : "";
|
||||
const desc = t.description || "(no desc)";
|
||||
const dir = t.task_dir ? (" · " + t.task_dir.split("/").slice(-2).join("/")) : "";
|
||||
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
|
||||
const taskName = t.name || "(unnamed)";
|
||||
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
|
||||
const desc = t.description || "";
|
||||
return `
|
||||
<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">
|
||||
<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.tokens || 0} tok</span>
|
||||
</div>
|
||||
<div class="meta muted" title="${escapeHtml(t.task_dir || "")}">
|
||||
${t.task_id.slice(0, 8)}${escapeHtml(dir)}
|
||||
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -448,10 +534,15 @@ async function selectTask(tid) {
|
|||
function renderChatMeta() {
|
||||
const t = state.taskMeta;
|
||||
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 = `
|
||||
<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="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="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";
|
||||
$("btn-done").disabled = !active;
|
||||
$("btn-abandon").disabled = !active;
|
||||
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
|
||||
$("btn-export").disabled = (t.n_messages || 0) === 0;
|
||||
$("btn-refresh-files").disabled = false;
|
||||
}
|
||||
|
|
@ -495,7 +587,7 @@ function renderMessages(msgs) {
|
|||
card.className = "msg " + role;
|
||||
let html = `<div class="role">${role}</div>`;
|
||||
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) {
|
||||
for (const tc of p.tool_calls) {
|
||||
|
|
@ -510,6 +602,7 @@ function renderMessages(msgs) {
|
|||
}
|
||||
}
|
||||
card.innerHTML = html;
|
||||
highlightIn(card);
|
||||
wrap.appendChild(card);
|
||||
}
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
|
|
@ -569,7 +662,8 @@ async function fetchSse(url, asstCard) {
|
|||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let acc = "";
|
||||
// 流式 markdown:累积 raw 文本 → rAF 节流重渲染整段 body
|
||||
const ctx = { acc: "", body, pending: false };
|
||||
$("chat-hint").textContent = "streaming…";
|
||||
|
||||
while (true) {
|
||||
|
|
@ -583,15 +677,17 @@ async function fetchSse(url, asstCard) {
|
|||
buf = buf.slice(idx + 2);
|
||||
const ev = parseSseFrame(frame);
|
||||
if (!ev) continue;
|
||||
handleSseEvent(ev, body, asstCard);
|
||||
if (ev.event === "text" && ev.data && ev.data.delta) acc += ev.data.delta;
|
||||
handleSseEvent(ev, asstCard, ctx);
|
||||
if (ev.event === "done" || ev.event === "error") break;
|
||||
}
|
||||
}
|
||||
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
|
||||
body.innerHTML = renderMd(ctx.acc);
|
||||
body.classList.remove("streaming");
|
||||
highlightIn(asstCard);
|
||||
$("chat-send").disabled = false;
|
||||
$("chat-hint").textContent = "ready";
|
||||
// 最后刷新 task meta + messages(拿真实持久化的)
|
||||
// 刷新 task meta + messages(拿真实持久化的)
|
||||
loadTaskList();
|
||||
await loadMessages();
|
||||
}
|
||||
|
|
@ -611,11 +707,22 @@ function parseSseFrame(frame) {
|
|||
return { event, data };
|
||||
}
|
||||
|
||||
function handleSseEvent(ev, body, asstCard) {
|
||||
function handleSseEvent(ev, asstCard, ctx) {
|
||||
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) {
|
||||
body.textContent += ev.data.delta;
|
||||
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
|
||||
ctx.acc += ev.data.delta;
|
||||
// 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") {
|
||||
const fn = (ev.data && ev.data.name) || "?";
|
||||
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);
|
||||
appendErrorCard(msg);
|
||||
}
|
||||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
function appendErrorCard(msg) {
|
||||
|
|
@ -643,9 +751,10 @@ function appendErrorCard(msg) {
|
|||
$("chat-stream").scrollTop = $("chat-stream").scrollHeight;
|
||||
}
|
||||
|
||||
// ───── done / abandon / export ─────
|
||||
// ───── done / abandon / delete / export ─────
|
||||
$("btn-done").onclick = () => patchStatus("completed");
|
||||
$("btn-abandon").onclick = () => patchStatus("abandoned");
|
||||
$("btn-delete-task").onclick = deleteCurrentTask;
|
||||
|
||||
async function patchStatus(status) {
|
||||
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 = () => {
|
||||
if (!state.taskId) return;
|
||||
// 同源下载:把 token 注入临时 fetch,blob 落地再触发下载
|
||||
|
|
@ -688,7 +828,7 @@ async function loadFiles() {
|
|||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
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 = "";
|
||||
} else {
|
||||
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
|
||||
|
|
@ -698,10 +838,17 @@ async function loadFiles() {
|
|||
}
|
||||
|
||||
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 label = (i === 0 && projName) ? projName : c.label;
|
||||
const isLast = i === data.crumbs.length - 1;
|
||||
if (isLast) return `<span>${escapeHtml(c.label)}</span>`;
|
||||
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(c.label)}</a> /`;
|
||||
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
||||
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
||||
}).join(" ");
|
||||
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
||||
$("file-crumbs").querySelectorAll("a").forEach((a) => {
|
||||
|
|
@ -723,6 +870,7 @@ function renderFiles(data) {
|
|||
${escapeHtml(e.name)}
|
||||
</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>
|
||||
`;
|
||||
}).join("");
|
||||
|
|
@ -734,6 +882,21 @@ function renderFiles(data) {
|
|||
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) {
|
||||
|
|
@ -751,21 +914,25 @@ function downloadFile(rel) {
|
|||
}
|
||||
|
||||
// ───── new task ─────
|
||||
$("hd-new").onclick = () => {
|
||||
$("nt-desc").value = ""; $("nt-mode").value = ""; $("nt-dir").value = "";
|
||||
$("hd-new").onclick = async () => {
|
||||
$("nt-name").value = ""; $("nt-wd").value = "";
|
||||
$("nt-desc").value = ""; $("nt-skill").value = "";
|
||||
$("nt-err").textContent = "";
|
||||
$("nt-wd-hint").textContent = "";
|
||||
$("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-go").onclick = async () => {
|
||||
const name = $("nt-name").value.trim();
|
||||
const working_dir = $("nt-wd").value.trim();
|
||||
const desc = $("nt-desc").value.trim();
|
||||
const mode = $("nt-mode").value.trim();
|
||||
const dir = $("nt-dir").value.trim();
|
||||
const skill = $("nt-skill").value.trim();
|
||||
$("nt-err").textContent = "";
|
||||
if (!desc && !dir) { $("nt-err").textContent = "description 与 task_dir 至少填一个"; return; }
|
||||
if (!name) { $("nt-err").textContent = "任务名 必填"; return; }
|
||||
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");
|
||||
await loadTaskList();
|
||||
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 ─────
|
||||
if (state.token) {
|
||||
// 已有 token:试探一下,失败回登录页
|
||||
|
|
|
|||