workflow-editor.html 138 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>VL Workflow DAG</title>
  6. <style>
  7. :root {
  8. --bg: #0a0d12; --bg2: #12161c; --bg3: #1a1f27; --border: #2a3140;
  9. --text: #e6edf3; --text2: #8b949e; --blue: #58a6ff; --purple: #a371f7;
  10. --orange: #d29922; --green: #3fb950; --red: #f85149; --cyan: #39c5cf;
  11. --violet: #8b5cf6; --emerald: #10b981;
  12. --pink: #db61a2; --teal: #2dd4bf;
  13. }
  14. * { margin:0; padding:0; box-sizing:border-box; }
  15. 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; }
  16. /* ===== Toolbar ===== */
  17. .toolbar {
  18. background:var(--bg2); border-bottom:1px solid var(--border);
  19. display:flex; align-items:center; gap:6px; padding:4px 12px; height:36px; flex-shrink:0;
  20. }
  21. .toolbar .title { font-size:11px; font-weight:600; color:var(--text); margin-right:8px; }
  22. .toolbar .sep { width:1px; height:18px; background:var(--border); }
  23. .runbar {
  24. background:var(--bg2); border-bottom:1px solid var(--border);
  25. display:flex; align-items:center; gap:10px; padding:6px 12px; min-height:40px; flex-shrink:0;
  26. }
  27. .runbar-label { font-size:10px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
  28. .run-strip { display:flex; align-items:center; gap:6px; flex:1; overflow-x:auto; scrollbar-width:thin; }
  29. .run-empty { font-size:10px; color:var(--text2); opacity:0.8; }
  30. .run-chip {
  31. border:1px solid var(--border); background:var(--bg3); color:var(--text2);
  32. border-radius:999px; padding:4px 8px; cursor:pointer; font-size:10px;
  33. font-family:inherit; display:flex; align-items:center; gap:6px; white-space:nowrap;
  34. }
  35. .run-chip:hover { border-color:var(--blue); color:var(--text); }
  36. .run-chip.active { border-color:var(--blue); color:var(--text); background:rgba(88,166,255,0.12); }
  37. .run-chip-dot { width:7px; height:7px; border-radius:50%; background:var(--text2); flex-shrink:0; }
  38. .run-chip-dot.running { background:var(--orange); box-shadow:0 0 8px rgba(210,153,34,0.5); }
  39. .run-chip-dot.waiting { background:var(--cyan); box-shadow:0 0 8px rgba(57,197,207,0.5); }
  40. .run-chip-dot.paused { background:var(--violet); box-shadow:0 0 8px rgba(139,92,246,0.5); }
  41. .run-chip-dot.done { background:var(--green); }
  42. .run-chip-dot.error { background:var(--red); }
  43. .run-chip-dot.skipped { background:var(--text2); opacity:0.7; }
  44. .run-chip-dot.idle { background:var(--text2); opacity:0.6; }
  45. .run-chip-id { color:var(--text2); opacity:0.85; }
  46. .run-meta {
  47. max-width:340px; font-size:10px; color:var(--text2);
  48. white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  49. }
  50. .eventbar {
  51. background:var(--bg2); border-bottom:1px solid var(--border);
  52. display:flex; flex-direction:column; min-height:120px; max-height:160px; flex-shrink:0;
  53. }
  54. .eventbar-head {
  55. display:flex; align-items:center; gap:8px; padding:6px 12px; border-bottom:1px solid rgba(255,255,255,0.05);
  56. }
  57. .eventbar-label { font-size:10px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
  58. .eventbar-spacer { flex:1; }
  59. .event-list {
  60. flex:1; overflow:auto; padding:6px 12px; display:flex; flex-direction:column; gap:4px; scrollbar-width:thin;
  61. }
  62. .event-empty { font-size:10px; color:var(--text2); opacity:0.8; padding:4px 0; }
  63. .event-item {
  64. display:grid; grid-template-columns:54px minmax(0, 1fr); gap:8px;
  65. padding:4px 0; border-bottom:1px dashed rgba(255,255,255,0.05);
  66. }
  67. .event-item:last-child { border-bottom:none; }
  68. .event-time { font-size:9px; color:var(--text2); padding-top:1px; }
  69. .event-body { min-width:0; }
  70. .event-line {
  71. font-size:10px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  72. }
  73. .event-line.level-warn { color:var(--orange); }
  74. .event-line.level-error { color:var(--red); }
  75. .event-line.level-success { color:var(--green); }
  76. .event-type {
  77. display:inline-block; min-width:76px; color:var(--cyan); text-transform:uppercase;
  78. letter-spacing:0.4px; font-size:9px;
  79. }
  80. .event-detail {
  81. margin-top:2px; font-size:9px; color:var(--text2); white-space:pre-wrap;
  82. word-break:break-word; line-height:1.4; max-height:42px; overflow:hidden;
  83. }
  84. .tb-btn {
  85. background:var(--bg3); border:1px solid var(--border); color:var(--text2);
  86. padding:3px 10px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:10px;
  87. }
  88. .tb-btn:hover { background:var(--border); color:var(--text); }
  89. .tb-btn.primary { background:var(--green); color:#000; border-color:var(--green); font-weight:600; }
  90. .tb-btn.primary:hover { opacity:0.9; }
  91. .tb-btn.danger { border-color:var(--red); color:var(--red); }
  92. .tb-btn.danger:hover { background:var(--red); color:#fff; }
  93. .tb-btn:disabled { opacity:0.45; cursor:not-allowed; }
  94. .tb-btn:disabled:hover { background:var(--bg3); color:var(--text2); }
  95. body.compact-ui .toolbar { height:32px; padding:3px 10px; gap:5px; }
  96. body.compact-ui .runbar { min-height:32px; padding:4px 10px; }
  97. body.compact-ui .run-chip { padding:3px 7px; font-size:9px; }
  98. body.compact-ui .run-meta, body.compact-ui #wfBreadcrumb, body.compact-ui #statusLabel { font-size:9px; }
  99. /* ===== Canvas container ===== */
  100. .editor-shell { flex:1; min-height:0; display:flex; }
  101. .sidebar {
  102. width:230px; flex-shrink:0; background:var(--bg2); border-right:1px solid var(--border);
  103. display:flex; flex-direction:column; min-height:0;
  104. }
  105. body.compact-ui .sidebar { width:188px; }
  106. body.types-collapsed .sidebar { display:none; }
  107. .sidebar-head {
  108. padding:10px 12px 8px; border-bottom:1px solid rgba(255,255,255,0.05);
  109. display:flex; align-items:center; justify-content:space-between; gap:8px;
  110. }
  111. body.compact-ui .sidebar-head { padding:8px 10px 6px; }
  112. .sidebar-title {
  113. font-size:10px; color:var(--text2); text-transform:uppercase; letter-spacing:0.55px;
  114. }
  115. .sidebar-summary {
  116. padding:10px 12px; display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:6px;
  117. border-bottom:1px solid rgba(255,255,255,0.05);
  118. }
  119. .sidebar-stat {
  120. background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.06);
  121. border-radius:6px; padding:6px 8px;
  122. }
  123. .sidebar-stat-label { font-size:8px; color:var(--text2); text-transform:uppercase; letter-spacing:0.45px; }
  124. .sidebar-stat-value { margin-top:3px; font-size:12px; color:var(--text); font-weight:600; }
  125. .type-list {
  126. flex:1; overflow:auto; padding:8px 10px 12px; display:flex; flex-direction:column; gap:6px;
  127. scrollbar-width:thin;
  128. }
  129. .type-row {
  130. width:100%; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.06);
  131. color:var(--text); border-radius:8px; padding:7px 8px; display:flex; align-items:center; gap:8px;
  132. text-align:left; font-family:inherit; cursor:pointer;
  133. }
  134. .type-row:hover { border-color:rgba(88,166,255,0.35); background:rgba(88,166,255,0.08); }
  135. .type-row.empty { opacity:0.55; }
  136. .type-icon {
  137. width:22px; height:22px; border-radius:6px; display:flex; align-items:center; justify-content:center;
  138. font-size:9px; font-weight:700; color:#fff; flex-shrink:0;
  139. }
  140. .type-meta { min-width:0; flex:1; }
  141. .type-name { font-size:10px; color:var(--text); }
  142. .type-caption { margin-top:2px; font-size:8px; color:var(--text2); text-transform:uppercase; letter-spacing:0.4px; }
  143. .type-count {
  144. min-width:24px; text-align:center; padding:2px 6px; border-radius:999px;
  145. background:rgba(255,255,255,0.05); color:var(--text2); font-size:9px;
  146. }
  147. .canvas-wrap { flex:1; overflow:auto; position:relative; min-width:0; }
  148. body.compact-ui .canvas-wrap { background:rgba(0,0,0,0.08); }
  149. .canvas {
  150. position:relative; min-width:1800px; min-height:1200px;
  151. background-image:radial-gradient(circle, var(--border) 1px, transparent 1px);
  152. background-size:20px 20px;
  153. }
  154. body.compact-ui .canvas { min-width:1200px; min-height:900px; background-size:18px 18px; }
  155. .empty-msg {
  156. position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  157. color:var(--text2); font-size:14px; text-align:center;
  158. }
  159. /* ===== Nodes ===== */
  160. .node {
  161. position:absolute; width:240px; background:var(--bg3); border:1px solid var(--border);
  162. border-radius:8px; cursor:grab; transition:border-color 0.2s, box-shadow 0.2s;
  163. z-index:2; user-select:none;
  164. }
  165. body.compact-ui .node { width:196px; }
  166. body.compact-ui .node.minified { width:154px; }
  167. .node:hover { border-color:var(--blue); box-shadow:0 0 12px rgba(88,166,255,0.15); }
  168. .node.selected { border-color:var(--blue); box-shadow:0 0 20px rgba(88,166,255,0.25); z-index:8; }
  169. body.compact-ui .node.selected { width:336px; }
  170. .node.dragging { cursor:grabbing; z-index:10; opacity:0.92; box-shadow:0 0 24px rgba(88,166,255,0.3); }
  171. .node-dismiss {
  172. position:absolute; top:6px; right:6px; width:18px; height:18px; border-radius:999px;
  173. border:1px solid rgba(255,255,255,0.12); background:rgba(10,13,18,0.85); color:var(--text2);
  174. display:flex; align-items:center; justify-content:center; font-size:11px; cursor:pointer; z-index:9;
  175. }
  176. .node-dismiss:hover { border-color:var(--blue); color:var(--text); background:rgba(88,166,255,0.16); }
  177. body.compact-ui .node-dismiss { top:5px; right:5px; }
  178. .node-header { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--border); }
  179. body.compact-ui .node-header { padding:6px 8px; gap:6px; }
  180. body.compact-ui .node.minified .node-header { border-bottom:none; }
  181. .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; }
  182. body.compact-ui .node-icon { width:22px; height:22px; font-size:8px; }
  183. .node-title { font-size:11px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  184. body.compact-ui .node-title { font-size:10px; }
  185. .node-meta-row { display:flex; align-items:center; gap:4px; flex-wrap:wrap; margin-top:2px; }
  186. .node-type { font-size:9px; color:var(--text2); }
  187. .node-type-pill, .node-subkind-pill, .node-state-pill {
  188. display:inline-flex; align-items:center; gap:4px; padding:1px 6px; border-radius:999px;
  189. font-size:8px; text-transform:uppercase; letter-spacing:0.4px; border:1px solid transparent;
  190. }
  191. body.compact-ui .node-type-pill, body.compact-ui .node-subkind-pill, body.compact-ui .node-state-pill { font-size:7px; padding:1px 5px; }
  192. .node-type-pill { background:rgba(255,255,255,0.05); color:var(--text); border-color:rgba(255,255,255,0.08); }
  193. .node-subkind-pill { background:rgba(88,166,255,0.12); color:var(--blue); border-color:rgba(88,166,255,0.18); }
  194. .node-state-pill.status-running { background:rgba(210,153,34,0.15); color:var(--orange); border-color:rgba(210,153,34,0.25); }
  195. .node-state-pill.status-waiting { background:rgba(57,197,207,0.15); color:var(--cyan); border-color:rgba(57,197,207,0.25); }
  196. .node-state-pill.status-paused { background:rgba(139,92,246,0.15); color:var(--violet); border-color:rgba(139,92,246,0.25); }
  197. .node-state-pill.status-done { background:rgba(63,185,80,0.15); color:var(--green); border-color:rgba(63,185,80,0.25); }
  198. .node-state-pill.status-error { background:rgba(248,81,73,0.15); color:var(--red); border-color:rgba(248,81,73,0.25); }
  199. .node-state-pill.status-skipped { background:rgba(139,148,158,0.14); color:var(--text2); border-color:rgba(139,148,158,0.2); }
  200. .node-state-pill.status-idle { background:rgba(255,255,255,0.05); color:var(--text2); border-color:rgba(255,255,255,0.08); }
  201. .node-desc { font-size:9px; color:var(--cyan); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:170px; }
  202. body.compact-ui .node-desc { display:none; }
  203. .node-body { padding:6px 10px; font-size:10px; color:var(--text2); overflow:hidden; }
  204. .node-body .field { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:1px; }
  205. body.compact-ui .node-body { padding:4px 8px; font-size:9px; max-height:70px; }
  206. body.compact-ui .node:not(.selected) .node-body { max-height:48px; }
  207. body.compact-ui .node.selected .node-body { max-height:none; overflow:visible; }
  208. body.compact-ui .node.minified .node-body,
  209. body.compact-ui .node.minified .node-io,
  210. body.compact-ui .node.minified .node-footer,
  211. body.compact-ui .node.minified .port-in,
  212. body.compact-ui .node.minified .port-out { display:none; }
  213. /* Rich body styles */
  214. .node-section { margin-bottom:5px; }
  215. .node-section:last-child { margin-bottom:0; }
  216. .node-section-title {
  217. font-size:8px; color:var(--text2); text-transform:uppercase;
  218. letter-spacing:0.5px; margin-bottom:2px; padding-bottom:2px;
  219. border-bottom:1px dashed var(--border);
  220. }
  221. .node-field { display:flex; align-items:flex-start; gap:4px; margin-bottom:2px; line-height:1.3; }
  222. .node-field:last-child { margin-bottom:0; }
  223. .node-label { color:var(--text2); min-width:36px; flex-shrink:0; font-size:9px; }
  224. .node-value {
  225. color:var(--text); font-family:'SF Mono','Fira Code',monospace;
  226. font-size:9px; background:rgba(255,255,255,0.05); padding:1px 4px;
  227. border-radius:2px; word-break:break-all; max-width:160px;
  228. overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  229. }
  230. .node.selected .node-title,
  231. .node.selected .node-desc,
  232. .node.selected .node-value,
  233. .node.selected .node-doc-tag,
  234. .node.selected .node-io-key,
  235. .node.selected .node-io-value,
  236. .node.selected .field {
  237. max-width:none;
  238. white-space:pre-wrap;
  239. overflow:visible;
  240. text-overflow:clip;
  241. word-break:break-word;
  242. }
  243. .node-value.var { color:var(--cyan); }
  244. .node-value.ref { color:var(--purple); }
  245. .node-value.file { color:var(--orange); }
  246. .node-io-list { display:flex; flex-direction:column; gap:2px; }
  247. .node-io-item {
  248. display:flex; align-items:center; gap:4px; font-size:9px;
  249. background:rgba(255,255,255,0.04); padding:2px 4px; border-radius:2px;
  250. }
  251. .node.selected .node-io-item,
  252. .node.selected .node-field { align-items:flex-start; }
  253. .node-io-key { color:var(--text); font-family:'SF Mono','Fira Code',monospace; min-width:44px; }
  254. .node-io-key.is-var { color:var(--cyan); }
  255. .node-io-key.is-file { color:var(--orange); }
  256. .node-io-arrow { color:var(--text2); }
  257. .node-io-value { color:var(--cyan); font-family:'SF Mono','Fira Code',monospace; }
  258. .node-docs-list { display:flex; flex-wrap:wrap; gap:3px; }
  259. .node-doc-tag {
  260. background:rgba(210,153,34,0.2); color:var(--orange);
  261. padding:1px 4px; border-radius:2px; font-size:8px;
  262. font-family:'SF Mono','Fira Code',monospace;
  263. max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  264. }
  265. .node-condition {
  266. background:rgba(163,113,247,0.15); color:var(--purple);
  267. padding:2px 6px; border-radius:2px; font-size:9px;
  268. font-family:'SF Mono','Fira Code',monospace; margin-top:4px;
  269. }
  270. .node-footer {
  271. padding:4px 8px; border-top:1px dashed var(--border);
  272. font-size:9px; color:var(--purple);
  273. }
  274. body.compact-ui .node:not(.selected) .node-io,
  275. body.compact-ui .node:not(.selected) .node-footer { display:none; }
  276. /* I/O audit badges */
  277. .node-io { display:flex; gap:3px; flex-wrap:wrap; padding:4px 10px 6px; border-top:1px solid var(--border); }
  278. .io-badge { font-size:8px; padding:1px 5px; border-radius:3px; font-family:inherit; }
  279. .io-badge.var-in { background:rgba(88,166,255,0.15); color:var(--blue); }
  280. .io-badge.var-out { background:rgba(63,185,80,0.15); color:var(--green); }
  281. .io-badge.doc { background:rgba(210,153,34,0.15); color:var(--orange); }
  282. .io-badge.file { background:rgba(163,113,247,0.15); color:var(--purple); }
  283. /* Node type colors — original 11 */
  284. .type-LLM .node-icon { background:linear-gradient(135deg, #6366f1, #8b5cf6); }
  285. .type-Service .node-icon { background:linear-gradient(135deg, var(--green), #2ea043); }
  286. .type-API .node-icon { background:linear-gradient(135deg, var(--cyan), #1a7f8a); }
  287. .type-Write .node-icon { background:linear-gradient(135deg, var(--orange), #b87f12); }
  288. .type-Set .node-icon { background:linear-gradient(135deg, var(--blue), #1f6feb); }
  289. .type-Branch .node-icon { background:linear-gradient(135deg, var(--purple), #8957e5); }
  290. .type-Loop .node-icon { background:linear-gradient(135deg, #db61a2, #bf4b8a); }
  291. .type-Stop .node-icon { background:linear-gradient(135deg, var(--red), #da3633); }
  292. .type-Component .node-icon { background:linear-gradient(135deg, #2dd4bf, #14b8a6); }
  293. .type-Pause .node-icon { background:linear-gradient(135deg, var(--violet), #7c3aed); }
  294. .type-Fork .node-icon { background:linear-gradient(135deg, var(--emerald), #059669); }
  295. .type-Subflow .node-icon { background:linear-gradient(135deg, #14b8a6, #2563eb); }
  296. /* New in Spec 3.16 */
  297. .type-Download .node-icon { background:linear-gradient(135deg, #38bdf8, #0284c7); }
  298. .type-Unzip .node-icon { background:linear-gradient(135deg, #facc15, #ca8a04); }
  299. /* Type accent bar */
  300. .node::before {
  301. content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px;
  302. border-radius:0 2px 2px 0;
  303. }
  304. .type-LLM::before { background:#6366f1; }
  305. .type-Service::before { background:var(--green); }
  306. .type-API::before { background:var(--cyan); }
  307. .type-Write::before { background:var(--orange); }
  308. .type-Set::before { background:var(--blue); }
  309. .type-Branch::before { background:var(--purple); }
  310. .type-Loop::before { background:#db61a2; }
  311. .type-Stop::before { background:var(--red); }
  312. .type-Component::before { background:#2dd4bf; }
  313. .type-Pause::before { background:var(--violet); }
  314. .type-Fork::before { background:var(--emerald); }
  315. .type-Subflow::before { background:var(--cyan); }
  316. /* New in Spec 3.16 */
  317. .type-Download::before { background:#38bdf8; }
  318. .type-Unzip::before { background:#facc15; }
  319. .node[data-custom-type="true"] .node-icon { background:linear-gradient(135deg, var(--cyan), var(--teal)); }
  320. .node[data-custom-type="true"]::before { background:var(--cyan); }
  321. /* Status overlays */
  322. .node.status-running { border-color:var(--orange); animation:pulse-border 1.5s ease-in-out infinite; }
  323. .node.status-waiting { border-color:var(--cyan); animation:pulse-border-cyan 1.5s ease-in-out infinite; }
  324. .node.status-done { border-color:var(--green); }
  325. .node.status-error { border-color:var(--red); }
  326. .node.status-paused { border-color:var(--violet); animation:pulse-border-purple 1.5s ease-in-out infinite; }
  327. .node.status-skipped { border-color:var(--text2); opacity:0.5; }
  328. .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; }
  329. .status-badge.running { background:var(--orange); animation:spin 1s linear infinite; }
  330. .status-badge.waiting { background:var(--cyan); animation:pulse-badge-cyan 1.2s ease-in-out infinite; }
  331. .status-badge.done { background:var(--green); }
  332. .status-badge.error { background:var(--red); }
  333. .status-badge.paused { background:var(--violet); animation:pulse-badge-purple 2s ease-in-out infinite; }
  334. .status-badge.skipped { background:var(--text2); opacity:0.5; }
  335. @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); } }
  336. @keyframes pulse-border-cyan { 0%,100% { box-shadow:0 0 8px rgba(57,197,207,0.3); } 50% { box-shadow:0 0 20px rgba(57,197,207,0.6); } }
  337. @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); } }
  338. @keyframes pulse-badge-cyan { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:0.55; transform:scale(1.08); } }
  339. @keyframes pulse-badge-purple { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
  340. @keyframes spin { to { transform:rotate(360deg); } }
  341. /* ===== Ports ===== */
  342. .port { position:absolute; width:8px; height:8px; border-radius:50%; background:var(--border); border:1px solid var(--text2); z-index:3; }
  343. .port-in { top:-4px; left:50%; transform:translateX(-50%); }
  344. .port-out { bottom:-4px; left:50%; transform:translateX(-50%); }
  345. /* ===== Connections (SVG) ===== */
  346. .connections { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:1; }
  347. .conn-path { fill:none; stroke-width:2; }
  348. .conn-path.serial { stroke:var(--blue); }
  349. .conn-path.parallel { stroke:var(--purple); stroke-dasharray:6 3; }
  350. .conn-path.branch-case { stroke:var(--orange); stroke-dasharray:4 2 1 2; }
  351. .conn-label { fill:var(--text2); font-size:9px; font-family:inherit; }
  352. svg defs marker path { fill:var(--blue); }
  353. /* ===== Legend ===== */
  354. .legend {
  355. position:fixed; bottom:12px; left:246px; background:var(--bg2); border:1px solid var(--border);
  356. border-radius:6px; padding:8px 12px; font-size:9px; color:var(--text2); display:flex; gap:12px; z-index:10;
  357. }
  358. body.compact-ui .legend { padding:6px 10px; font-size:8px; }
  359. body.types-collapsed .legend { left:12px; }
  360. body.map-hidden .legend,
  361. body.map-hidden .minimap { display:none !important; }
  362. .legend-item { display:flex; align-items:center; gap:4px; }
  363. .legend-line { width:20px; height:2px; }
  364. .legend-line.serial { background:var(--blue); }
  365. .legend-line.parallel { background:var(--purple); background:repeating-linear-gradient(90deg, var(--purple) 0 6px, transparent 6px 9px); }
  366. .legend-line.branch { background:var(--orange); }
  367. /* ===== Minimap ===== */
  368. .minimap {
  369. position:fixed; bottom:12px; right:12px; width:180px; height:100px;
  370. background:var(--bg2); border:1px solid var(--border); border-radius:6px;
  371. overflow:hidden; z-index:10; cursor:crosshair;
  372. }
  373. .minimap canvas { width:100%; height:100%; }
  374. /* ===== Toast ===== */
  375. .toast {
  376. position:fixed; top:48px; left:50%; transform:translateX(-50%);
  377. background:var(--bg3); border:1px solid var(--border); border-radius:6px;
  378. padding:6px 16px; font-size:11px; color:var(--text); z-index:100;
  379. opacity:0; transition:opacity 0.3s;
  380. }
  381. .toast.show { opacity:1; }
  382. /* ===== Context Menu ===== */
  383. .ctx-menu {
  384. position:fixed; background:var(--bg2); border:1px solid var(--border);
  385. border-radius:6px; padding:4px 0; z-index:200; min-width:180px;
  386. box-shadow:0 4px 16px rgba(0,0,0,0.5); display:none;
  387. }
  388. .ctx-menu.show { display:block; }
  389. .ctx-item {
  390. padding:6px 14px; font-size:11px; color:var(--text); cursor:pointer;
  391. display:flex; align-items:center; gap:8px; font-family:inherit;
  392. }
  393. .ctx-item:hover { background:var(--bg3); }
  394. .ctx-item.disabled { color:var(--text2); cursor:default; opacity:0.5; }
  395. .ctx-item.disabled:hover { background:transparent; }
  396. .ctx-sep { height:1px; background:var(--border); margin:4px 0; }
  397. .ctx-item .ctx-icon { font-size:12px; width:16px; text-align:center; }
  398. .ctx-item .ctx-label { flex:1; }
  399. .ctx-item .ctx-hint { font-size:9px; color:var(--text2); }
  400. /* ===== Node Editor Modal ===== */
  401. .modal-overlay {
  402. position:fixed; top:0; left:0; width:100%; height:100%;
  403. background:rgba(0,0,0,0.6); z-index:300; display:none;
  404. align-items:center; justify-content:center;
  405. }
  406. .modal-overlay.show { display:flex; }
  407. .modal {
  408. background:var(--bg2); border:1px solid var(--border); border-radius:10px;
  409. width:520px; max-height:80vh; display:flex; flex-direction:column;
  410. box-shadow:0 8px 32px rgba(0,0,0,0.5);
  411. }
  412. .modal-header {
  413. padding:12px 16px; border-bottom:1px solid var(--border);
  414. display:flex; align-items:center; gap:8px;
  415. }
  416. .modal-header .modal-title { font-size:12px; font-weight:600; color:var(--text); flex:1; }
  417. .modal-header .modal-close {
  418. background:none; border:none; color:var(--text2); font-size:16px;
  419. cursor:pointer; padding:2px 6px; border-radius:4px;
  420. }
  421. .modal-header .modal-close:hover { background:var(--bg3); color:var(--text); }
  422. .modal-body { padding:12px 16px; overflow-y:auto; flex:1; }
  423. .modal-footer {
  424. padding:10px 16px; border-top:1px solid var(--border);
  425. display:flex; justify-content:flex-end; gap:8px;
  426. }
  427. .modal-footer .tb-btn { padding:5px 14px; font-size:11px; }
  428. /* Editor fields */
  429. .editor-field { margin-bottom:10px; }
  430. .editor-field:last-child { margin-bottom:0; }
  431. .editor-label { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; }
  432. .editor-input {
  433. width:100%; background:var(--bg); border:1px solid var(--border);
  434. color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:11px;
  435. padding:6px 8px; border-radius:4px; resize:vertical;
  436. }
  437. .editor-input:focus { outline:none; border-color:var(--blue); }
  438. .editor-json {
  439. width:100%; background:var(--bg); border:1px solid var(--border);
  440. color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:10px;
  441. padding:8px; border-radius:4px; resize:vertical; min-height:120px;
  442. line-height:1.5;
  443. }
  444. .editor-json:focus { outline:none; border-color:var(--blue); }
  445. .editor-hint { font-size:9px; color:var(--text2); margin-top:3px; }
  446. .editor-error { font-size:9px; color:var(--red); margin-top:3px; display:none; }
  447. /* Node status info in editor */
  448. .editor-status {
  449. display:flex; align-items:center; gap:6px; padding:6px 8px;
  450. background:rgba(255,255,255,0.03); border-radius:4px; margin-bottom:10px;
  451. }
  452. .editor-status-dot { width:8px; height:8px; border-radius:50%; }
  453. .editor-status-dot.done { background:var(--green); }
  454. .editor-status-dot.running { background:var(--orange); }
  455. .editor-status-dot.waiting { background:var(--cyan); }
  456. .editor-status-dot.error { background:var(--red); }
  457. .editor-status-dot.paused { background:var(--violet); }
  458. .editor-status-dot.skipped { background:var(--text2); }
  459. .editor-status-dot.pending { background:var(--border); }
  460. .editor-status-text { font-size:10px; color:var(--text2); }
  461. /* Pause button (amber) */
  462. .tb-btn.warn { border-color:var(--orange); color:var(--orange); }
  463. .tb-btn.warn:hover { background:var(--orange); color:#000; }
  464. body.events-collapsed .eventbar { display:none; }
  465. body.compact-ui .eventbar { min-height:88px; max-height:112px; }
  466. body.compact-ui .eventbar-head { padding:5px 10px; }
  467. body.compact-ui .event-list { padding:5px 10px; }
  468. @media (max-width: 980px) {
  469. .sidebar { width:180px; }
  470. .legend { left:196px; }
  471. }
  472. </style>
  473. </head>
  474. <body>
  475. <!-- Toolbar -->
  476. <div class="toolbar">
  477. <button class="tb-btn" onclick="navigateBack()" title="Return to parent workflow" id="backBtn" style="display:none;">&larr; Back</button>
  478. <button class="tb-btn" onclick="openSelectedSubflow()" title="Open selected subflow workflow" id="openChildBtn" style="display:none;">&#10550; Open Child</button>
  479. <div class="sep" id="navSep" style="display:none;"></div>
  480. <span class="title" id="wfTitle">VL Workflow DAG</span>
  481. <span id="wfBreadcrumb" style="font-size:10px;color:var(--text2);max-width:360px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span>
  482. <div class="sep"></div>
  483. <button class="tb-btn" onclick="autoLayout();render();" title="Re-arrange nodes">&#8634; Layout</button>
  484. <button class="tb-btn" onclick="downloadPNG()" title="Export as PNG image">&#128247; PNG</button>
  485. <div class="sep"></div>
  486. <button class="tb-btn" onclick="exportJSON()" title="Export workflow JSON">&#8615; Export</button>
  487. <button class="tb-btn" onclick="importJSON()" title="Import workflow JSON">&#8613; Import</button>
  488. <button class="tb-btn" onclick="toggleCompactMode()" title="Toggle compact embedded mode" id="compactBtn">Compact</button>
  489. <button class="tb-btn" onclick="toggleSimplifyGraph()" title="Compress layout without hiding logic nodes" id="coreBtn">Dense</button>
  490. <button class="tb-btn" onclick="toggleTypePanel()" title="Show or hide node types" id="typesBtn">Types</button>
  491. <button class="tb-btn" onclick="toggleEventPanel()" title="Show or hide run events" id="eventsBtn">Events</button>
  492. <button class="tb-btn" onclick="toggleMapPanel()" title="Show or hide minimap and legend" id="mapBtn">Map</button>
  493. <div class="sep"></div>
  494. <button class="tb-btn primary" onclick="runWorkflow()" title="Execute this workflow" id="runBtn">&#9654; Run</button>
  495. <button class="tb-btn warn" onclick="pauseExecution()" title="Pause execution" id="pauseBtn" style="display:none;">&#9208; Pause</button>
  496. <button class="tb-btn" onclick="resumeExecution()" title="Resume execution" id="resumeBtn" style="display:none;">&#9654; Resume</button>
  497. <button class="tb-btn danger" onclick="stopExecution()" title="Stop execution" id="stopBtn" style="display:none;">&#9632; Stop</button>
  498. <div style="flex:1;"></div>
  499. <span id="statusLabel" style="font-size:10px;color:var(--text2);"></span>
  500. </div>
  501. <div class="runbar">
  502. <span class="runbar-label">Runs</span>
  503. <div class="run-strip" id="runStrip"><span class="run-empty">No runs yet</span></div>
  504. <div class="run-meta" id="runMeta">Selected run: none</div>
  505. </div>
  506. <div class="eventbar">
  507. <div class="eventbar-head">
  508. <span class="eventbar-label">Selected Run Events</span>
  509. <div class="eventbar-spacer"></div>
  510. <button class="tb-btn" onclick="toggleEventPanel()" title="Collapse this panel">Hide</button>
  511. <button class="tb-btn" onclick="clearSelectedRunEvents()" title="Clear selected run event log">Clear</button>
  512. </div>
  513. <div class="event-list" id="eventList"><div class="event-empty">Start or select a run to inspect its event stream.</div></div>
  514. </div>
  515. <!-- Canvas -->
  516. <div class="editor-shell">
  517. <aside class="sidebar">
  518. <div class="sidebar-head">
  519. <span class="sidebar-title" id="typePanelTitle">Node Types</span>
  520. <div style="display:flex;align-items:center;gap:6px;">
  521. <button class="tb-btn" onclick="toggleTypeFilter()" title="Switch between used and all node types" id="typeFilterBtn">Used</button>
  522. <button class="tb-btn" onclick="toggleTypePanel()" title="Collapse this panel">Hide</button>
  523. <span style="font-size:9px;color:var(--text2);" id="typePanelBadge">0 types</span>
  524. </div>
  525. </div>
  526. <div class="sidebar-summary" id="typeSummary">
  527. <div class="sidebar-stat"><div class="sidebar-stat-label">Supported</div><div class="sidebar-stat-value">0</div></div>
  528. <div class="sidebar-stat"><div class="sidebar-stat-label">Used</div><div class="sidebar-stat-value">0</div></div>
  529. <div class="sidebar-stat"><div class="sidebar-stat-label">Shown</div><div class="sidebar-stat-value">0</div></div>
  530. <div class="sidebar-stat"><div class="sidebar-stat-label">Nodes</div><div class="sidebar-stat-value">0</div></div>
  531. </div>
  532. <div class="type-list" id="typeList"></div>
  533. </aside>
  534. <div class="canvas-wrap" id="canvasWrap">
  535. <div class="empty-msg" id="emptyMsg">No workflow loaded.<br>Select a workflow to visualize.</div>
  536. <div class="canvas" id="canvas" style="display:none;">
  537. <svg class="connections" id="connSvg">
  538. <defs>
  539. <marker id="arrowSerial" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  540. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--blue)"/>
  541. </marker>
  542. <marker id="arrowParallel" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  543. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--purple)"/>
  544. </marker>
  545. <marker id="arrowBranch" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  546. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--orange)"/>
  547. </marker>
  548. </defs>
  549. </svg>
  550. <div class="nodes-layer" id="nodesLayer"></div>
  551. </div>
  552. </div>
  553. </div>
  554. <!-- Legend -->
  555. <div class="legend" id="legend" style="display:none;">
  556. <div class="legend-item"><div class="legend-line serial"></div>Serial (next)</div>
  557. <div class="legend-item"><div class="legend-line parallel"></div>Parallel (children)</div>
  558. <div class="legend-item"><div class="legend-line branch"></div>Branch (case)</div>
  559. </div>
  560. <!-- Minimap -->
  561. <div class="minimap" id="minimap" style="display:none;">
  562. <canvas id="minimapCanvas"></canvas>
  563. </div>
  564. <!-- Toast -->
  565. <div class="toast" id="toast"></div>
  566. <!-- Context Menu -->
  567. <div class="ctx-menu" id="ctxMenu">
  568. <div class="ctx-item" id="ctxOpenChild" onclick="ctxOpenChildWorkflow()">
  569. <span class="ctx-icon">&#10550;</span><span class="ctx-label">Open child workflow</span>
  570. </div>
  571. <div class="ctx-sep" id="ctxOpenChildSep"></div>
  572. <div class="ctx-item" id="ctxRerun" onclick="ctxRerunFromHere()">
  573. <span class="ctx-icon">&#9654;</span><span class="ctx-label">Re-run from here</span>
  574. </div>
  575. <div class="ctx-item" id="ctxEditRerun" onclick="ctxEditAndRerun()">
  576. <span class="ctx-icon">&#9998;</span><span class="ctx-label">Edit inputs &amp; re-run</span>
  577. </div>
  578. <div class="ctx-sep"></div>
  579. <div class="ctx-item" onclick="ctxViewDetails()">
  580. <span class="ctx-icon">&#128269;</span><span class="ctx-label">View details</span>
  581. </div>
  582. <div class="ctx-item" onclick="ctxCopyId()">
  583. <span class="ctx-icon">&#128203;</span><span class="ctx-label">Copy node ID</span><span class="ctx-hint" id="ctxNodeIdHint"></span>
  584. </div>
  585. </div>
  586. <!-- Node Editor Modal -->
  587. <div class="modal-overlay" id="editorOverlay">
  588. <div class="modal">
  589. <div class="modal-header">
  590. <span class="modal-title" id="editorTitle">Edit Node Inputs</span>
  591. <button class="modal-close" onclick="closeEditor()">&times;</button>
  592. </div>
  593. <div class="modal-body" id="editorBody"></div>
  594. <div class="modal-footer">
  595. <button class="tb-btn" onclick="closeEditor()">Cancel</button>
  596. <button class="tb-btn primary" onclick="editorRerun()" id="editorRerunBtn">&#9654; Re-run from here</button>
  597. </div>
  598. </div>
  599. </div>
  600. <!-- Hidden file input for import -->
  601. <input type="file" id="importInput" accept=".json" style="display:none">
  602. <script>
  603. // ===== VL Workflow Editor v2.0 (Spec 3.16) =====
  604. // Canonical source: vl-workflow-engine/ui/workflow-editor.html
  605. // Consumed by: VL-Code, VLCode-Lite, VLClaw
  606. const RESERVED_NEXT = new Set(['RETURN', 'BREAK']);
  607. const NODE_ICONS = {
  608. LLM:'AI', Service:'SV', API:'AP', Component:'CP',
  609. Set:'=', Write:'WR', Branch:'BR', Loop:'LP', Stop:'ST',
  610. Pause:'\u23F8', Fork:'FK', Tool:'TL', Subflow:'SF',
  611. Download:'DL', Unzip:'UZ'
  612. };
  613. const TYPE_COLORS = {
  614. LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
  615. Set:'#58a6ff', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
  616. Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981', Tool:'#f97316', Subflow:'#39c5cf',
  617. Download:'#38bdf8', Unzip:'#facc15'
  618. };
  619. const KNOWN_TYPES = ['LLM','Service','API','Component','Set','Write','Branch','Loop','Stop','Pause','Fork','Tool','Subflow','Download','Unzip'];
  620. const UI_PREFS_KEY = 'workflow_editor_ui_v2';
  621. const MINIFY_TYPES = new Set();
  622. const HIDEABLE_TYPES = new Set();
  623. let state = { nodes: [], connections: [], selectedNodeId: null, registry: {}, dragging: null };
  624. let _currentWorkflowJson = null;
  625. let _workflowRef = null;
  626. let _eventSource = null;
  627. let _lastCheckpoint = null; // latest checkpoint from engine
  628. let _currentRunID = null; // active run ID
  629. let _ctxTargetNode = null; // node targeted by context menu
  630. let _editorNode = null; // node being edited in modal
  631. let _runSessions = new Map();
  632. let _selectedRunID = null;
  633. let _runSeq = 0;
  634. let _runTokenSeq = 0;
  635. let _activeRunControllers = new Map();
  636. let _workflowNavStack = [];
  637. let _uiState = {
  638. compact: true,
  639. simplifyGraph: true,
  640. showTypes: true,
  641. showEvents: false,
  642. showAllTypes: false,
  643. showMap: false,
  644. };
  645. // ===== Utilities =====
  646. function $(id) { return document.getElementById(id); }
  647. function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
  648. function trunc(s, n) { return s && s.length > n ? s.substring(0, n) + '\u2026' : (s || ''); }
  649. function nodeText(value, n = 24, expanded = false) {
  650. if (value == null) return '';
  651. let text = '';
  652. if (typeof value === 'string') {
  653. text = value;
  654. } else {
  655. try {
  656. text = JSON.stringify(value, expanded ? null : 0, expanded ? 2 : 0);
  657. } catch {
  658. text = String(value);
  659. }
  660. }
  661. return expanded ? text : trunc(text, n);
  662. }
  663. function resolveDocName(docId) { return state.registry?.docs?.[docId] || `Doc #${docId}`; }
  664. function normalizeType(type) { return String(type || '').replace(/_+$/, ''); }
  665. function getTypeIcon(type) { return NODE_ICONS[type] || String(type || '?').substring(0, 2).toUpperCase(); }
  666. function getTypeColor(type) { return TYPE_COLORS[type] || '#2dd4bf'; }
  667. function resolveToolStepName(step = {}) {
  668. const direct = step.tool || step.toolName || step.name || '';
  669. if (direct) return String(direct);
  670. const parts = String(step.id || '').split('_');
  671. if (parts[0] === 'Tool') parts.shift();
  672. if (parts.length > 1 && /^\d+$/.test(parts[0])) parts.shift();
  673. return parts.join('_') || '';
  674. }
  675. function isWorkflowRunToolName(name) {
  676. return String(name || '').trim().toLowerCase() === 'workflowrun';
  677. }
  678. function isSubflowStep(step = {}) {
  679. const explicit = normalizeType(step.type || '');
  680. if (explicit === 'Subflow') return true;
  681. const prefix = normalizeType(String(step.id || '').split('_')[0]);
  682. if (prefix === 'Subflow') return true;
  683. return prefix === 'Tool' && isWorkflowRunToolName(resolveToolStepName(step));
  684. }
  685. function getNodeStateLabel(status) {
  686. switch (status) {
  687. case 'running': return 'Running';
  688. case 'waiting': return 'Waiting';
  689. case 'paused': return 'Paused';
  690. case 'done': return 'Done';
  691. case 'error': return 'Error';
  692. case 'skipped': return 'Skipped';
  693. default: return 'Idle';
  694. }
  695. }
  696. function getNodeKindLabel(type, data = {}) {
  697. if (type === 'Subflow') {
  698. const mode = data.mode || data.executionMode || 'sync';
  699. return `mode:${mode}`;
  700. }
  701. if (type === 'Tool') {
  702. const toolName = resolveToolStepName(data);
  703. return toolName ? `tool:${toolName}` : '';
  704. }
  705. return '';
  706. }
  707. function normalizeCheckpoint(cp) {
  708. if (!cp || typeof cp !== 'object') return null;
  709. if (cp.checkpoint && typeof cp.checkpoint === 'object') return normalizeCheckpoint(cp.checkpoint);
  710. return cp.currentStepID || cp.variables || cp.completedSteps ? cp : null;
  711. }
  712. function cloneJSON(value) {
  713. if (value == null) return value;
  714. if (typeof structuredClone === 'function') return structuredClone(value);
  715. return JSON.parse(JSON.stringify(value));
  716. }
  717. function normalizeWorkflowRef(raw) {
  718. const value = String(raw || '').trim();
  719. if (!value) return '';
  720. return value.replace(/\.json$/i, '');
  721. }
  722. function summarizePathLabel(value) {
  723. const text = String(value || '').trim();
  724. if (!text) return '';
  725. const clean = text.replace(/\\/g, '/').replace(/\.json$/i, '');
  726. const parts = clean.split('/').filter(Boolean);
  727. return parts.length ? parts[parts.length - 1] : clean;
  728. }
  729. function isDynamicExpression(value) {
  730. const text = String(value || '').trim();
  731. if (!text) return false;
  732. return text.startsWith('=') || text.includes('$');
  733. }
  734. function resolveSubflowTarget(step = {}) {
  735. const workflowRef = step.workflow_path || step.workflowPath || step.path || step.workflow || '';
  736. const workDir = step.work_dir || step.workDir || step.base_dir || step.baseDir || step.subspace || '';
  737. if (!workflowRef) return { ok: false, reason: 'Subflow node has no workflow_path' };
  738. if (isDynamicExpression(workflowRef)) {
  739. return { ok: false, reason: 'Dynamic workflow_path cannot be opened statically' };
  740. }
  741. return {
  742. ok: true,
  743. ref: String(workflowRef).trim(),
  744. label: summarizePathLabel(workflowRef),
  745. workDir: isDynamicExpression(workDir) ? '' : String(workDir || '').trim(),
  746. };
  747. }
  748. function saveUIPreferences() {
  749. try { localStorage.setItem(UI_PREFS_KEY, JSON.stringify(_uiState)); } catch {}
  750. }
  751. function loadUIPreferences() {
  752. try {
  753. const raw = localStorage.getItem(UI_PREFS_KEY);
  754. if (!raw) return;
  755. const parsed = JSON.parse(raw);
  756. if (parsed && typeof parsed === 'object') {
  757. _uiState = {
  758. ..._uiState,
  759. ...parsed,
  760. };
  761. }
  762. } catch {}
  763. }
  764. function applyUIState() {
  765. document.body.classList.toggle('compact-ui', !!_uiState.compact);
  766. document.body.classList.toggle('types-collapsed', !_uiState.showTypes);
  767. document.body.classList.toggle('events-collapsed', !_uiState.showEvents);
  768. document.body.classList.toggle('map-hidden', !_uiState.showMap);
  769. const compactBtn = $('compactBtn');
  770. const coreBtn = $('coreBtn');
  771. const typesBtn = $('typesBtn');
  772. const eventsBtn = $('eventsBtn');
  773. const mapBtn = $('mapBtn');
  774. const typeFilterBtn = $('typeFilterBtn');
  775. if (compactBtn) compactBtn.textContent = _uiState.compact ? 'Compact On' : 'Compact';
  776. if (coreBtn) coreBtn.textContent = _uiState.simplifyGraph ? 'Dense On' : 'Dense';
  777. if (typesBtn) typesBtn.textContent = _uiState.showTypes ? 'Types On' : 'Types';
  778. if (eventsBtn) eventsBtn.textContent = _uiState.showEvents ? 'Events On' : 'Events';
  779. if (mapBtn) mapBtn.textContent = _uiState.showMap ? 'Map On' : 'Map';
  780. if (typeFilterBtn) typeFilterBtn.textContent = _uiState.showAllTypes ? 'All' : 'Used';
  781. }
  782. function toggleCompactMode() {
  783. _uiState.compact = !_uiState.compact;
  784. saveUIPreferences();
  785. applyUIState();
  786. autoLayout();
  787. render();
  788. }
  789. function toggleSimplifyGraph() {
  790. _uiState.simplifyGraph = !_uiState.simplifyGraph;
  791. saveUIPreferences();
  792. applyUIState();
  793. autoLayout();
  794. render();
  795. }
  796. function toggleTypePanel() {
  797. _uiState.showTypes = !_uiState.showTypes;
  798. saveUIPreferences();
  799. applyUIState();
  800. render();
  801. }
  802. function toggleEventPanel() {
  803. _uiState.showEvents = !_uiState.showEvents;
  804. saveUIPreferences();
  805. applyUIState();
  806. }
  807. function toggleMapPanel() {
  808. _uiState.showMap = !_uiState.showMap;
  809. saveUIPreferences();
  810. applyUIState();
  811. }
  812. function toggleTypeFilter() {
  813. _uiState.showAllTypes = !_uiState.showAllTypes;
  814. saveUIPreferences();
  815. applyUIState();
  816. renderTypeSidebar();
  817. }
  818. function toast(msg) {
  819. const t = $('toast');
  820. t.textContent = msg;
  821. t.classList.add('show');
  822. setTimeout(() => t.classList.remove('show'), 2500);
  823. }
  824. function listRunSessions() {
  825. return Array.from(_runSessions.values()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
  826. }
  827. function getSelectedRunSession() {
  828. return _selectedRunID ? _runSessions.get(_selectedRunID) || null : null;
  829. }
  830. function isPendingRunID(runID) {
  831. return String(runID || '').startsWith('pending:');
  832. }
  833. function isClientRunToken(runID) {
  834. return String(runID || '').startsWith('client:');
  835. }
  836. function isTransientRunRef(runID) {
  837. return isPendingRunID(runID) || isClientRunToken(runID);
  838. }
  839. function makeClientRunToken() {
  840. _runTokenSeq += 1;
  841. return `client:${Date.now()}:${_runTokenSeq}`;
  842. }
  843. function shortRunID(runID) {
  844. const text = String(runID || '');
  845. if (!text) return 'unknown';
  846. if (isPendingRunID(text)) return 'pending';
  847. if (isClientRunToken(text)) return text.split(':').pop() || 'client';
  848. return text.length > 12 ? text.slice(-8) : text;
  849. }
  850. function formatEventTime(ts) {
  851. const date = ts ? new Date(ts) : new Date();
  852. if (Number.isNaN(date.getTime())) return '--:--:--';
  853. return date.toLocaleTimeString([], { hour12: false });
  854. }
  855. function previewValue(value, maxLen = 720) {
  856. if (value == null || value === '') return '';
  857. let text = '';
  858. if (typeof value === 'string') {
  859. text = value;
  860. } else {
  861. try {
  862. text = JSON.stringify(value, null, 2);
  863. } catch {
  864. text = String(value);
  865. }
  866. }
  867. return text.length > maxLen ? text.slice(0, maxLen) + '\u2026' : text;
  868. }
  869. function summarizeRunEvent(evt) {
  870. const payload = evt?.payload && typeof evt.payload === 'object' ? evt.payload : null;
  871. const data = payload ? { ...payload, ...evt } : (evt || {});
  872. const evtType = data.type || 'event';
  873. const nodeRef = data.nodeId || data.stepID || data.currentStepID || '';
  874. const toolRef = data.name || data.tool || data.stepId || nodeRef || 'tool';
  875. const info = { level: 'info', summary: evtType, detail: '' };
  876. switch (evtType) {
  877. case 'workflow_start':
  878. info.summary = `Workflow started${data.name ? `: ${data.name}` : ''}`;
  879. info.detail = previewValue(data.params || data.resumedFrom || data.payload || '');
  880. break;
  881. case 'workflow_done':
  882. case 'done':
  883. info.level = 'success';
  884. info.summary = `Workflow completed${data.stop_id ? ` @ ${data.stop_id}` : ''}`;
  885. info.detail = previewValue(data.filesWritten || data.payload || '');
  886. break;
  887. case 'workflow_failed':
  888. case 'error':
  889. info.level = 'error';
  890. info.summary = `Workflow failed${nodeRef ? ` @ ${nodeRef}` : ''}`;
  891. info.detail = previewValue(data.error || data.payload || '');
  892. break;
  893. case 'checkpoint':
  894. info.summary = `Checkpoint${data.checkpoint?.currentStepID ? ` @ ${data.checkpoint.currentStepID}` : data.currentStepID ? ` @ ${data.currentStepID}` : ''}`;
  895. info.detail = previewValue(data.checkpoint || data);
  896. break;
  897. case 'node_start':
  898. case 'step_start':
  899. info.summary = `Step start${nodeRef ? `: ${nodeRef}` : ''}`;
  900. info.detail = previewValue(data.input || data.resolvedInputs || '');
  901. break;
  902. case 'node_done':
  903. case 'step_done':
  904. info.level = 'success';
  905. info.summary = `Step done${nodeRef ? `: ${nodeRef}` : ''}`;
  906. info.detail = previewValue(data.output || data.outputs || data.selected || '');
  907. break;
  908. case 'node_error':
  909. case 'step_error':
  910. info.level = 'error';
  911. info.summary = `Step error${nodeRef ? `: ${nodeRef}` : ''}`;
  912. info.detail = previewValue(data.error || data.detail || '');
  913. break;
  914. case 'node_skipped':
  915. case 'step_skipped':
  916. info.summary = `Step skipped${nodeRef ? `: ${nodeRef}` : ''}`;
  917. info.detail = previewValue(data.condition || '');
  918. break;
  919. case 'pause':
  920. case 'pause_start':
  921. info.level = 'warn';
  922. info.summary = `Paused${nodeRef ? ` @ ${nodeRef}` : ''}`;
  923. info.detail = previewValue(data.reason || data.message || '');
  924. break;
  925. case 'resumed':
  926. case 'pause_resumed':
  927. info.level = 'success';
  928. info.summary = `Resumed${nodeRef ? ` @ ${nodeRef}` : ''}`;
  929. info.detail = previewValue(data.requestId || data.payload || '');
  930. break;
  931. case 'tool_start':
  932. info.summary = `Tool start: ${toolRef}`;
  933. info.detail = previewValue(data.inputSummary || data.input || '');
  934. break;
  935. case 'tool_done':
  936. info.level = 'success';
  937. info.summary = `Tool done: ${toolRef}`;
  938. info.detail = previewValue(data.outputSummary || data.output || data.toolResult || '');
  939. break;
  940. case 'tool_error':
  941. info.level = data.allowError ? 'warn' : 'error';
  942. info.summary = `Tool error: ${toolRef}${data.allowError ? ' (continued)' : ''}`;
  943. info.detail = previewValue(data.error || data.output || data.toolResult || '');
  944. break;
  945. case 'tool_message':
  946. info.level = data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info';
  947. info.summary = `${toolRef}: ${data.message || 'message'}`;
  948. info.detail = previewValue(data.data || '');
  949. break;
  950. case 'llm_tool_use':
  951. info.summary = `LLM tool use: ${toolRef}`;
  952. info.detail = previewValue(data.input || '');
  953. break;
  954. case 'llm_tool_result':
  955. info.level = data.is_error ? 'error' : 'success';
  956. info.summary = `LLM tool result: ${toolRef}`;
  957. info.detail = previewValue(data.content || '');
  958. break;
  959. case 'llm_done':
  960. info.level = 'success';
  961. info.summary = `LLM completed${nodeRef ? `: ${nodeRef}` : ''}`;
  962. info.detail = previewValue(data.usage || data.model || '');
  963. break;
  964. case 'llm_error':
  965. info.level = 'error';
  966. info.summary = `LLM error${nodeRef ? `: ${nodeRef}` : ''}`;
  967. info.detail = previewValue(data.error || '');
  968. break;
  969. default:
  970. info.summary = `${evtType}${nodeRef ? `: ${nodeRef}` : ''}`;
  971. info.detail = previewValue(data.message || data.error || data.output || data.input || data.payload || '');
  972. break;
  973. }
  974. return info;
  975. }
  976. function shouldRecordRunEvent(evtType) {
  977. return !['token', 'llm_token', 'llm_thinking'].includes(evtType);
  978. }
  979. function recordRunEvent(session, evt) {
  980. if (!session || !evt || !shouldRecordRunEvent(evt.type)) return;
  981. const summary = summarizeRunEvent(evt);
  982. if (!session.events) session.events = [];
  983. session.events.push({
  984. ts: evt.ts || new Date().toISOString(),
  985. type: evt.type || 'event',
  986. level: summary.level || 'info',
  987. summary: summary.summary || String(evt.type || 'event'),
  988. detail: summary.detail || '',
  989. });
  990. if (session.events.length > 120) session.events.splice(0, session.events.length - 120);
  991. }
  992. function renderEventLog() {
  993. const list = $('eventList');
  994. if (!list) return;
  995. const session = getSelectedRunSession();
  996. if (!session) {
  997. list.innerHTML = '<div class="event-empty">Start or select a run to inspect its event stream.</div>';
  998. return;
  999. }
  1000. const events = Array.isArray(session.events) ? session.events : [];
  1001. if (!events.length) {
  1002. list.innerHTML = `<div class="event-empty">${esc(session.label)} has no captured events yet.</div>`;
  1003. return;
  1004. }
  1005. list.innerHTML = events.slice().reverse().map((event) => {
  1006. const detail = event.detail ? `<div class="event-detail">${esc(event.detail)}</div>` : '';
  1007. return `<div class="event-item">
  1008. <div class="event-time">${esc(formatEventTime(event.ts))}</div>
  1009. <div class="event-body">
  1010. <div class="event-line level-${esc(event.level || 'info')}"><span class="event-type">${esc(event.type || 'event')}</span>${esc(event.summary || '')}</div>
  1011. ${detail}
  1012. </div>
  1013. </div>`;
  1014. }).join('');
  1015. }
  1016. function clearSelectedRunEvents() {
  1017. const session = getSelectedRunSession();
  1018. if (!session) return;
  1019. session.events = [];
  1020. session.updatedAt = Date.now();
  1021. renderEventLog();
  1022. saveCheckpointToStorage();
  1023. }
  1024. function completedCount(session) {
  1025. if (!session) return 0;
  1026. return session.checkpoint?.completedSteps?.length
  1027. || Object.values(session.nodeStatuses || {}).filter((status) => status === 'done').length;
  1028. }
  1029. function findRunSessionIDByRunID(runID) {
  1030. if (!runID) return null;
  1031. for (const [sessionID, session] of _runSessions) {
  1032. if (session.runID === runID) return sessionID;
  1033. }
  1034. return null;
  1035. }
  1036. function resolveRunSessionID({ sessionID = null, clientRunToken = null, runID = null, runHint = null } = {}) {
  1037. for (const candidate of [sessionID, clientRunToken, runHint]) {
  1038. if (candidate && _runSessions.has(candidate)) return candidate;
  1039. }
  1040. return findRunSessionIDByRunID(runID) || sessionID || clientRunToken || runHint || runID || null;
  1041. }
  1042. function getSessionRunRef(session) {
  1043. return session?.runID || session?.clientRunToken || session?.sessionID || '';
  1044. }
  1045. function setRunController(sessionID, controller) {
  1046. if (sessionID && controller) _activeRunControllers.set(sessionID, controller);
  1047. }
  1048. function getRunController(sessionID) {
  1049. return sessionID ? _activeRunControllers.get(sessionID) || null : null;
  1050. }
  1051. function clearRunController(sessionID, controller = null) {
  1052. if (!sessionID) return;
  1053. const active = _activeRunControllers.get(sessionID);
  1054. if (!active) return;
  1055. if (!controller || active === controller) _activeRunControllers.delete(sessionID);
  1056. }
  1057. function abortRunController(sessionID) {
  1058. const controller = getRunController(sessionID);
  1059. if (!controller) return false;
  1060. controller.abort();
  1061. clearRunController(sessionID, controller);
  1062. return true;
  1063. }
  1064. function isRunStreaming(sessionID) {
  1065. return !!getRunController(sessionID);
  1066. }
  1067. function ensureRunSession(sessionID, seed = {}) {
  1068. if (!sessionID) return null;
  1069. let session = _runSessions.get(sessionID);
  1070. if (!session) {
  1071. const seq = seed.seq || (++_runSeq);
  1072. const actualRunID = seed.runID || null;
  1073. session = {
  1074. sessionID,
  1075. clientRunToken: seed.clientRunToken || (isTransientRunRef(sessionID) ? sessionID : null),
  1076. runID: actualRunID,
  1077. seq,
  1078. label: seed.label || `Run ${seq}`,
  1079. workflowName: seed.workflowName || _currentWorkflowJson?.name || '',
  1080. status: seed.status || 'idle',
  1081. nodeStatuses: seed.nodeStatuses ? { ...seed.nodeStatuses } : {},
  1082. checkpoint: seed.checkpoint || null,
  1083. filesWritten: Array.isArray(seed.filesWritten) ? [...seed.filesWritten] : [],
  1084. currentStepID: seed.currentStepID || null,
  1085. events: Array.isArray(seed.events) ? [...seed.events] : [],
  1086. updatedAt: seed.updatedAt || Date.now(),
  1087. pending: seed.pending ?? (!actualRunID && isTransientRunRef(sessionID)),
  1088. };
  1089. _runSessions.set(sessionID, session);
  1090. } else {
  1091. if (seed.clientRunToken) session.clientRunToken = seed.clientRunToken;
  1092. if (seed.runID) session.runID = seed.runID;
  1093. if (seed.workflowName) session.workflowName = seed.workflowName;
  1094. if (seed.status) session.status = seed.status;
  1095. if (seed.checkpoint) session.checkpoint = seed.checkpoint;
  1096. if (seed.currentStepID) session.currentStepID = seed.currentStepID;
  1097. if (Array.isArray(seed.filesWritten)) session.filesWritten = [...seed.filesWritten];
  1098. if (seed.nodeStatuses) session.nodeStatuses = { ...seed.nodeStatuses };
  1099. if (Array.isArray(seed.events)) session.events = [...seed.events];
  1100. if (seed.label) session.label = seed.label;
  1101. if (seed.seq) session.seq = seed.seq;
  1102. if (seed.pending != null) session.pending = seed.pending;
  1103. session.updatedAt = seed.updatedAt || Date.now();
  1104. }
  1105. if (session.runID) session.pending = false;
  1106. if (session.seq > _runSeq) _runSeq = session.seq;
  1107. return session;
  1108. }
  1109. function renderRunSessions() {
  1110. const strip = $('runStrip');
  1111. const meta = $('runMeta');
  1112. if (!strip || !meta) return;
  1113. const sessions = listRunSessions();
  1114. if (!sessions.length) {
  1115. strip.innerHTML = '<span class="run-empty">No runs yet</span>';
  1116. meta.textContent = 'Selected run: none';
  1117. return;
  1118. }
  1119. strip.innerHTML = sessions.map((session) => {
  1120. const encoded = encodeURIComponent(session.sessionID);
  1121. const active = session.sessionID === _selectedRunID ? ' active' : '';
  1122. return `<button class="run-chip${active}" onclick="selectRunSession(decodeURIComponent('${encoded}'))">
  1123. <span class="run-chip-dot ${esc(session.status || 'idle')}"></span>
  1124. <span>${esc(session.label)}</span>
  1125. <span class="run-chip-id">${esc(shortRunID(getSessionRunRef(session)))}</span>
  1126. </button>`;
  1127. }).join('');
  1128. const selected = getSelectedRunSession();
  1129. if (!selected) {
  1130. meta.textContent = 'Selected run: none';
  1131. return;
  1132. }
  1133. const stepText = selected.currentStepID ? ` @ ${selected.currentStepID}` : '';
  1134. const fileText = selected.filesWritten?.length ? `, ${selected.filesWritten.length} files` : '';
  1135. const eventText = selected.events?.length ? `, ${selected.events.length} events` : '';
  1136. meta.textContent = `${selected.label} · ${selected.status || 'idle'}${stepText} · ${completedCount(selected)} done${fileText}${eventText}`;
  1137. }
  1138. function applySelectedRunToNodes() {
  1139. const session = getSelectedRunSession();
  1140. for (const node of state.nodes) {
  1141. node.status = session?.nodeStatuses?.[node.id] || null;
  1142. }
  1143. _lastCheckpoint = session?.checkpoint || null;
  1144. _currentRunID = session?.runID || null;
  1145. }
  1146. function updateExecutionControls() {
  1147. const session = getSelectedRunSession();
  1148. const running = !!(session && (session.status === 'running' || session.status === 'waiting' || isRunStreaming(session.sessionID)));
  1149. const resumable = !!(session && session.status === 'paused' && session.runID);
  1150. const stoppable = !!session && (running || session.status === 'paused');
  1151. $('runBtn').style.display = _currentWorkflowJson ? '' : 'none';
  1152. $('pauseBtn').style.display = running ? '' : 'none';
  1153. $('resumeBtn').style.display = resumable ? '' : 'none';
  1154. $('stopBtn').style.display = stoppable ? '' : 'none';
  1155. }
  1156. function updateStatusFromSelectedRun() {
  1157. const session = getSelectedRunSession();
  1158. if (!session) {
  1159. $('statusLabel').textContent = _currentWorkflowJson ? 'Ready' : '';
  1160. } else if (session.status === 'running') {
  1161. $('statusLabel').textContent = `Running ${session.label}${session.currentStepID ? ': ' + session.currentStepID : ''}`;
  1162. } else if (session.status === 'waiting') {
  1163. $('statusLabel').textContent = `Waiting ${session.label}${session.currentStepID ? ': ' + session.currentStepID : ''}`;
  1164. } else if (session.status === 'paused') {
  1165. $('statusLabel').textContent = `Paused ${session.label}${session.currentStepID ? ' @ ' + session.currentStepID : ''}`;
  1166. } else if (session.status === 'done') {
  1167. $('statusLabel').textContent = `Complete ${session.label}! ${session.filesWritten?.length || 0} files written`;
  1168. } else if (session.status === 'error') {
  1169. $('statusLabel').textContent = `Error in ${session.label}`;
  1170. } else {
  1171. $('statusLabel').textContent = `${session.label} ready`;
  1172. }
  1173. }
  1174. function syncSelectedRun(render = true) {
  1175. applySelectedRunToNodes();
  1176. updateExecutionControls();
  1177. updateStatusFromSelectedRun();
  1178. renderRunSessions();
  1179. renderEventLog();
  1180. if (render) {
  1181. renderNodes();
  1182. renderConnections();
  1183. updateMinimap();
  1184. }
  1185. }
  1186. function selectRunSession(runID) {
  1187. if (!runID || !_runSessions.has(runID)) return;
  1188. _selectedRunID = runID;
  1189. syncSelectedRun();
  1190. }
  1191. function clearRunSessions() {
  1192. for (const controller of _activeRunControllers.values()) {
  1193. try { controller.abort(); } catch {}
  1194. }
  1195. _runSessions = new Map();
  1196. _selectedRunID = null;
  1197. _currentRunID = null;
  1198. _lastCheckpoint = null;
  1199. _runTokenSeq = 0;
  1200. _activeRunControllers = new Map();
  1201. for (const node of state.nodes) node.status = null;
  1202. renderRunSessions();
  1203. renderEventLog();
  1204. updateExecutionControls();
  1205. updateStatusFromSelectedRun();
  1206. }
  1207. function getOrderedNodeTypes() {
  1208. const known = [...KNOWN_TYPES];
  1209. const extras = Array.from(new Set(state.nodes.map((node) => node.type).filter((type) => type && !KNOWN_TYPES.includes(type)))).sort();
  1210. return [...known, ...extras];
  1211. }
  1212. function isHideableUtilityNode(node) {
  1213. return !!node && HIDEABLE_TYPES.has(node.type);
  1214. }
  1215. function shouldHideNode(node) {
  1216. if (!_uiState.simplifyGraph || !node) return false;
  1217. if (node.id === state.selectedNodeId) return false;
  1218. if (['running', 'waiting', 'paused', 'error'].includes(node.status || '')) return false;
  1219. return isHideableUtilityNode(node);
  1220. }
  1221. function getRenderNodes() {
  1222. const filtered = state.nodes.filter((node) => !shouldHideNode(node));
  1223. return filtered.length ? filtered : [...state.nodes];
  1224. }
  1225. function getConnectionIndex() {
  1226. const outgoing = new Map();
  1227. for (const conn of state.connections) {
  1228. if (!outgoing.has(conn.from)) outgoing.set(conn.from, []);
  1229. outgoing.get(conn.from).push(conn);
  1230. }
  1231. return outgoing;
  1232. }
  1233. function resolveVisibleTargets(nodeId, visibleIds, outgoing, visited = new Set(), hiddenCount = 0) {
  1234. if (!nodeId) return [];
  1235. if (visibleIds.has(nodeId)) return [{ to: nodeId, hiddenCount }];
  1236. if (visited.has(nodeId)) return [];
  1237. visited.add(nodeId);
  1238. const nextEdges = outgoing.get(nodeId) || [];
  1239. if (!nextEdges.length) return [];
  1240. const results = [];
  1241. for (const edge of nextEdges) {
  1242. results.push(...resolveVisibleTargets(edge.to, visibleIds, outgoing, new Set(visited), hiddenCount + 1));
  1243. }
  1244. return results;
  1245. }
  1246. function getRenderConnections(renderNodes = getRenderNodes()) {
  1247. const visibleIds = new Set(renderNodes.map((node) => node.id));
  1248. if (!_uiState.simplifyGraph) {
  1249. return state.connections.filter((conn) => visibleIds.has(conn.from) && visibleIds.has(conn.to));
  1250. }
  1251. const outgoing = getConnectionIndex();
  1252. const dedup = new Set();
  1253. const compacted = [];
  1254. for (const conn of state.connections) {
  1255. if (!visibleIds.has(conn.from)) continue;
  1256. const targets = resolveVisibleTargets(conn.to, visibleIds, outgoing);
  1257. for (const target of targets) {
  1258. if (!target?.to || target.to === conn.from) continue;
  1259. const key = [conn.from, target.to, conn.type, conn.label || ''].join('::');
  1260. if (dedup.has(key)) continue;
  1261. dedup.add(key);
  1262. compacted.push({
  1263. ...conn,
  1264. to: target.to,
  1265. hiddenCount: target.hiddenCount || 0,
  1266. });
  1267. }
  1268. }
  1269. return compacted;
  1270. }
  1271. function shouldMinifyNode(node) {
  1272. if (!_uiState.compact || !node) return false;
  1273. if (node.id === state.selectedNodeId) return false;
  1274. if (['running', 'waiting', 'paused', 'error'].includes(node.status || '')) return false;
  1275. return MINIFY_TYPES.has(node.type);
  1276. }
  1277. function getLayoutMetrics() {
  1278. const nodeCount = getRenderNodes().length;
  1279. if (_uiState.compact) {
  1280. const dense = !!_uiState.simplifyGraph;
  1281. return {
  1282. layerGap: dense ? (nodeCount > 18 ? 178 : 194) : (nodeCount > 18 ? 214 : 238),
  1283. nodeGap: dense ? (nodeCount > 18 ? 96 : 112) : (nodeCount > 18 ? 122 : 142),
  1284. startX: 52,
  1285. startY: 44,
  1286. };
  1287. }
  1288. return {
  1289. layerGap: 300,
  1290. nodeGap: 180,
  1291. startX: 80,
  1292. startY: 60,
  1293. };
  1294. }
  1295. function focusNodeType(type) {
  1296. const node = state.nodes.find((entry) => entry.type === type);
  1297. if (!node) return;
  1298. state.selectedNodeId = node.id;
  1299. renderNodes();
  1300. renderConnections();
  1301. const el = $(`node-${node.id}`);
  1302. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  1303. updateNavigationChrome();
  1304. }
  1305. function renderTypeSidebar() {
  1306. const summary = $('typeSummary');
  1307. const list = $('typeList');
  1308. const badge = $('typePanelBadge');
  1309. const panelTitle = $('typePanelTitle');
  1310. if (!summary || !list || !badge) return;
  1311. const counts = new Map();
  1312. for (const node of state.nodes) {
  1313. counts.set(node.type, (counts.get(node.type) || 0) + 1);
  1314. }
  1315. const usedTypeCount = counts.size;
  1316. const shownNodeCount = getRenderNodes().length;
  1317. const orderedTypes = getOrderedNodeTypes().filter((type) => _uiState.showAllTypes || (counts.get(type) || 0) > 0);
  1318. badge.textContent = `${orderedTypes.length} shown`;
  1319. if (panelTitle) panelTitle.textContent = 'Node Types';
  1320. summary.innerHTML = `
  1321. <div class="sidebar-stat"><div class="sidebar-stat-label">Supported</div><div class="sidebar-stat-value">${KNOWN_TYPES.length}</div></div>
  1322. <div class="sidebar-stat"><div class="sidebar-stat-label">Used</div><div class="sidebar-stat-value">${usedTypeCount}</div></div>
  1323. <div class="sidebar-stat"><div class="sidebar-stat-label">Shown</div><div class="sidebar-stat-value">${shownNodeCount}</div></div>
  1324. <div class="sidebar-stat"><div class="sidebar-stat-label">Nodes</div><div class="sidebar-stat-value">${state.nodes.length}</div></div>
  1325. `;
  1326. list.innerHTML = orderedTypes.map((type) => {
  1327. const count = counts.get(type) || 0;
  1328. const caption = KNOWN_TYPES.includes(type) ? 'built-in' : 'custom';
  1329. const encodedType = encodeURIComponent(type);
  1330. return `<button class="type-row${count === 0 ? ' empty' : ''}" onclick="focusNodeType(decodeURIComponent('${encodedType}'))" title="${esc(type)}">
  1331. <span class="type-icon" style="background:${esc(getTypeColor(type))};">${esc(getTypeIcon(type))}</span>
  1332. <span class="type-meta">
  1333. <div class="type-name">${esc(type)}</div>
  1334. <div class="type-caption">${caption}</div>
  1335. </span>
  1336. <span class="type-count">${count}</span>
  1337. </button>`;
  1338. }).join('');
  1339. }
  1340. function buildWorkflowBreadcrumb() {
  1341. const parts = _workflowNavStack.map((entry) => entry.label || summarizePathLabel(entry.workflowRef) || entry.workflow?.name || 'workflow');
  1342. parts.push(_currentWorkflowJson?.name || summarizePathLabel(_workflowRef) || 'workflow');
  1343. return parts.join(' / ');
  1344. }
  1345. function updateNavigationChrome() {
  1346. const backBtn = $('backBtn');
  1347. const openBtn = $('openChildBtn');
  1348. const navSep = $('navSep');
  1349. const crumb = $('wfBreadcrumb');
  1350. const selectedNode = state.nodes.find((node) => node.id === state.selectedNodeId) || null;
  1351. const subflowTarget = selectedNode?.type === 'Subflow' ? resolveSubflowTarget(selectedNode.data || {}) : null;
  1352. if (backBtn) backBtn.style.display = _workflowNavStack.length ? '' : 'none';
  1353. if (navSep) navSep.style.display = (_workflowNavStack.length || selectedNode?.type === 'Subflow') ? '' : 'none';
  1354. if (openBtn) {
  1355. openBtn.style.display = selectedNode?.type === 'Subflow' ? '' : 'none';
  1356. openBtn.disabled = !(subflowTarget && subflowTarget.ok);
  1357. openBtn.title = subflowTarget?.ok
  1358. ? `Open child workflow: ${subflowTarget.label || subflowTarget.ref}`
  1359. : (subflowTarget?.reason || 'Select a Subflow node');
  1360. }
  1361. if (crumb) crumb.textContent = buildWorkflowBreadcrumb();
  1362. }
  1363. function deselectSelectedNode(render = true) {
  1364. if (!state.selectedNodeId) return;
  1365. state.selectedNodeId = null;
  1366. if (render) {
  1367. renderNodes();
  1368. renderConnections();
  1369. updateNavigationChrome();
  1370. }
  1371. }
  1372. // ===== Parse workflow JSON into internal state =====
  1373. function parseWorkflow(json, workflowRef = null, options = {}) {
  1374. if (!options.preserveNav) _workflowNavStack = [];
  1375. _currentWorkflowJson = json;
  1376. _workflowRef = workflowRef || null;
  1377. state.nodes = [];
  1378. state.connections = [];
  1379. state.registry = json.registry || {};
  1380. if (!json?.steps?.length) return;
  1381. const steps = json.steps;
  1382. $('wfTitle').textContent = options.title || json.name || summarizePathLabel(workflowRef) || 'VL Workflow DAG';
  1383. clearRunSessions();
  1384. // Create nodes
  1385. for (const step of steps) {
  1386. const type = getStepType(step);
  1387. state.nodes.push({
  1388. id: step.id,
  1389. type,
  1390. x: step._x || 0,
  1391. y: step._y || 0,
  1392. data: step,
  1393. status: null
  1394. });
  1395. }
  1396. // Create connections
  1397. let connId = 0;
  1398. for (const step of steps) {
  1399. // FIX #1: exclude both RETURN and BREAK (reserved keywords, not real nodes)
  1400. if (step.next && !RESERVED_NEXT.has(step.next)) {
  1401. state.connections.push({ id: 'c' + (connId++), from: step.id, to: step.next, type: 'serial' });
  1402. }
  1403. if (step.children?.length) {
  1404. for (const childId of step.children) {
  1405. state.connections.push({ id: 'c' + (connId++), from: step.id, to: childId, type: 'parallel' });
  1406. }
  1407. }
  1408. // Branch cases — support both array [[expr, target]] and object {expr: target}
  1409. if (step.branches) {
  1410. if (Array.isArray(step.branches)) {
  1411. for (const [expr, targetId] of step.branches) {
  1412. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  1413. }
  1414. } else {
  1415. for (const [expr, targetId] of Object.entries(step.branches)) {
  1416. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  1417. }
  1418. }
  1419. }
  1420. // Legacy: cases field
  1421. if (step.cases) {
  1422. for (const [expr, targetId] of Object.entries(step.cases)) {
  1423. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  1424. }
  1425. }
  1426. }
  1427. const hasPos = state.nodes.some(n => n.x > 0 || n.y > 0);
  1428. if (!hasPos) autoLayout();
  1429. // Restore checkpoint state (node statuses) if available
  1430. restoreFromStorage();
  1431. syncSelectedRun(false);
  1432. render();
  1433. if (options.selectedNodeId) {
  1434. state.selectedNodeId = options.selectedNodeId;
  1435. renderNodes();
  1436. renderConnections();
  1437. }
  1438. updateNavigationChrome();
  1439. }
  1440. function getStepType(step) {
  1441. if (isSubflowStep(step)) return 'Subflow';
  1442. if (step.type) return normalizeType(step.type) || 'LLM';
  1443. const id = step.id || '';
  1444. const prefix = normalizeType(id.split('_')[0]);
  1445. // FIX #3: include Download and Unzip in known types
  1446. return prefix || 'LLM';
  1447. }
  1448. // ===== Auto Layout (topological, with barycenter crossing reduction) =====
  1449. function autoLayout() {
  1450. const metrics = getLayoutMetrics();
  1451. const LAYER_GAP = metrics.layerGap;
  1452. const NODE_GAP = metrics.nodeGap;
  1453. const START_X = metrics.startX;
  1454. const START_Y = metrics.startY;
  1455. const renderNodes = getRenderNodes();
  1456. if (!renderNodes.length) return;
  1457. const renderConnections = getRenderConnections(renderNodes);
  1458. const nodeMap = new Map(state.nodes.map(n => [n.id, n]));
  1459. const succs = new Map(), preds = new Map();
  1460. for (const n of renderNodes) { succs.set(n.id, []); preds.set(n.id, []); }
  1461. for (const c of renderConnections) {
  1462. if (succs.has(c.from) && preds.has(c.to)) {
  1463. succs.get(c.from).push(c.to);
  1464. preds.get(c.to).push(c.from);
  1465. }
  1466. }
  1467. // Find roots
  1468. const roots = renderNodes.filter(n => (preds.get(n.id)?.length || 0) === 0).map(n => n.id);
  1469. if (roots.length === 0 && renderNodes.length > 0) roots.push(renderNodes[0].id);
  1470. // Longest-path layer assignment (with cycle protection)
  1471. const layers = new Map();
  1472. const inStack = new Set();
  1473. function assignLayer(id, depth) {
  1474. if (inStack.has(id)) return;
  1475. inStack.add(id);
  1476. layers.set(id, Math.max(layers.get(id) || 0, depth));
  1477. for (const s of (succs.get(id) || [])) assignLayer(s, depth + 1);
  1478. inStack.delete(id);
  1479. }
  1480. for (const r of roots) assignLayer(r, 0);
  1481. for (const n of renderNodes) { if (!layers.has(n.id)) layers.set(n.id, 0); }
  1482. // Group by layer
  1483. const layerGroups = new Map();
  1484. for (const [id, layer] of layers) {
  1485. if (!layerGroups.has(layer)) layerGroups.set(layer, []);
  1486. layerGroups.get(layer).push(id);
  1487. }
  1488. // Barycenter ordering (reduce edge crossings)
  1489. const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
  1490. for (let pass = 0; pass < 4; pass++) {
  1491. for (let li = 1; li < sortedLayers.length; li++) {
  1492. const group = layerGroups.get(sortedLayers[li]);
  1493. const prevGroup = layerGroups.get(sortedLayers[li - 1]);
  1494. const prevIdx = new Map(prevGroup.map((id, i) => [id, i]));
  1495. group.sort((a, b) => {
  1496. const aParents = preds.get(a)?.filter(p => prevIdx.has(p)) || [];
  1497. const bParents = preds.get(b)?.filter(p => prevIdx.has(p)) || [];
  1498. const aAvg = aParents.length ? aParents.reduce((s, p) => s + prevIdx.get(p), 0) / aParents.length : 0;
  1499. const bAvg = bParents.length ? bParents.reduce((s, p) => s + prevIdx.get(p), 0) / bParents.length : 0;
  1500. return aAvg - bAvg;
  1501. });
  1502. }
  1503. }
  1504. // Position nodes
  1505. for (const layer of sortedLayers) {
  1506. const group = layerGroups.get(layer);
  1507. const totalHeight = group.length * NODE_GAP;
  1508. const startY = Math.max(START_Y, 400 - totalHeight / 2);
  1509. for (let i = 0; i < group.length; i++) {
  1510. const node = nodeMap.get(group[i]);
  1511. if (node) {
  1512. node.x = START_X + layer * LAYER_GAP;
  1513. node.y = startY + i * NODE_GAP;
  1514. }
  1515. }
  1516. }
  1517. }
  1518. // ===== Compute I/O audit for a node =====
  1519. function computeIO(step) {
  1520. const io = { varsIn: [], varsOut: [], docs: [], files: [] };
  1521. if (!step) return io;
  1522. const data = step.in || step;
  1523. // Input vars (references starting with = or $)
  1524. const jsonStr = JSON.stringify(data);
  1525. const varRefs = jsonStr.match(/=\$?[\w.]+|"\$[\w.]+"/g) || [];
  1526. for (const ref of varRefs) {
  1527. const clean = ref.replace(/[="]/g, '');
  1528. if (clean.startsWith('$') && !io.varsIn.includes(clean)) io.varsIn.push(clean);
  1529. }
  1530. // Docs
  1531. if (step.in?.docs) io.docs = step.in.docs;
  1532. // Output vars
  1533. if (step.out) {
  1534. for (const [key, val] of Object.entries(step.out)) {
  1535. if (key.startsWith('$')) io.varsOut.push(key);
  1536. else if (key.startsWith('/')) io.files.push(key);
  1537. }
  1538. }
  1539. // Source (loop / download / unzip)
  1540. if (step.source) {
  1541. const src = String(step.source);
  1542. if (src.startsWith('=$') || src.startsWith('$')) io.varsIn.push(src.replace(/^=/, ''));
  1543. }
  1544. // FIX #4: while expression can reference variables
  1545. if (step.while) {
  1546. const whileStr = String(step.while);
  1547. const whileRefs = whileStr.match(/\$[\w.]+/g) || [];
  1548. for (const ref of whileRefs) {
  1549. if (!io.varsIn.includes(ref)) io.varsIn.push(ref);
  1550. }
  1551. }
  1552. return io;
  1553. }
  1554. // ===== Type-Specific Body Renderers =====
  1555. function renderLLMBody(data, expanded = false) {
  1556. let html = '';
  1557. const docs = data.in?.docs?.length ? data.in.docs : null;
  1558. if (docs) {
  1559. html += `<div class="node-section"><div class="node-section-title">Documents</div>
  1560. <div class="node-docs-list">${docs.map(d =>
  1561. `<span class="node-doc-tag" title="${esc(resolveDocName(d))}">${esc(d)}: ${esc(nodeText(resolveDocName(d), 16, expanded))}</span>`
  1562. ).join('')}</div></div>`;
  1563. }
  1564. if (data.in?.model || data.model) {
  1565. html += `<div class="node-field"><span class="node-label">model</span><span class="node-value">${esc(nodeText(data.in?.model || data.model, 20, expanded))}</span></div>`;
  1566. }
  1567. if (data.in?.max_tokens) {
  1568. html += `<div class="node-field"><span class="node-label">tokens</span><span class="node-value">${data.in.max_tokens}</span></div>`;
  1569. }
  1570. if (data.in?.messages?.length) {
  1571. html += `<div class="node-field"><span class="node-label">msgs</span><span class="node-value">${data.in.messages.length} messages</span></div>`;
  1572. }
  1573. html += renderOutSection(data.out, expanded);
  1574. return html;
  1575. }
  1576. function renderServiceBody(data, expanded = false) {
  1577. let html = '';
  1578. if (data.serviceId) {
  1579. html += `<div class="node-field"><span class="node-label">service</span><span class="node-value ref">${esc(nodeText(data.serviceId, 20, expanded))}</span></div>`;
  1580. }
  1581. html += renderInputSection(data.in, expanded);
  1582. html += renderOutSection(data.out, expanded);
  1583. return html;
  1584. }
  1585. function renderAPIBody(data, expanded = false) {
  1586. let html = '';
  1587. const apiId = data.apiId || '';
  1588. const apis = state.registry?.apis || [];
  1589. const apiDef = apis.find(a => a.id === apiId || a === apiId);
  1590. if (apiDef && typeof apiDef === 'object') {
  1591. html += `<div class="node-field"><span class="node-label">${esc(apiDef.method || 'POST')}</span><span class="node-value ref" title="${esc(apiDef.url || '')}">${esc(nodeText(apiDef.desc || apiDef.url || apiId, 20, expanded))}</span></div>`;
  1592. } else {
  1593. html += `<div class="node-field"><span class="node-label">api</span><span class="node-value ref">${esc(nodeText(apiId || data.id || '', 20, expanded))}</span></div>`;
  1594. }
  1595. html += renderInputSection(data.in, expanded);
  1596. html += renderOutSection(data.out, expanded);
  1597. return html;
  1598. }
  1599. function renderComponentBody(data, expanded = false) {
  1600. let html = '';
  1601. if (data.componentId) {
  1602. html += `<div class="node-field"><span class="node-label">comp</span><span class="node-value ref">${esc(nodeText(data.componentId, 20, expanded))}</span></div>`;
  1603. }
  1604. html += renderInputSection(data.in, expanded);
  1605. html += renderOutSection(data.out, expanded);
  1606. return html;
  1607. }
  1608. function renderSetBody(data, expanded = false) {
  1609. return `<div class="node-section"><div class="node-section-title">Assignment</div>
  1610. <div class="node-io-item"><span class="node-io-key is-var">${esc(data.target || '$var')}</span>
  1611. <span class="node-io-arrow">\u2190</span>
  1612. <span class="node-io-value">${esc(nodeText(data.value || '', 18, expanded))}</span></div></div>`;
  1613. }
  1614. function renderWriteBody(data, expanded = false) {
  1615. const modeStr = data.mode ? ` [${esc(data.mode)}]` : '';
  1616. return `<div class="node-section"><div class="node-section-title">Write Artifact${modeStr}</div>
  1617. <div class="node-field"><span class="node-label">target</span><span class="node-value file">${esc(nodeText(data.target || '', 20, expanded))}</span></div>
  1618. <div class="node-field"><span class="node-label">value</span><span class="node-value var">${esc(nodeText(data.value || '', 18, expanded))}</span></div></div>`;
  1619. }
  1620. function renderBranchBody(data, expanded = false) {
  1621. const cases = Array.isArray(data.branches) ? data.branches
  1622. : data.branches && typeof data.branches === 'object' ? Object.entries(data.branches)
  1623. : data.cases ? Object.entries(data.cases) : [];
  1624. if (cases.length === 0) return '';
  1625. let html = '<div class="node-section"><div class="node-section-title">Cases</div>';
  1626. const visibleCases = expanded ? cases : cases.slice(0, 4);
  1627. visibleCases.forEach(([cond, target]) => {
  1628. html += `<div class="node-io-item"><span class="node-io-key">${esc(nodeText(cond, 14, expanded))}</span>
  1629. <span class="node-io-arrow">\u2192</span>
  1630. <span class="node-io-value">${esc(nodeText(target || '?', 12, expanded))}</span></div>`;
  1631. });
  1632. if (!expanded && cases.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${cases.length - 4} more</div>`;
  1633. html += '</div>';
  1634. return html;
  1635. }
  1636. // FIX #2: Loop body now handles both source mode and while mode (Spec 3.16)
  1637. function renderLoopBody(data, expanded = false) {
  1638. const isWhile = !!data.while;
  1639. let html = '<div class="node-section"><div class="node-section-title">Loop Config</div>';
  1640. if (isWhile) {
  1641. // While mode (3.16)
  1642. html += `<div class="node-field"><span class="node-label">while</span><span class="node-value var">${esc(nodeText(data.while, 20, expanded))}</span></div>`;
  1643. if (data.maxIterations != null) {
  1644. html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
  1645. }
  1646. } else {
  1647. // Source mode (original)
  1648. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${esc(nodeText(data.source || '', 16, expanded))}</span></div>`;
  1649. if (data.maxIterations != null) {
  1650. html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
  1651. }
  1652. }
  1653. html += `<div class="node-field"><span class="node-label">mode</span><span class="node-value">${esc(data.mode || 'parallel')}</span></div>`;
  1654. html += '</div>';
  1655. return html;
  1656. }
  1657. function renderStopBody() {
  1658. return '<div class="node-section"><div class="node-field"><span class="node-value" style="color:var(--red);">Workflow terminates here</span></div></div>';
  1659. }
  1660. function renderPauseBody(data, expanded = false) {
  1661. const msg = data.in?.message || data.message || data.reason || '';
  1662. const displayKeys = data.in?.display ? Object.keys(data.in.display) : [];
  1663. let html = '<div class="node-section"><div class="node-section-title">\u23F8 Pause \u2014 Waiting for Approval</div>';
  1664. if (msg) html += `<div class="node-field"><span class="node-label">msg</span><span class="node-value">${esc(nodeText(msg, 24, expanded))}</span></div>`;
  1665. if (displayKeys.length > 0) html += `<div class="node-field"><span class="node-label">show</span><span class="node-value">${esc(nodeText(displayKeys.join(', '), 24, expanded))}</span></div>`;
  1666. html += '</div>';
  1667. html += renderOutSection(data.out, expanded);
  1668. return html;
  1669. }
  1670. function renderForkBody(data, expanded = false) {
  1671. const children = data.children || [];
  1672. return `<div class="node-section"><div class="node-section-title">Fork Config</div>
  1673. ${data.source ? `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${esc(nodeText(data.source, 16, expanded))}</span></div>` : ''}
  1674. <div class="node-field"><span class="node-label">branches</span><span class="node-value">${children.length} parallel</span></div></div>`;
  1675. }
  1676. function renderToolBody(data, expanded = false) {
  1677. let html = '<div class="node-section"><div class="node-section-title">Tool Call</div>';
  1678. const toolName = resolveToolStepName(data);
  1679. if (toolName) {
  1680. html += `<div class="node-field"><span class="node-label">tool</span><span class="node-value ref">${esc(nodeText(toolName, 20, expanded))}</span></div>`;
  1681. }
  1682. if (data.timeout != null) {
  1683. html += `<div class="node-field"><span class="node-label">timeout</span><span class="node-value">${esc(String(data.timeout))}</span></div>`;
  1684. }
  1685. if (data.allowError === true || data.continueOnError === true) {
  1686. html += `<div class="node-field"><span class="node-label">errors</span><span class="node-value">continue</span></div>`;
  1687. }
  1688. html += renderInputSection(data.input || data.in, expanded);
  1689. html += renderOutSection(data.out, expanded);
  1690. html += '</div>';
  1691. return html;
  1692. }
  1693. function renderSubflowBody(data, expanded = false) {
  1694. let html = '<div class="node-section"><div class="node-section-title">Subflow Call</div>';
  1695. const workflowPath = data.workflow_path || data.workflowPath || data.path || data.workflow || '';
  1696. const mode = data.mode || data.executionMode || 'sync';
  1697. const workDir = data.work_dir || data.workDir || data.base_dir || data.baseDir || data.subspace || '';
  1698. if (workflowPath) {
  1699. html += `<div class="node-field"><span class="node-label">workflow</span><span class="node-value ref">${esc(nodeText(String(workflowPath), 20, expanded))}</span></div>`;
  1700. }
  1701. html += `<div class="node-field"><span class="node-label">mode</span><span class="node-value">${esc(String(mode))}</span></div>`;
  1702. if (workDir) {
  1703. html += `<div class="node-field"><span class="node-label">workDir</span><span class="node-value file">${esc(nodeText(String(workDir), 18, expanded))}</span></div>`;
  1704. }
  1705. if (data.emit_events === false || data.emitEvents === false) {
  1706. html += `<div class="node-field"><span class="node-label">events</span><span class="node-value">minimal</span></div>`;
  1707. }
  1708. html += '</div>';
  1709. html += renderInputSection(data.params || data.input || data.in, expanded);
  1710. html += renderOutSection(data.out, expanded);
  1711. return html;
  1712. }
  1713. // FIX #3: New renderers for Download and Unzip (Spec 3.16)
  1714. function renderDownloadBody(data, expanded = false) {
  1715. let html = '<div class="node-section"><div class="node-section-title">Download</div>';
  1716. const src = data.source || '';
  1717. const srcStr = typeof src === 'object' ? src : String(src);
  1718. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value ref">${esc(nodeText(srcStr, 20, expanded))}</span></div>`;
  1719. if (data.target) {
  1720. html += `<div class="node-field"><span class="node-label">target</span><span class="node-value file">${esc(nodeText(data.target, 20, expanded))}</span></div>`;
  1721. }
  1722. if (data.routeByExt) {
  1723. const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
  1724. html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  1725. }
  1726. if (data.defaultDir) {
  1727. html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${esc(nodeText(data.defaultDir, 18, expanded))}</span></div>`;
  1728. }
  1729. html += '</div>';
  1730. html += renderOutSection(data.out, expanded);
  1731. return html;
  1732. }
  1733. function renderUnzipBody(data, expanded = false) {
  1734. let html = '<div class="node-section"><div class="node-section-title">Unzip</div>';
  1735. if (data.source) {
  1736. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value file">${esc(nodeText(String(data.source), 20, expanded))}</span></div>`;
  1737. }
  1738. if (data.routeByExt) {
  1739. const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
  1740. html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  1741. }
  1742. if (data.defaultDir) {
  1743. html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${esc(nodeText(data.defaultDir, 18, expanded))}</span></div>`;
  1744. }
  1745. if (data.overwrite != null) {
  1746. html += `<div class="node-field"><span class="node-label">overwrite</span><span class="node-value">${data.overwrite}</span></div>`;
  1747. }
  1748. html += '</div>';
  1749. html += renderOutSection(data.out, expanded);
  1750. return html;
  1751. }
  1752. // --- Shared sub-renderers ---
  1753. function renderInputSection(inData, expanded = false) {
  1754. if (!inData || typeof inData !== 'object') return '';
  1755. const skip = new Set(['model', 'stream', 'messages', 'docs', 'max_tokens', 'output_config']);
  1756. const entries = Object.entries(inData).filter(([k]) => !skip.has(k));
  1757. if (entries.length === 0) return '';
  1758. let html = '<div class="node-section"><div class="node-section-title">Input</div><div class="node-io-list">';
  1759. const visibleEntries = expanded ? entries : entries.slice(0, 4);
  1760. visibleEntries.forEach(([key, val]) => {
  1761. html += `<div class="node-io-item"><span class="node-io-key">${esc(nodeText(key, 10, expanded))}</span>
  1762. <span class="node-io-arrow">\u2190</span>
  1763. <span class="node-io-value">${esc(nodeText(val, 14, expanded))}</span></div>`;
  1764. });
  1765. if (!expanded && entries.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${entries.length - 4} more</div>`;
  1766. html += '</div></div>';
  1767. return html;
  1768. }
  1769. function renderOutSection(out, expanded = false) {
  1770. if (!out || typeof out !== 'object' || Array.isArray(out) || Object.keys(out).length === 0) return '';
  1771. let html = '<div class="node-section"><div class="node-section-title">Output</div><div class="node-io-list">';
  1772. const entries = Object.entries(out);
  1773. const visibleEntries = expanded ? entries : entries.slice(0, 4);
  1774. visibleEntries.forEach(([key, val]) => {
  1775. const isVar = key.startsWith('$');
  1776. const isFile = key.startsWith('/') || key.startsWith('{');
  1777. const keyClass = isVar ? 'is-var' : isFile ? 'is-file' : '';
  1778. html += `<div class="node-io-item"><span class="node-io-key ${keyClass}">${esc(nodeText(key, 16, expanded))}</span>
  1779. <span class="node-io-arrow">\u2190</span>
  1780. <span class="node-io-value">${esc(nodeText(val, 14, expanded))}</span></div>`;
  1781. });
  1782. if (!expanded && entries.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${entries.length - 4} more</div>`;
  1783. html += '</div></div>';
  1784. return html;
  1785. }
  1786. // ===== Render =====
  1787. function render() {
  1788. applyUIState();
  1789. $('emptyMsg').style.display = 'none';
  1790. $('canvas').style.display = 'block';
  1791. $('legend').style.display = 'flex';
  1792. $('minimap').style.display = 'block';
  1793. renderTypeSidebar();
  1794. renderNodes();
  1795. updateCanvasFootprint();
  1796. renderConnections();
  1797. updateNavigationChrome();
  1798. setTimeout(updateMinimap, 100);
  1799. }
  1800. function renderNodes() {
  1801. const layer = $('nodesLayer');
  1802. layer.innerHTML = '';
  1803. for (const node of getRenderNodes()) {
  1804. const div = document.createElement('div');
  1805. const type = node.type || 'LLM';
  1806. const isSelected = node.id === state.selectedNodeId;
  1807. div.className = `node type-${type}`
  1808. + (isSelected ? ' selected' : '')
  1809. + (node.status ? ` status-${node.status}` : '')
  1810. + (shouldMinifyNode(node) ? ' minified' : '');
  1811. if (!KNOWN_TYPES.includes(type)) div.dataset.customType = 'true';
  1812. div.id = `node-${node.id}`;
  1813. div.style.left = node.x + 'px';
  1814. div.style.top = node.y + 'px';
  1815. const icon = getTypeIcon(type);
  1816. const title = node.data?.meta?.title || node.id;
  1817. const desc = node.data?.meta?.description || '';
  1818. const data = node.data || {};
  1819. // Body: type-specific rendering
  1820. let bodyHtml = '';
  1821. switch (type) {
  1822. case 'LLM': bodyHtml = renderLLMBody(data, isSelected); break;
  1823. case 'Service': bodyHtml = renderServiceBody(data, isSelected); break;
  1824. case 'API': bodyHtml = renderAPIBody(data, isSelected); break;
  1825. case 'Component': bodyHtml = renderComponentBody(data, isSelected); break;
  1826. case 'Set': bodyHtml = renderSetBody(data, isSelected); break;
  1827. case 'Write': bodyHtml = renderWriteBody(data, isSelected); break;
  1828. case 'Branch': bodyHtml = renderBranchBody(data, isSelected); break;
  1829. case 'Loop': bodyHtml = renderLoopBody(data, isSelected); break;
  1830. case 'Stop': bodyHtml = renderStopBody(); break;
  1831. case 'Pause': bodyHtml = renderPauseBody(data, isSelected); break;
  1832. case 'Fork': bodyHtml = renderForkBody(data, isSelected); break;
  1833. case 'Tool': bodyHtml = renderToolBody(data, isSelected); break;
  1834. case 'Subflow': bodyHtml = renderSubflowBody(data, isSelected); break;
  1835. case 'Download': bodyHtml = renderDownloadBody(data, isSelected); break;
  1836. case 'Unzip': bodyHtml = renderUnzipBody(data, isSelected); break;
  1837. default:
  1838. if (data.in) {
  1839. const keys = Object.keys(data.in);
  1840. const visibleKeys = isSelected ? keys : keys.slice(0, 3);
  1841. bodyHtml = visibleKeys.map(k => `<div class="field">${esc(k)}: ${esc(nodeText(data.in[k], 35, isSelected))}</div>`).join('');
  1842. }
  1843. }
  1844. // Condition badge
  1845. const conditionHtml = data.if ? `<div class="node-condition">if: ${esc(nodeText(data.if, 28, isSelected))}</div>` : '';
  1846. // BREAK indicator for nodes inside Loop children
  1847. const breakHtml = data.next === 'BREAK' ? `<div class="node-condition" style="background:rgba(248,81,73,0.15);color:var(--red);">next: BREAK</div>` : '';
  1848. // I/O audit badges
  1849. const io = computeIO(data);
  1850. let ioHtml = '';
  1851. const badges = [];
  1852. for (const v of io.varsIn.slice(0, 3)) badges.push(`<span class="io-badge var-in">\u2193${esc(v)}</span>`);
  1853. for (const v of io.varsOut.slice(0, 3)) badges.push(`<span class="io-badge var-out">\u2191${esc(v)}</span>`);
  1854. for (const d of io.docs.slice(0, 2)) badges.push(`<span class="io-badge doc">\u{1F4C4}${esc(d)}</span>`);
  1855. for (const f of io.files.slice(0, 2)) badges.push(`<span class="io-badge file">\u{1F4C1}${esc(f.substring(0, 20))}</span>`);
  1856. if (badges.length) ioHtml = `<div class="node-io">${badges.join('')}</div>`;
  1857. // Status badge
  1858. let badgeHtml = '';
  1859. if (node.status === 'running') badgeHtml = '<div class="status-badge running">&#9881;</div>';
  1860. else if (node.status === 'waiting') badgeHtml = '<div class="status-badge waiting">&#8987;</div>';
  1861. else if (node.status === 'done') badgeHtml = '<div class="status-badge done">&#10003;</div>';
  1862. else if (node.status === 'error') badgeHtml = '<div class="status-badge error">&#10007;</div>';
  1863. else if (node.status === 'paused') badgeHtml = '<div class="status-badge paused">&#9208;</div>';
  1864. else if (node.status === 'skipped') badgeHtml = '<div class="status-badge skipped">&#8213;</div>';
  1865. const kindLabel = getNodeKindLabel(type, data);
  1866. const stateLabel = getNodeStateLabel(node.status);
  1867. // Footer
  1868. const footerHtml = data.children?.length ? `<div class="node-footer">\u2935 ${data.children.length} parallel children</div>` : '';
  1869. div.innerHTML = `
  1870. ${badgeHtml}
  1871. ${isSelected ? '<button class="node-dismiss" title="Collapse details">&times;</button>' : ''}
  1872. <div class="port port-in"></div>
  1873. <div class="node-header">
  1874. <div class="node-icon">${icon}</div>
  1875. <div style="overflow:hidden;">
  1876. <div class="node-title">${esc(title)}</div>
  1877. <div class="node-meta-row">
  1878. <span class="node-type-pill">${esc(type)}</span>
  1879. ${kindLabel ? `<span class="node-subkind-pill">${esc(kindLabel)}</span>` : ''}
  1880. <span class="node-state-pill status-${esc(node.status || 'idle')}">${esc(stateLabel)}</span>
  1881. </div>
  1882. ${desc ? `<div class="node-desc">${esc(desc)}</div>` : ''}
  1883. </div>
  1884. </div>
  1885. ${bodyHtml ? `<div class="node-body">${bodyHtml}${conditionHtml}${breakHtml}</div>` : ''}
  1886. ${ioHtml}
  1887. ${footerHtml}
  1888. <div class="port port-out"></div>
  1889. `;
  1890. // Drag + click handler (mousedown starts drag, stopDrag distinguishes click vs drag)
  1891. div.addEventListener('mousedown', (e) => {
  1892. if (e.button !== 0) return;
  1893. startDrag(e, node);
  1894. });
  1895. div.addEventListener('click', (e) => {
  1896. if (e.target?.closest?.('.node-dismiss')) {
  1897. e.preventDefault();
  1898. e.stopPropagation();
  1899. deselectSelectedNode();
  1900. }
  1901. });
  1902. // Right-click → context menu
  1903. div.addEventListener('contextmenu', (e) => {
  1904. showContextMenu(e, node);
  1905. });
  1906. if (type === 'Subflow') {
  1907. div.addEventListener('dblclick', async (e) => {
  1908. e.preventDefault();
  1909. e.stopPropagation();
  1910. state.selectedNodeId = node.id;
  1911. renderNodes();
  1912. renderConnections();
  1913. updateNavigationChrome();
  1914. await openSubflowNode(node);
  1915. });
  1916. }
  1917. layer.appendChild(div);
  1918. }
  1919. }
  1920. function renderConnections() {
  1921. const svg = $('connSvg');
  1922. svg.querySelectorAll('.conn-group').forEach(g => g.remove());
  1923. for (const conn of getRenderConnections()) {
  1924. const fromEl = $(`node-${conn.from}`);
  1925. const toEl = $(`node-${conn.to}`);
  1926. if (!fromEl || !toEl) continue;
  1927. const x1 = fromEl.offsetLeft + fromEl.offsetWidth / 2;
  1928. const y1 = fromEl.offsetTop + fromEl.offsetHeight + 4;
  1929. const x2 = toEl.offsetLeft + toEl.offsetWidth / 2;
  1930. const y2 = toEl.offsetTop - 4;
  1931. // Use straight lines for horizontal, bezier for vertical
  1932. const dx = Math.abs(x2 - x1);
  1933. const dy = y2 - y1;
  1934. let d;
  1935. if (dy > 0) {
  1936. const cp = Math.max(40, Math.abs(dy) * 0.4);
  1937. d = `M ${x1} ${y1} C ${x1} ${y1 + cp}, ${x2} ${y2 - cp}, ${x2} ${y2}`;
  1938. } else {
  1939. // Back-edge or same level: use wider bezier
  1940. const cpX = Math.max(80, dx * 0.3);
  1941. d = `M ${x1} ${y1} C ${x1 + cpX} ${y1 + 80}, ${x2 - cpX} ${y2 - 80}, ${x2} ${y2}`;
  1942. }
  1943. const markerMap = { serial: 'arrowSerial', parallel: 'arrowParallel', 'branch-case': 'arrowBranch' };
  1944. const marker = markerMap[conn.type] || 'arrowSerial';
  1945. const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
  1946. g.setAttribute('class', 'conn-group');
  1947. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1948. path.setAttribute('class', `conn-path ${conn.type}`);
  1949. path.setAttribute('d', d);
  1950. path.setAttribute('marker-end', `url(#${marker})`);
  1951. if (conn.hiddenCount) {
  1952. path.setAttribute('stroke-width', '2.5');
  1953. path.setAttribute('opacity', '0.95');
  1954. }
  1955. g.appendChild(path);
  1956. if (conn.label || conn.hiddenCount) {
  1957. const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  1958. text.setAttribute('class', 'conn-label');
  1959. text.setAttribute('x', (x1 + x2) / 2);
  1960. text.setAttribute('y', Math.min(y1, y2) + Math.abs(dy) / 2 - 5);
  1961. text.setAttribute('text-anchor', 'middle');
  1962. const rawLabel = conn.label && conn.hiddenCount
  1963. ? `${conn.label} · +${conn.hiddenCount}`
  1964. : (conn.label || (conn.hiddenCount ? `+${conn.hiddenCount} hidden` : ''));
  1965. text.textContent = rawLabel.length > 30 ? rawLabel.substring(0, 30) + '...' : rawLabel;
  1966. g.appendChild(text);
  1967. }
  1968. svg.appendChild(g);
  1969. }
  1970. }
  1971. function updateCanvasFootprint() {
  1972. const canvas = $('canvas');
  1973. const wrap = $('canvasWrap');
  1974. const renderNodes = getRenderNodes();
  1975. if (!canvas || !wrap || !renderNodes.length) return;
  1976. let maxX = 0;
  1977. let maxY = 0;
  1978. for (const node of renderNodes) {
  1979. const el = $(`node-${node.id}`);
  1980. const width = el?.offsetWidth || (_uiState.compact ? (shouldMinifyNode(node) ? 154 : 196) : 240);
  1981. const height = el?.offsetHeight || (_uiState.compact ? (shouldMinifyNode(node) ? 56 : 112) : 128);
  1982. maxX = Math.max(maxX, node.x + width);
  1983. maxY = Math.max(maxY, node.y + height);
  1984. }
  1985. const minWidth = Math.max(wrap.clientWidth + 120, _uiState.compact ? 1200 : 1800);
  1986. const minHeight = Math.max(wrap.clientHeight + 120, _uiState.compact ? 900 : 1200);
  1987. canvas.style.width = `${Math.max(minWidth, maxX + 180)}px`;
  1988. canvas.style.height = `${Math.max(minHeight, maxY + 180)}px`;
  1989. }
  1990. // ===== Node Drag =====
  1991. function initDrag() {
  1992. const canvas = $('canvas');
  1993. canvas.addEventListener('mousemove', onDrag);
  1994. canvas.addEventListener('mouseup', stopDrag);
  1995. canvas.addEventListener('mouseleave', stopDrag);
  1996. }
  1997. function startDrag(e, node) {
  1998. e.preventDefault();
  1999. state.dragging = { node, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y };
  2000. const el = $(`node-${node.id}`);
  2001. if (el) el.classList.add('dragging');
  2002. }
  2003. function onDrag(e) {
  2004. if (!state.dragging) return;
  2005. const { node, startX, startY, origX, origY } = state.dragging;
  2006. node.x = Math.max(0, origX + (e.clientX - startX));
  2007. node.y = Math.max(0, origY + (e.clientY - startY));
  2008. const el = $(`node-${node.id}`);
  2009. if (el) {
  2010. el.style.left = node.x + 'px';
  2011. el.style.top = node.y + 'px';
  2012. }
  2013. if (!state._dragRAF) {
  2014. state._dragRAF = requestAnimationFrame(() => {
  2015. renderConnections();
  2016. state._dragRAF = null;
  2017. });
  2018. }
  2019. }
  2020. function stopDrag() {
  2021. if (!state.dragging) return;
  2022. const { node, origX, origY } = state.dragging;
  2023. const dx = Math.abs(node.x - origX);
  2024. const dy = Math.abs(node.y - origY);
  2025. const el = $(`node-${node.id}`);
  2026. if (el) el.classList.remove('dragging');
  2027. if (dx < 3 && dy < 3) {
  2028. // Treat as click — select node
  2029. state.selectedNodeId = node.id;
  2030. renderNodes();
  2031. renderConnections();
  2032. updateNavigationChrome();
  2033. window.parent.postMessage({ type: 'nodeClick', nodeId: node.id, nodeType: node.type, nodeData: node.data }, '*');
  2034. } else {
  2035. // Dragged — update minimap
  2036. updateMinimap();
  2037. }
  2038. state.dragging = null;
  2039. }
  2040. // ===== Minimap =====
  2041. function updateMinimap() {
  2042. const mc = $('minimapCanvas');
  2043. const renderNodes = getRenderNodes();
  2044. const renderConnections = getRenderConnections(renderNodes);
  2045. if (!mc || renderNodes.length === 0) return;
  2046. const ctx = mc.getContext('2d');
  2047. const W = mc.width = mc.offsetWidth * 2;
  2048. const H = mc.height = mc.offsetHeight * 2;
  2049. ctx.clearRect(0, 0, W, H);
  2050. // Find bounds
  2051. let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
  2052. for (const n of renderNodes) {
  2053. minX = Math.min(minX, n.x);
  2054. minY = Math.min(minY, n.y);
  2055. maxX = Math.max(maxX, n.x + 240);
  2056. maxY = Math.max(maxY, n.y + 120);
  2057. }
  2058. const pad = 40;
  2059. const scaleX = W / (maxX - minX + pad * 2);
  2060. const scaleY = H / (maxY - minY + pad * 2);
  2061. const scale = Math.min(scaleX, scaleY);
  2062. // Draw connections
  2063. ctx.strokeStyle = '#2a3140';
  2064. ctx.lineWidth = 1;
  2065. for (const c of renderConnections) {
  2066. const from = renderNodes.find(n => n.id === c.from);
  2067. const to = renderNodes.find(n => n.id === c.to);
  2068. if (!from || !to) continue;
  2069. ctx.beginPath();
  2070. ctx.moveTo((from.x + 120 - minX + pad) * scale, (from.y + 60 - minY + pad) * scale);
  2071. ctx.lineTo((to.x + 120 - minX + pad) * scale, (to.y + 30 - minY + pad) * scale);
  2072. ctx.stroke();
  2073. }
  2074. // Draw nodes
  2075. for (const n of renderNodes) {
  2076. const x = (n.x - minX + pad) * scale;
  2077. const y = (n.y - minY + pad) * scale;
  2078. const w = 240 * scale;
  2079. const h = 60 * scale;
  2080. ctx.fillStyle = n.status === 'done' ? '#3fb950' : n.status === 'running' ? '#d29922' : n.status === 'error' ? '#f85149' : getTypeColor(n.type);
  2081. ctx.globalAlpha = n.status ? 0.9 : 0.6;
  2082. ctx.fillRect(x, y, Math.max(w, 3), Math.max(h, 2));
  2083. }
  2084. ctx.globalAlpha = 1;
  2085. // Draw viewport rectangle
  2086. const wrap = $('canvasWrap');
  2087. const vx = (wrap.scrollLeft - minX + pad) * scale;
  2088. const vy = (wrap.scrollTop - minY + pad) * scale;
  2089. const vw = wrap.clientWidth * scale;
  2090. const vh = wrap.clientHeight * scale;
  2091. ctx.strokeStyle = '#58a6ff';
  2092. ctx.lineWidth = 2;
  2093. ctx.strokeRect(vx, vy, vw, vh);
  2094. }
  2095. // ===== PNG Export =====
  2096. function downloadPNG() {
  2097. const renderNodes = getRenderNodes();
  2098. const renderConnections = getRenderConnections(renderNodes);
  2099. if (renderNodes.length === 0) return toast('No workflow to export');
  2100. const SCALE = 2;
  2101. const PAD = 60;
  2102. let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
  2103. for (const n of renderNodes) {
  2104. minX = Math.min(minX, n.x);
  2105. minY = Math.min(minY, n.y);
  2106. maxX = Math.max(maxX, n.x + 240);
  2107. maxY = Math.max(maxY, n.y + 140);
  2108. }
  2109. const cw = (maxX - minX + PAD * 2) * SCALE;
  2110. const ch = (maxY - minY + PAD * 2) * SCALE;
  2111. const canvas = document.createElement('canvas');
  2112. canvas.width = cw;
  2113. canvas.height = ch;
  2114. const ctx = canvas.getContext('2d');
  2115. ctx.scale(SCALE, SCALE);
  2116. const ox = -minX + PAD;
  2117. const oy = -minY + PAD;
  2118. // Background
  2119. ctx.fillStyle = '#0a0d12';
  2120. ctx.fillRect(0, 0, cw / SCALE, ch / SCALE);
  2121. // Grid dots
  2122. ctx.fillStyle = '#2a3140';
  2123. for (let gx = 0; gx < cw / SCALE; gx += 20) {
  2124. for (let gy = 0; gy < ch / SCALE; gy += 20) {
  2125. ctx.fillRect(gx, gy, 1, 1);
  2126. }
  2127. }
  2128. // Connections
  2129. for (const conn of renderConnections) {
  2130. const from = renderNodes.find(n => n.id === conn.from);
  2131. const to = renderNodes.find(n => n.id === conn.to);
  2132. if (!from || !to) continue;
  2133. const x1 = from.x + 120 + ox, y1 = from.y + 100 + oy;
  2134. const x2 = to.x + 120 + ox, y2 = to.y + oy;
  2135. ctx.strokeStyle = conn.type === 'parallel' ? '#a371f7' : conn.type === 'branch-case' ? '#d29922' : '#58a6ff';
  2136. ctx.lineWidth = 2;
  2137. if (conn.type === 'parallel') ctx.setLineDash([6, 3]);
  2138. else if (conn.type === 'branch-case') ctx.setLineDash([4, 2, 1, 2]);
  2139. else ctx.setLineDash([]);
  2140. const cp = Math.max(40, Math.abs(y2 - y1) * 0.4);
  2141. ctx.beginPath();
  2142. ctx.moveTo(x1, y1);
  2143. ctx.bezierCurveTo(x1, y1 + cp, x2, y2 - cp, x2, y2);
  2144. ctx.stroke();
  2145. ctx.setLineDash([]);
  2146. // Arrow
  2147. ctx.fillStyle = ctx.strokeStyle;
  2148. ctx.beginPath();
  2149. ctx.moveTo(x2 - 4, y2 - 8);
  2150. ctx.lineTo(x2, y2);
  2151. ctx.lineTo(x2 + 4, y2 - 8);
  2152. ctx.fill();
  2153. }
  2154. // Nodes
  2155. for (const node of renderNodes) {
  2156. const nx = node.x + ox, ny = node.y + oy;
  2157. // Card
  2158. ctx.fillStyle = '#1a1f27';
  2159. ctx.strokeStyle = '#2a3140';
  2160. ctx.lineWidth = 1;
  2161. roundRect(ctx, nx, ny, 240, 80, 8);
  2162. ctx.fill();
  2163. ctx.stroke();
  2164. // Type accent bar
  2165. ctx.fillStyle = getTypeColor(node.type);
  2166. ctx.fillRect(nx, ny + 8, 3, 64);
  2167. // Icon
  2168. ctx.fillStyle = getTypeColor(node.type);
  2169. roundRect(ctx, nx + 10, ny + 10, 24, 24, 5);
  2170. ctx.fill();
  2171. ctx.fillStyle = '#fff';
  2172. ctx.font = 'bold 9px monospace';
  2173. ctx.textAlign = 'center';
  2174. ctx.fillText(getTypeIcon(node.type), nx + 22, ny + 26);
  2175. // Title
  2176. ctx.fillStyle = '#e6edf3';
  2177. ctx.font = 'bold 11px monospace';
  2178. ctx.textAlign = 'left';
  2179. ctx.fillText((node.data?.meta?.title || node.id).substring(0, 28), nx + 42, ny + 24);
  2180. // Type
  2181. ctx.fillStyle = '#8b949e';
  2182. ctx.font = '9px monospace';
  2183. ctx.fillText(node.type, nx + 42, ny + 36);
  2184. // Status
  2185. if (node.status) {
  2186. const sc = { done:'#3fb950', running:'#d29922', error:'#f85149', paused:'#8b5cf6', skipped:'#8b949e' };
  2187. ctx.fillStyle = sc[node.status] || '#8b949e';
  2188. ctx.beginPath();
  2189. ctx.arc(nx + 230, ny + 4, 6, 0, Math.PI * 2);
  2190. ctx.fill();
  2191. }
  2192. }
  2193. // Title
  2194. ctx.fillStyle = '#e6edf3';
  2195. ctx.font = 'bold 14px monospace';
  2196. ctx.textAlign = 'left';
  2197. ctx.fillText(_currentWorkflowJson?.name || 'VL Workflow', PAD, 20);
  2198. ctx.fillStyle = '#8b949e';
  2199. ctx.font = '10px monospace';
  2200. const nodeLabel = renderNodes.length === state.nodes.length
  2201. ? `${renderNodes.length} nodes`
  2202. : `${renderNodes.length}/${state.nodes.length} nodes`;
  2203. ctx.fillText(`${nodeLabel} \u00b7 VL Workflow Spec ${_currentWorkflowJson?.version || '3.16'}`, PAD, 35);
  2204. // Download
  2205. const link = document.createElement('a');
  2206. link.download = `workflow-${(_currentWorkflowJson?.name || 'dag').replace(/\s+/g, '-')}-${Date.now()}.png`;
  2207. link.href = canvas.toDataURL('image/png');
  2208. link.click();
  2209. toast('PNG exported');
  2210. }
  2211. function roundRect(ctx, x, y, w, h, r) {
  2212. ctx.beginPath();
  2213. ctx.moveTo(x + r, y);
  2214. ctx.lineTo(x + w - r, y);
  2215. ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  2216. ctx.lineTo(x + w, y + h - r);
  2217. ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  2218. ctx.lineTo(x + r, y + h);
  2219. ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  2220. ctx.lineTo(x, y + r);
  2221. ctx.quadraticCurveTo(x, y, x + r, y);
  2222. ctx.closePath();
  2223. }
  2224. // ===== JSON Export / Import =====
  2225. function exportJSON() {
  2226. if (!_currentWorkflowJson) return toast('No workflow to export');
  2227. const blob = new Blob([JSON.stringify(_currentWorkflowJson, null, 2)], { type: 'application/json' });
  2228. const link = document.createElement('a');
  2229. link.download = `${(_currentWorkflowJson.name || 'workflow').replace(/\s+/g, '-')}-${Date.now()}.json`;
  2230. link.href = URL.createObjectURL(blob);
  2231. link.click();
  2232. toast('JSON exported');
  2233. }
  2234. function importJSON() {
  2235. $('importInput').click();
  2236. }
  2237. $('importInput')?.addEventListener('change', async (e) => {
  2238. const file = e.target.files[0];
  2239. if (!file) return;
  2240. try {
  2241. const text = await file.text();
  2242. const json = JSON.parse(text);
  2243. if (!json.steps?.length) throw new Error('Invalid workflow: missing steps');
  2244. parseWorkflow(json);
  2245. toast(`Loaded: ${json.name || file.name}`);
  2246. window.parent.postMessage({ type: 'workflowImported', workflow: json }, '*');
  2247. } catch (err) {
  2248. toast('Import failed: ' + err.message);
  2249. }
  2250. e.target.value = '';
  2251. });
  2252. // ===== Run Workflow (SSE) =====
  2253. async function runWorkflow() {
  2254. if (!_currentWorkflowJson) return;
  2255. const workflowRef = await ensureWorkflowRef();
  2256. if (!workflowRef) return toast('Workflow is not saved and could not be staged for execution');
  2257. const clientRunToken = makeClientRunToken();
  2258. ensureRunSession(clientRunToken, {
  2259. clientRunToken,
  2260. status: 'running',
  2261. workflowName: workflowRef,
  2262. nodeStatuses: {},
  2263. pending: true,
  2264. });
  2265. _selectedRunID = clientRunToken;
  2266. syncSelectedRun();
  2267. const controller = new AbortController();
  2268. setRunController(clientRunToken, controller);
  2269. try {
  2270. const res = await fetch('/api/workflow/execute', {
  2271. method: 'POST',
  2272. headers: { 'Content-Type': 'application/json' },
  2273. body: JSON.stringify({ workflowName: workflowRef, params: {}, clientRunToken }),
  2274. signal: controller.signal,
  2275. });
  2276. await streamSSE(res, clientRunToken, controller);
  2277. } catch (err) {
  2278. const session = _runSessions.get(clientRunToken);
  2279. if (err.name !== 'AbortError') {
  2280. if (session) {
  2281. session.status = 'error';
  2282. session.updatedAt = Date.now();
  2283. }
  2284. toast('Execution error: ' + err.message);
  2285. }
  2286. } finally {
  2287. clearRunController(clientRunToken, controller);
  2288. }
  2289. syncSelectedRun();
  2290. // Try to fetch final checkpoint
  2291. const session = _runSessions.get(clientRunToken);
  2292. if (session?.runID) fetchCheckpoint(session.runID);
  2293. }
  2294. function handleExecEvent(evt, runHint = null) {
  2295. // Map engine event types to UI (support both legacy and v0.3+ naming)
  2296. const nodeId = evt.nodeId || evt.stepID || evt.stepId;
  2297. const evtType = evt.type;
  2298. const actualRunID = evt.runID || evt.payload?.runID || null;
  2299. const clientRunToken = evt.clientRunToken || evt.payload?.clientRunToken || (isClientRunToken(runHint) ? runHint : null);
  2300. let session = null;
  2301. const resolvedSessionID = resolveRunSessionID({
  2302. sessionID: clientRunToken || runHint || null,
  2303. clientRunToken,
  2304. runID: actualRunID,
  2305. runHint,
  2306. }) || _selectedRunID || _currentRunID;
  2307. if (resolvedSessionID) {
  2308. session = ensureRunSession(resolvedSessionID, {
  2309. clientRunToken,
  2310. runID: actualRunID,
  2311. workflowName: evt.name || evt.workflowName || _currentWorkflowJson?.name || '',
  2312. pending: actualRunID ? false : undefined,
  2313. });
  2314. }
  2315. if (evtType === 'workflow_start') {
  2316. if (session) session.status = 'running';
  2317. } else if (evtType === 'node_start' || evtType === 'step_start') {
  2318. if (session) {
  2319. session.status = 'running';
  2320. session.currentStepID = nodeId;
  2321. session.nodeStatuses[nodeId] = 'running';
  2322. }
  2323. } else if (evtType === 'node_done' || evtType === 'step_done') {
  2324. if (session) {
  2325. session.status = 'running';
  2326. session.currentStepID = nodeId;
  2327. session.nodeStatuses[nodeId] = 'done';
  2328. }
  2329. } else if (evtType === 'node_error' || evtType === 'step_error') {
  2330. if (session) {
  2331. session.status = 'error';
  2332. session.currentStepID = nodeId;
  2333. session.nodeStatuses[nodeId] = 'error';
  2334. }
  2335. } else if (evtType === 'node_skipped' || evtType === 'step_skipped') {
  2336. if (session) {
  2337. session.currentStepID = nodeId;
  2338. session.nodeStatuses[nodeId] = 'skipped';
  2339. }
  2340. } else if (evtType === 'pause' || evtType === 'pause_start') {
  2341. if (session) {
  2342. session.status = 'paused';
  2343. session.currentStepID = nodeId;
  2344. session.nodeStatuses[nodeId] = 'paused';
  2345. }
  2346. } else if (evtType === 'resumed' || evtType === 'pause_resumed') {
  2347. if (session) {
  2348. session.status = 'running';
  2349. session.currentStepID = nodeId;
  2350. session.nodeStatuses[nodeId] = 'running';
  2351. }
  2352. } else if (evtType === 'tool_start') {
  2353. if (session) {
  2354. session.status = 'waiting';
  2355. session.currentStepID = nodeId || session.currentStepID;
  2356. if (nodeId) session.nodeStatuses[nodeId] = 'waiting';
  2357. }
  2358. } else if (evtType === 'tool_done') {
  2359. if (session) {
  2360. session.status = 'running';
  2361. session.currentStepID = nodeId || session.currentStepID;
  2362. if (nodeId && session.nodeStatuses[nodeId] === 'waiting') session.nodeStatuses[nodeId] = 'running';
  2363. }
  2364. } else if (evtType === 'tool_error') {
  2365. if (session) {
  2366. session.status = evt.allowError ? 'running' : 'error';
  2367. session.currentStepID = nodeId || session.currentStepID;
  2368. if (nodeId && !evt.allowError) session.nodeStatuses[nodeId] = 'error';
  2369. }
  2370. } else if (evtType === 'workflow_paused') {
  2371. if (session) {
  2372. session.status = 'paused';
  2373. session.currentStepID = evt.payload?.pausedAt || session.currentStepID || nodeId;
  2374. }
  2375. fetchCheckpoint(actualRunID || session?.runID || null);
  2376. } else if (evtType === 'done' || evtType === 'workflow_done') {
  2377. if (session) {
  2378. session.status = 'done';
  2379. session.filesWritten = evt.filesWritten || evt.payload?.filesWritten || session.filesWritten || [];
  2380. }
  2381. } else if (evtType === 'error' || evtType === 'workflow_failed') {
  2382. if (session) {
  2383. session.status = 'error';
  2384. }
  2385. }
  2386. // Capture checkpoint if provided
  2387. const checkpoint = normalizeCheckpoint(evt.checkpoint || (evtType === 'checkpoint' ? evt : null));
  2388. if (session && checkpoint) {
  2389. session.checkpoint = checkpoint;
  2390. session.currentStepID = checkpoint.currentStepID || session.currentStepID;
  2391. if (checkpoint.workflowID && !session.runID) session.runID = checkpoint.workflowID;
  2392. if (checkpoint.status === 'completed') session.status = 'done';
  2393. else if (checkpoint.status === 'failed') session.status = 'error';
  2394. }
  2395. if (session) {
  2396. if (actualRunID) session.runID = actualRunID;
  2397. if (clientRunToken) session.clientRunToken = clientRunToken;
  2398. if (session.runID) session.pending = false;
  2399. recordRunEvent(session, evt);
  2400. session.updatedAt = Date.now();
  2401. }
  2402. if (!_selectedRunID && session) {
  2403. _selectedRunID = session.sessionID;
  2404. }
  2405. const selectedSession = getSelectedRunSession();
  2406. const selectedMatchesRunID = !!(selectedSession && actualRunID && selectedSession.runID === actualRunID);
  2407. if (session && (_selectedRunID === session.sessionID || _selectedRunID === runHint || _selectedRunID === clientRunToken || selectedMatchesRunID || !_selectedRunID)) {
  2408. _selectedRunID = session.sessionID;
  2409. syncSelectedRun();
  2410. } else {
  2411. renderRunSessions();
  2412. }
  2413. if (_selectedRunID && _runSessions.has(_selectedRunID)) {
  2414. saveCheckpointToStorage();
  2415. }
  2416. }
  2417. function setNodeStatus(nodeId, status) {
  2418. const node = state.nodes.find(n => n.id === nodeId);
  2419. if (node) {
  2420. node.status = status;
  2421. // Avoid full re-render during drag
  2422. if (state.dragging) return;
  2423. renderNodes();
  2424. renderConnections();
  2425. updateMinimap();
  2426. // Scroll to node
  2427. const el = $(`node-${nodeId}`);
  2428. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  2429. }
  2430. }
  2431. function stopExecution() {
  2432. if (_eventSource) { _eventSource.close(); _eventSource = null; }
  2433. const session = getSelectedRunSession();
  2434. if (!session) return;
  2435. abortRunController(session.sessionID);
  2436. if (session.runID) {
  2437. fetch(`/api/workflow/${session.runID}/abort`, { method: 'POST' }).catch(() => {});
  2438. }
  2439. session.status = 'idle';
  2440. session.updatedAt = Date.now();
  2441. syncSelectedRun();
  2442. $('statusLabel').textContent = 'Stopped';
  2443. if (session.runID) fetchCheckpoint(session.runID);
  2444. }
  2445. async function pauseExecution() {
  2446. const session = getSelectedRunSession();
  2447. if (!session) return toast('No run selected');
  2448. $('statusLabel').textContent = 'Pausing...';
  2449. try {
  2450. if (session.runID) {
  2451. await fetch(`/api/workflow/${session.runID}/pause`, { method: 'POST' });
  2452. } else if (!abortRunController(session.sessionID)) {
  2453. throw new Error('No active run');
  2454. }
  2455. session.status = 'paused';
  2456. session.updatedAt = Date.now();
  2457. } catch (err) {
  2458. // Fallback: try cancel endpoint
  2459. try {
  2460. if (session.runID) {
  2461. await fetch(`/api/workflow/${session.runID}/cancel`, { method: 'POST' });
  2462. session.status = 'paused';
  2463. session.updatedAt = Date.now();
  2464. } else {
  2465. throw err;
  2466. }
  2467. } catch { toast('Pause failed'); }
  2468. }
  2469. syncSelectedRun();
  2470. if (session.runID) fetchCheckpoint(session.runID);
  2471. }
  2472. async function resumeExecution() {
  2473. const session = getSelectedRunSession();
  2474. if (!session?.runID) return toast('Selected run cannot be resumed yet');
  2475. $('statusLabel').textContent = `Resuming ${session.label}...`;
  2476. try {
  2477. const res = await fetch('/api/workflow/resume', {
  2478. method: 'POST',
  2479. headers: { 'Content-Type': 'application/json' },
  2480. body: JSON.stringify({
  2481. runID: session.runID,
  2482. nodeId: session.currentStepID || null,
  2483. }),
  2484. });
  2485. const body = await res.json().catch(() => ({}));
  2486. if (!res.ok || body?.ok === false) {
  2487. throw new Error(body?.error || `HTTP ${res.status}`);
  2488. }
  2489. session.status = 'running';
  2490. session.updatedAt = Date.now();
  2491. syncSelectedRun();
  2492. } catch (err) {
  2493. toast('Resume failed: ' + err.message);
  2494. syncSelectedRun();
  2495. }
  2496. }
  2497. // ===== Checkpoint Persistence =====
  2498. function saveCheckpointToStorage() {
  2499. if (!_currentWorkflowJson?.name) return;
  2500. try {
  2501. const key = `wf_cp_${_currentWorkflowJson.name}`;
  2502. const selected = getSelectedRunSession();
  2503. const data = {
  2504. checkpoint: selected?.checkpoint || _lastCheckpoint,
  2505. nodeStatuses: state.nodes.map(n => ({ id: n.id, status: n.status })),
  2506. runID: _currentRunID,
  2507. selectedRunID: _selectedRunID,
  2508. sessions: listRunSessions().map((session) => ({
  2509. sessionID: session.sessionID,
  2510. clientRunToken: session.clientRunToken,
  2511. runID: session.runID,
  2512. seq: session.seq,
  2513. label: session.label,
  2514. workflowName: session.workflowName,
  2515. status: session.status,
  2516. nodeStatuses: session.nodeStatuses,
  2517. checkpoint: session.checkpoint,
  2518. filesWritten: session.filesWritten,
  2519. currentStepID: session.currentStepID,
  2520. events: session.events,
  2521. updatedAt: session.updatedAt,
  2522. pending: !!session.pending,
  2523. })),
  2524. ts: Date.now()
  2525. };
  2526. localStorage.setItem(key, JSON.stringify(data));
  2527. } catch {}
  2528. }
  2529. function restoreFromStorage() {
  2530. if (!_currentWorkflowJson?.name) return false;
  2531. try {
  2532. const key = `wf_cp_${_currentWorkflowJson.name}`;
  2533. const raw = localStorage.getItem(key);
  2534. if (!raw) return false;
  2535. const data = JSON.parse(raw);
  2536. // Only restore if less than 24h old
  2537. if (Date.now() - data.ts > 86400000) { localStorage.removeItem(key); return false; }
  2538. if (Array.isArray(data.sessions) && data.sessions.length) {
  2539. _runSessions = new Map();
  2540. _runSeq = 0;
  2541. _runTokenSeq = 0;
  2542. for (const item of data.sessions) {
  2543. const sessionID = item.sessionID || item.clientRunToken || item.runID;
  2544. if (!sessionID) continue;
  2545. ensureRunSession(sessionID, {
  2546. clientRunToken: item.clientRunToken,
  2547. runID: item.runID,
  2548. seq: item.seq,
  2549. label: item.label,
  2550. workflowName: item.workflowName,
  2551. status: item.status,
  2552. nodeStatuses: item.nodeStatuses,
  2553. checkpoint: normalizeCheckpoint(item.checkpoint),
  2554. filesWritten: item.filesWritten,
  2555. currentStepID: item.currentStepID,
  2556. events: item.events,
  2557. updatedAt: item.updatedAt,
  2558. pending: item.pending,
  2559. });
  2560. }
  2561. _selectedRunID = data.selectedRunID && _runSessions.has(data.selectedRunID)
  2562. ? data.selectedRunID
  2563. : listRunSessions()[0]?.sessionID || null;
  2564. applySelectedRunToNodes();
  2565. return true;
  2566. }
  2567. _runSessions = new Map();
  2568. _runSeq = 0;
  2569. _runTokenSeq = 0;
  2570. const legacyRunID = data.runID || `restored:${Date.now()}`;
  2571. const nodeStatuses = {};
  2572. for (const ns of data.nodeStatuses || []) {
  2573. if (ns?.id && ns?.status) nodeStatuses[ns.id] = ns.status;
  2574. }
  2575. ensureRunSession(legacyRunID, {
  2576. status: 'paused',
  2577. nodeStatuses,
  2578. checkpoint: normalizeCheckpoint(data.checkpoint),
  2579. updatedAt: data.ts,
  2580. pending: isPendingRunID(legacyRunID),
  2581. });
  2582. _selectedRunID = legacyRunID;
  2583. applySelectedRunToNodes();
  2584. return true;
  2585. } catch { return false; }
  2586. }
  2587. async function fetchCheckpoint(runID = _currentRunID) {
  2588. if (!runID || isPendingRunID(runID)) return;
  2589. try {
  2590. const res = await fetch(`/api/workflow/${runID}/checkpoint`);
  2591. if (res.ok) {
  2592. const checkpoint = normalizeCheckpoint(await res.json());
  2593. if (checkpoint) {
  2594. const sessionID = findRunSessionIDByRunID(runID) || runID;
  2595. const session = ensureRunSession(sessionID, { runID });
  2596. if (session) {
  2597. session.checkpoint = checkpoint;
  2598. session.currentStepID = checkpoint.currentStepID || session.currentStepID;
  2599. session.updatedAt = Date.now();
  2600. }
  2601. const selectedSession = getSelectedRunSession();
  2602. if (selectedSession?.runID === runID || _selectedRunID === sessionID) _lastCheckpoint = checkpoint;
  2603. saveCheckpointToStorage();
  2604. if (selectedSession?.runID === runID || _selectedRunID === sessionID) syncSelectedRun();
  2605. }
  2606. }
  2607. } catch {}
  2608. }
  2609. // ===== Re-run from Step =====
  2610. async function rerunFromStep(stepId, overrides = {}) {
  2611. if (!_currentWorkflowJson) return toast('No workflow loaded');
  2612. const workflowRef = await ensureWorkflowRef();
  2613. if (!workflowRef) return toast('Workflow is not saved and could not be staged for re-run');
  2614. if (!_lastCheckpoint && _currentRunID) {
  2615. await fetchCheckpoint();
  2616. }
  2617. // Build checkpoint: use last checkpoint or create minimal one
  2618. const baseCheckpoint = normalizeCheckpoint(_lastCheckpoint);
  2619. const checkpoint = baseCheckpoint ? { ...baseCheckpoint, currentStepID: stepId }
  2620. : { currentStepID: stepId, params: {}, variables: {} };
  2621. // Apply overrides
  2622. if (Object.keys(overrides).length > 0 && checkpoint.variables) {
  2623. Object.assign(checkpoint.variables, overrides);
  2624. }
  2625. // Clear statuses for this node and all subsequent
  2626. const stepIdx = state.nodes.findIndex(n => n.id === stepId);
  2627. if (stepIdx >= 0) {
  2628. // Clear this node and downstream
  2629. const toClear = new Set();
  2630. function markDownstream(id) {
  2631. if (toClear.has(id)) return;
  2632. toClear.add(id);
  2633. for (const c of state.connections) {
  2634. if (c.from === id) markDownstream(c.to);
  2635. }
  2636. }
  2637. markDownstream(stepId);
  2638. for (const n of state.nodes) {
  2639. if (toClear.has(n.id)) n.status = null;
  2640. }
  2641. renderNodes();
  2642. renderConnections();
  2643. }
  2644. const clientRunToken = makeClientRunToken();
  2645. ensureRunSession(clientRunToken, {
  2646. clientRunToken,
  2647. status: 'running',
  2648. workflowName: workflowRef,
  2649. runID: null,
  2650. checkpoint,
  2651. currentStepID: stepId,
  2652. nodeStatuses: {},
  2653. pending: true,
  2654. });
  2655. _selectedRunID = clientRunToken;
  2656. syncSelectedRun();
  2657. $('statusLabel').textContent = `Re-running from ${stepId}...`;
  2658. const controller = new AbortController();
  2659. setRunController(clientRunToken, controller);
  2660. try {
  2661. const res = await fetch('/api/workflow/rerun', {
  2662. method: 'POST',
  2663. headers: { 'Content-Type': 'application/json' },
  2664. signal: controller.signal,
  2665. body: JSON.stringify({
  2666. workflowName: workflowRef,
  2667. checkpoint,
  2668. stepID: stepId,
  2669. overrides,
  2670. clientRunToken
  2671. })
  2672. });
  2673. if (!res.ok) {
  2674. // Fallback: try execute with fromStep
  2675. const res2 = await fetch('/api/workflow/execute', {
  2676. method: 'POST',
  2677. headers: { 'Content-Type': 'application/json' },
  2678. signal: controller.signal,
  2679. body: JSON.stringify({
  2680. workflowName: workflowRef,
  2681. params: {},
  2682. fromStep: stepId,
  2683. checkpoint,
  2684. overrides,
  2685. clientRunToken
  2686. })
  2687. });
  2688. if (!res2.ok) throw new Error('Rerun failed');
  2689. await streamSSE(res2, clientRunToken, controller);
  2690. } else {
  2691. await streamSSE(res, clientRunToken, controller);
  2692. }
  2693. } catch (err) {
  2694. const session = _runSessions.get(clientRunToken);
  2695. if (err.name !== 'AbortError') {
  2696. if (session) {
  2697. session.status = 'error';
  2698. session.updatedAt = Date.now();
  2699. }
  2700. toast('Re-run error: ' + err.message);
  2701. }
  2702. } finally {
  2703. clearRunController(clientRunToken, controller);
  2704. }
  2705. syncSelectedRun();
  2706. const session = _runSessions.get(clientRunToken);
  2707. if (session?.runID) fetchCheckpoint(session.runID);
  2708. }
  2709. async function streamSSE(res, runHint = null, controller = null) {
  2710. const reader = res.body.getReader();
  2711. const decoder = new TextDecoder();
  2712. let buf = '';
  2713. try {
  2714. while (true) {
  2715. const { done, value } = await reader.read();
  2716. if (done) break;
  2717. buf += decoder.decode(value, { stream: true });
  2718. const frames = buf.split('\n\n');
  2719. buf = frames.pop();
  2720. for (const frame of frames) {
  2721. let eventType = '';
  2722. const dataLines = [];
  2723. for (const rawLine of frame.split('\n')) {
  2724. const line = rawLine.trimEnd();
  2725. if (!line || line.startsWith(':')) continue;
  2726. if (line.startsWith('event:')) {
  2727. eventType = line.slice(6).trim();
  2728. } else if (line.startsWith('data:')) {
  2729. dataLines.push(line.slice(5).trimStart());
  2730. }
  2731. }
  2732. if (!dataLines.length) continue;
  2733. try {
  2734. const payload = JSON.parse(dataLines.join('\n'));
  2735. if (eventType && payload && typeof payload === 'object' && payload.type == null) {
  2736. payload.type = eventType;
  2737. }
  2738. handleExecEvent(payload, runHint);
  2739. } catch {}
  2740. }
  2741. }
  2742. } finally {
  2743. clearRunController(runHint, controller);
  2744. updateExecutionControls();
  2745. renderRunSessions();
  2746. const session = runHint ? _runSessions.get(runHint) : null;
  2747. if (_selectedRunID === runHint || (_selectedRunID && session && _selectedRunID === session.sessionID)) {
  2748. syncSelectedRun(false);
  2749. }
  2750. }
  2751. }
  2752. async function fetchWorkflowContent(ref, workDir = '') {
  2753. const qs = new URLSearchParams();
  2754. qs.set('ref', ref);
  2755. if (workDir) qs.set('work_dir', workDir);
  2756. const res = await fetch(`/api/workflow-content?${qs.toString()}`);
  2757. const data = await res.json().catch(() => ({}));
  2758. if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
  2759. return data;
  2760. }
  2761. async function openSubflowNode(node = null) {
  2762. const targetNode = node || state.nodes.find((entry) => entry.id === state.selectedNodeId) || null;
  2763. if (!targetNode || targetNode.type !== 'Subflow') {
  2764. toast('Select a Subflow node first');
  2765. return;
  2766. }
  2767. const target = resolveSubflowTarget(targetNode.data || {});
  2768. if (!target.ok) {
  2769. toast(target.reason || 'Child workflow cannot be opened');
  2770. return;
  2771. }
  2772. const parentSnapshot = {
  2773. workflow: cloneJSON(_currentWorkflowJson),
  2774. workflowRef: _workflowRef,
  2775. label: _currentWorkflowJson?.name || summarizePathLabel(_workflowRef) || 'workflow',
  2776. selectedNodeId: targetNode.id,
  2777. };
  2778. try {
  2779. const payload = await fetchWorkflowContent(target.ref, target.workDir);
  2780. _workflowNavStack.push(parentSnapshot);
  2781. parseWorkflow(payload.workflow || payload, payload.workflowRef || normalizeWorkflowRef(target.ref), {
  2782. preserveNav: true,
  2783. title: payload.title || payload.workflow?.name || target.label || 'Subflow',
  2784. });
  2785. toast(`Opened subflow: ${payload.title || target.label || target.ref}`);
  2786. } catch (err) {
  2787. toast(`Open child failed: ${err.message}`);
  2788. }
  2789. }
  2790. function openSelectedSubflow() {
  2791. openSubflowNode();
  2792. }
  2793. function navigateBack() {
  2794. const previous = _workflowNavStack.pop();
  2795. if (!previous?.workflow) return;
  2796. parseWorkflow(previous.workflow, previous.workflowRef || null, {
  2797. preserveNav: true,
  2798. selectedNodeId: previous.selectedNodeId || null,
  2799. title: previous.workflow?.name || summarizePathLabel(previous.workflowRef) || 'VL Workflow DAG',
  2800. });
  2801. if (previous.selectedNodeId) {
  2802. const el = $(`node-${previous.selectedNodeId}`);
  2803. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  2804. }
  2805. }
  2806. async function loadWorkflowFromLocation() {
  2807. const search = window.location?.search || '';
  2808. if (!search) return false;
  2809. const params = new URLSearchParams(search);
  2810. const workflowRef = params.get('workflow') || params.get('ref');
  2811. if (!workflowRef) return false;
  2812. const workDir = params.get('work_dir') || '';
  2813. try {
  2814. const payload = await fetchWorkflowContent(workflowRef, workDir);
  2815. parseWorkflow(payload.workflow || payload, payload.workflowRef || normalizeWorkflowRef(workflowRef), {
  2816. title: payload.title || payload.workflow?.name || summarizePathLabel(workflowRef) || 'VL Workflow DAG',
  2817. });
  2818. toast(`Loaded workflow: ${payload.title || summarizePathLabel(workflowRef) || workflowRef}`);
  2819. return true;
  2820. } catch (err) {
  2821. toast(`Load failed: ${err.message}`);
  2822. return false;
  2823. }
  2824. }
  2825. // ===== Context Menu =====
  2826. function showContextMenu(e, node) {
  2827. e.preventDefault();
  2828. _ctxTargetNode = node;
  2829. const menu = $('ctxMenu');
  2830. $('ctxNodeIdHint').textContent = node.id;
  2831. // Enable/disable based on state
  2832. const hasCheckpoint = !!_lastCheckpoint;
  2833. const rerunItem = $('ctxRerun');
  2834. const editItem = $('ctxEditRerun');
  2835. const openChildItem = $('ctxOpenChild');
  2836. const openChildSep = $('ctxOpenChildSep');
  2837. const childTarget = node.type === 'Subflow' ? resolveSubflowTarget(node.data || {}) : null;
  2838. if (hasCheckpoint || _currentWorkflowJson) {
  2839. rerunItem.classList.remove('disabled');
  2840. editItem.classList.remove('disabled');
  2841. } else {
  2842. rerunItem.classList.add('disabled');
  2843. editItem.classList.add('disabled');
  2844. }
  2845. if (node.type === 'Subflow') {
  2846. openChildItem.style.display = '';
  2847. openChildSep.style.display = '';
  2848. if (childTarget?.ok) openChildItem.classList.remove('disabled');
  2849. else openChildItem.classList.add('disabled');
  2850. } else {
  2851. openChildItem.style.display = 'none';
  2852. openChildSep.style.display = 'none';
  2853. }
  2854. // Position
  2855. menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
  2856. menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
  2857. menu.classList.add('show');
  2858. }
  2859. function hideContextMenu() {
  2860. $('ctxMenu').classList.remove('show');
  2861. _ctxTargetNode = null;
  2862. }
  2863. function ctxRerunFromHere() {
  2864. hideContextMenu();
  2865. if (!_ctxTargetNode) return;
  2866. rerunFromStep(_ctxTargetNode.id);
  2867. }
  2868. function ctxEditAndRerun() {
  2869. hideContextMenu();
  2870. if (!_ctxTargetNode) return;
  2871. openEditor(_ctxTargetNode);
  2872. }
  2873. function ctxOpenChildWorkflow() {
  2874. const target = _ctxTargetNode;
  2875. hideContextMenu();
  2876. if (!target) return;
  2877. openSubflowNode(target);
  2878. }
  2879. function ctxViewDetails() {
  2880. hideContextMenu();
  2881. if (!_ctxTargetNode) return;
  2882. window.parent.postMessage({
  2883. type: 'nodeClick',
  2884. nodeId: _ctxTargetNode.id,
  2885. nodeType: _ctxTargetNode.type,
  2886. nodeData: _ctxTargetNode.data
  2887. }, '*');
  2888. }
  2889. function ctxCopyId() {
  2890. hideContextMenu();
  2891. if (!_ctxTargetNode) return;
  2892. navigator.clipboard?.writeText(_ctxTargetNode.id);
  2893. toast('Copied: ' + _ctxTargetNode.id);
  2894. }
  2895. // Close context menu on click elsewhere
  2896. document.addEventListener('click', (e) => {
  2897. if (!e.target.closest('.ctx-menu')) hideContextMenu();
  2898. });
  2899. // ===== Node Editor Modal =====
  2900. function openEditor(node) {
  2901. _editorNode = node;
  2902. const data = node.data || {};
  2903. const type = node.type;
  2904. $('editorTitle').textContent = `Edit: ${data.meta?.title || node.id} (${type})`;
  2905. let html = '';
  2906. // Status indicator
  2907. const statusText = node.status || 'pending';
  2908. html += `<div class="editor-status">
  2909. <div class="editor-status-dot ${statusText}"></div>
  2910. <span class="editor-status-text">Status: ${statusText} | Node: ${esc(node.id)}</span>
  2911. </div>`;
  2912. // Type-specific editor fields
  2913. if (type === 'LLM') {
  2914. const model = data.in?.model || data.model || '';
  2915. const msgs = data.in?.messages ? JSON.stringify(data.in.messages, null, 2) : '[]';
  2916. const maxTokens = data.in?.max_tokens || '';
  2917. html += `<div class="editor-field"><div class="editor-label">Model</div>
  2918. <input class="editor-input" id="edit_model" value="${esc(model)}" placeholder="e.g. anthropic/claude-opus-4-6"></div>`;
  2919. html += `<div class="editor-field"><div class="editor-label">Max Tokens</div>
  2920. <input class="editor-input" id="edit_max_tokens" value="${esc(String(maxTokens))}" type="number" placeholder="4096"></div>`;
  2921. html += `<div class="editor-field"><div class="editor-label">Messages (JSON)</div>
  2922. <textarea class="editor-json" id="edit_messages">${esc(msgs)}</textarea>
  2923. <div class="editor-error" id="edit_messages_err"></div></div>`;
  2924. } else if (type === 'Service' || type === 'API' || type === 'Component') {
  2925. const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
  2926. html += `<div class="editor-field"><div class="editor-label">Input Parameters (JSON)</div>
  2927. <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
  2928. <div class="editor-error" id="edit_in_err"></div></div>`;
  2929. } else if (type === 'Tool') {
  2930. const toolName = data.tool || data.toolName || data.name || '';
  2931. const inData = (data.input || data.in) ? JSON.stringify(data.input || data.in, null, 2) : '{}';
  2932. html += `<div class="editor-field"><div class="editor-label">Tool Name</div>
  2933. <input class="editor-input" id="edit_tool_name" value="${esc(toolName)}" placeholder="e.g. ReadFile"></div>`;
  2934. html += `<div class="editor-field"><div class="editor-label">Tool Input (JSON)</div>
  2935. <textarea class="editor-json" id="edit_tool_input">${esc(inData)}</textarea>
  2936. <div class="editor-error" id="edit_tool_input_err"></div></div>`;
  2937. html += `<div class="editor-field"><div class="editor-label">Timeout (ms)</div>
  2938. <input class="editor-input" id="edit_timeout" value="${esc(String(data.timeout || ''))}" type="number" placeholder="30000"></div>`;
  2939. html += `<div class="editor-field"><label class="editor-label" style="display:flex;gap:8px;align-items:center;">
  2940. <input type="checkbox" id="edit_allow_error" ${data.allowError || data.continueOnError ? 'checked' : ''}>
  2941. Continue on tool error
  2942. </label></div>`;
  2943. } else if (type === 'Subflow') {
  2944. const workflowPath = data.workflow_path || data.workflowPath || data.path || data.workflow || '';
  2945. const paramsJson = JSON.stringify(data.params || data.input || data.in || {}, null, 2);
  2946. const mode = data.mode || data.executionMode || 'sync';
  2947. const workDir = data.work_dir || data.workDir || data.base_dir || data.baseDir || data.subspace || '';
  2948. const emitEvents = data.emit_events !== false && data.emitEvents !== false;
  2949. html += `<div class="editor-field"><div class="editor-label">Child Workflow</div>
  2950. <input class="editor-input" id="edit_subflow_path" value="${esc(String(workflowPath))}" placeholder="e.g. examples/workflows/child.json"></div>`;
  2951. html += `<div class="editor-field"><div class="editor-label">Mode</div>
  2952. <input class="editor-input" id="edit_subflow_mode" value="${esc(String(mode))}" placeholder="sync"></div>`;
  2953. html += `<div class="editor-field"><div class="editor-label">Child Work Dir</div>
  2954. <input class="editor-input" id="edit_subflow_workdir" value="${esc(String(workDir))}" placeholder="optional subspace dir"></div>`;
  2955. html += `<div class="editor-field"><div class="editor-label">Child Params (JSON)</div>
  2956. <textarea class="editor-json" id="edit_subflow_params">${esc(paramsJson)}</textarea>
  2957. <div class="editor-error" id="edit_subflow_params_err"></div></div>`;
  2958. html += `<div class="editor-field"><label class="editor-label" style="display:flex;gap:8px;align-items:center;">
  2959. <input type="checkbox" id="edit_subflow_events" ${emitEvents ? 'checked' : ''}>
  2960. Forward child events
  2961. </label></div>`;
  2962. } else if (type === 'Set') {
  2963. html += `<div class="editor-field"><div class="editor-label">Target Variable</div>
  2964. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  2965. html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
  2966. <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  2967. } else if (type === 'Write') {
  2968. html += `<div class="editor-field"><div class="editor-label">Target Path</div>
  2969. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  2970. html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
  2971. <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  2972. } else if (type === 'Download') {
  2973. const src = typeof data.source === 'object' ? JSON.stringify(data.source, null, 2) : String(data.source || '');
  2974. html += `<div class="editor-field"><div class="editor-label">Source URL / Config</div>
  2975. <textarea class="editor-json" id="edit_source">${esc(src)}</textarea></div>`;
  2976. if (data.target != null) {
  2977. html += `<div class="editor-field"><div class="editor-label">Target Path</div>
  2978. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  2979. }
  2980. } else if (type === 'Loop') {
  2981. if (data.while) {
  2982. html += `<div class="editor-field"><div class="editor-label">While Expression</div>
  2983. <input class="editor-input" id="edit_while" value="${esc(data.while)}"></div>`;
  2984. html += `<div class="editor-field"><div class="editor-label">Max Iterations</div>
  2985. <input class="editor-input" id="edit_maxIterations" value="${esc(String(data.maxIterations || ''))}" type="number"></div>`;
  2986. } else {
  2987. html += `<div class="editor-field"><div class="editor-label">Source Expression</div>
  2988. <input class="editor-input" id="edit_source" value="${esc(data.source || '')}"></div>`;
  2989. }
  2990. } else {
  2991. // Generic: show full step JSON
  2992. const stepJson = JSON.stringify(data, null, 2);
  2993. html += `<div class="editor-field"><div class="editor-label">Step Data (JSON)</div>
  2994. <textarea class="editor-json" id="edit_raw" style="min-height:200px">${esc(stepJson)}</textarea>
  2995. <div class="editor-error" id="edit_raw_err"></div></div>`;
  2996. }
  2997. // Variable overrides section
  2998. html += `<div class="editor-field" style="margin-top:12px; border-top:1px solid var(--border); padding-top:10px;">
  2999. <div class="editor-label">Variable Overrides (optional)</div>
  3000. <textarea class="editor-json" id="edit_overrides" placeholder='{ "$varName": "newValue" }'>{}</textarea>
  3001. <div class="editor-hint">Override pipeline variables before re-running. Use $varName keys.</div>
  3002. <div class="editor-error" id="edit_overrides_err"></div>
  3003. </div>`;
  3004. $('editorBody').innerHTML = html;
  3005. $('editorOverlay').classList.add('show');
  3006. }
  3007. function closeEditor() {
  3008. $('editorOverlay').classList.remove('show');
  3009. _editorNode = null;
  3010. }
  3011. function editorRerun() {
  3012. if (!_editorNode) return;
  3013. // Collect overrides
  3014. let overrides = {};
  3015. const overridesEl = $('edit_overrides');
  3016. if (overridesEl) {
  3017. try {
  3018. overrides = JSON.parse(overridesEl.value || '{}');
  3019. const errEl = $('edit_overrides_err');
  3020. if (errEl) errEl.style.display = 'none';
  3021. } catch (e) {
  3022. const errEl = $('edit_overrides_err');
  3023. if (errEl) { errEl.textContent = 'Invalid JSON: ' + e.message; errEl.style.display = 'block'; }
  3024. return;
  3025. }
  3026. }
  3027. // Collect edited step data and merge into overrides
  3028. const type = _editorNode.type;
  3029. const data = _editorNode.data || {};
  3030. if (type === 'LLM') {
  3031. const model = $('edit_model')?.value;
  3032. const maxTokens = $('edit_max_tokens')?.value;
  3033. const msgsEl = $('edit_messages');
  3034. if (msgsEl) {
  3035. try { JSON.parse(msgsEl.value); } catch (e) {
  3036. const err = $('edit_messages_err');
  3037. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  3038. return;
  3039. }
  3040. }
  3041. // Update step data in memory for visual accuracy
  3042. if (model && data.in) data.in.model = model;
  3043. if (data.model !== undefined && model) data.model = model;
  3044. if (maxTokens && data.in) data.in.max_tokens = parseInt(maxTokens);
  3045. if (msgsEl && data.in) { try { data.in.messages = JSON.parse(msgsEl.value); } catch {} }
  3046. } else if ((type === 'Service' || type === 'API' || type === 'Component') && $('edit_in')) {
  3047. try {
  3048. const newIn = JSON.parse($('edit_in').value);
  3049. data.in = newIn;
  3050. } catch (e) {
  3051. const err = $('edit_in_err');
  3052. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  3053. return;
  3054. }
  3055. } else if (type === 'Tool' && $('edit_tool_input')) {
  3056. const toolName = $('edit_tool_name')?.value?.trim();
  3057. if (toolName) data.tool = toolName;
  3058. else delete data.tool;
  3059. try {
  3060. const newIn = JSON.parse($('edit_tool_input').value || '{}');
  3061. data.input = newIn;
  3062. delete data.in;
  3063. } catch (e) {
  3064. const err = $('edit_tool_input_err');
  3065. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  3066. return;
  3067. }
  3068. const timeoutValue = $('edit_timeout')?.value?.trim();
  3069. if (timeoutValue) data.timeout = parseInt(timeoutValue, 10);
  3070. else delete data.timeout;
  3071. data.allowError = !!$('edit_allow_error')?.checked;
  3072. if (!data.allowError) delete data.allowError;
  3073. } else if (type === 'Subflow' && $('edit_subflow_params')) {
  3074. const workflowPath = $('edit_subflow_path')?.value?.trim();
  3075. const mode = $('edit_subflow_mode')?.value?.trim();
  3076. const workDir = $('edit_subflow_workdir')?.value?.trim();
  3077. if (workflowPath) data.workflow_path = workflowPath;
  3078. else delete data.workflow_path;
  3079. if (mode) data.mode = mode;
  3080. else delete data.mode;
  3081. if (workDir) data.work_dir = workDir;
  3082. else delete data.work_dir;
  3083. try {
  3084. data.params = JSON.parse($('edit_subflow_params').value || '{}');
  3085. delete data.input;
  3086. delete data.in;
  3087. } catch (e) {
  3088. const err = $('edit_subflow_params_err');
  3089. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  3090. return;
  3091. }
  3092. if ($('edit_subflow_events')?.checked) data.emit_events = true;
  3093. else data.emit_events = false;
  3094. } else if ((type === 'Set' || type === 'Write') && $('edit_target')) {
  3095. data.target = $('edit_target').value;
  3096. data.value = $('edit_value')?.value || data.value;
  3097. } else if (type === 'Download' && $('edit_source')) {
  3098. try {
  3099. data.source = JSON.parse($('edit_source').value);
  3100. } catch { data.source = $('edit_source').value; }
  3101. if ($('edit_target')) data.target = $('edit_target').value;
  3102. } else if (type === 'Loop') {
  3103. if ($('edit_while')) data.while = $('edit_while').value;
  3104. if ($('edit_maxIterations')) data.maxIterations = parseInt($('edit_maxIterations').value);
  3105. if ($('edit_source')) data.source = $('edit_source').value;
  3106. } else if ($('edit_raw')) {
  3107. try {
  3108. const raw = JSON.parse($('edit_raw').value);
  3109. Object.assign(data, raw);
  3110. } catch (e) {
  3111. const err = $('edit_raw_err');
  3112. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  3113. return;
  3114. }
  3115. }
  3116. // Notify parent of edits
  3117. window.parent.postMessage({
  3118. type: 'nodeEdited',
  3119. nodeId: _editorNode.id,
  3120. nodeData: data,
  3121. overrides
  3122. }, '*');
  3123. // Re-run
  3124. const rerunNodeId = _editorNode.id;
  3125. closeEditor();
  3126. rerunFromStep(rerunNodeId, overrides);
  3127. }
  3128. // ===== PostMessage API =====
  3129. window.addEventListener('message', (e) => {
  3130. if (!e.data?.type) return;
  3131. switch (e.data.type) {
  3132. case 'loadWorkflow':
  3133. parseWorkflow(e.data.data, e.data.workflowName || e.data.name || null);
  3134. break;
  3135. case 'highlightNode':
  3136. state.selectedNodeId = e.data.nodeId;
  3137. renderNodes();
  3138. renderConnections();
  3139. updateNavigationChrome();
  3140. const el = $(`node-${e.data.nodeId}`);
  3141. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  3142. break;
  3143. case 'updateNodeStatus': {
  3144. const typeMap = {
  3145. running: 'node_start',
  3146. done: 'node_done',
  3147. error: 'node_error',
  3148. skipped: 'node_skipped',
  3149. paused: 'pause',
  3150. };
  3151. handleExecEvent({
  3152. type: typeMap[e.data.status] || 'node_start',
  3153. nodeId: e.data.nodeId,
  3154. runID: e.data.runID || null,
  3155. clientRunToken: e.data.clientRunToken || null,
  3156. }, e.data.clientRunToken || e.data.runID || null);
  3157. break;
  3158. }
  3159. case 'workflowEvent':
  3160. handleExecEvent(e.data.event || {}, e.data.event?.clientRunToken || e.data.event?.runID || null);
  3161. break;
  3162. case 'clearStatus':
  3163. clearRunSessions();
  3164. renderNodes();
  3165. renderConnections();
  3166. updateMinimap();
  3167. break;
  3168. case 'setCheckpoint':
  3169. handleExecEvent({ type: 'checkpoint', checkpoint: e.data.checkpoint, runID: e.data.runID || null, clientRunToken: e.data.clientRunToken || null }, e.data.clientRunToken || e.data.runID || null);
  3170. break;
  3171. case 'rerunFromStep':
  3172. rerunFromStep(e.data.stepId || e.data.nodeId, e.data.overrides || {});
  3173. break;
  3174. case 'editNode': {
  3175. const node = state.nodes.find(n => n.id === (e.data.nodeId || e.data.stepId));
  3176. if (node) openEditor(node);
  3177. break;
  3178. }
  3179. }
  3180. });
  3181. // ===== Init =====
  3182. loadUIPreferences();
  3183. applyUIState();
  3184. initDrag();
  3185. window.parent.postMessage({ type: 'ready' }, '*');
  3186. loadWorkflowFromLocation();
  3187. // Scroll events update minimap
  3188. $('canvasWrap')?.addEventListener('scroll', () => { renderConnections(); updateMinimap(); });
  3189. $('canvasWrap')?.addEventListener('click', (e) => {
  3190. const target = e.target;
  3191. if (target?.closest?.('.node')) return;
  3192. if (target?.closest?.('.ctx-menu, .modal, .toolbar, .runbar, .eventbar, .sidebar, .minimap')) return;
  3193. deselectSelectedNode();
  3194. });
  3195. window.addEventListener('resize', () => { renderConnections(); updateMinimap(); });
  3196. async function ensureWorkflowRef() {
  3197. if (_workflowRef) return _workflowRef;
  3198. if (!_currentWorkflowJson) return null;
  3199. try {
  3200. const res = await fetch('/api/workflow/ephemeral', {
  3201. method: 'POST',
  3202. headers: { 'Content-Type': 'application/json' },
  3203. body: JSON.stringify(_currentWorkflowJson),
  3204. });
  3205. const data = await res.json();
  3206. if (data?.ok && data.name) {
  3207. _workflowRef = data.name;
  3208. return _workflowRef;
  3209. }
  3210. } catch {}
  3211. return _currentWorkflowJson?.name || null;
  3212. }
  3213. </script>
  3214. </body>
  3215. </html>