Add workspace-first new conversation flow
This commit is contained in:
parent
85c918dc2f
commit
c3a6f7deee
|
|
@ -5,6 +5,11 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.60.0 — 2026-07-27
|
||||
|
||||
- 登录后的空白页现在可以直接选择已有工作目录或新建工作目录,然后开始对话;点击左栏「+ 新对话」也进入同一个快速入口,发送第一条消息时才真正创建对话。
|
||||
- 新对话会根据第一条消息自动生成简短标题,工作目录名称保持不变;原有任务名、描述、智能体类型和模型均可预设的创建方式仍保留在「自定义」入口。
|
||||
|
||||
## 0.59.6 — 2026-07-27
|
||||
|
||||
- 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
10
PROGRESS.md
10
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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.59.6"
|
||||
__version__ = "0.60.0"
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
13
web/app.py
13
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 时按域名收紧
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue