refactor: integrate 27 MCP tools into manual analysis workflow builder (remove separate tool_lab)

This commit is contained in:
PM-pinou
2026-07-19 23:12:16 +08:00
parent 06db287c0e
commit 4fb1e4fdda
5 changed files with 264 additions and 356 deletions
-181
View File
@@ -1,181 +0,0 @@
{% extends 'base.html' %}
{% block title %}MCP 工具实验室 - 天璇{% endblock %}
{% block content %}
<style>
.tool-group { margin-bottom: 2rem; }
.tool-card {
border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; margin: 0.5rem 0;
cursor: pointer; transition: box-shadow 0.2s;
}
.tool-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.tool-card.selected { border-color: #4361ee; box-shadow: 0 0 0 2px #4361ee33; }
.tool-name { font-weight: 700; font-size: 1.05rem; color: #4361ee; }
.tool-desc { color: #666; font-size: 0.9rem; margin: 0.3rem 0; }
.tool-badge {
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px;
font-size: 0.75rem; font-weight: 600; margin-right: 0.5rem;
}
.badge-core { background: #4361ee; color: #fff; }
.badge-diag { background: #f72585; color: #fff; }
.badge-analysis { background: #43aa8b; color: #fff; }
.param-row { margin: 0.5rem 0; display: flex; align-items: center; gap: 0.5rem; }
.param-row label { min-width: 140px; font-size: 0.9rem; color: #555; }
.param-row input, .param-row select { flex: 1; padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; }
#resultArea {
background: #1a1a2e; color: #e0e0e0; padding: 1rem; border-radius: 6px;
margin-top: 1rem; max-height: 500px; overflow: auto; font-size: 0.8rem;
display: none; white-space: pre-wrap;
}
.executing { opacity: 0.6; pointer-events: none; }
</style>
<div class="card">
<h2>🔧 MCP 工具实验室</h2>
<p style="color:#666;">手动选择并执行分析工具。工具按类别分组,只读工具可安全任意调用。</p>
</div>
<div style="display:flex;gap:1rem;flex-wrap:wrap;">
<!-- Tool list panel -->
<div style="flex:1;min-width:350px;">
<div class="tool-group">
<h3>⚡ 核心工具</h3>
{% for t in core_tools %}
<div class="tool-card" data-tool="{{ t.name }}" onclick="selectTool('{{ t.name }}', this)">
<div><span class="tool-badge badge-core">CORE</span><span class="tool-name">{{ t.name }}</span></div>
<div class="tool-desc">{{ t.description }}</div>
</div>
{% endfor %}
</div>
<div class="tool-group">
<h3>🔍 分析工具(只读)</h3>
{% for t in analysis_tools %}
<div class="tool-card" data-tool="{{ t.name }}" onclick="selectTool('{{ t.name }}', this)">
<div><span class="tool-badge badge-analysis">ANALYSIS</span><span class="tool-name">{{ t.name }}</span></div>
<div class="tool-desc">{{ t.description }}</div>
</div>
{% endfor %}
</div>
<div class="tool-group">
<h3>🩺 诊断工具</h3>
{% for t in diag_tools %}
<div class="tool-card" data-tool="{{ t.name }}" onclick="selectTool('{{ t.name }}', this)">
<div><span class="tool-badge badge-diag">DIAG</span><span class="tool-name">{{ t.name }}</span></div>
<div class="tool-desc">{{ t.description }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Execution panel -->
<div style="flex:1;min-width:350px;">
<div class="card" id="execPanel">
<h3 id="selectedToolName">选择工具查看参数</h3>
<div id="toolParams" style="margin:1rem 0;"></div>
<button class="btn btn-primary" id="execBtn" onclick="executeTool()" disabled style="display:none;">执行</button>
<div id="executing" style="display:none;color:#666;margin-top:0.5rem;">执行中...</div>
</div>
<div id="resultArea"></div>
</div>
</div>
<script>
const toolsMeta = {{ tools_meta|safe }};
let currentTool = null;
function selectTool(name, el) {
document.querySelectorAll('.tool-card').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected');
currentTool = name;
document.getElementById('selectedToolName').textContent = '📌 ' + name;
document.getElementById('execBtn').style.display = 'inline-block';
document.getElementById('execBtn').disabled = false;
document.getElementById('resultArea').style.display = 'none';
// Build parameter form from tool schema
const tool = toolsMeta.find(t => t.name === name);
if (!tool || !tool.inputSchema || !tool.inputSchema.properties) {
document.getElementById('toolParams').innerHTML = '<p style="color:#888;">无需参数</p>';
return;
}
const props = tool.inputSchema.properties;
const required = tool.inputSchema.required || [];
let html = '';
for (const [key, prop] of Object.entries(props)) {
const isReq = required.includes(key);
const label = prop.description || key;
let input;
if (prop.type === 'boolean') {
input = `<select id="param_${key}"><option value="true">true</option><option value="false">false</option></select>`;
} else if (prop.type === 'array') {
input = `<input type="text" id="param_${key}" placeholder='["val1","val2"]' value='${prop.default || ""}'>`;
} else if (prop.type === 'number' || prop.type === 'integer') {
input = `<input type="number" id="param_${key}" value="${prop.default || ""}" step="${prop.type === 'integer' ? 1 : 0.01}">`;
} else {
{% if datasets %}
if (key === 'dataset_id' || key === 'dataset_id_a' || key === 'dataset_id_b') {
let opts = '<option value="">-- select --</option>';
{% for ds in datasets %}
opts += `<option value="${'{{ ds.id }}'}">${'{{ ds.id }}'}</option>`;
{% endfor %}
input = `<select id="param_${key}">${opts}</select>`;
} else {
input = `<input type="text" id="param_${key}" placeholder="${label}" value='${prop.default || ""}'>`;
}
{% else %}
input = `<input type="text" id="param_${key}" placeholder="${label}" value='${prop.default || ""}'>`;
{% endif %}
}
html += `<div class="param-row"><label>${key}${isReq ? ' *' : ''}</label>${input}</div>`;
}
document.getElementById('toolParams').innerHTML = html;
}
async function executeTool() {
if (!currentTool) return;
const tool = toolsMeta.find(t => t.name === currentTool);
if (!tool || !tool.inputSchema || !tool.inputSchema.properties) {
// No-param tool
await doExecute(currentTool, {});
return;
}
const params = {};
for (const key of Object.keys(tool.inputSchema.properties)) {
const el = document.getElementById('param_' + key);
if (!el) continue;
let val = el.value;
const prop = tool.inputSchema.properties[key];
if (prop.type === 'boolean') val = val === 'true';
else if (prop.type === 'number') val = parseFloat(val);
else if (prop.type === 'integer') val = parseInt(val);
else if (prop.type === 'array') { try { val = JSON.parse(val); } catch(e) { val = []; } }
if (val !== '' && val !== null && val !== undefined) params[key] = val;
}
await doExecute(currentTool, params);
}
async function doExecute(name, params) {
const btn = document.getElementById('execBtn');
const status = document.getElementById('executing');
const resultArea = document.getElementById('resultArea');
btn.disabled = true; status.style.display = 'block';
resultArea.style.display = 'none';
try {
const resp = await fetch('/tools/run/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
body: JSON.stringify({ tool: name, params: params }),
});
const data = await resp.json();
resultArea.textContent = JSON.stringify(data, null, 2);
resultArea.style.display = 'block';
} catch (err) {
resultArea.textContent = 'Error: ' + err.message;
resultArea.style.display = 'block';
}
btn.disabled = false; status.style.display = 'none';
}
</script>
{% endblock %}
+1 -1
View File
@@ -47,7 +47,7 @@
<a href="{% url 'analysis:auto' %}">LLM分析</a>
<a href="{% url 'analysis:run_list' %}">运行记录</a>
<a href="{% url 'analysis:globe' %}">地球</a>
<a href="{% url 'analysis:tool_lab' %}">工具</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
+227 -128
View File
@@ -1,148 +1,247 @@
{% extends 'base.html' %}
{% load query_helpers %}
{% block title %}手动分析 - 天璇{% endblock %}
{% block content %}
<style>
.step-card {
border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; margin: 0.75rem 0;
background: #fafafa;
}
.step-card.executing { opacity: 0.6; pointer-events: none; }
.step-result {
background: #1a1a2e; color: #e0e0e0; padding: 0.75rem; border-radius: 6px;
margin-top: 0.5rem; font-size: 0.8rem; max-height: 300px; overflow: auto; white-space: pre-wrap;
display: none;
}
.step-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.step-num {
background: #4361ee; color: #fff; border-radius: 50%; width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 700;
}
.step-name { font-weight: 600; font-size: 1rem; }
.param-row { margin: 0.4rem 0; display: flex; align-items: center; gap: 0.5rem; }
.param-row label { min-width: 120px; font-size: 0.85rem; color: #555; }
.param-row input, .param-row select { flex: 1; padding: 0.35rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.85rem; }
.remove-step { color: #dc3545; cursor: pointer; font-size: 1.2rem; margin-left: auto; }
.badge-tool {
display: inline-block; padding: 0.1rem 0.4rem; border-radius: 3px;
font-size: 0.7rem; font-weight: 600; margin-right: 0.3rem;
}
.badge-core { background: #4361ee; color: #fff; }
.badge-analysis { background: #43aa8b; color: #fff; }
.badge-diag { background: #f72585; color: #fff; }
</style>
<div class="card">
<h2>手动分析 - 第 {{ step }} 步 / 共 3 步</h2>
<h2>🔧 手动分析工作台</h2>
<p style="color:#666;">构建你的分析流程:选择数据集 → 添加分析步骤 → 逐条执行</p>
</div>
{% if step == 1 %}
<!-- Step 0: Dataset selector -->
<div class="card">
<h3>Step 1: 选择数据集</h3>
<p>选择已上传且预处理完成的数据集:</p>
{% if runs %}
<form method="get" action="/analyze/manual/" style="margin-top:1rem;">
<input type="hidden" name="step" value="2">
<table>
<thead><tr><th></th><th>ID</th><th>文件</th><th>行数</th><th>实体列</th><th>状态</th></tr></thead>
<tbody>
{% for run in runs %}
<tr>
<td><input type="radio" name="run_id" value="{{ run.id }}" required></td>
<td>#{{ run.id }}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;">{{ run.csv_glob }}</td>
<td>{{ run.total_flows|default:"-" }}</td>
<td>{{ run.entity_column|default:"-" }}</td>
<td><span class="badge badge-success">{{ run.status }}</span></td>
</tr>
{% empty %}
<tr><td colspan="6" style="text-align:center;">暂无已预处理的数据集。先去 <a href="/upload/">上传页面</a> 上传 CSV。</td></tr>
{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary" style="margin-top:1rem;">下一步 →</button>
</form>
<h3>📁 选择数据集</h3>
{% if datasets %}
<select id="datasetSelect" style="width:100%;padding:0.5rem;border:1px solid #ccc;border-radius:4px;">
<option value="">-- 选择数据集 --</option>
{% for ds in datasets %}
<option value="{{ ds.id }}">{{ ds.id }} ({{ ds.metadata.row_count|default:"?" }} rows, {{ ds.metadata.file_count|default:"?" }} files)</option>
{% endfor %}
</select>
{% else %}
<p style="color:#999;">暂无数据集。先去 <a href="/upload/">上传页面</a> 上传 CSV。</p>
{% endif %}
</div>
{% endif %}
{% if step == 2 %}
<!-- Workflow builder -->
<div class="card">
<h3>Step 2: 配置聚类参数</h3>
<form method="get" action="/analyze/manual/">
<input type="hidden" name="step" value="3">
<input type="hidden" name="run_id" value="{{ request.GET.run_id }}">
<div style="margin-bottom:1rem;">
<label>实体列(entity_columns):</label>
<select name="entity_columns" multiple style="width:100%;min-height:100px;">
{% if entity_candidates %}
{% for c in entity_candidates %}
<option value="{{ c.column }}" {% if c.column in selected_entity_cols %}selected{% endif %}>{{ c.column }} (score: {{ c.score }})</option>
{% endfor %}
{% elif schema_keys %}
{% for col in schema_keys %}
<option value="{{ col }}" {% if col in selected_entity_cols %}selected{% endif %}>{{ col }}</option>
{% endfor %}
{% else %}
<option value="">暂无可选列</option>
{% endif %}
</select>
<small style="color:#666;">按住 Ctrl 多选,留空则使用自动检测结果</small>
</div>
<div style="margin-bottom:1rem;">
<label>特征列(feature_columns):</label>
<select name="feature_columns" multiple style="width:100%;min-height:100px;">
{% if feature_options %}
{% for opt in feature_options %}
<option value="{{ opt.name }}">{{ opt.name }} ({{ opt.dtype }})</option>
{% endfor %}
{% else %}
<option value="">暂无可选数值列</option>
{% endif %}
</select>
<small style="color:#666;">按住 Ctrl 多选,留空则自动选择前10个数值列。仅显示数值类型列。</small>
</div>
<div style="margin-bottom:1rem;">
<label>聚类算法:</label>
<select name="algorithm">
<option value="hdbscan" {% if default_algo == 'hdbscan' %}selected{% endif %}>HDBSCAN(自动确定簇数)</option>
<option value="kmeans" {% if default_algo == 'kmeans' %}selected{% endif %}>KMeans(需预设簇数)</option>
</select>
</div>
<div style="margin-bottom:1rem;">
<label>最小簇大小(min_cluster_size):</label>
<input type="number" name="min_cluster_size" value="{{ default_min_size }}" min="2" max="100">
</div>
<a href="/analyze/manual/?step=1" class="btn" style="background:#ddd;margin-right:0.5rem;">← 上一步</a>
<button type="submit" class="btn btn-primary">下一步 →</button>
</form>
<h3>📋 分析流程 <span style="font-weight:400;font-size:0.85rem;color:#888;">依次添加分析步骤</span></h3>
<div id="workflowSteps"></div>
<button class="btn" onclick="showToolPicker()" style="margin-top:0.5rem;background:#e8f0fe;color:#4361ee;">+ 添加步骤</button>
</div>
{% endif %}
{% if step == 3 %}
<div class="card">
<h3>Step 3: 确认并运行</h3>
<p>请确认以下配置,确认无误后点击"开始分析":</p>
<table>
<tr><td>数据集 ID</td><td>#{{ request.GET.run_id }}</td></tr>
<tr><td>实体列</td><td>{{ request.GET|getlist:"entity_columns"|default:"自动检测" }}</td></tr>
<tr><td>特征列</td><td>{{ request.GET.feature_columns|default:"自动选择" }}</td></tr>
<tr><td>聚类算法</td><td>{{ request.GET.algorithm }}</td></tr>
<tr><td>最小簇大小</td><td>{{ request.GET.min_cluster_size }}</td></tr>
</table>
<div style="margin-top:1rem;">
<a href="/analyze/manual/?step=2&run_id={{ request.GET.run_id }}" class="btn" style="background:#ddd;margin-right:0.5rem;">← 上一步</a>
<button class="btn btn-primary" onclick="runAnalysis()">开始分析</button>
</div>
<div id="runProgress" style="display:none;margin-top:1rem;">
<p>分析进行中... <span id="runStatus"></span></p>
<div style="background:#eee;height:8px;border-radius:4px;overflow:hidden;">
<div id="runBar" style="background:#4361ee;height:100%;width:0%;"></div>
<!-- Tool picker modal -->
<div id="toolPicker" style="display:none;margin-top:1rem;">
<div class="card">
<h4>选择工具</h4>
<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap;">
<button class="btn" onclick="filterTools('core')" style="background:#4361ee;color:#fff;font-size:0.8rem;">核心</button>
<button class="btn" onclick="filterTools('analysis')" style="background:#43aa8b;color:#fff;font-size:0.8rem;">分析</button>
<button class="btn" onclick="filterTools('diag')" style="background:#f72585;color:#fff;font-size:0.8rem;">诊断</button>
<button class="btn" onclick="filterTools('all')" style="font-size:0.8rem;">全部</button>
</div>
<div id="toolList" style="max-height:300px;overflow-y:auto;"></div>
</div>
</div>
<script>
async function runAnalysis() {
document.getElementById('runProgress').style.display = 'block';
const params = new URLSearchParams(window.location.search);
const entityCols = params.getAll('entity_columns');
const featureCols = params.getAll('feature_columns');
const resp = await fetch('/analyze/run/', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}' },
body: JSON.stringify({
run_id: parseInt('{{ request.GET.run_id }}'),
entity_column: '',
entity_columns: entityCols.length ? entityCols : undefined,
feature_columns: featureCols.length ? featureCols : undefined,
algorithm: '{{ request.GET.algorithm }}',
min_cluster_size: parseInt('{{ request.GET.min_cluster_size }}'),
}),
// ── All 27 tools metadata (embedded from server) ──
const TOOLS = {{ tools_meta|safe }};
const CATEGORIES = {
{% for t in core_tools %}'{{ t.name }}': 'core', {% endfor %}
{% for t in analysis_tools %}'{{ t.name }}': 'analysis', {% endfor %}
{% for t in diag_tools %}'{{ t.name }}': 'diag', {% endfor %}
};
let stepCounter = 0;
const workflow = [];
// ── Tool picker ──
function showToolPicker() {
document.getElementById('toolPicker').style.display = 'block';
filterTools('all');
}
function filterTools(cat) {
const list = document.getElementById('toolList');
list.innerHTML = '';
TOOLS.forEach(t => {
const c = CATEGORIES[t.name] || 'other';
if (cat !== 'all' && c !== cat) return;
const badge = c === 'core' ? 'CORE' : c === 'analysis' ? 'ANALYSIS' : 'DIAG';
const color = c === 'core' ? '#4361ee' : c === 'analysis' ? '#43aa8b' : '#f72585';
const div = document.createElement('div');
div.style.cssText = 'padding:0.5rem;cursor:pointer;border-bottom:1px solid #eee;display:flex;align-items:center;gap:0.5rem;';
div.innerHTML = `<span style="background:${color};color:#fff;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.7rem;font-weight:600;">${badge}</span>
<span style="font-weight:600;">${t.name}</span>
<span style="color:#888;font-size:0.8rem;flex:1;">${t.description.substring(0, 80)}...</span>`;
div.onclick = () => addStep(t);
list.appendChild(div);
});
}
// ── Add a step to the workflow ──
function addStep(tool) {
document.getElementById('toolPicker').style.display = 'none';
const idx = stepCounter++;
const step = { idx, tool, params: {} };
workflow.push(step);
renderSteps();
}
function removeStep(idx) {
const pos = workflow.findIndex(s => s.idx === idx);
if (pos >= 0) workflow.splice(pos, 1);
renderSteps();
}
// ── Build parameter form for a tool ──
function buildParamForm(tool, container, stepIdx) {
const props = tool.inputSchema && tool.inputSchema.properties;
if (!props) { container.innerHTML = '<p style="color:#888;font-size:0.85rem;">无需参数</p>'; return; }
const required = tool.inputSchema.required || [];
let html = '';
for (const [key, prop] of Object.entries(props)) {
const isReq = required.includes(key);
const label = prop.description || key;
let input;
if (prop.type === 'boolean') {
input = `<select class="param-${stepIdx}" data-key="${key}"><option value="true">true</option><option value="false" selected>false</option></select>`;
} else if (prop.type === 'array') {
input = `<input class="param-${stepIdx}" data-key="${key}" placeholder='["val1","val2"]' style="width:100%;">`;
} else if (prop.type === 'number' || prop.type === 'integer') {
input = `<input class="param-${stepIdx}" data-key="${key}" type="number" value="${prop.default || ''}" step="${prop.type==='integer'?1:0.01}" style="width:100%;">`;
} else {
// Check if this is a dataset_id field — offer dataset list
if (key.includes('dataset_id')) {
let opts = '<option value="">-- select --</option>';
document.querySelectorAll('#datasetSelect option').forEach(o => {
if (o.value) opts += `<option value="${o.value}">${o.value}</option>`;
});
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">${opts}</select>`;
} else if (key === 'algorithm') {
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">
<option value="hdbscan">HDBSCAN</option><option value="kmeans">KMeans</option></select>`;
} else if (key === 'method') {
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">
<option value="zscore">zscore</option><option value="anova">anova</option></select>`;
} else {
input = `<input class="param-${stepIdx}" data-key="${key}" placeholder="${label}" style="width:100%;">`;
}
}
html += `<div class="param-row"><label>${key}${isReq ? ' <span style="color:#dc3545;">*</span>' : ''}</label>${input}</div>`;
}
container.innerHTML = html;
}
// ── Collect params from form ──
function collectParams(stepIdx) {
const params = {};
document.querySelectorAll(`.param-${stepIdx}`).forEach(el => {
let val = el.value;
const key = el.dataset.key;
if (!key || val === '' || val === null) return;
// Determine type from input type
if (el.type === 'number') val = parseFloat(val);
else if (el.tagName === 'SELECT' && el.dataset.key === 'algorithm') { /* keep string */ }
else if (val.startsWith('[') || val.startsWith('{')) { try { val = JSON.parse(val); } catch(e) {} }
params[key] = val;
});
return params;
}
// ── Execute a single step ──
async function executeStep(idx) {
const step = workflow.find(s => s.idx === idx);
if (!step) return;
const card = document.getElementById(`step-${idx}`);
const resultDiv = document.getElementById(`result-${idx}`);
const btn = document.getElementById(`exec-${idx}`);
btn.disabled = true; card.classList.add('executing');
resultDiv.style.display = 'none';
const params = collectParams(idx);
try {
const resp = await fetch('/tools/run/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
body: JSON.stringify({ tool: step.tool.name, params }),
});
const data = await resp.json();
resultDiv.textContent = JSON.stringify(data, null, 2);
resultDiv.style.display = 'block';
} catch (err) {
resultDiv.textContent = 'Error: ' + err.message;
resultDiv.style.display = 'block';
}
btn.disabled = false; card.classList.remove('executing');
// Update dataset_id suggestions from results
if (data && data.result && data.result.dataset_id) {
const sel = document.getElementById('datasetSelect');
const exists = Array.from(sel.options).some(o => o.value === data.result.dataset_id);
if (!exists) {
const opt = document.createElement('option');
opt.value = data.result.dataset_id;
opt.text = data.result.dataset_id + ' (from step ' + (stepCounter) + ')';
sel.add(opt);
}
}
}
// ── Render all workflow steps ──
function renderSteps() {
const container = document.getElementById('workflowSteps');
container.innerHTML = '';
workflow.forEach((step, i) => {
const c = CATEGORIES[step.tool.name] || 'other';
const color = c === 'core' ? '#4361ee' : c === 'analysis' ? '#43aa8b' : '#f72585';
const badge = c === 'core' ? 'CORE' : c === 'analysis' ? 'ANALYSIS' : 'DIAG';
const div = document.createElement('div');
div.className = 'step-card';
div.id = `step-${step.idx}`;
div.innerHTML = `
<div class="step-header">
<span class="step-num">${i+1}</span>
<span class="step-name">${step.tool.name}</span>
<span class="badge-tool" style="background:${color};">${badge}</span>
<span style="font-size:0.8rem;color:#888;flex:1;">${step.tool.description.substring(0, 60)}</span>
<span class="remove-step" onclick="removeStep(${step.idx})" title="移除此步骤">✕</span>
</div>
<div id="params-${step.idx}"></div>
<button class="btn btn-primary" id="exec-${step.idx}" onclick="executeStep(${step.idx})" style="font-size:0.85rem;padding:0.35rem 0.75rem;margin-top:0.3rem;">▶ 执行</button>
<div class="step-result" id="result-${step.idx}"></div>
`;
container.appendChild(div);
buildParamForm(step.tool, document.getElementById(`params-${step.idx}`), step.idx);
});
const data = await resp.json();
const runId = '{{ request.GET.run_id }}';
const poll = setInterval(async () => {
const r = await fetch(`/runs/${runId}/status/`);
const s = await r.json();
const pct = s.progress_pct || 0;
const msg = s.progress_msg || s.status;
document.getElementById('runStatus').textContent = msg + ' (' + pct + '%)';
document.getElementById('runBar').style.width = pct + '%';
if (s.status === 'completed') { clearInterval(poll); window.location.href = `/runs/${runId}/`; }
if (s.status === 'failed') { clearInterval(poll); document.getElementById('runStatus').textContent = '失败: ' + (s.error_message || ''); }
}, 1000);
}
</script>
{% endif %}
{% endblock %}
{% endblock %}