index.html 232 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <!-- 不发送 Referer:绕过小红书等 CDN 防盗链,使外链参考图/成品图能正常加载 -->
  7. <meta name="referrer" content="no-referrer" />
  8. <title>Production Build · 运行视图</title>
  9. <link rel="preconnect" href="https://fonts.googleapis.com">
  10. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  11. <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,500&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
  12. <style>
  13. :root {
  14. --bg: #0a0c0f; --bg2: #0e1116; --panel: #13171d; --panel2: #191f27;
  15. --border: #232a33; --border2: #333c48;
  16. --text: #e8ecf2; --muted: #838d9c; --faint: #5a6472;
  17. --accent: #8b9dff; /* iris — 交互/选中 */
  18. --plan: #a78bfa; /* plan 规划一侧 */
  19. --exec: #38bdf8; /* execute 执行一侧 */
  20. --kind: #f5b544; /* step kind 标签(amber) */
  21. --ok: #34d399; --fail: #f87171; --run: #fbbf24; --idle: #8b95a3;
  22. --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  23. --sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
  24. --serif: "Fraunces", Georgia, "Songti SC", serif;
  25. }
  26. * { box-sizing: border-box; }
  27. html, body { margin: 0; height: 100%; }
  28. body {
  29. font-family: var(--sans); background: var(--bg); color: var(--text);
  30. height: 100vh; overflow: hidden; -webkit-font-smoothing: antialiased;
  31. background-image:
  32. radial-gradient(880px 520px at 82% -8%, rgba(139,157,255,.10), transparent 60%),
  33. radial-gradient(760px 560px at 4% 108%, rgba(167,139,250,.09), transparent 62%);
  34. }
  35. ::selection { background: rgba(139,157,255,.30); }
  36. ::-webkit-scrollbar { width: 10px; height: 10px; }
  37. ::-webkit-scrollbar-thumb { background: #222933; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
  38. ::-webkit-scrollbar-thumb:hover { background: #2f3843; background-clip: padding-box; }
  39. .app { display: grid; grid-template-columns: 366px 1fr; height: 100vh; }
  40. /* ───────────── sidebar ───────────── */
  41. .sidebar { background: linear-gradient(180deg, var(--bg2), var(--bg)); border-right: 1px solid var(--border);
  42. display: flex; flex-direction: column; min-height: 0; }
  43. .brand { padding: 20px 22px 14px; border-bottom: 1px solid var(--border); }
  44. .brand-top { display: flex; align-items: baseline; gap: 10px; }
  45. .logo { font-family: var(--serif); font-style: italic; font-weight: 600; font-size: 25px; letter-spacing: .2px;
  46. background: linear-gradient(96deg, #eaeefc, #9db0ff 62%, #b79dff); -webkit-background-clip: text;
  47. background-clip: text; color: transparent; line-height: 1; }
  48. .logo-sub { font-family: var(--mono); font-size: 10px; letter-spacing: 2.5px; color: var(--faint);
  49. text-transform: uppercase; margin-left: auto; padding-bottom: 2px; }
  50. .brand-desc { margin-top: 9px; font-size: 11.5px; color: var(--muted); letter-spacing: .2px; line-height: 1.5; }
  51. .loop-glyph { font-family: var(--mono); color: var(--plan); font-weight: 700; }
  52. .loop-glyph b { color: var(--exec); font-weight: 700; }
  53. .controls { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
  54. .seg { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 3px; }
  55. .seg button { flex: 1; background: transparent; border: none; color: var(--muted); font-family: var(--mono);
  56. font-size: 11px; padding: 6px 4px; border-radius: 7px; cursor: pointer; letter-spacing: .3px;
  57. transition: background .15s, color .15s; }
  58. .seg button:hover { color: var(--text); }
  59. .seg button.active { background: var(--panel2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border2); }
  60. .seg button.active[data-s="success"] { color: var(--ok); }
  61. .seg button.active[data-s="running"] { color: var(--run); }
  62. .seg button.active[data-s="failed"] { color: var(--fail); }
  63. .ctl-row { display: flex; align-items: center; gap: 10px; }
  64. .ctl-row .count { font-family: var(--mono); font-size: 11px; color: var(--faint); }
  65. .icon-btn { margin-left: auto; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
  66. background: var(--panel); border: 1px solid var(--border); border-radius: 9px; color: var(--muted);
  67. cursor: pointer; font-size: 15px; transition: transform .4s, color .15s, border-color .15s; }
  68. .icon-btn:hover { color: var(--accent); border-color: var(--border2); }
  69. .icon-btn.spin { transform: rotate(360deg); }
  70. .runlist { overflow-y: auto; flex: 1; min-height: 0; padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
  71. .runlist .hint { color: var(--faint); font-size: 12px; text-align: center; margin-top: 40px; }
  72. .runcard { position: relative; border: 1px solid var(--border); border-radius: 12px; padding: 12px 13px 11px 15px;
  73. background: linear-gradient(180deg, var(--panel), var(--bg2)); cursor: pointer; flex: 0 0 auto;
  74. transition: border-color .15s, transform .12s, box-shadow .15s; overflow: hidden; }
  75. .runcard::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--idle); opacity: .8; }
  76. .runcard[data-s="success"]::before { background: var(--ok); }
  77. .runcard[data-s="running"]::before { background: var(--run); }
  78. .runcard[data-s="failed"]::before { background: var(--fail); }
  79. .runcard[data-s="interrupted"]::before { background: var(--idle); }
  80. .runcard:hover { border-color: var(--border2); transform: translateY(-1px); }
  81. .runcard.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 10px 30px -14px rgba(139,157,255,.6); }
  82. .rc-top { display: flex; align-items: center; gap: 8px; }
  83. .rc-id { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--accent); }
  84. .rc-agent { font-family: var(--mono); font-size: 10.5px; color: var(--faint); letter-spacing: .3px; }
  85. .rc-obj { margin-top: 7px; font-size: 12.5px; line-height: 1.5; color: var(--text); display: -webkit-box;
  86. -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
  87. .rc-meta { margin-top: 9px; display: flex; flex-wrap: wrap; gap: 4px 12px; font-family: var(--mono);
  88. font-size: 10.5px; color: var(--faint); }
  89. .rc-meta b { color: var(--muted); font-weight: 500; }
  90. .badge { font-family: var(--mono); font-size: 9.5px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
  91. padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; white-space: nowrap; }
  92. .badge.success { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
  93. .badge.running { color: var(--run); background: rgba(251,191,36,.11); border-color: rgba(251,191,36,.34); }
  94. .badge.failed { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
  95. .badge.interrupted { color: var(--idle); background: rgba(139,149,163,.12); border-color: rgba(139,149,163,.32); }
  96. .badge.dot::before { content: "●"; font-size: 7px; margin-right: 5px; vertical-align: middle; }
  97. .badge.running.dot::before { animation: pulse 1.4s ease-in-out infinite; }
  98. @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
  99. .pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 9px;
  100. border-top: 1px solid var(--border); flex-shrink: 0; }
  101. .pager:empty { display: none; }
  102. .pager button { font-family: var(--mono); padding: 4px 12px; border: 1px solid var(--border); border-radius: 8px;
  103. background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; }
  104. .pager button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
  105. .pager button:disabled { opacity: .35; cursor: default; }
  106. .pager .pi { font-family: var(--mono); font-size: 11px; color: var(--faint); }
  107. /* ───────────── main ───────────── */
  108. .main { overflow-y: auto; min-height: 0; position: relative; }
  109. .empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
  110. gap: 18px; color: var(--faint); }
  111. .empty .big { font-family: var(--serif); font-style: italic; font-size: 34px; color: var(--border2); }
  112. .empty .lp { font-family: var(--mono); font-size: 12px; letter-spacing: 1px; }
  113. .empty .lp b { color: var(--plan); } .empty .lp i { color: var(--exec); font-style: normal; }
  114. .topbar { position: sticky; top: 0; z-index: 20; background: rgba(10,12,15,.82); backdrop-filter: blur(12px);
  115. border-bottom: 1px solid var(--border); padding: 13px 30px; display: flex; align-items: center; gap: 14px; }
  116. .tb-id { font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--accent); flex: 0 0 auto; }
  117. .tb-obj { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  118. .tb-right { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
  119. .detailbody { padding: 24px 30px 90px; max-width: 1120px; }
  120. .loading { display: flex; align-items: center; gap: 12px; color: var(--muted); font-family: var(--mono);
  121. font-size: 13px; padding: 60px 30px; }
  122. .loading .dots span { animation: blink 1.2s infinite; } .loading .dots span:nth-child(2){animation-delay:.2s}
  123. .loading .dots span:nth-child(3){animation-delay:.4s}
  124. @keyframes blink { 0%,100%{opacity:.2} 50%{opacity:1} }
  125. /* ───────────── hero:目标 + 循环机制概览 ───────────── */
  126. .hero { border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px;
  127. background: linear-gradient(180deg, var(--panel), var(--bg2)); position: relative; overflow: hidden; }
  128. .hero::after { content: ""; position: absolute; right: -60px; top: -60px; width: 220px; height: 220px;
  129. background: radial-gradient(circle, rgba(139,157,255,.14), transparent 70%); pointer-events: none; }
  130. .hero-obj { font-family: var(--serif); font-size: 20px; line-height: 1.5; font-weight: 400; color: #f2f4f9; letter-spacing: .1px; }
  131. .hero-model { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 10px; }
  132. /* loop 机制章:PLAN ⇄ EXECUTE 环 + 计数 */
  133. .loopwrap { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 20px; padding-top: 18px;
  134. border-top: 1px solid var(--border); }
  135. .loopdiagram { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
  136. .lp-node { display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 11px 16px; border-radius: 12px;
  137. border: 1px solid var(--border2); min-width: 86px; }
  138. .lp-node.plan { background: rgba(167,139,250,.09); border-color: rgba(167,139,250,.34); }
  139. .lp-node.exec { background: rgba(56,189,248,.09); border-color: rgba(56,189,248,.34); }
  140. .lp-node .lp-t { font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: 1.5px; }
  141. .lp-node.plan .lp-t { color: var(--plan); } .lp-node.exec .lp-t { color: var(--exec); }
  142. .lp-node .lp-c { font-family: var(--mono); font-size: 18px; font-weight: 700; color: var(--text); }
  143. .lp-node .lp-c small { font-size: 10px; color: var(--faint); font-weight: 400; }
  144. .lp-arrows { display: flex; flex-direction: column; align-items: center; gap: 3px; color: var(--faint); font-family: var(--mono); }
  145. .lp-arrows .a1 { color: var(--plan); animation: slideR 2.4s ease-in-out infinite; }
  146. .lp-arrows .a2 { color: var(--exec); animation: slideL 2.4s ease-in-out infinite; }
  147. @keyframes slideR { 0%,100%{opacity:.35;transform:translateX(-2px)} 50%{opacity:1;transform:translateX(2px)} }
  148. @keyframes slideL { 0%,100%{opacity:.35;transform:translateX(2px)} 50%{opacity:1;transform:translateX(-2px)} }
  149. .lp-arrows .lbl { font-size: 8.5px; letter-spacing: 1px; color: var(--faint); text-transform: uppercase; }
  150. .loopstats { display: flex; flex-wrap: wrap; gap: 8px; flex: 1; min-width: 260px; }
  151. .lstat { border: 1px solid var(--border); border-radius: 10px; padding: 8px 13px; background: var(--bg2); min-width: 78px; }
  152. .lstat .k { font-family: var(--mono); font-size: 9.5px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint); }
  153. .lstat .v { font-family: var(--mono); font-size: 16px; color: var(--text); margin-top: 3px; font-weight: 600; }
  154. .lstat .v small { font-size: 10px; color: var(--muted); font-weight: 400; }
  155. .lstat .v.pass { color: var(--ok); } .lstat .v.fail { color: var(--fail); }
  156. .lstat .v.retry { color: var(--run); } .lstat .v.plan { color: var(--plan); }
  157. .err-box { margin-top: 16px; border: 1px solid rgba(248,113,113,.4); background: rgba(248,113,113,.07);
  158. border-radius: 10px; padding: 12px 15px; color: #ffb4b4; font-family: var(--mono); font-size: 12px;
  159. white-space: pre-wrap; word-break: break-word; }
  160. /* section titles */
  161. .sec-title { display: flex; align-items: center; gap: 10px; margin: 32px 0 16px; }
  162. .sec-title .t { font-family: var(--mono); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); }
  163. .sec-title .line { flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
  164. .sec-title .n { font-family: var(--mono); font-size: 11px; color: var(--faint); }
  165. /* final delivery */
  166. .delivery { border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: var(--panel); }
  167. .delivery-h { display: flex; align-items: center; gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border);
  168. font-family: var(--mono); font-size: 12px; color: var(--ok); background: rgba(52,211,153,.05); }
  169. .delivery-b { padding: 16px; font-size: 13.5px; line-height: 1.75; color: var(--text);
  170. white-space: pre-wrap; word-break: break-word; }
  171. .gallery { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 16px 18px; }
  172. .gcard { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg2); width: 150px; }
  173. .gcard img { width: 150px; height: 190px; object-fit: cover; display: block; cursor: zoom-in; background: var(--bg); }
  174. .gcard .cap { padding: 6px 8px; font-family: var(--mono); font-size: 10px; color: var(--muted); display: flex;
  175. align-items: center; gap: 5px; border-top: 1px solid var(--border); }
  176. .gcard .cap .sid { color: var(--kind); font-weight: 700; }
  177. /* ───────────── 抽屉复用:元信息 / 字段 / 产物图 / verdict / pending ───────────── */
  178. .tc-meta { display: flex; flex-wrap: wrap; gap: 5px 8px; margin: 2px 0 4px; }
  179. .metachip { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--bg2);
  180. border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
  181. .field { margin-top: 13px; }
  182. .field > .lbl { font-family: var(--mono); font-size: 10px; letter-spacing: 1px; text-transform: uppercase;
  183. color: var(--faint); margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
  184. .field .val { font-size: 13px; line-height: 1.65; color: var(--text); white-space: pre-wrap; word-break: break-word; }
  185. .imgs { display: flex; flex-wrap: wrap; gap: 9px; }
  186. .imgs img.thumb { width: 122px; height: 152px; object-fit: cover; border: 1px solid var(--border); border-radius: 9px;
  187. background: var(--bg); cursor: zoom-in; transition: border-color .15s, transform .12s; }
  188. .imgs img.thumb:hover { border-color: var(--accent); transform: scale(1.02); }
  189. .imgs img.thumb.ref { border-style: dashed; opacity: .92; }
  190. .imgs .imgfail { width: 122px; height: 152px; display: flex; align-items: center; justify-content: center; text-align: center;
  191. border: 1px dashed var(--fail); border-radius: 9px; color: var(--fail); font-family: var(--mono);
  192. font-size: 10px; background: rgba(248,113,113,.05); padding: 8px; cursor: pointer; text-decoration: none; }
  193. .a-verdict-box { margin-top: 10px; border-radius: 9px; padding: 9px 12px; border: 1px solid var(--border); }
  194. .a-verdict-box.pass { border-color: rgba(52,211,153,.32); background: rgba(52,211,153,.05); }
  195. .a-verdict-box.fail { border-color: rgba(248,113,113,.32); background: rgba(248,113,113,.05); }
  196. .vd-dims { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
  197. .vd-dim { font-family: var(--mono); font-size: 10.5px; color: var(--fail); background: rgba(248,113,113,.1);
  198. border: 1px solid rgba(248,113,113,.3); border-radius: 6px; padding: 2px 8px; }
  199. .vd-note { margin-top: 8px; font-size: 12px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
  200. /* pending 任务(尚未执行/等待重试) */
  201. .pending-note { margin-top: 12px; font-family: var(--mono); font-size: 11.5px; color: var(--run);
  202. display: flex; align-items: center; gap: 8px; }
  203. /* 详情抽屉 */
  204. .drawer-scrim { position: fixed; inset: 0; background: rgba(5,6,8,.4); z-index: 340; opacity: 0; pointer-events: none;
  205. transition: opacity .2s; }
  206. .drawer-scrim.open { opacity: 1; pointer-events: auto; }
  207. /* 抽屉里放的是 system prompt 全文、逐字段入参、带表格的长文档 —— 660px 一行塞不下几个字,
  208. 表格更是每格都要折。放宽到视口的一半上下(大屏 1080px 封顶,小屏按 vw 收)。 */
  209. .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(1080px, 62vw); min-width: 620px;
  210. max-width: 96vw; z-index: 341;
  211. background: linear-gradient(180deg, var(--panel), var(--bg2)); border-left: 1px solid var(--border2);
  212. box-shadow: -20px 0 50px -20px rgba(0,0,0,.7); transform: translateX(102%); transition: transform .24s cubic-bezier(.4,0,.2,1);
  213. display: flex; flex-direction: column; }
  214. .drawer.open { transform: none; }
  215. .drawer-h { display: flex; align-items: center; gap: 10px; padding: 15px 18px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
  216. .drawer-tag { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
  217. padding: 3px 10px; border-radius: 7px; }
  218. .drawer-tag.plan { color: var(--plan); background: rgba(167,139,250,.12); }
  219. .drawer-tag.execute { color: var(--exec); background: rgba(56,189,248,.12); }
  220. .drawer-title { font-size: 14px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  221. .drawer-x { margin-left: auto; width: 30px; height: 30px; border-radius: 8px; cursor: pointer; flex: 0 0 auto;
  222. display: flex; align-items: center; justify-content: center; color: var(--muted); background: var(--panel2);
  223. border: 1px solid var(--border); font-size: 15px; }
  224. .drawer-x:hover { color: var(--text); }
  225. .drawer-refresh + .drawer-x { margin-left: 0; } /* 刷新按钮吃掉 auto 边距,✕ 紧随其后 */
  226. @keyframes rot360 { to { transform: rotate(360deg); } }
  227. .spinning { animation: rot360 .5s linear; }
  228. .drawer-b { overflow-y: auto; padding: 16px 18px 40px; flex: 1; min-height: 0; }
  229. /* lightbox */
  230. .lb { position: fixed; inset: 0; background: rgba(5,6,8,.92); display: none; align-items: center; justify-content: center;
  231. z-index: 360; padding: 30px; }
  232. .lb.open { display: flex; }
  233. .lb img { max-width: 92vw; max-height: 86vh; object-fit: contain; border-radius: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.7); }
  234. .lb-counter { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 12px;
  235. color: var(--muted); background: rgba(0,0,0,.5); border: 1px solid var(--border); padding: 5px 14px; border-radius: 20px; }
  236. .lb-nav { position: fixed; top: 50%; transform: translateY(-50%); width: 46px; height: 70px; font-size: 30px; cursor: pointer;
  237. color: #fff; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.16); border-radius: 12px;
  238. display: flex; align-items: center; justify-content: center; }
  239. .lb-nav:hover { background: rgba(255,255,255,.16); }
  240. .lb-nav.prev { left: 22px; } .lb-nav.next { right: 22px; }
  241. .lb-close { position: fixed; top: 22px; right: 26px; width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
  242. color: var(--muted); background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.16); font-size: 18px;
  243. display: flex; align-items: center; justify-content: center; }
  244. .lb-close:hover { color: #fff; }
  245. /* ───────────── 快照步进器(Loop 1 → 2 → … → 收尾) ───────────── */
  246. .snap-empty { border: 1px dashed var(--border2); border-radius: 14px; padding: 40px 24px; text-align: center;
  247. color: var(--faint); font-family: var(--mono); font-size: 13px; margin-top: 20px; }
  248. .snap-stepper { display: flex; align-items: center; gap: 8px; padding: 14px 0 18px; flex-wrap: wrap; }
  249. .snap-arrow { width: 30px; height: 30px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  250. background: var(--panel); border: 1px solid var(--border); border-radius: 9px; color: var(--muted);
  251. cursor: pointer; font-size: 15px; transition: color .15s, border-color .15s; }
  252. .snap-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--border2); }
  253. .snap-arrow:disabled { opacity: .3; cursor: default; }
  254. .snap-track { display: flex; align-items: center; gap: 0; flex-wrap: wrap; }
  255. .snap-pill { position: relative; display: flex; align-items: center; gap: 6px; font-family: var(--mono);
  256. font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid var(--border);
  257. border-radius: 8px; padding: 5px 12px; cursor: pointer; transition: all .15s; white-space: nowrap; }
  258. .snap-pill:hover { border-color: var(--border2); color: var(--text); }
  259. .snap-pill.cur { background: rgba(139,157,255,.12); border-color: var(--accent); color: var(--accent); }
  260. .snap-pill.final { color: var(--ok); }
  261. .snap-pill.final.cur { background: rgba(52,211,153,.12); border-color: var(--ok); }
  262. .snap-sep { width: 20px; height: 1px; background: var(--border2); flex: 0 0 auto; }
  263. .mm-card { position: relative; width: 256px; flex: 0 0 auto; border: 1px solid var(--border2); border-radius: 13px;
  264. background: var(--panel); padding: 11px 13px 12px; cursor: pointer;
  265. transition: transform .12s, box-shadow .15s, border-color .15s; }
  266. .mm-card:hover { transform: translateY(-2px); box-shadow: 0 12px 26px -14px rgba(0,0,0,.7); border-color: var(--accent); }
  267. .mm-card.st-passed { border-left: 3px solid var(--ok); }
  268. .mm-card.st-failed { border-left: 3px solid var(--fail); }
  269. .mm-card.st-pending { border-left: 3px solid var(--idle); border-style: dashed; opacity: .92; }
  270. .mm-card.st-decomposed { border-left: 3px solid var(--plan); background: linear-gradient(180deg, rgba(167,139,250,.06), var(--panel)); }
  271. .mm-card.mm-modified { box-shadow: 0 0 0 1.5px var(--run); }
  272. .mmc-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
  273. .mmc-sid { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--panel2);
  274. border: 1px solid var(--border); border-radius: 5px; padding: 0 6px; flex: 0 0 auto; }
  275. .mmc-badge { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
  276. padding: 1px 7px; border-radius: 20px; border: 1px solid transparent; flex: 0 0 auto; }
  277. .mmc-badge.passed { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
  278. .mmc-badge.failed { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
  279. .mmc-badge.pending { color: var(--idle); background: rgba(139,149,163,.12); border-color: rgba(139,149,163,.32); }
  280. .mmc-badge.decomposed { color: var(--plan); background: rgba(167,139,250,.11); border-color: rgba(167,139,250,.34); }
  281. .mmc-name { font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1.4;
  282. display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  283. .mmc-goal { margin-top: 5px; font-size: 11px; line-height: 1.5; color: var(--muted);
  284. display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  285. .mmc-result { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); display: flex; gap: 9px; }
  286. .mmc-thumb { width: 46px; height: 58px; border-radius: 7px; object-fit: cover; flex: 0 0 auto; cursor: zoom-in;
  287. border: 1px solid var(--border); background: var(--bg); }
  288. .mmc-sum { font-size: 10.5px; line-height: 1.5; color: var(--muted); min-width: 0;
  289. display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
  290. .mmc-fail { margin-top: 7px; font-size: 10.5px; line-height: 1.5; color: #ffb4b4; }
  291. .mmc-tag { font-family: var(--mono); font-size: 9px; color: var(--faint); }
  292. .mmc-tag.retry { color: var(--run); }
  293. .mmc-kidsum { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); font-family: var(--mono);
  294. font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
  295. /* 规划变化 diff:逐字段「旧 → 新」,name/goal 走行内字符级 diff(删除红删除线 / 新增绿底) */
  296. .chg-list { display: flex; flex-direction: column; gap: 9px; }
  297. .chg-row { display: flex; gap: 9px; align-items: baseline; }
  298. .chg-k { flex: 0 0 auto; font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--faint);
  299. background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
  300. .chg-v { flex: 1 1 auto; min-width: 0; font-size: 12.5px; line-height: 1.7; color: var(--text); word-break: break-word; }
  301. .df-del { color: var(--fail); background: rgba(248,113,113,.13); text-decoration: line-through; text-decoration-color: rgba(248,113,113,.65); border-radius: 3px; }
  302. .df-ins { color: var(--ok); background: rgba(52,211,153,.15); border-radius: 3px; }
  303. .lesson { padding: 8px 14px; border-top: 1px solid var(--border); font-size: 11.5px; line-height: 1.55; color: var(--muted); }
  304. .lesson:first-of-type { border-top: none; }
  305. .lesson .l-src { font-family: var(--mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
  306. padding: 1px 6px; border-radius: 5px; margin-right: 6px; }
  307. .lesson .l-src.verdict { color: var(--fail); background: rgba(248,113,113,.1); }
  308. .lesson .l-src.planner { color: var(--plan); background: rgba(167,139,250,.1); }
  309. /* 阶段 / 生成类型(操作) chips —— planner 赋予的制作属性 */
  310. .mmc-attrs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
  311. .attr-chip { font-family: var(--mono); font-size: 9px; font-weight: 600; border-radius: 5px; padding: 1px 7px; }
  312. .attr-chip .k { opacity: .55; margin-right: 4px; font-weight: 700; }
  313. .attr-chip.stage { color: var(--plan); background: rgba(167,139,250,.1); border: 1px solid rgba(167,139,250,.28); }
  314. .attr-chip.cat { color: var(--exec); background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.26); }
  315. /* 能力 / 工具 chips */
  316. .mmc-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
  317. .kind-chip { font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--kind);
  318. background: rgba(245,181,68,.12); border: 1px solid rgba(245,181,68,.3); border-radius: 5px; padding: 1px 7px; }
  319. .tool-chip { font-family: var(--mono); font-size: 9px; color: var(--exec);
  320. background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.28); border-radius: 5px; padding: 1px 7px; }
  321. /* 执行中 状态(run 仍在跑、最新快照里的下一个待执行叶) */
  322. .mmc-badge.running { color: var(--run); background: rgba(251,191,36,.13); border-color: rgba(251,191,36,.4); }
  323. .mmc-badge.running::before { content: "●"; font-size: 7px; margin-right: 4px; animation: pulse 1.4s ease-in-out infinite; }
  324. .mm-card.st-running { border-left: 3px solid var(--run); box-shadow: 0 0 0 1px rgba(251,191,36,.25); }
  325. /* 验收标准 / 依赖参考图(节点详情复用) */
  326. .acc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
  327. .acc-list li { position: relative; padding-left: 20px; font-size: 12.5px; line-height: 1.55; color: var(--muted); }
  328. .acc-list li::before { content: "▪"; position: absolute; left: 4px; top: 2px; color: var(--exec); font-size: 11px; }
  329. /* 验收标准结构化项:标准(主)+ 说明(次)+ 参考图缩略图 */
  330. .acc-std { font-weight: 600; color: var(--text); }
  331. .acc-desc { margin-top: 3px; font-size: 12px; line-height: 1.6; color: var(--muted); }
  332. .acc-refs { margin-top: 7px; }
  333. .refchip { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--plan);
  334. background: rgba(167,139,250,.1); border: 1px solid rgba(167,139,250,.28); border-radius: 7px; padding: 3px 9px; }
  335. .imgs img.thumb.ref { border-style: dashed; opacity: .92; }
  336. /* 循环阶段步进器:轮次编号 + 改 Task 标记 + 实时点 */
  337. .snap-pill .sp-n { font-family: var(--mono); font-size: 9px; color: var(--faint); background: var(--bg);
  338. border: 1px solid var(--border); border-radius: 4px; padding: 0 4px; }
  339. .snap-pill.cur .sp-n { color: var(--accent); border-color: var(--accent); }
  340. .snap-pill.changed { border-color: rgba(52,211,153,.4); }
  341. .snap-pill .sp-mark { color: var(--ok); font-size: 10px; }
  342. .snap-pill .sp-live { color: var(--run); font-size: 8px; animation: pulse 1.4s ease-in-out infinite; }
  343. /* plan 过程抽屉:输入消息 / 输出 */
  344. .pp-msg { border: 1px solid var(--border); border-radius: 9px; background: var(--bg2); margin-top: 8px; overflow: hidden; }
  345. .pp-role { font-family: var(--mono); font-size: 9px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint);
  346. padding: 5px 10px; border-bottom: 1px solid var(--border); background: var(--panel); }
  347. .pp-content { font-family: var(--mono); font-size: 11px; line-height: 1.55; color: var(--muted); white-space: pre-wrap;
  348. word-break: break-word; padding: 9px 11px; max-height: 300px; overflow: auto; }
  349. .pp-json { color: var(--text); }
  350. /* 依附在消息块内容之下的图片条(与内容之间加分隔) */
  351. .pp-imgs { padding: 10px 11px; border-top: 1px solid var(--border); background: var(--panel); }
  352. /* 详情里的「分段单元」:一段一块,边界清晰,内容完整 */
  353. .sec-cell { border: 1px solid var(--border); border-radius: 12px; padding: 11px 13px 12px;
  354. background: rgba(139,157,255,.04); margin-top: 14px; }
  355. .sec-cell > .lbl { color: var(--accent); letter-spacing: .6px; text-transform: none; font-size: 11px; }
  356. .sec-cell .sec-meta { font-family: var(--mono); font-size: 10px; color: var(--faint); margin: 2px 0 8px; }
  357. /* 详情富文本:markdown 渲染(区别于等宽 JSON) */
  358. .pp-md { font-size: 12.5px; line-height: 1.68; color: var(--text); word-break: break-word;
  359. padding: 11px 13px; max-height: 340px; overflow: auto; }
  360. .pp-md > :first-child { margin-top: 0; }
  361. .pp-md > :last-child { margin-bottom: 0; }
  362. .rich-p { margin: 8px 0; }
  363. .rich-h { font-weight: 700; line-height: 1.4; margin: 15px 0 7px; color: var(--text); }
  364. .rich-h1 { font-size: 16px; } .rich-h2 { font-size: 14.5px; } .rich-h3 { font-size: 13.5px; }
  365. .rich-h4, .rich-h5, .rich-h6 { font-size: 12.5px; color: var(--muted); letter-spacing: .3px; }
  366. .rich-list { margin: 8px 0; padding-left: 20px; }
  367. .rich-list li { margin: 3px 0; }
  368. .pp-md code { font-family: var(--mono); font-size: 11px; background: var(--panel2); border: 1px solid var(--border);
  369. border-radius: 5px; padding: 1px 5px; color: var(--accent); }
  370. .rich-code { font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--muted); white-space: pre-wrap;
  371. word-break: break-word; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
  372. padding: 9px 11px; margin: 9px 0; overflow: auto; }
  373. .rich-quote { margin: 9px 0; padding: 4px 12px; border-left: 3px solid var(--border2); color: var(--muted); }
  374. /* 表格(GFM):窄容器里横向可滚,不撑破抽屉;表头置顶、行间细线、悬停整行提亮 */
  375. .rich-table-wrap { margin: 10px 0; overflow-x: auto; border: 1px solid var(--border);
  376. border-radius: 9px; background: var(--bg2); }
  377. .rich-table { border-collapse: collapse; width: 100%; font-size: 12px; line-height: 1.6; }
  378. .rich-table th, .rich-table td { padding: 7px 11px; border-bottom: 1px solid var(--border);
  379. vertical-align: top; }
  380. .rich-table th { background: var(--panel); color: var(--text); font-weight: 700; white-space: nowrap;
  381. position: sticky; top: 0; }
  382. .rich-table td { color: var(--muted); }
  383. .rich-table tbody tr:last-child td { border-bottom: 0; }
  384. .rich-table tbody tr:hover td { background: rgba(139,157,255,.06); color: var(--text); }
  385. .rich-table code { white-space: nowrap; }
  386. .pp-md strong { color: var(--text); font-weight: 700; }
  387. .pp-md a { color: var(--accent); text-decoration: none; }
  388. .pp-md a:hover { text-decoration: underline; }
  389. /* markdown 正文里就地内联的图片:自成一行、结合上下文查看,点击放大 */
  390. .pp-md .md-img { display: block; max-width: 240px; max-height: 180px; width: auto; height: auto; margin: 7px 0; border-radius: 8px;
  391. border: 1px solid var(--border); background: var(--bg); object-fit: contain; cursor: zoom-in; }
  392. .pp-md .imgfail { display: inline-block; margin: 6px 0; }
  393. /* 抽屉里**一律不套内嵌小滚动框**:一屏里嵌好几个各自能滚的小框,读者既不知道每块有多长、
  394. 也没法一路读下去(见 think_and_plan 的 thought/plan/action 三段)。内容整块铺开,由抽屉整体滚动。 */
  395. #drawerBody .pp-md, #drawerBody .pp-content { max-height: none; overflow: visible; }
  396. /* ── 详情里的「输入 / 输出」两区 ─────────────────────────────────────────────
  397. 一区一块:左侧 3px 色轨 + 同色抬头 + 淡色底。**入青 / 出绿 / 调用紫**,与卡面上那三段
  398. 同一套配色 —— 同一件事在哪儿看都是同一个颜色,读者不必每次重新认标签。 */
  399. .io-block { margin-top: 16px; border: 1px solid var(--border); border-left: 3px solid var(--io-c);
  400. border-radius: 12px; overflow: hidden; background: var(--panel); }
  401. .io-block.in { --io-c: rgb(56,189,248); --io-bg: rgba(56,189,248,.10); }
  402. .io-block.out { --io-c: rgb(74,222,128); --io-bg: rgba(74,222,128,.10); }
  403. .io-block.call { --io-c: rgb(167,139,250); --io-bg: rgba(167,139,250,.12); }
  404. .iob-h { display: flex; align-items: center; gap: 8px; padding: 9px 13px; background: var(--io-bg);
  405. border-bottom: 1px solid var(--border); font-family: var(--mono); font-size: 12px;
  406. font-weight: 700; letter-spacing: .8px; color: var(--io-c); }
  407. .iob-ic { font-size: 13px; opacity: .9; }
  408. .iob-meta { margin-left: auto; font-family: var(--mono); font-size: 9.5px; font-weight: 400;
  409. letter-spacing: .3px; color: var(--faint); }
  410. .iob-b { padding: 3px 13px 13px; }
  411. /* 字段块:键不再是灰色小字,而是**带色点的同色键名**(值有多大标在右侧),值用正文字号 */
  412. .iob-b .pp-msg { margin-top: 10px; border-color: var(--border2); background: var(--bg2); }
  413. .iob-b .pp-role { display: flex; align-items: center; gap: 7px; padding: 6px 11px;
  414. background: var(--io-bg); border-bottom: 1px solid var(--border2);
  415. font-size: 10.5px; font-weight: 700; letter-spacing: .4px;
  416. text-transform: none; color: var(--io-c); }
  417. .iob-b .pp-role::before { content: ""; flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%;
  418. background: var(--io-c); }
  419. .iob-b .pr-k { overflow: hidden; text-overflow: ellipsis; }
  420. .iob-b .pr-meta { margin-left: auto; font-weight: 400; font-size: 9px; color: var(--faint); }
  421. .iob-b .pp-md { font-size: 13px; color: var(--text); }
  422. .iob-b .pp-content { font-size: 11.5px; color: var(--text); }
  423. /* 一次调用一组:序号 + 工具名,参数在下面逐字段铺开 */
  424. .call-item { margin-top: 12px; }
  425. .call-item + .call-item { padding-top: 12px; border-top: 1px dashed var(--border2); }
  426. .ci-h { display: flex; align-items: center; gap: 8px; }
  427. .ci-i { flex: 0 0 auto; width: 17px; height: 17px; border-radius: 50%; display: grid; place-items: center;
  428. font-family: var(--mono); font-size: 10px; color: var(--bg); background: var(--io-c); }
  429. .ci-n { font-family: var(--mono); font-size: 13px; font-weight: 700; color: #c9b8ff; word-break: break-all; }
  430. .ci-b .pp-msg:first-child { margin-top: 8px; }
  431. /* ═══════════ 视图 Tab(结果视图 / 过程视图)═══════════ */
  432. .view-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 28px 0 4px; }
  433. .view-bar .view-tabs { margin: 0; }
  434. .fs-enter { font-family: var(--mono); font-size: 12px; letter-spacing: .5px; color: var(--muted); background: var(--panel);
  435. border: 1px solid var(--border); border-radius: 10px; padding: 9px 16px; cursor: pointer;
  436. display: inline-flex; align-items: center; gap: 7px; transition: color .15s, border-color .15s; }
  437. .fs-enter:hover { color: var(--accent); border-color: var(--accent); }
  438. .fs-enter .vt-ic { font-size: 14px; }
  439. .view-tabs { display: inline-flex; gap: 3px; background: var(--panel); border: 1px solid var(--border);
  440. border-radius: 12px; padding: 4px; margin: 28px 0 4px; }
  441. .view-tab { font-family: var(--mono); font-size: 12px; letter-spacing: .5px; color: var(--muted); background: transparent;
  442. border: none; border-radius: 9px; padding: 8px 20px; cursor: pointer; display: inline-flex; align-items: center;
  443. gap: 7px; transition: background .15s, color .15s; }
  444. .view-tab:hover { color: var(--text); }
  445. .view-tab.active { background: var(--panel2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border2); }
  446. .view-tab .vt-ic { font-size: 13px; }
  447. .view-pane { display: none; }
  448. .view-pane.active { display: block; }
  449. /* ═══════════ 过程视图 · 画板(可平移/缩放的白板;节点自由摆放 + SVG 连线)═══════════ */
  450. .board { position: relative; height: 76vh; min-height: 480px; margin-top: 8px; border: 1px solid var(--border);
  451. border-radius: 16px; overflow: hidden; background: var(--bg2);
  452. background-image: radial-gradient(rgba(120,132,150,.28) 1.1px, transparent 1.1px); background-size: 24px 24px;
  453. cursor: grab; touch-action: none; user-select: none; }
  454. .board.dragging { cursor: grabbing; }
  455. .board-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
  456. /* 模块间的引用边画在**块之上**(z 4 > .mod-block 的 2):块很宽且各行居中,两个模块的定义列
  457. 可能相隔数千 px,边必然斜穿过别的块 —— 压在块下面就等于没画。细虚线,压过去也不抢视觉。 */
  458. .board-edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; z-index: 4; }
  459. .board-labels { position: absolute; left: 0; top: 0; z-index: 6; pointer-events: none; }
  460. /* 悬停展开的那一两条实例级引用线:画在最上层(要的就是"是哪一条"一眼可辨) */
  461. .board-edges.hot { z-index: 5; }
  462. /* 悬停连线上的标签:运行层标「输入 / 输出」(与卡面同色系),结构层标「引用 · 本轮 ×N」 */
  463. .edge-tag { position: absolute; transform: translate(-50%, -50%); padding: 1px 7px; border-radius: 8px;
  464. font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--fg);
  465. background: var(--bg); border: 1px solid var(--border2); white-space: nowrap; }
  466. .edge-tag.in { color: rgb(56,189,248); border-color: rgba(56,189,248,.55); }
  467. .edge-tag.out { color: rgb(74,222,128); border-color: rgba(74,222,128,.55); }
  468. .edge-tag.ref { color: var(--muted); }
  469. .board-ctrls { position: absolute; right: 12px; bottom: 12px; z-index: 6; display: flex; gap: 6px; }
  470. .board-ctrls button { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border2); background: var(--panel);
  471. color: var(--muted); font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
  472. .board-ctrls button:hover { color: var(--accent); border-color: var(--accent); }
  473. .board-tip { position: absolute; left: 12px; bottom: 12px; z-index: 6; font-family: var(--mono); font-size: 10px; color: var(--faint);
  474. background: rgba(10,12,15,.6); padding: 3px 9px; border-radius: 7px; pointer-events: none; }
  475. /* 全屏 / 沉浸式:把过程视图(步进器 + 画板)提到 inset:0 覆盖层,画板吃满剩余高度 */
  476. #snapView.fs { position: fixed; inset: 0; z-index: 300; width: auto !important;
  477. background: var(--bg); padding: 14px 16px 0; display: flex; flex-direction: column; }
  478. #snapView.fs .snap-stepper { flex: 0 0 auto; padding: 4px 0 12px; margin: 0; }
  479. #snapView.fs .board { flex: 1 1 auto; height: auto !important; min-height: 0; margin-top: 0; }
  480. /* 评估 */
  481. .gn-sub { font-family: var(--mono); font-size: 10px; color: var(--faint); }
  482. /* 给槽位连线留出走线通道 */
  483. .io-col { display: flex; flex-direction: column; gap: 10px; flex: 0 0 auto; }
  484. /* 输入/输出卡加宽:保证一行放得下 3 张缩略图(3×52 + 2×5 = 166px + 内边距)而不换行,
  485. 卡片变宽也让整列更矮、更易读 */
  486. .io-col .pcard { width: 232px; }
  487. .io-col .pcard .pc-thumbs { flex-wrap: nowrap; }
  488. .io-col .pcard.pc-empty { border-style: dashed; opacity: .5; }
  489. /* 字段:标签在上、值在下(左右排会把值挤成窄条,读起来费劲) */
  490. .kv-list { display: flex; flex-direction: column; gap: 8px; margin-top: 9px; }
  491. .kv { display: flex; flex-direction: column; gap: 3px; }
  492. .kv-k { align-self: flex-start; font-family: var(--mono); font-size: 8.5px; color: rgb(var(--tone));
  493. border: 1px solid rgba(var(--tone), .4); border-radius: 4px; padding: 0 5px; }
  494. .kv-v { font-size: 11px; line-height: 1.55; color: var(--text); word-break: break-word;
  495. display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
  496. /* plan 的 ops:一条一块,说明 + 涉及的任务卡(操作标识已融进卡本身,见 .mm-card.mm-op;子任务缩进) */
  497. .ops-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
  498. .op-h { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; margin-bottom: 6px; }
  499. .op-detail { font-size: 10px; line-height: 1.5; color: var(--muted); }
  500. .ops-h { font-family: var(--mono); font-size: 9px; letter-spacing: .08em; color: var(--muted);
  501. margin-top: 12px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
  502. .op-tasks { display: flex; flex-direction: column; gap: 6px; }
  503. /* 操作态任务卡:与任务台账**同一种卡**,只是徽标位换成「本轮对它做了什么」、左侧色条按操作着色 */
  504. .mm-card.mm-op { border-left: 3px solid var(--border2); }
  505. .mm-card.mm-op.op-add { border-left-color: var(--ok); }
  506. .mm-card.mm-op.op-update { border-left-color: var(--run); }
  507. .mm-card.mm-op.op-requeue { border-left-color: var(--kind); }
  508. .mm-card.mm-op.op-drop { border-left-color: var(--fail); }
  509. .mm-card.mm-op.op-reorder, .mm-card.mm-op.op-next { border-left-color: var(--accent); }
  510. .mmc-badge.op-add { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
  511. .mmc-badge.op-update { color: var(--run); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.36); }
  512. .mmc-badge.op-requeue { color: var(--kind); background: rgba(245,181,68,.12); border-color: rgba(245,181,68,.36); }
  513. .mmc-badge.op-drop { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
  514. .mmc-badge.op-reorder, .mmc-badge.op-next { color: var(--accent); background: rgba(139,157,255,.12);
  515. border-color: rgba(139,157,255,.35); }
  516. .op-tasks .mm-card { width: auto; }
  517. .mm-card.mm-modified { box-shadow: 0 0 0 1.5px var(--run); }
  518. .mmc-chgs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
  519. .chg-f { font-family: var(--mono); font-size: 9px; color: var(--run); background: rgba(251,191,36,.12);
  520. border: 1px solid rgba(251,191,36,.35); border-radius: 5px; padding: 0 6px; }
  521. .op-child { padding-left: 16px; }
  522. /* 输出的交付件网格:图与「它是什么」成对,主件高亮 */
  523. .out-arts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
  524. .out-art { width: 64px; }
  525. .out-art img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; display: block;
  526. border: 1px solid var(--border); background: var(--bg); cursor: zoom-in; }
  527. .out-art .oa-tag { font-family: var(--mono); font-size: 8.5px; color: var(--muted); margin-top: 4px;
  528. text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  529. .out-art.is-primary img { border-color: rgb(var(--tone)); box-shadow: 0 0 0 1px rgba(var(--tone), .55); }
  530. .out-art.is-primary .oa-tag { color: rgb(var(--tone)); font-weight: 700; }
  531. .out-art.no-img { width: auto; max-width: 100%; }
  532. .out-art.no-img .oa-text { font-size: 10px; line-height: 1.5; color: var(--muted); border: 1px dashed var(--border2);
  533. border-radius: 8px; padding: 6px 8px; }
  534. /* 输出卡的摘要压到 3 行,别喧宾夺主 */
  535. .pcard.pc-final .pcard-sum { -webkit-line-clamp: 3; }
  536. .io-col .pcard.pc-final { border-width: 1.5px; border-color: rgb(var(--tone));
  537. background: rgba(var(--tone), .12); box-shadow: 0 0 0 1px rgba(var(--tone), .22); }
  538. /* 任务型输入槽:卡内嵌一串**任务卡**(与任务列表同款) */
  539. .pcard.pc-taskslot { width: 258px; cursor: default; }
  540. .pcard.pc-taskslot:hover { transform: none; border-color: var(--border2); }
  541. .task-strip { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
  542. .mm-card.mm-mini { width: auto; padding: 8px 10px 9px; }
  543. .mm-card.mm-mini .mmc-name { font-size: 11.5px; -webkit-line-clamp: 1; }
  544. .mm-card.mm-mini .mmc-goal { font-size: 10px; -webkit-line-clamp: 2; }
  545. .mm-card.st-planned { border-left: 3px solid var(--accent); }
  546. .mmc-badge.planned { color: var(--accent); background: rgba(139,157,255,.12); border-color: rgba(139,157,255,.35); }
  547. /* 「下一轮」节点:本轮产出回吐的落点,点击切到下一轮(那条回边落在下一轮,不属于本轮的模块图) */
  548. .next-round { position: absolute; z-index: 2; cursor: pointer; padding: 10px 16px; border-radius: 12px;
  549. border: 1px dashed rgba(139,157,255,.55); background: rgba(139,157,255,.08);
  550. transition: border-color .15s, background .15s; }
  551. .next-round:hover { border-color: var(--accent); background: rgba(139,157,255,.16); }
  552. .nr-cap { font-family: var(--mono); font-size: 10px; color: var(--faint); letter-spacing: .4px; }
  553. .nr-t { font-size: 12.5px; font-weight: 700; color: var(--accent); margin-top: 3px; }
  554. .slot-row .slot-empty { margin-left: auto; font-size: 9px; color: var(--faint); }
  555. /* 工序定义:一段一块(与运行流程里的子节点逐段对应) */
  556. .stage-row { border: 1px solid rgba(var(--tone), .35); background: rgba(var(--tone), .08); border-radius: 8px;
  557. padding: 6px 9px 7px; }
  558. .stage-row .stage-h { display: flex; align-items: center; gap: 6px; }
  559. .stage-row .stage-n { font-family: var(--mono); font-size: 9px; font-weight: 700; color: #0b0e13;
  560. background: rgb(var(--tone)); border-radius: 4px; padding: 0 5px; }
  561. .stage-row .nm { font-family: var(--mono); font-size: 10.5px; font-weight: 700; color: var(--text); }
  562. .stage-row .stage-io { font-size: 9.5px; line-height: 1.5; color: var(--muted); margin-top: 3px; }
  563. .stage-row .stage-io .io-k { font-family: var(--mono); font-size: 8.5px; color: rgb(var(--tone));
  564. border: 1px solid rgba(var(--tone), .45); border-radius: 4px;
  565. padding: 0 4px; margin-right: 5px; }
  566. .stage-row .stage-meta { font-family: var(--mono); font-size: 9px; color: var(--faint); margin-top: 3px;
  567. overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  568. .stage-row .stage-rules { margin-top: 5px; border-top: 1px dashed rgba(var(--tone), .3); padding-top: 5px; }
  569. .stage-row .rule { position: relative; padding-left: 11px; font-size: 9.5px; line-height: 1.55; color: var(--text); }
  570. .stage-row .rule::before { content: "▪"; position: absolute; left: 2px; color: rgb(var(--tone)); }
  571. .stage-arrow { text-align: center; font-size: 11px; color: rgba(var(--tone), .8); line-height: 1; margin: 1px 0; }
  572. /* 结构段:叶模块=一列;组合模块=左「本模块自身结构」+ 右「子模块结构链」两列并排。
  573. 宽度不写死——由内容在 min/max 之间自适应。 */
  574. .gs-own { flex: 0 0 auto; min-width: 0; display: flex; flex-direction: column; gap: 9px;
  575. min-width: 200px; max-width: 250px; }
  576. /* 槽位表:竖排一行一个槽(不再挤成 chip 云)——左侧输入盒逐行连线过来,线不打结 */
  577. .slot-list { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
  578. .slot-row { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 9.5px;
  579. color: var(--text); background: rgba(var(--tone), .10); border: 1px solid rgba(var(--tone), .30);
  580. border-radius: 6px; padding: 3px 8px; }
  581. .slot-row.off { color: var(--faint); border-style: dashed; background: transparent; opacity: .65; }
  582. .slot-row .dot { width: 5px; height: 5px; border-radius: 50%; background: rgb(var(--tone)); flex: 0 0 auto; }
  583. .slot-row.off .dot { background: var(--faint); }
  584. .slot-row .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  585. /* 结构里的「输入」槽位 ↔ 运行实例里的输入块:hover 任一侧,同键的两侧一起点亮(多个实例则各自那张都亮)。 */
  586. .slot-row.slot-hot { background: hsl(var(--mc) 60% 50% / .45); border-color: hsl(var(--mc) 70% 65%);
  587. color: var(--text); opacity: 1; }
  588. .slot-row.slot-hot .dot { background: hsl(var(--mc) 70% 68%); }
  589. .pcard.slot-hot { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 2px;
  590. background: hsl(var(--mc) 60% 50% / .26); }
  591. /* 输入/输出/结构卡上的构成 chips(如「输入由哪些块拼成」) */
  592. .pc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
  593. .pc-chip { font-family: var(--mono); font-size: 9px; color: var(--muted); background: var(--bg2);
  594. border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
  595. /* 槽位 chip:左侧输入盒连线的落点 */
  596. .pc-chip.slot-chip { color: var(--text); border-color: rgba(139,157,255,.42); background: rgba(139,157,255,.07); }
  597. .pc-note { font-family: var(--mono); font-size: 10px; color: var(--faint); margin-top: 7px; }
  598. /* 过程条:横向列序列,列高按内容自然撑开(不强制等高),列间留白供连线穿过 */
  599. /* 运行链**一行到底、不折行**:一条链就是一条链,折行会把「下一步」画到左边去,读起来是断的。
  600. 链再长也横着排(画板本就可平移缩放),步与步之间的留白留给 drawLaneEdges 画曲线箭头。 */
  601. /* 各步**竖向居中**对齐:链的轴线因此是一条直线,相邻两步的连线基本是平的;顶对齐时一个很高的
  602. 输入节点会把它的中心拉到别人下方好几百 px,连线就成了一条从画面外扫上来的大弧。 */
  603. .lane-steps { position: relative; display: flex; flex-wrap: nowrap; align-items: center;
  604. gap: 52px; }
  605. /* 链内连线画在泳道自己的 SVG 上:泳道背景之上、卡片之下(.lane-col 有 z-index:1),线因此是清晰的 */
  606. .lane-wires { position: absolute; left: 0; top: 0; z-index: 0; overflow: visible; pointer-events: none; }
  607. /* 冗余画了输入列的流程:各列竖向居中,运行卡对齐到输入整体的中心(见 leafLaneHtml) */
  608. .lane-empty { font-family: var(--mono); font-size: 11.5px; color: var(--faint); padding: 16px; }
  609. .lane-col { position: relative; z-index: 1; display: flex; flex-direction: column; }
  610. /* 并发批次列:同一 LLM 轮请求的多工具竖向并列(可见并发);分叉/汇聚由 lane-wires 连线体现 */
  611. .lane-col.conc { gap: 6px; }
  612. /* 非 Agent 模块在流程里冗余画的「输入」列:虚线描边 + 半透明,一眼看出它不是一步「跑动」,
  613. 而是喂进右边那一步的东西(连线由 lane-wires 照常画)。 */
  614. .board-none { margin: 48px auto; max-width: 520px; text-align: center;
  615. font-size: 12px; color: var(--faint); }
  616. .pc-conc-tag { font-family: var(--mono); font-size: 9.5px; font-weight: 700; letter-spacing: .6px; color: var(--accent); }
  617. .pc-parallel-stack { display: flex; flex-direction: column; gap: 14px; }
  618. .pcard { flex: 0 0 auto; width: 210px; border: 1px solid var(--border2); border-radius: 12px; background: var(--panel);
  619. padding: 10px 12px 11px; cursor: pointer; display: flex; flex-direction: column;
  620. transition: transform .12s, box-shadow .15s, border-color .15s; }
  621. .pcard:hover { transform: translateY(-2px); box-shadow: 0 12px 26px -14px rgba(0,0,0,.7); border-color: var(--accent); }
  622. .pc-plan { border-left: 3px solid var(--accent); } .pc-exec { border-left: 3px solid var(--ok); }
  623. .pc-eval { border-left: 3px solid var(--kind); border-style: dashed; }
  624. /* 失败节点(如生成报错):泳道里以红框 + 「✗ 失败」标记 */
  625. .pcard.pc-failed { border-color: var(--fail); border-left-color: var(--fail); background: linear-gradient(180deg, rgba(248,113,113,.07), var(--panel)); }
  626. .pcard.pc-failed .pc-label { color: var(--fail); }
  627. .pc-fail-badge { margin-left: auto; font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--fail);
  628. background: rgba(248,113,113,.14); border: 1px solid rgba(248,113,113,.45); border-radius: 5px; padding: 1px 5px; flex: 0 0 auto; }
  629. /* ── 旁路标注(perl_annotation):run 跑完后重放生成的「这步做了什么 + 依据来源」 ──
  630. **必须与运行时事实视觉分开**:卡上那些 args/result 是当时真实发生的,标注是另一个模型事后写的
  631. 解释;混在一片区域里,用的人迟早会把后者当前者。故这里独立底色 + 🔖 抬头。 */
  632. .pc-ann { margin: 7px -12px 0; padding: 7px 12px 8px; background: rgba(167,139,250,.09);
  633. border-top: 1px solid rgba(167,139,250,.22); border-bottom: 1px solid rgba(167,139,250,.16); }
  634. /* 摘要是这张卡上**最该被一眼读到**的东西("这步做了什么"),所以加粗提亮、字号压过其余行;
  635. 原来那条「🔖 标注 · 这步做了什么」抬头去掉了——紫色区块本身已经把"这是标注"说清楚了,
  636. 再加一行标签只是把视觉重心从内容拉到了说明上。 */
  637. .pc-ann-sum { font-size: 12.5px; line-height: 1.62; font-weight: 700; color: #ddd2ff;
  638. letter-spacing: .2px; }
  639. /* ── 工具卡的输入 / 输出:各成一段,图跟着自己那一段走 ──
  640. 以前是两行裸文本 + 图统一堆在卡底 —— 那样既看不出字段结构,也分不清哪张图是喂进去的、
  641. 哪张是产出的。现在一段一个小分区:入向左色条、出向右色条,缩略图就在各自分区里。 */
  642. .pc-io-sec { margin: 7px -12px 0; padding: 6px 12px 7px; border-top: 1px solid var(--border); }
  643. .pc-io-sec.in { border-left: 2px solid rgba(56,189,248,.55); }
  644. .pc-io-sec.out { border-left: 2px solid rgba(74,222,128,.55); }
  645. /* function calling:模型这一步**发出的调用**。与「输入/输出」区分开——它既不是喂给模型的输入,
  646. 也不是产物,而是模型的决策动作,下一步那个工具节点跑的就是它。
  647. **一次调用一块**:序号 + 工具名 + 该次入参;一轮发起多个就叠成多块(它们本就是一起发出的)。 */
  648. .pc-calls { margin: 8px -12px 0; border-top: 1px solid var(--border); }
  649. .pc-calls-h { font-family: var(--mono); font-size: 8.5px; font-weight: 700; letter-spacing: .6px;
  650. color: rgb(183,160,255); padding: 6px 12px 0; }
  651. .pc-call { margin-top: 5px; padding: 5px 12px 7px;
  652. border-left: 2px solid rgba(167,139,250,.6); background: rgba(167,139,250,.06); }
  653. .pcl-h { display: flex; align-items: center; gap: 6px; }
  654. .pcl-i { flex: 0 0 auto; width: 14px; height: 14px; border-radius: 50%; display: grid; place-items: center;
  655. font-family: var(--mono); font-size: 9px; color: var(--bg); background: rgba(167,139,250,.9); }
  656. .pcl-n { font-family: var(--mono); font-size: 11px; font-weight: 700; color: #c9b8ff; word-break: break-all; }
  657. .pcl-args { margin-top: 4px; }
  658. .pcl-args .pc-io-lead { font-size: 10.5px; -webkit-line-clamp: 2; }
  659. .pcl-args .io-chip { border-color: rgba(167,139,250,.35); }
  660. .pcl-thumbs { margin-top: 5px; gap: 4px; }
  661. .pcl-thumbs img { width: 40px; height: 40px; }
  662. .pcl-none { font-family: var(--mono); font-size: 9px; color: var(--faint); }
  663. .pc-io-h { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 8.5px;
  664. font-weight: 700; letter-spacing: .6px; margin-bottom: 5px; }
  665. .pc-io-h .io-ok { color: var(--ok); }
  666. .pc-io-h .io-bad { color: var(--fail); }
  667. /* 主文:这一侧最该被读到的那句话(query / prompt / thought) */
  668. .pc-io-lead { font-size: 11.5px; line-height: 1.6; color: var(--text); word-break: break-word;
  669. overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
  670. /* 标签:一批条目(工具名、字段名)——一枚枚,而不是一坨 JSON */
  671. .pc-io-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
  672. .pc-io-chips .io-chip { font-family: var(--mono); font-size: 9px; line-height: 1.5; color: var(--text);
  673. background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
  674. padding: 1px 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  675. white-space: nowrap; }
  676. .pc-io-sec.in .io-chip { border-color: rgba(56,189,248,.35); }
  677. .pc-io-sec.out .io-chip { border-color: rgba(74,222,128,.35); }
  678. .pc-io-sec.in .pc-io-h { color: rgba(56,189,248,.9); }
  679. .pc-io-sec.out .pc-io-h { color: rgba(74,222,128,.9); }
  680. .pc-io-kv { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; }
  681. .pc-io-kv .io-row { display: flex; gap: 6px; font-size: 10.5px; line-height: 1.5; }
  682. .pc-io-kv .io-k { flex: 0 0 auto; max-width: 74px; font-family: var(--mono); font-size: 9px; font-weight: 700;
  683. color: var(--faint); padding-top: 1px; overflow: hidden; text-overflow: ellipsis;
  684. white-space: nowrap; }
  685. .pc-io-kv .io-v { color: var(--muted); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2;
  686. -webkit-box-orient: vertical; word-break: break-all; }
  687. .pc-io-text { font-size: 10.5px; line-height: 1.55; color: var(--muted); overflow: hidden;
  688. display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; word-break: break-word; }
  689. .io-thumbs { margin-top: 6px; }
  690. .io-thumbs img { width: 46px; height: 46px; }
  691. /* 底部「决策依据」入口 */
  692. .pc-basis { margin: 8px -12px -11px; padding: 7px 12px; display: flex; align-items: center; gap: 6px;
  693. font-family: var(--mono); font-size: 9.5px; font-weight: 700; color: rgb(167,139,250);
  694. background: rgba(167,139,250,.10); border-top: 1px dashed rgba(167,139,250,.35);
  695. border-radius: 0 0 11px 11px; }
  696. .pc-basis { flex-wrap: wrap; }
  697. .pc-basis:hover { background: rgba(167,139,250,.2); }
  698. .pc-basis .bs-h { flex: 0 0 auto; }
  699. .pc-basis .bs-n { margin-left: auto; opacity: .8; }
  700. /* 一类来源一枚标签:图标 + 类型名 + 条数 —— 一眼看出这步是被 prompt 训导驱动的还是被输入驱动的 */
  701. .pc-basis .bs-tag { display: inline-flex; align-items: center; gap: 3px; font-size: 9px; font-weight: 600;
  702. color: var(--text); background: rgba(167,139,250,.16); border: 1px solid rgba(167,139,250,.3);
  703. border-radius: 5px; padding: 1px 5px; }
  704. .pc-basis .bs-tag b { font-family: var(--mono); color: rgb(167,139,250); }
  705. .pc-basis .bs-ic { opacity: .85; }
  706. /* 文档块顶部的引用跳转条:一处引用一枚,点了滚到该处并闪一下 */
  707. .src-jump { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 11px; border-bottom: 1px dashed var(--border2);
  708. background: rgba(255,255,255,.02); }
  709. .src-jump .sj { font-size: 10.5px; line-height: 1.5; color: var(--muted); background: var(--panel);
  710. border: 1px solid var(--border2); border-radius: 6px; padding: 2px 7px; cursor: pointer; }
  711. .src-jump .sj:hover { color: var(--text); border-color: rgb(167,139,250); }
  712. .src-jump .sj.off { opacity: .5; cursor: default; }
  713. .src-hl.flash { animation: hlflash .9s ease; }
  714. @keyframes hlflash { 0%,100% { background: rgba(245,181,68,.28); } 40% { background: rgba(245,181,68,.85); } }
  715. /* ── 左侧「依据来源」栏:点依据 → 依次列出各来源全文,并在其中高亮被引片段 ── */
  716. .srcbar { position: fixed; top: 0; left: 0; bottom: 0; width: 520px; max-width: 94vw; z-index: 342;
  717. background: linear-gradient(180deg, var(--panel), var(--bg2)); border-right: 1px solid var(--border2);
  718. box-shadow: 20px 0 50px -20px rgba(0,0,0,.7); transform: translateX(-102%);
  719. transition: transform .24s cubic-bezier(.4,0,.2,1); display: flex; flex-direction: column; }
  720. .srcbar.open { transform: none; }
  721. .srcbar-h { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
  722. .srcbar-tag { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 1px;
  723. padding: 3px 10px; border-radius: 7px; color: rgb(167,139,250); background: rgba(167,139,250,.14); }
  724. .srcbar-t { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden;
  725. text-overflow: ellipsis; white-space: nowrap; }
  726. .srcbar-x { margin-left: auto; width: 28px; height: 28px; border-radius: 8px; cursor: pointer; flex: 0 0 auto;
  727. display: flex; align-items: center; justify-content: center; color: var(--muted); background: var(--panel2); }
  728. .srcbar-x:hover { color: var(--text); }
  729. .srcbar-b { flex: 1; overflow: auto; padding: 14px 16px 40px; }
  730. .srcbar-sum { font-size: 12px; line-height: 1.7; color: var(--muted); background: rgba(167,139,250,.08);
  731. border: 1px solid rgba(167,139,250,.2); border-radius: 10px; padding: 9px 11px; margin-bottom: 14px; }
  732. .src-item { border: 1px solid var(--border2); border-radius: 11px; margin-bottom: 12px; overflow: hidden; }
  733. .src-h { display: flex; align-items: center; gap: 7px; padding: 8px 11px; background: var(--panel2);
  734. border-bottom: 1px solid var(--border); flex-wrap: wrap; }
  735. .src-n { font-family: var(--mono); font-size: 9px; font-weight: 700; color: #0b0e13; background: rgb(167,139,250);
  736. border-radius: 5px; padding: 1px 6px; flex: 0 0 auto; }
  737. .src-kind { font-family: var(--mono); font-size: 9px; color: var(--faint); border: 1px solid var(--border2);
  738. border-radius: 5px; padding: 1px 6px; }
  739. .src-title { font-size: 11.5px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden;
  740. text-overflow: ellipsis; white-space: nowrap; }
  741. .src-loc { margin-left: auto; font-family: var(--mono); font-size: 9px; flex: 0 0 auto; }
  742. .src-loc.ok { color: var(--ok); } .src-loc.no { color: var(--warn, #f5b544); }
  743. .src-body { padding: 10px 12px; font-size: 11.5px; line-height: 1.8; color: var(--muted);
  744. white-space: pre-wrap; word-break: break-word; max-height: 340px; overflow: auto; }
  745. /* markdown 渲染态:**直接复用 .pp-md**(右侧详情抽屉用的就是它)——同一种东西只该有一种画法,
  746. 另起一套字号/间距只会让同一份 prompt 在两个面板里长得不一样。这里只解掉 pre-wrap。 */
  747. .src-body.md { white-space: normal; padding: 0; }
  748. /* `.pp-md` 自带 `max-height:340px; overflow:auto`(它原本是抽屉里的小块富文本)。套进来源栏后
  749. 就成了**内层滚动条**:外层 .src-body 撑到 635px 却只显示 340px 的内容 → 下方一片空白,而
  750. jumpHl 滚的是外层(它根本没有可滚区)→ 点跳转纹丝不动。这里把滚动权交还给 .src-body。 */
  751. .src-body .pp-md { max-height: none; overflow: visible; }
  752. /* 只有一块来源 → 铺满剩余高度(一份 4600 字的 prompt 挤在 340px 里根本没法读) */
  753. .srcbar-b.single { display: flex; flex-direction: column; }
  754. .srcbar-b.single .src-item { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
  755. .srcbar-b.single .src-body { flex: 1; max-height: none; }
  756. .src-body img { max-width: 100%; border-radius: 8px; }
  757. .src-hl { background: rgba(245,181,68,.28); color: var(--text); border-bottom: 2px solid rgba(245,181,68,.9);
  758. border-radius: 3px; padding: 1px 0; }
  759. .src-quote { padding: 8px 12px; font-size: 11px; line-height: 1.7; color: var(--muted);
  760. border-top: 1px dashed var(--border2); background: rgba(255,255,255,.02); }
  761. /* 工具清单:用过的高亮(本次真的动用过这项能力),没用过的压暗 */
  762. .tool-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
  763. .tool-chip { display: inline-flex; align-items: center; gap: 4px; font-family: var(--mono); font-size: 9px;
  764. line-height: 1.6; color: var(--faint); background: var(--panel2);
  765. border: 1px solid var(--border2); border-radius: 5px; padding: 1px 6px; opacity: .62; }
  766. .tool-chip.used { color: var(--ok); border-color: rgba(74,222,128,.55);
  767. background: rgba(74,222,128,.12); opacity: 1; font-weight: 700; }
  768. .tool-chip .tc-dot { width: 4px; height: 4px; border-radius: 50%; background: var(--ok); flex: 0 0 auto; }
  769. /* 详情里的工具项:默认折叠,只露一行标题;点开才显示说明与参数 */
  770. .tool-item .tool-hd { display: flex; align-items: center; gap: 7px; cursor: pointer; flex-wrap: wrap; }
  771. .tool-item .tool-hd:hover { color: var(--accent); }
  772. .tool-item .tool-arrow { display: inline-block; font-size: 10px; color: var(--faint);
  773. transition: transform .15s; }
  774. .tool-item.open .tool-arrow { transform: rotate(90deg); color: var(--accent); }
  775. .tool-item .tool-meta { margin-left: auto; font-family: var(--mono); font-size: 9.5px; color: var(--faint);
  776. font-weight: 400; }
  777. .tool-item .tool-bd { display: none; margin-top: 9px; }
  778. .tool-item.open .tool-bd { display: block; }
  779. .tool-used-tag { margin-left: 8px; font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--ok);
  780. background: rgba(74,222,128,.14); border: 1px solid rgba(74,222,128,.45);
  781. border-radius: 5px; padding: 1px 6px; }
  782. /* 工具参数表:一行一个参数(名 / 类型 / 必填 / 说明) */
  783. .tool-params { margin-top: 9px; border-top: 1px dashed var(--border2); padding-top: 8px;
  784. display: flex; flex-direction: column; gap: 5px; }
  785. .tp-row { display: flex; align-items: baseline; gap: 8px; font-size: 11.5px; line-height: 1.6; flex-wrap: wrap; }
  786. .tp-n { font-family: var(--mono); font-weight: 700; color: var(--text); }
  787. .tp-t { font-family: var(--mono); font-size: 10px; color: var(--accent); }
  788. .tp-r { font-family: var(--mono); font-size: 9px; color: var(--faint); border: 1px solid var(--border2);
  789. border-radius: 4px; padding: 0 4px; }
  790. .tp-r.req { color: var(--warn, #f5b544); border-color: rgba(245,181,68,.5); }
  791. .tp-d { color: var(--muted); flex: 1 1 240px; min-width: 0; }
  792. .id-chip { font-family: var(--mono); font-size: 8.5px; font-weight: 600; color: var(--faint);
  793. background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
  794. padding: 1px 5px; cursor: pointer; user-select: all; flex: 0 0 auto; }
  795. .id-chip:hover { color: var(--text); border-color: var(--accent); }
  796. .id-chip.copied { color: var(--ok); border-color: var(--ok); }
  797. /* 段标题里的 id:段标题是深色徽标底,这里配一档亮色 */
  798. .pc-id { align-self: flex-start; margin: -2px 0 5px; }
  799. .pcard-h { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
  800. .pcard-h .pc-ic { font-size: 12px; flex: 0 0 auto; }
  801. .pc-label { font-family: var(--mono); font-size: 10.5px; font-weight: 700; color: var(--text); overflow: hidden;
  802. text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
  803. .pc-tok { margin-left: auto; font-family: var(--mono); font-size: 9px; color: var(--faint); flex: 0 0 auto; }
  804. /* 思考(extended thinking / reasoning):与正文分开的一段 —— 「怎么想的」和「说了什么」不是一回事。
  805. 斜体 + 淡紫左条,视觉上比正文轻一档;卡面压到 8 行,全文在详情里。 */
  806. .pc-think { margin: 8px -12px 0; padding: 6px 12px 7px; border-top: 1px solid var(--border);
  807. border-left: 2px solid rgba(167,139,250,.45); background: rgba(167,139,250,.05); }
  808. .pc-think-h { font-family: var(--mono); font-size: 8.5px; font-weight: 700; letter-spacing: .6px;
  809. color: rgba(183,160,255,.9); margin-bottom: 4px; }
  810. .pc-think-b { font-size: 11px; line-height: 1.6; color: var(--muted); font-style: italic;
  811. word-break: break-word; overflow: hidden; display: -webkit-box;
  812. -webkit-line-clamp: 8; -webkit-box-orient: vertical; }
  813. /* 模型推理节点:与工具节点**一眼可分** —— 紫色左条 + 略宽,正文(模型在想什么)多给几行。
  814. 它是链上做决策的那一步,后面那些工具节点跑的都是它这一步发出的 function calling。 */
  815. .pcard.pc-llm { width: 252px; border-left: 3px solid rgba(167,139,250,.85);
  816. background: linear-gradient(180deg, rgba(167,139,250,.07), var(--panel)); }
  817. .pcard.pc-llm .pcard-sum { -webkit-line-clamp: 8; }
  818. .pcard-sum { font-size: 11.5px; line-height: 1.55; color: var(--muted); flex: 1;
  819. display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
  820. .pc-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
  821. .pc-thumbs img { width: 52px; height: 52px; object-fit: cover; border-radius: 7px; border: 1px solid var(--border); background: var(--bg); cursor: zoom-in; }
  822. .pc-more { margin-top: 8px; font-family: var(--mono); font-size: 9.5px; color: var(--faint); }
  823. .pcard:hover .pc-more { color: var(--accent); }
  824. .mm-card.mm-round-exec { box-shadow: 0 0 0 2px var(--ok), 0 0 22px -6px rgba(52,211,153,.5); }
  825. /* **不设 max-width**:链里可能装着组合模块的格子(它自身又是两列),封顶就撑不开、
  826. 内容会溢出到相邻段上。宽度由内容决定,只留下限防过窄。 */
  827. .sub-struct { flex: 0 0 auto; min-width: max-content; border: 1px solid rgba(var(--tone), .42); background: rgba(var(--tone), .09);
  828. border-radius: 10px; padding: 7px 9px 8px; }
  829. .sub-struct.off { border-style: dashed; opacity: .6; background: transparent; }
  830. /* 结构格内部与最外层**同一规则**:叶=一列;组合=左「本模块自身结构」+ 右「子模块结构链」两列。
  831. 这条规则逐层适用——嵌多深都是这个形状。 */
  832. .sub-struct .gs-own { min-width: 196px; max-width: 260px; }
  833. .gs-own > .pcard { width: auto; background: rgba(14,17,22,.78); border-color: rgba(var(--tone), .3); }
  834. /* ① 输入段 / ③ 运行流程段 / ④ 输出段:一个子模块一个框,三列逐框对齐(等高,见 syncCompositeRows) */
  835. .sub-in { align-self: stretch; min-width: max-content; }
  836. .sub-in {
  837. --tone: 56,189,248; border: 1px solid rgba(var(--tone), .40);
  838. background: rgba(var(--tone), .06); border-radius: 12px; padding: 8px 10px 10px; }
  839. /* 同一行的四段(输入框 / 结构格 / 运行框 / 输出框)用**同一个** --tone —— 底色一致即为同一个子模块,
  840. 横着扫一眼就能对上,不必去读标题。tone 由 modTone(该行的第一路) 统一决定,四处同源。 */
  841. .sub-in.off { border-style: dashed; opacity: .55; background: transparent; }
  842. /* 模块块按**调用树横向生长**摆位(x = 上游右缘 + 间距、y = 上游顶边,兄弟向下堆),
  843. 具体坐标由 placeBlocks() 量完实际尺寸后写进 style.left/top —— CSS 只管块自己长什么样。 */
  844. #boardWorld { position: relative; width: max-content; }
  845. .mod-block { position: absolute; z-index: 2; display: flex; align-items: stretch; width: max-content;
  846. border: 1px solid hsl(var(--mc) 60% 55% / .45); border-radius: 14px;
  847. background: hsl(var(--mc) 60% 50% / .05); }
  848. /* 这一列的底色是模块色 .40(比页面底色亮不少)——faint / muted 两个次要文字色本是为近乎透明的
  849. 底色调的深灰,放这儿会糊掉。**在本列内重定义这两个变量**(自定义属性向下继承):所有用到它们的
  850. 子元素(标题 / 版本 / 摘要 / 流程图标签 / 源码路径…)一次性提亮,不必逐个枚举选择器。 */
  851. .mod-def { flex: 0 0 268px; display: flex; flex-direction: column; gap: 6px; padding: 14px;
  852. --faint: #a6b1c0; --muted: #ccd4e0;
  853. border-right: 1px solid hsl(var(--mc) 60% 55% / .35);
  854. background: hsl(var(--mc) 60% 50% / .40); border-radius: 14px 0 0 14px; }
  855. .md-cap { display: flex; align-items: center; gap: 7px;
  856. font-family: var(--mono); font-size: 10.5px; color: var(--faint); letter-spacing: .4px; }
  857. /* 调用树上的深度:L0 是本轮入口,往右一层加一 */
  858. .md-depth { font-family: var(--mono); font-size: 9.5px; font-weight: 700; color: hsl(var(--mc) 70% 72%);
  859. border: 1px solid hsl(var(--mc) 60% 55% / .5); border-radius: 5px; padding: 0 5px; }
  860. .md-key { font-family: var(--mono); font-size: 13px; font-weight: 700;
  861. color: hsl(var(--mc) 70% 72%); word-break: break-all; }
  862. .md-meta { display: flex; gap: 6px; flex-wrap: wrap; font-family: var(--mono); font-size: 10.5px; }
  863. .md-kind { padding: 1px 7px; border-radius: 6px; border: 1px solid hsl(var(--mc) 60% 55% / .5);
  864. color: hsl(var(--mc) 70% 72%); }
  865. .md-ver { color: var(--faint); }
  866. .md-sum { font-size: 11.5px; color: var(--muted); line-height: 1.6; }
  867. .md-cards { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
  868. .mod-runs { flex: 1; min-width: max-content; display: flex; flex-direction: column; gap: 12px; padding: 12px 14px; }
  869. .mr-cap { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
  870. /* 一个运行实例 = 一条泳道。同一个模块的 N 次运行就是 N 条,纵向排开。 */
  871. .inst-lane { border: 1px dashed hsl(var(--mc) 60% 55% / .45); border-radius: 10px;
  872. padding: 10px 12px; background: hsl(var(--mc) 60% 50% / .05); width: max-content; max-width: 100%; }
  873. .inst-lane.off { border-style: dotted; opacity: .55; }
  874. .inst-lane.focus { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 2px; }
  875. /* 悬停展开引用时:被指向的那条泳道 / 那个 call 芯片一起点亮 —— 线与两端同时可辨 */
  876. .inst-lane.hot { outline: 2px dashed hsl(var(--mc) 70% 62%); outline-offset: 2px;
  877. background: hsl(var(--mc) 60% 50% / .32); }
  878. .call-chip.hot { border-style: solid; box-shadow: 0 0 0 2px hsl(var(--mc) 70% 62% / .55); }
  879. .il-cap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px;
  880. font-family: var(--mono); font-size: 11px; color: var(--muted); }
  881. .il-cap .il-t { color: var(--fg); }
  882. .il-cap .il-flags { display: flex; align-items: center; gap: 6px; margin-left: auto; }
  883. .il-from { cursor: pointer; text-decoration: underline dotted; color: var(--faint); }
  884. .il-from:hover { color: var(--fg); }
  885. /* 链上的箭头不再是文字「→」,而是 SVG 实线曲线(drawLaneEdges),颜色 = 所在模块的 --mc。 */
  886. /* 首尾两个节点:输入是圆头、输出是右向箭头形 —— 与中间的运行节点区分开 */
  887. /* 宽度**跟着里面的卡走**(width:max-content):卡是定宽的(普通 210 / llm 252 / 任务槽 258),
  888. 写死 max-width:240px 会让任务槽那种更宽的卡直接顶出框外。 */
  889. .io-node { flex: 0 0 auto; width: max-content; padding: 8px 10px;
  890. border: 1px solid hsl(var(--mc) 60% 55% / .5); background: hsl(var(--mc) 60% 50% / .08); }
  891. .io-node.io-in { border-radius: 22px 10px 10px 22px; }
  892. .io-node.io-out { border-radius: 10px 22px 22px 10px; }
  893. .ion-cap { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-bottom: 6px; }
  894. .ion-body { display: flex; flex-direction: column; gap: 6px; }
  895. /* 引用外部模块:流程里的一步(虚线框以示"这一步在别处跑")。
  896. 配色用**被调模块自己的** --mc(由 hueStyle 内联覆盖)——这样芯片、结构图里那个引用节点、
  897. 跨块的引用连线、以及目标模块块本身,四处同色,顺着颜色就能一眼追到底。 */
  898. .call-chip { min-width: 180px; max-width: 230px; padding: 9px 11px; cursor: pointer;
  899. border: 1px dashed hsl(var(--mc) 60% 55% / .6); border-radius: 10px;
  900. background: hsl(var(--mc) 60% 50% / .10);
  901. font-family: var(--mono); font-size: 11.5px; line-height: 1.6; }
  902. .call-chip:hover { border-color: hsl(var(--mc) 70% 65%); box-shadow: 0 2px 10px rgba(0,0,0,.25); }
  903. .call-chip .cc-h { color: var(--faint); font-size: 10.5px; }
  904. .call-chip .cc-k { font-weight: 600; word-break: break-all; color: hsl(var(--mc) 70% 72%); }
  905. .call-chip .cc-i { color: var(--faint); }
  906. /* ── 结构按 kind 分化 ─────────────────────────────────────────────────────
  907. workflow = 一张流程图(节点只写「引用哪个模块 + 跑几次」,点节点跳到那个模块)
  908. code = 它的源码本身(散文规则说不清也会漂移,源码不会)
  909. agent = 原有的结构卡(system prompt / 上下文构成 / 工具 / 模型) */
  910. .fc { display: flex; flex-direction: column; gap: 0; margin-bottom: 8px; }
  911. .fc-node { display: flex; gap: 8px; align-items: flex-start; padding: 8px 10px;
  912. border: 1px solid hsl(var(--mc) 60% 55% / .45); border-radius: 9px;
  913. background: hsl(var(--mc) 60% 50% / .10); }
  914. .fc-node.ref { cursor: pointer; background: hsl(var(--mc) 60% 50% / .40); }
  915. .fc-node.ref:hover { border-color: hsl(var(--mc) 70% 65%); }
  916. .fc-i { flex: 0 0 auto; width: 17px; height: 17px; border-radius: 50%; font-size: 10px;
  917. display: grid; place-items: center; color: var(--bg); background: hsl(var(--mc) 60% 62%);
  918. font-family: var(--mono); }
  919. .fc-b { min-width: 0; }
  920. .fc-t { font-size: 12px; color: var(--text); }
  921. .fc-k { font-family: var(--mono); font-size: 10.5px; color: hsl(var(--mc) 70% 72%); word-break: break-all; }
  922. .fc-x { display: inline-block; margin-top: 3px; padding: 0 6px; border-radius: 5px;
  923. font-family: var(--mono); font-size: 9.5px; color: var(--faint); border: 1px solid var(--line); }
  924. /* 边:竖线 + 基数徽标 + 边标签 —— 1:N / N:1 直接读出扇出与汇聚 */
  925. .fc-edge { display: flex; align-items: center; gap: 6px; padding: 3px 0 3px 8px; margin-left: 8px;
  926. border-left: 2px solid hsl(var(--mc) 60% 55% / .45); min-height: 20px; }
  927. .fc-card { font-family: var(--mono); font-size: 9.5px; color: hsl(var(--mc) 70% 70%);
  928. border: 1px solid hsl(var(--mc) 60% 55% / .5); border-radius: 5px; padding: 0 5px; }
  929. .fc-l { font-size: 10.5px; color: var(--faint); }
  930. .src-card { border: 1px solid var(--line); border-radius: 9px; overflow: hidden; margin-bottom: 8px; }
  931. .src-h { display: flex; align-items: center; gap: 8px; padding: 6px 9px; background: var(--bg2);
  932. font-family: var(--mono); font-size: 10.5px; border-bottom: 1px solid var(--line); }
  933. .src-q { color: hsl(var(--mc) 70% 72%); font-weight: 600; }
  934. .src-f { color: var(--faint); margin-left: auto; }
  935. .src-code { margin: 0; padding: 9px 10px; max-height: 340px; overflow: auto;
  936. font-family: var(--mono); font-size: 10.5px; line-height: 1.65; color: var(--text);
  937. white-space: pre; background: rgba(0,0,0,.18); }
  938. #drawerBody .src-code { max-height: none; font-size: 12.5px; line-height: 1.7; } /* 抽屉里看全文:不限高 */
  939. /* 结构模块里 code 只给一条「查看完整代码」入口(不内联源码 → 结构列宽度与 agent 模块一致)。 */
  940. .src-more { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer;
  941. font-family: var(--mono); font-size: 10.5px; color: hsl(var(--mc) 70% 72%);
  942. background: var(--bg2); border-top: 1px solid var(--line); }
  943. .src-more:hover { color: var(--text); background: hsl(var(--mc) 60% 50% / .12); }
  944. .mod-block.focus { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 3px; }
  945. /* 悬停结构里的引用时:两端的模块块一起点亮(连线 + 两端,三者同时可辨) */
  946. .mod-block.hot { outline: 2px dashed hsl(var(--mc) 70% 62%); outline-offset: 3px; }
  947. .fc-node.hot, .fc-node[data-to]:hover { border-color: hsl(var(--mc) 70% 65%);
  948. background: hsl(var(--mc) 60% 50% / .2); }
  949. </style>
  950. </head>
  951. <body>
  952. <div class="app">
  953. <aside class="sidebar">
  954. <div class="brand">
  955. <div class="brand-top">
  956. <span class="logo">Production&nbsp;Build</span>
  957. <span class="logo-sub">Runs</span>
  958. </div>
  959. <div class="brand-desc">以运行事实为主 ·
  960. <span class="loop-glyph">plan → <b>execute</b> → validate</span></div>
  961. </div>
  962. <div class="controls">
  963. <div class="seg" id="statusSeg">
  964. <button data-s="" class="active">全部</button>
  965. <button data-s="success">成功</button>
  966. <button data-s="running">运行中</button>
  967. <button data-s="failed">失败</button>
  968. </div>
  969. <div class="ctl-row">
  970. <span class="count" id="runCount">—</span>
  971. <button class="icon-btn" id="refreshBtn" title="刷新">⟳</button>
  972. </div>
  973. </div>
  974. <div class="runlist" id="runlist"><div class="hint">加载中…</div></div>
  975. <div class="pager" id="pager"></div>
  976. </aside>
  977. <main class="main" id="main">
  978. <div class="empty" id="emptyState">
  979. <div class="big">◍ ⇄ ◍</div>
  980. <div class="lp">选择左侧一个 Run · 查看 <b>规划</b>、<i>执行</i>与验收链路</div>
  981. </div>
  982. </main>
  983. </div>
  984. <div class="drawer-scrim" id="drawerScrim" onclick="closeDrawer()"></div>
  985. <aside class="drawer" id="drawer">
  986. <div class="drawer-h">
  987. <span class="drawer-tag" id="drawerTag"></span>
  988. <span class="drawer-title" id="drawerTitle"></span>
  989. <div class="drawer-x drawer-refresh" onclick="refreshLoop(this)" title="按当前 Loop 拉取最新">⟳</div>
  990. <div class="drawer-x" onclick="closeDrawer()">✕</div>
  991. </div>
  992. <div class="drawer-b" id="drawerBody"></div>
  993. </aside>
  994. <!-- 依据来源栏(左侧):点某个工具节点底部的「决策依据」→ 依次列出各来源全文 + 高亮被引片段。
  995. 旁路数据,没有标注时这个栏永远不出现。 -->
  996. <aside class="srcbar" id="srcbar">
  997. <div class="srcbar-h">
  998. <span class="srcbar-tag">🔖 依据来源</span>
  999. <span class="srcbar-t" id="srcbarTitle"></span>
  1000. <div class="srcbar-x" onclick="closeSrcBar()">✕</div>
  1001. </div>
  1002. <div class="srcbar-b" id="srcbarBody"></div>
  1003. </aside>
  1004. <div class="lb" id="lb">
  1005. <div class="lb-close" onclick="closeLB()">✕</div>
  1006. <div class="lb-nav prev" onclick="stepLB(-1,event)">‹</div>
  1007. <img id="lbImg" src="" alt="" referrerpolicy="no-referrer">
  1008. <div class="lb-nav next" onclick="stepLB(1,event)">›</div>
  1009. <div class="lb-counter" id="lbCounter"></div>
  1010. </div>
  1011. <script>
  1012. "use strict";
  1013. const $ = (s, r=document) => r.querySelector(s);
  1014. const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
  1015. const isImgUrl = u => typeof u === "string" &&
  1016. (/^https?:\/\//.test(u) || /^\/api\/runs\/\d+\/media\//.test(u)) &&
  1017. (/\.(png|jpe?g|webp|gif|bmp|avif)(\?|$)/i.test(u) || /image|img|oss|cos|xhscdn|sns-|ci\./i.test(u));
  1018. // ── state ──
  1019. let RUNS = [], TOTAL = 0, PAGE = 1, PAGE_SIZE = 20, STATUS = "", CURRENT = null, LOADING = false;
  1020. let LBLIST = [], LBIDX = 0;
  1021. // ── list(翻页加载:首屏 + 滚动到底自动追加下一页)──
  1022. async function loadRuns(reset = true) {
  1023. if (LOADING) return;
  1024. LOADING = true;
  1025. if (reset) { PAGE = 1; RUNS = []; }
  1026. renderFooter();
  1027. const r = await fetch(`/api/runs?status=${encodeURIComponent(STATUS)}&page=${PAGE}&page_size=${PAGE_SIZE}`);
  1028. const d = await r.json();
  1029. TOTAL = d.total;
  1030. RUNS = reset ? d.runs : RUNS.concat(d.runs);
  1031. $("#runCount").textContent = `${TOTAL} 个 Run`;
  1032. LOADING = false; // 先落 loading 再渲染页脚,否则页脚卡在「加载中…」
  1033. renderList(); renderFooter();
  1034. }
  1035. const hasMore = () => RUNS.length < TOTAL;
  1036. function loadMore() {
  1037. if (LOADING || !hasMore()) return;
  1038. PAGE += 1; loadRuns(false);
  1039. }
  1040. // 深链:#run=N[&round=K] 打开任务并定位到第 K 轮(1-based;缺省第一轮)
  1041. let PENDING_ROUND = 1; // selectRun → loadSnapshots 之间传递「要停在第几轮」
  1042. const curRound = () => (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length ? SNAP.idx + 1 : 1);
  1043. function parseHash() {
  1044. const m = /run=(\d+)/.exec(location.hash), r = /round=(\d+)/.exec(location.hash);
  1045. return { run: m ? +m[1] : null, round: r ? Math.max(1, +r[1]) : null };
  1046. }
  1047. // 当前选中的任务 + 轮次写回地址栏(replaceState 不触发 hashchange,不会回灌 openFromHash)
  1048. function syncHash() {
  1049. if (CURRENT == null) return;
  1050. const h = `#run=${CURRENT}` + (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length ? `&round=${SNAP.idx + 1}` : "");
  1051. if (location.hash !== h) history.replaceState(null, "", h);
  1052. }
  1053. async function openFromHash() {
  1054. const { run, round } = parseHash();
  1055. if (run == null) return;
  1056. if (CURRENT !== run) { await selectRun(run, round || 1); return; }
  1057. if (round && SNAP && SNAP.loaded) gotoSnap(round - 1);
  1058. }
  1059. window.addEventListener("hashchange", openFromHash);
  1060. function timeAgo(iso) {
  1061. if (!iso) return "";
  1062. const s = (Date.now() - new Date(iso).getTime()) / 1000;
  1063. if (s < 60) return "刚刚";
  1064. if (s < 3600) return Math.floor(s/60) + "分钟前";
  1065. if (s < 86400) return Math.floor(s/3600) + "小时前";
  1066. return Math.floor(s/86400) + "天前";
  1067. }
  1068. const fmtDur = s => s == null ? "—" : (s < 60 ? s.toFixed(1)+"s" : Math.floor(s/60)+"m"+Math.round(s%60)+"s");
  1069. const fmtTok = n => n >= 1000 ? (n/1000).toFixed(1).replace(/\.0$/,"")+"k" : String(n);
  1070. function renderList() {
  1071. const el = $("#runlist");
  1072. if (!RUNS.length) { el.innerHTML = `<div class="hint">暂无 Run 记录</div>`; return; }
  1073. el.innerHTML = RUNS.map(r => `
  1074. <div class="runcard ${CURRENT===r.id?"active":""}" data-s="${r.status}" onclick="selectRun(${r.id})">
  1075. <div class="rc-top">
  1076. <span class="rc-id">${esc(r.run_key || `#${r.id}`)}</span>
  1077. <span class="rc-agent">${esc(r.agent_name)}</span>
  1078. <span style="margin-left:auto"><span class="badge ${r.status} ${r.status==='running'?'dot':''}">${r.status}</span></span>
  1079. </div>
  1080. <div class="rc-obj">${esc(r.objective || "(无目标描述)")}</div>
  1081. <div class="rc-meta">
  1082. <span><b>${r.step_count}</b> 节点</span>
  1083. <span>replan <b>${r.replan_count}</b></span>
  1084. <span>${fmtTok(r.input_tokens)}/${fmtTok(r.output_tokens)} tok</span>
  1085. <span>${r.cost_status === "unavailable" ? "成本 —" : `$${r.cost_usd.toFixed(4)}`}</span>
  1086. <span style="margin-left:auto">${timeAgo(r.start_time)}</span>
  1087. </div>
  1088. </div>`).join("");
  1089. }
  1090. function renderFooter() {
  1091. const el = $("#pager");
  1092. if (LOADING) { el.innerHTML = `<span class="pi">加载中…</span>`; return; }
  1093. if (hasMore()) { el.innerHTML = `<button onclick="loadMore()">加载更多 · ${RUNS.length}/${TOTAL}</button>`; return; }
  1094. el.innerHTML = RUNS.length ? `<span class="pi">已全部加载 · ${RUNS.length}</span>` : "";
  1095. }
  1096. // 滚动到底部附近自动追加下一页
  1097. $("#runlist").addEventListener("scroll", () => {
  1098. const el = $("#runlist");
  1099. if (el.scrollTop + el.clientHeight >= el.scrollHeight - 140) loadMore();
  1100. });
  1101. // ── detail ──
  1102. async function selectRun(id, round = 1) {
  1103. CURRENT = id; PENDING_ROUND = Math.max(1, round || 1); renderList();
  1104. if (location.hash !== `#run=${id}&round=${PENDING_ROUND}`) history.replaceState(null, "", `#run=${id}&round=${PENDING_ROUND}`);
  1105. $("#emptyState")?.remove();
  1106. $("#main").innerHTML = `<div class="loading">载入任务 #${id} <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
  1107. const r = await fetch(`/api/runs/${id}`);
  1108. if (!r.ok) { $("#main").innerHTML = `<div class="loading">任务 #${id} 载入失败</div>`; return; }
  1109. renderDetail(await r.json());
  1110. $("#main").scrollTop = 0;
  1111. }
  1112. // ─────────────────────────────────────────────────────────────
  1113. // 数据重构:把「节点时间线」还原成「任务(Task)模型」+「循环过程」
  1114. // task = { step_id, kind, name, goal, acceptance, inputs, parent_id,
  1115. // isContainer, status, attempts:[{n,feedback,out,url,verdict,traces}], pending }
  1116. // ─────────────────────────────────────────────────────────────
  1117. function normStep(step) {
  1118. if (step && typeof step === "object") return step;
  1119. return { name: String(step ?? ""), kind: "", goal: "", acceptance: [], inputs: [] };
  1120. }
  1121. // result 可能是裸字符串或 {output:{...}, ok};抽出展示用 output
  1122. function resultOutput(result) {
  1123. if (result == null) return null;
  1124. if (typeof result === "string") return { text: result };
  1125. if (typeof result === "object") return result.output != null ? result.output : result;
  1126. return { text: String(result) };
  1127. }
  1128. function outUrl(out) { return out && (out.url || (isImgUrl(out.text) ? out.text : null)); }
  1129. function outText(out) {
  1130. if (out == null) return null;
  1131. if (out.text != null && !isImgUrl(out.text)) return out.text;
  1132. if (typeof out === "string") return out;
  1133. if (out.url) return null;
  1134. const keys = Object.keys(out).filter(k => k !== "type");
  1135. return keys.length ? JSON.stringify(out, null, 2) : null;
  1136. }
  1137. function buildModel(steps) {
  1138. const tasks = new Map(); // key -> task
  1139. const order = [];
  1140. const stepKey = (st, nodeIdx) => st.step_id != null ? "s" + st.step_id : "x" + nodeIdx;
  1141. const ensure = (key, st) => {
  1142. if (!tasks.has(key)) {
  1143. tasks.set(key, { key, step_id: st.step_id ?? null, kind: st.kind || "", name: st.name || "",
  1144. goal: st.goal || "", acceptance: st.acceptance || [], inputs: st.inputs || [],
  1145. parent_id: st.parent_id ?? null, stage: st.stage || "", category: st.category || "",
  1146. isContainer: false, attempts: [], pending: false, pendingFeedback: null });
  1147. order.push(key);
  1148. }
  1149. return tasks.get(key);
  1150. };
  1151. const merge = (t, st) => { // 用最新已知的 step 字段补全任务元信息
  1152. if (st.name) t.name = st.name;
  1153. if (st.kind) t.kind = st.kind;
  1154. if (st.goal) t.goal = st.goal;
  1155. if (st.acceptance && st.acceptance.length) t.acceptance = st.acceptance;
  1156. if (st.inputs && st.inputs.length) t.inputs = st.inputs;
  1157. if (st.parent_id != null) t.parent_id = st.parent_id;
  1158. if (st.stage) t.stage = st.stage;
  1159. if (st.category) t.category = st.category;
  1160. };
  1161. // 1) 已执行任务(含重试):按 step_id 归组,每条 past_steps = 一次 attempt
  1162. for (const s of steps) {
  1163. if (s.node_type !== "execute") continue;
  1164. for (const e of (s.delta.past_steps || [])) {
  1165. const st = normStep(e.step);
  1166. const t = ensure(stepKey(st, s.step_index), st);
  1167. merge(t, st);
  1168. const out = resultOutput(e.result);
  1169. t.attempts.push({ n: t.attempts.length + 1, nodeIdx: s.step_index,
  1170. feedback: st._feedback || null, out, url: outUrl(out), text: outText(out),
  1171. verdict: e.verdict || null });
  1172. }
  1173. }
  1174. // 2) 容器(被拆分的父 Task):取最后一个带 containers 的 plan 节点
  1175. let containers = [];
  1176. for (const s of steps) if (Array.isArray(s.delta.containers) && s.delta.containers.length) containers = s.delta.containers;
  1177. for (const c of containers) {
  1178. const st = normStep(c);
  1179. const t = ensure(stepKey(st, "c"), st);
  1180. merge(t, st);
  1181. t.isContainer = true;
  1182. }
  1183. // 3) 待执行队列(最新 plan 快照里、尚未成为已执行任务的叶)
  1184. let queue = [];
  1185. for (const s of steps) if (Array.isArray(s.delta.plan)) queue = s.delta.plan; // 最后一次覆盖 = 最新剩余队列
  1186. queue.forEach((st0, i) => {
  1187. const st = normStep(st0);
  1188. const key = st.step_id != null ? "s" + st.step_id : "q" + i;
  1189. const t = ensure(key, st);
  1190. merge(t, st);
  1191. if (!t.attempts.length) t.pending = true; // 已执行过的(重试待跑)不覆盖其历史
  1192. if (st._feedback) t.pendingFeedback = st._feedback;
  1193. });
  1194. // 计算状态
  1195. for (const t of tasks.values()) {
  1196. if (t.isContainer) { t.status = "decomposed"; continue; }
  1197. if (t.attempts.length) {
  1198. const v = t.attempts[t.attempts.length - 1].verdict;
  1199. t.status = v == null ? "done" : (v.pass ? "passed" : (t.pending ? "pending" : "failed"));
  1200. } else {
  1201. t.status = "pending";
  1202. }
  1203. }
  1204. // step_id -> 产物 url(供 from_step 参考图解析 + 成品图库)
  1205. const imgMap = {};
  1206. for (const t of tasks.values()) {
  1207. if (t.step_id == null) continue;
  1208. const last = [...t.attempts].reverse().find(a => a.url);
  1209. if (last) imgMap[t.step_id] = { url: last.url, name: t.name, pass: t.status === "passed" };
  1210. }
  1211. // 组树:顶层任务 + 各自的子任务
  1212. const list = order.map(k => tasks.get(k));
  1213. const byId = {};
  1214. for (const t of list) if (t.step_id != null) byId[t.step_id] = t;
  1215. const roots = [];
  1216. for (const t of list) {
  1217. t.children = list.filter(x => x !== t && x.parent_id != null && x.parent_id === t.step_id);
  1218. if (t.parent_id == null || !byId[t.parent_id]) roots.push(t);
  1219. }
  1220. // 任务层级深度:顶层=0,子任务=父深度+1(沿 parent_id 链上溯,带环保护)
  1221. let maxDepth = 0;
  1222. for (const t of list) {
  1223. let d = 0, cur = t; const seen = new Set();
  1224. while (cur.parent_id != null && byId[cur.parent_id] && !seen.has(cur.parent_id)) {
  1225. seen.add(cur.parent_id); cur = byId[cur.parent_id]; d++;
  1226. }
  1227. t.depth = d; if (d > maxDepth) maxDepth = d;
  1228. }
  1229. return { tasks: list, roots, imgMap, containers, byId, maxDepth };
  1230. }
  1231. function renderDetail(d) {
  1232. const model = buildModel(d.steps);
  1233. // 运行概览计数(不再依赖画板节点:直接从步骤序列统计)
  1234. const fallbackPlanRounds = d.steps.filter(s => s.node_type === "plan" &&
  1235. (s.delta.reasoning || s.delta.response != null || ("replan_count" in s.delta))).length;
  1236. const fallbackExecRounds = d.steps.filter(s => s.node_type === "execute").length;
  1237. const leaves = model.tasks.filter(t => !t.isContainer);
  1238. const overview = d.overview || {};
  1239. const planRounds = overview.plan_rounds ?? fallbackPlanRounds;
  1240. const execRounds = overview.execute_rounds ?? fallbackExecRounds;
  1241. const taskCount = overview.task_count ?? leaves.length;
  1242. const passed = overview.passed ?? leaves.filter(t => t.status === "passed").length;
  1243. const failed = overview.failed ?? leaves.filter(t => t.status === "failed").length;
  1244. const retries = overview.retries ?? leaves.reduce((n, t) => n + Math.max(0, t.attempts.length - 1), 0);
  1245. const gallery = Object.keys(model.imgMap).map(k => ({ sid: k, ...model.imgMap[k] }))
  1246. .sort((a, b) => (+a.sid) - (+b.sid));
  1247. let html = `
  1248. <div class="topbar">
  1249. <span class="tb-id">${esc(d.run_key || `#${d.id}`)}</span>
  1250. <span class="tb-obj">${esc(d.objective || "")}</span>
  1251. <span class="tb-right">
  1252. <span class="badge ${d.status} ${d.status==='running'?'dot':''}">${d.status}</span>
  1253. <button class="icon-btn" title="刷新" onclick="selectRun(${d.id}, curRound())">⟳</button>
  1254. </span>
  1255. </div>
  1256. <div class="detailbody">
  1257. <div class="hero">
  1258. <div class="hero-obj">${esc(d.objective || "(无目标描述)")}</div>
  1259. <div class="hero-model">${esc(d.agent_name)} · ${esc(d.model_name)}</div>
  1260. <div class="loopwrap">
  1261. <div class="loopdiagram">
  1262. <div class="lp-node plan"><span class="lp-t">PLAN</span><span class="lp-c">${planRounds}</span></div>
  1263. <div class="lp-arrows">
  1264. <span class="a1">──▶</span><span class="lbl">loop</span><span class="a2">◀──</span>
  1265. </div>
  1266. <div class="lp-node exec"><span class="lp-t">EXECUTE</span><span class="lp-c">${execRounds}</span></div>
  1267. </div>
  1268. <div class="loopstats">
  1269. <div class="lstat"><div class="k">任务</div><div class="v">${taskCount}</div></div>
  1270. <div class="lstat"><div class="k">达标</div><div class="v pass">${passed}</div></div>
  1271. <div class="lstat"><div class="k">未达标</div><div class="v ${failed?'fail':''}">${failed}</div></div>
  1272. <div class="lstat"><div class="k">重试</div><div class="v ${retries?'retry':''}">${retries}</div></div>
  1273. <div class="lstat"><div class="k">重规划</div><div class="v plan">${d.replan_count}</div></div>
  1274. <div class="lstat"><div class="k">Tokens</div><div class="v">${fmtTok(d.input_tokens)}<small>/${fmtTok(d.output_tokens)}</small></div></div>
  1275. <div class="lstat"><div class="k">成本</div><div class="v">${d.cost_status === "unavailable" ? "—" : `$${d.cost_usd.toFixed(4)}`}</div></div>
  1276. <div class="lstat"><div class="k">耗时</div><div class="v">${fmtDur(d.duration_sec)}</div></div>
  1277. </div>
  1278. </div>
  1279. ${d.error_message ? `<div class="err-box">⚠ ${esc(d.error_message)}</div>` : ""}
  1280. </div>`;
  1281. // 结果视图内容:最终交付(final_output + 成品图库)
  1282. let resultHtml;
  1283. if (d.final_output || gallery.length) {
  1284. resultHtml = `
  1285. <div class="sec-title"><span class="t">最终交付</span><span class="line"></span></div>
  1286. <div class="delivery">
  1287. <div class="delivery-h">✓ 成品 / response</div>
  1288. ${d.final_output ? `<div class="delivery-b">${esc(d.final_output)}</div>` : ""}
  1289. ${gallery.length ? `<div class="gallery">${gallery.map(g => `
  1290. <div class="gcard">
  1291. <img src="${esc(g.url)}" referrerpolicy="no-referrer" onclick="openLB('${esc(g.url)}')" onerror="imgFail(this)">
  1292. <div class="cap"><span class="sid">[${esc(g.sid)}]</span> ${g.pass?'✓':'○'} <span style="color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(g.name||'')}</span></div>
  1293. </div>`).join("")}</div>` : ""}
  1294. </div>`;
  1295. } else {
  1296. resultHtml = `<div class="snap-empty">◍ 暂无最终交付<br><span style="font-size:11px">(运行尚未产出成品 / response)</span></div>`;
  1297. }
  1298. // 视图 Tab:结果视图(交付成果)/ 过程视图(Plan→执行→评估 大画板,默认)
  1299. html += `
  1300. <div class="view-bar">
  1301. <div class="view-tabs">
  1302. <button class="view-tab" data-v="result" onclick="switchView('result')"><span class="vt-ic">✦</span>结果视图</button>
  1303. <button class="view-tab active" data-v="process" onclick="switchView('process')"><span class="vt-ic">◫</span>过程视图</button>
  1304. </div>
  1305. <button class="fs-enter" onclick="enterBoardFullscreen()" title="全屏查看过程视图(Esc 退出)"><span class="vt-ic">⛶</span>全屏</button>
  1306. </div>
  1307. <div class="view-pane" id="tabResult">${resultHtml}</div>
  1308. <div class="view-pane active" id="tabProcess"><div id="snapView"></div></div>
  1309. </div>`;
  1310. $("#main").innerHTML = html;
  1311. SNAP = { runId: d.id, list: null, objective: d.objective || "", idx: 0, loaded: false,
  1312. agent: d.agent_name || "production" };
  1313. loadSnapshots();
  1314. }
  1315. // 视图切换:结果 / 过程。切到过程视图时重绘画板连线(隐藏时元素无尺寸,无法测量)。
  1316. function switchView(v) {
  1317. document.querySelectorAll(".view-tab").forEach(b => b.classList.toggle("active", b.dataset.v === v));
  1318. document.querySelectorAll(".view-pane").forEach(p =>
  1319. p.classList.toggle("active", p.id === (v === "result" ? "tabResult" : "tabProcess")));
  1320. if (v === "process") requestAnimationFrame(() => layoutBoard());
  1321. }
  1322. const clip = (s, n) => { s = String(s ?? "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + " …" : s; };
  1323. function openDrawer() { $("#drawer").classList.add("open"); $("#drawerScrim").classList.add("open"); }
  1324. function closeDrawer() {
  1325. $("#drawer").classList.remove("open");
  1326. $("#drawerScrim").classList.remove("open");
  1327. if (SNAP) SNAP.openNodeSid = null;
  1328. }
  1329. // ── images ──
  1330. function imgFail(img) {
  1331. const a = document.createElement("a");
  1332. a.href = img.src; a.target = "_blank"; a.className = "imgfail" + (img.classList.contains("ref")?" ref":"");
  1333. a.textContent = "图片加载失败 · 点击打开";
  1334. img.replaceWith(a);
  1335. }
  1336. // 收集当前上下文(抽屉开→详情,否则→画板)里可放大的图,供左右切换。含画板过程卡缩略图(无 class)与详情各图。
  1337. function collectImages() {
  1338. const scope = $("#drawer").classList.contains("open") ? "#drawer" : "#main";
  1339. return [...document.querySelectorAll(
  1340. `${scope} img.thumb, ${scope} .mmc-thumb, ${scope} .gcard img, ${scope} .pc-thumbs img, ${scope} .imgs img, ${scope} .pp-imgs img`
  1341. )].map(i => i.getAttribute("src")).filter(Boolean);
  1342. }
  1343. function openLB(url) {
  1344. const all = [...new Set(collectImages())];
  1345. LBIDX = all.indexOf(url);
  1346. if (LBIDX < 0) { all.unshift(url); LBIDX = 0; } // 点击图不在集合里 → 纳入并选中,绝不回退到第一张
  1347. LBLIST = all;
  1348. showLB();
  1349. $("#lb").classList.add("open");
  1350. }
  1351. function showLB() { $("#lbImg").src = LBLIST[LBIDX]; $("#lbCounter").textContent = `${LBIDX+1} / ${LBLIST.length}`; }
  1352. function stepLB(d, ev) { ev && ev.stopPropagation(); LBIDX = (LBIDX + d + LBLIST.length) % LBLIST.length; showLB(); }
  1353. function closeLB() { $("#lb").classList.remove("open"); }
  1354. $("#lb").addEventListener("click", e => { if (e.target.id === "lb") closeLB(); });
  1355. document.addEventListener("keydown", e => {
  1356. if ($("#lb").classList.contains("open")) {
  1357. if (e.key === "Escape") closeLB();
  1358. if (e.key === "ArrowLeft") stepLB(-1);
  1359. if (e.key === "ArrowRight") stepLB(1);
  1360. return;
  1361. }
  1362. const sv = $("#snapView");
  1363. if (e.key === "Escape" && sv && sv.classList.contains("fs")) { boardFullscreen(); e.preventDefault(); return; }
  1364. if ($("#srcbar").classList.contains("open") && e.key === "Escape") { closeSrcBar(); return; }
  1365. if ($("#drawer").classList.contains("open")) { if (e.key === "Escape") closeDrawer(); return; }
  1366. // 规划演进:左右键在快照序列间步进(仅过程视图可见时)
  1367. if (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length && $("#tabProcess") && $("#tabProcess").classList.contains("active")) {
  1368. if (e.key === "ArrowLeft") { gotoSnap(SNAP.idx - 1); e.preventDefault(); }
  1369. if (e.key === "ArrowRight") { gotoSnap(SNAP.idx + 1); e.preventDefault(); }
  1370. }
  1371. });
  1372. // ═══════════════════════════════════════════════════════════════════════════
  1373. // 规划演进 · 思维导图(快照序列)
  1374. // 每个快照 = 一份完整 Task 树状态;步进器在快照间切换,整树重绘为该轮状态;
  1375. // 高亮相邻快照 diff(新增/调整/移除)+ 侧栏显示 reasoning + 本轮教训 + diff 摘要。
  1376. // ═══════════════════════════════════════════════════════════════════════════
  1377. let SNAP = null;
  1378. let BOARD = null;
  1379. async function loadSnapshots() {
  1380. const panel = $("#snapView");
  1381. panel.innerHTML = `<div class="loading">载入规划快照 <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
  1382. const r = await fetch(`/api/runs/${SNAP.runId}/snapshots`);
  1383. if (!r.ok) { panel.innerHTML = `<div class="snap-empty">规划快照载入失败</div>`; return; }
  1384. const d = await r.json();
  1385. SNAP.list = d.snapshots || []; SNAP.objective = d.objective || SNAP.objective; SNAP.runStatus = d.status;
  1386. MODULES = await fetchModules(SNAP.runId); // 模块调用树;无模块行的 run → 画板显示「本轮无节点记录」
  1387. ANN = await fetchAnnotations(SNAP.runId); // 旁路标注(可能为空 → 卡上不画那块,一切照旧)
  1388. SNAP.loaded = true;
  1389. SNAP.idx = Math.max(0, Math.min(SNAP.list.length - 1, PENDING_ROUND - 1)); // 深链指定的轮次;缺省第一轮
  1390. if (!SNAP.list.length) {
  1391. panel.innerHTML = `<div class="snap-empty">◍ 本次运行暂无规划快照<br><span style="font-size:11px">(尚未产生任何 plan 轮次)</span></div>`;
  1392. return;
  1393. }
  1394. // 画板骨架(建一次;world 里每轮重摆节点):可平移(拖拽)/缩放(滚轮)的白板。
  1395. panel.innerHTML = `
  1396. <div class="snap-stepper" id="snapStepper"></div>
  1397. <div class="board" id="board">
  1398. <div class="board-world" id="boardWorld"></div>
  1399. <div class="board-ctrls">
  1400. <button title="按当前 Loop 拉取最新" onclick="refreshLoop(this)">⟳</button>
  1401. <button title="放大" onclick="boardZoom(1.15)">+</button>
  1402. <button title="缩小" onclick="boardZoom(0.87)">-</button>
  1403. <button title="适应窗口" onclick="boardFit()">⤢</button>
  1404. <button title="全屏 / 沉浸" id="btnFs" onclick="boardFullscreen()">⛶</button>
  1405. </div>
  1406. <div class="board-tip">双指滑动平移 · 双指捏合 / ⌘+滚轮缩放 · 点卡片看详情</div>
  1407. </div>`;
  1408. BOARD = { z: 1, panX: 0, panY: 0, worldW: 0, worldH: 0, fitted: false, contentFitted: false, suppressClick: false };
  1409. attachBoardPan();
  1410. renderSnapshot();
  1411. syncHash(); // 轮次可能被 clamp(如深链的 round 超出实际轮数),以真实落点回写
  1412. }
  1413. // ── 手动刷新(替代自动轮询):按当前所在 Loop 重新拉取快照并重渲染 ──
  1414. // 停留在当前 Loop(idx 不跳到最新轮);抽屉在看某任务详情/执行过程时,重开以刷新 meta + 实时 trace。
  1415. async function refreshLoop(btn) {
  1416. if (!SNAP || !SNAP.runId) return;
  1417. if (btn) btn.classList.add("spinning");
  1418. try {
  1419. const r = await fetch(`/api/runs/${SNAP.runId}/snapshots`);
  1420. const d = r.ok ? await r.json() : null;
  1421. const list = d && d.snapshots || [];
  1422. const mods = await fetchModules(SNAP.runId);
  1423. if (d && SNAP && SNAP.runId === d.run_id && list.length) {
  1424. SNAP.list = list; SNAP.runStatus = d.status;
  1425. if (mods) MODULES = mods; // 重建骨架 = 丢掉旧缓存,当前轮会按需重拉(run 在跑时要拿最新)
  1426. SNAP.idx = Math.min(SNAP.idx, list.length - 1);
  1427. await renderSnapshot();
  1428. if ($("#drawer").classList.contains("open") && SNAP.openNodeSid != null)
  1429. openSnapNode(SNAP.openNodeSid, SNAP.openNodePlanOnly);
  1430. }
  1431. } catch (e) {}
  1432. if (btn) setTimeout(() => btn.classList.remove("spinning"), 500);
  1433. }
  1434. const PLAN_KIND = { initial: "初始规划", advance: "推进(审视后未改规划)", replan: "重规划", finalize: "收尾" };
  1435. // 轮次标签:优先用 **planner 自述的本轮短标题**(决策里的 title,它说得出这一轮到底在干什么);
  1436. // 没有(老 run / 没填)才退回按 kind 归类的通用说法。
  1437. const roundLabel = (s, k) => (s && s.title) || PLAN_KIND[(s || {}).kind]
  1438. || ((s || {}).is_final ? "收尾" : `第 ${k + 1} 轮`);
  1439. function gotoSnap(i) {
  1440. if (!SNAP || !SNAP.list) return;
  1441. SNAP.idx = Math.max(0, Math.min(SNAP.list.length - 1, i));
  1442. if (BOARD) { BOARD.fitted = false; BOARD.contentFitted = false; } // 切换 Loop → 新面板自适应窗口,不保留上次位置/缩放
  1443. renderSnapshot();
  1444. syncHash();
  1445. }
  1446. function renderSnapStepper() {
  1447. const el = $("#snapStepper"); if (!el) return;
  1448. const n = SNAP.list.length, i = SNAP.idx;
  1449. const pills = SNAP.list.map((s, k) => {
  1450. const label = roundLabel(s, k);
  1451. const changed = s.tree_changed; // 精准标记:本轮 plan 是否改了 Task
  1452. const running = SNAP.runStatus === "running" && k === SNAP.list.length - 1;
  1453. return `${k ? `<span class="snap-sep"></span>` : ""}<span class="snap-pill ${k === i ? "cur" : ""} ${s.is_final ? "final" : ""} ${changed ? "changed" : ""}" onclick="gotoSnap(${k})" title="${changed ? "本轮改了 Task" : "本轮未改 Task(推进/同结构)"}">
  1454. <span class="sp-n">${k + 1}</span>${s.is_final ? "✓ " : ""}${esc(label)}${changed ? `<span class="sp-mark" title="改了 Task">✎</span>` : ""}${running ? `<span class="sp-live">●</span>` : ""}</span>`;
  1455. }).join("");
  1456. el.innerHTML = `
  1457. <button class="snap-arrow" ${i <= 0 ? "disabled" : ""} onclick="gotoSnap(${i - 1})" title="上一轮(←)">◀</button>
  1458. <div class="snap-track">${pills}</div>
  1459. <button class="snap-arrow" ${i >= n - 1 ? "disabled" : ""} onclick="gotoSnap(${i + 1})" title="下一轮(→)">▶</button>`;
  1460. }
  1461. const SNAP_STATUS = { passed: "通过", failed: "未达标", pending: "待执行", running: "执行中",
  1462. decomposed: "容器", planned: "已规划" };
  1463. // 节点显示状态(含 running:run 仍在跑、最新快照里的下一个待执行叶实际正在执行)——卡片与抽屉共用,保证一致
  1464. function nodeDisplayStatus(node) {
  1465. if (node.role === "container") return "decomposed";
  1466. if (node.status === "pending" && node.step_id === SNAP.execSid) return "running";
  1467. return node.status;
  1468. }
  1469. // 快照只在规划变化时落库:某任务执行期间不产新快照,故最新快照里它仍显示 pending。
  1470. // 若 run 仍在跑且这是最新快照,则「下一个待执行叶」实际正在执行 → 标 running。
  1471. function computeExecuting(snap) {
  1472. if (SNAP.runStatus !== "running" || SNAP.idx !== SNAP.list.length - 1) return null;
  1473. const pend = ((snap.tree && snap.tree.nodes) || [])
  1474. .filter(n => n.role !== "container" && n.status === "pending")
  1475. .sort((a, b) => (a.step_id ?? 1e9) - (b.step_id ?? 1e9));
  1476. return pend.length ? pend[0].step_id : null;
  1477. }
  1478. async function renderSnapshot() {
  1479. if (!SNAP || !SNAP.list) return;
  1480. const snap = SNAP.list[SNAP.idx];
  1481. SNAP.execSid = computeExecuting(snap);
  1482. SNAP.roundExecSid = (snap.round_execution || {}).task_id ?? null; // 本轮执行的任务 → 思维导图里高亮
  1483. renderSnapStepper();
  1484. if (MODULES && !MODULES.byIdx[SNAP.idx]) { // 本轮还没拉过 → 按需拉
  1485. const w = $("#boardWorld");
  1486. if (w) w.innerHTML = `<div class="loading">载入本轮模块树 <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
  1487. const want = SNAP.idx;
  1488. await ensureRound(want);
  1489. if (want !== SNAP.idx) return; // 期间又切了轮 → 让后一次接管
  1490. }
  1491. renderBoard();
  1492. }
  1493. // 任务图标:据角色 / 生成类型给出直观图标(category 为自由文本,正则匹配 + 兜底)。
  1494. function taskIcon(node) {
  1495. if (node.role === "container") return "🗂";
  1496. const c = String(node.category || "");
  1497. if (/组合/.test(c)) return "🧩";
  1498. if (/变化|变换|改/.test(c)) return "🔀";
  1499. if (/完整|整图|图片/.test(c)) return "🖼️";
  1500. if (/素材/.test(c)) return "🎨";
  1501. return "📌";
  1502. }
  1503. // **统一的任务卡**:plan 输出的任务列表、输入里的任务槽、生成输出里的那个任务,全用这一张。
  1504. // opts.withResult=false → 只呈现任务本身(plan 刚排的活,还没执行)。
  1505. function taskCard(node, opts) {
  1506. opts = opts || {};
  1507. const sid = node.step_id;
  1508. // planned:plan 刚排出来的计划态(状态一律「已规划」);showResult=false:保留真实状态但不显示执行结果
  1509. // badge:**用「本轮对它做了什么」占掉徽标位**(plan 的 ops / 下一步)——那一刻谈执行态状态没有意义。
  1510. const planned = opts.planned === true && node.role !== "container";
  1511. const status = planned ? "planned" : nodeDisplayStatus(node);
  1512. const r = (planned || opts.badge || opts.showResult === false) ? null : node.result;
  1513. let body = "";
  1514. if (node.role === "container") {
  1515. body = `<div class="mmc-kidsum"><span>◫ 已拆分,由子任务兑现</span></div>`;
  1516. } else if (r) {
  1517. const isImg = r.product_kind === "image" && r.product_url;
  1518. body = `<div class="mmc-result">
  1519. ${isImg ? `<img class="mmc-thumb" src="${esc(r.product_url)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(r.product_url)}')" onerror="this.style.display='none'">` : ""}
  1520. <div class="mmc-sum">${esc(r.summary || clip(r.product_text || "", 90)) || "<span style='color:var(--faint)'>(无摘要)</span>"}</div></div>`;
  1521. if (r.verdict_pass === false) body += `<div class="mmc-fail">✗ ${esc(clip(r.verdict_reason || "", 90))}</div>`;
  1522. }
  1523. const attrs = (node.stage || node.category)
  1524. ? `<div class="mmc-attrs">${node.stage ? `<span class="attr-chip stage"><span class="k">阶段</span>${esc(node.stage)}</span>` : ""}${
  1525. node.category ? `<span class="attr-chip cat"><span class="k">操作</span>${esc(node.category)}</span>` : ""}</div>` : "";
  1526. const cur = String(sid) === String(SNAP.roundExecSid) ? "mm-round-exec" : "";
  1527. // 本轮被 update 改过:卡上直接标出改了哪几个字段(点开看逐字段 diff)
  1528. const mod = (opts.modified && opts.modified.length)
  1529. ? `<div class="mmc-chgs">${opts.modified.map(f => `<span class="chg-f">~ ${esc(f)}</span>`).join("")}</div>` : "";
  1530. const b = opts.badge;
  1531. return `<div class="mm-card mm-mini ${b ? `mm-op op-${esc(b.key)}` : `st-${status}`} ${cur} ${
  1532. opts.modified ? "mm-modified" : ""}" data-sid="${esc(sid)}" onclick="${
  1533. opts.click ? opts.click : `openSnapNode('${esc(sid)}',${planned || !!b})`}">
  1534. <div class="mmc-top"><span class="mmc-sid">#${esc(sid)}</span>
  1535. <span class="mmc-badge ${b ? `op-${esc(b.key)}` : status}">${
  1536. b ? esc(b.text) : (SNAP_STATUS[status] || status)}</span></div>
  1537. <div class="mmc-name">${taskIcon(node)} ${esc(node.name || "任务")}</div>
  1538. ${node.goal ? `<div class="mmc-goal">${esc(node.goal)}</div>` : ""}
  1539. ${attrs}${mod}${body}</div>`;
  1540. }
  1541. // 行内字符级 diff(LCS):把旧串→新串的差异切成 eq/del/ins 片段。中文按字、英文数字按词、其余按符号切分,
  1542. // 保证 diff 粒度可读(非逐字节)。相邻同类片段合并。
  1543. function diffInline(oldS, newS) {
  1544. const tok = s => String(s ?? "").match(/[\u4e00-\u9fff]|[A-Za-z0-9_]+|\s+|[^\s]/g) || [];
  1545. const a = tok(oldS), b = tok(newS), n = a.length, m = b.length;
  1546. const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
  1547. for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
  1548. dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
  1549. const out = [];
  1550. const push = (type, s) => { const last = out[out.length - 1]; if (last && last.type === type) last.s += s; else out.push({ type, s }); };
  1551. let i = 0, j = 0;
  1552. while (i < n && j < m) {
  1553. if (a[i] === b[j]) { push("eq", a[i]); i++; j++; }
  1554. else if (dp[i + 1][j] >= dp[i][j + 1]) { push("del", a[i]); i++; }
  1555. else { push("ins", b[j]); j++; }
  1556. }
  1557. while (i < n) { push("del", a[i]); i++; }
  1558. while (j < m) { push("ins", b[j]); j++; }
  1559. return out;
  1560. }
  1561. function renderInlineDiff(oldS, newS) {
  1562. return diffInline(oldS, newS).map(p =>
  1563. p.type === "eq" ? esc(p.s)
  1564. : p.type === "del" ? `<span class="df-del">${esc(p.s)}</span>`
  1565. : `<span class="df-ins">${esc(p.s)}</span>`).join("");
  1566. }
  1567. // modified 节点的 changes → 逐字段「旧 → 新」对比(name/goal 展示具体字符级 diff,而非仅「改名/目标调整」)。
  1568. function renderChangesDiff(snap, sid) {
  1569. const m = (snap.diff && snap.diff.modified || []).find(x => String(x.step_id) === String(sid));
  1570. if (!m || !m.changes) return "";
  1571. const c = m.changes, rows = [];
  1572. const row = (k, v) => rows.push(`<div class="chg-row"><span class="chg-k">${k}</span><div class="chg-v">${v}</div></div>`);
  1573. const pid = v => v == null ? "顶层" : "#" + esc(v);
  1574. if (c.role) row("角色", c.role[0] === "leaf" && c.role[1] === "container"
  1575. ? "叶子 → 容器(拆分为子任务)" : `${esc(c.role[0])} → ${esc(c.role[1])}`);
  1576. if (c.parent_id) row("父任务", `${pid(c.parent_id[0])} → ${pid(c.parent_id[1])}`);
  1577. if (c.name) row("名称", renderInlineDiff(c.name[0], c.name[1]));
  1578. if (c.goal) row("目标", renderInlineDiff(c.goal[0], c.goal[1]));
  1579. return rows.join("");
  1580. }
  1581. // ═══ 画板:**渲染后端给的模块调用树**(不解析、不推断)═══════════════════════
  1582. // 后端 /api/runs/{id}/flat?round=N 给一轮的运行实例,每个实例是同样的四段:
  1583. // · input {blocks:[{name,text,images,source,origin}]} 本次映射进来的输入(块名=结构、块值=变化)
  1584. // · module {kind, spec, view} 这个模块的定义(agent/tool/workflow 各自的形状)
  1585. // · flow [子模块 | 事件],已按 seq 归并(同 seq = 同一并发列)
  1586. // · output {data, ok, images} 本次产物/结论(真 JSON)
  1587. // 前端只做布局与绘制。
  1588. // 一个标准节点:标题 / [输入] ─连线→ ┌结构 │ 运行流程┐ → [输出]
  1589. // 输入在结构**左侧**:每个输入槽用连线接到结构里同名的槽位 chip(逐槽一一对应,一眼可见)。
  1590. // 卡片登记:把一组卡放进 SNAP.cards[key](点击查看详情按 key+下标取回)
  1591. function putCards(key, cards) { SNAP.cards[key] = cards; return cards; }
  1592. // 输入 / 输出锚点列:用**与运行流程完全相同的卡片组件**(renderPCard)——同一种东西只有一种画法。
  1593. // 输入槽 → 卡(每槽一张:槽名 + **本次**取值 + 该槽携带的图)。**空槽不出卡** ——
  1594. // 「结构里声明了这一槽、但本轮没有内容」只由结构侧那张「输入」卡表达(该槽位置灰,
  1595. // 详情里仍写着「本轮为空(未进入 prompt)」)。输入列只放真正进了 prompt 的东西,
  1596. // 不再排一列「空」卡占着版面。
  1597. const blockCards = blocks => (blocks || []).filter(b => b.filled !== false).map(b => ({
  1598. label: b.title || b.name, icon: "⇢", key: b.key, hint: b.hint,
  1599. tasks: b.item_type === "task" ? (b.items || []) : null, // 任务型槽 → 用任务卡渲染
  1600. // 有图就不再把 URL 当文字摘要重复一遍(图本身就是内容,URL 在详情里看)
  1601. summary: b.text ? clip(b.text, 150) : ((b.images || []).length ? `${b.images.length} 张图` : ""),
  1602. images: b.images || [],
  1603. detail: { title: b.name, text: b.text || "", images: b.images || [] },
  1604. }));
  1605. // 结构 → 卡(system prompt / 输入槽位表 / 工具与模型)——全部来自后端申报的 structure
  1606. // 节点「结构」→ 卡片:**有什么画什么**。节点是 Agent 还是工序、还是别的形态,由产生侧在
  1607. // structure.node_kind / system_prompt 里申报,前端不预设它一定是哪一种。
  1608. // 结构按 kind 分化:workflow 走 flowchartHtml(流程图)、code 走 sourceHtml(源码),这里管 agent 那套卡。
  1609. function structCards(st, input, usedTools) {
  1610. const cards = [];
  1611. if (!st) return cards;
  1612. if (st.summary) cards.push({ label: "这是什么", icon: "◍", summary: st.summary,
  1613. chips: st.node_kind ? [st.node_kind] : [], detail: { title: "节点说明", text: st.summary } });
  1614. // 调模型的模块(agent / llm):system prompt
  1615. if (st.system_prompt) cards.push({ label: "System Prompt", icon: "⌘", summary: clip(st.system_prompt, 110),
  1616. detail: { title: "System Prompt", text: st.system_prompt } });
  1617. // 输入:固定由哪几段构成 + 本轮各段取值。**就是输入**——不叫「上下文工程」、也不表达「哪段交给谁」
  1618. // (那是下游各模块自己的输入,在它们的定义里;在这里再说一遍就是把分发规则冒充成自己的结构)。
  1619. const schema = st.input_schema || [];
  1620. if (schema.length) {
  1621. const val = {};
  1622. ((input || {}).blocks || []).forEach(b => val[b.key || b.name] = b);
  1623. // 不写摘要:「由几段构成、几段有内容」下面那张槽位表逐行都写着(有内容的亮、空的置灰),
  1624. // 再用一句话复述一遍就是同一件事的第二处表达。
  1625. cards.push({ label: "输入", icon: "⿻",
  1626. slots: schema.map(x => ({ key: x.key || x.name, hint: x.hint,
  1627. name: (x.title || x.name), filled: !!(val[x.key || x.name] || {}).filled })),
  1628. detail: { title: "输入 · 每一段 ↔ 本轮取值",
  1629. // 逐槽分块、**完整**呈现本轮取值(不截断)——要核对模型到底看到了什么
  1630. sections: schema.map(x => {
  1631. const v = val[x.key || x.name] || {};
  1632. return {
  1633. label: (x.title || x.name) + (x.required ? "(必填)" : ""),
  1634. meta: [x.hint ? "说明:" + x.hint : "", "取自:" + (x.source || "—"),
  1635. v.filled ? "" : "本轮为空(未进入 prompt)"].filter(Boolean).join(" · "),
  1636. text: v.filled ? (v.text || "") : "", images: v.images || [],
  1637. };
  1638. }) } });
  1639. }
  1640. // 工具:运行记录里只有**名字**(定义是代码、不是运行数据,按需走 /api/tools 取,见 TOOLDEF)。
  1641. // `used` 是后端给的「本次实际用过哪些」——用过的高亮,一眼看出这轮动用了哪几样能力。
  1642. const tools = (st.tools || []).map(t => typeof t === "string" ? { name: t } : (t || {}));
  1643. const used = new Set(usedTools || []);
  1644. if (tools.length) {
  1645. const nUsed = tools.filter(t => used.has(t.name)).length;
  1646. cards.push({ label: "工具 / 能力", icon: "⚙",
  1647. summary: `可用 ${tools.length} 个工具${nUsed ? ` · 本次用过 ${nUsed} 个` : ""}`,
  1648. tools, usedTools: [...used],
  1649. detail: { title: "工具 / 能力", tools, usedTools: [...used] } });
  1650. }
  1651. // 模型只属于**真正调模型的模块**(agent / llm);workflow / code 不调模型,模型在它们内部的子模块上。
  1652. // 可执行结构申报的是扁平的 st.model,环节配置则是 st.runtime —— 两种都渲染成同一张卡。
  1653. const isModelBearing = !st.node_kind || st.node_kind === "agent" || st.node_kind === "llm";
  1654. const rt = st.runtime;
  1655. if (isModelBearing && !(rt && rt.model) && st.model)
  1656. cards.push({ label: "模型配置", icon: "◎", summary: st.model,
  1657. chips: [st.runs || ""].filter(Boolean),
  1658. detail: { title: "模型配置 · 本段", text: `模型:${st.model}` + (st.runs ? `\n执行:${st.runs}` : "") } });
  1659. if (isModelBearing && rt && rt.model) {
  1660. const p = rt.params || {}, bud = rt.budget || {};
  1661. cards.push({ label: "模型配置", icon: "◎", summary: rt.model,
  1662. chips: [rt.overridden ? "环节特配" : "默认模型",
  1663. ...Object.keys(p).map(x => `${x}=${p[x]}`), ...Object.keys(bud).map(x => `${x}=${bud[x]}`),
  1664. rt.fallback_model ? "回退 " + rt.fallback_model : ""].filter(Boolean),
  1665. detail: { title: "模型配置 · 本环节", text:
  1666. `环节(role):${rt.role || "—"}\n模型:${rt.model}(${rt.overridden ? "models_by_role 特配" : "默认 model_name"})\n` +
  1667. `默认模型:${rt.default_model || "—"}\n安全回退模型:${rt.fallback_model || "(未启用)"}\n` +
  1668. `参数:${JSON.stringify(p)}\n中间件:${(rt.middleware || []).join("、") || "—"}\n预算:${JSON.stringify(bud)}` } });
  1669. }
  1670. return cards;
  1671. }
  1672. // 输出 → **一张卡**。输出本身只有客观数据;**怎么读它由结构里的 output_schema 决定**:
  1673. // fields[{key,name}] 给字段中文名(未列出的按原键名显示,hide_keys 里的不显示)
  1674. // items_key 输出的「构成」在哪个键(如交付件 results / 逐候选 candidates)
  1675. // primary_key 主件下标在哪个键;item_* 指明每件的标签/图/备注取哪
  1676. // 没有 schema 就按对象自身的键缺省摊平(autoFields)。任何情况下都不该把一坨 JSON 糊在卡面上。
  1677. function outputCards(out, schema) {
  1678. if (!out) return [];
  1679. const d = out.data, sc = schema || {};
  1680. const labels = {}; (sc.fields || []).forEach(f => labels[f.key] = f.name);
  1681. const hide = new Set([...(sc.hide_keys || []), sc.items_key, sc.primary_key].filter(Boolean));
  1682. const items = (sc.items_key && d && Array.isArray(d[sc.items_key])) ? d[sc.items_key] : [];
  1683. const pIdx = sc.primary_key && d ? d[sc.primary_key] : null;
  1684. const itemLabel = (it, i) => {
  1685. const raw = it[sc.item_label_key || "role"];
  1686. const mapped = (sc.item_labels || {})[raw] || (raw != null ? String(raw) : `#${i}`);
  1687. return (sc.item_label_prefix || "") + mapped;
  1688. };
  1689. const arts = items.map((it, i) => ({
  1690. label: itemLabel(it, i), primary: (pIdx != null && i === pIdx) || it.role === "primary",
  1691. note: it[sc.item_note_key || "note"] || "",
  1692. url: it[sc.item_image_key || "url"],
  1693. text: it.text || it.falsifiable_diff || "",
  1694. }));
  1695. // 其余字段:schema 给名字的优先按 schema 顺序,再补上数据里剩下的键
  1696. const rest = {};
  1697. if (d && typeof d === "object" && !Array.isArray(d))
  1698. Object.entries(d).forEach(([k, v]) => { if (!hide.has(k)) rest[k] = v; });
  1699. let fields = [];
  1700. // 投影层读时组装好的字段(如 plan 决策的 ops/理由/下一步)优先——它按框架自己的 schema 渲染过了
  1701. if (out.fields && out.fields.length) fields = out.fields;
  1702. else if (Object.keys(rest).length) {
  1703. const named = (sc.fields || []).filter(f => rest[f.key] != null && rest[f.key] !== "")
  1704. .map(f => ({ key: f.key, name: f.name }));
  1705. const others = Object.keys(rest).filter(k => !named.some(n => n.key === k)).map(k => ({ key: k, name: k }));
  1706. const auto = {}; autoFields(rest).forEach(f => auto[f.label] = f.value);
  1707. fields = [...named, ...others].map(f => ({ label: f.name, value: auto[f.key] }))
  1708. .filter(f => f.value != null && f.value !== "");
  1709. // 数组里是对象的(如 items)由 autoFields 展开成多行,补进来
  1710. autoFields(rest).filter(f => !fields.some(x => x.label === (labels[f.label] || f.label)) && /[ #]/.test(f.label))
  1711. .forEach(f => fields.push(f));
  1712. }
  1713. // 图:优先「输出构成」里的交付件;否则用产生侧申报的 out.images。
  1714. // **哪些图算这个节点的产物由产生侧说了算**(set_output(images=…),如 plan 显式申报「无图产物」,
  1715. // 免得把决策里引用的参考图当成品)——这里不再按「有没有可读字段」去猜要不要显示图。
  1716. const images = arts.map(a => a.url).filter(Boolean);
  1717. return [{
  1718. label: "输出" + (out.ok === false ? " · 未达标" : ""),
  1719. icon: out.ok === false ? "✗" : "◆", ok: out.ok, final: true,
  1720. fields, ops: out.ops || null, opsTitle: out.ops_title || "",
  1721. next: out.next || null, nextTitle: out.next_title || "", arts: arts.length ? arts : null,
  1722. summary: fields.length || arts.length ? "" : clip(summarizeData(d), 150),
  1723. images: images.length ? images : (out.images || []),
  1724. detail: { title: "输出", images: images.length ? images : (out.images || []),
  1725. text: (arts.length ? arts.map(a => `${a.primary ? "★ " : ""}${a.label}${a.note ? "\n " + a.note : ""}` +
  1726. `${a.url ? "\n " + a.url : ""}`).join("\n\n") + "\n\n———\n\n" : "") +
  1727. (typeof d === "string" ? d : JSON.stringify(d, null, 2)), json: d },
  1728. }];
  1729. }
  1730. // 对象 → 可读字段列表(产生侧没给 fields 时的缺省摊平)。
  1731. // 关键:**对象数组要逐项展开**(如多候选的逐候选结论),而不是压成「N 项」——那正是要看的东西。
  1732. function autoFields(d) {
  1733. if (!d || typeof d !== "object" || Array.isArray(d)) return [];
  1734. const scalar = v => v == null ? "" : (typeof v === "boolean" ? (v ? "✓" : "✗") : String(v));
  1735. const rowOf = (item, i, key) => {
  1736. // 标识:优先项里带序号/名字的字段,否则用下标
  1737. const idKey = ["candidate_index", "index", "idx", "name", "key", "id", "standard", "label"]
  1738. .find(k => item[k] != null);
  1739. const label = `${key}${idKey ? " " + String(item[idKey]) : " #" + i}`;
  1740. // 值:把这一项里的标量按「布尔 → ✓/✗、数字 → 原样、短文本 → 原文」拼成一行(长文本截断)
  1741. const bits = Object.entries(item)
  1742. .filter(([k, v]) => k !== idKey && v != null && typeof v !== "object")
  1743. .map(([, v]) => typeof v === "string" ? clip(v, 60) : scalar(v));
  1744. return { label, value: clip(bits.join(" "), 160) };
  1745. };
  1746. const out = [];
  1747. for (const [k, v] of Object.entries(d)) {
  1748. if (v == null || v === "") continue;
  1749. if (Array.isArray(v)) {
  1750. const objs = v.filter(x => x && typeof x === "object");
  1751. if (objs.length) { objs.slice(0, 8).forEach((it, i) => out.push(rowOf(it, i, k))); continue; }
  1752. if (v.length) out.push({ label: k, value: clip(v.join("、"), 160) });
  1753. continue;
  1754. }
  1755. if (typeof v === "object") { out.push({ label: k, value: clip(JSON.stringify(v), 120) }); continue; }
  1756. out.push({ label: k, value: typeof v === "string" ? v : scalar(v) });
  1757. }
  1758. return out.slice(0, 12);
  1759. }
  1760. // 结构化输出的一行摘要:优先常见的可读字段,否则整体压一行(不猜格式,取到什么算什么)
  1761. function summarizeData(d) {
  1762. if (d == null) return "";
  1763. if (typeof d === "string") return d;
  1764. for (const k of ["reasoning", "summary", "reason", "text", "note", "content"])
  1765. if (d[k]) return String(d[k]);
  1766. return JSON.stringify(d);
  1767. }
  1768. // ═══════════════════════════════════════════════════════════════════════════
  1769. // 模块渲染(统一模块模型的扁平四表:perl_module_def / _ref / _inst / perl_flow_node)
  1770. //
  1771. // 一切皆模块,模块 = 四段(输入 / 结构 / 执行流程 / 输出),模块可嵌套:
  1772. // 组合模块(workflow)的「执行流程」就是它的**子模块调用**,于是嵌套天然递归;
  1773. // 叶模块(agent / tool)的「执行流程」是事件流。同 seq = 同时发起(并发),直接分列。
  1774. //
  1775. // 前端只做两件事:**递归画四段盒子** + **按 spec.layout 摆位**。语义(并发/成败/图片/结构化
  1776. // 产物/布局列)全部由产生侧写进数据,这里不做任何解析或推断。
  1777. // ═══════════════════════════════════════════════════════════════════════════
  1778. let MODULES = null;
  1779. let MOD_COLOR = {}; // module_key → 色相(按模块出现顺序分配,不同模块不同色)
  1780. // 只拉第 0 轮建立骨架(root 的图定义 + 轮数);其余轮切到时再按需拉——长 run 的整树可达数 MB。
  1781. // ── 旁路标注:{summary, basis[]},**按被标注的那一步 join**——标注对象是**模型推理节点**,
  1782. // 故主键是 node_id(= perl_flow_node.id);旧 run 是按工具调用标的,仍按 tool_call_id 找。
  1783. // **拿不到就当没有** —— 这块数据是 run 之后另跑 `python -m framework.annotate <run_id>` 生成的,
  1784. // 缺失不影响查看器任何既有显示。
  1785. let ANN = { byNode: {}, byCall: {}, docs: {} };
  1786. async function fetchAnnotations(runId) {
  1787. try {
  1788. const r = await fetch(`/api/runs/${runId}/annotations`);
  1789. if (!r.ok) return { byNode: {}, byCall: {}, docs: {} };
  1790. const d = await r.json();
  1791. return { byNode: d.by_node || {}, byCall: d.by_call || {}, docs: d.docs || {} };
  1792. } catch (e) { return { byNode: {}, byCall: {}, docs: {} }; }
  1793. }
  1794. // 一张卡 → 它的标注键(`n:节点id` / `c:tool_call_id`):键里带上取哪张表,openBasis 据此回查同一条。
  1795. const annKeyOf = s => !s ? "" : (s.node_id ? "n:" + s.node_id : (s.tool_call_id ? "c:" + s.tool_call_id : ""));
  1796. const annByKey = k => !k ? null : (k[0] === "n" ? (ANN.byNode || {})[k.slice(2)] : (ANN.byCall || {})[k.slice(2)]);
  1797. const annOf = s => annByKey(annKeyOf(s));
  1798. // 工具定义(说明 / 参数)**按需拉一次**:它是代码里的静态信息,不该塞进每次运行的记录里。
  1799. // 只在打开「工具 / 能力」详情时才取,取到后整个会话共用。
  1800. let TOOLDEF = null;
  1801. async function loadToolDefs(agent) {
  1802. if (TOOLDEF) return TOOLDEF;
  1803. try {
  1804. const r = await fetch(`/api/tools?agent=${encodeURIComponent(agent || "production")}`);
  1805. TOOLDEF = r.ok ? ((await r.json()).tools || {}) : {};
  1806. } catch (e) { TOOLDEF = {}; }
  1807. return TOOLDEF;
  1808. }
  1809. // 依据来源栏:把每条来源的**全文**列出来,并按后端给的字符区间高亮被引片段。
  1810. // **先按偏移切片、再逐段 esc()** —— 反过来做的话,`&`→`&amp;` 会把后面所有字符推移,高亮就偏了。
  1811. function hlText(text, s, e) {
  1812. const t = String(text == null ? "" : text);
  1813. if (s == null || e == null || s < 0 || e > t.length || e <= s) return esc(t);
  1814. return esc(t.slice(0, s)) + `<mark class="src-hl">` + esc(t.slice(s, e)) + `</mark>` + esc(t.slice(e));
  1815. }
  1816. // 依据类型 → 给人看的名字与图标。**前端不认识业务自定义的 kind 也没关系**:认不出就原样显示 kind,
  1817. // 面板与高亮照常工作(认 anchor 与偏移,不认类型)。
  1818. const SRC_KIND = {
  1819. agent_system_prompt: { name: "Agent System Prompt", ic: "▤" },
  1820. input_block: { name: "输入", ic: "⇢" },
  1821. input_image: { name: "参考图", ic: "🖼" },
  1822. event: { name: "前序步骤", ic: "↩" },
  1823. };
  1824. const srcKind = k => SRC_KIND[k] || { name: k || "来源", ic: "◆" };
  1825. // 一份完整文档 + 其中的**多处**引用:整份显示、逐处高亮、顶部给快速切换。
  1826. // 为什么不切成几段摘要各自显示:读者会不知道这几处在整份 prompt 的什么位置、彼此什么关系。
  1827. // 高亮 + markdown 同时要:字符偏移是对着**原始 markdown 文本**算的,渲染成 HTML 后就不成立了。
  1828. // 做法是**先在原文里插哨兵字符、再渲染**(哨兵取私有区码点,markdown 渲染器当普通字不会动它),
  1829. // 渲染完把哨兵换成 <mark>。这样偏移始终对着原文,渲染器也不必知道高亮的存在。
  1830. const HL_O = "\uE000", HL_S = "\uE001", HL_C = "\uE002";
  1831. function mdWithHighlights(text, spans, gi) {
  1832. let t = String(text || "");
  1833. // 从后往前插,前面的偏移才不会被前一次插入推移
  1834. [...spans].sort((a, b) => b.s - a.s).forEach(x => {
  1835. t = t.slice(0, x.s) + HL_O + x.k + HL_S + t.slice(x.s, x.e) + HL_C + t.slice(x.e);
  1836. });
  1837. let html = renderMarkdown(t);
  1838. // 哨兵 → <mark>。高亮区间可能跨块级标签(一句话被渲染成 <p>…</p><ul>…</ul>),此时不能简单地
  1839. // 用一对 <mark> 包住(会产生跨块未闭合的非法结构)——按标签切开、只包文本片段。
  1840. return html.replace(new RegExp(HL_O + "(\\d+)" + HL_S + "([\\s\\S]*?)" + HL_C, "g"), (m, k, inner) => {
  1841. let first = true;
  1842. return inner.split(/(<[^>]+>)/).map(piece => {
  1843. if (!piece || piece[0] === "<") return piece;
  1844. const id = first ? ` id="hl-${gi}-${k}"` : "";
  1845. first = false;
  1846. return `<mark class="src-hl"${id}>${piece}</mark>`;
  1847. }).join("");
  1848. });
  1849. }
  1850. function srcDocHtml(doc, cites, gi) {
  1851. const t = String(doc.text || "");
  1852. const spans = cites.filter(c => c.doc_start != null && c.doc_end != null)
  1853. .sort((a, b) => a.doc_start - b.doc_start);
  1854. const html = mdWithHighlights(t, spans.map((c, k) => ({ s: c.doc_start, e: c.doc_end, k })), gi);
  1855. const unlocated = cites.filter(c => c.doc_start == null);
  1856. return `<div class="src-item">
  1857. <div class="src-h"><span class="src-n">${srcKind(doc.kind).ic}</span>
  1858. <span class="src-title">${esc(doc.title)}</span>
  1859. <span class="src-loc ok">${cites.length} 处引用 · 全文 ${t.length} 字</span></div>
  1860. <div class="src-jump">${spans.map((c, k) =>
  1861. `<span class="sj" onclick="jumpHl(${gi},${k})">${k + 1} ${esc(clip(c.quote || c.title || "", 26))}</span>`
  1862. ).join("")}${unlocated.map(c =>
  1863. `<span class="sj off" title="未能在原文中逐字定位">△ ${esc(clip(c.quote || "", 26))}</span>`).join("")}</div>
  1864. <div class="src-body md" id="src-doc-${gi}"><div class="pp-md">${html}</div></div>
  1865. </div>`;
  1866. }
  1867. function jumpHl(gi, k) {
  1868. const box = document.getElementById("src-doc-" + gi);
  1869. const m = document.getElementById(`hl-${gi}-${k}`);
  1870. if (!box || !m) return;
  1871. // 用视口坐标算增量,不用 offsetTop —— 后者依赖 offsetParent,中间多包一层容器就会算错。
  1872. box.scrollTop += m.getBoundingClientRect().top - box.getBoundingClientRect().top - 70;
  1873. m.classList.add("flash"); setTimeout(() => m.classList.remove("flash"), 900);
  1874. }
  1875. function srcItemHtml(b, k) {
  1876. const loc = !b.valid ? `<span class="src-loc no">✗ 悬空引用</span>`
  1877. : b.located ? `<span class="src-loc ok">✓ 逐字定位 [${b.start}:${b.end}]</span>`
  1878. : `<span class="src-loc no">△ 未逐字定位</span>`;
  1879. const isImg = b.kind === "input_image";
  1880. const body = isImg
  1881. ? `<img src="${esc(b.text || "")}" referrerpolicy="no-referrer" onclick="openLB('${esc(b.text || "")}')">`
  1882. : (b.text ? `<div class="pp-md">${mdWithHighlights(b.text, (b.located ? [{ s: b.start, e: b.end, k: 0 }] : []), "s" + k)}</div>`
  1883. : `<span style="color:var(--faint)">(来源正文不可用:该 run 缺少对应的结构记录)</span>`);
  1884. return `<div class="src-item" id="src-item-${k}">
  1885. <div class="src-h"><span class="src-n">${k + 1}</span>
  1886. <span class="src-kind">${srcKind(b.kind).ic} ${esc(srcKind(b.kind).name)}</span>
  1887. <span class="src-title">${esc(b.title || b.ref || "")}</span>${loc}</div>
  1888. <div class="src-body${isImg ? "" : " md"}" id="src-body-${k}">${body}</div>
  1889. ${(!isImg && b.quote) ? `<div class="src-quote">被引片段:「${esc(b.quote)}」</div>` : ""}
  1890. </div>`;
  1891. }
  1892. function openBasis(key, label) {
  1893. const a = annByKey(key); if (!a) return;
  1894. const basis = a.basis || [];
  1895. $("#srcbarTitle").textContent = label || "";
  1896. // 同一份文档(后端给的 doc key,现在只有 system prompt)的多条引用**合并成一块**:显示整份原文、
  1897. // 逐处高亮、顶部给跳转条。其余来源各自成块。顺序:先文档,后零散来源。
  1898. const groups = {}, singles = [];
  1899. basis.forEach(b => (b.doc && (ANN.docs || {})[b.doc]) ? (groups[b.doc] = groups[b.doc] || []).push(b)
  1900. : singles.push(b));
  1901. const gkeys = Object.keys(groups);
  1902. $("#srcbarBody").innerHTML =
  1903. (a.summary ? `<div class="srcbar-sum">🔖 ${esc(a.summary)}</div>` : "") +
  1904. (basis.length ? gkeys.map((k, gi) => srcDocHtml(ANN.docs[k], groups[k], gi)).join("")
  1905. + singles.map(srcItemHtml).join("")
  1906. : `<div class="lane-empty">(本次调用未标注依据)</div>`);
  1907. // 只有一块来源时铺满剩余高度(长 prompt 挤在 340px 的小窗里读不了);多块时各自限高、整栏滚动。
  1908. $("#srcbarBody").classList.toggle("single", gkeys.length + singles.length === 1);
  1909. $("#srcbar").classList.add("open");
  1910. requestAnimationFrame(() => {
  1911. gkeys.forEach((k, gi) => jumpHl(gi, 0)); // 文档块:定位到第一处引用
  1912. singles.forEach((b, k) => { // 零散来源:各自把高亮滚进可视区
  1913. const box = document.getElementById("src-body-" + k);
  1914. const m = box && box.querySelector(".src-hl");
  1915. if (m) box.scrollTop += m.getBoundingClientRect().top - box.getBoundingClientRect().top - 60;
  1916. });
  1917. });
  1918. }
  1919. function closeSrcBar() { $("#srcbar").classList.remove("open"); }
  1920. // id 一键复制:查看器上印的 invocation id / tool_call_id 就是标注器 CLI 的入参,
  1921. // 看到哪条标注不对 → 复制 id → `python -m framework.annotate <run> --invocation N`(或 --node X)重标。
  1922. function copyId(text, el) {
  1923. // 原文存在 dataset 里(只在第一次点击时记):**不能读 el.textContent 当原文** —— 连点两下时读到的
  1924. // 已经是「✓ 已复制」,那就再也变不回去了。同时清掉上一次未跑完的定时器,避免它把状态改回去。
  1925. if (!el.dataset.label) el.dataset.label = el.textContent;
  1926. clearTimeout(el._copyT);
  1927. const done = () => { el.textContent = "✓ 已复制"; el.classList.add("copied");
  1928. el._copyT = setTimeout(() => { el.textContent = el.dataset.label;
  1929. el.classList.remove("copied"); }, 1200); };
  1930. if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(String(text)).then(done, done);
  1931. else { const t = document.createElement("textarea"); t.value = String(text); document.body.appendChild(t);
  1932. t.select(); try { document.execCommand("copy"); } catch (e) {} t.remove(); done(); }
  1933. }
  1934. // ═══ 扁平模块视图 ═══════════════════════════════════════════════════════════════
  1935. // **没有嵌套**:一个「运行实例」一个盒子,盒子之间由 `call` 引用相连 —— 前端沿实例 id 走链,
  1936. // 不做层级下钻。模块**定义**是全局的、可被多个实例共享(盒子头标 module_key@版本 + 第 k/N 个实例)。
  1937. // 定义层的引用(perl_module_ref)画在 ② 结构里;实例层的引用(call 节点)画在 ③ 运行流程里。
  1938. async function fetchModules(runId) {
  1939. const d = await fetchRound(runId, 0);
  1940. if (!d || !d.root || !d.round_count) return null;
  1941. const byIdx = {};
  1942. (d.rounds || []).forEach(r => byIdx[r.index] = r);
  1943. return { runId, root: d.root, count: d.round_count, byIdx, defs: d.modules || {} };
  1944. }
  1945. async function fetchRound(runId, i) {
  1946. try {
  1947. const r = await fetch(`/api/runs/${runId}/flat?round=${i}`);
  1948. return r.ok ? await r.json() : null;
  1949. } catch (e) { return null; }
  1950. }
  1951. // 当前轮就绪(缓存命中直接返回)。轮数可能随 run 推进增长,故 count 也一并更新。
  1952. async function ensureRound(i) {
  1953. if (!MODULES || MODULES.byIdx[i]) return MODULES && MODULES.byIdx[i];
  1954. const d = await fetchRound(MODULES.runId, i);
  1955. if (!d) return null;
  1956. MODULES.count = d.round_count || MODULES.count;
  1957. if (d.root) MODULES.root = d.root;
  1958. Object.assign(MODULES.defs, d.modules || {});
  1959. (d.rounds || []).forEach(r => MODULES.byIdx[r.index] = r);
  1960. return MODULES.byIdx[i];
  1961. }
  1962. // DOM id 安全化:module_key 里有中文/. —— 连线锚点要用 querySelector,先规整
  1963. const domId = s => "m" + String(s).replace(/[^A-Za-z0-9_-]/g, "_");
  1964. const instDom = inst => domId("i" + inst.id);
  1965. const defOf = inst => (MODULES && MODULES.defs[inst.module_id]) || { spec: {}, kind: "", refs: [] };
  1966. // **指向别的模块的东西,一律用那个模块自己的颜色** —— 结构图里的引用节点、运行流程里的引用芯片、
  1967. // 跨块的引用连线,三处同源。不这么做的话它们会继承"所在块"的色相,画面上就成了
  1968. // 「handler 块里指向 generate 的节点是 handler 色」——颜色反而误导。
  1969. // 取不到(如本轮画板里没有那个模块)就不覆盖 --mc,自然回落到所在块的颜色。
  1970. const hueStyle = key => {
  1971. const h = (MOD_COLOR || {})[key];
  1972. return h == null ? "" : ` style="--mc:${h}"`;
  1973. };
  1974. // 某个模块在**别人的图定义**里被声明成什么样(tone / icon 等展示元信息)。
  1975. // 这是定义层的事实(谁引用它、怎么标它),不是前端按名字猜的。
  1976. // ③ 运行流程里的一格:**引用另一个模块的某个运行实例**。
  1977. // 它与 llm / tool 节点同列同序号 —— 引用只是流程里的一步。两条连线(本节点 → 被调实例的输入、
  1978. // 被调实例的输出 → 回到本节点)由 drawCallEdges 按 data-callee 画出来。
  1979. function callChipHtml(f) {
  1980. return `<div class="call-chip" data-callee="${f.callee_inst_id}"
  1981. data-callee-key="${esc(f.callee_module_key || "")}"${hueStyle(f.callee_module_key)}
  1982. onclick="focusInst(${f.callee_inst_id})"
  1983. title="引用模块 ${esc(f.callee_module_key)} 的运行实例 #${f.callee_inst_id}">
  1984. <div class="cc-h">◆ 引用外部模块${f.node_key ? ` · ${esc(f.node_key)}` : ""}</div>
  1985. <div class="cc-k">${esc(f.callee_module_key)}</div>
  1986. <div class="cc-i">运行实例 #${f.callee_inst_id}${f.branch ? ` · ${esc(f.branch)}` : ""}</div></div>`;
  1987. }
  1988. // 跳转落点:横向对到目标的**起点**(左缘 + 这点边距),而不是居中 —— 运行链很长时居中会停在
  1989. // 链的中段,看不到实例从哪开始。
  1990. const FOCUS_LEFT_PAD = 48;
  1991. // 平移量用「当前 pan + 屏幕差值」的**增量**算:只依赖两个 getBoundingClientRect 的差,
  1992. // 与缩放倍率 z、transform-origin、容器内边距都无关(照抄世界坐标反推的写法在缩放时会偏)。
  1993. function focusPan(targetEl, vCenterEl) {
  1994. const board = $("#board"); if (!board || !BOARD || !targetEl) return;
  1995. const bb = board.getBoundingClientRect(), r = targetEl.getBoundingClientRect();
  1996. const v = (vCenterEl || targetEl).getBoundingClientRect();
  1997. BOARD.panX += (bb.left + FOCUS_LEFT_PAD) - r.left; // 横向:起点靠左
  1998. BOARD.panY += (bb.top + bb.height / 2) - (v.top + v.height / 2); // 纵向:居中
  1999. boardApply();
  2000. }
  2001. // 跳到某个运行实例(画板是 transform 平移缩放的,不能用 scrollIntoView)。
  2002. // 横向按**所属模块块**的左缘对齐(于是「模块定义 + 这条运行链的起点」一起进画面),纵向让目标实例居中。
  2003. function focusInst(id) {
  2004. const el = document.getElementById("inst-" + id), board = $("#board");
  2005. if (!el || !board || !BOARD) return;
  2006. document.querySelectorAll("#boardWorld .inst-lane.focus").forEach(x => x.classList.remove("focus"));
  2007. el.classList.add("focus");
  2008. focusPan(el.closest(".mod-block") || el, el);
  2009. }
  2010. // ① 输入 / ④ 输出:它们是**这条运行链的首尾节点**,与中间的运行节点排在同一条链上
  2011. // (而不是另立两段)——照示意图:(输入) → 节点1 → 节点2 → 节点3 → ▷输出。
  2012. function ioNodeHtml(inst, side, cards) {
  2013. const key = "i" + inst.id + ":" + side;
  2014. putCards(key, cards);
  2015. const cap = side === "in" ? "输入" : "输出";
  2016. return `<div class="lane-col io-node io-${side}" id="io${side}-${inst.id}">
  2017. <div class="ion-cap">${cap}</div>
  2018. <div class="ion-body">${cards.length ? cards.map((c, i) => renderPCard(key, i, c)).join("")
  2019. : `<div class="lane-empty">(无${cap})</div>`}</div></div>`;
  2020. }
  2021. // 一个运行实例 → **一条链**:输入 → 运行节点… → 输出。
  2022. // 同 seq = 同时发起(扇出 N 路 / 一轮多工具),并成一列画并发 —— 产生侧已把这件事写进数据。
  2023. function instLaneHtml(inst) {
  2024. const key = "i" + inst.id + ":flow";
  2025. const flow = inst.flow || [];
  2026. const cards = flow.filter(f => f.kind === "node").map(f => f.card);
  2027. putCards(key, cards);
  2028. const steps = [];
  2029. for (const f of flow) {
  2030. const last = steps[steps.length - 1];
  2031. if (last && last.seq === f.seq) last.items.push(f); else steps.push({ seq: f.seq, items: [f] });
  2032. }
  2033. const cols = steps.map(st => {
  2034. const inner = st.items.map(f => f.kind === "call"
  2035. ? callChipHtml(f) : renderPCard(key, cards.indexOf(f.card), f.card)).join("");
  2036. return st.items.length > 1
  2037. ? `<div class="lane-col conc"><div class="pc-conc-tag">⇉ 并发 ×${st.items.length}</div>
  2038. <div class="pc-parallel-stack">${inner}</div></div>`
  2039. : `<div class="lane-col">${inner}</div>`;
  2040. });
  2041. const d = defOf(inst);
  2042. const chain = [ioNodeHtml(inst, "in", blockCards((inst.input || {}).blocks))]
  2043. .concat(cols.length ? cols : [`<div class="lane-col"><div class="lane-empty">(无运行节点)</div></div>`])
  2044. .concat([ioNodeHtml(inst, "out",
  2045. outputCards(inst.output, d.view || (d.spec || {}).output_schema))]);
  2046. // 步与步之间不放分隔元素:连接由 drawLaneEdges 画进这条泳道自己的 SVG(实线曲线箭头、与所在
  2047. // 模块同色),元素之间只留 gap 供曲线穿过。
  2048. return `<div class="lane-steps"><svg class="lane-wires"></svg>${chain.join("")}</div>`;
  2049. }
  2050. // ② 结构 · workflow —— **它的结构就是一张流程图**:节点只写「引用哪个模块 + 跑几次」,
  2051. // 细节在被引模块自己那里(点节点跳过去看)。边带基数,1:N / N:1 直接读出扇出与汇聚。
  2052. function flowchartHtml(spec) {
  2053. const nodes = spec.nodes || [], edges = spec.edges || [];
  2054. if (!nodes.length) return "";
  2055. const lbl = (a, b) => (edges.find(e => e.from === a && e.to === b) || {});
  2056. const card = (n, i) => {
  2057. const many = n.cardinality === "many";
  2058. const to = n.module_key || "";
  2059. return `<div class="fc-node${to ? " ref" : ""}"${hueStyle(to)}${to ? ` data-to="${esc(to)}"
  2060. onclick="focusModule('${esc(to)}')" title="悬停看这条引用连到哪 · 点击跳过去"` : ""}>
  2061. <div class="fc-i">${i + 1}</div>
  2062. <div class="fc-b"><div class="fc-t">${esc(n.title || n.key)}</div>
  2063. ${to ? `<div class="fc-k">${esc(to)}</div>` : ""}
  2064. ${many ? `<span class="fc-x">×N${n.fanout_over ? " · " + esc(n.fanout_over) : ""}</span>`
  2065. : (n.cardinality === "optional" ? `<span class="fc-x">可能不跑</span>` : "")}
  2066. ${n.dispatch ? `<span class="fc-x">动态派发 · ${esc(n.dispatch)}</span>` : ""}</div></div>`;
  2067. };
  2068. const rows = nodes.map((n, i) => {
  2069. const e = i < nodes.length - 1 ? lbl(n.key, nodes[i + 1].key) : null;
  2070. return card(n, i) + (i < nodes.length - 1
  2071. ? `<div class="fc-edge"><span class="fc-card">${esc(e.cardinality || "")}</span>${
  2072. e.label ? `<span class="fc-l">${esc(e.label)}</span>` : ""}</div>` : "");
  2073. }).join("");
  2074. return `<div class="fc">${rows}</div>`;
  2075. }
  2076. // ② 结构 · code —— **确定性代码段的定义就是它的实现**。散文规则说不清也会漂移,源码不会。
  2077. // 结构模块里**不内联源码**(否则 code 的结构列会被拉宽,与 agent 模块不一致):只给标题 + 一条
  2078. // 「查看完整代码」入口,全文进 CODE_SRC 登记,点开在**右侧抽屉**看(见 openCodeModal)。key 用模块键。
  2079. let CODE_SRC = {};
  2080. function sourceHtml(spec, key) {
  2081. const src = spec.source;
  2082. if (!src) return "";
  2083. CODE_SRC[key] = src;
  2084. return `<div class="src-card">
  2085. <div class="src-h"><span class="src-q">${esc(src.qualname || "")}</span>
  2086. <span class="src-f">${esc(src.file || "")}:${src.line}</span></div>
  2087. <div class="src-more" onclick="openCodeModal('${esc(key)}')">查看完整代码 ›</div></div>`;
  2088. }
  2089. // 完整源码 → 复用右侧详情抽屉(与其它节点详情同一个位置,不再另开居中弹窗)。
  2090. function openCodeModal(key) {
  2091. const s = CODE_SRC[key]; if (!s) return;
  2092. $("#drawerTag").textContent = "CODE"; $("#drawerTag").className = "drawer-tag";
  2093. $("#drawerTitle").textContent = s.qualname || key;
  2094. $("#drawerBody").innerHTML = `<div class="src-card">
  2095. <div class="src-h"><span class="src-f">${esc(s.file || "")}${s.line != null ? ":" + s.line : ""}</span></div>
  2096. <pre class="src-code">${esc(s.code || "")}</pre></div>`;
  2097. openDrawer();
  2098. }
  2099. // 跳到某个模块块(结构图上的节点 → 那个模块)。落点同样是这个模块的**起点**:
  2100. // 块左缘对到视口左侧,于是看到的是「模块定义 + 运行流程开头」(见 focusPan)。
  2101. function focusModule(key) {
  2102. const el = document.getElementById("bn-" + domId(key)), board = $("#board");
  2103. if (!el || !board || !BOARD) return;
  2104. document.querySelectorAll("#boardWorld .mod-block.focus").forEach(x => x.classList.remove("focus"));
  2105. el.classList.add("focus");
  2106. focusPan(el, el);
  2107. }
  2108. // 一个运行实例 → 泳道(定义那一侧只画一次,见 renderModuleBoard)
  2109. function instLaneBox(inst, n) {
  2110. const flags = [
  2111. inst.task_id != null ? `<span class="mmc-sid">Task #${esc(inst.task_id)}</span>` : "",
  2112. inst.branch ? `<span class="mmc-sid">${esc(inst.branch)}</span>` : "",
  2113. inst.ok === false ? `<span class="mmc-badge failed">未达标</span>`
  2114. : inst.ok === true ? `<span class="mmc-badge passed">通过</span>` : "",
  2115. (inst.timing && inst.timing.sec != null) ? `<span class="gn-sub">${inst.timing.sec}s</span>` : "",
  2116. inst.tokens ? `<span class="gn-sub">${esc(inst.tokens)} tok</span>` : "",
  2117. inst.caller ? `<span class="il-from" onclick="event.stopPropagation();focusInst(${inst.caller.inst_id})"
  2118. title="谁引用了本实例(沿 call 边反查)">← 被 #${inst.caller.inst_id} 引用</span>` : "",
  2119. ].join("");
  2120. return `<div class="inst-lane" id="inst-${inst.id}">
  2121. <div class="il-cap">运行实例 ${n}
  2122. <span class="id-chip" title="运行实例 id(点击复制)" onclick="event.stopPropagation();copyId('${inst.id}',this)">#${inst.id}</span>
  2123. ${inst.label ? `<span class="il-t">${esc(inst.label)}</span>` : ""}
  2124. <span class="il-flags">${flags}</span></div>
  2125. ${instLaneHtml(inst)}</div>`;
  2126. }
  2127. // 卡片里的图是**异步加载**的:加载完高度才定下来。不重排的话块的尺寸是按"图还没来"时量的,
  2128. // 连线端点就会落在旧位置。这里给每张图挂一次性回调,去抖后重排。
  2129. function relayoutOnImages(root) {
  2130. let t = null;
  2131. const kick = () => { clearTimeout(t); t = setTimeout(() => { layoutBoard(); }, 60); };
  2132. root.querySelectorAll("img").forEach(img => {
  2133. if (img.complete) return;
  2134. img.addEventListener("load", kick, { once: true });
  2135. img.addEventListener("error", kick, { once: true });
  2136. });
  2137. }
  2138. // 模块级**调用树**:本轮的模块按「谁把活交给谁」串成一棵**横向生长**的树。
  2139. //
  2140. // 父子 = ① 入口链的先后(`entry_ids`:plan 规划完 → 派发给 handler 执行,是**交接**不是引用)
  2141. // ② 实例层真实发生的 call 边(权威)
  2142. // ③ 定义层声明的 refs(补上本轮没跑、但结构里声明了的模块)
  2143. //
  2144. // 为什么不再按「调用图最长路径分层、层=行」:plan 与 handler 都没有被谁调用,纯调用图会把它们
  2145. // 判成同一层堆在一起 —— 但它们其实是先后关系。层次必须把**入口链的先后**算进去。
  2146. // 摆位(x/y)在 placeBlocks 里做:那要等 DOM 量出每块的实际尺寸。
  2147. function moduleTree(insts, defs, entryIds) {
  2148. const byKey = {}; // module_key → {insts, defIds}
  2149. const push = (k, id, inst) => {
  2150. const b = byKey[k] || (byKey[k] = { key: k, insts: [], defIds: [] });
  2151. if (inst) b.insts.push(inst);
  2152. const normalizedId = id == null ? null : String(id);
  2153. if (normalizedId != null && !b.defIds.includes(normalizedId)) b.defIds.push(normalizedId);
  2154. };
  2155. insts.forEach(n => push(n.module_key, n.module_id, n));
  2156. Object.keys(defs).forEach(id => push(defs[id].module_key, id, null));
  2157. // **run 级根模块不显示**:整个 run 就是它的一次执行,它是所有轮次的**容器**而不是本轮的一个模块
  2158. // —— 轮次按「根实例流程里每个 *.plan 调用」切分,根实例按构造永远落不进任何一轮的闭包,画出来只能是
  2159. // 恒为 0 实例的空块。这个视图看的是**每一轮里发生了什么**,run 级的循环图定义不在这个尺度上。
  2160. const rootKey = ((MODULES || {}).root || {}).module_key;
  2161. if (rootKey) delete byKey[rootKey];
  2162. const children = new Map(), parent = new Map(), edgeKind = new Map();
  2163. // 每条父子边带一个**类型**(entry / call / ref)——摆位据它分轴:entry 走水平主干,
  2164. // call/ref 走「往下 + 右缩进」(见 placeBlocks)。首个成边的 link 决定父与边类型(多父取第一个)。
  2165. const link = (x, y, kind) => { // 保序去重;已有父的不改父(多父取第一个)
  2166. if (!x || !y || x === y || !byKey[x] || !byKey[y] || parent.has(y)) return;
  2167. if (!children.has(x)) children.set(x, []);
  2168. if (children.get(x).includes(y)) return;
  2169. children.get(x).push(y); parent.set(y, x); edgeKind.set(y, kind);
  2170. };
  2171. const instById = {}; insts.forEach(n => instById[n.id] = n);
  2172. // ① 入口链:本轮根级实例按执行先后(plan → handler),前一个的产出交给后一个
  2173. const chain = (entryIds || []).map(id => (instById[id] || {}).module_key).filter(Boolean);
  2174. for (let i = 1; i < chain.length; i++) link(chain[i - 1], chain[i], "entry");
  2175. // ② 实例层真实调用(flow 已按 seq 排好 → 子的次序 = 本轮真实的调用先后)
  2176. insts.forEach(n => (n.flow || []).forEach(f => {
  2177. if (f.kind === "call") link(n.module_key, f.callee_module_key, "call");
  2178. }));
  2179. // ③ 定义层声明的引用(本轮没跑的模块也有个去处)
  2180. Object.keys(defs).forEach(id => (defs[id].refs || []).forEach(r => link(defs[id].module_key, r.to_module_key, "ref")));
  2181. const keys = Object.keys(byKey);
  2182. const roots = (chain.length && byKey[chain[0]] ? [chain[0]] : []).concat(
  2183. keys.filter(k => !parent.has(k) && k !== chain[0]));
  2184. // 深度(给块上标 L0/L1…;纯展示,摆位不依赖它)
  2185. const depth = {}; const dfs = (k, d) => {
  2186. if (depth[k] != null) return;
  2187. depth[k] = d; (children.get(k) || []).forEach(c => dfs(c, d + 1));
  2188. };
  2189. roots.forEach(r => dfs(r, 0));
  2190. keys.forEach(k => { if (depth[k] == null) depth[k] = 0; });
  2191. return { byKey, keys, roots, children, parent, depth, edgeKind,
  2192. lastEntry: chain.length ? chain[chain.length - 1] : null };
  2193. }
  2194. // 一轮 → 画板:**一个模块一块**(定义只画一次,右侧是它这一轮的全部运行实例),
  2195. // 块按**两个轴**摆(详见 placeBlocks):入口链的主干**水平**往右读;模块内部引用/调用的被调块
  2196. // **往下 + 右缩进一个「模块定义」宽度**展开,同一父的多个被调块彼此水平排开、递归适用。
  2197. // 不同模块用不同颜色(按块的出现顺序取色相),于是"同一个模块的多次运行"一眼可辨。
  2198. let TREE = null; // 本轮的调用树(供 placeBlocks 摆位)
  2199. function renderModuleBoard(round) {
  2200. const world = $("#boardWorld"); if (!world) return;
  2201. const insts = round.instances || [];
  2202. const defs = (MODULES && MODULES.defs) || {};
  2203. const t = moduleTree(insts, defs, round.entry_ids);
  2204. if (!t.keys.length) { TREE = null; world.innerHTML = `<div class="board-none">本轮无运行实例</div>`; return; }
  2205. // 摆位次序 = 树的先序(根 → 子 → 孙);不在树上的(孤儿)排在后面
  2206. const order = [], seen = new Set();
  2207. const walk = k => { if (seen.has(k)) return; seen.add(k); order.push(k);
  2208. (t.children.get(k) || []).forEach(walk); };
  2209. t.roots.forEach(walk);
  2210. t.keys.forEach(k => { if (!seen.has(k)) { seen.add(k); order.push(k); } });
  2211. // entryInsts:本轮入口实例的**实例 id**(按执行先后)——交接连线的端点取它们的输出/输入锚点
  2212. TREE = { ...t, order, entryInsts: round.entry_ids || [] };
  2213. MOD_COLOR = {};
  2214. order.forEach((k, i) => MOD_COLOR[k] = Math.round((i * 47) % 360));
  2215. const blockHtml = key => {
  2216. const b = t.byKey[key];
  2217. const d = defs[b.defIds[0]] || (b.insts[0] ? defOf(b.insts[0]) : {});
  2218. const sample = b.insts[0], sk = "mod:" + b.key;
  2219. // 结构按 kind 分化:workflow = 流程图(引用别的模块,点节点跳过去);code = 源码;agent = 卡片。
  2220. // 「这是什么 / System Prompt / 输入 / 工具 / 模型」是 **agent/llm 专属**的结构卡 —— workflow / code
  2221. // 不走这套(它们的结构就是流程图 / 源码本身,summary 也已在 md-sum 显示过),否则会多出一张「这是什么」。
  2222. const divergent = d.kind === "workflow" || d.kind === "code";
  2223. const cards = divergent ? [] : structCards(d.spec || {}, sample ? sample.input : null,
  2224. sample ? sample.used_tools : []);
  2225. putCards(sk, cards);
  2226. // **结构只描述自己**:被引模块的细节不在这里,所以这里没有它们的 prompt / 模型 / 输入槽。
  2227. const kindHtml = d.kind === "workflow" ? flowchartHtml(d.spec || {})
  2228. : d.kind === "code" ? sourceHtml(d.spec || {}, b.key) : "";
  2229. return `<div class="mod-block" id="bn-${domId(b.key)}" data-key="${esc(b.key)}" style="--mc:${MOD_COLOR[b.key]}">
  2230. <div class="mod-def">
  2231. <div class="md-cap">模块定义<span class="md-depth" title="调用树上的深度">L${t.depth[b.key]}</span></div>
  2232. <div class="md-key">${esc(b.key)}</div>
  2233. <div class="md-meta"><span class="md-kind">${esc(d.kind || "")}</span>
  2234. ${d.fingerprint ? `<span class="md-ver" title="定义版本指纹">@${esc(d.fingerprint)}</span>` : ""}
  2235. ${b.defIds.length > 1 ? `<span class="md-ver">${b.defIds.length} 个版本</span>` : ""}</div>
  2236. ${d.summary ? `<div class="md-sum">${esc(clip(d.summary, 90))}</div>` : ""}
  2237. <div class="md-cards">${kindHtml}${cards.map((c, i) => renderPCard(sk, i, c)).join("")
  2238. || (kindHtml ? "" : `<div class="lane-empty">(无结构信息)</div>`)}</div>
  2239. </div>
  2240. <div class="mod-runs">
  2241. <div class="mr-cap">运行流程 · 本轮 ${b.insts.length} 个实例</div>
  2242. ${b.insts.length ? b.insts.map((n, i) => instLaneBox(n, i + 1)).join("")
  2243. : `<div class="inst-lane off"><div class="lane-empty">(本轮没有这个模块的运行实例)</div></div>`}
  2244. </div></div>`;
  2245. };
  2246. // 「下一轮」落点:控制循环是 plan ⇄ execute 的**环**,本轮跑完要回到下一轮 plan。
  2247. // 那条回边不在本轮的模块图里(它落到的是下一轮),所以单摆一个节点放在**最右**、与本轮最终
  2248. // 产出那个块同一水平带:从它的产出连过去,点击即翻到下一轮。
  2249. const entries = round.entry_ids || [];
  2250. const lastEntry = entries.length ? entries[entries.length - 1] : null;
  2251. const nextIdx = SNAP.idx + 1, hasNext = SNAP.list && nextIdx < SNAP.list.length;
  2252. TREE.lastEntryKey = t.lastEntry;
  2253. const tail = (hasNext && lastEntry != null) ? `
  2254. <div class="next-round" id="nextRoundNode" data-from="${lastEntry}"
  2255. onclick="gotoSnap(${nextIdx})" title="翻到下一轮">
  2256. <div class="nr-cap">↩ 回吐 · 进入下一轮</div>
  2257. <div class="nr-t">第 ${nextIdx + 1} 轮 · ${esc(roundLabel(SNAP.list[nextIdx], nextIdx))}</div></div>` : "";
  2258. world.innerHTML = `<svg class="board-edges" id="boardEdges"></svg>
  2259. <svg class="board-edges hot" id="boardHot"></svg>
  2260. <div class="board-labels" id="boardLabels"></div>` +
  2261. order.map(blockHtml).join("") + tail;
  2262. relayoutOnImages(world);
  2263. requestAnimationFrame(() => layoutBoard());
  2264. }
  2265. // 摆位:块尺寸由内容决定,只能**先渲染再量**。量完分两个轴摆(边类型见 moduleTree.edgeKind),
  2266. // 并按「**竖直层**」拉齐高度:
  2267. // · **入口链(entry:plan→handler 的交接)→ 水平**:后继块放在父块右缘、同一层;
  2268. // · **引用/调用(call/ref:模块内部引用了别的模块)→ 往下一层 + 右缩进一个「模块定义」的宽度**:
  2269. // 被调块落到父块**下面一层**、左缘对齐父块运行流程起点,同一父的多个被调块依次向右排开;递归适用。
  2270. // **竖直层 = 从根经过几条「往下」的引用边**(entry 不增层、call/ref 增一层)。同一层的所有块**拉齐到
  2271. // 该层最高块的高度**(顶、底都对齐,形成整齐的水平带),于是下一层从统一的 y 起,层级带一目了然。
  2272. // **唯独最底层保持自适应高度**(它下面没有东西要对齐,拉齐只会平添空白)。
  2273. // 入口链后继放在**本子树右缘之后**(清开自己那串被调子树),避免压在一起;不设统一列宽,宽块只撑自己那带。
  2274. const BLK_GX = 56, BLK_GY = 56, BLK_PAD = 32;
  2275. function placeBlocks() {
  2276. const world = $("#boardWorld");
  2277. if (!world || !TREE) return { w: world ? world.scrollWidth : 0, h: world ? world.scrollHeight : 0 };
  2278. const ek = TREE.edgeKind || new Map();
  2279. // 先清掉上一次可能设过的强制高度,再量**自然**尺寸(否则量到的是被拉过的高度)。
  2280. const boxes = {};
  2281. TREE.order.forEach(k => {
  2282. const el = document.getElementById("bn-" + domId(k));
  2283. if (!el) return;
  2284. el.style.height = "";
  2285. const def = el.querySelector(".mod-def");
  2286. boxes[k] = { el, w: el.offsetWidth, h: el.offsetHeight, defW: def ? def.offsetWidth : 40 };
  2287. });
  2288. // ① 竖直分层:root=0;entry 后继同层;call/ref 被调 +1 层。首次赋值为准(多父取第一个)。
  2289. // 遍历顺序与下面的 walk 一致(refKids 先、entryKid 后),保证「多父块归到同一层与同一次摆放」。
  2290. const row = {};
  2291. const assignRow = (k, r) => {
  2292. if (row[k] != null) return;
  2293. row[k] = r;
  2294. const kids = TREE.children.get(k) || [];
  2295. kids.filter(c => ek.get(c) !== "entry").forEach(c => assignRow(c, r + 1));
  2296. const e = kids.find(c => ek.get(c) === "entry"); if (e) assignRow(e, r);
  2297. };
  2298. TREE.roots.forEach(r => assignRow(r, 0));
  2299. // ② 每层最高块 + 最底层号;③ 每层顶 y(层高累加)
  2300. const rowMaxH = {}; let maxRow = 0;
  2301. Object.keys(row).forEach(k => {
  2302. const b = boxes[k]; if (!b) return;
  2303. const r = row[k]; rowMaxH[r] = Math.max(rowMaxH[r] || 0, b.h); if (r > maxRow) maxRow = r;
  2304. });
  2305. const rowY = {}; let acc = BLK_PAD;
  2306. for (let r = 0; r <= maxRow; r++) { rowY[r] = acc; acc += (rowMaxH[r] || 0) + BLK_GY; }
  2307. const rowH = k => (row[k] < maxRow ? rowMaxH[row[k]] : boxes[k].h); // 非最底层拉齐层高;最底层自适应
  2308. let maxR = 0, maxB = 0;
  2309. const done = new Set();
  2310. const walk = (k, x) => { // 只算 x(横向摆放/避让);y 与高度由所在层决定。返回子树右缘
  2311. const s = boxes[k]; if (!s) return x;
  2312. const y = rowY[row[k]], h = rowH(k);
  2313. s.el.style.left = x + "px"; s.el.style.top = y + "px";
  2314. s.el.style.height = (row[k] < maxRow) ? h + "px" : "";
  2315. maxR = Math.max(maxR, x + s.w); maxB = Math.max(maxB, y + h);
  2316. const kids = (TREE.children.get(k) || []).filter(c => !done.has(c));
  2317. const refKids = kids.filter(c => ek.get(c) !== "entry"); // 引用/调用:往下一层 + 缩进
  2318. const entryKid = kids.find(c => ek.get(c) === "entry"); // 入口链后继:同层水平(至多一个,链是线性的)
  2319. let right = x + s.w;
  2320. let cx = x + s.defW; // 被调块缩进一个 mod-def 宽度(往下一层,y 自动来自 rowY)
  2321. refKids.forEach(c => { done.add(c); const r = walk(c, cx); cx = r + BLK_GX; right = Math.max(right, r); });
  2322. if (entryKid) { // 放在本子树右缘之后,清开被调子树避免重叠
  2323. done.add(entryKid);
  2324. const r = walk(entryKid, Math.max(x + s.w + BLK_GX, right + BLK_GX));
  2325. right = Math.max(right, r);
  2326. }
  2327. return right;
  2328. };
  2329. let rx = BLK_PAD; // 多个根同在第 0 层 → 依次向右排开
  2330. TREE.roots.forEach(r => { if (done.has(r)) return; done.add(r); rx = walk(r, rx) + BLK_GX; });
  2331. let ty = maxB + BLK_GY; // 兜底:没进树的块(既非 root 又没被引用到)接在最下面,自然高
  2332. TREE.order.forEach(k => {
  2333. if (done.has(k)) return;
  2334. done.add(k);
  2335. const s = boxes[k]; if (!s) return;
  2336. s.el.style.height = "";
  2337. s.el.style.left = BLK_PAD + "px"; s.el.style.top = ty + "px";
  2338. maxR = Math.max(maxR, BLK_PAD + s.w); maxB = Math.max(maxB, ty + s.h);
  2339. ty += s.h + BLK_GY;
  2340. });
  2341. const nr = document.getElementById("nextRoundNode");
  2342. if (nr) { // 最右、与本轮最终产出那块同一水平带
  2343. const anchor = boxes[TREE.lastEntryKey];
  2344. nr.style.left = (maxR + BLK_GX) + "px";
  2345. nr.style.top = (anchor ? anchor.el.offsetTop : BLK_PAD) + "px";
  2346. maxR = maxR + BLK_GX + nr.offsetWidth;
  2347. maxB = Math.max(maxB, nr.offsetTop + nr.offsetHeight);
  2348. }
  2349. return { w: maxR + BLK_PAD, h: maxB + BLK_PAD };
  2350. }
  2351. function renderBoard() {
  2352. const world = $("#boardWorld"); if (!world) return;
  2353. SNAP.cards = {};
  2354. const round = MODULES && MODULES.byIdx[SNAP.idx];
  2355. if (round) { renderModuleBoard(round); return; }
  2356. // 本轮没有模块行 → 没有可画的节点图,如实说明而不是空白一片。
  2357. SNAP.graph = { nodes: [], edges: [] };
  2358. TREE = null;
  2359. world.innerHTML = `<svg class="board-edges" id="boardEdges"></svg>
  2360. <svg class="board-edges hot" id="boardHot"></svg>
  2361. <div class="board-labels" id="boardLabels"></div>
  2362. <div class="board-none">本轮无节点记录</div>`;
  2363. }
  2364. // ── 画板布局:先按调用树给每个块定 x/y(块的尺寸由内容决定,必须先渲染再量),再画连线 ──
  2365. function layoutBoard() {
  2366. const world = $("#boardWorld"); if (!world || !world.offsetParent) return; // 隐藏时跳过
  2367. world.style.width = ""; world.style.height = "";
  2368. const s = placeBlocks();
  2369. BOARD.worldW = Math.max(s.w, world.scrollWidth);
  2370. BOARD.worldH = Math.max(s.h, world.scrollHeight);
  2371. world.style.width = BOARD.worldW + "px"; world.style.height = BOARD.worldH + "px";
  2372. drawLaneEdges();
  2373. drawCallEdges();
  2374. if (!BOARD.fitted) { BOARD.fitted = true; boardFit(); } else boardApply();
  2375. }
  2376. // 一条 SVG 路径的通用件:箭头**与线同色**(marker 不继承 stroke,只能一种颜色一个 marker)。
  2377. const mkId = c => "mk" + String(c).replace(/[^A-Za-z0-9]/g, "");
  2378. const svgPaths = paths =>
  2379. `<defs>${[...new Set(paths.map(p => p.c))].map(c =>
  2380. `<marker id="${mkId(c)}" markerWidth="7" markerHeight="7" refX="6.2" refY="2.4" orient="auto">
  2381. <path d="M0,0 L6.4,2.4 L0,4.8 Z" fill="${c}"/></marker>`).join("")}</defs>` +
  2382. paths.map(p => `<path d="${p.d}" fill="none" stroke="${p.c}" stroke-width="${p.w || 2}" opacity=".9"
  2383. stroke-linecap="round"${
  2384. p.dash ? ` stroke-dasharray="${p.dash}"` : ""} marker-end="url(#${mkId(p.c)})"/>`).join("");
  2385. // 模块块的色相 —— 连线与该模块的定义块、泳道边框同色(颜色是「这是谁」的编码,别用灰)
  2386. const hueOf = el => {
  2387. const blk = el && el.closest && el.closest(".mod-block");
  2388. const h = blk ? getComputedStyle(blk).getPropertyValue("--mc").trim() : "";
  2389. return h ? `hsl(${h} 70% 62%)` : "var(--muted)";
  2390. };
  2391. // ① 链内连接:一条运行链上相邻两步(输入 → 节点 → … → 输出),实线曲线箭头。
  2392. // 画在**泳道自己的** SVG 上(泳道背景之上、卡片之下),所以线是清晰的而不是隔着几层半透明底色。
  2393. // DOM 里的次序就是链的次序(instLaneHtml 按 seq 排好),这里只量几何、不做推断。
  2394. //
  2395. // **并发一列 = 多个端点**:同 seq 的 N 步是并成一列画的(产生侧标的并发事实),连线就该是
  2396. // 一分为 N(扇出)、N 合一(汇聚)—— 一条线连到"那一列"上,等于把 N 步画成了一步。
  2397. function drawLaneEdges() {
  2398. const z = (BOARD && BOARD.z) || 1;
  2399. // 一步的**连接端点**:并发列取列里的每张卡(N 个端点),其余取这一列本身(1 个端点)。
  2400. const portsOf = col => {
  2401. const stack = col.querySelector(":scope > .pc-parallel-stack");
  2402. return stack && stack.children.length ? [...stack.children] : [col];
  2403. };
  2404. document.querySelectorAll("#boardWorld .lane-steps").forEach(lane => {
  2405. const svg = lane.querySelector(":scope > .lane-wires"); if (!svg) return;
  2406. const items = [...lane.querySelectorAll(":scope > .lane-col")];
  2407. const lb = lane.getBoundingClientRect();
  2408. const w = lane.scrollWidth, h = lane.scrollHeight;
  2409. svg.setAttribute("width", w); svg.setAttribute("height", h);
  2410. svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
  2411. const R = el => {
  2412. const r = el.getBoundingClientRect();
  2413. return { l: (r.left - lb.left) / z, r: (r.right - lb.left) / z,
  2414. cy: (r.top + r.height / 2 - lb.top) / z };
  2415. };
  2416. const color = hueOf(lane), paths = [];
  2417. // 出入端都水平离开/进入,控制点按水平间距取、再按落差补一点 → 扇出扇入是对称的平顺弧
  2418. const link = (a, b) => paths.push({ d: hcurve(a, b), c: color });
  2419. for (let i = 0; i + 1 < items.length; i++) {
  2420. const A = portsOf(items[i]).map(R), B = portsOf(items[i + 1]).map(R);
  2421. if (!A.length || !B.length || B[0].l <= A[0].r) continue;
  2422. if (A.length === 1 || B.length === 1) A.forEach(a => B.forEach(b => link(a, b))); // 扇出 / 汇聚
  2423. else A.slice(0, Math.min(A.length, B.length)).forEach((a, k) => link(a, B[k])); // 等宽并发:逐路对接
  2424. }
  2425. svg.innerHTML = svgPaths(paths);
  2426. });
  2427. }
  2428. // 世界坐标下的矩形(画板是 transform 缩放的,量出来的像素要除以 z)
  2429. function boardRect(el) {
  2430. if (!el) return null;
  2431. const wb = $("#boardWorld").getBoundingClientRect(), z = (BOARD && BOARD.z) || 1;
  2432. const r = el.getBoundingClientRect();
  2433. return { l: (r.left - wb.left) / z, t: (r.top - wb.top) / z, r: (r.right - wb.left) / z,
  2434. b: (r.bottom - wb.top) / z, cx: (r.left + r.width / 2 - wb.left) / z,
  2435. cy: (r.top + r.height / 2 - wb.top) / z };
  2436. }
  2437. const vcurve = (a, b) => { // 竖向:平滑三次贝塞尔,不出折角
  2438. const dy = Math.max(40, Math.abs(b.cy - a.cy) * 0.45);
  2439. return `M${a.cx},${a.cy} C${a.cx},${a.cy + dy} ${b.cx},${b.cy - dy} ${b.cx},${b.cy}`;
  2440. };
  2441. const hcurve = (a, b) => { // 横向:右缘 → 左缘,控制点纯水平 → 平顺的 S
  2442. const c = Math.min(Math.max((b.l - a.r) * 0.55, 26), 150) + Math.abs(b.cy - a.cy) * 0.18;
  2443. return `M${a.r},${a.cy} C${a.r + c},${a.cy} ${b.l - c},${b.cy} ${b.l},${b.cy}`;
  2444. };
  2445. // ── 绕开模块块的正交布线 ─────────────────────────────────────────────────────
  2446. // 跨模块那几条线要跨过整块画面,直着连必然从别的模块的**执行流程**上压过去 —— 线是次要信息,
  2447. // 不该盖住主要内容。摆位是我们自己算的(placeBlocks),**块与块之间的空隙在哪是已知的**,
  2448. // 所以不需要通用避障算法:把线走成「竖直逃逸 → 在空隙里横穿 → 竖直切入」,逐段与所有块做
  2449. // 矩形碰撞检测,取不撞的那条(多条都可行时取最短)。撞不出路时才退回原来的曲线。
  2450. const CORRIDOR = 26; // 逃逸/横穿时离块边的留白(块间距 56 的一半)
  2451. function boardObstacles() {
  2452. return [...document.querySelectorAll("#boardWorld .mod-block, #boardWorld .next-round")]
  2453. .map(el => ({ el, ...boardRect(el) })).filter(o => o.l != null);
  2454. }
  2455. // 线段(水平或竖直)与矩形是否相交(留 6px 余量,贴边不算撞)
  2456. function segHits(x1, y1, x2, y2, r) {
  2457. const p = 6, L = r.l + p, R = r.r - p, T = r.t + p, B = r.b - p;
  2458. if (R <= L || B <= T) return false;
  2459. if (x1 === x2) return x1 > L && x1 < R && Math.max(y1, y2) > T && Math.min(y1, y2) < B;
  2460. return y1 > T && y1 < B && Math.max(x1, x2) > L && Math.min(x1, x2) < R;
  2461. }
  2462. // 两个锚点之间的正交路线:竖直出 → 走廊横穿 → 竖直入。srcBlk/dstBlk 是两端所在的块(首尾段允许穿过它们)。
  2463. function orthoRoute(a, b, srcBlk, dstBlk, obs) {
  2464. if (!a || !b || !srcBlk || !dstBlk) return null;
  2465. const lanes = [srcBlk.b + CORRIDOR, srcBlk.t - CORRIDOR, dstBlk.b + CORRIDOR, dstBlk.t - CORRIDOR];
  2466. let best = null;
  2467. for (const ye of lanes) {
  2468. const pts = [[a.cx, ye >= a.cy ? a.b : a.t], [a.cx, ye], [b.cx, ye], [b.cx, ye >= b.cy ? b.b : b.t]]
  2469. .filter((p, i, arr) => i === 0 || p[0] !== arr[i - 1][0] || p[1] !== arr[i - 1][1]);
  2470. let ok = pts.length > 1, len = 0;
  2471. for (let i = 0; i + 1 < pts.length && ok; i++) {
  2472. const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
  2473. len += Math.abs(x2 - x1) + Math.abs(y2 - y1);
  2474. for (const o of obs) {
  2475. if (i === 0 && o.el === srcBlk.el) continue; // 起点在源块里,第一段必然穿它
  2476. if (i === pts.length - 2 && o.el === dstBlk.el) continue; // 末段切进目标块
  2477. if (segHits(x1, y1, x2, y2, o)) { ok = false; break; }
  2478. }
  2479. }
  2480. if (ok && (!best || len < best.len)) best = { pts, len };
  2481. }
  2482. return best ? best.pts : null;
  2483. }
  2484. // 折线 → 圆角路径(拐角用二次贝塞尔抹圆,读起来像走线而不是折断)
  2485. function polyPath(pts, r = 16) {
  2486. let d = `M${pts[0][0]},${pts[0][1]}`;
  2487. for (let i = 1; i + 1 < pts.length; i++) {
  2488. const [px, py] = pts[i - 1], [cx, cy] = pts[i], [nx, ny] = pts[i + 1];
  2489. const d1 = Math.hypot(cx - px, cy - py), d2 = Math.hypot(nx - cx, ny - cy);
  2490. const rr = Math.min(r, d1 / 2, d2 / 2);
  2491. d += ` L${cx + (px - cx) / d1 * rr},${cy + (py - cy) / d1 * rr}`
  2492. + ` Q${cx},${cy} ${cx + (nx - cx) / d2 * rr},${cy + (ny - cy) / d2 * rr}`;
  2493. }
  2494. const e = pts[pts.length - 1];
  2495. return d + ` L${e[0]},${e[1]}`;
  2496. }
  2497. // 画板上的连线分**两层**,看的是两件不同的事,出现时机也不同:
  2498. //
  2499. // · 结构层(定义 → 定义):「这个模块的结构里引用了哪个模块」。它**逐轮不变**,一直挂在画面上
  2500. // 只会把运行过程压在底下 —— 改成**悬停结构才出现**(停在「模块定义」那一列、或结构图里那个引用节点上)。
  2501. // · 运行层(实例 → 实例):本轮**实际**怎么走的 —— 调用点把输入递进被调实例、被调实例把输出递回
  2502. // 调用点。这是跨模块的**输入 / 输出**两条线,同样悬停才出现。
  2503. //
  2504. // 常驻画面的只有一条:本轮最终产出 →「下一轮」落点(控制循环的回边,不属于任何模块)。
  2505. let CALL_AGG = new Map();
  2506. function drawCallEdges() {
  2507. const edges = $("#boardEdges"); if (!edges) return;
  2508. edges.setAttribute("width", BOARD.worldW); edges.setAttribute("height", BOARD.worldH);
  2509. edges.setAttribute("viewBox", `0 0 ${BOARD.worldW} ${BOARD.worldH}`);
  2510. // 按 (调用方模块 → 被调模块) 归并计数:悬停结构时给「本轮 ×N」用。这是对 call 边的**计数**,不是推断。
  2511. CALL_AGG = new Map();
  2512. $("#boardWorld").querySelectorAll(".call-chip[data-callee]").forEach(chip => {
  2513. const from = (chip.closest(".mod-block") || {}).dataset?.key || "";
  2514. const to = chip.dataset.calleeKey || "";
  2515. if (!from || !to) return;
  2516. const k = from + " " + to;
  2517. CALL_AGG.set(k, (CALL_AGG.get(k) || 0) + 1);
  2518. });
  2519. const paths = [];
  2520. // 入口链的**交接**:前一段的产出 → 后一段的输入(plan 选定的 Task 递给 handler 执行)。
  2521. // 它不是 call 边 —— 两者都是本轮的根级实例、彼此没有调用关系 —— 但它是这一轮的**主干**,
  2522. // 所以常驻显示(实线,与"引用"那种虚线区分开)。端点取两条泳道真实的输出 / 输入锚点。
  2523. const chain = (TREE && TREE.entryInsts) || [];
  2524. for (let i = 1; i < chain.length; i++) {
  2525. const o = boardRect(document.getElementById("ioout-" + chain[i - 1]));
  2526. const n = boardRect(document.getElementById("ioin-" + chain[i]));
  2527. if (!o || !n) continue;
  2528. paths.push({ d: n.l >= o.r - 4 ? hcurve(o, n)
  2529. : vcurve({ cx: o.cx, cy: o.b }, { cx: n.cx, cy: n.t }),
  2530. c: "var(--ok)", w: 2.6 });
  2531. }
  2532. const nr = document.getElementById("nextRoundNode");
  2533. if (nr && nr.dataset.from) {
  2534. // 这条要横跨大半张画面 → 同样走绕开所有块的正交路线(见 orthoRoute)
  2535. const oEl = document.getElementById("ioout-" + nr.dataset.from);
  2536. const o = boardRect(oEl), t = boardRect(nr), obs = boardObstacles();
  2537. if (o && t) {
  2538. const src = oEl && oEl.closest(".mod-block");
  2539. const pts = orthoRoute(o, t, obs.find(x => x.el === src), obs.find(x => x.el === nr), obs);
  2540. paths.push({ d: pts ? polyPath(pts) : vcurve({ cx: o.cx, cy: o.b }, { cx: t.cx, cy: t.t }),
  2541. c: "var(--accent)", w: 2, dash: "7 6" });
  2542. }
  2543. }
  2544. edges.innerHTML = svgPaths(paths);
  2545. drawHotEdges([], []); // 重画即清空悬停层
  2546. }
  2547. // 两个模块块之间的一条边:**方向**按几何选边(不预设生长方向)。入口链后继在右 → 右缘出、左缘进;
  2548. // 被调块在下 → 底缘出、顶缘进;回边(被调在左/在上)反过来。箭头永远指向被调方。
  2549. function blockLink(a, b) {
  2550. if (b.l >= a.r - 4) return { d: hcurve({ r: a.r, cy: a.cy }, { l: b.l, cy: b.cy }),
  2551. mid: [(a.r + b.l) / 2, (a.cy + b.cy) / 2] };
  2552. if (b.r <= a.l + 4) return { d: hcurve({ r: b.r, cy: b.cy }, { l: a.l, cy: a.cy }),
  2553. mid: [(b.r + a.l) / 2, (a.cy + b.cy) / 2] };
  2554. if (b.t >= a.b) return { d: vcurve({ cx: a.cx, cy: a.b }, { cx: b.cx, cy: b.t }),
  2555. mid: [(a.cx + b.cx) / 2, (a.b + b.t) / 2] };
  2556. return { d: vcurve({ cx: a.cx, cy: a.t }, { cx: b.cx, cy: b.b }),
  2557. mid: [(a.cx + b.cx) / 2, (a.t + b.b) / 2] };
  2558. }
  2559. // 悬停层:`chips` = 运行层(实例间的输入 / 输出两条),`links` = 结构层(定义间的引用一条)。
  2560. // 画在最上层(盖住卡片也无妨 —— 同时只有一两条,要的就是"是哪一条"一目了然)。
  2561. function drawHotEdges(chips, links) {
  2562. const hot = $("#boardHot"), labels = $("#boardLabels"); if (!hot) return;
  2563. hot.setAttribute("width", BOARD.worldW); hot.setAttribute("height", BOARD.worldH);
  2564. hot.setAttribute("viewBox", `0 0 ${BOARD.worldW} ${BOARD.worldH}`);
  2565. document.querySelectorAll("#boardWorld .hot").forEach(x => x.classList.remove("hot"));
  2566. const paths = [], tags = [];
  2567. const obs = boardObstacles();
  2568. const blkOf = el => { const b = el && el.closest(".mod-block");
  2569. return b ? obs.find(o => o.el === b) : null; };
  2570. // 一条跨模块的线:优先走**绕开所有块**的正交路线(不从别人的执行流程上压过去),
  2571. // 实在绕不出来(几何太挤)才退回原来的贝塞尔。
  2572. const routed = (a, b, ea, eb, style) => {
  2573. const pts = orthoRoute(a, b, blkOf(ea), blkOf(eb), obs);
  2574. const mid = pts ? pts[Math.floor(pts.length / 2)]
  2575. : [(a.cx + b.cx) / 2, (a.cy + b.cy) / 2];
  2576. paths.push({ ...style, d: pts ? polyPath(pts)
  2577. : vcurve({ cx: a.cx, cy: a.cy }, { cx: b.cx, cy: b.cy }) });
  2578. return mid;
  2579. };
  2580. // ① 运行层:调用点 —输入→ 被调实例;被调实例 —输出→ 回到调用点。两条方向相反的虚线。
  2581. (chips || []).forEach(chip => {
  2582. const id = chip.dataset.callee;
  2583. const c = boardRect(chip), i = boardRect(document.getElementById("ioin-" + id)),
  2584. o = boardRect(document.getElementById("ioout-" + id));
  2585. const lane = document.getElementById("inst-" + id);
  2586. const color = hueOf(lane);
  2587. chip.classList.add("hot");
  2588. if (lane) lane.classList.add("hot");
  2589. if (!c) return;
  2590. if (i) {
  2591. const m = routed(c, i, chip, document.getElementById("ioin-" + id),
  2592. { c: color, w: 2.6, dash: "7 6" });
  2593. tags.push({ x: m[0], y: m[1], t: "输入", cls: "in" });
  2594. }
  2595. if (o) {
  2596. const m = routed(o, c, document.getElementById("ioout-" + id), chip,
  2597. { c: color, w: 2.6, dash: "2 6" });
  2598. tags.push({ x: m[0], y: m[1], t: "输出", cls: "out" });
  2599. }
  2600. });
  2601. // ② 结构层:定义 → 定义,两端的模块块一起点亮;标签给出本轮实际调了几次
  2602. (links || []).forEach(e => {
  2603. const fa = document.getElementById("bn-" + domId(e.from)), fb = document.getElementById("bn-" + domId(e.to));
  2604. const a = boardRect((fa && fa.querySelector(":scope > .mod-def")) || fa);
  2605. const b = boardRect((fb && fb.querySelector(":scope > .mod-def")) || fb);
  2606. if (!a || !b) return;
  2607. if (fa) fa.classList.add("hot");
  2608. if (fb) fb.classList.add("hot");
  2609. const L = blockLink(a, b);
  2610. paths.push({ d: L.d, c: hueOf(fb), w: 2.2, dash: "7 6" });
  2611. const n = CALL_AGG.get(e.from + " " + e.to);
  2612. tags.push({ x: L.mid[0], y: L.mid[1], cls: "ref", t: n ? `引用 · 本轮 ×${n}` : "引用 · 本轮未调用" });
  2613. });
  2614. hot.innerHTML = paths.length ? svgPaths(paths) : "";
  2615. if (labels) labels.innerHTML = tags.map(t =>
  2616. `<div class="edge-tag ${t.cls}" style="left:${t.x}px;top:${t.y}px">${esc(t.t)}</div>`).join("");
  2617. }
  2618. // 悬停命中什么就画什么。
  2619. // 运行层:**只**停在 call 芯片(引用节点)上 → 亮它引用的那个具体实例;停在泳道本身不亮
  2620. // (hover 实例自己没有"要指向谁"的语义,点亮反而是噪声)。
  2621. // 结构层:停在结构图里的引用节点上 → 那一条;停在整列「模块定义」上 → 它声明的全部引用。
  2622. function hotChipsFor(el) {
  2623. const world = $("#boardWorld"); if (!world || !el || !el.closest) return [];
  2624. const chip = el.closest(".call-chip[data-callee]");
  2625. if (chip) return [chip];
  2626. return [];
  2627. }
  2628. // 「输入」的两侧联动:结构里那张输入卡的**槽位**(.slot-row[data-slot])与运行实例输入节点里的
  2629. // **输入块卡**(.io-in .pcard[data-slot])共用同一个 key(结构侧来自 input_schema,实例侧来自
  2630. // blockCards 的 b.key)——所以不需要推断,按 data-slot 直接对上。作用域限在**同一个模块块**内:
  2631. // 结构与它这一轮的全部运行实例本就同块,于是「多个运行实例各自对应的那张输入」会一起亮。
  2632. function slotHotFor(el) {
  2633. if (!el || !el.closest) return null;
  2634. const hit = el.closest(".mod-def .slot-row[data-slot], .io-in .pcard[data-slot]");
  2635. if (!hit || !hit.dataset.slot) return null;
  2636. const blk = hit.closest(".mod-block");
  2637. return blk ? { blk, key: hit.dataset.slot } : null;
  2638. }
  2639. function applySlotHot(hit) {
  2640. document.querySelectorAll("#boardWorld .slot-hot").forEach(x => x.classList.remove("slot-hot"));
  2641. if (!hit) return;
  2642. const k = (window.CSS && CSS.escape) ? CSS.escape(hit.key) : hit.key.replace(/"/g, '\\"');
  2643. hit.blk.querySelectorAll(
  2644. `.mod-def .slot-row[data-slot="${k}"], .io-in .pcard[data-slot="${k}"]`
  2645. ).forEach(x => x.classList.add("slot-hot"));
  2646. }
  2647. function hotLinksFor(el) {
  2648. if (!el || !el.closest) return [];
  2649. const from = (el.closest(".mod-block") || {}).dataset?.key || "";
  2650. if (!from) return [];
  2651. const node = el.closest(".fc-node[data-to]");
  2652. if (node) return [{ from, to: node.dataset.to }];
  2653. const def = el.closest(".mod-def");
  2654. if (!def) return [];
  2655. return [...new Set([...def.querySelectorAll(".fc-node[data-to]")].map(n => n.dataset.to))]
  2656. .map(to => ({ from, to }));
  2657. }
  2658. // ── 平移 / 缩放 ──
  2659. function boardApply() { const w = $("#boardWorld"); if (w) w.style.transform = `translate(${BOARD.panX}px,${BOARD.panY}px) scale(${BOARD.z})`; }
  2660. function boardZoom(f, cx, cy) {
  2661. const board = $("#board"); if (!board) return;
  2662. const rect = board.getBoundingClientRect();
  2663. if (cx == null) cx = rect.width / 2; if (cy == null) cy = rect.height / 2;
  2664. const nz = Math.max(.15, Math.min(2.5, BOARD.z * f));
  2665. BOARD.panX = cx - ((cx - BOARD.panX) / BOARD.z) * nz;
  2666. BOARD.panY = cy - ((cy - BOARD.panY) / BOARD.z) * nz;
  2667. BOARD.z = nz; boardApply();
  2668. }
  2669. function boardFit() {
  2670. const board = $("#board"); if (!board || !BOARD.worldW) return;
  2671. const rect = board.getBoundingClientRect();
  2672. // 调用树是**横向生长**的:宽度由最长的那条链决定、高度由并列分支决定,两个方向都可能溢出,
  2673. // 所以按**两个方向都能装下**取缩放(跟随内容,不预设哪个方向更长)。
  2674. const z = Math.max(.12, Math.min((rect.width - 48) / BOARD.worldW,
  2675. (rect.height - 48) / BOARD.worldH, 1.1));
  2676. BOARD.z = z;
  2677. BOARD.panX = Math.max(16, (rect.width - BOARD.worldW * z) / 2);
  2678. BOARD.panY = Math.max(16, (rect.height - BOARD.worldH * z) / 2);
  2679. boardApply();
  2680. }
  2681. // 全屏 / 沉浸式:把 #snapView 切成 inset:0 覆盖层,退出后恢复撑满宽度;进出后重新适应窗口。
  2682. function boardFullscreen() {
  2683. const sv = $("#snapView"); if (!sv) return;
  2684. const on = sv.classList.toggle("fs");
  2685. document.body.style.overflow = on ? "hidden" : "";
  2686. const btn = $("#btnFs");
  2687. if (btn) { btn.textContent = on ? "✕" : "⛶"; btn.title = on ? "退出全屏(Esc)" : "全屏 / 沉浸"; }
  2688. sv.style.width = ""; // 退出全屏回到详情列自然宽度(由 detailbody max-width 约束)
  2689. requestAnimationFrame(() => { layoutBoard(); boardFit(); });
  2690. }
  2691. // 顶部「全屏」按钮:确保切到过程视图后进入全屏(全屏时该按钮被覆盖层遮住,故只需负责进入)。
  2692. function enterBoardFullscreen() {
  2693. const sv = $("#snapView"); if (!sv) return;
  2694. const tab = $("#tabProcess");
  2695. if (tab && !tab.classList.contains("active")) switchView("process");
  2696. if (!sv.classList.contains("fs")) boardFullscreen();
  2697. }
  2698. function attachBoardPan() {
  2699. const board = $("#board"); if (!board) return;
  2700. // 不用 setPointerCapture(会截走子卡片的 click);改为:按下只记起点,移动超阈值才判定为拖拽平移,
  2701. // 拖拽时才吞掉紧随的 click;纯点击(无位移)原样落到卡片上。move/up 挂 window,避免快速拖出画板丢事件。
  2702. let active = false, dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
  2703. const onMove = e => {
  2704. if (!active) return;
  2705. if (!dragging && Math.hypot(e.clientX - ox, e.clientY - oy) > 5) { dragging = true; board.classList.add("dragging"); }
  2706. if (dragging) { BOARD.panX = e.clientX - sx; BOARD.panY = e.clientY - sy; boardApply(); }
  2707. };
  2708. const onUp = () => {
  2709. if (!active) return;
  2710. active = false;
  2711. window.removeEventListener("pointermove", onMove);
  2712. window.removeEventListener("pointerup", onUp);
  2713. if (dragging) { board.classList.remove("dragging"); BOARD.suppressClick = true; setTimeout(() => BOARD.suppressClick = false, 0); }
  2714. dragging = false;
  2715. };
  2716. board.addEventListener("pointerdown", e => {
  2717. if (e.button !== 0) return;
  2718. active = true; dragging = false; sx = e.clientX - BOARD.panX; sy = e.clientY - BOARD.panY; ox = e.clientX; oy = e.clientY;
  2719. window.addEventListener("pointermove", onMove);
  2720. window.addEventListener("pointerup", onUp);
  2721. });
  2722. // 跨模块引用的实例级连线**按需展开**:只在停到 call 芯片(引用节点)上时亮它引用的那个实例。
  2723. // 事件委托挂在画板上(卡片是每轮重绘的,逐个绑定会随重绘丢失)。拖拽平移时不画。
  2724. let hotKey = "";
  2725. board.addEventListener("pointermove", e => {
  2726. if (dragging) return;
  2727. const chips = hotChipsFor(e.target), links = hotLinksFor(e.target);
  2728. const slot = slotHotFor(e.target);
  2729. const k = chips.map(c => c.dataset.callee).join(",") + "|"
  2730. + links.map(l => l.from + ">" + l.to).join(",") + "|"
  2731. + (slot ? (slot.blk.dataset.key || "") + "#" + slot.key : "");
  2732. if (k === hotKey) return;
  2733. hotKey = k;
  2734. applySlotHot(slot);
  2735. drawHotEdges(chips, links);
  2736. });
  2737. board.addEventListener("pointerleave", () => {
  2738. hotKey = ""; applySlotHot(null); drawHotEdges([], []);
  2739. });
  2740. // macOS 触控板:双指滑动 = 平移(wheel, ctrlKey=false);双指捏合 = 缩放(wheel, ctrlKey=true)。
  2741. // 另支持 macOS ⌘(metaKey)+ 鼠标滚轮缩放,方便普通鼠标用户。
  2742. board.addEventListener("wheel", e => {
  2743. e.preventDefault();
  2744. const rect = board.getBoundingClientRect();
  2745. if (e.ctrlKey || e.metaKey) boardZoom(Math.exp(-e.deltaY * 0.01), e.clientX - rect.left, e.clientY - rect.top);
  2746. else { BOARD.panX -= e.deltaX; BOARD.panY -= e.deltaY; boardApply(); }
  2747. }, { passive: false });
  2748. }
  2749. // ── 运行流程的分列与语义(并发批次 / 分支 / 标题 / 成败 / 图片)**全部来自后端**(见 web/app.py
  2750. // _inv_node / _ev_card:事件按 seq 归并,同 seq = 同一并发列)。前端只负责铺列与画列间连线。──────
  2751. // 组合模块的 ③ 与 ④ 是两列**逐框对应**的(第 i 个运行框 ↔ 第 i 个输出框)。CSS 做不到跨容器对齐,
  2752. // 这里量一遍取每行较高的那个,把两边撑齐——横着读过去就是同一个子模块的「跑了什么 → 产出什么」。
  2753. // 同一行的三段(② 结构格 / ③ 运行框 / ④ 输出框)**顶边对齐**。
  2754. //
  2755. // 两件事,分开对待:
  2756. // · ③ 运行框 ↔ ④ 输出框:**顶边 + 底边都对齐**(等高)——这两段讲的是同一个子模块的
  2757. // 「跑了什么 → 产出什么」,等高才读得出「这一块是一件事」。
  2758. // · ② 结构格:只对齐顶边、**不拉伸** —— 结构列可能嵌着整条子链而高出一大截,
  2759. // 拉伸只会把矮的那个撑成不传达任何信息的空色块;多出来的距离留成段底色与连接线。
  2760. //
  2761. // 不靠公式推导(padding / margin / row-gap / 表头高度差 / 嵌套层数……推错一处整列就跑偏),
  2762. // 改成**实测驱动的收敛循环**:量当前实际位置 → 补差 → 再量,直到残差 < 1px。
  2763. //
  2764. // ⚠️ 画板带 transform: scale(z),`getBoundingClientRect()` 量到的是**缩放后**的值,
  2765. // 而写进 style 的 margin/height 是**未缩放**的布局 px —— 必须除以 z 换算回去。
  2766. // 槽位连线:左侧「输入」里的每个盒子 → 结构「输入拼接 · 槽位」里同名的那个槽 chip。
  2767. // 两端都带 data-slot(后端给的 key),所以是**按槽位一一配对**,不是按顺序凑。
  2768. // **逐层都连**:不只最外层那一对。配对规则是「同一层的输入盒 ↔ 同一层的槽位表」:
  2769. // · 本模块:① 的自有输入列 ↔ ② 的自有结构卡(组合模块的 .gs-own,不含子结构链)
  2770. // · 每个子段:同 data-rk 的 .sub-in ↔ .sub-struct —— **行内匹配**,不跨行也不跨层,
  2771. // 于是「execute 的验收标准」不会误连到父模块同名的槽上(这是嵌套后必须先解决的撞名问题)。
  2772. // 线色取该行的 --tone,与那一行的四段底色同源。
  2773. // 卡片渲染(流程卡 / 结构卡通用):标题、摘要、成败、图片、分支都由后端给定,前端不判断。
  2774. function renderPCard(key, i, s) {
  2775. const thumbs = (s.images && s.images.length)
  2776. ? `<div class="pc-thumbs">${s.images.slice(0, 3).map(u =>
  2777. `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : "";
  2778. // plan 的 ops:涉及具体 Task 的一律用**任务卡**画(与任务台账同一种视觉),把「做了什么操作」**融进卡本身**
  2779. // ——徽标位显示操作(+新增 / ~修改 / ↻重做…)、左侧色条按操作着色。规划这一刻不显示执行态状态:
  2780. // 那时这些 Task 还没跑(重做的更是即将重来),显示「待执行/未达标」是张冠李戴。
  2781. const ops = (s.ops && s.ops.length)
  2782. ? `<div class="ops-h">${esc(s.opsTitle || "任务更新")}</div>
  2783. <div class="ops-list">${(putCards(key + ":ops" + i, s.ops), s.ops.map((o, k) => `
  2784. <div class="op op-${esc(o.op)}">
  2785. ${o.detail && o.op !== "update" ? `<div class="op-h"><span class="op-detail">${esc(o.detail)}</span></div>` : ""}
  2786. ${(o.tasks || []).length ? `<div class="op-tasks">${o.tasks.map(t =>
  2787. `<div class="${t._child ? "op-child" : ""}">${taskCard(t, {
  2788. badge: { text: o.label, key: o.op },
  2789. modified: o.op === "update" ? (o.changed || []) : null,
  2790. click: o.op === "update" ? `openOpDiff('${key}:ops${i}',${k})` : "" })}</div>`).join("")}</div>` : ""}
  2791. </div>`).join(""))}</div>` : "";
  2792. // 本轮选定的下一步:它本身就是一个 Task —— 同样用任务卡画,徽标位标「▶ 下一步」
  2793. const next = s.next
  2794. ? `<div class="ops-h">${esc(s.nextTitle || "下一步执行")}</div>
  2795. <div class="ops-list"><div class="op op-next">${
  2796. taskCard(s.next, { badge: { text: "▶ 下一步", key: "next" } })}</div></div>` : "";
  2797. // 结构化输出的可读字段:一行一项「标签 + 值」
  2798. const fields = (s.fields && s.fields.length)
  2799. ? `<div class="kv-list">${s.fields.filter(f => f && f.value).map(f =>
  2800. `<div class="kv"><span class="kv-k">${esc(f.label)}</span><span class="kv-v">${esc(f.value)}</span></div>`
  2801. ).join("")}</div>` : "";
  2802. // 输出的构成:每件「缩略图 + 它是什么」成对呈现;主件加 ★ 与高亮描边
  2803. const arts = (s.arts && s.arts.length)
  2804. ? `<div class="out-arts">${s.arts.map(a => a.url
  2805. ? `<div class="out-art ${a.primary ? "is-primary" : ""}" title="${esc(a.note || a.label)}">
  2806. <img src="${esc(a.url)}" referrerpolicy="no-referrer"
  2807. onclick="event.stopPropagation();openLB('${esc(a.url)}')" onerror="this.style.display='none'">
  2808. <div class="oa-tag">${a.primary ? "★ " : ""}${esc(a.label)}</div></div>`
  2809. : `<div class="out-art no-img ${a.primary ? "is-primary" : ""}">
  2810. <div class="oa-text">${esc(clip(a.text || a.note || "", 60))}</div>
  2811. <div class="oa-tag">${a.primary ? "★ " : ""}${esc(a.label)}</div></div>`).join("")}</div>` : "";
  2812. // 工具清单:一枚工具一枚 chip,**本次用过的高亮**(数据由后端 used_tools 给,不靠前端猜)
  2813. const usedSet = new Set(s.usedTools || []);
  2814. const toolChips = (s.tools && s.tools.length)
  2815. ? `<div class="tool-chips">${s.tools.map(t =>
  2816. `<span class="tool-chip${usedSet.has(t.name) ? " used" : ""}"${
  2817. t.description ? ` title="${esc(clip(t.description, 200))}"` : ""}>${
  2818. usedSet.has(t.name) ? `<span class="tc-dot"></span>` : ""}${esc(t.name)}</span>`).join("")}</div>` : "";
  2819. const chips = (s.chips && s.chips.length)
  2820. ? `<div class="pc-chips">${s.chips.slice(0, 12).map(c => `<span class="pc-chip">${esc(c)}</span>`).join("")}</div>` : "";
  2821. // 槽位表:一行一个槽(左侧输入盒按 data-slot 连过来),竖排避免连线挤成一团
  2822. const slots = (s.slots && s.slots.length)
  2823. ? `<div class="slot-list">${s.slots.map(sl =>
  2824. `<div class="slot-row slot-chip ${sl.filled ? "" : "off"}" data-slot="${esc(sl.key)}"${
  2825. sl.hint ? ` title="${esc(sl.hint)}"` : ""}>
  2826. <span class="dot"></span><span class="nm">${esc(sl.name)}</span>${
  2827. sl.filled ? "" : `<span class="slot-empty">空</span>`}</div>`).join("")}</div>` : "";
  2828. const tone = String(key).split(":")[0].replace(/^execute$/, "exec").replace(/^evaluate$/, "eval");
  2829. const failed = s.ok === false;
  2830. const mods = [failed ? "pc-failed" : "", s.muted ? "pc-empty" : "",
  2831. s.final ? "pc-final" : "", s.type === "llm" ? "pc-llm" : ""].filter(Boolean).join(" ");
  2832. // 任务条目槽(待执行队列 / 已执行 Task / 容器):直接用**任务卡**渲染 —— 与任务列表里的任务同一种画法
  2833. if (s.tasks && s.tasks.length) {
  2834. putCards(key + ":tasks" + i, s.tasks); // 供点开看「这条进槽的原文」
  2835. return `<div class="pcard pc-${tone} pc-taskslot"${s.key ? ` data-slot="${esc(s.key)}"` : ""}>
  2836. <div class="pcard-h"><span class="pc-ic">◇</span><span class="pc-label">${esc(s.label || "")}</span>
  2837. <span class="pc-tok">${s.tasks.length} 个</span></div>
  2838. <div class="task-strip">${s.tasks.map((t, k) => taskCard(t, { showResult: false,
  2839. click: `openSlotTask('${key}:tasks${i}',${k})` })).join("")}</div></div>`;
  2840. }
  2841. // ── 旁路标注:**模型推理节点**上挂「做了什么(标注)」,底部给依据来源入口 —— 决策发生在模型
  2842. // 这一步,工具只是它的执行结果。标注块独立底色 + 🔖 抬头,与卡上那些**运行时事实**明确分开。
  2843. const ann = annOf(s);
  2844. const annBlock = ann ? `
  2845. <div class="pc-ann">
  2846. <div class="pc-ann-sum">${esc(ann.summary || "—")}</div>
  2847. </div>` : "";
  2848. // 一段「按形态渲染」的数据(工具的输出、模型 function calling 的入参都走这一个渲染器 ——
  2849. // 同一种东西只有一种画法):
  2850. // 主文 = 这段在说什么(query / prompt / thought)→ 正常字号,最先读到
  2851. // 标签 = 一批条目(工具名、字段名)→ 一枚枚 chip,不是一坨 JSON
  2852. // 字段 = 剩下的少量标量 → 小字键值
  2853. // 图 = 就在自己这一段里
  2854. // 成败折成表头一枚徽标(不再单独占一行说 success True)。
  2855. const ioSec = (d, cls, head) => {
  2856. d = d || {};
  2857. if (!d.lead && !(d.chips || []).length && !(d.rows || []).length && !(d.images || []).length) return "";
  2858. return `<div class="pc-io-sec ${cls}">
  2859. <div class="pc-io-h">${head}${
  2860. d.ok === true ? `<span class="io-ok">✓</span>` : d.ok === false ? `<span class="io-bad">✗ 失败</span>` : ""}</div>
  2861. ${d.lead ? `<div class="pc-io-lead">${esc(d.lead)}</div>` : ""}
  2862. ${(d.chips || []).length ? `<div class="pc-io-chips">${
  2863. d.chips.map(c => `<span class="io-chip">${esc(c)}</span>`).join("")}</div>` : ""}
  2864. ${(d.rows || []).length ? `<div class="pc-io-kv">${d.rows.map(f =>
  2865. `<div class="io-row"><span class="io-k">${esc(f.k)}</span><span class="io-v">${esc(f.v)}</span></div>`).join("")}</div>` : ""}
  2866. ${(d.images || []).length ? `<div class="pc-thumbs io-thumbs">${d.images.slice(0, 6).map(u =>
  2867. `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : ""}
  2868. </div>`;
  2869. };
  2870. // 工具节点:**只有输出**(入参是上一步 function calling 的那份,在模型推理卡上;后端据 tool_call_id
  2871. // 决定要不要给 input —— 没有上游那一步的工具节点仍自带入参)。图跟着自己那一段走。
  2872. const ioBlock = s.io ? ["input", "output"].map(side =>
  2873. s.io[side] ? ioSec(s.io[side], side === "input" ? "in" : "out",
  2874. `<span>${side === "input" ? "⇢ 输入" : "⇠ 输出"}</span>`) : "").join("") : "";
  2875. // 模型推理节点:它发出的 function calling。**一次调用一块**(序号 + 工具名 + 该次入参),
  2876. // 多个调用叠在一张卡里 —— 它们本就是同一轮请求里一起发出的(框架给同一个 batch),所以要看得出
  2877. // 「这一步一共发起了几个、分别是谁、各自带什么参数」,而不是糊成一段。
  2878. const argRow = d => {
  2879. d = d || {};
  2880. return [
  2881. d.lead ? `<div class="pc-io-lead">${esc(d.lead)}</div>` : "",
  2882. (d.rows || []).length ? `<div class="pc-io-kv">${d.rows.map(f =>
  2883. `<div class="io-row"><span class="io-k">${esc(f.k)}</span><span class="io-v">${esc(f.v)}</span></div>`).join("")}</div>` : "",
  2884. (d.chips || []).length ? `<div class="pc-io-chips">${
  2885. d.chips.map(c => `<span class="io-chip">${esc(c)}</span>`).join("")}</div>` : "",
  2886. (d.images || []).length ? `<div class="pc-thumbs pcl-thumbs">${d.images.slice(0, 8).map(u =>
  2887. `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : "",
  2888. ].join("") || `<div class="pcl-none">(无参数)</div>`;
  2889. };
  2890. const callBlock = (s.calls || []).length ? `<div class="pc-calls">
  2891. <div class="pc-calls-h">⚡ 推理结果 · 调用以下${s.calls.length > 1 ? ` ${s.calls.length} 个` : ""}工具</div>
  2892. ${s.calls.map((c, k) => `<div class="pc-call">
  2893. <div class="pcl-h"><span class="pcl-i">${k + 1}</span><span class="pcl-n">${esc(c.name)}</span></div>
  2894. <div class="pcl-args">${argRow(c.args)}</div></div>`).join("")}</div>` : "";
  2895. // 底部依据入口:**按类型分组计数**("2 个来源"看不出是哪种来源,也就看不出这步是被 prompt 训导
  2896. // 驱动的、还是被本 Task 的输入/上一步产物驱动的 —— 而那才是读者真正想一眼看到的东西)。
  2897. let basis = "";
  2898. if (ann && (ann.basis || []).length) {
  2899. const cnt = {};
  2900. ann.basis.forEach(b => cnt[b.kind || ""] = (cnt[b.kind || ""] || 0) + 1);
  2901. const tags = Object.keys(cnt).map(k =>
  2902. `<span class="bs-tag"><span class="bs-ic">${srcKind(k).ic}</span>${esc(srcKind(k).name)}<b>${cnt[k]}</b></span>`).join("");
  2903. basis = `<div class="pc-basis" onclick="event.stopPropagation();openBasis('${esc(annKeyOf(s))}','${esc(String(s.label || "").replace(/['\\]/g, ""))}')">
  2904. <span class="bs-h">🔗 决策依据</span>${tags}<span class="bs-n">›</span></div>`;
  2905. }
  2906. return `<div class="pcard pc-${tone} ${mods}"${s.key ? ` data-slot="${esc(s.key)}"` : ""}${
  2907. s.hint ? ` title="${esc(s.hint)}"` : ""} onclick="openCard('${key}',${i})">
  2908. <div class="pcard-h"><span class="pc-ic">${s.icon || pcIcon(s)}</span><span class="pc-label">${esc(s.label || "")}</span>${
  2909. failed ? `<span class="pc-fail-badge">✗ 失败</span>` : ""}${s.tokens ? `<span class="pc-tok">${esc(s.tokens)}</span>` : ""}</div>
  2910. ${s.tool_call_id ? `<div class="pc-id id-chip" title="tool_call_id(点击复制)"
  2911. onclick="event.stopPropagation();copyId('${esc(s.tool_call_id)}',this)">⧉ ${esc(String(s.tool_call_id).slice(-10))}</div>`
  2912. : (s.type === "llm" && s.node_id ? `<div class="pc-id id-chip" title="节点 id(点击复制)· 重标这一步:--node ${esc(String(s.node_id))}"
  2913. onclick="event.stopPropagation();copyId('${esc(String(s.node_id))}',this)">⧉ #${esc(String(s.node_id))}</div>` : "")}
  2914. ${s.reasoning ? `<div class="pc-think"><div class="pc-think-h">💭 思考</div>
  2915. <div class="pc-think-b">${esc(s.reasoning)}</div></div>` : ""}
  2916. ${(s.summary && !ann) ? `<div class="pcard-sum">${esc(s.summary)}</div>` : ""}
  2917. ${(s.type === "llm" && !s.summary && !s.reasoning && !callBlock)
  2918. ? `<div class="pc-note">(这一轮模型没有文本输出)</div>` : ""}
  2919. ${annBlock}
  2920. ${ioBlock}
  2921. ${callBlock}
  2922. ${fields}
  2923. ${ops}
  2924. ${next}
  2925. ${toolChips}
  2926. ${slots}
  2927. ${arts}
  2928. ${arts ? "" : chips}
  2929. ${(arts || s.io || callBlock) ? "" : thumbs}
  2930. <div class="pc-more">查看详情 ›</div>
  2931. ${basis}
  2932. </div>`;
  2933. }
  2934. const pcIcon = s => ({ tool: "🔧", llm: "◆", input: "⇢" })[s.type] || "•";
  2935. // update 的任务卡 → 看**这一改到底改了什么**:逐字段「旧 → 新」(有旧值走字符级 diff,否则给新值全文)
  2936. function openOpDiff(key, i) {
  2937. if (BOARD && BOARD.suppressClick) return;
  2938. const o = (SNAP.cards[key] || [])[i]; if (!o) return;
  2939. const t = (o.tasks || [])[0] || {};
  2940. $("#drawerTag").textContent = "PLAN · 修改";
  2941. $("#drawerTag").className = "drawer-tag plan";
  2942. $("#drawerTitle").textContent = `Task #${t.step_id ?? ""} ${t.name || ""}`;
  2943. const patch = o.patch || {}, changes = o.changes || {};
  2944. const rows = Object.keys(patch).map(f => {
  2945. const pair = changes[f];
  2946. const v = typeof patch[f] === "string" ? patch[f] : JSON.stringify(patch[f], null, 2);
  2947. return `<div class="field sec-cell"><div class="lbl">${esc(f)}</div>` +
  2948. (Array.isArray(pair)
  2949. ? `<div class="sec-meta">旧 → 新(字符级对比)</div><div class="chg-v">${
  2950. renderInlineDiff(typeof pair[0] === "string" ? pair[0] : JSON.stringify(pair[0]),
  2951. typeof pair[1] === "string" ? pair[1] : JSON.stringify(pair[1]))}</div>`
  2952. : `<div class="sec-meta">本轮设为(该字段无旧值快照,只给新值)</div><div class="pp-msg">${renderRich(v)}</div>`) +
  2953. `</div>`;
  2954. }).join("");
  2955. $("#drawerBody").innerHTML = (rows || `<div class="val" style="color:var(--faint)">(本次未改字段)</div>`);
  2956. openDrawer();
  2957. }
  2958. // 槽里的任务卡 → 看**这条 Task 在该槽里逐字进 prompt 的那段**(所见即模型所见)
  2959. function openSlotTask(key, i) {
  2960. if (BOARD && BOARD.suppressClick) return;
  2961. const t = (SNAP.cards[key] || [])[i]; if (!t) return;
  2962. $("#drawerTag").textContent = "输入 · 进槽数据";
  2963. $("#drawerTag").className = "drawer-tag plan";
  2964. $("#drawerTitle").textContent = t.name || `任务 #${t.step_id}`;
  2965. const raw = Object.fromEntries(Object.entries(t).filter(([k]) => k !== "prompt_text"));
  2966. $("#drawerBody").innerHTML =
  2967. (t.prompt_text
  2968. ? `<div class="field"><div class="lbl">进入 prompt 的原文(这一槽里属于本 Task 的那段)</div>
  2969. <div class="pp-msg">${renderRich(t.prompt_text)}</div></div>`
  2970. : `<div class="field"><div class="val" style="color:var(--faint)">这一槽未按 Task 逐条渲染(旧 run 无此字段)</div></div>`) +
  2971. `<div class="field"><div class="lbl">结构化条目 · item</div><div class="pp-msg">${
  2972. renderRich(JSON.stringify(raw, null, 2))}</div></div>`;
  2973. openDrawer();
  2974. }
  2975. // 卡片点击 → 抽屉看完整详情(后端 detail:工具=args/result;模型=text/calls/payload;其它=text)
  2976. function openCard(key, i) {
  2977. if (BOARD && BOARD.suppressClick) return; // 刚拖拽平移过 → 不误触
  2978. const s = (SNAP.cards[key] || [])[i]; if (!s) return;
  2979. const [nodeId, seg] = String(key).split(":");
  2980. $("#drawerTag").textContent = `${nodeId}${seg ? " · " + ({ in: "输入", out: "输出", struct: "结构", flow: "运行流程" }[seg] || seg) : ""}`;
  2981. $("#drawerTag").className = "drawer-tag " + (nodeId === "plan" ? "plan" : "execute");
  2982. $("#drawerTitle").textContent = s.label || "详情";
  2983. SNAP.openNodeSid = null;
  2984. $("#drawerBody").innerHTML = renderCardDetail(s);
  2985. openDrawer();
  2986. // 工具详情:定义按需取(代码里的静态信息,不进运行记录),到手后就地重画这一屏
  2987. if ((s.detail || {}).tools && !TOOLDEF) {
  2988. loadToolDefs(SNAP.agent).then(() => {
  2989. if ($("#drawer").classList.contains("open")) $("#drawerBody").innerHTML = renderCardDetail(s);
  2990. });
  2991. }
  2992. }
  2993. // Python 字符串转义(\n \t \xNN \uNNNN \' \" 等)→ 真实字符,供转 JSON 前还原。
  2994. function unescapePyStr(s) {
  2995. return s.replace(/\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|.)/g, (m, e) => {
  2996. if (e[0] === "x" || e[0] === "u") return String.fromCharCode(parseInt(e.slice(1), 16));
  2997. return ({ n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", "0": "\0", "\\": "\\", "'": "'", '"': '"' })[e] ?? e;
  2998. });
  2999. }
  3000. // Python 字面量 repr(单引号/混合引号字符串、True/False/None)→ JSON 文本:逐字符扫描,字符串区整体透传
  3001. // (正确处理内嵌撇号与转义),非字符串区把 True/False/None 转 true/false/null;遇未知裸标识符 → 返回 null 放弃。
  3002. function pyReprToJson(src) {
  3003. const s = String(src); let out = "", i = 0;
  3004. while (i < s.length) {
  3005. const c = s[i];
  3006. if (c === "'" || c === '"') {
  3007. i++; let str = "";
  3008. while (i < s.length && s[i] !== c) { if (s[i] === "\\") { str += s[i] + (s[i + 1] || ""); i += 2; } else { str += s[i]; i++; } }
  3009. i++; out += JSON.stringify(unescapePyStr(str));
  3010. } else if (/[A-Za-z_]/.test(c)) {
  3011. let w = ""; while (i < s.length && /[A-Za-z0-9_]/.test(s[i])) { w += s[i]; i++; }
  3012. if (w === "True") out += "true"; else if (w === "False") out += "false"; else if (w === "None") out += "null"; else return null;
  3013. } else { out += c; i++; }
  3014. }
  3015. return out;
  3016. }
  3017. // 富文本渲染:对象/JSON 串(含 Python dict repr)→ 美化 JSON(图片以缩略图条附于末尾);否则按 markdown
  3018. // 渲染(图片已就地内联,不再附条)。返回可放进 .pp-msg 的内容。
  3019. function renderRich(raw) {
  3020. let content, isMd = false;
  3021. if (raw == null || (typeof raw !== "object" && !String(raw).trim())) content = `<div class="pp-content"></div>`;
  3022. else if (typeof raw === "object") content = `<div class="pp-content pp-json">${esc(JSON.stringify(raw, null, 2))}</div>`;
  3023. else {
  3024. const s = String(raw).trim();
  3025. let json;
  3026. if (/^[[{]/.test(s)) {
  3027. try { json = JSON.stringify(JSON.parse(s), null, 2); }
  3028. catch (e) { // 非严格 JSON → 试作 Python repr 归一后再解析
  3029. const jt = pyReprToJson(s);
  3030. if (jt != null) { try { json = JSON.stringify(JSON.parse(jt), null, 2); } catch (e2) {} }
  3031. }
  3032. }
  3033. if (json != null) content = `<div class="pp-content pp-json">${esc(json)}</div>`;
  3034. else { content = `<div class="pp-md">${renderMarkdown(s)}</div>`; isMd = true; }
  3035. }
  3036. return content; // 图片不在这里捞:卡片/详情的图由后端 images 字段给定(markdown 里的图仍就地内联)
  3037. }
  3038. // 轻量 markdown → HTML(先转义再套标签,防注入):标题 / 粗斜体 / 行内码 / 代码块 / 列表 / 引用 /
  3039. // **表格(GFM)** / 链接 / 段落。表格必须支持——plan 的 system prompt 里「缺陷性质 → 处方」这类对照
  3040. // 全是表格,不渲染就是一屏竖排的管道符,等于没法读。
  3041. function renderMarkdown(src) {
  3042. const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
  3043. const inline = t => esc(t)
  3044. .replace(/`([^`]+)`/g, "<code>$1</code>")
  3045. .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
  3046. .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>")
  3047. .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
  3048. // 裸 URL 就地渲染:图片→内联 <img>(结合上下文文字直接看图),其它→可点链接。
  3049. // 负向后瞻跳过已在标签属性里的 URL(href="…" / src="…" / 紧跟 > 的 <code>URL</code>)。
  3050. .replace(/(?<![="(>])(https?:\/\/[^\s<>"'`)\]}\\、,。;:)】》」|]+)/g, (m, url) => {
  3051. // 去尾:标点 + URL 编码括号(%22=" %5D=] %29=) %7D=})+ 被转义的实体(&quot; &gt; &lt; &amp;)——
  3052. // inline 跑在已转义文本上,紧随 URL 的闭合引号会变成 &quot; 被吞进匹配,需一并剥掉,否则 src 带尾巴致 404。
  3053. const u = url.replace(/(?:[.,;)\]}]|%22|%5[Dd]|%29|%7[Dd]|&quot;|&gt;|&lt;|&amp;)+$/i, "");
  3054. return isImgUrl(u)
  3055. ? `<img class="md-img" src="${u}" referrerpolicy="no-referrer" loading="lazy" onclick="openLB('${u}')" onerror="imgFail(this)">`
  3056. : `<a href="${u}" target="_blank" rel="noreferrer">${u}</a>`;
  3057. });
  3058. const isList = l => /^\s*([-*+]|\d+\.)\s+/.test(l), isH = l => /^#{1,6}\s+/.test(l),
  3059. isQ = l => /^\s*>\s?/.test(l), isFence = l => /^```/.test(l);
  3060. // 表格:一行以 | 起(或含 |)+ 下一行是 |---|---| 分隔行 —— 两条同时满足才当表格,
  3061. // 免得把正文里偶然出现的竖线误判。单元格切分要**忽略被 \ 转义的 |**。
  3062. const isSep = l => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l);
  3063. const isRow = l => /\|/.test(l) && !isFence(l);
  3064. const cells = l => l.replace(/^\s*\|/, "").replace(/\|\s*$/, "")
  3065. .split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, "|"));
  3066. let html = "", i = 0;
  3067. while (i < lines.length) {
  3068. const line = lines[i];
  3069. if (isFence(line)) {
  3070. i++; const code = [];
  3071. while (i < lines.length && !isFence(lines[i])) { code.push(lines[i]); i++; }
  3072. i++; html += `<pre class="rich-code">${esc(code.join("\n"))}</pre>`; continue;
  3073. }
  3074. const hm = /^(#{1,6})\s+(.*)$/.exec(line);
  3075. if (hm) { const lv = Math.min(6, hm[1].length); html += `<div class="rich-h rich-h${lv}">${inline(hm[2])}</div>`; i++; continue; }
  3076. if (isRow(line) && i + 1 < lines.length && isSep(lines[i + 1])) {
  3077. const head = cells(line);
  3078. const align = cells(lines[i + 1]).map(c =>
  3079. /^:-+:$/.test(c) ? "center" : /-+:$/.test(c) ? "right" : "left");
  3080. i += 2;
  3081. const rows = [];
  3082. while (i < lines.length && isRow(lines[i]) && !/^\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
  3083. const ta = k => ` style="text-align:${align[k] || "left"}"`;
  3084. html += `<div class="rich-table-wrap"><table class="rich-table">
  3085. <thead><tr>${head.map((c, k) => `<th${ta(k)}>${inline(c)}</th>`).join("")}</tr></thead>
  3086. <tbody>${rows.map(r => `<tr>${head.map((_, k) =>
  3087. `<td${ta(k)}>${inline(r[k] || "")}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
  3088. continue;
  3089. }
  3090. if (isList(line)) {
  3091. const ordered = /^\s*\d+\.\s+/.test(line); const items = [];
  3092. while (i < lines.length && isList(lines[i])) { items.push(inline(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ""))); i++; }
  3093. html += `<${ordered ? "ol" : "ul"} class="rich-list">${items.map(x => `<li>${x}</li>`).join("")}</${ordered ? "ol" : "ul"}>`; continue;
  3094. }
  3095. if (isQ(line)) {
  3096. const q = [];
  3097. while (i < lines.length && isQ(lines[i])) { q.push(inline(lines[i].replace(/^\s*>\s?/, ""))); i++; }
  3098. html += `<blockquote class="rich-quote">${q.join("<br>")}</blockquote>`; continue;
  3099. }
  3100. if (/^\s*$/.test(line)) { i++; continue; }
  3101. const para = [];
  3102. while (i < lines.length && !/^\s*$/.test(lines[i]) && !isFence(lines[i]) && !isH(lines[i])
  3103. && !isList(lines[i]) && !isQ(lines[i])
  3104. && !(isRow(lines[i]) && i + 1 < lines.length && isSep(lines[i + 1]))) { // 表头行留给表格分支
  3105. para.push(inline(lines[i])); i++;
  3106. }
  3107. html += `<p class="rich-p">${para.join("<br>")}</p>`;
  3108. }
  3109. return html;
  3110. }
  3111. // 工具调用输入:若 args 是对象,逐字段渲染(字段名作小标题、字符串值走 markdown 富文本),
  3112. // 让 think_and_plan 这类长文本参数不再是「转义 \n + 未渲染 markdown」的一坨 JSON;非对象则原样富文本。
  3113. // 一坨数据 → 抽屉里的若干块:**对象逐键一块**(键当小标题,值按 markdown / JSON 渲染),
  3114. // 其余整块铺开。这样长文本字段(think_and_plan 的 thought/plan/action)是分开可读的,
  3115. // 而不是塞进一个 JSON 串里。
  3116. // 一段数据的**规模**:几个字段 / 几项 / 多少字。抬头右侧标一下,读者不点开也知道这块有多大。
  3117. const dataMeta = v => {
  3118. if (v == null) return "";
  3119. if (Array.isArray(v)) return `${v.length} 项`;
  3120. if (typeof v === "object") return `${Object.keys(v).length} 个字段`;
  3121. const n = String(v).length;
  3122. return n > 40 ? `${n} 字` : "";
  3123. };
  3124. function renderArgs(raw) {
  3125. let obj = null;
  3126. if (raw && typeof raw === "object" && !Array.isArray(raw)) obj = raw;
  3127. else if (typeof raw === "string" && /^\s*\{/.test(raw)) {
  3128. try { const p = JSON.parse(raw); if (p && typeof p === "object" && !Array.isArray(p)) obj = p; } catch (e) {}
  3129. }
  3130. if (!obj || !Object.keys(obj).length) return `<div class="pp-msg">${renderRich(raw)}</div>`;
  3131. return Object.entries(obj).map(([k, v]) =>
  3132. `<div class="pp-msg"><div class="pp-role"><span class="pr-k">${esc(k)}</span>${
  3133. dataMeta(v) ? `<span class="pr-meta">${esc(dataMeta(v))}</span>` : ""}</div>${renderRich(v)}</div>`).join("");
  3134. }
  3135. // 「输入」/「输出」一区:**一区一块**——左侧色轨 + 同色抬头(入青、出绿,与卡面上那两段同一套配色),
  3136. // 于是「哪些是吃进去的、哪些是吐出来的」是靠颜色与分块一眼分开的,而不是靠读两个小字标签。
  3137. // 空就不画(不摆一个写着「(无)」的空壳)。
  3138. function ioField(label, v, side) {
  3139. const empty = v == null || v === "" ||
  3140. (typeof v === "object" && !Array.isArray(v) && !Object.keys(v).length) ||
  3141. (Array.isArray(v) && !v.length);
  3142. if (empty) return "";
  3143. return `<div class="io-block ${side}">
  3144. <div class="iob-h"><span class="iob-ic">${side === "in" ? "⇢" : "⇠"}</span>
  3145. <span class="iob-t">${esc(label)}</span>
  3146. <span class="iob-meta">${esc(dataMeta(v))}</span></div>
  3147. <div class="iob-b">${renderArgs(v)}</div></div>`;
  3148. }
  3149. // 工具项展开/折叠(详情里默认全折叠:一屏先看清"有哪些工具、哪些用过",要细节再点开)
  3150. function toggleTool(i) {
  3151. const el = document.getElementById("tool-item-" + i);
  3152. if (el) el.classList.toggle("open");
  3153. }
  3154. function renderCardDetail(s) {
  3155. const d = s.detail || {};
  3156. let h = "";
  3157. if (d.tools) { // 工具清单详情:**默认全部折叠**,点标题展开「说明 + 参数表」
  3158. const used = new Set(d.usedTools || []);
  3159. const defs = TOOLDEF || {};
  3160. return d.tools.map((x, i) => {
  3161. const t = { ...(defs[x.name] || {}), name: x.name };
  3162. const np = (t.params || []).length;
  3163. return `<div class="field sec-cell tool-item" id="tool-item-${i}">
  3164. <div class="lbl tool-hd" onclick="toggleTool(${i})">
  3165. <span class="tool-arrow">▸</span>${esc(t.name)}${used.has(t.name)
  3166. ? `<span class="tool-used-tag">本次用过</span>` : ""}
  3167. <span class="tool-meta">${t.description ? `${t.description.length} 字说明` : "无说明"}${
  3168. np ? ` · ${np} 个参数` : ""}</span></div>
  3169. <div class="tool-bd">
  3170. ${t.description ? `<div class="pp-msg">${renderRich(t.description)}</div>`
  3171. : `<div class="val" style="color:var(--faint)">(未解析到该工具的定义:当前代码里已无同名工具?)</div>`}
  3172. ${np ? `<div class="tool-params">${t.params.map(p =>
  3173. `<div class="tp-row"><span class="tp-n">${esc(p.name)}</span>
  3174. <span class="tp-t">${esc(p.type || "—")}</span>
  3175. <span class="tp-r ${p.required ? "req" : ""}">${p.required ? "必填" : "可选"}</span>
  3176. <span class="tp-d">${esc(p.desc || "")}</span></div>`).join("")}</div>` : ""}
  3177. </div>
  3178. </div>`; }).join("");
  3179. }
  3180. if (d.sections) { // 逐段分块:每段标题 + 来源说明 + **完整**内容 + 该段的图
  3181. h = d.sections.map(sec => `<div class="field sec-cell">
  3182. <div class="lbl">${esc(sec.label)}</div>
  3183. ${sec.meta ? `<div class="sec-meta">${esc(sec.meta)}</div>` : ""}
  3184. ${sec.text ? `<div class="pp-msg">${renderRich(sec.text)}</div>` : ""}
  3185. ${(sec.images || []).length ? `<div class="imgs" style="margin-top:8px">${sec.images.map(u =>
  3186. `<img class="thumb" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`).join("")}</div>` : ""}
  3187. ${(!sec.text && !(sec.images || []).length) ? `<div class="val" style="color:var(--faint)">(空)</div>` : ""}
  3188. </div>`).join("");
  3189. return h;
  3190. }
  3191. // ── 运行节点的详情:**一律「输入 / 输出」两段,数据直接铺开** ──────────────────────
  3192. // 工具 / 模型推理 / 结构化收口,点开看到的是同一种东西:这一步吃进什么、吐出什么。
  3193. // 没有的那一段不画(模型推理没有独立入参 —— 它看到的是本段 ① 输入与前面几步,不在这个节点里)。
  3194. if (d.input !== undefined || d.output !== undefined || d.calls || d.reasoning) {
  3195. h += ioField("输入", d.input, "in");
  3196. // 思考在输出之前:模型先想、再说、再决定调什么工具,按这个次序读
  3197. if (d.reasoning) h += `<div class="io-block call"><div class="iob-h"><span class="iob-ic">💭</span>
  3198. <span class="iob-t">思考过程</span><span class="iob-meta">${esc(dataMeta(d.reasoning))}</span></div>
  3199. <div class="iob-b"><div class="pp-msg">${renderRich(d.reasoning)}</div></div></div>`;
  3200. h += ioField("输出", d.output, "out");
  3201. // 模型推理这一步的另一半产出:它决定调用的工具。用紫色一区(与卡面上那块同色),
  3202. // 一个调用一组:序号 + 工具名,参数仍是逐字段铺开。
  3203. if ((d.calls || []).length)
  3204. h += `<div class="io-block call">
  3205. <div class="iob-h"><span class="iob-ic">⚡</span><span class="iob-t">推理结果 · 调用工具</span>
  3206. <span class="iob-meta">${d.calls.length} 个</span></div>
  3207. <div class="iob-b">${d.calls.map((c, k) => `<div class="call-item">
  3208. <div class="ci-h"><span class="ci-i">${k + 1}</span><span class="ci-n">${esc(c.name)}</span>
  3209. <span class="iob-meta">${esc(dataMeta(c.args))}</span></div>
  3210. <div class="ci-b">${renderArgs(c.args)}</div></div>`).join("")}</div></div>`;
  3211. } else {
  3212. if (d.text) h += `<div class="field">${d.title ? `<div class="lbl">${esc(d.title)}</div>` : ""}<div class="pp-msg">${renderRich(d.text)}</div></div>`;
  3213. if (d.payload != null) h += `<div class="field"><div class="lbl">结构化产物</div><div class="pp-msg">${
  3214. renderRich(JSON.stringify(d.payload, null, 2))}</div></div>`;
  3215. if (d.json != null && d.payload == null && !d.text) h += `<div class="field"><div class="pp-msg">${
  3216. renderRich(JSON.stringify(d.json, null, 2))}</div></div>`;
  3217. }
  3218. if (d.images && d.images.length)
  3219. h += `<div class="field"><div class="lbl">图片 · ${d.images.length}</div><div class="imgs">${d.images.map(u =>
  3220. `<img class="thumb" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`).join("")}</div></div>`;
  3221. return h || `<div class="val" style="color:var(--faint)">(无更多详情)</div>`;
  3222. }
  3223. function renderLesson(l) {
  3224. const src = l.source === "verdict" ? "执行" : (l.source === "planner" ? "规划" : (l.source || ""));
  3225. const cls = l.source === "verdict" ? "verdict" : "planner";
  3226. return `<div class="lesson">${src ? `<span class="l-src ${cls}">${esc(src)}</span>` : ""}${l.task_id != null ? `<span class="mmc-sid" style="margin-right:6px">#${esc(l.task_id)}</span>` : ""}${esc(l.note || "")}</div>`;
  3227. }
  3228. // 卡片点击 → 抽屉展示节点全量详情(含跳执行过程 traces)
  3229. // planOnly=true(从 plan 的输出点开):只看**规划侧**的 Task 定义,不掺执行结果——
  3230. // 那一刻这个 Task 还没跑,显示产物/verdict/工具是张冠李戴。
  3231. function openSnapNode(sid, planOnly) {
  3232. if (BOARD && BOARD.suppressClick) return; // 刚拖拽平移过 → 不误触
  3233. const snap = SNAP.list[SNAP.idx];
  3234. const node = ((snap.tree && snap.tree.nodes) || []).find(n => String(n.step_id) === String(sid));
  3235. if (!node) return;
  3236. SNAP.openNodeSid = sid; // 记下当前所看任务 → 刷新时可重开
  3237. SNAP.openNodePlanOnly = !!planOnly;
  3238. const status = planOnly && node.role !== "container" ? "planned" : nodeDisplayStatus(node);
  3239. $("#drawerTag").textContent = SNAP_STATUS[status] || status;
  3240. $("#drawerTag").className = "drawer-tag " + (planOnly || node.role === "container" ? "plan" : "execute");
  3241. $("#drawerTitle").textContent = node.name || `任务 #${sid}`;
  3242. $("#drawerBody").innerHTML = renderSnapNodeDetail(node, snap, !!planOnly);
  3243. openDrawer();
  3244. }
  3245. // 依赖 / 参考图 inputs:外链图 → 缩略图;引用前序产物 → chip(前序产物 URL 由快照里的兄弟节点解析)
  3246. function renderNodeInputs(inputs, snap) {
  3247. if (!Array.isArray(inputs) || !inputs.length) return "";
  3248. const prod = {};
  3249. for (const n of ((snap && snap.tree && snap.tree.nodes) || []))
  3250. if (n.result && n.result.product_url) prod[String(n.step_id)] = n.result.product_url;
  3251. return inputs.map(inp => {
  3252. const ext = inp && inp.external;
  3253. if (ext) return isImgUrl(ext)
  3254. ? `<img class="thumb ref" src="${esc(ext)}" referrerpolicy="no-referrer" onclick="openLB('${esc(ext)}')" onerror="imgFail(this)">`
  3255. : `<span class="refchip">↗ ${esc(ext)}</span>`;
  3256. if (inp && inp.from_step != null) {
  3257. const u = prod[String(inp.from_step)];
  3258. return u ? `<img class="thumb ref" src="${esc(u)}" referrerpolicy="no-referrer" title="引用任务 #${esc(inp.from_step)} 的产物" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`
  3259. : `<span class="refchip">↑ 引用任务 #${esc(inp.from_step)} 产物</span>`;
  3260. }
  3261. return "";
  3262. }).filter(Boolean).join("");
  3263. }
  3264. // 验收标准逐条渲染:结构化项拆成「标准(主)+ 说明(次)+ 重要度 + 参考图」;纯字符串直接展示。
  3265. function renderAcceptance(items) {
  3266. return items.map(c => {
  3267. if (typeof c !== "object" || c === null) return `<li>${esc(String(c))}</li>`;
  3268. const imp = c.importance ? `<span class="mmc-tag">${esc(c.importance)}</span>` : "";
  3269. const std = c.standard != null ? `<div class="acc-std">${esc(c.standard)} ${imp}</div>` : "";
  3270. const desc = c.description != null ? `<div class="acc-desc">${esc(c.description)}</div>` : "";
  3271. const urls = Array.isArray(c.reference_urls) ? c.reference_urls : [];
  3272. const refs = urls.length ? `<div class="imgs acc-refs">${urls.map(u => isImgUrl(u)
  3273. ? `<img class="thumb ref" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`
  3274. : `<span class="refchip">↗ ${esc(u)}</span>`).join("")}</div>` : "";
  3275. return (std || desc || refs) ? `<li>${std}${desc}${refs}</li>` : `<li>${esc(JSON.stringify(c))}</li>`;
  3276. }).join("");
  3277. }
  3278. function renderSnapNodeDetail(node, snap, planOnly) {
  3279. const sid = node.step_id, r = planOnly ? null : node.result;
  3280. const dstatus = planOnly && node.role !== "container" ? "planned" : nodeDisplayStatus(node);
  3281. const chgHtml = renderChangesDiff(snap, sid);
  3282. let h = `<div class="tc-meta" style="margin-top:0">
  3283. <span class="metachip">任务 #${esc(sid)}</span>
  3284. <span class="metachip">${node.role === "container" ? "容器 · 已拆分" : SNAP_STATUS[dstatus] || dstatus}</span>
  3285. ${node.parent_id != null ? `<span class="metachip">← 父 #${esc(node.parent_id)}</span>` : ""}
  3286. ${(snap.node_change || {})[String(sid)] ? `<span class="metachip" style="color:var(--${snap.node_change[String(sid)] === "added" ? "ok" : "run"})">${snap.node_change[String(sid)] === "added" ? "+本轮新增" : "~本轮调整"}</span>` : ""}
  3287. </div>`;
  3288. if (chgHtml) h += `<div class="field"><div class="lbl">规划变化 · changes(本轮 diff)</div><div class="chg-list">${chgHtml}</div></div>`;
  3289. if (node.goal) h += `<div class="field"><div class="lbl">目标 · goal</div><div class="val">${esc(node.goal)}</div></div>`;
  3290. if (node.stage || node.category)
  3291. h += `<div class="field"><div class="lbl">制作属性 · stage & category</div><div class="mmc-attrs" style="margin-top:0">
  3292. ${node.stage ? `<span class="attr-chip stage"><span class="k">阶段</span>${esc(node.stage)}</span>` : ""}
  3293. ${node.category ? `<span class="attr-chip cat"><span class="k">操作</span>${esc(node.category)}</span>` : ""}</div></div>`;
  3294. // 依赖 / 参考图 · inputs(执行时喂入的素材)
  3295. const refHtml = renderNodeInputs(node.inputs, snap);
  3296. if (refHtml) h += `<div class="field"><div class="lbl">依赖 / 参考图 · inputs</div><div class="imgs">${refHtml}</div></div>`;
  3297. // 验收标准 · acceptance(评估据此逐条核对)
  3298. if (Array.isArray(node.acceptance) && node.acceptance.length)
  3299. h += `<div class="field"><div class="lbl">验收标准 · acceptance</div><ul class="acc-list">${
  3300. renderAcceptance(node.acceptance)}</ul></div>`;
  3301. // 能力 kind + 执行时用到的工具(执行侧信息;只看规划时不显示)
  3302. if (!planOnly && (node.kind || (node.tools && node.tools.length))) {
  3303. h += `<div class="field"><div class="lbl">能力 / 工具 · kind & tools</div><div class="mmc-tools" style="margin-top:0">
  3304. ${node.kind ? `<span class="kind-chip">⚙ ${esc(node.kind)}</span>` : ""}
  3305. ${(node.tools || []).map(t => `<span class="tool-chip">${esc(t)}</span>`).join("")}
  3306. </div></div>`;
  3307. }
  3308. if (node.role === "container") {
  3309. h += `<div class="field"><div class="val" style="color:var(--faint)">该任务已被拆分,由子任务集体兑现,本身不直接执行。</div></div>`;
  3310. return h;
  3311. }
  3312. if (r) {
  3313. if (r.product_kind === "image" && r.product_url)
  3314. h += `<div class="field"><div class="lbl">产物 · product</div><div class="imgs"><img class="thumb" src="${esc(r.product_url)}" referrerpolicy="no-referrer" onclick="openLB('${esc(r.product_url)}')" onerror="imgFail(this)"></div></div>`;
  3315. else if (r.product_text)
  3316. h += `<div class="field"><div class="lbl">产物 · text</div><div class="val">${esc(r.product_text)}</div></div>`;
  3317. if (r.summary) h += `<div class="field"><div class="lbl">结果摘要 · summary</div><div class="val">${esc(r.summary)}</div></div>`;
  3318. const cls = r.verdict_pass ? "pass" : "fail";
  3319. h += `<div class="field"><div class="lbl">评估 · verdict${r.attempts > 1 ? ` · 尝试 ${r.attempts} 次` : ""}</div>
  3320. <div class="a-verdict-box ${cls}">
  3321. <div style="font-family:var(--mono);font-size:11px;font-weight:700;color:var(--${r.verdict_pass ? "ok" : "fail"})">${r.verdict_pass ? "✓ 达标" : "✗ 未达标"}</div>
  3322. ${r.failed_dims && r.failed_dims.length ? `<div class="vd-dims">${r.failed_dims.map(x => `<span class="vd-dim">${esc(typeof x === "object" ? JSON.stringify(x) : x)}</span>`).join("")}</div>` : ""}
  3323. ${r.verdict_reason ? `<div class="vd-note">${esc(r.verdict_reason)}</div>` : ""}
  3324. </div></div>`;
  3325. } else if (!planOnly) {
  3326. h += dstatus === "running"
  3327. ? `<div class="pending-note" style="color:var(--run)">▸ 正在执行中… 执行过程(工具调用 / 评估)会在该步完成后归档,届时可在此查看。</div>`
  3328. : `<div class="pending-note">◔ 本轮该任务尚未执行(在剩余队列中,等待 execute)。</div>`;
  3329. }
  3330. // 完整原始数据(这个 Task 在本轮快照里的全部字段),便于核对
  3331. const raw = planOnly
  3332. ? Object.fromEntries(Object.entries(node).filter(([k]) =>
  3333. !["result", "tools", "trace_summary", "exec_step_id", "executing"].includes(k)))
  3334. : node;
  3335. h += `<div class="field"><div class="lbl">完整数据 · raw</div><div class="pp-msg">${
  3336. renderRich(JSON.stringify(raw, null, 2))}</div></div>`;
  3337. // 内部执行过程不在此渲染:execute/evaluate 的过程已在画板的对应节点里呈现。
  3338. return h;
  3339. }
  3340. let _mmResizeT = null;
  3341. window.addEventListener("resize", () => {
  3342. if (!SNAP || !SNAP.loaded) return;
  3343. clearTimeout(_mmResizeT); _mmResizeT = setTimeout(() => layoutBoard(), 120);
  3344. });
  3345. // ── controls ──
  3346. $("#statusSeg").addEventListener("click", e => {
  3347. const b = e.target.closest("button"); if (!b) return;
  3348. [...$("#statusSeg").children].forEach(x => x.classList.toggle("active", x === b));
  3349. STATUS = b.dataset.s; PAGE = 1; loadRuns();
  3350. });
  3351. $("#refreshBtn").addEventListener("click", () => {
  3352. const b = $("#refreshBtn"); b.classList.add("spin"); setTimeout(() => b.classList.remove("spin"), 450);
  3353. loadRuns(); if (CURRENT) selectRun(CURRENT, curRound());
  3354. });
  3355. loadRuns().then(openFromHash);
  3356. </script>
  3357. </body>
  3358. </html>