Compare commits
2 Commits
5c80fb202b
...
3c022494f5
| Author | SHA1 | Date |
|---|---|---|
|
|
3c022494f5 | |
|
|
59c41d22df |
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.60.14 — 2026-07-31
|
||||||
|
|
||||||
|
- 自动命名的普通对话在清空后会回到“新对话”,并根据下一次询问重新生成标题;用户手动设置的任务名以及微信、企业微信等固定会话名保持不变。
|
||||||
|
|
||||||
## 0.60.13 — 2026-07-31
|
## 0.60.13 — 2026-07-31
|
||||||
|
|
||||||
- 微信和企业微信对话不再显示完成、废弃、重命名和删除操作,避免把常驻渠道会话当作普通任务处理;导出和清空对话仍然保留。
|
- 微信和企业微信对话不再显示完成、废弃、重命名和删除操作,避免把常驻渠道会话当作普通任务处理;导出和清空对话仍然保留。
|
||||||
|
|
|
||||||
|
|
@ -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)。
|
**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
|
### 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`)
|
### 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 演化即时生效。
|
跨 task 事实放 `user_root/.memory/`:Core(`core.md`,每次 build_agent 进 prompt)+ Extended(`extended/*.md`,索引进 prompt、内容按需 `read`;索引优先 frontmatter description,legacy 退首行)。system prompt 每次 build_agent 重建,memory 演化即时生效。
|
||||||
|
|
@ -257,7 +257,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
|
|
||||||
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
|
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
|
||||||
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
||||||
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,硬删=永久丢失。`deleted_at` 置位 + restore;心智=**平台对数据 append-only,"删除"是可见性状态**。物理清理留管理员工具。
|
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
|
||||||
- **文件留存(设计已定,实现待办)**:用户文件在 FS,删除/覆盖即字节丢。方案=① restic/borg 定时增量备份做地基(与应用解耦,新端点自动覆盖,捕获删除+覆盖+成品)+ ② 应用层 `data_events` 事件日志(补用户意图语义)。**不选**每个删除端点内联 copytree:横切关注点手写 N 处必漏。起步同盘(不防整盘损坏,已知边界)。
|
- **文件留存(设计已定,实现待办)**:用户文件在 FS,删除/覆盖即字节丢。方案=① restic/borg 定时增量备份做地基(与应用解耦,新端点自动覆盖,捕获删除+覆盖+成品)+ ② 应用层 `data_events` 事件日志(补用户意图语义)。**不选**每个删除端点内联 copytree:横切关注点手写 N 处必漏。起步同盘(不防整盘损坏,已知边界)。
|
||||||
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
||||||
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
||||||
|
|
@ -301,6 +301,8 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
- **可靠性**:退避重试(transient/permanent 区分,v1 简化为下个 cron 点)、per-job 超时(默 1800s,复用协作 cancel;超时按 error 记不吞)、**无补跑**、定时 run 内禁 schedule_create(防自我繁殖)、连续失败 N 次自停。
|
- **可靠性**:退避重试(transient/permanent 区分,v1 简化为下个 cron 点)、per-job 超时(默 1800s,复用协作 cancel;超时按 error 记不吞)、**无补跑**、定时 run 内禁 schedule_create(防自我繁殖)、连续失败 N 次自停。
|
||||||
- **选型**:croniter 只当 next_run 计算器(vixie dom/dow OR 语义 + 时区,手搓必踩坑);**不引 APScheduler/Celery**(单机低并发,过度工程);**不用 JSON 文件持久化**(已有 PG)。persistent 绑定 task 正忙 → 跳过本次不排队。
|
- **选型**:croniter 只当 next_run 计算器(vixie dom/dow OR 语义 + 时区,手搓必踩坑);**不引 APScheduler/Celery**(单机低并发,过度工程);**不用 JSON 文件持久化**(已有 PG)。persistent 绑定 task 正忙 → 跳过本次不排队。
|
||||||
- **前端取舍**:对话端完整 CRUD(schedule_* 工具),前端只读看板 + 停用/删除——cron 构建器 UX 难题直接消失(用户对 bot 说"每天早九点");工具与 REST 共用 `core.scheduler` 服务层不漂移。v1 纯工具不配 skill(schema 够;skill 值钱处是教写好 job.prompt,v2 按需)。
|
- **前端取舍**:对话端完整 CRUD(schedule_* 工具),前端只读看板 + 停用/删除——cron 构建器 UX 难题直接消失(用户对 bot 说"每天早九点");工具与 REST 共用 `core.scheduler` 服务层不漂移。v1 纯工具不配 skill(schema 够;skill 值钱处是教写好 job.prompt,v2 按需)。
|
||||||
|
- **执行历史保留(方案已定,待容量信号实施)**:继续用 `isolated` 每次创建独立 task,不引 `rolling`;“执行隔离”与“数据生命周期”保持正交。未来由 `scheduler.isolated_history_limit`(拟默认 100)把同一 job 超出最近 N 次的终态 task 自动置 `deleted_at`,再由通用 Task GC 按 `retention.deleted_task_grace_days`(拟默认 30)分批物理清理所有到期软删 task。GC 永不碰 `running/cancelling`;`messages` 随 task 删除,`usage_events` 与 task 脱钩后保留,避免历史费用随内容删除而下降;task 与文件生命周期分离,共享 `scheduled-<jobid>` 工作目录及产物不随之删除。两项配置分别回答“何时进入回收站”和“回收站保留多久”,不得合并成 scheduler 专属硬删逻辑。
|
||||||
|
- **暂缓理由 / 实施信号**:当前用户量与定时任务量低,无限保留尚未造成容量、查询、备份或 vacuum 压力;现在引入不可逆删除、外键 migration、后台 GC 与并发保护的复杂度没有收益。出现任一信号再实施:定时 task/messages 成为 DB 主要增量、历史接口或维护明显变慢、用户感知历史冗余,或扩展高频定时任务/更多用户前需要容量边界。实施时同步把 `usage_events.task_id` 删除语义改为 `ON DELETE SET NULL`,为 `deleted_at` 及 `(scheduled_job_id, created_at)` 补清理索引,并更新删除/恢复的 30 天对外契约。
|
||||||
|
|
||||||
### 8.6 平台渲染层 rendering/(✅ 2026-06-23)
|
### 8.6 平台渲染层 rendering/(✅ 2026-06-23)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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 格式检查通过。
|
- **07-31 / 0.60.13 / 渠道常驻会话操作收口**:微信与企业微信的 web 只读镜像只保留导出和清空对话,隐藏普通任务才适用的完成、废弃、重命名和删除操作,顶部独立完成按钮同步隐藏;普通任务菜单不变。纯前端行为修正,无 API、schema、migration 或依赖变化;JavaScript 语法及 diff 格式检查通过。
|
||||||
|
|
||||||
### 2026-07-29
|
### 2026-07-29
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.60.13"
|
__version__ = "0.60.14"
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@ class Task(Base):
|
||||||
auto_title_pending: Mapped[bool] = mapped_column(
|
auto_title_pending: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False, server_default="false"
|
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)
|
working_dir: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
skill: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
skill: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ def ensure_local_task_row(
|
||||||
channel: str = "web",
|
channel: str = "web",
|
||||||
scheduled_job_id: Optional[UUID] = None,
|
scheduled_job_id: Optional[UUID] = None,
|
||||||
auto_title_pending: bool = False,
|
auto_title_pending: bool = False,
|
||||||
|
title_source: str = "manual",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
|
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ def ensure_local_task_row(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=name,
|
name=name,
|
||||||
auto_title_pending=auto_title_pending,
|
auto_title_pending=auto_title_pending,
|
||||||
|
title_source=title_source,
|
||||||
working_dir=working_dir,
|
working_dir=working_dir,
|
||||||
skill=skill,
|
skill=skill,
|
||||||
description=description,
|
description=description,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""快速新对话的首条消息自动命名。
|
"""快速新对话的首条消息自动命名。
|
||||||
|
|
||||||
这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后都以
|
这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后以
|
||||||
tasks.auto_title_pending 为闸:人工 PATCH name 会清闸,在途标题结果因此无法覆盖用户。
|
tasks.auto_title_pending + auto_title_version 为闸:人工 PATCH name 会清闸,清空
|
||||||
|
会递增版本,在途标题结果因此既不能覆盖用户命名,也不能跨会话轮次写回。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -59,14 +60,15 @@ def generate_task_title(
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""若 task 仍待自动命名,调用一次模型并原子写标题;任何失败均不影响主 run。"""
|
"""若 task 仍待自动命名,调用一次模型并原子写标题;任何失败均不影响主 run。"""
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
pending = s.execute(
|
state = s.execute(
|
||||||
select(Task.auto_title_pending).where(
|
select(Task.auto_title_pending, Task.auto_title_version).where(
|
||||||
Task.task_id == task_id,
|
Task.task_id == task_id,
|
||||||
Task.user_id == user_id,
|
Task.user_id == user_id,
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).first()
|
||||||
if not pending:
|
if state is None or not state.auto_title_pending:
|
||||||
return None
|
return None
|
||||||
|
title_version = state.auto_title_version
|
||||||
|
|
||||||
response: Any = None
|
response: Any = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
|
@ -96,7 +98,10 @@ def generate_task_title(
|
||||||
# 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸:
|
# 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸:
|
||||||
# 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。
|
# 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。
|
||||||
with session_scope() as s:
|
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:
|
if title:
|
||||||
values["name"] = title
|
values["name"] = title
|
||||||
result = s.execute(
|
result = s.execute(
|
||||||
|
|
@ -105,6 +110,7 @@ def generate_task_title(
|
||||||
Task.task_id == task_id,
|
Task.task_id == task_id,
|
||||||
Task.user_id == user_id,
|
Task.user_id == user_id,
|
||||||
Task.auto_title_pending.is_(True),
|
Task.auto_title_pending.is_(True),
|
||||||
|
Task.auto_title_version == title_version,
|
||||||
)
|
)
|
||||||
.values(**values)
|
.values(**values)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -353,7 +353,7 @@ def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]:
|
||||||
ensure_local_task_row(
|
ensure_local_task_row(
|
||||||
task_id=tid, name=task_name, working_dir=to_db_path(fs_dir),
|
task_id=tid, name=task_name, working_dir=to_db_path(fs_dir),
|
||||||
skill="", user_id=uid, model=caps.model_id, model_profile=profile,
|
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)
|
set_task(uid, tid)
|
||||||
return tid
|
return tid
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -115,6 +115,7 @@ class TasksCrudTests(unittest.TestCase):
|
||||||
self.assertTrue(t["model_profile"]) # 默认模型已填
|
self.assertTrue(t["model_profile"]) # 默认模型已填
|
||||||
self.assertEqual(t["run_status"], "idle")
|
self.assertEqual(t["run_status"], "idle")
|
||||||
self.assertFalse(t["auto_title_pending"]) # 旧创建契约不自动改名
|
self.assertFalse(t["auto_title_pending"]) # 旧创建契约不自动改名
|
||||||
|
self.assertEqual(t["title_source"], "manual")
|
||||||
|
|
||||||
# 列表含它,分页壳完整
|
# 列表含它,分页壳完整
|
||||||
r = _client.get("/v1/tasks", headers=_AUTH)
|
r = _client.get("/v1/tasks", headers=_AUTH)
|
||||||
|
|
@ -155,6 +156,7 @@ class TasksCrudTests(unittest.TestCase):
|
||||||
self.assertEqual(r.status_code, 201, r.text)
|
self.assertEqual(r.status_code, 201, r.text)
|
||||||
t = r.json()
|
t = r.json()
|
||||||
self.assertTrue(t["auto_title_pending"])
|
self.assertTrue(t["auto_title_pending"])
|
||||||
|
self.assertEqual(t["title_source"], "auto")
|
||||||
|
|
||||||
# 人工改名拥有最高优先级:立刻清 pending,异步标题条件 UPDATE 将失配。
|
# 人工改名拥有最高优先级:立刻清 pending,异步标题条件 UPDATE 将失配。
|
||||||
d = _client.patch(
|
d = _client.patch(
|
||||||
|
|
@ -164,6 +166,7 @@ class TasksCrudTests(unittest.TestCase):
|
||||||
).json()
|
).json()
|
||||||
self.assertEqual(d["name"], "用户指定标题")
|
self.assertEqual(d["name"], "用户指定标题")
|
||||||
self.assertFalse(d["auto_title_pending"])
|
self.assertFalse(d["auto_title_pending"])
|
||||||
|
self.assertEqual(d["title_source"], "manual")
|
||||||
|
|
||||||
def test_shared_working_dir_and_folders(self):
|
def test_shared_working_dir_and_folders(self):
|
||||||
for name in ("共享目录甲", "共享目录乙"):
|
for name in ("共享目录甲", "共享目录乙"):
|
||||||
|
|
@ -190,6 +193,53 @@ class TasksCrudTests(unittest.TestCase):
|
||||||
self.assertFalse(m["has_more"])
|
self.assertFalse(m["has_more"])
|
||||||
self.assertEqual(_client.get(f"/v1/tasks/{tid}/outline", headers=_AUTH).json()["items"], [])
|
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):
|
class MessageRunDurabilityTests(unittest.TestCase):
|
||||||
def _mk_task(self, name: str) -> str:
|
def _mk_task(self, name: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ def task_dict(
|
||||||
"task_id": str(row.task_id),
|
"task_id": str(row.task_id),
|
||||||
"name": row.name or "",
|
"name": row.name or "",
|
||||||
"auto_title_pending": bool(getattr(row, "auto_title_pending", False)),
|
"auto_title_pending": bool(getattr(row, "auto_title_pending", False)),
|
||||||
|
"title_source": getattr(row, "title_source", None) or "manual",
|
||||||
"description": row.description or "",
|
"description": row.description or "",
|
||||||
"working_dir": norm_path(row.working_dir or ""),
|
"working_dir": norm_path(row.working_dir or ""),
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,7 @@ def register_message_routes(app, *, require_user) -> None:
|
||||||
raise HTTPException(404, f"invalid task id: {task_id!r}")
|
raise HTTPException(404, f"invalid task id: {task_id!r}")
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
row = s.execute(
|
row = s.execute(
|
||||||
select(Task.run_status)
|
select(Task.run_status, Task.title_source)
|
||||||
.where(Task.task_id == tid, Task.user_id == user_id)
|
.where(Task.task_id == tid, Task.user_id == user_id)
|
||||||
.with_for_update()
|
.with_for_update()
|
||||||
).first()
|
).first()
|
||||||
|
|
@ -394,18 +394,27 @@ def register_message_routes(app, *, require_user) -> None:
|
||||||
f"cancel it first",
|
f"cancel it first",
|
||||||
)
|
)
|
||||||
s.execute(_delete(Message).where(Message.task_id == tid))
|
s.execute(_delete(Message).where(Message.task_id == tid))
|
||||||
s.execute(
|
reset_values: dict[str, Any] = {
|
||||||
update(Task).where(Task.task_id == tid).values(
|
"tokens_prompt": 0,
|
||||||
tokens_prompt=0,
|
"tokens_completion": 0,
|
||||||
tokens_completion=0,
|
"cost_cny": 0,
|
||||||
cost_cny=0,
|
"run_status": "idle",
|
||||||
run_status="idle",
|
"run_error": None,
|
||||||
run_error=None,
|
# 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019);
|
||||||
# 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019);
|
# 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021)
|
||||||
# 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021)
|
"context_base_idx": 0,
|
||||||
context_base_idx=0,
|
"context_summary": None,
|
||||||
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()
|
task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()
|
||||||
d = task_dict(task_row, n_messages=0)
|
d = task_dict(task_row, n_messages=0)
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ def register_task_routes(app, *, require_user) -> None:
|
||||||
description=description, user_id=user_id,
|
description=description, user_id=user_id,
|
||||||
model=model_id, model_profile=profile,
|
model=model_id, model_profile=profile,
|
||||||
auto_title_pending=body.auto_title,
|
auto_title_pending=body.auto_title,
|
||||||
|
title_source="auto" if body.auto_title else "manual",
|
||||||
)
|
)
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()
|
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 也会因
|
# 人工命名优先级最高:即使自动标题调用已在途,最终 UPDATE 也会因
|
||||||
# pending=False 条件失配而放弃,绝不覆盖用户刚改好的名称。
|
# pending=False 条件失配而放弃,绝不覆盖用户刚改好的名称。
|
||||||
updates["auto_title_pending"] = False
|
updates["auto_title_pending"] = False
|
||||||
|
updates["title_source"] = "manual"
|
||||||
if body.model_profile is not None:
|
if body.model_profile is not None:
|
||||||
# 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前
|
# 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前
|
||||||
# in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。
|
# in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ def start_scheduler(app, cfg: dict) -> Optional[asyncio.Task]:
|
||||||
model=model_id, model_profile=profile,
|
model=model_id, model_profile=profile,
|
||||||
description="(定时任务自动创建)",
|
description="(定时任务自动创建)",
|
||||||
scheduled_job_id=job_id,
|
scheduled_job_id=job_id,
|
||||||
|
title_source="fixed",
|
||||||
)
|
)
|
||||||
if snap["mode"] == "persistent":
|
if snap["mode"] == "persistent":
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue