| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <!-- 不发送 Referer:绕过小红书等 CDN 防盗链,使外链参考图/成品图能正常加载 -->
- <meta name="referrer" content="no-referrer" />
- <title>Production Build · 运行视图</title>
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <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">
- <style>
- :root {
- --bg: #0a0c0f; --bg2: #0e1116; --panel: #13171d; --panel2: #191f27;
- --border: #232a33; --border2: #333c48;
- --text: #e8ecf2; --muted: #838d9c; --faint: #5a6472;
- --accent: #8b9dff; /* iris — 交互/选中 */
- --plan: #a78bfa; /* plan 规划一侧 */
- --exec: #38bdf8; /* execute 执行一侧 */
- --kind: #f5b544; /* step kind 标签(amber) */
- --ok: #34d399; --fail: #f87171; --run: #fbbf24; --idle: #8b95a3;
- --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- --sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
- --serif: "Fraunces", Georgia, "Songti SC", serif;
- }
- * { box-sizing: border-box; }
- html, body { margin: 0; height: 100%; }
- body {
- font-family: var(--sans); background: var(--bg); color: var(--text);
- height: 100vh; overflow: hidden; -webkit-font-smoothing: antialiased;
- background-image:
- radial-gradient(880px 520px at 82% -8%, rgba(139,157,255,.10), transparent 60%),
- radial-gradient(760px 560px at 4% 108%, rgba(167,139,250,.09), transparent 62%);
- }
- ::selection { background: rgba(139,157,255,.30); }
- ::-webkit-scrollbar { width: 10px; height: 10px; }
- ::-webkit-scrollbar-thumb { background: #222933; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
- ::-webkit-scrollbar-thumb:hover { background: #2f3843; background-clip: padding-box; }
- .app { display: grid; grid-template-columns: 366px 1fr; height: 100vh; }
- /* ───────────── sidebar ───────────── */
- .sidebar { background: linear-gradient(180deg, var(--bg2), var(--bg)); border-right: 1px solid var(--border);
- display: flex; flex-direction: column; min-height: 0; }
- .brand { padding: 20px 22px 14px; border-bottom: 1px solid var(--border); }
- .brand-top { display: flex; align-items: baseline; gap: 10px; }
- .logo { font-family: var(--serif); font-style: italic; font-weight: 600; font-size: 25px; letter-spacing: .2px;
- background: linear-gradient(96deg, #eaeefc, #9db0ff 62%, #b79dff); -webkit-background-clip: text;
- background-clip: text; color: transparent; line-height: 1; }
- .logo-sub { font-family: var(--mono); font-size: 10px; letter-spacing: 2.5px; color: var(--faint);
- text-transform: uppercase; margin-left: auto; padding-bottom: 2px; }
- .brand-desc { margin-top: 9px; font-size: 11.5px; color: var(--muted); letter-spacing: .2px; line-height: 1.5; }
- .loop-glyph { font-family: var(--mono); color: var(--plan); font-weight: 700; }
- .loop-glyph b { color: var(--exec); font-weight: 700; }
- .controls { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
- .seg { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 3px; }
- .seg button { flex: 1; background: transparent; border: none; color: var(--muted); font-family: var(--mono);
- font-size: 11px; padding: 6px 4px; border-radius: 7px; cursor: pointer; letter-spacing: .3px;
- transition: background .15s, color .15s; }
- .seg button:hover { color: var(--text); }
- .seg button.active { background: var(--panel2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border2); }
- .seg button.active[data-s="success"] { color: var(--ok); }
- .seg button.active[data-s="running"] { color: var(--run); }
- .seg button.active[data-s="failed"] { color: var(--fail); }
- .ctl-row { display: flex; align-items: center; gap: 10px; }
- .ctl-row .count { font-family: var(--mono); font-size: 11px; color: var(--faint); }
- .icon-btn { margin-left: auto; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
- background: var(--panel); border: 1px solid var(--border); border-radius: 9px; color: var(--muted);
- cursor: pointer; font-size: 15px; transition: transform .4s, color .15s, border-color .15s; }
- .icon-btn:hover { color: var(--accent); border-color: var(--border2); }
- .icon-btn.spin { transform: rotate(360deg); }
- .runlist { overflow-y: auto; flex: 1; min-height: 0; padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; }
- .runlist .hint { color: var(--faint); font-size: 12px; text-align: center; margin-top: 40px; }
- .runcard { position: relative; border: 1px solid var(--border); border-radius: 12px; padding: 12px 13px 11px 15px;
- background: linear-gradient(180deg, var(--panel), var(--bg2)); cursor: pointer; flex: 0 0 auto;
- transition: border-color .15s, transform .12s, box-shadow .15s; overflow: hidden; }
- .runcard::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--idle); opacity: .8; }
- .runcard[data-s="success"]::before { background: var(--ok); }
- .runcard[data-s="running"]::before { background: var(--run); }
- .runcard[data-s="failed"]::before { background: var(--fail); }
- .runcard[data-s="interrupted"]::before { background: var(--idle); }
- .runcard:hover { border-color: var(--border2); transform: translateY(-1px); }
- .runcard.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 10px 30px -14px rgba(139,157,255,.6); }
- .rc-top { display: flex; align-items: center; gap: 8px; }
- .rc-id { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--accent); }
- .rc-agent { font-family: var(--mono); font-size: 10.5px; color: var(--faint); letter-spacing: .3px; }
- .rc-obj { margin-top: 7px; font-size: 12.5px; line-height: 1.5; color: var(--text); display: -webkit-box;
- -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
- .rc-meta { margin-top: 9px; display: flex; flex-wrap: wrap; gap: 4px 12px; font-family: var(--mono);
- font-size: 10.5px; color: var(--faint); }
- .rc-meta b { color: var(--muted); font-weight: 500; }
- .badge { font-family: var(--mono); font-size: 9.5px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
- padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; white-space: nowrap; }
- .badge.success { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
- .badge.running { color: var(--run); background: rgba(251,191,36,.11); border-color: rgba(251,191,36,.34); }
- .badge.failed { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
- .badge.interrupted { color: var(--idle); background: rgba(139,149,163,.12); border-color: rgba(139,149,163,.32); }
- .badge.dot::before { content: "●"; font-size: 7px; margin-right: 5px; vertical-align: middle; }
- .badge.running.dot::before { animation: pulse 1.4s ease-in-out infinite; }
- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
- .pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 9px;
- border-top: 1px solid var(--border); flex-shrink: 0; }
- .pager:empty { display: none; }
- .pager button { font-family: var(--mono); padding: 4px 12px; border: 1px solid var(--border); border-radius: 8px;
- background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; }
- .pager button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
- .pager button:disabled { opacity: .35; cursor: default; }
- .pager .pi { font-family: var(--mono); font-size: 11px; color: var(--faint); }
- /* ───────────── main ───────────── */
- .main { overflow-y: auto; min-height: 0; position: relative; }
- .empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
- gap: 18px; color: var(--faint); }
- .empty .big { font-family: var(--serif); font-style: italic; font-size: 34px; color: var(--border2); }
- .empty .lp { font-family: var(--mono); font-size: 12px; letter-spacing: 1px; }
- .empty .lp b { color: var(--plan); } .empty .lp i { color: var(--exec); font-style: normal; }
- .topbar { position: sticky; top: 0; z-index: 20; background: rgba(10,12,15,.82); backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border); padding: 13px 30px; display: flex; align-items: center; gap: 14px; }
- .tb-id { font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--accent); flex: 0 0 auto; }
- .tb-obj { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .tb-right { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
- .detailbody { padding: 24px 30px 90px; max-width: 1120px; }
- .loading { display: flex; align-items: center; gap: 12px; color: var(--muted); font-family: var(--mono);
- font-size: 13px; padding: 60px 30px; }
- .loading .dots span { animation: blink 1.2s infinite; } .loading .dots span:nth-child(2){animation-delay:.2s}
- .loading .dots span:nth-child(3){animation-delay:.4s}
- @keyframes blink { 0%,100%{opacity:.2} 50%{opacity:1} }
- /* ───────────── hero:目标 + 循环机制概览 ───────────── */
- .hero { border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px;
- background: linear-gradient(180deg, var(--panel), var(--bg2)); position: relative; overflow: hidden; }
- .hero::after { content: ""; position: absolute; right: -60px; top: -60px; width: 220px; height: 220px;
- background: radial-gradient(circle, rgba(139,157,255,.14), transparent 70%); pointer-events: none; }
- .hero-obj { font-family: var(--serif); font-size: 20px; line-height: 1.5; font-weight: 400; color: #f2f4f9; letter-spacing: .1px; }
- .hero-model { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 10px; }
- /* loop 机制章:PLAN ⇄ EXECUTE 环 + 计数 */
- .loopwrap { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 20px; padding-top: 18px;
- border-top: 1px solid var(--border); }
- .loopdiagram { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
- .lp-node { display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 11px 16px; border-radius: 12px;
- border: 1px solid var(--border2); min-width: 86px; }
- .lp-node.plan { background: rgba(167,139,250,.09); border-color: rgba(167,139,250,.34); }
- .lp-node.exec { background: rgba(56,189,248,.09); border-color: rgba(56,189,248,.34); }
- .lp-node .lp-t { font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: 1.5px; }
- .lp-node.plan .lp-t { color: var(--plan); } .lp-node.exec .lp-t { color: var(--exec); }
- .lp-node .lp-c { font-family: var(--mono); font-size: 18px; font-weight: 700; color: var(--text); }
- .lp-node .lp-c small { font-size: 10px; color: var(--faint); font-weight: 400; }
- .lp-arrows { display: flex; flex-direction: column; align-items: center; gap: 3px; color: var(--faint); font-family: var(--mono); }
- .lp-arrows .a1 { color: var(--plan); animation: slideR 2.4s ease-in-out infinite; }
- .lp-arrows .a2 { color: var(--exec); animation: slideL 2.4s ease-in-out infinite; }
- @keyframes slideR { 0%,100%{opacity:.35;transform:translateX(-2px)} 50%{opacity:1;transform:translateX(2px)} }
- @keyframes slideL { 0%,100%{opacity:.35;transform:translateX(2px)} 50%{opacity:1;transform:translateX(-2px)} }
- .lp-arrows .lbl { font-size: 8.5px; letter-spacing: 1px; color: var(--faint); text-transform: uppercase; }
- .loopstats { display: flex; flex-wrap: wrap; gap: 8px; flex: 1; min-width: 260px; }
- .lstat { border: 1px solid var(--border); border-radius: 10px; padding: 8px 13px; background: var(--bg2); min-width: 78px; }
- .lstat .k { font-family: var(--mono); font-size: 9.5px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint); }
- .lstat .v { font-family: var(--mono); font-size: 16px; color: var(--text); margin-top: 3px; font-weight: 600; }
- .lstat .v small { font-size: 10px; color: var(--muted); font-weight: 400; }
- .lstat .v.pass { color: var(--ok); } .lstat .v.fail { color: var(--fail); }
- .lstat .v.retry { color: var(--run); } .lstat .v.plan { color: var(--plan); }
- .err-box { margin-top: 16px; border: 1px solid rgba(248,113,113,.4); background: rgba(248,113,113,.07);
- border-radius: 10px; padding: 12px 15px; color: #ffb4b4; font-family: var(--mono); font-size: 12px;
- white-space: pre-wrap; word-break: break-word; }
- /* section titles */
- .sec-title { display: flex; align-items: center; gap: 10px; margin: 32px 0 16px; }
- .sec-title .t { font-family: var(--mono); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); }
- .sec-title .line { flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
- .sec-title .n { font-family: var(--mono); font-size: 11px; color: var(--faint); }
- /* final delivery */
- .delivery { border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: var(--panel); }
- .delivery-h { display: flex; align-items: center; gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border);
- font-family: var(--mono); font-size: 12px; color: var(--ok); background: rgba(52,211,153,.05); }
- .delivery-b { padding: 16px; font-size: 13.5px; line-height: 1.75; color: var(--text);
- white-space: pre-wrap; word-break: break-word; }
- .gallery { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 16px 18px; }
- .gcard { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg2); width: 150px; }
- .gcard img { width: 150px; height: 190px; object-fit: cover; display: block; cursor: zoom-in; background: var(--bg); }
- .gcard .cap { padding: 6px 8px; font-family: var(--mono); font-size: 10px; color: var(--muted); display: flex;
- align-items: center; gap: 5px; border-top: 1px solid var(--border); }
- .gcard .cap .sid { color: var(--kind); font-weight: 700; }
- /* ───────────── 抽屉复用:元信息 / 字段 / 产物图 / verdict / pending ───────────── */
- .tc-meta { display: flex; flex-wrap: wrap; gap: 5px 8px; margin: 2px 0 4px; }
- .metachip { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--bg2);
- border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
- .field { margin-top: 13px; }
- .field > .lbl { font-family: var(--mono); font-size: 10px; letter-spacing: 1px; text-transform: uppercase;
- color: var(--faint); margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
- .field .val { font-size: 13px; line-height: 1.65; color: var(--text); white-space: pre-wrap; word-break: break-word; }
- .imgs { display: flex; flex-wrap: wrap; gap: 9px; }
- .imgs img.thumb { width: 122px; height: 152px; object-fit: cover; border: 1px solid var(--border); border-radius: 9px;
- background: var(--bg); cursor: zoom-in; transition: border-color .15s, transform .12s; }
- .imgs img.thumb:hover { border-color: var(--accent); transform: scale(1.02); }
- .imgs img.thumb.ref { border-style: dashed; opacity: .92; }
- .imgs .imgfail { width: 122px; height: 152px; display: flex; align-items: center; justify-content: center; text-align: center;
- border: 1px dashed var(--fail); border-radius: 9px; color: var(--fail); font-family: var(--mono);
- font-size: 10px; background: rgba(248,113,113,.05); padding: 8px; cursor: pointer; text-decoration: none; }
- .a-verdict-box { margin-top: 10px; border-radius: 9px; padding: 9px 12px; border: 1px solid var(--border); }
- .a-verdict-box.pass { border-color: rgba(52,211,153,.32); background: rgba(52,211,153,.05); }
- .a-verdict-box.fail { border-color: rgba(248,113,113,.32); background: rgba(248,113,113,.05); }
- .vd-dims { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
- .vd-dim { font-family: var(--mono); font-size: 10.5px; color: var(--fail); background: rgba(248,113,113,.1);
- border: 1px solid rgba(248,113,113,.3); border-radius: 6px; padding: 2px 8px; }
- .vd-note { margin-top: 8px; font-size: 12px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
- /* pending 任务(尚未执行/等待重试) */
- .pending-note { margin-top: 12px; font-family: var(--mono); font-size: 11.5px; color: var(--run);
- display: flex; align-items: center; gap: 8px; }
- /* 详情抽屉 */
- .drawer-scrim { position: fixed; inset: 0; background: rgba(5,6,8,.4); z-index: 340; opacity: 0; pointer-events: none;
- transition: opacity .2s; }
- .drawer-scrim.open { opacity: 1; pointer-events: auto; }
- /* 抽屉里放的是 system prompt 全文、逐字段入参、带表格的长文档 —— 660px 一行塞不下几个字,
- 表格更是每格都要折。放宽到视口的一半上下(大屏 1080px 封顶,小屏按 vw 收)。 */
- .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(1080px, 62vw); min-width: 620px;
- max-width: 96vw; z-index: 341;
- background: linear-gradient(180deg, var(--panel), var(--bg2)); border-left: 1px solid var(--border2);
- box-shadow: -20px 0 50px -20px rgba(0,0,0,.7); transform: translateX(102%); transition: transform .24s cubic-bezier(.4,0,.2,1);
- display: flex; flex-direction: column; }
- .drawer.open { transform: none; }
- .drawer-h { display: flex; align-items: center; gap: 10px; padding: 15px 18px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
- .drawer-tag { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
- padding: 3px 10px; border-radius: 7px; }
- .drawer-tag.plan { color: var(--plan); background: rgba(167,139,250,.12); }
- .drawer-tag.execute { color: var(--exec); background: rgba(56,189,248,.12); }
- .drawer-title { font-size: 14px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .drawer-x { margin-left: auto; width: 30px; height: 30px; border-radius: 8px; cursor: pointer; flex: 0 0 auto;
- display: flex; align-items: center; justify-content: center; color: var(--muted); background: var(--panel2);
- border: 1px solid var(--border); font-size: 15px; }
- .drawer-x:hover { color: var(--text); }
- .drawer-refresh + .drawer-x { margin-left: 0; } /* 刷新按钮吃掉 auto 边距,✕ 紧随其后 */
- @keyframes rot360 { to { transform: rotate(360deg); } }
- .spinning { animation: rot360 .5s linear; }
- .drawer-b { overflow-y: auto; padding: 16px 18px 40px; flex: 1; min-height: 0; }
- /* lightbox */
- .lb { position: fixed; inset: 0; background: rgba(5,6,8,.92); display: none; align-items: center; justify-content: center;
- z-index: 360; padding: 30px; }
- .lb.open { display: flex; }
- .lb img { max-width: 92vw; max-height: 86vh; object-fit: contain; border-radius: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.7); }
- .lb-counter { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 12px;
- color: var(--muted); background: rgba(0,0,0,.5); border: 1px solid var(--border); padding: 5px 14px; border-radius: 20px; }
- .lb-nav { position: fixed; top: 50%; transform: translateY(-50%); width: 46px; height: 70px; font-size: 30px; cursor: pointer;
- color: #fff; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.16); border-radius: 12px;
- display: flex; align-items: center; justify-content: center; }
- .lb-nav:hover { background: rgba(255,255,255,.16); }
- .lb-nav.prev { left: 22px; } .lb-nav.next { right: 22px; }
- .lb-close { position: fixed; top: 22px; right: 26px; width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
- color: var(--muted); background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.16); font-size: 18px;
- display: flex; align-items: center; justify-content: center; }
- .lb-close:hover { color: #fff; }
- /* ───────────── 快照步进器(Loop 1 → 2 → … → 收尾) ───────────── */
- .snap-empty { border: 1px dashed var(--border2); border-radius: 14px; padding: 40px 24px; text-align: center;
- color: var(--faint); font-family: var(--mono); font-size: 13px; margin-top: 20px; }
- .snap-stepper { display: flex; align-items: center; gap: 8px; padding: 14px 0 18px; flex-wrap: wrap; }
- .snap-arrow { width: 30px; height: 30px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
- background: var(--panel); border: 1px solid var(--border); border-radius: 9px; color: var(--muted);
- cursor: pointer; font-size: 15px; transition: color .15s, border-color .15s; }
- .snap-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--border2); }
- .snap-arrow:disabled { opacity: .3; cursor: default; }
- .snap-track { display: flex; align-items: center; gap: 0; flex-wrap: wrap; }
- .snap-pill { position: relative; display: flex; align-items: center; gap: 6px; font-family: var(--mono);
- font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid var(--border);
- border-radius: 8px; padding: 5px 12px; cursor: pointer; transition: all .15s; white-space: nowrap; }
- .snap-pill:hover { border-color: var(--border2); color: var(--text); }
- .snap-pill.cur { background: rgba(139,157,255,.12); border-color: var(--accent); color: var(--accent); }
- .snap-pill.final { color: var(--ok); }
- .snap-pill.final.cur { background: rgba(52,211,153,.12); border-color: var(--ok); }
- .snap-sep { width: 20px; height: 1px; background: var(--border2); flex: 0 0 auto; }
- .mm-card { position: relative; width: 256px; flex: 0 0 auto; border: 1px solid var(--border2); border-radius: 13px;
- background: var(--panel); padding: 11px 13px 12px; cursor: pointer;
- transition: transform .12s, box-shadow .15s, border-color .15s; }
- .mm-card:hover { transform: translateY(-2px); box-shadow: 0 12px 26px -14px rgba(0,0,0,.7); border-color: var(--accent); }
- .mm-card.st-passed { border-left: 3px solid var(--ok); }
- .mm-card.st-failed { border-left: 3px solid var(--fail); }
- .mm-card.st-pending { border-left: 3px solid var(--idle); border-style: dashed; opacity: .92; }
- .mm-card.st-decomposed { border-left: 3px solid var(--plan); background: linear-gradient(180deg, rgba(167,139,250,.06), var(--panel)); }
- .mm-card.mm-modified { box-shadow: 0 0 0 1.5px var(--run); }
- .mmc-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
- .mmc-sid { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--panel2);
- border: 1px solid var(--border); border-radius: 5px; padding: 0 6px; flex: 0 0 auto; }
- .mmc-badge { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
- padding: 1px 7px; border-radius: 20px; border: 1px solid transparent; flex: 0 0 auto; }
- .mmc-badge.passed { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
- .mmc-badge.failed { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
- .mmc-badge.pending { color: var(--idle); background: rgba(139,149,163,.12); border-color: rgba(139,149,163,.32); }
- .mmc-badge.decomposed { color: var(--plan); background: rgba(167,139,250,.11); border-color: rgba(167,139,250,.34); }
- .mmc-name { font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1.4;
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
- .mmc-goal { margin-top: 5px; font-size: 11px; line-height: 1.5; color: var(--muted);
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
- .mmc-result { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); display: flex; gap: 9px; }
- .mmc-thumb { width: 46px; height: 58px; border-radius: 7px; object-fit: cover; flex: 0 0 auto; cursor: zoom-in;
- border: 1px solid var(--border); background: var(--bg); }
- .mmc-sum { font-size: 10.5px; line-height: 1.5; color: var(--muted); min-width: 0;
- display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
- .mmc-fail { margin-top: 7px; font-size: 10.5px; line-height: 1.5; color: #ffb4b4; }
- .mmc-tag { font-family: var(--mono); font-size: 9px; color: var(--faint); }
- .mmc-tag.retry { color: var(--run); }
- .mmc-kidsum { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); font-family: var(--mono);
- font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
- /* 规划变化 diff:逐字段「旧 → 新」,name/goal 走行内字符级 diff(删除红删除线 / 新增绿底) */
- .chg-list { display: flex; flex-direction: column; gap: 9px; }
- .chg-row { display: flex; gap: 9px; align-items: baseline; }
- .chg-k { flex: 0 0 auto; font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--faint);
- background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
- .chg-v { flex: 1 1 auto; min-width: 0; font-size: 12.5px; line-height: 1.7; color: var(--text); word-break: break-word; }
- .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; }
- .df-ins { color: var(--ok); background: rgba(52,211,153,.15); border-radius: 3px; }
- .lesson { padding: 8px 14px; border-top: 1px solid var(--border); font-size: 11.5px; line-height: 1.55; color: var(--muted); }
- .lesson:first-of-type { border-top: none; }
- .lesson .l-src { font-family: var(--mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
- padding: 1px 6px; border-radius: 5px; margin-right: 6px; }
- .lesson .l-src.verdict { color: var(--fail); background: rgba(248,113,113,.1); }
- .lesson .l-src.planner { color: var(--plan); background: rgba(167,139,250,.1); }
- /* 阶段 / 生成类型(操作) chips —— planner 赋予的制作属性 */
- .mmc-attrs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
- .attr-chip { font-family: var(--mono); font-size: 9px; font-weight: 600; border-radius: 5px; padding: 1px 7px; }
- .attr-chip .k { opacity: .55; margin-right: 4px; font-weight: 700; }
- .attr-chip.stage { color: var(--plan); background: rgba(167,139,250,.1); border: 1px solid rgba(167,139,250,.28); }
- .attr-chip.cat { color: var(--exec); background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.26); }
- /* 能力 / 工具 chips */
- .mmc-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
- .kind-chip { font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--kind);
- background: rgba(245,181,68,.12); border: 1px solid rgba(245,181,68,.3); border-radius: 5px; padding: 1px 7px; }
- .tool-chip { font-family: var(--mono); font-size: 9px; color: var(--exec);
- background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.28); border-radius: 5px; padding: 1px 7px; }
- /* 执行中 状态(run 仍在跑、最新快照里的下一个待执行叶) */
- .mmc-badge.running { color: var(--run); background: rgba(251,191,36,.13); border-color: rgba(251,191,36,.4); }
- .mmc-badge.running::before { content: "●"; font-size: 7px; margin-right: 4px; animation: pulse 1.4s ease-in-out infinite; }
- .mm-card.st-running { border-left: 3px solid var(--run); box-shadow: 0 0 0 1px rgba(251,191,36,.25); }
- /* 验收标准 / 依赖参考图(节点详情复用) */
- .acc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
- .acc-list li { position: relative; padding-left: 20px; font-size: 12.5px; line-height: 1.55; color: var(--muted); }
- .acc-list li::before { content: "▪"; position: absolute; left: 4px; top: 2px; color: var(--exec); font-size: 11px; }
- /* 验收标准结构化项:标准(主)+ 说明(次)+ 参考图缩略图 */
- .acc-std { font-weight: 600; color: var(--text); }
- .acc-desc { margin-top: 3px; font-size: 12px; line-height: 1.6; color: var(--muted); }
- .acc-refs { margin-top: 7px; }
- .refchip { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--plan);
- background: rgba(167,139,250,.1); border: 1px solid rgba(167,139,250,.28); border-radius: 7px; padding: 3px 9px; }
- .imgs img.thumb.ref { border-style: dashed; opacity: .92; }
- /* 循环阶段步进器:轮次编号 + 改 Task 标记 + 实时点 */
- .snap-pill .sp-n { font-family: var(--mono); font-size: 9px; color: var(--faint); background: var(--bg);
- border: 1px solid var(--border); border-radius: 4px; padding: 0 4px; }
- .snap-pill.cur .sp-n { color: var(--accent); border-color: var(--accent); }
- .snap-pill.changed { border-color: rgba(52,211,153,.4); }
- .snap-pill .sp-mark { color: var(--ok); font-size: 10px; }
- .snap-pill .sp-live { color: var(--run); font-size: 8px; animation: pulse 1.4s ease-in-out infinite; }
- /* plan 过程抽屉:输入消息 / 输出 */
- .pp-msg { border: 1px solid var(--border); border-radius: 9px; background: var(--bg2); margin-top: 8px; overflow: hidden; }
- .pp-role { font-family: var(--mono); font-size: 9px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint);
- padding: 5px 10px; border-bottom: 1px solid var(--border); background: var(--panel); }
- .pp-content { font-family: var(--mono); font-size: 11px; line-height: 1.55; color: var(--muted); white-space: pre-wrap;
- word-break: break-word; padding: 9px 11px; max-height: 300px; overflow: auto; }
- .pp-json { color: var(--text); }
- /* 依附在消息块内容之下的图片条(与内容之间加分隔) */
- .pp-imgs { padding: 10px 11px; border-top: 1px solid var(--border); background: var(--panel); }
- /* 详情里的「分段单元」:一段一块,边界清晰,内容完整 */
- .sec-cell { border: 1px solid var(--border); border-radius: 12px; padding: 11px 13px 12px;
- background: rgba(139,157,255,.04); margin-top: 14px; }
- .sec-cell > .lbl { color: var(--accent); letter-spacing: .6px; text-transform: none; font-size: 11px; }
- .sec-cell .sec-meta { font-family: var(--mono); font-size: 10px; color: var(--faint); margin: 2px 0 8px; }
- /* 详情富文本:markdown 渲染(区别于等宽 JSON) */
- .pp-md { font-size: 12.5px; line-height: 1.68; color: var(--text); word-break: break-word;
- padding: 11px 13px; max-height: 340px; overflow: auto; }
- .pp-md > :first-child { margin-top: 0; }
- .pp-md > :last-child { margin-bottom: 0; }
- .rich-p { margin: 8px 0; }
- .rich-h { font-weight: 700; line-height: 1.4; margin: 15px 0 7px; color: var(--text); }
- .rich-h1 { font-size: 16px; } .rich-h2 { font-size: 14.5px; } .rich-h3 { font-size: 13.5px; }
- .rich-h4, .rich-h5, .rich-h6 { font-size: 12.5px; color: var(--muted); letter-spacing: .3px; }
- .rich-list { margin: 8px 0; padding-left: 20px; }
- .rich-list li { margin: 3px 0; }
- .pp-md code { font-family: var(--mono); font-size: 11px; background: var(--panel2); border: 1px solid var(--border);
- border-radius: 5px; padding: 1px 5px; color: var(--accent); }
- .rich-code { font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--muted); white-space: pre-wrap;
- word-break: break-word; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
- padding: 9px 11px; margin: 9px 0; overflow: auto; }
- .rich-quote { margin: 9px 0; padding: 4px 12px; border-left: 3px solid var(--border2); color: var(--muted); }
- /* 表格(GFM):窄容器里横向可滚,不撑破抽屉;表头置顶、行间细线、悬停整行提亮 */
- .rich-table-wrap { margin: 10px 0; overflow-x: auto; border: 1px solid var(--border);
- border-radius: 9px; background: var(--bg2); }
- .rich-table { border-collapse: collapse; width: 100%; font-size: 12px; line-height: 1.6; }
- .rich-table th, .rich-table td { padding: 7px 11px; border-bottom: 1px solid var(--border);
- vertical-align: top; }
- .rich-table th { background: var(--panel); color: var(--text); font-weight: 700; white-space: nowrap;
- position: sticky; top: 0; }
- .rich-table td { color: var(--muted); }
- .rich-table tbody tr:last-child td { border-bottom: 0; }
- .rich-table tbody tr:hover td { background: rgba(139,157,255,.06); color: var(--text); }
- .rich-table code { white-space: nowrap; }
- .pp-md strong { color: var(--text); font-weight: 700; }
- .pp-md a { color: var(--accent); text-decoration: none; }
- .pp-md a:hover { text-decoration: underline; }
- /* markdown 正文里就地内联的图片:自成一行、结合上下文查看,点击放大 */
- .pp-md .md-img { display: block; max-width: 240px; max-height: 180px; width: auto; height: auto; margin: 7px 0; border-radius: 8px;
- border: 1px solid var(--border); background: var(--bg); object-fit: contain; cursor: zoom-in; }
- .pp-md .imgfail { display: inline-block; margin: 6px 0; }
- /* 抽屉里**一律不套内嵌小滚动框**:一屏里嵌好几个各自能滚的小框,读者既不知道每块有多长、
- 也没法一路读下去(见 think_and_plan 的 thought/plan/action 三段)。内容整块铺开,由抽屉整体滚动。 */
- #drawerBody .pp-md, #drawerBody .pp-content { max-height: none; overflow: visible; }
- /* ── 详情里的「输入 / 输出」两区 ─────────────────────────────────────────────
- 一区一块:左侧 3px 色轨 + 同色抬头 + 淡色底。**入青 / 出绿 / 调用紫**,与卡面上那三段
- 同一套配色 —— 同一件事在哪儿看都是同一个颜色,读者不必每次重新认标签。 */
- .io-block { margin-top: 16px; border: 1px solid var(--border); border-left: 3px solid var(--io-c);
- border-radius: 12px; overflow: hidden; background: var(--panel); }
- .io-block.in { --io-c: rgb(56,189,248); --io-bg: rgba(56,189,248,.10); }
- .io-block.out { --io-c: rgb(74,222,128); --io-bg: rgba(74,222,128,.10); }
- .io-block.call { --io-c: rgb(167,139,250); --io-bg: rgba(167,139,250,.12); }
- .iob-h { display: flex; align-items: center; gap: 8px; padding: 9px 13px; background: var(--io-bg);
- border-bottom: 1px solid var(--border); font-family: var(--mono); font-size: 12px;
- font-weight: 700; letter-spacing: .8px; color: var(--io-c); }
- .iob-ic { font-size: 13px; opacity: .9; }
- .iob-meta { margin-left: auto; font-family: var(--mono); font-size: 9.5px; font-weight: 400;
- letter-spacing: .3px; color: var(--faint); }
- .iob-b { padding: 3px 13px 13px; }
- /* 字段块:键不再是灰色小字,而是**带色点的同色键名**(值有多大标在右侧),值用正文字号 */
- .iob-b .pp-msg { margin-top: 10px; border-color: var(--border2); background: var(--bg2); }
- .iob-b .pp-role { display: flex; align-items: center; gap: 7px; padding: 6px 11px;
- background: var(--io-bg); border-bottom: 1px solid var(--border2);
- font-size: 10.5px; font-weight: 700; letter-spacing: .4px;
- text-transform: none; color: var(--io-c); }
- .iob-b .pp-role::before { content: ""; flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%;
- background: var(--io-c); }
- .iob-b .pr-k { overflow: hidden; text-overflow: ellipsis; }
- .iob-b .pr-meta { margin-left: auto; font-weight: 400; font-size: 9px; color: var(--faint); }
- .iob-b .pp-md { font-size: 13px; color: var(--text); }
- .iob-b .pp-content { font-size: 11.5px; color: var(--text); }
- /* 一次调用一组:序号 + 工具名,参数在下面逐字段铺开 */
- .call-item { margin-top: 12px; }
- .call-item + .call-item { padding-top: 12px; border-top: 1px dashed var(--border2); }
- .ci-h { display: flex; align-items: center; gap: 8px; }
- .ci-i { flex: 0 0 auto; width: 17px; height: 17px; border-radius: 50%; display: grid; place-items: center;
- font-family: var(--mono); font-size: 10px; color: var(--bg); background: var(--io-c); }
- .ci-n { font-family: var(--mono); font-size: 13px; font-weight: 700; color: #c9b8ff; word-break: break-all; }
- .ci-b .pp-msg:first-child { margin-top: 8px; }
- /* ═══════════ 视图 Tab(业务视图 / 结果视图 / 技术过程)═══════════ */
- .view-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 28px 0 4px; }
- .view-bar .view-tabs { margin: 0; }
- .fs-enter { font-family: var(--mono); font-size: 12px; letter-spacing: .5px; color: var(--muted); background: var(--panel);
- border: 1px solid var(--border); border-radius: 10px; padding: 9px 16px; cursor: pointer;
- display: inline-flex; align-items: center; gap: 7px; transition: color .15s, border-color .15s; }
- .fs-enter:hover { color: var(--accent); border-color: var(--accent); }
- .fs-enter .vt-ic { font-size: 14px; }
- .view-tabs { display: inline-flex; gap: 3px; background: var(--panel); border: 1px solid var(--border);
- border-radius: 12px; padding: 4px; margin: 28px 0 4px; }
- .view-tab { font-family: var(--mono); font-size: 12px; letter-spacing: .5px; color: var(--muted); background: transparent;
- border: none; border-radius: 9px; padding: 8px 20px; cursor: pointer; display: inline-flex; align-items: center;
- gap: 7px; transition: background .15s, color .15s; }
- .view-tab:hover { color: var(--text); }
- .view-tab.active { background: var(--panel2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border2); }
- .view-tab .vt-ic { font-size: 13px; }
- .view-pane { display: none; }
- .view-pane.active { display: block; }
- /* ═══════════ 业务视图 · 制作轮次与判断账本 ════════════ */
- .biz-story { margin-top: 12px; }
- .biz-lede { display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: start;
- padding: 20px 22px; border: 1px solid var(--border2); border-radius: 16px;
- background: linear-gradient(135deg, rgba(139,157,255,.10), rgba(56,189,248,.04) 42%, var(--panel)); }
- .biz-eyebrow { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--accent);
- text-transform: uppercase; }
- .biz-headline { margin-top: 7px; font-size: 18px; line-height: 1.45; font-weight: 700; color: var(--fg); }
- .biz-conclusion { margin-top: 8px; max-width: 860px; font-size: 12.5px; line-height: 1.75; color: var(--muted); }
- .biz-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
- .biz-chip { padding: 5px 8px; border: 1px solid var(--border); border-radius: 999px;
- background: rgba(10,12,15,.45); font-family: var(--mono); font-size: 10px; color: var(--muted); }
- .biz-failure { margin-top: 10px; padding: 12px 14px; border: 1px solid rgba(248,113,113,.42);
- border-left: 3px solid var(--fail); border-radius: 10px; background: rgba(248,113,113,.08); }
- .biz-failure strong { display: block; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; color: var(--fail); }
- .biz-failure span { display: block; margin-top: 5px; font-size: 12px; line-height: 1.65; color: #f4b4b4; }
- .biz-rounds { display: flex; flex-direction: column; gap: 18px; margin-top: 18px; }
- .biz-round { position: relative; border: 1px solid var(--border); border-radius: 16px; background: var(--panel); overflow: hidden; }
- .biz-round::before { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 3px; background: var(--run); }
- .biz-round.success::before { background: var(--ok); }
- .biz-round.failed::before { background: var(--fail); }
- .biz-round-h { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start;
- padding: 16px 18px; border-bottom: 1px solid var(--border); background: rgba(255,255,255,.015); }
- .biz-round-n { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 50%;
- background: rgba(139,157,255,.14); border: 1px solid rgba(139,157,255,.35);
- font-family: var(--mono); font-size: 12px; font-weight: 800; color: var(--accent); }
- .biz-round-title { font-size: 14px; font-weight: 700; color: var(--fg); }
- .biz-round-summary { margin-top: 4px; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
- .biz-step-list { padding: 4px 18px 18px 31px; }
- .biz-step { position: relative; margin-top: 14px; border: 1px solid var(--border); border-radius: 13px;
- background: var(--bg2); overflow: hidden; }
- .biz-step::before { content: ""; position: absolute; left: -20px; top: 25px; width: 8px; height: 8px;
- border-radius: 50%; background: var(--run); box-shadow: 0 0 0 4px var(--panel); }
- .biz-step.success::before { background: var(--ok); }
- .biz-step.failed::before { background: var(--fail); }
- .biz-step-h { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
- padding: 11px 13px; border-bottom: 1px solid var(--border); }
- .biz-stage { font-family: var(--mono); font-size: 9px; letter-spacing: .08em; color: var(--accent);
- border: 1px solid rgba(139,157,255,.3); border-radius: 5px; padding: 2px 6px; }
- .biz-step-title { font-size: 13px; font-weight: 650; color: var(--text); }
- .biz-status { font-family: var(--mono); font-size: 9px; letter-spacing: .06em; padding: 3px 7px;
- border: 1px solid var(--border2); border-radius: 999px; color: var(--muted); }
- .biz-status.success { color: var(--ok); border-color: rgba(52,211,153,.38); background: rgba(52,211,153,.08); }
- .biz-status.failed { color: var(--fail); border-color: rgba(248,113,113,.38); background: rgba(248,113,113,.08); }
- .biz-status.running { color: var(--run); border-color: rgba(251,191,36,.38); background: rgba(251,191,36,.08); }
- .biz-rail { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
- .biz-cell { min-width: 0; padding: 13px; border-right: 1px solid var(--border); }
- .biz-cell:last-child { border-right: 0; }
- .biz-cell-k { display: flex; align-items: center; gap: 6px; margin-bottom: 7px;
- font-family: var(--mono); font-size: 9px; font-weight: 750; letter-spacing: .09em; color: var(--faint); }
- .biz-cell-k::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--faint); }
- .biz-cell.action .biz-cell-k::before { background: rgb(56,189,248); }
- .biz-cell.basis .biz-cell-k::before { background: rgb(167,139,250); }
- .biz-cell.judge .biz-cell-k::before { background: var(--run); }
- .biz-cell.output .biz-cell-k::before { background: var(--ok); }
- .biz-cell-v { font-size: 11.5px; line-height: 1.65; color: var(--text); word-break: break-word; }
- .biz-reason { margin-top: 7px; padding-top: 7px; border-top: 1px dashed var(--border);
- font-size: 10.5px; line-height: 1.55; color: var(--muted); }
- .biz-reason b { color: var(--faint); font-weight: 600; }
- .biz-verdict { display: inline-flex; margin-bottom: 6px; padding: 2px 6px; border-radius: 5px;
- font-family: var(--mono); font-size: 9px; font-weight: 800; color: var(--run);
- border: 1px solid rgba(251,191,36,.35); }
- .biz-verdict.pass, .biz-verdict.completed { color: var(--ok); border-color: rgba(52,211,153,.38); }
- .biz-verdict.fail, .biz-verdict.failed { color: var(--fail); border-color: rgba(248,113,113,.38); }
- .biz-items { display: flex; flex-direction: column; gap: 7px; }
- .biz-item { padding: 7px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
- .biz-item strong { display: block; font-size: 10.5px; line-height: 1.45; color: var(--text); }
- .biz-item span { display: block; margin-top: 2px; font-size: 10px; line-height: 1.5; color: var(--muted); }
- .biz-item code { display: block; margin-top: 3px; font-family: var(--mono); font-size: 9px; color: var(--faint); word-break: break-all; }
- .biz-thumb { width: 100%; max-height: 118px; object-fit: cover; margin-top: 7px; border-radius: 7px;
- border: 1px solid var(--border); background: var(--bg); cursor: zoom-in; }
- .biz-more { margin-top: 8px; border-top: 1px dashed var(--border); }
- .biz-more summary { padding-top: 8px; cursor: pointer; font-family: var(--mono); font-size: 9.5px; color: var(--accent); }
- .biz-more-body { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; }
- .biz-criterion { padding: 7px; border-left: 2px solid var(--border2); background: rgba(255,255,255,.015); }
- .biz-criterion-h { display: flex; justify-content: space-between; gap: 8px; font-size: 9.5px; color: var(--text); }
- .biz-criterion-p { margin-top: 4px; font-size: 10px; line-height: 1.55; color: var(--muted); }
- @media (max-width: 980px) {
- .biz-rail { grid-template-columns: 1fr 1fr; }
- .biz-cell:nth-child(2) { border-right: 0; }
- .biz-cell:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
- }
- @media (max-width: 760px) {
- .biz-lede { grid-template-columns: 1fr; }
- .biz-meta { justify-content: flex-start; }
- .biz-rail { grid-template-columns: 1fr; }
- .biz-cell { border-right: 0; border-bottom: 1px solid var(--border); }
- .biz-cell:last-child { border-bottom: 0; }
- .biz-round-h { grid-template-columns: auto 1fr; }
- .biz-round-h > .biz-status { grid-column: 2; justify-self: start; }
- }
- /* ═══════════ 过程视图 · 画板(可平移/缩放的白板;节点自由摆放 + SVG 连线)═══════════ */
- .board { position: relative; height: 76vh; min-height: 480px; margin-top: 8px; border: 1px solid var(--border);
- border-radius: 16px; overflow: hidden; background: var(--bg2);
- background-image: radial-gradient(rgba(120,132,150,.28) 1.1px, transparent 1.1px); background-size: 24px 24px;
- cursor: grab; touch-action: none; user-select: none; }
- .board.dragging { cursor: grabbing; }
- .board-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
- /* 模块间的引用边画在**块之上**(z 4 > .mod-block 的 2):块很宽且各行居中,两个模块的定义列
- 可能相隔数千 px,边必然斜穿过别的块 —— 压在块下面就等于没画。细虚线,压过去也不抢视觉。 */
- .board-edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; z-index: 4; }
- .board-labels { position: absolute; left: 0; top: 0; z-index: 6; pointer-events: none; }
- /* 悬停展开的那一两条实例级引用线:画在最上层(要的就是"是哪一条"一眼可辨) */
- .board-edges.hot { z-index: 5; }
- /* 悬停连线上的标签:运行层标「输入 / 输出」(与卡面同色系),结构层标「引用 · 本轮 ×N」 */
- .edge-tag { position: absolute; transform: translate(-50%, -50%); padding: 1px 7px; border-radius: 8px;
- font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--fg);
- background: var(--bg); border: 1px solid var(--border2); white-space: nowrap; }
- .edge-tag.in { color: rgb(56,189,248); border-color: rgba(56,189,248,.55); }
- .edge-tag.out { color: rgb(74,222,128); border-color: rgba(74,222,128,.55); }
- .edge-tag.ref { color: var(--muted); }
- .board-ctrls { position: absolute; right: 12px; bottom: 12px; z-index: 6; display: flex; gap: 6px; }
- .board-ctrls button { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border2); background: var(--panel);
- color: var(--muted); font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
- .board-ctrls button:hover { color: var(--accent); border-color: var(--accent); }
- .board-tip { position: absolute; left: 12px; bottom: 12px; z-index: 6; font-family: var(--mono); font-size: 10px; color: var(--faint);
- background: rgba(10,12,15,.6); padding: 3px 9px; border-radius: 7px; pointer-events: none; }
- /* 全屏 / 沉浸式:把过程视图(步进器 + 画板)提到 inset:0 覆盖层,画板吃满剩余高度 */
- #snapView.fs { position: fixed; inset: 0; z-index: 300; width: auto !important;
- background: var(--bg); padding: 14px 16px 0; display: flex; flex-direction: column; }
- #snapView.fs .snap-stepper { flex: 0 0 auto; padding: 4px 0 12px; margin: 0; }
- #snapView.fs .board { flex: 1 1 auto; height: auto !important; min-height: 0; margin-top: 0; }
- /* 评估 */
- .gn-sub { font-family: var(--mono); font-size: 10px; color: var(--faint); }
- /* 给槽位连线留出走线通道 */
- .io-col { display: flex; flex-direction: column; gap: 10px; flex: 0 0 auto; }
- /* 输入/输出卡加宽:保证一行放得下 3 张缩略图(3×52 + 2×5 = 166px + 内边距)而不换行,
- 卡片变宽也让整列更矮、更易读 */
- .io-col .pcard { width: 232px; }
- .io-col .pcard .pc-thumbs { flex-wrap: nowrap; }
- .io-col .pcard.pc-empty { border-style: dashed; opacity: .5; }
- /* 字段:标签在上、值在下(左右排会把值挤成窄条,读起来费劲) */
- .kv-list { display: flex; flex-direction: column; gap: 8px; margin-top: 9px; }
- .kv { display: flex; flex-direction: column; gap: 3px; }
- .kv-k { align-self: flex-start; font-family: var(--mono); font-size: 8.5px; color: rgb(var(--tone));
- border: 1px solid rgba(var(--tone), .4); border-radius: 4px; padding: 0 5px; }
- .kv-v { font-size: 11px; line-height: 1.55; color: var(--text); word-break: break-word;
- display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
- /* plan 的 ops:一条一块,说明 + 涉及的任务卡(操作标识已融进卡本身,见 .mm-card.mm-op;子任务缩进) */
- .ops-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
- .op-h { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; margin-bottom: 6px; }
- .op-detail { font-size: 10px; line-height: 1.5; color: var(--muted); }
- .ops-h { font-family: var(--mono); font-size: 9px; letter-spacing: .08em; color: var(--muted);
- margin-top: 12px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
- .op-tasks { display: flex; flex-direction: column; gap: 6px; }
- /* 操作态任务卡:与任务台账**同一种卡**,只是徽标位换成「本轮对它做了什么」、左侧色条按操作着色 */
- .mm-card.mm-op { border-left: 3px solid var(--border2); }
- .mm-card.mm-op.op-add { border-left-color: var(--ok); }
- .mm-card.mm-op.op-update { border-left-color: var(--run); }
- .mm-card.mm-op.op-requeue { border-left-color: var(--kind); }
- .mm-card.mm-op.op-drop { border-left-color: var(--fail); }
- .mm-card.mm-op.op-reorder, .mm-card.mm-op.op-next { border-left-color: var(--accent); }
- .mmc-badge.op-add { color: var(--ok); background: rgba(52,211,153,.11); border-color: rgba(52,211,153,.34); }
- .mmc-badge.op-update { color: var(--run); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.36); }
- .mmc-badge.op-requeue { color: var(--kind); background: rgba(245,181,68,.12); border-color: rgba(245,181,68,.36); }
- .mmc-badge.op-drop { color: var(--fail); background: rgba(248,113,113,.11); border-color: rgba(248,113,113,.34); }
- .mmc-badge.op-reorder, .mmc-badge.op-next { color: var(--accent); background: rgba(139,157,255,.12);
- border-color: rgba(139,157,255,.35); }
- .op-tasks .mm-card { width: auto; }
- .mm-card.mm-modified { box-shadow: 0 0 0 1.5px var(--run); }
- .mmc-chgs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
- .chg-f { font-family: var(--mono); font-size: 9px; color: var(--run); background: rgba(251,191,36,.12);
- border: 1px solid rgba(251,191,36,.35); border-radius: 5px; padding: 0 6px; }
- .op-child { padding-left: 16px; }
- /* 输出的交付件网格:图与「它是什么」成对,主件高亮 */
- .out-arts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
- .out-art { width: 64px; }
- .out-art img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; display: block;
- border: 1px solid var(--border); background: var(--bg); cursor: zoom-in; }
- .out-art .oa-tag { font-family: var(--mono); font-size: 8.5px; color: var(--muted); margin-top: 4px;
- text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .out-art.is-primary img { border-color: rgb(var(--tone)); box-shadow: 0 0 0 1px rgba(var(--tone), .55); }
- .out-art.is-primary .oa-tag { color: rgb(var(--tone)); font-weight: 700; }
- .out-art.no-img { width: auto; max-width: 100%; }
- .out-art.no-img .oa-text { font-size: 10px; line-height: 1.5; color: var(--muted); border: 1px dashed var(--border2);
- border-radius: 8px; padding: 6px 8px; }
- /* 输出卡的摘要压到 3 行,别喧宾夺主 */
- .pcard.pc-final .pcard-sum { -webkit-line-clamp: 3; }
- .io-col .pcard.pc-final { border-width: 1.5px; border-color: rgb(var(--tone));
- background: rgba(var(--tone), .12); box-shadow: 0 0 0 1px rgba(var(--tone), .22); }
- /* 任务型输入槽:卡内嵌一串**任务卡**(与任务列表同款) */
- .pcard.pc-taskslot { width: 258px; cursor: default; }
- .pcard.pc-taskslot:hover { transform: none; border-color: var(--border2); }
- .task-strip { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
- .mm-card.mm-mini { width: auto; padding: 8px 10px 9px; }
- .mm-card.mm-mini .mmc-name { font-size: 11.5px; -webkit-line-clamp: 1; }
- .mm-card.mm-mini .mmc-goal { font-size: 10px; -webkit-line-clamp: 2; }
- .mm-card.st-planned { border-left: 3px solid var(--accent); }
- .mmc-badge.planned { color: var(--accent); background: rgba(139,157,255,.12); border-color: rgba(139,157,255,.35); }
- /* 「下一轮」节点:本轮产出回吐的落点,点击切到下一轮(那条回边落在下一轮,不属于本轮的模块图) */
- .next-round { position: absolute; z-index: 2; cursor: pointer; padding: 10px 16px; border-radius: 12px;
- border: 1px dashed rgba(139,157,255,.55); background: rgba(139,157,255,.08);
- transition: border-color .15s, background .15s; }
- .next-round:hover { border-color: var(--accent); background: rgba(139,157,255,.16); }
- .nr-cap { font-family: var(--mono); font-size: 10px; color: var(--faint); letter-spacing: .4px; }
- .nr-t { font-size: 12.5px; font-weight: 700; color: var(--accent); margin-top: 3px; }
- .slot-row .slot-empty { margin-left: auto; font-size: 9px; color: var(--faint); }
- /* 工序定义:一段一块(与运行流程里的子节点逐段对应) */
- .stage-row { border: 1px solid rgba(var(--tone), .35); background: rgba(var(--tone), .08); border-radius: 8px;
- padding: 6px 9px 7px; }
- .stage-row .stage-h { display: flex; align-items: center; gap: 6px; }
- .stage-row .stage-n { font-family: var(--mono); font-size: 9px; font-weight: 700; color: #0b0e13;
- background: rgb(var(--tone)); border-radius: 4px; padding: 0 5px; }
- .stage-row .nm { font-family: var(--mono); font-size: 10.5px; font-weight: 700; color: var(--text); }
- .stage-row .stage-io { font-size: 9.5px; line-height: 1.5; color: var(--muted); margin-top: 3px; }
- .stage-row .stage-io .io-k { font-family: var(--mono); font-size: 8.5px; color: rgb(var(--tone));
- border: 1px solid rgba(var(--tone), .45); border-radius: 4px;
- padding: 0 4px; margin-right: 5px; }
- .stage-row .stage-meta { font-family: var(--mono); font-size: 9px; color: var(--faint); margin-top: 3px;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .stage-row .stage-rules { margin-top: 5px; border-top: 1px dashed rgba(var(--tone), .3); padding-top: 5px; }
- .stage-row .rule { position: relative; padding-left: 11px; font-size: 9.5px; line-height: 1.55; color: var(--text); }
- .stage-row .rule::before { content: "▪"; position: absolute; left: 2px; color: rgb(var(--tone)); }
- .stage-arrow { text-align: center; font-size: 11px; color: rgba(var(--tone), .8); line-height: 1; margin: 1px 0; }
- /* 结构段:叶模块=一列;组合模块=左「本模块自身结构」+ 右「子模块结构链」两列并排。
- 宽度不写死——由内容在 min/max 之间自适应。 */
- .gs-own { flex: 0 0 auto; min-width: 0; display: flex; flex-direction: column; gap: 9px;
- min-width: 200px; max-width: 250px; }
- /* 槽位表:竖排一行一个槽(不再挤成 chip 云)——左侧输入盒逐行连线过来,线不打结 */
- .slot-list { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
- .slot-row { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 9.5px;
- color: var(--text); background: rgba(var(--tone), .10); border: 1px solid rgba(var(--tone), .30);
- border-radius: 6px; padding: 3px 8px; }
- .slot-row.off { color: var(--faint); border-style: dashed; background: transparent; opacity: .65; }
- .slot-row .dot { width: 5px; height: 5px; border-radius: 50%; background: rgb(var(--tone)); flex: 0 0 auto; }
- .slot-row.off .dot { background: var(--faint); }
- .slot-row .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- /* 结构里的「输入」槽位 ↔ 运行实例里的输入块:hover 任一侧,同键的两侧一起点亮(多个实例则各自那张都亮)。 */
- .slot-row.slot-hot { background: hsl(var(--mc) 60% 50% / .45); border-color: hsl(var(--mc) 70% 65%);
- color: var(--text); opacity: 1; }
- .slot-row.slot-hot .dot { background: hsl(var(--mc) 70% 68%); }
- .pcard.slot-hot { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 2px;
- background: hsl(var(--mc) 60% 50% / .26); }
- /* 输入/输出/结构卡上的构成 chips(如「输入由哪些块拼成」) */
- .pc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
- .pc-chip { font-family: var(--mono); font-size: 9px; color: var(--muted); background: var(--bg2);
- border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
- /* 槽位 chip:左侧输入盒连线的落点 */
- .pc-chip.slot-chip { color: var(--text); border-color: rgba(139,157,255,.42); background: rgba(139,157,255,.07); }
- .pc-note { font-family: var(--mono); font-size: 10px; color: var(--faint); margin-top: 7px; }
- /* 过程条:横向列序列,列高按内容自然撑开(不强制等高),列间留白供连线穿过 */
- /* 运行链**一行到底、不折行**:一条链就是一条链,折行会把「下一步」画到左边去,读起来是断的。
- 链再长也横着排(画板本就可平移缩放),步与步之间的留白留给 drawLaneEdges 画曲线箭头。 */
- /* 各步**竖向居中**对齐:链的轴线因此是一条直线,相邻两步的连线基本是平的;顶对齐时一个很高的
- 输入节点会把它的中心拉到别人下方好几百 px,连线就成了一条从画面外扫上来的大弧。 */
- .lane-steps { position: relative; display: flex; flex-wrap: nowrap; align-items: center;
- gap: 52px; }
- /* 链内连线画在泳道自己的 SVG 上:泳道背景之上、卡片之下(.lane-col 有 z-index:1),线因此是清晰的 */
- .lane-wires { position: absolute; left: 0; top: 0; z-index: 0; overflow: visible; pointer-events: none; }
- /* 冗余画了输入列的流程:各列竖向居中,运行卡对齐到输入整体的中心(见 leafLaneHtml) */
- .lane-empty { font-family: var(--mono); font-size: 11.5px; color: var(--faint); padding: 16px; }
- .lane-col { position: relative; z-index: 1; display: flex; flex-direction: column; }
- /* 并发批次列:同一 LLM 轮请求的多工具竖向并列(可见并发);分叉/汇聚由 lane-wires 连线体现 */
- .lane-col.conc { gap: 6px; }
- /* 非 Agent 模块在流程里冗余画的「输入」列:虚线描边 + 半透明,一眼看出它不是一步「跑动」,
- 而是喂进右边那一步的东西(连线由 lane-wires 照常画)。 */
- .board-none { margin: 48px auto; max-width: 520px; text-align: center;
- font-size: 12px; color: var(--faint); }
- .pc-conc-tag { font-family: var(--mono); font-size: 9.5px; font-weight: 700; letter-spacing: .6px; color: var(--accent); }
- .pc-parallel-stack { display: flex; flex-direction: column; gap: 14px; }
- .pcard { flex: 0 0 auto; width: 210px; border: 1px solid var(--border2); border-radius: 12px; background: var(--panel);
- padding: 10px 12px 11px; cursor: pointer; display: flex; flex-direction: column;
- transition: transform .12s, box-shadow .15s, border-color .15s; }
- .pcard:hover { transform: translateY(-2px); box-shadow: 0 12px 26px -14px rgba(0,0,0,.7); border-color: var(--accent); }
- .pc-plan { border-left: 3px solid var(--accent); } .pc-exec { border-left: 3px solid var(--ok); }
- .pc-eval { border-left: 3px solid var(--kind); border-style: dashed; }
- /* 失败节点(如生成报错):泳道里以红框 + 「✗ 失败」标记 */
- .pcard.pc-failed { border-color: var(--fail); border-left-color: var(--fail); background: linear-gradient(180deg, rgba(248,113,113,.07), var(--panel)); }
- .pcard.pc-failed .pc-label { color: var(--fail); }
- .pc-fail-badge { margin-left: auto; font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--fail);
- background: rgba(248,113,113,.14); border: 1px solid rgba(248,113,113,.45); border-radius: 5px; padding: 1px 5px; flex: 0 0 auto; }
- /* ── 旁路标注(perl_annotation):run 跑完后重放生成的「这步做了什么 + 依据来源」 ──
- **必须与运行时事实视觉分开**:卡上那些 args/result 是当时真实发生的,标注是另一个模型事后写的
- 解释;混在一片区域里,用的人迟早会把后者当前者。故这里独立底色 + 🔖 抬头。 */
- .pc-ann { margin: 7px -12px 0; padding: 7px 12px 8px; background: rgba(167,139,250,.09);
- border-top: 1px solid rgba(167,139,250,.22); border-bottom: 1px solid rgba(167,139,250,.16); }
- /* 摘要是这张卡上**最该被一眼读到**的东西("这步做了什么"),所以加粗提亮、字号压过其余行;
- 原来那条「🔖 标注 · 这步做了什么」抬头去掉了——紫色区块本身已经把"这是标注"说清楚了,
- 再加一行标签只是把视觉重心从内容拉到了说明上。 */
- .pc-ann-sum { font-size: 12.5px; line-height: 1.62; font-weight: 700; color: #ddd2ff;
- letter-spacing: .2px; }
- /* ── 工具卡的输入 / 输出:各成一段,图跟着自己那一段走 ──
- 以前是两行裸文本 + 图统一堆在卡底 —— 那样既看不出字段结构,也分不清哪张图是喂进去的、
- 哪张是产出的。现在一段一个小分区:入向左色条、出向右色条,缩略图就在各自分区里。 */
- .pc-io-sec { margin: 7px -12px 0; padding: 6px 12px 7px; border-top: 1px solid var(--border); }
- .pc-io-sec.in { border-left: 2px solid rgba(56,189,248,.55); }
- .pc-io-sec.out { border-left: 2px solid rgba(74,222,128,.55); }
- /* function calling:模型这一步**发出的调用**。与「输入/输出」区分开——它既不是喂给模型的输入,
- 也不是产物,而是模型的决策动作,下一步那个工具节点跑的就是它。
- **一次调用一块**:序号 + 工具名 + 该次入参;一轮发起多个就叠成多块(它们本就是一起发出的)。 */
- .pc-calls { margin: 8px -12px 0; border-top: 1px solid var(--border); }
- .pc-calls-h { font-family: var(--mono); font-size: 8.5px; font-weight: 700; letter-spacing: .6px;
- color: rgb(183,160,255); padding: 6px 12px 0; }
- .pc-call { margin-top: 5px; padding: 5px 12px 7px;
- border-left: 2px solid rgba(167,139,250,.6); background: rgba(167,139,250,.06); }
- .pcl-h { display: flex; align-items: center; gap: 6px; }
- .pcl-i { flex: 0 0 auto; width: 14px; height: 14px; border-radius: 50%; display: grid; place-items: center;
- font-family: var(--mono); font-size: 9px; color: var(--bg); background: rgba(167,139,250,.9); }
- .pcl-n { font-family: var(--mono); font-size: 11px; font-weight: 700; color: #c9b8ff; word-break: break-all; }
- .pcl-args { margin-top: 4px; }
- .pcl-args .pc-io-lead { font-size: 10.5px; -webkit-line-clamp: 2; }
- .pcl-args .io-chip { border-color: rgba(167,139,250,.35); }
- .pcl-thumbs { margin-top: 5px; gap: 4px; }
- .pcl-thumbs img { width: 40px; height: 40px; }
- .pcl-none { font-family: var(--mono); font-size: 9px; color: var(--faint); }
- .pc-io-h { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 8.5px;
- font-weight: 700; letter-spacing: .6px; margin-bottom: 5px; }
- .pc-io-h .io-ok { color: var(--ok); }
- .pc-io-h .io-bad { color: var(--fail); }
- /* 主文:这一侧最该被读到的那句话(query / prompt / thought) */
- .pc-io-lead { font-size: 11.5px; line-height: 1.6; color: var(--text); word-break: break-word;
- overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
- /* 标签:一批条目(工具名、字段名)——一枚枚,而不是一坨 JSON */
- .pc-io-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
- .pc-io-chips .io-chip { font-family: var(--mono); font-size: 9px; line-height: 1.5; color: var(--text);
- background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
- padding: 1px 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
- white-space: nowrap; }
- .pc-io-sec.in .io-chip { border-color: rgba(56,189,248,.35); }
- .pc-io-sec.out .io-chip { border-color: rgba(74,222,128,.35); }
- .pc-io-sec.in .pc-io-h { color: rgba(56,189,248,.9); }
- .pc-io-sec.out .pc-io-h { color: rgba(74,222,128,.9); }
- .pc-io-kv { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; }
- .pc-io-kv .io-row { display: flex; gap: 6px; font-size: 10.5px; line-height: 1.5; }
- .pc-io-kv .io-k { flex: 0 0 auto; max-width: 74px; font-family: var(--mono); font-size: 9px; font-weight: 700;
- color: var(--faint); padding-top: 1px; overflow: hidden; text-overflow: ellipsis;
- white-space: nowrap; }
- .pc-io-kv .io-v { color: var(--muted); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2;
- -webkit-box-orient: vertical; word-break: break-all; }
- .pc-io-text { font-size: 10.5px; line-height: 1.55; color: var(--muted); overflow: hidden;
- display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; word-break: break-word; }
- .io-thumbs { margin-top: 6px; }
- .io-thumbs img { width: 46px; height: 46px; }
- /* 底部「决策依据」入口 */
- .pc-basis { margin: 8px -12px -11px; padding: 7px 12px; display: flex; align-items: center; gap: 6px;
- font-family: var(--mono); font-size: 9.5px; font-weight: 700; color: rgb(167,139,250);
- background: rgba(167,139,250,.10); border-top: 1px dashed rgba(167,139,250,.35);
- border-radius: 0 0 11px 11px; }
- .pc-basis { flex-wrap: wrap; }
- .pc-basis:hover { background: rgba(167,139,250,.2); }
- .pc-basis .bs-h { flex: 0 0 auto; }
- .pc-basis .bs-n { margin-left: auto; opacity: .8; }
- /* 一类来源一枚标签:图标 + 类型名 + 条数 —— 一眼看出这步是被 prompt 训导驱动的还是被输入驱动的 */
- .pc-basis .bs-tag { display: inline-flex; align-items: center; gap: 3px; font-size: 9px; font-weight: 600;
- color: var(--text); background: rgba(167,139,250,.16); border: 1px solid rgba(167,139,250,.3);
- border-radius: 5px; padding: 1px 5px; }
- .pc-basis .bs-tag b { font-family: var(--mono); color: rgb(167,139,250); }
- .pc-basis .bs-ic { opacity: .85; }
- /* 文档块顶部的引用跳转条:一处引用一枚,点了滚到该处并闪一下 */
- .src-jump { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 11px; border-bottom: 1px dashed var(--border2);
- background: rgba(255,255,255,.02); }
- .src-jump .sj { font-size: 10.5px; line-height: 1.5; color: var(--muted); background: var(--panel);
- border: 1px solid var(--border2); border-radius: 6px; padding: 2px 7px; cursor: pointer; }
- .src-jump .sj:hover { color: var(--text); border-color: rgb(167,139,250); }
- .src-jump .sj.off { opacity: .5; cursor: default; }
- .src-hl.flash { animation: hlflash .9s ease; }
- @keyframes hlflash { 0%,100% { background: rgba(245,181,68,.28); } 40% { background: rgba(245,181,68,.85); } }
- /* ── 左侧「依据来源」栏:点依据 → 依次列出各来源全文,并在其中高亮被引片段 ── */
- .srcbar { position: fixed; top: 0; left: 0; bottom: 0; width: 520px; max-width: 94vw; z-index: 342;
- background: linear-gradient(180deg, var(--panel), var(--bg2)); border-right: 1px solid var(--border2);
- box-shadow: 20px 0 50px -20px rgba(0,0,0,.7); transform: translateX(-102%);
- transition: transform .24s cubic-bezier(.4,0,.2,1); display: flex; flex-direction: column; }
- .srcbar.open { transform: none; }
- .srcbar-h { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
- .srcbar-tag { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 1px;
- padding: 3px 10px; border-radius: 7px; color: rgb(167,139,250); background: rgba(167,139,250,.14); }
- .srcbar-t { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden;
- text-overflow: ellipsis; white-space: nowrap; }
- .srcbar-x { margin-left: auto; width: 28px; height: 28px; border-radius: 8px; cursor: pointer; flex: 0 0 auto;
- display: flex; align-items: center; justify-content: center; color: var(--muted); background: var(--panel2); }
- .srcbar-x:hover { color: var(--text); }
- .srcbar-b { flex: 1; overflow: auto; padding: 14px 16px 40px; }
- .srcbar-sum { font-size: 12px; line-height: 1.7; color: var(--muted); background: rgba(167,139,250,.08);
- border: 1px solid rgba(167,139,250,.2); border-radius: 10px; padding: 9px 11px; margin-bottom: 14px; }
- .src-item { border: 1px solid var(--border2); border-radius: 11px; margin-bottom: 12px; overflow: hidden; }
- .src-h { display: flex; align-items: center; gap: 7px; padding: 8px 11px; background: var(--panel2);
- border-bottom: 1px solid var(--border); flex-wrap: wrap; }
- .src-n { font-family: var(--mono); font-size: 9px; font-weight: 700; color: #0b0e13; background: rgb(167,139,250);
- border-radius: 5px; padding: 1px 6px; flex: 0 0 auto; }
- .src-kind { font-family: var(--mono); font-size: 9px; color: var(--faint); border: 1px solid var(--border2);
- border-radius: 5px; padding: 1px 6px; }
- .src-title { font-size: 11.5px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden;
- text-overflow: ellipsis; white-space: nowrap; }
- .src-loc { margin-left: auto; font-family: var(--mono); font-size: 9px; flex: 0 0 auto; }
- .src-loc.ok { color: var(--ok); } .src-loc.no { color: var(--warn, #f5b544); }
- .src-body { padding: 10px 12px; font-size: 11.5px; line-height: 1.8; color: var(--muted);
- white-space: pre-wrap; word-break: break-word; max-height: 340px; overflow: auto; }
- /* markdown 渲染态:**直接复用 .pp-md**(右侧详情抽屉用的就是它)——同一种东西只该有一种画法,
- 另起一套字号/间距只会让同一份 prompt 在两个面板里长得不一样。这里只解掉 pre-wrap。 */
- .src-body.md { white-space: normal; padding: 0; }
- /* `.pp-md` 自带 `max-height:340px; overflow:auto`(它原本是抽屉里的小块富文本)。套进来源栏后
- 就成了**内层滚动条**:外层 .src-body 撑到 635px 却只显示 340px 的内容 → 下方一片空白,而
- jumpHl 滚的是外层(它根本没有可滚区)→ 点跳转纹丝不动。这里把滚动权交还给 .src-body。 */
- .src-body .pp-md { max-height: none; overflow: visible; }
- /* 只有一块来源 → 铺满剩余高度(一份 4600 字的 prompt 挤在 340px 里根本没法读) */
- .srcbar-b.single { display: flex; flex-direction: column; }
- .srcbar-b.single .src-item { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
- .srcbar-b.single .src-body { flex: 1; max-height: none; }
- .src-body img { max-width: 100%; border-radius: 8px; }
- .src-hl { background: rgba(245,181,68,.28); color: var(--text); border-bottom: 2px solid rgba(245,181,68,.9);
- border-radius: 3px; padding: 1px 0; }
- .src-quote { padding: 8px 12px; font-size: 11px; line-height: 1.7; color: var(--muted);
- border-top: 1px dashed var(--border2); background: rgba(255,255,255,.02); }
- /* 工具清单:用过的高亮(本次真的动用过这项能力),没用过的压暗 */
- .tool-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }
- .tool-chip { display: inline-flex; align-items: center; gap: 4px; font-family: var(--mono); font-size: 9px;
- line-height: 1.6; color: var(--faint); background: var(--panel2);
- border: 1px solid var(--border2); border-radius: 5px; padding: 1px 6px; opacity: .62; }
- .tool-chip.used { color: var(--ok); border-color: rgba(74,222,128,.55);
- background: rgba(74,222,128,.12); opacity: 1; font-weight: 700; }
- .tool-chip .tc-dot { width: 4px; height: 4px; border-radius: 50%; background: var(--ok); flex: 0 0 auto; }
- /* 详情里的工具项:默认折叠,只露一行标题;点开才显示说明与参数 */
- .tool-item .tool-hd { display: flex; align-items: center; gap: 7px; cursor: pointer; flex-wrap: wrap; }
- .tool-item .tool-hd:hover { color: var(--accent); }
- .tool-item .tool-arrow { display: inline-block; font-size: 10px; color: var(--faint);
- transition: transform .15s; }
- .tool-item.open .tool-arrow { transform: rotate(90deg); color: var(--accent); }
- .tool-item .tool-meta { margin-left: auto; font-family: var(--mono); font-size: 9.5px; color: var(--faint);
- font-weight: 400; }
- .tool-item .tool-bd { display: none; margin-top: 9px; }
- .tool-item.open .tool-bd { display: block; }
- .tool-used-tag { margin-left: 8px; font-family: var(--mono); font-size: 9px; font-weight: 700; color: var(--ok);
- background: rgba(74,222,128,.14); border: 1px solid rgba(74,222,128,.45);
- border-radius: 5px; padding: 1px 6px; }
- /* 工具参数表:一行一个参数(名 / 类型 / 必填 / 说明) */
- .tool-params { margin-top: 9px; border-top: 1px dashed var(--border2); padding-top: 8px;
- display: flex; flex-direction: column; gap: 5px; }
- .tp-row { display: flex; align-items: baseline; gap: 8px; font-size: 11.5px; line-height: 1.6; flex-wrap: wrap; }
- .tp-n { font-family: var(--mono); font-weight: 700; color: var(--text); }
- .tp-t { font-family: var(--mono); font-size: 10px; color: var(--accent); }
- .tp-r { font-family: var(--mono); font-size: 9px; color: var(--faint); border: 1px solid var(--border2);
- border-radius: 4px; padding: 0 4px; }
- .tp-r.req { color: var(--warn, #f5b544); border-color: rgba(245,181,68,.5); }
- .tp-d { color: var(--muted); flex: 1 1 240px; min-width: 0; }
- .id-chip { font-family: var(--mono); font-size: 8.5px; font-weight: 600; color: var(--faint);
- background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
- padding: 1px 5px; cursor: pointer; user-select: all; flex: 0 0 auto; }
- .id-chip:hover { color: var(--text); border-color: var(--accent); }
- .id-chip.copied { color: var(--ok); border-color: var(--ok); }
- /* 段标题里的 id:段标题是深色徽标底,这里配一档亮色 */
- .pc-id { align-self: flex-start; margin: -2px 0 5px; }
- .pcard-h { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
- .pcard-h .pc-ic { font-size: 12px; flex: 0 0 auto; }
- .pc-label { font-family: var(--mono); font-size: 10.5px; font-weight: 700; color: var(--text); overflow: hidden;
- text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
- .pc-tok { margin-left: auto; font-family: var(--mono); font-size: 9px; color: var(--faint); flex: 0 0 auto; }
- /* 思考(extended thinking / reasoning):与正文分开的一段 —— 「怎么想的」和「说了什么」不是一回事。
- 斜体 + 淡紫左条,视觉上比正文轻一档;卡面压到 8 行,全文在详情里。 */
- .pc-think { margin: 8px -12px 0; padding: 6px 12px 7px; border-top: 1px solid var(--border);
- border-left: 2px solid rgba(167,139,250,.45); background: rgba(167,139,250,.05); }
- .pc-think-h { font-family: var(--mono); font-size: 8.5px; font-weight: 700; letter-spacing: .6px;
- color: rgba(183,160,255,.9); margin-bottom: 4px; }
- .pc-think-b { font-size: 11px; line-height: 1.6; color: var(--muted); font-style: italic;
- word-break: break-word; overflow: hidden; display: -webkit-box;
- -webkit-line-clamp: 8; -webkit-box-orient: vertical; }
- /* 模型推理节点:与工具节点**一眼可分** —— 紫色左条 + 略宽,正文(模型在想什么)多给几行。
- 它是链上做决策的那一步,后面那些工具节点跑的都是它这一步发出的 function calling。 */
- .pcard.pc-llm { width: 252px; border-left: 3px solid rgba(167,139,250,.85);
- background: linear-gradient(180deg, rgba(167,139,250,.07), var(--panel)); }
- .pcard.pc-llm .pcard-sum { -webkit-line-clamp: 8; }
- .pcard-sum { font-size: 11.5px; line-height: 1.55; color: var(--muted); flex: 1;
- display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
- .pc-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
- .pc-thumbs img { width: 52px; height: 52px; object-fit: cover; border-radius: 7px; border: 1px solid var(--border); background: var(--bg); cursor: zoom-in; }
- .pc-more { margin-top: 8px; font-family: var(--mono); font-size: 9.5px; color: var(--faint); }
- .pcard:hover .pc-more { color: var(--accent); }
- .mm-card.mm-round-exec { box-shadow: 0 0 0 2px var(--ok), 0 0 22px -6px rgba(52,211,153,.5); }
- /* **不设 max-width**:链里可能装着组合模块的格子(它自身又是两列),封顶就撑不开、
- 内容会溢出到相邻段上。宽度由内容决定,只留下限防过窄。 */
- .sub-struct { flex: 0 0 auto; min-width: max-content; border: 1px solid rgba(var(--tone), .42); background: rgba(var(--tone), .09);
- border-radius: 10px; padding: 7px 9px 8px; }
- .sub-struct.off { border-style: dashed; opacity: .6; background: transparent; }
- /* 结构格内部与最外层**同一规则**:叶=一列;组合=左「本模块自身结构」+ 右「子模块结构链」两列。
- 这条规则逐层适用——嵌多深都是这个形状。 */
- .sub-struct .gs-own { min-width: 196px; max-width: 260px; }
- .gs-own > .pcard { width: auto; background: rgba(14,17,22,.78); border-color: rgba(var(--tone), .3); }
- /* ① 输入段 / ③ 运行流程段 / ④ 输出段:一个子模块一个框,三列逐框对齐(等高,见 syncCompositeRows) */
- .sub-in { align-self: stretch; min-width: max-content; }
- .sub-in {
- --tone: 56,189,248; border: 1px solid rgba(var(--tone), .40);
- background: rgba(var(--tone), .06); border-radius: 12px; padding: 8px 10px 10px; }
- /* 同一行的四段(输入框 / 结构格 / 运行框 / 输出框)用**同一个** --tone —— 底色一致即为同一个子模块,
- 横着扫一眼就能对上,不必去读标题。tone 由 modTone(该行的第一路) 统一决定,四处同源。 */
- .sub-in.off { border-style: dashed; opacity: .55; background: transparent; }
- /* 模块块按**调用树横向生长**摆位(x = 上游右缘 + 间距、y = 上游顶边,兄弟向下堆),
- 具体坐标由 placeBlocks() 量完实际尺寸后写进 style.left/top —— CSS 只管块自己长什么样。 */
- #boardWorld { position: relative; width: max-content; }
- .mod-block { position: absolute; z-index: 2; display: flex; align-items: stretch; width: max-content;
- border: 1px solid hsl(var(--mc) 60% 55% / .45); border-radius: 14px;
- background: hsl(var(--mc) 60% 50% / .05); }
- /* 这一列的底色是模块色 .40(比页面底色亮不少)——faint / muted 两个次要文字色本是为近乎透明的
- 底色调的深灰,放这儿会糊掉。**在本列内重定义这两个变量**(自定义属性向下继承):所有用到它们的
- 子元素(标题 / 版本 / 摘要 / 流程图标签 / 源码路径…)一次性提亮,不必逐个枚举选择器。 */
- .mod-def { flex: 0 0 268px; display: flex; flex-direction: column; gap: 6px; padding: 14px;
- --faint: #a6b1c0; --muted: #ccd4e0;
- border-right: 1px solid hsl(var(--mc) 60% 55% / .35);
- background: hsl(var(--mc) 60% 50% / .40); border-radius: 14px 0 0 14px; }
- .md-cap { display: flex; align-items: center; gap: 7px;
- font-family: var(--mono); font-size: 10.5px; color: var(--faint); letter-spacing: .4px; }
- /* 调用树上的深度:L0 是本轮入口,往右一层加一 */
- .md-depth { font-family: var(--mono); font-size: 9.5px; font-weight: 700; color: hsl(var(--mc) 70% 72%);
- border: 1px solid hsl(var(--mc) 60% 55% / .5); border-radius: 5px; padding: 0 5px; }
- .md-key { font-family: var(--mono); font-size: 13px; font-weight: 700;
- color: hsl(var(--mc) 70% 72%); word-break: break-all; }
- .md-meta { display: flex; gap: 6px; flex-wrap: wrap; font-family: var(--mono); font-size: 10.5px; }
- .md-kind { padding: 1px 7px; border-radius: 6px; border: 1px solid hsl(var(--mc) 60% 55% / .5);
- color: hsl(var(--mc) 70% 72%); }
- .md-ver { color: var(--faint); }
- .md-sum { font-size: 11.5px; color: var(--muted); line-height: 1.6; }
- .md-cards { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
- .mod-runs { flex: 1; min-width: max-content; display: flex; flex-direction: column; gap: 12px; padding: 12px 14px; }
- .mr-cap { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
- /* 一个运行实例 = 一条泳道。同一个模块的 N 次运行就是 N 条,纵向排开。 */
- .inst-lane { border: 1px dashed hsl(var(--mc) 60% 55% / .45); border-radius: 10px;
- padding: 10px 12px; background: hsl(var(--mc) 60% 50% / .05); width: max-content; max-width: 100%; }
- .inst-lane.off { border-style: dotted; opacity: .55; }
- .inst-lane.focus { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 2px; }
- /* 悬停展开引用时:被指向的那条泳道 / 那个 call 芯片一起点亮 —— 线与两端同时可辨 */
- .inst-lane.hot { outline: 2px dashed hsl(var(--mc) 70% 62%); outline-offset: 2px;
- background: hsl(var(--mc) 60% 50% / .32); }
- .call-chip.hot { border-style: solid; box-shadow: 0 0 0 2px hsl(var(--mc) 70% 62% / .55); }
- .il-cap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px;
- font-family: var(--mono); font-size: 11px; color: var(--muted); }
- .il-cap .il-t { color: var(--fg); }
- .il-cap .il-flags { display: flex; align-items: center; gap: 6px; margin-left: auto; }
- .il-from { cursor: pointer; text-decoration: underline dotted; color: var(--faint); }
- .il-from:hover { color: var(--fg); }
- /* 链上的箭头不再是文字「→」,而是 SVG 实线曲线(drawLaneEdges),颜色 = 所在模块的 --mc。 */
- /* 首尾两个节点:输入是圆头、输出是右向箭头形 —— 与中间的运行节点区分开 */
- /* 宽度**跟着里面的卡走**(width:max-content):卡是定宽的(普通 210 / llm 252 / 任务槽 258),
- 写死 max-width:240px 会让任务槽那种更宽的卡直接顶出框外。 */
- .io-node { flex: 0 0 auto; width: max-content; padding: 8px 10px;
- border: 1px solid hsl(var(--mc) 60% 55% / .5); background: hsl(var(--mc) 60% 50% / .08); }
- .io-node.io-in { border-radius: 22px 10px 10px 22px; }
- .io-node.io-out { border-radius: 10px 22px 22px 10px; }
- .ion-cap { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-bottom: 6px; }
- .ion-body { display: flex; flex-direction: column; gap: 6px; }
- /* 引用外部模块:流程里的一步(虚线框以示"这一步在别处跑")。
- 配色用**被调模块自己的** --mc(由 hueStyle 内联覆盖)——这样芯片、结构图里那个引用节点、
- 跨块的引用连线、以及目标模块块本身,四处同色,顺着颜色就能一眼追到底。 */
- .call-chip { min-width: 180px; max-width: 230px; padding: 9px 11px; cursor: pointer;
- border: 1px dashed hsl(var(--mc) 60% 55% / .6); border-radius: 10px;
- background: hsl(var(--mc) 60% 50% / .10);
- font-family: var(--mono); font-size: 11.5px; line-height: 1.6; }
- .call-chip:hover { border-color: hsl(var(--mc) 70% 65%); box-shadow: 0 2px 10px rgba(0,0,0,.25); }
- .call-chip .cc-h { color: var(--faint); font-size: 10.5px; }
- .call-chip .cc-k { font-weight: 600; word-break: break-all; color: hsl(var(--mc) 70% 72%); }
- .call-chip .cc-i { color: var(--faint); }
- /* ── 结构按 kind 分化 ─────────────────────────────────────────────────────
- workflow = 一张流程图(节点只写「引用哪个模块 + 跑几次」,点节点跳到那个模块)
- code = 它的源码本身(散文规则说不清也会漂移,源码不会)
- agent = 原有的结构卡(system prompt / 上下文构成 / 工具 / 模型) */
- .fc { display: flex; flex-direction: column; gap: 0; margin-bottom: 8px; }
- .fc-node { display: flex; gap: 8px; align-items: flex-start; padding: 8px 10px;
- border: 1px solid hsl(var(--mc) 60% 55% / .45); border-radius: 9px;
- background: hsl(var(--mc) 60% 50% / .10); }
- .fc-node.ref { cursor: pointer; background: hsl(var(--mc) 60% 50% / .40); }
- .fc-node.ref:hover { border-color: hsl(var(--mc) 70% 65%); }
- .fc-i { flex: 0 0 auto; width: 17px; height: 17px; border-radius: 50%; font-size: 10px;
- display: grid; place-items: center; color: var(--bg); background: hsl(var(--mc) 60% 62%);
- font-family: var(--mono); }
- .fc-b { min-width: 0; }
- .fc-t { font-size: 12px; color: var(--text); }
- .fc-k { font-family: var(--mono); font-size: 10.5px; color: hsl(var(--mc) 70% 72%); word-break: break-all; }
- .fc-x { display: inline-block; margin-top: 3px; padding: 0 6px; border-radius: 5px;
- font-family: var(--mono); font-size: 9.5px; color: var(--faint); border: 1px solid var(--line); }
- /* 边:竖线 + 基数徽标 + 边标签 —— 1:N / N:1 直接读出扇出与汇聚 */
- .fc-edge { display: flex; align-items: center; gap: 6px; padding: 3px 0 3px 8px; margin-left: 8px;
- border-left: 2px solid hsl(var(--mc) 60% 55% / .45); min-height: 20px; }
- .fc-card { font-family: var(--mono); font-size: 9.5px; color: hsl(var(--mc) 70% 70%);
- border: 1px solid hsl(var(--mc) 60% 55% / .5); border-radius: 5px; padding: 0 5px; }
- .fc-l { font-size: 10.5px; color: var(--faint); }
- .src-card { border: 1px solid var(--line); border-radius: 9px; overflow: hidden; margin-bottom: 8px; }
- .src-h { display: flex; align-items: center; gap: 8px; padding: 6px 9px; background: var(--bg2);
- font-family: var(--mono); font-size: 10.5px; border-bottom: 1px solid var(--line); }
- .src-q { color: hsl(var(--mc) 70% 72%); font-weight: 600; }
- .src-f { color: var(--faint); margin-left: auto; }
- .src-code { margin: 0; padding: 9px 10px; max-height: 340px; overflow: auto;
- font-family: var(--mono); font-size: 10.5px; line-height: 1.65; color: var(--text);
- white-space: pre; background: rgba(0,0,0,.18); }
- #drawerBody .src-code { max-height: none; font-size: 12.5px; line-height: 1.7; } /* 抽屉里看全文:不限高 */
- /* 结构模块里 code 只给一条「查看完整代码」入口(不内联源码 → 结构列宽度与 agent 模块一致)。 */
- .src-more { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer;
- font-family: var(--mono); font-size: 10.5px; color: hsl(var(--mc) 70% 72%);
- background: var(--bg2); border-top: 1px solid var(--line); }
- .src-more:hover { color: var(--text); background: hsl(var(--mc) 60% 50% / .12); }
- .mod-block.focus { outline: 2px solid hsl(var(--mc) 70% 62%); outline-offset: 3px; }
- /* 悬停结构里的引用时:两端的模块块一起点亮(连线 + 两端,三者同时可辨) */
- .mod-block.hot { outline: 2px dashed hsl(var(--mc) 70% 62%); outline-offset: 3px; }
- .fc-node.hot, .fc-node[data-to]:hover { border-color: hsl(var(--mc) 70% 65%);
- background: hsl(var(--mc) 60% 50% / .2); }
- </style>
- </head>
- <body>
- <div class="app">
- <aside class="sidebar">
- <div class="brand">
- <div class="brand-top">
- <span class="logo">Production Build</span>
- <span class="logo-sub">Runs</span>
- </div>
- <div class="brand-desc">以运行事实为主 ·
- <span class="loop-glyph">plan → <b>execute</b> → validate</span></div>
- </div>
- <div class="controls">
- <div class="seg" id="statusSeg">
- <button data-s="" class="active">全部</button>
- <button data-s="success">成功</button>
- <button data-s="running">运行中</button>
- <button data-s="failed">失败</button>
- </div>
- <div class="ctl-row">
- <span class="count" id="runCount">—</span>
- <button class="icon-btn" id="refreshBtn" title="刷新">⟳</button>
- </div>
- </div>
- <div class="runlist" id="runlist"><div class="hint">加载中…</div></div>
- <div class="pager" id="pager"></div>
- </aside>
- <main class="main" id="main">
- <div class="empty" id="emptyState">
- <div class="big">◍ ⇄ ◍</div>
- <div class="lp">选择左侧一个 Run · 查看 <b>规划</b>、<i>执行</i>与验收链路</div>
- </div>
- </main>
- </div>
- <div class="drawer-scrim" id="drawerScrim" onclick="closeDrawer()"></div>
- <aside class="drawer" id="drawer">
- <div class="drawer-h">
- <span class="drawer-tag" id="drawerTag"></span>
- <span class="drawer-title" id="drawerTitle"></span>
- <div class="drawer-x drawer-refresh" onclick="refreshLoop(this)" title="按当前 Loop 拉取最新">⟳</div>
- <div class="drawer-x" onclick="closeDrawer()">✕</div>
- </div>
- <div class="drawer-b" id="drawerBody"></div>
- </aside>
- <!-- 依据来源栏(左侧):点某个工具节点底部的「决策依据」→ 依次列出各来源全文 + 高亮被引片段。
- 旁路数据,没有标注时这个栏永远不出现。 -->
- <aside class="srcbar" id="srcbar">
- <div class="srcbar-h">
- <span class="srcbar-tag">🔖 依据来源</span>
- <span class="srcbar-t" id="srcbarTitle"></span>
- <div class="srcbar-x" onclick="closeSrcBar()">✕</div>
- </div>
- <div class="srcbar-b" id="srcbarBody"></div>
- </aside>
- <div class="lb" id="lb">
- <div class="lb-close" onclick="closeLB()">✕</div>
- <div class="lb-nav prev" onclick="stepLB(-1,event)">‹</div>
- <img id="lbImg" src="" alt="" referrerpolicy="no-referrer">
- <div class="lb-nav next" onclick="stepLB(1,event)">›</div>
- <div class="lb-counter" id="lbCounter"></div>
- </div>
- <script>
- "use strict";
- const $ = (s, r=document) => r.querySelector(s);
- const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
- const isImgUrl = u => typeof u === "string" &&
- (/^https?:\/\//.test(u) || /^\/api\/runs\/\d+\/media\//.test(u)) &&
- (/\.(png|jpe?g|webp|gif|bmp|avif)(\?|$)/i.test(u) || /image|img|oss|cos|xhscdn|sns-|ci\./i.test(u));
- // ── state ──
- let RUNS = [], TOTAL = 0, PAGE = 1, PAGE_SIZE = 20, STATUS = "", CURRENT = null, LOADING = false;
- let LBLIST = [], LBIDX = 0;
- // ── list(翻页加载:首屏 + 滚动到底自动追加下一页)──
- async function loadRuns(reset = true) {
- if (LOADING) return;
- LOADING = true;
- if (reset) { PAGE = 1; RUNS = []; }
- renderFooter();
- const r = await fetch(`/api/runs?status=${encodeURIComponent(STATUS)}&page=${PAGE}&page_size=${PAGE_SIZE}`);
- const d = await r.json();
- TOTAL = d.total;
- RUNS = reset ? d.runs : RUNS.concat(d.runs);
- $("#runCount").textContent = `${TOTAL} 个 Run`;
- LOADING = false; // 先落 loading 再渲染页脚,否则页脚卡在「加载中…」
- renderList(); renderFooter();
- }
- const hasMore = () => RUNS.length < TOTAL;
- function loadMore() {
- if (LOADING || !hasMore()) return;
- PAGE += 1; loadRuns(false);
- }
- // 深链:#run=N[&round=K] 打开任务并定位到第 K 轮(1-based;缺省第一轮)
- let PENDING_ROUND = 1; // selectRun → loadSnapshots 之间传递「要停在第几轮」
- const curRound = () => (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length ? SNAP.idx + 1 : 1);
- function parseHash() {
- const m = /run=(\d+)/.exec(location.hash), r = /round=(\d+)/.exec(location.hash);
- return { run: m ? +m[1] : null, round: r ? Math.max(1, +r[1]) : null };
- }
- // 当前选中的任务 + 轮次写回地址栏(replaceState 不触发 hashchange,不会回灌 openFromHash)
- function syncHash() {
- if (CURRENT == null) return;
- const h = `#run=${CURRENT}` + (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length ? `&round=${SNAP.idx + 1}` : "");
- if (location.hash !== h) history.replaceState(null, "", h);
- }
- async function openFromHash() {
- const { run, round } = parseHash();
- if (run == null) return;
- if (CURRENT !== run) { await selectRun(run, round || 1); return; }
- if (round && SNAP && SNAP.loaded) gotoSnap(round - 1);
- }
- window.addEventListener("hashchange", openFromHash);
- function timeAgo(iso) {
- if (!iso) return "";
- const s = (Date.now() - new Date(iso).getTime()) / 1000;
- if (s < 60) return "刚刚";
- if (s < 3600) return Math.floor(s/60) + "分钟前";
- if (s < 86400) return Math.floor(s/3600) + "小时前";
- return Math.floor(s/86400) + "天前";
- }
- const fmtDur = s => s == null ? "—" : (s < 60 ? s.toFixed(1)+"s" : Math.floor(s/60)+"m"+Math.round(s%60)+"s");
- const fmtTok = n => n >= 1000 ? (n/1000).toFixed(1).replace(/\.0$/,"")+"k" : String(n);
- function renderList() {
- const el = $("#runlist");
- if (!RUNS.length) { el.innerHTML = `<div class="hint">暂无 Run 记录</div>`; return; }
- el.innerHTML = RUNS.map(r => `
- <div class="runcard ${CURRENT===r.id?"active":""}" data-s="${r.status}" onclick="selectRun(${r.id})">
- <div class="rc-top">
- <span class="rc-id">${esc(r.run_key || `#${r.id}`)}</span>
- <span class="rc-agent">${esc(r.business_label || r.agent_name)}</span>
- <span style="margin-left:auto"><span class="badge ${r.status} ${r.status==='running'?'dot':''}">${r.status}</span></span>
- </div>
- <div class="rc-obj">${esc(r.objective || "(无目标描述)")}</div>
- <div class="rc-meta">
- <span>${esc(r.business_progress || `${r.step_count} 条运行记录`)}</span>
- <span style="margin-left:auto">${timeAgo(r.start_time)}</span>
- </div>
- </div>`).join("");
- }
- function renderFooter() {
- const el = $("#pager");
- if (LOADING) { el.innerHTML = `<span class="pi">加载中…</span>`; return; }
- if (hasMore()) { el.innerHTML = `<button onclick="loadMore()">加载更多 · ${RUNS.length}/${TOTAL}</button>`; return; }
- el.innerHTML = RUNS.length ? `<span class="pi">已全部加载 · ${RUNS.length}</span>` : "";
- }
- // 滚动到底部附近自动追加下一页
- $("#runlist").addEventListener("scroll", () => {
- const el = $("#runlist");
- if (el.scrollTop + el.clientHeight >= el.scrollHeight - 140) loadMore();
- });
- // ── detail ──
- async function selectRun(id, round = 1) {
- CURRENT = id; PENDING_ROUND = Math.max(1, round || 1); renderList();
- if (location.hash !== `#run=${id}&round=${PENDING_ROUND}`) history.replaceState(null, "", `#run=${id}&round=${PENDING_ROUND}`);
- $("#emptyState")?.remove();
- $("#main").innerHTML = `<div class="loading">载入任务 #${id} <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
- const r = await fetch(`/api/runs/${id}`);
- if (!r.ok) { $("#main").innerHTML = `<div class="loading">任务 #${id} 载入失败</div>`; return; }
- renderDetail(await r.json());
- $("#main").scrollTop = 0;
- }
- // ─────────────────────────────────────────────────────────────
- // 数据重构:把「节点时间线」还原成「任务(Task)模型」+「循环过程」
- // task = { step_id, kind, name, goal, acceptance, inputs, parent_id,
- // isContainer, status, attempts:[{n,feedback,out,url,verdict,traces}], pending }
- // ─────────────────────────────────────────────────────────────
- function normStep(step) {
- if (step && typeof step === "object") return step;
- return { name: String(step ?? ""), kind: "", goal: "", acceptance: [], inputs: [] };
- }
- // result 可能是裸字符串或 {output:{...}, ok};抽出展示用 output
- function resultOutput(result) {
- if (result == null) return null;
- if (typeof result === "string") return { text: result };
- if (typeof result === "object") return result.output != null ? result.output : result;
- return { text: String(result) };
- }
- function outUrl(out) { return out && (out.url || (isImgUrl(out.text) ? out.text : null)); }
- function outText(out) {
- if (out == null) return null;
- if (out.text != null && !isImgUrl(out.text)) return out.text;
- if (typeof out === "string") return out;
- if (out.url) return null;
- const keys = Object.keys(out).filter(k => k !== "type");
- return keys.length ? JSON.stringify(out, null, 2) : null;
- }
- function buildModel(steps) {
- const tasks = new Map(); // key -> task
- const order = [];
- const stepKey = (st, nodeIdx) => st.step_id != null ? "s" + st.step_id : "x" + nodeIdx;
- const ensure = (key, st) => {
- if (!tasks.has(key)) {
- tasks.set(key, { key, step_id: st.step_id ?? null, kind: st.kind || "", name: st.name || "",
- goal: st.goal || "", acceptance: st.acceptance || [], inputs: st.inputs || [],
- parent_id: st.parent_id ?? null, stage: st.stage || "", category: st.category || "",
- isContainer: false, attempts: [], pending: false, pendingFeedback: null });
- order.push(key);
- }
- return tasks.get(key);
- };
- const merge = (t, st) => { // 用最新已知的 step 字段补全任务元信息
- if (st.name) t.name = st.name;
- if (st.kind) t.kind = st.kind;
- if (st.goal) t.goal = st.goal;
- if (st.acceptance && st.acceptance.length) t.acceptance = st.acceptance;
- if (st.inputs && st.inputs.length) t.inputs = st.inputs;
- if (st.parent_id != null) t.parent_id = st.parent_id;
- if (st.stage) t.stage = st.stage;
- if (st.category) t.category = st.category;
- };
- // 1) 已执行任务(含重试):按 step_id 归组,每条 past_steps = 一次 attempt
- for (const s of steps) {
- if (s.node_type !== "execute") continue;
- for (const e of (s.delta.past_steps || [])) {
- const st = normStep(e.step);
- const t = ensure(stepKey(st, s.step_index), st);
- merge(t, st);
- const out = resultOutput(e.result);
- t.attempts.push({ n: t.attempts.length + 1, nodeIdx: s.step_index,
- feedback: st._feedback || null, out, url: outUrl(out), text: outText(out),
- verdict: e.verdict || null });
- }
- }
- // 2) 容器(被拆分的父 Task):取最后一个带 containers 的 plan 节点
- let containers = [];
- for (const s of steps) if (Array.isArray(s.delta.containers) && s.delta.containers.length) containers = s.delta.containers;
- for (const c of containers) {
- const st = normStep(c);
- const t = ensure(stepKey(st, "c"), st);
- merge(t, st);
- t.isContainer = true;
- }
- // 3) 待执行队列(最新 plan 快照里、尚未成为已执行任务的叶)
- let queue = [];
- for (const s of steps) if (Array.isArray(s.delta.plan)) queue = s.delta.plan; // 最后一次覆盖 = 最新剩余队列
- queue.forEach((st0, i) => {
- const st = normStep(st0);
- const key = st.step_id != null ? "s" + st.step_id : "q" + i;
- const t = ensure(key, st);
- merge(t, st);
- if (!t.attempts.length) t.pending = true; // 已执行过的(重试待跑)不覆盖其历史
- if (st._feedback) t.pendingFeedback = st._feedback;
- });
- // 计算状态
- for (const t of tasks.values()) {
- if (t.isContainer) { t.status = "decomposed"; continue; }
- if (t.attempts.length) {
- const v = t.attempts[t.attempts.length - 1].verdict;
- t.status = v == null ? "done" : (v.pass ? "passed" : (t.pending ? "pending" : "failed"));
- } else {
- t.status = "pending";
- }
- }
- // step_id -> 产物 url(供 from_step 参考图解析 + 成品图库)
- const imgMap = {};
- for (const t of tasks.values()) {
- if (t.step_id == null) continue;
- const last = [...t.attempts].reverse().find(a => a.url);
- if (last) imgMap[t.step_id] = { url: last.url, name: t.name, pass: t.status === "passed" };
- }
- // 组树:顶层任务 + 各自的子任务
- const list = order.map(k => tasks.get(k));
- const byId = {};
- for (const t of list) if (t.step_id != null) byId[t.step_id] = t;
- const roots = [];
- for (const t of list) {
- t.children = list.filter(x => x !== t && x.parent_id != null && x.parent_id === t.step_id);
- if (t.parent_id == null || !byId[t.parent_id]) roots.push(t);
- }
- // 任务层级深度:顶层=0,子任务=父深度+1(沿 parent_id 链上溯,带环保护)
- let maxDepth = 0;
- for (const t of list) {
- let d = 0, cur = t; const seen = new Set();
- while (cur.parent_id != null && byId[cur.parent_id] && !seen.has(cur.parent_id)) {
- seen.add(cur.parent_id); cur = byId[cur.parent_id]; d++;
- }
- t.depth = d; if (d > maxDepth) maxDepth = d;
- }
- return { tasks: list, roots, imgMap, containers, byId, maxDepth };
- }
- const BIZ_STATUS = {
- success: "已通过", failed: "未通过", running: "进行中", pending: "待开始"
- };
- function renderBusinessItems(items, emptyText) {
- const list = (items || []).filter(Boolean);
- if (!list.length) return `<span style="color:var(--faint)">${esc(emptyText)}</span>`;
- return `<div class="biz-items">${list.map(item => {
- const media = item.media_url;
- const image = media && isImgUrl(media)
- ? `<img class="biz-thumb" src="${esc(media)}" referrerpolicy="no-referrer"
- onclick="openLB(decodeURIComponent('${encodeURIComponent(media)}'))" onerror="imgFail(this)">`
- : "";
- const link = media && !isImgUrl(media)
- ? `<a class="src-link" href="${esc(media)}" target="_blank" rel="noreferrer">打开正式产物 ↗</a>`
- : "";
- return `<div class="biz-item">
- <strong>${esc(item.label || "材料")}</strong>
- ${item.detail ? `<span>${esc(item.detail)}</span>` : ""}
- ${item.source ? `<code>${esc(item.source)}</code>` : ""}
- ${image}${link}
- </div>`;
- }).join("")}</div>`;
- }
- function renderBusinessCriteria(criteria) {
- const list = criteria || [];
- if (!list.length) return "";
- return `<details class="biz-more">
- <summary>展开 ${list.length} 项正式判断依据</summary>
- <div class="biz-more-body">${list.map(item => `
- <div class="biz-criterion">
- <div class="biz-criterion-h"><span>${esc(item.label || "判断项")}</span><b>${esc(item.verdict || "UNKNOWN")}</b></div>
- ${item.reason ? `<div class="biz-criterion-p">${esc(item.reason)}</div>` : ""}
- ${(item.evidence || []).length ? `<div class="biz-criterion-p">证据:${esc((item.evidence || []).join(";"))}</div>` : ""}
- </div>`).join("")}
- </div>
- </details>`;
- }
- function renderBusinessTools(tools) {
- const list = tools || [];
- if (!list.length) return "";
- return `<details class="biz-more">
- <summary>查看 ${list.length} 次真实工具操作</summary>
- <div class="biz-more-body">${list.map((tool, index) => `
- <div class="biz-criterion">
- <div class="biz-criterion-h"><span>${index + 1}. ${esc(tool.name || "tool")}</span><b>${esc(tool.status || "UNKNOWN")}</b></div>
- ${tool.detail ? `<div class="biz-criterion-p">${esc(clip(tool.detail, 320))}</div>` : ""}
- </div>`).join("")}
- </div>
- </details>`;
- }
- function renderBusinessStep(step) {
- const judgment = step.judgment || {};
- const verdictClass = String(judgment.verdict || "").toLowerCase();
- return `<article class="biz-step ${esc(step.status || "pending")}">
- <div class="biz-step-h">
- <span class="biz-stage">${esc(step.stage || "STEP")}</span>
- <span class="biz-step-title">${esc(step.title || step.id || "业务步骤")}</span>
- <span class="biz-status ${esc(step.status || "pending")}">${esc(BIZ_STATUS[step.status] || step.status || "待判断")}</span>
- </div>
- <div class="biz-rail">
- <section class="biz-cell action">
- <div class="biz-cell-k">做了什么</div>
- <div class="biz-cell-v">${esc(step.action || "尚未形成正式执行记录。")}</div>
- ${step.reason ? `<div class="biz-reason"><b>为什么:</b>${esc(step.reason)}</div>` : ""}
- ${renderBusinessTools(step.tools)}
- </section>
- <section class="biz-cell basis">
- <div class="biz-cell-k">依据什么</div>
- <div class="biz-cell-v">${renderBusinessItems(step.basis, "尚无正式输入依据")}</div>
- </section>
- <section class="biz-cell judge">
- <div class="biz-cell-k">如何判断</div>
- <div class="biz-cell-v">
- <span class="biz-verdict ${esc(verdictClass)}">${esc(judgment.verdict || "PENDING")}</span>
- <div>${esc(judgment.summary || "尚无正式判断。")}</div>
- ${renderBusinessCriteria(judgment.criteria)}
- </div>
- </section>
- <section class="biz-cell output">
- <div class="biz-cell-k">正式结果</div>
- <div class="biz-cell-v">${renderBusinessItems(step.outputs, "尚未形成可交付产物")}</div>
- </section>
- </div>
- </article>`;
- }
- function renderBusinessStory(story) {
- if (!story || !(story.rounds || []).length) {
- return `<div class="snap-empty">◍ 暂无可还原的业务轮次<br><span style="font-size:11px">(只展示正式落盘事实,不补造过程)</span></div>`;
- }
- const failure = story.failure ? `<div class="biz-failure">
- <strong>${esc(story.failure.code || "BUSINESS_FAILED")} · ${esc(story.failure.stage || "")}</strong>
- <span>${esc(story.failure.message || "业务运行未通过。")}</span>
- </div>` : "";
- return `<div class="biz-story">
- <div class="biz-lede">
- <div>
- <div class="biz-eyebrow">${story.kind === "global_data" ? "GLOBAL DATA PREPARATION" : "SEGMENT PRODUCTION"}</div>
- <div class="biz-headline">${esc(story.headline || "")}</div>
- <div class="biz-conclusion">${esc(story.conclusion || "")}</div>
- ${failure}
- </div>
- <div class="biz-meta">
- <span class="biz-chip">协议 ${esc(story.protocol_version || "—")}</span>
- <span class="biz-chip">阶段 ${esc(story.phase || "—")}</span>
- <span class="biz-status ${esc(story.status || "running")}">${esc(BIZ_STATUS[story.status] || story.status)}</span>
- </div>
- </div>
- <div class="biz-rounds">${(story.rounds || []).map(round => `
- <section class="biz-round ${esc(round.status || "running")}">
- <div class="biz-round-h">
- <span class="biz-round-n">${esc(round.index)}</span>
- <div><div class="biz-round-title">第 ${esc(round.index)} 轮 · ${esc(round.title || "")}</div>
- <div class="biz-round-summary">${esc(round.summary || "")}</div></div>
- <span class="biz-status ${esc(round.status || "running")}">${esc(BIZ_STATUS[round.status] || round.status)}</span>
- </div>
- <div class="biz-step-list">${(round.steps || []).map(renderBusinessStep).join("")}</div>
- </section>`).join("")}
- </div>
- </div>`;
- }
- function renderDetail(d) {
- const model = buildModel(d.steps);
- // 运行概览计数(不再依赖画板节点:直接从步骤序列统计)
- const fallbackPlanRounds = d.steps.filter(s => s.node_type === "plan" &&
- (s.delta.reasoning || s.delta.response != null || ("replan_count" in s.delta))).length;
- const fallbackExecRounds = d.steps.filter(s => s.node_type === "execute").length;
- const leaves = model.tasks.filter(t => !t.isContainer);
- const overview = d.overview || {};
- const planRounds = overview.plan_rounds ?? fallbackPlanRounds;
- const execRounds = overview.execute_rounds ?? fallbackExecRounds;
- const taskCount = overview.task_count ?? leaves.length;
- const passed = overview.passed ?? leaves.filter(t => t.status === "passed").length;
- const failed = overview.failed ?? leaves.filter(t => t.status === "failed").length;
- const retries = overview.retries ?? leaves.reduce((n, t) => n + Math.max(0, t.attempts.length - 1), 0);
- const gallery = Object.keys(model.imgMap).map(k => ({ sid: k, ...model.imgMap[k] }))
- .sort((a, b) => (+a.sid) - (+b.sid));
- let html = `
- <div class="topbar">
- <span class="tb-id">${esc(d.run_key || `#${d.id}`)}</span>
- <span class="tb-obj">${esc(d.objective || "")}</span>
- <span class="tb-right">
- <span class="badge ${d.status} ${d.status==='running'?'dot':''}">${d.status}</span>
- <button class="icon-btn" title="刷新" onclick="selectRun(${d.id}, curRound())">⟳</button>
- </span>
- </div>
- <div class="detailbody">
- <div class="hero">
- <div class="hero-obj">${esc(d.objective || "(无目标描述)")}</div>
- <div class="hero-model">${esc(d.agent_name)} · ${esc(d.model_name)}</div>
- <div class="loopwrap">
- <div class="loopdiagram">
- <div class="lp-node plan"><span class="lp-t">PLAN</span><span class="lp-c">${planRounds}</span></div>
- <div class="lp-arrows">
- <span class="a1">──▶</span><span class="lbl">loop</span><span class="a2">◀──</span>
- </div>
- <div class="lp-node exec"><span class="lp-t">EXECUTE</span><span class="lp-c">${execRounds}</span></div>
- </div>
- <div class="loopstats">
- <div class="lstat"><div class="k">任务</div><div class="v">${taskCount}</div></div>
- <div class="lstat"><div class="k">达标</div><div class="v pass">${passed}</div></div>
- <div class="lstat"><div class="k">未达标</div><div class="v ${failed?'fail':''}">${failed}</div></div>
- <div class="lstat"><div class="k">重试</div><div class="v ${retries?'retry':''}">${retries}</div></div>
- <div class="lstat"><div class="k">重规划</div><div class="v plan">${d.replan_count}</div></div>
- <div class="lstat"><div class="k">Tokens</div><div class="v">${fmtTok(d.input_tokens)}<small>/${fmtTok(d.output_tokens)}</small></div></div>
- <div class="lstat"><div class="k">成本</div><div class="v">${d.cost_status === "unavailable" ? "—" : `$${d.cost_usd.toFixed(4)}`}</div></div>
- <div class="lstat"><div class="k">耗时</div><div class="v">${fmtDur(d.duration_sec)}</div></div>
- </div>
- </div>
- ${d.error_message ? `<div class="err-box">⚠ ${esc(d.error_message)}</div>` : ""}
- </div>`;
- // 结果视图内容:最终交付(final_output + 成品图库)
- let resultHtml;
- if (d.final_output || gallery.length) {
- resultHtml = `
- <div class="sec-title"><span class="t">最终交付</span><span class="line"></span></div>
- <div class="delivery">
- <div class="delivery-h">✓ 成品 / response</div>
- ${d.final_output ? `<div class="delivery-b">${esc(d.final_output)}</div>` : ""}
- ${gallery.length ? `<div class="gallery">${gallery.map(g => `
- <div class="gcard">
- <img src="${esc(g.url)}" referrerpolicy="no-referrer" onclick="openLB('${esc(g.url)}')" onerror="imgFail(this)">
- <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>
- </div>`).join("")}</div>` : ""}
- </div>`;
- } else {
- resultHtml = `<div class="snap-empty">◍ 暂无最终交付<br><span style="font-size:11px">(运行尚未产出成品 / response)</span></div>`;
- }
- // 默认先回答业务问题;结果与技术 Trace 作为继续下钻。
- const businessHtml = renderBusinessStory(d.business_story);
- html += `
- <div class="view-bar">
- <div class="view-tabs">
- <button class="view-tab active" data-v="business" onclick="switchView('business')"><span class="vt-ic">◎</span>业务视图</button>
- <button class="view-tab" data-v="result" onclick="switchView('result')"><span class="vt-ic">✦</span>结果视图</button>
- <button class="view-tab" data-v="process" onclick="switchView('process')"><span class="vt-ic">◫</span>技术过程</button>
- </div>
- <button class="fs-enter" id="boardFullscreenButton" style="display:none" onclick="enterBoardFullscreen()" title="全屏查看技术过程(Esc 退出)"><span class="vt-ic">⛶</span>全屏</button>
- </div>
- <div class="view-pane active" id="tabBusiness">${businessHtml}</div>
- <div class="view-pane" id="tabResult">${resultHtml}</div>
- <div class="view-pane" id="tabProcess"><div id="snapView"></div></div>
- </div>`;
- $("#main").innerHTML = html;
- SNAP = { runId: d.id, list: null, objective: d.objective || "", idx: 0, loaded: false,
- agent: d.agent_name || "production" };
- loadSnapshots();
- }
- // 切到技术过程时重绘画板连线(隐藏时元素无尺寸,无法测量)。
- function switchView(v) {
- document.querySelectorAll(".view-tab").forEach(b => b.classList.toggle("active", b.dataset.v === v));
- const pane = { business: "tabBusiness", result: "tabResult", process: "tabProcess" }[v] || "tabBusiness";
- document.querySelectorAll(".view-pane").forEach(p => p.classList.toggle("active", p.id === pane));
- const fullscreen = $("#boardFullscreenButton");
- if (fullscreen) fullscreen.style.display = v === "process" ? "inline-flex" : "none";
- if (v === "process") requestAnimationFrame(() => layoutBoard());
- }
- const clip = (s, n) => { s = String(s ?? "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n) + " …" : s; };
- function openDrawer() { $("#drawer").classList.add("open"); $("#drawerScrim").classList.add("open"); }
- function closeDrawer() {
- $("#drawer").classList.remove("open");
- $("#drawerScrim").classList.remove("open");
- if (SNAP) SNAP.openNodeSid = null;
- }
- // ── images ──
- function imgFail(img) {
- const a = document.createElement("a");
- a.href = img.src; a.target = "_blank"; a.className = "imgfail" + (img.classList.contains("ref")?" ref":"");
- a.textContent = "图片加载失败 · 点击打开";
- img.replaceWith(a);
- }
- // 收集当前上下文(抽屉开→详情,否则→画板)里可放大的图,供左右切换。含画板过程卡缩略图(无 class)与详情各图。
- function collectImages() {
- const scope = $("#drawer").classList.contains("open") ? "#drawer" : "#main";
- return [...document.querySelectorAll(
- `${scope} img.thumb, ${scope} .mmc-thumb, ${scope} .gcard img, ${scope} .pc-thumbs img, ${scope} .imgs img, ${scope} .pp-imgs img`
- )].map(i => i.getAttribute("src")).filter(Boolean);
- }
- function openLB(url) {
- const all = [...new Set(collectImages())];
- LBIDX = all.indexOf(url);
- if (LBIDX < 0) { all.unshift(url); LBIDX = 0; } // 点击图不在集合里 → 纳入并选中,绝不回退到第一张
- LBLIST = all;
- showLB();
- $("#lb").classList.add("open");
- }
- function showLB() { $("#lbImg").src = LBLIST[LBIDX]; $("#lbCounter").textContent = `${LBIDX+1} / ${LBLIST.length}`; }
- function stepLB(d, ev) { ev && ev.stopPropagation(); LBIDX = (LBIDX + d + LBLIST.length) % LBLIST.length; showLB(); }
- function closeLB() { $("#lb").classList.remove("open"); }
- $("#lb").addEventListener("click", e => { if (e.target.id === "lb") closeLB(); });
- document.addEventListener("keydown", e => {
- if ($("#lb").classList.contains("open")) {
- if (e.key === "Escape") closeLB();
- if (e.key === "ArrowLeft") stepLB(-1);
- if (e.key === "ArrowRight") stepLB(1);
- return;
- }
- const sv = $("#snapView");
- if (e.key === "Escape" && sv && sv.classList.contains("fs")) { boardFullscreen(); e.preventDefault(); return; }
- if ($("#srcbar").classList.contains("open") && e.key === "Escape") { closeSrcBar(); return; }
- if ($("#drawer").classList.contains("open")) { if (e.key === "Escape") closeDrawer(); return; }
- // 规划演进:左右键在快照序列间步进(仅过程视图可见时)
- if (SNAP && SNAP.loaded && SNAP.list && SNAP.list.length && $("#tabProcess") && $("#tabProcess").classList.contains("active")) {
- if (e.key === "ArrowLeft") { gotoSnap(SNAP.idx - 1); e.preventDefault(); }
- if (e.key === "ArrowRight") { gotoSnap(SNAP.idx + 1); e.preventDefault(); }
- }
- });
- // ═══════════════════════════════════════════════════════════════════════════
- // 规划演进 · 思维导图(快照序列)
- // 每个快照 = 一份完整 Task 树状态;步进器在快照间切换,整树重绘为该轮状态;
- // 高亮相邻快照 diff(新增/调整/移除)+ 侧栏显示 reasoning + 本轮教训 + diff 摘要。
- // ═══════════════════════════════════════════════════════════════════════════
- let SNAP = null;
- let BOARD = null;
- async function loadSnapshots() {
- const panel = $("#snapView");
- panel.innerHTML = `<div class="loading">载入规划快照 <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
- const r = await fetch(`/api/runs/${SNAP.runId}/snapshots`);
- if (!r.ok) { panel.innerHTML = `<div class="snap-empty">规划快照载入失败</div>`; return; }
- const d = await r.json();
- SNAP.list = d.snapshots || []; SNAP.objective = d.objective || SNAP.objective; SNAP.runStatus = d.status;
- MODULES = await fetchModules(SNAP.runId); // 模块调用树;无模块行的 run → 画板显示「本轮无节点记录」
- ANN = await fetchAnnotations(SNAP.runId); // 旁路标注(可能为空 → 卡上不画那块,一切照旧)
- SNAP.loaded = true;
- SNAP.idx = Math.max(0, Math.min(SNAP.list.length - 1, PENDING_ROUND - 1)); // 深链指定的轮次;缺省第一轮
- if (!SNAP.list.length) {
- panel.innerHTML = `<div class="snap-empty">◍ 本次运行暂无规划快照<br><span style="font-size:11px">(尚未产生任何 plan 轮次)</span></div>`;
- return;
- }
- // 画板骨架(建一次;world 里每轮重摆节点):可平移(拖拽)/缩放(滚轮)的白板。
- panel.innerHTML = `
- <div class="snap-stepper" id="snapStepper"></div>
- <div class="board" id="board">
- <div class="board-world" id="boardWorld"></div>
- <div class="board-ctrls">
- <button title="按当前 Loop 拉取最新" onclick="refreshLoop(this)">⟳</button>
- <button title="放大" onclick="boardZoom(1.15)">+</button>
- <button title="缩小" onclick="boardZoom(0.87)">-</button>
- <button title="适应窗口" onclick="boardFit()">⤢</button>
- <button title="全屏 / 沉浸" id="btnFs" onclick="boardFullscreen()">⛶</button>
- </div>
- <div class="board-tip">双指滑动平移 · 双指捏合 / ⌘+滚轮缩放 · 点卡片看详情</div>
- </div>`;
- BOARD = { z: 1, panX: 0, panY: 0, worldW: 0, worldH: 0, fitted: false, contentFitted: false, suppressClick: false };
- attachBoardPan();
- renderSnapshot();
- syncHash(); // 轮次可能被 clamp(如深链的 round 超出实际轮数),以真实落点回写
- }
- // ── 手动刷新(替代自动轮询):按当前所在 Loop 重新拉取快照并重渲染 ──
- // 停留在当前 Loop(idx 不跳到最新轮);抽屉在看某任务详情/执行过程时,重开以刷新 meta + 实时 trace。
- async function refreshLoop(btn) {
- if (!SNAP || !SNAP.runId) return;
- if (btn) btn.classList.add("spinning");
- try {
- const r = await fetch(`/api/runs/${SNAP.runId}/snapshots`);
- const d = r.ok ? await r.json() : null;
- const list = d && d.snapshots || [];
- const mods = await fetchModules(SNAP.runId);
- if (d && SNAP && SNAP.runId === d.run_id && list.length) {
- SNAP.list = list; SNAP.runStatus = d.status;
- if (mods) MODULES = mods; // 重建骨架 = 丢掉旧缓存,当前轮会按需重拉(run 在跑时要拿最新)
- SNAP.idx = Math.min(SNAP.idx, list.length - 1);
- await renderSnapshot();
- if ($("#drawer").classList.contains("open") && SNAP.openNodeSid != null)
- openSnapNode(SNAP.openNodeSid, SNAP.openNodePlanOnly);
- }
- } catch (e) {}
- if (btn) setTimeout(() => btn.classList.remove("spinning"), 500);
- }
- const PLAN_KIND = { initial: "初始规划", advance: "推进(审视后未改规划)", replan: "重规划", finalize: "收尾" };
- // 轮次标签:优先用 **planner 自述的本轮短标题**(决策里的 title,它说得出这一轮到底在干什么);
- // 没有(老 run / 没填)才退回按 kind 归类的通用说法。
- const roundLabel = (s, k) => (s && s.title) || PLAN_KIND[(s || {}).kind]
- || ((s || {}).is_final ? "收尾" : `第 ${k + 1} 轮`);
- function gotoSnap(i) {
- if (!SNAP || !SNAP.list) return;
- SNAP.idx = Math.max(0, Math.min(SNAP.list.length - 1, i));
- if (BOARD) { BOARD.fitted = false; BOARD.contentFitted = false; } // 切换 Loop → 新面板自适应窗口,不保留上次位置/缩放
- renderSnapshot();
- syncHash();
- }
- function renderSnapStepper() {
- const el = $("#snapStepper"); if (!el) return;
- const n = SNAP.list.length, i = SNAP.idx;
- const pills = SNAP.list.map((s, k) => {
- const label = roundLabel(s, k);
- const changed = s.tree_changed; // 精准标记:本轮 plan 是否改了 Task
- const running = SNAP.runStatus === "running" && k === SNAP.list.length - 1;
- 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(推进/同结构)"}">
- <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>`;
- }).join("");
- el.innerHTML = `
- <button class="snap-arrow" ${i <= 0 ? "disabled" : ""} onclick="gotoSnap(${i - 1})" title="上一轮(←)">◀</button>
- <div class="snap-track">${pills}</div>
- <button class="snap-arrow" ${i >= n - 1 ? "disabled" : ""} onclick="gotoSnap(${i + 1})" title="下一轮(→)">▶</button>`;
- }
- const SNAP_STATUS = { passed: "通过", failed: "未达标", pending: "待执行", running: "执行中",
- decomposed: "容器", planned: "已规划" };
- // 节点显示状态(含 running:run 仍在跑、最新快照里的下一个待执行叶实际正在执行)——卡片与抽屉共用,保证一致
- function nodeDisplayStatus(node) {
- if (node.role === "container") return "decomposed";
- if (node.status === "pending" && node.step_id === SNAP.execSid) return "running";
- return node.status;
- }
- // 快照只在规划变化时落库:某任务执行期间不产新快照,故最新快照里它仍显示 pending。
- // 若 run 仍在跑且这是最新快照,则「下一个待执行叶」实际正在执行 → 标 running。
- function computeExecuting(snap) {
- if (SNAP.runStatus !== "running" || SNAP.idx !== SNAP.list.length - 1) return null;
- const pend = ((snap.tree && snap.tree.nodes) || [])
- .filter(n => n.role !== "container" && n.status === "pending")
- .sort((a, b) => (a.step_id ?? 1e9) - (b.step_id ?? 1e9));
- return pend.length ? pend[0].step_id : null;
- }
- async function renderSnapshot() {
- if (!SNAP || !SNAP.list) return;
- const snap = SNAP.list[SNAP.idx];
- SNAP.execSid = computeExecuting(snap);
- SNAP.roundExecSid = (snap.round_execution || {}).task_id ?? null; // 本轮执行的任务 → 思维导图里高亮
- renderSnapStepper();
- if (MODULES && !MODULES.byIdx[SNAP.idx]) { // 本轮还没拉过 → 按需拉
- const w = $("#boardWorld");
- if (w) w.innerHTML = `<div class="loading">载入本轮模块树 <span class="dots"><span>·</span><span>·</span><span>·</span></span></div>`;
- const want = SNAP.idx;
- await ensureRound(want);
- if (want !== SNAP.idx) return; // 期间又切了轮 → 让后一次接管
- }
- renderBoard();
- }
- // 任务图标:据角色 / 生成类型给出直观图标(category 为自由文本,正则匹配 + 兜底)。
- function taskIcon(node) {
- if (node.role === "container") return "🗂";
- const c = String(node.category || "");
- if (/组合/.test(c)) return "🧩";
- if (/变化|变换|改/.test(c)) return "🔀";
- if (/完整|整图|图片/.test(c)) return "🖼️";
- if (/素材/.test(c)) return "🎨";
- return "📌";
- }
- // **统一的任务卡**:plan 输出的任务列表、输入里的任务槽、生成输出里的那个任务,全用这一张。
- // opts.withResult=false → 只呈现任务本身(plan 刚排的活,还没执行)。
- function taskCard(node, opts) {
- opts = opts || {};
- const sid = node.step_id;
- // planned:plan 刚排出来的计划态(状态一律「已规划」);showResult=false:保留真实状态但不显示执行结果
- // badge:**用「本轮对它做了什么」占掉徽标位**(plan 的 ops / 下一步)——那一刻谈执行态状态没有意义。
- const planned = opts.planned === true && node.role !== "container";
- const status = planned ? "planned" : nodeDisplayStatus(node);
- const r = (planned || opts.badge || opts.showResult === false) ? null : node.result;
- let body = "";
- if (node.role === "container") {
- body = `<div class="mmc-kidsum"><span>◫ 已拆分,由子任务兑现</span></div>`;
- } else if (r) {
- const isImg = r.product_kind === "image" && r.product_url;
- body = `<div class="mmc-result">
- ${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'">` : ""}
- <div class="mmc-sum">${esc(r.summary || clip(r.product_text || "", 90)) || "<span style='color:var(--faint)'>(无摘要)</span>"}</div></div>`;
- if (r.verdict_pass === false) body += `<div class="mmc-fail">✗ ${esc(clip(r.verdict_reason || "", 90))}</div>`;
- }
- const attrs = (node.stage || node.category)
- ? `<div class="mmc-attrs">${node.stage ? `<span class="attr-chip stage"><span class="k">阶段</span>${esc(node.stage)}</span>` : ""}${
- node.category ? `<span class="attr-chip cat"><span class="k">操作</span>${esc(node.category)}</span>` : ""}</div>` : "";
- const cur = String(sid) === String(SNAP.roundExecSid) ? "mm-round-exec" : "";
- // 本轮被 update 改过:卡上直接标出改了哪几个字段(点开看逐字段 diff)
- const mod = (opts.modified && opts.modified.length)
- ? `<div class="mmc-chgs">${opts.modified.map(f => `<span class="chg-f">~ ${esc(f)}</span>`).join("")}</div>` : "";
- const b = opts.badge;
- return `<div class="mm-card mm-mini ${b ? `mm-op op-${esc(b.key)}` : `st-${status}`} ${cur} ${
- opts.modified ? "mm-modified" : ""}" data-sid="${esc(sid)}" onclick="${
- opts.click ? opts.click : `openSnapNode('${esc(sid)}',${planned || !!b})`}">
- <div class="mmc-top"><span class="mmc-sid">#${esc(sid)}</span>
- <span class="mmc-badge ${b ? `op-${esc(b.key)}` : status}">${
- b ? esc(b.text) : (SNAP_STATUS[status] || status)}</span></div>
- <div class="mmc-name">${taskIcon(node)} ${esc(node.name || "任务")}</div>
- ${node.goal ? `<div class="mmc-goal">${esc(node.goal)}</div>` : ""}
- ${attrs}${mod}${body}</div>`;
- }
- // 行内字符级 diff(LCS):把旧串→新串的差异切成 eq/del/ins 片段。中文按字、英文数字按词、其余按符号切分,
- // 保证 diff 粒度可读(非逐字节)。相邻同类片段合并。
- function diffInline(oldS, newS) {
- const tok = s => String(s ?? "").match(/[\u4e00-\u9fff]|[A-Za-z0-9_]+|\s+|[^\s]/g) || [];
- const a = tok(oldS), b = tok(newS), n = a.length, m = b.length;
- const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
- for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
- dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
- const out = [];
- const push = (type, s) => { const last = out[out.length - 1]; if (last && last.type === type) last.s += s; else out.push({ type, s }); };
- let i = 0, j = 0;
- while (i < n && j < m) {
- if (a[i] === b[j]) { push("eq", a[i]); i++; j++; }
- else if (dp[i + 1][j] >= dp[i][j + 1]) { push("del", a[i]); i++; }
- else { push("ins", b[j]); j++; }
- }
- while (i < n) { push("del", a[i]); i++; }
- while (j < m) { push("ins", b[j]); j++; }
- return out;
- }
- function renderInlineDiff(oldS, newS) {
- return diffInline(oldS, newS).map(p =>
- p.type === "eq" ? esc(p.s)
- : p.type === "del" ? `<span class="df-del">${esc(p.s)}</span>`
- : `<span class="df-ins">${esc(p.s)}</span>`).join("");
- }
- // modified 节点的 changes → 逐字段「旧 → 新」对比(name/goal 展示具体字符级 diff,而非仅「改名/目标调整」)。
- function renderChangesDiff(snap, sid) {
- const m = (snap.diff && snap.diff.modified || []).find(x => String(x.step_id) === String(sid));
- if (!m || !m.changes) return "";
- const c = m.changes, rows = [];
- const row = (k, v) => rows.push(`<div class="chg-row"><span class="chg-k">${k}</span><div class="chg-v">${v}</div></div>`);
- const pid = v => v == null ? "顶层" : "#" + esc(v);
- if (c.role) row("角色", c.role[0] === "leaf" && c.role[1] === "container"
- ? "叶子 → 容器(拆分为子任务)" : `${esc(c.role[0])} → ${esc(c.role[1])}`);
- if (c.parent_id) row("父任务", `${pid(c.parent_id[0])} → ${pid(c.parent_id[1])}`);
- if (c.name) row("名称", renderInlineDiff(c.name[0], c.name[1]));
- if (c.goal) row("目标", renderInlineDiff(c.goal[0], c.goal[1]));
- return rows.join("");
- }
- // ═══ 画板:**渲染后端给的模块调用树**(不解析、不推断)═══════════════════════
- // 后端 /api/runs/{id}/flat?round=N 给一轮的运行实例,每个实例是同样的四段:
- // · input {blocks:[{name,text,images,source,origin}]} 本次映射进来的输入(块名=结构、块值=变化)
- // · module {kind, spec, view} 这个模块的定义(agent/tool/workflow 各自的形状)
- // · flow [子模块 | 事件],已按 seq 归并(同 seq = 同一并发列)
- // · output {data, ok, images} 本次产物/结论(真 JSON)
- // 前端只做布局与绘制。
- // 一个标准节点:标题 / [输入] ─连线→ ┌结构 │ 运行流程┐ → [输出]
- // 输入在结构**左侧**:每个输入槽用连线接到结构里同名的槽位 chip(逐槽一一对应,一眼可见)。
- // 卡片登记:把一组卡放进 SNAP.cards[key](点击查看详情按 key+下标取回)
- function putCards(key, cards) { SNAP.cards[key] = cards; return cards; }
- // 输入 / 输出锚点列:用**与运行流程完全相同的卡片组件**(renderPCard)——同一种东西只有一种画法。
- // 输入槽 → 卡(每槽一张:槽名 + **本次**取值 + 该槽携带的图)。**空槽不出卡** ——
- // 「结构里声明了这一槽、但本轮没有内容」只由结构侧那张「输入」卡表达(该槽位置灰,
- // 详情里仍写着「本轮为空(未进入 prompt)」)。输入列只放真正进了 prompt 的东西,
- // 不再排一列「空」卡占着版面。
- const blockCards = blocks => (blocks || []).filter(b => b.filled !== false).map(b => ({
- label: b.title || b.name, icon: "⇢", key: b.key, hint: b.hint,
- tasks: b.item_type === "task" ? (b.items || []) : null, // 任务型槽 → 用任务卡渲染
- // 有图就不再把 URL 当文字摘要重复一遍(图本身就是内容,URL 在详情里看)
- summary: b.text ? clip(b.text, 150) : ((b.images || []).length ? `${b.images.length} 张图` : ""),
- images: b.images || [],
- detail: { title: b.name, text: b.text || "", images: b.images || [] },
- }));
- // 结构 → 卡(system prompt / 输入槽位表 / 工具与模型)——全部来自后端申报的 structure
- // 节点「结构」→ 卡片:**有什么画什么**。节点是 Agent 还是工序、还是别的形态,由产生侧在
- // structure.node_kind / system_prompt 里申报,前端不预设它一定是哪一种。
- // 结构按 kind 分化:workflow 走 flowchartHtml(流程图)、code 走 sourceHtml(源码),这里管 agent 那套卡。
- function structCards(st, input, usedTools) {
- const cards = [];
- if (!st) return cards;
- if (st.summary) cards.push({ label: "这是什么", icon: "◍", summary: st.summary,
- chips: st.node_kind ? [st.node_kind] : [], detail: { title: "节点说明", text: st.summary } });
- // 调模型的模块(agent / llm):system prompt
- if (st.system_prompt) cards.push({ label: "System Prompt", icon: "⌘", summary: clip(st.system_prompt, 110),
- detail: { title: "System Prompt", text: st.system_prompt } });
- // 输入:固定由哪几段构成 + 本轮各段取值。**就是输入**——不叫「上下文工程」、也不表达「哪段交给谁」
- // (那是下游各模块自己的输入,在它们的定义里;在这里再说一遍就是把分发规则冒充成自己的结构)。
- const schema = st.input_schema || [];
- if (schema.length) {
- const val = {};
- ((input || {}).blocks || []).forEach(b => val[b.key || b.name] = b);
- // 不写摘要:「由几段构成、几段有内容」下面那张槽位表逐行都写着(有内容的亮、空的置灰),
- // 再用一句话复述一遍就是同一件事的第二处表达。
- cards.push({ label: "输入", icon: "⿻",
- slots: schema.map(x => ({ key: x.key || x.name, hint: x.hint,
- name: (x.title || x.name), filled: !!(val[x.key || x.name] || {}).filled })),
- detail: { title: "输入 · 每一段 ↔ 本轮取值",
- // 逐槽分块、**完整**呈现本轮取值(不截断)——要核对模型到底看到了什么
- sections: schema.map(x => {
- const v = val[x.key || x.name] || {};
- return {
- label: (x.title || x.name) + (x.required ? "(必填)" : ""),
- meta: [x.hint ? "说明:" + x.hint : "", "取自:" + (x.source || "—"),
- v.filled ? "" : "本轮为空(未进入 prompt)"].filter(Boolean).join(" · "),
- text: v.filled ? (v.text || "") : "", images: v.images || [],
- };
- }) } });
- }
- // 工具:运行记录里只有**名字**(定义是代码、不是运行数据,按需走 /api/tools 取,见 TOOLDEF)。
- // `used` 是后端给的「本次实际用过哪些」——用过的高亮,一眼看出这轮动用了哪几样能力。
- const tools = (st.tools || []).map(t => typeof t === "string" ? { name: t } : (t || {}));
- const used = new Set(usedTools || []);
- if (tools.length) {
- const nUsed = tools.filter(t => used.has(t.name)).length;
- cards.push({ label: "工具 / 能力", icon: "⚙",
- summary: `可用 ${tools.length} 个工具${nUsed ? ` · 本次用过 ${nUsed} 个` : ""}`,
- tools, usedTools: [...used],
- detail: { title: "工具 / 能力", tools, usedTools: [...used] } });
- }
- // 模型只属于**真正调模型的模块**(agent / llm);workflow / code 不调模型,模型在它们内部的子模块上。
- // 可执行结构申报的是扁平的 st.model,环节配置则是 st.runtime —— 两种都渲染成同一张卡。
- const isModelBearing = !st.node_kind || st.node_kind === "agent" || st.node_kind === "llm";
- const rt = st.runtime;
- if (isModelBearing && !(rt && rt.model) && st.model)
- cards.push({ label: "模型配置", icon: "◎", summary: st.model,
- chips: [st.runs || ""].filter(Boolean),
- detail: { title: "模型配置 · 本段", text: `模型:${st.model}` + (st.runs ? `\n执行:${st.runs}` : "") } });
- if (isModelBearing && rt && rt.model) {
- const p = rt.params || {}, bud = rt.budget || {};
- cards.push({ label: "模型配置", icon: "◎", summary: rt.model,
- chips: [rt.overridden ? "环节特配" : "默认模型",
- ...Object.keys(p).map(x => `${x}=${p[x]}`), ...Object.keys(bud).map(x => `${x}=${bud[x]}`),
- rt.fallback_model ? "回退 " + rt.fallback_model : ""].filter(Boolean),
- detail: { title: "模型配置 · 本环节", text:
- `环节(role):${rt.role || "—"}\n模型:${rt.model}(${rt.overridden ? "models_by_role 特配" : "默认 model_name"})\n` +
- `默认模型:${rt.default_model || "—"}\n安全回退模型:${rt.fallback_model || "(未启用)"}\n` +
- `参数:${JSON.stringify(p)}\n中间件:${(rt.middleware || []).join("、") || "—"}\n预算:${JSON.stringify(bud)}` } });
- }
- return cards;
- }
- // 输出 → **一张卡**。输出本身只有客观数据;**怎么读它由结构里的 output_schema 决定**:
- // fields[{key,name}] 给字段中文名(未列出的按原键名显示,hide_keys 里的不显示)
- // items_key 输出的「构成」在哪个键(如交付件 results / 逐候选 candidates)
- // primary_key 主件下标在哪个键;item_* 指明每件的标签/图/备注取哪
- // 没有 schema 就按对象自身的键缺省摊平(autoFields)。任何情况下都不该把一坨 JSON 糊在卡面上。
- function outputCards(out, schema) {
- if (!out) return [];
- const d = out.data, sc = schema || {};
- const labels = {}; (sc.fields || []).forEach(f => labels[f.key] = f.name);
- const hide = new Set([...(sc.hide_keys || []), sc.items_key, sc.primary_key].filter(Boolean));
- const items = (sc.items_key && d && Array.isArray(d[sc.items_key])) ? d[sc.items_key] : [];
- const pIdx = sc.primary_key && d ? d[sc.primary_key] : null;
- const itemLabel = (it, i) => {
- const raw = it[sc.item_label_key || "role"];
- const mapped = (sc.item_labels || {})[raw] || (raw != null ? String(raw) : `#${i}`);
- return (sc.item_label_prefix || "") + mapped;
- };
- const arts = items.map((it, i) => ({
- label: itemLabel(it, i), primary: (pIdx != null && i === pIdx) || it.role === "primary",
- note: it[sc.item_note_key || "note"] || "",
- url: it[sc.item_image_key || "url"],
- text: it.text || it.falsifiable_diff || "",
- }));
- // 其余字段:schema 给名字的优先按 schema 顺序,再补上数据里剩下的键
- const rest = {};
- if (d && typeof d === "object" && !Array.isArray(d))
- Object.entries(d).forEach(([k, v]) => { if (!hide.has(k)) rest[k] = v; });
- let fields = [];
- // 投影层读时组装好的字段(如 plan 决策的 ops/理由/下一步)优先——它按框架自己的 schema 渲染过了
- if (out.fields && out.fields.length) fields = out.fields;
- else if (Object.keys(rest).length) {
- const named = (sc.fields || []).filter(f => rest[f.key] != null && rest[f.key] !== "")
- .map(f => ({ key: f.key, name: f.name }));
- const others = Object.keys(rest).filter(k => !named.some(n => n.key === k)).map(k => ({ key: k, name: k }));
- const auto = {}; autoFields(rest).forEach(f => auto[f.label] = f.value);
- fields = [...named, ...others].map(f => ({ label: f.name, value: auto[f.key] }))
- .filter(f => f.value != null && f.value !== "");
- // 数组里是对象的(如 items)由 autoFields 展开成多行,补进来
- autoFields(rest).filter(f => !fields.some(x => x.label === (labels[f.label] || f.label)) && /[ #]/.test(f.label))
- .forEach(f => fields.push(f));
- }
- // 图:优先「输出构成」里的交付件;否则用产生侧申报的 out.images。
- // **哪些图算这个节点的产物由产生侧说了算**(set_output(images=…),如 plan 显式申报「无图产物」,
- // 免得把决策里引用的参考图当成品)——这里不再按「有没有可读字段」去猜要不要显示图。
- const images = arts.map(a => a.url).filter(Boolean);
- return [{
- label: "输出" + (out.ok === false ? " · 未达标" : ""),
- icon: out.ok === false ? "✗" : "◆", ok: out.ok, final: true,
- fields, ops: out.ops || null, opsTitle: out.ops_title || "",
- next: out.next || null, nextTitle: out.next_title || "", arts: arts.length ? arts : null,
- summary: fields.length || arts.length ? "" : clip(summarizeData(d), 150),
- images: images.length ? images : (out.images || []),
- detail: { title: "输出", images: images.length ? images : (out.images || []),
- text: (arts.length ? arts.map(a => `${a.primary ? "★ " : ""}${a.label}${a.note ? "\n " + a.note : ""}` +
- `${a.url ? "\n " + a.url : ""}`).join("\n\n") + "\n\n———\n\n" : "") +
- (typeof d === "string" ? d : JSON.stringify(d, null, 2)), json: d },
- }];
- }
- // 对象 → 可读字段列表(产生侧没给 fields 时的缺省摊平)。
- // 关键:**对象数组要逐项展开**(如多候选的逐候选结论),而不是压成「N 项」——那正是要看的东西。
- function autoFields(d) {
- if (!d || typeof d !== "object" || Array.isArray(d)) return [];
- const scalar = v => v == null ? "" : (typeof v === "boolean" ? (v ? "✓" : "✗") : String(v));
- const rowOf = (item, i, key) => {
- // 标识:优先项里带序号/名字的字段,否则用下标
- const idKey = ["candidate_index", "index", "idx", "name", "key", "id", "standard", "label"]
- .find(k => item[k] != null);
- const label = `${key}${idKey ? " " + String(item[idKey]) : " #" + i}`;
- // 值:把这一项里的标量按「布尔 → ✓/✗、数字 → 原样、短文本 → 原文」拼成一行(长文本截断)
- const bits = Object.entries(item)
- .filter(([k, v]) => k !== idKey && v != null && typeof v !== "object")
- .map(([, v]) => typeof v === "string" ? clip(v, 60) : scalar(v));
- return { label, value: clip(bits.join(" "), 160) };
- };
- const out = [];
- for (const [k, v] of Object.entries(d)) {
- if (v == null || v === "") continue;
- if (Array.isArray(v)) {
- const objs = v.filter(x => x && typeof x === "object");
- if (objs.length) { objs.slice(0, 8).forEach((it, i) => out.push(rowOf(it, i, k))); continue; }
- if (v.length) out.push({ label: k, value: clip(v.join("、"), 160) });
- continue;
- }
- if (typeof v === "object") { out.push({ label: k, value: clip(JSON.stringify(v), 120) }); continue; }
- out.push({ label: k, value: typeof v === "string" ? v : scalar(v) });
- }
- return out.slice(0, 12);
- }
- // 结构化输出的一行摘要:优先常见的可读字段,否则整体压一行(不猜格式,取到什么算什么)
- function summarizeData(d) {
- if (d == null) return "";
- if (typeof d === "string") return d;
- for (const k of ["reasoning", "summary", "reason", "text", "note", "content"])
- if (d[k]) return String(d[k]);
- return JSON.stringify(d);
- }
- // ═══════════════════════════════════════════════════════════════════════════
- // 模块渲染(统一模块模型的扁平四表:perl_module_def / _ref / _inst / perl_flow_node)
- //
- // 一切皆模块,模块 = 四段(输入 / 结构 / 执行流程 / 输出),模块可嵌套:
- // 组合模块(workflow)的「执行流程」就是它的**子模块调用**,于是嵌套天然递归;
- // 叶模块(agent / tool)的「执行流程」是事件流。同 seq = 同时发起(并发),直接分列。
- //
- // 前端只做两件事:**递归画四段盒子** + **按 spec.layout 摆位**。语义(并发/成败/图片/结构化
- // 产物/布局列)全部由产生侧写进数据,这里不做任何解析或推断。
- // ═══════════════════════════════════════════════════════════════════════════
- let MODULES = null;
- let MOD_COLOR = {}; // module_key → 色相(按模块出现顺序分配,不同模块不同色)
- // 只拉第 0 轮建立骨架(root 的图定义 + 轮数);其余轮切到时再按需拉——长 run 的整树可达数 MB。
- // ── 旁路标注:{summary, basis[]},**按被标注的那一步 join**——标注对象是**模型推理节点**,
- // 故主键是 node_id(= perl_flow_node.id);旧 run 是按工具调用标的,仍按 tool_call_id 找。
- // **拿不到就当没有** —— 这块数据是 run 之后另跑 `python -m framework.annotate <run_id>` 生成的,
- // 缺失不影响查看器任何既有显示。
- let ANN = { byNode: {}, byCall: {}, docs: {} };
- async function fetchAnnotations(runId) {
- try {
- const r = await fetch(`/api/runs/${runId}/annotations`);
- if (!r.ok) return { byNode: {}, byCall: {}, docs: {} };
- const d = await r.json();
- return { byNode: d.by_node || {}, byCall: d.by_call || {}, docs: d.docs || {} };
- } catch (e) { return { byNode: {}, byCall: {}, docs: {} }; }
- }
- // 一张卡 → 它的标注键(`n:节点id` / `c:tool_call_id`):键里带上取哪张表,openBasis 据此回查同一条。
- const annKeyOf = s => !s ? "" : (s.node_id ? "n:" + s.node_id : (s.tool_call_id ? "c:" + s.tool_call_id : ""));
- const annByKey = k => !k ? null : (k[0] === "n" ? (ANN.byNode || {})[k.slice(2)] : (ANN.byCall || {})[k.slice(2)]);
- const annOf = s => annByKey(annKeyOf(s));
- // 工具定义(说明 / 参数)**按需拉一次**:它是代码里的静态信息,不该塞进每次运行的记录里。
- // 只在打开「工具 / 能力」详情时才取,取到后整个会话共用。
- let TOOLDEF = null;
- async function loadToolDefs(agent) {
- if (TOOLDEF) return TOOLDEF;
- try {
- const r = await fetch(`/api/tools?agent=${encodeURIComponent(agent || "production")}`);
- TOOLDEF = r.ok ? ((await r.json()).tools || {}) : {};
- } catch (e) { TOOLDEF = {}; }
- return TOOLDEF;
- }
- // 依据来源栏:把每条来源的**全文**列出来,并按后端给的字符区间高亮被引片段。
- // **先按偏移切片、再逐段 esc()** —— 反过来做的话,`&`→`&` 会把后面所有字符推移,高亮就偏了。
- function hlText(text, s, e) {
- const t = String(text == null ? "" : text);
- if (s == null || e == null || s < 0 || e > t.length || e <= s) return esc(t);
- return esc(t.slice(0, s)) + `<mark class="src-hl">` + esc(t.slice(s, e)) + `</mark>` + esc(t.slice(e));
- }
- // 依据类型 → 给人看的名字与图标。**前端不认识业务自定义的 kind 也没关系**:认不出就原样显示 kind,
- // 面板与高亮照常工作(认 anchor 与偏移,不认类型)。
- const SRC_KIND = {
- agent_system_prompt: { name: "Agent System Prompt", ic: "▤" },
- input_block: { name: "输入", ic: "⇢" },
- input_image: { name: "参考图", ic: "🖼" },
- event: { name: "前序步骤", ic: "↩" },
- };
- const srcKind = k => SRC_KIND[k] || { name: k || "来源", ic: "◆" };
- // 一份完整文档 + 其中的**多处**引用:整份显示、逐处高亮、顶部给快速切换。
- // 为什么不切成几段摘要各自显示:读者会不知道这几处在整份 prompt 的什么位置、彼此什么关系。
- // 高亮 + markdown 同时要:字符偏移是对着**原始 markdown 文本**算的,渲染成 HTML 后就不成立了。
- // 做法是**先在原文里插哨兵字符、再渲染**(哨兵取私有区码点,markdown 渲染器当普通字不会动它),
- // 渲染完把哨兵换成 <mark>。这样偏移始终对着原文,渲染器也不必知道高亮的存在。
- const HL_O = "\uE000", HL_S = "\uE001", HL_C = "\uE002";
- function mdWithHighlights(text, spans, gi) {
- let t = String(text || "");
- // 从后往前插,前面的偏移才不会被前一次插入推移
- [...spans].sort((a, b) => b.s - a.s).forEach(x => {
- t = t.slice(0, x.s) + HL_O + x.k + HL_S + t.slice(x.s, x.e) + HL_C + t.slice(x.e);
- });
- let html = renderMarkdown(t);
- // 哨兵 → <mark>。高亮区间可能跨块级标签(一句话被渲染成 <p>…</p><ul>…</ul>),此时不能简单地
- // 用一对 <mark> 包住(会产生跨块未闭合的非法结构)——按标签切开、只包文本片段。
- return html.replace(new RegExp(HL_O + "(\\d+)" + HL_S + "([\\s\\S]*?)" + HL_C, "g"), (m, k, inner) => {
- let first = true;
- return inner.split(/(<[^>]+>)/).map(piece => {
- if (!piece || piece[0] === "<") return piece;
- const id = first ? ` id="hl-${gi}-${k}"` : "";
- first = false;
- return `<mark class="src-hl"${id}>${piece}</mark>`;
- }).join("");
- });
- }
- function srcDocHtml(doc, cites, gi) {
- const t = String(doc.text || "");
- const spans = cites.filter(c => c.doc_start != null && c.doc_end != null)
- .sort((a, b) => a.doc_start - b.doc_start);
- const html = mdWithHighlights(t, spans.map((c, k) => ({ s: c.doc_start, e: c.doc_end, k })), gi);
- const unlocated = cites.filter(c => c.doc_start == null);
- return `<div class="src-item">
- <div class="src-h"><span class="src-n">${srcKind(doc.kind).ic}</span>
- <span class="src-title">${esc(doc.title)}</span>
- <span class="src-loc ok">${cites.length} 处引用 · 全文 ${t.length} 字</span></div>
- <div class="src-jump">${spans.map((c, k) =>
- `<span class="sj" onclick="jumpHl(${gi},${k})">${k + 1} ${esc(clip(c.quote || c.title || "", 26))}</span>`
- ).join("")}${unlocated.map(c =>
- `<span class="sj off" title="未能在原文中逐字定位">△ ${esc(clip(c.quote || "", 26))}</span>`).join("")}</div>
- <div class="src-body md" id="src-doc-${gi}"><div class="pp-md">${html}</div></div>
- </div>`;
- }
- function jumpHl(gi, k) {
- const box = document.getElementById("src-doc-" + gi);
- const m = document.getElementById(`hl-${gi}-${k}`);
- if (!box || !m) return;
- // 用视口坐标算增量,不用 offsetTop —— 后者依赖 offsetParent,中间多包一层容器就会算错。
- box.scrollTop += m.getBoundingClientRect().top - box.getBoundingClientRect().top - 70;
- m.classList.add("flash"); setTimeout(() => m.classList.remove("flash"), 900);
- }
- function srcItemHtml(b, k) {
- const loc = !b.valid ? `<span class="src-loc no">✗ 悬空引用</span>`
- : b.located ? `<span class="src-loc ok">✓ 逐字定位 [${b.start}:${b.end}]</span>`
- : `<span class="src-loc no">△ 未逐字定位</span>`;
- const isImg = b.kind === "input_image";
- const body = isImg
- ? `<img src="${esc(b.text || "")}" referrerpolicy="no-referrer" onclick="openLB('${esc(b.text || "")}')">`
- : (b.text ? `<div class="pp-md">${mdWithHighlights(b.text, (b.located ? [{ s: b.start, e: b.end, k: 0 }] : []), "s" + k)}</div>`
- : `<span style="color:var(--faint)">(来源正文不可用:该 run 缺少对应的结构记录)</span>`);
- return `<div class="src-item" id="src-item-${k}">
- <div class="src-h"><span class="src-n">${k + 1}</span>
- <span class="src-kind">${srcKind(b.kind).ic} ${esc(srcKind(b.kind).name)}</span>
- <span class="src-title">${esc(b.title || b.ref || "")}</span>${loc}</div>
- <div class="src-body${isImg ? "" : " md"}" id="src-body-${k}">${body}</div>
- ${(!isImg && b.quote) ? `<div class="src-quote">被引片段:「${esc(b.quote)}」</div>` : ""}
- </div>`;
- }
- function openBasis(key, label) {
- const a = annByKey(key); if (!a) return;
- const basis = a.basis || [];
- $("#srcbarTitle").textContent = label || "";
- // 同一份文档(后端给的 doc key,现在只有 system prompt)的多条引用**合并成一块**:显示整份原文、
- // 逐处高亮、顶部给跳转条。其余来源各自成块。顺序:先文档,后零散来源。
- const groups = {}, singles = [];
- basis.forEach(b => (b.doc && (ANN.docs || {})[b.doc]) ? (groups[b.doc] = groups[b.doc] || []).push(b)
- : singles.push(b));
- const gkeys = Object.keys(groups);
- $("#srcbarBody").innerHTML =
- (a.summary ? `<div class="srcbar-sum">🔖 ${esc(a.summary)}</div>` : "") +
- (basis.length ? gkeys.map((k, gi) => srcDocHtml(ANN.docs[k], groups[k], gi)).join("")
- + singles.map(srcItemHtml).join("")
- : `<div class="lane-empty">(本次调用未标注依据)</div>`);
- // 只有一块来源时铺满剩余高度(长 prompt 挤在 340px 的小窗里读不了);多块时各自限高、整栏滚动。
- $("#srcbarBody").classList.toggle("single", gkeys.length + singles.length === 1);
- $("#srcbar").classList.add("open");
- requestAnimationFrame(() => {
- gkeys.forEach((k, gi) => jumpHl(gi, 0)); // 文档块:定位到第一处引用
- singles.forEach((b, k) => { // 零散来源:各自把高亮滚进可视区
- const box = document.getElementById("src-body-" + k);
- const m = box && box.querySelector(".src-hl");
- if (m) box.scrollTop += m.getBoundingClientRect().top - box.getBoundingClientRect().top - 60;
- });
- });
- }
- function closeSrcBar() { $("#srcbar").classList.remove("open"); }
- // id 一键复制:查看器上印的 invocation id / tool_call_id 就是标注器 CLI 的入参,
- // 看到哪条标注不对 → 复制 id → `python -m framework.annotate <run> --invocation N`(或 --node X)重标。
- function copyId(text, el) {
- // 原文存在 dataset 里(只在第一次点击时记):**不能读 el.textContent 当原文** —— 连点两下时读到的
- // 已经是「✓ 已复制」,那就再也变不回去了。同时清掉上一次未跑完的定时器,避免它把状态改回去。
- if (!el.dataset.label) el.dataset.label = el.textContent;
- clearTimeout(el._copyT);
- const done = () => { el.textContent = "✓ 已复制"; el.classList.add("copied");
- el._copyT = setTimeout(() => { el.textContent = el.dataset.label;
- el.classList.remove("copied"); }, 1200); };
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(String(text)).then(done, done);
- else { const t = document.createElement("textarea"); t.value = String(text); document.body.appendChild(t);
- t.select(); try { document.execCommand("copy"); } catch (e) {} t.remove(); done(); }
- }
- // ═══ 扁平模块视图 ═══════════════════════════════════════════════════════════════
- // **没有嵌套**:一个「运行实例」一个盒子,盒子之间由 `call` 引用相连 —— 前端沿实例 id 走链,
- // 不做层级下钻。模块**定义**是全局的、可被多个实例共享(盒子头标 module_key@版本 + 第 k/N 个实例)。
- // 定义层的引用(perl_module_ref)画在 ② 结构里;实例层的引用(call 节点)画在 ③ 运行流程里。
- async function fetchModules(runId) {
- const d = await fetchRound(runId, 0);
- if (!d || !d.root || !d.round_count) return null;
- const byIdx = {};
- (d.rounds || []).forEach(r => byIdx[r.index] = r);
- return { runId, root: d.root, count: d.round_count, byIdx, defs: d.modules || {} };
- }
- async function fetchRound(runId, i) {
- try {
- const r = await fetch(`/api/runs/${runId}/flat?round=${i}`);
- return r.ok ? await r.json() : null;
- } catch (e) { return null; }
- }
- // 当前轮就绪(缓存命中直接返回)。轮数可能随 run 推进增长,故 count 也一并更新。
- async function ensureRound(i) {
- if (!MODULES || MODULES.byIdx[i]) return MODULES && MODULES.byIdx[i];
- const d = await fetchRound(MODULES.runId, i);
- if (!d) return null;
- MODULES.count = d.round_count || MODULES.count;
- if (d.root) MODULES.root = d.root;
- Object.assign(MODULES.defs, d.modules || {});
- (d.rounds || []).forEach(r => MODULES.byIdx[r.index] = r);
- return MODULES.byIdx[i];
- }
- // DOM id 安全化:module_key 里有中文/. —— 连线锚点要用 querySelector,先规整
- const domId = s => "m" + String(s).replace(/[^A-Za-z0-9_-]/g, "_");
- const instDom = inst => domId("i" + inst.id);
- const defOf = inst => (MODULES && MODULES.defs[inst.module_id]) || { spec: {}, kind: "", refs: [] };
- // **指向别的模块的东西,一律用那个模块自己的颜色** —— 结构图里的引用节点、运行流程里的引用芯片、
- // 跨块的引用连线,三处同源。不这么做的话它们会继承"所在块"的色相,画面上就成了
- // 「handler 块里指向 generate 的节点是 handler 色」——颜色反而误导。
- // 取不到(如本轮画板里没有那个模块)就不覆盖 --mc,自然回落到所在块的颜色。
- const hueStyle = key => {
- const h = (MOD_COLOR || {})[key];
- return h == null ? "" : ` style="--mc:${h}"`;
- };
- // 某个模块在**别人的图定义**里被声明成什么样(tone / icon 等展示元信息)。
- // 这是定义层的事实(谁引用它、怎么标它),不是前端按名字猜的。
- // ③ 运行流程里的一格:**引用另一个模块的某个运行实例**。
- // 它与 llm / tool 节点同列同序号 —— 引用只是流程里的一步。两条连线(本节点 → 被调实例的输入、
- // 被调实例的输出 → 回到本节点)由 drawCallEdges 按 data-callee 画出来。
- function callChipHtml(f) {
- return `<div class="call-chip" data-callee="${f.callee_inst_id}"
- data-callee-key="${esc(f.callee_module_key || "")}"${hueStyle(f.callee_module_key)}
- onclick="focusInst(${f.callee_inst_id})"
- title="引用模块 ${esc(f.callee_module_key)} 的运行实例 #${f.callee_inst_id}">
- <div class="cc-h">◆ 引用外部模块${f.node_key ? ` · ${esc(f.node_key)}` : ""}</div>
- <div class="cc-k">${esc(f.callee_module_key)}</div>
- <div class="cc-i">运行实例 #${f.callee_inst_id}${f.branch ? ` · ${esc(f.branch)}` : ""}</div></div>`;
- }
- // 跳转落点:横向对到目标的**起点**(左缘 + 这点边距),而不是居中 —— 运行链很长时居中会停在
- // 链的中段,看不到实例从哪开始。
- const FOCUS_LEFT_PAD = 48;
- // 平移量用「当前 pan + 屏幕差值」的**增量**算:只依赖两个 getBoundingClientRect 的差,
- // 与缩放倍率 z、transform-origin、容器内边距都无关(照抄世界坐标反推的写法在缩放时会偏)。
- function focusPan(targetEl, vCenterEl) {
- const board = $("#board"); if (!board || !BOARD || !targetEl) return;
- const bb = board.getBoundingClientRect(), r = targetEl.getBoundingClientRect();
- const v = (vCenterEl || targetEl).getBoundingClientRect();
- BOARD.panX += (bb.left + FOCUS_LEFT_PAD) - r.left; // 横向:起点靠左
- BOARD.panY += (bb.top + bb.height / 2) - (v.top + v.height / 2); // 纵向:居中
- boardApply();
- }
- // 跳到某个运行实例(画板是 transform 平移缩放的,不能用 scrollIntoView)。
- // 横向按**所属模块块**的左缘对齐(于是「模块定义 + 这条运行链的起点」一起进画面),纵向让目标实例居中。
- function focusInst(id) {
- const el = document.getElementById("inst-" + id), board = $("#board");
- if (!el || !board || !BOARD) return;
- document.querySelectorAll("#boardWorld .inst-lane.focus").forEach(x => x.classList.remove("focus"));
- el.classList.add("focus");
- focusPan(el.closest(".mod-block") || el, el);
- }
- // ① 输入 / ④ 输出:它们是**这条运行链的首尾节点**,与中间的运行节点排在同一条链上
- // (而不是另立两段)——照示意图:(输入) → 节点1 → 节点2 → 节点3 → ▷输出。
- function ioNodeHtml(inst, side, cards) {
- const key = "i" + inst.id + ":" + side;
- putCards(key, cards);
- const cap = side === "in" ? "输入" : "输出";
- return `<div class="lane-col io-node io-${side}" id="io${side}-${inst.id}">
- <div class="ion-cap">${cap}</div>
- <div class="ion-body">${cards.length ? cards.map((c, i) => renderPCard(key, i, c)).join("")
- : `<div class="lane-empty">(无${cap})</div>`}</div></div>`;
- }
- // 一个运行实例 → **一条链**:输入 → 运行节点… → 输出。
- // 同 seq = 同时发起(扇出 N 路 / 一轮多工具),并成一列画并发 —— 产生侧已把这件事写进数据。
- function instLaneHtml(inst) {
- const key = "i" + inst.id + ":flow";
- const flow = inst.flow || [];
- const cards = flow.filter(f => f.kind === "node").map(f => f.card);
- putCards(key, cards);
- const steps = [];
- for (const f of flow) {
- const last = steps[steps.length - 1];
- if (last && last.seq === f.seq) last.items.push(f); else steps.push({ seq: f.seq, items: [f] });
- }
- const cols = steps.map(st => {
- const inner = st.items.map(f => f.kind === "call"
- ? callChipHtml(f) : renderPCard(key, cards.indexOf(f.card), f.card)).join("");
- return st.items.length > 1
- ? `<div class="lane-col conc"><div class="pc-conc-tag">⇉ 并发 ×${st.items.length}</div>
- <div class="pc-parallel-stack">${inner}</div></div>`
- : `<div class="lane-col">${inner}</div>`;
- });
- const d = defOf(inst);
- const chain = [ioNodeHtml(inst, "in", blockCards((inst.input || {}).blocks))]
- .concat(cols.length ? cols : [`<div class="lane-col"><div class="lane-empty">(无运行节点)</div></div>`])
- .concat([ioNodeHtml(inst, "out",
- outputCards(inst.output, d.view || (d.spec || {}).output_schema))]);
- // 步与步之间不放分隔元素:连接由 drawLaneEdges 画进这条泳道自己的 SVG(实线曲线箭头、与所在
- // 模块同色),元素之间只留 gap 供曲线穿过。
- return `<div class="lane-steps"><svg class="lane-wires"></svg>${chain.join("")}</div>`;
- }
- // ② 结构 · workflow —— **它的结构就是一张流程图**:节点只写「引用哪个模块 + 跑几次」,
- // 细节在被引模块自己那里(点节点跳过去看)。边带基数,1:N / N:1 直接读出扇出与汇聚。
- function flowchartHtml(spec) {
- const nodes = spec.nodes || [], edges = spec.edges || [];
- if (!nodes.length) return "";
- const lbl = (a, b) => (edges.find(e => e.from === a && e.to === b) || {});
- const card = (n, i) => {
- const many = n.cardinality === "many";
- const to = n.module_key || "";
- return `<div class="fc-node${to ? " ref" : ""}"${hueStyle(to)}${to ? ` data-to="${esc(to)}"
- onclick="focusModule('${esc(to)}')" title="悬停看这条引用连到哪 · 点击跳过去"` : ""}>
- <div class="fc-i">${i + 1}</div>
- <div class="fc-b"><div class="fc-t">${esc(n.title || n.key)}</div>
- ${to ? `<div class="fc-k">${esc(to)}</div>` : ""}
- ${many ? `<span class="fc-x">×N${n.fanout_over ? " · " + esc(n.fanout_over) : ""}</span>`
- : (n.cardinality === "optional" ? `<span class="fc-x">可能不跑</span>` : "")}
- ${n.dispatch ? `<span class="fc-x">动态派发 · ${esc(n.dispatch)}</span>` : ""}</div></div>`;
- };
- const rows = nodes.map((n, i) => {
- const e = i < nodes.length - 1 ? lbl(n.key, nodes[i + 1].key) : null;
- return card(n, i) + (i < nodes.length - 1
- ? `<div class="fc-edge"><span class="fc-card">${esc(e.cardinality || "")}</span>${
- e.label ? `<span class="fc-l">${esc(e.label)}</span>` : ""}</div>` : "");
- }).join("");
- return `<div class="fc">${rows}</div>`;
- }
- // ② 结构 · code —— **确定性代码段的定义就是它的实现**。散文规则说不清也会漂移,源码不会。
- // 结构模块里**不内联源码**(否则 code 的结构列会被拉宽,与 agent 模块不一致):只给标题 + 一条
- // 「查看完整代码」入口,全文进 CODE_SRC 登记,点开在**右侧抽屉**看(见 openCodeModal)。key 用模块键。
- let CODE_SRC = {};
- function sourceHtml(spec, key) {
- const src = spec.source;
- if (!src) return "";
- CODE_SRC[key] = src;
- return `<div class="src-card">
- <div class="src-h"><span class="src-q">${esc(src.qualname || "")}</span>
- <span class="src-f">${esc(src.file || "")}:${src.line}</span></div>
- <div class="src-more" onclick="openCodeModal('${esc(key)}')">查看完整代码 ›</div></div>`;
- }
- // 完整源码 → 复用右侧详情抽屉(与其它节点详情同一个位置,不再另开居中弹窗)。
- function openCodeModal(key) {
- const s = CODE_SRC[key]; if (!s) return;
- $("#drawerTag").textContent = "CODE"; $("#drawerTag").className = "drawer-tag";
- $("#drawerTitle").textContent = s.qualname || key;
- $("#drawerBody").innerHTML = `<div class="src-card">
- <div class="src-h"><span class="src-f">${esc(s.file || "")}${s.line != null ? ":" + s.line : ""}</span></div>
- <pre class="src-code">${esc(s.code || "")}</pre></div>`;
- openDrawer();
- }
- // 跳到某个模块块(结构图上的节点 → 那个模块)。落点同样是这个模块的**起点**:
- // 块左缘对到视口左侧,于是看到的是「模块定义 + 运行流程开头」(见 focusPan)。
- function focusModule(key) {
- const el = document.getElementById("bn-" + domId(key)), board = $("#board");
- if (!el || !board || !BOARD) return;
- document.querySelectorAll("#boardWorld .mod-block.focus").forEach(x => x.classList.remove("focus"));
- el.classList.add("focus");
- focusPan(el, el);
- }
- // 一个运行实例 → 泳道(定义那一侧只画一次,见 renderModuleBoard)
- function instLaneBox(inst, n) {
- const flags = [
- inst.task_id != null ? `<span class="mmc-sid">Task #${esc(inst.task_id)}</span>` : "",
- inst.branch ? `<span class="mmc-sid">${esc(inst.branch)}</span>` : "",
- inst.ok === false ? `<span class="mmc-badge failed">未达标</span>`
- : inst.ok === true ? `<span class="mmc-badge passed">通过</span>` : "",
- (inst.timing && inst.timing.sec != null) ? `<span class="gn-sub">${inst.timing.sec}s</span>` : "",
- inst.tokens ? `<span class="gn-sub">${esc(inst.tokens)} tok</span>` : "",
- inst.caller ? `<span class="il-from" onclick="event.stopPropagation();focusInst(${inst.caller.inst_id})"
- title="谁引用了本实例(沿 call 边反查)">← 被 #${inst.caller.inst_id} 引用</span>` : "",
- ].join("");
- return `<div class="inst-lane" id="inst-${inst.id}">
- <div class="il-cap">运行实例 ${n}
- <span class="id-chip" title="运行实例 id(点击复制)" onclick="event.stopPropagation();copyId('${inst.id}',this)">#${inst.id}</span>
- ${inst.label ? `<span class="il-t">${esc(inst.label)}</span>` : ""}
- <span class="il-flags">${flags}</span></div>
- ${instLaneHtml(inst)}</div>`;
- }
- // 卡片里的图是**异步加载**的:加载完高度才定下来。不重排的话块的尺寸是按"图还没来"时量的,
- // 连线端点就会落在旧位置。这里给每张图挂一次性回调,去抖后重排。
- function relayoutOnImages(root) {
- let t = null;
- const kick = () => { clearTimeout(t); t = setTimeout(() => { layoutBoard(); }, 60); };
- root.querySelectorAll("img").forEach(img => {
- if (img.complete) return;
- img.addEventListener("load", kick, { once: true });
- img.addEventListener("error", kick, { once: true });
- });
- }
- // 模块级**调用树**:本轮的模块按「谁把活交给谁」串成一棵**横向生长**的树。
- //
- // 父子 = ① 入口链的先后(`entry_ids`:plan 规划完 → 派发给 handler 执行,是**交接**不是引用)
- // ② 实例层真实发生的 call 边(权威)
- // ③ 定义层声明的 refs(补上本轮没跑、但结构里声明了的模块)
- //
- // 为什么不再按「调用图最长路径分层、层=行」:plan 与 handler 都没有被谁调用,纯调用图会把它们
- // 判成同一层堆在一起 —— 但它们其实是先后关系。层次必须把**入口链的先后**算进去。
- // 摆位(x/y)在 placeBlocks 里做:那要等 DOM 量出每块的实际尺寸。
- function moduleTree(insts, defs, entryIds) {
- const byKey = {}; // module_key → {insts, defIds}
- const push = (k, id, inst) => {
- const b = byKey[k] || (byKey[k] = { key: k, insts: [], defIds: [] });
- if (inst) b.insts.push(inst);
- const normalizedId = id == null ? null : String(id);
- if (normalizedId != null && !b.defIds.includes(normalizedId)) b.defIds.push(normalizedId);
- };
- insts.forEach(n => push(n.module_key, n.module_id, n));
- Object.keys(defs).forEach(id => push(defs[id].module_key, id, null));
- // **run 级根模块不显示**:整个 run 就是它的一次执行,它是所有轮次的**容器**而不是本轮的一个模块
- // —— 轮次按「根实例流程里每个 *.plan 调用」切分,根实例按构造永远落不进任何一轮的闭包,画出来只能是
- // 恒为 0 实例的空块。这个视图看的是**每一轮里发生了什么**,run 级的循环图定义不在这个尺度上。
- const rootKey = ((MODULES || {}).root || {}).module_key;
- if (rootKey) delete byKey[rootKey];
- const children = new Map(), parent = new Map(), edgeKind = new Map();
- // 每条父子边带一个**类型**(entry / call / ref)——摆位据它分轴:entry 走水平主干,
- // call/ref 走「往下 + 右缩进」(见 placeBlocks)。首个成边的 link 决定父与边类型(多父取第一个)。
- const link = (x, y, kind) => { // 保序去重;已有父的不改父(多父取第一个)
- if (!x || !y || x === y || !byKey[x] || !byKey[y] || parent.has(y)) return;
- if (!children.has(x)) children.set(x, []);
- if (children.get(x).includes(y)) return;
- children.get(x).push(y); parent.set(y, x); edgeKind.set(y, kind);
- };
- const instById = {}; insts.forEach(n => instById[n.id] = n);
- // ① 入口链:本轮根级实例按执行先后(plan → handler),前一个的产出交给后一个
- const chain = (entryIds || []).map(id => (instById[id] || {}).module_key).filter(Boolean);
- for (let i = 1; i < chain.length; i++) link(chain[i - 1], chain[i], "entry");
- // ② 实例层真实调用(flow 已按 seq 排好 → 子的次序 = 本轮真实的调用先后)
- insts.forEach(n => (n.flow || []).forEach(f => {
- if (f.kind === "call") link(n.module_key, f.callee_module_key, "call");
- }));
- // ③ 定义层声明的引用(本轮没跑的模块也有个去处)
- Object.keys(defs).forEach(id => (defs[id].refs || []).forEach(r => link(defs[id].module_key, r.to_module_key, "ref")));
- const keys = Object.keys(byKey);
- const roots = (chain.length && byKey[chain[0]] ? [chain[0]] : []).concat(
- keys.filter(k => !parent.has(k) && k !== chain[0]));
- // 深度(给块上标 L0/L1…;纯展示,摆位不依赖它)
- const depth = {}; const dfs = (k, d) => {
- if (depth[k] != null) return;
- depth[k] = d; (children.get(k) || []).forEach(c => dfs(c, d + 1));
- };
- roots.forEach(r => dfs(r, 0));
- keys.forEach(k => { if (depth[k] == null) depth[k] = 0; });
- return { byKey, keys, roots, children, parent, depth, edgeKind,
- lastEntry: chain.length ? chain[chain.length - 1] : null };
- }
- // 一轮 → 画板:**一个模块一块**(定义只画一次,右侧是它这一轮的全部运行实例),
- // 块按**两个轴**摆(详见 placeBlocks):入口链的主干**水平**往右读;模块内部引用/调用的被调块
- // **往下 + 右缩进一个「模块定义」宽度**展开,同一父的多个被调块彼此水平排开、递归适用。
- // 不同模块用不同颜色(按块的出现顺序取色相),于是"同一个模块的多次运行"一眼可辨。
- let TREE = null; // 本轮的调用树(供 placeBlocks 摆位)
- function renderModuleBoard(round) {
- const world = $("#boardWorld"); if (!world) return;
- const insts = round.instances || [];
- const defs = (MODULES && MODULES.defs) || {};
- const t = moduleTree(insts, defs, round.entry_ids);
- if (!t.keys.length) { TREE = null; world.innerHTML = `<div class="board-none">本轮无运行实例</div>`; return; }
- // 摆位次序 = 树的先序(根 → 子 → 孙);不在树上的(孤儿)排在后面
- const order = [], seen = new Set();
- const walk = k => { if (seen.has(k)) return; seen.add(k); order.push(k);
- (t.children.get(k) || []).forEach(walk); };
- t.roots.forEach(walk);
- t.keys.forEach(k => { if (!seen.has(k)) { seen.add(k); order.push(k); } });
- // entryInsts:本轮入口实例的**实例 id**(按执行先后)——交接连线的端点取它们的输出/输入锚点
- TREE = { ...t, order, entryInsts: round.entry_ids || [] };
- MOD_COLOR = {};
- order.forEach((k, i) => MOD_COLOR[k] = Math.round((i * 47) % 360));
- const blockHtml = key => {
- const b = t.byKey[key];
- const d = defs[b.defIds[0]] || (b.insts[0] ? defOf(b.insts[0]) : {});
- const sample = b.insts[0], sk = "mod:" + b.key;
- // 结构按 kind 分化:workflow = 流程图(引用别的模块,点节点跳过去);code = 源码;agent = 卡片。
- // 「这是什么 / System Prompt / 输入 / 工具 / 模型」是 **agent/llm 专属**的结构卡 —— workflow / code
- // 不走这套(它们的结构就是流程图 / 源码本身,summary 也已在 md-sum 显示过),否则会多出一张「这是什么」。
- const divergent = d.kind === "workflow" || d.kind === "code";
- const cards = divergent ? [] : structCards(d.spec || {}, sample ? sample.input : null,
- sample ? sample.used_tools : []);
- putCards(sk, cards);
- // **结构只描述自己**:被引模块的细节不在这里,所以这里没有它们的 prompt / 模型 / 输入槽。
- const kindHtml = d.kind === "workflow" ? flowchartHtml(d.spec || {})
- : d.kind === "code" ? sourceHtml(d.spec || {}, b.key) : "";
- return `<div class="mod-block" id="bn-${domId(b.key)}" data-key="${esc(b.key)}" style="--mc:${MOD_COLOR[b.key]}">
- <div class="mod-def">
- <div class="md-cap">模块定义<span class="md-depth" title="调用树上的深度">L${t.depth[b.key]}</span></div>
- <div class="md-key">${esc(b.key)}</div>
- <div class="md-meta"><span class="md-kind">${esc(d.kind || "")}</span>
- ${d.fingerprint ? `<span class="md-ver" title="定义版本指纹">@${esc(d.fingerprint)}</span>` : ""}
- ${b.defIds.length > 1 ? `<span class="md-ver">${b.defIds.length} 个版本</span>` : ""}</div>
- ${d.summary ? `<div class="md-sum">${esc(clip(d.summary, 90))}</div>` : ""}
- <div class="md-cards">${kindHtml}${cards.map((c, i) => renderPCard(sk, i, c)).join("")
- || (kindHtml ? "" : `<div class="lane-empty">(无结构信息)</div>`)}</div>
- </div>
- <div class="mod-runs">
- <div class="mr-cap">运行流程 · 本轮 ${b.insts.length} 个实例</div>
- ${b.insts.length ? b.insts.map((n, i) => instLaneBox(n, i + 1)).join("")
- : `<div class="inst-lane off"><div class="lane-empty">(本轮没有这个模块的运行实例)</div></div>`}
- </div></div>`;
- };
- // 「下一轮」落点:控制循环是 plan ⇄ execute 的**环**,本轮跑完要回到下一轮 plan。
- // 那条回边不在本轮的模块图里(它落到的是下一轮),所以单摆一个节点放在**最右**、与本轮最终
- // 产出那个块同一水平带:从它的产出连过去,点击即翻到下一轮。
- const entries = round.entry_ids || [];
- const lastEntry = entries.length ? entries[entries.length - 1] : null;
- const nextIdx = SNAP.idx + 1, hasNext = SNAP.list && nextIdx < SNAP.list.length;
- TREE.lastEntryKey = t.lastEntry;
- const tail = (hasNext && lastEntry != null) ? `
- <div class="next-round" id="nextRoundNode" data-from="${lastEntry}"
- onclick="gotoSnap(${nextIdx})" title="翻到下一轮">
- <div class="nr-cap">↩ 回吐 · 进入下一轮</div>
- <div class="nr-t">第 ${nextIdx + 1} 轮 · ${esc(roundLabel(SNAP.list[nextIdx], nextIdx))}</div></div>` : "";
- world.innerHTML = `<svg class="board-edges" id="boardEdges"></svg>
- <svg class="board-edges hot" id="boardHot"></svg>
- <div class="board-labels" id="boardLabels"></div>` +
- order.map(blockHtml).join("") + tail;
- relayoutOnImages(world);
- requestAnimationFrame(() => layoutBoard());
- }
- // 摆位:块尺寸由内容决定,只能**先渲染再量**。量完分两个轴摆(边类型见 moduleTree.edgeKind),
- // 并按「**竖直层**」拉齐高度:
- // · **入口链(entry:plan→handler 的交接)→ 水平**:后继块放在父块右缘、同一层;
- // · **引用/调用(call/ref:模块内部引用了别的模块)→ 往下一层 + 右缩进一个「模块定义」的宽度**:
- // 被调块落到父块**下面一层**、左缘对齐父块运行流程起点,同一父的多个被调块依次向右排开;递归适用。
- // **竖直层 = 从根经过几条「往下」的引用边**(entry 不增层、call/ref 增一层)。同一层的所有块**拉齐到
- // 该层最高块的高度**(顶、底都对齐,形成整齐的水平带),于是下一层从统一的 y 起,层级带一目了然。
- // **唯独最底层保持自适应高度**(它下面没有东西要对齐,拉齐只会平添空白)。
- // 入口链后继放在**本子树右缘之后**(清开自己那串被调子树),避免压在一起;不设统一列宽,宽块只撑自己那带。
- const BLK_GX = 56, BLK_GY = 56, BLK_PAD = 32;
- function placeBlocks() {
- const world = $("#boardWorld");
- if (!world || !TREE) return { w: world ? world.scrollWidth : 0, h: world ? world.scrollHeight : 0 };
- const ek = TREE.edgeKind || new Map();
- // 先清掉上一次可能设过的强制高度,再量**自然**尺寸(否则量到的是被拉过的高度)。
- const boxes = {};
- TREE.order.forEach(k => {
- const el = document.getElementById("bn-" + domId(k));
- if (!el) return;
- el.style.height = "";
- const def = el.querySelector(".mod-def");
- boxes[k] = { el, w: el.offsetWidth, h: el.offsetHeight, defW: def ? def.offsetWidth : 40 };
- });
- // ① 竖直分层:root=0;entry 后继同层;call/ref 被调 +1 层。首次赋值为准(多父取第一个)。
- // 遍历顺序与下面的 walk 一致(refKids 先、entryKid 后),保证「多父块归到同一层与同一次摆放」。
- const row = {};
- const assignRow = (k, r) => {
- if (row[k] != null) return;
- row[k] = r;
- const kids = TREE.children.get(k) || [];
- kids.filter(c => ek.get(c) !== "entry").forEach(c => assignRow(c, r + 1));
- const e = kids.find(c => ek.get(c) === "entry"); if (e) assignRow(e, r);
- };
- TREE.roots.forEach(r => assignRow(r, 0));
- // ② 每层最高块 + 最底层号;③ 每层顶 y(层高累加)
- const rowMaxH = {}; let maxRow = 0;
- Object.keys(row).forEach(k => {
- const b = boxes[k]; if (!b) return;
- const r = row[k]; rowMaxH[r] = Math.max(rowMaxH[r] || 0, b.h); if (r > maxRow) maxRow = r;
- });
- const rowY = {}; let acc = BLK_PAD;
- for (let r = 0; r <= maxRow; r++) { rowY[r] = acc; acc += (rowMaxH[r] || 0) + BLK_GY; }
- const rowH = k => (row[k] < maxRow ? rowMaxH[row[k]] : boxes[k].h); // 非最底层拉齐层高;最底层自适应
- let maxR = 0, maxB = 0;
- const done = new Set();
- const walk = (k, x) => { // 只算 x(横向摆放/避让);y 与高度由所在层决定。返回子树右缘
- const s = boxes[k]; if (!s) return x;
- const y = rowY[row[k]], h = rowH(k);
- s.el.style.left = x + "px"; s.el.style.top = y + "px";
- s.el.style.height = (row[k] < maxRow) ? h + "px" : "";
- maxR = Math.max(maxR, x + s.w); maxB = Math.max(maxB, y + h);
- const kids = (TREE.children.get(k) || []).filter(c => !done.has(c));
- const refKids = kids.filter(c => ek.get(c) !== "entry"); // 引用/调用:往下一层 + 缩进
- const entryKid = kids.find(c => ek.get(c) === "entry"); // 入口链后继:同层水平(至多一个,链是线性的)
- let right = x + s.w;
- let cx = x + s.defW; // 被调块缩进一个 mod-def 宽度(往下一层,y 自动来自 rowY)
- refKids.forEach(c => { done.add(c); const r = walk(c, cx); cx = r + BLK_GX; right = Math.max(right, r); });
- if (entryKid) { // 放在本子树右缘之后,清开被调子树避免重叠
- done.add(entryKid);
- const r = walk(entryKid, Math.max(x + s.w + BLK_GX, right + BLK_GX));
- right = Math.max(right, r);
- }
- return right;
- };
- let rx = BLK_PAD; // 多个根同在第 0 层 → 依次向右排开
- TREE.roots.forEach(r => { if (done.has(r)) return; done.add(r); rx = walk(r, rx) + BLK_GX; });
- let ty = maxB + BLK_GY; // 兜底:没进树的块(既非 root 又没被引用到)接在最下面,自然高
- TREE.order.forEach(k => {
- if (done.has(k)) return;
- done.add(k);
- const s = boxes[k]; if (!s) return;
- s.el.style.height = "";
- s.el.style.left = BLK_PAD + "px"; s.el.style.top = ty + "px";
- maxR = Math.max(maxR, BLK_PAD + s.w); maxB = Math.max(maxB, ty + s.h);
- ty += s.h + BLK_GY;
- });
- const nr = document.getElementById("nextRoundNode");
- if (nr) { // 最右、与本轮最终产出那块同一水平带
- const anchor = boxes[TREE.lastEntryKey];
- nr.style.left = (maxR + BLK_GX) + "px";
- nr.style.top = (anchor ? anchor.el.offsetTop : BLK_PAD) + "px";
- maxR = maxR + BLK_GX + nr.offsetWidth;
- maxB = Math.max(maxB, nr.offsetTop + nr.offsetHeight);
- }
- return { w: maxR + BLK_PAD, h: maxB + BLK_PAD };
- }
- function renderBoard() {
- const world = $("#boardWorld"); if (!world) return;
- SNAP.cards = {};
- const round = MODULES && MODULES.byIdx[SNAP.idx];
- if (round) { renderModuleBoard(round); return; }
- // 本轮没有模块行 → 没有可画的节点图,如实说明而不是空白一片。
- SNAP.graph = { nodes: [], edges: [] };
- TREE = null;
- world.innerHTML = `<svg class="board-edges" id="boardEdges"></svg>
- <svg class="board-edges hot" id="boardHot"></svg>
- <div class="board-labels" id="boardLabels"></div>
- <div class="board-none">本轮无节点记录</div>`;
- }
- // ── 画板布局:先按调用树给每个块定 x/y(块的尺寸由内容决定,必须先渲染再量),再画连线 ──
- function layoutBoard() {
- const world = $("#boardWorld"); if (!world || !world.offsetParent) return; // 隐藏时跳过
- world.style.width = ""; world.style.height = "";
- const s = placeBlocks();
- BOARD.worldW = Math.max(s.w, world.scrollWidth);
- BOARD.worldH = Math.max(s.h, world.scrollHeight);
- world.style.width = BOARD.worldW + "px"; world.style.height = BOARD.worldH + "px";
- drawLaneEdges();
- drawCallEdges();
- if (!BOARD.fitted) { BOARD.fitted = true; boardFit(); } else boardApply();
- }
- // 一条 SVG 路径的通用件:箭头**与线同色**(marker 不继承 stroke,只能一种颜色一个 marker)。
- const mkId = c => "mk" + String(c).replace(/[^A-Za-z0-9]/g, "");
- const svgPaths = paths =>
- `<defs>${[...new Set(paths.map(p => p.c))].map(c =>
- `<marker id="${mkId(c)}" markerWidth="7" markerHeight="7" refX="6.2" refY="2.4" orient="auto">
- <path d="M0,0 L6.4,2.4 L0,4.8 Z" fill="${c}"/></marker>`).join("")}</defs>` +
- paths.map(p => `<path d="${p.d}" fill="none" stroke="${p.c}" stroke-width="${p.w || 2}" opacity=".9"
- stroke-linecap="round"${
- p.dash ? ` stroke-dasharray="${p.dash}"` : ""} marker-end="url(#${mkId(p.c)})"/>`).join("");
- // 模块块的色相 —— 连线与该模块的定义块、泳道边框同色(颜色是「这是谁」的编码,别用灰)
- const hueOf = el => {
- const blk = el && el.closest && el.closest(".mod-block");
- const h = blk ? getComputedStyle(blk).getPropertyValue("--mc").trim() : "";
- return h ? `hsl(${h} 70% 62%)` : "var(--muted)";
- };
- // ① 链内连接:一条运行链上相邻两步(输入 → 节点 → … → 输出),实线曲线箭头。
- // 画在**泳道自己的** SVG 上(泳道背景之上、卡片之下),所以线是清晰的而不是隔着几层半透明底色。
- // DOM 里的次序就是链的次序(instLaneHtml 按 seq 排好),这里只量几何、不做推断。
- //
- // **并发一列 = 多个端点**:同 seq 的 N 步是并成一列画的(产生侧标的并发事实),连线就该是
- // 一分为 N(扇出)、N 合一(汇聚)—— 一条线连到"那一列"上,等于把 N 步画成了一步。
- function drawLaneEdges() {
- const z = (BOARD && BOARD.z) || 1;
- // 一步的**连接端点**:并发列取列里的每张卡(N 个端点),其余取这一列本身(1 个端点)。
- const portsOf = col => {
- const stack = col.querySelector(":scope > .pc-parallel-stack");
- return stack && stack.children.length ? [...stack.children] : [col];
- };
- document.querySelectorAll("#boardWorld .lane-steps").forEach(lane => {
- const svg = lane.querySelector(":scope > .lane-wires"); if (!svg) return;
- const items = [...lane.querySelectorAll(":scope > .lane-col")];
- const lb = lane.getBoundingClientRect();
- const w = lane.scrollWidth, h = lane.scrollHeight;
- svg.setAttribute("width", w); svg.setAttribute("height", h);
- svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
- const R = el => {
- const r = el.getBoundingClientRect();
- return { l: (r.left - lb.left) / z, r: (r.right - lb.left) / z,
- cy: (r.top + r.height / 2 - lb.top) / z };
- };
- const color = hueOf(lane), paths = [];
- // 出入端都水平离开/进入,控制点按水平间距取、再按落差补一点 → 扇出扇入是对称的平顺弧
- const link = (a, b) => paths.push({ d: hcurve(a, b), c: color });
- for (let i = 0; i + 1 < items.length; i++) {
- const A = portsOf(items[i]).map(R), B = portsOf(items[i + 1]).map(R);
- if (!A.length || !B.length || B[0].l <= A[0].r) continue;
- if (A.length === 1 || B.length === 1) A.forEach(a => B.forEach(b => link(a, b))); // 扇出 / 汇聚
- else A.slice(0, Math.min(A.length, B.length)).forEach((a, k) => link(a, B[k])); // 等宽并发:逐路对接
- }
- svg.innerHTML = svgPaths(paths);
- });
- }
- // 世界坐标下的矩形(画板是 transform 缩放的,量出来的像素要除以 z)
- function boardRect(el) {
- if (!el) return null;
- const wb = $("#boardWorld").getBoundingClientRect(), z = (BOARD && BOARD.z) || 1;
- const r = el.getBoundingClientRect();
- return { l: (r.left - wb.left) / z, t: (r.top - wb.top) / z, r: (r.right - wb.left) / z,
- b: (r.bottom - wb.top) / z, cx: (r.left + r.width / 2 - wb.left) / z,
- cy: (r.top + r.height / 2 - wb.top) / z };
- }
- const vcurve = (a, b) => { // 竖向:平滑三次贝塞尔,不出折角
- const dy = Math.max(40, Math.abs(b.cy - a.cy) * 0.45);
- return `M${a.cx},${a.cy} C${a.cx},${a.cy + dy} ${b.cx},${b.cy - dy} ${b.cx},${b.cy}`;
- };
- const hcurve = (a, b) => { // 横向:右缘 → 左缘,控制点纯水平 → 平顺的 S
- const c = Math.min(Math.max((b.l - a.r) * 0.55, 26), 150) + Math.abs(b.cy - a.cy) * 0.18;
- return `M${a.r},${a.cy} C${a.r + c},${a.cy} ${b.l - c},${b.cy} ${b.l},${b.cy}`;
- };
- // ── 绕开模块块的正交布线 ─────────────────────────────────────────────────────
- // 跨模块那几条线要跨过整块画面,直着连必然从别的模块的**执行流程**上压过去 —— 线是次要信息,
- // 不该盖住主要内容。摆位是我们自己算的(placeBlocks),**块与块之间的空隙在哪是已知的**,
- // 所以不需要通用避障算法:把线走成「竖直逃逸 → 在空隙里横穿 → 竖直切入」,逐段与所有块做
- // 矩形碰撞检测,取不撞的那条(多条都可行时取最短)。撞不出路时才退回原来的曲线。
- const CORRIDOR = 26; // 逃逸/横穿时离块边的留白(块间距 56 的一半)
- function boardObstacles() {
- return [...document.querySelectorAll("#boardWorld .mod-block, #boardWorld .next-round")]
- .map(el => ({ el, ...boardRect(el) })).filter(o => o.l != null);
- }
- // 线段(水平或竖直)与矩形是否相交(留 6px 余量,贴边不算撞)
- function segHits(x1, y1, x2, y2, r) {
- const p = 6, L = r.l + p, R = r.r - p, T = r.t + p, B = r.b - p;
- if (R <= L || B <= T) return false;
- if (x1 === x2) return x1 > L && x1 < R && Math.max(y1, y2) > T && Math.min(y1, y2) < B;
- return y1 > T && y1 < B && Math.max(x1, x2) > L && Math.min(x1, x2) < R;
- }
- // 两个锚点之间的正交路线:竖直出 → 走廊横穿 → 竖直入。srcBlk/dstBlk 是两端所在的块(首尾段允许穿过它们)。
- function orthoRoute(a, b, srcBlk, dstBlk, obs) {
- if (!a || !b || !srcBlk || !dstBlk) return null;
- const lanes = [srcBlk.b + CORRIDOR, srcBlk.t - CORRIDOR, dstBlk.b + CORRIDOR, dstBlk.t - CORRIDOR];
- let best = null;
- for (const ye of lanes) {
- 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]]
- .filter((p, i, arr) => i === 0 || p[0] !== arr[i - 1][0] || p[1] !== arr[i - 1][1]);
- let ok = pts.length > 1, len = 0;
- for (let i = 0; i + 1 < pts.length && ok; i++) {
- const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
- len += Math.abs(x2 - x1) + Math.abs(y2 - y1);
- for (const o of obs) {
- if (i === 0 && o.el === srcBlk.el) continue; // 起点在源块里,第一段必然穿它
- if (i === pts.length - 2 && o.el === dstBlk.el) continue; // 末段切进目标块
- if (segHits(x1, y1, x2, y2, o)) { ok = false; break; }
- }
- }
- if (ok && (!best || len < best.len)) best = { pts, len };
- }
- return best ? best.pts : null;
- }
- // 折线 → 圆角路径(拐角用二次贝塞尔抹圆,读起来像走线而不是折断)
- function polyPath(pts, r = 16) {
- let d = `M${pts[0][0]},${pts[0][1]}`;
- for (let i = 1; i + 1 < pts.length; i++) {
- const [px, py] = pts[i - 1], [cx, cy] = pts[i], [nx, ny] = pts[i + 1];
- const d1 = Math.hypot(cx - px, cy - py), d2 = Math.hypot(nx - cx, ny - cy);
- const rr = Math.min(r, d1 / 2, d2 / 2);
- d += ` L${cx + (px - cx) / d1 * rr},${cy + (py - cy) / d1 * rr}`
- + ` Q${cx},${cy} ${cx + (nx - cx) / d2 * rr},${cy + (ny - cy) / d2 * rr}`;
- }
- const e = pts[pts.length - 1];
- return d + ` L${e[0]},${e[1]}`;
- }
- // 画板上的连线分**两层**,看的是两件不同的事,出现时机也不同:
- //
- // · 结构层(定义 → 定义):「这个模块的结构里引用了哪个模块」。它**逐轮不变**,一直挂在画面上
- // 只会把运行过程压在底下 —— 改成**悬停结构才出现**(停在「模块定义」那一列、或结构图里那个引用节点上)。
- // · 运行层(实例 → 实例):本轮**实际**怎么走的 —— 调用点把输入递进被调实例、被调实例把输出递回
- // 调用点。这是跨模块的**输入 / 输出**两条线,同样悬停才出现。
- //
- // 常驻画面的只有一条:本轮最终产出 →「下一轮」落点(控制循环的回边,不属于任何模块)。
- let CALL_AGG = new Map();
- function drawCallEdges() {
- const edges = $("#boardEdges"); if (!edges) return;
- edges.setAttribute("width", BOARD.worldW); edges.setAttribute("height", BOARD.worldH);
- edges.setAttribute("viewBox", `0 0 ${BOARD.worldW} ${BOARD.worldH}`);
- // 按 (调用方模块 → 被调模块) 归并计数:悬停结构时给「本轮 ×N」用。这是对 call 边的**计数**,不是推断。
- CALL_AGG = new Map();
- $("#boardWorld").querySelectorAll(".call-chip[data-callee]").forEach(chip => {
- const from = (chip.closest(".mod-block") || {}).dataset?.key || "";
- const to = chip.dataset.calleeKey || "";
- if (!from || !to) return;
- const k = from + " " + to;
- CALL_AGG.set(k, (CALL_AGG.get(k) || 0) + 1);
- });
- const paths = [];
- // 入口链的**交接**:前一段的产出 → 后一段的输入(plan 选定的 Task 递给 handler 执行)。
- // 它不是 call 边 —— 两者都是本轮的根级实例、彼此没有调用关系 —— 但它是这一轮的**主干**,
- // 所以常驻显示(实线,与"引用"那种虚线区分开)。端点取两条泳道真实的输出 / 输入锚点。
- const chain = (TREE && TREE.entryInsts) || [];
- for (let i = 1; i < chain.length; i++) {
- const o = boardRect(document.getElementById("ioout-" + chain[i - 1]));
- const n = boardRect(document.getElementById("ioin-" + chain[i]));
- if (!o || !n) continue;
- paths.push({ d: n.l >= o.r - 4 ? hcurve(o, n)
- : vcurve({ cx: o.cx, cy: o.b }, { cx: n.cx, cy: n.t }),
- c: "var(--ok)", w: 2.6 });
- }
- const nr = document.getElementById("nextRoundNode");
- if (nr && nr.dataset.from) {
- // 这条要横跨大半张画面 → 同样走绕开所有块的正交路线(见 orthoRoute)
- const oEl = document.getElementById("ioout-" + nr.dataset.from);
- const o = boardRect(oEl), t = boardRect(nr), obs = boardObstacles();
- if (o && t) {
- const src = oEl && oEl.closest(".mod-block");
- const pts = orthoRoute(o, t, obs.find(x => x.el === src), obs.find(x => x.el === nr), obs);
- paths.push({ d: pts ? polyPath(pts) : vcurve({ cx: o.cx, cy: o.b }, { cx: t.cx, cy: t.t }),
- c: "var(--accent)", w: 2, dash: "7 6" });
- }
- }
- edges.innerHTML = svgPaths(paths);
- drawHotEdges([], []); // 重画即清空悬停层
- }
- // 两个模块块之间的一条边:**方向**按几何选边(不预设生长方向)。入口链后继在右 → 右缘出、左缘进;
- // 被调块在下 → 底缘出、顶缘进;回边(被调在左/在上)反过来。箭头永远指向被调方。
- function blockLink(a, b) {
- if (b.l >= a.r - 4) return { d: hcurve({ r: a.r, cy: a.cy }, { l: b.l, cy: b.cy }),
- mid: [(a.r + b.l) / 2, (a.cy + b.cy) / 2] };
- if (b.r <= a.l + 4) return { d: hcurve({ r: b.r, cy: b.cy }, { l: a.l, cy: a.cy }),
- mid: [(b.r + a.l) / 2, (a.cy + b.cy) / 2] };
- if (b.t >= a.b) return { d: vcurve({ cx: a.cx, cy: a.b }, { cx: b.cx, cy: b.t }),
- mid: [(a.cx + b.cx) / 2, (a.b + b.t) / 2] };
- return { d: vcurve({ cx: a.cx, cy: a.t }, { cx: b.cx, cy: b.b }),
- mid: [(a.cx + b.cx) / 2, (a.t + b.b) / 2] };
- }
- // 悬停层:`chips` = 运行层(实例间的输入 / 输出两条),`links` = 结构层(定义间的引用一条)。
- // 画在最上层(盖住卡片也无妨 —— 同时只有一两条,要的就是"是哪一条"一目了然)。
- function drawHotEdges(chips, links) {
- const hot = $("#boardHot"), labels = $("#boardLabels"); if (!hot) return;
- hot.setAttribute("width", BOARD.worldW); hot.setAttribute("height", BOARD.worldH);
- hot.setAttribute("viewBox", `0 0 ${BOARD.worldW} ${BOARD.worldH}`);
- document.querySelectorAll("#boardWorld .hot").forEach(x => x.classList.remove("hot"));
- const paths = [], tags = [];
- const obs = boardObstacles();
- const blkOf = el => { const b = el && el.closest(".mod-block");
- return b ? obs.find(o => o.el === b) : null; };
- // 一条跨模块的线:优先走**绕开所有块**的正交路线(不从别人的执行流程上压过去),
- // 实在绕不出来(几何太挤)才退回原来的贝塞尔。
- const routed = (a, b, ea, eb, style) => {
- const pts = orthoRoute(a, b, blkOf(ea), blkOf(eb), obs);
- const mid = pts ? pts[Math.floor(pts.length / 2)]
- : [(a.cx + b.cx) / 2, (a.cy + b.cy) / 2];
- paths.push({ ...style, d: pts ? polyPath(pts)
- : vcurve({ cx: a.cx, cy: a.cy }, { cx: b.cx, cy: b.cy }) });
- return mid;
- };
- // ① 运行层:调用点 —输入→ 被调实例;被调实例 —输出→ 回到调用点。两条方向相反的虚线。
- (chips || []).forEach(chip => {
- const id = chip.dataset.callee;
- const c = boardRect(chip), i = boardRect(document.getElementById("ioin-" + id)),
- o = boardRect(document.getElementById("ioout-" + id));
- const lane = document.getElementById("inst-" + id);
- const color = hueOf(lane);
- chip.classList.add("hot");
- if (lane) lane.classList.add("hot");
- if (!c) return;
- if (i) {
- const m = routed(c, i, chip, document.getElementById("ioin-" + id),
- { c: color, w: 2.6, dash: "7 6" });
- tags.push({ x: m[0], y: m[1], t: "输入", cls: "in" });
- }
- if (o) {
- const m = routed(o, c, document.getElementById("ioout-" + id), chip,
- { c: color, w: 2.6, dash: "2 6" });
- tags.push({ x: m[0], y: m[1], t: "输出", cls: "out" });
- }
- });
- // ② 结构层:定义 → 定义,两端的模块块一起点亮;标签给出本轮实际调了几次
- (links || []).forEach(e => {
- const fa = document.getElementById("bn-" + domId(e.from)), fb = document.getElementById("bn-" + domId(e.to));
- const a = boardRect((fa && fa.querySelector(":scope > .mod-def")) || fa);
- const b = boardRect((fb && fb.querySelector(":scope > .mod-def")) || fb);
- if (!a || !b) return;
- if (fa) fa.classList.add("hot");
- if (fb) fb.classList.add("hot");
- const L = blockLink(a, b);
- paths.push({ d: L.d, c: hueOf(fb), w: 2.2, dash: "7 6" });
- const n = CALL_AGG.get(e.from + " " + e.to);
- tags.push({ x: L.mid[0], y: L.mid[1], cls: "ref", t: n ? `引用 · 本轮 ×${n}` : "引用 · 本轮未调用" });
- });
- hot.innerHTML = paths.length ? svgPaths(paths) : "";
- if (labels) labels.innerHTML = tags.map(t =>
- `<div class="edge-tag ${t.cls}" style="left:${t.x}px;top:${t.y}px">${esc(t.t)}</div>`).join("");
- }
- // 悬停命中什么就画什么。
- // 运行层:**只**停在 call 芯片(引用节点)上 → 亮它引用的那个具体实例;停在泳道本身不亮
- // (hover 实例自己没有"要指向谁"的语义,点亮反而是噪声)。
- // 结构层:停在结构图里的引用节点上 → 那一条;停在整列「模块定义」上 → 它声明的全部引用。
- function hotChipsFor(el) {
- const world = $("#boardWorld"); if (!world || !el || !el.closest) return [];
- const chip = el.closest(".call-chip[data-callee]");
- if (chip) return [chip];
- return [];
- }
- // 「输入」的两侧联动:结构里那张输入卡的**槽位**(.slot-row[data-slot])与运行实例输入节点里的
- // **输入块卡**(.io-in .pcard[data-slot])共用同一个 key(结构侧来自 input_schema,实例侧来自
- // blockCards 的 b.key)——所以不需要推断,按 data-slot 直接对上。作用域限在**同一个模块块**内:
- // 结构与它这一轮的全部运行实例本就同块,于是「多个运行实例各自对应的那张输入」会一起亮。
- function slotHotFor(el) {
- if (!el || !el.closest) return null;
- const hit = el.closest(".mod-def .slot-row[data-slot], .io-in .pcard[data-slot]");
- if (!hit || !hit.dataset.slot) return null;
- const blk = hit.closest(".mod-block");
- return blk ? { blk, key: hit.dataset.slot } : null;
- }
- function applySlotHot(hit) {
- document.querySelectorAll("#boardWorld .slot-hot").forEach(x => x.classList.remove("slot-hot"));
- if (!hit) return;
- const k = (window.CSS && CSS.escape) ? CSS.escape(hit.key) : hit.key.replace(/"/g, '\\"');
- hit.blk.querySelectorAll(
- `.mod-def .slot-row[data-slot="${k}"], .io-in .pcard[data-slot="${k}"]`
- ).forEach(x => x.classList.add("slot-hot"));
- }
- function hotLinksFor(el) {
- if (!el || !el.closest) return [];
- const from = (el.closest(".mod-block") || {}).dataset?.key || "";
- if (!from) return [];
- const node = el.closest(".fc-node[data-to]");
- if (node) return [{ from, to: node.dataset.to }];
- const def = el.closest(".mod-def");
- if (!def) return [];
- return [...new Set([...def.querySelectorAll(".fc-node[data-to]")].map(n => n.dataset.to))]
- .map(to => ({ from, to }));
- }
- // ── 平移 / 缩放 ──
- function boardApply() { const w = $("#boardWorld"); if (w) w.style.transform = `translate(${BOARD.panX}px,${BOARD.panY}px) scale(${BOARD.z})`; }
- function boardZoom(f, cx, cy) {
- const board = $("#board"); if (!board) return;
- const rect = board.getBoundingClientRect();
- if (cx == null) cx = rect.width / 2; if (cy == null) cy = rect.height / 2;
- const nz = Math.max(.15, Math.min(2.5, BOARD.z * f));
- BOARD.panX = cx - ((cx - BOARD.panX) / BOARD.z) * nz;
- BOARD.panY = cy - ((cy - BOARD.panY) / BOARD.z) * nz;
- BOARD.z = nz; boardApply();
- }
- function boardFit() {
- const board = $("#board"); if (!board || !BOARD.worldW) return;
- const rect = board.getBoundingClientRect();
- // 调用树是**横向生长**的:宽度由最长的那条链决定、高度由并列分支决定,两个方向都可能溢出,
- // 所以按**两个方向都能装下**取缩放(跟随内容,不预设哪个方向更长)。
- const z = Math.max(.12, Math.min((rect.width - 48) / BOARD.worldW,
- (rect.height - 48) / BOARD.worldH, 1.1));
- BOARD.z = z;
- BOARD.panX = Math.max(16, (rect.width - BOARD.worldW * z) / 2);
- BOARD.panY = Math.max(16, (rect.height - BOARD.worldH * z) / 2);
- boardApply();
- }
- // 全屏 / 沉浸式:把 #snapView 切成 inset:0 覆盖层,退出后恢复撑满宽度;进出后重新适应窗口。
- function boardFullscreen() {
- const sv = $("#snapView"); if (!sv) return;
- const on = sv.classList.toggle("fs");
- document.body.style.overflow = on ? "hidden" : "";
- const btn = $("#btnFs");
- if (btn) { btn.textContent = on ? "✕" : "⛶"; btn.title = on ? "退出全屏(Esc)" : "全屏 / 沉浸"; }
- sv.style.width = ""; // 退出全屏回到详情列自然宽度(由 detailbody max-width 约束)
- requestAnimationFrame(() => { layoutBoard(); boardFit(); });
- }
- // 顶部「全屏」按钮:确保切到过程视图后进入全屏(全屏时该按钮被覆盖层遮住,故只需负责进入)。
- function enterBoardFullscreen() {
- const sv = $("#snapView"); if (!sv) return;
- const tab = $("#tabProcess");
- if (tab && !tab.classList.contains("active")) switchView("process");
- if (!sv.classList.contains("fs")) boardFullscreen();
- }
- function attachBoardPan() {
- const board = $("#board"); if (!board) return;
- // 不用 setPointerCapture(会截走子卡片的 click);改为:按下只记起点,移动超阈值才判定为拖拽平移,
- // 拖拽时才吞掉紧随的 click;纯点击(无位移)原样落到卡片上。move/up 挂 window,避免快速拖出画板丢事件。
- let active = false, dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
- const onMove = e => {
- if (!active) return;
- if (!dragging && Math.hypot(e.clientX - ox, e.clientY - oy) > 5) { dragging = true; board.classList.add("dragging"); }
- if (dragging) { BOARD.panX = e.clientX - sx; BOARD.panY = e.clientY - sy; boardApply(); }
- };
- const onUp = () => {
- if (!active) return;
- active = false;
- window.removeEventListener("pointermove", onMove);
- window.removeEventListener("pointerup", onUp);
- if (dragging) { board.classList.remove("dragging"); BOARD.suppressClick = true; setTimeout(() => BOARD.suppressClick = false, 0); }
- dragging = false;
- };
- board.addEventListener("pointerdown", e => {
- if (e.button !== 0) return;
- active = true; dragging = false; sx = e.clientX - BOARD.panX; sy = e.clientY - BOARD.panY; ox = e.clientX; oy = e.clientY;
- window.addEventListener("pointermove", onMove);
- window.addEventListener("pointerup", onUp);
- });
- // 跨模块引用的实例级连线**按需展开**:只在停到 call 芯片(引用节点)上时亮它引用的那个实例。
- // 事件委托挂在画板上(卡片是每轮重绘的,逐个绑定会随重绘丢失)。拖拽平移时不画。
- let hotKey = "";
- board.addEventListener("pointermove", e => {
- if (dragging) return;
- const chips = hotChipsFor(e.target), links = hotLinksFor(e.target);
- const slot = slotHotFor(e.target);
- const k = chips.map(c => c.dataset.callee).join(",") + "|"
- + links.map(l => l.from + ">" + l.to).join(",") + "|"
- + (slot ? (slot.blk.dataset.key || "") + "#" + slot.key : "");
- if (k === hotKey) return;
- hotKey = k;
- applySlotHot(slot);
- drawHotEdges(chips, links);
- });
- board.addEventListener("pointerleave", () => {
- hotKey = ""; applySlotHot(null); drawHotEdges([], []);
- });
- // macOS 触控板:双指滑动 = 平移(wheel, ctrlKey=false);双指捏合 = 缩放(wheel, ctrlKey=true)。
- // 另支持 macOS ⌘(metaKey)+ 鼠标滚轮缩放,方便普通鼠标用户。
- board.addEventListener("wheel", e => {
- e.preventDefault();
- const rect = board.getBoundingClientRect();
- if (e.ctrlKey || e.metaKey) boardZoom(Math.exp(-e.deltaY * 0.01), e.clientX - rect.left, e.clientY - rect.top);
- else { BOARD.panX -= e.deltaX; BOARD.panY -= e.deltaY; boardApply(); }
- }, { passive: false });
- }
- // ── 运行流程的分列与语义(并发批次 / 分支 / 标题 / 成败 / 图片)**全部来自后端**(见 web/app.py
- // _inv_node / _ev_card:事件按 seq 归并,同 seq = 同一并发列)。前端只负责铺列与画列间连线。──────
- // 组合模块的 ③ 与 ④ 是两列**逐框对应**的(第 i 个运行框 ↔ 第 i 个输出框)。CSS 做不到跨容器对齐,
- // 这里量一遍取每行较高的那个,把两边撑齐——横着读过去就是同一个子模块的「跑了什么 → 产出什么」。
- // 同一行的三段(② 结构格 / ③ 运行框 / ④ 输出框)**顶边对齐**。
- //
- // 两件事,分开对待:
- // · ③ 运行框 ↔ ④ 输出框:**顶边 + 底边都对齐**(等高)——这两段讲的是同一个子模块的
- // 「跑了什么 → 产出什么」,等高才读得出「这一块是一件事」。
- // · ② 结构格:只对齐顶边、**不拉伸** —— 结构列可能嵌着整条子链而高出一大截,
- // 拉伸只会把矮的那个撑成不传达任何信息的空色块;多出来的距离留成段底色与连接线。
- //
- // 不靠公式推导(padding / margin / row-gap / 表头高度差 / 嵌套层数……推错一处整列就跑偏),
- // 改成**实测驱动的收敛循环**:量当前实际位置 → 补差 → 再量,直到残差 < 1px。
- //
- // ⚠️ 画板带 transform: scale(z),`getBoundingClientRect()` 量到的是**缩放后**的值,
- // 而写进 style 的 margin/height 是**未缩放**的布局 px —— 必须除以 z 换算回去。
- // 槽位连线:左侧「输入」里的每个盒子 → 结构「输入拼接 · 槽位」里同名的那个槽 chip。
- // 两端都带 data-slot(后端给的 key),所以是**按槽位一一配对**,不是按顺序凑。
- // **逐层都连**:不只最外层那一对。配对规则是「同一层的输入盒 ↔ 同一层的槽位表」:
- // · 本模块:① 的自有输入列 ↔ ② 的自有结构卡(组合模块的 .gs-own,不含子结构链)
- // · 每个子段:同 data-rk 的 .sub-in ↔ .sub-struct —— **行内匹配**,不跨行也不跨层,
- // 于是「execute 的验收标准」不会误连到父模块同名的槽上(这是嵌套后必须先解决的撞名问题)。
- // 线色取该行的 --tone,与那一行的四段底色同源。
- // 卡片渲染(流程卡 / 结构卡通用):标题、摘要、成败、图片、分支都由后端给定,前端不判断。
- function renderPCard(key, i, s) {
- const thumbs = (s.images && s.images.length)
- ? `<div class="pc-thumbs">${s.images.slice(0, 3).map(u =>
- `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : "";
- // plan 的 ops:涉及具体 Task 的一律用**任务卡**画(与任务台账同一种视觉),把「做了什么操作」**融进卡本身**
- // ——徽标位显示操作(+新增 / ~修改 / ↻重做…)、左侧色条按操作着色。规划这一刻不显示执行态状态:
- // 那时这些 Task 还没跑(重做的更是即将重来),显示「待执行/未达标」是张冠李戴。
- const ops = (s.ops && s.ops.length)
- ? `<div class="ops-h">${esc(s.opsTitle || "任务更新")}</div>
- <div class="ops-list">${(putCards(key + ":ops" + i, s.ops), s.ops.map((o, k) => `
- <div class="op op-${esc(o.op)}">
- ${o.detail && o.op !== "update" ? `<div class="op-h"><span class="op-detail">${esc(o.detail)}</span></div>` : ""}
- ${(o.tasks || []).length ? `<div class="op-tasks">${o.tasks.map(t =>
- `<div class="${t._child ? "op-child" : ""}">${taskCard(t, {
- badge: { text: o.label, key: o.op },
- modified: o.op === "update" ? (o.changed || []) : null,
- click: o.op === "update" ? `openOpDiff('${key}:ops${i}',${k})` : "" })}</div>`).join("")}</div>` : ""}
- </div>`).join(""))}</div>` : "";
- // 本轮选定的下一步:它本身就是一个 Task —— 同样用任务卡画,徽标位标「▶ 下一步」
- const next = s.next
- ? `<div class="ops-h">${esc(s.nextTitle || "下一步执行")}</div>
- <div class="ops-list"><div class="op op-next">${
- taskCard(s.next, { badge: { text: "▶ 下一步", key: "next" } })}</div></div>` : "";
- // 结构化输出的可读字段:一行一项「标签 + 值」
- const fields = (s.fields && s.fields.length)
- ? `<div class="kv-list">${s.fields.filter(f => f && f.value).map(f =>
- `<div class="kv"><span class="kv-k">${esc(f.label)}</span><span class="kv-v">${esc(f.value)}</span></div>`
- ).join("")}</div>` : "";
- // 输出的构成:每件「缩略图 + 它是什么」成对呈现;主件加 ★ 与高亮描边
- const arts = (s.arts && s.arts.length)
- ? `<div class="out-arts">${s.arts.map(a => a.url
- ? `<div class="out-art ${a.primary ? "is-primary" : ""}" title="${esc(a.note || a.label)}">
- <img src="${esc(a.url)}" referrerpolicy="no-referrer"
- onclick="event.stopPropagation();openLB('${esc(a.url)}')" onerror="this.style.display='none'">
- <div class="oa-tag">${a.primary ? "★ " : ""}${esc(a.label)}</div></div>`
- : `<div class="out-art no-img ${a.primary ? "is-primary" : ""}">
- <div class="oa-text">${esc(clip(a.text || a.note || "", 60))}</div>
- <div class="oa-tag">${a.primary ? "★ " : ""}${esc(a.label)}</div></div>`).join("")}</div>` : "";
- // 工具清单:一枚工具一枚 chip,**本次用过的高亮**(数据由后端 used_tools 给,不靠前端猜)
- const usedSet = new Set(s.usedTools || []);
- const toolChips = (s.tools && s.tools.length)
- ? `<div class="tool-chips">${s.tools.map(t =>
- `<span class="tool-chip${usedSet.has(t.name) ? " used" : ""}"${
- t.description ? ` title="${esc(clip(t.description, 200))}"` : ""}>${
- usedSet.has(t.name) ? `<span class="tc-dot"></span>` : ""}${esc(t.name)}</span>`).join("")}</div>` : "";
- const chips = (s.chips && s.chips.length)
- ? `<div class="pc-chips">${s.chips.slice(0, 12).map(c => `<span class="pc-chip">${esc(c)}</span>`).join("")}</div>` : "";
- // 槽位表:一行一个槽(左侧输入盒按 data-slot 连过来),竖排避免连线挤成一团
- const slots = (s.slots && s.slots.length)
- ? `<div class="slot-list">${s.slots.map(sl =>
- `<div class="slot-row slot-chip ${sl.filled ? "" : "off"}" data-slot="${esc(sl.key)}"${
- sl.hint ? ` title="${esc(sl.hint)}"` : ""}>
- <span class="dot"></span><span class="nm">${esc(sl.name)}</span>${
- sl.filled ? "" : `<span class="slot-empty">空</span>`}</div>`).join("")}</div>` : "";
- const tone = String(key).split(":")[0].replace(/^execute$/, "exec").replace(/^evaluate$/, "eval");
- const failed = s.ok === false;
- const mods = [failed ? "pc-failed" : "", s.muted ? "pc-empty" : "",
- s.final ? "pc-final" : "", s.type === "llm" ? "pc-llm" : ""].filter(Boolean).join(" ");
- // 任务条目槽(待执行队列 / 已执行 Task / 容器):直接用**任务卡**渲染 —— 与任务列表里的任务同一种画法
- if (s.tasks && s.tasks.length) {
- putCards(key + ":tasks" + i, s.tasks); // 供点开看「这条进槽的原文」
- return `<div class="pcard pc-${tone} pc-taskslot"${s.key ? ` data-slot="${esc(s.key)}"` : ""}>
- <div class="pcard-h"><span class="pc-ic">◇</span><span class="pc-label">${esc(s.label || "")}</span>
- <span class="pc-tok">${s.tasks.length} 个</span></div>
- <div class="task-strip">${s.tasks.map((t, k) => taskCard(t, { showResult: false,
- click: `openSlotTask('${key}:tasks${i}',${k})` })).join("")}</div></div>`;
- }
- // ── 旁路标注:**模型推理节点**上挂「做了什么(标注)」,底部给依据来源入口 —— 决策发生在模型
- // 这一步,工具只是它的执行结果。标注块独立底色 + 🔖 抬头,与卡上那些**运行时事实**明确分开。
- const ann = annOf(s);
- const annBlock = ann ? `
- <div class="pc-ann">
- <div class="pc-ann-sum">${esc(ann.summary || "—")}</div>
- </div>` : "";
- // 一段「按形态渲染」的数据(工具的输出、模型 function calling 的入参都走这一个渲染器 ——
- // 同一种东西只有一种画法):
- // 主文 = 这段在说什么(query / prompt / thought)→ 正常字号,最先读到
- // 标签 = 一批条目(工具名、字段名)→ 一枚枚 chip,不是一坨 JSON
- // 字段 = 剩下的少量标量 → 小字键值
- // 图 = 就在自己这一段里
- // 成败折成表头一枚徽标(不再单独占一行说 success True)。
- const ioSec = (d, cls, head) => {
- d = d || {};
- if (!d.lead && !(d.chips || []).length && !(d.rows || []).length && !(d.images || []).length) return "";
- return `<div class="pc-io-sec ${cls}">
- <div class="pc-io-h">${head}${
- d.ok === true ? `<span class="io-ok">✓</span>` : d.ok === false ? `<span class="io-bad">✗ 失败</span>` : ""}</div>
- ${d.lead ? `<div class="pc-io-lead">${esc(d.lead)}</div>` : ""}
- ${(d.chips || []).length ? `<div class="pc-io-chips">${
- d.chips.map(c => `<span class="io-chip">${esc(c)}</span>`).join("")}</div>` : ""}
- ${(d.rows || []).length ? `<div class="pc-io-kv">${d.rows.map(f =>
- `<div class="io-row"><span class="io-k">${esc(f.k)}</span><span class="io-v">${esc(f.v)}</span></div>`).join("")}</div>` : ""}
- ${(d.images || []).length ? `<div class="pc-thumbs io-thumbs">${d.images.slice(0, 6).map(u =>
- `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : ""}
- </div>`;
- };
- // 工具节点:**只有输出**(入参是上一步 function calling 的那份,在模型推理卡上;后端据 tool_call_id
- // 决定要不要给 input —— 没有上游那一步的工具节点仍自带入参)。图跟着自己那一段走。
- const ioBlock = s.io ? ["input", "output"].map(side =>
- s.io[side] ? ioSec(s.io[side], side === "input" ? "in" : "out",
- `<span>${side === "input" ? "⇢ 输入" : "⇠ 输出"}</span>`) : "").join("") : "";
- // 模型推理节点:它发出的 function calling。**一次调用一块**(序号 + 工具名 + 该次入参),
- // 多个调用叠在一张卡里 —— 它们本就是同一轮请求里一起发出的(框架给同一个 batch),所以要看得出
- // 「这一步一共发起了几个、分别是谁、各自带什么参数」,而不是糊成一段。
- const argRow = d => {
- d = d || {};
- return [
- d.lead ? `<div class="pc-io-lead">${esc(d.lead)}</div>` : "",
- (d.rows || []).length ? `<div class="pc-io-kv">${d.rows.map(f =>
- `<div class="io-row"><span class="io-k">${esc(f.k)}</span><span class="io-v">${esc(f.v)}</span></div>`).join("")}</div>` : "",
- (d.chips || []).length ? `<div class="pc-io-chips">${
- d.chips.map(c => `<span class="io-chip">${esc(c)}</span>`).join("")}</div>` : "",
- (d.images || []).length ? `<div class="pc-thumbs pcl-thumbs">${d.images.slice(0, 8).map(u =>
- `<img src="${esc(u)}" referrerpolicy="no-referrer" onclick="event.stopPropagation();openLB('${esc(u)}')" onerror="this.style.display='none'">`).join("")}</div>` : "",
- ].join("") || `<div class="pcl-none">(无参数)</div>`;
- };
- const callBlock = (s.calls || []).length ? `<div class="pc-calls">
- <div class="pc-calls-h">⚡ 推理结果 · 调用以下${s.calls.length > 1 ? ` ${s.calls.length} 个` : ""}工具</div>
- ${s.calls.map((c, k) => `<div class="pc-call">
- <div class="pcl-h"><span class="pcl-i">${k + 1}</span><span class="pcl-n">${esc(c.name)}</span></div>
- <div class="pcl-args">${argRow(c.args)}</div></div>`).join("")}</div>` : "";
- // 底部依据入口:**按类型分组计数**("2 个来源"看不出是哪种来源,也就看不出这步是被 prompt 训导
- // 驱动的、还是被本 Task 的输入/上一步产物驱动的 —— 而那才是读者真正想一眼看到的东西)。
- let basis = "";
- if (ann && (ann.basis || []).length) {
- const cnt = {};
- ann.basis.forEach(b => cnt[b.kind || ""] = (cnt[b.kind || ""] || 0) + 1);
- const tags = Object.keys(cnt).map(k =>
- `<span class="bs-tag"><span class="bs-ic">${srcKind(k).ic}</span>${esc(srcKind(k).name)}<b>${cnt[k]}</b></span>`).join("");
- basis = `<div class="pc-basis" onclick="event.stopPropagation();openBasis('${esc(annKeyOf(s))}','${esc(String(s.label || "").replace(/['\\]/g, ""))}')">
- <span class="bs-h">🔗 决策依据</span>${tags}<span class="bs-n">›</span></div>`;
- }
- return `<div class="pcard pc-${tone} ${mods}"${s.key ? ` data-slot="${esc(s.key)}"` : ""}${
- s.hint ? ` title="${esc(s.hint)}"` : ""} onclick="openCard('${key}',${i})">
- <div class="pcard-h"><span class="pc-ic">${s.icon || pcIcon(s)}</span><span class="pc-label">${esc(s.label || "")}</span>${
- failed ? `<span class="pc-fail-badge">✗ 失败</span>` : ""}${s.tokens ? `<span class="pc-tok">${esc(s.tokens)}</span>` : ""}</div>
- ${s.tool_call_id ? `<div class="pc-id id-chip" title="tool_call_id(点击复制)"
- onclick="event.stopPropagation();copyId('${esc(s.tool_call_id)}',this)">⧉ ${esc(String(s.tool_call_id).slice(-10))}</div>`
- : (s.type === "llm" && s.node_id ? `<div class="pc-id id-chip" title="节点 id(点击复制)· 重标这一步:--node ${esc(String(s.node_id))}"
- onclick="event.stopPropagation();copyId('${esc(String(s.node_id))}',this)">⧉ #${esc(String(s.node_id))}</div>` : "")}
- ${s.reasoning ? `<div class="pc-think"><div class="pc-think-h">💭 思考</div>
- <div class="pc-think-b">${esc(s.reasoning)}</div></div>` : ""}
- ${(s.summary && !ann) ? `<div class="pcard-sum">${esc(s.summary)}</div>` : ""}
- ${(s.type === "llm" && !s.summary && !s.reasoning && !callBlock)
- ? `<div class="pc-note">(这一轮模型没有文本输出)</div>` : ""}
- ${annBlock}
- ${ioBlock}
- ${callBlock}
- ${fields}
- ${ops}
- ${next}
- ${toolChips}
- ${slots}
- ${arts}
- ${arts ? "" : chips}
- ${(arts || s.io || callBlock) ? "" : thumbs}
- <div class="pc-more">查看详情 ›</div>
- ${basis}
- </div>`;
- }
- const pcIcon = s => ({ tool: "🔧", llm: "◆", input: "⇢" })[s.type] || "•";
- // update 的任务卡 → 看**这一改到底改了什么**:逐字段「旧 → 新」(有旧值走字符级 diff,否则给新值全文)
- function openOpDiff(key, i) {
- if (BOARD && BOARD.suppressClick) return;
- const o = (SNAP.cards[key] || [])[i]; if (!o) return;
- const t = (o.tasks || [])[0] || {};
- $("#drawerTag").textContent = "PLAN · 修改";
- $("#drawerTag").className = "drawer-tag plan";
- $("#drawerTitle").textContent = `Task #${t.step_id ?? ""} ${t.name || ""}`;
- const patch = o.patch || {}, changes = o.changes || {};
- const rows = Object.keys(patch).map(f => {
- const pair = changes[f];
- const v = typeof patch[f] === "string" ? patch[f] : JSON.stringify(patch[f], null, 2);
- return `<div class="field sec-cell"><div class="lbl">${esc(f)}</div>` +
- (Array.isArray(pair)
- ? `<div class="sec-meta">旧 → 新(字符级对比)</div><div class="chg-v">${
- renderInlineDiff(typeof pair[0] === "string" ? pair[0] : JSON.stringify(pair[0]),
- typeof pair[1] === "string" ? pair[1] : JSON.stringify(pair[1]))}</div>`
- : `<div class="sec-meta">本轮设为(该字段无旧值快照,只给新值)</div><div class="pp-msg">${renderRich(v)}</div>`) +
- `</div>`;
- }).join("");
- $("#drawerBody").innerHTML = (rows || `<div class="val" style="color:var(--faint)">(本次未改字段)</div>`);
- openDrawer();
- }
- // 槽里的任务卡 → 看**这条 Task 在该槽里逐字进 prompt 的那段**(所见即模型所见)
- function openSlotTask(key, i) {
- if (BOARD && BOARD.suppressClick) return;
- const t = (SNAP.cards[key] || [])[i]; if (!t) return;
- $("#drawerTag").textContent = "输入 · 进槽数据";
- $("#drawerTag").className = "drawer-tag plan";
- $("#drawerTitle").textContent = t.name || `任务 #${t.step_id}`;
- const raw = Object.fromEntries(Object.entries(t).filter(([k]) => k !== "prompt_text"));
- $("#drawerBody").innerHTML =
- (t.prompt_text
- ? `<div class="field"><div class="lbl">进入 prompt 的原文(这一槽里属于本 Task 的那段)</div>
- <div class="pp-msg">${renderRich(t.prompt_text)}</div></div>`
- : `<div class="field"><div class="val" style="color:var(--faint)">这一槽未按 Task 逐条渲染(旧 run 无此字段)</div></div>`) +
- `<div class="field"><div class="lbl">结构化条目 · item</div><div class="pp-msg">${
- renderRich(JSON.stringify(raw, null, 2))}</div></div>`;
- openDrawer();
- }
- // 卡片点击 → 抽屉看完整详情(后端 detail:工具=args/result;模型=text/calls/payload;其它=text)
- function openCard(key, i) {
- if (BOARD && BOARD.suppressClick) return; // 刚拖拽平移过 → 不误触
- const s = (SNAP.cards[key] || [])[i]; if (!s) return;
- const [nodeId, seg] = String(key).split(":");
- $("#drawerTag").textContent = `${nodeId}${seg ? " · " + ({ in: "输入", out: "输出", struct: "结构", flow: "运行流程" }[seg] || seg) : ""}`;
- $("#drawerTag").className = "drawer-tag " + (nodeId === "plan" ? "plan" : "execute");
- $("#drawerTitle").textContent = s.label || "详情";
- SNAP.openNodeSid = null;
- $("#drawerBody").innerHTML = renderCardDetail(s);
- openDrawer();
- // 工具详情:定义按需取(代码里的静态信息,不进运行记录),到手后就地重画这一屏
- if ((s.detail || {}).tools && !TOOLDEF) {
- loadToolDefs(SNAP.agent).then(() => {
- if ($("#drawer").classList.contains("open")) $("#drawerBody").innerHTML = renderCardDetail(s);
- });
- }
- }
- // Python 字符串转义(\n \t \xNN \uNNNN \' \" 等)→ 真实字符,供转 JSON 前还原。
- function unescapePyStr(s) {
- return s.replace(/\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|.)/g, (m, e) => {
- if (e[0] === "x" || e[0] === "u") return String.fromCharCode(parseInt(e.slice(1), 16));
- return ({ n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", "0": "\0", "\\": "\\", "'": "'", '"': '"' })[e] ?? e;
- });
- }
- // Python 字面量 repr(单引号/混合引号字符串、True/False/None)→ JSON 文本:逐字符扫描,字符串区整体透传
- // (正确处理内嵌撇号与转义),非字符串区把 True/False/None 转 true/false/null;遇未知裸标识符 → 返回 null 放弃。
- function pyReprToJson(src) {
- const s = String(src); let out = "", i = 0;
- while (i < s.length) {
- const c = s[i];
- if (c === "'" || c === '"') {
- i++; let str = "";
- while (i < s.length && s[i] !== c) { if (s[i] === "\\") { str += s[i] + (s[i + 1] || ""); i += 2; } else { str += s[i]; i++; } }
- i++; out += JSON.stringify(unescapePyStr(str));
- } else if (/[A-Za-z_]/.test(c)) {
- let w = ""; while (i < s.length && /[A-Za-z0-9_]/.test(s[i])) { w += s[i]; i++; }
- if (w === "True") out += "true"; else if (w === "False") out += "false"; else if (w === "None") out += "null"; else return null;
- } else { out += c; i++; }
- }
- return out;
- }
- // 富文本渲染:对象/JSON 串(含 Python dict repr)→ 美化 JSON(图片以缩略图条附于末尾);否则按 markdown
- // 渲染(图片已就地内联,不再附条)。返回可放进 .pp-msg 的内容。
- function renderRich(raw) {
- let content, isMd = false;
- if (raw == null || (typeof raw !== "object" && !String(raw).trim())) content = `<div class="pp-content"></div>`;
- else if (typeof raw === "object") content = `<div class="pp-content pp-json">${esc(JSON.stringify(raw, null, 2))}</div>`;
- else {
- const s = String(raw).trim();
- let json;
- if (/^[[{]/.test(s)) {
- try { json = JSON.stringify(JSON.parse(s), null, 2); }
- catch (e) { // 非严格 JSON → 试作 Python repr 归一后再解析
- const jt = pyReprToJson(s);
- if (jt != null) { try { json = JSON.stringify(JSON.parse(jt), null, 2); } catch (e2) {} }
- }
- }
- if (json != null) content = `<div class="pp-content pp-json">${esc(json)}</div>`;
- else { content = `<div class="pp-md">${renderMarkdown(s)}</div>`; isMd = true; }
- }
- return content; // 图片不在这里捞:卡片/详情的图由后端 images 字段给定(markdown 里的图仍就地内联)
- }
- // 轻量 markdown → HTML(先转义再套标签,防注入):标题 / 粗斜体 / 行内码 / 代码块 / 列表 / 引用 /
- // **表格(GFM)** / 链接 / 段落。表格必须支持——plan 的 system prompt 里「缺陷性质 → 处方」这类对照
- // 全是表格,不渲染就是一屏竖排的管道符,等于没法读。
- function renderMarkdown(src) {
- const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
- const inline = t => esc(t)
- .replace(/`([^`]+)`/g, "<code>$1</code>")
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
- .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>")
- .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
- // 裸 URL 就地渲染:图片→内联 <img>(结合上下文文字直接看图),其它→可点链接。
- // 负向后瞻跳过已在标签属性里的 URL(href="…" / src="…" / 紧跟 > 的 <code>URL</code>)。
- .replace(/(?<![="(>])(https?:\/\/[^\s<>"'`)\]}\\、,。;:)】》」|]+)/g, (m, url) => {
- // 去尾:标点 + URL 编码括号(%22=" %5D=] %29=) %7D=})+ 被转义的实体(" > < &)——
- // inline 跑在已转义文本上,紧随 URL 的闭合引号会变成 " 被吞进匹配,需一并剥掉,否则 src 带尾巴致 404。
- const u = url.replace(/(?:[.,;)\]}]|%22|%5[Dd]|%29|%7[Dd]|"|>|<|&)+$/i, "");
- return isImgUrl(u)
- ? `<img class="md-img" src="${u}" referrerpolicy="no-referrer" loading="lazy" onclick="openLB('${u}')" onerror="imgFail(this)">`
- : `<a href="${u}" target="_blank" rel="noreferrer">${u}</a>`;
- });
- const isList = l => /^\s*([-*+]|\d+\.)\s+/.test(l), isH = l => /^#{1,6}\s+/.test(l),
- isQ = l => /^\s*>\s?/.test(l), isFence = l => /^```/.test(l);
- // 表格:一行以 | 起(或含 |)+ 下一行是 |---|---| 分隔行 —— 两条同时满足才当表格,
- // 免得把正文里偶然出现的竖线误判。单元格切分要**忽略被 \ 转义的 |**。
- const isSep = l => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l);
- const isRow = l => /\|/.test(l) && !isFence(l);
- const cells = l => l.replace(/^\s*\|/, "").replace(/\|\s*$/, "")
- .split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, "|"));
- let html = "", i = 0;
- while (i < lines.length) {
- const line = lines[i];
- if (isFence(line)) {
- i++; const code = [];
- while (i < lines.length && !isFence(lines[i])) { code.push(lines[i]); i++; }
- i++; html += `<pre class="rich-code">${esc(code.join("\n"))}</pre>`; continue;
- }
- const hm = /^(#{1,6})\s+(.*)$/.exec(line);
- if (hm) { const lv = Math.min(6, hm[1].length); html += `<div class="rich-h rich-h${lv}">${inline(hm[2])}</div>`; i++; continue; }
- if (isRow(line) && i + 1 < lines.length && isSep(lines[i + 1])) {
- const head = cells(line);
- const align = cells(lines[i + 1]).map(c =>
- /^:-+:$/.test(c) ? "center" : /-+:$/.test(c) ? "right" : "left");
- i += 2;
- const rows = [];
- while (i < lines.length && isRow(lines[i]) && !/^\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
- const ta = k => ` style="text-align:${align[k] || "left"}"`;
- html += `<div class="rich-table-wrap"><table class="rich-table">
- <thead><tr>${head.map((c, k) => `<th${ta(k)}>${inline(c)}</th>`).join("")}</tr></thead>
- <tbody>${rows.map(r => `<tr>${head.map((_, k) =>
- `<td${ta(k)}>${inline(r[k] || "")}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
- continue;
- }
- if (isList(line)) {
- const ordered = /^\s*\d+\.\s+/.test(line); const items = [];
- while (i < lines.length && isList(lines[i])) { items.push(inline(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ""))); i++; }
- html += `<${ordered ? "ol" : "ul"} class="rich-list">${items.map(x => `<li>${x}</li>`).join("")}</${ordered ? "ol" : "ul"}>`; continue;
- }
- if (isQ(line)) {
- const q = [];
- while (i < lines.length && isQ(lines[i])) { q.push(inline(lines[i].replace(/^\s*>\s?/, ""))); i++; }
- html += `<blockquote class="rich-quote">${q.join("<br>")}</blockquote>`; continue;
- }
- if (/^\s*$/.test(line)) { i++; continue; }
- const para = [];
- while (i < lines.length && !/^\s*$/.test(lines[i]) && !isFence(lines[i]) && !isH(lines[i])
- && !isList(lines[i]) && !isQ(lines[i])
- && !(isRow(lines[i]) && i + 1 < lines.length && isSep(lines[i + 1]))) { // 表头行留给表格分支
- para.push(inline(lines[i])); i++;
- }
- html += `<p class="rich-p">${para.join("<br>")}</p>`;
- }
- return html;
- }
- // 工具调用输入:若 args 是对象,逐字段渲染(字段名作小标题、字符串值走 markdown 富文本),
- // 让 think_and_plan 这类长文本参数不再是「转义 \n + 未渲染 markdown」的一坨 JSON;非对象则原样富文本。
- // 一坨数据 → 抽屉里的若干块:**对象逐键一块**(键当小标题,值按 markdown / JSON 渲染),
- // 其余整块铺开。这样长文本字段(think_and_plan 的 thought/plan/action)是分开可读的,
- // 而不是塞进一个 JSON 串里。
- // 一段数据的**规模**:几个字段 / 几项 / 多少字。抬头右侧标一下,读者不点开也知道这块有多大。
- const dataMeta = v => {
- if (v == null) return "";
- if (Array.isArray(v)) return `${v.length} 项`;
- if (typeof v === "object") return `${Object.keys(v).length} 个字段`;
- const n = String(v).length;
- return n > 40 ? `${n} 字` : "";
- };
- function renderArgs(raw) {
- let obj = null;
- if (raw && typeof raw === "object" && !Array.isArray(raw)) obj = raw;
- else if (typeof raw === "string" && /^\s*\{/.test(raw)) {
- try { const p = JSON.parse(raw); if (p && typeof p === "object" && !Array.isArray(p)) obj = p; } catch (e) {}
- }
- if (!obj || !Object.keys(obj).length) return `<div class="pp-msg">${renderRich(raw)}</div>`;
- return Object.entries(obj).map(([k, v]) =>
- `<div class="pp-msg"><div class="pp-role"><span class="pr-k">${esc(k)}</span>${
- dataMeta(v) ? `<span class="pr-meta">${esc(dataMeta(v))}</span>` : ""}</div>${renderRich(v)}</div>`).join("");
- }
- // 「输入」/「输出」一区:**一区一块**——左侧色轨 + 同色抬头(入青、出绿,与卡面上那两段同一套配色),
- // 于是「哪些是吃进去的、哪些是吐出来的」是靠颜色与分块一眼分开的,而不是靠读两个小字标签。
- // 空就不画(不摆一个写着「(无)」的空壳)。
- function ioField(label, v, side) {
- const empty = v == null || v === "" ||
- (typeof v === "object" && !Array.isArray(v) && !Object.keys(v).length) ||
- (Array.isArray(v) && !v.length);
- if (empty) return "";
- return `<div class="io-block ${side}">
- <div class="iob-h"><span class="iob-ic">${side === "in" ? "⇢" : "⇠"}</span>
- <span class="iob-t">${esc(label)}</span>
- <span class="iob-meta">${esc(dataMeta(v))}</span></div>
- <div class="iob-b">${renderArgs(v)}</div></div>`;
- }
- // 工具项展开/折叠(详情里默认全折叠:一屏先看清"有哪些工具、哪些用过",要细节再点开)
- function toggleTool(i) {
- const el = document.getElementById("tool-item-" + i);
- if (el) el.classList.toggle("open");
- }
- function renderCardDetail(s) {
- const d = s.detail || {};
- let h = "";
- if (d.tools) { // 工具清单详情:**默认全部折叠**,点标题展开「说明 + 参数表」
- const used = new Set(d.usedTools || []);
- const defs = TOOLDEF || {};
- return d.tools.map((x, i) => {
- const t = { ...(defs[x.name] || {}), name: x.name };
- const np = (t.params || []).length;
- return `<div class="field sec-cell tool-item" id="tool-item-${i}">
- <div class="lbl tool-hd" onclick="toggleTool(${i})">
- <span class="tool-arrow">▸</span>${esc(t.name)}${used.has(t.name)
- ? `<span class="tool-used-tag">本次用过</span>` : ""}
- <span class="tool-meta">${t.description ? `${t.description.length} 字说明` : "无说明"}${
- np ? ` · ${np} 个参数` : ""}</span></div>
- <div class="tool-bd">
- ${t.description ? `<div class="pp-msg">${renderRich(t.description)}</div>`
- : `<div class="val" style="color:var(--faint)">(未解析到该工具的定义:当前代码里已无同名工具?)</div>`}
- ${np ? `<div class="tool-params">${t.params.map(p =>
- `<div class="tp-row"><span class="tp-n">${esc(p.name)}</span>
- <span class="tp-t">${esc(p.type || "—")}</span>
- <span class="tp-r ${p.required ? "req" : ""}">${p.required ? "必填" : "可选"}</span>
- <span class="tp-d">${esc(p.desc || "")}</span></div>`).join("")}</div>` : ""}
- </div>
- </div>`; }).join("");
- }
- if (d.sections) { // 逐段分块:每段标题 + 来源说明 + **完整**内容 + 该段的图
- h = d.sections.map(sec => `<div class="field sec-cell">
- <div class="lbl">${esc(sec.label)}</div>
- ${sec.meta ? `<div class="sec-meta">${esc(sec.meta)}</div>` : ""}
- ${sec.text ? `<div class="pp-msg">${renderRich(sec.text)}</div>` : ""}
- ${(sec.images || []).length ? `<div class="imgs" style="margin-top:8px">${sec.images.map(u =>
- `<img class="thumb" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`).join("")}</div>` : ""}
- ${(!sec.text && !(sec.images || []).length) ? `<div class="val" style="color:var(--faint)">(空)</div>` : ""}
- </div>`).join("");
- return h;
- }
- // ── 运行节点的详情:**一律「输入 / 输出」两段,数据直接铺开** ──────────────────────
- // 工具 / 模型推理 / 结构化收口,点开看到的是同一种东西:这一步吃进什么、吐出什么。
- // 没有的那一段不画(模型推理没有独立入参 —— 它看到的是本段 ① 输入与前面几步,不在这个节点里)。
- if (d.input !== undefined || d.output !== undefined || d.calls || d.reasoning) {
- h += ioField("输入", d.input, "in");
- // 思考在输出之前:模型先想、再说、再决定调什么工具,按这个次序读
- if (d.reasoning) h += `<div class="io-block call"><div class="iob-h"><span class="iob-ic">💭</span>
- <span class="iob-t">思考过程</span><span class="iob-meta">${esc(dataMeta(d.reasoning))}</span></div>
- <div class="iob-b"><div class="pp-msg">${renderRich(d.reasoning)}</div></div></div>`;
- h += ioField("输出", d.output, "out");
- // 模型推理这一步的另一半产出:它决定调用的工具。用紫色一区(与卡面上那块同色),
- // 一个调用一组:序号 + 工具名,参数仍是逐字段铺开。
- if ((d.calls || []).length)
- h += `<div class="io-block call">
- <div class="iob-h"><span class="iob-ic">⚡</span><span class="iob-t">推理结果 · 调用工具</span>
- <span class="iob-meta">${d.calls.length} 个</span></div>
- <div class="iob-b">${d.calls.map((c, k) => `<div class="call-item">
- <div class="ci-h"><span class="ci-i">${k + 1}</span><span class="ci-n">${esc(c.name)}</span>
- <span class="iob-meta">${esc(dataMeta(c.args))}</span></div>
- <div class="ci-b">${renderArgs(c.args)}</div></div>`).join("")}</div></div>`;
- } else {
- 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>`;
- if (d.payload != null) h += `<div class="field"><div class="lbl">结构化产物</div><div class="pp-msg">${
- renderRich(JSON.stringify(d.payload, null, 2))}</div></div>`;
- if (d.json != null && d.payload == null && !d.text) h += `<div class="field"><div class="pp-msg">${
- renderRich(JSON.stringify(d.json, null, 2))}</div></div>`;
- }
- if (d.images && d.images.length)
- h += `<div class="field"><div class="lbl">图片 · ${d.images.length}</div><div class="imgs">${d.images.map(u =>
- `<img class="thumb" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`).join("")}</div></div>`;
- return h || `<div class="val" style="color:var(--faint)">(无更多详情)</div>`;
- }
- function renderLesson(l) {
- const src = l.source === "verdict" ? "执行" : (l.source === "planner" ? "规划" : (l.source || ""));
- const cls = l.source === "verdict" ? "verdict" : "planner";
- 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>`;
- }
- // 卡片点击 → 抽屉展示节点全量详情(含跳执行过程 traces)
- // planOnly=true(从 plan 的输出点开):只看**规划侧**的 Task 定义,不掺执行结果——
- // 那一刻这个 Task 还没跑,显示产物/verdict/工具是张冠李戴。
- function openSnapNode(sid, planOnly) {
- if (BOARD && BOARD.suppressClick) return; // 刚拖拽平移过 → 不误触
- const snap = SNAP.list[SNAP.idx];
- const node = ((snap.tree && snap.tree.nodes) || []).find(n => String(n.step_id) === String(sid));
- if (!node) return;
- SNAP.openNodeSid = sid; // 记下当前所看任务 → 刷新时可重开
- SNAP.openNodePlanOnly = !!planOnly;
- const status = planOnly && node.role !== "container" ? "planned" : nodeDisplayStatus(node);
- $("#drawerTag").textContent = SNAP_STATUS[status] || status;
- $("#drawerTag").className = "drawer-tag " + (planOnly || node.role === "container" ? "plan" : "execute");
- $("#drawerTitle").textContent = node.name || `任务 #${sid}`;
- $("#drawerBody").innerHTML = renderSnapNodeDetail(node, snap, !!planOnly);
- openDrawer();
- }
- // 依赖 / 参考图 inputs:外链图 → 缩略图;引用前序产物 → chip(前序产物 URL 由快照里的兄弟节点解析)
- function renderNodeInputs(inputs, snap) {
- if (!Array.isArray(inputs) || !inputs.length) return "";
- const prod = {};
- for (const n of ((snap && snap.tree && snap.tree.nodes) || []))
- if (n.result && n.result.product_url) prod[String(n.step_id)] = n.result.product_url;
- return inputs.map(inp => {
- const ext = inp && inp.external;
- if (ext) return isImgUrl(ext)
- ? `<img class="thumb ref" src="${esc(ext)}" referrerpolicy="no-referrer" onclick="openLB('${esc(ext)}')" onerror="imgFail(this)">`
- : `<span class="refchip">↗ ${esc(ext)}</span>`;
- if (inp && inp.from_step != null) {
- const u = prod[String(inp.from_step)];
- return u ? `<img class="thumb ref" src="${esc(u)}" referrerpolicy="no-referrer" title="引用任务 #${esc(inp.from_step)} 的产物" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`
- : `<span class="refchip">↑ 引用任务 #${esc(inp.from_step)} 产物</span>`;
- }
- return "";
- }).filter(Boolean).join("");
- }
- // 验收标准逐条渲染:结构化项拆成「标准(主)+ 说明(次)+ 重要度 + 参考图」;纯字符串直接展示。
- function renderAcceptance(items) {
- return items.map(c => {
- if (typeof c !== "object" || c === null) return `<li>${esc(String(c))}</li>`;
- const imp = c.importance ? `<span class="mmc-tag">${esc(c.importance)}</span>` : "";
- const std = c.standard != null ? `<div class="acc-std">${esc(c.standard)} ${imp}</div>` : "";
- const desc = c.description != null ? `<div class="acc-desc">${esc(c.description)}</div>` : "";
- const urls = Array.isArray(c.reference_urls) ? c.reference_urls : [];
- const refs = urls.length ? `<div class="imgs acc-refs">${urls.map(u => isImgUrl(u)
- ? `<img class="thumb ref" src="${esc(u)}" referrerpolicy="no-referrer" onclick="openLB('${esc(u)}')" onerror="imgFail(this)">`
- : `<span class="refchip">↗ ${esc(u)}</span>`).join("")}</div>` : "";
- return (std || desc || refs) ? `<li>${std}${desc}${refs}</li>` : `<li>${esc(JSON.stringify(c))}</li>`;
- }).join("");
- }
- function renderSnapNodeDetail(node, snap, planOnly) {
- const sid = node.step_id, r = planOnly ? null : node.result;
- const dstatus = planOnly && node.role !== "container" ? "planned" : nodeDisplayStatus(node);
- const chgHtml = renderChangesDiff(snap, sid);
- let h = `<div class="tc-meta" style="margin-top:0">
- <span class="metachip">任务 #${esc(sid)}</span>
- <span class="metachip">${node.role === "container" ? "容器 · 已拆分" : SNAP_STATUS[dstatus] || dstatus}</span>
- ${node.parent_id != null ? `<span class="metachip">← 父 #${esc(node.parent_id)}</span>` : ""}
- ${(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>` : ""}
- </div>`;
- if (chgHtml) h += `<div class="field"><div class="lbl">规划变化 · changes(本轮 diff)</div><div class="chg-list">${chgHtml}</div></div>`;
- if (node.goal) h += `<div class="field"><div class="lbl">目标 · goal</div><div class="val">${esc(node.goal)}</div></div>`;
- if (node.stage || node.category)
- h += `<div class="field"><div class="lbl">制作属性 · stage & category</div><div class="mmc-attrs" style="margin-top:0">
- ${node.stage ? `<span class="attr-chip stage"><span class="k">阶段</span>${esc(node.stage)}</span>` : ""}
- ${node.category ? `<span class="attr-chip cat"><span class="k">操作</span>${esc(node.category)}</span>` : ""}</div></div>`;
- // 依赖 / 参考图 · inputs(执行时喂入的素材)
- const refHtml = renderNodeInputs(node.inputs, snap);
- if (refHtml) h += `<div class="field"><div class="lbl">依赖 / 参考图 · inputs</div><div class="imgs">${refHtml}</div></div>`;
- // 验收标准 · acceptance(评估据此逐条核对)
- if (Array.isArray(node.acceptance) && node.acceptance.length)
- h += `<div class="field"><div class="lbl">验收标准 · acceptance</div><ul class="acc-list">${
- renderAcceptance(node.acceptance)}</ul></div>`;
- // 能力 kind + 执行时用到的工具(执行侧信息;只看规划时不显示)
- if (!planOnly && (node.kind || (node.tools && node.tools.length))) {
- h += `<div class="field"><div class="lbl">能力 / 工具 · kind & tools</div><div class="mmc-tools" style="margin-top:0">
- ${node.kind ? `<span class="kind-chip">⚙ ${esc(node.kind)}</span>` : ""}
- ${(node.tools || []).map(t => `<span class="tool-chip">${esc(t)}</span>`).join("")}
- </div></div>`;
- }
- if (node.role === "container") {
- h += `<div class="field"><div class="val" style="color:var(--faint)">该任务已被拆分,由子任务集体兑现,本身不直接执行。</div></div>`;
- return h;
- }
- if (r) {
- if (r.product_kind === "image" && r.product_url)
- 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>`;
- else if (r.product_text)
- h += `<div class="field"><div class="lbl">产物 · text</div><div class="val">${esc(r.product_text)}</div></div>`;
- if (r.summary) h += `<div class="field"><div class="lbl">结果摘要 · summary</div><div class="val">${esc(r.summary)}</div></div>`;
- const cls = r.verdict_pass ? "pass" : "fail";
- h += `<div class="field"><div class="lbl">评估 · verdict${r.attempts > 1 ? ` · 尝试 ${r.attempts} 次` : ""}</div>
- <div class="a-verdict-box ${cls}">
- <div style="font-family:var(--mono);font-size:11px;font-weight:700;color:var(--${r.verdict_pass ? "ok" : "fail"})">${r.verdict_pass ? "✓ 达标" : "✗ 未达标"}</div>
- ${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>` : ""}
- ${r.verdict_reason ? `<div class="vd-note">${esc(r.verdict_reason)}</div>` : ""}
- </div></div>`;
- } else if (!planOnly) {
- h += dstatus === "running"
- ? `<div class="pending-note" style="color:var(--run)">▸ 正在执行中… 执行过程(工具调用 / 评估)会在该步完成后归档,届时可在此查看。</div>`
- : `<div class="pending-note">◔ 本轮该任务尚未执行(在剩余队列中,等待 execute)。</div>`;
- }
- // 完整原始数据(这个 Task 在本轮快照里的全部字段),便于核对
- const raw = planOnly
- ? Object.fromEntries(Object.entries(node).filter(([k]) =>
- !["result", "tools", "trace_summary", "exec_step_id", "executing"].includes(k)))
- : node;
- h += `<div class="field"><div class="lbl">完整数据 · raw</div><div class="pp-msg">${
- renderRich(JSON.stringify(raw, null, 2))}</div></div>`;
- // 内部执行过程不在此渲染:execute/evaluate 的过程已在画板的对应节点里呈现。
- return h;
- }
- let _mmResizeT = null;
- window.addEventListener("resize", () => {
- if (!SNAP || !SNAP.loaded) return;
- clearTimeout(_mmResizeT); _mmResizeT = setTimeout(() => layoutBoard(), 120);
- });
- // ── controls ──
- $("#statusSeg").addEventListener("click", e => {
- const b = e.target.closest("button"); if (!b) return;
- [...$("#statusSeg").children].forEach(x => x.classList.toggle("active", x === b));
- STATUS = b.dataset.s; PAGE = 1; loadRuns();
- });
- $("#refreshBtn").addEventListener("click", () => {
- const b = $("#refreshBtn"); b.classList.add("spin"); setTimeout(() => b.classList.remove("spin"), 450);
- loadRuns(); if (CURRENT) selectRun(CURRENT, curRound());
- });
- loadRuns().then(openFromHash);
- </script>
- </body>
- </html>
|