diff --git a/DESIGN.md b/DESIGN.md index 26ad8ab..18e9985 100644 --- a/DESIGN.md +++ b/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_dir:仅 skill 产物,state/messages 在 PG +│ └── users// +│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆(user 级,dotfile 隔离) +│ └── / # 工作目录,用户起名(同 working_dir 多 task 共享),仅 skill 产物 └── {main.py, cli.py} ``` -**task_dir = `workspace/tasks//`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent。写错位置(cwd / `skills/` / repo 根)git status 立刻报红,不再用无锚 .gitignore 通配盖污染。 +**工作目录(working_dir) = `workspace/users///`,所有 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/` 换 `/`,布局不变。 **启动**:读 `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|]`(无参列最近 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|]`(无参列最近 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|] [--remote ]`;`tasks [--status ...]`。 +CLI:`chat --name "<任务名>" [--working-dir <目录名>] [--skill coding] [--desc "..."] [--resume last|] [--remote ]`;`tasks [--status ...]`。 ### 3.7 双层记忆(`core/memory.py`) -跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/memory/`: +跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/users//.memory/`(per-user,dotfile 隔离): | 层 | 文件 | 加载 | 适合 | |---|---|---|---| @@ -104,7 +110,7 @@ CLI:`chat --mode coding --desc "..." [--resume last|] [--remote ]`;`tas memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 —— 关键差异:**事实由用户判断,不由 LLM 自动总结**。 -**memory 永远在 FS,不入 DB**:本地 `workspace/memory/`,SaaS `/users//memory/`(bind mount 进容器)。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。 +**memory 永远在 FS,不入 DB**:统一 `/users//.memory/`(本地直接是 `workspace/`,SaaS 是 `/`,bind mount 进容器)。本地 CLI 走 SENTINEL user;web/JWT 走 `sub`。**dotfile `.memory/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免项目名取 `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//`(留空时派生) | `/users//tasks//`(留空时派生);用户指定时走 `/users///` | -| Memory | `workspace/memory/`(FS) | `/users//memory/`(仍是 FS) | +| task_dir 派生 | `workspace/users///`(`name` 必填,简单名) | `/users///`(`name` 必填,简单名) | +| Memory | `workspace/users//.memory/`(FS,dotfile) | `/users//.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//` 子树布局,差别只在外层根目录(`workspace/` vs `/`),不在 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//`),等价"一次性对话"(ChatGPT thread 体验) + - **新建必给 `name`**(简单名),`task_dir = workspace/users///`。同 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// + POST /v1/tasks 创建 {name(必填), working_dir?, description?, skill?}; + 留空 working_dir → 用 name 作目录名; + working_dir 派生 workspace/users///; + 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/`); --- ROOT 外 → 绝对 str(用户自指定项目目录);空串 → 未绑项目。SaaS 阶段同理(基础是 --- /users//)。读写边界统一过 core/paths.py::{to_db_path,from_db_path}。 +create index on tasks (user_id, working_dir); +-- working_dir 存储约定:本地 ROOT 内 → 相对 ROOT 的 posix 串 +-- (`workspace/users//`,name 是简单名,无 /\..); +-- 新建强制 `name` 必填,空串只可能在 legacy 数据(开发期已 wipe)。 +-- SaaS 阶段同理(基础是 /users//)。 +-- 读写边界统一过 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。 -**文件系统**: +**文件系统**(本地 `` = `workspace/`,SaaS 替换为部署根,布局不变): ``` /users// - memory/{core.md, extended/} # per-user,不入 DB - /... # task_dir 散落其下 + .memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB + / # 项目目录,name 用户起(必填),task_dir 直接落这 + /... # 同 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//` / SaaS `/users//tasks//`)或显式指定(走用户给路径);`tools/fs.py::_resolve` 接 task_dir 注入;system prompt 注入两形态共用 | 1 天 | +| 3 | **task_dir 字段语义**:新建必给 `name`(简单名),task_dir 派生为 `/users///`(本地 `` = `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 天 | diff --git a/PROGRESS.md b/PROGRESS.md index 51424b7..cad329a 100644 --- a/PROGRESS.md +++ b/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=`(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/ `GET /tasks/{id}/files/download?path=`(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` + `` 行渲染目录用蓝色 + `/` 后缀,文件用 `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 渲两个 `` 按钮(原生 `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` 时渲 `export .docx`(浏览器原生下载,无 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` 末尾加 `
` + 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 `)。**实现**:`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 `;`/new ` 自动复用当前 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:任务名 + 工作目录(配 `` 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` 旁加 ``(`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///`),**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///`;同 name 多 task 自动共享同目录(§7.1 task-primary)。**`name` 校验**(`main.py::validate_task_name`):非空 / 不含 `/\NUL` / 不以 `.` 起头(挡 `.memory` 等系统区)/ ≤ 255 字符;允许 CJK 与其他 Unicode。**memory 搬 dotfile**:`workspace/users//.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//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//` + 全局 `workspace/memory/` 改为 **`workspace/users//{tasks/,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//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 含 `` markdown、tool_call 含 `
`、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//` + `memory/{core.md, extended/}` | memory 跨 task 共享 | +| Workspace 布局 | `workspace/users//{.memory/, /}` | per-user 隔离;memory dotfile 防撞;`` 用户起项目名,同 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/ diff --git a/RUN.md b/RUN.md index abab4fc..a28256c 100644 --- a/RUN.md +++ b/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// -.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//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 /status /done /abandon /desc <文本> /export []` +REPL 内命令:`/exit /reset /new [] /resume [last|] /id /status /done /abandon /desc <文本> /export []`(`/new ` 用新任务名 + 沿用当前 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: ` + `data: `) | 必填 | @@ -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 ` | +| `--working-dir` 指定后 `/exit` 没清目录 | 设计如此 —— 工作目录绝不 rmtree(同 working_dir 多 task 共享);DB 行该删还是删。要彻底删手动 `rm -rf ` | | 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//`(默认派生 task_dir,只放 skill 产物) +- **Workspace**(per-user 子树,本地 CLI sentinel = `00000000-0000-0000-0000-000000000000`,web/JWT 用 sub): + - `workspace/users//.memory/{core.md, extended/}` —— 跨 task 记忆,FS 永久,dotfile 隔离 + - `workspace/users///` —— 工作目录,用户起的目录名(`cli chat --working-dir` 或留空 fallback `--name` / API `POST /v1/tasks {working_dir?}`),同 working_dir 多 task 共享 --- diff --git a/cli.py b/cli.py index 9d5c4d4..3af8640 100644 --- a/cli.py +++ b/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// 默认派生路径**且无产物时执行 —— - 用户用 `--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//") -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///,同名多 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 ` → 新 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) diff --git a/core/export_docx.py b/core/export_docx.py index 7677b36..bafcd5b 100644 --- a/core/export_docx.py +++ b/core/export_docx.py @@ -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//); - DB 也空 → 报错(无处放产物)。out_path 留空 → task_dir / chat_.docx。 + working_dir 留空 → 用 PG tasks.working_dir(用户指定模式可能不在默认派生路径下); + DB 也空 → 报错(无处放产物)。out_path 留空 → working_dir / chat_.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: diff --git a/core/memory.py b/core/memory.py index 53f7d79..5ca52e9 100644 --- a/core/memory.py +++ b/core/memory.py @@ -1,4 +1,4 @@ -"""双层记忆: `workspace/memory/`。 +"""双层记忆: `workspace/users//.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/` 命名**:跟用户起的项目目录(同样落 `/` 下)区分,避免 +项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` 拒,双向防呆。 +本地 CLI = SENTINEL user;web/JWT 用 sub。SaaS 化时 `` 替换 +`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) diff --git a/core/paths.py b/core/paths.py index fd9958b..6e51cec 100644 --- a/core/paths.py +++ b/core/paths.py @@ -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//`) +- 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//`) ROOT 外 → str(Path)(保留 OS 原生分隔符) 空 → "" """ diff --git a/core/session.py b/core/session.py index 0c04e52..ec8c750 100644 --- a/core/session.py +++ b/core/session.py @@ -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", ""), diff --git a/core/storage/models.py b/core/storage/models.py index a84a0dc..036ddf9 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -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="") diff --git a/core/storage/utils.py b/core/storage/utils.py index 085e37d..7140aef 100644 --- a/core/storage/utils.py +++ b/core/storage/utils.py @@ -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" ) diff --git a/core/task.py b/core/task.py index 79a6dde..566f734 100644 --- a/core/task.py +++ b/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, diff --git a/db/migrations/versions/20260517_1830_0003_task_name_and_working_dir.py b/db/migrations/versions/20260517_1830_0003_task_name_and_working_dir.py new file mode 100644 index 0000000..6dbb42d --- /dev/null +++ b/db/migrations/versions/20260517_1830_0003_task_name_and_working_dir.py @@ -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"]) diff --git a/main.py b/main.py index c0c9aa5..1f66998 100644 --- a/main.py +++ b/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_dir,只承担 skill 产物 -task_id 用 UUID,state.json 已删除(元数据全在 PG)。 +存储布局(§7.0 / §7.4):本地 + SaaS 共用 `workspace/` 根,只差 user_id: + + PG tasks / messages ← 元数据 + 消息 + workspace/users/// ← 工作目录(用户起名,可多 task 共享) + workspace/users//.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 子树根:`/users//`。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// 默认派生模板下。 +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: + """`/users//` 绝对路径。 + + 入参 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//) - 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 + 校验过), + 工作目录 = `/users///`。 + 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 文档里出现的 `` 占位符,一律指上面这个绝对路径。" - 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: diff --git a/skills/ppt/assets/icons/tabler_bolt_C00000_128.png b/skills/ppt/assets/icons/tabler_bolt_C00000_128.png new file mode 100644 index 0000000..fcd4190 Binary files /dev/null and b/skills/ppt/assets/icons/tabler_bolt_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_book_C00000_128.png b/skills/ppt/assets/icons/tabler_book_C00000_128.png new file mode 100644 index 0000000..9a90320 Binary files /dev/null and b/skills/ppt/assets/icons/tabler_book_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_code_C00000_128.png b/skills/ppt/assets/icons/tabler_code_C00000_128.png new file mode 100644 index 0000000..59848ed Binary files /dev/null and b/skills/ppt/assets/icons/tabler_code_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_info-circle_C00000_128.png b/skills/ppt/assets/icons/tabler_info-circle_C00000_128.png new file mode 100644 index 0000000..434fa80 Binary files /dev/null and b/skills/ppt/assets/icons/tabler_info-circle_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_package_C00000_128.png b/skills/ppt/assets/icons/tabler_package_C00000_128.png new file mode 100644 index 0000000..ca5b2c2 Binary files /dev/null and b/skills/ppt/assets/icons/tabler_package_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_shield_C00000_128.png b/skills/ppt/assets/icons/tabler_shield_C00000_128.png new file mode 100644 index 0000000..324e7c8 Binary files /dev/null and b/skills/ppt/assets/icons/tabler_shield_C00000_128.png differ diff --git a/skills/ppt/assets/icons/tabler_users_C00000_128.png b/skills/ppt/assets/icons/tabler_users_C00000_128.png index f2fe599..81dc957 100644 Binary files a/skills/ppt/assets/icons/tabler_users_C00000_128.png and b/skills/ppt/assets/icons/tabler_users_C00000_128.png differ diff --git a/web/app.py b/web/app.py index 12d0141..81d6550 100644 --- a/web/app.py +++ b/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//`。 - `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//` 下非 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") diff --git a/web/static/dev.html b/web/static/dev.html index 3492d62..fcd56e8 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -4,6 +4,13 @@ zcbot dev + + + + + + +