Add workspace-first new conversation flow

This commit is contained in:
caoqianming 2026-07-27 12:28:56 +08:00
parent 85c918dc2f
commit c3a6f7deee
20 changed files with 517 additions and 27 deletions

View File

@ -5,6 +5,11 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.0 — 2026-07-27
- 登录后的空白页现在可以直接选择已有工作目录或新建工作目录,然后开始对话;点击左栏「+ 新对话」也进入同一个快速入口,发送第一条消息时才真正创建对话。
- 新对话会根据第一条消息自动生成简短标题,工作目录名称保持不变;原有任务名、描述、智能体类型和模型均可预设的创建方式仍保留在「自定义」入口。
## 0.59.6 — 2026-07-27
- 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。

View File

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

View File

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

View File

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

View File

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

View File

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

142
core/task_title.py Normal file
View File

@ -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. 618 个汉字为宜必要的英文缩写材料牌号和数字可以保留
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

View File

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

28
tests/test_task_title.py Normal file
View File

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

View File

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

View File

@ -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 时按域名收紧

View File

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

View File

@ -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/SSEgenerate_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"])

View File

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

View File

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

View File

@ -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 @@
<button id="pane-toggle-left" class="small" title="折叠任务列表"></button>
</div>
<div class="pane-head">
<button id="hd-new" class="primary" style="flex:1;">+ 新建任务</button>
<button id="hd-new" class="primary" style="flex:1;">+ 新对话</button>
<button id="hd-new-custom" class="small" title="预先指定任务名、描述、智能体类型和模型">自定义</button>
</div>
<!-- 渠道镜像对话(微信 / 企业微信)固定卡片:由 chat.js loadChannelCards 填充;无则 :empty 隐藏 -->
<div id="channel-cards"></div>

View File

@ -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 = [
`<option value="">选择已有目录…</option>`,
...folders.map(f => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录";
return `<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${tag}</option>`;
}),
].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 = `<span class="muted">新对话</span>`;
$("chat-stream").innerHTML = `
<div class="new-chat-start">
<h2>开始一段新对话</h2>
<p class="lead">先确定文件保存位置然后直接告诉助手你想做什么</p>
<label for="new-chat-dir-select">工作目录</label>
<div class="new-chat-dir-row">
<select id="new-chat-dir-select"><option value="">加载目录</option></select>
<button type="button" id="new-chat-dir-add">+ 新建工作目录</button>
</div>
<div id="new-chat-new-dir" class="new-chat-new-dir">
<input id="new-chat-dir-input" placeholder="输入新目录名" maxlength="255" />
</div>
<div id="new-chat-dir-note" class="dir-note"></div>
</div>`;
$("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 = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
renderTaskProgressDock([]);
$("chat-form").style.display = "none";
$("btn-done").disabled = true;
$("btn-task-menu").disabled = true;
await showNewConversationDraft();
}
loadTaskList();
loadFiles();

View File

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

View File

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

View File

@ -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: [],