mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-16 17:15:22 +00:00
feat:add ask-user tool and modularize react tools
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
189
web/new-components/chat/content/QuestionDock.tsx
Normal file
189
web/new-components/chat/content/QuestionDock.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user