From f61a8df6fa3af2c10ac89a0b90b804b943726e6f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 28 May 2026 16:30:55 +0800 Subject: [PATCH] feat: add blocking user question tool --- AGENTS.md | 4 +- core/main_terminal_parts/tools_definition.py | 43 ++ core/main_terminal_parts/tools_execution.py | 1 + core/tool_config.py | 5 + modules/user_question_manager.py | 164 +++++++ server/chat.py | 48 +- server/chat_flow_tool_loop.py | 173 ++++++- server/state.py | 3 + static/icons/message-circle-question-mark.svg | 1 + static/src/App.vue | 12 + static/src/app/components.ts | 4 + static/src/app/methods/message.ts | 10 + static/src/app/methods/taskPolling.ts | 131 ++++- static/src/app/methods/tooling.ts | 4 +- static/src/app/methods/ui.ts | 84 ++++ static/src/app/state.ts | 8 + .../components/chat/actions/ToolAction.vue | 57 +++ .../components/chat/actions/toolRenderers.ts | 57 +++ static/src/components/input/InputComposer.vue | 14 +- .../components/overlay/UserQuestionDialog.vue | 460 ++++++++++++++++++ .../styles/components/input/_composer.scss | 24 + static/src/utils/chatDisplay.ts | 18 +- static/src/utils/icons.ts | 3 + 23 files changed, 1302 insertions(+), 26 deletions(-) create mode 100644 modules/user_question_manager.py create mode 100644 static/icons/message-circle-question-mark.svg create mode 100644 static/src/components/overlay/UserQuestionDialog.vue diff --git a/AGENTS.md b/AGENTS.md index c204fbc..cab88d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ ### Frontend(根目录) - 安装依赖:`npm install` -- 构建:`npm run build` +- 构建:`npm run build --silent`(默认使用 silent,减少无关输出) - 开发监听(当前脚本是 build watch):`npm run dev` - Lint:`npm run lint` @@ -69,6 +69,7 @@ ## 4) 代码修改约定(实用版) - **最小改动原则**:只改与需求直接相关文件,避免顺手重构。 +- **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。 - **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。 - **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。 - **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 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。 - 日志:优先复用现有 logger/日志路径,不引入大量临时 `print`。 - 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。 +- 运行根目录前端构建时,默认使用 `npm run build --silent`。 ### CLI 当前交互约束(2026-05-15) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 5a4349b..303e1c1 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -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", "function": { diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index a403d5e..56a65f8 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -189,6 +189,7 @@ class MainTerminalToolsExecutionMixin: "todo_update_task", "todo_get", "sleep", + "ask_user", } _APPROVAL_REQUIRED_TOOLS = { "run_command", diff --git a/core/tool_config.py b/core/tool_config.py index cadefab..f9829a3 100644 --- a/core/tool_config.py +++ b/core/tool_config.py @@ -91,4 +91,9 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { tools=["manage_personalization"], default_enabled=True, ), + "communication": ToolCategory( + label="用户沟通", + tools=["ask_user"], + default_enabled=True, + ), } diff --git a/modules/user_question_manager.py b/modules/user_question_manager.py new file mode 100644 index 0000000..df5a1ba --- /dev/null +++ b/modules/user_question_manager.py @@ -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 "用户未回答。" diff --git a/server/chat.py b/server/chat.py index 0f0f5a6..91c494f 100644 --- a/server/chat.py +++ b/server/chat.py @@ -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 .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 tool_approval_manager +from .state import tool_approval_manager, user_question_manager from .extensions import socketio from .monitor import get_cached_monitor_snapshot 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//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']) @api_login_required @with_terminal diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index ad3f1f6..7510e6e 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio import json import time +import uuid from pathlib import Path from typing import Optional, Dict, Any, List 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 tool_approval_manager +from .state import tool_approval_manager, user_question_manager from .monitor import cache_monitor_snapshot from .security import compact_web_search_result 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 modules.personalization_manager import load_personalization_config, resolve_context_compression_settings 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 .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) +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): 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}") 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] = [] 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}") - tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments)) tool_result = None tool_cancelled = False - - # 在工具执行期间持续检查停止标志 + tool_task = None check_count = 0 - 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 + + if function_name == "ask_user" and str(tool_call_id) in user_question_results_by_tool_call_id: + answer_text = user_question_results_by_tool_call_id.get(str(tool_call_id)) or "用户未回答。" + qid = user_question_id_by_tool_call_id.get(str(tool_call_id)) + tool_result = json.dumps({ + "success": True, + "status": "answered", + "message": answer_text, + "answer_text": answer_text, + "question_id": qid, + }, 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}") # 获取工具结果或处理取消 if tool_cancelled: try: - await tool_task + if tool_task is not None: + await tool_task except asyncio.CancelledError: debug_log("[停止检测] 工具任务已被取消(CancelledError)") 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 return {"stopped": True, "last_tool_call_time": last_tool_call_time} 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]}...") execution_time = time.time() - start_time diff --git a/server/state.py b/server/state.py index 85872cf..f717d9d 100644 --- a/server/state.py +++ b/server/state.py @@ -16,6 +16,7 @@ from modules.user_container_manager import UserContainerManager from modules.user_manager import UserManager from modules.api_user_manager import ApiUserManager from modules.tool_approval_manager import ToolApprovalManager +from modules.user_question_manager import UserQuestionManager # 全局实例 user_manager = UserManager() @@ -36,6 +37,7 @@ RECENT_UPLOAD_FEED_LIMIT = 60 stop_flags: Dict[str, Dict[str, Any]] = {} active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling tool_approval_manager = ToolApprovalManager() +user_question_manager = UserQuestionManager() # 监控/限流/用量 MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} @@ -105,6 +107,7 @@ __all__ = [ "usage_trackers", "active_login_nonces", "tool_approval_manager", + "user_question_manager", "MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE_LIMIT", "PROJECT_STORAGE_CACHE", diff --git a/static/icons/message-circle-question-mark.svg b/static/icons/message-circle-question-mark.svg new file mode 100644 index 0000000..8fa6a30 --- /dev/null +++ b/static/icons/message-circle-question-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/src/App.vue b/static/src/App.vue index 15cc52a..413d13c 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -299,6 +299,9 @@ :current-context-tokens="currentContextTokens" :versioning-enabled="versioningEnabled" :runtime-queued-messages="runtimeQueuedMessages" + :user-question-minimized="userQuestionMinimized" + :pending-user-question-count="pendingUserQuestions.length" + @restore-user-question="restoreUserQuestionDialog" @update:input-message="inputSetMessage" @input-change="handleInputChange" @input-focus="handleInputFocus" @@ -427,6 +430,15 @@ + import('../components/overlay/HostWorkspaceCreateDialog.vue') ); +const UserQuestionDialog = defineAsyncComponent( + () => import('../components/overlay/UserQuestionDialog.vue') +); const TutorialOverlay = defineAsyncComponent( () => import('../components/overlay/TutorialOverlay.vue') ); @@ -50,6 +53,7 @@ export const appComponents = { BackgroundCommandDialog, VersioningDialog, HostWorkspaceCreateDialog, + UserQuestionDialog, TutorialOverlay, NewUserTutorialPrompt }; diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 79408db..b046e55 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -567,6 +567,16 @@ export const messageMethods = { (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); if (this.composerBusy) { 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); if (queued) { this.inputClearMessage(); diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 5b07cb5..312ae22 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -553,6 +553,14 @@ export const taskPollingMethods = { case 'tool_approval_resolved': this.handleToolApprovalResolved(eventData, eventIdx); 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': this.handleAutoApprovalProgress(eventData, eventIdx); break; @@ -664,7 +672,7 @@ export const taskPollingMethods = { if (action.streaming) return true; if (action.type === 'tool' && action.tool) { 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; }); @@ -1229,6 +1237,127 @@ export const taskPollingMethods = { 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) { const approvalId = data?.approval_id; if (!approvalId || !Array.isArray(this.pendingToolApprovals)) { diff --git a/static/src/app/methods/tooling.ts b/static/src/app/methods/tooling.ts index fda3f88..7080fd4 100644 --- a/static/src/app/methods/tooling.ts +++ b/static/src/app/methods/tooling.ts @@ -69,7 +69,7 @@ export const toolingMethods = { } const status = 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.message = action.tool.message || '已停止'; } @@ -114,7 +114,7 @@ export const toolingMethods = { } const status = 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); }); }); }, diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index fe37e33..58b782a 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -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) { const id = String(approvalId || '').trim(); if (!id) { diff --git a/static/src/app/state.ts b/static/src/app/state.ts index d545a24..46de7a7 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -172,6 +172,14 @@ export function dataState() { ], pendingToolApprovals: [], decidingApprovalIds: [], + pendingUserQuestions: [], + userQuestionDialogVisible: false, + userQuestionMinimized: false, + userQuestionActiveIndex: 0, + answeringUserQuestionIds: [], + userQuestionOriginalTitle: '', + userQuestionTitleBlinkTimer: null, + userQuestionTitleBlinkRed: true, autoApprovalFeedLines: [], autoApprovalFinalMessage: '', approvalAutoCloseTimer: null, diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 129b6fe..cf67aa9 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -89,6 +89,9 @@ function formatBytes(bytes: number): string { function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { 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') { return '待审批'; } @@ -169,6 +172,11 @@ function renderEnhancedToolResult(): string { return renderConversationReview(result, args); } + // 用户沟通类 + else if (name === 'ask_user') { + return renderAskUser(result, args); + } + // 个性化管理类 else if (name === 'manage_personalization') { return renderManagePersonalization(result, args); @@ -861,6 +869,55 @@ function formatPersonalizationFieldValue(field: string, value: any): string { 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 = '
'; + html += `
状态:${escapeHtml(status)}
`; + if (question) { + html += `
问题:${escapeHtml(String(question))}
`; + } + if (context) { + html += `
说明:${escapeHtml(String(context))}
`; + } + html += '
'; + + const options = Array.isArray(args.options) ? args.options : []; + if (options.length > 0) { + html += '
'; + html += '
'; + html += '
提供的选项:
'; + options.forEach((option: any, idx: number) => { + const label = String(option?.label || option?.id || `选项 ${idx + 1}`); + const desc = String(option?.description || '').trim(); + html += `
${idx + 1}. ${escapeHtml(label)}${desc ? ` — ${escapeHtml(desc)}` : ''}
`; + }); + html += '
'; + } + + if (answerText) { + html += '
'; + html += '
'; + 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 += `
${escapeHtml(label)}${escapeHtml(value)}
`; + } else { + html += `
用户回答:${escapeHtml(line)}
`; + } + }); + html += '
'; + } + + return html; +} + function renderManagePersonalization(result: any, args: any): string { const action = String(args.action || result.action || 'read'); const status = formatToolStatusLabel( diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 20eb2e2..2e4e67c 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -16,6 +16,9 @@ export function formatBytes(bytes: number): string { function formatToolStatusLabel(result: any, successLabel: string, failedLabel = '✗ 失败'): string { const state = String(result?.status || '').toLowerCase(); + if (state === 'awaiting_user_answer') { + return '等待回答'; + } if (state === 'awaiting_approval' || state === 'pending_approval' || state === 'pending') { return '待审批'; } @@ -104,6 +107,11 @@ export function renderEnhancedToolResult( } else if (name === 'conversation_review') { return renderConversationReview(result, args); } + // 用户沟通类 + else if (name === 'ask_user') { + return renderAskUser(result, args); + } + // 个性化管理类 else if (name === 'manage_personalization') { return renderManagePersonalization(result, args); @@ -767,6 +775,55 @@ function formatPersonalizationFieldValue(field: string, value: any): string { 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 = '
'; + html += `
状态:${escapeHtml(status)}
`; + if (question) { + html += `
问题:${escapeHtml(String(question))}
`; + } + if (context) { + html += `
说明:${escapeHtml(String(context))}
`; + } + html += '
'; + + const options = Array.isArray(args.options) ? args.options : []; + if (options.length > 0) { + html += '
'; + html += '
'; + html += '
提供的选项:
'; + options.forEach((option: any, idx: number) => { + const label = String(option?.label || option?.id || `选项 ${idx + 1}`); + const desc = String(option?.description || '').trim(); + html += `
${idx + 1}. ${escapeHtml(label)}${desc ? ` — ${escapeHtml(desc)}` : ''}
`; + }); + html += '
'; + } + + if (answerText) { + html += '
'; + html += '
'; + 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 += `
${escapeHtml(label)}${escapeHtml(value)}
`; + } else { + html += `
用户回答:${escapeHtml(line)}
`; + } + }); + html += '
'; + } + + return html; +} + function renderManagePersonalization(result: any, args: any): string { const action = String(args.action || result.action || 'read'); const status = formatToolStatusLabel(result, action === 'update' ? '✓ 已更新' : '✓ 已读取'); diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 7ada6ec..d989d81 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -116,6 +116,15 @@ + @@ -301,7 +310,8 @@ const emit = defineEmits([ 'open-versioning-dialog', 'guide-runtime-message', 'delete-runtime-message', - 'composer-height-change' + 'composer-height-change', + 'restore-user-question' ]); const props = defineProps<{ @@ -354,6 +364,8 @@ const props = defineProps<{ currentContextTokens: number; versioningEnabled?: boolean; runtimeQueuedMessages?: Array<{ id: string; text: string }>; + userQuestionMinimized?: boolean; + pendingUserQuestionCount?: number; }>(); const inputStore = useInputStore(); diff --git a/static/src/components/overlay/UserQuestionDialog.vue b/static/src/components/overlay/UserQuestionDialog.vue new file mode 100644 index 0000000..a22daaa --- /dev/null +++ b/static/src/components/overlay/UserQuestionDialog.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 11bf3ff..221148f 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -1006,3 +1006,27 @@ body[data-theme='dark'] { 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; +} diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index cd09b41..6b419a6 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -25,7 +25,8 @@ const RUNNING_ANIMATIONS: Record = { terminal_snapshot: 'terminal-animation', todo_create: '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 = { @@ -46,7 +47,8 @@ const RUNNING_STATUS_TEXTS: Record = { terminal_input: '调用 terminal_input', terminal_snapshot: '正在获取终端快照...', read_skill: '正在读取技能...', - create_skill: '正在归档技能...' + create_skill: '正在归档技能...', + ask_user: '等待用户回答...' }; const COMPLETED_STATUS_TEXTS: Record = { @@ -68,7 +70,8 @@ const COMPLETED_STATUS_TEXTS: Record = { terminal_input: '终端输入完成', terminal_snapshot: '终端快照已返回', read_skill: '技能读取完成', - create_skill: '技能归档完成' + create_skill: '技能归档完成', + ask_user: '用户已回答' }; const LANGUAGE_CLASS_MAP: Record = { @@ -222,6 +225,9 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }) const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || ''; return label ? label : '调用工具中'; } + if (tool.status === 'awaiting_user_answer') { + return '等待回答'; + } if ( tool.status === 'awaiting_approval' || tool.status === 'pending_approval' || @@ -245,6 +251,9 @@ export function getToolDescription(tool: any): string { if (!tool) { return ''; } + if (tool.name === 'ask_user') { + return ''; + } const args = tool.argumentSnapshot || tool.arguments; const argumentLabel = tool.argumentLabel || buildToolLabel(args); if (argumentLabel) { @@ -287,6 +296,9 @@ export function buildToolLabel(args: any): string { if (args.query) { return `"${args.query}"`; } + if (args.question) { + return String(args.question); + } if (typeof args.seconds !== 'undefined') { return `${args.seconds} 秒`; } diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index 5298a06..7fd5bc1 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -7,6 +7,7 @@ export const ICONS = Object.freeze({ camera: '/static/icons/camera.svg', check: '/static/icons/check.svg', chatBubble: '/static/icons/chat-bubble.svg', + messageQuestion: '/static/icons/message-circle-question-mark.svg', checkbox: '/static/icons/checkbox.svg', circleAlert: '/static/icons/circle-alert.svg', clipboard: '/static/icons/clipboard.svg', @@ -52,6 +53,7 @@ export const TOOL_ICON_MAP = Object.freeze({ create_file: 'file', create_skill: 'sparkles', manage_personalization: 'userPen', + ask_user: 'messageQuestion', create_folder: 'folder', create_sub_agent: 'bot', delete_file: 'trash', @@ -89,6 +91,7 @@ export const TOOL_CATEGORY_ICON_MAP = Object.freeze({ mcp: 'mcpLogo', file_edit: 'pencil', personalization: 'userPen', + communication: 'messageQuestion', read_focus: 'eye', skills: 'sparkles', terminal_realtime: 'monitor',