From c3a6f7deee9ecac9857f2b310d0a5a182ee95517 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 27 Jul 2026 12:28:56 +0800 Subject: [PATCH] Add workspace-first new conversation flow --- CHANGELOG.md | 5 + DESIGN.md | 9 +- PROGRESS.md | 10 +- core/__init__.py | 2 +- core/storage/models.py | 6 + core/storage/utils.py | 2 + core/task_title.py | 142 ++++++++++++ ...60727_1600_0023_task_auto_title_pending.py | 36 +++ tests/test_task_title.py | 28 +++ tests/test_web_routes_db.py | 24 ++ web/app.py | 13 ++ web/common.py | 1 + web/routers/messages.py | 21 +- web/routers/tasks.py | 5 + web/schemas.py | 2 + web/static/dev.html | 22 +- web/static/js/chat.js | 206 ++++++++++++++++-- web/static/js/main.js | 3 +- web/static/js/newtask.js | 4 +- web/static/js/state.js | 3 + 20 files changed, 517 insertions(+), 27 deletions(-) create mode 100644 core/task_title.py create mode 100644 db/migrations/versions/20260727_1600_0023_task_auto_title_pending.py create mode 100644 tests/test_task_title.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e891dbe..3b3520d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.60.0 — 2026-07-27 + +- 登录后的空白页现在可以直接选择已有工作目录或新建工作目录,然后开始对话;点击左栏「+ 新对话」也进入同一个快速入口,发送第一条消息时才真正创建对话。 +- 新对话会根据第一条消息自动生成简短标题,工作目录名称保持不变;原有任务名、描述、智能体类型和模型均可预设的创建方式仍保留在「自定义」入口。 + ## 0.59.6 — 2026-07-27 - 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。 diff --git a/DESIGN.md b/DESIGN.md index 40b8480..10ffe66 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -147,12 +147,15 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 +**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB,首发时才 `POST /v1/tasks`,避免空 task 堆积。task 先以「新对话」占位并置一次性 `auto_title_pending`,首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir;人工 PATCH name 同时清 pending,条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口(预设 name/description/skill/model)。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。 + ### 7.2 资源模型(/v1) 统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`): ``` -Tasks POST/GET/PATCH/DELETE /v1/tasks*(分页+筛选+ordering allowlist;DELETE=软删,FS 不动) +Tasks POST/GET/PATCH/DELETE /v1/tasks*(POST 可选 auto_title;分页+筛选+ordering allowlist; + DELETE=软删,FS 不动) GET /v1/folders(working_dir + task 计数) GET/POST /v1/tasks/{id}/messages(POST 起 run;单活 run:running/cancelling→409, SELECT FOR UPDATE 锁 task 行防 idx race) @@ -178,7 +181,8 @@ users(user_id pk, email unique null, password_hash, oidc_subject, plan, -- plan name, user_name, -- 0016 平台注入档案 role default 'user', -- 0009 admin 门控 created_at) -tasks(task_id pk, user_id fk, name NOT NULL, working_dir NOT NULL, skill, description, status, +tasks(task_id pk, user_id fk, name NOT NULL, auto_title_pending default false, + working_dir NOT NULL, skill, description, status, model_profile, tokens_*, cost_usd, channel default 'web', -- 0013 渠道来源,仅 INSERT 写定 run_status default 'idle', run_error, -- 0004 合并 runs 表 @@ -195,6 +199,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) ``` - working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。 +- `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false,快速入口首发后消费,人工改名优先清闸。 - **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。 - **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。 - **DB-aware service 是顶层 working_dir mutation 的唯一原语,DB-FS 一致性服务端内化**:文件面板 rename 与对话 `rename_working_dir` 共用同一服务(事务锁关联 task、running→409、DB UPDATE 先于 FS);对话工具只登记本轮内存动作,等 agent 正常回复完、当前 task 退出 running 后执行,避免 executor/system prompt 仍握旧 cwd。服务在收尾前退出时动作丢失但目录不变,不引持久队列或 migration。delete 仍仅走 files API,被 task 引用时 409。 diff --git a/PROGRESS.md b/PROGRESS.md index 2030b19..7172f1f 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-27(对话内安全重命名 working_dir,bump 0.59.6) +最后更新:2026-07-27(目录优先的新对话草稿页 + 首条消息自动命名,bump 0.60.0) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-27 / 0.60.0 / 目录优先的新对话草稿页 + 首条消息自动命名**:登录未选任务与左栏「+ 新对话」统一进入不落 DB 的草稿页,选择/新建 working_dir 后首发才创建 task,避免空任务;原完整表单保留为「自定义」。新增 0023 `tasks.auto_title_pending` 一次性闸与 `auto_title` 可选创建字段,首条消息并行生成短标题(`usage_events.kind=task_title`),只改 task 显示名不动目录,人工 PATCH name 清闸且条件 UPDATE 防在途覆盖;失败保留「新对话」不阻塞主 run。 - **07-27 / 0.59.6 / 对话内安全重命名 working_dir**:新增 `rename_working_dir` 受控工具,agent run 内只登记目标 leaf 名,正常回复结束并把当前 task 退出 running 后再落地,避免 executor/system prompt/宿主工具仍握旧 cwd;取消或失败不执行。文件面板与对话路径共用 `core/working_dirs.py` DB-aware 原语:锁定并同步更新共享目录的全部 task、活跃邻居与 no-subtask 冲突安全拒绝、DB UPDATE 后再做 FS rename。system prompt 明确当前 task_dir 禁走 shell/run_python 直接改名,定时 run 不挂该工具;无新增 schema/migration。全量 357 测试通过(17 项按环境跳过),DB 路由测试未设 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。 - **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价,banner/meta/usage snapshot 记录 mode、首帧及参考图;videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。 - **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview` 走 `.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。 @@ -237,6 +238,7 @@ core/skills.py 180 ← 多来源 registry(SkillSource)+ source 标 core/memory.py 81 ← per-user `.memory/` dotfile core/kb.py ~260 ← per-user `.kb/` 个人知识库(状态/视图/kb_block 注入) core/kb_ingest.py ~280 ← kb 入库管线(markitdown→OCR 兜底→flash 摘要→INDEX) +core/task_title.py ~135 ← 快速新对话首条消息自动命名(一次性竞态闸 + 独立记账) core/export_docx.py 383 core/storage/{engine,models,usage,telemetry,usage_report,utils}.py ← 计费写/失败埋点/聚合读三分(07-23) core/ark_client.py 105 ← 火山方舟 HTTP 客户端 @@ -246,8 +248,8 @@ core/agent_builder.py 649 ← 装配 lib:build_agent/system prompt(工具 core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池 tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio}.py ← media_common=媒体五工具共享原语;output=输出压缩/超时结果(自 base 析出) main.py ~210 ← 入口:web / db / probe / user / sandbox check -db/migrations/versions/ 0001-0022 -web/app.py 197 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等) +db/migrations/versions/ 0001-0023 +web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等) web/routers/*.py 11 个 ← misc/models/authroutes/wechat/kb/schedules/skills_memory/files/asr/tasks/messages web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出 web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全 @@ -255,7 +257,7 @@ web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/broker.py / sinks.py / pptx_render.py / admin.py web/static/dev.html + js/*.js ← dev SPA 零构建 ES module(main.js 入口;skills.js=技能 modal;kb.js=知识库 modal) web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx -tests/ 355 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) +tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) ───────────────────────────────── Python 合计 ~5.5 万行(core+web+tools+rendering+tests+scripts;+ dev SPA + vendor 1MB) ``` diff --git a/core/__init__.py b/core/__init__.py index 789a948..47e2d8b 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.59.6" +__version__ = "0.60.0" diff --git a/core/storage/models.py b/core/storage/models.py index a36b364..9107a84 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -67,6 +67,12 @@ class Task(Base): PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False ) name: Mapped[str] = mapped_column(Text, nullable=False) + # 快速新对话的一次性自动命名标记(0023)。True 仅存在于“目录优先、首发建 + # task”的草稿入口;首条消息触发辅助标题调用后清 False。用户手动 PATCH name + # 也会同步清 False,保证异步结果绝不覆盖用户命名。存量/普通创建恒 False。 + auto_title_pending: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) 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 5051559..d26181b 100644 --- a/core/storage/utils.py +++ b/core/storage/utils.py @@ -28,6 +28,7 @@ def ensure_local_task_row( reasoning_effort: str = "", channel: str = "web", scheduled_job_id: Optional[UUID] = None, + auto_title_pending: bool = False, ) -> None: """占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。 @@ -42,6 +43,7 @@ def ensure_local_task_row( task_id=task_id, user_id=user_id, name=name, + auto_title_pending=auto_title_pending, working_dir=working_dir, skill=skill, description=description, diff --git a/core/task_title.py b/core/task_title.py new file mode 100644 index 0000000..6ac9a8b --- /dev/null +++ b/core/task_title.py @@ -0,0 +1,142 @@ +"""快速新对话的首条消息自动命名。 + +这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后都以 +tasks.auto_title_pending 为闸:人工 PATCH name 会清闸,在途标题结果因此无法覆盖用户。 +""" +from __future__ import annotations + +import re +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import select, update + +from .agent_builder import ROOT, load_config +from .capabilities import ModelCapabilities +from .llm import LLM +from .storage import session_scope +from .storage.models import Task +from .storage.usage import record_chat_usage + + +_TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体的中文对话标题。 + +规则: +1. 只输出标题本身,不要解释、引号、前缀或 Markdown。 +2. 6~18 个汉字为宜,必要的英文缩写、材料牌号和数字可以保留。 +3. 概括用户真正要做的事,不要使用“新对话”“咨询问题”“帮我处理”等空泛标题。 +4. 不得包含斜杠或反斜杠。 + +用户消息: +{content} +""" + +_PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I) + + +def clean_generated_title(raw: str, user_message: str) -> str: + """把模型输出收敛为 validate_task_name 可接受的短标题,失败时按用户消息降级。""" + first = next((line.strip() for line in (raw or "").splitlines() if line.strip()), "") + title = _PREFIX_RE.sub("", first).strip(" \t\r\n\"'“”‘’`#*") + title = re.sub(r"[/\\\x00]+", "·", title) + title = re.sub(r"\s+", " ", title).strip(" .。,::;;-—_") + if not title or title == "新对话": + fallback = next( + (line.strip() for line in (user_message or "").splitlines() if line.strip()), + "新对话", + ) + title = re.sub(r"[/\\\x00]+", "·", fallback) + title = re.sub(r"\s+", " ", title).strip(" .。,::;;-—_") + return (title or "新对话")[:24] + + +def generate_task_title( + *, + task_id: UUID, + user_id: UUID, + user_message: str, + model_profile: str, +) -> Optional[str]: + """若 task 仍待自动命名,调用一次模型并原子写标题;任何失败均不影响主 run。""" + with session_scope() as s: + pending = s.execute( + select(Task.auto_title_pending).where( + Task.task_id == task_id, + Task.user_id == user_id, + ) + ).scalar_one_or_none() + if not pending: + return None + + response: Any = None + title: Optional[str] = None + caps: Optional[ModelCapabilities] = None + try: + cfg = load_config() + profile = model_profile or cfg["default_model"] + caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) + response = LLM(caps).chat( + messages=[{ + "role": "user", + "content": _TITLE_PROMPT.format(content=user_message[:3000]), + }], + tools=None, + max_retries=2, + ) + choices = getattr(response, "choices", None) or [] + raw = ((choices[0].message.content if choices else "") or "").strip() + title = clean_generated_title(raw, user_message) + except Exception as e: + print( + f"[task_title] generate failed task={task_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + + # 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸: + # 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。 + with session_scope() as s: + values: dict[str, Any] = {"auto_title_pending": False} + if title: + values["name"] = title + result = s.execute( + update(Task) + .where( + Task.task_id == task_id, + Task.user_id == user_id, + Task.auto_title_pending.is_(True), + ) + .values(**values) + ) + applied = bool(result.rowcount) + if response is not None and caps is not None: + usage = getattr(response, "usage", None) + try: + record_chat_usage( + task_id=task_id, + user_id=user_id, + message_id=None, + model_profile=f"{caps.family}.{caps.variant}", + prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0, + completion_tokens=getattr(usage, "completion_tokens", 0) or 0, + input_cny_per_mtoken=caps.input_cny_per_mtoken, + output_cny_per_mtoken=caps.output_cny_per_mtoken, + response=response, + kind="task_title", + ) + except Exception: + pass + return title if applied else None + + +def generate_task_title_safe(**kwargs: Any) -> Optional[str]: + """后台辅助任务入口:DB/配置层异常也必须被吞掉,主对话永不受标题影响。""" + try: + return generate_task_title(**kwargs) + except Exception as e: + print( + f"[task_title] auxiliary failed task={kwargs.get('task_id')}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return None diff --git a/db/migrations/versions/20260727_1600_0023_task_auto_title_pending.py b/db/migrations/versions/20260727_1600_0023_task_auto_title_pending.py new file mode 100644 index 0000000..d52225a --- /dev/null +++ b/db/migrations/versions/20260727_1600_0023_task_auto_title_pending.py @@ -0,0 +1,36 @@ +"""tasks 快速新对话的一次性自动命名标记。 + +Revision ID: 0023 +Revises: 0022 +Create Date: 2026-07-27 + +新增 auto_title_pending BOOLEAN NOT NULL DEFAULT false。存量任务全部为 false, +旧 POST /v1/tasks 调用方也缺省 false;只有新“目录优先、首发建 task”入口写 true。 +首条消息触发标题辅助调用后清 false,人工 PATCH name 也清 false,防异步覆盖。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0023" +down_revision: Union[str, None] = "0022" +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( + "auto_title_pending", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "auto_title_pending") diff --git a/tests/test_task_title.py b/tests/test_task_title.py new file mode 100644 index 0000000..1358e8a --- /dev/null +++ b/tests/test_task_title.py @@ -0,0 +1,28 @@ +"""快速新对话自动标题的纯函数边界。""" +from __future__ import annotations + +import unittest + +from core.task_title import clean_generated_title + + +class TaskTitleTests(unittest.TestCase): + def test_strips_model_wrappers_and_forbidden_path_chars(self): + self.assertEqual( + clean_generated_title('标题:“低碳水泥/XRD 分析”', "原消息"), + "低碳水泥·XRD 分析", + ) + + def test_empty_or_generic_output_falls_back_to_user_message(self): + self.assertEqual( + clean_generated_title("新对话", " 分析熟料三率值变化\n补充说明"), + "分析熟料三率值变化", + ) + + def test_title_is_bounded(self): + title = clean_generated_title("这是一段非常非常长而且不适合作为对话标题的模型输出内容", "") + self.assertLessEqual(len(title), 24) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index 40bdd81..c1853bc 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -109,6 +109,7 @@ class TasksCrudTests(unittest.TestCase): self.assertTrue(t["working_dir"].endswith("/路由测试任务")) self.assertTrue(t["model_profile"]) # 默认模型已填 self.assertEqual(t["run_status"], "idle") + self.assertFalse(t["auto_title_pending"]) # 旧创建契约不自动改名 # 列表含它,分页壳完整 r = _client.get("/v1/tasks", headers=_AUTH) @@ -136,6 +137,29 @@ class TasksCrudTests(unittest.TestCase): # 非法 id → 404 self.assertEqual(_client.get("/v1/tasks/not-a-uuid", headers=_AUTH).status_code, 404) + def test_quick_task_auto_title_marker_and_manual_rename_gate(self): + r = _client.post( + "/v1/tasks", + json={ + "name": "新对话", + "working_dir": "快速对话目录", + "auto_title": True, + }, + headers=_AUTH, + ) + self.assertEqual(r.status_code, 201, r.text) + t = r.json() + self.assertTrue(t["auto_title_pending"]) + + # 人工改名拥有最高优先级:立刻清 pending,异步标题条件 UPDATE 将失配。 + d = _client.patch( + f"/v1/tasks/{t['task_id']}", + json={"name": "用户指定标题"}, + headers=_AUTH, + ).json() + self.assertEqual(d["name"], "用户指定标题") + self.assertFalse(d["auto_title_pending"]) + def test_shared_working_dir_and_folders(self): for name in ("共享目录甲", "共享目录乙"): r = _client.post("/v1/tasks", json={"name": name, "working_dir": "共享项目"}, headers=_AUTH) diff --git a/web/app.py b/web/app.py index 075c40c..29aca2c 100644 --- a/web/app.py +++ b/web/app.py @@ -150,6 +150,16 @@ def create_app() -> FastAPI: except Exception as e: print(f"[shutdown] broker shutdown error: {type(e).__name__}: {e}") + # 自动标题等辅助调用不属于主 run,不参与协作式 cancel;给短调用一个 + # 有界收尾窗口,避免部署时丢掉刚生成但尚未落库的标题。 + aux = list(app.state.aux_tasks) + if aux: + _, pending_aux = await asyncio.wait(aux, timeout=10) + for task in pending_aux: + task.cancel() + if pending_aux: + await asyncio.gather(*pending_aux, return_exceptions=True) + # drain 已 await inflight 收尾、run 线程退完;非阻塞关池(进程在退出,保守清理) run_executor.shutdown(wait=False) @@ -162,6 +172,9 @@ def create_app() -> FastAPI: ), lifespan=lifespan, ) + # 自动标题等短辅助调用:与主 run 分开持强引用,避免 asyncio task 被 GC; + # 不计入 inflight(不会占用单活 run / drain 的协作式 cancel 语义)。 + app.state.aux_tasks = set() app.add_middleware( CORSMiddleware, allow_origins=["*"], # 本地宽松,部署 platform 时按域名收紧 diff --git a/web/common.py b/web/common.py index 0b35f02..8940c91 100644 --- a/web/common.py +++ b/web/common.py @@ -99,6 +99,7 @@ def task_dict( d = { "task_id": str(row.task_id), "name": row.name or "", + "auto_title_pending": bool(getattr(row, "auto_title_pending", False)), "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 b7f8611..a288ab7 100644 --- a/web/routers/messages.py +++ b/web/routers/messages.py @@ -209,7 +209,11 @@ def register_message_routes(app, *, require_user) -> None: print(f"[shortcut] {str(user_id)[:8]} '{_sc_hit}' expanded") with session_scope() as s: row = s.execute( - select(Task.run_status, Task.model_profile) + select( + Task.run_status, + Task.model_profile, + Task.auto_title_pending, + ) .where(Task.task_id == tid, Task.user_id == user_id) .with_for_update() ).first() @@ -247,6 +251,8 @@ def register_message_routes(app, *, require_user) -> None: s.execute( update(Task).where(Task.task_id == tid).values(**values) ) + title_profile = values.get("model_profile", cur_profile) + should_auto_title = bool(row.auto_title_pending) # image_model / video_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。 # 显式选中非空 variant 时过档位门控(档外 → 403);空串不查(走 yaml 默认)。 image_variant = resolve_image_model(body.image_model, user_id=user_id) @@ -260,6 +266,19 @@ def register_message_routes(app, *, require_user) -> None: )) app.state.inflight[run_task] = tid run_task.add_done_callback(lambda t: app.state.inflight.pop(t, None)) + # 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞 + # TTFT/SSE;generate_task_title 内再次查闸并用条件 UPDATE 防人工改名竞态。 + if should_auto_title: + from core.task_title import generate_task_title_safe + title_task = asyncio.create_task(asyncio.to_thread( + generate_task_title_safe, + task_id=tid, + user_id=user_id, + user_message=content, + model_profile=title_profile, + )) + app.state.aux_tasks.add(title_task) + title_task.add_done_callback(app.state.aux_tasks.discard) return {"events_url": f"/v1/tasks/{tid}/events"} @app.post("/v1/tasks/{task_id}/cancel", status_code=202, tags=["tasks"]) diff --git a/web/routers/tasks.py b/web/routers/tasks.py index 58ed8d0..678eb3c 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -39,6 +39,7 @@ def register_task_routes(app, *, require_user) -> None: - `name` 必填(任务显示名,DB 列 NOT NULL,UI 列表 / 标题用) - `working_dir` 可选(留空 → 用 name 作目录名);同 working_dir 多 task 共享同目录(§7.1) + - `auto_title=true` 仅标记“首条消息后自动命名”;旧调用方缺省 false - name / working_dir 都过 validate_task_name(简单名,无 `/\\..`,非 `.` 起头,≤255) - 前缀嵌套(no-subtask,同 user 内)→ 409 """ @@ -75,6 +76,7 @@ def register_task_routes(app, *, require_user) -> None: task_id=tid, name=name, working_dir=fs_dir_db, skill=skill, description=description, user_id=user_id, model=model_id, model_profile=profile, + auto_title_pending=body.auto_title, ) with session_scope() as s: row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() @@ -427,6 +429,9 @@ def register_task_routes(app, *, require_user) -> None: updates["name"] = validate_task_name(body.name) except InvalidTaskName as e: raise HTTPException(400, f"name 不合法: {e}") + # 人工命名优先级最高:即使自动标题调用已在途,最终 UPDATE 也会因 + # pending=False 条件失配而放弃,绝不覆盖用户刚改好的名称。 + updates["auto_title_pending"] = False if body.model_profile is not None: # 切模型:校验后双列同更(profile + model_id)。下条 send 才生效 — 当前 # in-flight run 不受影响(build_agent resume 时下次重读)。档外模型 → 403。 diff --git a/web/schemas.py b/web/schemas.py index 68d99b7..ed08d28 100644 --- a/web/schemas.py +++ b/web/schemas.py @@ -9,6 +9,8 @@ from pydantic import BaseModel class TaskCreateRequest(BaseModel): name: str # 任务显示名(必填,DB 列 NOT NULL) working_dir: str = "" # 工作目录名(可选,留空 → 用 name 作目录名) + # 快速新对话入口专用:首条消息后异步生成一次标题。缺省 False 保持旧契约。 + auto_title: bool = False description: str = "" skill: str = "" model_profile: str = "" # `family.variant`,留空 → cfg["default_model"];必须存在于 config/models/ diff --git a/web/static/dev.html b/web/static/dev.html index 945ec2f..c3facb3 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -759,6 +759,25 @@ display: flex; flex-direction: column; gap: 8px; min-height: 0; /* 允许在 flex 容器里收缩 + 触发自身滚动 */ } + .new-chat-start { + width: min(560px, calc(100% - 32px)); + margin: auto; + padding: 28px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); + box-shadow: 0 8px 28px rgba(0,0,0,.05); + text-align: left; + } + .new-chat-start h2 { margin: 0 0 8px; font-size: 20px; } + .new-chat-start .lead { margin: 0 0 24px; color: var(--muted); font-size: 13px; } + .new-chat-start label { display:block; margin-bottom:7px; font-size:13px; font-weight:600; } + .new-chat-dir-row { display:flex; gap:8px; align-items:center; } + .new-chat-dir-row select, .new-chat-dir-row input { min-width:0; flex:1; } + .new-chat-new-dir { display:none; margin-top:10px; } + .new-chat-new-dir.show { display:block; } + .new-chat-new-dir input { width:100%; } + .new-chat-start .dir-note { margin-top:10px; min-height:18px; color:var(--muted); font-size:12px; } /* 顶/底"上滑加载更早 / 下滑加载更新"占位:居中淡色小字,自身不占气泡样式 */ .msg-top-sentinel, .msg-bot-sentinel { align-self: center; font-size: 12px; padding: 4px 0; opacity: 0.6; } /* 跳转定位后的高亮闪一下 */ @@ -1735,7 +1754,8 @@
- + +
diff --git a/web/static/js/chat.js b/web/static/js/chat.js index b5b1d48..7a67443 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -21,6 +21,7 @@ const CHANNEL_BADGE = { wechat: { label: "微信", title: "微信 ClawBot 渠道" }, wecom: { label: "企业微信", title: "企业微信渠道" }, }; +const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir"; function channelCfg(ch) { return CHANNEL_BADGE[ch] || null; } import { logout } from "./auth.js"; import { openWechatModal } from "./wechat.js"; @@ -393,6 +394,133 @@ const _taskScrollObserver = new IntersectionObserver((entries) => { _taskScrollObserver.observe($("task-sentinel")); // ───── select task ───── +function draftWorkingDir() { + return (state.draftWorkingDir || "").trim(); +} + +function syncDraftAction() { + const ready = !!draftWorkingDir(); + setActionMode(ready ? "idle" : "loading"); + $("chat-hint").textContent = ready ? "输入消息开始对话" : "请先选择或新建工作目录"; + const note = $("new-chat-dir-note"); + if (note) { + note.textContent = ready + ? `本次对话生成的文件将保存到「${draftWorkingDir()}」` + : "工作目录用于保存本次对话生成的文件,同一目录可以包含多段对话。"; + } +} + +function renderDraftFolderOptions() { + const sel = $("new-chat-dir-select"); + if (!sel) return; + const folders = state.folders || []; + const selected = state.draftNewDirMode ? "" : draftWorkingDir(); + sel.innerHTML = [ + ``, + ...folders.map(f => { + const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录"; + return ``; + }), + ].join(""); + if (folders.some(f => f.name === selected)) sel.value = selected; +} + +function bindDraftControls() { + const sel = $("new-chat-dir-select"); + const add = $("new-chat-dir-add"); + const box = $("new-chat-new-dir"); + const input = $("new-chat-dir-input"); + if (!sel || !add || !box || !input) return; + renderDraftFolderOptions(); + box.classList.toggle("show", state.draftNewDirMode); + if (state.draftNewDirMode) input.value = draftWorkingDir(); + sel.onchange = () => { + state.draftNewDirMode = false; + state.draftWorkingDir = sel.value || ""; + box.classList.remove("show"); + if (state.draftWorkingDir) { + state.filesPath = state.draftWorkingDir; + loadFiles(); + } + syncDraftAction(); + }; + add.onclick = () => { + state.draftNewDirMode = true; + state.draftWorkingDir = ""; + sel.value = ""; + box.classList.add("show"); + input.value = ""; + input.focus(); + syncDraftAction(); + }; + input.oninput = () => { + state.draftWorkingDir = input.value.trim(); + syncDraftAction(); + }; +} + +export async function showNewConversationDraft() { + if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } + if (state.taskId) { _flushMediaArtifactCache(); clearAttachTray(); } + state.taskId = null; + state.taskMeta = null; + state.concurrentWarnings = []; + state.loadedMessages = []; + state.outline = []; + state.draftNewDirMode = false; + state.filesPath = ""; + document.querySelectorAll(".task-row").forEach(el => el.classList.remove("active")); + syncChannelCardActive(null); + renderConcurrentWarning(); + renderTaskProgressDock([]); + renderOutlineRail(); + $("chat-meta").innerHTML = `新对话`; + $("chat-stream").innerHTML = ` +
+

开始一段新对话

+

先确定文件保存位置,然后直接告诉助手你想做什么。

+ +
+ + +
+
+ +
+
+
`; + $("chat-form").style.display = "flex"; + $("chat-input").readOnly = false; + $("chat-input").classList.remove("readonly-locked"); + $("chat-input").placeholder = "输入消息…(Enter 发送,Shift+Enter 换行)"; + $("chat-attach-btn").disabled = true; // task_dir 尚不存在,首发建 task 后开放 + $("chat-mic").disabled = false; + $("btn-done").disabled = true; + $("btn-task-menu").disabled = true; + syncOptimizeBtn(); + bindDraftControls(); + syncDraftAction(); + loadFiles(); + if (mqPhone.matches) setMobileView("mv-mid"); + + try { + const data = await api("GET", "/v1/folders"); + if (state.taskId) return; // 拉取期间用户已切进真实 task + state.folders = data.folders || []; + if (!draftWorkingDir()) { + const recent = localStorage.getItem(LS_RECENT_WORKING_DIR) || ""; + if (state.folders.some(f => f.name === recent)) state.draftWorkingDir = recent; + } + renderDraftFolderOptions(); + syncDraftAction(); + } catch (e) { + // 无历史目录/拉取失败仍可新建,不阻断草稿入口。 + if (!state.taskId) renderDraftFolderOptions(); + } +} + +$("hd-new").onclick = () => showNewConversationDraft(); + export async function selectTask(tid) { if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同, @@ -1478,6 +1606,10 @@ $("chat-attach-btn").addEventListener("click", (e) => { async function uploadAttachFiles(files) { if (!files || !files.length) return; const hint = $("chat-hint"); + if (!state.taskId) { + hint.textContent = "发送第一条消息创建对话后即可添加附件"; + return; + } hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`; const saved = await uploadFiles(files, { onProgress: (loaded, total) => { @@ -2053,17 +2185,53 @@ function takePastedRels() { return rels; } +async function createTaskFromDraft() { + const workingDir = draftWorkingDir(); + if (!workingDir) throw new Error("请先选择或新建工作目录"); + const t = await api("POST", "/v1/tasks", { + name: "新对话", + working_dir: workingDir, + auto_title: true, + description: "", + skill: "", + model_profile: "", + }); + state.taskId = t.task_id; + state.taskMeta = t; + state.filesPath = workingDir; + localStorage.setItem(LS_RECENT_WORKING_DIR, workingDir); + $("chat-stream").innerHTML = ""; + renderChatMeta(); + applyChannelComposerLock(t); + $("chat-attach-btn").disabled = false; + loadTaskList(); // 新 task 立即进左栏;不等待列表 RTT 再发首条消息 + loadFiles(); + return t; +} + async function sendMessage(overrideText) { - if (!state.taskId) return; if (isCurrentTaskStreaming()) return; + const fromInput = typeof overrideText !== "string"; + let content = (fromInput ? $("chat-input").value : overrideText).trim(); + if (!content && !state.taskId) return; // 草稿态尚不能上传附件,空文本无需建 task + // 草稿页直到首发才创建 task,避免反复点“+ 新对话”留下空记录。 + if (!state.taskId) { + setActionMode("loading"); + $("chat-hint").textContent = "正在创建对话…"; + try { + await createTaskFromDraft(); + } catch (e) { + syncDraftAction(); + $("chat-hint").textContent = `创建失败:${e.message}`; + return; + } + } // 渠道镜像 task(微信 / 企业微信)只读:web 发的消息推不回去(单向),挡在入口避免不一致(见 applyChannelComposerLock) const _chCfg = state.taskMeta && channelCfg(state.taskMeta.channel); if (_chCfg) { $("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`; return; } - const fromInput = typeof overrideText !== "string"; - let content = (fromInput ? $("chat-input").value : overrideText).trim(); // 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」, // 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。 const pastedRels = fromInput ? takePastedRels() : []; @@ -2235,7 +2403,11 @@ async function fetchSse(url, run) { // 顶栏计量栏会停在发问前的旧值,直到切走再切回才更新 — 这里补一次让它即时刷新。 try { const meta = await api("GET", "/v1/tasks/" + ctx.taskId); - if (state.taskId === ctx.taskId) { state.taskMeta = meta; renderChatMeta(); } + if (state.taskId === ctx.taskId) { + state.taskMeta = meta; + renderChatMeta(); + if (meta.auto_title_pending) pollAutoTitle(ctx.taskId); + } } catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ } await loadMessages(); refreshOutline(); // 本轮新增 user 提问 → 目录补一条 @@ -2244,6 +2416,20 @@ async function fetchSse(url, run) { } } +async function pollAutoTitle(taskId, attempts = 6) { + if (attempts <= 0 || state.taskId !== taskId) return; + await new Promise(resolve => setTimeout(resolve, 2000)); + if (state.taskId !== taskId) return; + try { + const meta = await api("GET", "/v1/tasks/" + taskId); + if (state.taskId !== taskId) return; + state.taskMeta = meta; + renderChatMeta(); + loadTaskList(); + if (meta.auto_title_pending) pollAutoTitle(taskId, attempts - 1); + } catch (e) { /* 自动标题是装饰性能力,轮询失败静默 */ } +} + async function consumeSseStream(url, asstCard, ctx) { const r = await fetch(url, { headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" }, @@ -2640,17 +2826,7 @@ async function deleteTask(tid, name, nMsg) { try { await api("DELETE", "/v1/tasks/" + tid); if (state.taskId === tid) { - if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } - state.taskId = null; - state.taskMeta = null; - state.concurrentWarnings = []; - renderConcurrentWarning(); - $("chat-meta").innerHTML = `(未选中任务)`; - $("chat-stream").innerHTML = `
请在左侧选一个任务
`; - renderTaskProgressDock([]); - $("chat-form").style.display = "none"; - $("btn-done").disabled = true; - $("btn-task-menu").disabled = true; + await showNewConversationDraft(); } loadTaskList(); loadFiles(); diff --git a/web/static/js/main.js b/web/static/js/main.js index ddcb053..00043ac 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -16,7 +16,7 @@ import { closeFilePreview, closeMiniPreview } from "./preview.js"; import { closeSrcPicker, loadFiles } from "./files.js"; import { loadFolderSuggestions } from "./newtask.js"; import { embedInit } from "./embed.js"; -import { loadTaskList, loadModels, loadChannelCards } from "./chat.js"; +import { loadTaskList, loadModels, loadChannelCards, showNewConversationDraft } from "./chat.js"; import { refreshProcs } from "./procs.js"; // ───── enter app ───── @@ -24,6 +24,7 @@ export function enterApp() { $("login").style.display = "none"; $("app").classList.add("ready"); renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份 + showNewConversationDraft(); // 登录空态与“+ 新对话”共用同一草稿页,不先落 tasks 行 loadTaskList(); loadChannelCards(); // 渠道镜像对话(微信 / 企业微信)固定卡片,与列表并行拉 loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root diff --git a/web/static/js/newtask.js b/web/static/js/newtask.js index 3f636e1..e4e782a 100644 --- a/web/static/js/newtask.js +++ b/web/static/js/newtask.js @@ -1,6 +1,6 @@ // 新建任务弹框:任务名 / 工作目录(新建 sentinel 或复用已有,二级 input 联动)/ // 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。 -// 顶层自绑 hd-new 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions +// 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions //(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。 import { $ } from "./dom.js"; import { state } from "./state.js"; @@ -12,7 +12,7 @@ import { loadModels, loadTaskList, selectTask } from "./chat.js"; // ───── new task ───── // wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag let wdManuallyEdited = false; -$("hd-new").onclick = async () => { +$("hd-new-custom").onclick = async () => { $("nt-name").value = ""; $("nt-wd-sel").value = "__new__"; // 默认选 sentinel $("nt-wd-new").value = ""; diff --git a/web/static/js/state.js b/web/static/js/state.js index 6b9e5e2..141f9ea 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -43,6 +43,9 @@ export const state = { userEmail: localStorage.getItem(LS_EMAIL) || "", // email taskId: null, taskMeta: null, + // “+ 新对话”/登录空态共用的前端草稿;首发成功后才创建真实 task。 + draftWorkingDir: "", + draftNewDirMode: false, filesPath: "", // 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示 concurrentWarnings: [],