From 3c022494f5b451e0a17694e9020f26b62e6959fc Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 31 Jul 2026 13:00:34 +0800 Subject: [PATCH] fix(web): retitle cleared conversations --- CHANGELOG.md | 4 ++ DESIGN.md | 2 +- PROGRESS.md | 3 +- core/__init__.py | 2 +- core/storage/models.py | 10 +++ core/storage/utils.py | 2 + core/task_title.py | 20 ++++-- core/wechat/service.py | 2 +- .../20260731_1800_0024_task_title_source.py | 67 +++++++++++++++++++ tests/test_web_routes_db.py | 50 ++++++++++++++ web/common.py | 1 + web/routers/messages.py | 33 +++++---- web/routers/tasks.py | 2 + web/scheduler_runner.py | 1 + 14 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 db/migrations/versions/20260731_1800_0024_task_title_source.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 23fe618..ff137b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.14 — 2026-07-31 + +- 自动命名的普通对话在清空后会回到“新对话”,并根据下一次询问重新生成标题;用户手动设置的任务名以及微信、企业微信等固定会话名保持不变。 + ## 0.60.13 — 2026-07-31 - 微信和企业微信对话不再显示完成、废弃、重命名和删除操作,避免把常驻渠道会话当作普通任务处理;导出和清空对话仍然保留。 diff --git a/DESIGN.md b/DESIGN.md index 36adf05..aa0b867 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -82,7 +82,7 @@ yaml 是手填的,probe 用真实调用对账(basic_chat/parallel_tools/thinking **Skill 定向模型**(frontmatter `model:`,2026-07-06):内置 skill 声明"该工作流用这个模型最好"(如 ppt→glm.pro52)。**单一执行点**:对话中 `load_skill` 命中 → run 内热切(loop 换 `self.llm/self.caps`,下一轮生效)+ 持久化 task 模型;切失败降级原模型。取舍:**跳档位门控**(产品决策,任何档位可用);**只信内置 skill**(用户 skill 的 model 忽略,防自写 frontmatter 绕门控);**不自动切回**("skill 结束"不可判定);**不设 DB 开关、不做建 task 预切**——frontmatter 那一行本身就是热配置(删行即停,per-skill 粒度),全局开关是第二事实源、预切是第二执行点,都砍;**已选国际旗舰模型(unifyllm 网关族:Claude/GPT/Gemini)豁免切换**(2026-07-15)——定向 pin 本意是给较弱的国产默认模型托底产物质量,用户已主动选了旗舰模型则尊重其选择、不降级(判据:current profile 的 family==unifyllm)。 ### 3.6 Session 与 Task -Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`。 +Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`title_source`=标题来源(`auto` / `manual` / `fixed`)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。快速新对话的自动标题描述当前会话主题,清空后重置为“新对话”并由下一条消息重新生成;人工标题与渠道/调度固定标题不随清空变化。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`。 ### 3.7 双层记忆(`core/memory.py`) 跨 task 事实放 `user_root/.memory/`:Core(`core.md`,每次 build_agent 进 prompt)+ Extended(`extended/*.md`,索引进 prompt、内容按需 `read`;索引优先 frontmatter description,legacy 退首行)。system prompt 每次 build_agent 重建,memory 演化即时生效。 diff --git a/PROGRESS.md b/PROGRESS.md index 5f06ce3..8f6453f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-31(渠道常驻会话操作收口,bump 0.60.13) +最后更新:2026-07-31(清空对话后自动标题重生成,bump 0.60.14) --- @@ -23,6 +23,7 @@ ### 2026-07-31 +- **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`(auto/manual/fixed)与 `auto_title_version`(0024 migration);快速新对话的自动标题在清空后重置为“新对话”,下一条消息重新生成,人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过;DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。 - **07-31 / 0.60.13 / 渠道常驻会话操作收口**:微信与企业微信的 web 只读镜像只保留导出和清空对话,隐藏普通任务才适用的完成、废弃、重命名和删除操作,顶部独立完成按钮同步隐藏;普通任务菜单不变。纯前端行为修正,无 API、schema、migration 或依赖变化;JavaScript 语法及 diff 格式检查通过。 ### 2026-07-29 diff --git a/core/__init__.py b/core/__init__.py index f0c18e8..bb86f1d 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.60.13" +__version__ = "0.60.14" diff --git a/core/storage/models.py b/core/storage/models.py index 9107a84..2331269 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -73,6 +73,16 @@ class Task(Base): auto_title_pending: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="false" ) + # 标题来源(0024):auto=快速新对话自动标题,manual=用户/旧创建入口命名, + # fixed=渠道或调度等系统固定名。清空对话只重置 auto,避免覆盖人工/固定标题。 + title_source: Mapped[str] = mapped_column( + Text, nullable=False, default="manual", server_default="manual" + ) + # 自动标题轮次(0024):清空 auto task 时递增,防清空前仍在途的标题结果通过 + # auto_title_pending 的 false→true ABA 误写回新会话。 + auto_title_version: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) 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="") diff --git a/core/storage/utils.py b/core/storage/utils.py index d26181b..7e6a417 100644 --- a/core/storage/utils.py +++ b/core/storage/utils.py @@ -29,6 +29,7 @@ def ensure_local_task_row( channel: str = "web", scheduled_job_id: Optional[UUID] = None, auto_title_pending: bool = False, + title_source: str = "manual", ) -> None: """占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。 @@ -44,6 +45,7 @@ def ensure_local_task_row( user_id=user_id, name=name, auto_title_pending=auto_title_pending, + title_source=title_source, working_dir=working_dir, skill=skill, description=description, diff --git a/core/task_title.py b/core/task_title.py index 6ac9a8b..26179a5 100644 --- a/core/task_title.py +++ b/core/task_title.py @@ -1,7 +1,8 @@ """快速新对话的首条消息自动命名。 -这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后都以 -tasks.auto_title_pending 为闸:人工 PATCH name 会清闸,在途标题结果因此无法覆盖用户。 +这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后以 +tasks.auto_title_pending + auto_title_version 为闸:人工 PATCH name 会清闸,清空 +会递增版本,在途标题结果因此既不能覆盖用户命名,也不能跨会话轮次写回。 """ from __future__ import annotations @@ -59,14 +60,15 @@ def generate_task_title( ) -> Optional[str]: """若 task 仍待自动命名,调用一次模型并原子写标题;任何失败均不影响主 run。""" with session_scope() as s: - pending = s.execute( - select(Task.auto_title_pending).where( + state = s.execute( + select(Task.auto_title_pending, Task.auto_title_version).where( Task.task_id == task_id, Task.user_id == user_id, ) - ).scalar_one_or_none() - if not pending: + ).first() + if state is None or not state.auto_title_pending: return None + title_version = state.auto_title_version response: Any = None title: Optional[str] = None @@ -96,7 +98,10 @@ def generate_task_title( # 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸: # 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。 with session_scope() as s: - values: dict[str, Any] = {"auto_title_pending": False} + values: dict[str, Any] = { + "auto_title_pending": False, + "title_source": "auto", + } if title: values["name"] = title result = s.execute( @@ -105,6 +110,7 @@ def generate_task_title( Task.task_id == task_id, Task.user_id == user_id, Task.auto_title_pending.is_(True), + Task.auto_title_version == title_version, ) .values(**values) ) diff --git a/core/wechat/service.py b/core/wechat/service.py index 5ebb89e..36fa360 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -353,7 +353,7 @@ def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]: ensure_local_task_row( task_id=tid, name=task_name, working_dir=to_db_path(fs_dir), skill="", user_id=uid, model=caps.model_id, model_profile=profile, - description=desc, channel=channel, + description=desc, channel=channel, title_source="fixed", ) set_task(uid, tid) return tid diff --git a/db/migrations/versions/20260731_1800_0024_task_title_source.py b/db/migrations/versions/20260731_1800_0024_task_title_source.py new file mode 100644 index 0000000..37350f8 --- /dev/null +++ b/db/migrations/versions/20260731_1800_0024_task_title_source.py @@ -0,0 +1,67 @@ +"""记录 task 标题来源,支持清空后安全地重新自动命名。 + +Revision ID: 0024 +Revises: 0023 +Create Date: 2026-07-31 + +新增 title_source:auto / manual / fixed,以及防止旧标题结果跨清空轮次写回的 +auto_title_version。历史数据无法可靠区分“自动命名后又人工改名”,因此仅把仍 +处于 auto_title_pending 的行回填为 auto;渠道与调度任务回填 fixed,其余保守为 +manual,避免清空时覆盖人工标题。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0024" +down_revision: Union[str, None] = "0023" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column( + "title_source", + sa.Text(), + nullable=False, + server_default="manual", + ), + ) + op.add_column( + "tasks", + sa.Column( + "auto_title_version", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + op.execute( + """ + UPDATE tasks + SET title_source = 'fixed' + WHERE channel <> 'web' OR scheduled_job_id IS NOT NULL + """ + ) + op.execute( + """ + UPDATE tasks + SET title_source = 'auto' + WHERE title_source = 'manual' AND auto_title_pending = true + """ + ) + op.create_check_constraint( + "ck_tasks_title_source", + "tasks", + "title_source IN ('auto', 'manual', 'fixed')", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_tasks_title_source", "tasks", type_="check") + op.drop_column("tasks", "auto_title_version") + op.drop_column("tasks", "title_source") diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index c272a46..8a15fc2 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -115,6 +115,7 @@ class TasksCrudTests(unittest.TestCase): self.assertTrue(t["model_profile"]) # 默认模型已填 self.assertEqual(t["run_status"], "idle") self.assertFalse(t["auto_title_pending"]) # 旧创建契约不自动改名 + self.assertEqual(t["title_source"], "manual") # 列表含它,分页壳完整 r = _client.get("/v1/tasks", headers=_AUTH) @@ -155,6 +156,7 @@ class TasksCrudTests(unittest.TestCase): self.assertEqual(r.status_code, 201, r.text) t = r.json() self.assertTrue(t["auto_title_pending"]) + self.assertEqual(t["title_source"], "auto") # 人工改名拥有最高优先级:立刻清 pending,异步标题条件 UPDATE 将失配。 d = _client.patch( @@ -164,6 +166,7 @@ class TasksCrudTests(unittest.TestCase): ).json() self.assertEqual(d["name"], "用户指定标题") self.assertFalse(d["auto_title_pending"]) + self.assertEqual(d["title_source"], "manual") def test_shared_working_dir_and_folders(self): for name in ("共享目录甲", "共享目录乙"): @@ -190,6 +193,53 @@ class TasksCrudTests(unittest.TestCase): self.assertFalse(m["has_more"]) self.assertEqual(_client.get(f"/v1/tasks/{tid}/outline", headers=_AUTH).json()["items"], []) + def test_clear_rearms_only_auto_title(self): + auto = _client.post( + "/v1/tasks", + json={ + "name": "新对话", + "working_dir": "自动标题清空测试", + "auto_title": True, + }, + headers=_AUTH, + ).json() + auto_tid = auto["task_id"] + # 模拟首轮自动标题已经生成;不能走 PATCH,否则会被正确标为 manual。 + with session_scope() as s: + s.execute( + __import__("sqlalchemy").update(Task) + .where(Task.task_id == uuid.UUID(auto_tid)) + .values(name="熟料三率值分析", auto_title_pending=False) + ) + old_version = s.execute( + __import__("sqlalchemy").select(Task.auto_title_version) + .where(Task.task_id == uuid.UUID(auto_tid)) + ).scalar_one() + cleared = _client.post( + f"/v1/tasks/{auto_tid}/clear", headers=_AUTH + ).json() + self.assertEqual(cleared["name"], "新对话") + self.assertTrue(cleared["auto_title_pending"]) + self.assertEqual(cleared["title_source"], "auto") + with session_scope() as s: + new_version = s.execute( + __import__("sqlalchemy").select(Task.auto_title_version) + .where(Task.task_id == uuid.UUID(auto_tid)) + ).scalar_one() + self.assertEqual(new_version, old_version + 1) + + manual = _client.post( + "/v1/tasks", + json={"name": "用户固定项目名", "working_dir": "人工标题清空测试"}, + headers=_AUTH, + ).json() + cleared = _client.post( + f"/v1/tasks/{manual['task_id']}/clear", headers=_AUTH + ).json() + self.assertEqual(cleared["name"], "用户固定项目名") + self.assertFalse(cleared["auto_title_pending"]) + self.assertEqual(cleared["title_source"], "manual") + class MessageRunDurabilityTests(unittest.TestCase): def _mk_task(self, name: str) -> str: diff --git a/web/common.py b/web/common.py index 8940c91..b50b0ff 100644 --- a/web/common.py +++ b/web/common.py @@ -100,6 +100,7 @@ def task_dict( "task_id": str(row.task_id), "name": row.name or "", "auto_title_pending": bool(getattr(row, "auto_title_pending", False)), + "title_source": getattr(row, "title_source", None) or "manual", "description": row.description or "", "working_dir": norm_path(row.working_dir or ""), "status": row.status, diff --git a/web/routers/messages.py b/web/routers/messages.py index 0c3bcac..26bda30 100644 --- a/web/routers/messages.py +++ b/web/routers/messages.py @@ -305,7 +305,7 @@ def register_message_routes(app, *, require_user) -> None: raise HTTPException(404, f"invalid task id: {task_id!r}") with session_scope() as s: row = s.execute( - select(Task.run_status) + select(Task.run_status, Task.title_source) .where(Task.task_id == tid, Task.user_id == user_id) .with_for_update() ).first() @@ -394,18 +394,27 @@ def register_message_routes(app, *, require_user) -> None: f"cancel it first", ) s.execute(_delete(Message).where(Message.task_id == tid)) - s.execute( - update(Task).where(Task.task_id == tid).values( - tokens_prompt=0, - tokens_completion=0, - cost_cny=0, - run_status="idle", - run_error=None, - # 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019); - # 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021) - context_base_idx=0, - context_summary=None, + reset_values: dict[str, Any] = { + "tokens_prompt": 0, + "tokens_completion": 0, + "cost_cny": 0, + "run_status": "idle", + "run_error": None, + # 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019); + # 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021) + "context_base_idx": 0, + "context_summary": None, + } + # 自动标题描述的是当前对话主题:清空后回到草稿名,下一条消息重新命名。 + # manual/fixed 分别代表用户显式命名与渠道/调度固定名,均不得覆盖。 + if row.title_source == "auto": + reset_values.update( + name="新对话", + auto_title_pending=True, + auto_title_version=Task.auto_title_version + 1, ) + s.execute( + update(Task).where(Task.task_id == tid).values(**reset_values) ) task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() d = task_dict(task_row, n_messages=0) diff --git a/web/routers/tasks.py b/web/routers/tasks.py index 678eb3c..494026c 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -77,6 +77,7 @@ def register_task_routes(app, *, require_user) -> None: description=description, user_id=user_id, model=model_id, model_profile=profile, auto_title_pending=body.auto_title, + title_source="auto" if body.auto_title else "manual", ) with session_scope() as s: row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() @@ -432,6 +433,7 @@ def register_task_routes(app, *, require_user) -> None: # 人工命名优先级最高:即使自动标题调用已在途,最终 UPDATE 也会因 # pending=False 条件失配而放弃,绝不覆盖用户刚改好的名称。 updates["auto_title_pending"] = False + updates["title_source"] = "manual" if body.model_profile is not None: # 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前 # in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。 diff --git a/web/scheduler_runner.py b/web/scheduler_runner.py index d57c61b..2d8ec4f 100644 --- a/web/scheduler_runner.py +++ b/web/scheduler_runner.py @@ -85,6 +85,7 @@ def start_scheduler(app, cfg: dict) -> Optional[asyncio.Task]: model=model_id, model_profile=profile, description="(定时任务自动创建)", scheduled_job_id=job_id, + title_source="fixed", ) if snap["mode"] == "persistent": with session_scope() as s: