feat: add blocking user question tool

This commit is contained in:
JOJO 2026-05-28 16:30:55 +08:00
parent 92ca6e460e
commit f61a8df6fa
23 changed files with 1302 additions and 26 deletions

View File

@ -42,7 +42,7 @@
### Frontend根目录 ### Frontend根目录
- 安装依赖:`npm install` - 安装依赖:`npm install`
- 构建:`npm run build` - 构建:`npm run build --silent`(默认使用 silent减少无关输出
- 开发监听(当前脚本是 build watch`npm run dev` - 开发监听(当前脚本是 build watch`npm run dev`
- Lint`npm run lint` - Lint`npm run lint`
@ -69,6 +69,7 @@
## 4) 代码修改约定(实用版) ## 4) 代码修改约定(实用版)
- **最小改动原则**:只改与需求直接相关文件,避免顺手重构。 - **最小改动原则**:只改与需求直接相关文件,避免顺手重构。
- **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。
- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。 - **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。
- **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。 - **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。
- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。 - **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。
@ -83,6 +84,7 @@
- CLI React/TS保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。 - CLI React/TS保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。
- 日志:优先复用现有 logger/日志路径,不引入大量临时 `print` - 日志:优先复用现有 logger/日志路径,不引入大量临时 `print`
- 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。 - 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。
- 运行根目录前端构建时,默认使用 `npm run build --silent`
### CLI 当前交互约束2026-05-15 ### CLI 当前交互约束2026-05-15

View File

@ -393,6 +393,49 @@ class MainTerminalToolsDefinitionMixin:
} }
} }
}, },
{
"type": "function",
"function": {
"name": "ask_user",
"description": "向用户提问并阻塞等待回答。当开发/执行任务中遇到会影响实现方向、产品行为、数据安全或用户偏好的关键不确定性时使用。优先把问题设计成可选择题:默认应提供 2-4 个清晰选项,并把推荐项放第一位;只有用户必须提供具体文本、路径、命名等无法合理枚举的开放问题时,才不要提供 options。前端会弹出问题窗口用户可以选择预设选项也始终可以直接打字补充或改写回答拿到回答后工具才返回模型才能继续下一步。调用 ask_user 时不要同时调用其他执行工具;如需多个相互独立的问题,可以在同一轮并行调用多个 ask_user。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"question": {
"type": "string",
"description": "要问用户的核心问题。必须具体、简短、可回答;优先配合 options 让用户点选,而不是要求用户从零输入。"
},
"context": {
"type": "string",
"description": "可选。说明为什么需要确认,以及不同选择会影响什么。"
},
"options": {
"type": "array",
"description": "强烈建议提供。给用户的预设选项,通常 2-4 个;如果有推荐项,放第一位并在 label 中标注“推荐”。每个选项应互斥、短、能直接决策,并用 description 说明影响或取舍。除非问题必须让用户输入具体文本/路径/名称,否则不要省略 options。用户始终可以不选预设项而直接打字回答。",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "选项稳定 ID例如 modal / sidebar / skip / keep_current。"
},
"label": {
"type": "string",
"description": "展示给用户的短标签。"
},
"description": {
"type": "string",
"description": "一句话说明该选项的影响或取舍。"
}
},
"required": ["id", "label"]
}
}
}),
"required": ["question"]
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {

View File

@ -189,6 +189,7 @@ class MainTerminalToolsExecutionMixin:
"todo_update_task", "todo_update_task",
"todo_get", "todo_get",
"sleep", "sleep",
"ask_user",
} }
_APPROVAL_REQUIRED_TOOLS = { _APPROVAL_REQUIRED_TOOLS = {
"run_command", "run_command",

View File

@ -91,4 +91,9 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
tools=["manage_personalization"], tools=["manage_personalization"],
default_enabled=True, default_enabled=True,
), ),
"communication": ToolCategory(
label="用户沟通",
tools=["ask_user"],
default_enabled=True,
),
} }

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import threading
import time
import uuid
from typing import Any, Dict, List, Optional
class UserQuestionManager:
"""In-memory manager for blocking model-to-user questions."""
def __init__(self):
self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
@staticmethod
def _normalize_options(options: Any) -> List[Dict[str, str]]:
if not isinstance(options, list):
return []
normalized: List[Dict[str, str]] = []
seen = set()
for idx, raw in enumerate(options[:8], start=1):
if not isinstance(raw, dict):
continue
label = str(raw.get("label") or "").strip()
if not label:
continue
option_id = str(raw.get("id") or "").strip() or f"option_{idx}"
base = option_id
suffix = 2
while option_id in seen:
option_id = f"{base}_{suffix}"
suffix += 1
seen.add(option_id)
item = {
"id": option_id,
"label": label[:120],
}
desc = str(raw.get("description") or "").strip()
if desc:
item["description"] = desc[:500]
normalized.append(item)
return normalized
def create_question(
self,
*,
username: str,
conversation_id: Optional[str],
task_id: Optional[str],
tool_call_id: Optional[str],
question: str,
context: Optional[str] = None,
options: Any = None,
batch_id: Optional[str] = None,
batch_index: int = 0,
batch_total: int = 1,
) -> Dict[str, Any]:
question_id = f"question_{uuid.uuid4().hex}"
text = str(question or "").strip()
item = {
"question_id": question_id,
"batch_id": batch_id,
"batch_index": int(batch_index),
"batch_total": int(batch_total),
"username": username,
"conversation_id": conversation_id,
"task_id": task_id,
"tool_call_id": tool_call_id,
"question": text,
"context": str(context or "").strip(),
"options": self._normalize_options(options),
"status": "pending",
"created_at": time.time(),
"answered_at": None,
"answer": None,
}
with self._lock:
self._items[question_id] = item
return dict(item)
def get(self, question_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
item = self._items.get(question_id)
return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
rows = []
for item in self._items.values():
if item.get("username") != username:
continue
if item.get("status") != "pending":
continue
if conversation_id and item.get("conversation_id") != conversation_id:
continue
rows.append(dict(item))
rows.sort(key=lambda x: (x.get("created_at", 0.0), x.get("batch_index", 0)))
return rows
def answer(
self,
*,
question_id: str,
username: str,
selected_option_id: Optional[str] = None,
text: Optional[str] = None,
) -> Dict[str, Any]:
clean_option_id = str(selected_option_id or "").strip()
clean_text = str(text or "").strip()
if not clean_option_id and not clean_text:
raise ValueError("回答不能为空")
with self._lock:
item = self._items.get(question_id)
if not item:
raise KeyError("问题不存在")
if item.get("username") != username:
raise PermissionError("无权限回答该问题")
if item.get("status") != "pending":
return dict(item)
selected_option = None
if clean_option_id:
for opt in item.get("options") or []:
if str(opt.get("id") or "") == clean_option_id:
selected_option = dict(opt)
break
if selected_option is None:
raise ValueError("选项不存在")
answer_type = "option_with_text" if selected_option and clean_text else "option" if selected_option else "free_text"
answer = {
"type": answer_type,
"text": clean_text,
}
if selected_option:
answer["selected_option_id"] = selected_option.get("id")
answer["selected_option_label"] = selected_option.get("label")
if selected_option.get("description"):
answer["selected_option_description"] = selected_option.get("description")
if not clean_text:
answer["text"] = selected_option.get("label") or ""
item["status"] = "answered"
item["answered_at"] = time.time()
item["answer"] = answer
return dict(item)
def format_user_question_answer(item: Dict[str, Any]) -> str:
"""Return the compact, model-facing result text for an answered question."""
answer = item.get("answer") if isinstance(item, dict) else None
if not isinstance(answer, dict):
return "用户未回答。"
lines: List[str] = []
label = str(answer.get("selected_option_label") or "").strip()
text = str(answer.get("text") or "").strip()
if label:
lines.append(f"用户选择:{label}")
if text and text != label:
lines.append(f"用户补充:{text}")
elif text:
lines.append(f"用户回答:{text}")
return "\n".join(lines).strip() or "用户未回答。"

View File

@ -39,7 +39,7 @@ from .context import with_terminal, get_gui_manager, get_upload_guard, build_upl
from .security import rate_limited, prune_socket_tokens from .security import rate_limited, prune_socket_tokens
from .utils_common import debug_log from .utils_common import debug_log
from .state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS from .state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
from .state import tool_approval_manager from .state import tool_approval_manager, user_question_manager
from .extensions import socketio from .extensions import socketio
from .monitor import get_cached_monitor_snapshot from .monitor import get_cached_monitor_snapshot
from .files import sanitize_filename_preserve_unicode from .files import sanitize_filename_preserve_unicode
@ -832,6 +832,52 @@ def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, u
}) })
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
@api_login_required
@with_terminal
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待回答的问题列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = user_question_manager.list_pending(username=username, conversation_id=requested_conv_id)
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("user_question_answer", 120, 60, scope="user")
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
"""提交 ask_user 工具问题的回答。"""
data = request.get_json() or {}
try:
item = user_question_manager.answer(
question_id=question_id,
username=username,
selected_option_id=data.get("selected_option_id"),
text=data.get("text"),
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": "问题不存在"}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@chat_bp.route('/api/tool-approvals/pending', methods=['GET']) @chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -3,12 +3,13 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import time import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from .utils_common import debug_log, brief_log from .utils_common import debug_log, brief_log
from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT
from .state import tool_approval_manager from .state import tool_approval_manager, user_question_manager
from .monitor import cache_monitor_snapshot from .monitor import cache_monitor_snapshot
from .security import compact_web_search_result from .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure from .chat_flow_helpers import detect_tool_failure
@ -21,6 +22,7 @@ from utils.context_manager import AUTO_SHALLOW_PLACEHOLDER
from config import TOOL_CALL_COOLDOWN from config import TOOL_CALL_COOLDOWN
from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings
from modules.auto_approval_service import run_auto_approval from modules.auto_approval_service import run_auto_approval
from modules.user_question_manager import format_user_question_answer
from .deep_compression import run_deep_compression from .deep_compression import run_deep_compression
from .chat_flow_task_support import inject_runtime_user_message from .chat_flow_task_support import inject_runtime_user_message
@ -219,6 +221,51 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
function = tool_call.get("function") or {}
if function.get("name") != "ask_user":
return None
raw = function.get("arguments") or "{}"
if hasattr(web_terminal, 'api_client') and hasattr(web_terminal.api_client, '_safe_tool_arguments_parse'):
success, arguments, _error_msg = web_terminal.api_client._safe_tool_arguments_parse(raw, "ask_user")
if success and isinstance(arguments, dict):
return arguments
return None
parsed = json.loads(raw) if str(raw).strip() else {}
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0) -> Dict[str, Dict[str, Any]]:
started = time.time()
pending = {str(qid) for qid in question_ids if qid}
answered: Dict[str, Dict[str, Any]] = {}
while pending:
for qid in list(pending):
row = user_question_manager.get(qid)
if not row:
answered[qid] = {"status": "missing", "answer_text": "用户问题不存在。"}
pending.remove(qid)
continue
if row.get("username") != username:
answered[qid] = {"status": "forbidden", "answer_text": "用户问题所属用户不匹配。"}
pending.remove(qid)
continue
if row.get("status") == "answered":
answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
pending.remove(qid)
if not pending:
break
if (time.time() - started) >= timeout_seconds:
for qid in list(pending):
answered[qid] = {"status": "timeout", "answer_text": "等待用户回答超时。"}
pending.remove(qid)
break
await asyncio.sleep(0.2)
return answered
async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None): async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None):
previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False) previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False)
@ -234,6 +281,81 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
debug_log(f"构建工具白名单失败(降级继续): {exc}") debug_log(f"构建工具白名单失败(降级继续): {exc}")
recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or []) recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or [])
user_question_results_by_tool_call_id: Dict[str, str] = {}
user_question_id_by_tool_call_id: Dict[str, str] = {}
ask_user_items: List[Dict[str, Any]] = []
for idx, pending_tool_call in enumerate(tool_calls or []):
function = (pending_tool_call or {}).get("function") or {}
if function.get("name") != "ask_user":
continue
arguments = _safe_parse_tool_arguments_for_question(web_terminal, pending_tool_call)
if not isinstance(arguments, dict):
continue
question_text = str(arguments.get("question") or "").strip()
if not question_text:
continue
ask_user_items.append({
"tool_call": pending_tool_call,
"arguments": arguments,
"order": idx,
})
if ask_user_items:
batch_id = f"question_batch_{uuid.uuid4().hex}"
batch_total = len(ask_user_items)
created_questions: List[Dict[str, Any]] = []
for batch_index, item in enumerate(ask_user_items, start=1):
tool_call = item.get("tool_call") or {}
arguments = item.get("arguments") or {}
question = user_question_manager.create_question(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call.get("id"),
question=arguments.get("question"),
context=arguments.get("context"),
options=arguments.get("options"),
batch_id=batch_id,
batch_index=batch_index,
batch_total=batch_total,
)
created_questions.append(question)
if tool_call.get("id"):
user_question_id_by_tool_call_id[str(tool_call.get("id"))] = question.get("question_id")
sender('update_action', {
'preparing_id': tool_call.get("id"),
'status': 'awaiting_user_answer',
'result': {
"success": False,
"status": "awaiting_user_answer",
"question_id": question.get("question_id"),
"message": "等待用户回答"
},
'message': '等待用户回答',
'conversation_id': conversation_id
})
sender('user_questions_required', {
'batch_id': batch_id,
'questions': created_questions,
'conversation_id': conversation_id,
})
wait_answers = await _wait_for_user_questions(
question_ids=[str(q.get("question_id") or "") for q in created_questions],
username=username,
)
for question in created_questions:
qid = str(question.get("question_id") or "")
answer_row = wait_answers.get(qid) or {}
answer_text = str(answer_row.get("answer_text") or "用户未回答。").strip() or "用户未回答。"
tool_call_id = str(question.get("tool_call_id") or "")
if tool_call_id:
user_question_results_by_tool_call_id[tool_call_id] = answer_text
sender('user_questions_resolved', {
'batch_id': batch_id,
'question_ids': [q.get("question_id") for q in created_questions],
'conversation_id': conversation_id,
})
# 执行每个工具 # 执行每个工具
pending_runtime_mode_notices: List[str] = [] pending_runtime_mode_notices: List[str] = []
last_completed_tool_call_id: Optional[str] = None last_completed_tool_call_id: Optional[str] = None
@ -617,30 +739,44 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
# 执行工具,同时监听停止标志 # 执行工具,同时监听停止标志
debug_log(f"[停止检测] 开始执行工具: {function_name}") debug_log(f"[停止检测] 开始执行工具: {function_name}")
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))
tool_result = None tool_result = None
tool_cancelled = False tool_cancelled = False
tool_task = None
# 在工具执行期间持续检查停止标志
check_count = 0 check_count = 0
while not tool_task.done():
await asyncio.sleep(0.1) # 每100ms检查一次 if function_name == "ask_user" and str(tool_call_id) in user_question_results_by_tool_call_id:
check_count += 1 answer_text = user_question_results_by_tool_call_id.get(str(tool_call_id)) or "用户未回答。"
client_stop_info = get_stop_flag(client_sid, username) qid = user_question_id_by_tool_call_id.get(str(tool_call_id))
if client_stop_info: tool_result = json.dumps({
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info "success": True,
if stop_requested: "status": "answered",
debug_log(f"[停止检测] 工具执行过程中检测到停止请求(检查次数:{check_count}),立即取消工具") "message": answer_text,
tool_task.cancel() "answer_text": answer_text,
tool_cancelled = True "question_id": qid,
break }, ensure_ascii=False)
else:
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))
# 在工具执行期间持续检查停止标志
while not tool_task.done():
await asyncio.sleep(0.1) # 每100ms检查一次
check_count += 1
client_stop_info = get_stop_flag(client_sid, username)
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
debug_log(f"[停止检测] 工具执行过程中检测到停止请求(检查次数:{check_count}),立即取消工具")
tool_task.cancel()
tool_cancelled = True
break
debug_log(f"[停止检测] 工具执行完成cancelled={tool_cancelled}, 检查次数={check_count}") debug_log(f"[停止检测] 工具执行完成cancelled={tool_cancelled}, 检查次数={check_count}")
# 获取工具结果或处理取消 # 获取工具结果或处理取消
if tool_cancelled: if tool_cancelled:
try: try:
await tool_task if tool_task is not None:
await tool_task
except asyncio.CancelledError: except asyncio.CancelledError:
debug_log("[停止检测] 工具任务已被取消(CancelledError)") debug_log("[停止检测] 工具任务已被取消(CancelledError)")
except Exception as e: except Exception as e:
@ -695,7 +831,10 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
web_terminal._tool_loop_active = previous_tool_loop_active web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": True, "last_tool_call_time": last_tool_call_time} return {"stopped": True, "last_tool_call_time": last_tool_call_time}
else: else:
tool_result = await tool_task if tool_result is None and tool_task is not None:
tool_result = await tool_task
if tool_result is None:
tool_result = json.dumps({"success": False, "error": "工具未返回结果"}, ensure_ascii=False)
debug_log(f"工具结果: {tool_result[:200]}...") debug_log(f"工具结果: {tool_result[:200]}...")
execution_time = time.time() - start_time execution_time = time.time() - start_time

View File

@ -16,6 +16,7 @@ from modules.user_container_manager import UserContainerManager
from modules.user_manager import UserManager from modules.user_manager import UserManager
from modules.api_user_manager import ApiUserManager from modules.api_user_manager import ApiUserManager
from modules.tool_approval_manager import ToolApprovalManager from modules.tool_approval_manager import ToolApprovalManager
from modules.user_question_manager import UserQuestionManager
# 全局实例 # 全局实例
user_manager = UserManager() user_manager = UserManager()
@ -36,6 +37,7 @@ RECENT_UPLOAD_FEED_LIMIT = 60
stop_flags: Dict[str, Dict[str, Any]] = {} stop_flags: Dict[str, Dict[str, Any]] = {}
active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling
tool_approval_manager = ToolApprovalManager() tool_approval_manager = ToolApprovalManager()
user_question_manager = UserQuestionManager()
# 监控/限流/用量 # 监控/限流/用量
MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} MONITOR_FILE_TOOLS = {'write_file', 'edit_file'}
@ -105,6 +107,7 @@ __all__ = [
"usage_trackers", "usage_trackers",
"active_login_nonces", "active_login_nonces",
"tool_approval_manager", "tool_approval_manager",
"user_question_manager",
"MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE",
"MONITOR_SNAPSHOT_CACHE_LIMIT", "MONITOR_SNAPSHOT_CACHE_LIMIT",
"PROJECT_STORAGE_CACHE", "PROJECT_STORAGE_CACHE",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092a10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01"/></svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@ -299,6 +299,9 @@
:current-context-tokens="currentContextTokens" :current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled" :versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages" :runtime-queued-messages="runtimeQueuedMessages"
:user-question-minimized="userQuestionMinimized"
:pending-user-question-count="pendingUserQuestions.length"
@restore-user-question="restoreUserQuestionDialog"
@update:input-message="inputSetMessage" @update:input-message="inputSetMessage"
@input-change="handleInputChange" @input-change="handleInputChange"
@input-focus="handleInputFocus" @input-focus="handleInputFocus"
@ -427,6 +430,15 @@
</transition> </transition>
<SubAgentActivityDialog /> <SubAgentActivityDialog />
<BackgroundCommandDialog /> <BackgroundCommandDialog />
<UserQuestionDialog
:visible="userQuestionDialogVisible && !userQuestionMinimized && pendingUserQuestions.length > 0"
:questions="pendingUserQuestions"
:active-index="userQuestionActiveIndex"
:submitting-ids="answeringUserQuestionIds"
@minimize="minimizeUserQuestionDialog"
@update:active-index="userQuestionActiveIndex = $event"
@submit="submitUserQuestionAnswers"
/>
<transition name="overlay-fade"> <transition name="overlay-fade">
<VersioningDialog <VersioningDialog
v-if="versioningDialogOpen" v-if="versioningDialogOpen"

View File

@ -27,6 +27,9 @@ const VersioningDialog = defineAsyncComponent(
const HostWorkspaceCreateDialog = defineAsyncComponent( const HostWorkspaceCreateDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceCreateDialog.vue') () => import('../components/overlay/HostWorkspaceCreateDialog.vue')
); );
const UserQuestionDialog = defineAsyncComponent(
() => import('../components/overlay/UserQuestionDialog.vue')
);
const TutorialOverlay = defineAsyncComponent( const TutorialOverlay = defineAsyncComponent(
() => import('../components/overlay/TutorialOverlay.vue') () => import('../components/overlay/TutorialOverlay.vue')
); );
@ -50,6 +53,7 @@ export const appComponents = {
BackgroundCommandDialog, BackgroundCommandDialog,
VersioningDialog, VersioningDialog,
HostWorkspaceCreateDialog, HostWorkspaceCreateDialog,
UserQuestionDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt NewUserTutorialPrompt
}; };

View File

@ -567,6 +567,16 @@ export const messageMethods = {
(Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0);
if (this.composerBusy) { if (this.composerBusy) {
if (hasText) { if (hasText) {
if (Array.isArray(this.pendingUserQuestions) && this.pendingUserQuestions.length > 0) {
const answered = await this.answerUserQuestionFromComposer(this.inputMessage);
if (answered) {
this.inputClearMessage();
this.inputSetLineCount(1);
this.inputSetMultiline(false);
this.autoResizeInput();
}
return;
}
const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage); const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage);
if (queued) { if (queued) {
this.inputClearMessage(); this.inputClearMessage();

View File

@ -553,6 +553,14 @@ export const taskPollingMethods = {
case 'tool_approval_resolved': case 'tool_approval_resolved':
this.handleToolApprovalResolved(eventData, eventIdx); this.handleToolApprovalResolved(eventData, eventIdx);
break; break;
case 'user_question_required':
case 'user_questions_required':
this.handleUserQuestionsRequired(eventData, eventIdx);
break;
case 'user_question_resolved':
case 'user_questions_resolved':
this.handleUserQuestionsResolved(eventData, eventIdx);
break;
case 'auto_approval_progress': case 'auto_approval_progress':
this.handleAutoApprovalProgress(eventData, eventIdx); this.handleAutoApprovalProgress(eventData, eventIdx);
break; break;
@ -664,7 +672,7 @@ export const taskPollingMethods = {
if (action.streaming) return true; if (action.streaming) return true;
if (action.type === 'tool' && action.tool) { if (action.type === 'tool' && action.tool) {
const status = String(action.tool.status || '').toLowerCase(); const status = String(action.tool.status || '').toLowerCase();
return ['preparing', 'running', 'pending', 'queued', 'awaiting_approval'].includes(status); return ['preparing', 'running', 'pending', 'queued', 'awaiting_approval', 'awaiting_user_answer'].includes(status);
} }
return false; return false;
}); });
@ -1229,6 +1237,127 @@ export const taskPollingMethods = {
this.$forceUpdate(); this.$forceUpdate();
}, },
handleUserQuestionsRequired(data: any) {
const incoming = Array.isArray(data?.questions)
? data.questions
: data?.question
? [data.question]
: [];
const questions = incoming.filter((item: any) => item && item.question_id);
if (!questions.length) {
return;
}
if (!Array.isArray(this.pendingUserQuestions)) {
this.pendingUserQuestions = [];
}
questions.forEach((question: any) => {
const idx = this.pendingUserQuestions.findIndex(
(item: any) => item && item.question_id === question.question_id
);
if (idx >= 0) {
this.pendingUserQuestions.splice(idx, 1, question);
} else {
this.pendingUserQuestions.push(question);
}
});
this.pendingUserQuestions.sort((a: any, b: any) => {
const batchA = String(a?.batch_id || '');
const batchB = String(b?.batch_id || '');
if (batchA && batchB && batchA !== batchB) {
return Number(a?.created_at || 0) - Number(b?.created_at || 0);
}
return Number(a?.batch_index || 0) - Number(b?.batch_index || 0);
});
this.userQuestionActiveIndex = Math.min(
Math.max(0, Number(this.userQuestionActiveIndex || 0)),
Math.max(0, this.pendingUserQuestions.length - 1)
);
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
this.notifyUserQuestion(questions[0]);
this.$forceUpdate();
},
handleUserQuestionsResolved(data: any) {
const ids = Array.isArray(data?.question_ids)
? data.question_ids.map((id: any) => String(id || '')).filter(Boolean)
: data?.question_id
? [String(data.question_id)]
: [];
if (!ids.length || !Array.isArray(this.pendingUserQuestions)) {
return;
}
this.pendingUserQuestions = this.pendingUserQuestions.filter(
(item: any) => item && !ids.includes(String(item.question_id || ''))
);
if (!this.pendingUserQuestions.length) {
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = false;
this.userQuestionActiveIndex = 0;
this.restoreUserQuestionTitle();
} else {
this.userQuestionActiveIndex = Math.min(
this.userQuestionActiveIndex,
this.pendingUserQuestions.length - 1
);
}
this.$forceUpdate();
},
notifyUserQuestion(question: any) {
try {
if (!this.userQuestionOriginalTitle && typeof document !== 'undefined') {
this.userQuestionOriginalTitle = document.title || '';
}
if (typeof document !== 'undefined') {
if (this.userQuestionTitleBlinkTimer) {
clearInterval(this.userQuestionTitleBlinkTimer);
this.userQuestionTitleBlinkTimer = null;
}
this.userQuestionTitleBlinkRed = true;
const applyTitle = () => {
const dot = this.userQuestionTitleBlinkRed ? '🔴' : '⚪';
document.title = `${dot} 需要回答 - Agents`;
this.userQuestionTitleBlinkRed = !this.userQuestionTitleBlinkRed;
};
applyTitle();
this.userQuestionTitleBlinkTimer = setInterval(applyTitle, 900);
}
if (typeof window === 'undefined' || !('Notification' in window)) {
return;
}
const title = '需要你确认一个问题';
const body = String(question?.question || '').slice(0, 120);
if (Notification.permission === 'granted') {
new Notification(title, { body });
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
new Notification(title, { body });
}
}).catch(() => undefined);
}
} catch (_error) {
// ignore notification errors
}
},
restoreUserQuestionTitle() {
try {
if (this.userQuestionTitleBlinkTimer) {
clearInterval(this.userQuestionTitleBlinkTimer);
this.userQuestionTitleBlinkTimer = null;
}
if (this.userQuestionOriginalTitle && typeof document !== 'undefined') {
document.title = this.userQuestionOriginalTitle;
}
this.userQuestionOriginalTitle = '';
this.userQuestionTitleBlinkRed = true;
} catch (_error) {
// ignore
}
},
handleToolApprovalResolved(data: any) { handleToolApprovalResolved(data: any) {
const approvalId = data?.approval_id; const approvalId = data?.approval_id;
if (!approvalId || !Array.isArray(this.pendingToolApprovals)) { if (!approvalId || !Array.isArray(this.pendingToolApprovals)) {

View File

@ -69,7 +69,7 @@ export const toolingMethods = {
} }
const status = const status =
typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : ''; typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : '';
if (!status || ['preparing', 'running', 'pending', 'queued', 'stale'].includes(status)) { if (!status || ['preparing', 'running', 'pending', 'queued', 'stale', 'awaiting_user_answer'].includes(status)) {
action.tool.status = 'cancelled'; action.tool.status = 'cancelled';
action.tool.message = action.tool.message || '已停止'; action.tool.message = action.tool.message || '已停止';
} }
@ -114,7 +114,7 @@ export const toolingMethods = {
} }
const status = const status =
typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : ''; typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : '';
return !status || ['preparing', 'running', 'pending', 'queued'].includes(status); return !status || ['preparing', 'running', 'pending', 'queued', 'awaiting_user_answer'].includes(status);
}); });
}); });
}, },

View File

@ -1463,6 +1463,90 @@ export const uiMethods = {
} }
}, },
minimizeUserQuestionDialog() {
if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return;
}
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = true;
},
restoreUserQuestionDialog() {
if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return;
}
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
},
async submitUserQuestionAnswers(answers) {
const list = Array.isArray(answers) ? answers : [];
if (!list.length) {
return;
}
const ids = list.map((item) => String(item?.question_id || '')).filter(Boolean);
this.answeringUserQuestionIds = ids;
try {
for (const answer of list) {
const questionId = String(answer?.question_id || '').trim();
if (!questionId) {
continue;
}
const response = await fetch(`/api/user-questions/${encodeURIComponent(questionId)}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
selected_option_id: answer?.selected_option_id || undefined,
text: answer?.text || ''
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '提交回答失败');
}
}
this.pendingUserQuestions = (this.pendingUserQuestions || []).filter(
(item) => item && !ids.includes(String(item.question_id || ''))
);
if (!this.pendingUserQuestions.length) {
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = false;
this.userQuestionActiveIndex = 0;
if (typeof this.restoreUserQuestionTitle === 'function') {
this.restoreUserQuestionTitle();
}
} else {
this.userQuestionActiveIndex = Math.min(this.userQuestionActiveIndex, this.pendingUserQuestions.length - 1);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '提交回答失败');
this.uiPushToast({ title: '提交回答失败', message: msg, type: 'error' });
} finally {
this.answeringUserQuestionIds = [];
}
},
async answerUserQuestionFromComposer(text) {
const clean = String(text || '').trim();
if (!clean || !Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return false;
}
const index = Math.min(
Math.max(0, Number(this.userQuestionActiveIndex || 0)),
Math.max(0, this.pendingUserQuestions.length - 1)
);
const question = this.pendingUserQuestions[index] || this.pendingUserQuestions[0];
if (!question?.question_id) {
return false;
}
await this.submitUserQuestionAnswers([{ question_id: question.question_id, text: clean }]);
if (this.pendingUserQuestions.length > 0) {
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
}
return true;
},
async decideToolApproval(approvalId, decision) { async decideToolApproval(approvalId, decision) {
const id = String(approvalId || '').trim(); const id = String(approvalId || '').trim();
if (!id) { if (!id) {

View File

@ -172,6 +172,14 @@ export function dataState() {
], ],
pendingToolApprovals: [], pendingToolApprovals: [],
decidingApprovalIds: [], decidingApprovalIds: [],
pendingUserQuestions: [],
userQuestionDialogVisible: false,
userQuestionMinimized: false,
userQuestionActiveIndex: 0,
answeringUserQuestionIds: [],
userQuestionOriginalTitle: '',
userQuestionTitleBlinkTimer: null,
userQuestionTitleBlinkRed: true,
autoApprovalFeedLines: [], autoApprovalFeedLines: [],
autoApprovalFinalMessage: '', autoApprovalFinalMessage: '',
approvalAutoCloseTimer: null, approvalAutoCloseTimer: null,

View File

@ -89,6 +89,9 @@ function formatBytes(bytes: number): string {
function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string {
const state = String(result?.status || props.action?.tool?.status || '').toLowerCase(); const state = String(result?.status || props.action?.tool?.status || '').toLowerCase();
if (state === 'awaiting_user_answer') {
return '等待回答';
}
if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') { if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') {
return '待审批'; return '待审批';
} }
@ -169,6 +172,11 @@ function renderEnhancedToolResult(): string {
return renderConversationReview(result, args); return renderConversationReview(result, args);
} }
//
else if (name === 'ask_user') {
return renderAskUser(result, args);
}
// //
else if (name === 'manage_personalization') { else if (name === 'manage_personalization') {
return renderManagePersonalization(result, args); return renderManagePersonalization(result, args);
@ -861,6 +869,55 @@ function formatPersonalizationFieldValue(field: string, value: any): string {
return String(value); return String(value);
} }
function renderAskUser(result: any, args: any): string {
const question = args.question || result.question || '';
const context = args.context || result.context || '';
const status = formatToolStatusLabel(result, '✓ 已回答');
const answerText = result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${escapeHtml(status)}</div>`;
if (question) {
html += `<div><strong>问题:</strong>${escapeHtml(String(question))}</div>`;
}
if (context) {
html += `<div><strong>说明:</strong>${escapeHtml(String(context))}</div>`;
}
html += '</div>';
const options = Array.isArray(args.options) ? args.options : [];
if (options.length > 0) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
html += '<div><strong>提供的选项:</strong></div>';
options.forEach((option: any, idx: number) => {
const label = String(option?.label || option?.id || `选项 ${idx + 1}`);
const desc = String(option?.description || '').trim();
html += `<div>${idx + 1}. ${escapeHtml(label)}${desc ? `${escapeHtml(desc)}` : ''}</div>`;
});
html += '</div></div>';
}
if (answerText) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
answerText.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
html += '</div></div>';
}
return html;
}
function renderManagePersonalization(result: any, args: any): string { function renderManagePersonalization(result: any, args: any): string {
const action = String(args.action || result.action || 'read'); const action = String(args.action || result.action || 'read');
const status = formatToolStatusLabel( const status = formatToolStatusLabel(

View File

@ -16,6 +16,9 @@ export function formatBytes(bytes: number): string {
function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string {
const state = String(result?.status || '').toLowerCase(); const state = String(result?.status || '').toLowerCase();
if (state === 'awaiting_user_answer') {
return '等待回答';
}
if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') { if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') {
return '待审批'; return '待审批';
} }
@ -104,6 +107,11 @@ export function renderEnhancedToolResult(
} else if (name === 'conversation_review') { } else if (name === 'conversation_review') {
return renderConversationReview(result, args); return renderConversationReview(result, args);
} }
// 用户沟通类
else if (name === 'ask_user') {
return renderAskUser(result, args);
}
// 个性化管理类 // 个性化管理类
else if (name === 'manage_personalization') { else if (name === 'manage_personalization') {
return renderManagePersonalization(result, args); return renderManagePersonalization(result, args);
@ -767,6 +775,55 @@ function formatPersonalizationFieldValue(field: string, value: any): string {
return String(value); return String(value);
} }
function renderAskUser(result: any, args: any): string {
const question = args.question || result.question || '';
const context = args.context || result.context || '';
const status = formatToolStatusLabel(result, '✓ 已回答');
const answerText = result.status === 'answered' ? String(result.answer_text || result.message || '').trim() : '';
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${escapeHtml(status)}</div>`;
if (question) {
html += `<div><strong>问题:</strong>${escapeHtml(String(question))}</div>`;
}
if (context) {
html += `<div><strong>说明:</strong>${escapeHtml(String(context))}</div>`;
}
html += '</div>';
const options = Array.isArray(args.options) ? args.options : [];
if (options.length > 0) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
html += '<div><strong>提供的选项:</strong></div>';
options.forEach((option: any, idx: number) => {
const label = String(option?.label || option?.id || `选项 ${idx + 1}`);
const desc = String(option?.description || '').trim();
html += `<div>${idx + 1}. ${escapeHtml(label)}${desc ? `${escapeHtml(desc)}` : ''}</div>`;
});
html += '</div></div>';
}
if (answerText) {
html += '<div class="tool-result-content">';
html += '<div class="tool-result-meta">';
answerText.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
const separator = line.indexOf('');
if (separator > 0) {
const label = line.slice(0, separator + 1);
const value = line.slice(separator + 1);
html += `<div><strong>${escapeHtml(label)}</strong>${escapeHtml(value)}</div>`;
} else {
html += `<div><strong>用户回答:</strong>${escapeHtml(line)}</div>`;
}
});
html += '</div></div>';
}
return html;
}
function renderManagePersonalization(result: any, args: any): string { function renderManagePersonalization(result: any, args: any): string {
const action = String(args.action || result.action || 'read'); const action = String(args.action || result.action || 'read');
const status = formatToolStatusLabel(result, action === 'update' ? '✓ 已更新' : '✓ 已读取'); const status = formatToolStatusLabel(result, action === 'update' ? '✓ 已更新' : '✓ 已读取');

View File

@ -116,6 +116,15 @@
<span v-if="showStopIcon" class="stop-icon"></span> <span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span> <span v-else class="send-icon"></span>
</button> </button>
<button
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
type="button"
class="user-question-mini-dot"
aria-label="打开待回答问题"
@click.stop="$emit('restore-user-question')"
>
{{ pendingUserQuestionCount }}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -301,7 +310,8 @@ const emit = defineEmits([
'open-versioning-dialog', 'open-versioning-dialog',
'guide-runtime-message', 'guide-runtime-message',
'delete-runtime-message', 'delete-runtime-message',
'composer-height-change' 'composer-height-change',
'restore-user-question'
]); ]);
const props = defineProps<{ const props = defineProps<{
@ -354,6 +364,8 @@ const props = defineProps<{
currentContextTokens: number; currentContextTokens: number;
versioningEnabled?: boolean; versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>; runtimeQueuedMessages?: Array<{ id: string; text: string }>;
userQuestionMinimized?: boolean;
pendingUserQuestionCount?: number;
}>(); }>();
const inputStore = useInputStore(); const inputStore = useInputStore();

View File

@ -0,0 +1,460 @@
<template>
<transition name="user-question-fade" appear>
<div v-if="visible && questions.length" class="user-question-overlay" @click.self="emit('minimize')">
<section class="user-question-card" role="dialog" aria-modal="true" aria-label="需要你回答一个问题">
<header class="user-question-windowbar">
<div class="user-question-traffic" aria-label="窗口控制">
<button type="button" class="traffic-dot traffic-dot--close" aria-label="暂时收起" @click="emit('minimize')"></button>
</div>
<div class="user-question-window-title">需要你确认</div>
</header>
<header class="user-question-header">
<div class="user-question-title-block">
<div v-if="questions.length > 1" class="user-question-kicker-row">
<span class="user-question-kicker">问题 {{ currentIndex + 1 }} / {{ questions.length }}</span>
<span class="user-question-nav" aria-label="问题切换">
<button type="button" :disabled="currentIndex <= 0" @click="go(-1)" aria-label="上一个问题">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5l-5 5 5 5" /></svg>
</button>
<button type="button" :disabled="currentIndex >= questions.length - 1" @click="go(1)" aria-label="下一个问题">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5l5 5-5 5" /></svg>
</button>
</span>
</div>
<h2>{{ currentQuestion.question }}</h2>
</div>
</header>
<div class="user-question-body" :class="{ 'user-question-body--no-options': !currentOptions.length }">
<p v-if="currentQuestion.context" class="user-question-context">{{ currentQuestion.context }}</p>
<div v-if="currentOptions.length" class="user-question-options">
<button
v-for="option in currentOptions"
:key="option.id"
type="button"
class="user-question-option"
:class="{ active: currentDraft.selected_option_id === option.id }"
@click="selectOption(option.id)"
>
<span class="user-question-option__label">{{ option.label }}</span>
<span v-if="option.description" class="user-question-option__desc">{{ option.description }}</span>
</button>
</div>
<div class="user-question-textarea-wrap">
<textarea
v-model="currentDraft.text"
class="user-question-textarea"
rows="3"
placeholder="输入文字回答…"
spellcheck="false"
@keydown.meta.enter.prevent="submit"
@keydown.ctrl.enter.prevent="submit"
></textarea>
</div>
</div>
<footer class="user-question-footer">
<div class="user-question-spacer"></div>
<div class="user-question-actions">
<button type="button" class="user-question-btn primary" :disabled="!canSubmit || submitting" @click="submit">
{{ submitting ? '提交中...' : '确定' }}
</button>
</div>
</footer>
</section>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
const props = defineProps<{
visible: boolean;
questions: Array<any>;
activeIndex: number;
submittingIds?: string[];
}>();
const emit = defineEmits<{
(event: 'minimize'): void;
(event: 'update:active-index', value: number): void;
(event: 'submit', answers: Array<any>): void;
}>();
const drafts = reactive<Record<string, { selected_option_id: string; text: string }>>({});
const questionKey = (question: any, idx: number) => String(question?.question_id || `idx_${idx}`);
watch(
() => props.questions,
(items) => {
(items || []).forEach((question, idx) => {
const key = questionKey(question, idx);
if (!drafts[key]) drafts[key] = { selected_option_id: '', text: '' };
});
},
{ immediate: true, deep: true }
);
const currentIndex = computed(() => {
const max = Math.max(0, (props.questions || []).length - 1);
return Math.min(Math.max(0, Number(props.activeIndex || 0)), max);
});
const currentQuestion = computed(() => props.questions[currentIndex.value] || {});
const currentOptions = computed(() => Array.isArray(currentQuestion.value.options) ? currentQuestion.value.options : []);
const currentKey = computed(() => questionKey(currentQuestion.value, currentIndex.value));
const currentDraft = computed(() => {
if (!drafts[currentKey.value]) drafts[currentKey.value] = { selected_option_id: '', text: '' };
return drafts[currentKey.value];
});
const submitting = computed(() => (props.submittingIds || []).length > 0);
const isAnswered = (idx: number) => {
const q = props.questions[idx];
const d = drafts[questionKey(q, idx)];
return !!(d && (d.selected_option_id || d.text.trim()));
};
const canSubmit = computed(() => (props.questions || []).every((_q, idx) => isAnswered(idx)));
function go(delta: number) {
emit('update:active-index', currentIndex.value + delta);
}
function selectOption(optionId: string) {
currentDraft.value.selected_option_id = currentDraft.value.selected_option_id === optionId ? '' : optionId;
}
function submit() {
if (!canSubmit.value || submitting.value) return;
const answers = (props.questions || []).map((question, idx) => {
const d = drafts[questionKey(question, idx)] || { selected_option_id: '', text: '' };
return {
question_id: question.question_id,
selected_option_id: d.selected_option_id || undefined,
text: d.text.trim()
};
});
emit('submit', answers);
}
</script>
<style scoped>
.user-question-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}
.user-question-card {
position: relative;
width: min(800px, calc(100vw - 36px));
height: min(520px, calc(100vh - 36px));
max-height: min(520px, calc(100vh - 36px));
display: flex;
flex-direction: column;
color: var(--claude-text);
background: var(--theme-surface-card);
border: 1px solid var(--theme-control-border);
border-radius: 20px;
overflow: hidden;
}
.user-question-windowbar {
height: 42px;
flex: 0 0 42px;
display: grid;
grid-template-columns: 56px 1fr 56px;
align-items: center;
border-bottom: 1px solid var(--theme-control-border);
background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-left-rail) 8%);
}
.user-question-traffic {
display: inline-flex;
gap: 8px;
padding-left: 16px;
align-items: center;
}
.traffic-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.12);
padding: 0;
cursor: pointer;
}
.traffic-dot--close { background: #ff5f57; }
.user-question-window-title {
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--claude-text-secondary);
user-select: none;
}
.user-question-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 20px 22px 0;
}
.user-question-kicker-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
width: 100%;
}
.user-question-kicker {
color: var(--claude-text-secondary);
font-size: 12px;
}
.user-question-title-block {
min-width: 0;
width: 100%;
}
.user-question-header h2 {
margin: 0;
font-size: 16px;
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.01em;
}
.user-question-nav {
display: inline-flex;
gap: 4px;
}
.user-question-nav button {
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px solid var(--theme-control-border);
background: transparent;
color: var(--claude-text-secondary);
cursor: pointer;
display: grid;
place-items: center;
}
.user-question-nav svg {
width: 13px;
height: 13px;
stroke-width: 2;
}
.user-question-nav button:hover:not(:disabled) {
background: var(--theme-hover-bg, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
}
.user-question-nav button:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.user-question-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 22px 18px;
}
.user-question-body::-webkit-scrollbar,
.user-question-textarea::-webkit-scrollbar {
display: none;
}
.user-question-context {
margin: 0;
color: var(--claude-text-secondary);
line-height: 1.6;
font-size: 13px;
}
.user-question-options {
display: grid;
gap: 7px;
}
.user-question-option {
border: 1px solid var(--theme-control-border);
border-radius: 12px;
background: transparent;
color: var(--claude-text);
padding: 9px 11px;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.user-question-option:hover {
background: var(--theme-hover-bg, rgba(0, 0, 0, 0.055));
}
.user-question-option.active {
border-color: var(--theme-control-border-strong);
background: var(--theme-tab-active);
}
.user-question-option__label {
font-weight: 600;
font-size: 13px;
}
.user-question-option__desc {
color: var(--claude-text-secondary);
font-size: 12px;
line-height: 1.42;
}
.user-question-textarea-wrap {
display: flex;
}
.user-question-body--no-options .user-question-textarea-wrap {
margin-top: auto;
}
.user-question-textarea {
width: 100%;
resize: none;
min-height: 76px;
max-height: 150px;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border: 1px solid var(--theme-control-border);
border-radius: 12px;
padding: 10px 11px;
color: var(--claude-text);
background: var(--theme-surface-soft);
outline: none;
font: inherit;
font-size: 13px;
line-height: 1.5;
appearance: none;
-webkit-appearance: none;
}
.user-question-textarea:focus {
border-color: var(--theme-control-border-strong);
background: var(--theme-surface-strong);
}
.user-question-footer {
height: 54px;
flex: 0 0 54px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 0 18px;
border-top: 1px solid var(--theme-control-border);
background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-left-rail) 8%);
}
.user-question-spacer {
flex: 1 1 auto;
}
.user-question-actions {
display: flex;
gap: 8px;
}
.user-question-btn {
min-width: 72px;
border-radius: 9px;
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--theme-control-border-strong);
}
.user-question-btn.ghost {
background: transparent;
color: var(--claude-text-secondary);
}
.user-question-btn.ghost:hover {
background: var(--theme-hover-bg, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
}
.user-question-btn.primary {
background: var(--claude-accent);
border-color: var(--claude-accent-strong);
color: #fffaf0;
}
.user-question-btn.primary:hover:not(:disabled) {
background: var(--claude-button-hover, var(--claude-accent-strong));
}
.user-question-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.user-question-fade-enter-active,
.user-question-fade-leave-active {
transition: opacity 0.16s ease;
}
.user-question-fade-enter-active .user-question-card,
.user-question-fade-leave-active .user-question-card {
transition: transform 0.16s ease;
}
.user-question-fade-enter-from,
.user-question-fade-leave-to {
opacity: 0;
}
.user-question-fade-enter-from .user-question-card,
.user-question-fade-leave-to .user-question-card {
transform: translateY(6px) scale(0.99);
}
@media (max-width: 620px) {
.user-question-overlay {
padding: 12px;
}
.user-question-card {
width: 100%;
max-height: calc(100vh - 24px);
}
}
</style>

View File

@ -1006,3 +1006,27 @@ body[data-theme='dark'] {
font-size: 24px !important; font-size: 24px !important;
} }
} }
.user-question-mini-dot {
position: absolute;
right: 22px;
bottom: calc(100% + 6px);
z-index: 12;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1.5px solid var(--theme-surface-strong, #fff);
border-radius: 999px;
background: #ff5f57;
color: #fff;
cursor: pointer;
font-size: 11px;
line-height: 15px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}

View File

@ -25,7 +25,8 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
terminal_snapshot: 'terminal-animation', terminal_snapshot: 'terminal-animation',
todo_create: 'file-animation', todo_create: 'file-animation',
todo_update_task: 'file-animation', todo_update_task: 'file-animation',
create_sub_agent: 'terminal-animation' create_sub_agent: 'terminal-animation',
ask_user: 'default-animation'
}; };
const RUNNING_STATUS_TEXTS: Record<string, string> = { const RUNNING_STATUS_TEXTS: Record<string, string> = {
@ -46,7 +47,8 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
terminal_input: '调用 terminal_input', terminal_input: '调用 terminal_input',
terminal_snapshot: '正在获取终端快照...', terminal_snapshot: '正在获取终端快照...',
read_skill: '正在读取技能...', read_skill: '正在读取技能...',
create_skill: '正在归档技能...' create_skill: '正在归档技能...',
ask_user: '等待用户回答...'
}; };
const COMPLETED_STATUS_TEXTS: Record<string, string> = { const COMPLETED_STATUS_TEXTS: Record<string, string> = {
@ -68,7 +70,8 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
terminal_input: '终端输入完成', terminal_input: '终端输入完成',
terminal_snapshot: '终端快照已返回', terminal_snapshot: '终端快照已返回',
read_skill: '技能读取完成', read_skill: '技能读取完成',
create_skill: '技能归档完成' create_skill: '技能归档完成',
ask_user: '用户已回答'
}; };
const LANGUAGE_CLASS_MAP: Record<string, string> = { const LANGUAGE_CLASS_MAP: Record<string, string> = {
@ -222,6 +225,9 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean })
const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || ''; const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || '';
return label ? label : '调用工具中'; return label ? label : '调用工具中';
} }
if (tool.status === 'awaiting_user_answer') {
return '等待回答';
}
if ( if (
tool.status === 'awaiting_approval' || tool.status === 'awaiting_approval' ||
tool.status === 'pending_approval' || tool.status === 'pending_approval' ||
@ -245,6 +251,9 @@ export function getToolDescription(tool: any): string {
if (!tool) { if (!tool) {
return ''; return '';
} }
if (tool.name === 'ask_user') {
return '';
}
const args = tool.argumentSnapshot || tool.arguments; const args = tool.argumentSnapshot || tool.arguments;
const argumentLabel = tool.argumentLabel || buildToolLabel(args); const argumentLabel = tool.argumentLabel || buildToolLabel(args);
if (argumentLabel) { if (argumentLabel) {
@ -287,6 +296,9 @@ export function buildToolLabel(args: any): string {
if (args.query) { if (args.query) {
return `"${args.query}"`; return `"${args.query}"`;
} }
if (args.question) {
return String(args.question);
}
if (typeof args.seconds !== 'undefined') { if (typeof args.seconds !== 'undefined') {
return `${args.seconds}`; return `${args.seconds}`;
} }

View File

@ -7,6 +7,7 @@ export const ICONS = Object.freeze({
camera: '/static/icons/camera.svg', camera: '/static/icons/camera.svg',
check: '/static/icons/check.svg', check: '/static/icons/check.svg',
chatBubble: '/static/icons/chat-bubble.svg', chatBubble: '/static/icons/chat-bubble.svg',
messageQuestion: '/static/icons/message-circle-question-mark.svg',
checkbox: '/static/icons/checkbox.svg', checkbox: '/static/icons/checkbox.svg',
circleAlert: '/static/icons/circle-alert.svg', circleAlert: '/static/icons/circle-alert.svg',
clipboard: '/static/icons/clipboard.svg', clipboard: '/static/icons/clipboard.svg',
@ -52,6 +53,7 @@ export const TOOL_ICON_MAP = Object.freeze({
create_file: 'file', create_file: 'file',
create_skill: 'sparkles', create_skill: 'sparkles',
manage_personalization: 'userPen', manage_personalization: 'userPen',
ask_user: 'messageQuestion',
create_folder: 'folder', create_folder: 'folder',
create_sub_agent: 'bot', create_sub_agent: 'bot',
delete_file: 'trash', delete_file: 'trash',
@ -89,6 +91,7 @@ export const TOOL_CATEGORY_ICON_MAP = Object.freeze({
mcp: 'mcpLogo', mcp: 'mcpLogo',
file_edit: 'pencil', file_edit: 'pencil',
personalization: 'userPen', personalization: 'userPen',
communication: 'messageQuestion',
read_focus: 'eye', read_focus: 'eye',
skills: 'sparkles', skills: 'sparkles',
terminal_realtime: 'monitor', terminal_realtime: 'monitor',