workflow-editor.html 71 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844
  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. .tb-btn {
  24. background:var(--bg3); border:1px solid var(--border); color:var(--text2);
  25. padding:3px 10px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:10px;
  26. }
  27. .tb-btn:hover { background:var(--border); color:var(--text); }
  28. .tb-btn.primary { background:var(--green); color:#000; border-color:var(--green); font-weight:600; }
  29. .tb-btn.primary:hover { opacity:0.9; }
  30. .tb-btn.danger { border-color:var(--red); color:var(--red); }
  31. .tb-btn.danger:hover { background:var(--red); color:#fff; }
  32. /* ===== Canvas container ===== */
  33. .canvas-wrap { flex:1; overflow:auto; position:relative; }
  34. .canvas {
  35. position:relative; min-width:4000px; min-height:2500px;
  36. background-image:radial-gradient(circle, var(--border) 1px, transparent 1px);
  37. background-size:20px 20px;
  38. }
  39. .empty-msg {
  40. position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  41. color:var(--text2); font-size:14px; text-align:center;
  42. }
  43. /* ===== Nodes ===== */
  44. .node {
  45. position:absolute; width:240px; background:var(--bg3); border:1px solid var(--border);
  46. border-radius:8px; cursor:grab; transition:border-color 0.2s, box-shadow 0.2s;
  47. z-index:2; user-select:none;
  48. }
  49. .node:hover { border-color:var(--blue); box-shadow:0 0 12px rgba(88,166,255,0.15); }
  50. .node.selected { border-color:var(--blue); box-shadow:0 0 20px rgba(88,166,255,0.25); }
  51. .node.dragging { cursor:grabbing; z-index:10; opacity:0.92; box-shadow:0 0 24px rgba(88,166,255,0.3); }
  52. .node-header { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--border); }
  53. .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; }
  54. .node-title { font-size:11px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  55. .node-type { font-size:9px; color:var(--text2); }
  56. .node-desc { font-size:9px; color:var(--cyan); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:170px; }
  57. .node-body { padding:6px 10px; font-size:10px; color:var(--text2); overflow:hidden; }
  58. .node-body .field { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:1px; }
  59. /* Rich body styles */
  60. .node-section { margin-bottom:5px; }
  61. .node-section:last-child { margin-bottom:0; }
  62. .node-section-title {
  63. font-size:8px; color:var(--text2); text-transform:uppercase;
  64. letter-spacing:0.5px; margin-bottom:2px; padding-bottom:2px;
  65. border-bottom:1px dashed var(--border);
  66. }
  67. .node-field { display:flex; align-items:flex-start; gap:4px; margin-bottom:2px; line-height:1.3; }
  68. .node-field:last-child { margin-bottom:0; }
  69. .node-label { color:var(--text2); min-width:36px; flex-shrink:0; font-size:9px; }
  70. .node-value {
  71. color:var(--text); font-family:'SF Mono','Fira Code',monospace;
  72. font-size:9px; background:rgba(255,255,255,0.05); padding:1px 4px;
  73. border-radius:2px; word-break:break-all; max-width:160px;
  74. overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  75. }
  76. .node-value.var { color:var(--cyan); }
  77. .node-value.ref { color:var(--purple); }
  78. .node-value.file { color:var(--orange); }
  79. .node-io-list { display:flex; flex-direction:column; gap:2px; }
  80. .node-io-item {
  81. display:flex; align-items:center; gap:4px; font-size:9px;
  82. background:rgba(255,255,255,0.04); padding:2px 4px; border-radius:2px;
  83. }
  84. .node-io-key { color:var(--text); font-family:'SF Mono','Fira Code',monospace; min-width:44px; }
  85. .node-io-key.is-var { color:var(--cyan); }
  86. .node-io-key.is-file { color:var(--orange); }
  87. .node-io-arrow { color:var(--text2); }
  88. .node-io-value { color:var(--cyan); font-family:'SF Mono','Fira Code',monospace; }
  89. .node-docs-list { display:flex; flex-wrap:wrap; gap:3px; }
  90. .node-doc-tag {
  91. background:rgba(210,153,34,0.2); color:var(--orange);
  92. padding:1px 4px; border-radius:2px; font-size:8px;
  93. font-family:'SF Mono','Fira Code',monospace;
  94. max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  95. }
  96. .node-condition {
  97. background:rgba(163,113,247,0.15); color:var(--purple);
  98. padding:2px 6px; border-radius:2px; font-size:9px;
  99. font-family:'SF Mono','Fira Code',monospace; margin-top:4px;
  100. }
  101. .node-footer {
  102. padding:4px 8px; border-top:1px dashed var(--border);
  103. font-size:9px; color:var(--purple);
  104. }
  105. /* I/O audit badges */
  106. .node-io { display:flex; gap:3px; flex-wrap:wrap; padding:4px 10px 6px; border-top:1px solid var(--border); }
  107. .io-badge { font-size:8px; padding:1px 5px; border-radius:3px; font-family:inherit; }
  108. .io-badge.var-in { background:rgba(88,166,255,0.15); color:var(--blue); }
  109. .io-badge.var-out { background:rgba(63,185,80,0.15); color:var(--green); }
  110. .io-badge.doc { background:rgba(210,153,34,0.15); color:var(--orange); }
  111. .io-badge.file { background:rgba(163,113,247,0.15); color:var(--purple); }
  112. /* Node type colors — original 11 */
  113. .type-LLM .node-icon { background:linear-gradient(135deg, #6366f1, #8b5cf6); }
  114. .type-Service .node-icon { background:linear-gradient(135deg, var(--green), #2ea043); }
  115. .type-API .node-icon { background:linear-gradient(135deg, var(--cyan), #1a7f8a); }
  116. .type-Write .node-icon { background:linear-gradient(135deg, var(--orange), #b87f12); }
  117. .type-Set .node-icon { background:linear-gradient(135deg, var(--blue), #1f6feb); }
  118. .type-Branch .node-icon { background:linear-gradient(135deg, var(--purple), #8957e5); }
  119. .type-Loop .node-icon { background:linear-gradient(135deg, #db61a2, #bf4b8a); }
  120. .type-Stop .node-icon { background:linear-gradient(135deg, var(--red), #da3633); }
  121. .type-Component .node-icon { background:linear-gradient(135deg, #2dd4bf, #14b8a6); }
  122. .type-Pause .node-icon { background:linear-gradient(135deg, var(--violet), #7c3aed); }
  123. .type-Fork .node-icon { background:linear-gradient(135deg, var(--emerald), #059669); }
  124. /* New in Spec 3.16 */
  125. .type-Download .node-icon { background:linear-gradient(135deg, #38bdf8, #0284c7); }
  126. .type-Unzip .node-icon { background:linear-gradient(135deg, #facc15, #ca8a04); }
  127. /* Type accent bar */
  128. .node::before {
  129. content:''; position:absolute; left:0; top:8px; bottom:8px; width:3px;
  130. border-radius:0 2px 2px 0;
  131. }
  132. .type-LLM::before { background:#6366f1; }
  133. .type-Service::before { background:var(--green); }
  134. .type-API::before { background:var(--cyan); }
  135. .type-Write::before { background:var(--orange); }
  136. .type-Set::before { background:var(--blue); }
  137. .type-Branch::before { background:var(--purple); }
  138. .type-Loop::before { background:#db61a2; }
  139. .type-Stop::before { background:var(--red); }
  140. .type-Component::before { background:#2dd4bf; }
  141. .type-Pause::before { background:var(--violet); }
  142. .type-Fork::before { background:var(--emerald); }
  143. /* New in Spec 3.16 */
  144. .type-Download::before { background:#38bdf8; }
  145. .type-Unzip::before { background:#facc15; }
  146. /* Status overlays */
  147. .node.status-running { border-color:var(--orange); animation:pulse-border 1.5s ease-in-out infinite; }
  148. .node.status-done { border-color:var(--green); }
  149. .node.status-error { border-color:var(--red); }
  150. .node.status-paused { border-color:var(--violet); animation:pulse-border-purple 1.5s ease-in-out infinite; }
  151. .node.status-skipped { border-color:var(--text2); opacity:0.5; }
  152. .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; }
  153. .status-badge.running { background:var(--orange); animation:spin 1s linear infinite; }
  154. .status-badge.done { background:var(--green); }
  155. .status-badge.error { background:var(--red); }
  156. .status-badge.paused { background:var(--violet); animation:pulse-badge-purple 2s ease-in-out infinite; }
  157. .status-badge.skipped { background:var(--text2); opacity:0.5; }
  158. @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); } }
  159. @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); } }
  160. @keyframes pulse-badge-purple { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
  161. @keyframes spin { to { transform:rotate(360deg); } }
  162. /* ===== Ports ===== */
  163. .port { position:absolute; width:8px; height:8px; border-radius:50%; background:var(--border); border:1px solid var(--text2); z-index:3; }
  164. .port-in { top:-4px; left:50%; transform:translateX(-50%); }
  165. .port-out { bottom:-4px; left:50%; transform:translateX(-50%); }
  166. /* ===== Connections (SVG) ===== */
  167. .connections { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:1; }
  168. .conn-path { fill:none; stroke-width:2; }
  169. .conn-path.serial { stroke:var(--blue); }
  170. .conn-path.parallel { stroke:var(--purple); stroke-dasharray:6 3; }
  171. .conn-path.branch-case { stroke:var(--orange); stroke-dasharray:4 2 1 2; }
  172. .conn-label { fill:var(--text2); font-size:9px; font-family:inherit; }
  173. svg defs marker path { fill:var(--blue); }
  174. /* ===== Legend ===== */
  175. .legend {
  176. position:fixed; bottom:12px; left:12px; background:var(--bg2); border:1px solid var(--border);
  177. border-radius:6px; padding:8px 12px; font-size:9px; color:var(--text2); display:flex; gap:12px; z-index:10;
  178. }
  179. .legend-item { display:flex; align-items:center; gap:4px; }
  180. .legend-line { width:20px; height:2px; }
  181. .legend-line.serial { background:var(--blue); }
  182. .legend-line.parallel { background:var(--purple); background:repeating-linear-gradient(90deg, var(--purple) 0 6px, transparent 6px 9px); }
  183. .legend-line.branch { background:var(--orange); }
  184. /* ===== Minimap ===== */
  185. .minimap {
  186. position:fixed; bottom:12px; right:12px; width:180px; height:100px;
  187. background:var(--bg2); border:1px solid var(--border); border-radius:6px;
  188. overflow:hidden; z-index:10; cursor:crosshair;
  189. }
  190. .minimap canvas { width:100%; height:100%; }
  191. /* ===== Toast ===== */
  192. .toast {
  193. position:fixed; top:48px; left:50%; transform:translateX(-50%);
  194. background:var(--bg3); border:1px solid var(--border); border-radius:6px;
  195. padding:6px 16px; font-size:11px; color:var(--text); z-index:100;
  196. opacity:0; transition:opacity 0.3s;
  197. }
  198. .toast.show { opacity:1; }
  199. /* ===== Context Menu ===== */
  200. .ctx-menu {
  201. position:fixed; background:var(--bg2); border:1px solid var(--border);
  202. border-radius:6px; padding:4px 0; z-index:200; min-width:180px;
  203. box-shadow:0 4px 16px rgba(0,0,0,0.5); display:none;
  204. }
  205. .ctx-menu.show { display:block; }
  206. .ctx-item {
  207. padding:6px 14px; font-size:11px; color:var(--text); cursor:pointer;
  208. display:flex; align-items:center; gap:8px; font-family:inherit;
  209. }
  210. .ctx-item:hover { background:var(--bg3); }
  211. .ctx-item.disabled { color:var(--text2); cursor:default; opacity:0.5; }
  212. .ctx-item.disabled:hover { background:transparent; }
  213. .ctx-sep { height:1px; background:var(--border); margin:4px 0; }
  214. .ctx-item .ctx-icon { font-size:12px; width:16px; text-align:center; }
  215. .ctx-item .ctx-label { flex:1; }
  216. .ctx-item .ctx-hint { font-size:9px; color:var(--text2); }
  217. /* ===== Node Editor Modal ===== */
  218. .modal-overlay {
  219. position:fixed; top:0; left:0; width:100%; height:100%;
  220. background:rgba(0,0,0,0.6); z-index:300; display:none;
  221. align-items:center; justify-content:center;
  222. }
  223. .modal-overlay.show { display:flex; }
  224. .modal {
  225. background:var(--bg2); border:1px solid var(--border); border-radius:10px;
  226. width:520px; max-height:80vh; display:flex; flex-direction:column;
  227. box-shadow:0 8px 32px rgba(0,0,0,0.5);
  228. }
  229. .modal-header {
  230. padding:12px 16px; border-bottom:1px solid var(--border);
  231. display:flex; align-items:center; gap:8px;
  232. }
  233. .modal-header .modal-title { font-size:12px; font-weight:600; color:var(--text); flex:1; }
  234. .modal-header .modal-close {
  235. background:none; border:none; color:var(--text2); font-size:16px;
  236. cursor:pointer; padding:2px 6px; border-radius:4px;
  237. }
  238. .modal-header .modal-close:hover { background:var(--bg3); color:var(--text); }
  239. .modal-body { padding:12px 16px; overflow-y:auto; flex:1; }
  240. .modal-footer {
  241. padding:10px 16px; border-top:1px solid var(--border);
  242. display:flex; justify-content:flex-end; gap:8px;
  243. }
  244. .modal-footer .tb-btn { padding:5px 14px; font-size:11px; }
  245. /* Editor fields */
  246. .editor-field { margin-bottom:10px; }
  247. .editor-field:last-child { margin-bottom:0; }
  248. .editor-label { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; }
  249. .editor-input {
  250. width:100%; background:var(--bg); border:1px solid var(--border);
  251. color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:11px;
  252. padding:6px 8px; border-radius:4px; resize:vertical;
  253. }
  254. .editor-input:focus { outline:none; border-color:var(--blue); }
  255. .editor-json {
  256. width:100%; background:var(--bg); border:1px solid var(--border);
  257. color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:10px;
  258. padding:8px; border-radius:4px; resize:vertical; min-height:120px;
  259. line-height:1.5;
  260. }
  261. .editor-json:focus { outline:none; border-color:var(--blue); }
  262. .editor-hint { font-size:9px; color:var(--text2); margin-top:3px; }
  263. .editor-error { font-size:9px; color:var(--red); margin-top:3px; display:none; }
  264. /* Node status info in editor */
  265. .editor-status {
  266. display:flex; align-items:center; gap:6px; padding:6px 8px;
  267. background:rgba(255,255,255,0.03); border-radius:4px; margin-bottom:10px;
  268. }
  269. .editor-status-dot { width:8px; height:8px; border-radius:50%; }
  270. .editor-status-dot.done { background:var(--green); }
  271. .editor-status-dot.running { background:var(--orange); }
  272. .editor-status-dot.error { background:var(--red); }
  273. .editor-status-dot.paused { background:var(--violet); }
  274. .editor-status-dot.skipped { background:var(--text2); }
  275. .editor-status-dot.pending { background:var(--border); }
  276. .editor-status-text { font-size:10px; color:var(--text2); }
  277. /* Pause button (amber) */
  278. .tb-btn.warn { border-color:var(--orange); color:var(--orange); }
  279. .tb-btn.warn:hover { background:var(--orange); color:#000; }
  280. </style>
  281. </head>
  282. <body>
  283. <!-- Toolbar -->
  284. <div class="toolbar">
  285. <span class="title" id="wfTitle">VL Workflow DAG</span>
  286. <div class="sep"></div>
  287. <button class="tb-btn" onclick="autoLayout();render();" title="Re-arrange nodes">&#8634; Layout</button>
  288. <button class="tb-btn" onclick="downloadPNG()" title="Export as PNG image">&#128247; PNG</button>
  289. <div class="sep"></div>
  290. <button class="tb-btn" onclick="exportJSON()" title="Export workflow JSON">&#8615; Export</button>
  291. <button class="tb-btn" onclick="importJSON()" title="Import workflow JSON">&#8613; Import</button>
  292. <div class="sep"></div>
  293. <button class="tb-btn primary" onclick="runWorkflow()" title="Execute this workflow" id="runBtn">&#9654; Run</button>
  294. <button class="tb-btn warn" onclick="pauseExecution()" title="Pause execution" id="pauseBtn" style="display:none;">&#9208; Pause</button>
  295. <button class="tb-btn danger" onclick="stopExecution()" title="Stop execution" id="stopBtn" style="display:none;">&#9632; Stop</button>
  296. <div style="flex:1;"></div>
  297. <span id="statusLabel" style="font-size:10px;color:var(--text2);"></span>
  298. </div>
  299. <!-- Canvas -->
  300. <div class="canvas-wrap" id="canvasWrap">
  301. <div class="empty-msg" id="emptyMsg">No workflow loaded.<br>Select a workflow to visualize.</div>
  302. <div class="canvas" id="canvas" style="display:none;">
  303. <svg class="connections" id="connSvg">
  304. <defs>
  305. <marker id="arrowSerial" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  306. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--blue)"/>
  307. </marker>
  308. <marker id="arrowParallel" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  309. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--purple)"/>
  310. </marker>
  311. <marker id="arrowBranch" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
  312. <path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--orange)"/>
  313. </marker>
  314. </defs>
  315. </svg>
  316. <div class="nodes-layer" id="nodesLayer"></div>
  317. </div>
  318. </div>
  319. <!-- Legend -->
  320. <div class="legend" id="legend" style="display:none;">
  321. <div class="legend-item"><div class="legend-line serial"></div>Serial (next)</div>
  322. <div class="legend-item"><div class="legend-line parallel"></div>Parallel (children)</div>
  323. <div class="legend-item"><div class="legend-line branch"></div>Branch (case)</div>
  324. </div>
  325. <!-- Minimap -->
  326. <div class="minimap" id="minimap" style="display:none;">
  327. <canvas id="minimapCanvas"></canvas>
  328. </div>
  329. <!-- Toast -->
  330. <div class="toast" id="toast"></div>
  331. <!-- Context Menu -->
  332. <div class="ctx-menu" id="ctxMenu">
  333. <div class="ctx-item" id="ctxRerun" onclick="ctxRerunFromHere()">
  334. <span class="ctx-icon">&#9654;</span><span class="ctx-label">Re-run from here</span>
  335. </div>
  336. <div class="ctx-item" id="ctxEditRerun" onclick="ctxEditAndRerun()">
  337. <span class="ctx-icon">&#9998;</span><span class="ctx-label">Edit inputs &amp; re-run</span>
  338. </div>
  339. <div class="ctx-sep"></div>
  340. <div class="ctx-item" onclick="ctxViewDetails()">
  341. <span class="ctx-icon">&#128269;</span><span class="ctx-label">View details</span>
  342. </div>
  343. <div class="ctx-item" onclick="ctxCopyId()">
  344. <span class="ctx-icon">&#128203;</span><span class="ctx-label">Copy node ID</span><span class="ctx-hint" id="ctxNodeIdHint"></span>
  345. </div>
  346. </div>
  347. <!-- Node Editor Modal -->
  348. <div class="modal-overlay" id="editorOverlay">
  349. <div class="modal">
  350. <div class="modal-header">
  351. <span class="modal-title" id="editorTitle">Edit Node Inputs</span>
  352. <button class="modal-close" onclick="closeEditor()">&times;</button>
  353. </div>
  354. <div class="modal-body" id="editorBody"></div>
  355. <div class="modal-footer">
  356. <button class="tb-btn" onclick="closeEditor()">Cancel</button>
  357. <button class="tb-btn primary" onclick="editorRerun()" id="editorRerunBtn">&#9654; Re-run from here</button>
  358. </div>
  359. </div>
  360. </div>
  361. <!-- Hidden file input for import -->
  362. <input type="file" id="importInput" accept=".json" style="display:none">
  363. <script>
  364. // ===== VL Workflow Editor v2.0 (Spec 3.16) =====
  365. // Canonical source: vl-workflow-engine/ui/workflow-editor.html
  366. // Consumed by: VL-Code, VLCode-Lite, VLClaw
  367. const RESERVED_NEXT = new Set(['RETURN', 'BREAK']);
  368. const NODE_ICONS = {
  369. LLM:'AI', Service:'SV', API:'AP', Component:'CP',
  370. Set:'=', Write:'WR', Branch:'BR', Loop:'LP', Stop:'ST',
  371. Pause:'\u23F8', Fork:'FK',
  372. Download:'DL', Unzip:'UZ'
  373. };
  374. const KNOWN_TYPES = ['LLM','Service','API','Component','Set','Write','Branch','Loop','Stop','Pause','Fork','Download','Unzip'];
  375. let state = { nodes: [], connections: [], selectedNodeId: null, registry: {}, dragging: null };
  376. let _currentWorkflowJson = null;
  377. let _executing = false;
  378. let _eventSource = null;
  379. let _lastCheckpoint = null; // latest checkpoint from engine
  380. let _currentRunID = null; // active run ID
  381. let _ctxTargetNode = null; // node targeted by context menu
  382. let _editorNode = null; // node being edited in modal
  383. // ===== Utilities =====
  384. function $(id) { return document.getElementById(id); }
  385. function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
  386. function trunc(s, n) { return s && s.length > n ? s.substring(0, n) + '\u2026' : (s || ''); }
  387. function resolveDocName(docId) { return state.registry?.docs?.[docId] || `Doc #${docId}`; }
  388. function toast(msg) {
  389. const t = $('toast');
  390. t.textContent = msg;
  391. t.classList.add('show');
  392. setTimeout(() => t.classList.remove('show'), 2500);
  393. }
  394. // ===== Parse workflow JSON into internal state =====
  395. function parseWorkflow(json) {
  396. _currentWorkflowJson = json;
  397. state.nodes = [];
  398. state.connections = [];
  399. state.registry = json.registry || {};
  400. if (!json?.steps?.length) return;
  401. const steps = json.steps;
  402. $('wfTitle').textContent = json.name || 'VL Workflow DAG';
  403. // Create nodes
  404. for (const step of steps) {
  405. const type = getStepType(step);
  406. state.nodes.push({
  407. id: step.id,
  408. type,
  409. x: step._x || 0,
  410. y: step._y || 0,
  411. data: step,
  412. status: null
  413. });
  414. }
  415. // Create connections
  416. let connId = 0;
  417. for (const step of steps) {
  418. // FIX #1: exclude both RETURN and BREAK (reserved keywords, not real nodes)
  419. if (step.next && !RESERVED_NEXT.has(step.next)) {
  420. state.connections.push({ id: 'c' + (connId++), from: step.id, to: step.next, type: 'serial' });
  421. }
  422. if (step.children?.length) {
  423. for (const childId of step.children) {
  424. state.connections.push({ id: 'c' + (connId++), from: step.id, to: childId, type: 'parallel' });
  425. }
  426. }
  427. // Branch cases — support both array [[expr, target]] and object {expr: target}
  428. if (step.branches) {
  429. if (Array.isArray(step.branches)) {
  430. for (const [expr, targetId] of step.branches) {
  431. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  432. }
  433. } else {
  434. for (const [expr, targetId] of Object.entries(step.branches)) {
  435. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  436. }
  437. }
  438. }
  439. // Legacy: cases field
  440. if (step.cases) {
  441. for (const [expr, targetId] of Object.entries(step.cases)) {
  442. state.connections.push({ id: 'c' + (connId++), from: step.id, to: targetId, type: 'branch-case', label: expr });
  443. }
  444. }
  445. }
  446. const hasPos = state.nodes.some(n => n.x > 0 || n.y > 0);
  447. if (!hasPos) autoLayout();
  448. // Restore checkpoint state (node statuses) if available
  449. restoreFromStorage();
  450. render();
  451. }
  452. function getStepType(step) {
  453. if (step.type) return step.type;
  454. const id = step.id || '';
  455. const prefix = id.split('_')[0];
  456. // FIX #3: include Download and Unzip in known types
  457. return KNOWN_TYPES.includes(prefix) ? prefix : 'LLM';
  458. }
  459. // ===== Auto Layout (topological, with barycenter crossing reduction) =====
  460. function autoLayout() {
  461. const LAYER_GAP = 300, NODE_GAP = 180, START_X = 80, START_Y = 60;
  462. const nodeMap = new Map(state.nodes.map(n => [n.id, n]));
  463. const succs = new Map(), preds = new Map();
  464. for (const n of state.nodes) { succs.set(n.id, []); preds.set(n.id, []); }
  465. for (const c of state.connections) {
  466. if (succs.has(c.from) && preds.has(c.to)) {
  467. succs.get(c.from).push(c.to);
  468. preds.get(c.to).push(c.from);
  469. }
  470. }
  471. // Find roots
  472. const roots = state.nodes.filter(n => (preds.get(n.id)?.length || 0) === 0).map(n => n.id);
  473. if (roots.length === 0 && state.nodes.length > 0) roots.push(state.nodes[0].id);
  474. // Longest-path layer assignment (with cycle protection)
  475. const layers = new Map();
  476. const inStack = new Set();
  477. function assignLayer(id, depth) {
  478. if (inStack.has(id)) return;
  479. inStack.add(id);
  480. layers.set(id, Math.max(layers.get(id) || 0, depth));
  481. for (const s of (succs.get(id) || [])) assignLayer(s, depth + 1);
  482. inStack.delete(id);
  483. }
  484. for (const r of roots) assignLayer(r, 0);
  485. for (const n of state.nodes) { if (!layers.has(n.id)) layers.set(n.id, 0); }
  486. // Group by layer
  487. const layerGroups = new Map();
  488. for (const [id, layer] of layers) {
  489. if (!layerGroups.has(layer)) layerGroups.set(layer, []);
  490. layerGroups.get(layer).push(id);
  491. }
  492. // Barycenter ordering (reduce edge crossings)
  493. const sortedLayers = [...layerGroups.keys()].sort((a, b) => a - b);
  494. for (let pass = 0; pass < 4; pass++) {
  495. for (let li = 1; li < sortedLayers.length; li++) {
  496. const group = layerGroups.get(sortedLayers[li]);
  497. const prevGroup = layerGroups.get(sortedLayers[li - 1]);
  498. const prevIdx = new Map(prevGroup.map((id, i) => [id, i]));
  499. group.sort((a, b) => {
  500. const aParents = preds.get(a)?.filter(p => prevIdx.has(p)) || [];
  501. const bParents = preds.get(b)?.filter(p => prevIdx.has(p)) || [];
  502. const aAvg = aParents.length ? aParents.reduce((s, p) => s + prevIdx.get(p), 0) / aParents.length : 0;
  503. const bAvg = bParents.length ? bParents.reduce((s, p) => s + prevIdx.get(p), 0) / bParents.length : 0;
  504. return aAvg - bAvg;
  505. });
  506. }
  507. }
  508. // Position nodes
  509. for (const layer of sortedLayers) {
  510. const group = layerGroups.get(layer);
  511. const totalHeight = group.length * NODE_GAP;
  512. const startY = Math.max(START_Y, 400 - totalHeight / 2);
  513. for (let i = 0; i < group.length; i++) {
  514. const node = nodeMap.get(group[i]);
  515. if (node) {
  516. node.x = START_X + layer * LAYER_GAP;
  517. node.y = startY + i * NODE_GAP;
  518. }
  519. }
  520. }
  521. }
  522. // ===== Compute I/O audit for a node =====
  523. function computeIO(step) {
  524. const io = { varsIn: [], varsOut: [], docs: [], files: [] };
  525. if (!step) return io;
  526. const data = step.in || step;
  527. // Input vars (references starting with = or $)
  528. const jsonStr = JSON.stringify(data);
  529. const varRefs = jsonStr.match(/=\$?[\w.]+|"\$[\w.]+"/g) || [];
  530. for (const ref of varRefs) {
  531. const clean = ref.replace(/[="]/g, '');
  532. if (clean.startsWith('$') && !io.varsIn.includes(clean)) io.varsIn.push(clean);
  533. }
  534. // Docs
  535. if (step.in?.docs) io.docs = step.in.docs;
  536. // Output vars
  537. if (step.out) {
  538. for (const [key, val] of Object.entries(step.out)) {
  539. if (key.startsWith('$')) io.varsOut.push(key);
  540. else if (key.startsWith('/')) io.files.push(key);
  541. }
  542. }
  543. // Source (loop / download / unzip)
  544. if (step.source) {
  545. const src = String(step.source);
  546. if (src.startsWith('=$') || src.startsWith('$')) io.varsIn.push(src.replace(/^=/, ''));
  547. }
  548. // FIX #4: while expression can reference variables
  549. if (step.while) {
  550. const whileStr = String(step.while);
  551. const whileRefs = whileStr.match(/\$[\w.]+/g) || [];
  552. for (const ref of whileRefs) {
  553. if (!io.varsIn.includes(ref)) io.varsIn.push(ref);
  554. }
  555. }
  556. return io;
  557. }
  558. // ===== Type-Specific Body Renderers =====
  559. function renderLLMBody(data) {
  560. let html = '';
  561. const docs = data.in?.docs?.length ? data.in.docs : null;
  562. if (docs) {
  563. html += `<div class="node-section"><div class="node-section-title">Documents</div>
  564. <div class="node-docs-list">${docs.map(d =>
  565. `<span class="node-doc-tag" title="${esc(resolveDocName(d))}">${esc(d)}: ${trunc(resolveDocName(d), 16)}</span>`
  566. ).join('')}</div></div>`;
  567. }
  568. if (data.in?.model || data.model) {
  569. html += `<div class="node-field"><span class="node-label">model</span><span class="node-value">${trunc(data.in?.model || data.model, 20)}</span></div>`;
  570. }
  571. if (data.in?.max_tokens) {
  572. html += `<div class="node-field"><span class="node-label">tokens</span><span class="node-value">${data.in.max_tokens}</span></div>`;
  573. }
  574. if (data.in?.messages?.length) {
  575. html += `<div class="node-field"><span class="node-label">msgs</span><span class="node-value">${data.in.messages.length} messages</span></div>`;
  576. }
  577. html += renderOutSection(data.out);
  578. return html;
  579. }
  580. function renderServiceBody(data) {
  581. let html = '';
  582. if (data.serviceId) {
  583. html += `<div class="node-field"><span class="node-label">service</span><span class="node-value ref">${trunc(data.serviceId, 20)}</span></div>`;
  584. }
  585. html += renderInputSection(data.in);
  586. html += renderOutSection(data.out);
  587. return html;
  588. }
  589. function renderAPIBody(data) {
  590. let html = '';
  591. const apiId = data.apiId || '';
  592. const apis = state.registry?.apis || [];
  593. const apiDef = apis.find(a => a.id === apiId || a === apiId);
  594. if (apiDef && typeof apiDef === 'object') {
  595. html += `<div class="node-field"><span class="node-label">${esc(apiDef.method || 'POST')}</span><span class="node-value ref" title="${esc(apiDef.url || '')}">${trunc(apiDef.desc || apiDef.url || apiId, 20)}</span></div>`;
  596. } else {
  597. html += `<div class="node-field"><span class="node-label">api</span><span class="node-value ref">${trunc(apiId || data.id || '', 20)}</span></div>`;
  598. }
  599. html += renderInputSection(data.in);
  600. html += renderOutSection(data.out);
  601. return html;
  602. }
  603. function renderComponentBody(data) {
  604. let html = '';
  605. if (data.componentId) {
  606. html += `<div class="node-field"><span class="node-label">comp</span><span class="node-value ref">${trunc(data.componentId, 20)}</span></div>`;
  607. }
  608. html += renderInputSection(data.in);
  609. html += renderOutSection(data.out);
  610. return html;
  611. }
  612. function renderSetBody(data) {
  613. return `<div class="node-section"><div class="node-section-title">Assignment</div>
  614. <div class="node-io-item"><span class="node-io-key is-var">${esc(data.target || '$var')}</span>
  615. <span class="node-io-arrow">\u2190</span>
  616. <span class="node-io-value">${trunc(String(data.value || ''), 18)}</span></div></div>`;
  617. }
  618. function renderWriteBody(data) {
  619. const modeStr = data.mode ? ` [${esc(data.mode)}]` : '';
  620. return `<div class="node-section"><div class="node-section-title">Write Artifact${modeStr}</div>
  621. <div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target || '', 20)}</span></div>
  622. <div class="node-field"><span class="node-label">value</span><span class="node-value var">${trunc(String(data.value || ''), 18)}</span></div></div>`;
  623. }
  624. function renderBranchBody(data) {
  625. const cases = Array.isArray(data.branches) ? data.branches
  626. : data.branches && typeof data.branches === 'object' ? Object.entries(data.branches)
  627. : data.cases ? Object.entries(data.cases) : [];
  628. if (cases.length === 0) return '';
  629. let html = '<div class="node-section"><div class="node-section-title">Cases</div>';
  630. cases.slice(0, 4).forEach(([cond, target]) => {
  631. html += `<div class="node-io-item"><span class="node-io-key">${trunc(cond, 14)}</span>
  632. <span class="node-io-arrow">\u2192</span>
  633. <span class="node-io-value">${trunc(target || '?', 12)}</span></div>`;
  634. });
  635. if (cases.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${cases.length - 4} more</div>`;
  636. html += '</div>';
  637. return html;
  638. }
  639. // FIX #2: Loop body now handles both source mode and while mode (Spec 3.16)
  640. function renderLoopBody(data) {
  641. const isWhile = !!data.while;
  642. let html = '<div class="node-section"><div class="node-section-title">Loop Config</div>';
  643. if (isWhile) {
  644. // While mode (3.16)
  645. html += `<div class="node-field"><span class="node-label">while</span><span class="node-value var">${trunc(data.while, 20)}</span></div>`;
  646. if (data.maxIterations != null) {
  647. html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
  648. }
  649. } else {
  650. // Source mode (original)
  651. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source || '', 16)}</span></div>`;
  652. if (data.maxIterations != null) {
  653. html += `<div class="node-field"><span class="node-label">max</span><span class="node-value">${data.maxIterations}</span></div>`;
  654. }
  655. }
  656. html += `<div class="node-field"><span class="node-label">mode</span><span class="node-value">${esc(data.mode || 'parallel')}</span></div>`;
  657. html += '</div>';
  658. return html;
  659. }
  660. function renderStopBody() {
  661. return '<div class="node-section"><div class="node-field"><span class="node-value" style="color:var(--red);">Workflow terminates here</span></div></div>';
  662. }
  663. function renderPauseBody(data) {
  664. const msg = data.in?.message || data.message || data.reason || '';
  665. const displayKeys = data.in?.display ? Object.keys(data.in.display) : [];
  666. let html = '<div class="node-section"><div class="node-section-title">\u23F8 Pause \u2014 Waiting for Approval</div>';
  667. if (msg) html += `<div class="node-field"><span class="node-label">msg</span><span class="node-value">${trunc(msg, 24)}</span></div>`;
  668. if (displayKeys.length > 0) html += `<div class="node-field"><span class="node-label">show</span><span class="node-value">${trunc(displayKeys.join(', '), 24)}</span></div>`;
  669. html += '</div>';
  670. html += renderOutSection(data.out);
  671. return html;
  672. }
  673. function renderForkBody(data) {
  674. const children = data.children || [];
  675. return `<div class="node-section"><div class="node-section-title">Fork Config</div>
  676. ${data.source ? `<div class="node-field"><span class="node-label">source</span><span class="node-value var">${trunc(data.source, 16)}</span></div>` : ''}
  677. <div class="node-field"><span class="node-label">branches</span><span class="node-value">${children.length} parallel</span></div></div>`;
  678. }
  679. // FIX #3: New renderers for Download and Unzip (Spec 3.16)
  680. function renderDownloadBody(data) {
  681. let html = '<div class="node-section"><div class="node-section-title">Download</div>';
  682. const src = data.source || '';
  683. const srcStr = typeof src === 'object' ? (src.url || JSON.stringify(src)) : String(src);
  684. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value ref">${trunc(srcStr, 20)}</span></div>`;
  685. if (data.target) {
  686. html += `<div class="node-field"><span class="node-label">target</span><span class="node-value file">${trunc(data.target, 20)}</span></div>`;
  687. }
  688. if (data.routeByExt) {
  689. const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
  690. html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  691. }
  692. if (data.defaultDir) {
  693. html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
  694. }
  695. html += '</div>';
  696. html += renderOutSection(data.out);
  697. return html;
  698. }
  699. function renderUnzipBody(data) {
  700. let html = '<div class="node-section"><div class="node-section-title">Unzip</div>';
  701. if (data.source) {
  702. html += `<div class="node-field"><span class="node-label">source</span><span class="node-value file">${trunc(String(data.source), 20)}</span></div>`;
  703. }
  704. if (data.routeByExt) {
  705. const extCount = typeof data.routeByExt === 'object' ? Object.keys(data.routeByExt).length : '?';
  706. html += `<div class="node-field"><span class="node-label">route</span><span class="node-value">${extCount} ext rules</span></div>`;
  707. }
  708. if (data.defaultDir) {
  709. html += `<div class="node-field"><span class="node-label">default</span><span class="node-value file">${trunc(data.defaultDir, 18)}</span></div>`;
  710. }
  711. if (data.overwrite != null) {
  712. html += `<div class="node-field"><span class="node-label">overwrite</span><span class="node-value">${data.overwrite}</span></div>`;
  713. }
  714. html += '</div>';
  715. html += renderOutSection(data.out);
  716. return html;
  717. }
  718. // --- Shared sub-renderers ---
  719. function renderInputSection(inData) {
  720. if (!inData || typeof inData !== 'object') return '';
  721. const skip = new Set(['model', 'stream', 'messages', 'docs', 'max_tokens', 'output_config']);
  722. const entries = Object.entries(inData).filter(([k]) => !skip.has(k));
  723. if (entries.length === 0) return '';
  724. let html = '<div class="node-section"><div class="node-section-title">Input</div><div class="node-io-list">';
  725. entries.slice(0, 4).forEach(([key, val]) => {
  726. const valStr = typeof val === 'object' ? JSON.stringify(val) : String(val);
  727. html += `<div class="node-io-item"><span class="node-io-key">${trunc(key, 10)}</span>
  728. <span class="node-io-arrow">\u2190</span>
  729. <span class="node-io-value">${trunc(valStr, 14)}</span></div>`;
  730. });
  731. if (entries.length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${entries.length - 4} more</div>`;
  732. html += '</div></div>';
  733. return html;
  734. }
  735. function renderOutSection(out) {
  736. if (!out || typeof out !== 'object' || Array.isArray(out) || Object.keys(out).length === 0) return '';
  737. let html = '<div class="node-section"><div class="node-section-title">Output</div><div class="node-io-list">';
  738. Object.entries(out).slice(0, 4).forEach(([key, val]) => {
  739. const isVar = key.startsWith('$');
  740. const isFile = key.startsWith('/') || key.startsWith('{');
  741. const keyClass = isVar ? 'is-var' : isFile ? 'is-file' : '';
  742. html += `<div class="node-io-item"><span class="node-io-key ${keyClass}">${trunc(key, 16)}</span>
  743. <span class="node-io-arrow">\u2190</span>
  744. <span class="node-io-value">${trunc(String(val), 14)}</span></div>`;
  745. });
  746. if (Object.keys(out).length > 4) html += `<div class="node-io-item" style="color:var(--text2)">... +${Object.keys(out).length - 4} more</div>`;
  747. html += '</div></div>';
  748. return html;
  749. }
  750. // ===== Render =====
  751. function render() {
  752. $('emptyMsg').style.display = 'none';
  753. $('canvas').style.display = 'block';
  754. $('legend').style.display = 'flex';
  755. $('minimap').style.display = 'block';
  756. renderNodes();
  757. renderConnections();
  758. setTimeout(updateMinimap, 100);
  759. }
  760. function renderNodes() {
  761. const layer = $('nodesLayer');
  762. layer.innerHTML = '';
  763. for (const node of state.nodes) {
  764. const div = document.createElement('div');
  765. const type = node.type || 'LLM';
  766. div.className = `node type-${type}` + (node.id === state.selectedNodeId ? ' selected' : '') + (node.status ? ` status-${node.status}` : '');
  767. div.id = `node-${node.id}`;
  768. div.style.left = node.x + 'px';
  769. div.style.top = node.y + 'px';
  770. const icon = NODE_ICONS[type] || '?';
  771. const title = node.data?.meta?.title || node.id;
  772. const desc = node.data?.meta?.description || '';
  773. const data = node.data || {};
  774. // Body: type-specific rendering
  775. let bodyHtml = '';
  776. switch (type) {
  777. case 'LLM': bodyHtml = renderLLMBody(data); break;
  778. case 'Service': bodyHtml = renderServiceBody(data); break;
  779. case 'API': bodyHtml = renderAPIBody(data); break;
  780. case 'Component': bodyHtml = renderComponentBody(data); break;
  781. case 'Set': bodyHtml = renderSetBody(data); break;
  782. case 'Write': bodyHtml = renderWriteBody(data); break;
  783. case 'Branch': bodyHtml = renderBranchBody(data); break;
  784. case 'Loop': bodyHtml = renderLoopBody(data); break;
  785. case 'Stop': bodyHtml = renderStopBody(); break;
  786. case 'Pause': bodyHtml = renderPauseBody(data); break;
  787. case 'Fork': bodyHtml = renderForkBody(data); break;
  788. case 'Download': bodyHtml = renderDownloadBody(data); break;
  789. case 'Unzip': bodyHtml = renderUnzipBody(data); break;
  790. default:
  791. if (data.in) {
  792. const keys = Object.keys(data.in).slice(0, 3);
  793. bodyHtml = keys.map(k => `<div class="field">${esc(k)}: ${esc(String(data.in[k]).substring(0, 35))}</div>`).join('');
  794. }
  795. }
  796. // Condition badge
  797. const conditionHtml = data.if ? `<div class="node-condition">if: ${trunc(data.if, 28)}</div>` : '';
  798. // BREAK indicator for nodes inside Loop children
  799. const breakHtml = data.next === 'BREAK' ? `<div class="node-condition" style="background:rgba(248,81,73,0.15);color:var(--red);">next: BREAK</div>` : '';
  800. // I/O audit badges
  801. const io = computeIO(data);
  802. let ioHtml = '';
  803. const badges = [];
  804. for (const v of io.varsIn.slice(0, 3)) badges.push(`<span class="io-badge var-in">\u2193${esc(v)}</span>`);
  805. for (const v of io.varsOut.slice(0, 3)) badges.push(`<span class="io-badge var-out">\u2191${esc(v)}</span>`);
  806. for (const d of io.docs.slice(0, 2)) badges.push(`<span class="io-badge doc">\u{1F4C4}${esc(d)}</span>`);
  807. for (const f of io.files.slice(0, 2)) badges.push(`<span class="io-badge file">\u{1F4C1}${esc(f.substring(0, 20))}</span>`);
  808. if (badges.length) ioHtml = `<div class="node-io">${badges.join('')}</div>`;
  809. // Status badge
  810. let badgeHtml = '';
  811. if (node.status === 'running') badgeHtml = '<div class="status-badge running">&#9881;</div>';
  812. else if (node.status === 'done') badgeHtml = '<div class="status-badge done">&#10003;</div>';
  813. else if (node.status === 'error') badgeHtml = '<div class="status-badge error">&#10007;</div>';
  814. else if (node.status === 'paused') badgeHtml = '<div class="status-badge paused">&#9208;</div>';
  815. else if (node.status === 'skipped') badgeHtml = '<div class="status-badge skipped">&#8213;</div>';
  816. // Footer
  817. const footerHtml = data.children?.length ? `<div class="node-footer">\u2935 ${data.children.length} parallel children</div>` : '';
  818. div.innerHTML = `
  819. ${badgeHtml}
  820. <div class="port port-in"></div>
  821. <div class="node-header">
  822. <div class="node-icon">${icon}</div>
  823. <div style="overflow:hidden;">
  824. <div class="node-title">${esc(title)}</div>
  825. <div class="node-type">${esc(type)}</div>
  826. ${desc ? `<div class="node-desc">${esc(desc)}</div>` : ''}
  827. </div>
  828. </div>
  829. ${bodyHtml ? `<div class="node-body">${bodyHtml}${conditionHtml}${breakHtml}</div>` : ''}
  830. ${ioHtml}
  831. ${footerHtml}
  832. <div class="port port-out"></div>
  833. `;
  834. // Drag + click handler (mousedown starts drag, stopDrag distinguishes click vs drag)
  835. div.addEventListener('mousedown', (e) => {
  836. if (e.button !== 0) return;
  837. startDrag(e, node);
  838. });
  839. // Right-click → context menu
  840. div.addEventListener('contextmenu', (e) => {
  841. showContextMenu(e, node);
  842. });
  843. layer.appendChild(div);
  844. }
  845. }
  846. function renderConnections() {
  847. const svg = $('connSvg');
  848. svg.querySelectorAll('.conn-group').forEach(g => g.remove());
  849. for (const conn of state.connections) {
  850. const fromEl = $(`node-${conn.from}`);
  851. const toEl = $(`node-${conn.to}`);
  852. if (!fromEl || !toEl) continue;
  853. const x1 = fromEl.offsetLeft + fromEl.offsetWidth / 2;
  854. const y1 = fromEl.offsetTop + fromEl.offsetHeight + 4;
  855. const x2 = toEl.offsetLeft + toEl.offsetWidth / 2;
  856. const y2 = toEl.offsetTop - 4;
  857. // Use straight lines for horizontal, bezier for vertical
  858. const dx = Math.abs(x2 - x1);
  859. const dy = y2 - y1;
  860. let d;
  861. if (dy > 0) {
  862. const cp = Math.max(40, Math.abs(dy) * 0.4);
  863. d = `M ${x1} ${y1} C ${x1} ${y1 + cp}, ${x2} ${y2 - cp}, ${x2} ${y2}`;
  864. } else {
  865. // Back-edge or same level: use wider bezier
  866. const cpX = Math.max(80, dx * 0.3);
  867. d = `M ${x1} ${y1} C ${x1 + cpX} ${y1 + 80}, ${x2 - cpX} ${y2 - 80}, ${x2} ${y2}`;
  868. }
  869. const markerMap = { serial: 'arrowSerial', parallel: 'arrowParallel', 'branch-case': 'arrowBranch' };
  870. const marker = markerMap[conn.type] || 'arrowSerial';
  871. const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
  872. g.setAttribute('class', 'conn-group');
  873. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  874. path.setAttribute('class', `conn-path ${conn.type}`);
  875. path.setAttribute('d', d);
  876. path.setAttribute('marker-end', `url(#${marker})`);
  877. g.appendChild(path);
  878. if (conn.label) {
  879. const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  880. text.setAttribute('class', 'conn-label');
  881. text.setAttribute('x', (x1 + x2) / 2);
  882. text.setAttribute('y', Math.min(y1, y2) + Math.abs(dy) / 2 - 5);
  883. text.setAttribute('text-anchor', 'middle');
  884. text.textContent = conn.label.length > 30 ? conn.label.substring(0, 30) + '...' : conn.label;
  885. g.appendChild(text);
  886. }
  887. svg.appendChild(g);
  888. }
  889. }
  890. // ===== Node Drag =====
  891. function initDrag() {
  892. const canvas = $('canvas');
  893. canvas.addEventListener('mousemove', onDrag);
  894. canvas.addEventListener('mouseup', stopDrag);
  895. canvas.addEventListener('mouseleave', stopDrag);
  896. }
  897. function startDrag(e, node) {
  898. e.preventDefault();
  899. state.dragging = { node, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y };
  900. const el = $(`node-${node.id}`);
  901. if (el) el.classList.add('dragging');
  902. }
  903. function onDrag(e) {
  904. if (!state.dragging) return;
  905. const { node, startX, startY, origX, origY } = state.dragging;
  906. node.x = Math.max(0, origX + (e.clientX - startX));
  907. node.y = Math.max(0, origY + (e.clientY - startY));
  908. const el = $(`node-${node.id}`);
  909. if (el) {
  910. el.style.left = node.x + 'px';
  911. el.style.top = node.y + 'px';
  912. }
  913. if (!state._dragRAF) {
  914. state._dragRAF = requestAnimationFrame(() => {
  915. renderConnections();
  916. state._dragRAF = null;
  917. });
  918. }
  919. }
  920. function stopDrag() {
  921. if (!state.dragging) return;
  922. const { node, origX, origY } = state.dragging;
  923. const dx = Math.abs(node.x - origX);
  924. const dy = Math.abs(node.y - origY);
  925. const el = $(`node-${node.id}`);
  926. if (el) el.classList.remove('dragging');
  927. if (dx < 3 && dy < 3) {
  928. // Treat as click — select node
  929. state.selectedNodeId = node.id;
  930. renderNodes();
  931. renderConnections();
  932. window.parent.postMessage({ type: 'nodeClick', nodeId: node.id, nodeType: node.type, nodeData: node.data }, '*');
  933. } else {
  934. // Dragged — update minimap
  935. updateMinimap();
  936. }
  937. state.dragging = null;
  938. }
  939. // ===== Minimap =====
  940. function updateMinimap() {
  941. const mc = $('minimapCanvas');
  942. if (!mc || state.nodes.length === 0) return;
  943. const ctx = mc.getContext('2d');
  944. const W = mc.width = mc.offsetWidth * 2;
  945. const H = mc.height = mc.offsetHeight * 2;
  946. ctx.clearRect(0, 0, W, H);
  947. // Find bounds
  948. let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
  949. for (const n of state.nodes) {
  950. minX = Math.min(minX, n.x);
  951. minY = Math.min(minY, n.y);
  952. maxX = Math.max(maxX, n.x + 240);
  953. maxY = Math.max(maxY, n.y + 120);
  954. }
  955. const pad = 40;
  956. const scaleX = W / (maxX - minX + pad * 2);
  957. const scaleY = H / (maxY - minY + pad * 2);
  958. const scale = Math.min(scaleX, scaleY);
  959. // Draw connections
  960. ctx.strokeStyle = '#2a3140';
  961. ctx.lineWidth = 1;
  962. for (const c of state.connections) {
  963. const from = state.nodes.find(n => n.id === c.from);
  964. const to = state.nodes.find(n => n.id === c.to);
  965. if (!from || !to) continue;
  966. ctx.beginPath();
  967. ctx.moveTo((from.x + 120 - minX + pad) * scale, (from.y + 60 - minY + pad) * scale);
  968. ctx.lineTo((to.x + 120 - minX + pad) * scale, (to.y + 30 - minY + pad) * scale);
  969. ctx.stroke();
  970. }
  971. // Draw nodes
  972. const mmColors = {
  973. LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
  974. Set:'#58a6ff', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
  975. Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981',
  976. Download:'#38bdf8', Unzip:'#facc15'
  977. };
  978. for (const n of state.nodes) {
  979. const x = (n.x - minX + pad) * scale;
  980. const y = (n.y - minY + pad) * scale;
  981. const w = 240 * scale;
  982. const h = 60 * scale;
  983. ctx.fillStyle = n.status === 'done' ? '#3fb950' : n.status === 'running' ? '#d29922' : n.status === 'error' ? '#f85149' : (mmColors[n.type] || '#1a1f27');
  984. ctx.globalAlpha = n.status ? 0.9 : 0.6;
  985. ctx.fillRect(x, y, Math.max(w, 3), Math.max(h, 2));
  986. }
  987. ctx.globalAlpha = 1;
  988. // Draw viewport rectangle
  989. const wrap = $('canvasWrap');
  990. const vx = (wrap.scrollLeft - minX + pad) * scale;
  991. const vy = (wrap.scrollTop - minY + pad) * scale;
  992. const vw = wrap.clientWidth * scale;
  993. const vh = wrap.clientHeight * scale;
  994. ctx.strokeStyle = '#58a6ff';
  995. ctx.lineWidth = 2;
  996. ctx.strokeRect(vx, vy, vw, vh);
  997. }
  998. // ===== PNG Export =====
  999. function downloadPNG() {
  1000. if (state.nodes.length === 0) return toast('No workflow to export');
  1001. const SCALE = 2;
  1002. const PAD = 60;
  1003. let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
  1004. for (const n of state.nodes) {
  1005. minX = Math.min(minX, n.x);
  1006. minY = Math.min(minY, n.y);
  1007. maxX = Math.max(maxX, n.x + 240);
  1008. maxY = Math.max(maxY, n.y + 140);
  1009. }
  1010. const cw = (maxX - minX + PAD * 2) * SCALE;
  1011. const ch = (maxY - minY + PAD * 2) * SCALE;
  1012. const canvas = document.createElement('canvas');
  1013. canvas.width = cw;
  1014. canvas.height = ch;
  1015. const ctx = canvas.getContext('2d');
  1016. ctx.scale(SCALE, SCALE);
  1017. const ox = -minX + PAD;
  1018. const oy = -minY + PAD;
  1019. // Background
  1020. ctx.fillStyle = '#0a0d12';
  1021. ctx.fillRect(0, 0, cw / SCALE, ch / SCALE);
  1022. // Grid dots
  1023. ctx.fillStyle = '#2a3140';
  1024. for (let gx = 0; gx < cw / SCALE; gx += 20) {
  1025. for (let gy = 0; gy < ch / SCALE; gy += 20) {
  1026. ctx.fillRect(gx, gy, 1, 1);
  1027. }
  1028. }
  1029. // Connections
  1030. for (const conn of state.connections) {
  1031. const from = state.nodes.find(n => n.id === conn.from);
  1032. const to = state.nodes.find(n => n.id === conn.to);
  1033. if (!from || !to) continue;
  1034. const x1 = from.x + 120 + ox, y1 = from.y + 100 + oy;
  1035. const x2 = to.x + 120 + ox, y2 = to.y + oy;
  1036. ctx.strokeStyle = conn.type === 'parallel' ? '#a371f7' : conn.type === 'branch-case' ? '#d29922' : '#58a6ff';
  1037. ctx.lineWidth = 2;
  1038. if (conn.type === 'parallel') ctx.setLineDash([6, 3]);
  1039. else if (conn.type === 'branch-case') ctx.setLineDash([4, 2, 1, 2]);
  1040. else ctx.setLineDash([]);
  1041. const cp = Math.max(40, Math.abs(y2 - y1) * 0.4);
  1042. ctx.beginPath();
  1043. ctx.moveTo(x1, y1);
  1044. ctx.bezierCurveTo(x1, y1 + cp, x2, y2 - cp, x2, y2);
  1045. ctx.stroke();
  1046. ctx.setLineDash([]);
  1047. // Arrow
  1048. ctx.fillStyle = ctx.strokeStyle;
  1049. ctx.beginPath();
  1050. ctx.moveTo(x2 - 4, y2 - 8);
  1051. ctx.lineTo(x2, y2);
  1052. ctx.lineTo(x2 + 4, y2 - 8);
  1053. ctx.fill();
  1054. }
  1055. // Nodes
  1056. const typeColors = {
  1057. LLM:'#6366f1', Service:'#3fb950', API:'#39c5cf', Write:'#d29922',
  1058. Set:'#58a6ff', Branch:'#a371f7', Loop:'#db61a2', Stop:'#f85149',
  1059. Component:'#2dd4bf', Pause:'#8b5cf6', Fork:'#10b981',
  1060. Download:'#38bdf8', Unzip:'#facc15'
  1061. };
  1062. for (const node of state.nodes) {
  1063. const nx = node.x + ox, ny = node.y + oy;
  1064. // Card
  1065. ctx.fillStyle = '#1a1f27';
  1066. ctx.strokeStyle = '#2a3140';
  1067. ctx.lineWidth = 1;
  1068. roundRect(ctx, nx, ny, 240, 80, 8);
  1069. ctx.fill();
  1070. ctx.stroke();
  1071. // Type accent bar
  1072. ctx.fillStyle = typeColors[node.type] || '#6366f1';
  1073. ctx.fillRect(nx, ny + 8, 3, 64);
  1074. // Icon
  1075. ctx.fillStyle = typeColors[node.type] || '#6366f1';
  1076. roundRect(ctx, nx + 10, ny + 10, 24, 24, 5);
  1077. ctx.fill();
  1078. ctx.fillStyle = '#fff';
  1079. ctx.font = 'bold 9px monospace';
  1080. ctx.textAlign = 'center';
  1081. ctx.fillText(NODE_ICONS[node.type] || '?', nx + 22, ny + 26);
  1082. // Title
  1083. ctx.fillStyle = '#e6edf3';
  1084. ctx.font = 'bold 11px monospace';
  1085. ctx.textAlign = 'left';
  1086. ctx.fillText((node.data?.meta?.title || node.id).substring(0, 28), nx + 42, ny + 24);
  1087. // Type
  1088. ctx.fillStyle = '#8b949e';
  1089. ctx.font = '9px monospace';
  1090. ctx.fillText(node.type, nx + 42, ny + 36);
  1091. // Status
  1092. if (node.status) {
  1093. const sc = { done:'#3fb950', running:'#d29922', error:'#f85149', paused:'#8b5cf6', skipped:'#8b949e' };
  1094. ctx.fillStyle = sc[node.status] || '#8b949e';
  1095. ctx.beginPath();
  1096. ctx.arc(nx + 230, ny + 4, 6, 0, Math.PI * 2);
  1097. ctx.fill();
  1098. }
  1099. }
  1100. // Title
  1101. ctx.fillStyle = '#e6edf3';
  1102. ctx.font = 'bold 14px monospace';
  1103. ctx.textAlign = 'left';
  1104. ctx.fillText(_currentWorkflowJson?.name || 'VL Workflow', PAD, 20);
  1105. ctx.fillStyle = '#8b949e';
  1106. ctx.font = '10px monospace';
  1107. ctx.fillText(`${state.nodes.length} nodes \u00b7 VL Workflow Spec ${_currentWorkflowJson?.version || '3.16'}`, PAD, 35);
  1108. // Download
  1109. const link = document.createElement('a');
  1110. link.download = `workflow-${(_currentWorkflowJson?.name || 'dag').replace(/\s+/g, '-')}-${Date.now()}.png`;
  1111. link.href = canvas.toDataURL('image/png');
  1112. link.click();
  1113. toast('PNG exported');
  1114. }
  1115. function roundRect(ctx, x, y, w, h, r) {
  1116. ctx.beginPath();
  1117. ctx.moveTo(x + r, y);
  1118. ctx.lineTo(x + w - r, y);
  1119. ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  1120. ctx.lineTo(x + w, y + h - r);
  1121. ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  1122. ctx.lineTo(x + r, y + h);
  1123. ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  1124. ctx.lineTo(x, y + r);
  1125. ctx.quadraticCurveTo(x, y, x + r, y);
  1126. ctx.closePath();
  1127. }
  1128. // ===== JSON Export / Import =====
  1129. function exportJSON() {
  1130. if (!_currentWorkflowJson) return toast('No workflow to export');
  1131. const blob = new Blob([JSON.stringify(_currentWorkflowJson, null, 2)], { type: 'application/json' });
  1132. const link = document.createElement('a');
  1133. link.download = `${(_currentWorkflowJson.name || 'workflow').replace(/\s+/g, '-')}-${Date.now()}.json`;
  1134. link.href = URL.createObjectURL(blob);
  1135. link.click();
  1136. toast('JSON exported');
  1137. }
  1138. function importJSON() {
  1139. $('importInput').click();
  1140. }
  1141. $('importInput')?.addEventListener('change', async (e) => {
  1142. const file = e.target.files[0];
  1143. if (!file) return;
  1144. try {
  1145. const text = await file.text();
  1146. const json = JSON.parse(text);
  1147. if (!json.steps?.length) throw new Error('Invalid workflow: missing steps');
  1148. parseWorkflow(json);
  1149. toast(`Loaded: ${json.name || file.name}`);
  1150. window.parent.postMessage({ type: 'workflowImported', workflow: json }, '*');
  1151. } catch (err) {
  1152. toast('Import failed: ' + err.message);
  1153. }
  1154. e.target.value = '';
  1155. });
  1156. // ===== Run Workflow (SSE) =====
  1157. async function runWorkflow() {
  1158. if (!_currentWorkflowJson || _executing) return;
  1159. _executing = true;
  1160. $('runBtn').style.display = 'none';
  1161. $('pauseBtn').style.display = '';
  1162. $('stopBtn').style.display = '';
  1163. $('statusLabel').textContent = 'Executing...';
  1164. // Clear statuses
  1165. for (const n of state.nodes) n.status = null;
  1166. renderNodes();
  1167. try {
  1168. const res = await fetch('/api/workflow/execute', {
  1169. method: 'POST',
  1170. headers: { 'Content-Type': 'application/json' },
  1171. body: JSON.stringify({ workflowName: _currentWorkflowJson.name || 'ephemeral', params: {} })
  1172. });
  1173. await streamSSE(res);
  1174. } catch (err) {
  1175. toast('Execution error: ' + err.message);
  1176. }
  1177. _executing = false;
  1178. $('runBtn').style.display = '';
  1179. $('pauseBtn').style.display = 'none';
  1180. $('stopBtn').style.display = 'none';
  1181. $('statusLabel').textContent = 'Done';
  1182. updateMinimap();
  1183. // Try to fetch final checkpoint
  1184. fetchCheckpoint();
  1185. }
  1186. function handleExecEvent(evt) {
  1187. // Map engine event types to UI (support both legacy and v0.3+ naming)
  1188. const nodeId = evt.nodeId || evt.stepID;
  1189. const evtType = evt.type;
  1190. if (evtType === 'node_start' || evtType === 'step_start') {
  1191. setNodeStatus(nodeId, 'running');
  1192. $('statusLabel').textContent = `Running: ${evt.title || evt.payload?.meta?.title || nodeId}`;
  1193. } else if (evtType === 'node_done' || evtType === 'step_done') {
  1194. setNodeStatus(nodeId, 'done');
  1195. } else if (evtType === 'node_error' || evtType === 'step_error') {
  1196. setNodeStatus(nodeId, 'error');
  1197. } else if (evtType === 'node_skipped' || evtType === 'step_skipped') {
  1198. setNodeStatus(nodeId, 'skipped');
  1199. } else if (evtType === 'pause' || evtType === 'pause_start') {
  1200. setNodeStatus(nodeId, 'paused');
  1201. $('statusLabel').textContent = `Paused: ${evt.title || nodeId}`;
  1202. } else if (evtType === 'resumed' || evtType === 'pause_resumed') {
  1203. setNodeStatus(nodeId, 'running');
  1204. } else if (evtType === 'workflow_paused') {
  1205. // User-initiated graceful pause — engine stopped after current step
  1206. const pausedAt = evt.payload?.pausedAt || nodeId;
  1207. $('statusLabel').textContent = `Paused after ${pausedAt} — right-click a node to edit & re-run`;
  1208. _executing = false;
  1209. $('runBtn').style.display = '';
  1210. $('pauseBtn').style.display = 'none';
  1211. $('stopBtn').style.display = 'none';
  1212. fetchCheckpoint();
  1213. } else if (evtType === 'done' || evtType === 'workflow_done') {
  1214. $('statusLabel').textContent = `Complete! ${evt.filesWritten?.length || evt.payload?.filesWritten?.length || 0} files written`;
  1215. } else if (evtType === 'error' || evtType === 'workflow_failed') {
  1216. $('statusLabel').textContent = 'Error: ' + (evt.error || evt.payload?.error || 'unknown');
  1217. }
  1218. // Capture checkpoint if provided
  1219. if (evt.checkpoint) {
  1220. _lastCheckpoint = evt.checkpoint;
  1221. saveCheckpointToStorage();
  1222. }
  1223. // Capture runID
  1224. if (evt.runID || evt.payload?.runID) {
  1225. _currentRunID = evt.runID || evt.payload?.runID;
  1226. }
  1227. }
  1228. function setNodeStatus(nodeId, status) {
  1229. const node = state.nodes.find(n => n.id === nodeId);
  1230. if (node) {
  1231. node.status = status;
  1232. // Avoid full re-render during drag
  1233. if (state.dragging) return;
  1234. renderNodes();
  1235. renderConnections();
  1236. updateMinimap();
  1237. // Scroll to node
  1238. const el = $(`node-${nodeId}`);
  1239. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  1240. }
  1241. }
  1242. function stopExecution() {
  1243. if (_eventSource) { _eventSource.close(); _eventSource = null; }
  1244. // Send abort to server
  1245. if (_currentRunID) {
  1246. fetch(`/api/workflow/${_currentRunID}/abort`, { method: 'POST' }).catch(() => {});
  1247. }
  1248. _executing = false;
  1249. $('runBtn').style.display = '';
  1250. $('pauseBtn').style.display = 'none';
  1251. $('stopBtn').style.display = 'none';
  1252. $('statusLabel').textContent = 'Stopped';
  1253. fetchCheckpoint();
  1254. }
  1255. async function pauseExecution() {
  1256. if (!_currentRunID) return toast('No active run');
  1257. $('statusLabel').textContent = 'Pausing...';
  1258. try {
  1259. await fetch(`/api/workflow/${_currentRunID}/pause`, { method: 'POST' });
  1260. $('statusLabel').textContent = 'Paused (can resume from any node)';
  1261. } catch (err) {
  1262. // Fallback: try cancel endpoint
  1263. try {
  1264. await fetch(`/api/workflow/${_currentRunID}/cancel`, { method: 'POST' });
  1265. $('statusLabel').textContent = 'Paused';
  1266. } catch { toast('Pause failed'); }
  1267. }
  1268. _executing = false;
  1269. $('runBtn').style.display = '';
  1270. $('pauseBtn').style.display = 'none';
  1271. $('stopBtn').style.display = 'none';
  1272. fetchCheckpoint();
  1273. }
  1274. // ===== Checkpoint Persistence =====
  1275. function saveCheckpointToStorage() {
  1276. if (!_lastCheckpoint || !_currentWorkflowJson?.name) return;
  1277. try {
  1278. const key = `wf_cp_${_currentWorkflowJson.name}`;
  1279. const data = {
  1280. checkpoint: _lastCheckpoint,
  1281. nodeStatuses: state.nodes.map(n => ({ id: n.id, status: n.status })),
  1282. runID: _currentRunID,
  1283. ts: Date.now()
  1284. };
  1285. localStorage.setItem(key, JSON.stringify(data));
  1286. } catch {}
  1287. }
  1288. function restoreFromStorage() {
  1289. if (!_currentWorkflowJson?.name) return false;
  1290. try {
  1291. const key = `wf_cp_${_currentWorkflowJson.name}`;
  1292. const raw = localStorage.getItem(key);
  1293. if (!raw) return false;
  1294. const data = JSON.parse(raw);
  1295. // Only restore if less than 24h old
  1296. if (Date.now() - data.ts > 86400000) { localStorage.removeItem(key); return false; }
  1297. _lastCheckpoint = data.checkpoint;
  1298. _currentRunID = data.runID;
  1299. // Restore node statuses
  1300. if (data.nodeStatuses) {
  1301. for (const ns of data.nodeStatuses) {
  1302. const node = state.nodes.find(n => n.id === ns.id);
  1303. if (node && ns.status) node.status = ns.status;
  1304. }
  1305. }
  1306. return true;
  1307. } catch { return false; }
  1308. }
  1309. async function fetchCheckpoint() {
  1310. if (!_currentRunID) return;
  1311. try {
  1312. const res = await fetch(`/api/workflow/${_currentRunID}/checkpoint`);
  1313. if (res.ok) {
  1314. _lastCheckpoint = await res.json();
  1315. saveCheckpointToStorage();
  1316. }
  1317. } catch {}
  1318. }
  1319. // ===== Re-run from Step =====
  1320. async function rerunFromStep(stepId, overrides = {}) {
  1321. if (!_currentWorkflowJson) return toast('No workflow loaded');
  1322. // Build checkpoint: use last checkpoint or create minimal one
  1323. const checkpoint = _lastCheckpoint ? { ..._lastCheckpoint, currentStepID: stepId }
  1324. : { currentStepID: stepId, params: {}, variables: {} };
  1325. // Apply overrides
  1326. if (Object.keys(overrides).length > 0 && checkpoint.variables) {
  1327. Object.assign(checkpoint.variables, overrides);
  1328. }
  1329. // Clear statuses for this node and all subsequent
  1330. const stepIdx = state.nodes.findIndex(n => n.id === stepId);
  1331. if (stepIdx >= 0) {
  1332. // Clear this node and downstream
  1333. const toClear = new Set();
  1334. function markDownstream(id) {
  1335. if (toClear.has(id)) return;
  1336. toClear.add(id);
  1337. for (const c of state.connections) {
  1338. if (c.from === id) markDownstream(c.to);
  1339. }
  1340. }
  1341. markDownstream(stepId);
  1342. for (const n of state.nodes) {
  1343. if (toClear.has(n.id)) n.status = null;
  1344. }
  1345. renderNodes();
  1346. renderConnections();
  1347. }
  1348. _executing = true;
  1349. $('runBtn').style.display = 'none';
  1350. $('pauseBtn').style.display = '';
  1351. $('stopBtn').style.display = '';
  1352. $('statusLabel').textContent = `Re-running from ${stepId}...`;
  1353. try {
  1354. const res = await fetch('/api/workflow/rerun', {
  1355. method: 'POST',
  1356. headers: { 'Content-Type': 'application/json' },
  1357. body: JSON.stringify({
  1358. workflowName: _currentWorkflowJson.name || 'ephemeral',
  1359. checkpoint,
  1360. stepID: stepId,
  1361. overrides
  1362. })
  1363. });
  1364. if (!res.ok) {
  1365. // Fallback: try execute with fromStep
  1366. const res2 = await fetch('/api/workflow/execute', {
  1367. method: 'POST',
  1368. headers: { 'Content-Type': 'application/json' },
  1369. body: JSON.stringify({
  1370. workflowName: _currentWorkflowJson.name,
  1371. params: {},
  1372. fromStep: stepId,
  1373. checkpoint,
  1374. overrides
  1375. })
  1376. });
  1377. if (!res2.ok) throw new Error('Rerun failed');
  1378. await streamSSE(res2);
  1379. } else {
  1380. await streamSSE(res);
  1381. }
  1382. } catch (err) {
  1383. toast('Re-run error: ' + err.message);
  1384. }
  1385. _executing = false;
  1386. $('runBtn').style.display = '';
  1387. $('pauseBtn').style.display = 'none';
  1388. $('stopBtn').style.display = 'none';
  1389. $('statusLabel').textContent = 'Done';
  1390. updateMinimap();
  1391. fetchCheckpoint();
  1392. }
  1393. async function streamSSE(res) {
  1394. const reader = res.body.getReader();
  1395. const decoder = new TextDecoder();
  1396. let buf = '';
  1397. while (true) {
  1398. const { done, value } = await reader.read();
  1399. if (done) break;
  1400. buf += decoder.decode(value, { stream: true });
  1401. let lines = buf.split('\n');
  1402. buf = lines.pop();
  1403. for (const line of lines) {
  1404. if (line.startsWith('data: ')) {
  1405. try { handleExecEvent(JSON.parse(line.slice(6))); } catch {}
  1406. }
  1407. }
  1408. }
  1409. }
  1410. // ===== Context Menu =====
  1411. function showContextMenu(e, node) {
  1412. e.preventDefault();
  1413. _ctxTargetNode = node;
  1414. const menu = $('ctxMenu');
  1415. $('ctxNodeIdHint').textContent = node.id;
  1416. // Enable/disable based on state
  1417. const hasCheckpoint = !!_lastCheckpoint;
  1418. const rerunItem = $('ctxRerun');
  1419. const editItem = $('ctxEditRerun');
  1420. if (hasCheckpoint || _currentWorkflowJson) {
  1421. rerunItem.classList.remove('disabled');
  1422. editItem.classList.remove('disabled');
  1423. } else {
  1424. rerunItem.classList.add('disabled');
  1425. editItem.classList.add('disabled');
  1426. }
  1427. // Position
  1428. menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
  1429. menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
  1430. menu.classList.add('show');
  1431. }
  1432. function hideContextMenu() {
  1433. $('ctxMenu').classList.remove('show');
  1434. _ctxTargetNode = null;
  1435. }
  1436. function ctxRerunFromHere() {
  1437. hideContextMenu();
  1438. if (!_ctxTargetNode) return;
  1439. rerunFromStep(_ctxTargetNode.id);
  1440. }
  1441. function ctxEditAndRerun() {
  1442. hideContextMenu();
  1443. if (!_ctxTargetNode) return;
  1444. openEditor(_ctxTargetNode);
  1445. }
  1446. function ctxViewDetails() {
  1447. hideContextMenu();
  1448. if (!_ctxTargetNode) return;
  1449. window.parent.postMessage({
  1450. type: 'nodeClick',
  1451. nodeId: _ctxTargetNode.id,
  1452. nodeType: _ctxTargetNode.type,
  1453. nodeData: _ctxTargetNode.data
  1454. }, '*');
  1455. }
  1456. function ctxCopyId() {
  1457. hideContextMenu();
  1458. if (!_ctxTargetNode) return;
  1459. navigator.clipboard?.writeText(_ctxTargetNode.id);
  1460. toast('Copied: ' + _ctxTargetNode.id);
  1461. }
  1462. // Close context menu on click elsewhere
  1463. document.addEventListener('click', (e) => {
  1464. if (!e.target.closest('.ctx-menu')) hideContextMenu();
  1465. });
  1466. // ===== Node Editor Modal =====
  1467. function openEditor(node) {
  1468. _editorNode = node;
  1469. const data = node.data || {};
  1470. const type = node.type;
  1471. $('editorTitle').textContent = `Edit: ${data.meta?.title || node.id} (${type})`;
  1472. let html = '';
  1473. // Status indicator
  1474. const statusText = node.status || 'pending';
  1475. html += `<div class="editor-status">
  1476. <div class="editor-status-dot ${statusText}"></div>
  1477. <span class="editor-status-text">Status: ${statusText} | Node: ${esc(node.id)}</span>
  1478. </div>`;
  1479. // Type-specific editor fields
  1480. if (type === 'LLM') {
  1481. const model = data.in?.model || data.model || '';
  1482. const msgs = data.in?.messages ? JSON.stringify(data.in.messages, null, 2) : '[]';
  1483. const maxTokens = data.in?.max_tokens || '';
  1484. html += `<div class="editor-field"><div class="editor-label">Model</div>
  1485. <input class="editor-input" id="edit_model" value="${esc(model)}" placeholder="e.g. anthropic/claude-opus-4-6"></div>`;
  1486. html += `<div class="editor-field"><div class="editor-label">Max Tokens</div>
  1487. <input class="editor-input" id="edit_max_tokens" value="${esc(String(maxTokens))}" type="number" placeholder="4096"></div>`;
  1488. html += `<div class="editor-field"><div class="editor-label">Messages (JSON)</div>
  1489. <textarea class="editor-json" id="edit_messages">${esc(msgs)}</textarea>
  1490. <div class="editor-error" id="edit_messages_err"></div></div>`;
  1491. } else if (type === 'Service' || type === 'API' || type === 'Component') {
  1492. const inData = data.in ? JSON.stringify(data.in, null, 2) : '{}';
  1493. html += `<div class="editor-field"><div class="editor-label">Input Parameters (JSON)</div>
  1494. <textarea class="editor-json" id="edit_in">${esc(inData)}</textarea>
  1495. <div class="editor-error" id="edit_in_err"></div></div>`;
  1496. } else if (type === 'Set') {
  1497. html += `<div class="editor-field"><div class="editor-label">Target Variable</div>
  1498. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  1499. html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
  1500. <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  1501. } else if (type === 'Write') {
  1502. html += `<div class="editor-field"><div class="editor-label">Target Path</div>
  1503. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  1504. html += `<div class="editor-field"><div class="editor-label">Value (expression)</div>
  1505. <input class="editor-input" id="edit_value" value="${esc(String(data.value || ''))}"></div>`;
  1506. } else if (type === 'Download') {
  1507. const src = typeof data.source === 'object' ? JSON.stringify(data.source, null, 2) : String(data.source || '');
  1508. html += `<div class="editor-field"><div class="editor-label">Source URL / Config</div>
  1509. <textarea class="editor-json" id="edit_source">${esc(src)}</textarea></div>`;
  1510. if (data.target != null) {
  1511. html += `<div class="editor-field"><div class="editor-label">Target Path</div>
  1512. <input class="editor-input" id="edit_target" value="${esc(data.target || '')}"></div>`;
  1513. }
  1514. } else if (type === 'Loop') {
  1515. if (data.while) {
  1516. html += `<div class="editor-field"><div class="editor-label">While Expression</div>
  1517. <input class="editor-input" id="edit_while" value="${esc(data.while)}"></div>`;
  1518. html += `<div class="editor-field"><div class="editor-label">Max Iterations</div>
  1519. <input class="editor-input" id="edit_maxIterations" value="${esc(String(data.maxIterations || ''))}" type="number"></div>`;
  1520. } else {
  1521. html += `<div class="editor-field"><div class="editor-label">Source Expression</div>
  1522. <input class="editor-input" id="edit_source" value="${esc(data.source || '')}"></div>`;
  1523. }
  1524. } else {
  1525. // Generic: show full step JSON
  1526. const stepJson = JSON.stringify(data, null, 2);
  1527. html += `<div class="editor-field"><div class="editor-label">Step Data (JSON)</div>
  1528. <textarea class="editor-json" id="edit_raw" style="min-height:200px">${esc(stepJson)}</textarea>
  1529. <div class="editor-error" id="edit_raw_err"></div></div>`;
  1530. }
  1531. // Variable overrides section
  1532. html += `<div class="editor-field" style="margin-top:12px; border-top:1px solid var(--border); padding-top:10px;">
  1533. <div class="editor-label">Variable Overrides (optional)</div>
  1534. <textarea class="editor-json" id="edit_overrides" placeholder='{ "$varName": "newValue" }'>{}</textarea>
  1535. <div class="editor-hint">Override pipeline variables before re-running. Use $varName keys.</div>
  1536. <div class="editor-error" id="edit_overrides_err"></div>
  1537. </div>`;
  1538. $('editorBody').innerHTML = html;
  1539. $('editorOverlay').classList.add('show');
  1540. }
  1541. function closeEditor() {
  1542. $('editorOverlay').classList.remove('show');
  1543. _editorNode = null;
  1544. }
  1545. function editorRerun() {
  1546. if (!_editorNode) return;
  1547. // Collect overrides
  1548. let overrides = {};
  1549. const overridesEl = $('edit_overrides');
  1550. if (overridesEl) {
  1551. try {
  1552. overrides = JSON.parse(overridesEl.value || '{}');
  1553. const errEl = $('edit_overrides_err');
  1554. if (errEl) errEl.style.display = 'none';
  1555. } catch (e) {
  1556. const errEl = $('edit_overrides_err');
  1557. if (errEl) { errEl.textContent = 'Invalid JSON: ' + e.message; errEl.style.display = 'block'; }
  1558. return;
  1559. }
  1560. }
  1561. // Collect edited step data and merge into overrides
  1562. const type = _editorNode.type;
  1563. const data = _editorNode.data || {};
  1564. if (type === 'LLM') {
  1565. const model = $('edit_model')?.value;
  1566. const maxTokens = $('edit_max_tokens')?.value;
  1567. const msgsEl = $('edit_messages');
  1568. if (msgsEl) {
  1569. try { JSON.parse(msgsEl.value); } catch (e) {
  1570. const err = $('edit_messages_err');
  1571. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  1572. return;
  1573. }
  1574. }
  1575. // Update step data in memory for visual accuracy
  1576. if (model && data.in) data.in.model = model;
  1577. if (data.model !== undefined && model) data.model = model;
  1578. if (maxTokens && data.in) data.in.max_tokens = parseInt(maxTokens);
  1579. if (msgsEl && data.in) { try { data.in.messages = JSON.parse(msgsEl.value); } catch {} }
  1580. } else if ((type === 'Service' || type === 'API' || type === 'Component') && $('edit_in')) {
  1581. try {
  1582. const newIn = JSON.parse($('edit_in').value);
  1583. data.in = newIn;
  1584. } catch (e) {
  1585. const err = $('edit_in_err');
  1586. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  1587. return;
  1588. }
  1589. } else if ((type === 'Set' || type === 'Write') && $('edit_target')) {
  1590. data.target = $('edit_target').value;
  1591. data.value = $('edit_value')?.value || data.value;
  1592. } else if (type === 'Download' && $('edit_source')) {
  1593. try {
  1594. data.source = JSON.parse($('edit_source').value);
  1595. } catch { data.source = $('edit_source').value; }
  1596. if ($('edit_target')) data.target = $('edit_target').value;
  1597. } else if (type === 'Loop') {
  1598. if ($('edit_while')) data.while = $('edit_while').value;
  1599. if ($('edit_maxIterations')) data.maxIterations = parseInt($('edit_maxIterations').value);
  1600. if ($('edit_source')) data.source = $('edit_source').value;
  1601. } else if ($('edit_raw')) {
  1602. try {
  1603. const raw = JSON.parse($('edit_raw').value);
  1604. Object.assign(data, raw);
  1605. } catch (e) {
  1606. const err = $('edit_raw_err');
  1607. if (err) { err.textContent = 'Invalid JSON: ' + e.message; err.style.display = 'block'; }
  1608. return;
  1609. }
  1610. }
  1611. closeEditor();
  1612. // Notify parent of edits
  1613. window.parent.postMessage({
  1614. type: 'nodeEdited',
  1615. nodeId: _editorNode.id,
  1616. nodeData: data,
  1617. overrides
  1618. }, '*');
  1619. // Re-run
  1620. rerunFromStep(_editorNode.id, overrides);
  1621. }
  1622. // ===== PostMessage API =====
  1623. window.addEventListener('message', (e) => {
  1624. if (!e.data?.type) return;
  1625. switch (e.data.type) {
  1626. case 'loadWorkflow':
  1627. parseWorkflow(e.data.data);
  1628. break;
  1629. case 'highlightNode':
  1630. state.selectedNodeId = e.data.nodeId;
  1631. renderNodes();
  1632. renderConnections();
  1633. const el = $(`node-${e.data.nodeId}`);
  1634. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  1635. break;
  1636. case 'updateNodeStatus': {
  1637. setNodeStatus(e.data.nodeId, e.data.status);
  1638. break;
  1639. }
  1640. case 'clearStatus':
  1641. for (const n of state.nodes) n.status = null;
  1642. _lastCheckpoint = null;
  1643. renderNodes();
  1644. renderConnections();
  1645. updateMinimap();
  1646. break;
  1647. case 'setCheckpoint':
  1648. _lastCheckpoint = e.data.checkpoint;
  1649. _currentRunID = e.data.runID || _currentRunID;
  1650. saveCheckpointToStorage();
  1651. break;
  1652. case 'rerunFromStep':
  1653. rerunFromStep(e.data.stepId || e.data.nodeId, e.data.overrides || {});
  1654. break;
  1655. case 'editNode': {
  1656. const node = state.nodes.find(n => n.id === (e.data.nodeId || e.data.stepId));
  1657. if (node) openEditor(node);
  1658. break;
  1659. }
  1660. }
  1661. });
  1662. // ===== Init =====
  1663. initDrag();
  1664. window.parent.postMessage({ type: 'ready' }, '*');
  1665. // Scroll events update minimap
  1666. $('canvasWrap')?.addEventListener('scroll', () => { renderConnections(); updateMinimap(); });
  1667. window.addEventListener('resize', () => { renderConnections(); updateMinimap(); });
  1668. </script>
  1669. </body>
  1670. </html>