From 2b3692c8bf348b342c95c9bb4b6e5d0757cb0175 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 14 May 2026 11:38:05 +0800 Subject: [PATCH] =?UTF-8?q?core(=C2=A77=20B=20Step=204):=20--task-dir=20?= =?UTF-8?q?=E5=8F=8C=E5=BD=A2=E6=80=81=20+=20RUN.md=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=89=8B=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI `chat --task-dir ` 让用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地);留空走默认派生 workspace/tasks//。 - main.py::resolve_task_id 加 task_dir_arg;resume 时从 PG tasks.task_dir 读,空则降级默认派生。新增 is_managed_task_dir(td, ws) 判断 task_dir 是否在默认模板下。 - cli.py::_cleanup_if_empty 拿 workspace_dir 作保护开关 —— 用户自指定的 task_dir 绝不 rmtree(可能含用户已有素材);DB 行该删还是删。 - core/export_docx.py::export_chat_to_docx 重构:task_id 升一等参数(从 task_dir.name 提取改入参传入),task_dir 留空时自动从 PG 读;cli /export 与 cli.py export 子命令均改走 _resolve_uuid_or_prefix + task_id 直传。 - 新建 RUN.md(运行手册):env / 初始化 / 日常命令 / 故障兜底 / 关键路径。 - CLAUDE.md 加 RUN.md 维护规则(三文档边界:DESIGN=为什么 / PROGRESS=做到哪 / RUN=怎么跑),对外行为改动同步更 RUN。 Smoke 4 路径:default-derived(managed=True, cleanup rmtree)/ --task-dir (managed=False, FS preserved)/ resume reads DB task_dir / export 自动 PG 查路径,全绿。 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 +++ PROGRESS.md | 19 +++---- RUN.md | 125 ++++++++++++++++++++++++++++++++++++++++++++ cli.py | 72 ++++++++++++++++--------- core/export_docx.py | 29 +++++----- main.py | 71 +++++++++++++++++++------ 6 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 RUN.md diff --git a/CLAUDE.md b/CLAUDE.md index d4da472..8aaa8f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,3 +20,11 @@ - 实施中发现 DESIGN 描述与代码偏离 → 同步改回 bug 修复、重构、新加 skill、调参 —— **不动 DESIGN**,只更 PROGRESS。 + +**改任何对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局 / migration 步骤)→ 同步更新 `RUN.md`**: +- 新加 / 改 / 删 CLI 子命令 + 选项时,改"日常命令"段 +- env 变量 / 启动初始化变化时,改"环境" / "一次性初始化"段 +- 真实踩过的坑(用户报或自己跑出来),加一行到"故障兜底"表 +- 纯内部重构 / 不影响用户怎么跑的 —— **不动 RUN** + +三文档边界:`DESIGN`=为什么(架构 / 取舍),`PROGRESS`=做到哪(状态 / 历史),`RUN`=怎么跑(命令 / env / 兜底)。一次改动可能动多个,但每个动的理由要符合上述边界。 diff --git a/PROGRESS.md b/PROGRESS.md index 4cdf483..babb10a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-14(Step 3) +最后更新:2026-05-14(Step 4) --- @@ -15,7 +15,7 @@ | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | -| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 进行中(Step 1 基建 ✅;Step 2 Session ORM ✅;Step 3 TaskState ORM ✅;Step 4/6 待;Step 5 migrate-from-fs 取消)。Phase G(Web UI 简洁版)已上设计,排在 D 后。 | +| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 进行中(Step 1 基建 ✅;Step 2 Session ORM ✅;Step 3 TaskState ORM ✅;Step 4 task_dir 双形态 ✅;Step 6 待;Step 5 migrate-from-fs 取消)。Phase G(Web UI 简洁版)已上设计,排在 D 后。 | --- @@ -32,6 +32,7 @@ - **05-14 / §7 B Step 1 基建**:`core/storage/{engine,models}.py` SQLAlchemy 2.x ORM(users/tasks/messages/runs/usage_events 5 表)+ alembic(初版 migration `0001_initial_schema`,GIN/复合索引)+ `cli db {upgrade,downgrade,current}` 子命令组 + 本地 sentinel user(`00000000-...`)+ `ZCBOT_DB_URL` 必填(未设给清晰报错,不引导 docker)。已在远端测试 PG 跑通 `db upgrade head`。 - **05-14 / §7 B Step 2 Session ORM**:`core/session.py` 重写,messages 走 PG(append-only,jsonb,idx 严格递增);system prompt 不入库(每次 build_agent 重建);`Session.load(task_id, system_prompt=...)` resume 接口;`ensure_local_task_row` idempotent UPSERT(`INSERT ... ON CONFLICT DO NOTHING`)在首条非 system 消息前打底 tasks 行。task_id 切换为 UUID(原时间戳格式废弃,旧 workspace **不做兼容**)。main.py/cli.py 适配:`resolve_task_id`(UUID 前缀解析)、`_cleanup_if_empty` 双检查(DB messages + FS 产物)、`_list_task_rows` 改读 PG。`core/export_docx.py` 改从 PG 读 messages。端到端 build/append/resume/cleanup smoke 全绿。**取消 Step 5 migrate-from-fs**(用户决定不兼容旧 workspace)。 - **05-14 / §7 B Step 3 TaskState ORM**:`core/task.py` 重写,TaskState dataclass 保留为内存 DTO 但落地走 PG —— `save()` 调 `upsert_task`(INSERT ON CONFLICT DO UPDATE,显式 set `updated_at=func.now()`),`load(task_id)` 走 SELECT;**字段去掉 `cwd`**(改读 task_dir,§7 SaaS task_dir-as-identity)。`state.json` 文件**全面废除**,task_dir 只承担 skill 产物。`core/storage/utils.py` 加 `upsert_task` / `update_task` 工具。`main.py::sync_task_tokens` 改 `update_task(tokens_p,tokens_c)` 单字段 UPDATE(ORM-level update 自带 onupdate=func.now())。`core/session.py::Session.append` 的 ensure 调用补传 `mode/description/reasoning_effort`,避免首次 INSERT 后 _list_task_rows 看到空 meta。`cli.py` 全字段从 ORM Task 列读;`_cleanup_if_empty` 去 state.json 特例(任何 FS 文件 / 子目录都算实质痕迹);`/done /abandon /desc` 走 PG。`core/export_docx.py` meta 改从 `TaskState.load(tid)` 读(asdict 拿到 dict),去 CWD 字段。端到端 smoke:storage UPSERT/UPDATE round-trip + build_agent 懒创建 + Session.append 自动 INSERT 完整 meta + sync_task_tokens 局部 UPDATE + task_state.save UPSERT 保留 task_dir/tokens + export → .docx 37KB 全绿。 +- **05-14 / §7 B Step 4 task_dir 双形态**:CLI `chat --task-dir ` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks//`,显式走用户路径(绝对或相对 cwd,Path.resolve())。`main.py::resolve_task_id` 增 `task_dir_arg`;resume 时从 PG `tasks.task_dir` 读(`SELECT task_dir WHERE task_id=?`),空则降级默认派生。新增 `is_managed_task_dir(td, ws)` 判断是否在 `workspace/tasks//` 模板下,作 `_cleanup_if_empty` 保护开关 —— 用户自指定的项目目录**绝不 rmtree**(可能含用户已有文件);DB 行该删还是删。`core/export_docx.py::export_chat_to_docx` 重构:task_id 升一等参数(从 `task_dir.name` 提取改入参传入),task_dir 留空时自动从 PG 读,支持用户目录(非 UUID 命名)正常导出。cli `/export` 与 `cli.py export` 子命令均改走 `_resolve_uuid_or_prefix` + task_id 直传。Smoke 4 路径全绿:default-derived(managed=True, cleanup rmtree)/ --task-dir(managed=False, FS preserved)/ resume reads DB / export 自动 PG 查路径。 --- @@ -60,7 +61,7 @@ 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/export_docx.py 376 ← §7 B Step 2-3: meta 也走 PG +core/export_docx.py 379 ← §7 B Step 2-4: task_id 升一等 core/storage/__init__.py 27 ← §7 B Step 1-3 core/storage/engine.py 80 ← §7 B Step 1 core/storage/models.py 124 ← §7 B Step 1 @@ -70,13 +71,13 @@ tools/fs.py 182 tools/shell.py 94 tools/run_python.py 84 tools/skill_tool.py 45 -main.py 231 ← §7 B Step 3: sync_task_tokens UPDATE -cli.py 516 ← §7 B Step 3: _list_task_rows 全 DB +main.py 272 ← §7 B Step 4: +is_managed_task_dir / task_dir_arg +cli.py 538 ← §7 B Step 4: --task-dir / cleanup 保护用户目录 db/migrations/env.py 61 ← §7 B Step 1 db/migrations/versions/ 0001_initial_schema.py 125 ← §7 B Step 1 ───────────────────────────────── -Python 合计 ~3035 行 +Python 合计 ~3101 行 ``` 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 @@ -85,9 +86,9 @@ Python 合计 ~3035 行 ## 下一步候选(性价比排序) -1. **§7 B 剩余 Step 4 / 6**(~0.5 天) - - Step 4 main.py / cli.py 已 Step 3 收尾(`_list_task_rows` 全 DB / state.json 路径已删);剩 task_dir 字段语义在 §7.6 #3 还要补:留空时默认派生(目前已默认为 `workspace/tasks//`,但用户显式指定还没上) - - Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade) +1. **§7 B 剩余 Step 6**(~0.5 天) + - Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade,放 build_agent / Folder API 入口) + - ~~Step 4 task_dir 双形态~~ ✅(05-14) - ~~Step 5 migrate-from-fs~~(取消,不兼容旧 workspace) 2. **§7 Phase G Web UI 简洁版**(~2-3 天)—— FastAPI + Jinja2 + HTMX + SSE,task list / chat / folder tree / 文件上传下载;依赖 D(HTTP /v1)的 SSE 端点,与 E 无强序。 3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 diff --git a/RUN.md b/RUN.md new file mode 100644 index 0000000..84e3c5c --- /dev/null +++ b/RUN.md @@ -0,0 +1,125 @@ +# 运行手册 + +> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 + +最后更新:2026-05-14(Step 4) + +--- + +## 环境 + +- **Python**:虚拟环境 `.venv/`,所有依赖装在里面。一律用 `.venv/Scripts/python.exe ...`(Windows)/`.venv/bin/python ...`(Unix),不要全局 `python`(litellm/python-pptx 等会 ModuleNotFoundError)。 +- **配置文件 `.env`**(项目根,git 忽略,litellm 自动加载): + ``` + DEEPSEEK_API_KEY=sk-... + ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot + ``` + > litellm 在 import 时副作用加载 .env;CLI 入口直接走 `cli.py`,`.env` 会自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。 +- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里)。 +- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose 起 / 远端 dev / 生产任选。未设置时启动会清晰报错,不引导 docker(§7.4)。 + +--- + +## 一次性初始化 + +```bash +# 1) 装依赖(若 .venv 不在) +python -m venv .venv +.venv/Scripts/python.exe -m pip install -r requirements.txt + +# 2) 准备 .env(见上) + +# 3) DB schema 上车 +.venv/Scripts/python.exe cli.py db upgrade head +.venv/Scripts/python.exe cli.py db current # 应输出 0001 (head) +``` + +--- + +## 日常命令 + +### 聊天 / 任务 + +```bash +# 新建 task,默认派生 workspace/tasks// +.venv/Scripts/python.exe cli.py chat + +# 带模式 + 描述(便于后续 list 识别) +.venv/Scripts/python.exe cli.py chat --mode coding --desc "修 X 的 Y" + +# 项目化 task —— 产物落到指定目录(§7.1 task-primary + dir 副视图) +.venv/Scripts/python.exe cli.py chat --task-dir /path/to/proj --mode proposal + +# 恢复最近一个 task +.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 +``` + +REPL 内命令:`/exit /reset /new /resume [last|] /id /status /done /abandon /desc <文本> /export []` + +### 列表 / 导出 + +```bash +# 看最近 20 个 task +.venv/Scripts/python.exe cli.py tasks + +# 只看 active +.venv/Scripts/python.exe cli.py tasks --status active --limit 50 + +# 导出某 task 的对话为 .docx(自动从 PG 找 task_dir 作为输出目录) +.venv/Scripts/python.exe cli.py export 76c6bd25 + +# 导出最近的 +.venv/Scripts/python.exe cli.py export last -o /tmp/chat.docx +``` + +### 能力探测 / DB 管理 + +```bash +# 实测对账模型 yaml 声称的能力(费 token,有 API 开销) +.venv/Scripts/python.exe cli.py probe --model deepseek_v4.flash + +# DB migration +.venv/Scripts/python.exe cli.py db upgrade head +.venv/Scripts/python.exe cli.py db downgrade -1 +.venv/Scripts/python.exe cli.py db current +``` + +--- + +## 故障兜底 + +| 现象 | 原因 / 处理 | +|---|---| +| `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发。直跑脚本时 `import litellm`,或 `export ZCBOT_DB_URL=...` | +| `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` | +| 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 ` | +| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | + +--- + +## 关键路径与文件 + +- **入口**:`cli.py`(REPL + 子命令)→ `main.py::build_agent`(装配) +- **核心**:`core/loop.py`(ReAct)/ `core/session.py`(PG messages)/ `core/task.py`(PG tasks)/ `core/llm.py`(LiteLLM 封装) +- **工具**:`tools/{fs,shell,run_python,skill_tool}.py` +- **存储**:`core/storage/{engine,models,utils}.py`(SQLAlchemy 2.x ORM)+ `db/migrations/`(alembic) +- **配置**:`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 产物) + +--- + +## 维护约定 + +- **每改一个对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局)→ 同步更新本文档**。bug 修不动这个,只动 PROGRESS。 +- 故障兜底表新增条目:用过一次的真实坑,写一行(现象 + 处理),不预测。 +- 跟 DESIGN/PROGRESS 的边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。 diff --git a/cli.py b/cli.py index bfa4f73..4f7480a 100644 --- a/cli.py +++ b/cli.py @@ -22,6 +22,7 @@ from rich.table import Table from core.ui import make_console from main import ( ROOT, + _resolve_uuid_or_prefix, build_agent, load_config, resolve_workspace, @@ -80,20 +81,23 @@ def db_current() -> None: _run_alembic(command.current) -def _cleanup_if_empty(task_dir, session, console=None) -> bool: - """切走前清理空 task。两条都满足才删: - 1) session 在内存没有 user 消息 - 2) task_dir 在 FS 上无产物(懒创建后没说话就没目录,直接 no-op) +def _cleanup_if_empty(task_dir, session, workspace_dir, console=None) -> bool: + """切走前清理空 task。 - Step 3 后 state.json 已废除,task_dir 只承担 skill 产物。任何文件 / 子目录 - 都算实质痕迹,保留 task。DB tasks 行随之 DELETE(messages 走 CASCADE)。 + DB 行无条件删除(若存在且 session 内存无 user 消息)。 + FS rmtree **仅在 task_dir 是 workspace/tasks// 默认派生路径**且无产物时执行 —— + 用户用 `--task-dir` 指定的项目目录绝不 rmtree(可能含用户已有文件)。 """ + from main import is_managed_task_dir + 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 占位行(若 Session 早调过 ensure_local_task_row) + # 目录都没建,只清 DB 占位行 _delete_task_db_row(session.task_id) return False meaningful = [ @@ -102,10 +106,12 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool: ] if meaningful: return False - shutil.rmtree(task_dir, ignore_errors=True) + if managed: + shutil.rmtree(task_dir, ignore_errors=True) _delete_task_db_row(session.task_id) if console is not None: - console.print(f"[muted]cleaned empty task {str(session.task_id)[:8]}[/muted]") + tag = "empty" if managed else "empty (kept user dir)" + console.print(f"[muted]cleaned {tag} task {str(session.task_id)[:8]}[/muted]") return True @@ -175,9 +181,13 @@ def _list_task_rows(workspace_dir, limit=20, status=None): @click.option("--resume", default=None, help="恢复 task: 'last' 或 task_id") @click.option("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)") @click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别") -def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: +@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 接老的。""" console = make_console() + ws_dir = resolve_workspace(workspace) try: agent, session, sid, task_state, task_dir = build_agent( model_name=model, @@ -187,6 +197,7 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: resume=bool(resume), mode=mode, description=desc, + task_dir_arg=task_dir_arg, ) except Exception as e: console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}") @@ -217,23 +228,24 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: user_input = Prompt.ask("[user]you[/user]", console=console) except (EOFError, KeyboardInterrupt): console.print("\n[muted]bye[/muted]") - _cleanup_if_empty(task_dir, session, console) + _cleanup_if_empty(task_dir, session, ws_dir, console) break cmd = user_input.strip() if cmd in ("/exit", "/quit"): - _cleanup_if_empty(task_dir, session, console) + _cleanup_if_empty(task_dir, session, ws_dir, console) break if cmd == "/reset": session.reset(keep_system=True) console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]") continue if cmd == "/new": - _cleanup_if_empty(task_dir, session, console) + _cleanup_if_empty(task_dir, session, ws_dir, console) 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, ) except Exception as e: console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}") @@ -242,7 +254,6 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: continue if cmd.startswith("/resume"): arg = cmd[len("/resume"):].strip() - ws_dir = resolve_workspace(workspace) target_id = None if arg == "last": rs = _list_task_rows(ws_dir, limit=1) @@ -289,7 +300,7 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: if target_id == sid: console.print(f"[info]已是当前 task: {sid}[/info]") continue - _cleanup_if_empty(task_dir, session, console) + _cleanup_if_empty(task_dir, session, ws_dir, console) try: agent, session, sid, task_state, task_dir = build_agent( model_name=model, workspace=workspace, console=console, @@ -335,24 +346,31 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None: continue if cmd.startswith("/export"): arg = cmd[len("/export"):].strip() - target_dir = task_dir + from uuid import UUID if arg: - ws_dir = resolve_workspace(workspace) if arg == "last": rs = _list_task_rows(ws_dir, limit=1) if not rs: console.print("[warn]没有 task 可导出[/warn]") continue arg = rs[0][1] - target_dir = tasks_dir(ws_dir) / arg - if not _task_has_messages(target_dir.name): + try: + target_tid = _resolve_uuid_or_prefix(arg) + except Exception as e: + console.print(f"[err]task_id 解析失败:[/err] {type(e).__name__}: {e}") + continue + target_dir = None # 让 export_chat_to_docx 从 PG 读 task_dir + else: + target_tid = UUID(sid) + target_dir = task_dir + if not _task_has_messages(str(target_tid)): console.print( - f"[warn]无可导出内容: {target_dir.name[:8]} 还没有消息[/warn]" + f"[warn]无可导出内容: {str(target_tid)[:8]} 还没有消息[/warn]" ) continue try: from core.export_docx import export_chat_to_docx - out = export_chat_to_docx(target_dir) + out = export_chat_to_docx(target_tid, target_dir) except Exception as e: console.print(f"[err]导出失败:[/err] {type(e).__name__}: {e}") continue @@ -429,15 +447,19 @@ def export(task_id: str, workspace: str, output: str, include_system: bool, sys.exit(1) task_id = rs[0][1] - td = tasks_dir(ws) / task_id - if not _task_has_messages(task_id): - console.print(f"[err]task 不存在或无 messages:[/err] {task_id}") + try: + tid = _resolve_uuid_or_prefix(task_id) + except Exception as e: + console.print(f"[err]task_id 解析失败:[/err] {type(e).__name__}: {e}") + sys.exit(1) + if not _task_has_messages(str(tid)): + console.print(f"[err]task 不存在或无 messages:[/err] {tid}") sys.exit(1) out = Path(output).resolve() if output else None try: path = export_chat_to_docx( - td, out, + tid, None, out, include_system=include_system, include_reasoning=not no_reasoning, tool_head=tool_head, diff --git a/core/export_docx.py b/core/export_docx.py index 0cb1448..b1846e9 100644 --- a/core/export_docx.py +++ b/core/export_docx.py @@ -8,7 +8,7 @@ - tool_calls 把 function 名 + 参数 JSON 单列展示 调用入口: -- 顶层函数 export_chat_to_docx(task_dir, out_path=None, ...) +- 顶层函数 export_chat_to_docx(task_id, task_dir=None, out_path=None, ...) - CLI 子命令 `python cli.py export ` 与 REPL `/export []` 都走它 §7 B Step 3 后:meta 和 messages 都从 PG 读(state.json 已废除)。 @@ -315,7 +315,8 @@ def _render_message( # ───────────────────────── 顶层入口 ───────────────────────── def export_chat_to_docx( - task_dir: Path, + task_id: UUID, + task_dir: Optional[Path] = None, out_path: Optional[Path] = None, *, include_system: bool = False, @@ -325,14 +326,10 @@ def export_chat_to_docx( ) -> Path: """渲染 task 对话为 .docx,返回写入路径。 - task_dir 目录名必须是 UUID(messages / tasks 元数据都按该 task_id 从 PG 读)。 + task_id 是主标识(从 PG 读 messages + 元数据)。 + task_dir 留空 → 用 PG tasks.task_dir(用户指定模式可能不在 workspace/tasks//); + DB 也空 → 报错(无处放产物)。out_path 留空 → task_dir / chat_.docx。 """ - try: - tid = UUID(task_dir.name) - except ValueError: - raise ValueError(f"task_dir name 不是有效 UUID: {task_dir.name}") - - # 从 PG 读 messages 与 tasks 元数据 from dataclasses import asdict from sqlalchemy import select from core.storage import session_scope @@ -340,18 +337,24 @@ def export_chat_to_docx( with session_scope() as s: rows = s.execute( - select(MessageRow).where(MessageRow.task_id == tid).order_by(MessageRow.idx) + select(MessageRow).where(MessageRow.task_id == task_id).order_by(MessageRow.idx) ).scalars().all() messages = [dict(r.payload) for r in rows] - st = TaskState.load(tid) + 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 not td_str: + raise ValueError(f"task {task_id} 无 task_dir(PG 未存且未传参) —— 无处放 .docx") + task_dir = Path(td_str) + if out_path is None: - out_path = task_dir / f"chat_{tid}.docx" + out_path = task_dir / f"chat_{task_id}.docx" meta = { - "id": str(tid), + "id": str(task_id), "model": task_state.get("model", ""), "model_profile": task_state.get("model_profile", ""), "created_at": task_state.get("created_at", ""), diff --git a/main.py b/main.py index cd3eb51..4748785 100644 --- a/main.py +++ b/main.py @@ -49,15 +49,44 @@ def tasks_dir(workspace_dir: Path) -> Path: return d -def resolve_task_id( - workspace_dir: Path, task_id_arg: Optional[str], resume: bool -) -> Tuple[UUID, Path]: - """返回 (task_id, task_dir)。 +def _default_task_dir(workspace_dir: Path, task_id: UUID) -> Path: + return tasks_dir(workspace_dir) / str(task_id) - 新建:UUID + workspace/tasks//(懒创建,目录不预占) - Resume:解析 task_id_arg 为 UUID(支持前缀匹配);'last' 取最近(按 PG tasks.updated_at) + +def is_managed_task_dir(task_dir: Path, workspace_dir: Path) -> bool: + """task_dir 是否在 workspace/tasks// 默认派生模板下。 + + 用作 _cleanup_if_empty 的保护开关 —— 用户自指定的项目目录绝不 rmtree。 + """ + 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 + + +def resolve_task_id( + workspace_dir: Path, + task_id_arg: Optional[str], + resume: bool, + task_dir_arg: Optional[str] = None, +) -> Tuple[UUID, Path]: + """返回 (task_id, task_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) """ - tdir = tasks_dir(workspace_dir) if resume: from sqlalchemy import select from core.storage import session_scope @@ -66,18 +95,29 @@ def resolve_task_id( if task_id_arg in (None, "", "last"): with session_scope() as s: row = s.execute( - select(Task.task_id).order_by(Task.updated_at.desc()).limit(1) - ).scalar_one_or_none() + select(Task.task_id, Task.task_dir) + .order_by(Task.updated_at.desc()).limit(1) + ).first() if row is None: raise FileNotFoundError("no recoverable task: PG tasks 表为空") - return row, tdir / str(row) + tid, db_dir = row + else: + 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) + ).scalar_one_or_none() or "" - # 接受完整 UUID 或前缀(8 字符够辨识本机量级) - tid = _resolve_uuid_or_prefix(task_id_arg) - return tid, tdir / str(tid) + chosen = task_dir_arg.strip() if task_dir_arg else db_dir + fs_dir = Path(chosen).expanduser().resolve() if chosen else _default_task_dir(workspace_dir, tid) + return tid, fs_dir tid = uuid4() - return tid, tdir / str(tid) + 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 def _resolve_uuid_or_prefix(s: str) -> UUID: @@ -139,6 +179,7 @@ def build_agent( tool_base: Optional[Path] = None, mode: str = "", description: str = "", + task_dir_arg: Optional[str] = None, ) -> Tuple[AgentLoop, Session, str, TaskState, Path]: """返回 (agent, session, task_id_str, task_state, task_dir)。""" cfg = load_config() @@ -151,7 +192,7 @@ 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_id, task_dir = resolve_task_id(workspace_dir, session_id, resume, task_dir_arg) sid = str(task_id) tool_base = Path(tool_base) if tool_base else Path.cwd()