core(§7 B Step 4): --task-dir 双形态 + RUN.md 运行手册

- CLI `chat --task-dir <path>` 让用户显式指定项目目录(§7.1 task-primary +
  dir 副视图心智模型落地);留空走默认派生 workspace/tasks/<uuid>/。
- 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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-14 11:38:05 +08:00
parent aeecc7f0f3
commit 2b3692c8bf
6 changed files with 262 additions and 62 deletions

View File

@ -20,3 +20,11 @@
- 实施中发现 DESIGN 描述与代码偏离 → 同步改回 - 实施中发现 DESIGN 描述与代码偏离 → 同步改回
bug 修复、重构、新加 skill、调参 —— **不动 DESIGN**,只更 PROGRESS。 bug 修复、重构、新加 skill、调参 —— **不动 DESIGN**,只更 PROGRESS。
**改任何对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局 / migration 步骤)→ 同步更新 `RUN.md`**:
- 新加 / 改 / 删 CLI 子命令 + 选项时,改"日常命令"段
- env 变量 / 启动初始化变化时,改"环境" / "一次性初始化"段
- 真实踩过的坑(用户报或自己跑出来),加一行到"故障兜底"表
- 纯内部重构 / 不影响用户怎么跑的 —— **不动 RUN**
三文档边界:`DESIGN`=为什么(架构 / 取舍),`PROGRESS`=做到哪(状态 / 历史),`RUN`=怎么跑(命令 / env / 兜底)。一次改动可能动多个,但每个动的理由要符合上述边界。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-14(Step 3) 最后更新:2026-05-14(Step 4)
--- ---
@ -15,7 +15,7 @@
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 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 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 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 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 <path>` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks/<uuid>/`,显式走用户路径(绝对或相对 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/<uuid>/` 模板下,作 `_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/skills.py 81
core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd
core/memory.py 76 core/memory.py 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/__init__.py 27 ← §7 B Step 1-3
core/storage/engine.py 80 ← §7 B Step 1 core/storage/engine.py 80 ← §7 B Step 1
core/storage/models.py 124 ← §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/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 tools/skill_tool.py 45
main.py 231 ← §7 B Step 3: sync_task_tokens UPDATE main.py 272 ← §7 B Step 4: +is_managed_task_dir / task_dir_arg
cli.py 516 ← §7 B Step 3: _list_task_rows 全 DB cli.py 538 ← §7 B Step 4: --task-dir / cleanup 保护用户目录
db/migrations/env.py 61 ← §7 B Step 1 db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 0001_initial_schema.py 125 ← §7 B Step 1
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3035 Python 合计 ~3101
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 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 天) 1. **§7 B 剩余 Step 6**(~0.5 天)
- Step 4 main.py / cli.py 已 Step 3 收尾(`_list_task_rows` 全 DB / state.json 路径已删);剩 task_dir 字段语义在 §7.6 #3 还要补:留空时默认派生(目前已默认为 `workspace/tasks/<uuid>/`,但用户显式指定还没上) - Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade,放 build_agent / Folder API 入口)
- Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade) - ~~Step 4 task_dir 双形态~~ ✅(05-14)
- ~~Step 5 migrate-from-fs~~(取消,不兼容旧 workspace) - ~~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 无强序。 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 长上下文一般用不到。 3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。

125
RUN.md Normal file
View File

@ -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/<uuid>/
.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>] /id /status /done /abandon /desc <文本> /export [<id>]`
### 列表 / 导出
```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 <dir>` |
| 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/<uuid>/`(默认派生 task_dir,只放 skill 产物)
---
## 维护约定
- **每改一个对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局)→ 同步更新本文档**。bug 修不动这个,只动 PROGRESS。
- 故障兜底表新增条目:用过一次的真实坑,写一行(现象 + 处理),不预测。
- 跟 DESIGN/PROGRESS 的边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。

72
cli.py
View File

