feat:dbgpt skill template

This commit is contained in:
aries_ckt
2026-02-25 14:46:44 +08:00
parent 97fde0aa7d
commit cc5a4bccef
13 changed files with 359 additions and 69 deletions

View File

@@ -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}

View File

@@ -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",

View File

@@ -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."),
},

View File

@@ -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撰写的盈利能力分析...",

View File

@@ -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

View File

@@ -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

View File

@@ -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__':

View File

@@ -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__':

View File

@@ -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__":

View File

@@ -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__":

View File

@@ -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' />}

View File

@@ -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'>

View File

@@ -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',