| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <title>VL Workflow DAG</title>
- <style>
- :root {
- --bg: #0a0d12; --bg2: #12161c; --bg3: #1a1f27; --border: #2a3140;
- --text: #e6edf3; --text2: #8b949e; --blue: #58a6ff; --purple: #a371f7;
- --orange: #d29922; --green: #3fb950; --red: #f85149; --cyan: #39c5cf;
- --violet: #8b5cf6; --emerald: #10b981;
- --pink: #db61a2; --teal: #2dd4bf;
- }
- * { margin:0; padding:0; box-sizing:border-box; }
- body { background:var(--bg); color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:12px; overflow:hidden; height:100vh; display:flex; flex-direction:column; }
- /* ===== Toolbar ===== */
- .toolbar {
- background:var(--bg2); border-bottom:1px solid var(--border);
- display:flex; align-items:center; gap:6px; padding:4px 12px; height:36px; flex-shrink:0;
- }
- .toolbar .title { font-size:11px; font-weight:600; color:var(--text); margin-right:8px; }
- .toolbar .sep { width:1px; height:18px; background:var(--border); }
- .tb-btn {
- background:var(--bg3); border:1px solid var(--border); color:var(--text2);
- padding:3px 10px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:10px;
- }
- .tb-btn:hover { background:var(--border); color:var(--text); }
- .tb-btn.primary { background:var(--green); color:#000; border-color:var(--green); font-weight:600; }
- .tb-btn.primary:hover { opacity:0.9; }
- .tb-btn.danger { border-color:var(--red); color:var(--red); }
- .tb-btn.danger:hover { background:var(--red); color:#fff; }
- /* ===== Canvas container ===== */
- .canvas-wrap { flex:1; overflow:auto; position:relative; }
- .canvas {
- position:relative; min-width:4000px; min-height:2500px;
- background-image:radial-gradient(circle, var(--border) 1px, transparent 1px);
- background-size:20px 20px;
- }
- .empty-msg {
- position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
- color:var(--text2); font-size:14px; text-align:center;
- }
- /* ===== Nodes ===== */
- .node {
- position:absolute; width:240px; background:var(--bg3); border:1px solid var(--border);
- border-radius:8px; cursor:grab; transition:border-color 0.2s, box-shadow 0.2s;
- z-index:2; user-select:none;
- }
- .node:hover { border-color:var(--blue); box-shadow:0 0 12px rgba(88,166,255,0.15); }
- .node.selected { border-color:var(--blue); box-shadow:0 0 20px rgba(88,166,255,0.25); }
- .node.dragging { cursor:grabbing; z-index:10; opacity:0.92; box-shadow:0 0 24px rgba(88,166,255,0.3); }
- .node-header { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--border); }
- .node-icon { width:24px; height:24px; border-radius:5px; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:700; color:#fff; flex-shrink:0; }
- .node-title { font-size:11px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
- .node-type { font-size:9px; color:var(--text2); }
- .node-desc { font-size:9px; color:var(--cyan); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:170px; }
- .node-body { padding:6px 10px; font-size:10px; color:var(--text2); overflow:hidden; }
- .node-body .field { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:1px; }
- /* Rich body styles */
- .node-section { margin-bottom:5px; }
- .node-section:last-child { margin-bottom:0; }
- .node-section-title {
- font-size:8px; color:var(--text2); text-transform:uppercase;
- letter-spacing:0.5px; margin-bottom:2px; padding-bottom:2px;
- border-bottom:1px dashed var(--border);
- }
- .node-field { display:flex; align-items:flex-start; gap:4px; margin-bottom:2px; line-height:1.3; }
- .node-field:last-child { margin-bottom:0; }
- .node-label { color:var(--text2); min-width:36px; flex-shrink:0; font-size:9px; }
- .node-value {
- color:var(--text); font-family:'SF Mono','Fira Code',monospace;
- font-size:9px; background:rgba(255,255,255,0.05); padding:1px 4px;
- border-radius:2px; word-break:break-all; max-width:160px;
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
- }
- .node-value.var { color:var(--cyan); }
- .node-value.ref { color:var(--purple); }
- .node-value.file { color:var(--orange); }
- .node-io-list { display:flex; flex-direction:column; gap:2px; }
- .node-io-item {
- display:flex; align-items:center; gap:4px; font-size:9px;
- background:rgba(255,255,255,0.04); padding:2px 4px; border-radius:2px;
- }
- .node-io-key { color:var(--text); font-family:'SF Mono','Fira Code',monospace; min-width:44px; }
- .node-io-key.is-var { color:var(--cyan); }
- .node-io-key.is-file { color:var(--orange); }
- .node-io-arrow { color:var(--text2); }
- .node-io-value { color:var(--cyan); font-family:'SF Mono','Fira Code',monospace; }
- .node-docs-list { display:flex; flex-wrap:wrap; gap:3px; }
- .node-doc-tag {
- background:rgba(210,153,34,0.2); color:var(--orange);
- padding:1px 4px; border-radius:2px; font-size:8px;
- font-family:'SF Mono','Fira Code',monospace;
- max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
- }
- .node-condition {
- background:rgba(163,113,247,0.15); color:var(--purple);
- padding:2px 6px; border-radius:2px; font-size:9px;
- font-family:'SF Mono','Fira Code',monospace; margin-top:4px;
- }
- .node-footer {
- padding:4px 8px; border-top:1px dashed var(--border);
- font-size:9px; color:var(--purple);
- }
- /* I/O audit badges */
- .node-io { display:flex; gap:3px; flex-wrap:wrap; padding:4px 10px 6px; border-top:1px solid var(--border); }
- .io-badge { font-size:8px; padding:1px 5px; border-radius:3px; font-family:inherit; }
- .io-badge.var-in { background:rgba(88,166,255,0.15); color:var(--blue); }
- .io-badge.var-out { background:rgba(63,185,80,0.15); color:var(--green); }
- .io-badge.doc { background:rgba(210,153,34,0.15); color:var(--orange); }
- .io-badge.file { background:rgba(163,113,247,0.15); color:var(--purple); }
- /* Node type colors — original 11 */
- .type-LLM .node-icon { background:linear-gradient(135deg, #6366f1, #8b5cf6); }
- .type-Service .node-icon { background:linear-gradient(135deg, var(--green), #2ea043); }
- .type-API .node-icon { background:linear-gradient(135deg, var(--cyan), #1a7f8a); }
- .type-Write .node-icon { background:linear-gradient(135deg, var(--orange), #b87f12); }
- .type-Set .node-icon { background:linear-gradient(135deg, var(--blue), #1f6feb); }
- .type-Branch .node-icon { background:linear-gradient(135deg, var(--purple), #8957e5); }
- .type-Loop .node-icon { background:linear-gradient(135deg, #db61a2, #bf4b8a); }
- .type-Stop .node-icon { background:linear-gradient(135deg, var(--red), #da3633); }
- .type-Component .node-icon { background:linear-gradient(135deg, #2dd4bf, #14b8a6); }
- .type-Pause .node-icon { background:linear-gradient(135deg, var(--violet), #7c3aed); }
- .type-Fork .node-icon { background:linear-gradient(135deg, var(--emerald), #059669); }
- /* New in Spec 3.16 */
- .type-Download .node-icon { background:linear-gradient(135deg, #38bdf8, #0284c7); }
- .type-Unzip .node-icon { background:linear-gradient(135deg, #facc15, #ca8a04); }
- /* Type accent bar */
- .node::before {
- content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px;
- border-radius:0 2px 2px 0;
- }
- .type-LLM::before { background:#6366f1; }
- .type-Service::before { background:var(--green); }
- .type-API::before { background:var(--cyan); }
- .type-Write::before { background:var(--orange); }
- .type-Set::before { background:var(--blue); }
- .type-Branch::before { background:var(--purple); }
- .type-Loop::before { background:#db61a2; }
- .type-Stop::before { background:var(--red); }
- .type-Component::before { background:#2dd4bf; }
- .type-Pause::before { background:var(--violet); }
- .type-Fork::before { background:var(--emerald); }
- /* New in Spec 3.16 */
- .type-Download::before { background:#38bdf8; }
- .type-Unzip::before { background:#facc15; }
- /* Status overlays */
- .node.status-running { border-color:var(--orange); animation:pulse-border 1.5s ease-in-out infinite; }
- .node.status-done { border-color:var(--green); }
- .node.status-error { border-color:var(--red); }
- .node.status-paused { border-color:var(--violet); animation:pulse-border-purple 1.5s ease-in-out infinite; }
- .node.status-skipped { border-color:var(--text2); opacity:0.5; }
- .status-badge { position:absolute; top:-6px; right:-6px; width:16px; height:16px; border-radius:50%; font-size:9px; display:flex; align-items:center; justify-content:center; z-index:5; }
- .status-badge.running { background:var(--orange); animation:spin 1s linear infinite; }
- .status-badge.done { background:var(--green); }
- .status-badge.error { background:var(--red); }
- .status-badge.paused { background:var(--violet); animation:pulse-badge-purple 2s ease-in-out infinite; }
- .status-badge.skipped { background:var(--text2); opacity:0.5; }
- @keyframes pulse-border { 0%,100% { box-shadow:0 0 8px rgba(210,153,34,0.3); } 50% { box-shadow:0 0 20px rgba(210,153,34,0.6); } }
- @keyframes pulse-border-purple { 0%,100% { box-shadow:0 0 8px rgba(139,92,246,0.3); } 50% { box-shadow:0 0 20px rgba(139,92,246,0.6); } }
- @keyframes pulse-badge-purple { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
- @keyframes spin { to { transform:rotate(360deg); } }
- /* ===== Ports ===== */
- .port { position:absolute; width:8px; height:8px; border-radius:50%; background:var(--border); border:1px solid var(--text2); z-index:3; }
- .port-in { top:-4px; left:50%; transform:translateX(-50%); }
- .port-out { bottom:-4px; left:50%; transform:translateX(-50%); }
- /* ===== Connections (SVG) ===== */
- .connections { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:1; }
- .conn-path { fill:none; stroke-width:2; }
- .conn-path.serial { stroke:var(--blue); }
- .conn-path.parallel { stroke:var(--purple); stroke-dasharray:6 3; }
- .conn-path.branch-case { stroke:var(--orange); stroke-dasharray:4 2 1 2; }
- .conn-label { fill:var(--text2); font-size:9px; font-family:inherit; }
- svg defs marker path { fill:var(--blue); }
- /* ===== Legend ===== */
- .legend {
- position:fixed; bottom:12px; left:12px; background:var(--bg2); border:1px solid var(--border);
- border-radius:6px; padding:8px 12px; font-size:9px; color:var(--text2); display:flex; gap:12px; z-index:10;
- }
- .legend-item { display:flex; align-items:center; gap:4px; }
- .legend-line { width:20px; height:2px; }
- .legend-line.serial { background:var(--blue); }
- .legend-line.parallel { background:var(--purple); background:repeating-linear-gradient(90deg, var(--purple) 0 6px, transparent 6px 9px); }
- .legend-line.branch { background:var(--orange); }
- /* ===== Minimap ===== */
- .minimap {
- position:fixed; bottom:12px; right:12px; width:180px; height:100px;
- background:var(--bg2); border:1px solid var(--border); border-radius:6px;
- overflow:hidden; z-index:10; cursor:crosshair;
- }
- .minimap canvas { width:100%; height:100%; }
- /* ===== Toast ===== */
- .toast {
- position:fixed; top:48px; left:50%; transform:translateX(-50%);
- background:var(--bg3); border:1px solid var(--border); border-radius:6px;
- padding:6px 16px; font-size:11px; color:var(--text); z-index:100;
- opacity:0; transition:opacity 0.3s;
- }
- .toast.show { opacity:1; }
- /* ===== Context Menu ===== */
- .ctx-menu {
- position:fixed; background:var(--bg2); border:1px solid var(--border);
- border-radius:6px; padding:4px 0; z-index:200; min-width:180px;
- box-shadow:0 4px 16px rgba(0,0,0,0.5); display:none;
- }
- .ctx-menu.show { display:block; }
- .ctx-item {
- padding:6px 14px; font-size:11px; color:var(--text); cursor:pointer;
- display:flex; align-items:center; gap:8px; font-family:inherit;
- }
- .ctx-item:hover { background:var(--bg3); }
- .ctx-item.disabled { color:var(--text2); cursor:default; opacity:0.5; }
- .ctx-item.disabled:hover { background:transparent; }
- .ctx-sep { height:1px; background:var(--border); margin:4px 0; }
- .ctx-item .ctx-icon { font-size:12px; width:16px; text-align:center; }
- .ctx-item .ctx-label { flex:1; }
- .ctx-item .ctx-hint { font-size:9px; color:var(--text2); }
- /* ===== Node Editor Modal ===== */
- .modal-overlay {
- position:fixed; top:0; left:0; width:100%; height:100%;
- background:rgba(0,0,0,0.6); z-index:300; display:none;
- align-items:center; justify-content:center;
- }
- .modal-overlay.show { display:flex; }
- .modal {
- background:var(--bg2); border:1px solid var(--border); border-radius:10px;
- width:520px; max-height:80vh; display:flex; flex-direction:column;
- box-shadow:0 8px 32px rgba(0,0,0,0.5);
- }
- .modal-header {
- padding:12px 16px; border-bottom:1px solid var(--border);
- display:flex; align-items:center; gap:8px;
- }
- .modal-header .modal-title { font-size:12px; font-weight:600; color:var(--text); flex:1; }
- .modal-header .modal-close {
- background:none; border:none; color:var(--text2); font-size:16px;
- cursor:pointer; padding:2px 6px; border-radius:4px;
- }
- .modal-header .modal-close:hover { background:var(--bg3); color:var(--text); }
- .modal-body { padding:12px 16px; overflow-y:auto; flex:1; }
- .modal-footer {
- padding:10px 16px; border-top:1px solid var(--border);
- display:flex; justify-content:flex-end; gap:8px;
- }
- .modal-footer .tb-btn { padding:5px 14px; font-size:11px; }
- /* Editor fields */
- .editor-field { margin-bottom:10px; }
- .editor-field:last-child { margin-bottom:0; }
- .editor-label { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; }
- .editor-input {
- width:100%; background:var(--bg); border:1px solid var(--border);
- color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:11px;
- padding:6px 8px; border-radius:4px; resize:vertical;
- }
- .editor-input:focus { outline:none; border-color:var(--blue); }
- .editor-json {
- width:100%; background:var(--bg); border:1px solid var(--border);
- color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:10px;
- padding:8px; border-radius:4px; resize:vertical; min-height:120px;
- line-height:1.5;
- }
- .editor-json:focus { outline:none; border-color:var(--blue); }
- .editor-hint { font-size:9px; color:var(--text2); margin-top:3px; }
- .editor-error { font-size:9px; color:var(--red); margin-top:3px; display:none; }
- /* Node status info in editor */
- .editor-status {
- display:flex; align-items:center; gap:6px; padding:6px 8px;
- background:rgba(255,255,255,0.03); border-radius:4px; margin-bottom:10px;
- }
- .editor-status-dot { width:8px; height:8px; border-radius:50%; }
- .editor-status-dot.done { background:var(--green); }
- .editor-status-dot.running { background:var(--orange); }
- .editor-status-dot.error { background:var(--red); }
- .editor-status-dot.paused { background:var(--violet); }
- .editor-status-dot.skipped { background:var(--text2); }
- .editor-status-dot.pending { background:var(--border); }
- .editor-status-text { font-size:10px; color:var(--text2); }
- /* Pause button (amber) */
- .tb-btn.warn { border-color:var(--orange); color:var(--orange); }
- .tb-btn.warn:hover { background:var(--orange); color:#000; }
- </style>
- </head>
- <body>
- <!-- Toolbar -->
- <div class="toolbar">
- <span class="title" id="wfTitle">VL Workflow DAG</span>
- <div class="sep"></div>
- <button class="tb-btn" onclick="autoLayout();render();" title="Re-arrange nodes">↺ Layout</button>
- <button class="tb-btn" onclick="downloadPNG()" title="Export as PNG image">📷 PNG</button>
- <div class="sep"></div>
- <button class="tb-btn" onclick="exportJSON()" title="Export workflow JSON">↧ Export</button>
- <button class="tb-btn" onclick="importJSON()" title="Import workflow JSON">↥ Import</button>
- <div class="sep"></div>
- <button class="tb-btn primary" onclick="runWorkflow()" title="Execute this workflow" id="runBtn">▶ Run</button>
- <button class="tb-btn warn" onclick="pauseExecution()" title="Pause execution" id="pauseBtn" style="display:none;">⏸ Pause</button>
- <button class="tb-btn danger" onclick="stopExecution()" title="Stop execution" id="stopBtn" style="display:none;">■ Stop</button>
- <div style="flex:1;"></div>
- <span id="statusLabel" style="font-size:10px;color:var(--text2);"></span>
- </div>
- <!-- Canvas -->
- <div class="canvas-wrap" id="canvasWrap">
- <div class="empty-msg" id="emptyMsg">No workflow loaded.<br>Select a workflow to visualize.</div>
- <div class="canvas" id="canvas" style="display:none;">
- <svg class="connections" id="connSvg">
- <defs>
- <marker id="arrowSerial" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
- <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--blue)"/>
- </marker>
- <marker id="arrowParallel" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
- <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--purple)"/>
- </marker>
- <marker id="arrowBranch" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
- <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--orange)"/>
- </marker>
- </defs>
- </svg>
- <div class="nodes-layer" id="nodesLayer"></div>
- </div>
- </div>
- <!-- Legend -->
- <div class="legend" id="legend" style="display:none;">
- <div class="legend-item"><div class="legend-line serial"></div>Serial (next)</div>
- <div class="legend-item"><div class="legend-line parallel"></div>Parallel (children)</div>
- <div class="legend-item"><div class="legend-line branch"></div>Branch (case)</div>
- </div>
- <!-- Minimap -->
- <div class="minimap" id="minimap" style="display:none;">
- <canvas id="minimapCanvas"></canvas>
- </div>
- <!-- Toast -->
- <div class="toast" id="toast"></div>
- <!-- Context Menu -->
- <div class="ctx-menu" id="ctxMenu">
- <div class="ctx-item" id="ctxRerun" onclick="ctxRerunFromHere()">
- <span class="ctx-icon">▶</span><span class="ctx-label">Re-run from here</span>
- </div>
- <div class="ctx-item" id="ctxEditRerun" onclick="ctxEditAndRerun()">
- <span class="ctx-icon">✎</span><span class="ctx-label">Edit inputs & re-run</span>
- </div>
- <div class="ctx-sep"></div>
- <div class="ctx-item" onclick="ctxViewDetails()">
- <span class="ctx-icon">🔍</span><span class="ctx-label">View details</span>
- </div>
- <div class="ctx-item" onclick="ctxCopyId()">
- <span class="ctx-icon">📋</span><span class="ctx-label">Copy node ID</span><span class="ctx-hint" id="ctxNodeIdHint"></span>
- </div>
- </div>
- <!-- Node Editor Modal -->
- <div class="modal-overlay" id="editorOverlay">
- <div class="modal">
- <div class="modal-header">
- <span class="modal-title" id="editorTitle">Edit Node Inputs</span>
- <button class="modal-close" onclick="closeEditor()">×</button>
- </div>
- <div class="modal-body" id="editorBody"></div>
- <div class="modal-footer">
- <button class="tb-btn" onclick="closeEditor()">Cancel</button>
- <button class="tb-btn primary" onclick="editorRerun()" id="editorRerunBtn">▶ Re-run from here</button>
- </div>
- </div>
- </div>
- <!-- Hidden file input for import -->
- <input type="file" id="importInput" accept=".json" style="display:none">
- <script>
- // ===== VL Workflow Editor v2.0 (Spec 3.16) =====
- // Canonical source: vl-workflow-engine/ui/workflow-editor.html
- // Consumed by: VL-Code, VLCode-Lite, VLClaw
- const RESERVED_NEXT = new Set(['RETURN', 'BREAK']);
- const NODE_ICONS = {
- LLM:'AI', Service:'SV', API:'AP', Component:'CP',
- Set:'=', Write:'WR', Branch:'BR', Loop:'LP', Stop:'ST',
- Pause:'\u23F8', Fork:'FK',
- Download:'DL', Unzip:'UZ'
- };
- const KNOWN_TYPES = ['LLM','Service','API','Component','Set','Write','Branch','Loop','Stop','Pause','Fork','Download','Unzip'];
- let state = { nodes: [], connections: [], selectedNodeId: null, registry: {}, dragging: null };
- let _currentWorkflowJson = null;
- let _executing = false;
- let _eventSource = null;
- let _lastCheckpoint = null; // latest checkpoint from engine
- let _currentRunID = null; // active run ID
- let _ctxTargetNode = null; // node targeted by context menu
- let _editorNode = null; // node being edited in modal
- // ===== Utilities =====
- function $(id) { return document.getElementById(id); }
- function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') : ''; }
- function trunc(s, n) { return s && s.length > n ? s.substring(0, n) + '\u2026' : (s || ''); }
- function resolveDocName(docId) { return state.registry?.docs?.[docId] || `Doc #${docId}`; }
- function toast(msg) {
- const t = $('toast');
- t.textContent = msg;
- t.classList.add('show');
- setTimeout(() => t.classList.remove('show'), 2500);
- }
- // ===== Parse workflow JSON into internal state =====
- function parseWorkflow(json) {
- _currentWorkflowJson = json;
- state.nodes = [];
- state.connections = [];
- state.registry = json.registry || {};
- if (!json?.steps?.length) return;
- const steps = json.steps;
- $('wfTitle').textContent = json.name || 'VL Workflow DAG';
- // Create nodes
- for (const step of steps) {
- const type = getStepType(step);
- state.nodes.push({
- id: step.id,
- type,
- x: step._x || 0,
- y: step._y || 0,
- data: step,
- status: null
- });
- }
- // Create connections
- let connId = 0;
- for (const step of steps) {
- // FIX #1: exclude both RETURN and BREAK (reserved keywords, not real nodes)
- if (step.next && !RESERVED_NEXT.has(step.next)) {
- state.connections.push({ id: 'c' + (connId++), from: step.id, to: step.next, type: 'serial' });
- }
- if (step.children?.length) {
- for (const childId of step.children) {
- state.connections.push({ id: 'c' + (connId++), from: step.id, to: childId, type: 'parallel' });
- }
- }
- // Branch cases — support both array [[expr, target]] and object {expr: target}
- if (step.branches) {
- if (Array.isArray(step.branches)) {
- for (const [expr, targetId] of step.branches) {
- state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
- }
- } else {
- for (const [expr, targetId] of Object.entries(step.branches)) {
- state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
- }
- }
- }
- // Legacy: cases field
- if (step.cases) {
- for (const [expr, targetId] of Object.entries(step.cases)) {
- state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
- }
- }
- }
- const hasPos = state.nodes.some(n => n.x > 0 || n.y > 0);
- if (!hasPos) autoLayout();
- // Restore checkpoint state (node statuses) if available
- restoreFromStorage();
- render();
- }
- function getStepType(step) {
- if (step.type) return step.type;
- const id = step.id || '';
- const prefix = id.split('_')[0];
- // FIX #3: include Download and Unzip in known types
- return KNOWN_TYPES.includes(prefix) ? prefix : 'LLM';
- }
- // ===== Auto Layout (topological, with barycenter crossing reduction) =====
- function autoLayout() {
- const LAYER_GAP = 300, NODE_GAP = 180, START_X = 80, START_Y = 60;
- const nodeMap = new Map(state.nodes.map(n => [n.id, n]));
- const succs = new Map(), preds = new Map();
- for (const n of state.nodes) { succs.set(n.id, []); preds.set(n.id, []); }
- for (const c of state.connections) {
- if (succs.has(c.from) && preds.has(c.to)) {
- succs.get(c.from).push(c.to);
- preds.get(c.to).push(c.from);
- }
- }
- // Find roots
- const roots = state.nodes.filter(n => (preds.get(n.id)?.length || 0) === 0).map(n => n.id);
- if (roots.length === 0 && state.nodes.length > 0) roots.push(state.nodes[0].id);
- // Longest-path layer assignment (with cycle protection)
- const layers = new Map();
- const inStack = new Set();
- function assignLayer(id, depth) {
- if (inStack.has(id)) return;
- inStack.add(id);
- layers.set(id, Math.max(layers.get(id) || 0, depth));
- for (const s of (succs.get(id) || [])) assignLayer(s, depth + 1);
- inStack.delete(id);
- }
- for (const r of roots) assignLayer(r, 0);
- for (const n of state.nodes) { if (!layers.has(n.id)) layers.set(n.id, 0); }
- // Group by layer
- const layerGroups = new Map();
- for (const [id, layer] of layers) {
- if (!layerGroups.has(layer)) layerGroups.set(layer, []);
- layerGroups.get(layer).push(id);
- }
- // Barycenter ordering (reduce edge crossings)
- const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
- for (let pass = 0; pass < 4; pass++) {
- for (let li = 1; li < sortedLayers.length; li++) {
- const group = layerGroups.get(sortedLayers[li]);
- const prevGroup = layerGroups.get(sortedLayers[li - 1]);
- const prevIdx = new Map(prevGroup.map((id, i) => [id, i]));
- group.sort((a, b) => {
- const aParents = preds.get(a)?.filter(p => prevIdx.has(p)) || [];
- const bParents = preds.get(b)?.filter(p => prevIdx.has(p)) || [];
- const aAvg = aParents.length ? aParents.reduce((s, p) => s + prevIdx.get(p), 0) / aParents.length : 0;
- const bAvg = bParents.length ? bParents.reduce((s, p) => s + prevIdx.get(p), 0) / bParents.length : 0;
- return aAvg - bAvg;
- });
- }
- }
- // Position nodes
- for (const layer of sortedLayers) {
- const group = layerGroups.get(layer);
- const totalHeight = group.length * NODE_GAP;
- const startY = Math.max(START_Y, 400 - totalHeight / 2);
- for (let i = 0; i < group.length; i++) {
- const node = nodeMap.get(group[i]);
- if (node) {
- node.x = START_X + layer * LAYER_GAP;
- node.y = startY + i * NODE_GAP;
- }
- }
- }
- }
- // ===== Compute I/O audit for a node =====
- function computeIO(step) {
- const io = { varsIn: [], varsOut: [], docs: [], files: [] };
- if (!step) return io;
- const data = step.in || step;
- // Input vars (references starting with = or $)
- const jsonStr = JSON.stringify(data);
- const varRefs = jsonStr.match(/=\$?[\w.]+|"\$[\w.]+"/g) || [];
- for (const ref of varRefs) {
- const clean = ref.replace(/[="]/g, '');
- if (clean.startsWith('$') && !io.varsIn.includes(clean)) io.varsIn.push(clean);
- }
- // Docs
- if (step.in?.docs) io.docs = step.in.docs;
- // Output vars
- if (step.out) {
- for (const [key, val] of Object.entries(step.out)) {
- if (key.startsWith('$')) io.varsOut.push(key);
- else if (key.startsWith('/')) io.files.push(key);
- }
- }
- // Source (loop / download / unzip)
- if (step.source) {
- const src = String(step.source);
- if (src.startsWith('=$') || src.startsWith('$')) io.varsIn.push(src.replace(/^=/, ''));
- }
- // FIX #4: while expression can reference variables
- if (step.while) {
- const whileStr = String(step.while);
- const whileRefs = whileStr.match(/\$[\w.]+/g) || [];
- for (const ref of whileRefs) {
- if (!io.varsIn.includes(ref)) io.varsIn.push(ref);
- }
- }
- return io;
- }
- // ===== Type-Specific Body Renderers =====
- function renderLLMBody(data) {
- let html = '';
- const docs = data.in?.docs?.length ? data.in.docs : null;
- if (docs) {
- html += `<div class="node-section"><div class="node-section-title">Documents</div>
- <div class="node-docs-list">${docs.map(d =>
- `<span class="node-doc-tag" title="${esc(resolveDocName(d))}">${esc(d)}: ${trunc(resolveDocName(d), 16)}</span>`
- ).join('')}</div></div>`;
- }
- if (data.in?.model || data.model) {
- html += `<div class="node-field"><span class="node-label">model</span><span class="node-value">${trunc(data.in?.model || data.model, 20)}</span></div>`;
- }
- if (data.in?.max_tokens) {
- html += `<div class="node-field"><span class="node-label">tokens</span><span class="node-value">${data.in.max_tokens}</span></div>`;
- }
- if (data.in?.messages?.length) {
- html += `<div class="node-field"><span class="node-label">msgs</span><span class="node-value">${data.in.messages.length} messages</span></div>`;
- }
- html += renderOutSection(data.out);
- return html;
- }
- function renderServiceBody(data) {
- let html = '';
- if (data.serviceId) {
- html += `<div class="node-field"><span class="node-label">service</span><span class="node-value ref">${trunc(data.serviceId, 20)}</span></div>`;
- }
- html += renderInputSection(data.in);
- html += renderOutSection(data.out);
- return html;
- }
- function renderAPIBody(data) {
- let html = '';
- const apiId = data.apiId || '';
- const apis = state.registry?.apis || [];
- const apiDef = apis.find(a => a.id === apiId || a === apiId);
- if (apiDef && typeof apiDef === 'object') {
- html += `<div class="node-field"><span class="node-label">${esc(apiDef.method || 'POST')}</span><span class="node-value ref" title="${esc(apiDef.url || '')}">${trunc(apiDef.desc || apiDef.url || apiId, 20)}</span></div>`;
- } else {
- html += `<div class="node-field"><span class="node-label">api</span><span class="node-value ref">${trunc(apiId || data.id || '', 20)}</span></div>`;
- }
- html += renderInputSection(data.in);
- html += renderOutSection(data.out);
- return html;
- }
- function renderComponentBody(data) {
- let html = '';
- if (data.componentId) {
- html += `<div class="node-field"><span class="node-label">comp</span><span class="node-value ref">${trunc(data.componentId, 20)}</span></div>`;
- }
- html += renderInputSection(data.in);
- html += renderOutSection(data.out);
- return html;
- }
- function renderSetBody(data) {
- return `<div class="node-section"><div class="node-section-title">Assignment</div>
- <div class="node-io-item"><span class="node-io-key is-var">${esc(data.target || '$var')}</span>
- <span class="node-io-arrow">\u2190</span>
- <span class="node-io-value">${trunc(String(data.value || ''), 18)}</span></div></div>`;
- }
- function renderWriteBody(data) {
- const modeStr = data.mode ? ` [${esc(data.mode)}]` : '';
- return `<div class="node-section"><div class="node-section-title">Write Artifact${modeStr}</div>
- <div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target || '', 20)}</span></div>
- <div class="node-field"><span class="node-label">value</span><span class="node-value var">${trunc(String(data.value || ''), 18)}</span></div></div>`;
- }
- function renderBranchBody(data) {
- const cases = Array.isArray(data.branches) ? data.branches
- : data.branches && typeof data.branches === 'object' ? Object.entries(data.branches)
- : data.cases ? Object.entries(data.cases) : [];
- if (cases.length === 0) return '';
- let html = '<div class="node-section"><div class="node-section-title">Cases</div>';
- cases.slice(0, 4).forEach(([cond, target]) => {
- html += `<div class="node-io-item"><span class="node-io-key">${trunc(cond, 14)}</span>
- <span class="node-io-arrow">\u2192</span>
- <span class="node-io-value">${trunc(target || '?', 12)}</span></div>`;
- });
- if (cases.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${cases.length - 4} more</div>`;
- html += '</div>';
- return html;
- }
- // FIX #2: Loop body now handles both source mode and while mode (Spec 3.16)
- function renderLoopBody(data) {
- const isWhile = !!data.while;
- let html = '<div class="node-section"><div class="node-section-title">Loop Config</div>';
- if (isWhile) {
- // While mode (3.16)
- html += `<div class="node-field"><span class="node-label">while</span><span class="node-value var">${trunc(data.while, 20)}</span></div>`;
- if (data.maxIterations != null) {
- html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
- }
- } else {
- // Source mode (original)
- html += `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source || '', 16)}</span></div>`;
- if (data.maxIterations != null) {
- html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
- }
- }
- html += `<div class="node-field"><span class="node-label">mode</span><span class="node-value">${esc(data.mode || 'parallel')}</span></div>`;
- html += '</div>';
- return html;
- }
- function renderStopBody() {
- return '<div class="node-section"><div class="node-field"><span class="node-value" style="color:var(--red);">Workflow terminates here</span></div></div>';
- }
- function renderPauseBody(data) {
- const msg = data.in?.message || data.message || data.reason || '';
- const displayKeys = data.in?.display ? Object.keys(data.in.display) : [];
- let html = '<div class="node-section"><div class="node-section-title">\u23F8 Pause \u2014 Waiting for Approval</div>';
- if (msg) html += `<div class="node-field"><span class="node-label">msg</span><span class="node-value">${trunc(msg, 24)}</span></div>`;
- if (displayKeys.length > 0) html += `<div class="node-field"><span class="node-label">show</span><span class="node-value">${trunc(displayKeys.join(', '), 24)}</span></div>`;
- html += '</div>';
- html += renderOutSection(data.out);
- return html;
- }
- function renderForkBody(data) {
- const children = data.children || [];
- return `<div class="node-section"><div class="node-section-title">Fork Config</div>
- ${data.source ? `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source, 16)}</span></div>` : ''}
- <div class="node-field"><span class="node-label">branches</span><span class="node-value">${children.length} parallel</span></div></div>`;
- }
- // FIX #3: New renderers for Download and Unzip (Spec 3.16)
- function renderDownloadBody(data) {
- let html = '<div class="node-section"><div class="node-section-title">Download</div>';
- const src = data.source || '';
- const srcStr = typeof src === 'object' ? (src.url || JSON.stringify(src)) : String(src);
- html += `<div class="node-field"><span class="node-label">source</span><span class="node-value ref">${trunc(srcStr, 20)}</span></div>`;
- if (data.target) {
- html += `<div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target, 20)}</span></div>`;
- }
- if (data.routeByExt) {
- const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
- html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
- }
- if (data.defaultDir) {
- html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
- }
- html += '</div>';
- html += renderOutSection(data.out);
- return html;
- }
- function renderUnzipBody(data) {
- let html = '<div class="node-section"><div class="node-section-title">Unzip</div>';
- if (data.source) {
- html += `<div class="node-field"><span class="node-label">source</span><span class="node-value file">${trunc(String(data.source), 20)}</span></div>`;
- }
- if (data.routeByExt) {
- const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
- html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
- }
- if (data.defaultDir) {
- html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
- }
- if (data.overwrite != null) {
- html += `<div class="node-field"><span class="node-label">overwrite</span><span class="node-value">${data.overwrite}</span></div>`;
- }
- html += '</div>';
- html += renderOutSection(data.out);
- return html;
- }
- // --- Shared sub-renderers ---
- function renderInputSection(inData) {
- if (!inData || typeof inData !== 'object') return '';
- const skip = new Set(['model', 'stream', 'messages', 'docs', 'max_tokens', 'output_config']);
- const entries = Object.entries(inData).filter(([k]) => !skip.has(k));
- if (entries.length === 0) return '';
- let html = '<div class="node-section"><div class="node-section-title">Input</div><div class="node-io-list">';
- entries.slice(0, 4).forEach(([key, val]) => {
- const valStr = typeof val === 'object' ? JSON.stringify(val) : String(val);
- html += `<div class="node-io-item"><span class="node-io-key">${trunc(key, 10)}</span>
- <span class="node-io-arrow">\u2190</span>
- <span class="node-io-value">${trunc(valStr, 14)}</span></div>`;
- });
- if (entries.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${entries.length - 4} more</div>`;
- html += '</div></div>';
- return html;
- }
- function renderOutSection(out) {
- if (!out || typeof out !== 'object' || Array.isArray(out) || Object.keys(out).length === 0) return '';
- let html = '<div class="node-section"><div class="node-section-title">Output</div><div class="node-io-list">';
- Object.entries(out).slice(0, 4).forEach(([key, val]) => {
- const isVar = key.startsWith('$');
- const isFile = key.startsWith('/') || key.startsWith('{');
- const keyClass = isVar ? 'is-var' : isFile ? 'is-file' : '';
- html += `<div class="node-io-item"><span class="node-io-key ${keyClass}">${trunc(key, 16)}</span>
- <span class="node-io-arrow">\u2190</span>
- <span class="node-io-value">${trunc(String(val), 14)}</span></div>`;
- });
- if (Object.keys(out).length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${Object.keys(out).length - 4} more</div>`;
- html += '</div></div>';
- return html;
- }
- // ===== Render =====
- function render() {
- $('emptyMsg').style.display = 'none';
- $('canvas').style.display = 'block';
- $('legend').style.display = 'flex';
- $('minimap').style.display = 'block';
- renderNodes();
- renderConnections();
- setTimeout(updateMinimap, 100);
- }
- function renderNodes() {
- const layer = $('nodesLayer');
- layer.innerHTML = '';
- for (const node of state.nodes) {
- const div = document.createElement('div');
- const type = node.type || 'LLM';
- div.className = `node type-${type}` + (node.id === state.selectedNodeId ? ' selected' : '') + (node.status ? ` status-${node.status}` : '');
- div.id = `node-${node.id}`;
- div.style.left = node.x + 'px';
- div.style.top = node.y + 'px';
- const icon = NODE_ICONS[type] || '?';
- const title = node.data?.meta?.title || node.id;
- const desc = node.data?.meta?.description || '';
- const data = node.data || {};
- // Body: type-specific rendering
- let bodyHtml = '';
- switch (type) {
- case 'LLM': bodyHtml = renderLLMBody(data); break;
- case 'Service': bodyHtml = renderServiceBody(data); break;
- case 'API': bodyHtml = renderAPIBody(data); break;
- case 'Component': bodyHtml = renderComponentBody(data); break;
- case 'Set': bodyHtml = renderSetBody(data); break;
- case 'Write': bodyHtml = renderWriteBody(data); break;
- case 'Branch': bodyHtml = renderBranchBody(data); break;
- case 'Loop': bodyHtml = renderLoopBody(data); break;
- case 'Stop': bodyHtml = renderStopBody(); break;
- case 'Pause': bodyHtml = renderPauseBody(data); break;
- case 'Fork': bodyHtml = renderForkBody(data); break;
- case 'Download': bodyHtml = renderDownloadBody(data); break;
- case 'Unzip': bodyHtml = renderUnzipBody(data); break;
- default:
- if (data.in) {
- const keys = Object.keys(data.in).slice(0, 3);
- bodyHtml = keys.map(k => `<div class="field">${esc(k)}: ${esc(String(data.in[k]).substring(0, 35))}</div>`).join('');
- }
- }
- // Condition badge
- const conditionHtml = data.if ? `<div class="node-condition">if: ${trunc(data.if, 28)}</div>` : '';
- // BREAK indicator for nodes inside Loop children
- const breakHtml = data.next === 'BREAK' ? `<div class="node-condition" style="background:rgba(248,81,73,0.15);color:var(--red);">next: BREAK</div>` : '';
- // I/O audit badges
- const io = computeIO(data);
- let ioHtml = '';
- const badges = [];
- for (const v of io.varsIn.slice(0, 3)) badges.push(`<span class="io-badge var-in">\u2193${esc(v)}</span>`);
- for (const v of io.varsOut.slice(0, 3)) badges.push(`<span class="io-badge var-out">\u2191${esc(v)}</span>`);
- for (const d of io.docs.slice(0, 2)) badges.push(`<span class="io-badge doc">\u{1F4C4}${esc(d)}</span>`);
- for (const f of io.files.slice(0, 2)) badges.push(`<span class="io-badge file">\u{1F4C1}${esc(f.substring(0, 20))}</span>`);
- if (badges.length) ioHtml = `<div class="node-io">${badges.join('')}</div>`;
- // Status badge
- let badgeHtml = '';
- if (node.status === 'running') badgeHtml = '<div class="status-badge running">⚙</div>';
- else if (node.status === 'done') badgeHtml = '<div class="status-badge done">✓</div>';
- else if (node.status === 'error') badgeHtml = '<div class="status-badge error">✗</div>';
- else if (node.status === 'paused') badgeHtml = '<div class="status-badge paused">⏸</div>';
- else if (node.status === 'skipped') badgeHtml = '<div class="status-badge skipped">―</div>';
- // Footer
- const footerHtml = data.children?.length ? `<div class="node-footer">\u2935 ${data.children.length} parallel children</div>` : '';
- div.innerHTML = `
- ${badgeHtml}
- <div class="port port-in"></div>
- <div class="node-header">
- <div class="node-icon">${icon}</div>
- <div style="overflow:hidden;">
- <div class="node-title">${esc(title)}</div>
- <div class="node-type">${esc(type)}</div>
- ${desc ? `<div class="node-desc">${esc(desc)}</div>` : ''}
- </div>
- </div>
- ${bodyHtml ? `<div class="node-body">${bodyHtml}${conditionHtml}${breakHtml}</div>` : ''}
- ${ioHtml}
- ${footerHtml}
- <div class="port port-out"></div>
- `;
- // Drag + click handler (mousedown starts drag, stopDrag distinguishes click vs drag)
- div.addEventListener('mousedown', (e) => {
- if (e.button !== 0) return;
- startDrag(e, node);
- });
- // Right-click → context menu
- div.addEventListener('contextmenu', (e) => {
- showContextMenu(e, node);
- });
- layer.appendChild(div);
- }
- }
- function renderConnections() {
- const svg = $('connSvg');
- svg.querySelectorAll('.conn-group').forEach(g => g.remove());
- for (const conn of state.connections) {
- const fromEl = $(`node-${conn.from}`);
- const toEl = $(`node-${conn.to}`);
- if (!fromEl || !toEl) continue;
- const x1 = fromEl.offsetLeft + fromEl.offsetWidth / 2;
- const y1 = fromEl.offsetTop + fromEl.offsetHeight + 4;
- const x2 = toEl.offsetLeft + toEl.offsetWidth / 2;
- const y2 = toEl.offsetTop - 4;
- // Use straight lines for horizontal, bezier for vertical
- const dx = Math.abs(x2 - x1);
- const dy = y2 - y1;
- let d;
- if (dy > 0) {
- const cp = Math.max(40, Math.abs(dy) * 0.4);
- d = `M ${x1} ${y1} C ${x1} ${y1 + cp}, ${x2} ${y2 - cp}, ${x2} ${y2}`;
- } else {
- // Back-edge or same level: use wider bezier
- const cpX = Math.max(80, dx * 0.3);
- d = `M ${x1} ${y1} C ${x1 + cpX} ${y1 + 80}, ${x2 - cpX} ${y2 - 80}, ${x2} ${y2}`;
- }
- const markerMap = { serial: 'arrowSerial', parallel: 'arrowParallel', 'branch-case': 'arrowBranch' };
- const marker = markerMap[conn.type] || 'arrowSerial';
- const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- g.setAttribute('class', 'conn-group');
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('class', `conn-path ${conn.type}`);
- path.setAttribute('d', d);
- path.setAttribute('marker-end', `url(#${marker})`);
- g.appendChild(path);
- if (conn.label) {
- const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- text.setAttribute('class', 'conn-label');
- text.setAttribute('x', (x1 + x2) / 2);
- text.setAttribute('y', Math.min(y1, y2) + Math.abs(dy) / 2 - 5);
- text.setAttribute('text-anchor', 'middle');
- text.textContent = conn.label.length > 30 ? conn.label.substring(0, 30) + '...' : conn.label;
- g.appendChild(text);
- }
- svg.appendChild(g);
- }
- }
- // ===== Node Drag =====
- function initDrag() {
- const canvas = $('canvas');
- canvas.addEventListener('mousemove', onDrag);
- canvas.addEventListener('mouseup', stopDrag);
- canvas.addEventListener('mouseleave', stopDrag);
- }
- function startDrag(e, node) {
- e.preventDefault();
- state.dragging = { node, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y };
- const el = $(`node-${node.id}`);
- if (el) el.classList.add('dragging');
- }
- function onDrag(e) {
- if (!state.dragging) return;
- const { node, startX, startY, origX, origY } = state.dragging;
- node.x = Math.max(0, origX + (e.clientX - startX));
- node.y = Math.max(0, origY + (e.clientY - startY));
- const el = $(`node-${node.id}`);
- if (el) {
- el.style.left = node.x + 'px';
- el.style.top = node.y + 'px';
- }
- if (!state._dragRAF) {
- state._dragRAF = requestAnimationFrame(() => {
- renderConnections();
- state._dragRAF = null;
- });
- }
- }
- function stopDrag() {
- if (!state.dragging) return;
- const { node, origX, origY } = state.dragging;
- const dx = Math.abs(node.x - origX);
- const dy = Math.abs(node.y - origY);
- const el = $(`node-${node.id}`);
- if (el) el.classList.remove('dragging');
- if (dx < 3 && dy < 3) {
- // Treat as click — select node
- state.selectedNodeId = node.id;
- renderNodes();
- renderConnections();
- window.parent.postMessage({ type: 'nodeClick', nodeId: node.id, nodeType: node.type, nodeData: node.data }, '*');
- } else {
- // Dragged — update minimap
- updateMinimap();
- }
- state.dragging = null;
- }
- // ===== Minimap =====
- function updateMinimap() {
- const mc = $('minimapCanvas');
- if (!mc || state.nodes.length === 0) return;
- const ctx = mc.getContext('2d');
- const W = mc.width = mc.offsetWidth * 2;
- const H = mc.height = mc.offsetHeight * 2;
- ctx.clearRect(0, 0, W, H);
- // Find bounds
- let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
- for (const n of state.nodes) {
- minX = Math.min(minX, n.x);
- minY = Math.min(minY, n.y);
- maxX = Math.max(maxX, n.x + 240);
- maxY = Math.max(maxY, n.y + 120);
- }
- const pad = 40;
- const scaleX = W / (maxX - minX + pad * 2);
- const scaleY = H / (maxY - minY + pad * 2);
- const scale = Math.min(scaleX, scaleY);
- // Draw connections
- ctx.strokeStyle = '#2a3140';
- ctx.lineWidth = 1;
- for (const c of state.connections) {
- const from = state.nodes.find(n => n.id === c.from);
- const to = state.nodes.find(n => n.id === c.to);
- if (!from || !to) continue;
- ctx.beginPath();
- ctx.moveTo((from.x + 120 - minX + pad) * scale, (from.y + 60 - minY + pad) * scale);
- ctx.lineTo((to.x + 120 - minX + pad) * scale, (to.y + 30 - minY + pad) * scale);
- ctx.stroke();
- }
- // Draw nodes
- const mmColors = {
- LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
- Set:'#58a6ff', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
- Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981',
- Download:'#38bdf8', Unzip:'#facc15'
- };
- for (const n of state.nodes) {
- const x = (n.x - minX + pad) * scale;
- const y = (n.y - minY + pad) * scale;
- const w = 240 * scale;
- const h = 60 * scale;
- ctx.fillStyle = n.status === 'done' ? '#3fb950' : n.status === 'running' ? '#d29922' : n.status === 'error' ? '#f85149' : (mmColors[n.type] || '#1a1f27');
- ctx.globalAlpha = n.status ? 0.9 : 0.6;
- ctx.fillRect(x, y, Math.max(w, 3), Math.max(h, 2));
- }
- ctx.globalAlpha = 1;
- // Draw viewport rectangle
- const wrap = $('canvasWrap');
- const vx = (wrap.scrollLeft - minX + pad) * scale;
- const vy = (wrap.scrollTop - minY + pad) * scale;
- const vw = wrap.clientWidth * scale;
- const vh = wrap.clientHeight * scale;
- ctx.strokeStyle = '#58a6ff';
- ctx.lineWidth = 2;
- ctx.strokeRect(vx, vy, vw, vh);
- }
- // ===== PNG Export =====
- function downloadPNG() {
- if (state.nodes.length === 0) return toast('No workflow to export');
- const SCALE = 2;
- const PAD = 60;
- let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
- for (const n of state.nodes) {
- minX = Math.min(minX, n.x);
- minY = Math.min(minY, n.y);
- maxX = Math.max(maxX, n.x + 240);
- maxY = Math.max(maxY, n.y + 140);
- }
- const cw = (maxX - minX + PAD * 2) * SCALE;
- const ch = (maxY - minY + PAD * 2) * SCALE;
- const canvas = document.createElement('canvas');
- canvas.width = cw;
- canvas.height = ch;
- const ctx = canvas.getContext('2d');
- ctx.scale(SCALE, SCALE);
- const ox = -minX + PAD;
- const oy = -minY + PAD;
- // Background
- ctx.fillStyle = '#0a0d12';
- ctx.fillRect(0, 0, cw / SCALE, ch / SCALE);
- // Grid dots
- ctx.fillStyle = '#2a3140';
- for (let gx = 0; gx < cw / SCALE; gx += 20) {
- for (let gy = 0; gy < ch / SCALE; gy += 20) {
- ctx.fillRect(gx, gy, 1, 1);
- }
- }
- // Connections
- for (const conn of state.connections) {
- const from = state.nodes.find(n => n.id === conn.from);
- const to = state.nodes.find(n => n.id === conn.to);
- if (!from || !to) continue;
- const x1 = from.x + 120 + ox, y1 = from.y + 100 + oy;
- const x2 = to.x + 120 + ox, y2 = to.y + oy;
- ctx.strokeStyle = conn.type === 'parallel' ? '#a371f7' : conn.type === 'branch-case' ? '#d29922' : '#58a6ff';
- ctx.lineWidth = 2;
- if (conn.type === 'parallel') ctx.setLineDash([6, 3]);
- else if (conn.type === 'branch-case') ctx.setLineDash([4, 2, 1, 2]);
- else ctx.setLineDash([]);
- const cp = Math.max(40, Math.abs(y2 - y1) * 0.4);
- ctx.beginPath();
- ctx.moveTo(x1, y1);
- ctx.bezierCurveTo(x1, y1 + cp, x2, y2 - cp, x2, y2);
- ctx.stroke();
- ctx.setLineDash([]);
- // Arrow
- ctx.fillStyle = ctx.strokeStyle;
- ctx.beginPath();
- ctx.moveTo(x2 - 4, y2 - 8);
- ctx.lineTo(x2, y2);
- ctx.lineTo(x2 + 4, y2 - 8);
- ctx.fill();
- }
- // Nodes
- const typeColors = {
- LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
- Set:'#58a6ff', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
- Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981',
- Download:'#38bdf8', Unzip:'#facc15'
- };
- for (const node of state.nodes) {
- const nx = node.x + ox, ny = node.y + oy;
- // Card
- ctx.fillStyle = '#1a1f27';
- ctx.strokeStyle = '#2a3140';
- ctx.lineWidth = 1;
- roundRect(ctx, nx, ny, 240, 80, 8);
- ctx.fill();
- ctx.stroke();
- // Type accent bar
- ctx.fillStyle = typeColors[node.type] || '#6366f1';
- ctx.fillRect(nx, ny + 8, 3, 64);
- // Icon
- ctx.fillStyle = typeColors[node.type] || '#6366f1';
- roundRect(ctx, nx + 10, ny + 10, 24, 24, 5);
- ctx.fill();
- ctx.fillStyle = '#fff';
- ctx.font = 'bold 9px monospace';
- ctx.textAlign = 'center';
- ctx.fillText(NODE_ICONS[node.type] || '?', nx + 22, ny + 26);
- // Title
- ctx.fillStyle = '#e6edf3';
- ctx.font = 'bold 11px monospace';
- ctx.textAlign = 'left';
- ctx.fillText((node.data?.meta?.title || node.id).substring(0, 28), nx + 42, ny + 24);
- // Type
- ctx.fillStyle = '#8b949e';
- ctx.font = '9px monospace';
- ctx.fillText(node.type, nx + 42, ny + 36);
- // Status
- if (node.status) {
- const sc = { done:'#3fb950', running:'#d29922', error:'#f85149', paused:'#8b5cf6', skipped:'#8b949e' };
- ctx.fillStyle = sc[node.status] || '#8b949e';
- ctx.beginPath();
- ctx.arc(nx + 230, ny + 4, 6, 0, Math.PI * 2);
- ctx.fill();
- }
- }
- // Title
- ctx.fillStyle = '#e6edf3';
- ctx.font = 'bold 14px monospace';
- ctx.textAlign = 'left';
- ctx.fillText(_currentWorkflowJson?.name || 'VL Workflow', PAD, 20);
- ctx.fillStyle = '#8b949e';
- ctx.font = '10px monospace';
- ctx.fillText(`${state.nodes.length} nodes \u00b7 VL Workflow Spec ${_currentWorkflowJson?.version || '3.16'}`, PAD, 35);
- // Download
- const link = document.createElement('a');
- link.download = `workflow-${(_currentWorkflowJson?.name || 'dag').replace(/\s+/g, '-')}-${Date.now()}.png`;
- link.href = canvas.toDataURL('image/png');
- link.click();
- toast('PNG exported');
- }
- function roundRect(ctx, x, y, w, h, r) {
- ctx.beginPath();
- ctx.moveTo(x + r, y);
- ctx.lineTo(x + w - r, y);
- ctx.quadraticCurveTo(x + w, y, x + w, y + r);
- ctx.lineTo(x + w, y + h - r);
- ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
- ctx.lineTo(x + r, y + h);
- ctx.quadraticCurveTo(x, y + h, x, y + h - r);
- ctx.lineTo(x, y + r);
- ctx.quadraticCurveTo(x, y, x + r, y);
- ctx.closePath();
- }
- // ===== JSON Export / Import =====
- function exportJSON() {
- if (!_currentWorkflowJson) return toast('No workflow to export');
- const blob = new Blob([JSON.stringify(_currentWorkflowJson, null, 2)], { type: 'application/json' });
- const link = document.createElement('a');
- link.download = `${(_currentWorkflowJson.name || 'workflow').replace(/\s+/g, '-')}-${Date.now()}.json`;
- link.href = URL.createObjectURL(blob);
- link.click();
- toast('JSON exported');
- }
- function importJSON() {
- $('importInput').click();
- }
- $('importInput')?.addEventListener('change', async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- try {
- const text = await file.text();
- const json = JSON.parse(text);
- if (!json.steps?.length) throw new Error('Invalid workflow: missing steps');
- parseWorkflow(json);
- toast(`Loaded: ${json.name || file.name}`);
- window.parent.postMessage({ type: 'workflowImported', workflow: json }, '*');
- } catch (err) {
- toast('Import failed: ' + err.message);
- }
- e.target.value = '';
- });
- // ===== Run Workflow (SSE) =====
- async function runWorkflow() {
- if (!_currentWorkflowJson || _executing) return;
- _executing = true;
- $('runBtn').style.display = 'none';
- $('pauseBtn').style.display = '';
- $('stopBtn').style.display = '';
- $('statusLabel').textContent = 'Executing...';
- // Clear statuses
- for (const n of state.nodes) n.status = null;
- renderNodes();
- try {
- const res = await fetch('/api/workflow/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflowName: _currentWorkflowJson.name || 'ephemeral', params: {} })
- });
- await streamSSE(res);
- } catch (err) {
- toast('Execution error: ' + err.message);
- }
- _executing = false;
- $('runBtn').style.display = '';
- $('pauseBtn').style.display = 'none';
- $('stopBtn').style.display = 'none';
- $('statusLabel').textContent = 'Done';
- updateMinimap();
- // Try to fetch final checkpoint
- fetchCheckpoint();
- }
- function handleExecEvent(evt) {
- // Map engine event types to UI (support both legacy and v0.3+ naming)
- const nodeId = evt.nodeId || evt.stepID;
- const evtType = evt.type;
- if (evtType === 'node_start' || evtType === 'step_start') {
- setNodeStatus(nodeId, 'running');
- $('statusLabel').textContent = `Running: ${evt.title || evt.payload?.meta?.title || nodeId}`;
- } else if (evtType === 'node_done' || evtType === 'step_done') {
- setNodeStatus(nodeId, 'done');
- } else if (evtType === 'node_error' || evtType === 'step_error') {
- setNodeStatus(nodeId, 'error');
- } else if (evtType === 'node_skipped' || evtType === 'step_skipped') {
- setNodeStatus(nodeId, 'skipped');
- } else if (evtType === 'pause' || evtType === 'pause_start') {
- setNodeStatus(nodeId, 'paused');
- $('statusLabel').textContent = `Paused: ${evt.title || nodeId}`;
- } else if (evtType === 'resumed' || evtType === 'pause_resumed') {
- setNodeStatus(nodeId, 'running');
- } else if (evtType === 'workflow_paused') {
- // User-initiated graceful pause — engine stopped after current step
- const pausedAt = evt.payload?.pausedAt || nodeId;
- $('statusLabel').textContent = `Paused after ${pausedAt} — right-click a node to edit & re-run`;
- _executing = false;
- $('runBtn').style.display = '';
- $('pauseBtn').style.display = 'none';
- $('stopBtn').style.display = 'none';
- fetchCheckpoint();
- } else if (evtType === 'done' || evtType === 'workflow_done') {
- $('statusLabel').textContent = `Complete! ${evt.filesWritten?.length || evt.payload?.filesWritten?.length || 0} files written`;
- } else if (evtType === 'error' || evtType === 'workflow_failed') {
- $('statusLabel').textContent = 'Error: ' + (evt.error || evt.payload?.error || 'unknown');
- }
- // Capture checkpoint if provided
- if (evt.checkpoint) {
- _lastCheckpoint = evt.checkpoint;
- saveCheckpointToStorage();
- }
- // Capture runID
- if (evt.runID || evt.payload?.runID) {
- _currentRunID = evt.runID || evt.payload?.runID;
- }
- }
- function setNodeStatus(nodeId, status) {
- const node = state.nodes.find(n => n.id === nodeId);
- if (node) {
- node.status = status;
- // Avoid full re-render during drag
- if (state.dragging) return;
- renderNodes();
- renderConnections();
- updateMinimap();
- // Scroll to node
- const el = $(`node-${nodeId}`);
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
- }
- }
- function stopExecution() {
- if (_eventSource) { _eventSource.close(); _eventSource = null; }
- // Send abort to server
- if (_currentRunID) {
- fetch(`/api/workflow/${_currentRunID}/abort`, { method: 'POST' }).catch(() => {});
- }
- _executing = false;
- $('runBtn').style.display = '';
- $('pauseBtn').style.display = 'none';
- $('stopBtn').style.display = 'none';
- $('statusLabel').textContent = 'Stopped';
- fetchCheckpoint();
- }
- async function pauseExecution() {
- if (!_currentRunID) return toast('No active run');
- $('statusLabel').textContent = 'Pausing...';
- try {
- await fetch(`/api/workflow/${_currentRunID}/pause`, { method: 'POST' });
- $('statusLabel').textContent = 'Paused (can resume from any node)';
- } catch (err) {
- // Fallback: try cancel endpoint
- try {
- await fetch(`/api/workflow/${_currentRunID}/cancel`, { method: 'POST' });
- $('statusLabel').textContent = 'Paused';
- } catch { toast('Pause failed'); }
- }
- _executing = false;
- $('runBtn').style.display = '';
- $('pauseBtn').style.display = 'none';
- $('stopBtn').style.display = 'none';
- fetchCheckpoint();
- }
- // ===== Checkpoint Persistence =====
- function saveCheckpointToStorage() {
- if (!_lastCheckpoint || !_currentWorkflowJson?.name) return;
- try {
- const key = `wf_cp_${_currentWorkflowJson.name}`;
- const data = {
- checkpoint: _lastCheckpoint,
- nodeStatuses: state.nodes.map(n => ({ id: n.id, status: n.status })),
- runID: _currentRunID,
- ts: Date.now()
- };
- localStorage.setItem(key, JSON.stringify(data));
- } catch {}
- }
- function restoreFromStorage() {
- if (!_currentWorkflowJson?.name) return false;
- try {
- const key = `wf_cp_${_currentWorkflowJson.name}`;
- const raw = localStorage.getItem(key);
- if (!raw) return false;
- const data = JSON.parse(raw);
- // Only restore if less than 24h old
- if (Date.now() - data.ts > 86400000) { localStorage.removeItem(key); return false; }
- _lastCheckpoint = data.checkpoint;
- _currentRunID = data.runID;
- // Restore node statuses
- if (data.nodeStatuses) {
- for (const ns of data.nodeStatuses) {
- const node = state.nodes.find(n => n.id === ns.id);
- if (node && ns.status) node.status = ns.status;
- }
- }
- return true;
- } catch { return false; }
- }
- async function fetchCheckpoint() {
- if (!_currentRunID) return;
- try {
- const res = await fetch(`/api/workflow/${_currentRunID}/checkpoint`);
- if (res.ok) {
- _lastCheckpoint = await res.json();
- saveCheckpointToStorage();
- }
- } catch {}
- }
- // ===== Re-run from Step =====
- async function rerunFromStep(stepId, overrides = {}) {
- if (!_currentWorkflowJson) return toast('No workflow loaded');
- // Build checkpoint: use last checkpoint or create minimal one
- const checkpoint = _lastCheckpoint ? { ..._lastCheckpoint, currentStepID: stepId }
- : { currentStepID: stepId, params: {}, variables: {} };
- // Apply overrides
- if (Object.keys(overrides).length > 0 && checkpoint.variables) {
- Object.assign(checkpoint.variables, overrides);
- }
- // Clear statuses for this node and all subsequent
- const stepIdx = state.nodes.findIndex(n => n.id === stepId);
- if (stepIdx >= 0) {
- // Clear this node and downstream
- const toClear = new Set();
- function markDownstream(id) {
- if (toClear.has(id)) return;
- toClear.add(id);
- for (const c of state.connections) {
- if (c.from === id) markDownstream(c.to);
- }
- }
- markDownstream(stepId);
- for (const n of state.nodes) {
- if (toClear.has(n.id)) n.status = null;
- }
- renderNodes();
- renderConnections();
- }
- _executing = true;
- $('runBtn').style.display = 'none';
- $('pauseBtn').style.display = '';
- $('stopBtn').style.display = '';
- $('statusLabel').textContent = `Re-running from ${stepId}...`;
- try {
- const res = await fetch('/api/workflow/rerun', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- workflowName: _currentWorkflowJson.name || 'ephemeral',
- checkpoint,
- stepID: stepId,
- overrides
- })
- });
- if (!res.ok) {
- // Fallback: try execute with fromStep
- const res2 = await fetch('/api/workflow/execute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- workflowName: _currentWorkflowJson.name,
- params: {},
- fromStep: stepId,
- checkpoint,
- overrides
- })
- });
- if (!res2.ok) throw new Error('Rerun failed');
- await streamSSE(res2);
- } else {
- await streamSSE(res);
- }
- } catch (err) {
- toast('Re-run error: ' + err.message);
- }
- _executing = false;
- $('runBtn').style.display = '';
- $('pauseBtn').style.display = 'none';
- $('stopBtn').style.display = 'none';
- $('statusLabel').textContent = 'Done';
- updateMinimap();
- fetchCheckpoint();
- }
- async function streamSSE(res) {
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buf = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- let lines = buf.split('\n');
- buf = lines.pop();
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- try { handleExecEvent(JSON.parse(line.slice(6))); } catch {}
- }
- }
- }
- }
- // ===== Context Menu =====
- function showContextMenu(e, node) {
- e.preventDefault();
- _ctxTargetNode = node;
- const menu = $('ctxMenu');
- $('ctxNodeIdHint').textContent = node.id;
- // Enable/disable based on state
- const hasCheckpoint = !!_lastCheckpoint;
- const rerunItem = $('ctxRerun');
- const editItem = $('ctxEditRerun');
- if (hasCheckpoint || _currentWorkflowJson) {
- rerunItem.classList.remove('disabled');
- editItem.classList.remove('disabled');
- } else {
- rerunItem.classList.add('disabled');
- editItem.classList.add('disabled');
- }
- // Position
- menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
- menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
- menu.classList.add('show');
- }
- function hideContextMenu() {
- $('ctxMenu').classList.remove('show');
- _ctxTargetNode = null;
- }
- function ctxRerunFromHere() {
- hideContextMenu();
- if (!_ctxTargetNode) return;
- rerunFromStep(_ctxTargetNode.id);
- }
- function ctxEditAndRerun() {
- hideContextMenu();
- if (!_ctxTargetNode) return;
- openEditor(_ctxTargetNode);
- }
- function ctxViewDetails() {
- hideContextMenu();
- if (!_ctxTargetNode) return;
- window.parent.postMessage({
- type: 'nodeClick',
- nodeId: _ctxTargetNode.id,
- nodeType: _ctxTargetNode.type,
- nodeData: _ctxTargetNode.data
- }, '*');
- }
- function ctxCopyId() {
- hideContextMenu();
- if (!_ctxTargetNode) return;
- navigator.clipboard?.writeText(_ctxTargetNode.id);
- toast('Copied: ' + _ctxTargetNode.id);
- }
- // Close context menu on click elsewhere
- document.addEventListener('click', (e) => {
- if (!e.target.closest('.ctx-menu')) hideContextMenu();
- });
- // ===== Node Editor Modal =====
- function openEditor(node) {
- _editorNode = node;
- const data = node.data || {};
- const type = node.type;
- $('editorTitle').textContent = `Edit: ${data.meta?.title || node.id} (${type})`;
- let html = '';
- // Status indicator
- const statusText = node.status || 'pending';
- html += `<div class="editor-status">
- <div class="editor-status-dot ${statusText}"></div>
- <span class="editor-status-text">Status: ${statusText} | Node: ${esc(node.id)}</span>
- </div>`;
- // Type-specific editor fields
- if (type === 'LLM') {
- const model = data.in?.model || data.model || '';
- const msgs = data.in?.messages ? JSON.stringify(data.in.messages, null, 2) : '[]';
- const maxTokens = data.in?.max_tokens || '';
- html += `<div class="editor-field"><div class="editor-label">Model</div>
- <input class="editor-input" id="edit_model" value="${esc(model)}" placeholder="e.g. anthropic/claude-opus-4-6"></div>`;
- html += `<div class="editor-field"><div class="editor-label">Max Tokens</div>
- <input class="editor-input" id="edit_max_tokens" value="${esc(String(maxTokens))}" type="number" placeholder="4096"></div>`;
- html += `<div class="editor-field"><div class="editor-label">Messages (JSON)</div>
- <textarea class="editor-json" id="edit_messages">${esc(msgs)}</textarea>
- <div class="editor-error" id="edit_messages_err"></div></div>`;
- } else if (type === 'Service' || type === 'API' || type === 'Component') {
- const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
- html += `<div class="editor-field"><div class="editor-label">Input Parameters (JSON)</div>
- <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
- <div class="editor-error" id="edit_in_err"></div></div>`;
- } else if (type === 'Set') {
- html += `<div class="editor-field"><div class="editor-label">Target Variable</div>
- <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
- html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
- <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
- } else if (type === 'Write') {
- html += `<div class="editor-field"><div class="editor-label">Target Path</div>
- <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
- html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
- <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
- } else if (type === 'Download') {
- const src = typeof data.source === 'object' ? JSON.stringify(data.source, null, 2) : String(data.source || '');
- html += `<div class="editor-field"><div class="editor-label">Source URL / Config</div>
- <textarea class="editor-json" id="edit_source">${esc(src)}</textarea></div>`;
- if (data.target != null) {
- html += `<div class="editor-field"><div class="editor-label">Target Path</div>
- <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
- }
- } else if (type === 'Loop') {
- if (data.while) {
- html += `<div class="editor-field"><div class="editor-label">While Expression</div>
- <input class="editor-input" id="edit_while" value="${esc(data.while)}"></div>`;
- html += `<div class="editor-field"><div class="editor-label">Max Iterations</div>
- <input class="editor-input" id="edit_maxIterations" value="${esc(String(data.maxIterations || ''))}" type="number"></div>`;
- } else {
- html += `<div class="editor-field"><div class="editor-label">Source Expression</div>
- <input class="editor-input" id="edit_source" value="${esc(data.source || '')}"></div>`;
- }
- } else {
- // Generic: show full step JSON
- const stepJson = JSON.stringify(data, null, 2);
- html += `<div class="editor-field"><div class="editor-label">Step Data (JSON)</div>
- <textarea class="editor-json" id="edit_raw" style="min-height:200px">${esc(stepJson)}</textarea>
- <div class="editor-error" id="edit_raw_err"></div></div>`;
- }
- // Variable overrides section
- html += `<div class="editor-field" style="margin-top:12px; border-top:1px solid var(--border); padding-top:10px;">
- <div class="editor-label">Variable Overrides (optional)</div>
- <textarea class="editor-json" id="edit_overrides" placeholder='{ "$varName": "newValue" }'>{}</textarea>
- <div class="editor-hint">Override pipeline variables before re-running. Use $varName keys.</div>
- <div class="editor-error" id="edit_overrides_err"></div>
- </div>`;
- $('editorBody').innerHTML = html;
- $('editorOverlay').classList.add('show');
- }
- function closeEditor() {
- $('editorOverlay').classList.remove('show');
- _editorNode = null;
- }
- function editorRerun() {
- if (!_editorNode) return;
- // Collect overrides
- let overrides = {};
- const overridesEl = $('edit_overrides');
- if (overridesEl) {
- try {
- overrides = JSON.parse(overridesEl.value || '{}');
- const errEl = $('edit_overrides_err');
- if (errEl) errEl.style.display = 'none';
- } catch (e) {
- const errEl = $('edit_overrides_err');
- if (errEl) { errEl.textContent = 'Invalid JSON: ' + e.message; errEl.style.display = 'block'; }
- return;
- }
- }
- // Collect edited step data and merge into overrides
- const type = _editorNode.type;
- const data = _editorNode.data || {};
- if (type === 'LLM') {
- const model = $('edit_model')?.value;
- const maxTokens = $('edit_max_tokens')?.value;
- const msgsEl = $('edit_messages');
- if (msgsEl) {
- try { JSON.parse(msgsEl.value); } catch (e) {
- const err = $('edit_messages_err');
- if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
- return;
- }
- }
- // Update step data in memory for visual accuracy
- if (model && data.in) data.in.model = model;
- if (data.model !== undefined && model) data.model = model;
- if (maxTokens && data.in) data.in.max_tokens = parseInt(maxTokens);
- if (msgsEl && data.in) { try { data.in.messages = JSON.parse(msgsEl.value); } catch {} }
- } else if ((type === 'Service' || type === 'API' || type === 'Component') && $('edit_in')) {
- try {
- const newIn = JSON.parse($('edit_in').value);
- data.in = newIn;
- } catch (e) {
- const err = $('edit_in_err');
- if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
- return;
- }
- } else if ((type === 'Set' || type === 'Write') && $('edit_target')) {
- data.target = $('edit_target').value;
- data.value = $('edit_value')?.value || data.value;
- } else if (type === 'Download' && $('edit_source')) {
- try {
- data.source = JSON.parse($('edit_source').value);
- } catch { data.source = $('edit_source').value; }
- if ($('edit_target')) data.target = $('edit_target').value;
- } else if (type === 'Loop') {
- if ($('edit_while')) data.while = $('edit_while').value;
- if ($('edit_maxIterations')) data.maxIterations = parseInt($('edit_maxIterations').value);
- if ($('edit_source')) data.source = $('edit_source').value;
- } else if ($('edit_raw')) {
- try {
- const raw = JSON.parse($('edit_raw').value);
- Object.assign(data, raw);
- } catch (e) {
- const err = $('edit_raw_err');
- if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
- return;
- }
- }
- closeEditor();
- // Notify parent of edits
- window.parent.postMessage({
- type: 'nodeEdited',
- nodeId: _editorNode.id,
- nodeData: data,
- overrides
- }, '*');
- // Re-run
- rerunFromStep(_editorNode.id, overrides);
- }
- // ===== PostMessage API =====
- window.addEventListener('message', (e) => {
- if (!e.data?.type) return;
- switch (e.data.type) {
- case 'loadWorkflow':
- parseWorkflow(e.data.data);
- break;
- case 'highlightNode':
- state.selectedNodeId = e.data.nodeId;
- renderNodes();
- renderConnections();
- const el = $(`node-${e.data.nodeId}`);
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
- break;
- case 'updateNodeStatus': {
- setNodeStatus(e.data.nodeId, e.data.status);
- break;
- }
- case 'clearStatus':
- for (const n of state.nodes) n.status = null;
- _lastCheckpoint = null;
- renderNodes();
- renderConnections();
- updateMinimap();
- break;
- case 'setCheckpoint':
- _lastCheckpoint = e.data.checkpoint;
- _currentRunID = e.data.runID || _currentRunID;
- saveCheckpointToStorage();
- break;
- case 'rerunFromStep':
- rerunFromStep(e.data.stepId || e.data.nodeId, e.data.overrides || {});
- break;
- case 'editNode': {
- const node = state.nodes.find(n => n.id === (e.data.nodeId || e.data.stepId));
- if (node) openEditor(node);
- break;
- }
- }
- });
- // ===== Init =====
- initDrag();
- window.parent.postMessage({ type: 'ready' }, '*');
- // Scroll events update minimap
- $('canvasWrap')?.addEventListener('scroll', () => { renderConnections(); updateMinimap(); });
- window.addEventListener('resize', () => { renderConnections(); updateMinimap(); });
- </script>
- </body>
- </html>
|