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:
parent
91e200ef4f
commit
1cfeb000a6
|
|
@ -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 出标题气泡,点击滚动定位。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.13.0"
|
||||
__version__ = "0.14.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)` 拿完整指引
|
||||
|
|
|
|||
|
|
@ -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=(",", ":"),
|
||||
)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue