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:
parent
aeecc7f0f3
commit
2b3692c8bf
|
|
@ -20,3 +20,11 @@
|
|||
- 实施中发现 DESIGN 描述与代码偏离 → 同步改回
|
||||
|
||||
bug 修复、重构、新加 skill、调参 —— **不动 DESIGN**,只更 PROGRESS。
|
||||
|
||||
**改任何对外行为(CLI 选项 / REPL 命令 / env 变量 / 文件布局 / migration 步骤)→ 同步更新 `RUN.md`**:
|
||||
- 新加 / 改 / 删 CLI 子命令 + 选项时,改"日常命令"段
|
||||
- env 变量 / 启动初始化变化时,改"环境" / "一次性初始化"段
|
||||
- 真实踩过的坑(用户报或自己跑出来),加一行到"故障兜底"表
|
||||
- 纯内部重构 / 不影响用户怎么跑的 —— **不动 RUN**
|
||||
|
||||
三文档边界:`DESIGN`=为什么(架构 / 取舍),`PROGRESS`=做到哪(状态 / 历史),`RUN`=怎么跑(命令 / env / 兜底)。一次改动可能动多个,但每个动的理由要符合上述边界。
|
||||
|
|
|
|||
19
PROGRESS.md
19
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 <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/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/<uuid>/`,但用户显式指定还没上)
|
||||
- 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 长上下文一般用不到。
|
||||
|
|
|
|||
|
|
@ -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 写"怎么跑"。
|
||||
70
cli.py
70
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/<uuid>/ 默认派生路径**且无产物时执行 ——
|
||||
用户用 `--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
|
||||
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/<uuid>/")
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 <task_id>` 与 REPL `/export [<task_id>]` 都走它
|
||||
|
||||
§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/<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 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", ""),
|
||||
|
|
|
|||
71
main.py
71
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/<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:
|
||||
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)
|
||||
|
||||
# 接受完整 UUID 或前缀(8 字符够辨识本机量级)
|
||||
tid, db_dir = row
|
||||
else:
|
||||
tid = _resolve_uuid_or_prefix(task_id_arg)
|
||||
return tid, tdir / str(tid)
|
||||
with session_scope() as s:
|
||||
db_dir = s.execute(
|
||||
select(Task.task_dir).where(Task.task_id == tid)
|
||||
).scalar_one_or_none() or ""
|
||||
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue