feat:add ask-user tool and modularize react tools

This commit is contained in:
aries_ckt
2026-05-25 00:03:53 +08:00
parent 3a07e8dc33
commit c3e361fbb8
21 changed files with 2145 additions and 1611 deletions

View File

@@ -0,0 +1,32 @@
"""Built-in tools for the ReAct agent in agentic_data_api."""
from .code_interpreter import make_code_interpreter
from .execute_analysis import make_execute_analysis
from .execute_tool import make_execute_tool
from .html_interpreter import make_html_interpreter
from .knowledge_retrieve import make_knowledge_retrieve
from .load_file import make_load_file
from .load_tools import make_load_tools
from .question import make_question
from .select_skill import make_select_skill
from .shell_interpreter import make_shell_interpreter
from .skill_tools import make_execute_skill_script_file, make_load_skill
from .sql_query import make_sql_query
from .todowrite import make_todowrite
__all__ = [
"make_code_interpreter",
"make_execute_analysis",
"make_execute_tool",
"make_html_interpreter",
"make_knowledge_retrieve",
"make_load_file",
"make_load_tools",
"make_question",
"make_select_skill",
"make_shell_interpreter",
"make_execute_skill_script_file",
"make_load_skill",
"make_sql_query",
"make_todowrite",
]

View File

@@ -0,0 +1,21 @@
"""Shared helper utilities for built-in tools."""
import re
from typing import Dict, Tuple
_AUTO_DATA_MARKER_PATTERN = re.compile(
r"###([A-Z0-9_]+)_START###\s*(.*?)\s*###\1_END###", re.DOTALL
)
def _extract_auto_data_markers(text: str) -> Tuple[str, Dict[str, str]]:
"""Extract AUTO_DATA markers from text and return (cleaned_text, data_dict)."""
extracted: Dict[str, str] = {}
def _replacer(m: re.Match) -> str:
extracted[m.group(1)] = m.group(2)
return ""
cleaned = _AUTO_DATA_MARKER_PATTERN.sub(_replacer, text)
return cleaned.strip(), extracted

View File

@@ -0,0 +1,208 @@
"""code_interpreter tool — execute Python code in a subprocess."""
import asyncio
import json
import logging
import os
import shutil
import sys
import uuid
from typing import Any, Dict, List, Optional
from dbgpt.agent.resource.tool.base import tool
logger = logging.getLogger(__name__)
def _try_repair_truncated_code(raw_code: str) -> Optional[str]:
"""Attempt to fix code that was truncated by the LLM's token limit."""
lines = raw_code.split("\n")
for trim in range(1, min(11, len(lines))):
candidate_lines = lines[: len(lines) - trim]
if not candidate_lines:
continue
candidate = "\n".join(candidate_lines)
open_chars = {"(": ")", "[": "]", "{": "}"}
close_chars = set(open_chars.values())
stack: list = []
for ch in candidate:
if ch in open_chars:
stack.append(open_chars[ch])
elif ch in close_chars:
if stack and stack[-1] == ch:
stack.pop()
if stack:
candidate += "\n" + "".join(reversed(stack))
try:
compile(candidate, "<repair>", "exec")
return candidate
except SyntaxError:
continue
return None
def make_code_interpreter(react_state: Dict[str, Any]):
@tool(
description=(
"Execute Python code for data analysis and computation. "
"Supports pandas, numpy, matplotlib, json, os, etc. "
"Use this tool when you need to run Python code to process data, "
"generate charts, or perform calculations. "
'Parameters: {{"code": "python code string"}}'
)
)
async def code_interpreter(code: str) -> str:
"""Execute arbitrary Python code and return stdout/stderr.
CRITICAL: Each call is completely independent — variables do NOT
persist between calls. Every code snippet MUST include all necessary
data loading and processing. Always print() results you want to see.
"""
from dbgpt.configs.model_config import PILOT_PATH, STATIC_MESSAGE_IMG_PATH
if not code or not code.strip():
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No code provided"}]},
ensure_ascii=False,
)
cid = react_state.get("conv_id") or "default"
work_dir = os.path.join(PILOT_PATH, "tmp", cid)
os.makedirs(work_dir, exist_ok=True)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
pre_existing_images: set = set()
for root, _dirs, files in os.walk(work_dir):
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in IMAGE_EXTS:
pre_existing_images.add(os.path.join(root, f))
preamble_lines = [
"import json",
"import os",
"import pandas as pd",
"import numpy as np",
f'PLOT_DIR = r"{work_dir}"',
"os.makedirs(PLOT_DIR, exist_ok=True)",
]
fp = react_state.get("file_path")
if fp:
preamble_lines.append(f'FILE_PATH = r"{fp}"')
preamble = "\n".join(preamble_lines) + "\n"
full_code = preamble + code
try:
compile(full_code, "<code_interpreter>", "exec")
except SyntaxError as se:
repaired = _try_repair_truncated_code(full_code)
if repaired is not None:
logger.warning(
"code_interpreter: auto-repaired truncated code "
"(original SyntaxError: %s line %s)",
se.msg,
se.lineno,
)
full_code = repaired
code = full_code[len(preamble) :]
else:
error_msg = (
f"SyntaxError before execution: {se.msg} "
f"(line {se.lineno})\n"
"Please regenerate complete, syntactically valid Python code. "
"Keep code under 80 lines."
)
return json.dumps(
{
"chunks": [
{"output_type": "code", "content": code.strip()},
{"output_type": "text", "content": error_msg},
]
},
ensure_ascii=False,
)
output_text = ""
try:
tmp_path = os.path.join(work_dir, "_run.py")
with open(tmp_path, "w") as tmp:
tmp.write(full_code)
proc = await asyncio.create_subprocess_exec(
sys.executable,
tmp_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=work_dir,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
output_text = stdout.decode("utf-8", errors="replace")
error_text = stderr.decode("utf-8", errors="replace")
if proc.returncode != 0 and error_text:
output_text = (
output_text + "\n[ERROR]\n" + error_text
if output_text
else error_text
)
except asyncio.TimeoutError:
output_text = "Execution timed out (60s limit)"
except Exception as e:
output_text = f"Execution error: {e}"
chunks: List[Dict[str, Any]] = [
{"output_type": "code", "content": code.strip()},
]
if output_text.strip():
clean_output = output_text.strip()
max_out_len = 2000
if len(clean_output) > max_out_len:
truncation_notice = (
f"\n\n... [Output truncated, length: {len(clean_output)} chars."
f" Only showing first {max_out_len} chars.]"
)
clean_output = clean_output[:max_out_len] + truncation_notice
chunks.append({"output_type": "text", "content": clean_output})
else:
chunks.append(
{
"output_type": "text",
"content": "(no output — add print() to see results)",
}
)
# Scan for new images generated by this run
try:
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
for root, _dirs, files in os.walk(work_dir):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
full_path = os.path.join(root, fname)
if ext in IMAGE_EXTS and full_path not in pre_existing_images:
unique_name = f"{uuid.uuid4().hex[:8]}_{fname}"
dest = os.path.join(STATIC_MESSAGE_IMG_PATH, unique_name)
shutil.copy2(full_path, dest)
img_url = f"/images/{unique_name}"
chunks.append({"output_type": "image", "content": img_url})
react_state.setdefault("generated_images", []).append(img_url)
except Exception:
pass
# Clean up temp script
try:
script_path = os.path.join(work_dir, "_run.py")
if os.path.exists(script_path):
os.remove(script_path)
except Exception:
pass
all_images = react_state.get("generated_images", [])
if all_images:
img_summary = "已生成的图片URL在生成HTML时请使用这些URL:\n" + "\n".join(
f" - {url}" for url in all_images
)
chunks.append({"output_type": "text", "content": img_summary})
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return code_interpreter

View File

@@ -0,0 +1,115 @@
"""execute_analysis tool — quick Excel/CSV analysis via code server."""
import json
from typing import Any, Dict, List
from dbgpt.agent.resource.tool.base import tool
def make_execute_analysis(react_state: Dict[str, Any]):
@tool(description="Execute quick analysis on uploaded Excel/CSV file.")
async def execute_analysis() -> str:
from dbgpt._private.config import Config
from dbgpt_app.openapi.api_v1.agentic_data_api import get_code_server
CFG = Config()
def _is_excel_skill(meta) -> bool:
name = (meta.name or "").lower()
desc = (meta.description or "").lower()
tags = [tag.lower() for tag in (meta.tags or [])]
return any(
token in name or token in desc or token in tags
for token in ["excel", "xlsx", "xls", "spreadsheet"]
)
matched = react_state.get("matched")
if not react_state.get("file_path"):
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No file to analyze"}]},
ensure_ascii=False,
)
if matched and not _is_excel_skill(matched.metadata):
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "Selected skill is not for Excel analysis",
}
]
},
ensure_ascii=False,
)
code_server = await get_code_server(CFG.SYSTEM_APP)
analysis_code = """
import json
import pandas as pd
file_path = r"{file_path}"
if file_path.lower().endswith((".xls", ".xlsx")):
df = pd.read_excel(file_path)
else:
df = pd.read_csv(file_path)
summary = {{
"shape": list(df.shape),
"columns": list(df.columns),
"dtypes": {{col: str(dtype) for col, dtype in df.dtypes.items()}},
"head": df.head(5).to_dict(orient="records"),
}}
print(json.dumps(summary, ensure_ascii=False))
""".format(file_path=react_state["file_path"])
result = await code_server.exec(analysis_code, "python")
output_text = (
result.output.decode("utf-8") if isinstance(result.output, bytes) else ""
)
chunks: List[Dict[str, Any]] = [
{"output_type": "code", "content": analysis_code.strip()}
]
if output_text:
try:
summary = json.loads(output_text)
chunks.append({"output_type": "json", "content": summary})
head_rows = summary.get("head")
columns = summary.get("columns")
if isinstance(head_rows, list) and isinstance(columns, list):
chunks.append(
{
"output_type": "table",
"content": {
"columns": [
{"title": col, "dataIndex": col, "key": col}
for col in columns
],
"rows": head_rows,
},
}
)
numeric_columns = [
col
for col, dtype in (summary.get("dtypes") or {}).items()
if "int" in dtype or "float" in dtype
]
if numeric_columns and isinstance(head_rows, list):
series_col = numeric_columns[0]
data = [
{"x": idx + 1, "y": row.get(series_col)}
for idx, row in enumerate(head_rows)
if row.get(series_col) is not None
]
if data:
chunks.append(
{
"output_type": "chart",
"content": {
"data": data,
"xField": "x",
"yField": "y",
},
}
)
except Exception:
chunks.append({"output_type": "text", "content": output_text})
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return execute_analysis

View File

@@ -0,0 +1,43 @@
"""execute_tool tool — run a registered business tool by name."""
import json
from typing import Any, Dict
from dbgpt.agent.resource.tool.base import tool
def make_execute_tool(react_state: Dict[str, Any]):
@tool(description="Execute a tool by name with JSON args.")
async def execute_tool(tool_name: str, args: dict) -> str:
from dbgpt._private.config import Config
from dbgpt.agent.resource.manage import get_resource_manager
from dbgpt.agent.resource.resource_api import AgentResource, ResourceType
from dbgpt.agent.resource.tool.pack import ToolPack
CFG = Config()
rm = get_resource_manager(CFG.SYSTEM_APP)
try:
tool_resource = rm.build_resource_by_type(
ResourceType.Tool.value,
AgentResource(type=ResourceType.Tool.value, value=tool_name),
)
tool_pack = ToolPack([tool_resource])
result = await tool_pack.async_execute(resource_name=tool_name, **args)
return json.dumps(
{"chunks": [{"output_type": "text", "content": str(result)}]},
ensure_ascii=False,
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Tool execute failed: {e}",
}
]
},
ensure_ascii=False,
)
return execute_tool

View File

@@ -0,0 +1,272 @@
"""html_interpreter tool — render HTML as an interactive report."""
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from dbgpt.agent.resource.tool.base import tool
logger = logging.getLogger(__name__)
def make_html_interpreter(react_state: Dict[str, Any], skills_dir: str):
@tool(
description=(
"将 HTML 渲染为可交互的网页报告,这是向用户展示网页报告的唯一方式。"
"【默认用法】直接传入完整的 HTML 字符串:"
'{"html": "<html>...</html>", "title": "报告标题"}。'
"你需要自己生成完整的 HTML 代码"
"(包含 <!DOCTYPE html>、<html>、<head>、<body> 等),"
"然后传给 html 参数即可。"
"HTML 可以很长,没有长度限制,不需要分段传入。"
"【技能模式 - 仅在使用技能时可选】如果正在使用技能skill可以用模板模式"
'{"template_path": "技能名/templates/模板.html", '
'"data": {"KEY": ""}, "title": "标题"}。'
'也可以用文件模式:{"file_path": "/path/to/report.html"}'
)
)
async def html_interpreter(
html: str = "",
title: str = "Report",
file_path: str = "",
template_path: str = "",
data: dict | str = None,
) -> str:
"""Render HTML as an interactive web report."""
from dbgpt.configs.model_config import STATIC_MESSAGE_IMG_PATH
skills_path = Path(skills_dir).expanduser().resolve()
# ── Mode 1: template_path + data ──
if template_path and template_path.strip():
tp = template_path.strip()
target = (skills_path / tp).resolve()
try:
target.relative_to(skills_path)
except ValueError:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Invalid template_path: {tp}",
}
]
},
ensure_ascii=False,
)
if not target.is_file():
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"Template not found: {tp}. "
"Please retry using the `html` parameter directly — "
"generate complete HTML yourself and pass it via "
'{"html": "<html>...</html>", "title": "title"}.'
),
}
]
},
ensure_ascii=False,
)
try:
raw_template = target.read_text(encoding="utf-8")
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Error reading template: {e}",
}
]
},
ensure_ascii=False,
)
replacements = data
if isinstance(replacements, str):
try:
replacements = json.loads(replacements)
except Exception:
try:
fixed = str(replacements).rstrip()
if not fixed.endswith("}"):
fixed += '"}' if not fixed.endswith('"') else "}"
replacements = json.loads(fixed)
except Exception:
replacements = {}
if not isinstance(replacements, dict):
replacements = {}
auto_data = react_state.get("auto_data", {})
if isinstance(auto_data, dict):
replacements = {**auto_data, **replacements}
ratio_data = react_state.get("ratio_data", {})
if isinstance(ratio_data, dict):
replacements = {**ratio_data, **replacements}
image_url_map = react_state.get("image_url_map", {})
if isinstance(image_url_map, dict):
for stem, url in image_url_map.items():
chart_key = f"CHART_{stem.upper()}"
if chart_key not in replacements:
replacements[chart_key] = url
def _replace_placeholder(m):
key = m.group(1)
return str(replacements.get(key, ""))
html = re.sub(r"\{\{([A-Z_0-9]+)\}\}", _replace_placeholder, raw_template)
if not title or title == "Report":
title = target.stem
# ── Mode 2: file_path ──
elif file_path and file_path.strip():
fp = file_path.strip()
if not os.path.isfile(fp):
cid = react_state.get("conv_id") or "default"
from dbgpt.configs.model_config import PILOT_PATH
alt = os.path.join(PILOT_PATH, "data", cid, os.path.basename(fp))
if os.path.isfile(alt):
fp = alt
else:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"File not found: {file_path}",
}
]
},
ensure_ascii=False,
)
try:
with open(fp, "r", encoding="utf-8") as f:
html = f.read()
if not title or title == "Report":
title = os.path.splitext(os.path.basename(fp))[0]
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Error reading file: {e}",
}
]
},
ensure_ascii=False,
)
# ── Mode 3: inline html ──
if html and isinstance(html, str) and not template_path and not file_path:
if "\\n" in html:
html = html.replace("\\n", "\n")
if "\\t" in html:
html = html.replace("\\t", "\t")
if not html or not html.strip():
return json.dumps(
{
"chunks": [
{"output_type": "text", "content": "No HTML content provided"}
]
},
ensure_ascii=False,
)
# Post-process: fix image URLs
fixed_html = html.strip()
try:
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
name_to_served: Dict[str, str] = {}
if os.path.isdir(STATIC_MESSAGE_IMG_PATH):
for fname in os.listdir(STATIC_MESSAGE_IMG_PATH):
ext = os.path.splitext(fname)[1].lower()
if ext not in IMAGE_EXTS:
continue
m = re.match(r"^[0-9a-f]{8}_(.+)$", fname, re.IGNORECASE)
if m:
name_to_served[m.group(1).lower()] = f"/images/{fname}"
if name_to_served:
def _fix_img_src(match: re.Match) -> str:
prefix = match.group(1)
raw_path = match.group(2)
quote = match.group(3)
filename = raw_path.rsplit("/", 1)[-1].lower()
if re.match(r"^[0-9a-f]{8}_.+$", filename, re.IGNORECASE):
return match.group(0)
if filename in name_to_served:
return f"{prefix}{name_to_served[filename]}{quote}"
return match.group(0)
fixed_html = re.sub(
r"""(src\s*=\s*["'])([^"']+\.(?:png|jpg|jpeg|gif|svg|webp))(["'])""",
_fix_img_src,
fixed_html,
flags=re.IGNORECASE,
)
except Exception:
pass
# Auto-append missing images
try:
gen_images = react_state.get("generated_images", [])
if gen_images:
html_img_stems = set(
re.sub(r"^[0-9a-f]+_", "", os.path.basename(src))
for src in re.findall(
r'<img[^>]+src=["\']([^"\']+)["\']', fixed_html, re.IGNORECASE
)
)
def _img_stem(url):
return re.sub(r"^[0-9a-f]+_", "", os.path.basename(url))
missing = [
url
for url in gen_images
if url not in fixed_html and _img_stem(url) not in html_img_stems
]
if missing:
imgs_html = "".join(
f'<div style="margin:16px 0">'
f'<img src="{url}" style="max-width:100%;height:auto;border-radius:8px">'
f"</div>"
for url in missing
)
section = (
'<div style="margin-top:32px"><h2>📊 分析图表</h2>'
f"{imgs_html}</div>"
)
if "</body>" in fixed_html.lower():
fixed_html = re.sub(
r"(</body>)",
section + r"\1",
fixed_html,
count=1,
flags=re.IGNORECASE,
)
else:
fixed_html += section
except Exception:
pass
chunks: List[Dict[str, Any]] = [
{"output_type": "html", "content": fixed_html, "title": title},
]
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return html_interpreter

View File

@@ -0,0 +1,77 @@
"""knowledge_retrieve tool — search the knowledge base."""
import json
from typing import Any, Dict, List
from dbgpt.agent.resource.tool.base import tool
def make_knowledge_retrieve(react_state: Dict[str, Any], knowledge_resources: List):
@tool(
description=(
"Retrieve relevant information from the knowledge base. "
"Use this tool when the user question involves content that may be "
'in the knowledge base. Parameters: {{"query": "search query"}}'
)
)
async def knowledge_retrieve(query: str) -> str:
if not knowledge_resources:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No knowledge base available",
}
]
},
ensure_ascii=False,
)
resource = knowledge_resources[0]
try:
chunks = await resource.retrieve(query)
if chunks:
content = "\n".join(
[f"[{i + 1}] {chunk.content}" for i, chunk in enumerate(chunks[:5])]
)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"Retrieved {len(chunks)} relevant documents"
),
},
{"output_type": "markdown", "content": content},
]
},
ensure_ascii=False,
)
else:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No relevant information found",
}
]
},
ensure_ascii=False,
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Knowledge retrieval failed: {str(e)}",
}
]
},
ensure_ascii=False,
)
return knowledge_retrieve

View File

@@ -0,0 +1,30 @@
"""load_file tool — returns the uploaded file path from react_state."""
import json
from typing import Any, Dict
from dbgpt.agent.resource.tool.base import tool
def make_load_file(react_state: Dict[str, Any]):
@tool(description="Load uploaded file info if provided.")
def load_file() -> str:
if not react_state.get("file_path"):
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No file uploaded"}]},
ensure_ascii=False,
)
return json.dumps(
{
"chunks": [
{"output_type": "text", "content": react_state["file_path"]},
{
"output_type": "text",
"content": "File path provided by user upload",
},
]
},
ensure_ascii=False,
)
return load_file

View File

@@ -0,0 +1,54 @@
"""load_tools tool — resolves required tools for the selected skill."""
import json
from typing import Any, Dict
from dbgpt.agent.resource.tool.base import tool
def make_load_tools(react_state: Dict[str, Any]):
@tool(description="Resolve required tools for the selected skill.")
def load_tools() -> str:
from dbgpt._private.config import Config
from dbgpt.agent.resource.manage import get_resource_manager
from dbgpt.agent.resource.resource_api import AgentResource, ResourceType
CFG = Config()
matched = react_state.get("matched")
rm = get_resource_manager(CFG.SYSTEM_APP)
required_tools = matched.metadata.required_tools if matched else []
if not required_tools:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No required tools specified",
}
]
},
ensure_ascii=False,
)
loaded = []
failed = []
for tool_name in required_tools:
try:
rm.build_resource_by_type(
ResourceType.Tool.value,
AgentResource(type=ResourceType.Tool.value, value=tool_name),
)
loaded.append(tool_name)
except Exception as e:
failed.append(f"{tool_name} ({e})")
chunks = []
if loaded:
chunks.append(
{"output_type": "text", "content": f"Loaded: {', '.join(loaded)}"}
)
if failed:
chunks.append(
{"output_type": "text", "content": f"Failed: {', '.join(failed)}"}
)
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return load_tools

View File

@@ -0,0 +1,129 @@
"""question tool — human-in-the-loop: ask the user questions and block until answered."""
import asyncio
import json
import logging
from typing import Any, Callable, Dict
from dbgpt.agent.resource.tool.base import tool
from .question_manager import question_manager
logger = logging.getLogger(__name__)
_DESCRIPTION = """\
Use this tool when you need to ask the user questions during execution. This allows you to:
1. Gather user preferences or requirements
2. Clarify ambiguous instructions
3. Get decisions on implementation choices as you work
4. Offer choices to the user about what direction to take.
Usage notes:
- When `custom` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
Parameter: {"questions": [{"question": "...", "header": "...", "options": [{"label": "...", "description": "..."}], "multiple": false}]}
"""
def make_question(react_state: Dict[str, Any], stream_callback: Callable):
"""Return a ``question`` FunctionTool bound to react_state and stream_callback."""
@tool(description=_DESCRIPTION)
async def question(questions: str) -> str:
"""Ask the user one or more questions and wait for their answers.
Args:
questions: JSON string of a list of question objects. Each question has:
- question (str): Complete question text
- header (str): Very short label (max 30 chars)
- options (list): [{label, description}] available choices
- multiple (bool, optional): allow multi-select
"""
conv_id = react_state.get("conv_id", "default")
# Parse questions JSON
try:
parsed_questions = (
json.loads(questions) if isinstance(questions, str) else questions
)
if not isinstance(parsed_questions, list):
parsed_questions = parsed_questions.get("questions", [])
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Error: invalid questions JSON — {e}",
}
]
},
ensure_ascii=False,
)
# 1. Register in QuestionManager → creates asyncio.Event
pq = question_manager.create(conv_id=conv_id, questions=parsed_questions)
# 2. Push question.asked SSE event to frontend
await stream_callback(
"question.asked",
{
"request_id": pq.request_id,
"conv_id": conv_id,
"questions": parsed_questions,
},
)
logger.info(
"question tool: pushed question.asked, request_id=%s", pq.request_id
)
# 3. Block until user answers (or timeout)
try:
await asyncio.wait_for(pq.event.wait(), timeout=300)
except asyncio.TimeoutError:
question_manager.remove(pq.request_id)
await stream_callback(
"question.rejected", {"request_id": pq.request_id, "conv_id": conv_id}
)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "Question timed out after 300 seconds. Proceeding without user answer.",
}
]
},
ensure_ascii=False,
)
finally:
question_manager.remove(pq.request_id)
# 4. User rejected
if pq.rejected:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "The user dismissed the question. Proceeding without answer.",
}
]
},
ensure_ascii=False,
)
# 5. Format answers for the LLM
answers = pq.answers or []
formatted = ", ".join(
f'"{q.get("question", "")}"="{", ".join(answers[i]) if i < len(answers) and answers[i] else "Unanswered"}"'
for i, q in enumerate(parsed_questions)
)
output = f"User has answered your questions: {formatted}. You can now continue with the user's answers in mind."
return json.dumps(
{"chunks": [{"output_type": "text", "content": output}]},
ensure_ascii=False,
)
return question

View File

@@ -0,0 +1,110 @@
"""QuestionManager — session-level pending question state with asyncio.Event blocking."""
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class QuestionOption:
label: str
description: str
@dataclass
class QuestionInfo:
question: str
header: str
options: List[QuestionOption]
multiple: bool = False
custom: bool = True
@dataclass
class PendingQuestion:
request_id: str
conv_id: str
questions: List[dict] # raw dicts from LLM JSON for easy serialization
event: asyncio.Event = field(default_factory=asyncio.Event)
answers: Optional[List[List[str]]] = None
rejected: bool = False
class QuestionManager:
"""Global manager for all pending question requests across sessions.
Each question tool invocation:
1. Calls ``create()`` → gets a PendingQuestion with a fresh asyncio.Event.
2. Pushes a ``question.asked`` SSE event to the frontend.
3. Awaits ``pending.event.wait()`` — blocks the tool coroutine.
4. When the user replies via HTTP, ``reply()`` sets the event and stores answers.
5. The tool coroutine wakes up, reads the answers, and returns to the LLM.
"""
def __init__(self) -> None:
self._pending: Dict[str, PendingQuestion] = {}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _new_id(self) -> str:
return f"que_{uuid.uuid4().hex[:12]}"
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def create(
self,
conv_id: str,
questions: List[dict],
request_id: Optional[str] = None,
) -> PendingQuestion:
rid = request_id or self._new_id()
pq = PendingQuestion(request_id=rid, conv_id=conv_id, questions=questions)
self._pending[rid] = pq
logger.info("QuestionManager.create: request_id=%s, n=%d", rid, len(questions))
return pq
def reply(self, request_id: str, answers: List[List[str]]) -> None:
pq = self._pending.get(request_id)
if not pq:
raise KeyError(f"No pending question: {request_id}")
pq.answers = answers
pq.event.set()
logger.info("QuestionManager.reply: request_id=%s answered", request_id)
def reject(self, request_id: str) -> None:
pq = self._pending.get(request_id)
if not pq:
raise KeyError(f"No pending question: {request_id}")
pq.rejected = True
pq.event.set()
logger.info("QuestionManager.reject: request_id=%s rejected", request_id)
def remove(self, request_id: str) -> None:
self._pending.pop(request_id, None)
def list_pending(self, conv_id: Optional[str] = None) -> List[dict]:
result = []
for pq in self._pending.values():
if conv_id is None or pq.conv_id == conv_id:
result.append(
{
"request_id": pq.request_id,
"conv_id": pq.conv_id,
"questions": pq.questions,
}
)
return result
# ---------------------------------------------------------------------------
# Module-level singleton — same pattern as _todo_list / REACT_AGENT_MEMORY_CACHE
# ---------------------------------------------------------------------------
question_manager = QuestionManager()

View File

@@ -0,0 +1,63 @@
"""select_skill tool — matches a skill from registry based on user query."""
import json
from typing import Any, Dict
from dbgpt.agent.resource.tool.base import tool
def make_select_skill(react_state: Dict[str, Any], registry: Any):
"""Return a ``select_skill`` FunctionTool bound to the given react_state."""
@tool(
description="Select the most relevant skill based on user query from the "
"available skills list in system prompt."
)
def select_skill(query: str) -> str:
def _is_excel_skill(meta) -> bool:
name = (meta.name or "").lower()
desc = (meta.description or "").lower()
tags = [tag.lower() for tag in (meta.tags or [])]
return any(
token in name or token in desc or token in tags
for token in ["excel", "xlsx", "xls", "spreadsheet"]
)
def _mentions_excel(text: str) -> bool:
t = (text or "").lower()
return any(
kw in t for kw in ["excel", "xlsx", "xls", "spreadsheet", "表格"]
)
match_input = query or ""
if react_state.get("file_path"):
match_input = f"{match_input} excel xlsx spreadsheet file"
matched = registry.match_skill(match_input)
if (
matched
and _is_excel_skill(matched.metadata)
and not (_mentions_excel(query) or react_state.get("file_path"))
):
matched = None
react_state["matched"] = matched
if matched:
detail = (
f"Matched: {matched.metadata.name} - {matched.metadata.description}"
)
return json.dumps(
{"chunks": [{"output_type": "text", "content": detail}]},
ensure_ascii=False,
)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No skill matched; proceed without skill",
}
]
},
ensure_ascii=False,
)
return select_skill

View File

@@ -0,0 +1,186 @@
"""shell_interpreter tool — run bash commands in a sandboxed environment."""
import json
import logging
import os
import uuid
from typing import Any, Dict, List
from dbgpt.agent.resource.tool.base import tool
logger = logging.getLogger(__name__)
def make_shell_interpreter(react_state: Dict[str, Any]):
@tool(
description=(
"Execute shell/bash commands in a sandboxed environment. "
"Use this tool when you need to run shell commands such as ls, cat, "
"grep, curl, apt, pip, git, or any other CLI tool. "
"The sandbox provides resource limits (256MB memory, 30s timeout) "
"and process isolation. "
'Parameters: {"code": "shell command(s) to execute"}'
)
)
async def shell_interpreter(code: str) -> str:
"""Execute shell/bash commands in a sandboxed environment."""
if not code or not code.strip():
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No command provided"}]},
ensure_ascii=False,
)
try:
from dbgpt_sandbox.sandbox.execution_layer.base import (
ExecutionStatus,
SessionConfig,
)
from dbgpt_sandbox.sandbox.execution_layer.local_runtime import LocalRuntime
except ImportError:
return json.dumps(
{
"chunks": [
{"output_type": "code", "content": code.strip()},
{
"output_type": "text",
"content": (
"Error: dbgpt-sandbox package is not installed. "
"Please install it with: pip install dbgpt-sandbox"
),
},
]
},
ensure_ascii=False,
)
from dbgpt.configs.model_config import ROOT_PATH
session_id = f"bash_{uuid.uuid4().hex[:12]}"
runtime = LocalRuntime()
sandbox_work_dir = ROOT_PATH
os.makedirs(sandbox_work_dir, exist_ok=True)
config = SessionConfig(
language="bash",
working_dir=sandbox_work_dir,
max_memory=256 * 1024 * 1024, # 256MB
timeout=30,
)
output_text = ""
try:
session = await runtime.create_session(session_id, config)
result = await session.execute(code)
if result.status == ExecutionStatus.SUCCESS:
output_text = result.output or ""
elif result.status == ExecutionStatus.TIMEOUT:
output_text = f"Execution timed out ({config.timeout}s limit)"
else:
output_text = result.error or "Unknown execution error"
if result.output:
output_text = result.output + "\n[ERROR]\n" + output_text
except Exception as e:
output_text = f"Sandbox execution error: {e}"
finally:
try:
await runtime.destroy_session(session_id)
except Exception:
pass
chunks: List[Dict[str, Any]] = [
{"output_type": "code", "content": code.strip()},
]
if output_text.strip():
chunks.append({"output_type": "text", "content": output_text.strip()})
else:
chunks.append({"output_type": "text", "content": "(no output)"})
# Safety-net post-processing for skill script execution
_code_lower = code.strip().lower()
_is_skill_script = "skills/" in _code_lower and ".py" in _code_lower
if _is_skill_script and output_text.strip():
import shutil
from dbgpt.configs.model_config import PILOT_PATH, STATIC_MESSAGE_IMG_PATH
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
if "calculate_ratios" in _code_lower:
try:
ratio_data = json.loads(output_text.strip())
if isinstance(ratio_data, dict):
react_state["ratio_data"] = ratio_data
except Exception:
pass
if "generate_charts" in _code_lower:
try:
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
try:
chart_output = json.loads(output_text.strip())
if isinstance(chart_output, dict):
chart_map = chart_output.get("charts", chart_output)
for name, abs_path in chart_map.items():
if isinstance(abs_path, str) and os.path.isfile(
abs_path
):
ext = os.path.splitext(abs_path)[1].lower()
if ext in IMAGE_EXTS:
unique_name = f"{uuid.uuid4().hex[:8]}_{os.path.basename(abs_path)}"
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
react_state.setdefault(
"generated_images", []
).append(img_url)
orig_stem = os.path.splitext(
os.path.basename(abs_path)
)[0].lower()
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
except (json.JSONDecodeError, TypeError):
pass
cid = react_state.get("conv_id") or "default"
out_dir = os.path.join(PILOT_PATH, "tmp", cid)
if os.path.isdir(out_dir):
for fname in os.listdir(out_dir):
ext = os.path.splitext(fname)[1].lower()
if ext in IMAGE_EXTS:
abs_path = os.path.join(out_dir, fname)
orig_stem = os.path.splitext(fname)[0].lower()
if orig_stem not in react_state.get(
"image_url_map", {}
):
unique_name = f"{uuid.uuid4().hex[:8]}_{fname}"
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
react_state.setdefault(
"generated_images", []
).append(img_url)
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
all_images = react_state.get("generated_images", [])
if all_images:
img_summary = (
"已生成的图片URL在生成HTML报告时请使用这些URL:\n"
+ "\n".join(f" - {url}" for url in all_images)
)
chunks.append({"output_type": "text", "content": img_summary})
except Exception as e:
logger.warning(
"shell_interpreter: image post-processing failed: %s", e
)
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return shell_interpreter

View File

@@ -0,0 +1,211 @@
"""load_skill tool — loads skill content (SKILL.md) by name."""
import json
from typing import Any, Dict
from dbgpt.agent.resource.tool.base import tool
def make_load_skill(react_state: Dict[str, Any]):
"""Return a ``load_skill`` FunctionTool bound to the given react_state."""
@tool(
description="Load skill content by skill name and file path. "
"Returns the SKILL.md content of the specified skill. "
'参数: {"skill_name": "技能名称", "file_path": "技能文件路径"}'
)
def load_skill(skill_name: str, file_path: str) -> str:
"""Load the skill content (SKILL.md) by skill name and file path."""
from dbgpt.agent.claude_skill import get_registry
registry = get_registry()
matched = registry.get_skill(skill_name)
if not matched:
for s in registry.list_skills():
if s.name.lower() == skill_name.lower():
matched = registry.get_skill(s.name)
break
if not matched:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Skill '{skill_name}' not found",
}
]
},
ensure_ascii=False,
)
react_state["matched"] = matched
react_state["skill_prompt"] = matched.get_prompt()
chunks = [
{"output_type": "text", "content": f"Skill: {matched.metadata.name}"},
{"output_type": "text", "content": f"File path: {file_path}"},
{"output_type": "text", "content": "---"},
]
if matched.instructions:
chunks.append({"output_type": "markdown", "content": matched.instructions})
elif matched.prompt_template:
prompt_text = (
matched.prompt_template.template
if hasattr(matched.prompt_template, "template")
else str(matched.prompt_template)
)
chunks.append({"output_type": "markdown", "content": prompt_text})
return json.dumps({"chunks": chunks}, ensure_ascii=False)
return load_skill
def make_execute_skill_script_file(react_state: Dict[str, Any]):
"""Return an ``execute_skill_script_file`` FunctionTool bound to react_state."""
@tool(
description="执行技能scripts目录下的脚本文件。参数: "
'{"skill_name": "技能名称", "script_file_name": "脚本文件名", "args": {参数}}'
)
async def execute_skill_script_file(
skill_name: str, script_file_name: str, args: dict | None = None
) -> str:
"""Execute a script file from a skill's scripts directory."""
import os
import shutil
import uuid
from dbgpt.agent.skill.manage import get_skill_manager
from dbgpt.configs.model_config import PILOT_PATH, STATIC_MESSAGE_IMG_PATH
from dbgpt._private.config import Config
from dbgpt_app.openapi.api_v1.tools._helpers import (
_extract_auto_data_markers,
)
CFG = Config()
try:
sm = get_skill_manager(CFG.SYSTEM_APP)
cid = react_state.get("conv_id") or "default"
out_dir = os.path.join(PILOT_PATH, "tmp", cid)
os.makedirs(out_dir, exist_ok=True)
real_file_path = react_state.get("file_path")
if real_file_path and args:
_FILE_PATH_KEYS = {
"input_file",
"file_path",
"data_path",
"csv_path",
"excel_path",
"data_file",
}
for key in list(args.keys()):
if key in _FILE_PATH_KEYS:
args[key] = real_file_path
result_str = await sm.execute_skill_script_file(
skill_name,
script_file_name,
args or {},
output_dir=out_dir,
)
try:
_skill_path = sm._get_skill_path(skill_name)
_sf = script_file_name.lstrip("/\\")
if _sf.startswith("scripts/") or _sf.startswith("scripts\\"):
_sf = _sf[8:]
_script_abs = os.path.join(_skill_path, "scripts", _sf)
with open(_script_abs, "r", encoding="utf-8") as _f:
_script_source = _f.read()
except Exception:
_script_source = None
try:
result_obj = json.loads(result_str)
chunks = result_obj.get("chunks", [])
if _script_source:
chunks.insert(0, {"output_type": "code", "content": _script_source})
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
for chunk in chunks:
if chunk.get("output_type") == "image":
abs_path = chunk["content"]
if os.path.isabs(abs_path) and os.path.isfile(abs_path):
ext = os.path.splitext(abs_path)[1].lower()
if ext in IMAGE_EXTS:
unique_name = (
f"{uuid.uuid4().hex[:8]}_"
f"{os.path.basename(abs_path)}"
)
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
chunk["content"] = img_url
react_state.setdefault("generated_images", []).append(
img_url
)
orig_stem = os.path.splitext(
os.path.basename(abs_path)
)[0].lower()
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
all_images = react_state.get("generated_images", [])
if all_images:
img_summary = (
"已生成的图片URL在生成HTML报告时请使用这些URL:\n"
+ "\n".join(f" - {url}" for url in all_images)
)
chunks.append({"output_type": "text", "content": img_summary})
auto_data = react_state.get("auto_data")
if not isinstance(auto_data, dict):
auto_data = {}
react_state["auto_data"] = auto_data
filtered_chunks = []
for chunk in chunks:
if chunk.get("output_type") != "text":
filtered_chunks.append(chunk)
continue
content = chunk.get("content") or ""
cleaned, extracted = _extract_auto_data_markers(content)
if extracted:
auto_data.update(extracted)
if cleaned:
chunk["content"] = cleaned
filtered_chunks.append(chunk)
elif not extracted:
filtered_chunks.append(chunk)
chunks = filtered_chunks
if script_file_name == "calculate_ratios.py":
for chunk in chunks:
if chunk.get("output_type") == "text":
try:
ratio_data = json.loads(chunk["content"])
react_state["ratio_data"] = ratio_data
except Exception:
pass
return json.dumps({"chunks": chunks}, ensure_ascii=False)
except (json.JSONDecodeError, KeyError):
return result_str
except Exception as e:
return json.dumps(
{"chunks": [{"output_type": "text", "content": f"Error: {str(e)}"}]},
ensure_ascii=False,
)
return execute_skill_script_file

View File

@@ -0,0 +1,102 @@
"""sql_query tool — read-only SQL query against the selected database."""
import json
from typing import Any, Dict, Optional
from dbgpt.agent.resource.tool.base import tool
def make_sql_query(react_state: Dict[str, Any], database_connector: Optional[Any]):
@tool(
description=(
"对用户选择的数据库执行 SQL 查询(仅支持 SELECT"
'参数: {"sql": "SELECT 语句"}'
)
)
def sql_query(sql: str) -> str:
"""Execute a read-only SQL query against the selected database."""
if database_connector is None:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "未选择数据库,请先在左侧面板选择一个数据源。",
}
]
},
ensure_ascii=False,
)
sql_stripped = sql.strip().rstrip(";")
sql_upper = sql_stripped.upper().lstrip()
forbidden = [
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"ALTER",
"TRUNCATE",
"CREATE",
"GRANT",
"REVOKE",
]
for kw in forbidden:
if sql_upper.startswith(kw):
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"安全限制: 不允许执行 {kw} 语句,仅支持 SELECT 查询。"
),
}
]
},
ensure_ascii=False,
)
try:
result = database_connector.run(sql_stripped)
if not result:
return json.dumps(
{
"chunks": [
{"output_type": "text", "content": "查询返回空结果。"}
]
},
ensure_ascii=False,
)
columns = result[0]
col_names = [str(c[0]) if isinstance(c, tuple) else str(c) for c in columns]
rows = result[1:]
header = "| " + " | ".join(col_names) + " |"
separator = "| " + " | ".join(["---"] * len(col_names)) + " |"
md_rows = []
for row in rows[:50]:
md_rows.append("| " + " | ".join(str(v) for v in row) + " |")
table = "\n".join([header, separator] + md_rows)
if len(rows) > 50:
table += f"\n\n(仅显示前 50 行,共 {len(rows)} 行)"
return json.dumps(
{"chunks": [{"output_type": "markdown", "content": table}]},
ensure_ascii=False,
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"SQL 执行失败: {str(e)}",
}
]
},
ensure_ascii=False,
)
return sql_query

View File

@@ -0,0 +1,76 @@
"""todowrite tool — maintain a session-level structured task list."""
import json
from typing import Any, Callable, Dict, List
from dbgpt.agent.resource.tool.base import tool
def make_todowrite(
todo_list: List[Dict[str, str]],
stream_callback: Callable,
):
"""Return a ``todowrite`` FunctionTool that mutates ``todo_list`` in-place."""
@tool(
description=(
"Create and manage a structured task list for the current session. "
"Use this tool to plan complex tasks (3+ steps), track progress, "
"and show the user what you are doing. "
"Pass the FULL todo list every time (not incremental). "
"Each todo has: content (brief description), "
"status (pending | in_progress | completed | cancelled), "
"priority (high | medium | low). "
"Rules: only ONE task in_progress at a time; mark tasks completed "
"immediately after finishing; do NOT use for single trivial tasks."
'\nParameter: {"todos": [{"content": "...", "status": "...", '
'"priority": "..."}]}'
)
)
def todowrite(todos: str) -> str:
"""Update the session todo list (full replacement)."""
parsed: List[Dict[str, str]] = []
try:
raw = json.loads(todos) if isinstance(todos, str) else todos
items = raw if isinstance(raw, list) else raw.get("todos", raw)
if isinstance(items, list):
for item in items:
parsed.append(
{
"content": str(item.get("content", "")),
"status": str(item.get("status", "pending")),
"priority": str(item.get("priority", "medium")),
}
)
except Exception:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "Error: invalid todos JSON",
}
]
},
ensure_ascii=False,
)
todo_list.clear()
todo_list.extend(parsed)
total = len(parsed)
done = sum(1 for t in parsed if t["status"] == "completed")
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Todo list updated: {done}/{total} completed",
}
],
"__todos__": parsed,
},
ensure_ascii=False,
)
return todowrite

View File

@@ -7,7 +7,13 @@
import { MessagePart, ToolPart } from '@/new-components/chat/content/OpenCodeSessionTurn';
import { ChatHistoryResponse } from '@/types/chat';
import { ContextStatus, ReActSSEState, createReActSSEState, parseSSELine } from '@/utils/react-sse-parser';
import {
ContextStatus,
ReActSSEState,
SSEQuestionAskedEvent,
createReActSSEState,
parseSSELine,
} from '@/utils/react-sse-parser';
import { useCallback, useEffect, useRef, useState } from 'react';
export interface ReActChatRequest {
@@ -45,12 +51,15 @@ export interface UseReActAgentChatReturn {
streamingTurn: StreamingTurn | null;
isStreaming: boolean;
contextStatus: ContextStatus | null;
pendingQuestion: SSEQuestionAskedEvent | null;
sendMessage: (
request: ReActChatRequest,
currentHistory: ChatHistoryResponse,
order: number,
) => Promise<ChatHistoryResponse>;
cancel: () => void;
replyQuestion: (requestId: string, answers: string[][]) => Promise<void>;
rejectQuestion: (requestId: string) => Promise<void>;
}
export function useReActAgentChat(options: UseReActAgentChatOptions = {}): UseReActAgentChatReturn {
@@ -59,6 +68,7 @@ export function useReActAgentChat(options: UseReActAgentChatOptions = {}): UseRe
const [streamingTurn, setStreamingTurn] = useState<StreamingTurn | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [contextStatus, setContextStatus] = useState<ContextStatus | null>(null);
const [pendingQuestion, setPendingQuestion] = useState<SSEQuestionAskedEvent | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sseStateRef = useRef<ReActSSEState | null>(null);
@@ -113,6 +123,10 @@ export function useReActAgentChat(options: UseReActAgentChatOptions = {}): UseRe
setContextStatus(latestContextStatus);
}
// Update pending question state
const latestQuestion = sseStateRef.current.getPendingQuestion();
setPendingQuestion(latestQuestion);
setStreamingTurn(prev => {
if (!prev) return null;
return {
@@ -329,12 +343,41 @@ export function useReActAgentChat(options: UseReActAgentChatOptions = {}): UseRe
[baseUrl, cancel, processSSELine, onHistoryUpdate, onError, onComplete],
);
const replyQuestion = useCallback(async (requestId: string, answers: string[][]) => {
try {
const res = await fetch(`/api/v1/chat/question/${requestId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setPendingQuestion(null);
} catch (e) {
console.error('replyQuestion failed:', e);
}
}, []);
const rejectQuestion = useCallback(async (requestId: string) => {
try {
const res = await fetch(`/api/v1/chat/question/${requestId}/reject`, {
method: 'POST',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setPendingQuestion(null);
} catch (e) {
console.error('rejectQuestion failed:', e);
}
}, []);
return {
streamingTurn,
isStreaming,
contextStatus,
pendingQuestion,
sendMessage,
cancel,
replyQuestion,
rejectQuestion,
};
}

View File

@@ -3,6 +3,7 @@ import React, { memo, useCallback, useMemo, useRef } from 'react';
import ChatHeader from './ChatHeader';
import ChatMessageList, { ChatTurn } from './ChatMessageList';
import ChatWelcome from './ChatWelcome';
import QuestionDock, { QuestionRequest } from './content/QuestionDock';
import { SlashCommand } from './input/CommandPopover';
import { ContentPart } from './input/EnhancedChatInput';
import StandaloneChatInput, { StandaloneChatInputRef } from './input/StandaloneChatInput';
@@ -27,6 +28,10 @@ export interface ChatPageProps {
inputPlaceholder?: string;
showSteps?: boolean;
pendingQuestion?: QuestionRequest | null;
onReplyQuestion?: (requestId: string, answers: string[][]) => void;
onRejectQuestion?: (requestId: string) => void;
headerExtra?: React.ReactNode;
welcomeExtra?: React.ReactNode;
inputExtra?: React.ReactNode;
@@ -54,6 +59,10 @@ const ChatPage: React.FC<ChatPageProps> = ({
inputPlaceholder,
showSteps = true,
pendingQuestion,
onReplyQuestion,
onRejectQuestion,
headerExtra,
welcomeExtra,
inputExtra,
@@ -117,6 +126,15 @@ const ChatPage: React.FC<ChatPageProps> = ({
)}
<div className='flex-shrink-0'>
{pendingQuestion && onReplyQuestion && onRejectQuestion && (
<div className='mx-auto max-w-3xl px-4 pt-2'>
<QuestionDock
request={pendingQuestion}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
</div>
)}
<StandaloneChatInput
ref={inputRef}
onSubmit={handleSubmit}

View File

@@ -0,0 +1,189 @@
/**
* QuestionDock — Human-in-the-loop question confirmation UI.
*
* Renders above the chat input when the agent's `question` tool
* pushes a `question.asked` SSE event. The user selects options
* and confirms, or dismisses the question entirely.
*/
import { CheckOutlined, CloseOutlined, QuestionCircleFilled } from '@ant-design/icons';
import React, { useMemo, useState } from 'react';
import type { QuestionInfo } from '@/utils/react-sse-parser';
export interface QuestionRequest {
request_id: string;
conv_id: string;
questions: QuestionInfo[];
}
interface QuestionDockProps {
request: QuestionRequest;
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}
const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject }) => {
const [selected, setSelected] = useState<string[][]>(
() => request.questions.map(() => []),
);
const [customInputs, setCustomInputs] = useState<string[]>(
() => request.questions.map(() => ''),
);
const canSubmit = useMemo(() => {
return request.questions.every((q, i) => {
if (!q.multiple) {
return selected[i].length === 1 || (q.custom !== false && customInputs[i].trim() !== '');
}
return selected[i].length > 0 || (q.custom !== false && customInputs[i].trim() !== '');
});
}, [selected, customInputs, request.questions]);
const toggleOption = (qIndex: number, label: string, multiple: boolean) => {
setSelected(prev => {
const next = [...prev];
const current = [...next[qIndex]];
if (multiple) {
const idx = current.indexOf(label);
if (idx >= 0) current.splice(idx, 1);
else current.push(label);
} else {
current.length = 0;
current.push(label);
}
next[qIndex] = current;
return next;
});
setCustomInputs(prev => {
const next = [...prev];
if (!request.questions[qIndex].multiple) next[qIndex] = '';
return next;
});
};
const setCustom = (qIndex: number, value: string) => {
setSelected(prev => {
const next = [...prev];
next[qIndex] = [];
return next;
});
setCustomInputs(prev => {
const next = [...prev];
next[qIndex] = value;
return next;
});
};
const handleSubmit = () => {
const answers = request.questions.map((q, i) => {
if (selected[i].length > 0) return selected[i];
if (q.custom !== false && customInputs[i].trim()) return [customInputs[i].trim()];
return [];
});
onReply(request.request_id, answers);
};
const handleDismiss = () => {
onReject(request.request_id);
};
return (
<div className='w-full overflow-hidden rounded-t-xl border border-b-0 border-slate-200/80 bg-white/95 shadow-[0_-4px_20px_rgba(15,23,42,0.08)] backdrop-blur-xl dark:border-white/10 dark:bg-[#1b1c22]/95 dark:shadow-[0_-4px_20px_rgba(0,0,0,0.25)]'>
{/* Header */}
<div className='flex items-center justify-between gap-3 px-4 py-2.5'>
<div className='flex items-center gap-2.5 text-slate-700 dark:text-slate-200'>
<span className='flex h-6 w-6 items-center justify-center rounded-md bg-amber-50 ring-1 ring-amber-200/80 dark:bg-amber-500/10 dark:ring-amber-500/20'>
<QuestionCircleFilled className='text-[12px] text-amber-500' />
</span>
<span className='text-sm font-semibold leading-5 tracking-tight'>
</span>
</div>
<button
onClick={handleDismiss}
className='flex h-6 w-6 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-white/5 dark:hover:text-slate-300'
title='取消'
>
<CloseOutlined className='text-[11px]' />
</button>
</div>
<div className='h-px bg-slate-100 dark:bg-white/10' />
{/* Questions */}
<div className='max-h-[320px] space-y-4 overflow-y-auto overscroll-contain px-4 py-3'>
{request.questions.map((q, qi) => (
<div key={qi}>
{/* Header label */}
{q.header && (
<div className='mb-1 text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500'>
{q.header}
</div>
)}
{/* Question text */}
<div className='mb-2 text-sm leading-5 text-slate-800 dark:text-slate-100'>
{q.question}
</div>
{/* Options */}
<div className='flex flex-wrap gap-2'>
{q.options.map((opt) => {
const isSelected = selected[qi].includes(opt.label);
return (
<button
key={opt.label}
onClick={() => toggleOption(qi, opt.label, !!q.multiple)}
className={`rounded-lg border px-3 py-1.5 text-[13px] leading-4 transition-all ${
isSelected
? 'border-sky-400 bg-sky-50 font-medium text-sky-700 dark:border-sky-500/50 dark:bg-sky-500/15 dark:text-sky-300'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:border-white/20 dark:hover:bg-white/8'
}`}
title={opt.description}
>
{isSelected && <CheckOutlined className='mr-1 text-[10px]' />}
{opt.label}
</button>
);
})}
</div>
{/* Custom input */}
{q.custom !== false && (
<div className='mt-2'>
<input
type='text'
placeholder='或输入自定义答案...'
value={customInputs[qi]}
onChange={(e) => setCustom(qi, e.target.value)}
className='w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-[13px] leading-4 text-slate-700 placeholder-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-1 focus:ring-sky-400/30 dark:border-white/10 dark:bg-white/5 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:border-sky-500/50'
/>
</div>
)}
</div>
))}
</div>
{/* Footer with actions */}
<div className='flex items-center justify-end gap-2 border-t border-slate-100 px-4 py-2.5 dark:border-white/10'>
<button
onClick={handleDismiss}
className='rounded-lg px-3 py-1.5 text-[13px] text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-white/5'
>
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={`rounded-lg px-4 py-1.5 text-[13px] font-medium transition-all ${
canSubmit
? 'bg-sky-500 text-white shadow-sm hover:bg-sky-600 active:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-500'
: 'cursor-not-allowed bg-slate-100 text-slate-400 dark:bg-white/5 dark:text-slate-600'
}`}
>
</button>
</div>
</div>
);
};
export default QuestionDock;

View File

@@ -66,12 +66,47 @@ export interface SSEContextStatusEvent {
compact_layer?: string | null;
}
// ── Human-in-the-loop: question events ──────────────────────────────────────
export interface QuestionOption {
label: string;
description: string;
}
export interface QuestionInfo {
question: string;
header: string;
options: QuestionOption[];
multiple?: boolean;
custom?: boolean;
}
export interface SSEQuestionAskedEvent {
type: 'question.asked';
request_id: string;
conv_id: string;
questions: QuestionInfo[];
}
export interface SSEQuestionRepliedEvent {
type: 'question.replied';
request_id: string;
}
export interface SSEQuestionRejectedEvent {
type: 'question.rejected';
request_id: string;
}
export type SSEEvent =
| SSEStepStartEvent
| SSEStepChunkEvent
| SSEStepMetaEvent
| SSEStepDoneEvent
| SSEContextStatusEvent
| SSEQuestionAskedEvent
| SSEQuestionRepliedEvent
| SSEQuestionRejectedEvent
| SSEFinalEvent
| SSEDoneEvent;
@@ -108,6 +143,7 @@ export class ReActSSEState {
private startTime: number;
private endTime?: number;
private _contextStatus: ContextStatus | null = null;
private _pendingQuestion: SSEQuestionAskedEvent | null = null;
constructor() {
this.startTime = Date.now();
@@ -133,6 +169,13 @@ export class ReActSSEState {
case 'context.status':
this.handleContextStatus(event);
break;
case 'question.asked':
this._pendingQuestion = event;
break;
case 'question.replied':
case 'question.rejected':
this._pendingQuestion = null;
break;
case 'final':
this.handleFinal(event);
break;
@@ -142,6 +185,13 @@ export class ReActSSEState {
}
}
/**
* Get the current pending question (null if none)
*/
getPendingQuestion(): SSEQuestionAskedEvent | null {
return this._pendingQuestion;
}
private handleStepStart(event: SSEStepStartEvent): void {
const step: StepState = {
id: event.id,