mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-17 10:16:49 +00:00
feat:dbgpt skill template
This commit is contained in:
@@ -568,7 +568,10 @@ async def _react_agent_stream(
|
||||
continue
|
||||
output_type = item.get("output_type") or "text"
|
||||
payload = item.get("content")
|
||||
if output_type in ["text", "markdown", "code"] and isinstance(
|
||||
if output_type == "code" and isinstance(payload, str):
|
||||
# Send code as a single chunk — never split it.
|
||||
raw_chunks.append(step_chunk(step_id, output_type, payload))
|
||||
elif output_type in ["text", "markdown"] and isinstance(
|
||||
payload, str
|
||||
):
|
||||
for chunk in chunk_text(payload, max_len=800):
|
||||
@@ -1294,11 +1297,31 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
output_dir=out_dir,
|
||||
)
|
||||
|
||||
|
||||
# Read script source code and prepend as a 'code' chunk
|
||||
# so the frontend can display it in the left pane.
|
||||
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
|
||||
|
||||
# Post-process: copy image files to static dir and replace
|
||||
# absolute paths with /images/ URLs.
|
||||
try:
|
||||
result_obj = json.loads(result_str)
|
||||
chunks = result_obj.get("chunks", [])
|
||||
# Prepend script source code as a 'code' chunk
|
||||
if _script_source:
|
||||
chunks.insert(0, {
|
||||
"output_type": "code",
|
||||
"content": _script_source,
|
||||
})
|
||||
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
|
||||
for chunk in chunks:
|
||||
@@ -1319,6 +1342,15 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
react_state.setdefault(
|
||||
"generated_images", []
|
||||
).append(img_url)
|
||||
# Also store a map: original filename (no ext)
|
||||
# -> served URL for template placeholder resolution.
|
||||
# e.g. "financial_overview" -> "/images/abc_financial_overview.png"
|
||||
orig_stem = os.path.splitext(
|
||||
os.path.basename(abs_path)
|
||||
)[0].lower()
|
||||
react_state.setdefault(
|
||||
"image_url_map", {}
|
||||
)[orig_stem] = img_url
|
||||
|
||||
# Append image URL summary for LLM reference
|
||||
all_images = react_state.get("generated_images", [])
|
||||
@@ -1351,14 +1383,14 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
)
|
||||
|
||||
@tool(
|
||||
description="Render HTML content as a web report. "
|
||||
"Supports three modes: "
|
||||
"(1) template_path + data (RECOMMENDED): pass a template file path (relative to skills dir) "
|
||||
"and a data dict whose keys match {{PLACEHOLDER}} tokens in the template. "
|
||||
"The backend reads the template and performs all replacements. "
|
||||
"(2) file_path: pass a path to an .html file on disk. "
|
||||
"(3) html: pass a complete HTML string directly (only for short content). "
|
||||
'Parameters: {"template_path": "skill-name/templates/report.html", "data": {"KEY": "value", ...}, "title": "Report Title"}'
|
||||
description="将 HTML 渲染为可交互的网页报告,这是向用户展示网页报告的唯一方式。"
|
||||
"【默认用法】直接传入完整的 HTML 字符串:{\"html\": \"<html>...</html>\", \"title\": \"报告标题\"}。"
|
||||
"你需要自己生成完整的 HTML 代码(包含 <!DOCTYPE html>、<html>、<head>、<body> 等),然后传给 html 参数即可。"
|
||||
"HTML 可以很长,没有长度限制,不需要分段传入。"
|
||||
"【禁止】不要用 code_interpreter 写 HTML 再 print,不要用 code_interpreter 把 HTML 写入文件再读取,直接把 HTML 传给本工具即可。"
|
||||
"【技能模式 - 仅在使用技能时可选】如果正在使用技能(skill),可以用模板模式:"
|
||||
"{\"template_path\": \"技能名/templates/模板.html\", \"data\": {\"KEY\": \"值\"}, \"title\": \"标题\"}。"
|
||||
"也可以用文件模式:{\"file_path\": \"/path/to/report.html\"}"
|
||||
)
|
||||
async def html_interpreter(
|
||||
html: str = "",
|
||||
@@ -1367,15 +1399,16 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
template_path: str = "",
|
||||
data: dict | str = None,
|
||||
) -> str:
|
||||
"""Accept HTML content and return it for rendering.
|
||||
|
||||
Preferred usage: pass `template_path` (relative to skills dir) plus
|
||||
a `data` dict whose keys match {{PLACEHOLDER}} tokens in the template.
|
||||
The backend reads the template and performs all replacements.
|
||||
|
||||
Fallback modes:
|
||||
- `html`: pass a complete HTML string directly.
|
||||
- `file_path`: read HTML from a file on disk.
|
||||
"""Render HTML as an interactive web report.
|
||||
|
||||
Default usage: pass a complete HTML string via the `html` parameter.
|
||||
The HTML can be arbitrarily long — no length limit, no chunking needed.
|
||||
|
||||
Skill template mode (optional): pass `template_path` (relative to skills
|
||||
dir) plus a `data` dict whose keys match {{PLACEHOLDER}} tokens in the
|
||||
template. The backend reads the template and performs all replacements.
|
||||
|
||||
Legacy fallback: `file_path` reads HTML from a file on disk.
|
||||
"""
|
||||
import re
|
||||
from dbgpt.configs.model_config import STATIC_MESSAGE_IMG_PATH
|
||||
@@ -1432,6 +1465,16 @@ print(json.dumps(summary, ensure_ascii=False))
|
||||
merged = {**ratio_data, **replacements}
|
||||
replacements = merged
|
||||
|
||||
# Auto-resolve CHART_* placeholders from generated images.
|
||||
# image_url_map: {"financial_overview": "/images/abc_financial_overview.png"}
|
||||
# Template uses: {{CHART_FINANCIAL_OVERVIEW}} -> /images/abc_financial_overview.png
|
||||
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, "NA"))
|
||||
@@ -1822,20 +1865,20 @@ Action Input: {{"skill_name": "financial-report-analyzer", "script_file_name": "
|
||||
- 生成的代码必须语法正确,确保所有字符串、f-string、括号、引号都正确闭合。不要截断代码。
|
||||
- **代码长度限制(极其重要)**: 每次调用 code_interpreter 的代码**不得超过 80 行**。如果任务复杂,**必须拆分为多次调用**,每次完成一个子任务。违反此规则会导致代码被截断产生语法错误。
|
||||
- 如果需要用到之前步骤的分析结果,必须在当前代码中重新加载数据并重新计算。
|
||||
- **禁止用 code_interpreter 直接生成最终HTML报告给用户**。如需生成网页报告,必须用 code_interpreter 将 HTML 写入文件(如 `os.path.join(PLOT_DIR, 'report.html')`),然后**必须**调用 `html_interpreter(file_path=...)` 渲染,否则用户无法看到网页。
|
||||
- **禁止在 code_interpreter 中 print() HTML 内容**,用户看不到。生成网页报告时,直接调用 `html_interpreter` 工具,把完整 HTML 传给 `html` 参数即可。
|
||||
- **分步执行流程(推荐)**: 对于复杂分析任务,建议按以下顺序分步执行:
|
||||
① **数据处理**:先加载数据,进行清洗、计算关键指标,用 print() 输出摘要
|
||||
② **生成图表**:基于上一步的结果,生成可视化图表,保存到 PLOT_DIR
|
||||
③ **生成HTML文件**:用 `code_interpreter` 将完整 HTML 写入文件(如 `os.path.join(PLOT_DIR, 'report.html')`)
|
||||
④ **渲染HTML(必须)**:调用 `html_interpreter(file_path=...)` 渲染上一步写入的文件,**此步骤不可省略**
|
||||
③ **生成网页报告(必须)**:直接调用 `html_interpreter`,把你生成的完整 HTML 字符串传给 `html` 参数,如 `Action: html_interpreter`,`Action Input: {{"html": "<!DOCTYPE html><html>...</html>", "title": "报告标题"}}`
|
||||
- **图片URL**: 执行后系统会在 Observation 中返回生成的图片URL(如 `/images/xxxx_chart.png`)。在后续生成 HTML 报告时,必须使用这些实际URL来嵌入图片。
|
||||
7. **html_interpreter**: 将 HTML 渲染为网页报告。支持三种模式:
|
||||
**模式A - 模板填充(技能报告推荐)**:传入模板路径和数据字典,后端自动读取模板并替换占位符:
|
||||
参数: {{"template_path": "技能名/templates/模板.html", "data": {{"KEY": "值", ...}}, "title": "报告标题"}}
|
||||
**模式B - 文件渲染(自定义HTML)**:先用 `code_interpreter` 将完整 HTML 写入文件,然后调用:
|
||||
参数: {{"file_path": "/path/to/report.html"}}
|
||||
**模式C - 内联HTML(仅限短内容)**:直接传入 html 字符串:
|
||||
参数: {{"html": "短HTML内容", "title": "报告标题"}}
|
||||
7. **html_interpreter**: 将 HTML 渲染为可交互的网页报告,这是向用户展示网页报告的**唯一方式**。
|
||||
**默认用法 - 直接传 HTML(推荐)**:你自己生成完整的 HTML 代码,然后直接传给 html 参数:
|
||||
参数: {{"html": "<!DOCTYPE html><html><head>...</head><body>...</body></html>", "title": "报告标题"}}
|
||||
- HTML 可以很长,没有长度限制,不需要分段传入
|
||||
- 你需要自己在 HTML 中用 `<img src="/images/xxxx_chart.png">` 嵌入之前生成的图表
|
||||
- **不要**用 code_interpreter 写 HTML 再 print,**不要**用 code_interpreter 把 HTML 写入文件再读取,直接把 HTML 传给本工具即可
|
||||
**技能模式(仅在使用技能时可选)**:传入模板路径和数据字典:
|
||||
参数: {{"template_path": "技能名/templates/模板.html", "data": {{"KEY": "值"}}, "title": "报告标题"}}
|
||||
**HTML 生成规范**:
|
||||
- **精简至上**: 使用简洁的内联 style 属性,**禁止**写大段 `<style>` 块或 CSS 类定义。直接在元素上写 `style="..."`。
|
||||
- **图片嵌入与说明(重要)**: 之前 code_interpreter 的 Observation 中返回了图片URL(格式 `/images/xxxx_chart.png`),**必须使用这些完整的实际URL**。用 `<img src="/images/xxxx_chart.png" style="max-width:100%;height:auto">` 嵌入。**绝对不要**猜测或编造图片路径。
|
||||
@@ -1846,11 +1889,10 @@ Action Input: {{"skill_name": "financial-report-analyzer", "script_file_name": "
|
||||
|
||||
## ⚠️ 网页报告生成的强制流程(违反将导致用户看不到报告)
|
||||
当用户要求生成「网页报告」「交互式报告」「HTML报告」「可视化报告」时,**必须**执行以下流程:
|
||||
1. 用 `code_interpreter` 分析数据、生成图表(可多次调用)
|
||||
2. 用 `code_interpreter` 将最终 HTML 写入文件(**不超过80行,如需更多内容分多次写入同一文件**)
|
||||
3. **必须调用** `html_interpreter` 的 file_path 模式渲染该文件:`Action: html_interpreter`,`Action Input: {{"file_path": "/path/to/report.html"}}`
|
||||
4. 确认渲染成功后,调用 `terminate` 返回结果
|
||||
**绝对禁止**:在 code_interpreter 中 print() HTML 内容后直接 terminate,这样用户无法看到网页报告。
|
||||
1. 用 `code_interpreter` 分析数据、生成图表(可多次调用,保存图表到 PLOT_DIR)
|
||||
2. **直接调用 `html_interpreter`**,将完整 HTML 传给 `html` 参数:`Action: html_interpreter`,`Action Input: {{"html": "<!DOCTYPE html><html>...包含 <img src='/images/xxx.png'> 引用之前生成的图表...</html>", "title": "报告标题"}}`
|
||||
3. 确认渲染成功后,调用 `terminate` 返回结果
|
||||
**绝对禁止**:在 code_interpreter 中 print() HTML 内容后直接 terminate,用户无法看到网页。也不需要先用 code_interpreter 把 HTML 写入文件再用 html_interpreter(file_path=...) 读取,直接传 html 参数即可。
|
||||
|
||||
## 业务工具(可直接执行)
|
||||
{file_context}
|
||||
|
||||
@@ -18,6 +18,10 @@ EXAMPLE_FILES = {
|
||||
"path": "docker/examples/excel/Walmart_Sales.csv",
|
||||
"name": "Walmart_Sales.csv",
|
||||
},
|
||||
"csv_visual_report": {
|
||||
"path": "docker/examples/excel/Walmart_Sales.csv",
|
||||
"name": "Walmart_Sales.csv",
|
||||
},
|
||||
"fin_report": {
|
||||
"path": "docker/examples/fin_report/pdf/2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf",
|
||||
"name": "2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf",
|
||||
|
||||
@@ -42,7 +42,7 @@ class VolcengineDeployModelParameters(OpenAICompatibleDeployModelParameters):
|
||||
provider: str = "proxy/volcengine"
|
||||
|
||||
api_base: Optional[str] = field(
|
||||
default="${env:ARK_API_BASE:-https://ark.cn-beijing.volces.com/api/v3}",
|
||||
default="${env:ARK_API_BASE:-https://ark.cn-beijing.volces.com/api/coding/v3}",
|
||||
metadata={
|
||||
"help": _("The base url of the Volcengine API."),
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ description: 专门用于上市公司财报(如年度报告、季度报告)
|
||||
- 将提取的 JSON 数据传递给 `scripts/generate_charts.py` 脚本。
|
||||
- 自动生成 3 张可视化图表:
|
||||
- `financial_overview.png`:核心财务指标对比柱状图
|
||||
- `profitability_radar.png`:盈利能力指标横向条形图
|
||||
- `profitability.png`:盈利能力指标横向条形图
|
||||
- `asset_structure.png`:资产结构环形饼图
|
||||
- 脚本执行后,系统自动扫描生成的图片文件并返回 `/images/xxx.png` 格式的 URL,可直接用于 HTML 报告。
|
||||
|
||||
@@ -52,7 +52,7 @@ Step 2: execute_skill_script_file(skill_name="financial-report-analyzer", script
|
||||
→ 返回 30 个模板键值: {"COMPANY_NAME": "XX公司", "REVENUE": "105.00亿元", "GROSS_MARGIN": "28.57%", ...}
|
||||
|
||||
Step 3: execute_skill_script_file(skill_name="financial-report-analyzer", script_file_name="generate_charts.py", args=<Step1的JSON结果>)
|
||||
→ 生成图表,系统自动返回图片URL: /images/xxx_financial_overview.png, /images/xxx_profitability_radar.png, /images/xxx_asset_structure.png
|
||||
→ 生成图表,系统自动返回图片URL: /images/xxx_financial_overview.png, /images/xxx_profitability.png, /images/xxx_asset_structure.png
|
||||
|
||||
Step 4: html_interpreter(template_path="financial-report-analyzer/templates/report_template.html", data={
|
||||
"PROFITABILITY_ANALYSIS": "LLM撰写的盈利能力分析...",
|
||||
|
||||
@@ -36,26 +36,39 @@ CHART_DPI = 150
|
||||
# Font setup for Chinese characters
|
||||
# ---------------------------------------------------------------------------
|
||||
def setup_chinese_font():
|
||||
"""Try several common Chinese fonts; fall back to DejaVu Sans."""
|
||||
"""Configure matplotlib to render Chinese characters correctly.
|
||||
|
||||
Tries a priority-ordered list of CJK fonts commonly available on
|
||||
macOS, Linux, and Windows. Falls back to DejaVu Sans.
|
||||
"""
|
||||
candidates = [
|
||||
# macOS
|
||||
"Heiti TC",
|
||||
"Hiragino Sans GB",
|
||||
"PingFang SC",
|
||||
"PingFang HK",
|
||||
"STHeiti",
|
||||
"Songti SC",
|
||||
"Arial Unicode MS",
|
||||
# Linux
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans SC",
|
||||
"WenQuanYi Micro Hei",
|
||||
"WenQuanYi Zen Hei",
|
||||
"Droid Sans Fallback",
|
||||
# Windows
|
||||
"Microsoft YaHei",
|
||||
"SimHei",
|
||||
"STHeiti",
|
||||
"WenQuanYi Micro Hei",
|
||||
"Noto Sans CJK SC",
|
||||
"Arial Unicode MS",
|
||||
"SimSun",
|
||||
]
|
||||
|
||||
available = {f.name for f in fm.fontManager.ttflist}
|
||||
for font_name in candidates:
|
||||
try:
|
||||
font_path = fm.findfont(fm.FontProperties(family=font_name))
|
||||
if font_path and "LastResort" not in font_path:
|
||||
plt.rcParams["font.sans-serif"] = [font_name]
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
# Fallback
|
||||
if font_name in available:
|
||||
plt.rcParams["font.sans-serif"] = [font_name, "sans-serif"]
|
||||
plt.rcParams["font.family"] = "sans-serif"
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
return
|
||||
plt.rcParams["font.sans-serif"] = ["DejaVu Sans"]
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
@@ -219,13 +232,13 @@ def chart_financial_overview(data, output_dir):
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(output_dir, "financial_overview.png")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white", bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chart 2 – 盈利能力指标 (profitability_radar.png)
|
||||
# Chart 2 – 盈利能力指标 (profitability.png)
|
||||
# ---------------------------------------------------------------------------
|
||||
def chart_profitability(data, output_dir):
|
||||
"""Bar chart of profitability / efficiency ratios (%)."""
|
||||
@@ -298,8 +311,8 @@ def chart_profitability(data, output_dir):
|
||||
ax.set_xlim(0, max_val * 1.2 if max_val > 0 else 100)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(output_dir, "profitability_radar.png")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white")
|
||||
path = os.path.join(output_dir, "profitability.png")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white", bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return path
|
||||
|
||||
@@ -386,7 +399,7 @@ def chart_asset_structure(data, output_dir):
|
||||
ax.axis("equal")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(output_dir, "asset_structure.png")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white")
|
||||
fig.savefig(path, dpi=CHART_DPI, facecolor="white", bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return path
|
||||
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import matplotlib
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.font_manager as fm
|
||||
|
||||
|
||||
def setup_chinese_font():
|
||||
plt.rcParams["font.family"] = ["Noto Sans CJK SC", "sans-serif"]
|
||||
"""Configure matplotlib to render Chinese characters correctly.
|
||||
|
||||
Tries a priority-ordered list of CJK fonts commonly available on
|
||||
macOS, Linux, and Windows. Falls back to whatever the system has.
|
||||
"""
|
||||
candidates = [
|
||||
# macOS
|
||||
"Heiti TC",
|
||||
"Hiragino Sans GB",
|
||||
"PingFang SC",
|
||||
"PingFang HK",
|
||||
"STHeiti",
|
||||
"Songti SC",
|
||||
"Arial Unicode MS",
|
||||
# Linux
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans SC",
|
||||
"WenQuanYi Micro Hei",
|
||||
"WenQuanYi Zen Hei",
|
||||
"Droid Sans Fallback",
|
||||
# Windows
|
||||
"Microsoft YaHei",
|
||||
"SimHei",
|
||||
"SimSun",
|
||||
]
|
||||
|
||||
available = {f.name for f in fm.fontManager.ttflist}
|
||||
|
||||
for font_name in candidates:
|
||||
if font_name in available:
|
||||
plt.rcParams["font.sans-serif"] = [font_name, "sans-serif"]
|
||||
plt.rcParams["font.family"] = "sans-serif"
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
return
|
||||
|
||||
# Last resort: let matplotlib pick, but still fix minus sign
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
@@ -15,7 +15,7 @@ def generate_correlation_heatmap(data_path, output_dir):
|
||||
plt.tight_layout()
|
||||
|
||||
output_path = os.path.join(output_dir, 'correlation_heatmap.png')
|
||||
plt.savefig(output_path)
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -16,7 +16,7 @@ def generate_sales_unemployment_scatter(data_path, output_dir):
|
||||
plt.tight_layout()
|
||||
|
||||
output_path = os.path.join(output_dir, 'sales_vs_unemployment_scatter.png')
|
||||
plt.savefig(output_path)
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -17,7 +17,7 @@ def generate_store_avg_comparison(data_path, output_dir):
|
||||
plt.tight_layout()
|
||||
|
||||
output_path = os.path.join(output_dir, "store_avg_comparison.png")
|
||||
plt.savefig(output_path)
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -27,7 +27,7 @@ def generate_time_series_trend(data_path, output_dir, selected_stores=[1, 4, 20]
|
||||
plt.tight_layout()
|
||||
|
||||
output_path = os.path.join(output_dir, 'time_series_trend.png')
|
||||
plt.savefig(output_path)
|
||||
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -361,7 +361,30 @@ const StepCard: React.FC<{
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Compact pill for "思考中" thinking steps
|
||||
if (step.title === '思考中' && step.status === 'running') {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 rounded-full cursor-pointer transition-all duration-200',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
'transform',
|
||||
{
|
||||
'opacity-0 translate-y-1': !isVisible,
|
||||
'opacity-100 translate-y-0': isVisible,
|
||||
},
|
||||
)}
|
||||
style={{ transition: 'opacity 0.2s ease-out, transform 0.2s ease-out' }}
|
||||
>
|
||||
<span className='relative flex h-2.5 w-2.5'>
|
||||
<span className='animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75' />
|
||||
<span className='relative inline-flex rounded-full h-2.5 w-2.5 bg-gradient-to-r from-blue-400 to-cyan-400' />
|
||||
</span>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>思考中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -407,14 +430,12 @@ const StepCard: React.FC<{
|
||||
>
|
||||
{getTypeLabel(step.type)}
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col min-w-0 flex-1'>
|
||||
<span className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>{step.title}</span>
|
||||
{detailLine && (
|
||||
<span className='text-[11px] text-gray-500 dark:text-gray-400 truncate'>{detailLine}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0'>
|
||||
{step.status === 'pending' && <ClockCircleOutlined className='text-xs text-gray-400' />}
|
||||
{step.status === 'running' && <LoadingOutlined spin className='text-xs text-blue-500' />}
|
||||
|
||||
@@ -465,6 +465,159 @@ const parseSkillResourceDetail = (
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse execute_skill_script_file detail text to extract script info and output
|
||||
const parseSkillScriptDetail = (
|
||||
detail?: string,
|
||||
): { skillName: string; scriptFileName: string; args: Record<string, any>; outputText: string } | null => {
|
||||
if (!detail) return null;
|
||||
try {
|
||||
const inputMatch = detail.match(/Action Input:\s*({[\s\S]*?})(?:\n|$)/);
|
||||
if (!inputMatch) return null;
|
||||
const input = JSON.parse(inputMatch[1]);
|
||||
const skillName = input.skill_name || '';
|
||||
const scriptFileName = input.script_file_name || '';
|
||||
const args = input.args || {};
|
||||
if (!skillName && !scriptFileName) return null;
|
||||
const afterInput = detail.slice(detail.indexOf(inputMatch[0]) + inputMatch[0].length);
|
||||
const outputText = afterInput.trim();
|
||||
return { skillName, scriptFileName, args, outputText };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Extract /images/ URLs from text */
|
||||
const extractImageUrls = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
const matches = text.match(/\/images\/[^\s"')]+/g);
|
||||
return matches ? [...new Set(matches)] : [];
|
||||
};
|
||||
|
||||
/** Split-pane renderer for execute_skill_script_file steps */
|
||||
const SkillScriptRenderer: React.FC<{
|
||||
parsed: { skillName: string; scriptFileName: string; args: Record<string, any>; outputText: string };
|
||||
outputs: ExecutionOutput[];
|
||||
}> = memo(({ parsed, outputs }) => {
|
||||
// Separate code outputs (script source) from other outputs — concatenate all
|
||||
// code chunks because the backend may split large code across multiple events.
|
||||
const codeOutputs = outputs.filter(o => o.output_type === 'code');
|
||||
const scriptSource = codeOutputs.length > 0 ? codeOutputs.map(o => String(o.content)).join('') : null;
|
||||
const imageOutputs = outputs.filter(o => o.output_type === 'image');
|
||||
const textOutputs = outputs.filter(o => o.output_type === 'text');
|
||||
// Also extract image URLs from outputText that may not be in outputs
|
||||
const inlineImageUrls = extractImageUrls(parsed.outputText);
|
||||
// Deduplicate: filter out URLs already in imageOutputs
|
||||
const existingUrls = new Set(imageOutputs.map(o => typeof o.content === 'string' ? o.content : o.content?.url || ''));
|
||||
const extraImageUrls = inlineImageUrls.filter(u => !existingUrls.has(u));
|
||||
const cleanTextOutputs = textOutputs.map(o => {
|
||||
const text = String(o.content);
|
||||
const cleaned = text.split('\n').filter(line => !line.match(/^\s*[-\u2013]\s*\/images\//)).join('\n').trim();
|
||||
return { ...o, content: cleaned };
|
||||
}).filter(o => o.content);
|
||||
const cleanOutputText = parsed.outputText
|
||||
.split('\n')
|
||||
.filter(line => !line.match(/^\s*[-\u2013]\s*\/images\//) && !line.match(/\u5df2\u751f\u6210\u7684\u56fe\u7247URL/))
|
||||
.join('\n')
|
||||
.trim();
|
||||
const htmlReportMatch = parsed.outputText.match(/HTML[_ ]report[_ ]generated[_ ]at:\s*(.+)/i);
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 min-h-0 overflow-hidden'>
|
||||
{/* Left Pane - Script Source Code */}
|
||||
<div className='w-[45%] flex-shrink-0 border-r border-gray-200 dark:border-gray-700 overflow-y-auto flex flex-col bg-[#0f172a]'>
|
||||
{/* Header */}
|
||||
<div className='px-4 py-3 border-b border-gray-700/50 bg-[#1e293b] flex-shrink-0'>
|
||||
<div className='flex items-center gap-2 mb-1.5'>
|
||||
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-indigo-900/40 text-indigo-300 border border-indigo-700/50'>
|
||||
{parsed.skillName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CodeOutlined className='text-blue-400 text-xs' />
|
||||
<span className='text-sm font-medium text-gray-200 break-all font-mono'>
|
||||
{parsed.scriptFileName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script Source Code */}
|
||||
<div className='flex-1 min-h-0 overflow-auto'>
|
||||
{scriptSource ? (
|
||||
<CodePreview
|
||||
code={scriptSource}
|
||||
language='python'
|
||||
customStyle={{ background: '#0f172a', margin: 0, borderRadius: 0, padding: '12px 16px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-gray-500'>
|
||||
<CodeOutlined className='text-2xl mb-2' />
|
||||
<span className='text-xs'>\u52A0\u8F7D\u811A\u672C\u4E2D...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right Pane - Results */}
|
||||
<div className='flex-1 min-w-0 overflow-y-auto'>
|
||||
{/* HTML report badge */}
|
||||
{htmlReportMatch && (
|
||||
<div className='flex items-center gap-2 px-3 py-2 mx-3 mt-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800'>
|
||||
<FileTextOutlined className='text-emerald-500' />
|
||||
<span className='text-xs font-medium text-emerald-700 dark:text-emerald-400 break-all'>
|
||||
{htmlReportMatch[1].trim()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Text results */}
|
||||
{cleanTextOutputs.length > 0 && cleanTextOutputs.map((o, idx) => (
|
||||
<div key={`text-${idx}`} className='rounded-lg bg-gray-900 mx-3 mt-2 px-4 py-3 text-sm text-green-400 font-mono whitespace-pre-wrap leading-relaxed overflow-x-auto'>
|
||||
{String(o.content)}
|
||||
</div>
|
||||
))}
|
||||
{/* Fallback: if no text outputs but cleanOutputText has content */}
|
||||
{cleanTextOutputs.length === 0 && cleanOutputText && !htmlReportMatch && (
|
||||
<div className='rounded-lg bg-gray-900 mx-3 mt-2 px-4 py-3 text-sm text-green-400 font-mono whitespace-pre-wrap leading-relaxed overflow-x-auto'>
|
||||
{cleanOutputText}
|
||||
</div>
|
||||
)}
|
||||
{/* Images from outputs */}
|
||||
{imageOutputs.map((img, idx) => (
|
||||
<div key={`img-${idx}`} className='overflow-hidden bg-gray-50 dark:bg-gray-900'>
|
||||
<img
|
||||
src={resolveImageUrl(
|
||||
typeof img.content === 'string' ? img.content : img.content?.url || img.content?.src || String(img.content),
|
||||
)}
|
||||
alt={`Result ${idx + 1}`}
|
||||
className='w-full h-auto block'
|
||||
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Extra images extracted from outputText */}
|
||||
{extraImageUrls.map((url, idx) => (
|
||||
<div key={`extra-img-${idx}`} className='overflow-hidden bg-gray-50 dark:bg-gray-900'>
|
||||
<img
|
||||
src={resolveImageUrl(url)}
|
||||
alt={`Generated ${idx + 1}`}
|
||||
className='w-full h-auto block'
|
||||
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Empty state */}
|
||||
{imageOutputs.length === 0 && extraImageUrls.length === 0 && cleanTextOutputs.length === 0 && !cleanOutputText && !htmlReportMatch && (
|
||||
<div className='flex flex-col items-center justify-center py-8 text-gray-400'>
|
||||
<FileSearchOutlined className='text-2xl mb-2' />
|
||||
<span className='text-xs'>\u7B49\u5F85\u6267\u884C\u7ED3\u679C...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SkillScriptRenderer.displayName = 'SkillScriptRenderer';
|
||||
|
||||
const HtmlTabbedRenderer: React.FC<{ code?: ExecutionOutput; html: ExecutionOutput }> = memo(({ code, html }) => {
|
||||
const [activeTab, setActiveTab] = useState<'preview' | 'source'>('preview');
|
||||
const htmlContent = html.content;
|
||||
@@ -1028,24 +1181,28 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
|
||||
|
||||
{/* Expanded detail */}
|
||||
{!inputCollapsed && activeStep.detail && (
|
||||
<div className='px-4 pb-3'>
|
||||
{activeStep.detail.includes('Action: get_skill_resource') && (() => {
|
||||
<div className={activeStep.detail.includes('Action: execute_skill_script_file') ? 'flex-1 min-h-0 flex flex-col' : 'px-4 pb-3'}>
|
||||
{activeStep.detail.includes('Action: execute_skill_script_file') && (() => {
|
||||
const parsed = parseSkillScriptDetail(activeStep.detail);
|
||||
if (parsed) {
|
||||
return <SkillScriptRenderer parsed={parsed} outputs={visibleOutputs} />;
|
||||
}
|
||||
return null;
|
||||
})() || activeStep.detail.includes('Action: get_skill_resource') && (() => {
|
||||
const parsed = parseSkillResourceDetail(activeStep.detail);
|
||||
if (parsed) {
|
||||
const resourceName = parsed.resourcePath.split('/').pop() || parsed.resourcePath;
|
||||
return (
|
||||
<div className='rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden bg-gray-50 dark:bg-[#161719]'>
|
||||
{/* Header: skill name + resource name */}
|
||||
<div className='flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-[#1a1b1e]'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800'>
|
||||
{parsed.skillName}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-gray-300 dark:text-gray-600'>·</span>
|
||||
<span className='text-gray-300 dark:text-gray-600'>{"\u00b7"}</span>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 truncate'>{resourceName}</span>
|
||||
</div>
|
||||
{/* Markdown content */}
|
||||
{parsed.content && (
|
||||
<div className='px-4 py-3 text-sm text-gray-600 dark:text-gray-400 leading-relaxed overflow-auto max-h-[600px] prose prose-sm dark:prose-invert max-w-none'>
|
||||
<MarkDownContext>{parsed.content}</MarkDownContext>
|
||||
@@ -1066,7 +1223,7 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* Divider + Outputs (hide for get_skill_resource since content is already shown above) */}
|
||||
{visibleOutputs.length > 0 && !activeStep?.detail?.includes('Action: get_skill_resource') && (
|
||||
{visibleOutputs.length > 0 && !activeStep?.detail?.includes('Action: get_skill_resource') && !activeStep?.detail?.includes('Action: execute_skill_script_file') && (
|
||||
<>
|
||||
<div className='border-t border-gray-100 dark:border-gray-800 shrink-0' />
|
||||
<div className='flex-1 min-h-0 p-4 flex flex-col space-y-3 overflow-y-auto'>
|
||||
|
||||
@@ -445,6 +445,21 @@ const EXAMPLE_CARDS = [
|
||||
color: 'from-blue-500/10 to-cyan-500/10',
|
||||
borderColor: 'border-blue-200/60 dark:border-blue-800/40',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
|
||||
skillName: 'walmart-sales-analyzer',
|
||||
},
|
||||
{
|
||||
id: 'csv_visual_report',
|
||||
icon: '📋',
|
||||
title: '自主分析表格',
|
||||
description: '自主分析CSV表格数据,生成可视化网页报告',
|
||||
query:
|
||||
'请自主分析这份表格,理解数据结构、字段含义和基本信息,然后进行深入分析并生成一份精美的可视化网页报告。',
|
||||
fileName: 'Walmart_Sales.csv',
|
||||
fileType: 'text/csv',
|
||||
fileSize: 363735, // ~355.21 KB
|
||||
color: 'from-emerald-500/10 to-teal-500/10',
|
||||
borderColor: 'border-emerald-200/60 dark:border-emerald-800/40',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
},
|
||||
{
|
||||
id: 'fin_report',
|
||||
|
||||
Reference in New Issue
Block a user