feat: add blocking user question tool
This commit is contained in:
parent
92ca6e460e
commit
f61a8df6fa
@ -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)
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -189,6 +189,7 @@ class MainTerminalToolsExecutionMixin:
|
||||
"todo_update_task",
|
||||
"todo_get",
|
||||
"sleep",
|
||||
"ask_user",
|
||||
}
|
||||
_APPROVAL_REQUIRED_TOOLS = {
|
||||
"run_command",
|
||||
|
||||
@ -91,4 +91,9 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
tools=["manage_personalization"],
|
||||
default_enabled=True,
|
||||
),
|
||||
"communication": ToolCategory(
|
||||
label="用户沟通",
|
||||
tools=["ask_user"],
|
||||
default_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
164
modules/user_question_manager.py
Normal file
164
modules/user_question_manager.py
Normal 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 "用户未回答。"
|
||||
@ -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/<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'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
1
static/icons/message-circle-question-mark.svg
Normal file
1
static/icons/message-circle-question-mark.svg
Normal 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 |
@ -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 @@
|
||||
</transition>
|
||||
<SubAgentActivityDialog />
|
||||
<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">
|
||||
<VersioningDialog
|
||||
v-if="versioningDialogOpen"
|
||||
|
||||
@ -27,6 +27,9 @@ const VersioningDialog = defineAsyncComponent(
|
||||
const HostWorkspaceCreateDialog = defineAsyncComponent(
|
||||
() => 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
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = '<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 {
|
||||
const action = String(args.action || result.action || 'read');
|
||||
const status = formatToolStatusLabel(
|
||||
|
||||
@ -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 = '<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 {
|
||||
const action = String(args.action || result.action || 'read');
|
||||
const status = formatToolStatusLabel(result, action === 'update' ? '✓ 已更新' : '✓ 已读取');
|
||||
|
||||
@ -116,6 +116,15 @@
|
||||
<span v-if="showStopIcon" class="stop-icon"></span>
|
||||
<span v-else class="send-icon"></span>
|
||||
</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>
|
||||
@ -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();
|
||||
|
||||
460
static/src/components/overlay/UserQuestionDialog.vue
Normal file
460
static/src/components/overlay/UserQuestionDialog.vue
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
@ -46,7 +47,8 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
|
||||
terminal_input: '调用 terminal_input',
|
||||
terminal_snapshot: '正在获取终端快照...',
|
||||
read_skill: '正在读取技能...',
|
||||
create_skill: '正在归档技能...'
|
||||
create_skill: '正在归档技能...',
|
||||
ask_user: '等待用户回答...'
|
||||
};
|
||||
|
||||
const COMPLETED_STATUS_TEXTS: Record<string, string> = {
|
||||
@ -68,7 +70,8 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
|
||||
terminal_input: '终端输入完成',
|
||||
terminal_snapshot: '终端快照已返回',
|
||||
read_skill: '技能读取完成',
|
||||
create_skill: '技能归档完成'
|
||||
create_skill: '技能归档完成',
|
||||
ask_user: '用户已回答'
|
||||
};
|
||||
|
||||
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 || '';
|
||||
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} 秒`;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user