feat(web): ask_user 工具 — 回复里渲染可点击「方案确认」选项卡 + bump 0.14.0

agent 在真正的分叉点(2-4 个互斥方向且选择会实质改变后续动作)调用 ask_user,
前端渲染可点击选项卡:点一个即作为回复继续,或不点直接用文字讨论。

收窄定位防 agent 变爱问(高轮数烧 token 已知痛点),系统提示严格约束使用条件。
与轮次模型同构、无阻塞:ask_user 是虚拟工具(同 task_progress 范式),loop 检测到
本步调用它就提前结束本轮、不回灌 LLM;点选项=发该选项 label 作新用户消息,零额外
LLM 往返。选项落在 tool_calls.arguments 里,刷新页面按钮还在;已答的卡自动置灰。

- tools/ask_user.py 新增 AskUserTool;core/agent_builder.py 注册
- core/loop.py 加 ask_user 提前终止分支
- prompts/system/general_v1.md 加「方案确认约定」段
- web/static/js/chat.js buildAskUserCard + SSE/历史重渲特判 + sendMessage(overrideText) + 点击委托
- web/static/dev.html 加 .ask-user/.ask-option 样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-16 11:23:59 +08:00
parent 91e200ef4f
commit 1cfeb000a6
8 changed files with 194 additions and 5 deletions

View File

@ -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 出标题气泡,点击滚动定位。

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 落到 `<task_dir>/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)` 拿完整指引

74
tools/ask_user.py Normal file
View File

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

View File

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

View File

@ -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;
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => {
const askBtn = e.target.closest && e.target.closest(".ask-option");
if (askBtn) {
if (askBtn.disabled || isCurrentTaskStreaming()) return;
const label = askBtn.dataset.label || "";
const card = askBtn.closest(".ask-user");
if (card) {
card.classList.add("answered");
card.querySelectorAll(".ask-option").forEach((b) => { b.disabled = true; });
}
askBtn.classList.add("selected");
if (label) sendMessage(label);
return;
}
const chip = e.target.closest && e.target.closest(".art-chip");
if (chip) {
const rel = chip.dataset.rel;
@ -1035,10 +1063,13 @@ async function postMessageWithRetry(taskId, body) {
}
}
async function sendMessage() {
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
async function sendMessage(overrideText) {
if (!state.taskId) return;
if (isCurrentTaskStreaming()) return;
const content = $("chat-input").value.trim();
const fromInput = typeof overrideText !== "string";
const content = (fromInput ? $("chat-input").value : overrideText).trim();
if (!content) return;
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
$("chat-hint").textContent = "发送中…";
@ -1063,7 +1094,7 @@ async function sendMessage() {
image_model: state.imageModel || "",
video_model: state.videoModel || "",
});
$("chat-input").value = "";
if (fromInput) $("chat-input").value = "";
syncOptimizeBtn();
const run = {
taskId,
@ -1238,6 +1269,34 @@ function parseSseFrame(frame) {
return { event, data };
}
// ask_user 选项卡:question + 一组可点击选项。点击走 chat-stream 的事件委托(把选项
// label 当作新用户消息发出);interactive=false(历史里已答)时按钮置灰、命中项标「已选」。
// 返回 DOM 元素(实时流式直接 append);历史重渲取其 outerHTML 注入(点击仍由委托接住)。
function buildAskUserCard(args, opts) {
const o = opts || {};
const interactive = o.interactive !== false;
const chosen = o.chosenLabel || "";
const question = (args && args.question) || "";
const options = (args && Array.isArray(args.options)) ? args.options : [];
const card = document.createElement("div");
card.className = "ask-user" + (interactive ? "" : " answered");
const btns = options.map((op) => {
const label = (op && op.label) || "";
const desc = (op && op.description) || "";
const sel = (!interactive && chosen && chosen === label) ? " selected" : "";
const dis = interactive ? "" : " disabled";
return `<button type="button" class="ask-option${sel}" data-label="${escapeHtml(label)}"${dis}>
<span class="ask-option-label">${escapeHtml(label)}</span>
${desc ? `<span class="ask-option-desc">${escapeHtml(desc)}</span>` : ""}
</button>`;
}).join("");
card.innerHTML = `
<div class="ask-q">${escapeHtml(question)}</div>
<div class="ask-options">${btns}</div>
<div class="ask-hint">${interactive ? "点选项继续,或在下方输入框就此讨论" : "已选择"}</div>`;
return card;
}
function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event;
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
@ -1273,6 +1332,11 @@ function handleSseEvent(ev, asstCard, ctx) {
setTaskProgress(ctx.taskId, ctx.progressSteps);
return;
}
if (fn === "ask_user") {
asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
if (nearBottom) stream.scrollTop = stream.scrollHeight;
return;
}
const label = toolActivityLabel(fn, args);
const det = document.createElement("details");
det.className = "tool-call";
@ -1294,6 +1358,7 @@ function handleSseEvent(ev, asstCard, ctx) {
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
const toolName = (ev.data && ev.data.name) || "";
if (toolName === "task_progress") return;
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
const banner = extractMediaBanner(toolName, txtStr);
const det = document.createElement("details");
det.className = "tool-call";