diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45eef94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.egg-info/ +build/ diff --git a/pipeline/__init__.py b/pipeline/__init__.py new file mode 100644 index 0000000..4584e63 --- /dev/null +++ b/pipeline/__init__.py @@ -0,0 +1,6 @@ +"""Pipeline module - Sage frontend for Hermes pipeline backend""" + +from .init import ( + load_pipeline, + get_pipeline_config, +) diff --git a/pipeline/init.py b/pipeline/init.py new file mode 100644 index 0000000..748ee41 --- /dev/null +++ b/pipeline/init.py @@ -0,0 +1,87 @@ +"""Pipeline module initialization - bridges Sage frontend to Hermes pipeline API""" + +import aiohttp +import json +from ahserver.serverenv import ServerEnv + +MODULE_NAME = "pipeline" +MODULE_VERSION = "1.0.0" + +# Hermes Pipeline API base URL (configurable) +PIPELINE_API_BASE = "https://token.opencomputing.cn/pipeline/v1" + + +async def _hermes_request(method, path, data=None, timeout=120): + """Generic HTTP request to Hermes pipeline API""" + url = f"{PIPELINE_API_BASE}{path}" + async with aiohttp.ClientSession() as session: + kwargs = {"timeout": aiohttp.ClientTimeout(total=timeout)} + if method == "GET": + async with session.get(url, **kwargs) as resp: + return await resp.json() + elif method == "POST": + async with session.post(url, json=data, **kwargs) as resp: + return await resp.json() + elif method == "PUT": + async with session.put(url, json=data, **kwargs) as resp: + return await resp.json() + return {"status": "error", "message": f"Unknown method: {method}"} + + +async def hermes_pipeline_list(user_id): + """Get all pipelines for a user""" + return await _hermes_request("GET", f"/tasks?user_id={user_id}") + + +async def hermes_pipeline_detail(pipeline_id): + """Get pipeline detail with node tree""" + return await _hermes_request("GET", f"/task/{pipeline_id}") + + +async def hermes_pipeline_submit(data): + """Submit a new pipeline task""" + return await _hermes_request("POST", "/task/submit", data, timeout=30) + + +async def hermes_pipeline_node(pipeline_id, step, version=None): + """Get a specific node's input and output""" + path = f"/task/{pipeline_id}/node/{step}" + if version: + path += f"?version={version}" + return await _hermes_request("GET", path) + + +async def hermes_pipeline_modify(pipeline_id, updates, rerun_from="node"): + """Modify node artifacts and rerun pipeline. + + rerun_from: + - 'node': rerun from the modified node itself (input changed) + - 'next': rerun from the node after the modified one (output changed) + """ + data = { + "pipeline_id": pipeline_id, + "updates": updates, + "rerun_from": rerun_from, + } + return await _hermes_request("POST", "/task/update", data, timeout=60) + + +def get_pipeline_config(): + """Return pipeline module configuration""" + return { + "api_base": PIPELINE_API_BASE, + "module_name": MODULE_NAME, + "version": MODULE_VERSION, + } + + +def load_pipeline(): + """Register all pipeline functions with ServerEnv""" + env = ServerEnv() + env.hermes_pipeline_list = hermes_pipeline_list + env.hermes_pipeline_detail = hermes_pipeline_detail + env.hermes_pipeline_submit = hermes_pipeline_submit + env.hermes_pipeline_node = hermes_pipeline_node + env.hermes_pipeline_modify = hermes_pipeline_modify + env.get_pipeline_config = get_pipeline_config + return True diff --git a/wwwroot/api/pipeline_detail.dspy b/wwwroot/api/pipeline_detail.dspy new file mode 100644 index 0000000..8ba2156 --- /dev/null +++ b/wwwroot/api/pipeline_detail.dspy @@ -0,0 +1,13 @@ +user_id = await get_user() +if not user_id: + return json.dumps({"status": "error", "message": "未登录"}, ensure_ascii=False) + +pipeline_id = params_kw.get('pipeline_id', '') +if not pipeline_id: + return json.dumps({"status": "error", "message": "缺少pipeline_id"}, ensure_ascii=False) + +try: + result = await hermes_pipeline_detail(pipeline_id) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) diff --git a/wwwroot/api/pipeline_list.dspy b/wwwroot/api/pipeline_list.dspy new file mode 100644 index 0000000..dbef4bd --- /dev/null +++ b/wwwroot/api/pipeline_list.dspy @@ -0,0 +1,9 @@ +user_id = await get_user() +if not user_id: + return json.dumps({"status": "error", "message": "未登录"}, ensure_ascii=False) + +try: + result = await hermes_pipeline_list(user_id) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) diff --git a/wwwroot/api/pipeline_modify.dspy b/wwwroot/api/pipeline_modify.dspy new file mode 100644 index 0000000..a8fec93 --- /dev/null +++ b/wwwroot/api/pipeline_modify.dspy @@ -0,0 +1,25 @@ +user_id = await get_user() +if not user_id: + return json.dumps({"status": "error", "message": "未登录"}, ensure_ascii=False) + +pipeline_id = params_kw.get('pipeline_id', '') +step = params_kw.get('step', '') +modify_type = params_kw.get('modify_type', 'input') # 'input' or 'output' +content = params_kw.get('content', '') + +if not pipeline_id or not step or not content: + return json.dumps({"status": "error", "message": "缺少必要参数"}, ensure_ascii=False) + +try: + content_data = json.loads(content) +except Exception: + content_data = content + +updates = {step: {"content": content_data}} +rerun_from = "node" if modify_type == "input" else "next" + +try: + result = await hermes_pipeline_modify(pipeline_id, updates, rerun_from) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) diff --git a/wwwroot/api/pipeline_node.dspy b/wwwroot/api/pipeline_node.dspy new file mode 100644 index 0000000..a00cdbe --- /dev/null +++ b/wwwroot/api/pipeline_node.dspy @@ -0,0 +1,16 @@ +user_id = await get_user() +if not user_id: + return json.dumps({"status": "error", "message": "未登录"}, ensure_ascii=False) + +pipeline_id = params_kw.get('pipeline_id', '') +step = params_kw.get('step', '') +version = params_kw.get('version', None) + +if not pipeline_id or not step: + return json.dumps({"status": "error", "message": "缺少pipeline_id或step"}, ensure_ascii=False) + +try: + result = await hermes_pipeline_node(pipeline_id, step, version) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) diff --git a/wwwroot/api/pipeline_submit.dspy b/wwwroot/api/pipeline_submit.dspy new file mode 100644 index 0000000..ed4cdb9 --- /dev/null +++ b/wwwroot/api/pipeline_submit.dspy @@ -0,0 +1,29 @@ +user_id = await get_user() +if not user_id: + return json.dumps({"status": "error", "message": "未登录"}, ensure_ascii=False) + +mode = params_kw.get('mode', '') +title = params_kw.get('title', '') +lyrics = params_kw.get('lyrics', '') + +if not mode: + return json.dumps({"status": "error", "message": "缺少mode参数"}, ensure_ascii=False) + +submit_data = { + "mode": mode, + "title": title, + "lyrics": lyrics, + "user_id": user_id, +} + +# 收集其他可选参数 +for key in ['input_audio', 'input_video', 'outline', 'lyricist', 'composer', 'scene']: + val = params_kw.get(key, '') + if val: + submit_data[key] = val + +try: + result = await hermes_pipeline_submit(submit_data) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False) diff --git a/wwwroot/pipeline.css b/wwwroot/pipeline.css new file mode 100644 index 0000000..8ea17cb --- /dev/null +++ b/wwwroot/pipeline.css @@ -0,0 +1,122 @@ +/* Pipeline Module Styles */ + +.pipeline-container { padding: 20px; height: 100%; overflow-y: auto; } + +.pipeline-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 20px; padding-bottom: 12px; border-bottom: 2px solid #e0e0e0; +} +.pipeline-header h2 { margin: 0; font-size: 22px; color: #333; } + +.pipeline-btn { + padding: 8px 18px; border: none; border-radius: 6px; cursor: pointer; + font-size: 14px; font-weight: 500; transition: all 0.2s; +} +.pipeline-btn-primary { background: #4a90d9; color: #fff; } +.pipeline-btn-primary:hover { background: #357abd; } +.pipeline-btn-secondary { background: #e8e8e8; color: #333; } +.pipeline-btn-secondary:hover { background: #d5d5d5; } +.pipeline-btn-danger { background: #e74c3c; color: #fff; } +.pipeline-btn-danger:hover { background: #c0392b; } +.pipeline-btn-sm { padding: 4px 12px; font-size: 12px; } + +.pipeline-task-list { list-style: none; padding: 0; margin: 0; } +.pipeline-task-item { + display: flex; align-items: center; padding: 14px 16px; + border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 10px; + cursor: pointer; transition: all 0.2s; background: #fff; +} +.pipeline-task-item:hover { border-color: #4a90d9; box-shadow: 0 2px 8px rgba(74,144,217,0.12); } +.pipeline-task-id { font-family: monospace; font-size: 13px; color: #4a90d9; font-weight: 600; min-width: 180px; } +.pipeline-task-title { flex: 1; font-size: 15px; color: #333; margin: 0 16px; } +.pipeline-task-meta { font-size: 12px; color: #888; display: flex; gap: 16px; align-items: center; } + +.pipeline-status { + padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; + display: inline-block; +} +.status-completed { background: #d4edda; color: #155724; } +.status-running { background: #fff3cd; color: #856404; } +.status-failed { background: #f8d7da; color: #721c24; } +.status-submitted { background: #d1ecf1; color: #0c5460; } + +/* Task Detail */ +.pipeline-detail-header { + display: flex; align-items: center; gap: 16px; margin-bottom: 20px; + padding-bottom: 12px; border-bottom: 2px solid #e0e0e0; +} +.pipeline-back-btn { + background: none; border: 1px solid #ccc; border-radius: 6px; + padding: 6px 14px; cursor: pointer; font-size: 13px; color: #666; +} +.pipeline-back-btn:hover { background: #f0f0f0; } +.pipeline-detail-title { font-size: 20px; font-weight: 600; color: #333; } +.pipeline-detail-meta { font-size: 13px; color: #888; margin-top: 4px; } + +/* Node Tree */ +.pipeline-node-tree { padding: 0; } +.pipeline-node-card { + border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 8px; + overflow: hidden; background: #fff; position: relative; +} +.pipeline-node-card::before { + content: ''; position: absolute; left: 20px; top: -8px; + width: 2px; height: 8px; background: #d0d0d0; +} +.pipeline-node-card:first-child::before { display: none; } +.pipeline-node-header { + display: flex; align-items: center; padding: 12px 16px; + cursor: pointer; transition: background 0.15s; +} +.pipeline-node-header:hover { background: #f8f9fa; } +.pipeline-node-icon { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; margin-right: 12px; flex-shrink: 0; } +.node-icon-completed { background: #d4edda; color: #155724; } +.node-icon-running { background: #fff3cd; color: #856404; } +.node-icon-pending { background: #e8e8e8; color: #888; } +.node-icon-failed { background: #f8d7da; color: #721c24; } +.pipeline-node-name { flex: 1; font-size: 14px; font-weight: 500; color: #333; } +.pipeline-node-version { font-size: 11px; color: #888; background: #f0f0f0; padding: 2px 8px; border-radius: 10px; margin-left: 8px; } + +.pipeline-node-body { padding: 0 16px 16px; display: none; border-top: 1px solid #f0f0f0; } +.pipeline-node-body.expanded { display: block; padding-top: 12px; } + +.pipeline-io-section { margin-bottom: 12px; } +.pipeline-io-label { font-size: 12px; font-weight: 600; color: #666; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } +.pipeline-io-content { + background: #f8f9fa; border: 1px solid #e8e8e8; border-radius: 6px; + padding: 10px; font-family: monospace; font-size: 12px; + max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; +} +.pipeline-io-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* Create Form */ +.pipeline-form { max-width: 600px; } +.pipeline-form-group { margin-bottom: 18px; } +.pipeline-form-label { display: block; font-size: 13px; font-weight: 600; color: #555; margin-bottom: 6px; } +.pipeline-form-input, .pipeline-form-select, .pipeline-form-textarea { + width: 100%; padding: 10px 12px; border: 1px solid #d0d0d0; + border-radius: 6px; font-size: 14px; box-sizing: border-box; + transition: border-color 0.2s; +} +.pipeline-form-input:focus, .pipeline-form-select:focus, .pipeline-form-textarea:focus { + border-color: #4a90d9; outline: none; box-shadow: 0 0 0 3px rgba(74,144,217,0.1); +} +.pipeline-form-textarea { min-height: 150px; resize: vertical; font-family: inherit; } + +/* Edit Modal */ +.pipeline-edit-section { + background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px; + padding: 16px; margin-top: 12px; +} +.pipeline-edit-textarea { + width: 100%; min-height: 200px; padding: 10px; border: 1px solid #d0d0d0; + border-radius: 6px; font-family: monospace; font-size: 12px; + resize: vertical; box-sizing: border-box; +} +.pipeline-edit-type { display: flex; gap: 12px; margin-bottom: 12px; } +.pipeline-edit-type label { display: flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; } +.pipeline-edit-actions { display: flex; gap: 8px; margin-top: 12px; } + +.pipeline-empty { text-align: center; padding: 60px 20px; color: #aaa; } +.pipeline-loading { text-align: center; padding: 40px; color: #888; font-size: 14px; } +.pipeline-error { background: #fff3f3; border: 1px solid #fcc; border-radius: 6px; padding: 12px; color: #c00; font-size: 13px; margin: 10px 0; } diff --git a/wwwroot/pipeline.js b/wwwroot/pipeline.js new file mode 100644 index 0000000..757bfc9 --- /dev/null +++ b/wwwroot/pipeline.js @@ -0,0 +1,492 @@ +/* Pipeline Module - Main JavaScript */ +(function() { + 'use strict'; + + var API_BASE = ''; // Will be set on init + var currentView = 'list'; + var currentPipelineId = null; + + // === Initialization === + + function init() { + // Detect API base from current URL + var base = window.location.pathname.replace(/\/pipeline\/index\.ui.*$/, '/pipeline'); + API_BASE = base; + showListView(); + } + + // === API Helpers === + + function apiGet(path, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', API_BASE + path, true); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + try { + var data = JSON.parse(xhr.responseText); + callback(null, data); + } catch(e) { + callback('解析响应失败', null); + } + } + }; + xhr.onerror = function() { callback('网络请求失败', null); }; + xhr.send(); + } + + function apiPost(path, body, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', API_BASE + path, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + try { + var data = JSON.parse(xhr.responseText); + callback(null, data); + } catch(e) { + callback('解析响应失败', null); + } + } + }; + xhr.onerror = function() { callback('网络请求失败', null); }; + xhr.send(JSON.stringify(body)); + } + + // === View Rendering === + + function getContainer() { + return document.getElementById('pipeline_content'); + } + + function showListView() { + currentView = 'list'; + currentPipelineId = null; + var c = getContainer(); + if (!c) return; + c.innerHTML = '
' + + '
' + + '

产线任务

' + + '' + + '
' + + '
加载中...
' + + '
'; + loadTaskList(); + } + + function loadTaskList() { + apiGet('/api/pipeline_list.dspy', function(err, data) { + var el = document.getElementById('pipeline_list_body'); + if (!el) return; + if (err) { el.innerHTML = '
' + err + '
'; return; } + if (data.status === 'error') { el.innerHTML = '
' + (data.message || '加载失败') + '
'; return; } + + var tasks = data.tasks || data.data || []; + if (!tasks.length) { + el.innerHTML = '
暂无产线任务

'; + return; + } + var html = ''; + el.innerHTML = html; + }); + } + + function showCreateView() { + currentView = 'create'; + var c = getContainer(); + if (!c) return; + c.innerHTML = '
' + + '
' + + '' + + '新建产线任务
' + + '
' + + + '
' + + '' + + '
' + + + '' + + + '' + + + '
' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + '
'; + } + + function onModeChange() { + var mode = document.getElementById('pf_mode').value; + var audioG = document.getElementById('pf_audio_group'); + var videoG = document.getElementById('pf_video_group'); + var lyricsG = document.getElementById('pf_lyrics_group'); + if (audioG) audioG.style.display = (mode === 'audio_lyrics') ? 'block' : 'none'; + if (videoG) videoG.style.display = (mode === 'video_lyrics') ? 'block' : 'none'; + if (lyricsG) lyricsG.style.display = (mode === 'video_lyrics') ? 'none' : 'block'; + } + + function submitTask() { + var mode = document.getElementById('pf_mode').value; + var title = document.getElementById('pf_title').value; + var lyrics = document.getElementById('pf_lyrics') ? document.getElementById('pf_lyrics').value : ''; + var lyricist = document.getElementById('pf_lyricist').value; + var composer = document.getElementById('pf_composer').value; + + if (!title) { alert('请输入歌曲名称'); return; } + + var body = { mode: mode, title: title, lyrics: lyrics, lyricist: lyricist, composer: composer }; + + if (mode === 'audio_lyrics') { + body.input_audio = document.getElementById('pf_audio').value; + if (!body.input_audio) { alert('请输入音频文件路径'); return; } + } else if (mode === 'video_lyrics') { + body.input_video = document.getElementById('pf_video').value; + if (!body.input_video) { alert('请输入视频文件路径'); return; } + } + + var btn = document.getElementById('pf_submit_btn'); + if (btn) { btn.disabled = true; btn.textContent = '提交中...'; } + + apiPost('/api/pipeline_submit.dspy', body, function(err, data) { + if (btn) { btn.disabled = false; btn.textContent = '提交任务'; } + if (err) { alert('提交失败: ' + err); return; } + if (data.status === 'error') { alert('提交失败: ' + (data.message || '未知错误')); return; } + + var pid = data.pipeline_id; + if (pid) { + showDetailView(pid); + } else { + showListView(); + } + }); + } + + // === Task Detail View === + + function showDetailView(pipelineId) { + currentView = 'detail'; + currentPipelineId = pipelineId; + var c = getContainer(); + if (!c) return; + c.innerHTML = '
' + + '
' + + '' + + '
加载中...
' + + '
' + + '
加载任务节点...
' + + '
'; + loadTaskDetail(pipelineId); + } + + function loadTaskDetail(pid) { + apiGet('/api/pipeline_detail.dspy?pipeline_id=' + encodeURIComponent(pid), function(err, data) { + if (err) { + document.getElementById('pd_nodes').innerHTML = '
' + err + '
'; + return; + } + if (data.status === 'error') { + document.getElementById('pd_nodes').innerHTML = '
' + (data.message || '加载失败') + '
'; + return; + } + + var titleEl = document.getElementById('pd_title'); + var metaEl = document.getElementById('pd_meta'); + var nodesEl = document.getElementById('pd_nodes'); + + if (titleEl) titleEl.textContent = data.title || pid; + if (metaEl) { + var st = data.state || ''; + var stText = {completed:'已完成', running:'运行中', failed:'失败', submitted:'已提交'}[st] || st; + metaEl.innerHTML = 'ID: ' + esc(pid) + ' | ' + + esc(data.mode || '') + ' | ' + + '' + stText + '' + + ' | 版本 v' + (data.current_version || 1); + } + + renderNodeTree(nodesEl, data); + }); + } + + function renderNodeTree(container, data) { + if (!container) return; + var steps = data.steps || {}; + var artifacts = data.artifacts || {}; + var versions = data.versions || {}; + + // Sort steps by order + var stepList = []; + for (var name in steps) { + if (steps.hasOwnProperty(name)) { + var s = steps[name]; + stepList.push({name: name, order: s.order || 0, version: s.version || 1, deps: s.deps || [], state: s.state || ''}); + } + } + stepList.sort(function(a, b) { return a.order - b.order; }); + + if (!stepList.length) { + container.innerHTML = '
无步骤数据
'; + return; + } + + var html = '
'; + for (var i = 0; i < stepList.length; i++) { + var step = stepList[i]; + var st = step.state || 'pending'; + var stText = {completed:'✓', running:'⟳', pending:'○', failed:'✗'}[st] || '?'; + var iconClass = 'node-icon-' + st; + var nameCN = STEP_NAMES[step.name] || step.name; + + // Find artifact for this step + var artKey = ''; + for (var ak in artifacts) { + if (artifacts.hasOwnProperty(ak) && artifacts[ak].step === step.name) { + artKey = ak; + break; + } + } + + html += '
' + + '
' + + '
' + stText + '
' + + '' + esc(nameCN) + '' + + 'v' + step.version + '' + + '
' + + '
'; + + if (st === 'completed') { + html += '
' + + '
输入 / 输出
' + + '
点击加载...
' + + '
' + + '' + + '' + + '' + + '
'; + } else if (st === 'running') { + html += '
该节点正在运行中...
'; + } else if (st === 'failed') { + html += '
该节点执行失败
'; + } else { + html += '
等待执行
'; + } + + html += '
'; + } + html += '
'; + container.innerHTML = html; + + // Auto-expand failed or running node + for (var j = 0; j < stepList.length; j++) { + if (stepList[j].state === 'failed' || stepList[j].state === 'running') { + toggleNode(stepList[j].name); + } + } + } + + // Step name Chinese mapping + var STEP_NAMES = { + 'audio_preparing': '音频准备', + 'video_preparing': '视频准备', + 'demucs_separating': '人声分离', + 'lyric_generating': '歌词生成', + 'lyric_evaluating': '歌词评估', + 'music_generating': '音乐生成', + 'music_polling': '音乐轮询', + 'lyric_calibrating': '歌词校准', + 'subtitle_rendering': '字幕渲染', + 'subtitle_exporting': '字幕导出', + 'character_designing': '角色设计', + 'character_image_generating': '角色图生成', + 'storyboard_generating': '分镜剧本', + 'scene_video_generating': '分镜视频生成', + 'scene_video_evaluating': '分镜视频评估', + 'scene_video_concatenating': '分镜视频拼接', + 'ktv_synthesizing': 'KTV合成' + }; + + function toggleNode(stepName) { + var body = document.getElementById('node_body_' + stepName); + if (!body) return; + var isExpanded = body.classList.contains('expanded'); + body.classList.toggle('expanded'); + + // Load IO data on first expand + if (!isExpanded) { + var ioEl = document.getElementById('node_io_' + stepName); + if (ioEl && ioEl.textContent === '点击加载...') { + loadNodeIO(stepName); + } + } + } + + function loadNodeIO(stepName) { + var ioEl = document.getElementById('node_io_' + stepName); + if (!ioEl) return; + ioEl.textContent = '加载中...'; + + apiGet('/api/pipeline_node.dspy?pipeline_id=' + encodeURIComponent(currentPipelineId) + '&step=' + encodeURIComponent(stepName), function(err, data) { + if (err) { ioEl.textContent = '加载失败: ' + err; return; } + if (data.status === 'error') { ioEl.textContent = '错误: ' + (data.message || ''); return; } + + var text = ''; + if (data.input) { + text += '【输入】\n' + formatData(data.input) + '\n\n'; + } + if (data.output) { + text += '【输出】\n' + formatData(data.output); + } + if (!text) text = '(无数据)'; + ioEl.textContent = text; + }); + } + + // === Node Edit === + + function showNodeEdit(stepName, modifyType) { + var nodeBody = document.getElementById('node_body_' + stepName); + if (!nodeBody) return; + + // Check if edit section already exists + var existing = document.getElementById('node_edit_' + stepName); + if (existing) { existing.remove(); return; } + + var typeLabel = modifyType === 'input' ? '修改输入(从该节点重跑)' : '修改输出(从下一节点重跑)'; + + // Get current content + var ioEl = document.getElementById('node_io_' + stepName); + var currentContent = ''; + if (ioEl) { + var text = ioEl.textContent; + if (modifyType === 'input' && text.indexOf('【输入】') >= 0) { + currentContent = text.substring(text.indexOf('【输入】') + 4, text.indexOf('【输出】') >= 0 ? text.indexOf('【输出】') : text.length).trim(); + } else if (modifyType === 'output' && text.indexOf('【输出】') >= 0) { + currentContent = text.substring(text.indexOf('【输出】') + 4).trim(); + } + } + + var html = '
' + + '
' + esc(typeLabel) + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + + nodeBody.insertAdjacentHTML('beforeend', html); + } + + function submitNodeEdit(stepName) { + var content = document.getElementById('node_edit_content_' + stepName).value; + var modifyType = document.getElementById('node_edit_type_' + stepName).value; + var msgEl = document.getElementById('node_edit_msg_' + stepName); + var btn = document.getElementById('node_edit_btn_' + stepName); + + if (!content.trim()) { alert('内容不能为空'); return; } + + if (btn) { btn.disabled = true; btn.textContent = '提交中...'; } + if (msgEl) msgEl.innerHTML = '
提交修改...
'; + + apiPost('/api/pipeline_modify.dspy', { + pipeline_id: currentPipelineId, + step: stepName, + modify_type: modifyType, + content: content + }, function(err, data) { + if (btn) { btn.disabled = false; btn.textContent = '确认修改并重跑'; } + if (err) { + if (msgEl) msgEl.innerHTML = '
' + err + '
'; + return; + } + if (data.status === 'error') { + if (msgEl) msgEl.innerHTML = '
' + (data.message || '修改失败') + '
'; + return; + } + + if (msgEl) msgEl.innerHTML = '
修改成功!' + + (data.new_version ? ' 新版本: v' + data.new_version : '') + + (data.rerun_steps ? ' 重跑步骤: ' + data.rerun_steps.join(', ') : '') + + '
'; + + // Reload task detail after 2 seconds + setTimeout(function() { loadTaskDetail(currentPipelineId); }, 2000); + }); + } + + // === Utilities === + + function formatData(obj) { + if (typeof obj === 'string') return obj; + try { return JSON.stringify(obj, null, 2); } catch(e) { return String(obj); } + } + + function esc(s) { + if (!s) return ''; + var d = document.createElement('div'); + d.appendChild(document.createTextNode(s)); + return d.innerHTML; + } + + // === Expose Public API === + + window.PipelineUI = { + init: init, + showListView: showListView, + showCreateView: showCreateView, + showDetailView: showDetailView, + submitTask: submitTask, + onModeChange: onModeChange, + toggleNode: toggleNode, + loadNodeIO: loadNodeIO, + showNodeEdit: showNodeEdit, + submitNodeEdit: submitNodeEdit + }; + + // Auto-init on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})();