@ -22,6 +22,7 @@ from rich.table import Table
from core.ui import make_console from core.ui import make_console
from main import ( from main import (
ROOT, ROOT,
_resolve_uuid_or_prefix,
build_agent, build_agent,
load_config, load_config,
resolve_workspace, resolve_workspace,
@ -80,20 +81,23 @@ def db_current() -> None:
_run_alembic(command.current) _run_alembic(command.current)
def _cleanup_if_empty(task_dir, session, console=None) -> bool: def _cleanup_if_empty(task_dir, session, workspace_dir, console=None) -> bool:
"""切走前清理空 task。两条都满足才删: """切走前清理空 task。
1) session 在内存没有 user 消息
2) task_dir FS 上无产物(懒创建后没说话就没目录,直接 no-op)
Step 3 state.json 已废除,task_dir 只承担 skill 产物任何文件 / 子目录 DB 行无条件删除(若存在且 session 内存无 user 消息)
都算实质痕迹,保留 taskDB tasks 行随之 DELETE(messages CASCADE) FS rmtree **仅在 task_dir workspace/tasks/<uuid>/ 默认派生路径**且无产物时执行
用户用 `--task-dir` 指定的项目目录绝不 rmtree(可能含用户已有文件)
""" """
from main import is_managed_task_dir
if session.n_user_msgs() > 0: if session.n_user_msgs() > 0:
return False return False
managed = is_managed_task_dir(task_dir, workspace_dir)
try: try:
entries = list(task_dir.iterdir()) entries = list(task_dir.iterdir())
except FileNotFoundError: except FileNotFoundError:
# 目录都没建,只清 DB 占位行(若 Session 早调过 ensure_local_task_row) # 目录都没建,只清 DB 占位行
_delete_task_db_row(session.task_id) _delete_task_db_row(session.task_id)
return False return False
meaningful = [ meaningful = [
@ -102,10 +106,12 @@ def _cleanup_if_empty(task_dir, session, console=None) -> bool:
] ]
if meaningful: if meaningful:
return False 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) _delete_task_db_row(session.task_id)
if console is not None: 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 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("--resume", default=None, help="恢复 task: 'last' 或 task_id")
@click.option("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)") @click.option("--mode", default="", help="任务模式标签(coding/ppt/proposal/...自由形式)")
@click.option("--desc", default="", help="一句话任务描述,便于 tasks 列表识别") @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/<uuid>/")
def chat(model: str, workspace: str, resume: str, mode: str, desc: str,
task_dir_arg: str) -> None:
"""启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。""" """启动交互式 REPL。每次启动默认开新 task,用 --resume 接老的。"""
console = make_console() console = make_console()
ws_dir = resolve_workspace(workspace)
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, model_name=model,
@ -187,6 +197,7 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
resume=bool(resume), resume=bool(resume),
mode=mode, mode=mode,
description=desc, description=desc,
task_dir_arg=task_dir_arg,
) )
except Exception as e: except Exception as e:
console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}") console.print(f"[err]启动失败:[/err] {type(e).__name__}: {e}")
@ -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) user_input = Prompt.ask("[user]you[/user]", console=console)
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
console.print("\n[muted]bye[/muted]") console.print("\n[muted]bye[/muted]")
_cleanup_if_empty(task_dir, session, console) _cleanup_if_empty(task_dir, session, ws_dir, console)
break break
cmd = user_input.strip() cmd = user_input.strip()
if cmd in ("/exit", "/quit"): if cmd in ("/exit", "/quit"):
_cleanup_if_empty(task_dir, session, console) _cleanup_if_empty(task_dir, session, ws_dir, console)
break break
if cmd == "/reset": if cmd == "/reset":
session.reset(keep_system=True) session.reset(keep_system=True)
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]") console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue continue
if cmd == "/new": if cmd == "/new":
_cleanup_if_empty(task_dir, session, console) _cleanup_if_empty(task_dir, session, ws_dir, console)
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console, model_name=model, workspace=workspace, console=console,
mode=mode, description=desc, mode=mode, description=desc,
task_dir_arg=task_dir_arg,
) )
except Exception as e: except Exception as e:
console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}") console.print(f"[err]新建失败:[/err] {type(e).__name__}: {e}")
@ -242,7 +254,6 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
continue continue
if cmd.startswith("/resume"): if cmd.startswith("/resume"):
arg = cmd[len("/resume"):].strip() arg = cmd[len("/resume"):].strip()
ws_dir = resolve_workspace(workspace)
target_id = None target_id = None
if arg == "last": if arg == "last":
rs = _list_task_rows(ws_dir, limit=1) 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: if target_id == sid:
console.print(f"[info]已是当前 task: {sid}[/info]") console.print(f"[info]已是当前 task: {sid}[/info]")
continue continue
_cleanup_if_empty(task_dir, session, console) _cleanup_if_empty(task_dir, session, ws_dir, console)
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console, model_name=model, workspace=workspace, console=console,
@ -335,24 +346,31 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
continue continue
if cmd.startswith("/export"): if cmd.startswith("/export"):
arg = cmd[len("/export"):].strip() arg = cmd[len("/export"):].strip()
target_dir = task_dir from uuid import UUID
if arg: if arg:
ws_dir = resolve_workspace(workspace)
if arg == "last": if arg == "last":
rs = _list_task_rows(ws_dir, limit=1) rs = _list_task_rows(ws_dir, limit=1)
if not rs: if not rs:
console.print("[warn]没有 task 可导出[/warn]") console.print("[warn]没有 task 可导出[/warn]")
continue continue
arg = rs[0][1] arg = rs[0][1]
target_dir = tasks_dir(ws_dir) / arg try:
if not _task_has_messages(target_dir.name): 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( console.print(
f"[warn]无可导出内容: {target_dir.name[:8]} 还没有消息[/warn]" f"[warn]无可导出内容: {str(target_tid)[:8]} 还没有消息[/warn]"
) )
continue continue
try: try:
from core.export_docx import export_chat_to_docx 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: except Exception as e:
console.print(f"[err]导出失败:[/err] {type(e).__name__}: {e}") console.print(f"[err]导出失败:[/err] {type(e).__name__}: {e}")
continue continue
@ -429,15 +447,19 @@ def export(task_id: str, workspace: str, output: str, include_system: bool,
sys.exit(1) sys.exit(1)
task_id = rs[0][1] task_id = rs[0][1]
td = tasks_dir(ws) / task_id try:
if not _task_has_messages(task_id): tid = _resolve_uuid_or_prefix(task_id)
console.print(f"[err]task 不存在或无 messages:[/err] {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) sys.exit(1)
out = Path(output).resolve() if output else None out = Path(output).resolve() if output else None
try: try:
path = export_chat_to_docx( path = export_chat_to_docx(
td, out, tid, None, out,
include_system=include_system, include_system=include_system,
include_reasoning=not no_reasoning, include_reasoning=not no_reasoning,
tool_head=tool_head, tool_head=tool_head,

View File

@ -8,7 +8,7 @@
- tool_calls function + 参数 JSON 单列展示 - 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 <task_id>` REPL `/export [<task_id>]` 都走它 - CLI 子命令 `python cli.py export <task_id>` REPL `/export [<task_id>]` 都走它
§7 B Step 3 :meta messages 都从 PG (state.json 已废除) §7 B Step 3 :meta messages 都从 PG (state.json 已废除)
@ -315,7 +315,8 @@ def _render_message(
# ───────────────────────── 顶层入口 ───────────────────────── # ───────────────────────── 顶层入口 ─────────────────────────
def export_chat_to_docx( def export_chat_to_docx(
task_dir: Path, task_id: UUID,
task_dir: Optional[Path] = None,
out_path: Optional[Path] = None, out_path: Optional[Path] = None,
*, *,
include_system: bool = False, include_system: bool = False,
@ -325,14 +326,10 @@ def export_chat_to_docx(
) -> Path: ) -> Path:
"""渲染 task 对话为 .docx,返回写入路径。 """渲染 task 对话为 .docx,返回写入路径。
task_dir 目录名必须是 UUID(messages / tasks 元数据都按该 task_id PG ) task_id 是主标识( PG messages + 元数据)
task_dir 留空 PG tasks.task_dir(用户指定模式可能不在 workspace/tasks/<uuid>/);
DB 也空 报错(无处放产物)out_path 留空 task_dir / chat_<uuid>.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 dataclasses import asdict
from sqlalchemy import select from sqlalchemy import select
from core.storage import session_scope from core.storage import session_scope
@ -340,18 +337,24 @@ def export_chat_to_docx(
with session_scope() as s: with session_scope() as s:
rows = s.execute( 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() ).scalars().all()
messages = [dict(r.payload) for r in rows] 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 {} 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: if out_path is None:
out_path = task_dir / f"chat_{tid}.docx" out_path = task_dir / f"chat_{task_id}.docx"
meta = { meta = {
"id": str(tid), "id": str(task_id),
"model": task_state.get("model", ""), "model": task_state.get("model", ""),
"model_profile": task_state.get("model_profile", ""), "model_profile": task_state.get("model_profile", ""),
"created_at": task_state.get("created_at", ""), "created_at": task_state.get("created_at", ""),

71
main.py
View File

@ -49,15 +49,44 @@ def tasks_dir(workspace_dir: Path) -> Path:
return d return d
def resolve_task_id( def _default_task_dir(workspace_dir: Path, task_id: UUID) -> Path:
workspace_dir: Path, task_id_arg: Optional[str], resume: bool return tasks_dir(workspace_dir) / str(task_id)
) -> Tuple[UUID, Path]:
"""返回 (task_id, task_dir)。
新建:UUID + workspace/tasks/<uuid>/(懒创建,目录不预占)
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/<uuid>/ 默认派生模板下。
用作 _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/<uuid>/)
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: if resume:
from sqlalchemy import select from sqlalchemy import select
from core.storage import session_scope from core.storage import session_scope
@ -66,18 +95,29 @@ def resolve_task_id(
if task_id_arg in (None, "", "last"): if task_id_arg in (None, "", "last"):
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(Task.task_id).order_by(Task.updated_at.desc()).limit(1) select(Task.task_id, Task.task_dir)
).scalar_one_or_none() .order_by(Task.updated_at.desc()).limit(1)
).first()
if row is None: if row is None:
raise FileNotFoundError("no recoverable task: PG tasks 表为空") 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 字符够辨识本机量级) chosen = task_dir_arg.strip() if task_dir_arg else db_dir
tid = _resolve_uuid_or_prefix(task_id_arg) fs_dir = Path(chosen).expanduser().resolve() if chosen else _default_task_dir(workspace_dir, tid)
return tid, tdir / str(tid) return tid, fs_dir
tid = uuid4() 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: def _resolve_uuid_or_prefix(s: str) -> UUID:
@ -139,6 +179,7 @@ def build_agent(
tool_base: Optional[Path] = None, tool_base: Optional[Path] = None,
mode: str = "", mode: str = "",
description: str = "", description: str = "",
task_dir_arg: Optional[str] = None,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]: ) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, task_dir)。""" """返回 (agent, session, task_id_str, task_state, task_dir)。"""
cfg = load_config() cfg = load_config()
@ -151,7 +192,7 @@ def build_agent(
llm = LLM(caps) llm = LLM(caps)
workspace_dir = resolve_workspace(workspace, cfg) workspace_dir = resolve_workspace(workspace, cfg)
task_id, task_dir = resolve_task_id(workspace_dir, session_id, resume) task_id, task_dir = resolve_task_id(workspace_dir, session_id, resume, task_dir_arg)
sid = str(task_id) sid = str(task_id)
tool_base = Path(tool_base) if tool_base else Path.cwd() tool_base = Path(tool_base) if tool_base else Path.cwd()