let requirements = [];
let currentSelectedIndex = null;
let activeRuns = {};
let statusInterval = null;
let currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
let currentPromptName = null;
const modalPrompts = document.getElementById('prompts-modal');
const elPromptList = document.getElementById('prompt-list');
const elPromptTextarea = document.getElementById('prompt-textarea');
const elSchemaTextarea = document.getElementById('schema-textarea');
const elPromptStatus = document.getElementById('prompt-save-status');
// DOM Elements
const elReqSelector = document.getElementById('req-selector');
const elStatsContainer = document.getElementById('stats-container');
const elMainContent = document.getElementById('main-content');
const elEmptyState = document.getElementById('empty-state');
const elDetailView = document.getElementById('detail-view');
const elStatusBanner = document.getElementById('status-banner');
const elStatusText = document.getElementById('status-text');
// Form logic
const selectForcePhase = document.getElementById('select-force-phase');
const groupPlatforms = document.getElementById('group-platforms');
function updateRunModalVisibility() {
const val = document.getElementById('select-force-phase').value;
const groupPlatforms = document.getElementById('group-platforms');
const groupModel = document.getElementById('group-model');
let showPlatforms = false;
let showModel = false;
if (val === 'custom_range') {
if (chainStartNode) {
const startIndex = PIPELINE_STEPS.findIndex(s => s.id === chainStartNode);
const endIndex = chainEndNode ? PIPELINE_STEPS.findIndex(s => s.id === chainEndNode) : startIndex;
const finalStart = Math.min(startIndex, endIndex);
const finalEnd = Math.max(startIndex, endIndex);
if (finalStart === 0) { // 'research' is index 0
showPlatforms = true;
}
// 'apply-grounding' is index 4 in PIPELINE_STEPS
const applyIndex = PIPELINE_STEPS.findIndex(s => s.id === 'apply-grounding');
if (applyIndex >= finalStart && applyIndex <= finalEnd) {
showModel = true;
}
}
} else if (val.startsWith('step_')) {
if (val === 'step_1.1') showPlatforms = true;
if (val === 'step_2.2') showModel = true;
} else if (val === 'phase1') {
showPlatforms = true;
} else if (val === 'smart') {
showPlatforms = true;
// Depending on smart mode logic, we might not always need to show it, but default is fine
}
if (groupPlatforms) groupPlatforms.style.display = showPlatforms ? 'block' : 'none';
if (groupModel) groupModel.style.display = showModel ? 'block' : 'none';
}
if (selectForcePhase) {
selectForcePhase.addEventListener('change', updateRunModalVisibility);
}
const jsonStrategy = document.getElementById('json-strategy');
const jsonBlueprint = document.getElementById('json-blueprint');
const jsonCapability = document.getElementById('json-capability');
const jsonSource = document.getElementById('json-source');
const jsonRaw = document.getElementById('json-raw');
const modalRun = document.getElementById('run-modal');
const modalLogs = document.getElementById('logs-modal');
const terminalLogs = document.getElementById('terminal-logs');
const modalFragDetail = document.getElementById('frag-detail-modal');
const btnCloseFragDetail = document.getElementById('btn-close-frag-detail');
if (btnCloseFragDetail) btnCloseFragDetail.onclick = () => modalFragDetail.classList.add('hidden');
window.allFragmentsMap = {};
const PIPELINE_STEPS = [
{ id: 'research', label: '1.1 分布式爬取' },
{ id: 'source', label: '1.5 提取数据源' },
{ id: 'generate-case', label: '1.6 生成 case.json' },
{ id: 'decode-workflow', label: '2.1 解析工序' },
{ id: 'apply-grounding', label: '2.2 场景映射' },
{ id: 'process-cluster', label: '2.1.1 工序聚类' },
{ id: 'process-score', label: '2.1.2 工序打分' },
{ id: 'capability-extract', label: '2.2.1 能力提取' },
{ id: 'capability-enrich', label: '2.2.2 能力丰富化' },
{ id: 'strategy', label: '3.0 策略组装' }
];
let chainStartNode = null;
let chainEndNode = null;
let currentPipelineStatus = {};
// Initialize
async function init() {
await fetchRequirements();
setupEventListeners();
setupFloatingApplyToTooltips();
startStatusPolling();
}
function setupFloatingApplyToTooltips() {
if (window.__applyToTooltipReady) return;
window.__applyToTooltipReady = true;
let floatingTooltip = null;
let activeTarget = null;
const hideTooltip = () => {
activeTarget = null;
if (floatingTooltip) {
floatingTooltip.remove();
floatingTooltip = null;
}
};
const positionTooltip = () => {
if (!floatingTooltip || !activeTarget) return;
const rect = activeTarget.getBoundingClientRect();
const tipRect = floatingTooltip.getBoundingClientRect();
const gap = 8;
const margin = 12;
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - tipRect.width - margin));
let top = rect.bottom + gap;
if (top + tipRect.height > window.innerHeight - margin) {
top = rect.top - tipRect.height - gap;
}
top = Math.max(margin, top);
floatingTooltip.style.left = `${left}px`;
floatingTooltip.style.top = `${top}px`;
floatingTooltip.style.visibility = 'visible';
floatingTooltip.style.opacity = '1';
};
document.addEventListener('pointerover', (event) => {
const target = event.target.closest('.apply-to-path-item.has-tooltip');
if (!target) return;
const sourceTooltip = target.querySelector('.apply-to-tooltip');
if (!sourceTooltip) return;
activeTarget = target;
if (!floatingTooltip) {
floatingTooltip = document.createElement('div');
floatingTooltip.className = 'floating-apply-to-tooltip';
document.body.appendChild(floatingTooltip);
}
floatingTooltip.innerHTML = sourceTooltip.innerHTML;
floatingTooltip.style.visibility = 'hidden';
floatingTooltip.style.opacity = '0';
requestAnimationFrame(positionTooltip);
});
document.addEventListener('pointerout', (event) => {
const target = event.target.closest('.apply-to-path-item.has-tooltip');
if (!target || target.contains(event.relatedTarget)) return;
hideTooltip();
});
document.addEventListener('pointerover', (event) => {
const target = event.target.closest('.apply-to-evidence-note[data-excerpt-key]');
if (!target) return;
const row = target.closest('tr');
if (!row) return;
row.querySelectorAll(`.body-excerpt-highlight[data-excerpt-key="${target.dataset.excerptKey}"]`).forEach(el => {
el.classList.add('active');
});
});
document.addEventListener('pointerout', (event) => {
const target = event.target.closest('.apply-to-evidence-note[data-excerpt-key]');
if (!target || target.contains(event.relatedTarget)) return;
const row = target.closest('tr');
if (!row) return;
row.querySelectorAll(`.body-excerpt-highlight[data-excerpt-key="${target.dataset.excerptKey}"]`).forEach(el => {
el.classList.remove('active');
});
});
window.addEventListener('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip);
}
function makeExcerptKey(text) {
let hash = 0;
const str = String(text || '');
for (let i = 0; i < str.length; i += 1) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return `e${Math.abs(hash)}`;
}
function getWorkflowGroups(item) {
if (!item || !item.decode_workflow) return [];
// decode_workflow is an object (not an array), so we wrap it in an array to maintain compatibility
return [item.decode_workflow];
}
function getWorkflowItems(item) {
return getWorkflowGroups(item)
.filter(group => group.steps || group.workflow)
.map(group => {
const wf = group.workflow || group;
return {
...wf,
workflow_id: wf.workflow_id || group.workflow_id,
capability: Array.isArray(group.capability) ? group.capability : []
};
});
}
function getCapabilityItems(item) {
return getWorkflowGroups(item).flatMap(group => Array.isArray(group.capability) ? group.capability : []);
}
// Fetch Data
async function fetchRequirements() {
try {
const res = await fetch('/api/requirements');
requirements = await res.json();
requirements.sort((a, b) => b.index - a.index);
renderTaskList(requirements);
updateStats();
} catch (e) {
console.error("Failed to fetch requirements", e);
elReqSelector.innerHTML = '';
}
}
window.selectSubTab = function (p) {
document.querySelectorAll('.sub-tab-btn').forEach(b => {
b.classList.remove('active');
if (b.textContent === p.replace('case_', '').toUpperCase()) b.classList.add('active');
});
document.querySelectorAll('.sub-tab-pane').forEach(pane => pane.classList.add('hidden'));
const target = document.getElementById('sub-tab-' + p);
if (target) target.classList.remove('hidden');
};
window.hoverWorkflowStep = function(scopeId, stepId, outputIdx) {
const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
if (el) {
el.classList.add('hl-target');
}
};
window.unhoverWorkflowStep = function(scopeId, stepId, outputIdx) {
const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
if (el) {
el.classList.remove('hl-target');
}
};
window.jumpToWorkflowStep = function(scopeId, stepId, outputIdx) {
const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('hl-flash');
void el.offsetWidth; // trigger reflow
el.classList.add('hl-flash');
}
};
window.jumpToCase = function (caseId) {
// Switch to raw tab
document.querySelector('.tab-btn-pill[data-target="tab-raw"]').click();
// Find the case card
const targetCard = document.getElementById('case-card-' + caseId);
if (targetCard) {
// Find which sub-tab pane it's inside
const pane = targetCard.closest('.sub-tab-pane');
if (pane) {
const platformId = pane.id.replace('sub-tab-', '');
selectSubTab(platformId);
}
// Scroll into view
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight
targetCard.classList.remove('highlight-pulse');
void targetCard.offsetWidth; // Trigger reflow
targetCard.classList.add('highlight-pulse');
} else {
alert("Case not found in raw cases data.");
}
};
async function fetchPromptsList() {
try {
const res = await fetch('/api/prompts');
let list = await res.json();
// Handle DAG nodes
document.querySelectorAll('.prompt-node').forEach(node => {
const p = node.dataset.prompt;
if (list.includes(p)) {
node.onclick = () => selectPrompt(p, node);
// Remove from list so it doesn't appear in "other"
list = list.filter(item => item !== p);
} else {
node.style.opacity = 0.5; // gray out if not found
node.style.cursor = 'not-allowed';
}
});
const elOtherPromptsList = document.getElementById('other-prompts-list');
if (elOtherPromptsList) {
elOtherPromptsList.innerHTML = '';
list.forEach((p) => {
const div = document.createElement('div');
div.className = 'prompt-tab';
div.textContent = p;
div.onclick = () => selectPrompt(p, div);
elOtherPromptsList.appendChild(div);
});
}
// Select the first prompt by default (maybe researcher.prompt)
const firstNode = document.querySelector('.prompt-node[data-prompt="researcher.prompt"]');
if (firstNode) {
selectPrompt('researcher.prompt', firstNode);
}
} catch (e) {
console.error("Failed to load prompts", e);
}
}
async function selectPrompt(name, tabEl) {
currentPromptName = name;
document.querySelectorAll('.prompt-tab').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.prompt-node').forEach(el => el.classList.remove('active'));
if (tabEl) tabEl.classList.add('active');
elPromptTextarea.value = 'Loading...';
elPromptTextarea.disabled = true;
if (elSchemaTextarea) {
elSchemaTextarea.value = 'Loading...';
elSchemaTextarea.disabled = true;
}
try {
const res = await fetch(`/api/prompts/${name}`);
const data = await res.json();
elPromptTextarea.value = data.content || '';
if (elSchemaTextarea) {
elSchemaTextarea.value = data.schema_content || '';
}
} catch (e) {
elPromptTextarea.value = 'Error loading prompt.';
if (elSchemaTextarea) elSchemaTextarea.value = '';
}
elPromptTextarea.disabled = false;
if (elSchemaTextarea) elSchemaTextarea.disabled = false;
elPromptStatus.textContent = '';
}
function renderAggregatedPerCaseData(cases, type) {
if (!cases || !Array.isArray(cases) || cases.length === 0) {
return '
暂无案例数据
';
}
let sidebarHtml = ``;
if (!hasData) {
return `当前需求的所有案例均无提取的${type === 'workflow' ? '工序' : '能力'}
`;
}
return `${sidebarHtml}${contentHtml}
`;
}
async function fetchRequirementData(index) {
try {
const res = await fetch(`/api/requirements/${index}/data`);
const data = await res.json();
window.dataCache = window.dataCache || {};
window.dataCache[index] = data;
let rawCasesClone = null;
let casesList = [];
if (data.raw_cases) {
rawCasesClone = { ...data.raw_cases };
const detailedCaseObj = data.raw_cases['case'] || data.raw_cases['case_detailed'];
if (detailedCaseObj && detailedCaseObj.cases) {
casesList = detailedCaseObj.cases;
}
}
if (jsonStrategy) jsonStrategy.innerHTML = ''; // Tab removed
if (jsonCapability) {
const reqStr = (index + 1).toString().padStart(3, '0');
jsonCapability.innerHTML = `
`;
}
jsonRaw.innerHTML = renderDataOrRaw(rawCasesClone, renderRawCases);
const clusterData = window.dataCache[index].cluster;
const oldActiveWorkflowTab = jsonBlueprint.querySelector('.sub-tab-btn.active')?.dataset?.target || 'sub-tab-workflow-cases';
const workflowCasesHtml = renderAggregatedPerCaseData(casesList, 'workflow');
let bpHtml = ``;
bpHtml += `
`;
bpHtml += ``;
bpHtml += ``;
bpHtml += `
`;
bpHtml += `
${workflowCasesHtml}
`;
bpHtml += `
${renderClusterDeletable(clusterData)}
`;
bpHtml += `
`;
jsonBlueprint.innerHTML = bpHtml;
const clusterFileInput = document.getElementById('input-upload-cluster');
if (clusterFileInput) {
clusterFileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`/api/requirements/${index}/upload_cluster`, {
method: 'POST',
body: formData
});
if (res.ok) {
fetchRequirementData(index);
} else {
alert('上传失败');
}
} catch (err) {
console.error(err);
alert('上传出错');
}
};
}
const btnUpload = document.getElementById('btn-upload-source-ex');
const fileInput = document.getElementById('input-upload-source-ex');
if (btnUpload && fileInput) {
btnUpload.onclick = () => fileInput.click();
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`/api/requirements/${index}/upload_source_ex`, {
method: 'POST',
body: formData
});
if (res.ok) {
alert('上传成功!');
fetchRequirementData(index);
} else {
alert('上传失败');
}
} catch (err) {
console.error(err);
alert('上传出错');
}
};
}
if (rawCasesClone && Object.keys(rawCasesClone).length > 0) {
currentAvailablePlatforms = Object.keys(rawCasesClone)
.filter(p => p.startsWith('case_') && p !== 'case_detailed' && p !== 'case')
.map(p => p.replace('case_', ''));
if (currentAvailablePlatforms.length === 0) {
currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
}
} else {
currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
}
} catch (e) {
console.error("Failed to fetch data", e);
}
// Automatically re-apply search filter on newly loaded data
if (typeof applySearchFilter === 'function') {
applySearchFilter();
}
}
async function pollStatus() {
try {
const res = await fetch('/api/pipeline/status');
const statusData = await res.json();
let needsListUpdate = false;
// Check if any status changed
for (const [idxStr, runInfo] of Object.entries(statusData)) {
const idx = parseInt(idxStr);
if (!activeRuns[idx] || activeRuns[idx].status !== runInfo.status) {
needsListUpdate = true;
}
activeRuns[idx] = runInfo;
// Update logs if modal is open for this index
if (currentSelectedIndex === idx && !modalLogs.classList.contains('hidden')) {
terminalLogs.textContent = runInfo.logs.join('');
terminalLogs.scrollTop = terminalLogs.scrollHeight;
}
// Update detail view banner if this is the selected one
if (currentSelectedIndex === idx) {
updateDetailBannerStatus(runInfo.status);
}
}
if (needsListUpdate) {
// update in requirements array
requirements.forEach(req => {
if (activeRuns[req.index]) {
req.status = activeRuns[req.index].status;
}
});
renderTaskList(requirements);
updateStats();
}
} catch (e) {
console.error("Failed to poll status", e);
}
}
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollStatus, 2000);
}
// Render
function renderTaskList(list) {
elReqSelector.innerHTML = '';
list.forEach(req => {
const option = document.createElement('option');
option.value = req.index;
let statusMarker = '';
if (req.status === 'running') statusMarker = '🚀';
else if (req.status === 'completed') statusMarker = '✅';
else if (req.status === 'partial') statusMarker = '⚠️';
else if (req.status === 'failed') statusMarker = '❌';
else statusMarker = '⏳';
option.textContent = `${statusMarker} [#${req.id}] ${req.requirement} (Cases: ${req.raw_cases_count})`;
if (currentSelectedIndex === req.index) {
option.selected = true;
}
elReqSelector.appendChild(option);
});
}
function updateStats() {
const total = requirements.length;
const completed = requirements.filter(r => r.status === 'completed').length;
const running = requirements.filter(r => r.status === 'running').length;
elStatsContainer.innerHTML = `
Total: ${total}
Done: ${completed}
${running > 0 ? `Running: ${running}` : ''}
`;
}
function selectRequirement(index) {
currentSelectedIndex = index;
const req = requirements.find(r => r.index === index);
if (!req) return;
// Sync dropdown if called from somewhere else
if (elReqSelector.value != index) {
elReqSelector.value = index;
}
// Update Detail UI
elEmptyState.classList.add('hidden');
elDetailView.classList.remove('hidden');
updateDetailBannerStatus(activeRuns[index] ? activeRuns[index].status : req.status);
// Fetch data
if (jsonStrategy) jsonStrategy.textContent = 'Loading...';
if (jsonBlueprint) jsonBlueprint.textContent = 'Loading...';
if (jsonRaw) jsonRaw.textContent = 'Loading...';
fetchRequirementData(index);
}
function updateDetailBannerStatus(status) {
const btnStop = document.getElementById('btn-stop-pipeline');
if (status === 'running') {
elStatusBanner.classList.remove('hidden');
elStatusBanner.style.background = 'rgba(59, 130, 246, 0.1)';
elStatusText.textContent = 'Pipeline is currently running...';
elStatusBanner.querySelector('.status-indicator').style.display = 'block';
elStatusBanner.querySelector('.status-indicator').style.background = 'var(--accent-primary)';
if (btnStop) btnStop.style.display = 'inline-block';
} else if (status === 'failed') {
elStatusBanner.classList.remove('hidden');
elStatusBanner.style.background = 'rgba(239, 68, 68, 0.1)';
elStatusText.textContent = 'Pipeline run failed.';
elStatusBanner.querySelector('.status-indicator').style.display = 'block';
elStatusBanner.querySelector('.status-indicator').style.background = 'var(--danger)';
if (btnStop) btnStop.style.display = 'none';
} else {
elStatusBanner.classList.add('hidden');
if (btnStop) btnStop.style.display = 'none';
}
}
// Actions
async function triggerRun() {
if (currentSelectedIndex === null) return;
const checkSdkEl = document.getElementById('check-claude-sdk');
const selectModelEl = document.getElementById('select-model');
const checkSdk = checkSdkEl ? checkSdkEl.checked : (selectModelEl && selectModelEl.value === 'sdk');
const mode = document.getElementById('select-force-phase').value;
const platforms = document.getElementById('input-platforms').value;
let only_step = null;
let start_from = null;
let end_at = null;
let restart_mode = null;
let phase = null;
if (mode === "custom_range") {
if (!chainStartNode) {
alert('请在下方链条中选择至少一个节点作为起点。');
return;
}
if (chainStartNode === chainEndNode || !chainEndNode) {
only_step = chainStartNode.replace(/-1$/, '');
} else {
start_from = chainStartNode.replace(/-1$/, '');
end_at = chainEndNode.replace(/-1$/, '');
}
} else if (mode.startsWith("step_")) {
const stepMap = {
"step_1.1": "research",
"step_1.5": "source",
"step_1.6": "generate-case",
"step_2.1": "decode-workflow",
"step_2.2": "apply-grounding",
"step_2.1.1": "process-cluster",
"step_2.1.2": "process-score",
"step_2.2.1": "capability-extract",
"step_2.2.2": "capability-enrich",
"step_3.0": "strategy"
};
only_step = stepMap[mode];
if (mode === "step_1.1") {
restart_mode = "single_platforms"; // triggers backend's platform specific clearing
}
} else if (mode.startsWith("phase")) {
if (mode === "phase1") {
phase = 1;
} else if (mode === "phase2") {
phase = 2;
} else if (mode === "phase3") {
phase = 3;
}
} else {
restart_mode = "smart";
}
const groupPlatforms = document.getElementById('group-platforms');
const platformsToSend = groupPlatforms && groupPlatforms.style.display !== 'none' ? platforms : "";
const requestData = {
platforms: platformsToSend,
use_claude_sdk: checkSdk,
restart_mode: restart_mode,
phase: phase,
only_step: only_step,
start_from: start_from,
end_at: end_at
};
modalRun.classList.add('hidden');
// Optimistic UI update
const req = requirements.find(r => r.index === currentSelectedIndex);
if (req) req.status = 'running';
activeRuns[currentSelectedIndex] = { status: 'running', logs: ['Initializing request...'] };
renderTaskList(requirements);
updateDetailBannerStatus('running');
try {
const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
if (!res.ok) {
const err = await res.json();
alert("Error: " + err.detail);
}
} catch (e) {
console.error("Run failed", e);
alert("Failed to trigger run");
}
}
// Event Listeners
function setupEventListeners() {
setupScriptEvents();
setupLogViewerEvents();
// Global JSON Toggle
const globalJsonToggle = document.getElementById('global-json-toggle');
if (globalJsonToggle) {
globalJsonToggle.addEventListener('change', (e) => {
const isRaw = e.target.checked;
document.querySelectorAll('.data-view-ui').forEach(el => el.style.display = isRaw ? 'none' : '');
document.querySelectorAll('.data-view-raw').forEach(el => el.style.display = isRaw ? '' : 'none');
});
}
// Dropdown change
elReqSelector.addEventListener('change', (e) => {
const val = e.target.value;
if (val) {
selectRequirement(parseInt(val));
} else {
currentSelectedIndex = null;
elEmptyState.classList.remove('hidden');
elDetailView.classList.add('hidden');
}
});
// Refresh Data without changing page position
const btnRefresh = document.getElementById('btn-refresh-data');
if (btnRefresh) {
btnRefresh.addEventListener('click', async () => {
if (currentSelectedIndex === null) {
alert("请先选择一个需求项目!");
return;
}
const oldText = btnRefresh.innerHTML;
btnRefresh.innerHTML = '🔄 刷新中...';
btnRefresh.disabled = true;
const modalCaseDetail = document.getElementById('case-detail-modal');
const isModalOpen = modalCaseDetail && !modalCaseDetail.classList.contains('hidden');
const activeSidebarItem = document.querySelector('.modal-sidebar-item.active');
const activeCaseIdx = activeSidebarItem ? parseInt(activeSidebarItem.id.replace('sidebar-item-', '')) : null;
try {
await fetchRequirementData(currentSelectedIndex);
if (isModalOpen && activeCaseIdx !== null && typeof window.renderSingleCaseDetail === 'function') {
window.renderSingleCaseDetail(activeCaseIdx);
}
} catch (e) {
console.error("Failed to refresh data", e);
} finally {
btnRefresh.innerHTML = oldText;
btnRefresh.disabled = false;
}
});
}
// Tabs
document.querySelectorAll('.tab-btn-pill').forEach(btn => {
btn.addEventListener('click', () => {
// Un-active sibling buttons
const tabGroup = btn.closest('.data-tabs-pill') || document;
tabGroup.querySelectorAll('.tab-btn-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Find target content
const targetId = btn.dataset.target;
const targetEl = document.getElementById(targetId);
if (targetEl) {
// Find parent container of the target to hide siblings
const contentGroup = targetEl.parentElement;
Array.from(contentGroup.children).forEach(child => {
if (child.classList.contains('tab-content') || child.classList.contains('adv-tab-content')) {
child.classList.remove('active');
child.classList.add('hidden'); // hidden for adv-tab-content
}
});
// Show target
targetEl.classList.add('active');
targetEl.classList.remove('hidden');
}
});
});
// Modals
document.getElementById('btn-open-run-modal').addEventListener('click', async () => {
if (currentSelectedIndex !== null) {
modalRun.classList.remove('hidden');
const selectForcePhase = document.getElementById('select-force-phase');
if (selectForcePhase) selectForcePhase.dispatchEvent(new Event('change'));
const inputPlatforms = document.getElementById('input-platforms');
if (inputPlatforms && currentAvailablePlatforms && currentAvailablePlatforms.length > 0) {
inputPlatforms.value = currentAvailablePlatforms.join(',');
}
// Fetch status and render chain
await fetchAndRenderPipelineChain(currentSelectedIndex);
}
});
document.getElementById('btn-close-modal').addEventListener('click', () => {
modalRun.classList.add('hidden');
});
document.getElementById('btn-cancel-run').addEventListener('click', () => {
modalRun.classList.add('hidden');
});
const selectForcePhase = document.getElementById('select-force-phase');
const groupPlatforms = document.getElementById('group-platforms');
const chainContainer = document.getElementById('pipeline-chain-container');
if (selectForcePhase) {
selectForcePhase.addEventListener('change', (e) => {
const val = e.target.value;
if (groupPlatforms) {
if (['smart', 'phase1', 'step_1.1'].includes(val) || (val === 'custom_range' && chainStartNode === 'research')) {
groupPlatforms.style.display = 'block';
} else {
groupPlatforms.style.display = 'none';
}
}
const selectModel = document.getElementById('select-model');
const groupModel = selectModel ? selectModel.parentElement : null;
if (groupModel) {
if (['phase1', 'step_1.1'].includes(val)) {
groupModel.style.display = 'none';
} else {
groupModel.style.display = 'block';
}
}
if (val === 'custom_range') {
chainContainer.classList.remove('hidden');
} else {
chainContainer.classList.add('hidden');
}
});
// Trigger initial state
selectForcePhase.dispatchEvent(new Event('change'));
}
document.getElementById('btn-reset-chain').addEventListener('click', () => {
chainStartNode = null;
chainEndNode = null;
renderPipelineChain();
});
// Add Requirement Modal Events
const modalAddReq = document.getElementById('add-req-modal');
const inputAddReq = document.getElementById('input-new-req');
document.getElementById('btn-add-req').addEventListener('click', () => {
inputAddReq.value = "";
modalAddReq.classList.remove('hidden');
});
document.getElementById('btn-close-add-req').addEventListener('click', () => {
modalAddReq.classList.add('hidden');
});
document.getElementById('btn-cancel-add-req').addEventListener('click', () => {
modalAddReq.classList.add('hidden');
});
document.getElementById('btn-submit-add-req').addEventListener('click', async () => {
const val = inputAddReq.value.trim();
if (!val) {
alert('需求内容不能为空');
return;
}
try {
const res = await fetch(`/api/requirements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement: val })
});
if (res.ok) {
modalAddReq.classList.add('hidden');
await fetchRequirements();
} else {
alert('添加失败');
}
} catch (e) {
console.error(e);
alert('网络错误');
}
});
const modalEditReq = document.getElementById('edit-req-modal');
const inputEditReq = document.getElementById('edit-req-textarea');
document.getElementById('btn-edit-req').addEventListener('click', () => {
if (currentSelectedIndex !== null) {
const req = requirements.find(r => r.index === currentSelectedIndex);
if (req) {
inputEditReq.value = req.requirement;
modalEditReq.classList.remove('hidden');
}
}
});
const closeEditReq = () => modalEditReq.classList.add('hidden');
document.getElementById('btn-close-edit-req').addEventListener('click', closeEditReq);
document.getElementById('btn-cancel-edit-req').addEventListener('click', closeEditReq);
const saveRequirementText = async (runAfter) => {
if (currentSelectedIndex === null) return;
const newText = inputEditReq.value.trim();
if (!newText) {
alert('需求文本不能为空');
return;
}
try {
const res = await fetch(`/api/requirements/${currentSelectedIndex}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement: newText })
});
if (!res.ok) {
const err = await res.json();
alert('保存失败: ' + (err.detail || '未知错误'));
return;
}
// Update locally
const req = requirements.find(r => r.index === currentSelectedIndex);
if (req) {
req.requirement = newText;
renderTaskList(requirements);
}
closeEditReq();
if (runAfter) {
document.getElementById('btn-open-run-modal').click();
}
} catch (e) {
console.error(e);
alert('保存请求失败');
}
};
document.getElementById('btn-save-edit-req').addEventListener('click', () => saveRequirementText(false));
document.getElementById('btn-save-run-edit-req').addEventListener('click', () => saveRequirementText(true));
document.getElementById('btn-confirm-run').addEventListener('click', triggerRun);
document.getElementById('btn-view-logs').addEventListener('click', () => {
modalLogs.classList.remove('hidden');
if (activeRuns[currentSelectedIndex]) {
terminalLogs.textContent = activeRuns[currentSelectedIndex].logs.join('');
terminalLogs.scrollTop = terminalLogs.scrollHeight;
} else {
terminalLogs.textContent = 'No logs available.';
}
});
const btnStop = document.getElementById('btn-stop-pipeline');
if (btnStop) {
btnStop.addEventListener('click', async () => {
if (currentSelectedIndex === null) return;
if (!confirm('Are you sure you want to stop the running pipeline?')) return;
try {
const res = await fetch(`/api/pipeline/stop/${currentSelectedIndex}`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
alert("Error stopping pipeline: " + err.detail);
}
} catch (e) {
console.error("Failed to stop pipeline", e);
alert("Failed to stop pipeline");
}
});
}
document.getElementById('btn-close-logs').addEventListener('click', () => {
modalLogs.classList.add('hidden');
});
const btnOpenPrompts = document.getElementById('btn-open-prompts');
if (btnOpenPrompts) {
btnOpenPrompts.addEventListener('click', () => {
modalPrompts.classList.remove('hidden');
fetchPromptsList();
});
}
const btnClosePrompts = document.getElementById('btn-close-prompts');
if (btnClosePrompts) {
btnClosePrompts.addEventListener('click', () => {
modalPrompts.classList.add('hidden');
});
}
const btnSavePrompt = document.getElementById('btn-save-prompt');
if (btnSavePrompt) {
btnSavePrompt.addEventListener('click', async () => {
if (!currentPromptName) return;
elPromptStatus.textContent = 'Saving...';
elPromptStatus.style.color = 'var(--text-muted)';
try {
const reqBody = { content: elPromptTextarea.value };
if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
const res = await fetch(`/api/prompts/${currentPromptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody)
});
if (res.ok) {
elPromptStatus.textContent = 'Saved!';
elPromptStatus.style.color = 'var(--success)';
setTimeout(() => elPromptStatus.textContent = '', 2000);
} else {
const errData = await res.json();
throw new Error(errData.detail || "Failed to save");
}
} catch (e) {
elPromptStatus.textContent = e.message || 'Save failed';
elPromptStatus.style.color = 'var(--danger)';
}
});
}
const btnUpdateSchema = document.getElementById('update-schema-btn');
if (btnUpdateSchema) {
btnUpdateSchema.addEventListener('click', async () => {
if (!currentPromptName) return;
const originalText = btnUpdateSchema.innerHTML;
btnUpdateSchema.innerHTML = '⏳ 更新中...';
btnUpdateSchema.disabled = true;
try {
// First save the prompt so the backend reads the latest content
const reqBody = { content: elPromptTextarea.value };
if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
let res = await fetch(`/api/prompts/${currentPromptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody)
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || "保存 Prompt 失败");
}
// Then call the schema update API
res = await fetch(`/api/prompts/${currentPromptName}/update_schema`, {
method: 'POST'
});
if (res.ok) {
const data = await res.json();
if (elSchemaTextarea && data.schema_content) {
elSchemaTextarea.value = data.schema_content;
}
btnUpdateSchema.innerHTML = '✅ 更新成功';
setTimeout(() => {
btnUpdateSchema.innerHTML = originalText;
btnUpdateSchema.disabled = false;
}, 2000);
} else {
const errData = await res.json();
throw new Error(errData.detail || "更新 Schema 失败");
}
} catch (e) {
alert(e.message || '更新失败');
btnUpdateSchema.innerHTML = '❌ 失败';
setTimeout(() => {
btnUpdateSchema.innerHTML = originalText;
btnUpdateSchema.disabled = false;
}, 2000);
}
});
}
// Search input character matching for Case tab
const searchInput = document.querySelector('.search-input');
if (searchInput) {
searchInput.addEventListener('input', () => {
applySearchFilter();
});
}
}
window.applySearchFilter = function() {
const searchInput = document.querySelector('.search-input');
if (!searchInput) return;
const query = searchInput.value.toLowerCase().trim();
// Filter raw case cards (on "案例" page)
const cards = document.querySelectorAll('#json-raw .masonry-card');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
if (text.includes(query)) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Handle empty group headers and grids
const grids = document.querySelectorAll('#json-raw .masonry-grid');
grids.forEach(grid => {
const visibleCards = Array.from(grid.querySelectorAll('.masonry-card')).filter(card => card.style.display !== 'none');
const prevSibling = grid.previousElementSibling;
if (visibleCards.length > 0) {
grid.style.display = '';
if (prevSibling && prevSibling.tagName === 'H3') {
prevSibling.style.display = '';
}
} else {
grid.style.display = 'none';
if (prevSibling && prevSibling.tagName === 'H3') {
prevSibling.style.display = 'none';
}
}
});
};
// Boot
// ----------------------------------------------------
// Pipeline Chain Visualization Logic
// ----------------------------------------------------
async function fetchAndRenderPipelineChain(index) {
try {
const res = await fetch(`/api/requirements/${index}/pipeline-status`);
if (res.ok) {
currentPipelineStatus = await res.json();
} else {
currentPipelineStatus = {};
}
} catch (e) {
console.error("Failed to fetch pipeline status", e);
currentPipelineStatus = {};
}
chainStartNode = null;
chainEndNode = null;
renderPipelineChain();
}
function bindPipelineChainEvents() {
PIPELINE_STEPS.forEach((step) => {
const node = document.getElementById('node-' + step.id);
if (!node) return;
node.addEventListener('click', () => {
if (!chainStartNode) {
chainStartNode = step.id;
} else if (!chainEndNode) {
if (chainStartNode === step.id) {
chainEndNode = step.id; // Double click = single step mode
} else {
chainEndNode = step.id;
}
} else {
chainStartNode = step.id; // Reset and start new
chainEndNode = null;
}
renderPipelineChain();
updateRunModalVisibility();
});
});
}
function renderPipelineChain() {
if (chainStartNode && chainEndNode) {
const startIndex = PIPELINE_STEPS.findIndex(s => s.id === chainStartNode);
const endIndex = PIPELINE_STEPS.findIndex(s => s.id === chainEndNode);
if (endIndex < startIndex) {
const tempId = chainStartNode;
chainStartNode = chainEndNode;
chainEndNode = tempId;
}
}
const LINEAR_PREFIX = ["research", "source", "generate-case", "decode-workflow", "apply-grounding"];
const BRANCH_21 = ["process-cluster", "process-score"];
const BRANCH_22 = ["capability-extract", "capability-enrich"];
const STRATEGY = "strategy";
let activeSteps = new Set();
if (chainStartNode && !chainEndNode) {
activeSteps.add(chainStartNode);
} else if (chainStartNode && chainEndNode) {
const start = chainStartNode;
const end = chainEndNode;
const start_in_linear = LINEAR_PREFIX.includes(start);
const start_in_21 = BRANCH_21.includes(start);
const start_in_22 = BRANCH_22.includes(start);
const end_in_linear = LINEAR_PREFIX.includes(end);
const end_in_21 = BRANCH_21.includes(end);
const end_in_22 = BRANCH_22.includes(end);
const end_is_strategy = end === STRATEGY;
// 1. Linear Prefix
LINEAR_PREFIX.forEach(s => {
const s_idx = LINEAR_PREFIX.indexOf(s);
const start_idx = LINEAR_PREFIX.indexOf(start);
if (start_idx >= 0 && s_idx >= start_idx) {
if (end_in_linear) {
if (s_idx <= LINEAR_PREFIX.indexOf(end)) activeSteps.add(s);
} else {
activeSteps.add(s);
}
}
});
// 2. Branches
if (end_is_strategy || (!end_in_21 && !end_in_22 && !end_in_linear)) {
if (!start_in_21 && !start_in_22) {
BRANCH_21.forEach(s => activeSteps.add(s));
BRANCH_22.forEach(s => activeSteps.add(s));
} else if (start_in_21) {
const idx = BRANCH_21.indexOf(start);
BRANCH_21.slice(idx).forEach(s => activeSteps.add(s));
BRANCH_22.forEach(s => activeSteps.add(s));
} else if (start_in_22) {
const idx = BRANCH_22.indexOf(start);
BRANCH_22.slice(idx).forEach(s => activeSteps.add(s));
BRANCH_21.forEach(s => activeSteps.add(s));
}
} else if (end_in_21) {
const end_idx = BRANCH_21.indexOf(end);
const start_idx = start_in_21 ? BRANCH_21.indexOf(start) : 0;
BRANCH_21.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
} else if (end_in_22) {
const end_idx = BRANCH_22.indexOf(end);
const start_idx = start_in_22 ? BRANCH_22.indexOf(start) : 0;
BRANCH_22.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
}
// 3. Strategy
if (end_is_strategy) {
activeSteps.add("strategy");
}
}
PIPELINE_STEPS.forEach((step) => {
const node = document.getElementById('node-' + step.id);
if (!node) return;
node.className = 'chain-node';
if (currentPipelineStatus[step.id]) {
node.classList.add('completed');
} else {
node.classList.add('missing');
}
if (activeSteps.has(step.id)) {
if ((chainStartNode && !chainEndNode) || step.id === chainStartNode || step.id === chainEndNode) {
node.classList.add('selected');
} else {
node.classList.add('selected-range');
}
}
});
}
bindPipelineChainEvents();
// Case Detail Modal Listeners
const btnCloseCaseDetail = document.getElementById('btn-close-case-detail');
const modalCaseDetail = document.getElementById('case-detail-modal');
if (btnCloseCaseDetail && modalCaseDetail) {
btnCloseCaseDetail.addEventListener('click', () => {
modalCaseDetail.classList.add('hidden');
});
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modalCaseDetail.classList.contains('hidden')) {
modalCaseDetail.classList.add('hidden');
}
});
// Close on click outside
modalCaseDetail.addEventListener('click', (e) => {
if (e.target === modalCaseDetail) {
modalCaseDetail.classList.add('hidden');
}
});
}
window.triggerSingleCaseRerun = async function (step, caseIndex) {
if (typeof currentSelectedIndex === 'undefined' || currentSelectedIndex === null) {
alert("请先选择一个需求项目!");
return;
}
const confirmMsg = `确定要针对当前 Case 单独重跑 [${step}] 步骤吗?\n注意:这会覆盖现有的提取结果!`;
if (!confirm(confirmMsg)) return;
try {
const payload = {
use_claude_sdk: false, // Default
only_step: step,
case_index: caseIndex
};
// Use global Claude SDK checkbox if it's checked in the UI
const cbClaudeSdk = document.getElementById('check-claude-sdk');
if (cbClaudeSdk) {
payload.use_claude_sdk = cbClaudeSdk.checked;
}
const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
alert(`✅ 单Case重跑已触发 (${step})!请在 Pipeline 终端查看进度。`);
// Show the logs modal instead of non-existent logs tab
const btnViewLogs = document.getElementById('btn-view-logs');
if (btnViewLogs) {
btnViewLogs.click();
}
const modalCaseDetail = document.getElementById('case-detail-modal');
if (modalCaseDetail) modalCaseDetail.classList.add('hidden');
} else {
const err = await res.json();
alert(`启动重跑失败: ${err.detail || JSON.stringify(err)}`);
}
} catch (e) {
console.error("Error triggering single case rerun:", e);
alert(`发生错误: ${e.message}`);
}
};
window.toggleCaseFilter = async function (caseId, isRestore) {
if (currentSelectedIndex === null) return;
const reqIndex = currentSelectedIndex;
let reason = "manual_delete";
if (!isRestore) {
reason = prompt("请输入移除原因 (默认: manual_delete):", "manual_delete");
if (reason === null) return; // Cancelled
if (!reason.trim()) reason = "manual_delete";
}
try {
const action = isRestore ? 'restore' : 'filter';
const res = await fetch(`/api/requirements/${reqIndex}/cases/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ case_id: caseId, reason: reason })
});
if (!res.ok) {
const err = await res.json();
alert('操作失败: ' + (err.detail || '未知错误'));
return;
}
// Close modal and refresh data
document.getElementById('case-detail-modal').classList.add('hidden');
document.getElementById('btn-refresh-data').click();
} catch (e) {
console.error(e);
alert('操作失败');
}
};
function renderFileTree(files, container, fileClickHandler, fileActionHtmlBuilder = null, actionHandler = null) {
container.innerHTML = '';
const tree = { folders: {}, files: [] };
files.forEach(f => {
const path = f.path || f.name;
const parts = path.split('/');
let current = tree;
for (let i = 0; i < parts.length - 1; i++) {
if (!current.folders[parts[i]]) current.folders[parts[i]] = { folders: {}, files: [] };
current = current.folders[parts[i]];
}
current.files.push({ ...f, baseName: parts[parts.length - 1], fullPath: path });
});
function createNode(node, level, parentEl) {
Object.keys(node.folders).sort().forEach(folderName => {
const folderDiv = document.createElement('div');
folderDiv.style.marginLeft = level > 0 ? '12px' : '0';
const header = document.createElement('div');
header.style.cursor = 'pointer';
header.style.padding = '4px';
header.style.display = 'flex';
header.style.alignItems = 'center';
header.innerHTML = `▶ 📁 ${folderName}`;
const content = document.createElement('div');
content.style.display = 'block';
header.onclick = () => {
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? 'block' : 'none';
header.querySelector('.folder-icon').style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
};
folderDiv.appendChild(header);
folderDiv.appendChild(content);
parentEl.appendChild(folderDiv);
createNode(node.folders[folderName], level + 1, content);
});
node.files.sort((a,b) => a.baseName.localeCompare(b.baseName)).forEach(f => {
const fileDiv = document.createElement('div');
fileDiv.style.marginLeft = level > 0 ? '20px' : '0';
fileDiv.style.padding = '3px 8px';
fileDiv.style.cursor = 'pointer';
fileDiv.style.borderRadius = '4px';
fileDiv.style.display = 'flex';
fileDiv.style.justifyContent = 'space-between';
fileDiv.style.alignItems = 'center';
fileDiv.style.wordBreak = 'break-all';
const nameSpan = document.createElement('span');
nameSpan.textContent = `📄 ${f.baseName}`;
fileDiv.appendChild(nameSpan);
if (fileActionHtmlBuilder) {
const actionSpan = document.createElement('span');
actionSpan.innerHTML = fileActionHtmlBuilder(f);
fileDiv.appendChild(actionSpan);
if (actionHandler) actionHandler(actionSpan, f);
}
fileDiv.onmouseover = () => fileDiv.style.background = 'rgba(255,255,255,0.5)';
fileDiv.onmouseout = () => fileDiv.style.background = 'transparent';
if (fileClickHandler) {
fileDiv.onclick = (e) => {
if (e.target.tagName === 'A') return;
fileClickHandler(f, fileDiv);
};
}
parentEl.appendChild(fileDiv);
});
}
createNode(tree, 0, container);
if (files.length === 0) {
container.innerHTML = '没有文件
';
}
}
function setupLogViewerEvents() {
const logReqSelector = document.getElementById('log-req-selector');
const btnRefreshLogs = document.getElementById('btn-refresh-logs');
const logFilesTree = document.getElementById('log-files-tree');
const logViewerContent = document.getElementById('log-viewer-content');
const logViewerTitle = document.getElementById('log-viewer-title');
if (!logReqSelector || !btnRefreshLogs) return;
function populateSelector() {
logReqSelector.innerHTML = '';
requirements.forEach(req => {
const opt = document.createElement('option');
opt.value = req.index;
opt.textContent = `[${(req.index + 1).toString().padStart(3, '0')}] ${req.requirement.substring(0, 30)}...`;
if (currentSelectedIndex === req.index) {
opt.selected = true;
}
logReqSelector.appendChild(opt);
});
}
const advLogsTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-logs"]');
if (advLogsTabBtn) {
advLogsTabBtn.addEventListener('click', () => {
populateSelector();
btnRefreshLogs.click();
});
}
btnRefreshLogs.addEventListener('click', async () => {
const reqIndex = parseInt(logReqSelector.value);
if (isNaN(reqIndex)) return;
btnRefreshLogs.textContent = '刷新中...';
btnRefreshLogs.disabled = true;
logFilesTree.innerHTML = '';
logViewerContent.textContent = '';
logViewerTitle.innerHTML = '选择一个文件查看';
try {
const res = await fetch(`/api/requirements/${reqIndex}/files`);
if (res.ok) {
const data = await res.json();
renderFileTree(data.files, logFilesTree, async (f) => {
logViewerTitle.innerHTML = `${f.fullPath} 新窗口打开`;
logViewerContent.textContent = '加载中...';
try {
if (f.fullPath.match(/\.(jpg|jpeg|png|gif)$/i)) {
logViewerContent.innerHTML = `
`;
} else {
const contentRes = await fetch(`/api/requirements/${reqIndex}/files/content?path=${encodeURIComponent(f.fullPath)}`);
if (contentRes.ok) {
const contentData = await contentRes.json();
logViewerContent.textContent = contentData.content;
} else {
logViewerContent.textContent = '无法读取文件内容 (可能不是文本格式)';
}
}
} catch(e) {
logViewerContent.textContent = '读取出错';
}
});
} else {
logFilesTree.innerHTML = '加载失败
';
}
} catch (e) {
logFilesTree.innerHTML = '加载出错
';
}
btnRefreshLogs.textContent = '刷新文件列表';
btnRefreshLogs.disabled = false;
});
}
init();
function setupScriptEvents() {
const selector = document.getElementById('script-selector');
const btnUpload = document.getElementById('btn-upload-script');
const inputUpload = document.getElementById('script-upload-input');
const btnDelete = document.getElementById('btn-delete-script');
const formContainer = document.getElementById('script-form-container');
const argsForm = document.getElementById('script-args-form');
const btnRun = document.getElementById('btn-run-script');
const btnStop = document.getElementById('btn-stop-script');
const runOutput = document.getElementById('script-run-output');
const runStatus = document.getElementById('script-run-status');
const generatedFiles = document.getElementById('script-generated-files');
const workspaceContainer = document.getElementById('script-workspace-container');
const workspaceFiles = document.getElementById('script-workspace-files');
const btnRefreshWorkspace = document.getElementById('btn-refresh-workspace');
if (!selector) return;
let currentScriptArgs = [];
let currentProcessController = null;
async function loadScripts() {
try {
const res = await fetch('/api/scripts');
if (res.ok) {
const data = await res.json();
const currentVal = selector.value;
selector.innerHTML = '';
data.scripts.forEach(s => {
const opt = document.createElement('option');
opt.value = `${s.folder}/${s.name}`;
opt.textContent = `${s.name} (${s.folder})`;
selector.appendChild(opt);
});
if (currentVal && data.scripts.some(s => `${s.folder}/${s.name}` === currentVal)) {
selector.value = currentVal;
} else {
selector.value = '';
}
}
} catch (e) {
console.error("Failed to load scripts", e);
}
}
async function loadScriptWorkspace(folder) {
if (!folder) return;
try {
const res = await fetch(`/api/scripts/${folder}/files`);
if (res.ok) {
const data = await res.json();
renderFileTree(data.files, workspaceFiles, null, (f) => {
return `
预览
下载
删除
`;
}, (actionSpan, f) => {
const delBtn = actionSpan.querySelector('.del-script-file');
delBtn.onclick = async (e) => {
e.preventDefault();
if (!confirm(`确定删除文件 ${f.fullPath} 吗?`)) return;
try {
const res = await fetch(`/api/scripts/files/${f.fullPath}`, { method: 'DELETE' });
if (res.ok) loadScriptWorkspace(folder);
else alert('删除失败');
} catch (err) { alert('删除出错'); }
};
});
}
} catch (e) {
console.error("Failed to load workspace files", e);
}
}
async function selectScript() {
const val = selector.value;
if (!val) {
formContainer.classList.add('hidden');
workspaceContainer.classList.add('hidden');
btnDelete.classList.add('hidden');
return;
}
btnDelete.classList.remove('hidden');
formContainer.classList.remove('hidden');
workspaceContainer.classList.remove('hidden');
argsForm.innerHTML = '解析参数中...';
const [folder, filename] = val.split('/');
try {
const res = await fetch(`/api/scripts/${folder}/${filename}/parse`);
if (res.ok) {
const data = await res.json();
currentScriptArgs = data.args || [];
argsForm.innerHTML = '';
if (currentScriptArgs.length === 0) {
argsForm.innerHTML = '该脚本无需参数,或无法解析参数。
';
} else {
currentScriptArgs.forEach((arg, i) => {
const div = document.createElement('div');
div.className = 'form-group';
div.style.marginBottom = '5px';
const label = document.createElement('label');
label.style.fontWeight = 'bold';
label.textContent = (arg.names ? arg.names.join(', ') : '参数') + (arg.required ? ' *' : '');
const desc = document.createElement('div');
desc.style.fontSize = '0.85em';
desc.style.color = 'var(--text-muted)';
desc.textContent = arg.desc || '';
div.appendChild(label);
div.appendChild(desc);
const isOutputArg = (arg.names && arg.names.some(n => /^-[oO]$|out|save|write/i.test(n))) ||
(arg.desc && /(输出|保存|写入)/.test(arg.desc));
const isFileArg = !isOutputArg && arg.action_type === '_StoreAction' && (
(arg.type && arg.type.includes('FileType')) ||
(arg.names && arg.names.some(n => /file|path|dir|input/i.test(n))) ||
(arg.desc && /(文件|目录|路径)/.test(arg.desc))
);
if (arg.action_type === '_StoreTrueAction' || arg.action_type === '_StoreFalseAction') {
const input = document.createElement('input');
input.type = 'checkbox';
input.id = `script-arg-${i}`;
input.checked = arg.default || false;
div.appendChild(input);
} else if (isFileArg) {
const fileContainer = document.createElement('div');
fileContainer.style.display = 'flex';
fileContainer.style.alignItems = 'center';
fileContainer.style.gap = '8px';
fileContainer.style.flexWrap = 'wrap';
const tagContainer = document.createElement('div');
tagContainer.id = `script-arg-tag-${i}`;
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = `script-arg-${i}`;
if (arg.default !== null && arg.default !== undefined) {
hiddenInput.value = arg.default;
tagContainer.innerHTML = `📄 ${arg.default} ×`;
}
const fileSelect = document.createElement('select');
fileSelect.className = 'glass-input';
fileSelect.style.flex = '1';
fileSelect.style.minWidth = '150px';
fileSelect.innerHTML = '';
fetch(`/api/scripts/${folder}/files`).then(r => r.json()).then(data => {
if (data.files) {
data.files.filter(f => f.path.startsWith('inputs/')).forEach(f => {
const opt = document.createElement('option');
opt.value = f.path;
opt.textContent = f.path.replace('inputs/', '');
fileSelect.appendChild(opt);
});
}
});
fileSelect.onchange = () => {
if (fileSelect.value) {
hiddenInput.value = fileSelect.value;
tagContainer.innerHTML = `📄 ${fileSelect.value} ×`;
fileSelect.value = '';
}
};
const uploadBtn = document.createElement('button');
uploadBtn.className = 'btn btn-secondary btn-small';
uploadBtn.textContent = '上传新文件';
const realFileInput = document.createElement('input');
realFileInput.type = 'file';
realFileInput.style.display = 'none';
uploadBtn.onclick = (e) => {
e.preventDefault();
realFileInput.click();
};
realFileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
uploadBtn.textContent = '上传中...';
uploadBtn.disabled = true;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`/api/scripts/${folder}/upload_data`, { method: 'POST', body: formData });
if (res.ok) {
const data = await res.json();
hiddenInput.value = data.filename;
tagContainer.innerHTML = `📄 ${data.filename} ×`;
if (!Array.from(fileSelect.options).some(o => o.value === data.filename)) {
const opt = document.createElement('option');
opt.value = data.filename;
opt.textContent = data.filename.replace('inputs/', '');
fileSelect.appendChild(opt);
}
loadScriptWorkspace(folder);
} else { alert('上传失败'); }
} catch (err) { alert('上传出错'); }
uploadBtn.textContent = '上传新文件';
uploadBtn.disabled = false;
realFileInput.value = '';
};
fileContainer.appendChild(tagContainer);
fileContainer.appendChild(hiddenInput);
fileContainer.appendChild(fileSelect);
fileContainer.appendChild(uploadBtn);
fileContainer.appendChild(realFileInput);
div.appendChild(fileContainer);
} else {
const input = document.createElement('input');
input.type = 'text';
input.className = 'glass-input';
input.id = `script-arg-${i}`;
if (arg.default !== null && arg.default !== undefined) {
input.value = arg.default;
}
input.placeholder = arg.required ? '必填' : '选填';
div.appendChild(input);
}
argsForm.appendChild(div);
});
}
} else {
argsForm.innerHTML = '解析脚本参数失败
';
}
await loadScriptWorkspace(folder);
} catch (e) {
argsForm.innerHTML = '解析出错
';
}
}
selector.addEventListener('change', selectScript);
const advScriptTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-script"]');
if (advScriptTabBtn) {
advScriptTabBtn.addEventListener('click', loadScripts);
}
btnUpload.addEventListener('click', () => inputUpload.click());
inputUpload.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const statusEl = document.getElementById('script-upload-status');
statusEl.textContent = '上传中...';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/scripts/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
statusEl.textContent = '上传成功!';
await loadScripts();
const data = await res.json();
selector.value = `${data.folder}/${data.filename}`;
selectScript();
setTimeout(() => statusEl.textContent = '', 3000);
} else {
statusEl.textContent = '上传失败';
}
} catch (err) {
statusEl.textContent = '上传出错';
}
});
btnDelete.addEventListener('click', async () => {
const val = selector.value;
if (!val) return;
const [folder] = val.split('/');
if (!confirm(`确定要删除目录 ${folder} 下的所有脚本及临时文件吗?`)) return;
try {
const res = await fetch(`/api/scripts/${folder}`, { method: 'DELETE' });
if (res.ok) {
await loadScripts();
selectScript();
} else {
alert('删除失败');
}
} catch (e) {
alert('删除出错');
}
});
btnRefreshWorkspace.addEventListener('click', () => {
const val = selector.value;
if (val) loadScriptWorkspace(val.split('/')[0]);
});
btnStop.addEventListener('click', async () => {
const val = selector.value;
if (!val) return;
const [folder] = val.split('/');
try {
await fetch(`/api/scripts/${folder}/stop`, { method: 'POST' });
} catch (e) {
console.error(e);
}
});
btnRun.addEventListener('click', async () => {
const val = selector.value;
if (!val) return;
const [folder, filename] = val.split('/');
const reqArgs = [];
let missingReq = false;
currentScriptArgs.forEach((arg, i) => {
const input = document.getElementById(`script-arg-${i}`);
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else {
value = input.value.trim();
}
if (arg.required && !value && input.type !== 'checkbox') {
missingReq = true;
input.style.borderColor = 'red';
} else {
if (input.type !== 'checkbox') input.style.borderColor = '';
reqArgs.push({
name: arg.names ? arg.names[0] : null,
value: value,
is_positional: arg.is_positional
});
}
});
if (missingReq) {
alert("请填写所有必填参数");
return;
}
btnRun.disabled = true;
btnRun.textContent = '运行中...';
btnStop.classList.remove('hidden');
runOutput.textContent = '';
runStatus.textContent = '运行中';
runStatus.style.color = '#3b82f6';
generatedFiles.innerHTML = '';
generatedFiles.classList.add('hidden');
currentProcessController = new AbortController();
try {
const res = await fetch('/api/scripts/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder: folder,
filename: filename,
args: reqArgs
}),
signal: currentProcessController.signal
});
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.stdout !== undefined) {
runOutput.textContent += data.stdout;
} else if (data.stderr !== undefined) {
runOutput.textContent += data.stderr;
}
if (data.returncode !== undefined) {
if (data.returncode === 0) {
runStatus.textContent = '运行成功';
runStatus.style.color = '#10b981';
} else {
runStatus.textContent = '运行失败或中断';
runStatus.style.color = '#ef4444';
}
if (data.files && data.files.length > 0) {
generatedFiles.classList.remove('hidden');
generatedFiles.innerHTML = '生成产物:';
data.files.forEach(f => {
const a = document.createElement('a');
a.href = `/api/scripts/files/${f.name}`;
a.target = '_blank';
a.className = 'structured-badge';
a.style.background = '#d1fae5';
a.style.color = '#047857';
a.style.textDecoration = 'none';
a.textContent = `📄 ${f.name.split('/').pop()}`;
generatedFiles.appendChild(a);
});
}
loadScriptWorkspace(folder);
}
} catch(e) {
runOutput.textContent += line + '\n';
}
}
runOutput.scrollTop = runOutput.scrollHeight;
}
} catch (e) {
runStatus.textContent = '网络错误或中断';
runStatus.style.color = '#ef4444';
} finally {
btnRun.disabled = false;
btnRun.textContent = '运行脚本';
btnStop.classList.add('hidden');
currentProcessController = null;
}
});
}