diff --git a/PROGRESS.md b/PROGRESS.md index c080d4e..44b5384 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,14 @@ ## 已完成关键能力 +### 2026-06-16 / ask_user:回复里渲染可点击「方案确认」选项卡(Claude 式) + +- 需求:agent 在分叉点能像 Claude 那样抛出可点选项,用户点一个继续、或不点直接用文字讨论。设计取舍见下。 +- **收窄定位**:不是通用提问器,只做「方案/分支确认」——存在 2-4 个互斥方向且选择会实质改变后续动作时才用。防 agent「变爱问」(高轮数烧 token 已知痛点)是成败关键,故系统提示严格约束使用条件。 +- **与轮次模型同构、无阻塞**:复用「LLM 出无 tool_call 消息即结束本轮」语义——`ask_user` 是虚拟工具(同 `task_progress` 范式),`core/loop.py` 检测到本步调用它就 emit done 提前结束本轮、不回灌 LLM;点选项 = 把该选项 label 当新用户消息发出(复用 `POST /messages`),零额外 LLM 往返。 +- 后端:新增 `tools/ask_user.py`(`AskUserTool`,question + 2-4 个 `{label, description}` 选项,结果仅占位);`core/agent_builder.py` 注册;`core/loop.py` 加提前终止分支;`prompts/system/general_v1.md` 加「方案确认约定」段 + 工具清单一行。 +- 前端 `web/static/js/chat.js`:`buildAskUserCard` 渲染选项卡;`handleSseEvent` 的 `tool_call`/`tool_result` 特判 ask_user(选项卡 / 抑制占位结果);`renderMessages` 历史重渲特判(改 index 遍历,向后看有无 user 回复判「已答」,命中项标「✓ 已选」);`sendMessage(overrideText)` 支持点击直发不清输入框;`chat-stream` 点击委托接 `.ask-option`。`dev.html` 加 `.ask-user/.ask-option` 等样式。持久化天然免费(选项在 `tool_calls.arguments` 里,刷新页面按钮还在)。bump 0.13.0 → 0.14.0。 + ### 2026-06-16 / 消息目录:右侧悬浮圆点轨道导航(ChatGPT 式)+ 双向分页 - 需求:长对话里快速定位历史某轮提问。参考 ChatGPT 扩展(Scrollbar / Outline)的交互——每点=一轮"我"的提问,hover 出标题气泡,点击滚动定位。 diff --git a/core/__init__.py b/core/__init__.py index b8b24dd..c41a19a 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.13.0" +__version__ = "0.14.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index 2623429..c7e490f 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -52,6 +52,7 @@ from tools.shell import ShellTool from tools.skill_authoring import ForkSkillTool, SaveSkillTool from tools.skill_tool import LoadSkillTool from tools.task_progress import TaskProgressTool +from tools.ask_user import AskUserTool from tools.web_fetch import WebFetchTool from tools.web_search import WebSearchTool @@ -483,6 +484,8 @@ def build_agent( tools = {} tp = TaskProgressTool(base_dir=tool_base, user_root=ur_path) tools[tp.name] = tp + au = AskUserTool(base_dir=tool_base, user_root=ur_path) + tools[au.name] = au for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): t = cls(base_dir=tool_base, user_root=ur_path) diff --git a/core/loop.py b/core/loop.py index ec768be..c21dad5 100644 --- a/core/loop.py +++ b/core/loop.py @@ -323,6 +323,13 @@ class AgentLoop: } ) + # ask_user:本步调用了人工选择工具 → 提前结束本轮,等用户点选项 / 文字讨论, + # 不回灌 LLM。选项已随该 tool_call 的 arguments 流给前端渲染成选项卡;tool 结果 + # 只是占位,下轮用户回复(点选项 = 发选项 label 文本)后模型自然接上。 + if any(getattr(tc.function, "name", "") == "ask_user" for tc in tool_calls): + self._emit({"type": "done"}) + return getattr(msg, "content", None) or "" + # 全局「无进展」熔断:整步所有 tool 都无净产出(全是 [Error]/重复/被拦)→ 累计; # 连续 _STALL_LIMIT 步空转就主动停,别烧到 max_iterations。一旦某步有净产出立即清零。 if step_productive: diff --git a/prompts/system/general_v1.md b/prompts/system/general_v1.md index 3d750a7..9d1b44a 100644 --- a/prompts/system/general_v1.md +++ b/prompts/system/general_v1.md @@ -7,6 +7,7 @@ - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write` 把 `.py` 落到 `/scripts/`(如 `scripts/analyze.py`),再 `run_python(script_path="scripts/analyze.py")` 执行 —— 源码留文件里可重读可改可重跑,不挤占对话历史;`scripts/` 只放过程脚本,交付产物仍落 task_dir 根或 SKILL 指定路径。真·一次性短代码(算个数/探查一行)才用 `run_python(code=...)` 内联。 - `load_skill` —— 加载某个 skill 的完整指引 - `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。 +- `ask_user` —— 在真正的分叉点让用户在 2-4 个互斥方向间点选拍板(见下「方案确认约定」)。 ## 进度展示约定 - 多步骤任务开始后,用 `task_progress(action="set_plan", steps=[...])` 发布一份简短计划。 @@ -15,6 +16,12 @@ - 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。 - 简单问答、单次文件读取、很小的改动不需要调用 `task_progress`。 +## 方案确认约定(ask_user) +- **只在真正的分叉点用**:存在 2-4 个互斥方向、且用户选哪个会**实质改变你接下来的动作**时,用 `ask_user(question, options=[{label, description}])` 让用户点选拍板。典型:确认实施方案、在多条候选路线里选一条、在明确取舍间二选一。 +- `label` 写成「用户可直接当作回复的一句话」—— 用户点它就等于发出这句话;`description` 可选,补一句该选项的取舍/后果。 +- **不要滥用**:信息缺失的开放性提问直接用文字问;你能自己合理默认就推进的决定别问(跟「能自己定的别停下来问」一致);单纯是/否确认、进度播报都不用 `ask_user`。 +- 每轮最多调用一次;**调用后你的发言即结束、等待用户**,不要在同一轮里继续往下做。用户可能点选项、也可能不点直接用文字与你讨论,两种都要能自然接住。 + ## Skill 机制 你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在 某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引 diff --git a/tools/ask_user.py b/tools/ask_user.py new file mode 100644 index 0000000..c4bd585 --- /dev/null +++ b/tools/ask_user.py @@ -0,0 +1,74 @@ +"""UI-only "ask the user to pick a branch" tool. + +收窄定位:**方案/分支确认**,不是通用提问器。模型在「需要用户在 2-4 个互斥方向 +间拍板、且该选择会实质改变后续动作」时调用,前端把 question + options 渲成一组可点击 +选项卡。用户点某项即作为其回复继续,也可不点直接用文字讨论。 + +与 task_progress 同属「虚拟工具」:结果体极小(只是占位),真正给前端用的内容全在 +assistant tool_call 的 arguments 里(question/options),供 Web 渲染。loop 检测到本步 +调用了本工具就提前结束本轮,等用户响应(见 core/loop.py)。 +""" +from __future__ import annotations + +import json +from typing import Any + +from .base import Tool + + +class AskUserTool(Tool): + name = "ask_user" + description = ( + "在需要用户在 2-4 个互斥方向间拍板、且该选择会实质改变你接下来动作时,展示一组可点击" + "选项让用户选。典型:确认实施方案、在多条候选路线里选一条、在明确的取舍间二选一。" + "用户点某个选项即作为其回复继续,也可不点直接用文字与你讨论。" + "不要用于:信息缺失的开放性提问(直接用文字问)、你能自己合理默认就推进的决定、" + "单纯的是/否确认、给用户播报进度。每轮最多用一次,且只在真正的分叉点用;" + "调用本工具后你的发言即结束、等待用户,不要再继续动作。" + ) + parameters = { + "type": "object", + "additionalProperties": False, + "properties": { + "question": { + "type": "string", + "description": "要用户拍板的问题,一句话讲清在选什么。", + }, + "options": { + "type": "array", + "description": "2-4 个互斥选项。", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "label": { + "type": "string", + "description": "选项短标题,用户点击后即作为其回复原文发出,写成可直接当回复的一句话。", + }, + "description": { + "type": "string", + "description": "可选,一句话说明该选项的取舍 / 后果。", + }, + }, + "required": ["label"], + }, + }, + }, + "required": ["question", "options"], + } + + def execute(self, **kwargs: Any) -> str: + options = kwargs.get("options") + n = len(options) if isinstance(options, list) else 0 + return json.dumps( + { + "ok": True, + "shown": True, + "n_options": n, + "note": "已向用户展示选项,等待其点选或继续讨论。", + }, + ensure_ascii=False, + separators=(",", ":"), + ) diff --git a/web/static/dev.html b/web/static/dev.html index 36c5146..c07ff81 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -624,6 +624,31 @@ margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-sm); overflow-x: auto; max-height: 300px; white-space: pre-wrap; } + /* ask_user 选项卡:question + 一组可点击选项(点击=发该选项 label 作为回复) */ + .ask-user { + margin-top: 10px; padding: 10px 12px; border: 1px solid var(--border); + border-left: 3px solid var(--accent); border-radius: var(--r-md); + background: var(--accent-soft); + } + .ask-user.answered { border-left-color: var(--border); background: var(--code-bg); opacity: 0.85; } + .ask-q { font-size: 14px; font-weight: 600; margin-bottom: 8px; line-height: 1.45; } + .ask-options { display: flex; flex-direction: column; gap: 6px; } + .ask-option { + display: flex; flex-direction: column; align-items: flex-start; gap: 2px; + width: 100%; text-align: left; padding: 8px 10px; + background: #fff; border: 1px solid var(--border); border-radius: var(--r-md); + cursor: pointer; transition: var(--t); + } + .ask-option:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); } + .ask-option:disabled { cursor: default; } + .ask-option.selected { + border-color: var(--accent); background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--accent); + } + .ask-option.selected .ask-option-label::after { content: " ✓ 已选"; color: var(--accent); font-weight: 600; } + .ask-option-label { font-size: 13px; font-weight: 600; color: var(--text); } + .ask-option-desc { font-size: 12px; color: var(--muted); line-height: 1.4; } + .ask-hint { margin-top: 8px; font-size: 11px; color: var(--muted); } .task-progress { margin-top: 8px; padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--r-md); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 3869bca..6b66568 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -752,7 +752,8 @@ function renderMessages(msgs) { } return fresh; }; - for (const m of msgs) { + for (let mi = 0; mi < msgs.length; mi++) { + const m = msgs[mi]; const p = m.payload || {}; const role = p.role || "?"; if (role === "system") continue; // 不显示 system @@ -767,6 +768,7 @@ function renderMessages(msgs) { } if (role === "tool") { if ((p.name || "") === "task_progress") continue; + if ((p.name || "") === "ask_user") continue; // 占位结果不展示;选项卡在 assistant tool_call 里渲染 // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) const card = document.createElement("div"); card.className = "msg tool"; @@ -816,6 +818,19 @@ function renderMessages(msgs) { if (fn === "task_progress") { continue; } + if (fn === "ask_user") { + // 之后若已有 user 消息 → 这轮选择已答:卡置灰,命中项标「已选」;否则仍可点。 + let answered = false, chosen = ""; + for (let k = mi + 1; k < msgs.length; k++) { + if (((msgs[k].payload || {}).role) === "user") { + answered = true; + chosen = (msgs[k].payload.content || ""); + break; + } + } + html += buildAskUserCard(argsObj, { interactive: !answered, chosenLabel: chosen }).outerHTML; + continue; + } const label = toolActivityLabel(fn, argsObj); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; @@ -1004,6 +1019,19 @@ $("chat-optimize").onclick = optimizePrompt; // 视频走原生