fix(web): retitle cleared conversations

This commit is contained in:
caoqianming 2026-07-31 13:00:34 +08:00
parent 59c41d22df
commit 3c022494f5
14 changed files with 176 additions and 23 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.14 — 2026-07-31
- 自动命名的普通对话在清空后会回到“新对话”,并根据下一次询问重新生成标题;用户手动设置的任务名以及微信、企业微信等固定会话名保持不变。
## 0.60.13 — 2026-07-31
- 微信和企业微信对话不再显示完成、废弃、重命名和删除操作,避免把常驻渠道会话当作普通任务处理;导出和清空对话仍然保留。

View File

@ -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 演化即时生效。

View File

@ -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

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.13"
__version__ = "0.60.14"

View File

@ -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="")

View File

@ -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,

View File

@ -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)
)

View File

@ -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

View File

@ -0,0 +1,67 @@
"""记录 task 标题来源,支持清空后安全地重新自动命名。
Revision ID: 0024
Revises: 0023
Create Date: 2026-07-31
新增 title_sourceauto / 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")

View File

@ -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:

View File

@ -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,

View File

@ -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)

View File

@ -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。

View File

@ -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: