index.html 482 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>VLCode Lite</title>
  7. <link id="dynFavicon" rel="icon" type="image/png" sizes="32x32" href="/assets/vlcode-lite-favicon-32.png?v=20260315">
  8. <link rel="icon" type="image/png" sizes="16x16" href="/assets/vlcode-lite-favicon-16.png?v=20260315">
  9. <link rel="icon" type="image/svg+xml" href="/assets/vlcode-lite-icon.svg?v=20260315">
  10. <!-- CodeMirror 5 (served locally from node_modules) -->
  11. <link rel="stylesheet" href="/lib/codemirror/lib/codemirror.css">
  12. <link rel="stylesheet" href="/lib/codemirror/addon/fold/foldgutter.css">
  13. <script src="/lib/codemirror/lib/codemirror.js"></script>
  14. <script src="/lib/codemirror/addon/edit/matchbrackets.js"></script>
  15. <script src="/lib/codemirror/addon/edit/closebrackets.js"></script>
  16. <script src="/lib/codemirror/addon/selection/active-line.js"></script>
  17. <script src="/lib/codemirror/addon/fold/foldcode.js"></script>
  18. <script src="/lib/codemirror/addon/fold/foldgutter.js"></script>
  19. <script src="/lib/codemirror/addon/fold/indent-fold.js"></script>
  20. <script src="/lib/codemirror/mode/javascript/javascript.js"></script>
  21. <script src="/lib/codemirror/mode/css/css.js"></script>
  22. <script src="/lib/codemirror/mode/xml/xml.js"></script>
  23. <script src="/lib/codemirror/mode/htmlmixed/htmlmixed.js"></script>
  24. <style>
  25. :root {
  26. --bg: #0d1117; --bg1: #0a0f14; --bg2: #161b22; --bg3: #21262d; --hover: #1b2129; --border: #30363d;
  27. --text: #e6edf3; --text2: #8b949e; --accent: #58a6ff; --accent-rgb: 88, 166, 255; --green: #3fb950;
  28. --yellow: #d29922; --orange: #f0883e; --red: #f85149; --purple: #a371f7; --blue: #79c0ff; --cyan: #5ccfe6;
  29. --font: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  30. }
  31. * { margin:0; padding:0; box-sizing:border-box; }
  32. body { background:var(--bg); color:var(--text); font-family:var(--font); font-size:13px; height:100vh; display:flex; flex-direction:column; }
  33. /* Header */
  34. header { background:linear-gradient(180deg, #151b23 0%, #111720 100%); border-bottom:1px solid var(--border); padding:4px 10px; display:flex; align-items:center; gap:8px; height:32px; z-index:110; position:relative; }
  35. body.desktop-app header { -webkit-app-region:drag; padding-left:84px; }
  36. body.desktop-app header button,
  37. body.desktop-app header input,
  38. body.desktop-app header select,
  39. body.desktop-app header textarea,
  40. body.desktop-app header .ws-current,
  41. body.desktop-app header .ws-popover,
  42. body.desktop-app header .wf-selector,
  43. body.desktop-app header .auth-status,
  44. body.desktop-app header .llm-badge,
  45. body.desktop-app header .ctx-bar { -webkit-app-region:no-drag; }
  46. header h1 { font-size:13px; color:var(--accent); font-weight:600; white-space:nowrap; }
  47. header .info { color:var(--text2); font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  48. header .spacer { flex:1; }
  49. header .ctx-bar { display:flex; align-items:center; gap:6px; }
  50. header .ctx-bar .bar { width:100px; height:5px; background:var(--bg3); border-radius:3px; overflow:hidden; }
  51. header .ctx-bar .bar-fill { height:100%; background:var(--green); border-radius:3px; transition:width 0.3s; }
  52. .hdr-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:3px 8px; border-radius:4px; cursor:pointer; font-family:var(--font); font-size:10px; white-space:nowrap; }
  53. .hdr-btn:hover { background:var(--border); color:var(--text); }
  54. .hdr-btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
  55. #compileBtn { background:rgba(63,185,80,0.15); color:#a8f0b2; border-color:rgba(63,185,80,0.35); }
  56. #compileBtn:hover { background:rgba(63,185,80,0.22); color:#fff; border-color:rgba(63,185,80,0.55); }
  57. /* Mode toggle switch */
  58. .mode-toggle { display:inline-flex; align-items:center; cursor:pointer; vertical-align:middle; }
  59. .mode-toggle-track { position:relative; width:80px; height:26px; border-radius:13px; background:var(--accent); border:1px solid var(--accent); transition:background .2s, border-color .2s; display:flex; align-items:center; justify-content:space-between; padding:0 8px; user-select:none; }
  60. .mode-toggle-track.human { background:#2ea043; border-color:#2ea043; }
  61. .mode-toggle-label { font-size:10px; font-weight:600; font-family:var(--font); color:rgba(255,255,255,.5); z-index:1; transition:color .2s; }
  62. .mode-toggle-track .mode-label-human { color:rgba(255,255,255,.4); }
  63. .mode-toggle-track .mode-label-ai { color:#fff; }
  64. .mode-toggle-track.human .mode-label-human { color:#fff; }
  65. .mode-toggle-track.human .mode-label-ai { color:rgba(255,255,255,.4); }
  66. .mode-toggle-thumb { position:absolute; top:2px; right:2px; width:20px; height:20px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.3); transition:right .2s, left .2s; }
  67. .mode-toggle-track.human .mode-toggle-thumb { right:auto; left:2px; }
  68. .hdr-btn-primary:hover { background:#79b8ff; }
  69. main { flex:1; display:flex; overflow:hidden; }
  70. /* File sidebar */
  71. .sidebar { width:220px; background:var(--bg2); border-right:1px solid var(--border); display:flex; flex-direction:column; position:relative; }
  72. .sidebar h3 { padding:10px 12px; font-size:11px; color:var(--text2); text-transform:uppercase; letter-spacing:1px; }
  73. .file-tree { flex:1; overflow-y:auto; padding:0 6px 6px; }
  74. .file-tree .category { margin-bottom:6px; }
  75. .file-tree .cat-name { color:var(--accent); font-size:10px; padding:3px 8px; cursor:pointer; }
  76. .file-tree .file { padding:2px 8px 2px 12px; cursor:pointer; color:var(--text2); border-radius:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:12px; }
  77. .file-tree .file:hover { background:var(--bg3); color:var(--text); }
  78. .file-tree .file.active { background:var(--accent); color:#fff; }
  79. /* Workflow selector dropdown */
  80. .wf-selector { position:relative; }
  81. .wf-dropdown { display:none; position:absolute; top:100%; right:0; margin-top:4px; background:var(--bg2); border:1px solid var(--border); border-radius:6px; min-width:240px; z-index:50; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
  82. .wf-dropdown.open { display:block; }
  83. .wf-item { padding:6px 10px; cursor:pointer; font-size:11px; display:flex; align-items:center; justify-content:space-between; }
  84. .wf-item:hover { background:var(--bg3); }
  85. .wf-item .wf-name { color:var(--text); }
  86. .wf-item .wf-steps { color:var(--text2); font-size:9px; }
  87. .wf-item .wf-view { color:var(--accent); font-size:9px; cursor:pointer; margin-left:6px; }
  88. .wf-item .wf-view:hover { text-decoration:underline; }
  89. /* Sidebar actions */
  90. .sidebar-actions { display:flex; gap:3px; padding:0 10px 6px; flex-wrap:wrap; }
  91. .sidebar-actions .sa-btn { display:inline-flex; align-items:center; gap:3px; background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:3px 7px; border-radius:4px; cursor:pointer; font-family:var(--font); font-size:9px; text-align:center; white-space:nowrap; }
  92. .sidebar-actions .sa-btn:hover { background:var(--border); color:var(--text); }
  93. .sidebar-actions .sa-btn.active { background:rgba(88,166,255,0.12); color:var(--accent); border-color:rgba(88,166,255,0.35); }
  94. .sidebar-actions .sa-btn.sa-danger:hover { background:var(--red); color:#fff; border-color:var(--red); }
  95. .sidebar-actions .sa-btn .sa-icon { font-size:10px; }
  96. /* File tree context menu */
  97. .ctx-menu { display:none; position:fixed; z-index:200; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:4px 0; min-width:140px; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
  98. .ctx-menu.open { display:block; }
  99. .ctx-menu-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); }
  100. .ctx-menu-item:hover { background:var(--bg3); color:var(--text); }
  101. .ctx-menu-item.danger { color:var(--red); }
  102. .ctx-menu-item.danger:hover { background:var(--red); color:#fff; }
  103. .ctx-menu-sep { height:1px; background:var(--border); margin:3px 0; }
  104. /* File icons — VS Code / Claude Code inspired */
  105. .file-icon { display:inline-block; width:14px; text-align:center; margin-right:4px; font-size:11px; flex-shrink:0; }
  106. .type-badge { display:inline-block; font-size:8px; padding:1px 3px; border-radius:2px; margin-right:3px; font-weight:600; }
  107. .type-app { background:#1f6feb33; color:var(--accent); }
  108. .type-section { background:#3fb95033; color:var(--green); }
  109. .type-component { background:#d2992233; color:var(--yellow); }
  110. .type-service { background:#f8514933; color:var(--red); }
  111. .type-database { background:#8b949e33; color:var(--text2); }
  112. .type-theme { background:#a371f733; color:var(--purple); }
  113. .type-process { background:#a371f720; color:#c49bff; }
  114. .type-json { background:#d2992220; color:#e0ad40; }
  115. .type-doc { background:#8b949e20; color:#9da5ae; }
  116. .type-image { background:#f0883e20; color:#f0883e; }
  117. .type-report { background:#3fb95020; color:#3fb950; }
  118. .type-log { background:#8b949e20; color:#8b949e; }
  119. .type-config { background:#d2992220; color:#d29922; }
  120. .type-workflow { background:#a371f720; color:#a371f7; }
  121. /* Mode tabs (Code / Map / Flow) */
  122. .mode-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); padding:0 8px; gap:2px; }
  123. .mode-tab { padding:6px 16px; cursor:pointer; color:var(--text2); font-size:11px; font-weight:600; border-bottom:2px solid transparent; border-radius:4px 4px 0 0; transition:all 0.15s; white-space:nowrap; }
  124. .mode-tab:hover { color:var(--text); background:var(--bg3); }
  125. .mode-tab.active { color:var(--accent); border-bottom-color:var(--accent); background:var(--bg); }
  126. /* Folder path link */
  127. .folder-path { padding:4px 12px; font-size:9px; color:var(--text2); cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:flex; align-items:center; gap:4px; border-bottom:1px solid var(--border); }
  128. .folder-path:hover { color:var(--accent); background:var(--bg3); }
  129. .folder-path .fp-icon { font-size:10px; flex-shrink:0; }
  130. /* Workflow binding labels */
  131. .wf-binding { display:flex; align-items:center; justify-content:space-between; padding:5px 10px; font-size:10px; border-bottom:1px solid var(--border); }
  132. .wf-binding .wf-b-label { color:var(--text2); }
  133. .wf-binding select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 4px; font-family:var(--font); font-size:9px; outline:none; max-width:140px; }
  134. /* Auto-linked URLs */
  135. .msg.assistant .content-text a { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
  136. .msg.assistant .content-text a:hover { color:#79b8ff; }
  137. /* Preview URL list in sidebar */
  138. .project-config { border-top:1px solid var(--border); }
  139. .pc-header { font-size:9px; color:var(--text2); padding:6px 12px; cursor:pointer; text-transform:uppercase; letter-spacing:0.5px; margin:0; }
  140. .pc-header:hover { color:var(--text); }
  141. .pc-file { padding:3px 12px 3px 18px; font-size:11px; cursor:pointer; color:var(--yellow); border-radius:4px; display:flex; align-items:center; justify-content:space-between; }
  142. .pc-file:hover { background:var(--bg3); color:var(--text); }
  143. .pc-sync-btn { background:none; border:1px solid var(--border); color:var(--text2); border-radius:3px; cursor:pointer; font-size:11px; padding:0 4px; line-height:1.4; }
  144. .pc-sync-btn:hover { color:var(--accent); border-color:var(--accent); }
  145. .doc-id-panel-note { padding:6px 12px 2px; font-size:9px; color:var(--text2); line-height:1.5; }
  146. .doc-id-panel-actions { display:flex; gap:6px; padding:6px 12px 4px; }
  147. .doc-id-panel-actions .pc-sync-btn { flex:1; padding:2px 0; }
  148. .doc-id-grid { display:flex; flex-direction:column; gap:6px; padding:6px 12px 10px; }
  149. .doc-id-grid .settings-doc-card { padding:8px; }
  150. .doc-id-section-title { padding:4px 12px 0; font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
  151. .doc-id-section-toggle { display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
  152. .doc-id-section-toggle:hover { color:var(--text); }
  153. .settings-doc-card.is-locked { opacity:0.78; }
  154. .settings-doc-card.is-locked input { opacity:0.65; cursor:not-allowed; }
  155. .settings-doc-header { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
  156. .settings-doc-card .settings-doc-title { font-size:11px; color:var(--text); font-weight:600; }
  157. .settings-doc-card .settings-doc-meta { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.4px; }
  158. .settings-doc-card .settings-doc-ref { font-size:10px; color:var(--accent); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  159. .settings-doc-card .settings-doc-link { font-size:9px; color:var(--text2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  160. .settings-doc-actions { display:flex; gap:6px; margin-top:2px; }
  161. .settings-doc-action { flex:1; background:var(--bg2); border:1px solid var(--border); color:var(--text2); border-radius:4px; cursor:pointer; font-size:10px; padding:4px 6px; font-family:var(--font); }
  162. .settings-doc-action:hover { color:var(--accent); border-color:var(--accent); }
  163. .settings-doc-action:disabled { opacity:0.5; cursor:not-allowed; }
  164. .pc-doc-toggle { font-size:9px; opacity:0.5; cursor:pointer; padding:1px 4px; border-radius:3px; background:none; border:none; color:var(--text2); }
  165. .pc-doc-toggle:hover { opacity:1; }
  166. .pc-doc-toggle.active { color:var(--green); opacity:1; }
  167. .preview-urls { padding:4px 12px 8px; }
  168. .preview-urls h4 { font-size:9px; color:var(--text2); margin-bottom:4px; text-transform:uppercase; letter-spacing:0.5px; }
  169. .preview-url-item { display:flex; align-items:center; gap:6px; padding:3px 6px; border-radius:3px; cursor:pointer; font-size:10px; color:var(--green); margin-bottom:2px; }
  170. .preview-url-item:hover { background:var(--bg3); }
  171. .preview-url-item .pui-name { font-weight:600; min-width:40px; }
  172. .preview-url-item .pui-url { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
  173. /* Cloud panel */
  174. .cloud-dot { width:6px; height:6px; border-radius:50%; background:var(--red); display:inline-block; }
  175. .cloud-dot.connected { background:var(--green); }
  176. .cloud-section { }
  177. .cloud-user { padding:4px 12px; font-size:10px; color:var(--text); display:flex; align-items:center; gap:6px; }
  178. .cloud-user .cu-name { font-weight:600; color:var(--accent); }
  179. .cloud-user .cu-company { color:var(--text2); font-size:9px; }
  180. .cloud-actions { display:flex; gap:4px; padding:4px 12px; }
  181. .cloud-actions .sa-btn { flex:1; text-align:center; font-size:9px; padding:3px 0; }
  182. .cloud-gid { padding:2px 0; }
  183. .cloud-status { padding:4px 12px; font-size:9px; }
  184. .cloud-status.syncing { color:var(--yellow); }
  185. .cloud-status.ok { color:var(--green); }
  186. .cloud-status.error { color:var(--red); }
  187. .cloud-app-item { padding:3px 12px; font-size:10px; cursor:pointer; color:var(--text2); display:flex; justify-content:space-between; align-items:center; }
  188. .cloud-app-item:hover { background:var(--bg3); color:var(--text); }
  189. .cloud-app-item .ca-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
  190. .cloud-app-item .ca-gid { font-size:8px; color:var(--text2); opacity:0.6; }
  191. #cloudBtn.connected { color:var(--green); }
  192. .cloud-login-tabs { display:flex; gap:2px; margin-bottom:12px; border-bottom:1px solid var(--border); }
  193. .cl-tab { padding:6px 14px; cursor:pointer; color:var(--text2); font-size:11px; font-weight:600; border-bottom:2px solid transparent; transition:all 0.15s; }
  194. .cl-tab:hover { color:var(--text); }
  195. .cl-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
  196. .cl-panel { }
  197. .cl-panel code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:10px; }
  198. /* Auth status in header (Claude Code style) */
  199. .auth-status { display:flex; align-items:center; gap:6px; font-size:11px; cursor:pointer; padding:3px 8px; border-radius:5px; border:1px solid var(--border); background:var(--bg3); }
  200. .auth-status:hover { background:var(--border); }
  201. .auth-status .auth-dot { width:6px; height:6px; border-radius:50%; background:var(--red); flex-shrink:0; }
  202. .auth-status .auth-dot.ok { background:var(--green); }
  203. .auth-status .auth-name { color:var(--text); max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  204. .auth-status .auth-label { color:var(--text2); }
  205. /* Message context toggle */
  206. .msg-ctx-toggle { position:absolute; top:4px; right:4px; background:none; border:none; color:var(--text2); cursor:pointer; font-size:10px; opacity:0; padding:2px 5px; border-radius:3px; transition:opacity 0.15s; }
  207. .msg:hover .msg-ctx-toggle { opacity:0.6; }
  208. .msg-ctx-toggle:hover { opacity:1 !important; background:var(--bg3); }
  209. .msg-ctx-toggle.excluded { color:var(--red); opacity:0.8 !important; }
  210. .msg.excluded-msg { opacity:0.4; border-left:2px solid var(--red); }
  211. .msg.excluded-msg .label::after { content:' (excluded from context)'; color:var(--red); font-size:8px; }
  212. /* Preview mode tab */
  213. .mode-tab[data-mode="preview"] { color:var(--green); }
  214. .mode-tab[data-mode="preview"].active { color:var(--green); border-bottom-color:var(--green); }
  215. .preview-bar { display:flex; align-items:center; gap:8px; padding:4px 12px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:10px; }
  216. .preview-bar .preview-url { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  217. .preview-bar .preview-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
  218. .preview-bar .preview-btn:hover { background:var(--border); color:var(--text); }
  219. .preview-bar select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 4px; font-family:var(--font); font-size:9px; outline:none; }
  220. /* Flow toolbar */
  221. .flow-toolbar { display:flex; align-items:center; justify-content:space-between; padding:4px 10px; background:var(--bg2); border-bottom:1px solid var(--border); gap:8px; }
  222. .flow-sub-tabs { display:flex; gap:2px; }
  223. .flow-sub-tab { padding:3px 12px; cursor:pointer; color:var(--text2); font-size:10px; font-weight:600; border-radius:4px; transition:all 0.15s; }
  224. .flow-sub-tab:hover { color:var(--text); background:var(--bg3); }
  225. .flow-sub-tab.active { color:var(--accent); background:var(--bg3); }
  226. .flow-actions { display:flex; align-items:center; gap:6px; }
  227. .flow-actions select { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; padding:2px 6px; font-family:var(--font); font-size:9px; outline:none; max-width:180px; }
  228. .flow-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
  229. .flow-btn:hover { background:var(--border); color:var(--text); }
  230. .flow-btn-run { background:var(--green); color:#000; border-color:var(--green); font-weight:700; }
  231. .flow-btn-run:hover { background:#2ea043; }
  232. .flow-btn-run:disabled { opacity:0.4; cursor:not-allowed; }
  233. .flow-btn-run.running { background:var(--orange); border-color:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
  234. .flow-run-status { font-size:9px; color:var(--text2); max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  235. /* Flow tab — workflow picker (Generate / Adjust sub-tabs) */
  236. .flow-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); font-size:10px; }
  237. .flow-wf-list.visible { display:flex; flex-wrap:wrap; gap:4px; padding:5px 10px; }
  238. .flow-wf-item { display:flex; flex-direction:column; gap:1px; padding:4px 10px; border-radius:4px; cursor:pointer; border:1px solid var(--border); color:var(--text2); min-width:90px; max-width:200px; }
  239. .flow-wf-item:hover { background:var(--bg3); color:var(--text); border-color:var(--accent); }
  240. .flow-wf-item.active { background:var(--accent); color:#fff; border-color:var(--accent); }
  241. .flow-wf-item .fwi-name { font-weight:600; font-size:10px; }
  242. .flow-wf-item .fwi-desc { font-size:8.5px; opacity:0.7; line-height:1.2; }
  243. .flow-wf-item.active .fwi-desc { opacity:0.85; }
  244. /* AutoTest 3-layer workflow hierarchy */
  245. .at-wf-list { display:none; background:var(--bg); border-bottom:1px solid var(--border); max-height:200px; overflow-y:auto; font-size:10px; }
  246. .at-wf-list.visible { display:block; }
  247. .at-wf-pipeline { display:flex; align-items:center; gap:6px; padding:4px 10px; cursor:pointer; color:var(--orange); font-weight:600; border-left:3px solid transparent; border-bottom:1px solid var(--border); }
  248. .at-wf-pipeline:hover { background:var(--bg2); }
  249. .at-wf-pipeline.active { background:var(--bg2); border-left-color:var(--orange); }
  250. .at-wf-apps { display:flex; flex-wrap:wrap; gap:4px; padding:4px 10px; border-bottom:1px solid var(--border); }
  251. .at-wf-app { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:3px; cursor:pointer; color:var(--text2); background:var(--bg2); border:1px solid transparent; font-size:10px; }
  252. .at-wf-app:hover { color:var(--text); border-color:var(--border); }
  253. .at-wf-app.active { color:var(--accent); border-color:var(--accent); background:rgba(100,180,255,0.1); }
  254. .at-wf-app .app-count { font-size:8px; color:var(--text2); margin-left:2px; }
  255. .at-wf-cases { display:flex; flex-wrap:wrap; gap:3px; padding:3px 10px; }
  256. .at-wf-case { display:inline-flex; align-items:center; gap:3px; padding:1px 6px; border-radius:2px; cursor:pointer; font-size:9px; color:var(--text2); background:var(--bg2); border:1px solid transparent; }
  257. .at-wf-case:hover { color:var(--text); border-color:var(--border); }
  258. .at-wf-case.active { color:var(--accent); border-color:var(--accent); }
  259. .at-wf-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; display:inline-block; }
  260. .at-wf-dot.idle { background:var(--text2); }
  261. .at-wf-dot.running { background:var(--orange); animation:pulse 1.5s ease-in-out infinite; }
  262. .at-wf-dot.done { background:var(--green); }
  263. .at-wf-dot.error { background:var(--red); }
  264. .at-wf-dot.skipped { background:#cc0; }
  265. .at-wf-dot.soft { background:#cc0; }
  266. /* Editor + Chat */
  267. .content { flex:1; display:flex; flex-direction:column; }
  268. .panels { flex:1; display:flex; overflow:hidden; margin-right:400px; }
  269. /* Editor */
  270. .editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); }
  271. .editor-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:32px; }
  272. .editor-tabs .tab { padding:6px 14px; cursor:pointer; color:var(--text2); border-bottom:2px solid transparent; white-space:nowrap; font-size:11px; display:flex; align-items:center; gap:6px; }
  273. .editor-tabs .tab.active { color:var(--text); border-bottom-color:var(--accent); background:var(--bg); }
  274. .editor-tabs .tab .tab-icon { font-size:10px; opacity:0.7; }
  275. .editor-tabs .tab .tab-close { font-size:9px; opacity:0; padding:1px 3px; border-radius:3px; line-height:1; }
  276. .editor-tabs .tab:hover .tab-close { opacity:0.5; }
  277. .editor-tabs .tab .tab-close:hover { opacity:1; background:var(--bg3); }
  278. .editor-area { flex:1; position:relative; overflow:hidden; }
  279. .editor-area textarea { width:100%; height:100%; background:var(--bg); color:var(--text); border:none; padding:12px; font-family:var(--font); font-size:13px; line-height:1.6; resize:none; outline:none; tab-size:2; display:none; }
  280. .editor-area .CodeMirror { width:100%; height:100%; font-family:'SF Mono','Fira Code','JetBrains Mono',monospace; font-size:13px; line-height:1.6; background:var(--bg); }
  281. .editor-area .CodeMirror-gutters { background:var(--bg2); border-right:1px solid var(--border); }
  282. .editor-area .CodeMirror-linenumber { color:var(--text2); opacity:0.5; padding:0 8px 0 4px; }
  283. .editor-area .CodeMirror-cursor { border-left:2px solid var(--accent); }
  284. .editor-area .CodeMirror-selected { background:rgba(88,166,255,0.15); }
  285. .editor-area .CodeMirror-activeline-background { background:rgba(255,255,255,0.03); }
  286. .editor-area .CodeMirror-matchingbracket { color:var(--green) !important; text-decoration:underline; }
  287. .editor-area .CodeMirror-foldgutter { width:14px; }
  288. .editor-area .CodeMirror-foldgutter-open, .editor-area .CodeMirror-foldgutter-folded { color:var(--text2); }
  289. /* Dark theme token colors — VL optimized, no black text */
  290. .editor-area .cm-keyword { color:#ff7b72; font-weight:600; }
  291. .editor-area .cm-variable-2 { color:#ffa657; }
  292. .editor-area .cm-def { color:#d2a8ff; }
  293. .editor-area .cm-string { color:#a5d6ff; }
  294. .editor-area .cm-number { color:#79c0ff; }
  295. .editor-area .cm-atom { color:#79c0ff; }
  296. .editor-area .cm-type { color:#79c0ff; font-weight:600; }
  297. .editor-area .cm-builtin { color:#7ee787; }
  298. .editor-area .cm-tag { color:#7ee787; font-weight:600; }
  299. .editor-area .cm-attribute { color:#79c0ff; }
  300. .editor-area .cm-property { color:#c9d1d9; }
  301. .editor-area .cm-comment { color:#8b949e; font-style:italic; }
  302. .editor-area .cm-meta { color:#8b949e; }
  303. .editor-area .cm-qualifier { color:#6e7681; }
  304. .editor-area .cm-indent-marker { color:#6e7681; }
  305. .editor-area .cm-section-header { color:#d2a8ff; font-weight:700; font-size:14px; }
  306. .editor-area .cm-operator { color:#e6edf3; }
  307. .editor-area .cm-variable { color:#c9d1d9; }
  308. .editor-area .cm-variable-3 { color:#ffa657; }
  309. .editor-area .cm-bracket { color:#e6edf3; }
  310. .editor-area .cm-punctuation { color:#8b949e; }
  311. .editor-area .cm-link { color:#58a6ff; }
  312. .editor-area .CodeMirror-line { color:var(--text); }
  313. .editor-area .CodeMirror pre.CodeMirror-line { color:#e6edf3; } /* JSON punctuation: unstyled spans inherit this */
  314. .editor-area .CodeMirror-scroll { scrollbar-color:var(--bg3) transparent; overflow:scroll !important; }
  315. .editor-area .CodeMirror-hscrollbar { height:10px !important; }
  316. .editor-area .CodeMirror-hscrollbar div { background:var(--bg3) !important; border-radius:4px; }
  317. .editor-area .iframe-container { display:none; width:100%; height:100%; }
  318. .editor-area .iframe-container iframe { width:100%; height:100%; border:none; background:#fff; }
  319. .editor-placeholder { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); color:var(--text2); text-align:center; line-height:2; }
  320. /* Code preview (read-only syntax view) */
  321. .code-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; }
  322. .code-preview pre { padding:12px; font-family:var(--font); font-size:13px; line-height:1.6; tab-size:2; margin:0; counter-reset:line; }
  323. .code-preview .line { display:block; white-space:pre-wrap; word-break:break-all; }
  324. .code-preview .line::before { counter-increment:line; content:counter(line); display:inline-block; width:3.5em; margin-right:1em; text-align:right; color:var(--text2); opacity:0.4; font-size:11px; user-select:none; }
  325. /* VL Syntax colors */
  326. .code-preview .kw { color:#ff7b72; } /* keywords: SERVICE, PUBLIC_SERVICE, SECTION, EVENT, etc */
  327. .code-preview .str { color:#a5d6ff; } /* strings */
  328. .code-preview .cmt { color:#8b949e; font-style:italic; } /* comments */
  329. .code-preview .var { color:#ffa657; } /* $variables */
  330. .code-preview .evt { color:#d2a8ff; } /* @events */
  331. .code-preview .type { color:#79c0ff; } /* type names */
  332. .code-preview .num { color:#79c0ff; } /* numbers */
  333. .code-preview .tag { color:#7ee787; } /* <Component-X>, <Section-Y> */
  334. .code-preview .prop { color:#d2a8ff; } /* property keys in JSON */
  335. /* Markdown preview */
  336. .md-preview { width:100%; height:100%; background:var(--bg); overflow:auto; display:none; padding:16px 24px; color:var(--text); line-height:1.7; }
  337. .md-preview h1 { font-size:20px; border-bottom:1px solid var(--border); padding-bottom:8px; margin:16px 0 12px; }
  338. .md-preview h2 { font-size:17px; border-bottom:1px solid var(--border); padding-bottom:6px; margin:14px 0 10px; }
  339. .md-preview h3 { font-size:14px; margin:12px 0 8px; }
  340. .md-preview p { margin:8px 0; }
  341. .md-preview ul, .md-preview ol { margin:8px 0; padding-left:24px; }
  342. .md-preview code { background:var(--bg3); padding:2px 5px; border-radius:3px; font-size:12px; }
  343. .md-preview pre { background:var(--bg3); padding:12px; border-radius:6px; overflow-x:auto; margin:8px 0; }
  344. .md-preview pre code { background:none; padding:0; }
  345. .md-preview blockquote { border-left:3px solid var(--accent); padding-left:12px; color:var(--text2); }
  346. /* Chat panel — fixed floating, draggable, always visible */
  347. .chat-panel { position:fixed; right:0; top:32px; width:400px; height:calc(100vh - 32px); z-index:100; display:flex; flex-direction:column; background:var(--bg); border:1px solid var(--border); box-shadow:-4px 0 16px rgba(0,0,0,0.3); border-radius:0; transition:none; }
  348. .chat-panel.floating { border-radius:8px; height:auto; bottom:auto; resize:both; overflow:hidden; min-height:300px; min-width:300px; max-width:800px; }
  349. .chat-resize-handle { position:absolute; left:-3px; top:0; bottom:0; width:6px; cursor:col-resize; z-index:101; }
  350. .chat-resize-handle:hover, .chat-resize-handle.dragging { background:var(--accent); opacity:0.3; }
  351. .chat-panel.collapsed { width:36px !important; min-width:36px; }
  352. .chat-panel.collapsed .chat-messages, .chat-panel.collapsed .chat-input-area, .chat-panel.collapsed .conv-tabs, .chat-panel.collapsed .chat-search, .chat-panel.collapsed .chat-resize-handle, .chat-panel.collapsed .chat-actions { display:none !important; }
  353. .chat-panel.collapsed .chat-header { writing-mode:vertical-rl; text-orientation:mixed; padding:12px 6px; cursor:pointer; justify-content:center; border-bottom:none; }
  354. .chat-panel.collapsed .chat-header>*:not(.chat-collapse-btn) { display:none; }
  355. .chat-panel.collapsed .chat-collapse-btn { writing-mode:horizontal-tb; }
  356. .chat-collapse-btn { background:none; border:none; color:var(--text2); cursor:pointer; font-size:12px; padding:2px 4px; border-radius:3px; }
  357. .chat-collapse-btn:hover { color:var(--text); background:var(--bg3); }
  358. /* Detail Panel */
  359. .detail-panel { position:fixed; right:401px; top:32px; width:420px; height:calc(100vh - 32px); z-index:99; display:none; flex-direction:column; background:var(--bg); border:1px solid var(--border); border-right:none; box-shadow:-4px 0 16px rgba(0,0,0,0.3); border-radius:0; }
  360. .detail-panel.open { display:flex; }
  361. .detail-header { padding:6px 10px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:10px; color:var(--text2); display:flex; justify-content:space-between; align-items:center; border-radius:0; cursor:grab; user-select:none; }
  362. .detail-header .dh-title { font-weight:600; color:var(--orange); }
  363. .detail-body { flex:1; overflow-y:auto; padding:8px; font-size:10px; font-family:var(--font); }
  364. .detail-entry { margin-bottom:6px; padding:4px 6px; border-left:2px solid var(--border); }
  365. .detail-entry.info { border-left-color:var(--accent); }
  366. .detail-entry.success { border-left-color:var(--green); }
  367. .detail-entry.error { border-left-color:var(--red); }
  368. .detail-entry.warn { border-left-color:var(--orange); }
  369. .detail-entry.depth-1 { margin-left:14px; border-left-style:dashed; }
  370. .detail-entry.depth-2 { margin-left:28px; border-left-style:dotted; }
  371. .detail-entry.depth-3 { margin-left:42px; border-left-style:dotted; opacity:0.85; }
  372. .detail-entry .de-time { color:var(--text2); font-size:8px; }
  373. .detail-entry .de-phase { color:var(--orange); font-weight:600; margin-left:4px; font-size:9px; }
  374. .detail-entry .de-agent { color:var(--purple); font-weight:600; margin-left:4px; font-size:9px; }
  375. .detail-entry .de-msg { color:var(--text); margin-top:2px; white-space:pre-wrap; word-break:break-word; }
  376. .detail-entry .de-data { color:var(--text2); margin-top:2px; white-space:pre-wrap; word-break:break-word; font-size:9px; max-height:300px; overflow-y:auto; background:var(--bg2); padding:4px; border-radius:3px; cursor:pointer; }
  377. .detail-entry .de-data.collapsed { max-height:40px; overflow:hidden; position:relative; }
  378. .detail-entry .de-data.collapsed::after { content:'... click to expand'; position:absolute; bottom:0; right:4px; background:var(--bg2); padding:0 4px; color:var(--accent); font-size:8px; }
  379. /* Step card in detail panel — enhanced workflow step display */
  380. .detail-step-card { margin:6px 0; background:var(--bg2); border-radius:5px; border:1px solid var(--border); overflow:hidden; }
  381. .detail-step-card.running { border-color:var(--orange); }
  382. .detail-step-card.done { border-color:var(--green); }
  383. .detail-step-card.error { border-color:var(--red); }
  384. .detail-step-card.skipped { border-color:var(--text2); opacity:0.7; }
  385. .dsc-header { display:flex; align-items:center; gap:6px; padding:5px 8px; cursor:pointer; font-size:10px; }
  386. .dsc-header:hover { background:var(--bg3); }
  387. .dsc-icon { font-size:12px; width:16px; text-align:center; }
  388. .dsc-title { color:var(--text); font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  389. .dsc-type { color:var(--accent); font-size:8px; background:var(--bg); padding:1px 5px; border-radius:8px; }
  390. .dsc-duration { color:var(--text2); font-size:8px; }
  391. .dsc-body { padding:0 8px 6px; display:none; }
  392. .dsc-body.open { display:block; }
  393. .dsc-section { margin:4px 0; }
  394. .dsc-section-header { display:flex; align-items:center; gap:4px; font-size:9px; color:var(--text2); cursor:pointer; padding:2px 0; }
  395. .dsc-section-header:hover { color:var(--accent); }
  396. .dsc-section-header .dsc-arrow { font-size:8px; transition:transform 0.15s; }
  397. .dsc-section-header .dsc-arrow.open { transform:rotate(90deg); }
  398. .dsc-section-content { display:none; max-height:300px; overflow-y:auto; background:var(--bg); padding:4px 6px; border-radius:3px; font-size:9px; color:var(--text2); white-space:pre-wrap; word-break:break-word; margin-top:2px; }
  399. .dsc-section-content.open { display:block; }
  400. .dsc-section-content.truncated::after { content:'(truncated)'; color:var(--orange); font-style:italic; }
  401. .dsc-actions { display:flex; gap:6px; margin-top:6px; padding-top:4px; border-top:1px solid var(--border); }
  402. .dsc-rerun-btn { background:none; border:1px solid var(--accent); color:var(--accent); font-size:9px; padding:2px 8px; border-radius:3px; cursor:pointer; }
  403. .dsc-rerun-btn:hover { background:var(--accent); color:var(--bg); }
  404. /* Hover action buttons on step card header */
  405. .dsc-hover-actions { display:none; gap:3px; margin-left:auto; flex-shrink:0; }
  406. .detail-step-card:hover .dsc-hover-actions { display:flex; }
  407. .dsc-hover-btn { background:none; border:none; color:var(--text2); font-size:10px; cursor:pointer; padding:1px 4px; border-radius:3px; line-height:1; }
  408. .dsc-hover-btn:hover { background:var(--bg3); color:var(--accent); }
  409. /* Step card context menu */
  410. .step-ctx-menu { display:none; position:fixed; z-index:300; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:4px 0; min-width:180px; box-shadow:0 6px 20px rgba(0,0,0,0.5); }
  411. .step-ctx-menu.open { display:block; }
  412. .step-ctx-item { padding:5px 14px; font-size:11px; cursor:pointer; color:var(--text2); display:flex; align-items:center; gap:8px; }
  413. .step-ctx-item:hover { background:var(--bg3); color:var(--text); }
  414. .step-ctx-item .sci-icon { width:16px; text-align:center; font-size:12px; }
  415. .step-ctx-item .sci-label { flex:1; }
  416. .step-ctx-item .sci-hint { font-size:9px; color:var(--text2); opacity:0.6; }
  417. .step-ctx-sep { height:1px; background:var(--border); margin:3px 0; }
  418. /* File list in step card */
  419. .dsc-file { font-size:9px; color:var(--green); padding:1px 0; }
  420. .dsc-file::before { content:'📄 '; }
  421. /* Re-run dialog variable rows */
  422. .rr-var-row { margin:4px 0; }
  423. .rr-var-name { font-size:10px; font-weight:600; color:var(--accent); margin-bottom:2px; }
  424. .rr-var-val { width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text); font-size:9px; font-family:monospace; padding:4px 6px; border-radius:3px; resize:vertical; }
  425. .rr-var-val:focus { border-color:var(--accent); outline:none; }
  426. /* Agent group in detail panel */
  427. .detail-agent-group { margin:4px 0; background:var(--bg2); border-radius:4px; border:1px solid var(--border); overflow:hidden; }
  428. .detail-agent-header { display:flex; align-items:center; gap:6px; padding:4px 8px; cursor:pointer; font-size:10px; }
  429. .detail-agent-header:hover { background:var(--bg3); }
  430. .detail-agent-header .dag-icon { color:var(--purple); font-size:11px; }
  431. .detail-agent-header .dag-name { color:var(--accent); font-weight:600; }
  432. .detail-agent-header .dag-desc { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  433. .detail-agent-header .dag-status { font-size:9px; }
  434. .detail-agent-children { padding:0 4px 4px; }
  435. .detail-agent-children.collapsed { display:none; }
  436. .detail-entry.stream-box { border-left-color:var(--accent); padding:0; }
  437. .de-stream-header { display:flex; align-items:center; gap:6px; padding:4px 6px; cursor:pointer; background:var(--bg2); border-radius:3px 3px 0 0; }
  438. .de-stream-header:hover { background:var(--hover); }
  439. .de-stream-label { color:var(--orange); font-weight:600; font-size:9px; }
  440. .de-stream-size { color:var(--text2); font-size:8px; margin-left:auto; }
  441. .de-stream-toggle { color:var(--text2); font-size:8px; transition:transform .2s; }
  442. .stream-box.collapsed .de-stream-toggle { transform:rotate(-90deg); }
  443. .de-stream-content { white-space:pre-wrap; word-break:break-all; font-size:9px; color:var(--text); max-height:300px; overflow-y:auto; padding:4px 6px; background:var(--bg1); border-radius:0 0 3px 3px; }
  444. .stream-box.collapsed .de-stream-content { display:none; }
  445. /* LLM communication phase colors */
  446. .detail-entry .de-phase[data-phase="llm"] { color:var(--purple); }
  447. .detail-entry .de-phase[data-phase="tool-call"] { color:var(--blue); }
  448. .detail-entry .de-phase[data-phase="tool-result"] { color:var(--green); }
  449. .detail-entry .de-phase[data-phase="var"] { color:var(--cyan, #5ccfe6); }
  450. .detail-entry .de-phase[data-phase="file"] { color:var(--yellow); }
  451. .detail-entry .de-phase[data-phase="step"] { color:var(--accent); }
  452. .detail-entry .de-phase[data-phase="node"] { color:var(--accent); font-weight:600; }
  453. .detail-entry .de-phase[data-phase="tool"] { color:var(--blue); }
  454. .detail-entry .de-phase[data-phase="result"] { color:var(--green); }
  455. /* Thinking stream box: distinct purple accent */
  456. .detail-entry.stream-box.thinking-stream { border-left-color:var(--purple); }
  457. .thinking-stream .de-stream-label { color:var(--purple); }
  458. .thinking-stream .de-stream-content { color:var(--text2); font-style:italic; }
  459. /* Workflow LLM chat streaming */
  460. .wf-tool-full.collapsed { display:none; }
  461. /* Message truncation + show more — only for extremely long messages */
  462. .msg.assistant .content-text.truncated { max-height:2000px; overflow:hidden; position:relative; }
  463. .msg.assistant .content-text.truncated::after { content:''; position:absolute; bottom:0; left:0; right:0; height:40px; background:linear-gradient(transparent, var(--bg)); pointer-events:none; }
  464. .msg-toggle { display:block; color:var(--accent); font-size:11px; cursor:pointer; margin-top:6px; border:1px solid var(--accent); background:none; font-family:var(--font); padding:2px 10px; border-radius:4px; text-align:center; }
  465. .msg-toggle:hover { background:var(--accent); color:#fff; }
  466. /* Compact mode — collapse tool groups */
  467. .chat-panel.compact .tool-group .tool-body { display:none !important; }
  468. .chat-panel.compact .msg.assistant .content-text.auto-truncate { max-height:100px; overflow:hidden; }
  469. .chat-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 10px; background:linear-gradient(180deg, #161c25 0%, #131922 100%); border-bottom:1px solid var(--border); }
  470. .chat-action-group { display:flex; align-items:center; gap:6px; min-width:0; }
  471. .chat-actions .ca-btn { background:var(--bg3); color:var(--text2); border:1px solid var(--border); padding:4px 10px; border-radius:999px; cursor:pointer; font-family:var(--font); font-size:9px; line-height:1; white-space:nowrap; }
  472. .chat-actions .ca-btn:hover { background:var(--border); color:var(--text); }
  473. .chat-actions .ca-btn.ca-primary { color:var(--text); background:rgba(88,166,255,0.09); border-color:rgba(88,166,255,0.22); }
  474. .chat-actions .ca-btn.ca-primary:hover { background:rgba(88,166,255,0.16); border-color:rgba(88,166,255,0.38); }
  475. .chat-actions .ca-btn.ca-log { color:var(--orange); border-color:rgba(240,136,62,0.24); background:rgba(240,136,62,0.08); }
  476. .chat-actions .ca-btn.ca-log:hover { background:rgba(240,136,62,0.14); color:#ffd4b6; }
  477. .ca-menu { position:relative; }
  478. .ca-menu-panel { display:none; position:absolute; top:calc(100% + 6px); right:0; min-width:150px; background:var(--bg2); border:1px solid var(--border); border-radius:10px; padding:6px; box-shadow:0 12px 32px rgba(0,0,0,0.35); z-index:90; }
  479. .ca-menu.open .ca-menu-panel { display:block; }
  480. .ca-menu-item { display:block; width:100%; text-align:left; background:none; border:none; color:var(--text2); font-family:var(--font); font-size:10px; padding:7px 8px; border-radius:7px; cursor:pointer; }
  481. .ca-menu-item:hover { background:var(--bg3); color:var(--text); }
  482. .ca-menu-item.menu-accent { color:var(--accent); }
  483. .ca-menu-item.menu-log { color:var(--orange); }
  484. /* Workflow progress widget in chat */
  485. .wf-progress { background:var(--bg2); border:1px solid var(--border); border-radius:6px; margin:6px 0; padding:8px 10px; font-size:10px; }
  486. .wf-progress-header { display:flex; align-items:center; gap:6px; margin-bottom:6px; font-weight:600; color:var(--text); font-size:11px; }
  487. .wf-progress-header .wf-icon { font-size:12px; }
  488. .wf-step { display:flex; align-items:center; gap:6px; padding:2px 0; color:var(--text2); }
  489. .wf-step-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--border); transition:background 0.3s; }
  490. .wf-step-dot.pending { background:var(--border); }
  491. .wf-step-dot.running { background:var(--yellow); animation:wfpulse 1s ease-in-out infinite; }
  492. .wf-step-dot.done { background:var(--green); }
  493. .wf-step-dot.error { background:var(--red); }
  494. .wf-step-dot.paused { background:var(--purple); animation:wfpulse 1.5s ease-in-out infinite; }
  495. .wf-step-dot.skipped { background:var(--text2); opacity:0.4; }
  496. @keyframes wfpulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(1.3)} }
  497. .wf-step.active { color:var(--text); font-weight:500; }
  498. .wf-step.completed { color:var(--text2); opacity:0.6; }
  499. .wf-progress-actions { margin-top:6px; display:flex; gap:4px; }
  500. .wf-progress-actions button { background:var(--bg3); border:1px solid var(--border); color:var(--text2); padding:2px 8px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:9px; }
  501. .wf-progress-actions button:hover { background:var(--border); color:var(--text); }
  502. .wf-approve-btn { border-color:var(--green) !important; color:var(--green) !important; }
  503. .wf-approve-btn:hover { background:var(--green) !important; color:#fff !important; }
  504. .wf-cancel-btn:hover { background:var(--red) !important; color:#fff !important; border-color:var(--red) !important; }
  505. .wf-step-type { font-size:8px; color:var(--text2); opacity:0.6; margin-left:auto; }
  506. .chat-header { padding:8px 14px; background:var(--bg2); border-bottom:1px solid var(--border); font-size:11px; color:var(--text2); display:flex; justify-content:space-between; align-items:center; cursor:grab; user-select:none; }
  507. .chat-header:active { cursor:grabbing; }
  508. .chat-messages { flex:1; overflow-y:auto; padding:10px; }
  509. .msg { margin-bottom:8px; padding:6px 10px; border-radius:6px; line-height:1.5; white-space:pre-wrap; word-break:break-word; font-size:12px; position:relative; }
  510. .msg.user { background:var(--bg3); border:1px solid var(--border); }
  511. .msg.assistant { background:#161b22; border:1px solid #30363d; }
  512. .msg .label { font-size:9px; color:var(--text2); margin-bottom:3px; text-transform:uppercase; letter-spacing:0.5px; }
  513. .msg .msg-time { font-size:9px; color:var(--text2); opacity:0.7; text-transform:none; letter-spacing:0; margin-left:6px; }
  514. /* Claude Code-style compact tool indicators */
  515. .tool-group { margin:4px 0; background:var(--bg2); border-radius:6px; border:1px solid var(--border); overflow:hidden; }
  516. .tool-header { display:flex; align-items:center; gap:6px; padding:6px 10px; cursor:pointer; font-size:11px; color:var(--text2); }
  517. .tool-header:hover { background:var(--bg3); }
  518. .tool-header .tool-icon { width:16px; text-align:center; font-size:12px; flex-shrink:0; }
  519. .tool-header .tool-name { color:var(--accent); font-weight:600; min-width:50px; }
  520. .tool-header .tool-desc { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); }
  521. .tool-header .tool-time { font-size:9px; color:var(--text2); opacity:0.6; min-width:28px; text-align:right; flex-shrink:0; }
  522. .tool-header .tool-toggle { font-size:8px; color:var(--text2); transition:transform 0.2s; flex-shrink:0; }
  523. .tool-header .tool-toggle.open { transform:rotate(90deg); }
  524. .tool-body { display:none; padding:6px 10px 8px; font-size:10px; color:var(--text2); border-top:1px solid var(--border); max-height:200px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; }
  525. .tool-body.open { display:block; }
  526. .tool-detail { font-size:10px; color:var(--text2); padding:2px 0; display:flex; gap:6px; }
  527. .tool-detail .td-label { color:var(--text2); opacity:0.7; min-width:50px; }
  528. .tool-detail .td-val { color:var(--text); flex:1; }
  529. .tool-diff { margin:3px 0; font-family:var(--font); font-size:11px; }
  530. .tool-diff .td-old { color:var(--red); opacity:0.8; padding:1px 4px; background:rgba(248,81,73,0.08); border-radius:2px; }
  531. .tool-diff .td-new { color:var(--green); padding:1px 4px; background:rgba(63,185,80,0.08); border-radius:2px; }
  532. .tool-result-badge { display:inline-block; font-size:9px; padding:1px 6px; border-radius:8px; margin-left:6px; }
  533. .tool-result-badge.ok { background:rgba(63,185,80,0.15); color:var(--green); }
  534. .tool-result-badge.err { background:rgba(248,81,73,0.15); color:var(--red); }
  535. .tool-status-icon { font-size:12px; flex-shrink:0; }
  536. .tool-status-icon.running { color:var(--accent); }
  537. .tool-status-icon.done { color:var(--green); }
  538. .tool-status-icon.error { color:var(--red); }
  539. /* Spinner for active tool */
  540. @keyframes spin { to { transform:rotate(360deg); } }
  541. .tool-spinner { width:12px; height:12px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; }
  542. /* Todo list (compact) */
  543. .msg.todo-list { background:var(--bg2); font-size:11px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); }
  544. .todo-item { display:flex; align-items:center; gap:5px; padding:2px 0; }
  545. .todo-icon { width:12px; text-align:center; font-size:10px; }
  546. .todo-done { color:var(--green); }
  547. .todo-active { color:var(--yellow); }
  548. .todo-pending { color:var(--text2); }
  549. .todo-subtask { padding-left:20px; font-size:10px; }
  550. .todo-timing { margin-left:auto; font-size:9px; opacity:0.6; font-variant-numeric:tabular-nums; }
  551. .todo-spinner { width:10px; height:10px; border:2px solid var(--border); border-top-color:var(--yellow); border-radius:50%; animation:spin 0.8s linear infinite; flex-shrink:0; margin-right:2px; }
  552. .todo-text { flex:1; }
  553. /* Thinking indicator */
  554. .thinking-block { margin:4px 0; padding:6px 10px; background:linear-gradient(135deg, #1a1e2e, #161b22); border-radius:6px; border:1px solid #30365d; font-size:11px; }
  555. .thinking-header { display:flex; align-items:center; gap:6px; color:var(--purple); cursor:pointer; }
  556. .thinking-header .think-icon { animation:pulse 1.5s ease-in-out infinite; }
  557. @keyframes pulse { 0%,100% { opacity:0.5; } 50% { opacity:1; } }
  558. .thinking-body { display:none; margin-top:4px; color:var(--text2); font-size:10px; max-height:100px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; }
  559. .thinking-body.open { display:block; }
  560. .thinking-block.done .think-icon { animation:none; opacity:0.5; }
  561. .thinking-block.done .thinking-header { color:var(--text2); }
  562. /* Markdown in assistant messages */
  563. .msg.assistant .content-text { white-space:normal; }
  564. .msg.assistant .content-text p { margin:4px 0; }
  565. .msg.assistant .content-text code { background:var(--bg3); padding:1px 4px; border-radius:3px; font-size:11px; }
  566. .msg.assistant .content-text pre { background:var(--bg); border:1px solid var(--border); border-radius:4px; padding:8px; margin:6px 0; overflow-x:auto; }
  567. .msg.assistant .content-text pre code { background:none; padding:0; }
  568. .msg.assistant .content-text h1,.msg.assistant .content-text h2,.msg.assistant .content-text h3 { margin:8px 0 4px; font-size:13px; color:var(--accent); }
  569. .msg.assistant .content-text ul,.msg.assistant .content-text ol { padding-left:18px; margin:4px 0; }
  570. .msg.assistant .content-text li { margin:2px 0; }
  571. .msg.assistant .content-text strong { color:var(--text); }
  572. .msg.assistant .content-text a { color:var(--accent); text-decoration:none; }
  573. .msg.assistant .content-text blockquote { border-left:2px solid var(--border); padding-left:8px; color:var(--text2); margin:4px 0; }
  574. /* Retry indicator */
  575. .retry-msg { margin:4px 0; padding:4px 10px; font-size:10px; color:var(--yellow); display:flex; align-items:center; gap:5px; }
  576. /* Token details in context bar */
  577. .ctx-tooltip { position:relative; cursor:help; }
  578. .ctx-detail { display:none; position:absolute; bottom:20px; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px; padding:8px 12px; font-size:10px; white-space:nowrap; z-index:50; min-width:180px; }
  579. .ctx-tooltip:hover .ctx-detail { display:block; }
  580. .chat-input { display:flex; border-top:1px solid var(--border); background:var(--bg2); }
  581. .chat-input input { flex:1; background:transparent; border:none; color:var(--text); padding:10px 14px; font-family:var(--font); font-size:12px; outline:none; }
  582. .chat-input button { background:var(--accent); color:#fff; border:none; padding:6px 14px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; }
  583. .chat-input button:disabled { opacity:0.4; cursor:default; }
  584. /* Bottom bar */
  585. .bottom-bar { background:var(--bg2); border-top:1px solid var(--border); padding:3px 16px; display:flex; gap:16px; font-size:10px; color:var(--text2); align-items:center; }
  586. .bottom-bar .status { display:flex; align-items:center; gap:5px; }
  587. .bottom-bar .dot { width:6px; height:6px; border-radius:50%; }
  588. .dot-green { background:var(--green); }
  589. .dot-yellow { background:var(--yellow); }
  590. .dot-red { background:var(--red); }
  591. /* Modals */
  592. .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6); z-index:100; justify-content:center; align-items:center; }
  593. .modal-overlay.open { display:flex; }
  594. .modal-box { background:var(--bg2); border:1px solid var(--border); border-radius:10px; width:560px; max-height:80vh; overflow-y:auto; padding:20px; }
  595. .modal-box h2 { margin-bottom:14px; font-size:16px; }
  596. .modal-box label { display:block; font-size:11px; color:var(--text2); margin-bottom:4px; margin-top:12px; }
  597. .modal-box input[type=text], .modal-box input[type=password], .modal-box select, .modal-box textarea {
  598. width:100%; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px;
  599. padding:8px 10px; font-family:var(--font); font-size:12px; outline:none; }
  600. .modal-box input:focus, .modal-box select:focus, .modal-box textarea:focus { border-color:var(--accent); }
  601. .modal-box textarea { height:100px; resize:vertical; }
  602. .modal-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
  603. .modal-actions .hdr-btn { padding:6px 18px; }
  604. /* Settings-specific */
  605. .key-row { display:flex; gap:6px; align-items:center; }
  606. .key-row input { flex:1; }
  607. .key-row button { flex-shrink:0; }
  608. .key-status { font-size:10px; margin-top:3px; }
  609. .key-ok { color:var(--green); }
  610. .key-missing { color:var(--red); }
  611. .model-option { padding:2px 0; }
  612. .model-desc { font-size:10px; color:var(--text2); }
  613. /* Setup overlay (shown when no API key) */
  614. /* Landing / Login page */
  615. .landing-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:var(--bg); z-index:300; justify-content:center; align-items:center; padding:24px; overflow:auto; }
  616. .landing-overlay.active { display:flex; }
  617. .landing-shell { width:min(1360px, 100%); min-height:min(860px, calc(100vh - 48px)); display:grid; grid-template-columns:minmax(380px, 520px) minmax(0, 1fr); gap:18px; align-items:stretch; }
  618. .landing-box { width:100%; text-align:center; background:linear-gradient(180deg, rgba(17,22,29,0.98) 0%, rgba(13,17,23,0.98) 100%); border:1px solid var(--border); border-radius:14px; padding:24px; box-shadow:0 18px 42px rgba(0,0,0,0.3); }
  619. .landing-brand { display:flex; flex-direction:column; align-items:center; gap:12px; margin-bottom:14px; }
  620. .landing-brand img { width:120px; height:auto; filter:drop-shadow(0 14px 32px rgba(23, 94, 183, 0.28)); }
  621. .landing-box h1 { color:var(--accent); font-size:28px; margin-bottom:4px; }
  622. .landing-box .landing-sub { color:var(--text2); font-size:12px; margin-bottom:20px; line-height:1.5; }
  623. .app-brand { display:flex; align-items:center; gap:10px; }
  624. .app-brand img { width:20px; height:20px; border-radius:6px; }
  625. .landing-box input { width:100%; margin-bottom:8px; }
  626. .landing-box .hdr-btn { width:100%; padding:8px; font-size:12px; }
  627. .landing-section { background:var(--bg2); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; text-align:left; }
  628. .landing-section h3 { font-size:12px; color:var(--text); margin-bottom:10px; display:flex; align-items:center; gap:6px; }
  629. .landing-section h3 .ls-badge { font-size:9px; padding:2px 6px; border-radius:3px; font-weight:400; }
  630. .landing-section h3 .ls-badge.recommended { background:var(--green); color:#fff; }
  631. .landing-section h3 .ls-badge.optional { background:var(--bg3); color:var(--text2); }
  632. .landing-tabs { display:flex; gap:0; margin-bottom:10px; border-bottom:1px solid var(--border); }
  633. .landing-tab { padding:6px 14px; font-size:11px; color:var(--text2); cursor:pointer; border-bottom:2px solid transparent; font-family:var(--font); }
  634. .landing-tab:hover { color:var(--text); }
  635. .landing-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
  636. .landing-tab-panel { display:none; }
  637. .landing-tab-panel.active { display:block; }
  638. .landing-or { text-align:center; color:var(--text2); font-size:10px; margin:8px 0; position:relative; }
  639. .landing-or::before, .landing-or::after { content:''; position:absolute; top:50%; width:40%; height:1px; background:var(--border); }
  640. .landing-or::before { left:0; }
  641. .landing-or::after { right:0; }
  642. .landing-skip { text-align:center; margin-top:8px; }
  643. .landing-skip a { color:var(--text2); font-size:11px; cursor:pointer; text-decoration:underline; }
  644. .landing-skip a:hover { color:var(--accent); }
  645. .landing-docs { min-width:0; background:linear-gradient(180deg, rgba(17,22,29,0.98) 0%, rgba(13,17,23,0.98) 100%); border:1px solid var(--border); border-radius:14px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 18px 42px rgba(0,0,0,0.3); }
  646. .landing-docs-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:18px 20px 14px; border-bottom:1px solid var(--border); background:rgba(22,27,34,0.88); }
  647. .landing-docs-head h2 { font-size:16px; color:var(--text); margin:0 0 4px; }
  648. .landing-docs-copy { font-size:11px; color:var(--text2); line-height:1.6; }
  649. .landing-docs-note { padding:10px 20px; border-bottom:1px solid var(--border); background:rgba(88,166,255,0.06); font-size:11px; color:var(--text2); line-height:1.6; }
  650. .landing-docs-note code { color:var(--accent); }
  651. .landing-docs-frame { flex:1; width:100%; border:0; background:var(--bg); min-height:560px; }
  652. @media (max-width: 1100px) {
  653. .landing-shell { grid-template-columns:1fr; min-height:auto; }
  654. .landing-docs-frame { min-height:460px; }
  655. }
  656. /* LLM provider indicator */
  657. .llm-badge { display:inline-flex; align-items:center; gap:4px; font-size:9px; padding:1px 6px; border-radius:3px; font-family:var(--font); cursor:pointer; }
  658. .llm-badge.cli { background:#3fb95022; color:var(--green); border:1px solid #3fb95044; }
  659. .llm-badge.apikey { background:#58a6ff22; color:var(--accent); border:1px solid #58a6ff44; }
  660. /* Gen progress */
  661. .gen-progress { margin-top:14px; }
  662. .gen-step { padding:4px 0; display:flex; align-items:center; gap:6px; font-size:11px; }
  663. /* Drag-and-drop overlay */
  664. .drop-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(88,166,255,0.12); border:3px dashed var(--accent); z-index:200; justify-content:center; align-items:center; cursor:pointer; }
  665. .drop-overlay.active { display:flex; }
  666. .drop-overlay .drop-msg { pointer-events:none; }
  667. .drop-overlay .drop-msg { background:var(--bg2); border:1px solid var(--accent); border-radius:12px; padding:24px 36px; text-align:center; }
  668. .drop-overlay .drop-msg h2 { color:var(--accent); font-size:18px; margin-bottom:6px; }
  669. .drop-overlay .drop-msg p { color:var(--text2); font-size:12px; }
  670. /* Workspace display — current workspace only */
  671. .ws-tabs { display:flex; align-items:center; min-width:0; max-width:260px; flex:0 1 260px; }
  672. .ws-current { display:flex; align-items:center; gap:8px; min-width:0; width:100%; background:linear-gradient(180deg, #1b2430 0%, #161d27 100%); border:1px solid #2d3743; border-radius:7px; padding:4px 10px; cursor:pointer; color:var(--text); transition:all 0.15s; }
  673. .ws-current:hover { border-color:#3a4654; background:linear-gradient(180deg, #202a36 0%, #18212b 100%); }
  674. .ws-current.empty { color:var(--text2); }
  675. .ws-current-icon { color:var(--green); font-size:9px; flex-shrink:0; }
  676. .ws-current.empty .ws-current-icon { color:var(--text2); }
  677. .ws-current-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:600; }
  678. .ws-current.ws-btn-highlight { border-color:var(--accent); color:var(--accent); animation:wsBtnPulse 1.5s ease-in-out infinite; }
  679. @keyframes wsBtnPulse { 0%,100%{box-shadow:0 0 0 0 rgba(var(--accent-rgb,255,200,0),0.5)} 50%{box-shadow:0 0 0 4px rgba(var(--accent-rgb,255,200,0),0)} }
  680. /* Workspace popover (reuses old dropdown items) */
  681. .ws-popover { display:none; position:absolute; top:100%; left:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px; max-height:420px; overflow-y:auto; z-index:90; width:320px; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
  682. .ws-popover.open { display:block; }
  683. .ws-popover .ws-section { font-size:9px; color:var(--text2); padding:6px 12px; text-transform:uppercase; letter-spacing:0.5px; border-bottom:1px solid var(--border); }
  684. .ws-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; gap:6px; }
  685. .ws-item:hover { background:var(--bg3); }
  686. .ws-item.active { background:rgba(88,166,255,0.08); border-left:2px solid var(--accent); }
  687. .ws-item .ws-item-name { font-size:11px; color:var(--text); font-weight:600; }
  688. .ws-item .ws-item-path { font-size:9px; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:200px; }
  689. .ws-item .ws-del { color:var(--red); cursor:pointer; font-size:12px; opacity:0.5; padding:2px 4px; }
  690. .ws-item .ws-del:hover { opacity:1; }
  691. .ws-add { padding:8px 12px; display:flex; gap:6px; align-items:center; }
  692. .ws-add input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:4px 8px; font-family:var(--font); font-size:10px; outline:none; }
  693. .ws-add input:focus { border-color:var(--accent); }
  694. .ws-add button { font-size:10px; }
  695. /* Directory browser in workspace dropdown */
  696. .ws-browse { border-top:1px solid var(--border); }
  697. .ws-browse-header { display:flex; align-items:center; gap:4px; padding:6px 8px; background:var(--bg); border-bottom:1px solid var(--border); }
  698. .ws-browse-header .browse-up { background:none; border:1px solid var(--border); color:var(--text2); border-radius:3px; cursor:pointer; font-size:11px; padding:2px 6px; font-family:var(--font); }
  699. .ws-browse-header .browse-up:hover { color:var(--text); border-color:var(--text2); }
  700. .ws-browse-header .browse-path { flex:1; font-size:9px; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; }
  701. .ws-browse-header .browse-select { background:var(--accent); color:#fff; border:none; border-radius:3px; cursor:pointer; font-size:9px; padding:2px 8px; font-family:var(--font); font-weight:600; }
  702. .ws-browse-header .browse-select:hover { opacity:0.85; }
  703. .ws-browse-list { max-height:180px; overflow-y:auto; }
  704. .ws-browse-item { display:flex; align-items:center; gap:6px; padding:4px 10px; cursor:pointer; font-size:11px; border-bottom:1px solid rgba(48,54,61,0.5); }
  705. .ws-browse-item:hover { background:var(--bg3); }
  706. .ws-browse-item.is-vl { background:rgba(88,166,255,0.05); }
  707. .ws-browse-item .dir-icon { font-size:12px; flex-shrink:0; }
  708. .ws-browse-item .dir-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  709. .ws-browse-item .dir-vl { font-size:8px; color:var(--accent); background:rgba(88,166,255,0.12); padding:1px 4px; border-radius:2px; flex-shrink:0; }
  710. /* Chat input area (enhanced with image + mention) */
  711. .chat-input-area { border-top:1px solid var(--border); background:linear-gradient(180deg, rgba(13,17,23,0.98) 0%, rgba(20,25,33,0.98) 100%); padding:8px 10px 10px; }
  712. .chat-attachments { display:flex; gap:6px; padding:0 0 6px; flex-wrap:wrap; }
  713. .chat-attachments:empty { display:none; }
  714. .chat-attach-item { background:rgba(88,166,255,0.08); border:1px solid rgba(88,166,255,0.22); border-radius:999px; padding:4px 8px; font-size:10px; display:flex; align-items:center; gap:6px; }
  715. .chat-attach-item img { width:24px; height:24px; object-fit:cover; border-radius:2px; }
  716. .chat-attach-item .remove { cursor:pointer; color:var(--red); font-size:10px; }
  717. .plan-mode-bar { display:flex; align-items:center; gap:8px; padding:6px 10px; margin-bottom:6px; background:rgba(28,34,44,0.95); border:1px solid var(--border); border-radius:9px; font-size:11px; }
  718. .plan-mode-label { color:var(--yellow, #e2b714); font-weight:600; flex:1; }
  719. .plan-approve-btn { background:var(--green, #3fb950); color:#fff; border:none; padding:4px 12px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; border-radius:3px; }
  720. .plan-approve-btn:hover { opacity:0.85; }
  721. .plan-cancel-btn { background:var(--red, #f85149); color:#fff; border:none; padding:4px 12px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:600; border-radius:3px; }
  722. .plan-cancel-btn:hover { opacity:0.85; }
  723. #planModeToggle.active { color:var(--yellow, #e2b714); }
  724. .chat-input-row { display:flex; align-items:flex-end; gap:6px; background:linear-gradient(180deg, #121922 0%, #0f151d 100%); border:1px solid #2a3441; border-radius:12px; padding:6px; box-shadow:inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 24px rgba(0,0,0,0.18); }
  725. .chat-input-row:focus-within { border-color:rgba(88,166,255,0.75); box-shadow:0 0 0 1px rgba(88,166,255,0.2), 0 12px 28px rgba(0,0,0,0.22); }
  726. .chat-input-row textarea { flex:1; background:transparent; border:none; color:var(--text); padding:8px 2px 8px 0; font-family:var(--font); font-size:12.5px; outline:none; min-height:42px; max-height:180px; resize:none; overflow-y:auto; line-height:1.55; }
  727. .chat-input-row textarea::placeholder { color:#778291; }
  728. .chat-input-row .input-btn { width:34px; height:34px; background:none; border:none; color:var(--text2); cursor:pointer; border-radius:8px; padding:0; font-size:15px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
  729. .chat-input-row .input-btn:hover { color:var(--text); background:rgba(255,255,255,0.05); }
  730. .chat-input-row button.send-btn { background:linear-gradient(180deg, #58a6ff 0%, #3e8ae0 100%); color:#fff; border:none; min-width:74px; padding:10px 16px; cursor:pointer; font-family:var(--font); font-size:12px; font-weight:700; border-radius:10px; margin:0; align-self:stretch; box-shadow:0 8px 20px rgba(32,96,176,0.25); }
  731. .chat-input-row button.send-btn:disabled { opacity:0.4; cursor:default; }
  732. .chat-input-row button.stop-btn { background:linear-gradient(180deg, #f85149 0%, #d83b34 100%); color:#fff; border:none; min-width:74px; padding:8px 14px; cursor:pointer; font-family:var(--font); font-size:11px; font-weight:700; border-radius:10px; align-self:stretch; }
  733. .chat-input-row button.stop-btn:hover { opacity:0.85; }
  734. .settings-provider-switch { display:flex; gap:8px; margin-bottom:8px; }
  735. .settings-provider-option { flex:1; display:flex; align-items:flex-start; gap:6px; padding:8px 10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); cursor:pointer; }
  736. .settings-provider-option input { margin-top:2px; }
  737. .settings-provider-copy { display:flex; flex-direction:column; gap:2px; }
  738. .settings-provider-copy strong { font-size:11px; color:var(--text); }
  739. .settings-provider-copy span { font-size:10px; color:var(--text2); line-height:1.4; }
  740. .settings-provider-hint { font-size:10px; color:var(--text2); margin:-2px 0 10px; line-height:1.5; }
  741. .settings-doc-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; margin-top:8px; }
  742. .settings-doc-card { display:flex; flex-direction:column; gap:4px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); }
  743. .settings-doc-card input { margin-top:2px; }
  744. .settings-doc-hint { font-size:10px; color:var(--text2); line-height:1.5; margin-top:8px; }
  745. .mode-tab[data-mode="docs"] { color:var(--yellow); }
  746. .mode-tab[data-mode="docs"].active { color:var(--yellow); border-bottom-color:var(--yellow); }
  747. .chat-status-bar { display:flex; align-items:center; gap:6px; padding:4px 10px; margin-bottom:6px; background:rgba(20,27,35,0.95); border:1px solid var(--border); border-radius:9px; font-size:9px; color:var(--text2); min-height:24px; }
  748. .chat-status-bar .cs-dot { width:6px; height:6px; border-radius:50%; background:var(--orange); animation:csPulse 1.2s ease-in-out infinite; flex-shrink:0; }
  749. @keyframes csPulse { 0%,100%{opacity:.4;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
  750. .chat-status-bar .cs-phase { color:var(--text); font-weight:500; }
  751. .chat-status-bar .cs-detail { color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px; }
  752. .chat-status-bar .cs-elapsed { margin-left:auto; color:var(--text2); }
  753. .chat-status-bar .cs-kill { background:var(--red); color:#fff; border:none; padding:1px 6px; border-radius:3px; cursor:pointer; font-family:var(--font); font-size:8px; font-weight:700; margin-left:4px; }
  754. .chat-status-bar .cs-kill:hover { opacity:0.8; }
  755. /* @-mention autocomplete dropdown */
  756. .mention-dropdown { display:none; position:absolute; bottom:100%; left:0; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px 6px 0 0; max-height:200px; overflow-y:auto; z-index:60; }
  757. .mention-dropdown.open { display:block; }
  758. .mention-item { padding:5px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:6px; }
  759. .mention-item:hover,.mention-item.selected { background:var(--bg3); }
  760. .mention-item .m-type { font-size:8px; padding:1px 3px; border-radius:2px; font-weight:600; }
  761. /* Apply button for code blocks */
  762. .code-apply { position:absolute; top:4px; right:4px; background:var(--accent); color:#fff; border:none; border-radius:3px; padding:2px 8px; font-size:9px; cursor:pointer; font-family:var(--font); opacity:0.8; }
  763. .code-apply:hover { opacity:1; }
  764. .msg.assistant .content-text pre { position:relative; }
  765. /* Inline Diff */
  766. .diff-block { margin:6px 0; border:1px solid var(--border); border-radius:6px; overflow:hidden; font-size:11px; }
  767. .diff-header { display:flex; justify-content:space-between; align-items:center; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
  768. .diff-header .diff-file { color:var(--accent); font-weight:600; }
  769. .diff-actions { display:flex; gap:4px; }
  770. .diff-actions button { padding:2px 8px; border-radius:3px; font-size:9px; cursor:pointer; font-family:var(--font); border:none; }
  771. .diff-accept { background:var(--green); color:#fff; }
  772. .diff-reject { background:var(--red); color:#fff; }
  773. .diff-body { max-height:200px; overflow-y:auto; }
  774. .diff-line { padding:0 8px; font-family:var(--font); white-space:pre; }
  775. .diff-add { background:#3fb95018; color:var(--green); }
  776. .diff-add::before { content:'+'; margin-right:6px; }
  777. .diff-del { background:#f8514918; color:var(--red); text-decoration:line-through; }
  778. .diff-del::before { content:'-'; margin-right:6px; }
  779. .diff-ctx { color:var(--text2); }
  780. /* Conversation tabs */
  781. .conv-tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--border); overflow-x:auto; min-height:28px; align-items:center; position:relative; }
  782. .conv-tab { padding:5px 12px; cursor:pointer; color:var(--text2); font-size:10px; border-bottom:2px solid transparent; white-space:nowrap; display:flex; align-items:center; gap:4px; }
  783. .conv-tab.active { color:var(--text); border-bottom-color:var(--accent); }
  784. .conv-tab .conv-close { font-size:8px; opacity:0.5; cursor:pointer; margin-left:4px; }
  785. .conv-tab .conv-close:hover { opacity:1; color:var(--red); }
  786. .conv-new { padding:5px 8px; cursor:pointer; color:var(--text2); font-size:12px; border:none; background:none; }
  787. .conv-new:hover { color:var(--accent); }
  788. /* History dropdown */
  789. .conv-tabs .tab-spacer { flex:1; min-width:8px; }
  790. .conv-history-btn { padding:4px 8px; cursor:pointer; color:var(--text2); font-size:10px; border:none; background:none; opacity:0.7; }
  791. .conv-history-btn:hover { opacity:1; color:var(--accent); }
  792. .history-panel { display:none; position:absolute; top:100%; right:0; width:360px; max-height:420px; background:var(--bg2); border:1px solid var(--border); border-radius:0 0 8px 8px; box-shadow:0 6px 20px rgba(0,0,0,0.5); z-index:80; overflow:hidden; flex-direction:column; }
  793. .history-panel.open { display:flex; }
  794. .history-search { padding:8px; border-bottom:1px solid var(--border); }
  795. .history-search input { width:100%; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-family:var(--font); font-size:11px; outline:none; }
  796. .history-search input:focus { border-color:var(--accent); }
  797. .history-list { flex:1; overflow-y:auto; padding:4px 0; }
  798. .history-item { padding:8px 12px; cursor:pointer; border-bottom:1px solid var(--border); }
  799. .history-item:hover { background:var(--bg3); }
  800. .history-item:last-child { border-bottom:none; }
  801. .history-item .hi-title { font-size:11px; color:var(--text); font-weight:500; margin-bottom:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  802. .history-item .hi-meta { display:flex; align-items:center; gap:6px; font-size:9px; color:var(--text2); }
  803. .history-item .hi-tag { background:var(--bg3); border:1px solid var(--border); border-radius:3px; padding:1px 5px; font-size:8px; color:var(--accent); }
  804. .history-item .hi-summary { font-size:10px; color:var(--text2); margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  805. .history-empty { padding:20px; text-align:center; color:var(--text2); font-size:11px; }
  806. /* Image in user message */
  807. .msg-images { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
  808. .msg-images img { max-width:120px; max-height:80px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
  809. /* Auto-screenshots from tests */
  810. .msg-screenshots { display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; padding:6px; background:var(--bg2); border-radius:6px; border:1px solid var(--border); }
  811. .msg-screenshots .ss-item { position:relative; }
  812. .msg-screenshots img { max-width:200px; max-height:140px; border-radius:4px; border:1px solid var(--border); cursor:pointer; }
  813. .msg-screenshots img:hover { border-color:var(--accent); }
  814. .msg-screenshots .ss-label { position:absolute; bottom:4px; left:4px; background:rgba(0,0,0,0.7); color:#fff; font-size:9px; padding:1px 5px; border-radius:3px; }
  815. .debug-entry .debug-screenshots { display:flex; gap:4px; margin-top:4px; flex-wrap:wrap; }
  816. .debug-entry .debug-screenshots img { max-width:160px; max-height:100px; border-radius:3px; border:1px solid var(--border); cursor:pointer; }
  817. /* AskUserQuestion widget */
  818. .ask-user-widget { margin:6px 0; background:var(--bg2); border:1px solid var(--accent); border-radius:8px; padding:10px 14px; }
  819. .ask-user-widget .ask-question { color:var(--text); font-size:12px; font-weight:600; margin-bottom:8px; }
  820. .ask-user-option { display:flex; align-items:flex-start; gap:8px; padding:6px 10px; margin:3px 0; border-radius:6px; cursor:pointer; border:1px solid var(--border); transition:all 0.15s; }
  821. .ask-user-option:hover { border-color:var(--accent); background:rgba(88,166,255,0.08); }
  822. .ask-user-option.selected { border-color:var(--accent); background:rgba(88,166,255,0.15); }
  823. .ask-user-option input[type=radio],.ask-user-option input[type=checkbox] { margin-top:3px; accent-color:var(--accent); }
  824. .ask-user-option .opt-label { font-size:11px; color:var(--text); font-weight:600; }
  825. .ask-user-option .opt-desc { font-size:10px; color:var(--text2); margin-top:1px; }
  826. .ask-user-other { margin-top:6px; display:flex; gap:6px; }
  827. .ask-user-other input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:5px 8px; font-family:var(--font); font-size:11px; outline:none; }
  828. .ask-user-submit { margin-top:8px; display:flex; justify-content:flex-end; }
  829. .ask-user-submit button { background:var(--accent); color:#fff; border:none; border-radius:4px; padding:5px 14px; font-family:var(--font); font-size:11px; cursor:pointer; }
  830. /* Skill command palette */
  831. .skill-palette { display:none; position:absolute; bottom:100%; left:0; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:6px 6px 0 0; max-height:220px; overflow-y:auto; z-index:65; }
  832. .skill-palette.open { display:block; }
  833. .skill-item { padding:6px 12px; cursor:pointer; font-size:11px; display:flex; align-items:center; gap:8px; }
  834. .skill-item:hover,.skill-item.selected { background:var(--bg3); }
  835. .skill-item .sk-name { color:var(--accent); font-weight:600; }
  836. .skill-item .sk-desc { color:var(--text2); font-size:10px; }
  837. /* Search bar in chat */
  838. .chat-search { display:none; padding:4px 10px; background:var(--bg3); border-bottom:1px solid var(--border); }
  839. .chat-search.open { display:flex; gap:6px; align-items:center; }
  840. .chat-search input { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); padding:4px 8px; font-family:var(--font); font-size:10px; outline:none; }
  841. .chat-search .search-count { font-size:10px; color:var(--text2); }
  842. /* Scrollbar */
  843. ::-webkit-scrollbar { width:5px; }
  844. ::-webkit-scrollbar-track { background:transparent; }
  845. ::-webkit-scrollbar-thumb { background:var(--bg3); border-radius:3px; }
  846. </style>
  847. </head>
  848. <body>
  849. <!-- Landing / Login page (shown before entering IDE) -->
  850. <div class="landing-overlay" id="landingOverlay">
  851. <div class="landing-shell">
  852. <div class="landing-box">
  853. <div class="landing-brand">
  854. <img src="/assets/vlcode-lite-icon.svg?v=20260315" alt="VL-Code logo">
  855. <div>
  856. <h1>VL-Code</h1>
  857. <div class="landing-sub">AI Programming IDE for VL Language &middot; Powered by Claude</div>
  858. </div>
  859. </div>
  860. <!-- Section 1: VL Cloud Login -->
  861. <div class="landing-section">
  862. <h3>&#9729; VL Cloud Platform <span class="ls-badge recommended">Recommended</span></h3>
  863. <div class="landing-tabs">
  864. <div class="landing-tab active" data-ltab="enterprise" onclick="switchLandingTab('enterprise')">Enterprise</div>
  865. <div class="landing-tab" data-ltab="google" onclick="switchLandingTab('google')">Google</div>
  866. <div class="landing-tab" data-ltab="token" onclick="switchLandingTab('token')">Token</div>
  867. </div>
  868. <!-- Enterprise -->
  869. <div class="landing-tab-panel active" id="ltEnterprise">
  870. <input type="text" id="landingUsername" placeholder="Email" autocomplete="username" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
  871. <input type="password" id="landingPassword" placeholder="Password" autocomplete="current-password" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
  872. <input type="text" id="landingCompany" placeholder="Company name (e.g. ivx)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
  873. <button class="hdr-btn hdr-btn-primary" onclick="doLandingEnterpriseLogin()" style="margin-top:4px;">Login &amp; Enter IDE</button>
  874. <div id="landingLoginError" style="display:none;color:var(--red);font-size:10px;margin-top:6px;"></div>
  875. </div>
  876. <!-- Google -->
  877. <div class="landing-tab-panel" id="ltGoogle">
  878. <div style="text-align:center;padding:8px 0;">
  879. <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Google Login in Browser</button>
  880. <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
  881. After Google login, copy your <code style="color:var(--accent)">ih5bearer</code> cookie<br>and paste it in the Token tab.
  882. </div>
  883. </div>
  884. </div>
  885. <!-- Token -->
  886. <div class="landing-tab-panel" id="ltToken">
  887. <input type="text" id="landingDirectCookie" placeholder="ih5bearer token (eyJhbGci...)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:10px;">
  888. <button class="hdr-btn hdr-btn-primary" onclick="doLandingTokenLogin()" style="margin-top:4px;">Connect &amp; Enter IDE</button>
  889. </div>
  890. </div>
  891. <!-- Section 2: Claude API Key (optional) -->
  892. <div class="landing-section">
  893. <h3>&#129302; Claude API Key <span class="ls-badge optional">Optional</span></h3>
  894. <div style="font-size:10px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
  895. If you have a Claude CLI Team subscription, the API Key is <strong style="color:var(--green)">not needed</strong> — CLI will be used automatically.<br>
  896. Only fill this if you don't have a CLI subscription.
  897. </div>
  898. <div id="landingCliStatus" style="font-size:11px;margin-bottom:8px;display:none;"></div>
  899. <input type="password" id="landingApiKey" placeholder="sk-ant-api03-..." style="background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-family:var(--font);font-size:12px;">
  900. </div>
  901. <!-- Enter IDE -->
  902. <button class="hdr-btn hdr-btn-primary" onclick="enterIDE()" style="width:100%;padding:10px;font-size:13px;margin-top:4px;">Enter VL-Code IDE</button>
  903. <div class="landing-skip">
  904. <a onclick="enterIDE()">Skip login, enter IDE directly</a>
  905. </div>
  906. <div style="font-size:9px;color:var(--text2);margin-top:12px;">Settings are saved locally and never shared.</div>
  907. </div>
  908. <div class="landing-docs">
  909. <div class="landing-docs-head">
  910. <div>
  911. <h2>Official DocCenter</h2>
  912. <div class="landing-docs-copy">Search official docs, confirm the slot/path, then copy the numeric <code>Doc ID</code> into VLCode after login. The page also shows the matching ref and viewer link for debugging.</div>
  913. </div>
  914. <button class="hdr-btn" onclick="refreshLandingDocsFrame()">Refresh</button>
  915. </div>
  916. <div class="landing-docs-note">Workflow execution resolves official specs and workflow prompts by <code>Doc ID</code>. Slots stay as reserved aliases; viewer links and <code>Doc Ref</code> are only debug helpers.</div>
  917. <iframe id="landingDocsFrame" class="landing-docs-frame" src="/doc-center.html?embed=landing" title="VLCode DocCenter"></iframe>
  918. </div>
  919. </div>
  920. </div>
  921. <header>
  922. <h1 class="app-brand"><img src="/assets/vlcode-lite-icon.svg?v=20260315" alt="VLCode Lite icon"><span>VLCode Lite <span id="appVersion" style="font-size:11px;font-weight:400;color:var(--text2);vertical-align:middle;"></span></span></h1>
  923. <div class="ws-tabs" id="wsTabs"></div>
  924. <div style="position:relative;">
  925. <div class="ws-popover" id="wsPopover" style="min-width:340px;">
  926. <div class="ws-section" style="display:flex;justify-content:space-between;align-items:center;">
  927. <span>Workspaces</span>
  928. <div style="display:flex;gap:4px;">
  929. <button class="hdr-btn" id="wsOpenFolderBtn" onclick="event.stopPropagation();openWorkspacePicker()" style="display:none;font-size:9px;padding:2px 8px;">Open Folder...</button>
  930. <button class="hdr-btn" onclick="event.stopPropagation();closeWorkspace()" style="font-size:9px;padding:2px 8px;color:var(--text2);">Close</button>
  931. <button class="hdr-btn" onclick="event.stopPropagation();toggleNewProjectForm()" style="font-size:9px;padding:2px 8px;">+ New Project</button>
  932. </div>
  933. </div>
  934. <div id="wsNewProjectForm" style="display:none;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg2);">
  935. <div style="font-size:10px;color:var(--text2);margin-bottom:6px;">Create New VL Project</div>
  936. <div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;">
  937. <span style="font-size:9px;color:var(--text2);white-space:nowrap;">Location:</span>
  938. <input type="text" id="newProjectLocation" placeholder="/path/to/parent" style="flex:1;font-size:10px;padding:3px 6px;color:var(--accent);background:var(--bg1);border:1px solid var(--border);border-radius:3px;">
  939. <button class="hdr-btn" id="newProjectLocationPickBtn" onclick="event.stopPropagation();pickNewProjectLocation()" style="display:none;font-size:9px;padding:3px 8px;">Pick</button>
  940. </div>
  941. <div style="display:flex;gap:4px;">
  942. <input type="text" id="newProjectName" placeholder="Project name..." style="flex:1;font-size:11px;padding:4px 6px;" onkeydown="if(event.key==='Enter')createNewProject()">
  943. <button class="hdr-btn hdr-btn-primary" onclick="createNewProject()" style="font-size:10px;padding:4px 10px;">Create</button>
  944. </div>
  945. <div id="newProjectError" style="font-size:9px;color:var(--red);margin-top:4px;display:none;"></div>
  946. </div>
  947. <div id="wsList"></div>
  948. <div class="ws-browse">
  949. <div class="ws-browse-header">
  950. <button class="browse-up" onclick="event.stopPropagation();browseDirUp()" title="Go to parent directory">&#9650;</button>
  951. <span class="browse-path" id="browsePath">~</span>
  952. <button class="browse-select" onclick="event.stopPropagation();selectBrowseDir()" title="Open this directory as workspace">Select</button>
  953. </div>
  954. <div class="ws-browse-list" id="browseList"></div>
  955. </div>
  956. <div class="ws-add">
  957. <input type="text" id="wsAddPath" placeholder="Or type path..." onkeydown="if(event.key==='Enter')addWorkspace()">
  958. <button class="hdr-btn" onclick="addWorkspace()" style="font-size:10px;padding:3px 8px;">Go</button>
  959. </div>
  960. </div>
  961. </div>
  962. <div class="spacer"></div>
  963. <span class="info" id="projectInfo" style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
  964. <div class="ctx-bar ctx-tooltip">
  965. <span class="info" id="ctxLabel">0%</span>
  966. <div class="bar"><div class="bar-fill" id="ctxBar" style="width:0%"></div></div>
  967. <div class="ctx-detail" id="ctxDetail">Context: 0 / 200K tokens</div>
  968. </div>
  969. <div class="wf-selector" style="position:relative;display:none;">
  970. <button class="hdr-btn" onclick="toggleWorkflowPanel()" id="wfSelectorBtn" title="Select codegen workflow">
  971. <span id="wfSelectorLabel">Parallel</span> <span style="font-size:9px;opacity:0.6">&#9660;</span>
  972. </button>
  973. <div class="wf-dropdown" id="wfDropdown">
  974. <div style="font-size:10px;color:var(--text2);padding:8px 10px;border-bottom:1px solid var(--border);">Codegen Workflow</div>
  975. <div id="wfCodegenOptions"></div>
  976. <div style="font-size:10px;color:var(--text2);padding:8px 10px;border-bottom:1px solid var(--border);border-top:1px solid var(--border);">Adjustment Workflow</div>
  977. <div id="wfAdjustOptions"></div>
  978. <div style="font-size:9px;color:var(--text2);padding:6px 10px;border-top:1px solid var(--border);">
  979. <div style="cursor:pointer;" onclick="toggleWfAllList()">All workflows <span id="wfAllToggle">&#9654;</span></div>
  980. </div>
  981. <div id="wfList" style="max-height:120px;overflow-y:auto;display:none;"></div>
  982. </div>
  983. </div>
  984. <button class="hdr-btn" id="compileBtn" onclick="compileProject()" title="Compile & Preview">&#9654; Compile</button>
  985. <!-- Mode toggle removed (VLCode Lite — no fleet management) -->
  986. <span class="llm-badge cli" id="llmBadge" title="LLM Provider" onclick="openSettings()">CLI</span>
  987. <div class="auth-status" id="authStatus" onclick="onAuthStatusClick()" title="Cloud Platform Account">
  988. <span class="auth-dot" id="authDot"></span>
  989. <span class="auth-label" id="authLabel">Not logged in</span>
  990. </div>
  991. <button class="hdr-btn" onclick="switchMode('docs')" title="Documentation">Docs</button>
  992. <button class="hdr-btn" id="cloudBtn" onclick="toggleCloudPanel()" title="Cloud Platform">&#9729; Cloud</button>
  993. <button class="hdr-btn" onclick="restartBackend()" title="Restart Backend" id="restartBtn">&#8635;</button>
  994. <button class="hdr-btn" onclick="openSettings()" title="Settings">&#9881;</button>
  995. </header>
  996. <main>
  997. <div class="sidebar">
  998. <h3 style="display:flex;justify-content:space-between;align-items:center;">Files <span id="sidebarProjectName" style="font-weight:400;font-size:9px;color:var(--accent);cursor:pointer;text-transform:none;letter-spacing:0;" onclick="openFolderInFinder()" title="Open in Finder"></span></h3>
  999. <div class="sidebar-actions">
  1000. <button class="sa-btn" onclick="importFiles()" title="Import files into project"><span class="sa-icon">+</span>Import</button>
  1001. <button class="sa-btn" onclick="importZipAsProject()" title="Create new project from ZIP"><span class="sa-icon">&#9634;</span>ZIP</button>
  1002. <button class="sa-btn" onclick="exportAll()" title="Export project with all files"><span class="sa-icon">&#8615;</span>Export</button>
  1003. <button class="sa-btn" onclick="exportVLOnly()" title="Export VL files only"><span class="sa-icon">&#8615;</span>VL</button>
  1004. <button class="sa-btn" id="toggleInternalFilesBtn" onclick="toggleInternalFiles()" title="Show internal files and generated artifacts"><span class="sa-icon">&#8942;</span>Internal</button>
  1005. <button class="sa-btn sa-danger" onclick="clearAllFiles()" title="Remove all files"><span class="sa-icon">&times;</span>Clear</button>
  1006. </div>
  1007. <div class="file-tree" id="fileTree"
  1008. ondragover="handleFileTreeDragOver(event)"
  1009. ondragleave="handleFileTreeDragLeave(event)"
  1010. ondrop="handleFileTreeDrop(event)">
  1011. </div>
  1012. <div id="sidebarDropOverlay" style="display:none;position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(99,102,241,0.15);border:2px dashed var(--accent);border-radius:6px;z-index:50;pointer-events:none;">
  1013. <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:var(--accent);font-size:12px;font-weight:600;">Drop files here</div>
  1014. </div>
  1015. <div class="project-config" id="projectConfigPanel">
  1016. <h4 class="pc-header" onclick="$('pcFiles').style.display=$('pcFiles').style.display==='none'?'block':'none'">Project <span style="float:right;font-size:8px;">&#9660;</span></h4>
  1017. <div class="pc-files" id="pcFiles">
  1018. <div class="pc-file" onclick="openFile('.vl-code/VL.md')" title="Project instructions for AI">VL.md</div>
  1019. </div>
  1020. </div>
  1021. <div class="project-config" id="docIdConfigPanel">
  1022. <h4 class="pc-header" onclick="toggleDocIdConfigPanel()" style="display:flex;justify-content:space-between;align-items:center;">
  1023. Official Doc IDs
  1024. <span style="display:flex;gap:4px;align-items:center;">
  1025. <button class="pc-sync-btn" onclick="event.stopPropagation();switchMode('docs')" title="Open embedded DocCenter">Docs</button>
  1026. <button class="pc-sync-btn" onclick="event.stopPropagation();saveDocIdConfigPanel()" title="Save document IDs">Save</button>
  1027. <span style="font-size:8px;">&#9660;</span>
  1028. </span>
  1029. </h4>
  1030. <div id="docIdConfigBody">
  1031. <div class="doc-id-panel-note">这里真正填写的是 <code>Doc ID</code>。也支持直接粘贴 <code>vl://doc/&lt;id&gt;</code> 或 <code>/doc-center.html?docId=&lt;id&gt;</code>,保存时会自动归一成 Doc ID。Slot 只保留给 runtime 内部映射。</div>
  1032. <div class="doc-id-section-title">Core Runtime</div>
  1033. <div class="doc-id-grid" id="docIdCoreGrid"></div>
  1034. <div class="doc-id-section-title doc-id-section-toggle" onclick="toggleDocWorkflowGrid()">
  1035. <span>Workflow Docs</span>
  1036. <span id="docWorkflowToggle">&#9660;</span>
  1037. </div>
  1038. <div class="doc-id-grid" id="docIdWorkflowGrid" style="display:flex;"></div>
  1039. <div class="doc-id-section-title">Locked By Tooling</div>
  1040. <div class="doc-id-grid" id="docIdLockedGrid"></div>
  1041. </div>
  1042. </div>
  1043. <div class="project-config" id="vlDocsPanel">
  1044. <h4 class="pc-header" onclick="$('vlDocsList').style.display=$('vlDocsList').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
  1045. VL Reference Docs
  1046. <span style="display:flex;gap:4px;align-items:center;">
  1047. <button class="pc-sync-btn" onclick="event.stopPropagation();syncVLDocs()" title="Sync docs from DocCenter">&#8635;</button>
  1048. <span style="font-size:8px;">&#9660;</span>
  1049. </span>
  1050. </h4>
  1051. <div class="pc-files" id="vlDocsList" style="display:none;"></div>
  1052. </div>
  1053. <div class="preview-urls" id="previewUrlsPanel" style="display:none">
  1054. <h4>App Previews</h4>
  1055. <div id="previewUrlsList"></div>
  1056. </div>
  1057. <div class="project-config" id="cloudPanel" style="display:none;">
  1058. <h4 class="pc-header" onclick="$('cloudPanelBody').style.display=$('cloudPanelBody').style.display==='none'?'block':'none'" style="display:flex;justify-content:space-between;align-items:center;">
  1059. Cloud Platform
  1060. <span style="display:flex;gap:4px;align-items:center;">
  1061. <span class="cloud-dot" id="cloudDot"></span>
  1062. <span style="font-size:8px;">&#9660;</span>
  1063. </span>
  1064. </h4>
  1065. <div id="cloudPanelBody">
  1066. <div id="cloudLoginPrompt" class="cloud-section">
  1067. <div style="padding:6px 12px;font-size:10px;color:var(--text2);">Not connected</div>
  1068. <button class="sa-btn" onclick="openCloudLogin()" style="margin:0 12px 8px;width:calc(100% - 24px);">Login</button>
  1069. </div>
  1070. <div id="cloudConnected" class="cloud-section" style="display:none;">
  1071. <div class="cloud-user" id="cloudUserInfo"></div>
  1072. <div class="cloud-actions">
  1073. <button class="sa-btn" onclick="cloudSyncPush()" title="Push local files to cloud workspace">Push</button>
  1074. <button class="sa-btn" onclick="cloudSyncPull()" title="Pull cloud files to local">Pull</button>
  1075. <button class="sa-btn" onclick="cloudCompile()" title="Sync + Compile via cloud workspace">Compile</button>
  1076. </div>
  1077. <div class="cloud-gid">
  1078. <div style="display:flex;align-items:center;justify-content:space-between;padding:0 12px;margin-bottom:2px;">
  1079. <label style="font-size:9px;color:var(--text2);">Workspace GID</label>
  1080. <button class="sa-btn" onclick="createCloudProject()" style="font-size:8px;padding:1px 6px;" title="Create a new cloud workspace and get GID">+ New</button>
  1081. </div>
  1082. <input type="text" id="cloudGid" placeholder="select below or enter manually" style="font-size:10px;margin:0 12px 4px;width:calc(100% - 24px);background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:3px 6px;">
  1083. </div>
  1084. <div id="cloudAppsList" style="max-height:120px;overflow-y:auto;"></div>
  1085. <button class="sa-btn sa-small" onclick="cloudLogout()" style="margin:4px 12px 8px;font-size:9px;color:var(--red);">Logout</button>
  1086. </div>
  1087. <div class="cloud-status" id="cloudSyncStatus" style="display:none;"></div>
  1088. </div>
  1089. </div>
  1090. </div>
  1091. <div class="content">
  1092. <div class="panels">
  1093. <div class="editor-panel">
  1094. <div class="mode-tabs" id="modeTabs">
  1095. <div class="mode-tab active" data-mode="code" onclick="switchMode('code')">Code</div>
  1096. <div class="mode-tab" data-mode="meta" onclick="switchMode('meta')">Map <span id="mapReadyDot" style="display:none;width:6px;height:6px;border-radius:50%;background:var(--green,#4caf50);margin-left:3px;vertical-align:middle;display:none;"></span></div>
  1097. <div class="mode-tab" data-mode="flow" onclick="switchMode('flow')">Flow</div>
  1098. <div class="mode-tab" data-mode="docs" onclick="switchMode('docs')">Docs</div>
  1099. <div class="mode-tab" data-mode="preview" id="previewModeTab" onclick="switchMode('preview')" style="display:none">Preview</div>
  1100. </div>
  1101. <div class="preview-bar" id="previewBar" style="display:none;">
  1102. <select id="previewAppSelect" onchange="loadPreviewApp()"></select>
  1103. <span class="preview-url" id="previewUrlLabel"></span>
  1104. <button class="preview-btn" onclick="refreshPreview()">Refresh</button>
  1105. <button class="preview-btn" onclick="openPreviewExternal()">Open External</button>
  1106. </div>
  1107. <div class="flow-toolbar" id="flowToolbar" style="display:none;">
  1108. <div class="flow-sub-tabs">
  1109. <div class="flow-sub-tab active" data-flow="generate" onclick="switchFlowTab('generate')">Generate</div>
  1110. <div class="flow-sub-tab" data-flow="adjust" onclick="switchFlowTab('adjust')">Adjust</div>
  1111. <div class="flow-sub-tab" data-flow="autotest" onclick="switchFlowTab('autotest')" style="color:var(--orange)">Autotest</div>
  1112. </div>
  1113. <div class="flow-actions">
  1114. <select id="flowWfSelect" onchange="onFlowWfSelectChange(this.value)" title="Select workflow">
  1115. <option value="">-- Select Workflow --</option>
  1116. </select>
  1117. <button class="flow-btn" onclick="importFlowJson()" title="Load workflow JSON from file">Load JSON</button>
  1118. <input type="file" id="flowJsonInput" accept=".json" style="display:none">
  1119. <button class="flow-btn flow-btn-run" id="flowRunBtn" onclick="runFlowWorkflow()" title="Execute selected workflow">&#9654; Run</button>
  1120. <span class="flow-run-status" id="flowRunStatus"></span>
  1121. </div>
  1122. </div>
  1123. <!-- Workflow picker for Generate / Adjust sub-tabs -->
  1124. <div class="flow-wf-list" id="flowWfList"></div>
  1125. <!-- AutoTest 3-layer workflow hierarchy (shown only in autotest tab) -->
  1126. <div class="at-wf-list" id="atWfList"></div>
  1127. <div class="editor-tabs" id="editorTabs"></div>
  1128. <div class="editor-area">
  1129. <div id="cmEditorWrap" style="display:none;width:100%;height:100%;"></div>
  1130. <textarea id="editor" spellcheck="false" style="display:none"></textarea>
  1131. <div class="code-preview" id="codePreview"><pre></pre></div>
  1132. <div class="md-preview" id="mdPreview"></div>
  1133. <div class="iframe-container" id="iframeContainer"></div>
  1134. <div class="editor-placeholder" id="editorPlaceholder">
  1135. Click a file in the tree to view &amp; edit<br>
  1136. <span style="font-size:11px;color:var(--text2)">Use Load button to open a project folder</span>
  1137. </div>
  1138. </div>
  1139. </div>
  1140. <!-- Detail Panel — detailed logs without consuming AI context -->
  1141. <div class="detail-panel" id="detailPanel">
  1142. <div class="detail-header" id="detailHeader">
  1143. <span class="dh-title">Detail Log</span>
  1144. <div style="display:flex;gap:4px;align-items:center;">
  1145. <span id="detailCount" style="font-size:8px;color:var(--text2);"></span>
  1146. <button onclick="clearDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:10px;" title="Clear">&times; Clear</button>
  1147. <button onclick="toggleDetailPanel()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:12px;" title="Close">&times;</button>
  1148. </div>
  1149. </div>
  1150. <div class="detail-body" id="detailBody"></div>
  1151. </div>
  1152. <!-- Debug panel removed — replaced by Detail Log -->
  1153. <div class="chat-panel" id="chatPanel">
  1154. <div class="chat-resize-handle" id="chatResizeHandle"></div>
  1155. <div class="chat-header" onclick="if(document.querySelector('.chat-panel').classList.contains('collapsed'))toggleChatCollapse()">
  1156. <span>AI Assistant</span>
  1157. <div style="display:flex;align-items:center;gap:6px;">
  1158. <span id="chatModel"></span>
  1159. <button class="chat-collapse-btn" onclick="event.stopPropagation();toggleChatCollapse()" title="Collapse/Expand chat">&#9664;</button>
  1160. </div>
  1161. </div>
  1162. <div class="chat-actions" id="chatActions">
  1163. <div class="chat-action-group">
  1164. <button class="ca-btn ca-primary" onclick="sendSkillCmd('validate-all')">Validate</button>
  1165. <button class="ca-btn ca-primary" onclick="sendSkillCmd('deploy')">Deploy</button>
  1166. <button class="ca-btn ca-log" onclick="toggleDetailPanel()" title="Toggle Detail Log panel">Logs</button>
  1167. </div>
  1168. <div class="chat-action-group">
  1169. <div class="ca-menu" id="chatMoreMenu">
  1170. <button class="ca-btn" id="chatMoreBtn" onclick="toggleChatMoreMenu(event)">More &#9662;</button>
  1171. <div class="ca-menu-panel">
  1172. <button class="ca-menu-item menu-accent" onclick="chatMenuAction('blueprint')">Blueprint</button>
  1173. <button class="ca-menu-item" onclick="chatMenuAction('search')">Search Chat</button>
  1174. <button class="ca-menu-item" id="compactMenuItem" onclick="chatMenuAction('compact')">Compact Mode</button>
  1175. <button class="ca-menu-item menu-log" onclick="chatMenuAction('settings')">Settings</button>
  1176. </div>
  1177. </div>
  1178. </div>
  1179. </div>
  1180. <div class="conv-tabs" id="convTabs">
  1181. <div class="conv-tab active" data-conv="0">Chat 1</div>
  1182. <button class="conv-new" onclick="newConversation()" title="New conversation">+</button>
  1183. </div>
  1184. <div class="chat-search" id="chatSearch">
  1185. <input id="chatSearchInput" placeholder="Search conversation..." oninput="searchConversation(this.value)">
  1186. <span class="search-count" id="searchCount"></span>
  1187. <button class="input-btn" onclick="closeChatSearch()" style="font-size:12px">&times;</button>
  1188. </div>
  1189. <div class="chat-messages" id="chatMessages"></div>
  1190. <div class="chat-input-area" style="position:relative;">
  1191. <div class="skill-palette" id="skillPalette"></div>
  1192. <div class="mention-dropdown" id="mentionDropdown"></div>
  1193. <div class="chat-attachments" id="chatAttachments"></div>
  1194. <div class="chat-status-bar" id="chatStatusBar" style="display:none">
  1195. <span class="cs-dot"></span>
  1196. <span class="cs-phase" id="csPhase"></span>
  1197. <span class="cs-detail" id="csDetail"></span>
  1198. <span class="cs-elapsed" id="csElapsed"></span>
  1199. <button class="cs-kill" onclick="stopExecution()" title="Kill all running tasks">STOP</button>
  1200. </div>
  1201. <div class="plan-mode-bar" id="planModeBar" style="display:none">
  1202. <span class="plan-mode-label">&#128270; Explore Mode (read-only)</span>
  1203. <button class="plan-approve-btn" id="planApproveBtn" onclick="approvePlan()" style="display:none">&#10003; Approve</button>
  1204. <button class="plan-cancel-btn" id="planCancelBtn" onclick="cancelPlan()">&#10007; Cancel</button>
  1205. </div>
  1206. <div class="chat-input-row">
  1207. <button class="input-btn" onclick="$('imageInput').click()" title="Attach image">&#128247;</button>
  1208. <button class="input-btn" id="planModeToggle" onclick="togglePlanMode()" title="Toggle Plan Mode (explore before implement)">&#128270;</button>
  1209. <textarea id="chatInput" rows="1" placeholder="Describe changes, @mention files, /skill..." autocomplete="off"></textarea>
  1210. <button class="send-btn" id="chatSend" onclick="sendMessage()">Send</button>
  1211. <button class="stop-btn" id="chatStop" onclick="stopExecution()" style="display:none">Stop</button>
  1212. </div>
  1213. </div>
  1214. <input type="file" id="imageInput" accept="image/*" multiple style="display:none">
  1215. </div>
  1216. </div>
  1217. </div>
  1218. </main>
  1219. <div class="bottom-bar">
  1220. <div class="status"><div class="dot dot-green"></div> <span id="statusText">Ready</span></div>
  1221. <span id="fileCount"></span>
  1222. <span id="currentFile"></span>
  1223. <span style="margin-left:auto" id="modelLabel"></span>
  1224. </div>
  1225. <!-- Drop overlay -->
  1226. <div class="drop-overlay" id="dropOverlay" onclick="this.classList.remove('active');dragCounter=0;">
  1227. <div class="drop-msg">
  1228. <h2>Drop Files or Folder</h2>
  1229. <p>Code files, VL files, JSON, ZIP — preserves folder structure</p>
  1230. </div>
  1231. </div>
  1232. <input type="file" id="folderInput" webkitdirectory directory multiple style="display:none">
  1233. <input type="file" id="zipInput" accept=".zip" style="display:none">
  1234. <!-- File context menu -->
  1235. <div class="ctx-menu" id="fileCtxMenu">
  1236. <div class="ctx-menu-item" onclick="ctxOpenFile()">Open</div>
  1237. <div class="ctx-menu-sep"></div>
  1238. <div class="ctx-menu-item danger" onclick="ctxDeleteFile()">Delete File</div>
  1239. </div>
  1240. <!-- Step card context menu -->
  1241. <div class="step-ctx-menu" id="stepCtxMenu">
  1242. <div class="step-ctx-item" onclick="stepCtxRerun()"><span class="sci-icon">🔄</span><span class="sci-label">Re-run from here</span></div>
  1243. <div class="step-ctx-item" onclick="stepCtxViewInDAG()"><span class="sci-icon">🔍</span><span class="sci-label">Highlight in DAG</span></div>
  1244. <div class="step-ctx-sep"></div>
  1245. <div class="step-ctx-item" onclick="stepCtxCopyOutputs()"><span class="sci-icon">📋</span><span class="sci-label">Copy outputs</span></div>
  1246. <div class="step-ctx-item" onclick="stepCtxCopyFiles()"><span class="sci-icon">📄</span><span class="sci-label">Copy file list</span></div>
  1247. <div class="step-ctx-sep"></div>
  1248. <div class="step-ctx-item" onclick="stepCtxToggleBody()"><span class="sci-icon">📂</span><span class="sci-label">Toggle details</span></div>
  1249. <div class="step-ctx-item" onclick="stepCtxExpandAll()"><span class="sci-icon">⬇</span><span class="sci-label">Expand all sections</span></div>
  1250. <div class="step-ctx-item" onclick="stepCtxCollapseAll()"><span class="sci-icon">⬆</span><span class="sci-label">Collapse all sections</span></div>
  1251. </div>
  1252. <!-- Settings Modal -->
  1253. <div class="modal-overlay" id="settingsModal">
  1254. <div class="modal-box">
  1255. <h2>Settings</h2>
  1256. <label>Claude LLM Provider</label>
  1257. <div class="key-status" id="keyStatus" style="margin-bottom:8px;"></div>
  1258. <div class="settings-provider-switch">
  1259. <label class="settings-provider-option">
  1260. <input type="radio" name="settingsProvider" id="settingsProviderCli" value="cli">
  1261. <span class="settings-provider-copy">
  1262. <strong>CLI</strong>
  1263. <span>Default for daily use. Lower cost, works well when Claude CLI is installed.</span>
  1264. </span>
  1265. </label>
  1266. <label class="settings-provider-option">
  1267. <input type="radio" name="settingsProvider" id="settingsProviderApiKey" value="api-key">
  1268. <span class="settings-provider-copy">
  1269. <strong>API Key</strong>
  1270. <span>Use Anthropic API directly when you want full cloud-only execution.</span>
  1271. </span>
  1272. </label>
  1273. </div>
  1274. <div class="settings-provider-hint" id="settingsProviderHint"></div>
  1275. <label>API Key <span style="font-size:9px;color:var(--text2);">(optional if CLI subscription active)</span></label>
  1276. <div class="key-row">
  1277. <input type="password" id="settingsKey" placeholder="sk-ant-api03-...">
  1278. <button class="hdr-btn" onclick="toggleKeyVisibility()">Show</button>
  1279. </div>
  1280. <label>Model</label>
  1281. <select id="settingsModel">
  1282. <option value="claude-opus-4-6">Claude Opus 4.6 (Most capable)</option>
  1283. <option value="claude-sonnet-4-6">Claude Sonnet 4.6 (Faster)</option>
  1284. <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 (Fastest)</option>
  1285. </select>
  1286. <label>Max Output Tokens</label>
  1287. <input type="number" id="settingsMaxTokens" placeholder="32000" value="32000">
  1288. <label>VL Platform <span style="font-size:9px;color:var(--text2)">(use Cloud button to login)</span></label>
  1289. <div style="display:flex;align-items:center;gap:6px;">
  1290. <span id="settingsCloudStatus" style="font-size:10px;color:var(--text2);">Not connected</span>
  1291. <button class="hdr-btn" onclick="closeSettings();toggleCloudPanel();openCloudLogin();" style="font-size:9px;">Login</button>
  1292. </div>
  1293. <input type="hidden" id="settingsCookie">
  1294. <label>Working Directory</label>
  1295. <input type="text" id="settingsWorkDir" disabled style="opacity:0.6">
  1296. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1297. <label>Official Doc IDs</label>
  1298. <div class="settings-doc-hint">输入框里填的是 <code>Doc ID</code>。也支持直接粘贴 <code>vl://doc/&lt;id&gt;</code> 或 <code>/doc-center.html?docId=&lt;id&gt;</code>,保存时会自动归一成稳定 Doc ID;<code>Meta Spec / Workflow Spec</code> 仍保持只读。</div>
  1299. <div class="settings-doc-grid" id="settingsDocIdCoreGrid"></div>
  1300. <div class="settings-doc-hint">Workflow Docs</div>
  1301. <div class="settings-doc-grid" id="settingsDocIdWorkflowGrid"></div>
  1302. <div class="settings-doc-hint">Locked By Tooling</div>
  1303. <div class="settings-doc-grid" id="settingsDocIdLockedGrid"></div>
  1304. </div>
  1305. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1306. <label>AutoTest</label>
  1307. <div style="display:flex;flex-direction:column;gap:6px;">
  1308. <label style="font-size:11px;display:flex;align-items:center;gap:6px;"><input type="checkbox" id="settingsHeadless"> Headless mode (hide browser window)</label>
  1309. <label style="font-size:11px;display:flex;align-items:center;gap:6px;"><input type="checkbox" id="settingsUseWorkflow" checked> Use Workflow Engine (no API Key needed)</label>
  1310. <div style="display:flex;gap:12px;">
  1311. <label style="font-size:11px;display:flex;align-items:center;gap:4px;">Parallel browsers <input type="number" id="settingsParallelBrowsers" value="5" min="1" max="10" style="width:50px;"></label>
  1312. <label style="font-size:11px;display:flex;align-items:center;gap:4px;">Max test cases <input type="number" id="settingsMaxCases" value="10" min="1" max="100" style="width:50px;"></label>
  1313. </div>
  1314. </div>
  1315. </div>
  1316. <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
  1317. <label>Server</label>
  1318. <div style="display:flex;gap:8px;align-items:center;">
  1319. <span id="settingsVersion" style="font-size:11px;color:var(--text2);"></span>
  1320. <button class="hdr-btn" onclick="location.reload();" style="font-size:10px;">Reload Page</button>
  1321. </div>
  1322. </div>
  1323. <div class="modal-actions">
  1324. <button class="hdr-btn" onclick="closeSettings()">Cancel</button>
  1325. <button class="hdr-btn hdr-btn-primary" onclick="saveSettings()">Save</button>
  1326. </div>
  1327. </div>
  1328. </div>
  1329. <!-- Cloud Login Modal -->
  1330. <div class="modal-overlay" id="cloudLoginModal">
  1331. <div class="modal-box" style="max-width:400px;">
  1332. <h2>VL Cloud Login</h2>
  1333. <div id="cloudLoginError" style="display:none;color:var(--red);font-size:11px;padding:6px 8px;background:rgba(255,80,80,0.1);border-radius:4px;margin-bottom:8px;"></div>
  1334. <!-- Login Tabs -->
  1335. <div class="cloud-login-tabs">
  1336. <div class="cl-tab active" data-tab="enterprise" onclick="switchLoginTab('enterprise')">Enterprise</div>
  1337. <div class="cl-tab" data-tab="google" onclick="switchLoginTab('google')">Google</div>
  1338. <div class="cl-tab" data-tab="token" onclick="switchLoginTab('token')">Token</div>
  1339. </div>
  1340. <!-- Enterprise Login -->
  1341. <div class="cl-panel" id="clEnterprise">
  1342. <label>Email</label>
  1343. <input type="text" id="cloudUsername" placeholder="your@email.com" autocomplete="username">
  1344. <label>Password</label>
  1345. <input type="password" id="cloudPassword" placeholder="password" autocomplete="current-password">
  1346. <label>Company Name</label>
  1347. <input type="text" id="cloudCompany" placeholder="e.g. ivx">
  1348. <div class="modal-actions">
  1349. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1350. <button class="hdr-btn hdr-btn-primary" id="cloudLoginBtn" onclick="doEnterpriseLogin()">Login</button>
  1351. </div>
  1352. </div>
  1353. <!-- Google Login -->
  1354. <div class="cl-panel" id="clGoogle" style="display:none;">
  1355. <div style="text-align:center;padding:12px 0;">
  1356. <div id="googleSignInBtn" style="display:inline-block;"></div>
  1357. <div id="googleSignInFallback" style="display:none;padding-top:12px;">
  1358. <div style="font-size:11px;color:var(--text2);margin-bottom:8px;">Google Sign-In unavailable in this context.</div>
  1359. <button class="hdr-btn hdr-btn-primary" onclick="googleLoginViaBrowser()" style="width:100%;">Open Platform Login in Browser</button>
  1360. <div style="font-size:10px;color:var(--text2);margin-top:8px;line-height:1.5;">
  1361. After logging in with Google on the platform,<br>
  1362. copy your <code>ih5bearer</code> cookie and paste it in the Token tab.
  1363. </div>
  1364. </div>
  1365. </div>
  1366. <div id="googleLoginStatus" style="text-align:center;font-size:11px;color:var(--text2);display:none;"></div>
  1367. <div class="modal-actions">
  1368. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1369. </div>
  1370. </div>
  1371. <!-- Token (Advanced) -->
  1372. <div class="cl-panel" id="clToken" style="display:none;">
  1373. <div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.5;">
  1374. Paste your <code>ih5bearer</code> token from the VL platform cookie.<br>
  1375. <span style="font-size:10px;">DevTools &rarr; Application &rarr; Cookies &rarr; ih5bearer</span>
  1376. </div>
  1377. <label>ih5bearer Token</label>
  1378. <input type="text" id="cloudDirectCookie" placeholder="eyJhbGciOiJI..." style="font-size:10px;">
  1379. <div class="modal-actions">
  1380. <button class="hdr-btn" onclick="closeCloudLogin()">Cancel</button>
  1381. <button class="hdr-btn hdr-btn-primary" onclick="doTokenLogin()">Connect</button>
  1382. </div>
  1383. </div>
  1384. </div>
  1385. </div>
  1386. <!-- AutoTest Result Dialog -->
  1387. <div class="modal-overlay" id="autotestResultModal">
  1388. <div class="modal-box" style="max-width:500px;">
  1389. <h2 style="color:var(--orange);">AutoTest Results</h2>
  1390. <div id="autotestResultSummary" style="font-size:12px;margin-bottom:12px;"></div>
  1391. <div id="autotestResultFailures" style="font-size:11px;max-height:200px;overflow-y:auto;margin-bottom:12px;background:var(--bg);padding:8px;border-radius:4px;"></div>
  1392. <label style="font-size:11px;color:var(--text2);">Choose an action:</label>
  1393. <div class="modal-actions" style="flex-direction:column;gap:6px;align-items:stretch;">
  1394. <button class="hdr-btn hdr-btn-primary" onclick="autotestAction('fix')" style="text-align:left;padding:8px 12px;">
  1395. AI Debug + Rerun — Send failures to AI for analysis and auto-fix
  1396. </button>
  1397. <button class="hdr-btn" onclick="autotestAction('report')" style="text-align:left;padding:8px 12px;">
  1398. Generate Report — View detailed test report
  1399. </button>
  1400. <button class="hdr-btn" onclick="autotestAction('skip')" style="text-align:left;padding:8px 12px;">
  1401. Skip — Close and handle manually
  1402. </button>
  1403. </div>
  1404. </div>
  1405. </div>
  1406. <script>
  1407. let currentFile = null;
  1408. let openFiles = new Map(); // key → { type:'file'|'workflow'|'metadata', content?, title?, data? }
  1409. let activeToolGroup = null;
  1410. let ctxMenuTarget = null; // path of right-clicked file
  1411. let currentMode = 'code'; // 'code' | 'meta' | 'flow' | 'docs' | 'preview'
  1412. let _workflowActive = false; // true while a workflow is executing (prevents mode-stealing)
  1413. let currentWorkDir = ''; // workspace folder path
  1414. let currentPort = location.port ? parseInt(location.port) : 80; // this instance's port
  1415. let _settingsSnapshot = null;
  1416. let showInternalFiles = false;
  1417. let workflowBindings = { generate: '3-file-codegen', adjust: 'incremental-update', autotest: 'autotest-pipeline' }; // workflow ID bindings
  1418. let flowRunning = false; // true while a workflow is executing
  1419. const $ = id => document.getElementById(id);
  1420. // ===================== CODEMIRROR SETUP =====================
  1421. // Define VL syntax mode for CodeMirror
  1422. CodeMirror.defineMode('vl', function() {
  1423. const keywords = new Set([
  1424. 'APP','SECTION','COMPONENT','SERVICE','PUBLIC_SERVICE','HANDLER','METHOD','METHOD_PUB',
  1425. 'TABLE','VT','VIRTUAL_TABLE','INDEX','RETURN','IF','ELSE','ELSEIF','FOR','IN','SET',
  1426. 'CALL','GOTO','WHILE','BREAK','CONTINUE','ENUM','NAV','ROUTE','DEVICE_TARGET',
  1427. 'SCREEN_RESOLUTION','STYLE','THEME','EVENT','DATA','FIELD','TRIGGER','BLOCK','TIMER',
  1428. 'TEMPLATE','QUERY','PARAM','COMPUTED','EMIT','USE','DEFAULT','FROM','PUSH','REMOVE',
  1429. 'MATCH','CONDITIONS','ALERT','CONFIRM','NAVIGATE','DISMISS','OPEN','CLOSE',
  1430. 'sourceArray','loopVar','conditions','returns','params','value','options','label',
  1431. 'placeholder','sourceTable','notNull','type','min-height','min-width',
  1432. ]);
  1433. const types = new Set([
  1434. 'STRING','INT','FLOAT','BOOL','BOOLEAN','NUMBER','TIMESTAMP','OBJECT','ARRAY','NULL',
  1435. 'UNIQUE','NORMAL','JSON','LIST','MAP','DATE','TEXT','DECIMAL',
  1436. ]);
  1437. const boolLit = new Set(['true','false','null','undefined']);
  1438. // Style attributes that appear as key:value (e.g. padding:"12px", gap:"8px")
  1439. const styleProps = new Set([
  1440. 'padding','margin','gap','width','height','display','flex','color','background-color',
  1441. 'background','border','border-radius','border-width','border-style','border-color',
  1442. 'font-size','font-weight','font-family','text-transform','text-align','text-decoration',
  1443. 'justify-content','align-items','flex-direction','flex-wrap','overflow','opacity',
  1444. 'position','top','left','right','bottom','z-index','cursor','transition','box-shadow',
  1445. 'margin-top','margin-bottom','margin-left','margin-right','padding-top','padding-bottom',
  1446. 'padding-left','padding-right','border-bottom','border-top','border-left','border-right',
  1447. 'max-width','max-height','min-width','min-height','line-height','letter-spacing',
  1448. 'object-fit','white-space','word-break','text-overflow',
  1449. ]);
  1450. return {
  1451. startState: function() { return { inTag: false }; },
  1452. token: function(stream, state) {
  1453. if (stream.eatSpace()) return null;
  1454. // === VL tree dashes (indent markers) ===
  1455. if (stream.sol() && stream.match(/^-+(?=\s|<|$)/)) return 'qualifier';
  1456. // === Section headers: # Name, ## Frontend Tree, etc. ===
  1457. if (stream.sol() && stream.match(/^#{1,3}\s+.*/)) return 'section-header';
  1458. // === Comments ===
  1459. if (stream.match(/^\/\/.*/)) return 'comment';
  1460. // === Tags: <Component-X>, <Text-Title>, </Row>, <ServiceDomain-Bet> ===
  1461. if (stream.match(/^<\/?[\w-]+/)) { state.inTag = true; return 'tag'; }
  1462. if (state.inTag) {
  1463. if (stream.eat('>')) { state.inTag = false; return 'tag'; }
  1464. if (stream.match(/^"[^"]*"/)) return 'string';
  1465. // key:value attributes inside tags (e.g. type:INT, sourceTable:Users)
  1466. if (stream.match(/^[\w-]+(?=:)/)) return 'attribute';
  1467. if (stream.eat(':')) return 'punctuation';
  1468. // remaining words inside tag are attribute values
  1469. if (stream.match(/^[\w.-]+/)) return 'variable';
  1470. stream.next();
  1471. return null;
  1472. }
  1473. // === $variables ===
  1474. if (stream.match(/^\$\w+/)) return 'variable-2';
  1475. // === @events / @handlers ===
  1476. if (stream.match(/^@\w+/)) return 'def';
  1477. // === Strings ===
  1478. if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return 'string';
  1479. if (stream.match(/^'(?:[^'\\]|\\.)*'/)) return 'string';
  1480. // === Numbers ===
  1481. if (stream.match(/^-?\d+(\.\d+)?(?!\w)/)) return 'number';
  1482. // === CSS variables: --colorBrandPrimary ===
  1483. if (stream.match(/^--[\w-]+/)) return 'atom';
  1484. // === Hex colors: #1a1a1a ===
  1485. if (stream.match(/^#[0-9a-fA-F]{3,8}\b/)) return 'number';
  1486. // === Word tokens (keywords, types, identifiers, properties) ===
  1487. if (stream.match(/^[\w-]+/)) {
  1488. const w = stream.current();
  1489. // Check keywords
  1490. if (keywords.has(w)) return 'keyword';
  1491. // Check types
  1492. if (types.has(w)) return 'type';
  1493. // Check booleans
  1494. if (boolLit.has(w)) return 'atom';
  1495. // Service.Method pattern: word followed by . and another word
  1496. if (stream.peek() === '.' && stream.match(/^\.[\w]+(?=\s*\()/)) return 'def';
  1497. // Property key before colon (e.g. padding: gap: font-size:)
  1498. if (stream.peek() === ':') return 'property';
  1499. // Style property names
  1500. if (styleProps.has(w)) return 'property';
  1501. // Known VL identifiers look-ahead: all-uppercase is likely a keyword/type we missed
  1502. if (/^[A-Z][A-Z_]+$/.test(w)) return 'keyword';
  1503. // PascalCase words are likely component/section/type references
  1504. if (/^[A-Z][a-z]/.test(w)) return 'variable-3';
  1505. // everything else — use default text color (light, visible)
  1506. return 'variable';
  1507. }
  1508. // === Operators & punctuation ===
  1509. if (stream.match(/^[(){}[\]]/)) return 'bracket';
  1510. if (stream.match(/^[=!<>]+/)) return 'operator';
  1511. if (stream.match(/^[,:;|&+*/%-]/)) return 'punctuation';
  1512. // Fallback: advance one char, return visible style
  1513. stream.next();
  1514. return 'variable';
  1515. }
  1516. };
  1517. });
  1518. // Register file extension → CodeMirror mode mapping
  1519. const CM_MODE_MAP = {
  1520. 'vx': 'vl', 'sc': 'vl', 'cp': 'vl', 'vs': 'vl', 'vdb': 'vl', 'vth': 'vl',
  1521. 'js': 'javascript', 'mjs': 'javascript', 'cjs': 'javascript',
  1522. 'json': { name: 'javascript', json: true },
  1523. 'css': 'css',
  1524. 'html': 'htmlmixed', 'htm': 'htmlmixed',
  1525. 'xml': 'xml', 'svg': 'xml',
  1526. 'md': 'text/plain', 'txt': 'text/plain',
  1527. };
  1528. function getCmMode(filePath) {
  1529. const ext = (filePath || '').split('.').pop().toLowerCase();
  1530. return CM_MODE_MAP[ext] || 'text/plain';
  1531. }
  1532. // Global CodeMirror editor instance
  1533. let cmEditor = null;
  1534. function initCodeMirror() {
  1535. if (cmEditor) return;
  1536. if (typeof CodeMirror === 'undefined') {
  1537. console.warn('CodeMirror not loaded, using textarea fallback');
  1538. cmEditor = null;
  1539. return;
  1540. }
  1541. cmEditor = CodeMirror($('cmEditorWrap'), {
  1542. value: '',
  1543. mode: 'vl',
  1544. theme: 'default',
  1545. lineNumbers: true,
  1546. matchBrackets: true,
  1547. autoCloseBrackets: true,
  1548. styleActiveLine: true,
  1549. indentUnit: 2,
  1550. tabSize: 2,
  1551. indentWithTabs: false,
  1552. lineWrapping: false,
  1553. foldGutter: true,
  1554. gutters: ['CodeMirror-linenumber', 'CodeMirror-foldgutter'],
  1555. extraKeys: {
  1556. 'Cmd-S': function() { saveCurrentFile(); },
  1557. 'Ctrl-S': function() { saveCurrentFile(); },
  1558. 'Tab': function(cm) {
  1559. if (cm.somethingSelected()) cm.indentSelection('add');
  1560. else cm.replaceSelection(' ', 'end');
  1561. },
  1562. }
  1563. });
  1564. // Track changes: update openFiles map
  1565. cmEditor.on('change', function() {
  1566. if (currentFile && openFiles.has(currentFile)) {
  1567. const info = openFiles.get(currentFile);
  1568. if (info.type === 'file') info.content = cmEditor.getValue();
  1569. }
  1570. });
  1571. }
  1572. // Chat state
  1573. let pendingImages = []; // [{data: base64, mediaType, preview}]
  1574. let pendingMentions = []; // [filename]
  1575. let allFileNames = []; // for @-mention autocomplete
  1576. let mentionIdx = -1; // selected autocomplete index
  1577. // Multi-conversation state — persisted in localStorage
  1578. let conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  1579. let activeConvId = 0;
  1580. let convIdCounter = 1;
  1581. function resetConversationState() {
  1582. conversations = [{ id: 0, name: 'Chat 1', messages: [], dom: '' }];
  1583. activeConvId = 0;
  1584. convIdCounter = 1;
  1585. if ($('chatMessages')) $('chatMessages').innerHTML = '';
  1586. renderConvTabs();
  1587. }
  1588. function setWorkspaceTriggerHighlight(active) {
  1589. const btn = document.querySelector('.ws-current');
  1590. if (btn) btn.classList.toggle('ws-btn-highlight', !!active);
  1591. }
  1592. function chatStorageKey(wsPath) {
  1593. const p = wsPath || currentWorkDir || '_global';
  1594. return 'vl-code-chat:' + p;
  1595. }
  1596. function saveChatState(wsPath) {
  1597. try {
  1598. const cur = conversations.find(c => c.id === activeConvId);
  1599. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  1600. const state = { conversations, activeConvId, convIdCounter };
  1601. localStorage.setItem(chatStorageKey(wsPath), JSON.stringify(state));
  1602. } catch {}
  1603. }
  1604. /** Fetch chat state from backend (single source of truth) */
  1605. async function fetchChatStateFromServer() {
  1606. try {
  1607. const res = await fetch('/api/chat/state');
  1608. if (!res.ok) return false;
  1609. const data = await res.json();
  1610. if (!data?.conversations?.length) return false;
  1611. conversations = data.conversations.map(c => ({
  1612. id: c.id, name: c.name, messages: c.messages || [], dom: c.dom || '',
  1613. messageCount: c.messageCount || 0,
  1614. }));
  1615. activeConvId = data.activeConvId ?? 0;
  1616. convIdCounter = data.convIdCounter ?? conversations.length;
  1617. // Auto-switch: if active conversation is empty but others have messages, pick first with messages
  1618. const cur = conversations.find(c => c.id === activeConvId);
  1619. if ((!cur || cur.messageCount === 0) && !cur?.dom) {
  1620. const withMessages = conversations.find(c => c.messageCount > 0 || c.dom);
  1621. if (withMessages) {
  1622. activeConvId = withMessages.id;
  1623. }
  1624. }
  1625. const target = conversations.find(c => c.id === activeConvId);
  1626. if ($('chatMessages')) {
  1627. if (target?.dom) {
  1628. // Check if DOM is stale (fewer messages than server has)
  1629. const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
  1630. if (domMsgCount < (target.messageCount || 0)) {
  1631. // DOM is stale — rebuild from server messages
  1632. $('chatMessages').innerHTML = '';
  1633. await _rebuildChatDom(target.id);
  1634. } else {
  1635. $('chatMessages').innerHTML = target.dom;
  1636. }
  1637. } else {
  1638. $('chatMessages').innerHTML = '';
  1639. // If dom is empty but server has messages, rebuild from server
  1640. if (target?.messageCount > 0) {
  1641. await _rebuildChatDom(target.id);
  1642. }
  1643. }
  1644. }
  1645. renderConvTabs();
  1646. // Write-through to localStorage as offline fallback
  1647. saveChatState();
  1648. return true;
  1649. } catch { return false; }
  1650. }
  1651. /**
  1652. * Rebuild chat DOM from server-side messages when dom snapshot is empty.
  1653. * Server endpoint already strips system-reminder content and flattens content blocks.
  1654. */
  1655. async function _rebuildChatDom(convId) {
  1656. try {
  1657. const res = await fetch(`/api/conversations/${convId}/messages`);
  1658. if (!res.ok) return;
  1659. const data = await res.json();
  1660. const msgs = data.messages || [];
  1661. if (!msgs.length) return;
  1662. const container = $('chatMessages');
  1663. if (!container) return;
  1664. container.innerHTML = '';
  1665. for (const m of msgs) {
  1666. if (!m.role || !m.content) continue;
  1667. const el = addMsg(m.role, m.content);
  1668. if (m.role === 'assistant' && el) finalizeAssistantMsg(el);
  1669. }
  1670. // Save rebuilt DOM into conversation object so it doesn't need rebuilding again
  1671. const conv = conversations.find(c => c.id === convId);
  1672. if (conv) conv.dom = container.innerHTML;
  1673. // Push rebuilt DOM to server for persistence
  1674. pushChatStateToServer();
  1675. } catch (e) {
  1676. console.warn('[RebuildDom] Failed for conv', convId, e);
  1677. }
  1678. }
  1679. /** Push chat state to backend (periodic save) */
  1680. async function pushChatStateToServer() {
  1681. const cur = conversations.find(c => c.id === activeConvId);
  1682. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  1683. const state = {
  1684. conversations: conversations.map(c => ({
  1685. id: c.id, name: c.name,
  1686. dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
  1687. })),
  1688. activeConvId,
  1689. convIdCounter,
  1690. };
  1691. try {
  1692. await fetch('/api/chat/state', {
  1693. method: 'POST',
  1694. headers: { 'Content-Type': 'application/json' },
  1695. body: JSON.stringify(state),
  1696. });
  1697. } catch {}
  1698. // Write-through to localStorage
  1699. saveChatState();
  1700. }
  1701. /** Persist debug log entries to filesystem */
  1702. async function persistDebugLog() {} // Debug panel removed
  1703. function loadChatState(wsPath) {
  1704. try {
  1705. const key = chatStorageKey(wsPath);
  1706. const raw = localStorage.getItem(key);
  1707. // Also try migrating old global key on first load
  1708. const fallback = !wsPath && !raw ? localStorage.getItem('vl-code-chat') : null;
  1709. const data = raw || fallback;
  1710. if (!data) return;
  1711. const state = JSON.parse(data);
  1712. if (state.conversations?.length) {
  1713. conversations = state.conversations;
  1714. activeConvId = state.activeConvId || 0;
  1715. convIdCounter = state.convIdCounter || conversations.length;
  1716. const cur = conversations.find(c => c.id === activeConvId);
  1717. if (cur?.dom && $('chatMessages')) {
  1718. $('chatMessages').innerHTML = cur.dom;
  1719. }
  1720. renderConvTabs();
  1721. }
  1722. // Clean up old global key after migration
  1723. if (fallback) localStorage.removeItem('vl-code-chat');
  1724. } catch {}
  1725. }
  1726. /** Sync client conversations with server sessions so chatId targeting works after refresh */
  1727. async function syncSessionsFromServer() {
  1728. try {
  1729. const data = await api('/api/sessions');
  1730. if (!data?.sessions?.length) return;
  1731. let changed = false;
  1732. for (const s of data.sessions) {
  1733. const id = Number(s.chatId);
  1734. if (isNaN(id)) continue;
  1735. if (!conversations.some(c => c.id === id)) {
  1736. conversations.push({ id, name: s.summary ? s.summary.substring(0, 30) : `Chat ${id + 1}`, messages: [] });
  1737. changed = true;
  1738. }
  1739. if (id >= convIdCounter) convIdCounter = id + 1;
  1740. }
  1741. if (changed) {
  1742. renderConvTabs();
  1743. saveChatState();
  1744. }
  1745. } catch {}
  1746. }
  1747. async function clearChatHistory() {
  1748. // Backend first — clears sessions + registry + broadcasts to other tabs
  1749. try {
  1750. await fetch('/api/conversations', { method: 'DELETE' });
  1751. } catch {}
  1752. // Then local
  1753. localStorage.removeItem(chatStorageKey());
  1754. conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  1755. activeConvId = 0;
  1756. convIdCounter = 1;
  1757. $('chatMessages').innerHTML = '';
  1758. renderConvTabs();
  1759. saveChatState();
  1760. }
  1761. // ===================== CHAT PANEL: RESIZE + COLLAPSE =====================
  1762. let chatWidth = parseInt(localStorage.getItem('vl-chat-width') || '400');
  1763. function getDockedChatReserveWidth() {
  1764. const panel = $('chatPanel');
  1765. if (!panel || panel.classList.contains('floating')) return 0;
  1766. return panel.classList.contains('collapsed') ? 36 : chatWidth;
  1767. }
  1768. function syncDockedLayout() {
  1769. const reserve = getDockedChatReserveWidth();
  1770. const panels = document.querySelector('.panels');
  1771. const detailPanel = $('detailPanel');
  1772. if (panels) panels.style.marginRight = reserve + 'px';
  1773. if (detailPanel) detailPanel.style.right = reserve > 0 ? `${reserve + 1}px` : '0';
  1774. }
  1775. function applyChatWidth(w) {
  1776. chatWidth = Math.max(300, Math.min(800, w));
  1777. const panel = $('chatPanel');
  1778. if (!panel.classList.contains('collapsed')) panel.style.width = chatWidth + 'px';
  1779. syncDockedLayout();
  1780. localStorage.setItem('vl-chat-width', String(chatWidth));
  1781. }
  1782. (function initChatResize() {
  1783. document.addEventListener('DOMContentLoaded', () => {
  1784. applyChatWidth(chatWidth);
  1785. const handle = $('chatResizeHandle');
  1786. if (!handle) return;
  1787. handle.addEventListener('mousedown', function(e) {
  1788. e.preventDefault();
  1789. this.classList.add('dragging');
  1790. const startX = e.clientX, startW = chatWidth;
  1791. function onMove(ev) { applyChatWidth(startW + (startX - ev.clientX)); }
  1792. function onUp() {
  1793. document.removeEventListener('mousemove', onMove);
  1794. document.removeEventListener('mouseup', onUp);
  1795. handle.classList.remove('dragging');
  1796. if (typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
  1797. }
  1798. document.addEventListener('mousemove', onMove);
  1799. document.addEventListener('mouseup', onUp);
  1800. });
  1801. // Drag-to-move chat panel by header
  1802. const header = document.querySelector('.chat-header');
  1803. const panel = $('chatPanel');
  1804. if (header && panel) {
  1805. header.addEventListener('mousedown', function(e) {
  1806. // Don't drag if clicking buttons inside header
  1807. if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
  1808. if (panel.classList.contains('collapsed')) return;
  1809. e.preventDefault();
  1810. const rect = panel.getBoundingClientRect();
  1811. const offsetX = e.clientX - rect.left;
  1812. const offsetY = e.clientY - rect.top;
  1813. const wasDocked = !panel.classList.contains('floating');
  1814. function onMove(ev) {
  1815. if (wasDocked && Math.abs(ev.clientX - e.clientX) < 8 && Math.abs(ev.clientY - e.clientY) < 8) return;
  1816. if (!panel.classList.contains('floating')) {
  1817. panel.classList.add('floating');
  1818. panel.style.height = Math.min(rect.height, window.innerHeight - 40) + 'px';
  1819. panel.style.right = 'auto';
  1820. panel.style.top = 'auto';
  1821. panel.style.bottom = 'auto';
  1822. syncDockedLayout();
  1823. }
  1824. let nx = ev.clientX - offsetX;
  1825. let ny = ev.clientY - offsetY;
  1826. nx = Math.max(0, Math.min(window.innerWidth - 100, nx));
  1827. ny = Math.max(0, Math.min(window.innerHeight - 40, ny));
  1828. panel.style.left = nx + 'px';
  1829. panel.style.top = ny + 'px';
  1830. }
  1831. function onUp(ev) {
  1832. document.removeEventListener('mousemove', onMove);
  1833. document.removeEventListener('mouseup', onUp);
  1834. // Snap back to docked if dragged to right edge
  1835. if (panel.classList.contains('floating')) {
  1836. const pr = panel.getBoundingClientRect();
  1837. if (pr.right >= window.innerWidth - 20) {
  1838. snapChatDocked();
  1839. }
  1840. }
  1841. }
  1842. document.addEventListener('mousemove', onMove);
  1843. document.addEventListener('mouseup', onUp);
  1844. });
  1845. }
  1846. });
  1847. })();
  1848. /** Snap chat panel back to docked right-side position */
  1849. function snapChatDocked() {
  1850. const panel = $('chatPanel');
  1851. panel.classList.remove('floating');
  1852. panel.style.left = '';
  1853. panel.style.top = '32px';
  1854. panel.style.right = '0';
  1855. panel.style.height = 'calc(100vh - 32px)';
  1856. panel.style.bottom = '';
  1857. applyChatWidth(chatWidth);
  1858. }
  1859. function toggleChatCollapse() {
  1860. const panel = $('chatPanel');
  1861. const isCollapsed = panel.classList.toggle('collapsed');
  1862. const w = isCollapsed ? 36 : chatWidth;
  1863. panel.style.width = w + 'px';
  1864. syncDockedLayout();
  1865. // Update collapse button arrow direction
  1866. const btn = panel.querySelector('.chat-collapse-btn');
  1867. if (btn) btn.innerHTML = isCollapsed ? '&#9654;' : '&#9664;';
  1868. if (!isCollapsed && typeof cmEditor !== 'undefined' && cmEditor) cmEditor.refresh();
  1869. // Detail Panel follows main chat: hide when collapsed, restore when expanded
  1870. const detailPanel = $('detailPanel');
  1871. if (isCollapsed) {
  1872. // Save detail panel state before hiding
  1873. detailPanel._wasOpenBeforeCollapse = detailPanel.classList.contains('open');
  1874. detailPanel.classList.remove('open');
  1875. } else {
  1876. // Restore detail panel if it was open before collapse
  1877. if (detailPanel._wasOpenBeforeCollapse && !_detailManualClosed) {
  1878. detailPanel.classList.add('open');
  1879. }
  1880. }
  1881. }
  1882. function sendSkillCmd(name) {
  1883. $('chatInput').value = '/' + name;
  1884. sendMessage();
  1885. }
  1886. // ===================== ACTION SHORTCUTS =====================
  1887. function executeAction(actionName) {
  1888. const skillActions = {
  1889. 'validate': 'validate-all',
  1890. 'blueprint': 'blueprint',
  1891. 'deploy': 'deploy',
  1892. 'debug': 'debug',
  1893. };
  1894. const skillName = skillActions[actionName];
  1895. if (skillName) {
  1896. sendSkillCmd(skillName);
  1897. return;
  1898. }
  1899. setStatus('Unknown action: ' + actionName, 'red');
  1900. }
  1901. // ===================== WORKFLOW PROGRESS IN CHAT =====================
  1902. let _activeWfProgress = null;
  1903. /** Create a workflow progress widget in the chat panel */
  1904. function addWorkflowProgress(workflowName, steps) {
  1905. const container = $('chatMessages');
  1906. const div = document.createElement('div');
  1907. div.className = 'wf-progress';
  1908. div.id = 'wfProgress_' + Date.now();
  1909. const stepsHtml = steps.map(s => {
  1910. const typeLabel = s.type || s.id.split('_')[0];
  1911. return '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
  1912. '<span class="wf-step-dot pending"></span>' +
  1913. '<span>' + escapeHtml(s.title || s.id) + '</span>' +
  1914. '<span class="wf-step-type">' + escapeHtml(typeLabel) + '</span>' +
  1915. '</div>';
  1916. }).join('');
  1917. div.innerHTML = '<div class="wf-progress-header">' +
  1918. '<span class="wf-icon">&#9881;</span>' +
  1919. '<span>' + escapeHtml(workflowName) + '</span>' +
  1920. '</div>' + stepsHtml;
  1921. container.appendChild(div);
  1922. _activeWfProgress = div;
  1923. scrollChat();
  1924. return div;
  1925. }
  1926. /** Update a node's status dot in the active workflow progress widget */
  1927. function updateWfProgressNode(nodeId, status) {
  1928. if (!_activeWfProgress) return;
  1929. const step = _activeWfProgress.querySelector('[data-node-id="' + nodeId + '"]');
  1930. if (!step) return;
  1931. const dot = step.querySelector('.wf-step-dot');
  1932. if (dot) dot.className = 'wf-step-dot ' + status;
  1933. step.classList.toggle('active', status === 'running');
  1934. step.classList.toggle('completed', status === 'done');
  1935. }
  1936. /** Show a workflow approval prompt in chat (for LLM-generated workflows) */
  1937. function addWorkflowApproval(wfData) {
  1938. const container = $('chatMessages');
  1939. const div = document.createElement('div');
  1940. div.className = 'wf-progress';
  1941. const stepsHtml = (wfData.steps || []).map(s =>
  1942. '<div class="wf-step" data-node-id="' + escapeHtml(s.id) + '">' +
  1943. '<span class="wf-step-dot pending"></span>' +
  1944. '<span>' + escapeHtml(s.title || s.id) + '</span>' +
  1945. '<span class="wf-step-type">' + escapeHtml(s.type || '') + '</span>' +
  1946. '</div>'
  1947. ).join('');
  1948. div.innerHTML = '<div class="wf-progress-header">' +
  1949. '<span class="wf-icon">&#128736;</span>' +
  1950. '<span>Workflow: ' + escapeHtml(wfData.workflow?.name || wfData.name || 'Untitled') + '</span>' +
  1951. '</div>' +
  1952. stepsHtml +
  1953. '<div class="wf-progress-actions" id="wfApprovalActions_' + (wfData.name || '') + '">' +
  1954. '<button class="wf-approve-btn" onclick="approveAndRunWorkflow(\'' + escapeHtml(wfData.name || '') + '\', this)">&#10003; Approve & Run</button>' +
  1955. '<button class="wf-cancel-btn" onclick="this.closest(\'.wf-progress-actions\').innerHTML=\'<span style=color:var(--red);font-size:10px>Cancelled.</span>\'">&#10007; Cancel</button>' +
  1956. '<button onclick="viewWorkflow(\'' + escapeHtml(wfData.name || '') + '\');switchMode(\'flow\')">View DAG</button>' +
  1957. '</div>';
  1958. container.appendChild(div);
  1959. scrollChat();
  1960. return div;
  1961. }
  1962. // ── Workflow LLM Chat Streaming ──
  1963. // Streams LLM thinking/response/tool messages into the main chat window
  1964. // during local workflow execution, so the user sees everything in real-time.
  1965. let _wfLlmChatEl = null; // Current chat message element
  1966. let _wfLlmThinkingEl = null; // Thinking collapsible block inside chat
  1967. let _wfLlmResponseEl = null; // Response text element inside chat
  1968. let _wfLlmRawText = ''; // Raw accumulated response for markdown render
  1969. let _wfLlmFlushTimer = null; // Batch DOM updates
  1970. function _wfLlmChatEnsure() {
  1971. if (_wfLlmChatEl) return;
  1972. const container = $('chatMessages');
  1973. const div = document.createElement('div');
  1974. div.className = 'msg assistant';
  1975. div.style.position = 'relative';
  1976. const now = formatMsgTime(new Date());
  1977. div.innerHTML = `<div class="label">assistant <span class="msg-time">${now}</span></div>` +
  1978. `<div class="wf-llm-thinking" style="display:none;margin:4px 0;padding:6px 8px;background:var(--bg2);border-radius:4px;border-left:2px solid var(--purple);font-size:10px;color:var(--text2);max-height:150px;overflow:auto;cursor:pointer;font-style:italic;" onclick="this.style.maxHeight=this.style.maxHeight==='150px'?'none':'150px'"></div>` +
  1979. `<span class="content-text"></span>`;
  1980. container.appendChild(div);
  1981. _wfLlmChatEl = div;
  1982. _wfLlmThinkingEl = div.querySelector('.wf-llm-thinking');
  1983. _wfLlmResponseEl = div.querySelector('.content-text');
  1984. _wfLlmRawText = '';
  1985. scrollChat();
  1986. }
  1987. function _wfLlmChatAppend(type, text) {
  1988. _wfLlmChatEnsure();
  1989. if (type === 'thinking') {
  1990. _wfLlmThinkingEl.style.display = '';
  1991. _wfLlmThinkingEl.textContent += text;
  1992. } else {
  1993. _wfLlmRawText += text;
  1994. // Batch DOM updates for smooth rendering
  1995. if (!_wfLlmFlushTimer) {
  1996. _wfLlmFlushTimer = setTimeout(() => {
  1997. _wfLlmResponseEl.textContent = _wfLlmRawText;
  1998. _wfLlmFlushTimer = null;
  1999. scrollChat();
  2000. }, 100);
  2001. }
  2002. }
  2003. }
  2004. function _wfLlmChatToolUse(name, input) {
  2005. _wfLlmChatEnsure();
  2006. const toolDiv = document.createElement('div');
  2007. toolDiv.style.cssText = 'margin:4px 0;padding:4px 8px;background:var(--bg2);border-radius:4px;font-size:10px;border-left:2px solid var(--accent);';
  2008. const inputStr = JSON.stringify(input);
  2009. const short = inputStr.length > 120 ? inputStr.slice(0, 120) + '...' : inputStr;
  2010. toolDiv.innerHTML = `<span style="color:var(--accent);font-weight:600;">🔧 ${escapeHtml(name)}</span> <span style="color:var(--text2);">${escapeHtml(short)}</span>`;
  2011. toolDiv.style.cursor = 'pointer';
  2012. toolDiv.title = 'Click to expand';
  2013. toolDiv.onclick = () => { toolDiv.querySelector('.wf-tool-full')?.classList.toggle('collapsed'); };
  2014. if (inputStr.length > 120) {
  2015. toolDiv.innerHTML += `<div class="wf-tool-full collapsed" style="margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2);">${escapeHtml(inputStr)}</div>`;
  2016. }
  2017. _wfLlmChatEl.appendChild(toolDiv);
  2018. scrollChat();
  2019. }
  2020. function _wfLlmChatToolResult(result, isError) {
  2021. _wfLlmChatEnsure();
  2022. const resDiv = document.createElement('div');
  2023. resDiv.style.cssText = `margin:2px 0 4px;padding:4px 8px;background:var(--bg2);border-radius:4px;font-size:10px;border-left:2px solid ${isError ? 'var(--red)' : 'var(--green)'};max-height:100px;overflow:auto;cursor:pointer;`;
  2024. resDiv.onclick = () => { resDiv.style.maxHeight = resDiv.style.maxHeight === '100px' ? 'none' : '100px'; };
  2025. const short = result.length > 200 ? result.slice(0, 200) + '...' : result;
  2026. resDiv.innerHTML = `<span style="color:${isError ? 'var(--red)' : 'var(--green)'};font-weight:600;">${isError ? '✗' : '✓'} Result</span> <span style="color:var(--text2);white-space:pre-wrap;">${escapeHtml(short)}</span>`;
  2027. if (result.length > 200) {
  2028. resDiv.innerHTML += `<div style="margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2);">${escapeHtml(result)}</div>`;
  2029. }
  2030. _wfLlmChatEl.appendChild(resDiv);
  2031. scrollChat();
  2032. }
  2033. function _wfLlmChatFinalize(summary) {
  2034. if (!_wfLlmChatEl) return;
  2035. // Flush pending text
  2036. if (_wfLlmFlushTimer) { clearTimeout(_wfLlmFlushTimer); _wfLlmFlushTimer = null; }
  2037. // Render markdown
  2038. if (_wfLlmRawText && _wfLlmResponseEl) {
  2039. _wfLlmResponseEl.innerHTML = renderMarkdown(_wfLlmRawText);
  2040. // Add Apply buttons to code blocks
  2041. _wfLlmResponseEl.querySelectorAll('pre').forEach(pre => {
  2042. const btn = document.createElement('button');
  2043. btn.className = 'code-apply';
  2044. btn.textContent = 'Apply';
  2045. btn.onclick = () => applyCodeBlock(pre);
  2046. pre.style.position = 'relative';
  2047. pre.appendChild(btn);
  2048. });
  2049. }
  2050. // Add usage summary footer
  2051. if (summary) {
  2052. const footer = document.createElement('div');
  2053. footer.style.cssText = 'font-size:9px;color:var(--text2);margin-top:4px;padding-top:4px;border-top:1px solid var(--border);';
  2054. footer.textContent = summary;
  2055. _wfLlmChatEl.appendChild(footer);
  2056. }
  2057. scrollChat();
  2058. // Reset for next LLM call within same workflow
  2059. _wfLlmChatEl = null;
  2060. _wfLlmThinkingEl = null;
  2061. _wfLlmResponseEl = null;
  2062. _wfLlmRawText = '';
  2063. }
  2064. function _wfLlmChatError(errMsg, retryable) {
  2065. _wfLlmChatEnsure();
  2066. const errDiv = document.createElement('div');
  2067. errDiv.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;';
  2068. errDiv.textContent = `✗ LLM Error${retryable ? ' (retryable)' : ''}: ${errMsg}`;
  2069. _wfLlmChatEl.appendChild(errDiv);
  2070. scrollChat();
  2071. _wfLlmChatEl = null;
  2072. _wfLlmThinkingEl = null;
  2073. _wfLlmResponseEl = null;
  2074. _wfLlmRawText = '';
  2075. }
  2076. /** Approve and execute a workflow from chat */
  2077. async function approveAndRunWorkflow(name, btn) {
  2078. const actionsDiv = btn.closest('.wf-progress-actions');
  2079. actionsDiv.innerHTML = '<span style="color:var(--green);font-size:10px;">Approved. Executing...</span>';
  2080. const approvalWidget = btn.closest('.wf-progress');
  2081. _activeWfProgress = approvalWidget;
  2082. try {
  2083. const res = await fetch('/api/workflow/execute', {
  2084. method: 'POST',
  2085. headers: { 'Content-Type': 'application/json' },
  2086. body: JSON.stringify({ workflowName: name, params: {} }),
  2087. });
  2088. const reader = res.body.getReader();
  2089. const decoder = new TextDecoder();
  2090. let buffer = '';
  2091. while (true) {
  2092. const { done, value } = await reader.read();
  2093. if (done) break;
  2094. buffer += decoder.decode(value, { stream: true });
  2095. const blocks = buffer.split('\n\n');
  2096. buffer = blocks.pop();
  2097. for (const block of blocks) {
  2098. let eType = 'message', eData = null;
  2099. for (const line of block.split('\n')) {
  2100. if (line.startsWith('event: ')) eType = line.slice(7).trim();
  2101. else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
  2102. }
  2103. if (!eData) continue;
  2104. switch (eType) {
  2105. case 'workflow_start': {
  2106. const wfModel = eData.model ? ` [${eData.model}]` : '';
  2107. addDetailEntry('workflow', `► Workflow started: ${eData.name || ''}${wfModel} (${eData.stepCount || '?'} steps)`, null, 'info');
  2108. addMsg('assistant', `**▶ Workflow: ${eData.name || 'running'}**${wfModel ? ' — Model: ' + eData.model : ''}`);
  2109. break;
  2110. }
  2111. case 'node_start': {
  2112. updateWfProgressNode(eData.nodeId, 'running');
  2113. const nsLabel = eData.title || eData.nodeId;
  2114. const nsType = eData.type ? `[${eData.type}] ` : '';
  2115. const nsInput = eData.input ? JSON.stringify(eData.input, null, 2) : null;
  2116. addDetailEntry('node', `▶ ${nsType}${nsLabel}`, nsInput, 'info');
  2117. addMsg('assistant', `**Step: ${nsType}${nsLabel}**`);
  2118. break;
  2119. }
  2120. case 'node_done': {
  2121. updateWfProgressNode(eData.nodeId, 'done');
  2122. const ndLabel = eData.title || eData.nodeId;
  2123. const ndDur = eData.duration_ms ? ` (${eData.duration_ms >= 1000 ? (eData.duration_ms / 1000).toFixed(1) + 's' : eData.duration_ms + 'ms'})` : '';
  2124. const ndOutput = eData.output ? JSON.stringify(eData.output, null, 2) : null;
  2125. addDetailEntry('node', `✓ ${ndLabel}${ndDur}`, ndOutput, 'success');
  2126. break;
  2127. }
  2128. case 'node_error': {
  2129. updateWfProgressNode(eData.nodeId, 'error');
  2130. const neLabel = eData.title || eData.nodeId;
  2131. const neType = eData.type ? `[${eData.type}] ` : '';
  2132. const neDur = eData.duration_ms ? ` (${(eData.duration_ms / 1000).toFixed(1)}s)` : '';
  2133. addDetailEntry('node', `✗ ${neType}${neLabel}${neDur} — ${eData.error || ''}`, eData.detail || null, 'error');
  2134. addMsg('assistant', `**✗ Error: ${neLabel}** — ${eData.error || ''}`);
  2135. break;
  2136. }
  2137. case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
  2138. // ── Extended LLM events → Detail Log + Main Chat ──
  2139. case 'llm_thinking':
  2140. appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
  2141. _wfLlmChatAppend('thinking', eData.delta || '');
  2142. break;
  2143. case 'token':
  2144. appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || eData.delta || '');
  2145. _wfLlmChatAppend('response', eData.token || eData.delta || '');
  2146. break;
  2147. case 'llm_tool_use': {
  2148. const ltuInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
  2149. addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, ltuInput, 'info', { depth: 1 });
  2150. _wfLlmChatToolUse(eData.name || 'unknown', eData.input || {});
  2151. break;
  2152. }
  2153. case 'llm_tool_result': {
  2154. const isErr = eData.is_error || false;
  2155. const rc = eData.content || '';
  2156. const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
  2157. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${eData.name || 'Result'}${eData.tool_use_id ? ' [' + eData.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
  2158. _wfLlmChatToolResult(rs, isErr);
  2159. break;
  2160. }
  2161. case 'tool_start': {
  2162. const toolInput = eData.input ? (typeof eData.input === 'string' ? eData.input : JSON.stringify(eData.input, null, 2)) : null;
  2163. addDetailEntry('tool-call', `🛠 ${eData.name || eData.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
  2164. break;
  2165. }
  2166. case 'tool_done': {
  2167. const toolOutput = eData.output ? (typeof eData.output === 'string' ? eData.output : JSON.stringify(eData.output, null, 2)) : null;
  2168. addDetailEntry('tool-result', `✓ ${eData.name || eData.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
  2169. break;
  2170. }
  2171. case 'tool_error': {
  2172. addDetailEntry('tool-result', `✗ ${eData.name || eData.stepId || 'tool'}${eData.allowError ? ' (continued)' : ''}`, eData.error || null, eData.allowError ? 'warn' : 'error', { depth: 1 });
  2173. break;
  2174. }
  2175. case 'tool_message': {
  2176. const detail = eData.data ? (typeof eData.data === 'string' ? eData.data : JSON.stringify(eData.data, null, 2)) : null;
  2177. addDetailEntry('tool-call', `• ${eData.name || eData.stepId || 'tool'}: ${eData.message || ''}`, detail, eData.level === 'error' ? 'error' : eData.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  2178. break;
  2179. }
  2180. case 'llm_done': {
  2181. flushStreamBoxes();
  2182. const mdl = eData.model || '';
  2183. const usg = eData.usage || {};
  2184. const inTok = usg.input_tokens || 0;
  2185. const outTok = usg.output_tokens || 0;
  2186. const cacheTok = usg.cache_read_input_tokens || 0;
  2187. const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
  2188. const tokParts = [];
  2189. if (inTok) tokParts.push(`in:${inTok}`);
  2190. if (cacheTok) tokParts.push(`cache:${cacheTok}`);
  2191. if (outTok) tokParts.push(`out:${outTok}`);
  2192. const parts = [mdl, tokParts.join(' '), lat].filter(Boolean).join(' | ');
  2193. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  2194. _wfLlmChatFinalize(parts);
  2195. break;
  2196. }
  2197. case 'llm_error': {
  2198. const errParts = [eData.error || 'Unknown'];
  2199. if (eData.type) errParts.push(`type:${eData.type}`);
  2200. if (eData.code) errParts.push(`code:${eData.code}`);
  2201. addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${errParts.join(' | ')}`, eData, 'error');
  2202. _wfLlmChatError(eData.error || 'Unknown LLM error', eData.retryable);
  2203. break;
  2204. }
  2205. case 'var_changed': {
  2206. const vn = eData.name || '?';
  2207. const vo = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 120) : '—';
  2208. const vn2 = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 120) : '—';
  2209. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, eData, 'info', { depth: 1 });
  2210. break;
  2211. }
  2212. case 'file_start':
  2213. addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
  2214. break;
  2215. case 'pause':
  2216. updateWfProgressNode(eData.nodeId, 'paused');
  2217. addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
  2218. addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
  2219. break;
  2220. case 'resumed':
  2221. updateWfProgressNode(eData.nodeId, 'running');
  2222. addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
  2223. break;
  2224. case 'file_written':
  2225. { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
  2226. break;
  2227. case 'done':
  2228. flushStreamBoxes();
  2229. addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
  2230. addDetailEntry('workflow', 'Workflow completed', null, 'success');
  2231. await loadFileTree();
  2232. break;
  2233. case 'error':
  2234. addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
  2235. addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
  2236. break;
  2237. }
  2238. }
  2239. }
  2240. } catch (e) {
  2241. addMsg('assistant', '**Workflow execution error:** ' + e.message);
  2242. }
  2243. _activeWfProgress = null;
  2244. }
  2245. /** Show Pause/Resume UI in chat */
  2246. function addPauseResumeUI(nodeId, title, runID) {
  2247. const container = $('chatMessages');
  2248. const div = document.createElement('div');
  2249. div.className = 'wf-progress';
  2250. div.innerHTML = '<div class="wf-progress-header">' +
  2251. '<span class="wf-icon" style="color:var(--purple);">&#9208;</span>' +
  2252. '<span>Paused: ' + escapeHtml(title || nodeId) + '</span>' +
  2253. '</div>' +
  2254. '<div style="padding:4px 0;font-size:10px;color:var(--text2);">Review the current state and approve to continue.</div>' +
  2255. '<div class="wf-progress-actions">' +
  2256. '<button class="wf-approve-btn">&#10003; Continue</button>' +
  2257. '<button class="wf-cancel-btn">&#10007; Abort</button>' +
  2258. '</div>';
  2259. const approveBtn = div.querySelector('.wf-approve-btn');
  2260. const cancelBtn = div.querySelector('.wf-cancel-btn');
  2261. if (approveBtn) approveBtn.onclick = () => resumeWorkflow(nodeId, runID, approveBtn);
  2262. if (cancelBtn) cancelBtn.onclick = () => cancelWorkflow(nodeId, runID, cancelBtn);
  2263. container.appendChild(div);
  2264. scrollChat();
  2265. }
  2266. /** Resume a paused workflow */
  2267. async function resumeWorkflow(nodeId, runID, btn) {
  2268. btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--green);font-size:10px;">Resumed...</span>';
  2269. await fetch('/api/workflow/resume', {
  2270. method: 'POST',
  2271. headers: { 'Content-Type': 'application/json' },
  2272. body: JSON.stringify({ runID, nodeId, payload: { approved: true } }),
  2273. });
  2274. }
  2275. /** Cancel a paused workflow */
  2276. async function cancelWorkflow(nodeId, runID, btn) {
  2277. btn.closest('.wf-progress-actions').innerHTML = '<span style="color:var(--red);font-size:10px;">Aborted.</span>';
  2278. await fetch('/api/workflow/cancel', {
  2279. method: 'POST',
  2280. headers: { 'Content-Type': 'application/json' },
  2281. body: JSON.stringify({ runID, nodeId }),
  2282. });
  2283. }
  2284. // ===================== LANDING PAGE =====================
  2285. function switchLandingTab(tab) {
  2286. document.querySelectorAll('.landing-tab').forEach(t => t.classList.toggle('active', t.dataset.ltab === tab));
  2287. document.querySelectorAll('.landing-tab-panel').forEach(p => p.classList.remove('active'));
  2288. const panelMap = { enterprise: 'ltEnterprise', google: 'ltGoogle', token: 'ltToken' };
  2289. if (panelMap[tab]) $(panelMap[tab]).classList.add('active');
  2290. }
  2291. async function doLandingEnterpriseLogin() {
  2292. const username = $('landingUsername').value.trim();
  2293. const password = $('landingPassword').value.trim();
  2294. const companyName = $('landingCompany').value.trim();
  2295. if (!username || !password) { $('landingLoginError').textContent = 'Email and password required'; $('landingLoginError').style.display = 'block'; return; }
  2296. $('landingLoginError').style.display = 'none';
  2297. try {
  2298. const res = await fetch('/api/cloud/login', {
  2299. method: 'POST', headers: {'Content-Type':'application/json'},
  2300. body: JSON.stringify({ username, password, companyName })
  2301. });
  2302. const data = await res.json();
  2303. if (data.error) { $('landingLoginError').textContent = data.error; $('landingLoginError').style.display = 'block'; return; }
  2304. _cloudConnected = true;
  2305. enterIDE();
  2306. } catch (e) { $('landingLoginError').textContent = e.message; $('landingLoginError').style.display = 'block'; }
  2307. }
  2308. async function doLandingTokenLogin() {
  2309. const cookie = $('landingDirectCookie').value.trim();
  2310. if (!cookie) return;
  2311. await fetch('/api/cookie/refresh', {
  2312. method: 'POST', headers: {'Content-Type':'application/json'},
  2313. body: JSON.stringify({ cookie })
  2314. });
  2315. _cloudConnected = true;
  2316. enterIDE();
  2317. }
  2318. function refreshLandingDocsFrame() {
  2319. const frame = $('landingDocsFrame');
  2320. if (!frame) return;
  2321. frame.src = buildDocCenterEmbedSrc({ embed: 'landing', force: true });
  2322. }
  2323. async function enterIDE() {
  2324. // Save API key if provided
  2325. const apiKey = $('landingApiKey')?.value?.trim();
  2326. if (apiKey) {
  2327. await fetch('/api/settings', {
  2328. method: 'POST', headers: {'Content-Type':'application/json'},
  2329. body: JSON.stringify({ apiKey })
  2330. });
  2331. }
  2332. // Mark as entered and hide landing
  2333. sessionStorage.setItem('vlcode_entered', '1');
  2334. $('landingOverlay').classList.remove('active');
  2335. await initIDE();
  2336. }
  2337. async function checkCliStatus() {
  2338. try {
  2339. const res = await fetch('/api/cli-status');
  2340. const data = await res.json();
  2341. const el = $('landingCliStatus');
  2342. if (data.available) {
  2343. el.innerHTML = '<span style="color:var(--green);">&#10003; Claude CLI detected — Team subscription active, no API Key needed</span>';
  2344. el.style.display = 'block';
  2345. } else {
  2346. el.innerHTML = '<span style="color:var(--yellow);">&#9888; Claude CLI not detected — API Key may be needed</span>';
  2347. el.style.display = 'block';
  2348. }
  2349. return data;
  2350. } catch { return { available: false }; }
  2351. }
  2352. function updateLlmBadge(provider) {
  2353. const badge = $('llmBadge');
  2354. if (!badge) return;
  2355. if (provider === 'cli') {
  2356. badge.textContent = 'CLI';
  2357. badge.className = 'llm-badge cli';
  2358. badge.title = 'Using Claude CLI (Team subscription)';
  2359. } else if (provider === 'api-key') {
  2360. badge.textContent = 'API Key';
  2361. badge.className = 'llm-badge apikey';
  2362. badge.title = 'Using Anthropic API Key';
  2363. } else {
  2364. badge.textContent = provider || 'CLI';
  2365. badge.className = 'llm-badge cli';
  2366. }
  2367. }
  2368. function getSelectedSettingsProvider() {
  2369. return document.querySelector('input[name="settingsProvider"]:checked')?.value || 'cli';
  2370. }
  2371. function renderProviderSettingsState(settings = {}) {
  2372. _settingsSnapshot = { ...(_settingsSnapshot || {}), ...settings };
  2373. const selected = _settingsSnapshot.llmProvider || 'cli';
  2374. const effective = _settingsSnapshot.effectiveProvider || selected;
  2375. const cliAvailable = !!_settingsSnapshot.cliAvailable;
  2376. const hasApiKey = !!_settingsSnapshot.hasApiKey;
  2377. $('settingsProviderCli').checked = selected === 'cli';
  2378. $('settingsProviderApiKey').checked = selected === 'api-key';
  2379. const summary = effective === 'cli'
  2380. ? '<span class="key-ok" style="color:var(--green);">Active provider: CLI</span>'
  2381. : '<span class="key-ok">Active provider: API Key</span>';
  2382. $('keyStatus').innerHTML = summary;
  2383. let hint = '';
  2384. if (selected === 'cli') {
  2385. hint = cliAvailable
  2386. ? 'CLI mode selected. This is the recommended lower-cost path when Claude CLI is installed.'
  2387. : (hasApiKey
  2388. ? 'CLI mode selected, but Claude CLI is not available here. Runtime will fall back to API Key until CLI is installed.'
  2389. : 'CLI mode selected, but Claude CLI is not available yet.');
  2390. } else {
  2391. hint = hasApiKey
  2392. ? 'API Key mode selected. Requests will use the configured Anthropic key directly.'
  2393. : (cliAvailable
  2394. ? 'API Key mode selected, but no key is configured. Runtime will fall back to CLI.'
  2395. : 'API Key mode selected, but no key is configured yet.');
  2396. }
  2397. $('settingsProviderHint').textContent = hint;
  2398. }
  2399. // ===================== INIT =====================
  2400. async function init() {
  2401. const entered = sessionStorage.getItem('vlcode_entered');
  2402. let cloudLoggedIn = false;
  2403. try {
  2404. const cs = await api('/api/cloud/status');
  2405. if (cs.loggedIn) {
  2406. _cloudConnected = true;
  2407. cloudLoggedIn = true;
  2408. }
  2409. } catch {}
  2410. if (!isDesktopApp() && !entered && !cloudLoggedIn) {
  2411. $('landingOverlay').classList.add('active');
  2412. checkCliStatus();
  2413. refreshLandingDocsFrame();
  2414. return;
  2415. }
  2416. sessionStorage.setItem('vlcode_entered', '1');
  2417. await initIDE();
  2418. }
  2419. async function initIDE() {
  2420. syncDesktopWorkspaceUI();
  2421. const settings = await api('/api/settings');
  2422. applyDocIdSettings(settings);
  2423. updateLlmBadge(settings.effectiveProvider || settings.llmProvider || 'cli');
  2424. setInternalFilesVisible(localStorage.getItem('vl-code-show-internal') === '1', { reload: false, persist: false });
  2425. const proj = await api('/api/project');
  2426. currentWorkDir = proj.workDir || '';
  2427. if (proj.port) currentPort = proj.port;
  2428. if (proj.version) $('appVersion').textContent = 'v' + proj.version;
  2429. $('chatModel').textContent = shortModel(proj.model);
  2430. $('modelLabel').textContent = shortModel(proj.model);
  2431. // Determine if a valid VL workspace is loaded
  2432. const hasVLProject = proj.isVL && proj.summary?.totalFiles > 0;
  2433. if (hasVLProject) {
  2434. // VL project loaded — show project info and file tree
  2435. const wsName = proj.summary?.projectName || path_basename(proj.workDir);
  2436. $('projectInfo').textContent = `${proj.summary.totalFiles} files`;
  2437. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2438. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2439. _tabWorkspaceName = wsName;
  2440. setTabStatus(_tabStatus);
  2441. await loadFileTree();
  2442. } else if (currentWorkDir) {
  2443. // Non-VL workspace selected — still show file tree and workspace name
  2444. const wsName = path_basename(currentWorkDir);
  2445. $('projectInfo').textContent = 'Workspace';
  2446. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2447. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2448. _tabWorkspaceName = wsName;
  2449. setTabStatus(_tabStatus);
  2450. await loadFileTree();
  2451. } else {
  2452. // No workspace at all — show "Open File" prompt
  2453. $('projectInfo').textContent = '';
  2454. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2455. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2456. _tabWorkspaceName = '';
  2457. $('fileTree').innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a project workspace from the top-left</div>';
  2458. }
  2459. await loadWorkspaces();
  2460. // Show workspace selector on startup if no workspace loaded at all
  2461. if (!currentWorkDir) {
  2462. resetConversationState();
  2463. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2464. $('chatInput').disabled = false;
  2465. $('chatSend').disabled = false;
  2466. }
  2467. await checkCloudLoginStatus();
  2468. if (currentWorkDir) {
  2469. await Promise.all([
  2470. loadPreviewUrlsFromProfile(),
  2471. loadCloudGid(),
  2472. ]);
  2473. }
  2474. updateContext();
  2475. connectSSE();
  2476. setupImagePaste();
  2477. if (currentWorkDir) {
  2478. // Restore chat state from backend (single source of truth)
  2479. const chatStateRestored = await fetchChatStateFromServer();
  2480. if (!chatStateRestored) {
  2481. loadChatState();
  2482. }
  2483. // Restore AI session context (messages, todos) from backend
  2484. try {
  2485. const chatId = activeConvId ?? 0;
  2486. const sessStatus = await api(`/api/session/${chatId}/status`);
  2487. if (sessStatus?.restored && sessStatus.messageCount > 0) {
  2488. console.log(`[Session] Restored ${sessStatus.messageCount} messages from ${sessStatus.source} (${sessStatus.turnCount} turns)`);
  2489. if (sessStatus.todos?.length) renderTodos(sessStatus.todos);
  2490. }
  2491. } catch {}
  2492. // Restore workspace UI state (files, mode — NOT chat)
  2493. if (hasVLProject) {
  2494. await restoreWorkspaceState();
  2495. }
  2496. } else {
  2497. resetConversationState();
  2498. switchMode('docs');
  2499. }
  2500. // Unified save: push chat state to backend every 10s
  2501. setInterval(pushChatStateToServer, 10000);
  2502. // Save workspace state (non-chat: files, mode) every 30s
  2503. setInterval(saveWorkspaceState, 30000);
  2504. // Keep window tab bar in sync with running instances
  2505. setInterval(renderWsTabs, 5000);
  2506. // Save state before page unload
  2507. window.addEventListener('beforeunload', () => {
  2508. // Push chat state to backend via sendBeacon
  2509. const cur = conversations.find(c => c.id === activeConvId);
  2510. if (cur) cur.dom = $('chatMessages')?.innerHTML || '';
  2511. const chatState = {
  2512. conversations: conversations.map(c => ({
  2513. id: c.id, name: c.name,
  2514. dom: c.id === activeConvId ? ($('chatMessages')?.innerHTML || '') : (c.dom || ''),
  2515. })),
  2516. activeConvId,
  2517. convIdCounter,
  2518. };
  2519. navigator.sendBeacon('/api/chat/state', new Blob([JSON.stringify(chatState)], { type: 'application/json' }));
  2520. // Also save to localStorage as offline fallback
  2521. saveChatState();
  2522. // Workspace state (non-chat fields)
  2523. const wsState = {
  2524. savedAt: Date.now(),
  2525. mode: currentMode || 'code',
  2526. activeFile: currentFile || null,
  2527. openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
  2528. debugPanelOpen: $('debugPanel')?.style.display !== 'none',
  2529. chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
  2530. chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
  2531. showInternalFiles,
  2532. wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
  2533. };
  2534. navigator.sendBeacon('/api/workspace/state', new Blob([JSON.stringify(wsState)], { type: 'application/json' }));
  2535. });
  2536. }
  2537. async function loadProjectInfo() {
  2538. const proj = await api('/api/project');
  2539. const vlCount = proj.summary?.totalFiles || 0;
  2540. const hasVL = proj.isVL && vlCount > 0;
  2541. $('chatModel').textContent = shortModel(proj.model);
  2542. if (proj.version) $('appVersion').textContent = 'v' + proj.version;
  2543. $('modelLabel').textContent = shortModel(proj.model);
  2544. currentWorkDir = proj.workDir || '';
  2545. if (hasVL) {
  2546. const wsName = proj.summary?.projectName || path_basename(proj.workDir);
  2547. $('projectInfo').textContent = `${vlCount} files`;
  2548. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2549. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2550. _tabWorkspaceName = wsName;
  2551. setTabStatus(_tabStatus);
  2552. } else if (currentWorkDir) {
  2553. const wsName = path_basename(currentWorkDir);
  2554. $('projectInfo').textContent = 'Workspace';
  2555. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = wsName;
  2556. if ($('wsCurrentName')) $('wsCurrentName').textContent = wsName;
  2557. _tabWorkspaceName = wsName;
  2558. setTabStatus(_tabStatus);
  2559. } else {
  2560. $('projectInfo').textContent = '';
  2561. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2562. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2563. _tabWorkspaceName = '';
  2564. setTabStatus(_tabStatus);
  2565. $('chatInput').disabled = false;
  2566. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2567. $('chatSend').disabled = false;
  2568. }
  2569. // Disable compile button for non-VL workspaces
  2570. const compileBtn = $('compileBtn');
  2571. if (compileBtn) {
  2572. if (!proj.isVL) {
  2573. compileBtn.style.opacity = '0.4';
  2574. compileBtn.title = 'No VL files in current workspace';
  2575. } else {
  2576. compileBtn.style.opacity = '1';
  2577. compileBtn.title = 'Compile & Preview';
  2578. }
  2579. }
  2580. renderWsTabs();
  2581. }
  2582. function shortModel(m) {
  2583. if (m?.includes('opus')) return 'Opus 4.6';
  2584. if (m?.includes('sonnet')) return 'Sonnet 4.6';
  2585. if (m?.includes('haiku')) return 'Haiku 4.5';
  2586. return m || '';
  2587. }
  2588. // ===================== SETUP KEY =====================
  2589. // Landing page enter key handlers
  2590. $('landingApiKey')?.addEventListener('keydown', e => { if (e.key === 'Enter') enterIDE(); });
  2591. $('landingPassword')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingEnterpriseLogin(); });
  2592. $('landingDirectCookie')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLandingTokenLogin(); });
  2593. // ===================== WORKSPACE (MULTI-WINDOW) =====================
  2594. let _wsInstances = []; // running instances: { port, workDir, pid, startedAt }
  2595. function isDesktopApp() {
  2596. return !!window.vlcodeDesktop?.isElectron;
  2597. }
  2598. function syncDesktopWorkspaceUI() {
  2599. const openFolderBtn = $('wsOpenFolderBtn');
  2600. const pickLocationBtn = $('newProjectLocationPickBtn');
  2601. document.body.classList.toggle('desktop-app', isDesktopApp());
  2602. if (openFolderBtn) openFolderBtn.style.display = isDesktopApp() ? '' : 'none';
  2603. if (pickLocationBtn) pickLocationBtn.style.display = isDesktopApp() ? '' : 'none';
  2604. }
  2605. /** Render current workspace in the header */
  2606. async function renderWsTabs() {
  2607. _renderWsTabsDom();
  2608. }
  2609. /** Kept for SSE compat — instances list is now authoritative */
  2610. function renderWsTabsFromData() { renderWsTabs(); }
  2611. function _renderWsTabsDom() {
  2612. const container = $('wsTabs');
  2613. if (!container) return;
  2614. container.innerHTML = '';
  2615. const hasWorkspace = !!currentWorkDir;
  2616. const el = document.createElement('button');
  2617. el.type = 'button';
  2618. el.className = 'ws-current' + (hasWorkspace ? '' : ' empty');
  2619. el.title = hasWorkspace ? currentWorkDir : 'No workspace selected';
  2620. el.onclick = toggleWsPopover;
  2621. el.innerHTML = `<span class="ws-current-icon">${hasWorkspace ? '&#9679;' : '&#9675;'}</span><span class="ws-current-name" id="wsCurrentName">${escapeHtml(hasWorkspace ? path_basename(currentWorkDir) : 'Open Workspace')}</span>`;
  2622. container.appendChild(el);
  2623. setWorkspaceTriggerHighlight(!hasWorkspace);
  2624. }
  2625. /** Close a window instance by port */
  2626. async function closeWindowInstance(port, isCurrent) {
  2627. try {
  2628. await fetch(`/api/windows/${port}`, { method: 'DELETE' });
  2629. if (isCurrent) {
  2630. window.close();
  2631. } else {
  2632. await renderWsTabs();
  2633. }
  2634. } catch (e) { console.error('closeWindowInstance error:', e); }
  2635. }
  2636. /** Open a workspace in a new browser window */
  2637. async function openWorkspaceInNewWindow(dirPath) {
  2638. $('wsPopover').classList.remove('open');
  2639. setStatus('Opening new window...', 'yellow');
  2640. try {
  2641. if (isDesktopApp() && window.vlcodeDesktop?.openWorkspaceWindow) {
  2642. await window.vlcodeDesktop.openWorkspaceWindow({ dirPath });
  2643. setStatus('Ready', 'green');
  2644. return;
  2645. }
  2646. const data = await api('/api/windows/open', { method: 'POST', body: JSON.stringify({ dirPath }) });
  2647. if (data.url) {
  2648. const popup = window.open(data.url, `vlcode_${data.port}`);
  2649. if (!popup) {
  2650. addMsg('assistant', `**Workspace ready:** ${data.url}\n\nYour browser blocked the popup. Open this URL manually, or use the Electron app for native multi-window support.`);
  2651. setStatus('Popup blocked by browser', 'red');
  2652. return;
  2653. }
  2654. }
  2655. // refresh tab bar after a short delay (new process needs time to register)
  2656. setTimeout(renderWsTabs, 2000);
  2657. setStatus('Ready', 'green');
  2658. } catch (e) { setStatus('Failed to open window: ' + e.message, 'red'); }
  2659. }
  2660. async function loadWorkspaces() {
  2661. try {
  2662. const data = await api('/api/workspaces');
  2663. // API returns a plain array; mark active entry by comparing to currentWorkDir
  2664. const workspaces = Array.isArray(data) ? data : (data.workspaces || []);
  2665. const wsWithActive = workspaces.map(w => ({ ...w, active: w.path === currentWorkDir }));
  2666. renderWsListInPopover(wsWithActive);
  2667. const active = wsWithActive.find(w => w.active);
  2668. const name = active ? active.name : (currentWorkDir ? path_basename(currentWorkDir) : '');
  2669. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = name;
  2670. // Update workspace tabs
  2671. await renderWsTabs();
  2672. } catch {}
  2673. }
  2674. function renderWsListInPopover(workspaces) {
  2675. const list = $('wsList');
  2676. list.innerHTML = '';
  2677. for (const ws of workspaces) {
  2678. const isCurrent = ws.path === currentWorkDir;
  2679. const div = document.createElement('div');
  2680. div.className = 'ws-item' + (isCurrent ? ' active' : '');
  2681. div.innerHTML = `<div><div class="ws-item-name">${escapeHtml(ws.name)}</div><div class="ws-item-path">${escapeHtml(ws.path)}</div></div><span class="ws-del" onclick="event.stopPropagation();deleteWorkspace('${ws.id}')">&times;</span>`;
  2682. if (isCurrent) {
  2683. // Already open in this window — no action
  2684. div.title = 'Current window';
  2685. } else {
  2686. div.onclick = () => switchWorkspace(ws.path);
  2687. div.title = 'Open in this window';
  2688. }
  2689. list.appendChild(div);
  2690. }
  2691. }
  2692. function setInternalFilesVisible(visible, { reload = true, persist = true } = {}) {
  2693. showInternalFiles = !!visible;
  2694. const btn = $('toggleInternalFilesBtn');
  2695. if (btn) {
  2696. btn.classList.toggle('active', showInternalFiles);
  2697. btn.title = showInternalFiles
  2698. ? 'Hide internal files and generated artifacts'
  2699. : 'Show internal files and generated artifacts';
  2700. }
  2701. if (persist) localStorage.setItem('vl-code-show-internal', showInternalFiles ? '1' : '0');
  2702. if (reload && currentWorkDir) loadFileTree();
  2703. }
  2704. function toggleInternalFiles() {
  2705. setInternalFilesVisible(!showInternalFiles);
  2706. }
  2707. function toggleWsPopover() {
  2708. const pop = $('wsPopover');
  2709. pop.classList.toggle('open');
  2710. if (pop.classList.contains('open') && !_browseCurrentDir) {
  2711. const startDir = currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '';
  2712. browseDir(startDir || '');
  2713. }
  2714. }
  2715. document.addEventListener('click', e => {
  2716. if (!e.target.closest('.ws-popover') && !e.target.closest('.ws-current')) $('wsPopover').classList.remove('open');
  2717. if (!e.target.closest('.mention-dropdown') && !e.target.closest('#chatInput')) $('mentionDropdown').classList.remove('open');
  2718. if (!e.target.closest('.ca-menu')) closeChatMoreMenu();
  2719. });
  2720. // ===================== NEW VL PROJECT =====================
  2721. function toggleNewProjectForm() {
  2722. const form = $('wsNewProjectForm');
  2723. const visible = form.style.display !== 'none';
  2724. form.style.display = visible ? 'none' : 'block';
  2725. if (!visible) {
  2726. // Set location to current browse directory or parent of workDir
  2727. const loc = _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
  2728. $('newProjectLocation').value = loc || '';
  2729. $('newProjectName').value = '';
  2730. $('newProjectError').style.display = 'none';
  2731. $('newProjectName').focus();
  2732. }
  2733. }
  2734. async function pickNewProjectLocation() {
  2735. if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) return;
  2736. try {
  2737. const defaultPath = $('newProjectLocation').value.trim() || _browseCurrentDir || currentWorkDir || '';
  2738. const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath });
  2739. if (!picked?.canceled && picked?.path) {
  2740. $('newProjectLocation').value = picked.path;
  2741. }
  2742. } catch (e) {
  2743. $('newProjectError').textContent = e.message || 'Failed to choose location';
  2744. $('newProjectError').style.display = 'block';
  2745. }
  2746. }
  2747. async function openWorkspacePicker() {
  2748. if (!isDesktopApp() || !window.vlcodeDesktop?.pickDirectory) {
  2749. browseDir(_browseCurrentDir || '');
  2750. return;
  2751. }
  2752. try {
  2753. const picked = await window.vlcodeDesktop.pickDirectory({ defaultPath: _browseCurrentDir || currentWorkDir || '' });
  2754. if (!picked?.canceled && picked?.path) {
  2755. await switchWorkspace(picked.path);
  2756. }
  2757. } catch (e) {
  2758. setStatus('Failed to choose folder: ' + (e.message || e), 'red');
  2759. }
  2760. }
  2761. async function createNewProject() {
  2762. const name = $('newProjectName').value.trim();
  2763. if (!name) { $('newProjectError').textContent = 'Project name is required'; $('newProjectError').style.display = 'block'; return; }
  2764. let parentDir = $('newProjectLocation').value.trim() || _browseCurrentDir || (currentWorkDir ? currentWorkDir.split('/').slice(0, -1).join('/') : '');
  2765. if (!parentDir && isDesktopApp()) {
  2766. await pickNewProjectLocation();
  2767. parentDir = $('newProjectLocation').value.trim();
  2768. }
  2769. if (!parentDir) { $('newProjectError').textContent = 'Browse to a location first'; $('newProjectError').style.display = 'block'; return; }
  2770. try {
  2771. $('newProjectError').style.display = 'none';
  2772. const res = await fetch('/api/workspaces/create-project', {
  2773. method: 'POST', headers: { 'Content-Type': 'application/json' },
  2774. body: JSON.stringify({ parentDir, projectName: name })
  2775. });
  2776. const data = await res.json();
  2777. if (!res.ok) { $('newProjectError').textContent = data.error || 'Failed'; $('newProjectError').style.display = 'block'; return; }
  2778. // Open new project in the current window
  2779. $('wsNewProjectForm').style.display = 'none';
  2780. $('wsPopover').classList.remove('open');
  2781. await switchWorkspace(data.path);
  2782. addMsg('assistant', `**New VL project created:** ${data.name}\nLocation: ${data.path}`);
  2783. } catch (e) {
  2784. $('newProjectError').textContent = e.message || 'Failed to create project';
  2785. $('newProjectError').style.display = 'block';
  2786. }
  2787. }
  2788. // ===================== FILE TREE DRAG & DROP =====================
  2789. function handleFileTreeDragOver(e) {
  2790. e.preventDefault();
  2791. e.stopPropagation(); // prevent global drop overlay
  2792. e.dataTransfer.dropEffect = 'copy';
  2793. const overlay = $('sidebarDropOverlay');
  2794. if (overlay) overlay.style.display = 'block';
  2795. }
  2796. function handleFileTreeDragLeave(e) {
  2797. const overlay = $('sidebarDropOverlay');
  2798. // Only hide if truly leaving the file tree (not entering a child)
  2799. const tree = e.currentTarget;
  2800. if (!tree.contains(e.relatedTarget)) {
  2801. if (overlay) overlay.style.display = 'none';
  2802. }
  2803. }
  2804. async function handleFileTreeDrop(e) {
  2805. e.preventDefault();
  2806. e.stopPropagation(); // prevent global drop overlay
  2807. dragCounter = 0; $('dropOverlay').classList.remove('active'); // ensure global overlay clears
  2808. const overlay = $('sidebarDropOverlay');
  2809. if (overlay) overlay.style.display = 'none';
  2810. const items = e.dataTransfer.items;
  2811. if (!items || items.length === 0) return;
  2812. const filesToUpload = [];
  2813. // Accept all common code/text file types — preserve real folder structure
  2814. const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
  2815. // Process dropped items — preserve original folder structure
  2816. const readEntries = async (entry, basePath) => {
  2817. if (entry.isFile) {
  2818. return new Promise((resolve) => {
  2819. entry.file(f => {
  2820. // Accept files matching code extensions or without extension (Makefile, Dockerfile, etc.)
  2821. const ext = f.name.includes('.') ? '.' + f.name.split('.').pop().toLowerCase() : '';
  2822. if (ext && !codeExts.includes(ext)) { resolve(); return; }
  2823. const reader = new FileReader();
  2824. reader.onload = () => {
  2825. // Preserve the real relative path from the drop — no auto-mapping
  2826. const relPath = basePath ? basePath + '/' + f.name : f.name;
  2827. filesToUpload.push({ path: relPath, content: reader.result });
  2828. resolve();
  2829. };
  2830. reader.readAsText(f);
  2831. });
  2832. });
  2833. } else if (entry.isDirectory) {
  2834. const dirReader = entry.createReader();
  2835. return new Promise((resolve) => {
  2836. dirReader.readEntries(async (entries) => {
  2837. for (const sub of entries) {
  2838. await readEntries(sub, basePath ? basePath + '/' + entry.name : entry.name);
  2839. }
  2840. resolve();
  2841. });
  2842. });
  2843. }
  2844. };
  2845. setStatus('Importing dropped files...', 'yellow');
  2846. try {
  2847. const promises = [];
  2848. for (let i = 0; i < items.length; i++) {
  2849. const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
  2850. if (entry) promises.push(readEntries(entry, ''));
  2851. }
  2852. await Promise.all(promises);
  2853. if (filesToUpload.length === 0) {
  2854. setStatus('No files to import', 'yellow');
  2855. return;
  2856. }
  2857. // Upload files to server
  2858. const res = await fetch('/api/upload-folder', {
  2859. method: 'POST', headers: { 'Content-Type': 'application/json' },
  2860. body: JSON.stringify({ files: filesToUpload })
  2861. });
  2862. const data = await res.json();
  2863. if (data.ok) {
  2864. await loadFileTree();
  2865. setStatus(`Imported ${data.filesWritten} file(s)`, 'green');
  2866. addMsg('assistant', `**Imported ${data.filesWritten} file(s)** via drag & drop:\n${data.paths.map(p => ' - ' + p).join('\n')}`);
  2867. } else {
  2868. setStatus(data.error || 'Import failed', 'red');
  2869. }
  2870. } catch (e) {
  2871. setStatus('Drop import error: ' + e.message, 'red');
  2872. }
  2873. }
  2874. async function addWorkspace() {
  2875. const dirPath = $('wsAddPath').value.trim();
  2876. if (!dirPath) return;
  2877. $('wsAddPath').value = '';
  2878. // If user typed a path, try browsing to it first; if valid dir, navigate the browser
  2879. try {
  2880. const data = await api(`/api/browse-dir?path=${encodeURIComponent(dirPath)}`);
  2881. if (data.current) {
  2882. browseDir(data.current);
  2883. return;
  2884. }
  2885. } catch {}
  2886. // Fallback: open in this window
  2887. await switchWorkspace(dirPath);
  2888. }
  2889. async function switchWorkspace(dirPath) {
  2890. setStatus('Switching workspace...', 'yellow');
  2891. $('wsPopover').classList.remove('open');
  2892. try {
  2893. // 1. Save current workspace's chat history before leaving
  2894. saveChatState();
  2895. const oldWorkDir = currentWorkDir;
  2896. const switchRes = await fetch('/api/workspaces/switch', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ dirPath }) });
  2897. if (!switchRes.ok) { const err = await switchRes.json().catch(() => ({})); throw new Error(err.error || 'Server switch failed'); }
  2898. // 2. Close all open file tabs (they belong to old workspace)
  2899. openFiles.clear();
  2900. currentFile = null;
  2901. renderTabs();
  2902. $('editor').style.display = 'none';
  2903. $('codePreview').style.display = 'none';
  2904. $('mdPreview').style.display = 'none';
  2905. $('iframeContainer').style.display = 'none';
  2906. $('editorPlaceholder').style.display = 'block';
  2907. $('currentFile').textContent = '';
  2908. // 3. Destroy all cached iframes (metadata/workflow from old workspace)
  2909. const container = $('iframeContainer');
  2910. container.innerHTML = '';
  2911. // 4. Switch back to code mode
  2912. currentMode = 'code';
  2913. document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === 'code'));
  2914. // 5. Reload everything from the new workspace
  2915. await loadProjectInfo();
  2916. await loadFileTree();
  2917. await loadWorkspaces();
  2918. await refreshDocIdSettings();
  2919. updateContext();
  2920. // 5b. Reload preview URLs and GID for the new workspace
  2921. previewUrls = {};
  2922. $('previewUrlsPanel').style.display = 'none';
  2923. $('previewUrlsList').innerHTML = '';
  2924. $('previewUrlLabel').textContent = '';
  2925. if ($('cloudGid')) $('cloudGid').value = '';
  2926. loadPreviewUrlsFromProfile();
  2927. loadCloudGid();
  2928. // 6. Restore chat history from backend (or start fresh)
  2929. resetConversationState();
  2930. const wsRestored = await fetchChatStateFromServer();
  2931. if (!wsRestored) loadChatState(); // offline fallback
  2932. // 6b. Re-enable chat input (may have been disabled when no workspace was selected)
  2933. $('chatInput').disabled = false;
  2934. $('chatInput').placeholder = 'Describe changes, @mention files, /s...';
  2935. $('chatSend').disabled = false;
  2936. // 7. Auto-open the first VL file in the new workspace
  2937. autoOpenFirstFile();
  2938. setStatus('Ready', 'green');
  2939. } catch(e) { console.error('switchWorkspace error:', e); setStatus('Switch failed: ' + (e.message || e), 'red'); }
  2940. }
  2941. /** Close current workspace — return to "Open File" initial state */
  2942. async function closeWorkspace() {
  2943. $('wsPopover').classList.remove('open');
  2944. try {
  2945. await fetch('/api/workspaces/close', { method:'POST' });
  2946. } catch {}
  2947. // Clear editor
  2948. openFiles.clear();
  2949. currentFile = null;
  2950. renderTabs();
  2951. $('editor').style.display = 'none';
  2952. $('codePreview').style.display = 'none';
  2953. $('mdPreview').style.display = 'none';
  2954. $('iframeContainer').style.display = 'none';
  2955. $('iframeContainer').innerHTML = '';
  2956. $('editorPlaceholder').style.display = 'block';
  2957. $('currentFile').textContent = '';
  2958. previewUrls = {};
  2959. $('previewUrlsPanel').style.display = 'none';
  2960. $('previewUrlsList').innerHTML = '';
  2961. $('previewUrlLabel').textContent = '';
  2962. if ($('cloudGid')) $('cloudGid').value = '';
  2963. // Reset workspace display
  2964. $('projectInfo').textContent = '';
  2965. if ($('wsCurrentName')) $('wsCurrentName').textContent = 'Open File';
  2966. if ($('sidebarProjectName')) $('sidebarProjectName').textContent = '';
  2967. _tabWorkspaceName = '';
  2968. setTabStatus(_tabStatus);
  2969. // Clear file tree
  2970. $('fileTree').innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a VL project workspace from the top-left</div>';
  2971. currentWorkDir = '';
  2972. resetConversationState();
  2973. $('chatInput').disabled = false;
  2974. $('chatInput').placeholder = 'Describe a new project, or select/import a workspace...';
  2975. $('chatSend').disabled = false;
  2976. renderWsTabs();
  2977. await refreshDocIdSettings();
  2978. switchMode('docs');
  2979. setStatus('Workspace closed', 'green');
  2980. }
  2981. async function deleteWorkspace(id) {
  2982. await fetch(`/api/workspaces/${id}`, { method:'DELETE' });
  2983. await loadWorkspaces();
  2984. }
  2985. function path_basename(p) { return p ? p.split('/').pop() || p : 'No workspace'; }
  2986. function toPascalProjectName(raw) {
  2987. if (!raw) return '';
  2988. const cleaned = String(raw)
  2989. .replace(/\.[^.]+$/, '')
  2990. .replace(/[`"'“”‘’]/g, ' ')
  2991. .replace(/[^A-Za-z0-9]+/g, ' ')
  2992. .trim();
  2993. if (!cleaned) return '';
  2994. const merged = cleaned.split(/\s+/).filter(Boolean)
  2995. .map(part => part.charAt(0).toUpperCase() + part.slice(1))
  2996. .join('');
  2997. return /^[A-Z][A-Za-z0-9]*$/.test(merged) ? merged : '';
  2998. }
  2999. async function ensureWorkspaceForImport(suggestedName) {
  3000. if (currentWorkDir) return true;
  3001. const projectName = toPascalProjectName(suggestedName) || `ImportedProject${Date.now().toString().slice(-6)}`;
  3002. setStatus(`Creating workspace ${projectName}...`, 'yellow');
  3003. const res = await fetch('/api/workspaces/create-project', {
  3004. method: 'POST',
  3005. headers: { 'Content-Type': 'application/json' },
  3006. body: JSON.stringify({ projectName }),
  3007. });
  3008. const data = await res.json();
  3009. if (!res.ok || !data.path) throw new Error(data.error || 'Create workspace failed');
  3010. await switchWorkspace(data.path);
  3011. return true;
  3012. }
  3013. // ===================== VL REFERENCE DOCS =====================
  3014. async function loadVLDocs() {
  3015. try {
  3016. const data = await api('/api/vl-docs');
  3017. renderVLDocs(data.docs || []);
  3018. } catch {}
  3019. }
  3020. function renderVLDocs(docs) {
  3021. const list = $('vlDocsList');
  3022. if (!list) return;
  3023. if (!docs.length) {
  3024. list.innerHTML = '<div style="padding:4px 12px;font-size:10px;color:var(--text2);">No docs cached. Click &#8635; to sync from DocCenter.</div>';
  3025. return;
  3026. }
  3027. list.innerHTML = '';
  3028. for (const doc of docs) {
  3029. const el = document.createElement('div');
  3030. el.className = 'pc-file';
  3031. el.title = doc.path || doc.name;
  3032. el.innerHTML = `<span onclick="viewVLDoc(${doc.id})" style="cursor:pointer;flex:1;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(doc.name)}</span>` +
  3033. `<button class="pc-doc-toggle ${doc.active ? 'active' : ''}" onclick="event.stopPropagation();toggleVLDoc(${doc.id},${!doc.active})" title="${doc.active ? 'Active (in AI context)' : 'Inactive (not in AI context)'}">` +
  3034. `${doc.active ? '&#9679;' : '&#9675;'}</button>`;
  3035. list.appendChild(el);
  3036. }
  3037. }
  3038. async function syncVLDocs() {
  3039. setStatus('Syncing VL docs from DocCenter...', 'yellow');
  3040. try {
  3041. const res = await fetch('/api/vl-docs/sync', { method: 'POST' });
  3042. const data = await res.json();
  3043. if (data.error) { setStatus('Sync failed: ' + data.error, 'red'); return; }
  3044. renderVLDocs(data.docs || []);
  3045. $('vlDocsList').style.display = 'block';
  3046. setStatus(`Synced ${data.synced} VL docs`, 'green');
  3047. } catch (e) { setStatus('Sync failed: ' + e.message, 'red'); }
  3048. }
  3049. async function toggleVLDoc(docId, active) {
  3050. try {
  3051. await fetch('/api/vl-docs/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ docId, active }) });
  3052. await loadVLDocs();
  3053. } catch {}
  3054. }
  3055. async function viewVLDoc(docId) {
  3056. try {
  3057. const data = await api('/api/vl-docs');
  3058. const doc = (data.docs || []).find(d => d.id === docId);
  3059. if (!doc) return;
  3060. // Open doc content in editor as read-only preview
  3061. const res = await fetch(`/api/vl-docs/content?file=${encodeURIComponent(doc.file)}`);
  3062. const content = await res.text();
  3063. addMsg('assistant', `**${doc.name}**\n\`\`\`\n${content.substring(0, 2000)}\n\`\`\`${content.length > 2000 ? '\n...(truncated)' : ''}`);
  3064. } catch {}
  3065. }
  3066. // ===================== DIRECTORY BROWSER =====================
  3067. let _browseCurrentDir = '';
  3068. async function browseDir(dirPath) {
  3069. try {
  3070. const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
  3071. const data = await api(`/api/browse-dir${params}`);
  3072. _browseCurrentDir = data.current;
  3073. $('browsePath').textContent = data.current;
  3074. $('browsePath').title = data.current;
  3075. const list = $('browseList');
  3076. list.innerHTML = '';
  3077. if (!data.dirs.length) {
  3078. list.innerHTML = '<div style="padding:8px 12px;font-size:10px;color:var(--text2);">No subdirectories</div>';
  3079. return;
  3080. }
  3081. for (const d of data.dirs) {
  3082. const div = document.createElement('div');
  3083. div.className = 'ws-browse-item' + (d.isVL ? ' is-vl' : '');
  3084. div.innerHTML = `<span class="dir-icon">${d.isVL ? '&#9670;' : '&#128193;'}</span><span class="dir-name">${escapeHtml(d.name)}</span>${d.isVL ? '<span class="dir-vl">VL</span>' : ''}`;
  3085. div.onclick = (e) => { e.stopPropagation(); browseDir(d.path); };
  3086. div.ondblclick = (e) => { e.stopPropagation(); switchWorkspace(d.path); };
  3087. div.title = d.path + (d.isVL ? ' (VL Project — double-click to open in this window)' : ' (click to browse, double-click to open in this window)');
  3088. list.appendChild(div);
  3089. }
  3090. } catch (e) {
  3091. $('browseList').innerHTML = `<div style="padding:8px 12px;font-size:10px;color:var(--red);">Error: ${escapeHtml(e.message || 'Failed to browse')}</div>`;
  3092. }
  3093. }
  3094. function browseDirUp() {
  3095. if (!_browseCurrentDir) return;
  3096. const parent = _browseCurrentDir.split('/').slice(0, -1).join('/') || '/';
  3097. browseDir(parent);
  3098. }
  3099. function selectBrowseDir() {
  3100. if (_browseCurrentDir) switchWorkspace(_browseCurrentDir);
  3101. }
  3102. // Directory browser auto-load is now handled inside toggleWsPopover()
  3103. // ===================== RESTART BACKEND =====================
  3104. async function restartBackend() {
  3105. const btn = $('restartBtn');
  3106. if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
  3107. try {
  3108. await fetch('/api/restart', { method: 'POST' });
  3109. } catch (_) { /* connection will drop */ }
  3110. // Poll until server is back
  3111. const poll = async () => {
  3112. for (let i = 0; i < 30; i++) {
  3113. await new Promise(r => setTimeout(r, 1000));
  3114. try {
  3115. const resp = await fetch('/api/health');
  3116. if (resp.ok) {
  3117. if (btn) { btn.disabled = false; btn.textContent = '↻'; }
  3118. appendLog('system', 'Backend restarted successfully.');
  3119. location.reload();
  3120. return;
  3121. }
  3122. } catch (_) { /* still down */ }
  3123. }
  3124. if (btn) { btn.disabled = false; btn.textContent = '↻'; }
  3125. appendLog('error', 'Backend restart timed out. Please restart manually.');
  3126. };
  3127. poll();
  3128. }
  3129. // ===================== SETTINGS =====================
  3130. const DOC_REF_PREFIX = 'vl://doc/';
  3131. let _docCenterFocusDocId = null;
  3132. function normalizeDocRefInput(value) {
  3133. if (typeof value === 'number') {
  3134. return Number.isInteger(value) && value > 0 ? value : null;
  3135. }
  3136. if (typeof value !== 'string') return null;
  3137. const trimmed = value.trim();
  3138. if (!trimmed) return null;
  3139. if (/^\d+$/.test(trimmed)) {
  3140. const n = parseInt(trimmed, 10);
  3141. return Number.isInteger(n) && n > 0 ? n : null;
  3142. }
  3143. if (trimmed.toLowerCase().startsWith(DOC_REF_PREFIX)) {
  3144. return normalizeDocRefInput(trimmed.slice(DOC_REF_PREFIX.length));
  3145. }
  3146. const inlineMatch = trimmed.match(/(?:^|[?&#])(?:docId|id|ref)=([^&#]+)/i);
  3147. if (inlineMatch?.[1]) {
  3148. try {
  3149. return normalizeDocRefInput(decodeURIComponent(inlineMatch[1]));
  3150. } catch {
  3151. return normalizeDocRefInput(inlineMatch[1]);
  3152. }
  3153. }
  3154. try {
  3155. const parsed = new URL(trimmed, window.location.origin);
  3156. const docId = parsed.searchParams.get('docId')
  3157. || parsed.searchParams.get('id')
  3158. || parsed.searchParams.get('ref');
  3159. if (docId) return normalizeDocRefInput(docId);
  3160. } catch {}
  3161. return null;
  3162. }
  3163. function formatDocRef(value) {
  3164. const docId = normalizeDocRefInput(value);
  3165. return docId ? `${DOC_REF_PREFIX}${docId}` : '';
  3166. }
  3167. function formatDocHref(value) {
  3168. const docId = normalizeDocRefInput(value);
  3169. return docId ? `/doc-center.html?docId=${docId}` : '';
  3170. }
  3171. function escapeAttr(s) {
  3172. return escapeHtml(String(s || ''))
  3173. .replace(/"/g, '&quot;')
  3174. .replace(/'/g, '&#39;');
  3175. }
  3176. const DOC_ID_CORE_FIELDS = [
  3177. { alias: 'vlSyntax', label: 'VL Syntax', path: 1 },
  3178. { alias: 'theme', label: 'Theme', path: 2 },
  3179. ];
  3180. const DOC_ID_WORKFLOW_FIELDS = [
  3181. { alias: 'workflow3File', label: '3-File CodeGen', path: 30 },
  3182. { alias: 'workflow6File', label: '6-File CodeGen', path: 60 },
  3183. { alias: 'workflow9File', label: '9-File CodeGen', path: 90 },
  3184. { alias: 'workflowMetaDirect', label: 'MetaDirect', path: 110 },
  3185. { alias: 'workflowAddPage', label: 'Add Page', path: 120 },
  3186. { alias: 'workflowAddService', label: 'Add Service', path: 130 },
  3187. { alias: 'workflowThemeCustomize', label: 'Theme Customize', path: 140 },
  3188. { alias: 'workflowIncrementalUpdate', label: 'Incremental Update', path: 141 },
  3189. { alias: 'workflowCompileFix', label: 'Compile Fix', path: 142 },
  3190. ];
  3191. const DOC_ID_LOCKED_FIELDS = [
  3192. { alias: 'workflowSpec', label: 'Workflow Spec', path: 3 },
  3193. { alias: 'metaSpec', label: 'Meta Spec', path: 4 },
  3194. ];
  3195. const DOC_ID_EDITABLE_FIELDS = [...DOC_ID_CORE_FIELDS, ...DOC_ID_WORKFLOW_FIELDS];
  3196. let _docIdSettingsSnapshot = { docIdOverrides: {}, coreDocIds: {} };
  3197. function docIdInputId(prefix, alias) {
  3198. return `${prefix}DocId_${alias}`;
  3199. }
  3200. function getDocIdValue(settings, alias) {
  3201. const editable = normalizeDocRefInput(settings?.docIdOverrides?.[alias]);
  3202. if (editable) return editable;
  3203. const locked = normalizeDocRefInput(settings?.coreDocIds?.[alias]);
  3204. if (locked) return locked;
  3205. return '';
  3206. }
  3207. function readDocBindingInput(prefix, alias) {
  3208. return $(docIdInputId(prefix, alias))?.value?.trim?.() || '';
  3209. }
  3210. function copyTextValue(value, successText) {
  3211. if (!value) {
  3212. setStatus('Document reference is empty', 'red');
  3213. return;
  3214. }
  3215. if (navigator.clipboard?.writeText) {
  3216. navigator.clipboard.writeText(value)
  3217. .then(() => setStatus(successText, 'green'))
  3218. .catch(() => setStatus('Copy failed', 'red'));
  3219. return;
  3220. }
  3221. const temp = document.createElement('textarea');
  3222. temp.value = value;
  3223. document.body.appendChild(temp);
  3224. temp.select();
  3225. try {
  3226. document.execCommand('copy');
  3227. setStatus(successText, 'green');
  3228. } catch {
  3229. setStatus('Copy failed', 'red');
  3230. } finally {
  3231. temp.remove();
  3232. }
  3233. }
  3234. function copyConfiguredDocRef(prefix, alias, kind = 'ref') {
  3235. const raw = readDocBindingInput(prefix, alias);
  3236. const docId = normalizeDocRefInput(raw);
  3237. if (!docId) {
  3238. setStatus('Invalid document reference', 'red');
  3239. return;
  3240. }
  3241. const value = kind === 'link'
  3242. ? `${window.location.origin}${formatDocHref(docId)}`
  3243. : String(docId);
  3244. copyTextValue(value, kind === 'link' ? 'Doc link copied' : 'Doc ID copied');
  3245. }
  3246. function openConfiguredDoc(prefix, alias) {
  3247. const raw = readDocBindingInput(prefix, alias);
  3248. const docId = normalizeDocRefInput(raw);
  3249. if (!docId) {
  3250. setStatus('Invalid document reference', 'red');
  3251. return;
  3252. }
  3253. _docCenterFocusDocId = docId;
  3254. if (prefix === 'settings') closeSettings();
  3255. switchMode('docs');
  3256. }
  3257. function syncDocBindingCard(input) {
  3258. const card = input?.closest?.('.settings-doc-card');
  3259. if (!card) return;
  3260. const docId = normalizeDocRefInput(input.value);
  3261. const docRef = formatDocRef(docId);
  3262. const docHref = formatDocHref(docId);
  3263. const refEl = card.querySelector('.settings-doc-ref');
  3264. const linkEl = card.querySelector('.settings-doc-link');
  3265. const openBtn = card.querySelector('.settings-doc-action[data-action="open"]');
  3266. const copyBtn = card.querySelector('.settings-doc-action[data-action="copy-id"]');
  3267. const inputEl = card.querySelector('input');
  3268. if (inputEl && docId && /^\s*(vl:\/\/doc\/|\/doc-center\.html\?docId=|https?:)/i.test(input.value)) {
  3269. inputEl.value = String(docId);
  3270. }
  3271. if (refEl) refEl.textContent = docRef || 'Not set';
  3272. if (linkEl) linkEl.textContent = docHref || 'No viewer link yet';
  3273. if (openBtn) openBtn.disabled = !docId;
  3274. if (copyBtn) copyBtn.disabled = !docId;
  3275. }
  3276. function renderDocIdFieldGroup(containerId, prefix, fields, settings, { locked = false } = {}) {
  3277. const container = $(containerId);
  3278. if (!container) return;
  3279. container.innerHTML = fields.map((field) => {
  3280. const value = getDocIdValue(settings, field.alias);
  3281. const docRef = formatDocRef(value);
  3282. const docHref = formatDocHref(value);
  3283. return `
  3284. <label class="settings-doc-card${locked ? ' is-locked' : ''}">
  3285. <span class="settings-doc-header">
  3286. <span class="settings-doc-title">${escapeHtml(field.label)}</span>
  3287. <span class="settings-doc-meta">Slot ${field.path}</span>
  3288. </span>
  3289. <input type="text" id="${docIdInputId(prefix, field.alias)}" placeholder="Doc ID / doc link" value="${escapeAttr(value ? String(value) : '')}" oninput="syncDocBindingCard(this)" ${locked ? 'disabled' : ''}>
  3290. <span class="settings-doc-ref">${docRef ? escapeHtml(docRef) : 'Not set'}</span>
  3291. <span class="settings-doc-link">${docHref ? escapeHtml(docHref) : 'No viewer link yet'}</span>
  3292. <div class="settings-doc-actions">
  3293. <button type="button" class="settings-doc-action" data-action="open" onclick="event.preventDefault();event.stopPropagation();openConfiguredDoc('${prefix}','${field.alias}')" ${value ? '' : 'disabled'}>Open</button>
  3294. <button type="button" class="settings-doc-action" data-action="copy-id" onclick="event.preventDefault();event.stopPropagation();copyConfiguredDocRef('${prefix}','${field.alias}')" ${value ? '' : 'disabled'}>Copy ID</button>
  3295. </div>
  3296. </label>
  3297. `;
  3298. }).join('');
  3299. }
  3300. function applyDocIdSettings(settings = {}) {
  3301. _docIdSettingsSnapshot = {
  3302. docIdOverrides: { ...(settings.docIdOverrides || {}) },
  3303. coreDocIds: { ...(settings.coreDocIds || {}) },
  3304. };
  3305. renderDocIdFieldGroup('settingsDocIdCoreGrid', 'settings', DOC_ID_CORE_FIELDS, settings);
  3306. renderDocIdFieldGroup('settingsDocIdWorkflowGrid', 'settings', DOC_ID_WORKFLOW_FIELDS, settings);
  3307. renderDocIdFieldGroup('settingsDocIdLockedGrid', 'settings', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
  3308. renderDocIdFieldGroup('docIdCoreGrid', 'sidebar', DOC_ID_CORE_FIELDS, settings);
  3309. renderDocIdFieldGroup('docIdWorkflowGrid', 'sidebar', DOC_ID_WORKFLOW_FIELDS, settings);
  3310. renderDocIdFieldGroup('docIdLockedGrid', 'sidebar', DOC_ID_LOCKED_FIELDS, settings, { locked: true });
  3311. }
  3312. function collectDocIdSettings(prefix) {
  3313. const out = {};
  3314. for (const field of DOC_ID_EDITABLE_FIELDS) {
  3315. const raw = readDocBindingInput(prefix, field.alias);
  3316. out[field.alias] = normalizeDocRefInput(raw);
  3317. }
  3318. return out;
  3319. }
  3320. async function refreshDocIdSettings() {
  3321. try {
  3322. const settings = await api('/api/settings');
  3323. applyDocIdSettings(settings);
  3324. return settings;
  3325. } catch {
  3326. return null;
  3327. }
  3328. }
  3329. function toggleDocIdConfigPanel() {
  3330. const body = $('docIdConfigBody');
  3331. if (!body) return;
  3332. body.style.display = body.style.display === 'none' ? 'block' : 'none';
  3333. }
  3334. function toggleDocWorkflowGrid(forceOpen = null) {
  3335. const grid = $('docIdWorkflowGrid');
  3336. const toggle = $('docWorkflowToggle');
  3337. if (!grid || !toggle) return;
  3338. const shouldOpen = forceOpen === null ? grid.style.display === 'none' : !!forceOpen;
  3339. grid.style.display = shouldOpen ? 'flex' : 'none';
  3340. toggle.innerHTML = shouldOpen ? '&#9660;' : '&#9654;';
  3341. }
  3342. async function saveDocIdConfigPanel() {
  3343. const body = {
  3344. docIdOverrides: collectDocIdSettings('sidebar'),
  3345. };
  3346. await fetch('/api/settings', {
  3347. method: 'POST',
  3348. headers: { 'Content-Type': 'application/json' },
  3349. body: JSON.stringify(body),
  3350. });
  3351. const updatedSettings = await refreshDocIdSettings();
  3352. if (updatedSettings) {
  3353. updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
  3354. }
  3355. setStatus('Document IDs saved', 'green');
  3356. }
  3357. async function openSettings() {
  3358. const s = await api('/api/settings');
  3359. $('settingsKey').value = '';
  3360. $('settingsKey').placeholder = 'sk-ant-api03-...';
  3361. $('settingsModel').value = s.model;
  3362. $('settingsMaxTokens').value = s.maxOutputTokens;
  3363. $('settingsWorkDir').value = s.workDir;
  3364. applyDocIdSettings(s);
  3365. // Show cloud connection status
  3366. $('settingsCloudStatus').textContent = _cloudConnected ? 'Connected' : 'Not connected';
  3367. $('settingsCloudStatus').style.color = _cloudConnected ? 'var(--green)' : 'var(--text2)';
  3368. renderProviderSettingsState(s);
  3369. // Load autotest settings
  3370. const at = s.autotest || {};
  3371. $('settingsHeadless').checked = !!at.headless;
  3372. $('settingsUseWorkflow').checked = at.useWorkflowEngine !== false;
  3373. $('settingsParallelBrowsers').value = at.parallelWorkers || 5;
  3374. $('settingsMaxCases').value = at.maxCases || 10;
  3375. $('settingsVersion').textContent = `VL-Code v${$('appVersion')?.textContent?.replace('v','') || '?'}`;
  3376. $('settingsModal').classList.add('open');
  3377. }
  3378. function closeSettings() { $('settingsModal').classList.remove('open'); }
  3379. document.querySelectorAll('input[name="settingsProvider"]').forEach((el) => {
  3380. el.addEventListener('change', () => {
  3381. renderProviderSettingsState({
  3382. ...(_settingsSnapshot || {}),
  3383. llmProvider: getSelectedSettingsProvider(),
  3384. });
  3385. });
  3386. });
  3387. function toggleKeyVisibility() {
  3388. const inp = $('settingsKey');
  3389. inp.type = inp.type === 'password' ? 'text' : 'password';
  3390. }
  3391. async function saveSettings() {
  3392. const body = {};
  3393. const key = $('settingsKey').value.trim();
  3394. if (key) body.apiKey = key;
  3395. body.llmProvider = getSelectedSettingsProvider();
  3396. body.model = $('settingsModel').value;
  3397. body.maxOutputTokens = parseInt($('settingsMaxTokens').value) || 16000;
  3398. body.autotest = {
  3399. headless: $('settingsHeadless').checked,
  3400. useWorkflowEngine: $('settingsUseWorkflow').checked,
  3401. parallelWorkers: parseInt($('settingsParallelBrowsers').value) || 5,
  3402. maxCases: parseInt($('settingsMaxCases').value) || 10,
  3403. };
  3404. body.docIdOverrides = collectDocIdSettings('settings');
  3405. await fetch('/api/settings', {
  3406. method: 'POST', headers: {'Content-Type':'application/json'},
  3407. body: JSON.stringify(body)
  3408. });
  3409. closeSettings();
  3410. await loadProjectInfo();
  3411. // Update LLM provider badge
  3412. const updatedSettings = await refreshDocIdSettings() || await api('/api/settings');
  3413. updateLlmBadge(updatedSettings.effectiveProvider || updatedSettings.llmProvider || 'cli');
  3414. setStatus('Settings saved', 'green');
  3415. }
  3416. // ===================== CONTEXT EXCLUSION =====================
  3417. let _lastBackendMsgCount = 0;
  3418. async function toggleMsgContext(btnEl) {
  3419. // Find the parent user message with turn boundaries
  3420. const msgEl = btnEl.closest('.msg');
  3421. if (!msgEl) return;
  3422. // Walk up/down to find the user message that has turnStart/turnEnd
  3423. let turnEl = msgEl;
  3424. if (!turnEl.dataset.turnStart) {
  3425. // This is an assistant msg or tool group — find sibling user msg
  3426. let prev = turnEl.previousElementSibling;
  3427. while (prev && !prev.dataset.turnStart) prev = prev.previousElementSibling;
  3428. if (prev) turnEl = prev;
  3429. else return; // can't find turn boundary
  3430. }
  3431. const startIdx = parseInt(turnEl.dataset.turnStart);
  3432. const endIdx = parseInt(turnEl.dataset.turnEnd);
  3433. if (isNaN(startIdx) || isNaN(endIdx)) return;
  3434. try {
  3435. const res = await fetch('/api/context/toggle-exclude', {
  3436. method: 'POST',
  3437. headers: { 'Content-Type': 'application/json' },
  3438. body: JSON.stringify({ startIdx, endIdx, chatId: activeConvId }),
  3439. });
  3440. const data = await res.json();
  3441. if (!data.ok) return;
  3442. const isExcluded = data.nowExcluded;
  3443. // Mark all messages in this turn visually
  3444. let el = turnEl;
  3445. while (el) {
  3446. if (el.classList.contains('msg') || el.classList.contains('tool-group') || el.classList.contains('thinking-block')) {
  3447. el.classList.toggle('excluded-msg', isExcluded);
  3448. const ctxBtn = el.querySelector('.msg-ctx-toggle');
  3449. if (ctxBtn) ctxBtn.classList.toggle('excluded', isExcluded);
  3450. }
  3451. el = el.nextElementSibling;
  3452. // Stop at the next user message (start of next turn) or end
  3453. if (el?.classList.contains('msg') && el?.querySelector('.label')?.textContent === 'user') break;
  3454. }
  3455. // Update context bar
  3456. if (data.usage) {
  3457. const pct = Math.round(data.usage.usedTokens / data.usage.maxTokens * 100);
  3458. $('ctxLabel').textContent = `${pct}%`;
  3459. $('ctxBar').style.width = pct + '%';
  3460. $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
  3461. }
  3462. } catch (e) {
  3463. console.error('toggleMsgContext failed:', e);
  3464. }
  3465. }
  3466. // ===================== AUTH STATUS (header bar) =====================
  3467. function updateAuthStatus(connected, userName) {
  3468. const dot = $('authDot');
  3469. const label = $('authLabel');
  3470. if (connected) {
  3471. dot.classList.add('ok');
  3472. label.textContent = userName || 'Connected';
  3473. label.className = 'auth-name';
  3474. } else {
  3475. dot.classList.remove('ok');
  3476. label.textContent = 'Not logged in';
  3477. label.className = 'auth-label';
  3478. }
  3479. }
  3480. function onAuthStatusClick() {
  3481. if (_cloudConnected) {
  3482. // Toggle cloud panel to show full status + logout option
  3483. toggleCloudPanel();
  3484. } else {
  3485. openCloudLogin();
  3486. }
  3487. }
  3488. // ===================== CLOUD PLATFORM =====================
  3489. let _cloudConnected = false;
  3490. function toggleCloudPanel() {
  3491. const panel = $('cloudPanel');
  3492. const visible = panel.style.display !== 'none';
  3493. panel.style.display = visible ? 'none' : 'block';
  3494. if (!visible) checkCloudStatus();
  3495. }
  3496. async function checkCloudStatus() {
  3497. try {
  3498. const data = await api('/api/cloud/status');
  3499. if (data.connected) {
  3500. showCloudConnected(data.user);
  3501. } else {
  3502. showCloudDisconnected();
  3503. }
  3504. } catch {
  3505. showCloudDisconnected();
  3506. }
  3507. }
  3508. function normalizeProjectProfile(data) {
  3509. if (!data || typeof data !== 'object') return {};
  3510. return (data.profile && typeof data.profile === 'object') ? data.profile : data;
  3511. }
  3512. function getProfileGid(profile) {
  3513. const gid = profile?.groupId ?? profile?.compileGid ?? '';
  3514. return gid ? String(gid) : '';
  3515. }
  3516. function showCloudConnected(user) {
  3517. _cloudConnected = true;
  3518. $('cloudLoginPrompt').style.display = 'none';
  3519. $('cloudConnected').style.display = 'block';
  3520. $('cloudDot').classList.add('connected');
  3521. $('cloudBtn').classList.add('connected');
  3522. const name = user?.name || user?.nickName || 'User';
  3523. const company = user?.companyName || '';
  3524. $('cloudUserInfo').innerHTML = `<span class="cu-name">${name}</span>` +
  3525. (company ? `<span class="cu-company">${company}</span>` : '');
  3526. // Update header auth status
  3527. updateAuthStatus(true, name + (company ? ` (${company})` : ''));
  3528. // Load GID from profile
  3529. loadCloudGid();
  3530. loadCloudApps();
  3531. }
  3532. function showCloudDisconnected() {
  3533. _cloudConnected = false;
  3534. $('cloudLoginPrompt').style.display = 'block';
  3535. $('cloudConnected').style.display = 'none';
  3536. $('cloudDot').classList.remove('connected');
  3537. $('cloudBtn').classList.remove('connected');
  3538. updateAuthStatus(false);
  3539. }
  3540. async function loadCloudGid() {
  3541. try {
  3542. const profile = normalizeProjectProfile(await api('/api/profile'));
  3543. const gid = getProfileGid(profile);
  3544. if (gid) $('cloudGid').value = gid;
  3545. } catch {}
  3546. }
  3547. async function createCloudProject() {
  3548. const projectName = prompt('Cloud project name:', document.getElementById('sidebarProjectName')?.textContent || 'VLCode-Project');
  3549. if (!projectName) return;
  3550. showCloudSyncStatus('Creating cloud project...', 'syncing');
  3551. try {
  3552. const data = await api('/api/cloud/create-project', { method: 'POST', body: JSON.stringify({ name: projectName }) });
  3553. if (data.error) {
  3554. showCloudSyncStatus('Create failed: ' + data.error, 'error');
  3555. addMsg('assistant', `**Create project failed:** ${data.error}\n\nPlease create a project on the VL platform manually and paste the GID into the Workspace GID field.`);
  3556. return;
  3557. }
  3558. $('cloudGid').value = data.gid;
  3559. // Save GID to Config/ProjectConfig
  3560. await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) });
  3561. showCloudSyncStatus(`Project created (GID: ${data.gid})`, 'ok');
  3562. setStatus(`Cloud project created: GID ${data.gid}`, 'green');
  3563. addMsg('assistant', `**Cloud project created** — GID: \`${data.gid}\`\n\nGID saved to \`Config/ProjectConfig\`. You can now click **Compile** to push files and build.`);
  3564. loadCloudApps();
  3565. } catch (e) {
  3566. showCloudSyncStatus('Error: ' + e.message, 'error');
  3567. }
  3568. }
  3569. async function loadCloudApps() {
  3570. try {
  3571. const data = await api('/api/cloud/apps?limit=20');
  3572. const list = $('cloudAppsList');
  3573. list.innerHTML = '';
  3574. if (!data.apps?.length) {
  3575. list.innerHTML = '<div style="padding:4px 12px;font-size:9px;color:var(--text2);">No cloud apps</div>';
  3576. return;
  3577. }
  3578. for (const app of data.apps) {
  3579. const el = document.createElement('div');
  3580. el.className = 'cloud-app-item';
  3581. el.innerHTML = `<span class="ca-title">${app.title || 'Untitled'}</span><span class="ca-gid">GID:${app.gid}</span>`;
  3582. el.onclick = () => {
  3583. $('cloudGid').value = app.gid;
  3584. setStatus(`Selected cloud workspace: ${app.title} (GID:${app.gid})`, 'green');
  3585. };
  3586. list.appendChild(el);
  3587. }
  3588. } catch {}
  3589. }
  3590. function openCloudLogin() {
  3591. $('cloudLoginError').style.display = 'none';
  3592. $('cloudUsername').value = '';
  3593. $('cloudPassword').value = '';
  3594. $('cloudCompany').value = '';
  3595. $('cloudDirectCookie').value = '';
  3596. switchLoginTab('enterprise');
  3597. $('cloudLoginModal').classList.add('open');
  3598. $('cloudUsername').focus();
  3599. // Load Google Identity Services
  3600. initGoogleSignIn();
  3601. }
  3602. function closeCloudLogin() {
  3603. $('cloudLoginModal').classList.remove('open');
  3604. }
  3605. function switchLoginTab(tab) {
  3606. document.querySelectorAll('.cl-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
  3607. $('clEnterprise').style.display = tab === 'enterprise' ? 'block' : 'none';
  3608. $('clGoogle').style.display = tab === 'google' ? 'block' : 'none';
  3609. $('clToken').style.display = tab === 'token' ? 'block' : 'none';
  3610. $('cloudLoginError').style.display = 'none';
  3611. }
  3612. function showLoginError(msg) {
  3613. $('cloudLoginError').textContent = msg;
  3614. $('cloudLoginError').style.display = 'block';
  3615. }
  3616. // --- Enterprise Login ---
  3617. async function doEnterpriseLogin() {
  3618. const btn = $('cloudLoginBtn');
  3619. btn.disabled = true;
  3620. btn.textContent = 'Logging in...';
  3621. $('cloudLoginError').style.display = 'none';
  3622. try {
  3623. const username = $('cloudUsername').value.trim();
  3624. const password = $('cloudPassword').value;
  3625. const companyName = $('cloudCompany').value.trim();
  3626. if (!username || !password) { showLoginError('Email and password required'); return; }
  3627. const res = await fetch('/api/cloud/login', {
  3628. method: 'POST',
  3629. headers: { 'Content-Type': 'application/json' },
  3630. body: JSON.stringify({ username, password, companyName }),
  3631. });
  3632. const data = await res.json();
  3633. if (data.ok) {
  3634. closeCloudLogin();
  3635. showCloudConnected(data.user);
  3636. setStatus('Cloud connected', 'green');
  3637. $('cloudPanel').style.display = 'block';
  3638. } else {
  3639. showLoginError(data.error || 'Login failed');
  3640. }
  3641. } catch (e) {
  3642. showLoginError(e.message);
  3643. } finally {
  3644. btn.disabled = false;
  3645. btn.textContent = 'Login';
  3646. }
  3647. }
  3648. // --- Google Sign-In ---
  3649. const GOOGLE_CLIENT_ID = '877956091268-3kjo5pbn2hptvt8s8q8l82mqlbs2fa3l.apps.googleusercontent.com';
  3650. let _gsiLoaded = false;
  3651. function initGoogleSignIn() {
  3652. if (_gsiLoaded) return;
  3653. // Load Google Identity Services script
  3654. if (!document.getElementById('gsi-script')) {
  3655. const script = document.createElement('script');
  3656. script.id = 'gsi-script';
  3657. script.src = 'https://accounts.google.com/gsi/client';
  3658. script.onload = () => { _gsiLoaded = true; renderGoogleButton(); };
  3659. script.onerror = () => {
  3660. $('googleSignInBtn').style.display = 'none';
  3661. $('googleSignInFallback').style.display = 'block';
  3662. };
  3663. document.head.appendChild(script);
  3664. } else if (window.google?.accounts) {
  3665. renderGoogleButton();
  3666. }
  3667. }
  3668. function renderGoogleButton() {
  3669. try {
  3670. google.accounts.id.initialize({
  3671. client_id: GOOGLE_CLIENT_ID,
  3672. callback: handleGoogleCredential,
  3673. auto_select: false,
  3674. });
  3675. google.accounts.id.renderButton($('googleSignInBtn'), {
  3676. theme: 'filled_black',
  3677. size: 'large',
  3678. width: 300,
  3679. text: 'signin_with',
  3680. });
  3681. $('googleSignInBtn').style.display = 'inline-block';
  3682. $('googleSignInFallback').style.display = 'none';
  3683. } catch (e) {
  3684. console.warn('Google Sign-In render failed:', e);
  3685. $('googleSignInBtn').style.display = 'none';
  3686. $('googleSignInFallback').style.display = 'block';
  3687. }
  3688. }
  3689. async function handleGoogleCredential(response) {
  3690. // response.credential is a JWT ID token
  3691. const statusEl = $('googleLoginStatus');
  3692. statusEl.style.display = 'block';
  3693. statusEl.textContent = 'Authenticating with VL Platform...';
  3694. try {
  3695. // Decode JWT to get user info (base64url decode the payload)
  3696. const payload = JSON.parse(atob(response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  3697. const googleUser = {
  3698. email: payload.email,
  3699. name: payload.name,
  3700. id: payload.sub,
  3701. picture: payload.picture || '',
  3702. };
  3703. // Call our server to do the platform googleLoginOrRegister
  3704. const res = await fetch('/api/cloud/google-login', {
  3705. method: 'POST',
  3706. headers: { 'Content-Type': 'application/json' },
  3707. body: JSON.stringify(googleUser),
  3708. });
  3709. const data = await res.json();
  3710. if (data.ok) {
  3711. closeCloudLogin();
  3712. showCloudConnected(data.user);
  3713. setStatus('Cloud connected via Google', 'green');
  3714. $('cloudPanel').style.display = 'block';
  3715. } else {
  3716. statusEl.style.color = 'var(--red)';
  3717. statusEl.textContent = data.error || 'Google login failed';
  3718. }
  3719. } catch (e) {
  3720. statusEl.style.color = 'var(--red)';
  3721. statusEl.textContent = 'Error: ' + e.message;
  3722. }
  3723. }
  3724. function googleLoginViaBrowser() {
  3725. window.open('https://www.visuallogic.ai', '_blank');
  3726. switchLoginTab('token');
  3727. }
  3728. // --- Token Login ---
  3729. async function doTokenLogin() {
  3730. const token = $('cloudDirectCookie').value.trim();
  3731. if (!token) { showLoginError('Paste the ih5bearer token value'); return; }
  3732. try {
  3733. await fetch('/api/settings', {
  3734. method: 'POST',
  3735. headers: { 'Content-Type': 'application/json' },
  3736. body: JSON.stringify({ cookie: token }),
  3737. });
  3738. // Verify
  3739. const status = await api('/api/cloud/status?refresh=true');
  3740. if (status.connected) {
  3741. closeCloudLogin();
  3742. showCloudConnected(status.user);
  3743. setStatus('Cloud connected via token', 'green');
  3744. $('cloudPanel').style.display = 'block';
  3745. } else {
  3746. showLoginError('Token invalid or expired');
  3747. }
  3748. } catch (e) {
  3749. showLoginError(e.message);
  3750. }
  3751. }
  3752. async function cloudLogout() {
  3753. await fetch('/api/cloud/logout', { method: 'POST' });
  3754. showCloudDisconnected();
  3755. setStatus('Cloud disconnected', 'yellow');
  3756. }
  3757. function showCloudSyncStatus(text, type) {
  3758. const el = $('cloudSyncStatus');
  3759. el.style.display = 'block';
  3760. el.className = 'cloud-status ' + (type || '');
  3761. el.textContent = text;
  3762. if (type === 'ok') setTimeout(() => { el.style.display = 'none'; }, 5000);
  3763. }
  3764. async function cloudSyncPush() {
  3765. let gid = $('cloudGid').value.trim();
  3766. // Auto-load GID from project profile if not set in UI
  3767. if (!gid) {
  3768. try {
  3769. const profile = normalizeProjectProfile(await api('/api/profile'));
  3770. gid = getProfileGid(profile);
  3771. if (gid) $('cloudGid').value = gid;
  3772. } catch {}
  3773. }
  3774. if (!gid) {
  3775. setStatus('No GID found — compile first to get one', 'yellow');
  3776. addMsg('assistant', 'No workspace GID. Running compile to create one...');
  3777. await compileProject();
  3778. gid = $('cloudGid').value.trim();
  3779. if (!gid) return;
  3780. }
  3781. showCloudSyncStatus('Pushing files to cloud...', 'syncing');
  3782. try {
  3783. const res = await fetch('/api/cloud/sync/push', {
  3784. method: 'POST',
  3785. headers: { 'Content-Type': 'application/json' },
  3786. body: JSON.stringify({ gid }),
  3787. });
  3788. const data = await res.json();
  3789. if (data.error) {
  3790. showCloudSyncStatus('Push failed: ' + data.error, 'error');
  3791. } else {
  3792. showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
  3793. addMsg('assistant', `**Cloud Push:** ${data.total} files synced to workspace GID:${gid}`);
  3794. }
  3795. } catch (e) {
  3796. showCloudSyncStatus('Push error: ' + e.message, 'error');
  3797. }
  3798. }
  3799. async function cloudSyncPull() {
  3800. const gid = $('cloudGid').value.trim();
  3801. if (!gid) {
  3802. setStatus('Set a Workspace GID first', 'red');
  3803. return;
  3804. }
  3805. showCloudSyncStatus('Pulling files from cloud...', 'syncing');
  3806. try {
  3807. const res = await fetch('/api/cloud/sync/pull', {
  3808. method: 'POST',
  3809. headers: { 'Content-Type': 'application/json' },
  3810. body: JSON.stringify({ gid }),
  3811. });
  3812. const data = await res.json();
  3813. if (data.error) {
  3814. showCloudSyncStatus('Pull failed: ' + data.error, 'error');
  3815. } else {
  3816. showCloudSyncStatus(`Pulled ${data.filesPulled} files`, 'ok');
  3817. addMsg('assistant', `**Cloud Pull:** ${data.filesPulled} files pulled from workspace GID:${gid}`);
  3818. await loadFileTree();
  3819. }
  3820. } catch (e) {
  3821. showCloudSyncStatus('Pull error: ' + e.message, 'error');
  3822. }
  3823. }
  3824. async function cloudCompile() {
  3825. const gid = $('cloudGid').value.trim();
  3826. const btn = $('compileBtn');
  3827. btn.disabled = true;
  3828. btn.innerHTML = '&#9203; Compiling...';
  3829. btn.style.opacity = '0.6';
  3830. setStatus('Cloud compile: syncing + compiling...', 'yellow');
  3831. showCloudSyncStatus('Sync + Compile in progress...', 'syncing');
  3832. try {
  3833. const body = {};
  3834. if (gid) body.gid = gid;
  3835. const res = await fetch('/api/cloud/compile', {
  3836. method: 'POST',
  3837. headers: { 'Content-Type': 'application/json' },
  3838. body: JSON.stringify(body),
  3839. });
  3840. const data = await res.json();
  3841. if (data.error) {
  3842. setStatus('Cloud compile failed: ' + data.error, 'red');
  3843. showCloudSyncStatus('Compile failed', 'error');
  3844. addMsg('assistant', 'Cloud compile failed: ' + data.error);
  3845. return;
  3846. }
  3847. // Update GID field with the result
  3848. if (data.gid) $('cloudGid').value = data.gid;
  3849. const urls = data.previewUrls || {};
  3850. const keys = Object.keys(urls);
  3851. const errList = data.errList || [];
  3852. const errCount = errList.length;
  3853. if (keys.length > 0) {
  3854. activatePreview(urls);
  3855. const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
  3856. addMsg('assistant', `**Cloud Compile ${errCount > 0 ? 'with ' + errCount + ' error(s)' : 'success'}** (GID: ${data.gid}, synced: ${data.syncedFiles} files)\n\n**Preview URLs:**\n${urlList}`);
  3857. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile — preview ready', errCount > 0 ? 'yellow' : 'green');
  3858. } else {
  3859. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Cloud compile done', errCount > 0 ? 'yellow' : 'green');
  3860. }
  3861. showCloudSyncStatus(`Compiled (${data.syncedFiles} files synced)`, 'ok');
  3862. if (errCount > 0) {
  3863. const errLines = errList.map((e, i) => {
  3864. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  3865. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  3866. return ` ${i + 1}. ${JSON.stringify(e)}`;
  3867. }).join('\n');
  3868. addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
  3869. addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
  3870. }
  3871. // Completion summary
  3872. addMsg('assistant', errCount > 0
  3873. ? `Compile done, ${errCount} error(s). Check the error list and fix them.`
  3874. : `Compile done, no errors.`);
  3875. } catch (e) {
  3876. setStatus('Cloud compile error', 'red');
  3877. showCloudSyncStatus('Error: ' + e.message, 'error');
  3878. addMsg('assistant', 'Cloud compile error: ' + e.message);
  3879. } finally {
  3880. btn.disabled = false;
  3881. btn.innerHTML = '&#9654; Compile';
  3882. btn.style.opacity = '1';
  3883. }
  3884. }
  3885. // Check cloud status on load
  3886. async function initCloudStatus() {
  3887. try {
  3888. const data = await api('/api/cloud/status');
  3889. if (data.connected) {
  3890. $('cloudPanel').style.display = 'block';
  3891. showCloudConnected(data.user);
  3892. }
  3893. } catch {}
  3894. // Init backend message count for context exclusion
  3895. try {
  3896. const ctx = await api('/api/context/messages', activeConvId);
  3897. _lastBackendMsgCount = ctx.count || 0;
  3898. } catch {}
  3899. }
  3900. /** Check cloud login status on startup — validate cookie, show offline if expired */
  3901. async function checkCloudLoginStatus() {
  3902. try {
  3903. const data = await api('/api/cloud/status?refresh=true');
  3904. if (data.connected) {
  3905. $('cloudPanel').style.display = 'block';
  3906. showCloudConnected(data.user);
  3907. } else {
  3908. showCloudDisconnected();
  3909. if (data.error === 'Session expired') {
  3910. addMsg('assistant', 'Cloud session expired. Please log in again to enable compile & push.');
  3911. }
  3912. }
  3913. } catch {
  3914. showCloudDisconnected();
  3915. }
  3916. // Init backend message count
  3917. try {
  3918. const ctx = await api('/api/context/messages', activeConvId);
  3919. _lastBackendMsgCount = ctx.count || 0;
  3920. } catch {}
  3921. }
  3922. // ===================== FILE TREE =====================
  3923. function formatFileSize(bytes) {
  3924. if (bytes == null) return '';
  3925. if (bytes < 1024) return bytes + ' B';
  3926. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  3927. return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  3928. }
  3929. let _fileSizeMap = {}; // path → size in bytes
  3930. const FILE_TREE_CATEGORY_ORDER = ['Apps', 'Sections', 'ExtComponents', 'Services', 'Database', 'Theme', 'Process', 'Config', 'root', '.vl-code'];
  3931. const FILE_TREE_TYPE_ORDER = { app: 0, section: 1, component: 2, service: 3, database: 4, theme: 5, process: 6, config: 7, json: 8, doc: 9, report: 10, workflow: 11, image: 20, log: 21 };
  3932. function getFileTreeCategoryRank(cat) {
  3933. const top = cat === 'root' ? 'root' : cat.split('/')[0];
  3934. const idx = FILE_TREE_CATEGORY_ORDER.indexOf(top);
  3935. const base = idx === -1 ? 90 : idx * 10;
  3936. return base + (cat === top ? 0 : 1);
  3937. }
  3938. function shouldHideFileTreePath(filePath, type) {
  3939. if (!showInternalFiles && filePath.startsWith('.vl-code/sessions/')) return true;
  3940. if (!showInternalFiles && filePath.startsWith('.vl-code/workflows/')) return true;
  3941. if (!showInternalFiles && filePath === '.vl-code/workspace.json') return true;
  3942. if (!showInternalFiles && filePath === '.vl-code/last-compile.json') return true;
  3943. if (/^manual_\d+\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) return true;
  3944. if (type === 'image' && /(?:^|\/)(manual_|screenshot_|screen_)/i.test(filePath)) return true;
  3945. return false;
  3946. }
  3947. function compareFileTreeItems(a, b) {
  3948. const typeDiff = (FILE_TREE_TYPE_ORDER[a.type] ?? 50) - (FILE_TREE_TYPE_ORDER[b.type] ?? 50);
  3949. if (typeDiff !== 0) return typeDiff;
  3950. return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
  3951. }
  3952. async function loadFileTree() {
  3953. try {
  3954. const data = await api('/api/files');
  3955. const tree = $('fileTree');
  3956. if (!currentWorkDir) {
  3957. tree.innerHTML = '<div style="color:var(--text2);font-size:11px;padding:20px 10px;text-align:center;">Select a project workspace from the top-left</div>';
  3958. return;
  3959. }
  3960. tree.innerHTML = '';
  3961. // Rebuild the visible tree client-side so internal artifacts stay out of the way.
  3962. _fileSizeMap = {};
  3963. const grouped = new Map();
  3964. let hiddenCount = 0;
  3965. for (const file of data.files) {
  3966. if (file.size != null) _fileSizeMap[file.path] = file.size;
  3967. const parts = file.path.split('/');
  3968. const cat = parts.length > 1 ? parts.slice(0, -1).join('/') : 'root';
  3969. const name = file.name || parts[parts.length - 1];
  3970. const type = getType(name, cat);
  3971. if (shouldHideFileTreePath(file.path, type)) {
  3972. hiddenCount++;
  3973. continue;
  3974. }
  3975. if (!grouped.has(cat)) grouped.set(cat, []);
  3976. grouped.get(cat).push({ name, path: file.path, type });
  3977. }
  3978. const sorted = [...grouped.entries()].sort((a, b) => {
  3979. const rankDiff = getFileTreeCategoryRank(a[0]) - getFileTreeCategoryRank(b[0]);
  3980. if (rankDiff !== 0) return rankDiff;
  3981. return a[0].localeCompare(b[0], undefined, { numeric: true, sensitivity: 'base' });
  3982. });
  3983. let visibleCount = 0;
  3984. for (const [cat, files] of sorted) {
  3985. files.sort(compareFileTreeItems);
  3986. if (files.length === 0) continue;
  3987. const div = document.createElement('div');
  3988. div.className = 'category';
  3989. div.innerHTML = `<div class="cat-name">${cat === 'root' ? './' : cat + '/'}</div>`;
  3990. for (const file of files) {
  3991. visibleCount++;
  3992. const el = document.createElement('div');
  3993. el.className = 'file';
  3994. el.style.display = 'flex';
  3995. el.style.alignItems = 'center';
  3996. const fp = file.path;
  3997. el.dataset.path = fp;
  3998. const sizeStr = _fileSizeMap[fp] != null ? `<span style="font-size:9px;color:var(--text2);margin-left:auto;padding-left:6px;white-space:nowrap">${formatFileSize(_fileSizeMap[fp])}</span>` : '';
  3999. el.innerHTML = `${getFileIcon(file.type, file.name)}<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${file.name}</span>${sizeStr}`;
  4000. el.onclick = () => openFileOrPreview(fp, file.type);
  4001. el.oncontextmenu = (e) => showFileCtxMenu(e, fp);
  4002. div.appendChild(el);
  4003. }
  4004. tree.appendChild(div);
  4005. }
  4006. $('fileCount').textContent = hiddenCount > 0
  4007. ? `${visibleCount} shown / ${data.files.length} total`
  4008. : `${visibleCount} files`;
  4009. } catch {}
  4010. }
  4011. function getType(name, cat) {
  4012. const ext = name.split('.').pop().toLowerCase();
  4013. const base = name.toLowerCase();
  4014. // VL source types
  4015. const vlType = {vx:'app',sc:'section',cp:'component',vs:'service',vdb:'database',vth:'theme'}[ext];
  4016. if (vlType) return vlType;
  4017. // Image files
  4018. if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return 'image';
  4019. // Reports
  4020. if (base.includes('report') || base.includes('result')) return 'report';
  4021. // Workflows
  4022. if ((cat && cat.includes('workflow')) || base.includes('workflow')) return 'workflow';
  4023. // Log files
  4024. if (ext === 'log' || base.includes('log')) return 'log';
  4025. // Process artifacts
  4026. if ((cat && cat.startsWith('Process')) || base.includes('process')) return 'process';
  4027. // Config files
  4028. if (base === 'conventions.json' || base === 'project.json' || base.includes('config')) return 'config';
  4029. // Standard types
  4030. return {json:'json',md:'doc',txt:'doc',html:'doc',csv:'doc'}[ext] || 'doc';
  4031. }
  4032. /** Get VS Code-style icon for file type */
  4033. function getFileIcon(type, name) {
  4034. const icons = {
  4035. app: '<span class="file-icon" style="color:var(--accent)">&#9670;</span>', // diamond
  4036. section: '<span class="file-icon" style="color:var(--green)">&#9638;</span>', // square
  4037. component: '<span class="file-icon" style="color:var(--yellow)">&#9672;</span>', // nested diamond
  4038. service: '<span class="file-icon" style="color:var(--red)">&#9881;</span>', // gear
  4039. database: '<span class="file-icon" style="color:var(--text2)">&#9707;</span>', // cylinder-like
  4040. theme: '<span class="file-icon" style="color:var(--purple)">&#9733;</span>', // star
  4041. json: '<span class="file-icon" style="color:#e0ad40">{ }</span>',
  4042. doc: '<span class="file-icon" style="color:#9da5ae">&#9776;</span>', // hamburger/lines
  4043. image: '<span class="file-icon" style="color:#f0883e">&#9634;</span>', // frame
  4044. report: '<span class="file-icon" style="color:#3fb950">&#9745;</span>', // ballot check
  4045. log: '<span class="file-icon" style="color:#8b949e">&#9683;</span>', // circle half
  4046. config: '<span class="file-icon" style="color:#d29922">&#9881;</span>', // gear
  4047. workflow: '<span class="file-icon" style="color:#a371f7">&#9654;</span>', // play
  4048. process: '<span class="file-icon" style="color:#c49bff">&#9998;</span>', // pencil
  4049. };
  4050. return icons[type] || icons.doc;
  4051. }
  4052. // ===================== EDITOR =====================
  4053. async function openFile(fpath) {
  4054. try {
  4055. const data = await api(`/api/file?path=${encodeURIComponent(fpath)}`);
  4056. const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
  4057. // Clear all previous file tabs (no caching) — keep only special tabs
  4058. for (const [k, v] of openFiles) {
  4059. if (v.type === 'file') openFiles.delete(k);
  4060. }
  4061. openFiles.set(fpath, { type: 'file', content });
  4062. currentFile = fpath;
  4063. // Switch to code mode if in meta/flow mode
  4064. if (currentMode !== 'code') switchMode('code');
  4065. renderTabs();
  4066. showTabContent(fpath);
  4067. document.querySelectorAll('.file').forEach(el => el.classList.toggle('active', el.dataset.path === fpath));
  4068. $('currentFile').textContent = fpath;
  4069. } catch (err) {
  4070. console.error('openFile failed:', fpath, err);
  4071. setStatus('Failed to open ' + fpath.split('/').pop(), 'red');
  4072. }
  4073. }
  4074. function openFileOrPreview(fpath, type) {
  4075. if (type === 'image') {
  4076. showImagePreview(fpath);
  4077. } else {
  4078. openFile(fpath);
  4079. }
  4080. }
  4081. function showImagePreview(fpath) {
  4082. let overlay = $('imagePreviewOverlay');
  4083. if (!overlay) {
  4084. overlay = document.createElement('div');
  4085. overlay.id = 'imagePreviewOverlay';
  4086. overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;';
  4087. overlay.onclick = () => overlay.style.display = 'none';
  4088. overlay.innerHTML = `
  4089. <div id="imgPreviewTitle" style="color:#fff;font-size:13px;margin-bottom:8px;"></div>
  4090. <img id="imgPreviewImg" style="max-width:90vw;max-height:80vh;border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,.5);">
  4091. <div id="imgPreviewInfo" style="color:var(--text2);font-size:11px;margin-top:8px;"></div>
  4092. `;
  4093. document.body.appendChild(overlay);
  4094. }
  4095. const url = `/api/file/raw?path=${encodeURIComponent(fpath)}`;
  4096. $('imgPreviewImg').src = url;
  4097. $('imgPreviewTitle').textContent = fpath;
  4098. const size = _fileSizeMap[fpath];
  4099. $('imgPreviewInfo').textContent = size != null ? formatFileSize(size) : '';
  4100. overlay.style.display = 'flex';
  4101. }
  4102. /** Open a special (non-file) tab: workflow DAG or metadata graph */
  4103. function openSpecialTab(key, type, title, data) {
  4104. openFiles.set(key, { type, title, data });
  4105. currentFile = key;
  4106. renderTabs();
  4107. showTabContent(key);
  4108. $('currentFile').textContent = title;
  4109. }
  4110. /** Close a tab by key */
  4111. function closeTab(key, evt) {
  4112. if (evt) evt.stopPropagation();
  4113. openFiles.delete(key);
  4114. // Clean up iframe if it was a special tab
  4115. const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
  4116. if (iframe) iframe.remove();
  4117. // Switch to another tab or show placeholder
  4118. if (currentFile === key) {
  4119. const keys = [...openFiles.keys()];
  4120. if (keys.length > 0) {
  4121. currentFile = keys[keys.length - 1];
  4122. renderTabs();
  4123. showTabContent(currentFile);
  4124. $('currentFile').textContent = currentFile;
  4125. } else {
  4126. currentFile = null;
  4127. renderTabs();
  4128. $('cmEditorWrap').style.display = 'none';
  4129. $('editor').style.display = 'none';
  4130. $('iframeContainer').style.display = 'none';
  4131. $('editorPlaceholder').style.display = 'block';
  4132. $('currentFile').textContent = '';
  4133. }
  4134. } else {
  4135. renderTabs();
  4136. }
  4137. }
  4138. function renderTabs() {
  4139. const tabs = $('editorTabs');
  4140. tabs.innerHTML = '';
  4141. // Only show tab bar if there are special (non-file) tabs alongside the current file
  4142. const hasSpecialTabs = [...openFiles.values()].some(v => v.type !== 'file');
  4143. tabs.style.display = (openFiles.size > 1 || hasSpecialTabs) ? 'flex' : 'none';
  4144. for (const [key, info] of openFiles) {
  4145. const tab = document.createElement('div');
  4146. tab.className = 'tab' + (key === currentFile ? ' active' : '');
  4147. const icons = { file: '', workflow: '\u2B21 ', metadata: '\u25C9 ' };
  4148. const label = info.type === 'file' ? key.split('/').pop() : (info.title || key);
  4149. tab.innerHTML = `<span class="tab-icon">${icons[info.type] || ''}</span><span>${escapeHtml(label)}</span><span class="tab-close" onclick="closeTab('${key.replace(/'/g, "\\'")}', event)">&times;</span>`;
  4150. tab.onclick = () => { currentFile = key; renderTabs(); showTabContent(key); };
  4151. tabs.appendChild(tab);
  4152. }
  4153. }
  4154. /** Show content for the active tab (file editor or iframe) */
  4155. function showTabContent(key) {
  4156. const info = openFiles.get(key);
  4157. if (!info) return;
  4158. $('editorPlaceholder').style.display = 'none';
  4159. $('codePreview').style.display = 'none';
  4160. $('mdPreview').style.display = 'none';
  4161. if (info.type === 'file') {
  4162. $('iframeContainer').style.display = 'none';
  4163. // Try CodeMirror, fall back to textarea
  4164. initCodeMirror();
  4165. if (cmEditor) {
  4166. $('editor').style.display = 'none';
  4167. $('cmEditorWrap').style.display = 'block';
  4168. cmEditor.setValue(info.content || '');
  4169. cmEditor.setOption('mode', getCmMode(key));
  4170. cmEditor.clearHistory();
  4171. setTimeout(() => cmEditor.refresh(), 10);
  4172. } else {
  4173. // Textarea fallback
  4174. $('cmEditorWrap').style.display = 'none';
  4175. $('editor').style.display = 'block';
  4176. $('editor').value = info.content || '';
  4177. }
  4178. $('currentFile').textContent = key;
  4179. } else {
  4180. // Show iframe, hide code editor
  4181. $('editor').style.display = 'none';
  4182. $('cmEditorWrap').style.display = 'none';
  4183. $('iframeContainer').style.display = 'block';
  4184. // Hide all iframes, show the one for this tab
  4185. const container = $('iframeContainer');
  4186. [...container.children].forEach(f => f.style.display = 'none');
  4187. let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
  4188. if (!iframe) {
  4189. iframe = document.createElement('iframe');
  4190. iframe.dataset.tab = key;
  4191. iframe.sandbox = 'allow-scripts allow-same-origin';
  4192. if (info.type === 'workflow') iframe.src = '/workflow-editor.html';
  4193. else if (info.type === 'metadata') iframe.src = '/metadata-viewer.html';
  4194. iframe.onload = () => {
  4195. // Send data to iframe once ready
  4196. if (info.data) {
  4197. const msg = info.type === 'workflow'
  4198. ? { type: 'loadWorkflow', data: info.data, workflowName: info.workflowName || info.name || null }
  4199. : { type: 'loadMetadata', data: info.data };
  4200. iframe.contentWindow.postMessage(msg, '*');
  4201. }
  4202. };
  4203. container.appendChild(iframe);
  4204. }
  4205. iframe.style.display = 'block';
  4206. }
  4207. }
  4208. // Legacy alias
  4209. function showEditor(content) {
  4210. initCodeMirror();
  4211. $('iframeContainer').style.display = 'none';
  4212. $('editorPlaceholder').style.display = 'none';
  4213. if (cmEditor) {
  4214. $('cmEditorWrap').style.display = 'block';
  4215. $('editor').style.display = 'none';
  4216. cmEditor.setValue(content || '');
  4217. setTimeout(() => cmEditor.refresh(), 10);
  4218. } else {
  4219. $('cmEditorWrap').style.display = 'none';
  4220. $('editor').style.display = 'block';
  4221. $('editor').value = content || '';
  4222. }
  4223. }
  4224. $('editor').addEventListener('keydown', e => {
  4225. if ((e.metaKey || e.ctrlKey) && e.key === 's') {
  4226. e.preventDefault();
  4227. saveCurrentFile();
  4228. }
  4229. });
  4230. async function saveCurrentFile() {
  4231. if (!currentFile) return;
  4232. const info = openFiles.get(currentFile);
  4233. if (!info || info.type !== 'file') return; // Only save file tabs
  4234. const content = cmEditor ? cmEditor.getValue() : $('editor').value;
  4235. info.content = content;
  4236. await fetch('/api/file', { method:'POST', headers:{'Content-Type':'application/json'},
  4237. body: JSON.stringify({ path: currentFile, content }) });
  4238. setStatus('Saved ' + currentFile.split('/').pop(), 'green');
  4239. }
  4240. // ===================== DETAIL PANEL =====================
  4241. let _detailEntryCount = 0;
  4242. let _detailManualClosed = false; // When user manually closes, prevent auto-open
  4243. function toggleDetailPanel() {
  4244. const panel = $('detailPanel');
  4245. const wasOpen = panel.classList.contains('open');
  4246. panel.classList.toggle('open');
  4247. // If user is closing it manually, set flag to prevent auto-open
  4248. if (wasOpen) {
  4249. _detailManualClosed = true;
  4250. } else {
  4251. _detailManualClosed = false;
  4252. }
  4253. }
  4254. /** Cross-panel navigation: chat → detail */
  4255. function scrollToDetailEntry(linkId) {
  4256. const panel = $('detailPanel');
  4257. // Open detail panel (even if manually closed — user explicitly clicked)
  4258. if (!panel.classList.contains('open')) {
  4259. panel.classList.add('open');
  4260. _detailManualClosed = false;
  4261. }
  4262. const detailEl = panel.querySelector(`.detail-entry[data-link-id="${linkId}"]`);
  4263. if (detailEl) {
  4264. detailEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
  4265. detailEl.style.outline = '2px solid var(--accent)';
  4266. detailEl.style.background = 'rgba(139,233,253,0.08)';
  4267. setTimeout(() => { detailEl.style.outline = ''; detailEl.style.background = ''; }, 2000);
  4268. }
  4269. }
  4270. /** Cross-panel navigation: detail → chat */
  4271. function scrollToChatEntry(linkId) {
  4272. const chatEl = document.querySelector(`.tool-group[data-link-id="${linkId}"]`);
  4273. if (chatEl) {
  4274. chatEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
  4275. chatEl.style.outline = '2px solid var(--accent)';
  4276. chatEl.style.background = 'rgba(139,233,253,0.08)';
  4277. setTimeout(() => { chatEl.style.outline = ''; chatEl.style.background = ''; }, 2000);
  4278. }
  4279. }
  4280. function clearDetailPanel() {
  4281. $('detailBody').innerHTML = '';
  4282. _detailEntryCount = 0;
  4283. $('detailCount').textContent = '';
  4284. // Also clear backend compile cache so stale data doesn't reappear
  4285. fetch('/api/detail-log', { method: 'DELETE' }).catch(() => {});
  4286. }
  4287. /**
  4288. * Add entry to detail panel.
  4289. * @param {string} phase - Category tag (e.g., 'generate', 'workflow', 'tool')
  4290. * @param {string} message - Main message text
  4291. * @param {*} data - Optional JSON data to display
  4292. * @param {string} type - 'info'|'success'|'error'|'warn'
  4293. * @param {Object} opts - Optional: { depth:0-3, agentId, agentName, parentContainer }
  4294. */
  4295. let _detailLinkId = 0;
  4296. function addDetailEntry(phase, message, data, type = 'info', opts = {}) {
  4297. const panel = $('detailPanel');
  4298. // Only auto-open if user hasn't manually closed it
  4299. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4300. _detailEntryCount++;
  4301. $('detailCount').textContent = `${_detailEntryCount} entries`;
  4302. const body = opts.parentContainer || $('detailBody');
  4303. const now = new Date().toLocaleTimeString();
  4304. const div = document.createElement('div');
  4305. const depth = opts.depth || 0;
  4306. div.className = `detail-entry ${type}` + (depth > 0 ? ` depth-${Math.min(depth, 3)}` : '');
  4307. // Cross-panel linkage: assign ID so chat can scroll to this entry
  4308. if (opts.linkId) {
  4309. div.dataset.linkId = opts.linkId;
  4310. div.style.cursor = 'pointer';
  4311. div.title = 'Click to scroll to chat';
  4312. div.onclick = () => scrollToChatEntry(opts.linkId);
  4313. }
  4314. let html = `<span class="de-time">${now}</span>`;
  4315. if (opts.agentName) html += `<span class="de-agent">[${escapeHtml(opts.agentName)}]</span>`;
  4316. if (phase) html += `<span class="de-phase" data-phase="${escapeHtml(phase)}">[${escapeHtml(phase)}]</span>`;
  4317. html += `<div class="de-msg">${escapeHtml(message)}</div>`;
  4318. if (data) {
  4319. const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  4320. const isLong = dataStr.length > 200;
  4321. html += `<div class="de-data${isLong ? ' collapsed' : ''}" onclick="event.stopPropagation();this.classList.toggle('collapsed')">${escapeHtml(dataStr)}</div>`;
  4322. }
  4323. div.innerHTML = html;
  4324. body.appendChild(div);
  4325. body.scrollTop = body.scrollHeight;
  4326. return div;
  4327. }
  4328. /**
  4329. * Create or get an agent group in the detail panel for hierarchical display.
  4330. * Returns a container element where child entries can be appended.
  4331. */
  4332. const _detailAgentGroups = {};
  4333. function getOrCreateAgentGroup(agentId, agentName, parentContainer) {
  4334. if (_detailAgentGroups[agentId]) return _detailAgentGroups[agentId];
  4335. const body = parentContainer || $('detailBody');
  4336. const group = document.createElement('div');
  4337. group.className = 'detail-agent-group';
  4338. group.dataset.agentId = agentId;
  4339. group.innerHTML = `
  4340. <div class="detail-agent-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
  4341. <span class="dag-icon">&#9654;</span>
  4342. <span class="dag-name">${escapeHtml(agentName)}</span>
  4343. <span class="dag-desc"></span>
  4344. <span class="dag-status" style="color:var(--yellow);">running</span>
  4345. </div>
  4346. <div class="detail-agent-children"></div>`;
  4347. body.appendChild(group);
  4348. const children = group.querySelector('.detail-agent-children');
  4349. _detailAgentGroups[agentId] = children;
  4350. body.scrollTop = body.scrollHeight;
  4351. return children;
  4352. }
  4353. function completeAgentGroup(agentId, status) {
  4354. const container = _detailAgentGroups[agentId];
  4355. if (!container) return;
  4356. const group = container.closest('.detail-agent-group');
  4357. if (!group) return;
  4358. const statusEl = group.querySelector('.dag-status');
  4359. if (statusEl) {
  4360. statusEl.textContent = status || 'done';
  4361. statusEl.style.color = status === 'error' ? 'var(--red)' : 'var(--green)';
  4362. }
  4363. const icon = group.querySelector('.dag-icon');
  4364. if (icon) icon.textContent = status === 'error' ? '✗' : '✓';
  4365. }
  4366. // Stream box: accumulates streaming content (LLM tokens) in one expandable container
  4367. const _streamBoxes = {};
  4368. function appendToStreamBox(boxId, label, text) {
  4369. const body = $('detailBody');
  4370. const panel = $('detailPanel');
  4371. // Only auto-open if user hasn't manually closed it
  4372. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4373. let box = _streamBoxes[boxId];
  4374. if (!box) {
  4375. const div = document.createElement('div');
  4376. const isThinking = boxId.includes('thinking');
  4377. div.className = 'detail-entry stream-box' + (isThinking ? ' thinking-stream' : '');
  4378. div.innerHTML = `<div class="de-stream-header" onclick="this.parentElement.classList.toggle('collapsed')">
  4379. <span class="de-stream-label">${escapeHtml(label)}</span>
  4380. <span class="de-stream-size">0 chars</span>
  4381. <span class="de-stream-toggle">▼</span>
  4382. </div><div class="de-stream-content"></div>`;
  4383. body.appendChild(div);
  4384. const contentEl = div.querySelector('.de-stream-content');
  4385. const sizeEl = div.querySelector('.de-stream-size');
  4386. box = { el: div, contentEl, sizeEl, charCount: 0 };
  4387. _streamBoxes[boxId] = box;
  4388. }
  4389. box.charCount += text.length;
  4390. // Batch DOM updates: buffer text and flush periodically for smooth rendering
  4391. if (!box._buffer) box._buffer = '';
  4392. box._buffer += text;
  4393. if (!box._flushTimer) {
  4394. box._flushTimer = setTimeout(() => {
  4395. box.contentEl.textContent += box._buffer;
  4396. box._buffer = '';
  4397. box._flushTimer = null;
  4398. box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
  4399. body.scrollTop = body.scrollHeight;
  4400. }, 150); // flush every 150ms — smooth but not jittery
  4401. }
  4402. }
  4403. function flushStreamBoxes() {
  4404. for (const id in _streamBoxes) {
  4405. const box = _streamBoxes[id];
  4406. if (box._buffer) {
  4407. box.contentEl.textContent += box._buffer;
  4408. box._buffer = '';
  4409. if (box._flushTimer) { clearTimeout(box._flushTimer); box._flushTimer = null; }
  4410. box.sizeEl.textContent = box.charCount > 1000 ? `${(box.charCount / 1000).toFixed(1)}k chars` : `${box.charCount} chars`;
  4411. }
  4412. }
  4413. }
  4414. function clearStreamBoxes() {
  4415. flushStreamBoxes();
  4416. for (const id in _streamBoxes) delete _streamBoxes[id];
  4417. }
  4418. // ===================== WORKFLOW STEP CARDS (Detail Log) =====================
  4419. // Enhanced step cards for workflow execution — inputs, outputs, files, re-run
  4420. const _stepCards = {}; // stepID → { el, status, startTime, inputs, outputs, files, thinking, response }
  4421. let _lastWorkflowName = ''; // For rerun
  4422. let _lastRunCheckpoint = null;
  4423. function addStepCard(stepID, type, title, resolvedInputs) {
  4424. const body = $('detailBody');
  4425. const panel = $('detailPanel');
  4426. if (!panel.classList.contains('open') && !_detailManualClosed) panel.classList.add('open');
  4427. const card = document.createElement('div');
  4428. card.className = 'detail-step-card running';
  4429. card.dataset.stepId = stepID;
  4430. // Icon based on type
  4431. const typeIcons = { LLM: '🤖', Write: '📝', Set: '⚙️', Branch: '🔀', Loop: '🔁', Noop: '•', Service: '🔌', API: '🌐', Pause: '⏸', MetaDiff: '📊', ComponentFetch: '📦', ClearFiles: '🗑' };
  4432. const icon = typeIcons[type] || '▶';
  4433. card.innerHTML = `
  4434. <div class="dsc-header" onclick="toggleStepCardBody('${stepID}')" oncontextmenu="showStepCtxMenu(event, '${escapeHtml(stepID)}')">
  4435. <span class="dsc-icon">${icon}</span>
  4436. <span class="dsc-title">${escapeHtml(title || stepID)}</span>
  4437. <span class="dsc-type">${escapeHtml(type || '')}</span>
  4438. <span class="dsc-duration" id="dsc-dur-${stepID}"></span>
  4439. <span class="dsc-hover-actions">
  4440. <button class="dsc-hover-btn" title="Re-run from here" onclick="event.stopPropagation();openRerunDialog('${escapeHtml(stepID)}')">🔄</button>
  4441. <button class="dsc-hover-btn" title="Highlight in DAG" onclick="event.stopPropagation();highlightStepInDAG('${escapeHtml(stepID)}')">🔍</button>
  4442. <button class="dsc-hover-btn" title="Copy outputs" onclick="event.stopPropagation();copyStepOutputs('${escapeHtml(stepID)}')">📋</button>
  4443. </span>
  4444. </div>
  4445. <div class="dsc-body" id="dsc-body-${stepID}"></div>`;
  4446. body.appendChild(card);
  4447. body.scrollTop = body.scrollHeight;
  4448. const state = {
  4449. el: card,
  4450. bodyEl: card.querySelector('.dsc-body'),
  4451. status: 'running',
  4452. startTime: Date.now(),
  4453. type, title, stepID,
  4454. files: [],
  4455. };
  4456. _stepCards[stepID] = state;
  4457. // Add inputs section if available
  4458. if (resolvedInputs) {
  4459. addStepCardSection(stepID, 'Inputs', resolvedInputs);
  4460. }
  4461. _detailEntryCount++;
  4462. $('detailCount').textContent = `${_detailEntryCount} entries`;
  4463. return state;
  4464. }
  4465. function toggleStepCardBody(stepID) {
  4466. const state = _stepCards[stepID];
  4467. if (!state) return;
  4468. state.bodyEl.classList.toggle('open');
  4469. }
  4470. function addStepCardSection(stepID, label, data, collapsed = true) {
  4471. const state = _stepCards[stepID];
  4472. if (!state) return;
  4473. const sec = document.createElement('div');
  4474. sec.className = 'dsc-section';
  4475. const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  4476. const isLong = dataStr.length > 500;
  4477. const sectionId = `dsc-sec-${stepID}-${label.replace(/\s/g, '')}`;
  4478. sec.innerHTML = `
  4479. <div class="dsc-section-header" onclick="toggleStepCardSection('${sectionId}')">
  4480. <span class="dsc-arrow${collapsed ? '' : ' open'}" id="arr-${sectionId}">▶</span>
  4481. <span>${escapeHtml(label)}</span>
  4482. ${isLong ? '<span style="color:var(--text2);font-size:8px;">(' + (dataStr.length > 1024 ? (dataStr.length / 1024).toFixed(1) + 'KB' : dataStr.length + 'B') + ')</span>' : ''}
  4483. </div>
  4484. <div class="dsc-section-content${collapsed ? '' : ' open'}${isLong ? ' truncated' : ''}" id="${sectionId}">${escapeHtml(isLong ? dataStr.substring(0, 2000) : dataStr)}</div>`;
  4485. state.bodyEl.appendChild(sec);
  4486. }
  4487. function toggleStepCardSection(sectionId) {
  4488. const el = document.getElementById(sectionId);
  4489. const arr = document.getElementById('arr-' + sectionId);
  4490. if (el) el.classList.toggle('open');
  4491. if (arr) arr.classList.toggle('open');
  4492. }
  4493. function completeStepCard(stepID, outputs, selected, duration_ms) {
  4494. const state = _stepCards[stepID];
  4495. if (!state) return;
  4496. state.status = 'done';
  4497. state.el.className = 'detail-step-card done';
  4498. // Update icon
  4499. const iconEl = state.el.querySelector('.dsc-icon');
  4500. if (iconEl) iconEl.textContent = '✓';
  4501. // Duration
  4502. const dur = duration_ms || (Date.now() - state.startTime);
  4503. const durEl = state.el.querySelector('.dsc-duration');
  4504. if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
  4505. // Add outputs section
  4506. if (outputs) {
  4507. addStepCardSection(stepID, 'Outputs', outputs);
  4508. }
  4509. if (selected) {
  4510. addStepCardSection(stepID, 'Branch Selected', selected, false);
  4511. }
  4512. // Add files section if any files were written during this step
  4513. if (state.files.length > 0) {
  4514. const fileList = state.files.map(f => `📄 ${f}`).join('\n');
  4515. addStepCardSection(stepID, `Files (${state.files.length})`, fileList, false);
  4516. }
  4517. // Add re-run button
  4518. const actions = document.createElement('div');
  4519. actions.className = 'dsc-actions';
  4520. actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')">🔄 Re-run from here</button>`;
  4521. state.bodyEl.appendChild(actions);
  4522. // Open body to show results
  4523. state.bodyEl.classList.add('open');
  4524. }
  4525. function errorStepCard(stepID, error, duration_ms) {
  4526. const state = _stepCards[stepID];
  4527. if (!state) return;
  4528. state.status = 'error';
  4529. state.el.className = 'detail-step-card error';
  4530. const iconEl = state.el.querySelector('.dsc-icon');
  4531. if (iconEl) iconEl.textContent = '✗';
  4532. const dur = duration_ms || (Date.now() - state.startTime);
  4533. const durEl = state.el.querySelector('.dsc-duration');
  4534. if (durEl) durEl.textContent = dur >= 1000 ? (dur / 1000).toFixed(1) + 's' : dur + 'ms';
  4535. addStepCardSection(stepID, 'Error', error, false);
  4536. // Add re-run button even on error (especially useful here)
  4537. const actions = document.createElement('div');
  4538. actions.className = 'dsc-actions';
  4539. actions.innerHTML = `<button class="dsc-rerun-btn" onclick="openRerunDialog('${escapeHtml(stepID)}')" style="border-color:var(--red);color:var(--red);">🔄 Re-run from here</button>`;
  4540. state.bodyEl.appendChild(actions);
  4541. state.bodyEl.classList.add('open');
  4542. }
  4543. /** Track file writes to the current running step card */
  4544. function addFileToStepCard(stepID, filePath) {
  4545. const state = _stepCards[stepID];
  4546. if (!state) return;
  4547. state.files.push(filePath);
  4548. }
  4549. /** Get current running step ID (for file_done association) */
  4550. function getCurrentRunningStepID() {
  4551. for (const [id, s] of Object.entries(_stepCards)) {
  4552. if (s.status === 'running') return id;
  4553. }
  4554. return null;
  4555. }
  4556. // ===================== STEP CARD CONTEXT MENU =====================
  4557. let _stepCtxTarget = null; // stepID of the right-clicked card
  4558. function showStepCtxMenu(e, stepID) {
  4559. e.preventDefault();
  4560. e.stopPropagation();
  4561. _stepCtxTarget = stepID;
  4562. const menu = $('stepCtxMenu');
  4563. menu.style.left = e.clientX + 'px';
  4564. menu.style.top = e.clientY + 'px';
  4565. menu.classList.add('open');
  4566. }
  4567. function _closeStepCtxMenu() { $('stepCtxMenu').classList.remove('open'); }
  4568. document.addEventListener('click', _closeStepCtxMenu);
  4569. function stepCtxRerun() {
  4570. _closeStepCtxMenu();
  4571. if (_stepCtxTarget) openRerunDialog(_stepCtxTarget);
  4572. }
  4573. function stepCtxViewInDAG() {
  4574. _closeStepCtxMenu();
  4575. if (_stepCtxTarget) highlightStepInDAG(_stepCtxTarget);
  4576. }
  4577. function stepCtxCopyOutputs() {
  4578. _closeStepCtxMenu();
  4579. if (_stepCtxTarget) copyStepOutputs(_stepCtxTarget);
  4580. }
  4581. function stepCtxCopyFiles() {
  4582. _closeStepCtxMenu();
  4583. const state = _stepCards[_stepCtxTarget];
  4584. if (!state || !state.files.length) return;
  4585. navigator.clipboard.writeText(state.files.join('\n')).then(
  4586. () => setStatus(`Copied ${state.files.length} file path(s)`, 'green'),
  4587. () => setStatus('Copy failed', 'red')
  4588. );
  4589. }
  4590. function stepCtxToggleBody() {
  4591. _closeStepCtxMenu();
  4592. if (_stepCtxTarget) toggleStepCardBody(_stepCtxTarget);
  4593. }
  4594. function stepCtxExpandAll() {
  4595. _closeStepCtxMenu();
  4596. const state = _stepCards[_stepCtxTarget];
  4597. if (!state) return;
  4598. state.bodyEl.classList.add('open');
  4599. state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.add('open'));
  4600. state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.add('open'));
  4601. }
  4602. function stepCtxCollapseAll() {
  4603. _closeStepCtxMenu();
  4604. const state = _stepCards[_stepCtxTarget];
  4605. if (!state) return;
  4606. state.bodyEl.querySelectorAll('.dsc-section-content').forEach(el => el.classList.remove('open'));
  4607. state.bodyEl.querySelectorAll('.dsc-arrow').forEach(el => el.classList.remove('open'));
  4608. }
  4609. /** Highlight a step in the DAG visualization */
  4610. function highlightStepInDAG(stepID) {
  4611. if (currentMode !== 'flow') switchMode('flow');
  4612. sendToWorkflowIframe({ type: 'highlightNode', nodeId: stepID });
  4613. }
  4614. /** Copy step outputs to clipboard */
  4615. function copyStepOutputs(stepID) {
  4616. const state = _stepCards[stepID];
  4617. if (!state) return;
  4618. // Find outputs section content
  4619. const outputSec = state.bodyEl.querySelector(`#dsc-sec-${stepID}-Outputs`);
  4620. const text = outputSec ? outputSec.textContent : '(no outputs)';
  4621. navigator.clipboard.writeText(text).then(
  4622. () => setStatus('Outputs copied to clipboard', 'green'),
  4623. () => setStatus('Copy failed', 'red')
  4624. );
  4625. }
  4626. // Debug panel removed — all debug info goes to Detail Log
  4627. function debugLog() {} // no-op stub for any remaining calls
  4628. function closeChatMoreMenu() {
  4629. $('chatMoreMenu')?.classList.remove('open');
  4630. }
  4631. function toggleChatMoreMenu(e) {
  4632. e?.stopPropagation();
  4633. const menu = $('chatMoreMenu');
  4634. if (!menu) return;
  4635. menu.classList.toggle('open');
  4636. }
  4637. function chatMenuAction(action) {
  4638. closeChatMoreMenu();
  4639. if (action === 'blueprint') return sendSkillCmd('blueprint');
  4640. if (action === 'search') return openChatSearch();
  4641. if (action === 'compact') return toggleCompactMode();
  4642. if (action === 'settings') return openSettings();
  4643. }
  4644. function toggleCompactMode() {
  4645. const panel = $('chatPanel');
  4646. const isCompact = panel.classList.toggle('compact');
  4647. const menuItem = $('compactMenuItem');
  4648. if (menuItem) menuItem.textContent = isCompact ? 'Full Mode' : 'Compact Mode';
  4649. }
  4650. // ===================== AUTO-SCREENSHOTS =====================
  4651. let _contextScreenshots = []; // Auto-attached to next LLM message
  4652. /** Append screenshot thumbnails to a chat message element */
  4653. function appendScreenshotToChat(msgEl, url, name) {
  4654. if (!msgEl) return;
  4655. let container = msgEl.querySelector('.msg-screenshots');
  4656. if (!container) {
  4657. container = document.createElement('div');
  4658. container.className = 'msg-screenshots';
  4659. msgEl.appendChild(container);
  4660. }
  4661. const item = document.createElement('div');
  4662. item.className = 'ss-item';
  4663. const img = document.createElement('img');
  4664. img.src = url;
  4665. img.onclick = () => window.open(url);
  4666. img.title = name;
  4667. const label = document.createElement('span');
  4668. label.className = 'ss-label';
  4669. label.textContent = name.replace(/^step_/, 'Step ').replace(/_\d+$/, '');
  4670. item.appendChild(img);
  4671. item.appendChild(label);
  4672. container.appendChild(item);
  4673. scrollChat();
  4674. }
  4675. /** Convert blob to base64 data string */
  4676. function blobToBase64(blob) {
  4677. return new Promise((resolve) => {
  4678. const reader = new FileReader();
  4679. reader.onload = () => resolve(reader.result.split(',')[1]);
  4680. reader.readAsDataURL(blob);
  4681. });
  4682. }
  4683. // ===================== CHAT =====================
  4684. let _currentAbortController = null;
  4685. let _chatStartTime = 0;
  4686. let _chatElapsedTimer = null;
  4687. async function stopExecution() {
  4688. // 1. Abort the frontend fetch
  4689. if (_currentAbortController) { _currentAbortController.abort(); _currentAbortController = null; }
  4690. // 2. Tell the server to abort this chat session
  4691. try { await fetch('/api/abort', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) }); } catch {}
  4692. // 3. Finalize all spinning indicators
  4693. finalizeAllToolSpinners();
  4694. clearSpinnerSafetyTimeout();
  4695. // 4. UI cleanup
  4696. $('chatStop').style.display = 'none';
  4697. $('chatSend').style.display = '';
  4698. $('chatSend').disabled = false;
  4699. setChatStatusRunning(false);
  4700. setStatus('Stopped', 'red');
  4701. setTimeout(() => setStatus('Ready', 'green'), 2000);
  4702. }
  4703. function setChatStatusRunning(running) {
  4704. const statusBar = $('chatStatusBar');
  4705. if (running) {
  4706. statusBar.style.display = 'flex';
  4707. _chatStartTime = Date.now();
  4708. updateChatStatusBar('Thinking...', '');
  4709. _chatElapsedTimer = setInterval(updateChatElapsed, 1000);
  4710. setTabStatus('busy');
  4711. } else {
  4712. statusBar.style.display = 'none';
  4713. if (_chatElapsedTimer) { clearInterval(_chatElapsedTimer); _chatElapsedTimer = null; }
  4714. // If tab is not focused, show "new output" indicator instead of idle
  4715. if (!_tabHasFocus) {
  4716. setTabStatus('newOutput');
  4717. } else {
  4718. setTabStatus('idle');
  4719. }
  4720. }
  4721. }
  4722. const _toolVerbs = {
  4723. ReadFile:'Reading', WriteFile:'Writing', EditFile:'Editing', Glob:'Searching files',
  4724. Grep:'Searching code', VLCompile:'Compiling', VLParse:'Compiling', VLValidate:'Validating',
  4725. VLMetadata:'Analyzing metadata', VLSymbols:'Indexing symbols', VLImpact:'Analyzing impact',
  4726. VLAutoFix:'Auto-fixing', VLSyntaxRef:'Looking up syntax', VLCascadeEdit:'Cascade editing',
  4727. SubAgent:'Running agent', AutoTestPipeline:'Running tests', TodoWrite:'Planning',
  4728. AskUserQuestion:'Waiting for input', MetaDiff:'Diffing metadata', SectionDiff:'Diffing sections',
  4729. BrowserNavigate:'Navigating', BrowserClick:'Clicking', BrowserType:'Typing', BrowserScreenshot:'Taking screenshot',
  4730. };
  4731. function updateChatStatusBar(phase, detail) {
  4732. $('csPhase').textContent = phase || '';
  4733. $('csDetail').textContent = detail || '';
  4734. updateChatElapsed();
  4735. }
  4736. function toolToVerb(toolName, input) {
  4737. const verb = _toolVerbs[toolName] || (toolName + '...');
  4738. if (input && typeof input === 'object') {
  4739. if (input.file_path) return `${verb} ${input.file_path.split('/').pop()}`;
  4740. if (input.pattern) return `${verb} "${input.pattern}"`;
  4741. if (input.path) return `${verb} ${input.path.split('/').pop()}`;
  4742. }
  4743. if (input && typeof input === 'string' && input.length < 60) return `${verb} ${input}`;
  4744. return verb;
  4745. }
  4746. function _toolCallSummary(name, input) {
  4747. if (!input) return '';
  4748. const inp = typeof input === 'object' ? input : {};
  4749. switch (name) {
  4750. case 'Bash': return (inp.command || '').substring(0, 100);
  4751. case 'ReadFile': return inp.file_path || '';
  4752. case 'WriteFile': return inp.file_path || '';
  4753. case 'EditFile': return inp.file_path || '';
  4754. case 'Glob': return inp.pattern || '';
  4755. case 'Grep': return `"${inp.pattern || ''}" in ${inp.path || '.'}`;
  4756. case 'VLCompile': case 'VLParse': return 'project';
  4757. case 'VLMetadata': return inp.action || '';
  4758. case 'VLValidate': return inp.file_path || 'all';
  4759. default:
  4760. if (inp.file_path) return inp.file_path;
  4761. if (inp.command) return inp.command.substring(0, 80);
  4762. return '';
  4763. }
  4764. }
  4765. function updateChatElapsed() {
  4766. if (!_chatStartTime) return;
  4767. const sec = Math.round((Date.now() - _chatStartTime) / 1000);
  4768. $('csElapsed').textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m${sec%60}s`;
  4769. }
  4770. // ===================== PLAN MODE =====================
  4771. let _planModeActive = false;
  4772. function togglePlanMode() {
  4773. if (_planModeActive) {
  4774. cancelPlan();
  4775. } else {
  4776. enterPlanMode();
  4777. }
  4778. }
  4779. async function enterPlanMode() {
  4780. try {
  4781. await fetch('/api/plan/enter', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
  4782. _planModeActive = true;
  4783. $('planModeBar').style.display = 'flex';
  4784. $('planApproveBtn').style.display = 'none';
  4785. $('planModeToggle').classList.add('active');
  4786. $('chatInput').placeholder = 'Describe what you want to explore/plan...';
  4787. setStatus('Plan Mode (read-only)', 'yellow');
  4788. } catch (e) {
  4789. console.error('enterPlanMode error:', e);
  4790. }
  4791. }
  4792. async function approvePlan() {
  4793. // Send "approve" as a chat message to trigger plan implementation
  4794. $('chatInput').value = 'approve';
  4795. sendMessage();
  4796. }
  4797. async function cancelPlan() {
  4798. try {
  4799. await fetch('/api/plan/cancel', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ chatId: activeConvId }) });
  4800. } catch {}
  4801. _planModeActive = false;
  4802. $('planModeBar').style.display = 'none';
  4803. $('planModeToggle').classList.remove('active');
  4804. $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
  4805. setStatus('Ready', 'green');
  4806. }
  4807. function handlePlanModeEvent(data) {
  4808. if (data.phase === 'enter') {
  4809. _planModeActive = true;
  4810. $('planModeBar').style.display = 'flex';
  4811. $('planApproveBtn').style.display = 'none';
  4812. $('planModeToggle').classList.add('active');
  4813. setStatus('Plan Mode (exploring...)', 'yellow');
  4814. } else if (data.phase === 'ready') {
  4815. // Plan is ready for approval
  4816. $('planApproveBtn').style.display = '';
  4817. document.querySelector('.plan-mode-label').innerHTML = '&#128203; Plan ready — review above';
  4818. setStatus('Plan ready — Approve or Cancel', 'yellow');
  4819. } else if (data.phase === 'exit') {
  4820. _planModeActive = false;
  4821. $('planModeBar').style.display = 'none';
  4822. $('planModeToggle').classList.remove('active');
  4823. $('chatInput').placeholder = 'Describe changes, @mention files, /skill...';
  4824. document.querySelector('.plan-mode-label').innerHTML = '&#128270; Explore Mode (read-only)';
  4825. }
  4826. }
  4827. async function sendMessage() {
  4828. const input = $('chatInput');
  4829. const msg = input.value.trim();
  4830. if (!msg && !pendingImages.length) return;
  4831. input.value = '';
  4832. autoResizeChatInput(true);
  4833. $('chatSend').disabled = true;
  4834. $('chatSend').style.display = 'none';
  4835. $('chatStop').style.display = '';
  4836. _currentAbortController = new AbortController();
  4837. setChatStatusRunning(true);
  4838. setStatus('Thinking...', 'yellow');
  4839. $('mentionDropdown').classList.remove('open');
  4840. // Add turn separator in Detail Panel
  4841. const detailBody = $('detailBody');
  4842. if (detailBody) {
  4843. const sep = document.createElement('div');
  4844. sep.style.cssText = 'border-top:1px solid var(--border);margin:8px 0 4px;font-size:8px;color:var(--text2);padding-top:2px;';
  4845. sep.textContent = '— ' + new Date().toLocaleTimeString() + ' — new turn —';
  4846. detailBody.appendChild(sep);
  4847. }
  4848. clearStreamBoxes();
  4849. // Track turn boundary: record backend message count before this turn
  4850. const turnStartIdx = _lastBackendMsgCount;
  4851. // Show user message with image previews
  4852. const userMsgEl = addMsg('user', msg, pendingImages.map(i => i.preview));
  4853. userMsgEl.dataset.turnStart = turnStartIdx;
  4854. activeToolGroup = null;
  4855. // Build request body
  4856. const body = { message: msg, chatId: activeConvId };
  4857. if (pendingImages.length) {
  4858. body.images = pendingImages.map(i => ({ data: i.data, mediaType: i.mediaType }));
  4859. }
  4860. if (pendingMentions.length) {
  4861. body.mentions = [...pendingMentions];
  4862. }
  4863. // Clear attachments
  4864. pendingImages = [];
  4865. pendingMentions = [];
  4866. $('chatAttachments').innerHTML = '';
  4867. // Auto-attach context screenshots from previous test runs (sent to LLM)
  4868. if (_contextScreenshots.length) {
  4869. body.images = body.images || [];
  4870. for (const ss of _contextScreenshots) {
  4871. try {
  4872. const resp = await fetch(ss.url);
  4873. const blob = await resp.blob();
  4874. const base64 = await blobToBase64(blob);
  4875. body.images.push({ data: base64, mediaType: 'image/png' });
  4876. } catch {}
  4877. }
  4878. _contextScreenshots = [];
  4879. }
  4880. try {
  4881. const res = await fetch('/api/chat', {
  4882. method:'POST', headers:{'Content-Type':'application/json'},
  4883. body: JSON.stringify(body),
  4884. signal: _currentAbortController?.signal,
  4885. });
  4886. startSpinnerSafetyTimeout();
  4887. const reader = res.body.getReader();
  4888. const decoder = new TextDecoder();
  4889. let assistantEl = null;
  4890. let buffer = '';
  4891. let currentEvent = '';
  4892. let _lastSubAgentId = null;
  4893. let _chatCurrentTool = null;
  4894. while (true) {
  4895. const {done, value} = await reader.read();
  4896. if (done) break;
  4897. buffer += decoder.decode(value, {stream:true});
  4898. const lines = buffer.split('\n');
  4899. buffer = lines.pop();
  4900. for (const line of lines) {
  4901. if (line.startsWith('event: ')) {
  4902. currentEvent = line.slice(7);
  4903. continue;
  4904. }
  4905. if (line.startsWith('data: ')) {
  4906. try {
  4907. const data = JSON.parse(line.slice(6));
  4908. debugLog(currentEvent || 'data', data);
  4909. // Thinking indicator
  4910. if (currentEvent === 'thinking') {
  4911. if (data.phase === 'start') {
  4912. addThinkingIndicator();
  4913. updateChatStatusBar('Thinking deeply...', '');
  4914. setStatus('Thinking deeply...', 'yellow');
  4915. addDetailEntry('thinking', 'Extended thinking started...', null, 'info');
  4916. } else if (data.phase === 'delta' && data.text) {
  4917. appendToStreamBox('thinking_stream', 'Thinking', data.text);
  4918. appendThinkingText(data.text);
  4919. } else if (data.phase === 'end') {
  4920. finalizeThinking();
  4921. }
  4922. }
  4923. // Retry indicator
  4924. else if (currentEvent === 'retry') {
  4925. addRetryIndicator(data.attempt, data.delay, data.status);
  4926. const retryMsg = data.status === 'Overloaded'
  4927. ? `API overloaded, retrying (${data.attempt}/3) in ${Math.round(data.delay/1000)}s...`
  4928. : `Retrying (${data.attempt}/3)...`;
  4929. updateChatStatusBar(retryMsg, '');
  4930. setStatus(retryMsg, 'yellow');
  4931. addDetailEntry('retry', retryMsg, null, 'warn');
  4932. }
  4933. // Text token
  4934. else if (data.text) {
  4935. if (!assistantEl) {
  4936. assistantEl = addMsg('assistant', '');
  4937. assistantEl.querySelector('.content-text').dataset.raw = '';
  4938. addDetailEntry('response', 'LLM response streaming...', null, 'info');
  4939. }
  4940. appendToStreamBox('response_stream', 'Response', data.text);
  4941. const textEl = assistantEl.querySelector('.content-text');
  4942. textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
  4943. textEl.textContent += data.text;
  4944. updateChatStatusBar('Responding...', '');
  4945. scrollChat();
  4946. }
  4947. // Tool call - compact indicator
  4948. else if (data.name && data.input !== undefined) {
  4949. _chatCurrentTool = data.name;
  4950. const _linkId = 'tl_' + (++_detailLinkId);
  4951. addToolIndicator(data.name, data.input, 'running', data.detail, _linkId);
  4952. updateChatStatusBar(toolToVerb(data.name, data.input), '');
  4953. setStatus(`${data.name}...`, 'yellow');
  4954. // Detail Panel: full tool info (not sent to LLM — no need to truncate)
  4955. const inputStr = typeof data.input === 'string' ? data.input : JSON.stringify(data.input);
  4956. if (data.name === 'SubAgent') {
  4957. const agentLabel = inputStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
  4958. const agentId = 'agent_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5);
  4959. getOrCreateAgentGroup(agentId, agentLabel);
  4960. _lastSubAgentId = agentId;
  4961. } else {
  4962. const toolSummary = _toolCallSummary(data.name, data.input);
  4963. // Always pass full input as expandable data — no truncation
  4964. addDetailEntry('tool', `${data.name} ${toolSummary}`, inputStr, 'info', { depth: 0, linkId: _linkId });
  4965. }
  4966. }
  4967. // Tool result - update indicator
  4968. else if (data.name && data.preview !== undefined) {
  4969. updateToolIndicator(data.name, data.preview, data.diff);
  4970. updateChatStatusBar('Thinking...', data.name + ' done');
  4971. // Auto-activate preview when VLParse returns preview URLs
  4972. if (data.name === 'VLParse' && data.preview.includes('Preview URLs')) {
  4973. loadPreviewUrlsFromProfile();
  4974. }
  4975. // Detail Panel: full result — no truncation
  4976. const resultStr = data.detail || (typeof data.preview === 'string' ? data.preview : JSON.stringify(data.preview));
  4977. if (data.name === 'SubAgent' && _lastSubAgentId) {
  4978. completeAgentGroup(_lastSubAgentId, 'done');
  4979. addDetailEntry('result', resultStr, null, 'success', { depth: 1 });
  4980. } else {
  4981. // Use same linkId as the tool call (last assigned)
  4982. const resultLinkId = 'tl_' + _detailLinkId;
  4983. addDetailEntry('result', `${data.name}`, resultStr, 'success', { depth: 0, linkId: resultLinkId });
  4984. }
  4985. }
  4986. // AskUserQuestion widget
  4987. else if (currentEvent === 'ask_user') {
  4988. showAskUserWidget(data);
  4989. updateChatStatusBar('Waiting for your answer...', '');
  4990. setStatus('Waiting for your answer...', 'yellow');
  4991. }
  4992. // Plan Mode events
  4993. else if (currentEvent === 'plan_mode') {
  4994. handlePlanModeEvent(data);
  4995. }
  4996. // Todos
  4997. else if (data.todos) {
  4998. renderTodos(data.todos);
  4999. }
  5000. // Workflow events — show approval UI in chat + load into flow editor
  5001. else if (currentEvent === 'workflow_generated') {
  5002. addWorkflowApproval(data);
  5003. if (data.workflow) {
  5004. showModeIframe('workflow', '/workflow-editor.html', async () => {
  5005. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
  5006. });
  5007. }
  5008. }
  5009. else if (currentEvent === 'workflow_start') {
  5010. forwardWorkflowEventToIframe('workflow_start', data);
  5011. const wfName = data.name || '';
  5012. _lastWorkflowName = wfName;
  5013. _lastRunCheckpoint = null; // Reset for new run
  5014. // Clear previous step cards
  5015. for (const k in _stepCards) delete _stepCards[k];
  5016. if (wfName.startsWith('autotest')) switchFlowTab('autotest');
  5017. else if (wfName.includes('codegen') || wfName.includes('generate')) switchFlowTab('generate');
  5018. else switchFlowTab('adjust');
  5019. if (wfName) loadWorkflowIntoFlowTab(wfName);
  5020. const wfModel = data.model ? ` [${data.model}]` : '';
  5021. addDetailEntry('workflow', `► Workflow started: ${wfName}${wfModel} (${data.stepCount || '?'} steps)`, null, 'info');
  5022. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5023. assistantEl.querySelector('.content-text').innerHTML += `<div style="font-size:11px;color:var(--blue);padding:4px 0;font-weight:600;">Workflow: ${escapeHtml(wfName)} (${data.stepCount || '?'} steps)${wfModel ? ' — Model: ' + escapeHtml(data.model) : ''}</div>`;
  5024. scrollChat();
  5025. }
  5026. else if (currentEvent === 'node_start') {
  5027. forwardWorkflowEventToIframe('node_start', data);
  5028. updateWfProgressNode(data.nodeId, 'running');
  5029. const nodeLabel = data.title || data.nodeId || '?';
  5030. const nodeType = data.type || '';
  5031. const typeBadge = nodeType ? `[${nodeType}] ` : '';
  5032. // Use enhanced step card in detail log
  5033. addStepCard(data.nodeId, nodeType, nodeLabel, data.resolvedInputs || data.input);
  5034. updateChatStatusBar(`Running ${nodeLabel}...`, '');
  5035. // Show workflow step in chat for visibility
  5036. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5037. const stepLine = document.createElement('div');
  5038. stepLine.className = 'wf-chat-step';
  5039. stepLine.id = `wf-step-${data.nodeId}`;
  5040. stepLine.style.cssText = 'font-size:11px;color:var(--text2);padding:2px 0;';
  5041. stepLine.textContent = `▶ ${typeBadge}${nodeLabel}`;
  5042. assistantEl.querySelector('.content-text').appendChild(stepLine);
  5043. scrollChat();
  5044. }
  5045. else if (currentEvent === 'node_done') {
  5046. forwardWorkflowEventToIframe('node_done', data);
  5047. updateWfProgressNode(data.nodeId, 'done');
  5048. const doneLabel = data.title || data.nodeId || '?';
  5049. const duration = data.duration_ms ? ` (${data.duration_ms >= 1000 ? (data.duration_ms / 1000).toFixed(1) + 's' : data.duration_ms + 'ms'})` : '';
  5050. // Complete step card with outputs
  5051. completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
  5052. // Update chat step line
  5053. const chatStep = document.getElementById(`wf-step-${data.nodeId}`);
  5054. if (chatStep) { chatStep.style.color = 'var(--green)'; chatStep.textContent = `✓ ${doneLabel}${duration}`; }
  5055. }
  5056. else if (currentEvent === 'node_error') {
  5057. forwardWorkflowEventToIframe('node_error', data);
  5058. updateWfProgressNode(data.nodeId, 'error');
  5059. const errLabel = data.title || data.nodeId || '?';
  5060. const errDur = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(1)}s)` : '';
  5061. // Error step card
  5062. errorStepCard(data.nodeId, data.error || data.detail || 'Unknown error', data.duration_ms);
  5063. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5064. const errLine = document.createElement('div');
  5065. errLine.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
  5066. errLine.textContent = '✗ Error in ' + errLabel + errDur + ': ' + (data.error || 'unknown');
  5067. assistantEl.querySelector('.content-text').appendChild(errLine);
  5068. scrollChat();
  5069. }
  5070. else if (currentEvent === 'node_skipped') {
  5071. forwardWorkflowEventToIframe('node_skipped', data);
  5072. addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
  5073. }
  5074. // Workflow pause — show resume/cancel UI in chat
  5075. else if (currentEvent === 'pause') {
  5076. forwardWorkflowEventToIframe('pause', data);
  5077. updateWfProgressNode(data.nodeId, 'paused');
  5078. addPauseResumeUI(data.nodeId, data.title || data.reason || data.nodeId, data.runID || _currentRunID);
  5079. addDetailEntry('workflow', `⏸ Paused: ${data.title || data.nodeId}`, null, 'warn');
  5080. }
  5081. else if (currentEvent === 'resumed') {
  5082. forwardWorkflowEventToIframe('resumed', data);
  5083. updateWfProgressNode(data.nodeId, 'running');
  5084. addDetailEntry('workflow', `▶ Resumed: ${data.nodeId}`, null, 'info');
  5085. }
  5086. // ── Extended LLM communication events (workflow internal) ──
  5087. else if (currentEvent === 'llm_thinking') {
  5088. appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
  5089. }
  5090. else if (currentEvent === 'llm_tool_use') {
  5091. // Show full tool input as expandable JSON
  5092. const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  5093. addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInputStr, 'info', { depth: 1 });
  5094. updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
  5095. }
  5096. else if (currentEvent === 'llm_tool_result') {
  5097. const isErr = data.is_error || false;
  5098. const rc = data.content || '';
  5099. const rs = typeof rc === 'string' ? rc : JSON.stringify(rc);
  5100. // Always show result as expandable data (not just >120 chars)
  5101. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} ${data.name || 'Result'}${data.tool_use_id ? ' [' + data.tool_use_id.slice(-8) + ']' : ''}`, rs || null, isErr ? 'error' : 'success', { depth: 1 });
  5102. }
  5103. else if (currentEvent === 'tool_start') {
  5104. forwardWorkflowEventToIframe('tool_start', data);
  5105. const toolInputStr = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  5106. addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInputStr, 'info', { depth: 1 });
  5107. updateChatStatusBar(`Tool step: ${data.name || '?'}`, '');
  5108. }
  5109. else if (currentEvent === 'tool_done') {
  5110. forwardWorkflowEventToIframe('tool_done', data);
  5111. const toolOutputStr = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
  5112. addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutputStr, 'success', { depth: 1 });
  5113. }
  5114. else if (currentEvent === 'tool_error') {
  5115. forwardWorkflowEventToIframe('tool_error', data);
  5116. addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
  5117. }
  5118. else if (currentEvent === 'tool_message') {
  5119. forwardWorkflowEventToIframe('tool_message', data);
  5120. const toolDetailStr = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  5121. addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetailStr, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  5122. }
  5123. else if (currentEvent === 'llm_done') {
  5124. flushStreamBoxes();
  5125. const mdl = data.model || '';
  5126. const usg = data.usage || {};
  5127. const inTok = usg.input_tokens || usg.prompt_tokens || 0;
  5128. const outTok = usg.output_tokens || usg.completion_tokens || 0;
  5129. const cacheTok = usg.cache_read_input_tokens || 0;
  5130. const lat = data.latency_ms ? `${(data.latency_ms / 1000).toFixed(1)}s` : '';
  5131. const tokenParts = [];
  5132. if (inTok) tokenParts.push(`in:${inTok}`);
  5133. if (cacheTok) tokenParts.push(`cache:${cacheTok}`);
  5134. if (outTok) tokenParts.push(`out:${outTok}`);
  5135. const parts = [mdl, tokenParts.join(' '), lat].filter(Boolean).join(' | ');
  5136. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  5137. }
  5138. else if (currentEvent === 'llm_error') {
  5139. const errInfo = [data.error || 'Unknown'];
  5140. if (data.type) errInfo.push(`type:${data.type}`);
  5141. if (data.code) errInfo.push(`code:${data.code}`);
  5142. if (data.latency_ms) errInfo.push(`${(data.latency_ms / 1000).toFixed(1)}s`);
  5143. addDetailEntry('llm', `✗ LLM Error${data.retryable ? ' (retryable)' : ''}: ${errInfo.join(' | ')}`, data, 'error');
  5144. // Show LLM errors in chat too
  5145. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5146. const errDiv = document.createElement('div');
  5147. errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:2px 0;';
  5148. errDiv.textContent = `✗ LLM Error: ${data.error || 'Unknown'}`;
  5149. assistantEl.querySelector('.content-text').appendChild(errDiv);
  5150. scrollChat();
  5151. }
  5152. else if (currentEvent === 'var_changed') {
  5153. const vn = data.name || '?';
  5154. const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 120) : '—';
  5155. const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 120) : '—';
  5156. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
  5157. }
  5158. else if (currentEvent === 'file_start') {
  5159. addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
  5160. }
  5161. else if (currentEvent === 'file_written') {
  5162. const fp = data.path || '?';
  5163. addDetailEntry('file', `✓ Written: ${fp}`, null, 'success', { depth: 1 });
  5164. // Associate file with current running step card
  5165. const runningStep = getCurrentRunningStepID();
  5166. if (runningStep) addFileToStepCard(runningStep, fp);
  5167. // Trigger file tree refresh
  5168. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  5169. window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
  5170. _generatedFileCount++;
  5171. }
  5172. else if (currentEvent === 'checkpoint') {
  5173. // Store checkpoint for potential rerun
  5174. _lastRunCheckpoint = data.checkpoint || data;
  5175. addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} steps done)`, null, 'info', { depth: 1 });
  5176. }
  5177. // Screenshots — display inline in chat + add to LLM context
  5178. else if (currentEvent === 'screenshot' && data.screenshots?.length) {
  5179. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5180. for (const ssName of data.screenshots) {
  5181. const url = `/api/browser/screenshot/${ssName}`;
  5182. appendScreenshotToChat(assistantEl, url, ssName);
  5183. _contextScreenshots.push({ url, name: ssName });
  5184. }
  5185. }
  5186. // Done — finalize markdown rendering
  5187. else if (currentEvent === 'done') {
  5188. finalizeAssistantMsg(assistantEl);
  5189. activeToolGroup = null;
  5190. finalizeAllToolSpinners();
  5191. flushStreamBoxes();
  5192. clearSpinnerSafetyTimeout();
  5193. // Track turn end boundary for context exclusion
  5194. if (data.msgCount !== undefined) {
  5195. _lastBackendMsgCount = data.msgCount;
  5196. // Stamp turn boundaries on user message element
  5197. if (userMsgEl) {
  5198. userMsgEl.dataset.turnEnd = data.msgCount - 1;
  5199. }
  5200. }
  5201. // Auto-generate conversation title after first turn
  5202. autoTitleConversation(activeConvId, msg);
  5203. // Auto-compile if VL files were written during this turn
  5204. if (_generatedFileCount > 0) {
  5205. addMsg('assistant', `${_generatedFileCount} VL file(s) written — auto-compiling...`);
  5206. compileProject();
  5207. }
  5208. // Push DOM snapshot to server immediately so other windows can sync
  5209. pushChatStateToServer();
  5210. }
  5211. // Error
  5212. else if (data.message && currentEvent === 'error') {
  5213. if (!assistantEl) assistantEl = addMsg('assistant', '');
  5214. assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
  5215. addDetailEntry('error', data.message, null, 'error');
  5216. }
  5217. } catch {}
  5218. }
  5219. }
  5220. }
  5221. } catch(e) {
  5222. if (e.name === 'AbortError') {
  5223. addMsg('assistant', '⏹ Stopped by user.');
  5224. } else {
  5225. addMsg('assistant', 'Connection error: ' + e.message);
  5226. }
  5227. finalizeAllToolSpinners();
  5228. }
  5229. clearSpinnerSafetyTimeout();
  5230. _currentAbortController = null;
  5231. $('chatStop').style.display = 'none';
  5232. $('chatSend').style.display = '';
  5233. $('chatSend').disabled = false;
  5234. setChatStatusRunning(false);
  5235. setStatus('Ready', 'green');
  5236. updateContext();
  5237. }
  5238. function formatMsgTime(date) {
  5239. const now = new Date();
  5240. const d = date || now;
  5241. const hms = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
  5242. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  5243. const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  5244. const diffDays = Math.round((today - msgDay) / 86400000);
  5245. let prefix;
  5246. if (diffDays === 0) prefix = 'Today';
  5247. else if (diffDays === 1) prefix = 'Yesterday';
  5248. else prefix = `${d.getMonth() + 1}/${d.getDate()}`;
  5249. return `${prefix} ${hms}`;
  5250. }
  5251. function addMsg(role, text, imagePreviews, timestamp) {
  5252. const container = $('chatMessages');
  5253. const div = document.createElement('div');
  5254. div.className = 'msg ' + role;
  5255. div.style.position = 'relative';
  5256. const content = role === 'assistant' ? renderMarkdown(text) : escapeHtml(text);
  5257. const msgDate = timestamp ? new Date(timestamp) : new Date();
  5258. div.dataset.timestamp = msgDate.toISOString();
  5259. const timeStr = formatMsgTime(msgDate);
  5260. let html = `<div class="label">${role} <span class="msg-time">${timeStr}</span></div><span class="content-text">${content}</span>`;
  5261. // Context toggle button (excludes entire turn from LLM context)
  5262. html += `<button class="msg-ctx-toggle" onclick="toggleMsgContext(this)" title="Toggle: include/exclude this turn from LLM context">ctx</button>`;
  5263. // Show image thumbnails in user messages
  5264. if (imagePreviews?.length) {
  5265. html += '<div class="msg-images">';
  5266. for (const src of imagePreviews) html += `<img src="${src}" onclick="window.open(this.src)">`;
  5267. html += '</div>';
  5268. }
  5269. div.innerHTML = html;
  5270. container.appendChild(div);
  5271. scrollChat();
  5272. return div;
  5273. }
  5274. /** Finalize assistant message: re-render as markdown + add Apply buttons to code blocks */
  5275. function finalizeAssistantMsg(el) {
  5276. if (!el) return;
  5277. const textEl = el.querySelector('.content-text');
  5278. if (textEl) {
  5279. const raw = textEl.dataset.raw || textEl.textContent;
  5280. textEl.innerHTML = renderMarkdown(raw);
  5281. // Add Apply buttons to code blocks
  5282. textEl.querySelectorAll('pre').forEach(pre => {
  5283. const btn = document.createElement('button');
  5284. btn.className = 'code-apply';
  5285. btn.textContent = 'Apply';
  5286. btn.onclick = () => applyCodeBlock(pre);
  5287. pre.style.position = 'relative';
  5288. pre.appendChild(btn);
  5289. });
  5290. // All messages shown in full — no truncation
  5291. }
  5292. }
  5293. /** Apply a code block to the current file */
  5294. async function applyCodeBlock(preEl) {
  5295. if (!currentFile) { setStatus('No file open to apply to', 'red'); return; }
  5296. const code = preEl.querySelector('code')?.textContent || preEl.textContent;
  5297. const editorContent = cmEditor ? cmEditor.getValue() : $('editor').value;
  5298. // Show inline diff
  5299. showInlineDiff(currentFile, editorContent, code, () => {
  5300. // Accept: write to file
  5301. if (cmEditor) cmEditor.setValue(code);
  5302. else $('editor').value = code;
  5303. const info = openFiles.get(currentFile);
  5304. if (info && info.type === 'file') info.content = code;
  5305. else openFiles.set(currentFile, { type: 'file', content: code });
  5306. saveCurrentFile();
  5307. setStatus('Applied to ' + currentFile.split('/').pop(), 'green');
  5308. });
  5309. }
  5310. /** Show inline diff block in chat */
  5311. function showInlineDiff(filePath, oldText, newText, onAccept) {
  5312. const container = $('chatMessages');
  5313. const div = document.createElement('div');
  5314. div.className = 'diff-block';
  5315. const oldLines = oldText.split('\n');
  5316. const newLines = newText.split('\n');
  5317. let diffHtml = '';
  5318. // Simple line-by-line diff
  5319. const maxLen = Math.max(oldLines.length, newLines.length);
  5320. for (let i = 0; i < maxLen; i++) {
  5321. const ol = oldLines[i], nl = newLines[i];
  5322. if (ol === nl) {
  5323. if (ol !== undefined) diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(ol)}</div>`;
  5324. } else {
  5325. if (ol !== undefined) diffHtml += `<div class="diff-line diff-del">${escapeHtml(ol)}</div>`;
  5326. if (nl !== undefined) diffHtml += `<div class="diff-line diff-add">${escapeHtml(nl)}</div>`;
  5327. }
  5328. }
  5329. div.innerHTML = `
  5330. <div class="diff-header">
  5331. <span class="diff-file">${escapeHtml(filePath)}</span>
  5332. <div class="diff-actions">
  5333. <button class="diff-accept" id="diffAccept">Accept</button>
  5334. <button class="diff-reject" id="diffReject">Reject</button>
  5335. </div>
  5336. </div>
  5337. <div class="diff-body">${diffHtml}</div>`;
  5338. container.appendChild(div);
  5339. scrollChat();
  5340. div.querySelector('.diff-accept').onclick = () => { onAccept(); div.remove(); };
  5341. div.querySelector('.diff-reject').onclick = () => { div.remove(); setStatus('Changes rejected', 'yellow'); };
  5342. }
  5343. // Claude Code-style compact tool indicator
  5344. let _generatedFileCount = 0;
  5345. let _subAgentCount = 0;
  5346. // Tool name → icon mapping
  5347. const TOOL_ICONS = {
  5348. Read: '&#128196;', ReadFile: '&#128196;', Glob: '&#128269;', Grep: '&#128270;',
  5349. Edit: '&#9998;', EditFile: '&#9998;', Write: '&#128221;', WriteFile: '&#128221;',
  5350. Bash: '&#9654;', VLParse: '&#9881;', VLValidate: '&#10003;', VLImpact: '&#9889;',
  5351. VLMetadata: '&#128202;', VLSection: '&#9638;', SubAgent: '&#9881;', Agent: '&#9881;',
  5352. };
  5353. function getToolIcon(name) { return TOOL_ICONS[name] || '&#9654;'; }
  5354. function addToolIndicator(name, desc, status, detail, linkId) {
  5355. const container = $('chatMessages');
  5356. const descStr = typeof desc === 'string' ? desc : JSON.stringify(desc);
  5357. // For WriteFile: group all writes into a single summary
  5358. if (name === 'WriteFile') {
  5359. _generatedFileCount++;
  5360. let summary = container.querySelector('.tool-files-summary');
  5361. if (!summary) {
  5362. summary = document.createElement('div');
  5363. summary.className = 'tool-group tool-files-summary';
  5364. summary.innerHTML = `
  5365. <div class="tool-header" onclick="toggleToolBody(this)">
  5366. <div class="tool-spinner"></div>
  5367. <span class="tool-name">WriteFile</span>
  5368. <span class="tool-desc file-count-desc">Writing files... (${_generatedFileCount})</span>
  5369. <span class="tool-toggle">&#9654;</span>
  5370. </div>
  5371. <div class="tool-body file-list-body" style="display:block;padding:4px 8px;"></div>`;
  5372. container.appendChild(summary);
  5373. }
  5374. summary.querySelector('.file-count-desc').textContent = `Writing files... (${_generatedFileCount})`;
  5375. const entry = document.createElement('div');
  5376. entry.style.cssText = 'color:var(--green);font-size:10px;padding:1px 0;';
  5377. entry.textContent = `+ ${descStr}`;
  5378. summary.querySelector('.file-list-body').appendChild(entry);
  5379. activeToolGroup = summary;
  5380. scrollChat();
  5381. return;
  5382. }
  5383. // For SubAgent: group into a progress list showing parallel status
  5384. if (name === 'SubAgent') {
  5385. _subAgentCount++;
  5386. let agentSummary = container.querySelector('.tool-agents-summary');
  5387. if (!agentSummary) {
  5388. agentSummary = document.createElement('div');
  5389. agentSummary.className = 'tool-group tool-agents-summary';
  5390. agentSummary.innerHTML = `
  5391. <div class="tool-header" onclick="toggleToolBody(this)">
  5392. <div class="tool-spinner"></div>
  5393. <span class="tool-name">Parallel Agents</span>
  5394. <span class="tool-desc agent-count-desc">Running ${_subAgentCount} agent(s) in parallel...</span>
  5395. <span class="tool-toggle">&#9654;</span>
  5396. </div>
  5397. <div class="tool-body agent-list-body" style="display:block;padding:4px 8px;"></div>`;
  5398. container.appendChild(agentSummary);
  5399. }
  5400. const runningCount = agentSummary.querySelectorAll('.agent-step:not([data-done])').length + 1;
  5401. agentSummary.querySelector('.agent-count-desc').textContent = `Running ${runningCount} agent(s) in parallel...`;
  5402. const entry = document.createElement('div');
  5403. entry.className = 'agent-step';
  5404. entry.dataset.idx = _subAgentCount;
  5405. entry.dataset.startTime = Date.now();
  5406. // Extract meaningful label from prompt
  5407. const label = descStr.replace(/^["{}]*(prompt|explore|general)["{}:\s]*/i, '').trim();
  5408. entry.innerHTML = `<span class="agent-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--yellow);margin-right:6px;animation:pulse 1s ease-in-out infinite;"></span><span style="font-size:10px;">${escapeHtml(label)}...</span><span class="agent-timer" style="margin-left:auto;font-size:9px;color:var(--text2);"></span>`;
  5409. agentSummary.querySelector('.agent-list-body').appendChild(entry);
  5410. activeToolGroup = agentSummary;
  5411. scrollChat();
  5412. // Update timer
  5413. const timerId = setInterval(() => {
  5414. if (entry.dataset.done) { clearInterval(timerId); return; }
  5415. const elapsed = ((Date.now() - parseInt(entry.dataset.startTime)) / 1000).toFixed(0);
  5416. const timer = entry.querySelector('.agent-timer');
  5417. if (timer) timer.textContent = elapsed + 's';
  5418. }, 1000);
  5419. return;
  5420. }
  5421. const group = document.createElement('div');
  5422. group.className = 'tool-group';
  5423. group.dataset.toolName = name;
  5424. group.dataset.startTime = Date.now();
  5425. if (linkId) {
  5426. group.dataset.linkId = linkId;
  5427. // Click tool in chat → open detail panel & highlight corresponding entry
  5428. group.addEventListener('click', () => scrollToDetailEntry(linkId));
  5429. }
  5430. // Build detail body based on structured detail from server
  5431. let detailHtml = '';
  5432. if (detail) {
  5433. if (detail.type === 'edit' && detail.preview?.length) {
  5434. detailHtml = detail.preview.map(e =>
  5435. `<div class="tool-diff"><div class="td-old">- ${escapeHtml(e.old)}</div><div class="td-new">+ ${escapeHtml(e.new)}</div></div>`
  5436. ).join('') + (detail.editCount > 2 ? `<div class="tool-detail"><span class="td-label">total:</span> <span class="td-val">${detail.editCount} edits</span></div>` : '');
  5437. } else if (detail.type === 'grep') {
  5438. detailHtml = `<div class="tool-detail"><span class="td-label">pattern:</span> <span class="td-val">${escapeHtml(detail.pattern)}</span></div>` +
  5439. `<div class="tool-detail"><span class="td-label">path:</span> <span class="td-val">${escapeHtml(detail.path)}</span></div>`;
  5440. } else if (detail.type === 'vlparse') {
  5441. detailHtml = `<div class="tool-detail"><span class="td-label">action:</span> <span class="td-val">${escapeHtml(detail.action || 'parse')}</span></div>` +
  5442. `<div class="tool-detail"><span class="td-label">cookie:</span> <span class="td-val">${detail.cookie}</span></div>`;
  5443. } else if (detail.type === 'write') {
  5444. detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines}</span></div>`;
  5445. } else if (detail.type === 'vledit') {
  5446. detailHtml = `<div class="tool-detail"><span class="td-label">edits:</span> <span class="td-val">${detail.editCount} change(s)</span></div>`;
  5447. } else if (detail.type === 'read') {
  5448. detailHtml = `<div class="tool-detail"><span class="td-label">lines:</span> <span class="td-val">${detail.lines || '?'}</span></div>`;
  5449. } else if (detail.type === 'other' && detail.raw) {
  5450. detailHtml = `<div class="tool-detail" style="opacity:0.6">${escapeHtml(detail.raw)}</div>`;
  5451. }
  5452. }
  5453. const icon = getToolIcon(name);
  5454. group.innerHTML = `
  5455. <div class="tool-header" onclick="toggleToolBody(this)">
  5456. <div class="tool-spinner"></div>
  5457. <span class="tool-icon">${icon}</span>
  5458. <span class="tool-name">${escapeHtml(name)}</span>
  5459. <span class="tool-desc">${escapeHtml(descStr)}</span>
  5460. <span class="tool-time"></span>
  5461. <span class="tool-toggle">&#9654;</span>
  5462. </div>
  5463. <div class="tool-body${detailHtml ? ' open' : ''}">${detailHtml}</div>`;
  5464. // Auto-open toggle if detail present
  5465. if (detailHtml) {
  5466. const toggle = group.querySelector('.tool-toggle');
  5467. if (toggle) toggle.classList.add('open');
  5468. }
  5469. // Start elapsed timer
  5470. const _timerEl = group.querySelector('.tool-time');
  5471. const _timerStart = Date.now();
  5472. group._timerId = setInterval(() => {
  5473. const elapsed = ((Date.now() - _timerStart) / 1000).toFixed(1);
  5474. if (_timerEl) _timerEl.textContent = elapsed + 's';
  5475. }, 200);
  5476. container.appendChild(group);
  5477. activeToolGroup = group;
  5478. scrollChat();
  5479. }
  5480. function updateToolIndicator(name, result, diffData) {
  5481. // For WriteFile: update the summary group's count label
  5482. if (name === 'WriteFile') {
  5483. const summary = document.querySelector('.tool-files-summary');
  5484. if (summary) {
  5485. summary.querySelector('.file-count-desc').textContent = `${_generatedFileCount} files written`;
  5486. }
  5487. return;
  5488. }
  5489. // For SubAgent: mark the latest unfinished step as done
  5490. if (name === 'SubAgent') {
  5491. const agentSummary = document.querySelector('.tool-agents-summary');
  5492. if (agentSummary) {
  5493. const steps = agentSummary.querySelectorAll('.agent-step');
  5494. // Find first step that hasn't been marked done
  5495. for (let i = 0; i < steps.length; i++) {
  5496. if (!steps[i].dataset.done) {
  5497. steps[i].dataset.done = '1';
  5498. const dot = steps[i].querySelector('.agent-dot');
  5499. if (dot) { dot.style.background = 'var(--green)'; dot.style.animation = 'none'; }
  5500. // Show elapsed time
  5501. const startTime = parseInt(steps[i].dataset.startTime || 0);
  5502. if (startTime) {
  5503. const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  5504. const timer = steps[i].querySelector('.agent-timer');
  5505. if (timer) timer.textContent = elapsed + 's';
  5506. }
  5507. break;
  5508. }
  5509. }
  5510. // Update header with counts
  5511. const doneCount = agentSummary.querySelectorAll('.agent-step[data-done]').length;
  5512. const totalCount = steps.length;
  5513. const runningCount = totalCount - doneCount;
  5514. if (runningCount > 0) {
  5515. agentSummary.querySelector('.agent-count-desc').textContent = `${runningCount} running, ${doneCount}/${totalCount} done`;
  5516. } else {
  5517. agentSummary.querySelector('.agent-count-desc').textContent = `All ${totalCount} agents done`;
  5518. // Finalize spinner
  5519. const spinner = agentSummary.querySelector('.tool-spinner');
  5520. if (spinner) {
  5521. const parent = spinner.parentNode;
  5522. spinner.remove();
  5523. const icon = document.createElement('div');
  5524. icon.className = 'tool-icon';
  5525. icon.style.color = 'var(--green)';
  5526. icon.textContent = '\u2713';
  5527. parent.prepend(icon);
  5528. }
  5529. }
  5530. }
  5531. return;
  5532. }
  5533. // Find the most recent tool group matching name
  5534. const groups = document.querySelectorAll('.tool-group');
  5535. let target = null;
  5536. for (let i = groups.length - 1; i >= 0; i--) {
  5537. if (groups[i].dataset.toolName === name) {
  5538. target = groups[i]; break;
  5539. }
  5540. }
  5541. if (!target) return;
  5542. // Stop timer
  5543. if (target._timerId) { clearInterval(target._timerId); target._timerId = null; }
  5544. // Replace spinner with check/x icon
  5545. const spinner = target.querySelector('.tool-spinner');
  5546. if (spinner) {
  5547. spinner.remove();
  5548. const doneIcon = document.createElement('span');
  5549. doneIcon.className = 'tool-status-icon done';
  5550. doneIcon.innerHTML = '&#10003;';
  5551. target.querySelector('.tool-header').prepend(doneIcon);
  5552. }
  5553. // Add result badge to header desc
  5554. const desc = target.querySelector('.tool-desc');
  5555. if (desc && result) {
  5556. const shortResult = result.length > 60 ? result.substring(0, 60) + '...' : result;
  5557. if (name === 'ReadFile') {
  5558. desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">${escapeHtml(result)}</span>`;
  5559. } else if (name === 'VLValidate' && (result.includes('valid') || result.includes('pass'))) {
  5560. desc.innerHTML = escapeHtml(desc.textContent) + `<span class="tool-result-badge ok">passed</span>`;
  5561. }
  5562. }
  5563. // Set body content (preserve existing detail if result is short)
  5564. const body = target.querySelector('.tool-body');
  5565. // EditFile: show inline diff + undo button
  5566. if (diffData && diffData.diff) {
  5567. body.innerHTML = '';
  5568. const diffEl = document.createElement('div');
  5569. diffEl.className = 'edit-diff-preview';
  5570. const lines = diffData.diff.split('\n');
  5571. let diffHtml = '';
  5572. for (const line of lines) {
  5573. if (line.startsWith('+')) diffHtml += `<div class="diff-line diff-add">${escapeHtml(line)}</div>`;
  5574. else if (line.startsWith('-')) diffHtml += `<div class="diff-line diff-del">${escapeHtml(line)}</div>`;
  5575. else diffHtml += `<div class="diff-line diff-ctx">${escapeHtml(line)}</div>`;
  5576. }
  5577. diffEl.innerHTML = diffHtml;
  5578. body.appendChild(diffEl);
  5579. // Undo button
  5580. if (diffData.undoId) {
  5581. const undoBtn = document.createElement('button');
  5582. undoBtn.className = 'plan-cancel-btn';
  5583. undoBtn.style.cssText = 'margin-top:4px; font-size:10px; padding:2px 8px;';
  5584. undoBtn.textContent = 'Undo';
  5585. undoBtn.onclick = async () => {
  5586. try {
  5587. const r = await fetch(`/api/undo/${diffData.undoId}`, { method: 'POST' });
  5588. const d = await r.json();
  5589. if (d.ok) { undoBtn.textContent = 'Undone'; undoBtn.disabled = true; setStatus(`Undid edit to ${d.file}`, 'green'); }
  5590. else { setStatus(d.error || 'Undo failed', 'red'); }
  5591. } catch (e) { setStatus('Undo error: ' + e.message, 'red'); }
  5592. };
  5593. body.appendChild(undoBtn);
  5594. }
  5595. body.classList.add('open');
  5596. target.querySelector('.tool-toggle')?.classList.add('open');
  5597. } else if (result && result.length > 30) {
  5598. body.textContent = result;
  5599. } else if (result) {
  5600. const badge = document.createElement('div');
  5601. badge.className = 'tool-detail';
  5602. badge.innerHTML = `<span class="td-val" style="color:var(--green)">${escapeHtml(result)}</span>`;
  5603. body.appendChild(badge);
  5604. }
  5605. }
  5606. /** Finalize ALL tool spinners — called when chat response completes */
  5607. function finalizeAllToolSpinners() {
  5608. // Finalize WriteFile summary
  5609. const wfSummary = document.querySelector('.tool-files-summary');
  5610. if (wfSummary) {
  5611. const desc = wfSummary.querySelector('.file-count-desc');
  5612. if (desc) desc.textContent = `${_generatedFileCount} files written`;
  5613. }
  5614. _generatedFileCount = 0;
  5615. // Finalize SubAgent summary
  5616. const agentSummary = document.querySelector('.tool-agents-summary');
  5617. if (agentSummary) {
  5618. const desc = agentSummary.querySelector('.agent-count-desc');
  5619. if (desc) desc.textContent = `${_subAgentCount} agents completed`;
  5620. // Mark all steps as done
  5621. agentSummary.querySelectorAll('.agent-step:not([data-done])').forEach(step => {
  5622. step.dataset.done = '1';
  5623. const marker = step.querySelector('span');
  5624. if (marker) { marker.textContent = '\u25CF'; marker.style.color = 'var(--green)'; }
  5625. });
  5626. }
  5627. _subAgentCount = 0;
  5628. // Finalize ALL remaining spinning tool groups — stop timers, replace spinners
  5629. document.querySelectorAll('.tool-group').forEach(group => {
  5630. if (group._timerId) { clearInterval(group._timerId); group._timerId = null; }
  5631. });
  5632. document.querySelectorAll('.tool-group .tool-spinner').forEach(spinner => {
  5633. const header = spinner.closest('.tool-header');
  5634. spinner.remove();
  5635. const icon = document.createElement('span');
  5636. icon.className = 'tool-status-icon done';
  5637. icon.innerHTML = '&#10003;';
  5638. header?.prepend(icon);
  5639. });
  5640. }
  5641. // Spinner safety timeout — auto-finalize if stream hangs and fully reset UI
  5642. let _spinnerSafetyTimer = null;
  5643. function startSpinnerSafetyTimeout() {
  5644. clearSpinnerSafetyTimeout();
  5645. _spinnerSafetyTimer = setTimeout(() => {
  5646. console.warn('[VL-Code] Spinner safety timeout (120s) — force-finalizing and resetting UI');
  5647. finalizeAllToolSpinners();
  5648. flushStreamBoxes();
  5649. // Full UI reset so the user is never permanently stuck
  5650. _currentAbortController = null;
  5651. try { $('chatStop').style.display = 'none'; } catch {}
  5652. try { $('chatSend').style.display = ''; $('chatSend').disabled = false; } catch {}
  5653. setChatStatusRunning(false);
  5654. setStatus('Timed out — Ready', 'yellow');
  5655. setTimeout(() => setStatus('Ready', 'green'), 3000);
  5656. _spinnerSafetyTimer = null;
  5657. }, 120000); // 2 minutes
  5658. }
  5659. function clearSpinnerSafetyTimeout() {
  5660. if (_spinnerSafetyTimer) { clearTimeout(_spinnerSafetyTimer); _spinnerSafetyTimer = null; }
  5661. }
  5662. function toggleToolBody(header) {
  5663. const body = header.nextElementSibling;
  5664. const toggle = header.querySelector('.tool-toggle');
  5665. body.classList.toggle('open');
  5666. toggle.classList.toggle('open');
  5667. }
  5668. function renderTodos(todos) {
  5669. // Remove existing
  5670. document.querySelectorAll('.msg.todo-list').forEach(e => e.remove());
  5671. if (!todos || todos.length === 0) return;
  5672. const div = document.createElement('div');
  5673. div.className = 'msg todo-list';
  5674. let html = '<div class="label">Tasks</div>';
  5675. for (const t of todos) {
  5676. const cls = t.status === 'completed' ? 'todo-done' : t.status === 'in_progress' ? 'todo-active' : 'todo-pending';
  5677. const icon = t.status === 'completed' ? '\u2713' : t.status === 'in_progress' ? '\u25B6' : '\u25CB';
  5678. const text = t.status === 'in_progress' ? (t.activeForm || t.content) : t.content;
  5679. // Timing info
  5680. let timing = '';
  5681. if (t.status === 'completed' && t.startedAt && t.completedAt) {
  5682. const secs = ((t.completedAt - t.startedAt) / 1000).toFixed(1);
  5683. timing = `<span class="todo-timing">${secs}s</span>`;
  5684. } else if (t.status === 'in_progress' && t.startedAt) {
  5685. timing = `<span class="todo-timing todo-elapsed" data-start="${t.startedAt}">0s</span>`;
  5686. }
  5687. const spinnerHtml = t.status === 'in_progress' ? '<span class="todo-spinner"></span>' : '';
  5688. html += `<div class="todo-item ${cls}">${spinnerHtml}<span class="todo-icon">${icon}</span><span class="todo-text">${escapeHtml(text)}</span>${timing}</div>`;
  5689. // Render subtasks
  5690. if (t.subtasks?.length) {
  5691. for (const st of t.subtasks) {
  5692. const stCls = st.status === 'completed' ? 'todo-done' : st.status === 'in_progress' ? 'todo-active' : 'todo-pending';
  5693. const stIcon = st.status === 'completed' ? '\u2713' : st.status === 'in_progress' ? '\u25B6' : '\u25CB';
  5694. html += `<div class="todo-item todo-subtask ${stCls}"><span class="todo-icon">${stIcon}</span>${escapeHtml(st.content)}</div>`;
  5695. }
  5696. }
  5697. }
  5698. div.innerHTML = html;
  5699. $('chatMessages').appendChild(div);
  5700. scrollChat();
  5701. // Start elapsed time timers
  5702. div.querySelectorAll('.todo-elapsed').forEach(el => {
  5703. const start = parseInt(el.dataset.start);
  5704. if (!start) return;
  5705. el._tid = setInterval(() => {
  5706. el.textContent = ((Date.now() - start) / 1000).toFixed(0) + 's';
  5707. }, 1000);
  5708. });
  5709. }
  5710. let _chatUserScrolled = false;
  5711. function scrollChat() {
  5712. const el = $('chatMessages');
  5713. // Only auto-scroll if user hasn't manually scrolled up
  5714. if (!_chatUserScrolled) {
  5715. el.scrollTop = el.scrollHeight;
  5716. }
  5717. }
  5718. // Detect manual scroll: if user scrolls up, stop auto-scroll; if near bottom, resume
  5719. (function() {
  5720. let _scrollTimer;
  5721. document.addEventListener('DOMContentLoaded', () => {
  5722. const el = $('chatMessages');
  5723. if (!el) return;
  5724. el.addEventListener('scroll', () => {
  5725. clearTimeout(_scrollTimer);
  5726. _scrollTimer = setTimeout(() => {
  5727. const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
  5728. _chatUserScrolled = !atBottom;
  5729. }, 50);
  5730. });
  5731. });
  5732. })();
  5733. // chatInput keydown is handled in @-mention section above
  5734. // ===================== GENERATE =====================
  5735. function openGenerate() { $('genModal').classList.add('open'); }
  5736. function closeGenerate() { $('genModal').classList.remove('open'); }
  5737. /** Focus chat input with generate hint (new approach — no modal) */
  5738. function focusGenerate() {
  5739. const input = $('chatInput');
  5740. const wfName = workflowBindings.generate || '3-file-codegen';
  5741. input.value = `Generate a VL project (workflow: ${wfName}): `;
  5742. input.focus();
  5743. input.setSelectionRange(input.value.length, input.value.length);
  5744. }
  5745. async function startGenerate() {
  5746. const req = $('genInput').value.trim();
  5747. if (!req) return;
  5748. $('genStart').disabled = true;
  5749. $('genProgress').style.display = 'block';
  5750. $('genProgress').innerHTML = '';
  5751. setStatus('Generating...', 'yellow');
  5752. try {
  5753. const res = await fetch('/api/generate', {
  5754. method:'POST', headers:{'Content-Type':'application/json'},
  5755. body: JSON.stringify({ userRequest: req, targetLang: 'en' })
  5756. });
  5757. const reader = res.body.getReader();
  5758. const decoder = new TextDecoder();
  5759. let buffer = '';
  5760. while (true) {
  5761. const {done, value} = await reader.read();
  5762. if (done) break;
  5763. buffer += decoder.decode(value, {stream:true});
  5764. const lines = buffer.split('\n');
  5765. buffer = lines.pop();
  5766. for (const line of lines) {
  5767. if (line.startsWith('data: ')) {
  5768. try {
  5769. const data = JSON.parse(line.slice(6));
  5770. if (data.title) addGenStep('step', data.title);
  5771. if (data.path) addGenStep('file', data.path);
  5772. if (data.filesWritten) { addGenStep('done', `${data.filesWritten.length} files generated`); await loadFileTree(); }
  5773. if (data.message) addGenStep('error', data.message);
  5774. } catch {}
  5775. }
  5776. }
  5777. }
  5778. } catch(e) { addGenStep('error', e.message); }
  5779. $('genStart').disabled = false;
  5780. setStatus('Ready', 'green');
  5781. }
  5782. function addGenStep(type, text) {
  5783. const div = document.createElement('div');
  5784. div.className = 'gen-step';
  5785. const icon = {error:'\u2717', done:'\u2713', file:'\u25A0', step:'\u25B8'}[type] || '\u25B8';
  5786. const color = type === 'error' ? 'var(--red)' : type === 'done' ? 'var(--green)' : 'var(--text2)';
  5787. div.innerHTML = `<span style="color:${color}">${icon}</span> ${escapeHtml(text)}`;
  5788. $('genProgress').appendChild(div);
  5789. }
  5790. /** Run generation via workflow execution with progress visualization in chat */
  5791. async function runGenerateWorkflow() {
  5792. const desc = $('genQuickInput')?.value?.trim();
  5793. if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
  5794. $('genQuickInput')?.closest('.wf-progress')?.remove();
  5795. addMsg('user', desc);
  5796. const wfName = workflowBindings.generate || '3-file-codegen';
  5797. setStatus(`Running workflow: ${wfName}...`, 'yellow');
  5798. addMsg('assistant', `**Running workflow** \`${wfName}\` for: "${desc}"`);
  5799. const progressWidget = addWorkflowProgress(wfName, []);
  5800. try {
  5801. const res = await fetch('/api/workflow/execute', {
  5802. method: 'POST',
  5803. headers: { 'Content-Type': 'application/json' },
  5804. body: JSON.stringify({ workflowName: wfName, params: { userRequest: desc, description: desc } }),
  5805. });
  5806. const reader = res.body.getReader();
  5807. const decoder = new TextDecoder();
  5808. let buffer = '';
  5809. while (true) {
  5810. const { done, value } = await reader.read();
  5811. if (done) break;
  5812. buffer += decoder.decode(value, { stream: true });
  5813. const blocks = buffer.split('\n\n');
  5814. buffer = blocks.pop();
  5815. for (const block of blocks) {
  5816. let eType = 'message', eData = null;
  5817. for (const line of block.split('\n')) {
  5818. if (line.startsWith('event: ')) eType = line.slice(7).trim();
  5819. else if (line.startsWith('data: ')) { try { eData = JSON.parse(line.slice(6)); } catch {} }
  5820. }
  5821. if (!eData) continue;
  5822. switch (eType) {
  5823. case 'workflow_start':
  5824. if (eData.name) loadWorkflowIntoFlowTab(eData.name);
  5825. addDetailEntry('workflow', `Workflow started: ${eData.name || ''}`, null, 'info');
  5826. break;
  5827. case 'node_start':
  5828. updateWfProgressNode(eData.nodeId, 'running');
  5829. addDetailEntry('workflow', `▶ ${eData.title || eData.nodeId}`, null, 'info');
  5830. break;
  5831. case 'node_done':
  5832. updateWfProgressNode(eData.nodeId, 'done');
  5833. addDetailEntry('workflow', `✓ ${eData.title || eData.nodeId}`, null, 'success');
  5834. break;
  5835. case 'node_error':
  5836. updateWfProgressNode(eData.nodeId, 'error');
  5837. addDetailEntry('workflow', `✗ ${eData.title || eData.nodeId} — ${eData.error || ''}`, null, 'error');
  5838. break;
  5839. case 'node_skipped': updateWfProgressNode(eData.nodeId, 'skipped'); break;
  5840. // ── Extended LLM communication events ──
  5841. case 'llm_thinking':
  5842. appendToStreamBox(`wf-thinking-${eData.stepId || 'main'}`, '💭 Thinking', eData.delta || '');
  5843. break;
  5844. case 'token':
  5845. appendToStreamBox(`wf-response-${eData.stepId || 'main'}`, '💬 Response', eData.token || '');
  5846. break;
  5847. case 'llm_tool_use':
  5848. addDetailEntry('tool-call', `🔧 ${eData.name || 'unknown'}`, eData.input || null, 'info', { depth: 1 });
  5849. updateChatStatusBar(`Tool: ${eData.name || '?'}`, '');
  5850. break;
  5851. case 'llm_tool_result': {
  5852. const isErr = eData.is_error || false;
  5853. const resultContent = eData.content || '';
  5854. const resultStr = typeof resultContent === 'string' ? resultContent : JSON.stringify(resultContent);
  5855. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} Result${eData.tool_use_id ? ' [' + eData.tool_use_id.slice(-8) + ']' : ''}`, resultStr.length > 120 ? resultStr : null, isErr ? 'error' : 'success', { depth: 1 });
  5856. break;
  5857. }
  5858. case 'llm_done': {
  5859. flushStreamBoxes();
  5860. const mdl = eData.model || '';
  5861. const usg = eData.usage || {};
  5862. const lat = eData.latency_ms ? `${(eData.latency_ms / 1000).toFixed(1)}s` : '';
  5863. const parts = [mdl, usg.input_tokens ? `in:${usg.input_tokens}` : '', usg.output_tokens ? `out:${usg.output_tokens}` : '', lat].filter(Boolean).join(' | ');
  5864. addDetailEntry('llm', `✓ LLM complete — ${parts}`, null, 'success');
  5865. break;
  5866. }
  5867. case 'llm_error':
  5868. addDetailEntry('llm', `✗ LLM Error${eData.retryable ? ' (retryable)' : ''}: ${eData.error || 'Unknown'}`, eData, 'error');
  5869. break;
  5870. case 'var_changed': {
  5871. const vName = eData.name || '?';
  5872. const vOld = eData.oldValue != null ? JSON.stringify(eData.oldValue).slice(0, 80) : '—';
  5873. const vNew = eData.newValue != null ? JSON.stringify(eData.newValue).slice(0, 80) : '—';
  5874. addDetailEntry('var', `📊 ${vName}: ${vOld} → ${vNew}`, eData, 'info', { depth: 1 });
  5875. break;
  5876. }
  5877. case 'file_start':
  5878. addDetailEntry('file', `📄 Writing: ${eData.path || '?'}`, null, 'info', { depth: 1 });
  5879. break;
  5880. case 'pause':
  5881. updateWfProgressNode(eData.nodeId, 'paused');
  5882. addPauseResumeUI(eData.nodeId, eData.title || eData.reason, eData.runID || _currentRunID);
  5883. addDetailEntry('workflow', `⏸ Paused: ${eData.title || eData.nodeId}`, null, 'warn');
  5884. break;
  5885. case 'resumed':
  5886. updateWfProgressNode(eData.nodeId, 'running');
  5887. addDetailEntry('workflow', `▶ Resumed: ${eData.nodeId}`, null, 'info');
  5888. break;
  5889. case 'pause_timeout':
  5890. addDetailEntry('pause', `⏰ Pause timed out → ${eData.timeoutAction || ''}`, eData, 'warn');
  5891. break;
  5892. case 'pause_rejected':
  5893. addDetailEntry('pause', `✗ Resume rejected: ${eData.reason || ''}`, eData, 'error');
  5894. break;
  5895. case 'file_written':
  5896. { const fp = eData.path || '?'; const fn = fp.split('/').pop(); addDetailEntry('file', `✓ Written: ${fn} (${fp})`, null, 'success', { depth: 1 }); }
  5897. break;
  5898. case 'done':
  5899. addMsg('assistant', '**Workflow completed.** ' + (eData.filesWritten?.length || 0) + ' files written.');
  5900. addDetailEntry('workflow', 'Workflow completed', null, 'success');
  5901. await loadFileTree();
  5902. await loadProjectInfo();
  5903. setStatus('Generation complete', 'green');
  5904. break;
  5905. case 'error':
  5906. addMsg('assistant', '**Workflow error:** ' + (eData.message || 'Unknown error'));
  5907. addDetailEntry('workflow', eData.message || 'Workflow error', null, 'error');
  5908. setStatus('Workflow error', 'red');
  5909. break;
  5910. }
  5911. }
  5912. }
  5913. } catch (e) {
  5914. addMsg('assistant', '**Workflow failed:** ' + e.message);
  5915. setStatus('Workflow failed', 'red');
  5916. }
  5917. }
  5918. /** Send generate request as normal chat message */
  5919. function sendGenerateAsChat() {
  5920. const desc = $('genQuickInput')?.value?.trim();
  5921. if (!desc) { setStatus('Please describe what to generate', 'yellow'); return; }
  5922. $('genQuickInput')?.closest('.wf-progress')?.remove();
  5923. const wfName = workflowBindings.generate || '3-file-codegen';
  5924. $('chatInput').value = `Generate a VL project (workflow: ${wfName}): ${desc}`;
  5925. sendMessage();
  5926. }
  5927. // ===================== WORKSPACE STATE =====================
  5928. /** Save full workspace state to .vl-code/workspace.json */
  5929. async function saveWorkspaceState() {
  5930. try {
  5931. const state = {
  5932. savedAt: Date.now(),
  5933. mode: currentMode || 'code',
  5934. activeFile: currentFile || null,
  5935. openFilePaths: [...openFiles.keys()].filter(k => openFiles.get(k)?.type === 'file'),
  5936. debugPanelOpen: $('debugPanel')?.style.display !== 'none',
  5937. chatCollapsed: $('chatPanel')?.classList.contains('collapsed') || false,
  5938. chatWidth: parseInt(localStorage.getItem('vl-chat-width')) || null,
  5939. showInternalFiles,
  5940. wfBindings: (() => { try { return JSON.parse(localStorage.getItem('vl-code-wf-bindings')); } catch { return null; } })(),
  5941. // Chat state is managed by /api/chat/state, not here
  5942. };
  5943. await fetch('/api/workspace/state', {
  5944. method: 'POST',
  5945. headers: { 'Content-Type': 'application/json' },
  5946. body: JSON.stringify(state),
  5947. });
  5948. } catch {}
  5949. }
  5950. /** Restore workspace state from .vl-code/workspace.json */
  5951. async function restoreWorkspaceState() {
  5952. try {
  5953. const state = await api('/api/workspace/state');
  5954. if (!state || !state.savedAt) return false;
  5955. // Restore conversations
  5956. // Chat state is restored by fetchChatStateFromServer() — NOT from workspace.json
  5957. // Restore open files
  5958. if (state.openFilePaths?.length) {
  5959. for (const fp of state.openFilePaths) {
  5960. try {
  5961. const data = await api(`/api/file?path=${encodeURIComponent(fp)}`);
  5962. const content = (data.content || '').split('\n').map(l => l.replace(/^\s*\d+\t/, '')).join('\n');
  5963. openFiles.set(fp, { type: 'file', content });
  5964. } catch {}
  5965. }
  5966. if (state.activeFile && openFiles.has(state.activeFile)) {
  5967. currentFile = state.activeFile;
  5968. } else if (openFiles.size > 0) {
  5969. currentFile = [...openFiles.keys()].pop();
  5970. }
  5971. renderTabs();
  5972. if (currentFile) showTabContent(currentFile);
  5973. }
  5974. // Restore mode (after files are loaded)
  5975. if (state.mode && state.mode !== 'code') {
  5976. switchMode(state.mode);
  5977. }
  5978. // Restore chatWidth and wfBindings from backend (fill localStorage if missing)
  5979. if (state.chatWidth && !localStorage.getItem('vl-chat-width')) {
  5980. localStorage.setItem('vl-chat-width', String(state.chatWidth));
  5981. applyChatWidth(state.chatWidth);
  5982. }
  5983. if (typeof state.showInternalFiles === 'boolean') {
  5984. setInternalFilesVisible(state.showInternalFiles, { reload: true, persist: true });
  5985. }
  5986. if (state.wfBindings && !localStorage.getItem('vl-code-wf-bindings')) {
  5987. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(state.wfBindings));
  5988. }
  5989. setStatus('Workspace restored', 'green');
  5990. setTimeout(() => setStatus('Ready', 'green'), 2000);
  5991. return true;
  5992. } catch {
  5993. return false;
  5994. }
  5995. }
  5996. // ===================== UTILITIES =====================
  5997. async function api(url, chatId) {
  5998. // Append chatId for session-specific GET endpoints
  5999. const u = chatId !== undefined ? `${url}${url.includes('?') ? '&' : '?'}chatId=${chatId}` : url;
  6000. return (await fetch(u)).json();
  6001. }
  6002. async function updateContext() {
  6003. try {
  6004. const ctx = await api('/api/context', activeConvId);
  6005. const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
  6006. $('ctxLabel').textContent = `${pct}%`;
  6007. $('ctxBar').style.width = pct + '%';
  6008. $('ctxBar').style.background = pct > 85 ? 'var(--red)' : pct > 60 ? 'var(--yellow)' : 'var(--green)';
  6009. // Token detail tooltip
  6010. let detail = `Context: ${(ctx.usedTokens/1000).toFixed(1)}K / ${(ctx.maxTokens/1000).toFixed(0)}K`;
  6011. detail += `\nMessages: ${ctx.messageCount} (${ctx.turnCount} turns)`;
  6012. if (ctx.inputTokens !== undefined) {
  6013. detail += `\nInput: ${(ctx.inputTokens/1000).toFixed(1)}K tokens`;
  6014. detail += `\nOutput: ${(ctx.outputTokens/1000).toFixed(1)}K tokens`;
  6015. if (ctx.cacheRead) detail += `\nCache hit: ${(ctx.cacheRead/1000).toFixed(1)}K`;
  6016. detail += `\nSession: ${(ctx.totalInputTokens/1000).toFixed(1)}K in / ${(ctx.totalOutputTokens/1000).toFixed(1)}K out`;
  6017. // Daily token tracking
  6018. const dailyStats = trackDailyTokens(ctx.totalInputTokens, ctx.totalOutputTokens);
  6019. const dayTotal = ((dailyStats.input + dailyStats.output) / 1000).toFixed(1);
  6020. detail += `\n── Today ──`;
  6021. detail += `\nToday: ${(dailyStats.input/1000).toFixed(1)}K in / ${(dailyStats.output/1000).toFixed(1)}K out (${dayTotal}K total)`;
  6022. }
  6023. $('ctxDetail').textContent = detail;
  6024. } catch {}
  6025. }
  6026. /** Track daily token usage in localStorage */
  6027. let _dailyTokenState = null;
  6028. function trackDailyTokens(sessionIn, sessionOut) {
  6029. const today = new Date().toISOString().slice(0, 10);
  6030. if (!_dailyTokenState) {
  6031. try {
  6032. _dailyTokenState = JSON.parse(localStorage.getItem('vl-daily-tokens') || '{}');
  6033. } catch { _dailyTokenState = {}; }
  6034. }
  6035. if (_dailyTokenState.date !== today) {
  6036. _dailyTokenState = { date: today, input: 0, output: 0, lastSessionIn: sessionIn, lastSessionOut: sessionOut };
  6037. }
  6038. const deltaIn = Math.max(0, sessionIn - (_dailyTokenState.lastSessionIn || 0));
  6039. const deltaOut = Math.max(0, sessionOut - (_dailyTokenState.lastSessionOut || 0));
  6040. _dailyTokenState.input += deltaIn;
  6041. _dailyTokenState.output += deltaOut;
  6042. _dailyTokenState.lastSessionIn = sessionIn;
  6043. _dailyTokenState.lastSessionOut = sessionOut;
  6044. try { localStorage.setItem('vl-daily-tokens', JSON.stringify(_dailyTokenState)); } catch {}
  6045. return { input: _dailyTokenState.input, output: _dailyTokenState.output };
  6046. }
  6047. function setStatus(text, color) {
  6048. $('statusText').textContent = text;
  6049. const dot = document.querySelector('.bottom-bar .dot');
  6050. if (dot) dot.className = 'dot dot-' + color;
  6051. }
  6052. // ─── Tab Activity System ─────────────────────────────────────────────────
  6053. // Tab title management (simplified — no Dragon identity)
  6054. let _tabWorkspaceName = '';
  6055. let _tabStatus = 'idle';
  6056. let _tabFlashTimer = null;
  6057. let _tabFlashState = false;
  6058. let _tabHasFocus = true;
  6059. document.addEventListener('visibilitychange', () => {
  6060. _tabHasFocus = !document.hidden;
  6061. if (_tabHasFocus && _tabStatus === 'newOutput') {
  6062. setTabStatus('idle');
  6063. }
  6064. });
  6065. function setTabStatus(status) {
  6066. _tabStatus = status;
  6067. if (_tabFlashTimer) { clearInterval(_tabFlashTimer); _tabFlashTimer = null; }
  6068. const ws = _tabWorkspaceName;
  6069. const name = ws ? `VLCode Lite — ${ws}` : 'VLCode Lite';
  6070. if (status === 'idle') {
  6071. document.title = name;
  6072. } else if (status === 'busy') {
  6073. document.title = `⚡ ${name} — working...`;
  6074. _tabFlashState = false;
  6075. _tabFlashTimer = setInterval(() => {
  6076. _tabFlashState = !_tabFlashState;
  6077. document.title = _tabFlashState ? `⚡ ${name} — working...` : name;
  6078. }, 1200);
  6079. } else if (status === 'newOutput') {
  6080. document.title = `💬 ${name} ✦ NEW`;
  6081. _tabFlashState = false;
  6082. _tabFlashTimer = setInterval(() => {
  6083. _tabFlashState = !_tabFlashState;
  6084. document.title = _tabFlashState ? `💬 ${name} ✦ NEW` : name;
  6085. }, 1500);
  6086. }
  6087. }
  6088. let _sseSource = null;
  6089. function connectSSE() {
  6090. if (_sseSource) { try { _sseSource.close(); } catch {} }
  6091. const es = new EventSource('/api/events');
  6092. _sseSource = es;
  6093. es.onmessage = (e) => {
  6094. try {
  6095. const data = JSON.parse(e.data);
  6096. if (data.type === 'file_changed') {
  6097. setStatus(`Changed: ${data.path}`, 'yellow');
  6098. setTimeout(() => setStatus('Ready', 'green'), 2000);
  6099. // Debounced file tree refresh — show files appearing in real-time
  6100. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  6101. window._fileTreeRefreshTimer = setTimeout(() => {
  6102. loadFileTree();
  6103. window._fileTreeRefreshTimer = null;
  6104. }, 800);
  6105. }
  6106. if (data.type === 'validation_result') {
  6107. setStatus(data.result?.substring(0, 60) || 'Validated', data.result?.includes('Error') ? 'red' : 'green');
  6108. }
  6109. if (data.type === 'file_tree_updated') {
  6110. // File tree data is ready on server — refresh immediately (no debounce needed)
  6111. if (window._fileTreeRefreshTimer) { clearTimeout(window._fileTreeRefreshTimer); window._fileTreeRefreshTimer = null; }
  6112. loadFileTree();
  6113. }
  6114. if (data.type === 'project_reloaded') {
  6115. loadFileTree();
  6116. loadProjectInfo();
  6117. }
  6118. if (data.type === 'conversations_cleared') {
  6119. // Server cleared all sessions — reset frontend to match
  6120. localStorage.removeItem(chatStorageKey());
  6121. resetConversationState();
  6122. saveChatState();
  6123. }
  6124. if (data.type === 'workspace_switched') {
  6125. $('wsPopover')?.classList.remove('open');
  6126. // Update currentWorkDir immediately so loadWorkspaces() marks the right entry as active
  6127. currentWorkDir = data.workDir || '';
  6128. loadFileTree();
  6129. loadProjectInfo();
  6130. previewUrls = {};
  6131. $('previewUrlsPanel').style.display = 'none';
  6132. $('previewUrlsList').innerHTML = '';
  6133. $('previewUrlLabel').textContent = '';
  6134. if ($('cloudGid')) $('cloudGid').value = '';
  6135. if (currentWorkDir) {
  6136. Promise.all([
  6137. loadPreviewUrlsFromProfile(),
  6138. loadCloudGid(),
  6139. ]).catch(() => {});
  6140. }
  6141. renderWsTabs(); // Refresh tabs directly in case loadWorkspaces fails
  6142. loadWorkspaces();
  6143. // Reload chat state (conversations/tabs) from new workspace
  6144. if (currentWorkDir) fetchChatStateFromServer();
  6145. else resetConversationState();
  6146. // Refresh Map if currently visible — metadata from old workspace is stale
  6147. if (currentMode === 'meta') {
  6148. switchMode('meta'); // re-triggers metadata load from new workspace
  6149. }
  6150. }
  6151. if (data.type === 'settings_changed') {
  6152. loadProjectInfo();
  6153. }
  6154. if (data.type === 'cloud_status') {
  6155. if (data.status === 'connected') showCloudConnected(data.user);
  6156. else showCloudDisconnected();
  6157. }
  6158. if (data.type === 'compile_done') {
  6159. if (data.gid && $('cloudGid')) $('cloudGid').value = String(data.gid);
  6160. if (data.previewUrls && Object.keys(data.previewUrls).length > 0) {
  6161. activatePreview(data.previewUrls);
  6162. } else if (currentWorkDir) {
  6163. loadPreviewUrlsFromProfile();
  6164. }
  6165. }
  6166. if (data.type === 'cloud_sync') {
  6167. if (data.status === 'pushing' || data.status === 'pulling') {
  6168. showCloudSyncStatus(data.status === 'pushing' ? 'Pushing...' : 'Pulling...', 'syncing');
  6169. } else if (data.status === 'pushed') {
  6170. showCloudSyncStatus(`Pushed ${data.total} files`, 'ok');
  6171. } else if (data.status === 'pulled') {
  6172. showCloudSyncStatus(`Pulled ${data.fileCount} files`, 'ok');
  6173. } else if (data.status === 'error') {
  6174. showCloudSyncStatus('Error: ' + data.message, 'error');
  6175. }
  6176. }
  6177. if (data.type === 'server_restart') {
  6178. setStatus('Server restarting...', 'yellow');
  6179. // Wait for server to come back, then reload
  6180. setTimeout(() => waitForServerAndReload(), data.delay || 2000);
  6181. }
  6182. // Workflow trigger (local — approveAndRunWorkflow)
  6183. if (data.type === 'run_workflow') {
  6184. const wfName = data.payload?.workflowName;
  6185. if (wfName) {
  6186. addMsg('assistant', `**Workflow trigger:** ${wfName}`);
  6187. const fakeBtn = document.createElement('button');
  6188. const fakeDiv = document.createElement('div');
  6189. fakeDiv.className = 'wf-progress-actions';
  6190. fakeDiv.appendChild(fakeBtn);
  6191. const wrapper = document.createElement('div');
  6192. wrapper.className = 'wf-progress';
  6193. wrapper.appendChild(fakeDiv);
  6194. $('chatMessages').appendChild(wrapper);
  6195. approveAndRunWorkflow(wfName, fakeBtn);
  6196. }
  6197. }
  6198. // ── Multi-window sync events ──
  6199. if (data.type === 'ws_tabs_changed') {
  6200. renderWsTabs(); // refresh from /api/windows
  6201. }
  6202. // Codegen workflow selector changed in another window
  6203. if (data.type === 'wf_selection_changed' && data.workflow) {
  6204. if (data.workflow !== _selectedCodegenWorkflow) {
  6205. _selectedCodegenWorkflow = data.workflow;
  6206. if (CODEGEN_WORKFLOWS[data.workflow]) {
  6207. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.workflow].label;
  6208. workflowBindings.generate = CODEGEN_WORKFLOWS[data.workflow].file;
  6209. }
  6210. }
  6211. }
  6212. // Flow tab workflow dropdown changed in another window
  6213. if (data.type === 'ui_state_changed' || data.type === 'ui_state') {
  6214. if (data.flowWorkflow) {
  6215. _setFlowWfSelectOrStore(data.flowWorkflow, $('flowWfSelect'));
  6216. }
  6217. }
  6218. // Workflow run state — sent on SSE connect for windows opening mid-run
  6219. if (data.type === 'current_run_state' && data.active && data.workflowName) {
  6220. // Restore running workflow in Flow tab (already handled by wf_start broadcasts during run)
  6221. _workflowActive = true;
  6222. _lastWorkflowName = data.workflowName;
  6223. window._skipFlowAutoLoad = true;
  6224. loadWorkflowIntoFlowTab(data.workflowName);
  6225. }
  6226. // Chat state changed in another window — sync messages
  6227. if (data.type === 'chat_state_changed') {
  6228. const convId = data.chatId;
  6229. const conv = conversations.find(c => c.id === convId);
  6230. if (conv && data.messageCount > 0 && !_currentAbortController) {
  6231. const domCount = ($('chatMessages')?.querySelectorAll('.msg').length) || 0;
  6232. if (convId === activeConvId && data.messageCount > domCount) {
  6233. _rebuildChatDom(convId);
  6234. } else if (convId !== activeConvId) {
  6235. conv.dom = '';
  6236. conv.messageCount = data.messageCount;
  6237. }
  6238. }
  6239. }
  6240. } catch {}
  6241. };
  6242. // Auto-reconnect on disconnect
  6243. es.onerror = () => {
  6244. es.close();
  6245. _sseSource = null;
  6246. setStatus('Disconnected — reconnecting...', 'red');
  6247. setTimeout(() => {
  6248. fetch('/api/version').then(r => r.json()).then(() => {
  6249. setStatus('Reconnected', 'green');
  6250. connectSSE();
  6251. loadProjectInfo();
  6252. loadFileTree();
  6253. }).catch(() => setTimeout(connectSSE, 3000));
  6254. }, 2000);
  6255. };
  6256. }
  6257. // ═══════ Server Health Detection (P2) ═══════
  6258. let _healthFails = 0;
  6259. let _disconnectOverlay = null;
  6260. setInterval(async () => {
  6261. try {
  6262. const r = await fetch('/api/health', { signal: AbortSignal.timeout(3000) });
  6263. if (r.ok) {
  6264. _healthFails = 0;
  6265. if (_disconnectOverlay) { _disconnectOverlay.remove(); _disconnectOverlay = null; }
  6266. } else _healthFails++;
  6267. } catch { _healthFails++; }
  6268. if (_healthFails >= 3 && !_disconnectOverlay) {
  6269. _disconnectOverlay = document.createElement('div');
  6270. _disconnectOverlay.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#f38ba8;color:#1e1e2e;text-align:center;padding:6px;font-weight:600;font-size:13px;';
  6271. _disconnectOverlay.textContent = '⚠ Server disconnected — reconnecting...';
  6272. document.body.appendChild(_disconnectOverlay);
  6273. }
  6274. }, 10000);
  6275. /** Wait for server to come back after restart, then soft-reload */
  6276. function waitForServerAndReload() {
  6277. let attempts = 0;
  6278. const check = () => {
  6279. fetch('/api/version').then(r => r.json()).then(data => {
  6280. setStatus(`Server v${data.version} ready`, 'green');
  6281. // Soft reload: reconnect SSE, reload data, keep UI state
  6282. connectSSE();
  6283. loadProjectInfo();
  6284. loadFileTree();
  6285. loadWorkspaces();
  6286. updateContext();
  6287. }).catch(() => {
  6288. if (++attempts < 20) setTimeout(check, 1000);
  6289. else setStatus('Server not responding — please reload page', 'red');
  6290. });
  6291. };
  6292. setTimeout(check, 1000);
  6293. }
  6294. /** Show the update button in header */
  6295. function showUpdateButton() {
  6296. const btn = $('updateBtn');
  6297. if (btn) btn.style.display = '';
  6298. }
  6299. function escapeHtml(s) {
  6300. if (!s) return '';
  6301. return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  6302. }
  6303. /** Simple markdown → HTML renderer */
  6304. function renderMarkdown(text) {
  6305. if (!text) return '';
  6306. let html = escapeHtml(text);
  6307. // Code blocks (``` ... ```)
  6308. html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
  6309. `<pre><code class="lang-${lang}">${code.trim()}</code></pre>`);
  6310. // Inline code
  6311. html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
  6312. // Headers
  6313. html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
  6314. html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
  6315. html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
  6316. // Bold + italic
  6317. html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
  6318. html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  6319. html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
  6320. // Blockquote
  6321. html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
  6322. // Unordered list
  6323. html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
  6324. html = html.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
  6325. // Ordered list
  6326. html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
  6327. // Links (markdown format)
  6328. html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
  6329. // Auto-link bare URLs (not already inside href or tag)
  6330. html = html.replace(/(^|[^"=>])(https?:\/\/[^\s<)"']+)/g, '$1<a href="$2" target="_blank">$2</a>');
  6331. // Paragraphs (double newline)
  6332. html = html.replace(/\n\n/g, '</p><p>');
  6333. html = `<p>${html}</p>`;
  6334. html = html.replace(/<p><(h[123]|pre|ul|ol|blockquote)/g, '<$1');
  6335. html = html.replace(/<\/(h[123]|pre|ul|ol|blockquote)><\/p>/g, '</$1>');
  6336. // Single newlines → <br> (but not inside pre)
  6337. html = html.replace(/<p>([\s\S]*?)<\/p>/g, (_, inner) =>
  6338. `<p>${inner.replace(/\n/g, '<br>')}</p>`);
  6339. return html;
  6340. }
  6341. // ===================== IMAGE UPLOAD =====================
  6342. function setupImagePaste() {
  6343. // Paste images from clipboard
  6344. document.addEventListener('paste', e => {
  6345. const items = e.clipboardData?.items;
  6346. if (!items) return;
  6347. for (const item of items) {
  6348. if (item.type.startsWith('image/')) {
  6349. e.preventDefault();
  6350. const file = item.getAsFile();
  6351. addImageAttachment(file);
  6352. }
  6353. }
  6354. });
  6355. // File input change
  6356. $('imageInput').addEventListener('change', e => {
  6357. for (const file of e.target.files) addImageAttachment(file);
  6358. e.target.value = '';
  6359. });
  6360. // Drag image onto chat input area
  6361. const inputArea = document.querySelector('.chat-input-area');
  6362. inputArea.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
  6363. inputArea.addEventListener('drop', e => {
  6364. e.preventDefault();
  6365. for (const file of e.dataTransfer.files) {
  6366. if (file.type.startsWith('image/')) addImageAttachment(file);
  6367. }
  6368. });
  6369. }
  6370. function addImageAttachment(file) {
  6371. const reader = new FileReader();
  6372. reader.onload = () => {
  6373. const dataUrl = reader.result;
  6374. const base64 = dataUrl.split(',')[1];
  6375. const mediaType = file.type;
  6376. pendingImages.push({ data: base64, mediaType, preview: dataUrl });
  6377. renderAttachments();
  6378. };
  6379. reader.readAsDataURL(file);
  6380. }
  6381. function renderAttachments() {
  6382. const container = $('chatAttachments');
  6383. container.innerHTML = '';
  6384. for (let i = 0; i < pendingImages.length; i++) {
  6385. const div = document.createElement('div');
  6386. div.className = 'chat-attach-item';
  6387. div.innerHTML = `<img src="${pendingImages[i].preview}"><span class="remove" onclick="removeImage(${i})">&times;</span>`;
  6388. container.appendChild(div);
  6389. }
  6390. for (let i = 0; i < pendingMentions.length; i++) {
  6391. const div = document.createElement('div');
  6392. div.className = 'chat-attach-item';
  6393. div.innerHTML = `@${escapeHtml(pendingMentions[i])}<span class="remove" onclick="removeMention(${i})">&times;</span>`;
  6394. container.appendChild(div);
  6395. }
  6396. }
  6397. function removeImage(idx) { pendingImages.splice(idx, 1); renderAttachments(); }
  6398. function removeMention(idx) { pendingMentions.splice(idx, 1); renderAttachments(); }
  6399. function autoResizeChatInput(reset = false) {
  6400. const input = $('chatInput');
  6401. if (!input) return;
  6402. if (reset) input.style.height = '';
  6403. input.style.height = 'auto';
  6404. input.style.height = Math.min(input.scrollHeight, 180) + 'px';
  6405. }
  6406. // ===================== @-MENTION AUTOCOMPLETE =====================
  6407. $('chatInput').addEventListener('input', async function(e) {
  6408. autoResizeChatInput();
  6409. const val = this.value;
  6410. const atIdx = val.lastIndexOf('@');
  6411. if (atIdx === -1 || atIdx < val.lastIndexOf(' ', this.selectionStart - 1)) {
  6412. $('mentionDropdown').classList.remove('open');
  6413. return;
  6414. }
  6415. const query = val.slice(atIdx + 1, this.selectionStart).toLowerCase();
  6416. if (query.length === 0 && val[atIdx - 1] && val[atIdx - 1] !== ' ') {
  6417. $('mentionDropdown').classList.remove('open');
  6418. return;
  6419. }
  6420. // Fetch matching files
  6421. try {
  6422. const files = await api(`/api/files/autocomplete?q=${encodeURIComponent(query)}`);
  6423. if (files.length === 0) { $('mentionDropdown').classList.remove('open'); return; }
  6424. const dd = $('mentionDropdown');
  6425. dd.innerHTML = '';
  6426. mentionIdx = -1;
  6427. for (const f of files) {
  6428. const item = document.createElement('div');
  6429. item.className = 'mention-item';
  6430. const typeClass = 'type-' + getType(f.name);
  6431. item.innerHTML = `<span class="m-type ${typeClass}">${f.type || getType(f.name)}</span>${escapeHtml(f.name)}`;
  6432. item.onclick = () => selectMention(f.name, atIdx);
  6433. dd.appendChild(item);
  6434. }
  6435. dd.classList.add('open');
  6436. } catch {}
  6437. });
  6438. $('chatInput').addEventListener('keydown', function(e) {
  6439. const dd = $('mentionDropdown');
  6440. if (!dd.classList.contains('open')) {
  6441. if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
  6442. return;
  6443. }
  6444. const items = dd.querySelectorAll('.mention-item');
  6445. if (e.key === 'ArrowDown') { e.preventDefault(); mentionIdx = Math.min(mentionIdx + 1, items.length - 1); highlightMention(items); }
  6446. else if (e.key === 'ArrowUp') { e.preventDefault(); mentionIdx = Math.max(mentionIdx - 1, 0); highlightMention(items); }
  6447. else if (e.key === 'Enter' || e.key === 'Tab') {
  6448. e.preventDefault();
  6449. if (mentionIdx >= 0 && items[mentionIdx]) {
  6450. const name = items[mentionIdx].textContent.trim();
  6451. const atIdx = this.value.lastIndexOf('@');
  6452. selectMention(name, atIdx);
  6453. }
  6454. } else if (e.key === 'Escape') {
  6455. dd.classList.remove('open');
  6456. }
  6457. });
  6458. function highlightMention(items) {
  6459. items.forEach((el, i) => el.classList.toggle('selected', i === mentionIdx));
  6460. }
  6461. function selectMention(name, atIdx) {
  6462. const input = $('chatInput');
  6463. input.value = input.value.slice(0, atIdx) + '@' + name + ' ' + input.value.slice(input.selectionStart);
  6464. input.focus();
  6465. $('mentionDropdown').classList.remove('open');
  6466. if (!pendingMentions.includes(name)) {
  6467. pendingMentions.push(name);
  6468. renderAttachments();
  6469. }
  6470. }
  6471. // ===================== CONVERSATION TABS =====================
  6472. function renderConvTabs() {
  6473. const tabs = $('convTabs');
  6474. // Preserve history panel if open
  6475. const existingPanel = tabs.querySelector('.history-panel');
  6476. const panelWasOpen = existingPanel?.classList.contains('open');
  6477. tabs.innerHTML = '';
  6478. for (const conv of conversations) {
  6479. const tab = document.createElement('div');
  6480. tab.className = 'conv-tab' + (conv.id === activeConvId ? ' active' : '');
  6481. tab.dataset.conv = conv.id;
  6482. tab.innerHTML = `${escapeHtml(conv.name)}${conversations.length > 1 ? '<span class="conv-close" onclick="event.stopPropagation();closeConversation(' + conv.id + ')">&times;</span>' : ''}`;
  6483. tab.onclick = () => switchConversation(conv.id);
  6484. tabs.appendChild(tab);
  6485. }
  6486. const addBtn = document.createElement('button');
  6487. addBtn.className = 'conv-new';
  6488. addBtn.textContent = '+';
  6489. addBtn.onclick = newConversation;
  6490. addBtn.title = 'New conversation';
  6491. tabs.appendChild(addBtn);
  6492. // Spacer pushes history button to the right
  6493. const spacer = document.createElement('div');
  6494. spacer.className = 'tab-spacer';
  6495. tabs.appendChild(spacer);
  6496. // History button (right-aligned)
  6497. const histBtn = document.createElement('button');
  6498. histBtn.className = 'conv-history-btn';
  6499. histBtn.innerHTML = 'History';
  6500. histBtn.title = 'Chat history';
  6501. histBtn.onclick = (e) => { e.stopPropagation(); toggleHistoryPanel(); };
  6502. tabs.appendChild(histBtn);
  6503. // History dropdown panel
  6504. const panel = document.createElement('div');
  6505. panel.className = 'history-panel';
  6506. panel.id = 'historyPanel';
  6507. panel.innerHTML = `<div class="history-search"><input id="historySearchInput" placeholder="Search history..." oninput="searchHistory(this.value)"></div><div class="history-list" id="historyList"><div class="history-empty">Loading...</div></div>`;
  6508. panel.onclick = (e) => e.stopPropagation();
  6509. tabs.appendChild(panel);
  6510. if (panelWasOpen) { panel.classList.add('open'); loadHistoryItems(); }
  6511. }
  6512. async function newConversation() {
  6513. // Save current chat DOM
  6514. const curConv = conversations.find(c => c.id === activeConvId);
  6515. if (curConv) curConv.dom = $('chatMessages').innerHTML;
  6516. // Create on backend first (source of truth)
  6517. try {
  6518. const res = await fetch('/api/conversations', {
  6519. method: 'POST',
  6520. headers: { 'Content-Type': 'application/json' },
  6521. body: JSON.stringify({}),
  6522. });
  6523. const data = await res.json();
  6524. const id = data.id;
  6525. const name = data.name;
  6526. conversations.push({ id, name, messages: [], dom: '' });
  6527. if (id >= convIdCounter) convIdCounter = id + 1;
  6528. activeConvId = id;
  6529. } catch {
  6530. // Fallback: local creation
  6531. const id = convIdCounter++;
  6532. conversations.push({ id, name: `Chat ${id + 1}`, messages: [], dom: '' });
  6533. activeConvId = id;
  6534. }
  6535. $('chatMessages').innerHTML = '';
  6536. renderConvTabs();
  6537. saveChatState();
  6538. }
  6539. async function switchConversation(id) {
  6540. if (id === activeConvId) return;
  6541. const curConv = conversations.find(c => c.id === activeConvId);
  6542. if (curConv) curConv.dom = $('chatMessages').innerHTML;
  6543. activeConvId = id;
  6544. const target = conversations.find(c => c.id === id);
  6545. if (target?.dom) {
  6546. // Check if DOM is stale vs server message count
  6547. const domMsgCount = (target.dom.match(/class="msg (user|assistant)"/g) || []).length;
  6548. if (domMsgCount < (target.messageCount || 0)) {
  6549. $('chatMessages').innerHTML = '';
  6550. await _rebuildChatDom(id);
  6551. } else {
  6552. $('chatMessages').innerHTML = target.dom;
  6553. }
  6554. } else {
  6555. $('chatMessages').innerHTML = '';
  6556. // Try to rebuild from server messages when dom is empty
  6557. await _rebuildChatDom(id);
  6558. }
  6559. renderConvTabs();
  6560. // Push active tab change to backend
  6561. pushChatStateToServer();
  6562. }
  6563. async function closeConversation(id) {
  6564. if (conversations.length <= 1) return;
  6565. const conv = conversations.find(c => c.id === id);
  6566. // If conversation has content, AI summarize & archive
  6567. if (conv) {
  6568. const dom = (id === activeConvId) ? ($('chatMessages')?.innerHTML || '') : (conv.dom || '');
  6569. if (dom && dom.length > 50) {
  6570. const tmp = document.createElement('div');
  6571. tmp.innerHTML = dom;
  6572. const msgs = [];
  6573. tmp.querySelectorAll('.msg').forEach(el => {
  6574. const role = el.classList.contains('user') ? 'user' : 'assistant';
  6575. const text = el.querySelector('.content-text')?.textContent || '';
  6576. const time = el.dataset.timestamp || '';
  6577. if (text.trim()) msgs.push({ role, text: text.substring(0, 1000), time });
  6578. });
  6579. if (msgs.length > 0) {
  6580. fetch('/api/chat/summarize-and-save', {
  6581. method: 'POST',
  6582. headers: { 'Content-Type': 'application/json' },
  6583. body: JSON.stringify({ convId: id, name: conv.name, messages: msgs }),
  6584. }).catch(() => {});
  6585. }
  6586. }
  6587. }
  6588. // Delete on backend first
  6589. try { await fetch(`/api/conversations/${id}`, { method: 'DELETE' }); } catch {}
  6590. // Update local state
  6591. conversations = conversations.filter(c => c.id !== id);
  6592. if (activeConvId === id) {
  6593. activeConvId = conversations[0].id;
  6594. const target = conversations[0];
  6595. $('chatMessages').innerHTML = target?.dom || '';
  6596. }
  6597. renderConvTabs();
  6598. saveChatState();
  6599. // Immediately sync to backend (don't wait for 10s interval)
  6600. pushChatStateToServer();
  6601. }
  6602. /** Auto-generate a short title for a conversation after its first chat turn */
  6603. async function autoTitleConversation(convId, userMessage) {
  6604. const conv = conversations.find(c => c.id === convId);
  6605. if (!conv) return;
  6606. // Only auto-title if name still matches default pattern (Chat N)
  6607. if (!/^Chat \d+$/.test(conv.name)) return;
  6608. try {
  6609. const res = await fetch('/api/chat/generate-title', {
  6610. method: 'POST',
  6611. headers: { 'Content-Type': 'application/json' },
  6612. body: JSON.stringify({ chatId: convId, userMessage }),
  6613. });
  6614. const data = await res.json();
  6615. if (data.ok && data.title) {
  6616. conv.name = data.title;
  6617. renderConvTabs();
  6618. saveChatState();
  6619. // Sync to backend registry
  6620. fetch(`/api/conversations/${convId}`, {
  6621. method: 'PATCH',
  6622. headers: { 'Content-Type': 'application/json' },
  6623. body: JSON.stringify({ name: data.title }),
  6624. }).catch(() => {});
  6625. }
  6626. } catch {}
  6627. }
  6628. /** Save current conversation to server (persistent across browser refreshes) */
  6629. async function saveConversationToServer(title) {
  6630. try {
  6631. const convTitle = title || `Chat ${activeConvId + 1} — ${new Date().toLocaleString()}`;
  6632. const res = await fetch('/api/conversation/save', {
  6633. method: 'POST',
  6634. headers: { 'Content-Type': 'application/json' },
  6635. body: JSON.stringify({ id: `conv_${activeConvId}`, title: convTitle, chatId: activeConvId }),
  6636. });
  6637. const data = await res.json();
  6638. if (data.ok) setStatus(`Saved: ${convTitle}`, 'green');
  6639. else setStatus(data.error || 'Save failed', 'red');
  6640. } catch (e) { setStatus('Save error: ' + e.message, 'red'); }
  6641. }
  6642. /** Restore a conversation from server */
  6643. async function restoreConversationFromServer(convId) {
  6644. try {
  6645. const res = await fetch(`/api/conversation/restore/${convId}`, { method: 'POST' });
  6646. const data = await res.json();
  6647. if (data.ok) {
  6648. $('chatMessages').innerHTML = '';
  6649. addMsg('assistant', `Restored conversation: ${data.conversation.title} (${data.conversation.messageCount} messages in context)`);
  6650. setStatus(`Restored: ${data.conversation.title}`, 'green');
  6651. updateContext();
  6652. } else {
  6653. setStatus(data.error || 'Restore failed', 'red');
  6654. }
  6655. } catch (e) { setStatus('Restore error: ' + e.message, 'red'); }
  6656. }
  6657. // ── History Panel ──
  6658. let _historyCache = null;
  6659. let _historyQuery = '';
  6660. function toggleHistoryPanel() {
  6661. const panel = $('historyPanel');
  6662. if (!panel) return;
  6663. const isOpen = panel.classList.contains('open');
  6664. if (isOpen) {
  6665. panel.classList.remove('open');
  6666. } else {
  6667. panel.classList.add('open');
  6668. loadHistoryItems();
  6669. // Close on outside click
  6670. setTimeout(() => {
  6671. const closer = (e) => {
  6672. if (!panel.contains(e.target) && !e.target.classList.contains('conv-history-btn')) {
  6673. panel.classList.remove('open');
  6674. document.removeEventListener('click', closer);
  6675. }
  6676. };
  6677. document.addEventListener('click', closer);
  6678. }, 0);
  6679. }
  6680. }
  6681. async function loadHistoryItems(query) {
  6682. const list = $('historyList');
  6683. if (!list) return;
  6684. try {
  6685. const q = query !== undefined ? query : _historyQuery;
  6686. const res = await fetch('/api/chat/history' + (q ? '?q=' + encodeURIComponent(q) : ''));
  6687. const data = await res.json();
  6688. _historyCache = data.items || [];
  6689. renderHistoryList(_historyCache);
  6690. } catch (e) {
  6691. list.innerHTML = '<div class="history-empty">Failed to load history</div>';
  6692. }
  6693. }
  6694. function searchHistory(query) {
  6695. _historyQuery = query;
  6696. if (_historyCache && !query) {
  6697. renderHistoryList(_historyCache);
  6698. return;
  6699. }
  6700. // Debounce: load from server with query
  6701. clearTimeout(searchHistory._timer);
  6702. searchHistory._timer = setTimeout(() => loadHistoryItems(query), 200);
  6703. }
  6704. const CATEGORY_COLORS = {
  6705. bug_fix: '#f85149', feature: '#3fb950', refactor: '#a371f7',
  6706. question: '#58a6ff', config: '#d29922', design: '#f778ba', general: '#8b949e', other: '#8b949e',
  6707. };
  6708. function renderHistoryList(items) {
  6709. const list = $('historyList');
  6710. if (!list) return;
  6711. if (!items || items.length === 0) {
  6712. list.innerHTML = '<div class="history-empty">No history found</div>';
  6713. return;
  6714. }
  6715. list.innerHTML = '';
  6716. for (const item of items) {
  6717. const div = document.createElement('div');
  6718. div.className = 'history-item';
  6719. const date = item.archivedAt ? new Date(item.archivedAt) : null;
  6720. const dateStr = date ? formatRelativeDate(date) : '';
  6721. const tagColor = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.general;
  6722. div.innerHTML = `<div class="hi-title">${escapeHtml(item.name)}</div>`
  6723. + `<div class="hi-meta">`
  6724. + `<span class="hi-tag" style="color:${tagColor};border-color:${tagColor}40">${item.category}</span>`
  6725. + `<span>${item.messageCount} msgs</span>`
  6726. + `<span>${dateStr}</span>`
  6727. + `</div>`
  6728. + (item.summary ? `<div class="hi-summary">${escapeHtml(item.summary)}</div>` : '');
  6729. div.onclick = () => restoreHistoryItem(item);
  6730. list.appendChild(div);
  6731. }
  6732. }
  6733. function formatRelativeDate(d) {
  6734. const now = new Date();
  6735. const diff = now - d;
  6736. const mins = Math.floor(diff / 60000);
  6737. if (mins < 1) return 'just now';
  6738. if (mins < 60) return `${mins}m ago`;
  6739. const hours = Math.floor(mins / 60);
  6740. if (hours < 24) return `${hours}h ago`;
  6741. const days = Math.floor(hours / 24);
  6742. if (days < 7) return `${days}d ago`;
  6743. return d.toLocaleDateString();
  6744. }
  6745. async function restoreHistoryItem(item) {
  6746. // Close the panel
  6747. $('historyPanel')?.classList.remove('open');
  6748. // Show summary in chat
  6749. addMsg('assistant', `**Restored from history:** ${item.name}\n\n` +
  6750. (item.summary ? `> ${item.summary}\n\n` : '') +
  6751. (item.userNeeds ? `**User needs:** ${item.userNeeds}\n` : '') +
  6752. `**Category:** ${item.category} | **Messages:** ${item.messageCount}`);
  6753. scrollChat();
  6754. setStatus(`Loaded: ${item.name}`, 'green');
  6755. }
  6756. // ===================== THINKING INDICATOR =====================
  6757. let activeThinkingEl = null;
  6758. function addThinkingIndicator() {
  6759. const container = $('chatMessages');
  6760. const div = document.createElement('div');
  6761. div.className = 'thinking-block';
  6762. div.innerHTML = `
  6763. <div class="thinking-header" onclick="this.nextElementSibling.classList.toggle('open')">
  6764. <span class="think-icon">&#x1F4AD;</span> <span>Thinking...</span>
  6765. </div>
  6766. <div class="thinking-body"></div>`;
  6767. container.appendChild(div);
  6768. activeThinkingEl = div;
  6769. scrollChat();
  6770. }
  6771. function appendThinkingText(text) {
  6772. if (!activeThinkingEl) return;
  6773. activeThinkingEl.querySelector('.thinking-body').textContent += text;
  6774. }
  6775. function finalizeThinking() {
  6776. if (!activeThinkingEl) return;
  6777. activeThinkingEl.classList.add('done');
  6778. const header = activeThinkingEl.querySelector('.thinking-header span:last-child');
  6779. const body = activeThinkingEl.querySelector('.thinking-body');
  6780. const chars = body.textContent.length;
  6781. header.textContent = `Thought for ${chars > 500 ? Math.round(chars / 100) + ' blocks' : chars + ' chars'}`;
  6782. activeThinkingEl = null;
  6783. }
  6784. function addRetryIndicator(attempt, delay, status) {
  6785. const container = $('chatMessages');
  6786. const div = document.createElement('div');
  6787. div.className = 'retry-msg';
  6788. div.innerHTML = `&#9889; Retry ${attempt}/3 (${status}) — waiting ${(delay / 1000).toFixed(1)}s`;
  6789. container.appendChild(div);
  6790. scrollChat();
  6791. }
  6792. // ===================== KEYBOARD SHORTCUTS =====================
  6793. document.addEventListener('keydown', e => {
  6794. // Escape: close any open modal / context menu
  6795. if (e.key === 'Escape') {
  6796. $('settingsModal').classList.remove('open');
  6797. $('genModal').classList.remove('open');
  6798. $('fileCtxMenu').classList.remove('open');
  6799. closeChatMoreMenu();
  6800. }
  6801. // Cmd/Ctrl+K: clear chat
  6802. if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
  6803. e.preventDefault();
  6804. $('chatMessages').innerHTML = '';
  6805. }
  6806. // Cmd/Ctrl+/: focus chat input
  6807. if ((e.metaKey || e.ctrlKey) && e.key === '/') {
  6808. e.preventDefault();
  6809. $('chatInput').focus();
  6810. }
  6811. });
  6812. // ===================== LOAD FOLDER =====================
  6813. async function loadFolder() {
  6814. if (window.showDirectoryPicker) {
  6815. try {
  6816. const dirHandle = await window.showDirectoryPicker();
  6817. setStatus('Reading folder...', 'yellow');
  6818. const files = await readDirHandle(dirHandle, '');
  6819. await uploadFiles(files);
  6820. return;
  6821. } catch (e) { if (e.name === 'AbortError') return; }
  6822. }
  6823. $('folderInput').click();
  6824. }
  6825. $('folderInput').addEventListener('change', async (e) => {
  6826. if (!e.target.files?.length) return;
  6827. setStatus('Reading folder...', 'yellow');
  6828. await uploadFiles(await readFileList(e.target.files));
  6829. e.target.value = '';
  6830. });
  6831. async function readDirHandle(dirHandle, prefix) {
  6832. const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
  6833. const files = [];
  6834. for await (const entry of dirHandle.values()) {
  6835. const ep = prefix ? `${prefix}/${entry.name}` : entry.name;
  6836. if (entry.kind === 'directory') {
  6837. if (entry.name === 'node_modules' || entry.name === '.git') continue; // skip heavy dirs
  6838. files.push(...await readDirHandle(entry, ep));
  6839. } else if (entry.kind === 'file') {
  6840. const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop().toLowerCase() : '';
  6841. if (!ext || codeExts.includes(ext)) {
  6842. files.push({ path: ep, content: await (await entry.getFile()).text() });
  6843. }
  6844. }
  6845. }
  6846. return files;
  6847. }
  6848. async function readFileList(fileList) {
  6849. const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
  6850. const files = [];
  6851. for (const file of fileList) {
  6852. const ext = file.name.includes('.') ? '.' + file.name.split('.').pop().toLowerCase() : '';
  6853. if (ext && !codeExts.includes(ext)) continue;
  6854. const parts = (file.webkitRelativePath || file.name).split('/');
  6855. files.push({ path: parts.length > 1 ? parts.slice(1).join('/') : parts[0], content: await file.text() });
  6856. }
  6857. return files;
  6858. }
  6859. async function uploadFiles(files) {
  6860. if (!files?.length) { setStatus('No files found to import', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
  6861. setStatus(`Uploading ${files.length} files...`, 'yellow');
  6862. try {
  6863. await ensureWorkspaceForImport('ImportedFolder');
  6864. const res = await fetch('/api/upload-folder', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({files}) });
  6865. const data = await res.json();
  6866. if (data.error) { setStatus(data.error, 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); return; }
  6867. setStatus(`Imported ${data.filesWritten} files`, 'green');
  6868. await loadFileTree();
  6869. await loadProjectInfo();
  6870. autoOpenFirstFile();
  6871. } catch (e) { setStatus('Upload failed', 'red'); setTimeout(() => setStatus('Ready', 'green'), 3000); }
  6872. }
  6873. /** Auto-open the first VL file (.vx preferred) after loading a project */
  6874. async function autoOpenFirstFile() {
  6875. try {
  6876. const data = await api('/api/files');
  6877. const priority = ['vx','sc','cp','vs','vdb','vth'];
  6878. for (const ext of priority) {
  6879. const found = data.files.find(f => f.endsWith('.' + ext));
  6880. if (found) { openFile(found); return; }
  6881. }
  6882. if (data.files.length > 0) openFile(data.files[0]);
  6883. } catch {}
  6884. }
  6885. // ===================== FILE MANAGEMENT (Delete / Clear / ZIP) =====================
  6886. async function deleteFile(fpath) {
  6887. if (!fpath) return;
  6888. try {
  6889. await fetch(`/api/file?path=${encodeURIComponent(fpath)}`, { method: 'DELETE' });
  6890. // Close tab if open
  6891. if (openFiles.has(fpath)) closeTab(fpath);
  6892. await loadFileTree();
  6893. setStatus('Deleted ' + fpath.split('/').pop(), 'green');
  6894. } catch (e) { setStatus('Delete failed', 'red'); }
  6895. }
  6896. async function clearAllFiles() {
  6897. if (!confirm('Delete ALL VL files in this project? This cannot be undone.')) return;
  6898. try {
  6899. await fetch('/api/files/clear', { method: 'POST' });
  6900. openFiles.clear();
  6901. currentFile = null;
  6902. renderTabs();
  6903. $('editor').style.display = 'none';
  6904. $('iframeContainer').style.display = 'none';
  6905. $('editorPlaceholder').style.display = 'block';
  6906. await loadFileTree();
  6907. await loadProjectInfo();
  6908. setStatus('All files cleared', 'green');
  6909. } catch (e) { setStatus('Clear failed', 'red'); }
  6910. }
  6911. /** Initialize VL project structure with directories and core files */
  6912. async function initProject() {
  6913. setStatus('Initializing project...', 'yellow');
  6914. try {
  6915. await fetch('/api/project/init', { method: 'POST' });
  6916. await loadFileTree();
  6917. await loadProjectInfo();
  6918. setStatus('Project initialized', 'green');
  6919. } catch (e) { setStatus('Init failed', 'red'); }
  6920. }
  6921. function importZip() { $('zipInput').click(); }
  6922. $('zipInput').addEventListener('change', async (e) => {
  6923. const file = e.target.files[0];
  6924. if (!file) return;
  6925. setStatus('Importing ZIP...', 'yellow');
  6926. try {
  6927. const formData = new FormData();
  6928. formData.append('zip', file);
  6929. // Read as base64 and send as JSON since our server uses JSON
  6930. const reader = new FileReader();
  6931. reader.onload = async () => {
  6932. await ensureWorkspaceForImport(file.name);
  6933. const base64 = reader.result.split(',')[1];
  6934. const res = await fetch('/api/upload-zip', {
  6935. method: 'POST', headers: { 'Content-Type': 'application/json' },
  6936. body: JSON.stringify({ data: base64, filename: file.name })
  6937. });
  6938. const data = await res.json();
  6939. if (data.error) { setStatus(data.error, 'red'); return; }
  6940. setStatus(`Imported ZIP ${file.name}`, 'green');
  6941. await loadFileTree();
  6942. await loadProjectInfo();
  6943. // Auto-switch to Code tab and open first file
  6944. autoOpenFirstFile();
  6945. };
  6946. reader.readAsDataURL(file);
  6947. } catch (e) { setStatus('ZIP import failed', 'red'); }
  6948. e.target.value = '';
  6949. });
  6950. // File tree context menu
  6951. function showFileCtxMenu(e, fpath) {
  6952. e.preventDefault();
  6953. e.stopPropagation();
  6954. ctxMenuTarget = fpath;
  6955. const menu = $('fileCtxMenu');
  6956. menu.style.left = e.clientX + 'px';
  6957. menu.style.top = e.clientY + 'px';
  6958. menu.classList.add('open');
  6959. }
  6960. function ctxOpenFile() {
  6961. $('fileCtxMenu').classList.remove('open');
  6962. if (ctxMenuTarget) openFile(ctxMenuTarget);
  6963. }
  6964. function ctxDeleteFile() {
  6965. $('fileCtxMenu').classList.remove('open');
  6966. if (ctxMenuTarget && confirm(`Delete ${ctxMenuTarget}?`)) deleteFile(ctxMenuTarget);
  6967. }
  6968. // Close context menu on click elsewhere
  6969. document.addEventListener('click', () => $('fileCtxMenu').classList.remove('open'));
  6970. // ===================== DRAG-AND-DROP =====================
  6971. let dragCounter = 0;
  6972. document.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; $('dropOverlay').classList.add('active'); });
  6973. document.addEventListener('dragleave', e => { e.preventDefault(); if (--dragCounter <= 0) { dragCounter = 0; $('dropOverlay').classList.remove('active'); } });
  6974. document.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
  6975. document.addEventListener('drop', async (e) => {
  6976. e.preventDefault(); dragCounter = 0; $('dropOverlay').classList.remove('active');
  6977. // Check if drop is on chat input area (handled separately for images)
  6978. if (e.target.closest('.chat-input-area')) return;
  6979. const items = e.dataTransfer.items;
  6980. const dtFiles = e.dataTransfer.files;
  6981. if (!items?.length && !dtFiles?.length) return;
  6982. setStatus('Reading dropped files...', 'yellow');
  6983. // Handle ZIP files dropped directly
  6984. for (const f of dtFiles) {
  6985. if (f.name.endsWith('.zip')) {
  6986. const reader = new FileReader();
  6987. reader.onload = async () => {
  6988. await ensureWorkspaceForImport(f.name);
  6989. const base64 = reader.result.split(',')[1];
  6990. try {
  6991. const res = await fetch('/api/upload-zip', {
  6992. method: 'POST', headers: { 'Content-Type': 'application/json' },
  6993. body: JSON.stringify({ data: base64, filename: f.name })
  6994. });
  6995. const data = await res.json();
  6996. if (data.error) { setStatus(data.error, 'red'); return; }
  6997. setStatus(`Imported ZIP ${f.name}`, 'green');
  6998. await loadFileTree();
  6999. await loadProjectInfo();
  7000. autoOpenFirstFile();
  7001. } catch { setStatus('ZIP import failed', 'red'); }
  7002. };
  7003. reader.readAsDataURL(f);
  7004. return;
  7005. }
  7006. }
  7007. const files = [];
  7008. // Accept all common code/text file types
  7009. const codeExts = ['.vx','.sc','.cp','.vs','.vdb','.vth','.json','.md','.txt','.js','.ts','.jsx','.tsx','.css','.html','.vue','.svelte','.py','.rb','.go','.rs','.java','.kt','.swift','.c','.cpp','.h','.hpp','.xml','.yaml','.yml','.toml','.ini','.cfg','.sh','.bat','.sql','.graphql','.proto','.env','.gitignore','.csv','.svg'];
  7010. const isCodeFile = (name) => {
  7011. const ext = name.includes('.') ? '.' + name.split('.').pop().toLowerCase() : '';
  7012. return !ext || codeExts.includes(ext); // extensionless files like Makefile are also ok
  7013. };
  7014. if (items[0]?.getAsFileSystemHandle) {
  7015. try {
  7016. for (const item of items) {
  7017. const handle = await item.getAsFileSystemHandle();
  7018. if (handle.kind === 'directory') files.push(...await readDirHandle(handle, ''));
  7019. else if (handle.kind === 'file' && isCodeFile(handle.name)) {
  7020. const content = await (await handle.getFile()).text();
  7021. files.push({ path: handle.name, content });
  7022. }
  7023. }
  7024. await uploadFiles(files); return;
  7025. } catch {}
  7026. }
  7027. const entries = [...items].map(i => i.webkitGetAsEntry?.()).filter(Boolean);
  7028. if (entries.length) {
  7029. async function readEntry(entry, prefix) {
  7030. return new Promise(resolve => {
  7031. if (entry.isFile) {
  7032. if (isCodeFile(entry.name)) {
  7033. entry.file(f => { const r = new FileReader(); r.onload = () => {
  7034. files.push({ path: prefix ? `${prefix}/${entry.name}` : entry.name, content: r.result }); resolve();
  7035. }; r.readAsText(f); });
  7036. } else resolve();
  7037. } else if (entry.isDirectory) {
  7038. const dr = entry.createReader();
  7039. dr.readEntries(async subs => { const ep = prefix ? `${prefix}/${entry.name}` : entry.name; for (const s of subs) await readEntry(s, ep); resolve(); });
  7040. } else resolve();
  7041. });
  7042. }
  7043. for (const entry of entries) await readEntry(entry, '');
  7044. await uploadFiles(files);
  7045. }
  7046. });
  7047. // ===================== ASK USER QUESTION (Interactive Choices) =====================
  7048. function showAskUserWidget(data) {
  7049. const container = $('chatMessages');
  7050. const div = document.createElement('div');
  7051. div.className = 'ask-user-widget';
  7052. const inputType = data.multiSelect ? 'checkbox' : 'radio';
  7053. let optionsHtml = '';
  7054. for (let i = 0; i < data.options.length; i++) {
  7055. const opt = data.options[i];
  7056. optionsHtml += `<div class="ask-user-option" onclick="toggleAskOption(this, '${inputType}')">
  7057. <input type="${inputType}" name="ask-opt" value="${i}">
  7058. <div><div class="opt-label">${escapeHtml(opt.label)}</div>${opt.description ? `<div class="opt-desc">${escapeHtml(opt.description)}</div>` : ''}</div>
  7059. </div>`;
  7060. }
  7061. div.innerHTML = `
  7062. <div class="ask-question">${escapeHtml(data.question)}</div>
  7063. ${optionsHtml}
  7064. <div class="ask-user-other"><input type="text" id="askOtherInput" placeholder="Or type your own answer..."></div>
  7065. <div class="ask-user-submit"><button onclick="submitAskAnswer(this.closest('.ask-user-widget'))">Submit</button></div>`;
  7066. container.appendChild(div);
  7067. scrollChat();
  7068. }
  7069. function toggleAskOption(optEl, type) {
  7070. if (type === 'radio') {
  7071. optEl.closest('.ask-user-widget').querySelectorAll('.ask-user-option').forEach(el => el.classList.remove('selected'));
  7072. }
  7073. optEl.classList.toggle('selected');
  7074. optEl.querySelector('input').checked = optEl.classList.contains('selected');
  7075. }
  7076. async function submitAskAnswer(widget) {
  7077. const otherInput = widget.querySelector('#askOtherInput').value.trim();
  7078. if (otherInput) {
  7079. await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer: otherInput, chatId: activeConvId }) });
  7080. } else {
  7081. const selected = [...widget.querySelectorAll('input:checked')].map(inp => {
  7082. const opt = inp.closest('.ask-user-option').querySelector('.opt-label');
  7083. return opt ? opt.textContent : '';
  7084. }).filter(Boolean);
  7085. const answer = selected.length ? selected.join(', ') : 'No selection';
  7086. await fetch('/api/answer', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ answer, chatId: activeConvId }) });
  7087. }
  7088. // Dim the widget after answering
  7089. widget.style.opacity = '0.5';
  7090. widget.querySelector('.ask-user-submit button').disabled = true;
  7091. }
  7092. // ===================== SKILL PALETTE =====================
  7093. let cachedSkills = null;
  7094. let skillIdx = -1;
  7095. // Client-side commands (instant, no LLM)
  7096. const CLIENT_COMMANDS = [
  7097. { name: 'help', description: 'Show all available commands' },
  7098. { name: 'clear', description: 'Clear conversation context (fresh start)' },
  7099. { name: 'context', description: 'Show context window usage' },
  7100. { name: 'compile', description: 'Compile project and get preview URLs' },
  7101. { name: 'status', description: 'Show project status summary' },
  7102. { name: 'docs', description: 'Sync VL reference docs from DocCenter' },
  7103. { name: 'version', description: 'Show VL-Code version' },
  7104. { name: 'screenshot', description: 'Take IDE screenshot (self-test via Playwright)' },
  7105. { name: 'console', description: 'Show browser console logs (errors/warnings)' },
  7106. { name: 'inspect', description: 'Evaluate JS expression in browser context' },
  7107. { name: 'test', description: 'List VL component instance-ids in compiled preview' },
  7108. { name: 'syntax', description: 'Look up VL syntax reference (widget, section, rules)' },
  7109. { name: 'cookie', description: 'Refresh cloud cookie from global auth or paste a new one' },
  7110. { name: 'compile-errors', description: 'Show last compile errors from parsevl' },
  7111. ];
  7112. function handleClientCommand(name, args) {
  7113. switch (name) {
  7114. case 'help': {
  7115. const cmds = CLIENT_COMMANDS.map(c => ` **/${c.name}** — ${c.description}`).join('\n');
  7116. const skills = (cachedSkills || []).map(s => ` **/${s.name}** — ${s.description}`).join('\n');
  7117. addMsg('assistant', `**Available Commands**\n\n_Client commands (instant):_\n${cmds}\n\n_AI skills (LLM-powered):_\n${skills || ' Loading...'}`);
  7118. return true;
  7119. }
  7120. case 'clear':
  7121. fetch('/api/conversations', { method: 'DELETE' }).catch(() => {});
  7122. $('chatMessages').innerHTML = '';
  7123. _lastBackendMsgCount = 0;
  7124. conversations = [{ id: 0, name: 'Chat 1', messages: [] }];
  7125. activeConvId = 0; convIdCounter = 1;
  7126. localStorage.removeItem(chatStorageKey());
  7127. saveChatState();
  7128. renderConvTabs();
  7129. addMsg('assistant', 'Context cleared. Starting fresh conversation.');
  7130. return true;
  7131. case 'context':
  7132. updateContext();
  7133. api('/api/context').then(ctx => {
  7134. const pct = Math.round(ctx.usedTokens / ctx.maxTokens * 100);
  7135. addMsg('assistant', `**Context Usage:** ${(ctx.usedTokens/1000).toFixed(1)}K / ${(ctx.maxTokens/1000).toFixed(0)}K tokens (${pct}%)\nTurns: ${ctx.turnCount || 0}\nCache read: ${(ctx.cacheRead/1000).toFixed(1)}K | Cache write: ${(ctx.cacheCreation/1000).toFixed(1)}K`);
  7136. });
  7137. return true;
  7138. case 'compile':
  7139. compileProject();
  7140. return true;
  7141. case 'status':
  7142. api('/api/project').then(proj => {
  7143. const s = proj.summary || {};
  7144. const files = s.totalFiles || 0;
  7145. const types = s.filesByType ? Object.entries(s.filesByType).map(([k,v]) => `${k}: ${v}`).join(', ') : 'none';
  7146. addMsg('assistant', `**Project Status**\nName: ${s.projectName || 'Unknown'}\nDirectory: ${proj.workDir}\nVL Project: ${proj.isVL ? 'Yes' : 'No'}\nFiles: ${files} (${types})\nModel: ${proj.model}\nVersion: v${proj.version}`);
  7147. });
  7148. return true;
  7149. case 'docs':
  7150. syncVLDocs();
  7151. return true;
  7152. case 'version':
  7153. api('/api/version').then(v => addMsg('assistant', `VL-Code **v${v.version}**`));
  7154. return true;
  7155. case 'screenshot': {
  7156. addMsg('assistant', 'Taking screenshot via Playwright...');
  7157. fetch('/api/browser/screenshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: args || 'ide_' + Date.now() }) })
  7158. .then(r => r.json()).then(data => {
  7159. if (data.error) addMsg('assistant', 'Screenshot error: ' + data.error);
  7160. else addMsg('assistant', `Screenshot saved: **${data.path}**\nSize: ${data.size}\n\n_View at: /api/browser/screenshot/${(args || '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'ide_' + Date.now()}_`);
  7161. }).catch(e => addMsg('assistant', 'Screenshot failed: ' + e.message));
  7162. return true;
  7163. }
  7164. case 'console': {
  7165. fetch('/api/browser/console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filter: args || 'error' }) })
  7166. .then(r => r.json()).then(data => {
  7167. if (!data.logs?.length) { addMsg('assistant', `No ${args || 'error'} logs found.`); return; }
  7168. const lines = data.logs.map(l => `[${l.type}] ${l.text}`).join('\n');
  7169. addMsg('assistant', `**Browser Console** (${data.logs.length}/${data.total}):\n\`\`\`\n${lines}\n\`\`\``);
  7170. }).catch(e => addMsg('assistant', 'Console fetch failed: ' + e.message));
  7171. return true;
  7172. }
  7173. case 'inspect': {
  7174. if (!args) { addMsg('assistant', 'Usage: /inspect <js expression>'); return true; }
  7175. fetch('/api/browser/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expression: args }) })
  7176. .then(r => r.json()).then(data => {
  7177. if (data.error) addMsg('assistant', 'Eval error: ' + data.error);
  7178. else addMsg('assistant', `**Result:**\n\`\`\`\n${data.result}\n\`\`\``);
  7179. }).catch(e => addMsg('assistant', 'Inspect failed: ' + e.message));
  7180. return true;
  7181. }
  7182. case 'test': {
  7183. addMsg('assistant', 'Scanning VL components in compiled preview...');
  7184. const previewUrl = args || null;
  7185. const body = previewUrl ? { action: 'open', url: previewUrl } : { action: 'listIds' };
  7186. // If a URL is provided, open it first then listIds
  7187. if (previewUrl) {
  7188. fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'open', url: previewUrl }) })
  7189. .then(r => r.json()).then(openRes => {
  7190. if (openRes.error) { addMsg('assistant', 'Open failed: ' + openRes.error); return; }
  7191. return fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) }).then(r => r.json());
  7192. }).then(data => {
  7193. if (!data) return;
  7194. if (data.error) { addMsg('assistant', 'listIds error: ' + data.error); return; }
  7195. const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
  7196. addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
  7197. }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
  7198. } else {
  7199. fetch('/api/vl-test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'listIds' }) })
  7200. .then(r => r.json()).then(data => {
  7201. if (data.error) { addMsg('assistant', 'listIds error: ' + data.error + '\n\n_Usage: /test <preview-url> to open a VL preview first_'); return; }
  7202. const lines = (data.elements || []).map(e => ` **${e.iid}** — \`<${e.tag}>\` ${e.visible ? '✓' : '✗'} ${e.text ? '"' + e.text.slice(0, 30) + '"' : ''}`).join('\n');
  7203. addMsg('assistant', `**VL Components** (${data.count} found):\n${lines || ' None found'}`);
  7204. }).catch(e => addMsg('assistant', 'Test failed: ' + e.message));
  7205. }
  7206. return true;
  7207. }
  7208. case 'compile-errors': {
  7209. fetch('/api/compile/errors').then(r => r.json()).then(data => {
  7210. if (!data.errList?.length) {
  7211. addMsg('assistant', data.message || 'No compile errors. Last compile was clean.');
  7212. return;
  7213. }
  7214. const errLines = data.errList.map((e, i) => {
  7215. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  7216. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  7217. return ` ${i + 1}. ${JSON.stringify(e)}`;
  7218. }).join('\n');
  7219. addMsg('assistant', `**Last Compile Errors** (${data.errCount}) — ${data.timestamp || 'unknown time'}\nGID: ${data.gid || 'N/A'}\n\n${errLines}\n\n_Ask the AI to debug these errors, or use /debug to auto-analyze._`);
  7220. }).catch(e => addMsg('assistant', 'Failed to fetch compile errors: ' + e.message));
  7221. return true;
  7222. }
  7223. case 'syntax': {
  7224. if (!args) { addMsg('assistant', 'Usage:\n **/syntax rules** — show VL generation hard rules\n **/syntax widget Button** — look up a widget\n **/syntax <keyword>** — search the VL reference'); return true; }
  7225. const parts = args.trim().split(/\s+/);
  7226. let body;
  7227. if (parts[0] === 'rules') body = { action: 'rules' };
  7228. else if (parts[0] === 'widget' && parts[1]) body = { action: 'widget', query: parts.slice(1).join(' ') };
  7229. else if (parts[0] === 'section' && parts[1]) body = { action: 'section', query: parts.slice(1).join(' ') };
  7230. else body = { action: 'search', query: args.trim() };
  7231. addMsg('assistant', `Looking up VL syntax: **${args.trim()}**...`);
  7232. fetch('/api/vl-syntax', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
  7233. .then(r => r.json()).then(data => {
  7234. if (data.error) { addMsg('assistant', 'Syntax lookup error: ' + data.error + (data.availableSections ? '\n\nAvailable sections: ' + data.availableSections.map(s => s.id).join(', ') : '') + (data.candidates ? '\n\nDid you mean: ' + data.candidates.map(c => c.heading).join(', ') : '')); return; }
  7235. if (data.content) { addMsg('assistant', `**VL Reference: ${data.title || data.widget || data.section || args}**\n\n\`\`\`vl\n${data.content.slice(0, 3000)}\n\`\`\`${data.content.length > 3000 ? '\n\n_(truncated — use more specific query for full content)_' : ''}`); return; }
  7236. if (data.results) { const lines = data.results.map(r => `**Line ${r.line}**: ${r.match}`).join('\n'); addMsg('assistant', `**Search: "${data.query}"** (${data.totalMatches} matches)\n\n${lines}`); return; }
  7237. if (data.availableSections) { addMsg('assistant', `**Available sections:**\n${data.availableSections.map(s => ` - **${s.id}** — ${s.title}`).join('\n')}`); return; }
  7238. addMsg('assistant', '```json\n' + JSON.stringify(data, null, 2) + '\n```');
  7239. }).catch(e => addMsg('assistant', 'Syntax lookup failed: ' + e.message));
  7240. return true;
  7241. }
  7242. case 'cookie': {
  7243. if (args && args.trim().length > 20) {
  7244. // User pasted a cookie directly: /cookie <jwt>
  7245. addMsg('assistant', 'Setting cookie...');
  7246. fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cookie: args.trim() }) })
  7247. .then(r => r.json()).then(d => {
  7248. if (d.ok) { addMsg('assistant', 'Cookie updated. Cloud features ready.'); initCloudStatus(); }
  7249. else addMsg('assistant', 'Failed to set cookie: ' + (d.error || 'unknown'));
  7250. }).catch(e => addMsg('assistant', 'Error: ' + e.message));
  7251. } else {
  7252. // Refresh cookie from global auth
  7253. addMsg('assistant', 'Refreshing cookie from global auth...');
  7254. fetch('/api/cookie/refresh', { method: 'POST' })
  7255. .then(r => r.json()).then(d => {
  7256. if (d.ok) { addMsg('assistant', `Cookie refreshed (source: ${d.source || 'auth.json'}). User: **${d.userId || '?'}**`); initCloudStatus(); }
  7257. else addMsg('assistant', 'No cookie found. Use `/cookie <jwt>` to paste one, or login via Cloud panel.');
  7258. }).catch(e => addMsg('assistant', 'Error: ' + e.message));
  7259. }
  7260. return true;
  7261. }
  7262. default:
  7263. return false;
  7264. }
  7265. }
  7266. async function showSkillPalette() {
  7267. if (!cachedSkills) {
  7268. try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
  7269. }
  7270. const palette = $('skillPalette');
  7271. palette.innerHTML = '';
  7272. skillIdx = -1;
  7273. const input = $('chatInput').value.slice(1).toLowerCase();
  7274. // Combine client commands + server skills
  7275. const all = [
  7276. ...CLIENT_COMMANDS.map(c => ({ ...c, isClient: true })),
  7277. ...(cachedSkills || []).map(s => ({ ...s, isClient: false })),
  7278. ];
  7279. const filtered = all.filter(s => s.name.includes(input) || s.description.toLowerCase().includes(input));
  7280. for (const skill of filtered) {
  7281. const item = document.createElement('div');
  7282. item.className = 'skill-item';
  7283. const badge = skill.isClient ? '<span style="font-size:8px;color:var(--green);margin-left:2px;">&#9679;</span>' : '';
  7284. item.innerHTML = `<span class="sk-name">/${escapeHtml(skill.name)}${badge}</span><span class="sk-desc">${escapeHtml(skill.description)}</span>`;
  7285. item.onclick = () => selectSkill(skill.name);
  7286. palette.appendChild(item);
  7287. }
  7288. if (filtered.length > 0) palette.classList.add('open');
  7289. else palette.classList.remove('open');
  7290. }
  7291. function selectSkill(name) {
  7292. $('chatInput').value = `/${name} `;
  7293. $('chatInput').focus();
  7294. $('skillPalette').classList.remove('open');
  7295. }
  7296. // Extend chatInput handler to detect / commands
  7297. const origInputHandler = $('chatInput').oninput;
  7298. $('chatInput').addEventListener('input', function() {
  7299. const val = this.value;
  7300. if (val.startsWith('/') && !val.includes(' ')) {
  7301. showSkillPalette();
  7302. } else {
  7303. $('skillPalette').classList.remove('open');
  7304. }
  7305. });
  7306. // ===================== CONVERSATION SEARCH =====================
  7307. function openChatSearch() {
  7308. $('chatSearch').classList.add('open');
  7309. $('chatSearchInput').focus();
  7310. }
  7311. function closeChatSearch() {
  7312. $('chatSearch').classList.remove('open');
  7313. $('chatSearchInput').value = '';
  7314. $('searchCount').textContent = '';
  7315. // Remove highlights
  7316. $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
  7317. el.replaceWith(el.textContent);
  7318. });
  7319. }
  7320. function searchConversation(query) {
  7321. // Remove old highlights
  7322. $('chatMessages').querySelectorAll('.search-highlight').forEach(el => {
  7323. el.replaceWith(el.textContent);
  7324. });
  7325. if (!query || query.length < 2) { $('searchCount').textContent = ''; return; }
  7326. let count = 0;
  7327. const msgs = $('chatMessages').querySelectorAll('.msg .content-text');
  7328. const q = query.toLowerCase();
  7329. for (const el of msgs) {
  7330. if (el.textContent.toLowerCase().includes(q)) {
  7331. count++;
  7332. // Highlight matches (simple text node replacement)
  7333. const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  7334. const textNodes = [];
  7335. while (walker.nextNode()) textNodes.push(walker.currentNode);
  7336. for (const node of textNodes) {
  7337. if (node.textContent.toLowerCase().includes(q)) {
  7338. const parts = node.textContent.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'));
  7339. if (parts.length > 1) {
  7340. const span = document.createElement('span');
  7341. for (const part of parts) {
  7342. if (part.toLowerCase() === q) {
  7343. const mark = document.createElement('mark');
  7344. mark.className = 'search-highlight';
  7345. mark.style.cssText = 'background:var(--yellow);color:var(--bg);border-radius:2px;padding:0 1px;';
  7346. mark.textContent = part;
  7347. span.appendChild(mark);
  7348. } else {
  7349. span.appendChild(document.createTextNode(part));
  7350. }
  7351. }
  7352. node.replaceWith(span);
  7353. }
  7354. }
  7355. }
  7356. }
  7357. }
  7358. $('searchCount').textContent = count > 0 ? `${count} found` : 'No matches';
  7359. }
  7360. // Cmd/Ctrl+F in chat: open search
  7361. document.addEventListener('keydown', e => {
  7362. if ((e.metaKey || e.ctrlKey) && e.key === 'f' && document.activeElement?.closest('.chat-panel')) {
  7363. e.preventDefault();
  7364. openChatSearch();
  7365. }
  7366. });
  7367. // ===================== EXTEND SENDMESSAGE FOR SKILLS =====================
  7368. const origSendMessage = sendMessage;
  7369. sendMessage = async function() {
  7370. const input = $('chatInput');
  7371. const msg = input.value.trim();
  7372. $('skillPalette').classList.remove('open');
  7373. // Handle skill commands
  7374. if (msg.startsWith('/') && !msg.startsWith('//')) {
  7375. const parts = msg.substring(1).split(/\s+/);
  7376. const skillName = parts[0];
  7377. const args = parts.slice(1).join(' ');
  7378. // Client-side commands (no LLM needed)
  7379. const clientCmd = handleClientCommand(skillName, args);
  7380. if (clientCmd) { input.value = ''; return; }
  7381. // Check if it's a known skill
  7382. if (!cachedSkills) {
  7383. try { cachedSkills = (await api('/api/skills')).skills; } catch { cachedSkills = []; }
  7384. }
  7385. const skill = cachedSkills.find(s => s.name === skillName);
  7386. if (skill) {
  7387. input.value = '';
  7388. $('chatSend').disabled = true;
  7389. $('chatSend').style.display = 'none';
  7390. $('chatStop').style.display = '';
  7391. _currentAbortController = new AbortController();
  7392. setStatus(`Running /${skillName}...`, 'yellow');
  7393. addMsg('user', msg);
  7394. try {
  7395. const res = await fetch('/api/skill', {
  7396. method:'POST', headers:{'Content-Type':'application/json'},
  7397. body: JSON.stringify({ skill: skillName, args, chatId: activeConvId }),
  7398. signal: _currentAbortController?.signal,
  7399. });
  7400. startSpinnerSafetyTimeout();
  7401. const reader = res.body.getReader();
  7402. const decoder = new TextDecoder();
  7403. let assistantEl = null;
  7404. let buffer = '';
  7405. let currentEvent = '';
  7406. while (true) {
  7407. const {done, value} = await reader.read();
  7408. if (done) break;
  7409. buffer += decoder.decode(value, {stream:true});
  7410. const lines = buffer.split('\n');
  7411. buffer = lines.pop();
  7412. for (const line of lines) {
  7413. if (line.startsWith('event: ')) { currentEvent = line.slice(7); continue; }
  7414. if (line.startsWith('data: ')) {
  7415. try {
  7416. const data = JSON.parse(line.slice(6));
  7417. debugLog(currentEvent || 'data', data);
  7418. if (currentEvent === 'thinking') {
  7419. if (data.phase === 'start') addThinkingIndicator();
  7420. else if (data.phase === 'delta' && data.text) appendThinkingText(data.text);
  7421. else if (data.phase === 'end') finalizeThinking();
  7422. } else if (currentEvent === 'ask_user') {
  7423. showAskUserWidget(data);
  7424. } else if (currentEvent === 'plan_mode') {
  7425. handlePlanModeEvent(data);
  7426. } else if (data.text) {
  7427. if (!assistantEl) { assistantEl = addMsg('assistant', ''); assistantEl.querySelector('.content-text').dataset.raw = ''; }
  7428. const textEl = assistantEl.querySelector('.content-text');
  7429. textEl.dataset.raw = (textEl.dataset.raw || '') + data.text;
  7430. textEl.textContent += data.text;
  7431. scrollChat();
  7432. } else if (data.name && data.input !== undefined) {
  7433. addToolIndicator(data.name, data.input, 'running', data.detail);
  7434. } else if (data.name && data.preview !== undefined) {
  7435. updateToolIndicator(data.name, data.preview);
  7436. } else if (data.todos) {
  7437. renderTodos(data.todos);
  7438. } else if (currentEvent === 'workflow_generated') {
  7439. if (data.workflow) {
  7440. showModeIframe('workflow', '/workflow-editor.html', async () => {
  7441. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || data.name || null };
  7442. });
  7443. }
  7444. } else if (currentEvent === 'node_start') {
  7445. forwardWorkflowEventToIframe('node_start', data);
  7446. } else if (currentEvent === 'node_done') {
  7447. forwardWorkflowEventToIframe('node_done', data);
  7448. } else if (currentEvent === 'node_error') {
  7449. forwardWorkflowEventToIframe('node_error', data);
  7450. } else if (currentEvent === 'screenshot' && data.screenshots?.length) {
  7451. if (!assistantEl) assistantEl = addMsg('assistant', '');
  7452. for (const ssName of data.screenshots) {
  7453. const url = `/api/browser/screenshot/${ssName}`;
  7454. appendScreenshotToChat(assistantEl, url, ssName);
  7455. _contextScreenshots.push({ url, name: ssName });
  7456. }
  7457. } else if (currentEvent === 'done') {
  7458. finalizeAssistantMsg(assistantEl);
  7459. finalizeAllToolSpinners();
  7460. clearSpinnerSafetyTimeout();
  7461. if (data.msgCount !== undefined) _lastBackendMsgCount = data.msgCount;
  7462. } else if (currentEvent === 'error') {
  7463. if (!assistantEl) assistantEl = addMsg('assistant', '');
  7464. assistantEl.querySelector('.content-text').textContent += '\nError: ' + data.message;
  7465. }
  7466. } catch {}
  7467. }
  7468. }
  7469. }
  7470. } catch(e) {
  7471. if (e.name === 'AbortError') {
  7472. addMsg('assistant', '⏹ Stopped by user.');
  7473. } else {
  7474. addMsg('assistant', 'Skill error: ' + e.message);
  7475. }
  7476. finalizeAllToolSpinners();
  7477. }
  7478. clearSpinnerSafetyTimeout();
  7479. _currentAbortController = null;
  7480. $('chatStop').style.display = 'none';
  7481. $('chatSend').style.display = '';
  7482. $('chatSend').disabled = false;
  7483. setStatus('Ready', 'green');
  7484. updateContext();
  7485. return;
  7486. }
  7487. }
  7488. // Fall through to original sendMessage
  7489. return origSendMessage.call(this);
  7490. };
  7491. // Also handle ask_user events in the main chat SSE stream
  7492. const origSSEParseLine = null; // We need to modify the sendMessage SSE handler
  7493. // Patch: intercept ask_user events in main sendMessage flow
  7494. // This is done by modifying the sendMessage function's SSE parsing
  7495. // The easiest approach: also check for ask_user in the main sendMessage
  7496. // Let's patch it by adding ask_user handling to the main SSE loop
  7497. // ===================== SPECIAL TAB HELPERS =====================
  7498. /** Open workflow DAG tab */
  7499. function openWorkflowTab(workflowData, title) {
  7500. openSpecialTab('__workflow__', 'workflow', title || 'Workflow DAG', workflowData);
  7501. }
  7502. /** Open metadata visualization tab */
  7503. function openMetadataTab(metaData, title) {
  7504. if (currentMode === 'meta') {
  7505. showModeIframe('metadata', '/metadata-viewer.html', async () =>
  7506. metaData ? { type: 'loadMetadata', data: metaData } : null
  7507. );
  7508. } else {
  7509. _setMapIndicator(!!metaData);
  7510. }
  7511. }
  7512. /** Send message to a special tab iframe */
  7513. function postToSpecialTab(key, message) {
  7514. const iframe = $('iframeContainer').querySelector(`iframe[data-tab="${key}"]`);
  7515. if (iframe?.contentWindow) iframe.contentWindow.postMessage(message, '*');
  7516. }
  7517. /** Update workflow node status (called during workflow execution) */
  7518. function updateWorkflowNode(nodeId, status) {
  7519. const msg = { type: 'updateNodeStatus', nodeId, status };
  7520. // Try both special-tab and mode-iframe keys
  7521. postToSpecialTab('__workflow__', msg);
  7522. sendToWorkflowIframe(msg);
  7523. }
  7524. // ===================== WORKFLOW MANAGEMENT =====================
  7525. let cachedWorkflows = null;
  7526. let activeWorkflowName = null;
  7527. let _selectedCodegenWorkflow = 'parallel'; // server-persisted default
  7528. const CODEGEN_WORKFLOWS = {
  7529. 'parallel': { label: 'Parallel', desc: 'Default: Theme.vth + fully parallel VL fanout', file: 'parallel-codegen' },
  7530. 'meta-direct': { label: 'Meta-Direct', desc: 'Small projects: direct ProjectMeta + parallel VL fanout', file: 'meta-direct-codegen' },
  7531. '3-file': { label: '3-File', desc: 'Medium: PRD + ServiceMap + UIMap + Theme.vth', file: '3-file-codegen' },
  7532. '6-file': { label: '6-File', desc: 'Medium-large: 6 specs + Theme.vth + parallel VL fanout', file: '6-file-codegen' },
  7533. '9-file': { label: '9-File', desc: 'Large: 9 specs + Theme.vth + parallel VL fanout', file: '9-file-codegen' },
  7534. };
  7535. const ADJUST_WORKFLOWS = {
  7536. 'add-page': { label: 'Add Page', desc: 'Add new page: section + components + route', file: 'add-page' },
  7537. 'add-service': { label: 'Add Service', desc: 'Add new backend service domain + DB schema', file: 'add-service' },
  7538. 'theme-customize': { label: 'Theme', desc: 'Customize theme tokens + cascade updates', file: 'theme-customize' },
  7539. 'general': { label: 'General', desc: 'General changes via Meta diff + affected file regen', file: 'incremental-update' },
  7540. };
  7541. function toggleWorkflowPanel() {
  7542. const dd = $('wfDropdown');
  7543. dd.classList.toggle('open');
  7544. if (dd.classList.contains('open')) {
  7545. renderCodegenOptions();
  7546. renderAdjustOptions();
  7547. loadWorkflowList();
  7548. }
  7549. }
  7550. function toggleWfAllList() {
  7551. const list = $('wfList');
  7552. const toggle = $('wfAllToggle');
  7553. if (list.style.display === 'none') {
  7554. list.style.display = 'block';
  7555. toggle.innerHTML = '&#9660;';
  7556. } else {
  7557. list.style.display = 'none';
  7558. toggle.innerHTML = '&#9654;';
  7559. }
  7560. }
  7561. function renderCodegenOptions() {
  7562. const container = $('wfCodegenOptions');
  7563. container.innerHTML = '';
  7564. for (const [key, info] of Object.entries(CODEGEN_WORKFLOWS)) {
  7565. const div = document.createElement('div');
  7566. div.className = 'wf-item';
  7567. div.style.cursor = 'pointer';
  7568. if (key === _selectedCodegenWorkflow) {
  7569. div.style.background = 'var(--accent)';
  7570. div.style.color = '#fff';
  7571. div.style.borderRadius = '4px';
  7572. }
  7573. div.innerHTML = `<div style="flex:1"><span class="wf-name" style="${key === _selectedCodegenWorkflow ? 'color:#fff' : ''}">${info.label}</span><div style="font-size:9px;color:${key === _selectedCodegenWorkflow ? 'rgba(255,255,255,0.7)' : 'var(--text2)'};margin-top:2px;">${info.desc}</div></div><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${info.file}')" style="${key === _selectedCodegenWorkflow ? 'color:rgba(255,255,255,0.8)' : ''}">View</span>`;
  7574. div.onclick = () => selectCodegenWorkflow(key);
  7575. container.appendChild(div);
  7576. }
  7577. }
  7578. function renderAdjustOptions() {
  7579. const container = $('wfAdjustOptions');
  7580. container.innerHTML = '';
  7581. for (const [key, info] of Object.entries(ADJUST_WORKFLOWS)) {
  7582. const div = document.createElement('div');
  7583. div.className = 'wf-item';
  7584. div.style.cursor = 'pointer';
  7585. div.innerHTML = `<div style="flex:1"><span class="wf-name">${info.label}</span><div style="font-size:9px;color:var(--text2);margin-top:2px;">${info.desc}</div></div><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${info.file}')">View</span>`;
  7586. div.onclick = () => { $('wfDropdown').classList.remove('open'); setStatus(`Adjustment workflow: ${info.label} (used automatically by VLAdjust tool)`, 'green'); };
  7587. container.appendChild(div);
  7588. }
  7589. }
  7590. async function selectCodegenWorkflow(key) {
  7591. _selectedCodegenWorkflow = key;
  7592. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[key].label;
  7593. $('wfDropdown').classList.remove('open');
  7594. // Persist on server
  7595. try { await api('/api/workflow-selection', { method: 'POST', body: JSON.stringify({ workflow: key }), headers: { 'Content-Type': 'application/json' } }); } catch {}
  7596. // Also update local binding
  7597. workflowBindings.generate = CODEGEN_WORKFLOWS[key].file;
  7598. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
  7599. setStatus(`Codegen workflow: ${CODEGEN_WORKFLOWS[key].label}`, 'green');
  7600. }
  7601. async function loadCodegenWorkflowSelection() {
  7602. try {
  7603. const data = await api('/api/workflow-selection');
  7604. if (data.defaultWorkflow && CODEGEN_WORKFLOWS[data.defaultWorkflow]) {
  7605. _selectedCodegenWorkflow = data.defaultWorkflow;
  7606. $('wfSelectorLabel').textContent = CODEGEN_WORKFLOWS[data.defaultWorkflow].label;
  7607. workflowBindings.generate = CODEGEN_WORKFLOWS[data.defaultWorkflow].file;
  7608. }
  7609. } catch {}
  7610. }
  7611. // Close workflow panel on click outside
  7612. document.addEventListener('click', e => {
  7613. if (!e.target.closest('.wf-selector')) $('wfDropdown').classList.remove('open');
  7614. });
  7615. async function loadWorkflowList() {
  7616. try {
  7617. const data = await api('/api/workflows');
  7618. cachedWorkflows = data.workflows;
  7619. const list = $('wfList');
  7620. list.innerHTML = '';
  7621. if (!data.workflows.length) {
  7622. list.innerHTML = '<div style="padding:8px 10px;font-size:10px;color:var(--text2);">No workflows found</div>';
  7623. return;
  7624. }
  7625. for (const wf of data.workflows) {
  7626. const div = document.createElement('div');
  7627. div.className = 'wf-item';
  7628. if (wf.name === activeWorkflowName) div.style.background = 'var(--bg3)';
  7629. div.innerHTML = `<span class="wf-name">${escapeHtml(wf.title || wf.name)}</span><span class="wf-steps">${wf.stepCount} steps</span><span class="wf-view" onclick="event.stopPropagation();viewWorkflow('${escapeHtml(wf.name)}')">View</span>`;
  7630. div.onclick = () => { activeWorkflowName = wf.name; $('wfDropdown').classList.remove('open'); setStatus('Workflow: ' + (wf.title || wf.name), 'green'); };
  7631. list.appendChild(div);
  7632. }
  7633. } catch { $('wfList').innerHTML = '<div style="padding:8px;font-size:10px;color:var(--red)">Failed to load</div>'; }
  7634. }
  7635. async function viewWorkflow(name) {
  7636. try {
  7637. const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
  7638. const wf = data.workflow || (data.steps ? data : null);
  7639. if (wf) {
  7640. // Switch to Flow tab and load the workflow for viewing
  7641. if (currentMode !== 'flow') switchMode('flow');
  7642. showModeIframe('workflow', '/workflow-editor.html', async () => {
  7643. return { type: 'loadWorkflow', data: wf, workflowName: name };
  7644. });
  7645. }
  7646. } catch (e) { setStatus('Failed to load workflow', 'red'); }
  7647. $('wfDropdown').classList.remove('open');
  7648. }
  7649. async function viewMetadataTab() {
  7650. $('wfDropdown').classList.remove('open');
  7651. try {
  7652. // Check if VL project first
  7653. const proj = await api('/api/project');
  7654. if (!proj.isVL) {
  7655. setStatus('No VL files — cannot extract metadata', 'yellow');
  7656. switchMode('meta');
  7657. openMetadataTab(null, 'Project Meta');
  7658. return;
  7659. }
  7660. let data = await api('/api/metadata');
  7661. if (!_hasRenderableMetadata(data.meta)) {
  7662. setStatus('Extracting metadata...', 'yellow');
  7663. data = await api('/api/metadata/extract');
  7664. if (data.meta) setStatus('Metadata extracted', 'green');
  7665. else setStatus('No metadata found', 'yellow');
  7666. }
  7667. switchMode('meta');
  7668. openMetadataTab(data.meta || null, 'Project Meta');
  7669. } catch {
  7670. switchMode('meta');
  7671. openMetadataTab(null, 'Project Meta');
  7672. }
  7673. }
  7674. // ===================== MODE TABS =====================
  7675. /** Show/hide green dot on Map tab indicating metadata is available */
  7676. function _setMapIndicator(show) {
  7677. const dot = $('mapReadyDot');
  7678. if (dot) dot.style.display = show ? 'inline-block' : 'none';
  7679. }
  7680. function _hasRenderableMetadata(meta) {
  7681. if (!meta || typeof meta !== 'object') return false;
  7682. if ((meta.apps || []).length > 0) return true;
  7683. if ((meta.sections || []).length > 0) return true;
  7684. if ((meta.components || []).length > 0) return true;
  7685. if ((meta.services || meta.serviceDomains || []).length > 0) return true;
  7686. if ((meta.dataSchema?.tables || meta.tables || meta.database?.tables || []).length > 0) return true;
  7687. return false;
  7688. }
  7689. function buildDocCenterEmbedSrc({ embed = 'ide', docId = null, force = false } = {}) {
  7690. const params = new URLSearchParams();
  7691. if (embed) params.set('embed', embed);
  7692. const normalizedDocId = normalizeDocRefInput(docId);
  7693. if (normalizedDocId) params.set('docId', String(normalizedDocId));
  7694. if (force) params.set('t', String(Date.now()));
  7695. return `/doc-center.html?${params.toString()}`;
  7696. }
  7697. async function resolveDocCenterEmbedSrc({ force = false, embed = 'ide', docId = _docCenterFocusDocId } = {}) {
  7698. return buildDocCenterEmbedSrc({ embed, docId, force });
  7699. }
  7700. async function showDocCenterMode(docId = _docCenterFocusDocId) {
  7701. $('editorTabs').style.display = 'none';
  7702. $('cmEditorWrap').style.display = 'none';
  7703. $('editor').style.display = 'none';
  7704. $('codePreview').style.display = 'none';
  7705. $('mdPreview').style.display = 'none';
  7706. $('editorPlaceholder').style.display = 'none';
  7707. $('iframeContainer').style.display = 'block';
  7708. const src = await resolveDocCenterEmbedSrc({ docId });
  7709. showModeIframe('docs', src, async () => null);
  7710. setStatus('Documentation ready', 'green');
  7711. }
  7712. /** Switch between Code / Map / Flow modes */
  7713. function switchMode(mode) {
  7714. // Clear map indicator when user visits Map tab
  7715. if (mode === 'meta') _setMapIndicator(false);
  7716. currentMode = mode;
  7717. // Update mode tab styling
  7718. document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode));
  7719. if (mode === 'code') {
  7720. // Show normal editor tabs + content — Code tab shows the selected file
  7721. $('editorTabs').style.display = 'flex';
  7722. $('iframeContainer').style.display = 'none';
  7723. $('codePreview').style.display = 'none';
  7724. $('mdPreview').style.display = 'none';
  7725. if (currentFile && openFiles.has(currentFile)) {
  7726. showTabContent(currentFile);
  7727. } else if (openFiles.size > 0) {
  7728. const keys = [...openFiles.keys()].filter(k => openFiles.get(k).type === 'file');
  7729. if (keys.length > 0) { currentFile = keys[keys.length - 1]; renderTabs(); showTabContent(currentFile); }
  7730. else { $('cmEditorWrap').style.display = 'none'; $('editor').style.display = 'none'; $('editorPlaceholder').style.display = 'block'; }
  7731. } else {
  7732. $('cmEditorWrap').style.display = 'none';
  7733. $('editor').style.display = 'none';
  7734. $('editorPlaceholder').style.display = 'block';
  7735. }
  7736. } else if (mode === 'meta') {
  7737. // Show metadata viewer
  7738. $('editorTabs').style.display = 'none';
  7739. $('cmEditorWrap').style.display = 'none';
  7740. $('editor').style.display = 'none';
  7741. $('codePreview').style.display = 'none';
  7742. $('mdPreview').style.display = 'none';
  7743. $('editorPlaceholder').style.display = 'none';
  7744. $('iframeContainer').style.display = 'block';
  7745. showModeIframe('metadata', '/metadata-viewer.html', async () => {
  7746. try {
  7747. // Check if this is a VL project first
  7748. const proj = await api('/api/project');
  7749. if (!proj.isVL) {
  7750. setStatus('No VL files — metadata extraction skipped', 'yellow');
  7751. return null;
  7752. }
  7753. // Try loading existing metadata
  7754. let data = await api('/api/metadata');
  7755. // If no metadata, auto-extract from VL files
  7756. if (!_hasRenderableMetadata(data.meta)) {
  7757. setStatus('Extracting metadata from VL files...', 'yellow');
  7758. data = await api('/api/metadata/extract');
  7759. if (data.meta) setStatus('Metadata extracted', 'green');
  7760. else setStatus('No metadata to extract', 'yellow');
  7761. }
  7762. return data.meta ? { type: 'loadMetadata', data: data.meta } : null;
  7763. } catch { return null; }
  7764. });
  7765. } else if (mode === 'flow') {
  7766. // Show workflow editor with Gen/Adjust toolbar
  7767. $('editorTabs').style.display = 'none';
  7768. $('previewBar').style.display = 'none';
  7769. $('cmEditorWrap').style.display = 'none';
  7770. $('editor').style.display = 'none';
  7771. $('editorPlaceholder').style.display = 'none';
  7772. $('flowToolbar').style.display = 'flex';
  7773. $('iframeContainer').style.display = 'block';
  7774. populateFlowWorkflowSelect();
  7775. loadActiveFlowWorkflow();
  7776. } else if (mode === 'docs') {
  7777. showDocCenterMode();
  7778. } else if (mode === 'preview') {
  7779. // Show live preview
  7780. $('editorTabs').style.display = 'none';
  7781. $('cmEditorWrap').style.display = 'none';
  7782. $('editor').style.display = 'none';
  7783. $('editorPlaceholder').style.display = 'none';
  7784. $('previewBar').style.display = 'flex';
  7785. $('iframeContainer').style.display = 'block';
  7786. loadPreviewApp();
  7787. }
  7788. // Hide bars when not in their respective modes
  7789. if (mode !== 'preview') $('previewBar').style.display = 'none';
  7790. if (mode !== 'flow') $('flowToolbar').style.display = 'none';
  7791. }
  7792. // ===================== PREVIEW =====================
  7793. let previewUrls = {}; // { appId: url }
  7794. /** Activate preview mode with URLs */
  7795. function activatePreview(urls) {
  7796. previewUrls = urls || {};
  7797. const keys = Object.keys(previewUrls);
  7798. if (keys.length === 0) {
  7799. $('previewUrlsPanel').style.display = 'none';
  7800. $('previewUrlLabel').textContent = '';
  7801. return;
  7802. }
  7803. // Show the Preview tab
  7804. $('previewModeTab').style.display = '';
  7805. // Populate app selector in preview bar
  7806. const sel = $('previewAppSelect');
  7807. sel.innerHTML = '';
  7808. for (const [appId, url] of Object.entries(previewUrls)) {
  7809. sel.innerHTML += `<option value="${appId}">${appId}</option>`;
  7810. }
  7811. sel.value = keys[0];
  7812. $('previewUrlLabel').textContent = previewUrls[keys[0]] || '';
  7813. // Populate sidebar preview URL list
  7814. const list = $('previewUrlsList');
  7815. list.innerHTML = '';
  7816. for (const [appId, url] of Object.entries(previewUrls)) {
  7817. const item = document.createElement('div');
  7818. item.className = 'preview-url-item';
  7819. item.innerHTML = `<span class="pui-name">${escapeHtml(appId)}</span><span class="pui-url">${escapeHtml(url)}</span>`;
  7820. item.onclick = () => { window.open(url, '_blank'); };
  7821. item.title = `Open ${url} in new tab`;
  7822. list.appendChild(item);
  7823. }
  7824. $('previewUrlsPanel').style.display = 'block';
  7825. // NOTE: Do NOT auto-switch to preview mode — user opens preview manually via Preview tab or sidebar links
  7826. }
  7827. function loadPreviewApp() {
  7828. const appId = $('previewAppSelect').value;
  7829. const url = previewUrls[appId];
  7830. if (!url) return;
  7831. $('previewUrlLabel').textContent = url;
  7832. // Always open in browser — no iframe embedding (cross-origin blocked by VL platform)
  7833. window.open(url, '_blank');
  7834. }
  7835. function refreshPreview() {
  7836. // Re-open current preview app in browser
  7837. const appId = $('previewAppSelect')?.value;
  7838. const url = previewUrls[appId];
  7839. if (url) window.open(url, '_blank');
  7840. }
  7841. function openPreviewExternal() {
  7842. const appId = $('previewAppSelect').value;
  7843. const url = previewUrls[appId];
  7844. if (url) window.open(url, '_blank');
  7845. }
  7846. /** Load preview URLs from project profile (saved by VLParse) */
  7847. async function loadPreviewUrlsFromProfile() {
  7848. try {
  7849. const profile = normalizeProjectProfile(await api('/api/profile'));
  7850. if (profile.previewUrls && Object.keys(profile.previewUrls).length > 0) {
  7851. activatePreview(profile.previewUrls);
  7852. }
  7853. } catch {}
  7854. }
  7855. /** Show/create a mode iframe (reused across switches) */
  7856. function showModeIframe(type, src, getDataFn) {
  7857. const container = $('iframeContainer');
  7858. const key = `__mode_${type}__`;
  7859. const resolvedSrc = new URL(src, window.location.href).href;
  7860. // Hide all iframes
  7861. [...container.children].forEach(f => f.style.display = 'none');
  7862. let iframe = container.querySelector(`iframe[data-tab="${key}"]`);
  7863. const onLoad = async () => {
  7864. const msg = await getDataFn();
  7865. if (msg) iframe.contentWindow.postMessage(msg, '*');
  7866. };
  7867. if (!iframe) {
  7868. iframe = document.createElement('iframe');
  7869. iframe.dataset.tab = key;
  7870. iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups';
  7871. iframe.onload = onLoad;
  7872. iframe.src = src;
  7873. container.appendChild(iframe);
  7874. } else {
  7875. iframe.style.display = 'block';
  7876. iframe.onload = onLoad;
  7877. if (iframe.src !== resolvedSrc) {
  7878. iframe.src = src;
  7879. } else {
  7880. // Refresh data
  7881. getDataFn().then(msg => { if (msg) iframe.contentWindow.postMessage(msg, '*'); });
  7882. }
  7883. }
  7884. iframe.style.display = 'block';
  7885. }
  7886. // ===================== FOLDER PATH =====================
  7887. function openFolderInFinder() {
  7888. if (!currentWorkDir) return;
  7889. // Use backend to open folder (cross-platform)
  7890. fetch('/api/open-folder', {
  7891. method: 'POST',
  7892. headers: { 'Content-Type': 'application/json' },
  7893. body: JSON.stringify({ path: currentWorkDir }),
  7894. }).catch(() => {});
  7895. }
  7896. // ===================== WORKFLOW BINDINGS =====================
  7897. function loadWorkflowBindings() {
  7898. try {
  7899. const saved = localStorage.getItem('vl-code-wf-bindings');
  7900. if (saved) workflowBindings = JSON.parse(saved);
  7901. } catch {}
  7902. // Also load server-persisted codegen selection
  7903. loadCodegenWorkflowSelection();
  7904. }
  7905. function saveWorkflowBindings() {
  7906. localStorage.setItem('vl-code-wf-bindings', JSON.stringify(workflowBindings));
  7907. }
  7908. // Listen for messages from embedded iframes
  7909. window.addEventListener('message', (e) => {
  7910. if (!e.data?.type) return;
  7911. if (e.data.type === 'nodeClick') {
  7912. // User clicked a node in workflow DAG — could highlight related file
  7913. setStatus(`Node: ${e.data.nodeId || e.data.nodeName || 'unknown'}`, 'green');
  7914. }
  7915. if (e.data.type === 'metaNodeClick') {
  7916. // User clicked a node in metadata graph — could navigate to related file
  7917. setStatus(`Meta: ${e.data.nodeType}/${e.data.nodeName || 'unknown'}`, 'green');
  7918. }
  7919. });
  7920. // Extend SSE handler for workflow/metadata events
  7921. const origConnectSSE = connectSSE;
  7922. connectSSE = function() {
  7923. origConnectSSE(); // proper setup: sets _sseSource, reconnect, base handlers
  7924. const es = _sseSource;
  7925. if (!es) return;
  7926. // On connect/reconnect: sync running workflow state from server so all tabs stay consistent
  7927. setTimeout(async () => {
  7928. try {
  7929. const state = await api('/api/workflow/current-state');
  7930. if (state.active && state.workflowName) {
  7931. _workflowActive = true;
  7932. _lastWorkflowName = state.workflowName;
  7933. window._skipFlowAutoLoad = true;
  7934. const wn = state.workflowName;
  7935. const syncRunToken = state.clientRunToken || state.runID || `state:${wn}`;
  7936. if (wn.startsWith('autotest')) switchFlowTab('autotest');
  7937. else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
  7938. else switchFlowTab('adjust');
  7939. await populateFlowWorkflowSelect();
  7940. _setFlowWfSelectOrStore(wn, $('flowWfSelect'));
  7941. updateFlowWfList();
  7942. await loadFlowWorkflow(wn);
  7943. forwardWorkflowEventToIframe('workflow_start', {
  7944. workflowName: wn,
  7945. name: wn,
  7946. runID: state.runID || null,
  7947. clientRunToken: syncRunToken,
  7948. });
  7949. if (state.checkpoint) {
  7950. sendToWorkflowIframe({
  7951. type: 'setCheckpoint',
  7952. checkpoint: state.checkpoint,
  7953. runID: state.runID || null,
  7954. clientRunToken: syncRunToken,
  7955. });
  7956. }
  7957. // Replay node statuses into DAG
  7958. for (const [nodeId, status] of Object.entries(state.nodeStatuses || {})) {
  7959. sendToWorkflowIframe({
  7960. type: 'updateNodeStatus',
  7961. nodeId,
  7962. status,
  7963. runID: state.runID || null,
  7964. clientRunToken: syncRunToken,
  7965. });
  7966. }
  7967. updateChatStatusBar(`Running workflow: ${wn}...`, '');
  7968. } else if (!state.active) {
  7969. window._skipFlowAutoLoad = false;
  7970. // Check localStorage for last workflow to restore view (not state) after refresh
  7971. try {
  7972. const saved = localStorage.getItem('vl-code-last-flow-wf');
  7973. if (saved) {
  7974. const { name, tab } = JSON.parse(saved);
  7975. if (name && tab) {
  7976. switchFlowTab(tab);
  7977. await loadFlowWorkflow(name);
  7978. }
  7979. localStorage.removeItem('vl-code-last-flow-wf');
  7980. }
  7981. } catch {}
  7982. }
  7983. } catch {}
  7984. }, 800);
  7985. function ensureWorkflowBroadcastChat(workflowName, meta = {}) {
  7986. const existing = window._wfBroadcastChatEl;
  7987. if (existing && document.body.contains(existing)) return existing;
  7988. const wfEl = addMsg('assistant', '');
  7989. wfEl.dataset.wfChat = 'true';
  7990. const header = `<div style="font-size:11px;color:var(--blue);padding:4px 0;font-weight:600;">▶ Workflow: ${escapeHtml(workflowName || 'running')}${meta.stepCount ? ' (' + escapeHtml(String(meta.stepCount)) + ' steps)' : ''}${meta.model ? ' — ' + escapeHtml(meta.model) : ''}</div>`;
  7991. wfEl.querySelector('.content-text').innerHTML = header;
  7992. window._wfBroadcastChatEl = wfEl;
  7993. scrollChat();
  7994. return wfEl;
  7995. }
  7996. function appendWorkflowBroadcastChatLine(text, style = '', lineId = '') {
  7997. const wfEl = ensureWorkflowBroadcastChat(_lastWorkflowName || 'running');
  7998. if (!wfEl) return null;
  7999. const body = wfEl.querySelector('.content-text');
  8000. if (!body) return null;
  8001. if (lineId) {
  8002. const existing = document.getElementById(lineId);
  8003. if (existing) return existing;
  8004. }
  8005. const line = document.createElement('div');
  8006. if (lineId) line.id = lineId;
  8007. line.className = 'wf-chat-step';
  8008. line.style.cssText = style || 'font-size:11px;color:var(--text2);padding:2px 0;';
  8009. line.textContent = text;
  8010. body.appendChild(line);
  8011. scrollChat();
  8012. return line;
  8013. }
  8014. // Add extended handlers (workflow/autotest/metadata) — additive via addEventListener
  8015. es.addEventListener('message', (e) => {
  8016. try {
  8017. const data = JSON.parse(e.data);
  8018. // ── Workflow execution start (from VLGenerate / WorkflowRun tools) ──
  8019. // Auto-switch to Flow tab + load the workflow DAG for live visualization
  8020. if (data.type === 'workflow_execution_start') {
  8021. _workflowActive = true;
  8022. addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || 'unknown'}`, null, 'info');
  8023. updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
  8024. if (data.workflow) {
  8025. // Set _skipFlowAutoLoad flag to prevent switchMode('flow') from loading a different workflow
  8026. window._skipFlowAutoLoad = true;
  8027. // Load the actual workflow JSON into the Flow tab
  8028. showModeIframe('workflow', '/workflow-editor.html', async () => {
  8029. return { type: 'loadWorkflow', data: data.workflow, workflowName: data.workflowName || null };
  8030. });
  8031. // Clear previous node statuses after iframe loads
  8032. setTimeout(() => sendToWorkflowIframe({ type: 'clearStatus' }), 300);
  8033. // Keep _skipFlowAutoLoad true while workflow is active (cleared on workflow_done/error)
  8034. }
  8035. }
  8036. // ── Workflow node status updates ──
  8037. // Detail panel: ALL node updates (full log)
  8038. // Chat: only status bar update (no duplication)
  8039. // Flow DAG: highlight animation
  8040. if (data.type === 'workflow_node_update') {
  8041. updateWorkflowNode(data.nodeId, data.status);
  8042. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: data.nodeId, status: data.status, runID: data.runID || null, clientRunToken: data.clientRunToken || null });
  8043. // Skip addDetailEntry here — wf_node_start/done/error handlers create step cards instead
  8044. // Only keep detail entries for autotest case-level updates (have caseId)
  8045. if (data.caseId) {
  8046. const nodeLabel = (data.nodeId || '').replace(/_/g, ' ');
  8047. const statusIcon = data.status === 'done' ? '>' : data.status === 'error' ? 'x' : data.status === 'running' ? '>' : '-';
  8048. const detailType = data.status === 'error' ? 'error' : data.status === 'done' ? 'success' : 'info';
  8049. addDetailEntry('node', `${statusIcon} [${data.caseId}] ${nodeLabel} [${data.status}]`, null, detailType, { depth: 2 });
  8050. }
  8051. // Auto-load test-case sub-workflow when step nodes start running
  8052. // STEP_xxx nodes indicate per-step progress — auto-switch to that test case's workflow
  8053. if (data.caseId && data.nodeId?.startsWith('STEP_') && data.status === 'running' && data.nodeId === 'STEP_001') {
  8054. const tcWfName = `autotest-tc-${(data.caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  8055. loadWorkflowIntoFlowTab(tcWfName);
  8056. // Also select it in the autotest hierarchy
  8057. _atActiveLevel = 'testcase'; _atActiveCase = data.caseId;
  8058. const matchApp = _atApps.find(a => (a.cases || []).some(c => c.id === data.caseId));
  8059. if (matchApp) _atActiveApp = matchApp.appId;
  8060. updateAtWfList();
  8061. }
  8062. }
  8063. // ── Workflow completed/failed (legacy broadcast — kept for backward compat, wf_done/wf_error are preferred) ──
  8064. if (data.type === 'workflow_done' && !_workflowActive) {
  8065. // Only handle if wf_done hasn't already processed it
  8066. _setMapIndicator(true);
  8067. }
  8068. if (data.type === 'workflow_error' && !_workflowActive) {
  8069. // Already handled by wf_error
  8070. }
  8071. // ── Rich workflow broadcast events (wf_*) — show step cards from ANY tab ──
  8072. if (data.type === 'wf_start') {
  8073. forwardWorkflowEventToIframe('workflow_start', data);
  8074. _workflowActive = true;
  8075. _lastWorkflowName = data.workflowName || '';
  8076. _lastRunCheckpoint = null;
  8077. for (const k in _stepCards) delete _stepCards[k];
  8078. setChatStatusRunning(true);
  8079. updateChatStatusBar(`Running workflow: ${data.workflowName || ''}...`, '');
  8080. addDetailEntry('workflow', `▶ Workflow started: ${data.workflowName || ''} (${data.stepCount || '?'} steps) [${data.model || ''}]`, null, 'info');
  8081. ensureWorkflowBroadcastChat(data.workflowName || '', { stepCount: data.stepCount || '?', model: data.model || '' });
  8082. // Switch to correct sub-tab based on workflow name
  8083. const wn = data.workflowName || '';
  8084. if (wn.startsWith('autotest')) switchFlowTab('autotest');
  8085. else if (wn.includes('codegen') || wn.includes('parallel') || wn.includes('generate')) switchFlowTab('generate');
  8086. else switchFlowTab('adjust');
  8087. // Persist running workflow for refresh-restore
  8088. try { localStorage.setItem('vl-code-last-flow-wf', JSON.stringify({ name: wn, tab: currentFlowTab })); } catch {}
  8089. // Keep workflow data refreshed in the background without stealing focus
  8090. if (_lastWorkflowName) loadWorkflowIntoFlowTab(_lastWorkflowName);
  8091. }
  8092. if (data.type === 'wf_node_start') {
  8093. forwardWorkflowEventToIframe('node_start', data);
  8094. const nodeType = data.nodeType || '';
  8095. const nodeTitle = data.title || data.nodeId || '?';
  8096. addStepCard(data.nodeId, nodeType, nodeTitle, data.resolvedInputs || data.input);
  8097. updateChatStatusBar(`Running ${nodeTitle}...`, '');
  8098. updateWorkflowNode(data.nodeId, 'running');
  8099. appendWorkflowBroadcastChatLine(
  8100. `▶ ${nodeType ? '[' + nodeType + '] ' : ''}${nodeTitle}`,
  8101. 'font-size:11px;color:var(--text2);padding:2px 0;',
  8102. `wf-bc-step-${data.nodeId}`
  8103. );
  8104. }
  8105. if (data.type === 'wf_node_done') {
  8106. forwardWorkflowEventToIframe('node_done', data);
  8107. completeStepCard(data.nodeId, data.outputs || data.output, data.selected, data.duration_ms);
  8108. updateWorkflowNode(data.nodeId, 'done');
  8109. // Update step line in chat
  8110. const chatStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
  8111. if (chatStep) {
  8112. chatStep.style.color = 'var(--green)';
  8113. chatStep.textContent = chatStep.textContent.replace('▶', '✓');
  8114. }
  8115. }
  8116. if (data.type === 'wf_node_error') {
  8117. forwardWorkflowEventToIframe('node_error', data);
  8118. errorStepCard(data.nodeId, data.error || 'Unknown error', data.duration_ms);
  8119. updateWorkflowNode(data.nodeId, 'error');
  8120. // Update step line in chat
  8121. const errStep = document.getElementById(`wf-bc-step-${data.nodeId}`);
  8122. if (errStep) {
  8123. errStep.style.color = 'var(--red)';
  8124. errStep.textContent = errStep.textContent.replace('▶', '✗') + ' — ' + (data.error || '');
  8125. }
  8126. }
  8127. if (data.type === 'wf_node_skipped') {
  8128. forwardWorkflowEventToIframe('node_skipped', data);
  8129. addDetailEntry('node', `⊘ ${data.nodeId || '?'} skipped`, null, 'info', { depth: 1 });
  8130. updateWorkflowNode(data.nodeId, 'skipped');
  8131. }
  8132. if (data.type === 'wf_file_start') {
  8133. addDetailEntry('file', `📄 Writing: ${data.path || '?'}`, null, 'info', { depth: 1 });
  8134. appendWorkflowBroadcastChatLine(`📄 Writing ${data.path || '?'}`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  8135. }
  8136. if (data.type === 'wf_file_done') {
  8137. addDetailEntry('file', `✓ Written: ${data.path || '?'}`, null, 'success', { depth: 1 });
  8138. const runStep = getCurrentRunningStepID();
  8139. if (runStep) addFileToStepCard(runStep, data.path || '?');
  8140. appendWorkflowBroadcastChatLine(`✓ Wrote ${data.path || '?'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8141. }
  8142. if (data.type === 'wf_llm_thinking') {
  8143. appendToStreamBox(`wf-thinking-${data.stepId || 'main'}`, '💭 Thinking', data.delta || '');
  8144. }
  8145. if (data.type === 'wf_llm_tool_use') {
  8146. const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  8147. addDetailEntry('tool-call', `🔧 ${data.name || 'unknown'}`, toolInput, 'info', { depth: 1 });
  8148. updateChatStatusBar(`Tool: ${data.name || '?'}`, '');
  8149. appendWorkflowBroadcastChatLine(`🔧 Tool: ${data.name || 'unknown'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
  8150. }
  8151. if (data.type === 'wf_llm_tool_result') {
  8152. const isErr = data.is_error || false;
  8153. addDetailEntry('tool-result', `${isErr ? '✗' : '✓'} Result`, data.content || null, isErr ? 'error' : 'success', { depth: 1 });
  8154. appendWorkflowBroadcastChatLine(
  8155. `${isErr ? '✗' : '✓'} Tool result`,
  8156. `font-size:10px;color:${isErr ? 'var(--red)' : 'var(--green)'};padding:1px 0 1px 14px;`
  8157. );
  8158. }
  8159. if (data.type === 'wf_tool_start') {
  8160. forwardWorkflowEventToIframe('tool_start', data);
  8161. const toolInput = data.input ? (typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2)) : null;
  8162. addDetailEntry('tool-call', `🛠 ${data.name || data.stepId || 'tool'}`, toolInput, 'info', { depth: 1 });
  8163. appendWorkflowBroadcastChatLine(`🛠 Tool step: ${data.name || 'tool'}`, 'font-size:10px;color:var(--accent);padding:1px 0 1px 14px;');
  8164. }
  8165. if (data.type === 'wf_tool_done') {
  8166. forwardWorkflowEventToIframe('tool_done', data);
  8167. const toolOutput = data.output ? (typeof data.output === 'string' ? data.output : JSON.stringify(data.output, null, 2)) : null;
  8168. addDetailEntry('tool-result', `✓ ${data.name || data.stepId || 'tool'}`, toolOutput, 'success', { depth: 1 });
  8169. appendWorkflowBroadcastChatLine(`✓ Tool step done: ${data.name || 'tool'}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8170. }
  8171. if (data.type === 'wf_tool_error') {
  8172. forwardWorkflowEventToIframe('tool_error', data);
  8173. addDetailEntry('tool-result', `✗ ${data.name || data.stepId || 'tool'}${data.allowError ? ' (continued)' : ''}`, data.error || null, data.allowError ? 'warn' : 'error', { depth: 1 });
  8174. appendWorkflowBroadcastChatLine(
  8175. `${data.allowError ? '⚠' : '✗'} Tool step error: ${data.name || 'tool'}`,
  8176. `font-size:10px;color:${data.allowError ? 'var(--orange)' : 'var(--red)'};padding:1px 0 1px 14px;`
  8177. );
  8178. }
  8179. if (data.type === 'wf_tool_message') {
  8180. forwardWorkflowEventToIframe('tool_message', data);
  8181. const toolDetail = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  8182. addDetailEntry('tool-call', `• ${data.name || data.stepId || 'tool'}: ${data.message || ''}`, toolDetail, data.level === 'error' ? 'error' : data.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  8183. appendWorkflowBroadcastChatLine(
  8184. `• ${data.name || 'tool'}: ${data.message || ''}`,
  8185. `font-size:10px;color:${data.level === 'error' ? 'var(--red)' : data.level === 'warn' ? 'var(--orange)' : 'var(--text2)'};padding:1px 0 1px 14px;`
  8186. );
  8187. }
  8188. if (data.type === 'wf_llm_done') {
  8189. flushStreamBoxes();
  8190. const mdl = data.model || '';
  8191. const usg = data.usage || {};
  8192. const parts = [mdl, usg.input_tokens ? `in:${usg.input_tokens}` : '', usg.output_tokens ? `out:${usg.output_tokens}` : '', data.latency_ms ? `${(data.latency_ms / 1000).toFixed(1)}s` : ''].filter(Boolean).join(' | ');
  8193. addDetailEntry('llm', `✓ LLM done — ${parts}`, null, 'success');
  8194. appendWorkflowBroadcastChatLine(`✓ LLM done${parts ? ' — ' + parts : ''}`, 'font-size:10px;color:var(--green);padding:1px 0 1px 14px;');
  8195. }
  8196. if (data.type === 'wf_llm_error') {
  8197. addDetailEntry('llm', `✗ LLM Error: ${data.error || 'unknown'}`, data, 'error');
  8198. appendWorkflowBroadcastChatLine(`✗ LLM error: ${data.error || 'unknown'}`, 'font-size:10px;color:var(--red);padding:1px 0 1px 14px;');
  8199. }
  8200. if (data.type === 'wf_var_changed') {
  8201. const vn = data.name || '?';
  8202. const vo = data.oldValue != null ? JSON.stringify(data.oldValue).slice(0, 80) : '—';
  8203. const vn2 = data.newValue != null ? JSON.stringify(data.newValue).slice(0, 80) : '—';
  8204. addDetailEntry('var', `📊 ${vn}: ${vo} → ${vn2}`, data, 'info', { depth: 1 });
  8205. appendWorkflowBroadcastChatLine(`📊 ${vn} updated`, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  8206. }
  8207. if (data.type === 'wf_text') {
  8208. addDetailEntry('workflow', data.text || '', null, 'info', { depth: 1 });
  8209. if (data.text) appendWorkflowBroadcastChatLine(data.text, 'font-size:10px;color:var(--text2);padding:1px 0 1px 14px;');
  8210. }
  8211. if (data.type === 'wf_token') {
  8212. // Streaming LLM tokens from VLGenerate-initiated workflows (broadcast path)
  8213. appendToStreamBox('wf-response-broadcast', '💬 Response', data.token || '');
  8214. }
  8215. if (data.type === 'wf_checkpoint') {
  8216. _lastRunCheckpoint = data.checkpoint || data;
  8217. addDetailEntry('checkpoint', `💾 Checkpoint: ${data.stepID || '?'} (${(data.completedSteps || []).length} done)`, null, 'info', { depth: 1 });
  8218. // Forward checkpoint to workflow-editor iframe for re-run support
  8219. sendToWorkflowIframe({ type: 'setCheckpoint', checkpoint: data.checkpoint || data, runID: data.runID, clientRunToken: data.clientRunToken || null });
  8220. }
  8221. if (data.type === 'wf_done') {
  8222. forwardWorkflowEventToIframe('workflow_done', data);
  8223. _workflowActive = false;
  8224. window._skipFlowAutoLoad = false;
  8225. try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
  8226. flushStreamBoxes();
  8227. setChatStatusRunning(false);
  8228. setStatus(`Workflow done: ${data.workflowName || ''}`, 'green');
  8229. addDetailEntry('workflow', `✅ Workflow completed: ${data.workflowName || ''}`, data, 'success');
  8230. _setMapIndicator(true);
  8231. // Show completion in main chat
  8232. if (window._wfBroadcastChatEl) {
  8233. const doneDiv = document.createElement('div');
  8234. doneDiv.style.cssText = 'font-size:11px;color:var(--green);padding:4px 0;font-weight:600;';
  8235. doneDiv.textContent = `✓ Workflow completed. ${data.filesWritten?.length || 0} files written.`;
  8236. window._wfBroadcastChatEl.querySelector('.content-text').appendChild(doneDiv);
  8237. scrollChat();
  8238. window._wfBroadcastChatEl = null;
  8239. } else {
  8240. addMsg('assistant', `**Workflow completed.** ${data.filesWritten?.length || 0} files written.`);
  8241. }
  8242. loadFileTree();
  8243. }
  8244. if (data.type === 'wf_error') {
  8245. forwardWorkflowEventToIframe('workflow_failed', data);
  8246. _workflowActive = false;
  8247. window._skipFlowAutoLoad = false;
  8248. try { localStorage.removeItem('vl-code-last-flow-wf'); } catch {}
  8249. flushStreamBoxes();
  8250. setChatStatusRunning(false);
  8251. setStatus(`Workflow error: ${data.error || ''}`, 'red');
  8252. addDetailEntry('workflow', `❌ Workflow failed: ${data.workflowName || ''} — ${data.error || ''}`, null, 'error');
  8253. // Show error in main chat
  8254. if (window._wfBroadcastChatEl) {
  8255. const errDiv = document.createElement('div');
  8256. errDiv.style.cssText = 'font-size:11px;color:var(--red);padding:4px 0;font-weight:600;';
  8257. errDiv.textContent = `✗ Workflow error: ${data.error || 'Unknown'}`;
  8258. window._wfBroadcastChatEl.querySelector('.content-text').appendChild(errDiv);
  8259. scrollChat();
  8260. window._wfBroadcastChatEl = null;
  8261. } else {
  8262. addMsg('assistant', `**Workflow error:** ${data.error || 'Unknown'}`);
  8263. }
  8264. }
  8265. // Metadata ready — show green dot on Map tab instead of stealing focus
  8266. if (data.type === 'metadata_ready' && data.meta) {
  8267. _setMapIndicator(true);
  8268. openMetadataTab(data.meta);
  8269. }
  8270. // ── AutoTest SSE events ──
  8271. // PRINCIPLE: Chat shows only high-level milestones (phase start/done, app workflows, case results, summary)
  8272. // Detail panel shows ALL granular info (server logs, LLM data, selectors, timing, node progress)
  8273. if (data.type === 'autotest_progress') {
  8274. const { phase, status, message } = data;
  8275. setStatus(`AutoTest: ${message}`, status === 'error' ? 'red' : status === 'done' ? 'green' : 'yellow');
  8276. if (status === 'running') {
  8277. $('chatStatusBar').style.display = 'flex';
  8278. if (!_chatStartTime) { _chatStartTime = Date.now(); _chatElapsedTimer = setInterval(updateChatElapsed, 1000); }
  8279. if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
  8280. _autotestChatBlock = null;
  8281. window._wfEngineBox = null; // Reset WF engine aggregation box
  8282. window._wfEngineTokens = 0;
  8283. window._wfEngineEvents = 0;
  8284. }
  8285. updateChatStatusBar(status === 'running' ? `Testing ${phase}...` : `Test ${phase} ${status}`, message);
  8286. if (status === 'done' || status === 'error') { if (phase === 'run') setChatStatusRunning(false); }
  8287. // Detail panel: full progress log
  8288. addDetailEntry('autotest', `[${phase}] ${message}`, null, status === 'error' ? 'error' : status === 'done' ? 'success' : 'info');
  8289. // Chat: only show phase milestones (start/done), not every intermediate step
  8290. if (status === 'done' || status === 'error' || (status === 'running' && (phase === 'generate' || phase === 'run'))) {
  8291. const icon = status === 'done' ? '>' : status === 'error' ? 'x' : '...';
  8292. _ensureAutotestChatBlock();
  8293. const stepEl = document.createElement('div');
  8294. stepEl.style.cssText = `font-size:11px;padding:2px 0;color:${status === 'error' ? 'var(--red)' : status === 'done' ? 'var(--green)' : 'var(--text2)'};${status === 'done' || status === 'error' ? 'font-weight:600;' : ''}`;
  8295. stepEl.textContent = `${icon} [${phase}] ${message}`;
  8296. _autotestChatBlock.appendChild(stepEl);
  8297. scrollChat();
  8298. }
  8299. if (status === 'running') {
  8300. _atPipelineStatus = 'running';
  8301. if (currentFlowTab !== 'autotest') switchFlowTab('autotest');
  8302. }
  8303. if (status === 'done' && phase === 'run') _atPipelineStatus = 'done';
  8304. if (status === 'error') _atPipelineStatus = 'error';
  8305. if (phase === 'run' && status === 'running') _atApps.forEach(a => { if (a.status !== 'done' && a.status !== 'error') a.status = 'running'; });
  8306. updateAtWfList();
  8307. }
  8308. // autotest_detail → Detail panel ONLY (server logs, LLM responses, selector info, timing)
  8309. if (data.type === 'autotest_detail') {
  8310. const dType = data.detailType === 'warn' ? 'warn' : data.detailType === 'success' ? 'success' : data.detailType === 'error' ? 'error' : 'info';
  8311. const detailData = data.data ? (typeof data.data === 'string' ? data.data : JSON.stringify(data.data, null, 2)) : null;
  8312. addDetailEntry('autotest', data.message || '', detailData, dType, { depth: data.phase === 'run' ? 2 : 1 });
  8313. }
  8314. // autotest_case_done → Both panels (it's a milestone)
  8315. if (data.type === 'autotest_case_done') {
  8316. const { caseId, status, current, total } = data;
  8317. const icon = status === 'passed' ? '>' : status === 'soft_pass' ? '~' : 'x';
  8318. const dotStatus = status === 'passed' ? 'done' : status === 'soft_pass' ? 'skipped' : 'error';
  8319. setStatus(`AutoTest: ${current}/${total} ${caseId}`, status === 'passed' ? 'green' : 'red');
  8320. // Detail panel: case result
  8321. addDetailEntry('autotest', `${icon} ${current}/${total} ${caseId} [${status}]`, null, dotStatus === 'done' ? 'success' : 'error', { depth: 1 });
  8322. setAtCaseStatus(caseId, dotStatus);
  8323. // Chat: case result (milestone — not a duplicate since detail shows step-level)
  8324. _ensureAutotestChatBlock();
  8325. const caseEl = document.createElement('div');
  8326. caseEl.style.cssText = `font-size:10px;padding:1px 0 1px 12px;color:${dotStatus === 'done' ? 'var(--green)' : dotStatus === 'skipped' ? '#cc0' : 'var(--red)'};`;
  8327. caseEl.textContent = `${icon} ${current}/${total} ${caseId} [${status}]`;
  8328. _autotestChatBlock.appendChild(caseEl);
  8329. scrollChat();
  8330. }
  8331. // autotest_workflow_saved → Both panels (workflow creation is a milestone)
  8332. if (data.type === 'autotest_workflow_saved') {
  8333. const { name, appId, caseCount, level, cases } = data;
  8334. if (level === 'pipeline') {
  8335. _atPipelineStatus = 'idle';
  8336. addDetailEntry('autotest', `Pipeline workflow saved: ${name}`, null, 'success');
  8337. loadWorkflowIntoFlowTab('autotest-pipeline');
  8338. } else if (level === 'app') {
  8339. const existing = _atApps.find(a => a.appId === appId);
  8340. if (existing) { existing.caseCount = caseCount; existing.name = name; if (cases) existing.cases = cases.map(c => ({ ...c, status: 'idle' })); }
  8341. else _atApps.push({ name, appId, caseCount, status: 'idle', cases: (cases || []).map(c => ({ ...c, status: 'idle' })) });
  8342. if (!_atActiveApp && _atApps.length > 0) _atActiveApp = _atApps[0].appId;
  8343. // Detail: full case list
  8344. addDetailEntry('autotest', `App workflow: ${name} (${caseCount} cases)`, cases ? JSON.stringify(cases.map(c => `${c.id}: ${c.name} [${c.priority}] (${c.stepsCount} steps)`), null, 2) : null, 'success', { depth: 1 });
  8345. // Chat: just workflow name
  8346. _ensureAutotestChatBlock();
  8347. const wfEl = document.createElement('div');
  8348. wfEl.style.cssText = 'font-size:10px;padding:1px 0 1px 12px;color:var(--accent);';
  8349. wfEl.textContent = `+ Workflow: ${name} (${caseCount} test cases)`;
  8350. _autotestChatBlock.appendChild(wfEl);
  8351. scrollChat();
  8352. } else if (level === 'testcase') {
  8353. // Detail only: per-testcase workflow (granular log)
  8354. addDetailEntry('autotest', `Test workflow: ${data.caseName || data.caseId} (${data.stepsCount} steps)`, null, 'info', { depth: 2 });
  8355. }
  8356. updateAtWfList();
  8357. if (currentFlowTab === 'autotest') populateFlowWorkflowSelect();
  8358. }
  8359. // autotest_run_complete → Both panels (final summary)
  8360. if (data.type === 'autotest_run_complete') {
  8361. const { passed, failed, softPassed, total, failures } = data;
  8362. for (const app of _atApps) {
  8363. const hasError = (app.cases || []).some(c => c.status === 'error');
  8364. app.status = hasError ? 'error' : 'done';
  8365. }
  8366. _atPipelineStatus = (failed > 0 || (softPassed || 0) > 0) ? 'error' : 'done';
  8367. updateAtWfList();
  8368. // Detail: full evaluation data
  8369. addDetailEntry('autotest', `Run complete: ${passed} passed, ${failed} failed, ${softPassed || 0} soft-passed / ${total} total`,
  8370. failures?.length ? JSON.stringify(failures.map(f => ({ case: f.caseId, reason: f.reason, softPass: f.softPass })), null, 2) : null,
  8371. failed > 0 ? 'error' : 'success');
  8372. // Chat: summary line
  8373. _ensureAutotestChatBlock();
  8374. const sumEl = document.createElement('div');
  8375. sumEl.style.cssText = 'font-size:11px;font-weight:600;padding:4px 0 0;border-top:1px solid var(--border);margin-top:4px;';
  8376. sumEl.innerHTML = `<span style="color:var(--green)">${passed} passed</span> / <span style="color:var(--red)">${failed} failed</span>${softPassed > 0 ? ` / <span style="color:#cc0">${softPassed} soft</span>` : ''} / ${total} total`;
  8377. _autotestChatBlock.appendChild(sumEl);
  8378. scrollChat();
  8379. if (failed > 0 || (softPassed || 0) > 0) showAutotestResultDialog(passed, failed, softPassed || 0, total, failures || []);
  8380. }
  8381. // WF Engine events → Detail panel ONLY — aggregate into a collapsible summary box
  8382. if (data.type === 'autotest_workflow_event') {
  8383. if (!window._wfEngineBox) {
  8384. window._wfEngineTokens = 0;
  8385. window._wfEngineEvents = 0;
  8386. const box = document.createElement('div');
  8387. box.className = 'detail-entry info depth-2';
  8388. box.innerHTML = `
  8389. <span class="de-time">${new Date().toLocaleTimeString()}</span>
  8390. <span class="de-phase">[wf-engine]</span>
  8391. <div class="de-msg" style="cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
  8392. WF Engine stream <span class="wfe-stats" style="color:var(--accent);font-weight:600">0 events, 0 tokens</span> (click to expand)
  8393. </div>
  8394. <div class="wfe-log" style="display:none;max-height:300px;overflow:auto;font-size:10px;padding:4px;background:var(--bg2);border-radius:4px;margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--text2)"></div>`;
  8395. const body = $('detailBody');
  8396. body.appendChild(box);
  8397. body.scrollTop = body.scrollHeight;
  8398. window._wfEngineBox = box;
  8399. }
  8400. const evt = data.event || data;
  8401. const evtStr = typeof evt === 'string' ? evt : JSON.stringify(evt);
  8402. window._wfEngineEvents++;
  8403. window._wfEngineTokens += Math.round(evtStr.length / 4); // rough token estimate
  8404. const statsEl = window._wfEngineBox.querySelector('.wfe-stats');
  8405. const tokK = (window._wfEngineTokens / 1000).toFixed(1);
  8406. statsEl.textContent = `${window._wfEngineEvents} events, ~${tokK}K tokens`;
  8407. const logEl = window._wfEngineBox.querySelector('.wfe-log');
  8408. // Only keep last 50 events in DOM to prevent memory bloat
  8409. const lines = logEl.children;
  8410. if (lines.length > 50) logEl.removeChild(lines[0]);
  8411. const line = document.createElement('div');
  8412. line.style.cssText = 'border-bottom:1px solid var(--border);padding:1px 0;';
  8413. // Show summary: node status or truncated content
  8414. const nodeId = evt.nodeId || evt.node_id || '';
  8415. const status = evt.status || evt.type || '';
  8416. const content = evt.content || evt.text || '';
  8417. if (nodeId || status) {
  8418. line.textContent = `[${nodeId}] ${status}${content ? ': ' + content.slice(0, 120) : ''}`;
  8419. } else {
  8420. line.textContent = evtStr.slice(0, 200);
  8421. }
  8422. logEl.appendChild(line);
  8423. }
  8424. } catch {}
  8425. });
  8426. };
  8427. // ===================== WORKFLOW ENGINE SSE (Dragon Broker) =====================
  8428. // Connects to external workflow engine via Dragon Broker SSE endpoint.
  8429. // Spec 3.16 §13.3 events + Engine v0.2.1 extensions.
  8430. // Event order: step_start → llm_thinking → llm_token → llm_tool_use →
  8431. // llm_tool_result → [循环] → llm_done → var_changed → file_done → step_done
  8432. let _wfRunSSE = null; // EventSource for active workflow run
  8433. let _activeRunID = null; // Current workflow run ID
  8434. let _wfRunChatBlock = null; // Chat block for workflow milestones
  8435. let _wfPauseToken = null; // waitToken from pause_start (needed for resume)
  8436. const BROKER_BASE = 'http://localhost:9160';
  8437. function connectWorkflowSSE(runID) {
  8438. disconnectWorkflowSSE();
  8439. _activeRunID = runID;
  8440. _wfRunChatBlock = null;
  8441. clearStreamBoxes();
  8442. const es = new EventSource(`${BROKER_BASE}/workflow/${runID}/events`);
  8443. _wfRunSSE = es;
  8444. // Show status bar
  8445. setChatStatusRunning(true);
  8446. updateChatStatusBar('Workflow starting...', runID);
  8447. addDetailEntry('workflow', `Connected to workflow ${runID}`, null, 'info');
  8448. es.onmessage = (e) => {
  8449. try {
  8450. const data = JSON.parse(e.data);
  8451. _handleWorkflowEngineEvent(data);
  8452. } catch {}
  8453. };
  8454. es.onerror = () => {
  8455. // SSE auto-reconnects; only log once
  8456. if (es.readyState === EventSource.CLOSED) {
  8457. addDetailEntry('workflow', `SSE connection closed for ${runID}`, null, 'warn');
  8458. }
  8459. };
  8460. }
  8461. function disconnectWorkflowSSE() {
  8462. if (_wfRunSSE) {
  8463. try { _wfRunSSE.close(); } catch {}
  8464. _wfRunSSE = null;
  8465. }
  8466. _activeRunID = null;
  8467. _wfRunChatBlock = null;
  8468. }
  8469. function _ensureWfChatBlock() {
  8470. if (_wfRunChatBlock) return;
  8471. const container = $('chatMessages');
  8472. const block = document.createElement('div');
  8473. block.className = 'msg assistant';
  8474. block.style.position = 'relative';
  8475. const now = formatMsgTime(new Date());
  8476. block.innerHTML = `<div class="label">workflow <span class="msg-time">${now}</span></div><div class="wf-chat-body" style="font-size:11px;line-height:1.6;"></div>`;
  8477. container.appendChild(block);
  8478. _wfRunChatBlock = block.querySelector('.wf-chat-body');
  8479. scrollChat();
  8480. }
  8481. function _handleWorkflowEngineEvent(raw) {
  8482. // SSE format per Spec §13.2: { run_id, seq, ts, type, step_id, payload }
  8483. // Normalize: payload fields may be top-level (Engine) or nested in payload (Spec)
  8484. const payload = raw.payload || raw;
  8485. const type = raw.type || raw.event;
  8486. const stepID = raw.stepID || raw.step_id || payload.stepId || payload.nodeId || null;
  8487. // ── workflow_start → Status Bar + Detail Log + Chat ──
  8488. if (type === 'workflow_start') {
  8489. const name = payload.name || '';
  8490. _lastWorkflowName = name;
  8491. _lastRunCheckpoint = null;
  8492. for (const k in _stepCards) delete _stepCards[k];
  8493. addDetailEntry('workflow', `▶ Workflow started${name ? ': ' + name : ''}${payload.resumedFrom ? ' (resumed from ' + payload.resumedFrom + ')' : ''}`, payload.params || null, 'info');
  8494. updateChatStatusBar(`Running: ${name || _activeRunID}`, '');
  8495. _ensureWfChatBlock();
  8496. const startEl = document.createElement('div');
  8497. startEl.style.cssText = 'color:var(--accent);font-weight:600;padding:2px 0;';
  8498. startEl.textContent = `▶ Workflow: ${name || _activeRunID}${payload.resumedFrom ? ' (from ' + payload.resumedFrom + ')' : ''}`;
  8499. _wfRunChatBlock.appendChild(startEl);
  8500. scrollChat();
  8501. return;
  8502. }
  8503. // ── LLM thinking tokens → Detail Log (stream box, collapsible) ──
  8504. if (type === 'llm_thinking') {
  8505. appendToStreamBox(`wf-thinking-${stepID || _activeRunID}`, '💭 Thinking', payload.delta || payload.chunk || '');
  8506. return;
  8507. }
  8508. // ── LLM response tokens → Detail Log (stream box) ──
  8509. if (type === 'llm_token') {
  8510. appendToStreamBox(`wf-response-${stepID || _activeRunID}`, '💬 Response', payload.delta || payload.chunk || '');
  8511. return;
  8512. }
  8513. // ── LLM tool use → Detail Log (collapsible) ──
  8514. if (type === 'llm_tool_use') {
  8515. const toolName = payload.name || payload.tool_name || 'unknown';
  8516. const toolInput = payload.input || payload.params || {};
  8517. addDetailEntry('tool-call', `🔧 ${toolName}`, toolInput, 'info', { depth: 1 });
  8518. updateChatStatusBar(`Tool: ${toolName}`, '');
  8519. return;
  8520. }
  8521. // ── LLM tool result → Detail Log (collapsible) ──
  8522. if (type === 'llm_tool_result') {
  8523. const isError = payload.is_error || false;
  8524. const content = payload.content || payload.result || '';
  8525. const toolId = payload.tool_use_id || '';
  8526. addDetailEntry('tool-result', `${isError ? '✗' : '✓'} Result${toolId ? ' [' + toolId.slice(-8) + ']' : ''}`, content, isError ? 'error' : 'success', { depth: 1 });
  8527. return;
  8528. }
  8529. if (type === 'tool_start') {
  8530. const toolName = payload.name || stepID || 'tool';
  8531. addDetailEntry('tool-call', `🛠 ${toolName}`, payload.input || null, 'info', { depth: 1 });
  8532. updateChatStatusBar(`Tool step: ${toolName}`, '');
  8533. return;
  8534. }
  8535. if (type === 'tool_done') {
  8536. const toolName = payload.name || stepID || 'tool';
  8537. addDetailEntry('tool-result', `✓ ${toolName}`, payload.output || null, 'success', { depth: 1 });
  8538. return;
  8539. }
  8540. if (type === 'tool_error') {
  8541. const toolName = payload.name || stepID || 'tool';
  8542. addDetailEntry('tool-result', `✗ ${toolName}${payload.allowError ? ' (continued)' : ''}`, payload.error || null, payload.allowError ? 'warn' : 'error', { depth: 1 });
  8543. return;
  8544. }
  8545. if (type === 'tool_message') {
  8546. const toolName = payload.name || stepID || 'tool';
  8547. addDetailEntry('tool-call', `• ${toolName}: ${payload.message || ''}`, payload.data || null, payload.level === 'error' ? 'error' : payload.level === 'warn' ? 'warn' : 'info', { depth: 1 });
  8548. return;
  8549. }
  8550. // ── LLM done → Detail Log (summary) ──
  8551. if (type === 'llm_done') {
  8552. flushStreamBoxes();
  8553. const model = payload.model || '';
  8554. const tokens = payload.usage || {};
  8555. const latency = payload.latency_ms ? `${(payload.latency_ms / 1000).toFixed(1)}s` : '';
  8556. const summary = [model, tokens.input_tokens ? `in:${tokens.input_tokens}` : '', tokens.output_tokens ? `out:${tokens.output_tokens}` : '', latency].filter(Boolean).join(' | ');
  8557. addDetailEntry('llm', `✓ LLM complete — ${summary}`, null, 'success');
  8558. return;
  8559. }
  8560. // ── LLM error → Main Chat + Detail Log ──
  8561. if (type === 'llm_error') {
  8562. const err = payload.error || {};
  8563. const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Unknown LLM error';
  8564. const retryable = (payload.retryable || err.retryable) ? ' (retryable)' : '';
  8565. addDetailEntry('llm', `✗ LLM Error${retryable}: ${errMsg}`, payload, 'error');
  8566. _ensureWfChatBlock();
  8567. const errEl = document.createElement('div');
  8568. errEl.style.cssText = 'color:var(--red);font-weight:600;padding:2px 0;';
  8569. errEl.textContent = `✗ LLM Error${retryable}: ${errMsg}`;
  8570. _wfRunChatBlock.appendChild(errEl);
  8571. scrollChat();
  8572. return;
  8573. }
  8574. // ── Variable changed → Detail Log ──
  8575. if (type === 'var_changed') {
  8576. const varName = payload.name || payload.variable || '?';
  8577. const oldVal = payload.oldValue != null ? JSON.stringify(payload.oldValue).slice(0, 80) : (payload.old != null ? JSON.stringify(payload.old).slice(0, 80) : '—');
  8578. const newVal = payload.newValue != null ? JSON.stringify(payload.newValue).slice(0, 80) : (payload.new != null ? JSON.stringify(payload.new).slice(0, 80) : '—');
  8579. addDetailEntry('var', `📊 ${varName}: ${oldVal} → ${newVal}`, payload, 'info', { depth: 1 });
  8580. return;
  8581. }
  8582. // ── Step start → Status Bar + Step Card + Chat + DAG ──
  8583. if (type === 'step_start') {
  8584. const stepType = payload.type || payload.step_type || payload.stepType || '';
  8585. const stepTitle = payload.meta?.title || stepID || '?';
  8586. // Use enhanced step card
  8587. addStepCard(stepID, stepType, stepTitle, payload.resolvedInputs);
  8588. updateChatStatusBar(`Running ${stepTitle}...`, stepType);
  8589. updateWorkflowNode(stepID, 'running');
  8590. clearStreamBoxes();
  8591. // Chat: show step progress
  8592. _ensureWfChatBlock();
  8593. const stepEl = document.createElement('div');
  8594. stepEl.id = `wf-step-${stepID}`;
  8595. stepEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text2);';
  8596. stepEl.textContent = ` ▶ ${stepTitle}${stepType ? ' [' + stepType + ']' : ''}`;
  8597. _wfRunChatBlock.appendChild(stepEl);
  8598. scrollChat();
  8599. return;
  8600. }
  8601. // ── Step done → Step Card + Chat + DAG ──
  8602. if (type === 'step_done') {
  8603. flushStreamBoxes();
  8604. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8605. // Complete step card with outputs
  8606. completeStepCard(stepID, payload.outputs, payload.selected, payload.duration_ms);
  8607. updateWorkflowNode(stepID, 'done');
  8608. // Chat: update step line to show done
  8609. const chatStepEl = document.getElementById(`wf-step-${stepID}`);
  8610. if (chatStepEl) {
  8611. chatStepEl.style.color = 'var(--green)';
  8612. chatStepEl.textContent = ` ✓ ${stepID || '?'} done${duration}`;
  8613. }
  8614. return;
  8615. }
  8616. // ── Step error → Step Card + Main Chat + DAG ──
  8617. if (type === 'step_error') {
  8618. flushStreamBoxes();
  8619. const err = payload.error || {};
  8620. const errMsg = (typeof err === 'string' ? err : err.message) || 'Step error';
  8621. // Error step card with re-run button
  8622. errorStepCard(stepID, errMsg, payload.duration_ms);
  8623. updateWorkflowNode(stepID, 'error');
  8624. // Show in chat — step errors are actionable
  8625. _ensureWfChatBlock();
  8626. const errEl = document.createElement('div');
  8627. errEl.style.cssText = 'color:var(--red);padding:2px 0;';
  8628. errEl.textContent = `✗ Step ${stepID || '?'}: ${errMsg}`;
  8629. _wfRunChatBlock.appendChild(errEl);
  8630. scrollChat();
  8631. return;
  8632. }
  8633. // ── Step skipped → Detail Log + DAG ──
  8634. if (type === 'step_skipped') {
  8635. const reason = payload.reason || 'if_false';
  8636. addDetailEntry('step', `⊘ ${stepID || '?'} skipped (${reason})`, null, 'info', { depth: 1 });
  8637. updateWorkflowNode(stepID, 'skipped');
  8638. return;
  8639. }
  8640. // ── Step print → Detail Log + Chat ──
  8641. if (type === 'step_print') {
  8642. const msg = payload.message || payload.value || '';
  8643. addDetailEntry('print', `📝 ${stepID || ''}: ${msg}`, null, 'info', { depth: 1 });
  8644. // Print messages are user-facing — show in chat
  8645. _ensureWfChatBlock();
  8646. const printEl = document.createElement('div');
  8647. printEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--text);';
  8648. printEl.textContent = ` 📝 ${msg}`;
  8649. _wfRunChatBlock.appendChild(printEl);
  8650. scrollChat();
  8651. return;
  8652. }
  8653. // ── File start → Detail Log ──
  8654. if (type === 'file_start') {
  8655. const path = payload.path || '?';
  8656. addDetailEntry('file', `📄 Writing: ${path}`, null, 'info', { depth: 1 });
  8657. return;
  8658. }
  8659. // ── File done → Detail Log + Step Card + Chat + File Tree ──
  8660. if (type === 'file_done') {
  8661. const filePath = payload.path || '?';
  8662. const size = payload.size_bytes != null ? ` (${payload.size_bytes > 1024 ? (payload.size_bytes / 1024).toFixed(1) + 'KB' : payload.size_bytes + 'B'})` : '';
  8663. addDetailEntry('file', `✓ Written: ${filePath}${size}`, null, 'success', { depth: 1 });
  8664. // Associate with running step card
  8665. const runStep = getCurrentRunningStepID();
  8666. if (runStep) addFileToStepCard(runStep, filePath);
  8667. // Trigger file tree refresh
  8668. if (window._fileTreeRefreshTimer) clearTimeout(window._fileTreeRefreshTimer);
  8669. window._fileTreeRefreshTimer = setTimeout(() => { loadFileTree(); window._fileTreeRefreshTimer = null; }, 600);
  8670. // Chat: show file written
  8671. _ensureWfChatBlock();
  8672. const fileEl = document.createElement('div');
  8673. fileEl.style.cssText = 'font-size:10px;padding:1px 0;color:var(--green);';
  8674. fileEl.textContent = ` 📄 ${filePath}${size}`;
  8675. _wfRunChatBlock.appendChild(fileEl);
  8676. scrollChat();
  8677. return;
  8678. }
  8679. // ── Pause start → Main Chat (approval buttons) ──
  8680. if (type === 'pause_start') {
  8681. const reason = payload.reason || 'Workflow paused — awaiting approval';
  8682. _wfPauseToken = payload.waitToken || null;
  8683. addDetailEntry('pause', `⏸ Paused: ${reason}`, payload, 'warn');
  8684. updateChatStatusBar('Paused', reason);
  8685. _ensureWfChatBlock();
  8686. const pauseDiv = document.createElement('div');
  8687. pauseDiv.className = 'wf-pause-block';
  8688. pauseDiv.style.cssText = 'padding:6px 0;border-top:1px solid var(--border);margin-top:4px;';
  8689. pauseDiv.innerHTML = `
  8690. <div style="color:var(--orange);font-weight:600;margin-bottom:4px;">⏸ ${escapeHtml(reason)}</div>
  8691. <div style="display:flex;gap:6px;">
  8692. <button onclick="resumeBrokerWorkflow()" style="background:var(--green);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">▶ Resume</button>
  8693. <button onclick="abortWorkflow()" style="background:var(--red);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">✗ Abort</button>
  8694. </div>`;
  8695. _wfRunChatBlock.appendChild(pauseDiv);
  8696. scrollChat();
  8697. return;
  8698. }
  8699. // ── Pause resumed → Detail Log + update chat ──
  8700. if (type === 'pause_resumed') {
  8701. _wfPauseToken = null;
  8702. addDetailEntry('pause', `▶ Resumed (req: ${payload.requestId || '—'})`, null, 'success');
  8703. updateChatStatusBar('Resumed', '');
  8704. // Disable pause buttons
  8705. const btns = document.querySelectorAll('.wf-pause-block button');
  8706. btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
  8707. return;
  8708. }
  8709. // ── Pause timeout → Detail Log + Main Chat ──
  8710. if (type === 'pause_timeout') {
  8711. _wfPauseToken = null;
  8712. const action = payload.timeoutAction || '';
  8713. addDetailEntry('pause', `⏰ Pause timed out → ${action}`, payload, 'warn');
  8714. _ensureWfChatBlock();
  8715. const toEl = document.createElement('div');
  8716. toEl.style.cssText = 'color:var(--orange);padding:2px 0;';
  8717. toEl.textContent = `⏰ Pause timed out${action ? ' → ' + action : ''}`;
  8718. _wfRunChatBlock.appendChild(toEl);
  8719. scrollChat();
  8720. // Disable pause buttons
  8721. const btns = document.querySelectorAll('.wf-pause-block button');
  8722. btns.forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
  8723. return;
  8724. }
  8725. // ── Pause rejected → Detail Log ──
  8726. if (type === 'pause_rejected') {
  8727. const reasonCode = payload.reasonCode || 'unknown';
  8728. addDetailEntry('pause', `✗ Resume rejected: ${reasonCode}`, payload, 'error');
  8729. return;
  8730. }
  8731. // ── Workflow done → Main Chat + Status Bar ──
  8732. if (type === 'workflow_done') {
  8733. flushStreamBoxes();
  8734. const stopId = payload.stop_id || '';
  8735. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8736. const summary = `Workflow completed${duration}${stopId ? ' at ' + stopId : ''}`;
  8737. addDetailEntry('workflow', `✓ ${summary}`, payload, 'success');
  8738. setChatStatusRunning(false);
  8739. setStatus('Workflow done', 'green');
  8740. _ensureWfChatBlock();
  8741. const doneEl = document.createElement('div');
  8742. doneEl.style.cssText = 'color:var(--green);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8743. doneEl.textContent = `✓ ${summary}`;
  8744. _wfRunChatBlock.appendChild(doneEl);
  8745. scrollChat();
  8746. disconnectWorkflowSSE();
  8747. return;
  8748. }
  8749. // ── Workflow failed → Main Chat + Status Bar ──
  8750. if (type === 'workflow_failed') {
  8751. flushStreamBoxes();
  8752. const err = payload.error || {};
  8753. const errMsg = (typeof err === 'string' ? err : err.message) || payload.message || 'Workflow failed';
  8754. const failedStep = payload.failed_step_id || '';
  8755. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8756. addDetailEntry('workflow', `✗ ${errMsg}${failedStep ? ' at ' + failedStep : ''}${duration}`, payload, 'error');
  8757. setChatStatusRunning(false);
  8758. setStatus('Workflow failed', 'red');
  8759. _ensureWfChatBlock();
  8760. const failEl = document.createElement('div');
  8761. failEl.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8762. failEl.textContent = `✗ ${errMsg}${failedStep ? ' (step: ' + failedStep + ')' : ''}`;
  8763. _wfRunChatBlock.appendChild(failEl);
  8764. scrollChat();
  8765. disconnectWorkflowSSE();
  8766. return;
  8767. }
  8768. // ── Workflow cancelled → Main Chat + Status Bar ──
  8769. if (type === 'workflow_cancelled') {
  8770. flushStreamBoxes();
  8771. const reason = payload.reason || 'cancelled';
  8772. const duration = payload.duration_ms ? ` (${(payload.duration_ms / 1000).toFixed(1)}s)` : '';
  8773. addDetailEntry('workflow', `⊘ Cancelled: ${reason}${duration}`, payload, 'warn');
  8774. setChatStatusRunning(false);
  8775. setStatus('Workflow cancelled', 'orange');
  8776. _ensureWfChatBlock();
  8777. const cancelEl = document.createElement('div');
  8778. cancelEl.style.cssText = 'color:var(--orange);font-weight:600;padding:4px 0;border-top:1px solid var(--border);margin-top:4px;';
  8779. cancelEl.textContent = `⊘ Workflow cancelled: ${reason}`;
  8780. _wfRunChatBlock.appendChild(cancelEl);
  8781. scrollChat();
  8782. disconnectWorkflowSSE();
  8783. return;
  8784. }
  8785. }
  8786. // ── Workflow control actions ──
  8787. // Resume per Spec §11.4: { runId, token, payload, requestId }
  8788. async function resumeBrokerWorkflow(resumePayload) {
  8789. const runID = _activeRunID;
  8790. if (!runID) return;
  8791. try {
  8792. const body = {
  8793. runId: runID,
  8794. token: _wfPauseToken || '',
  8795. payload: resumePayload || {},
  8796. requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  8797. };
  8798. const res = await fetch(`${BROKER_BASE}/workflow/${runID}/resume`, {
  8799. method: 'POST',
  8800. headers: { 'Content-Type': 'application/json' },
  8801. body: JSON.stringify(body)
  8802. });
  8803. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8804. addDetailEntry('workflow', `▶ Resume requested (req: ${body.requestId})`, null, 'info');
  8805. updateChatStatusBar('Resuming...', '');
  8806. } catch (e) {
  8807. addDetailEntry('workflow', `Resume failed: ${e.message}`, null, 'error');
  8808. }
  8809. }
  8810. async function abortWorkflow() {
  8811. const runID = _activeRunID;
  8812. if (!runID) return;
  8813. try {
  8814. const res = await fetch(`${BROKER_BASE}/workflow/${runID}/abort`, { method: 'POST' });
  8815. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8816. addDetailEntry('workflow', '✗ Abort requested', null, 'warn');
  8817. updateChatStatusBar('Aborting...', '');
  8818. } catch (e) {
  8819. addDetailEntry('workflow', `Abort failed: ${e.message}`, null, 'error');
  8820. }
  8821. }
  8822. async function fetchWorkflowSnapshot(runID) {
  8823. try {
  8824. const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/status`);
  8825. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8826. return await res.json();
  8827. } catch (e) {
  8828. addDetailEntry('workflow', `Snapshot fetch failed: ${e.message}`, null, 'error');
  8829. return null;
  8830. }
  8831. }
  8832. async function fetchWorkflowVariables(runID) {
  8833. try {
  8834. const res = await fetch(`${BROKER_BASE}/workflow/${runID || _activeRunID}/variables`);
  8835. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  8836. return await res.json();
  8837. } catch (e) {
  8838. return null;
  8839. }
  8840. }
  8841. // ===================== WORKFLOW RE-RUN DIALOG =====================
  8842. async function openRerunDialog(stepID) {
  8843. // Fetch checkpoint — try local API first, then broker
  8844. let checkpoint = null;
  8845. try {
  8846. // Try local API (for workflows run through /api/workflow/execute)
  8847. const localRes = await fetch('/api/workflow/variables');
  8848. if (localRes.ok) {
  8849. // Use the stored checkpoint from last run
  8850. const cpRes = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
  8851. if (cpRes.ok) checkpoint = await cpRes.json();
  8852. }
  8853. } catch {}
  8854. if (!checkpoint && _activeRunID) {
  8855. try {
  8856. const res = await fetch(`${BROKER_BASE}/workflow/${_activeRunID}/checkpoint`);
  8857. if (res.ok) checkpoint = await res.json();
  8858. } catch {}
  8859. }
  8860. if (!checkpoint && _lastRunCheckpoint) {
  8861. // Use the last checkpoint received via SSE
  8862. checkpoint = _lastRunCheckpoint;
  8863. }
  8864. // Build the dialog
  8865. const vars = checkpoint?.variables || {};
  8866. let varRows = '';
  8867. for (const [k, v] of Object.entries(vars)) {
  8868. const valStr = typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '');
  8869. const isLong = valStr.length > 200;
  8870. varRows += `
  8871. <div class="rr-var-row">
  8872. <div class="rr-var-name">${escapeHtml(k)}</div>
  8873. <textarea class="rr-var-val" data-var="${escapeHtml(k)}" rows="${isLong ? 6 : 2}">${escapeHtml(isLong ? valStr.substring(0, 2000) : valStr)}</textarea>
  8874. </div>`;
  8875. }
  8876. const dialog = document.createElement('div');
  8877. dialog.className = 'modal-overlay open';
  8878. dialog.id = 'rerunDialog';
  8879. dialog.onclick = (e) => { if (e.target === dialog) dialog.remove(); };
  8880. dialog.innerHTML = `
  8881. <div class="modal-box" style="max-width:600px;max-height:80vh;overflow-y:auto;">
  8882. <h3 style="margin:0 0 12px;font-size:14px;color:var(--accent);">🔄 Re-run from: ${escapeHtml(stepID)}</h3>
  8883. <div style="font-size:10px;color:var(--text2);margin-bottom:8px;">
  8884. Workflow: ${escapeHtml(_lastWorkflowName || 'unknown')}<br>
  8885. Steps before this one will NOT re-execute.<br>
  8886. You can edit variables below before re-running.
  8887. </div>
  8888. <div style="font-size:11px;font-weight:600;margin:8px 0 4px;color:var(--text);">Pipeline Variables:</div>
  8889. <div class="rr-vars" style="max-height:300px;overflow-y:auto;">
  8890. ${varRows || '<div style="color:var(--text2);font-size:10px;">No variables available</div>'}
  8891. </div>
  8892. <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;">
  8893. <button onclick="document.getElementById('rerunDialog').remove()" style="background:none;border:1px solid var(--border);color:var(--text2);padding:6px 16px;border-radius:4px;cursor:pointer;">Cancel</button>
  8894. <button onclick="executeRerun('${escapeHtml(stepID)}')" style="background:var(--accent);color:var(--bg);border:none;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600;">🚀 Re-run</button>
  8895. </div>
  8896. </div>`;
  8897. document.body.appendChild(dialog);
  8898. }
  8899. async function executeRerun(stepID) {
  8900. const dialog = document.getElementById('rerunDialog');
  8901. if (!dialog) return;
  8902. // Collect overrides from edited variables
  8903. const overrides = {};
  8904. const textareas = dialog.querySelectorAll('.rr-var-val');
  8905. for (const ta of textareas) {
  8906. const varName = ta.dataset.var;
  8907. const val = ta.value.trim();
  8908. try {
  8909. // Try to parse as JSON
  8910. overrides[varName] = JSON.parse(val);
  8911. } catch {
  8912. overrides[varName] = val;
  8913. }
  8914. }
  8915. dialog.remove();
  8916. // Fetch checkpoint
  8917. let checkpoint = _lastRunCheckpoint;
  8918. if (!checkpoint) {
  8919. try {
  8920. const res = await fetch(`/api/workflow/${_lastWorkflowName || 'unknown'}/checkpoint`);
  8921. if (res.ok) checkpoint = await res.json();
  8922. } catch {}
  8923. }
  8924. if (!checkpoint) {
  8925. addDetailEntry('workflow', '✗ Cannot re-run: no checkpoint available', null, 'error');
  8926. return;
  8927. }
  8928. addDetailEntry('workflow', `🔄 Re-running from ${stepID} with ${Object.keys(overrides).length} variable(s)`, null, 'info');
  8929. // Clear step cards for fresh display
  8930. for (const k in _stepCards) delete _stepCards[k];
  8931. // Call rerun API (SSE stream)
  8932. try {
  8933. const response = await fetch('/api/workflow/rerun', {
  8934. method: 'POST',
  8935. headers: { 'Content-Type': 'application/json' },
  8936. body: JSON.stringify({
  8937. workflowName: _lastWorkflowName,
  8938. checkpoint,
  8939. stepID,
  8940. overrides,
  8941. }),
  8942. });
  8943. if (!response.body) {
  8944. addDetailEntry('workflow', '✗ Re-run failed: no response body', null, 'error');
  8945. return;
  8946. }
  8947. // Process SSE stream (same as sendMessage workflow handling)
  8948. setChatStatusRunning(true);
  8949. updateChatStatusBar(`Re-running from ${stepID}...`, '');
  8950. const reader = response.body.getReader();
  8951. const decoder = new TextDecoder();
  8952. let buffer = '';
  8953. let currentEvent = '';
  8954. while (true) {
  8955. const { done, value } = await reader.read();
  8956. if (done) break;
  8957. buffer += decoder.decode(value, { stream: true });
  8958. const lines = buffer.split('\n');
  8959. buffer = lines.pop() || '';
  8960. for (const line of lines) {
  8961. if (line.startsWith('event: ')) {
  8962. currentEvent = line.slice(7).trim();
  8963. } else if (line.startsWith('data: ')) {
  8964. try {
  8965. const data = JSON.parse(line.slice(6));
  8966. // Dispatch to the same workflow event handler
  8967. const raw = { type: currentEvent, payload: data, stepID: data.stepId || data.nodeId };
  8968. _handleWorkflowEngineEvent(raw);
  8969. } catch {}
  8970. }
  8971. }
  8972. }
  8973. setChatStatusRunning(false);
  8974. } catch (e) {
  8975. addDetailEntry('workflow', `✗ Re-run failed: ${e.message}`, null, 'error');
  8976. setChatStatusRunning(false);
  8977. }
  8978. }
  8979. // ===================== SYNTAX HIGHLIGHTING =====================
  8980. function highlightCode(code, ext) {
  8981. const lines = code.split('\n');
  8982. const isVL = ['vx','sc','cp','vs','vdb','vth'].includes(ext);
  8983. const isJson = ext === 'json';
  8984. return lines.map(line => {
  8985. let html = escapeHtml(line);
  8986. if (isVL) {
  8987. html = highlightVL(html);
  8988. } else if (isJson) {
  8989. html = highlightJSON(html);
  8990. }
  8991. return `<span class="line">${html}</span>`;
  8992. }).join('\n');
  8993. }
  8994. function highlightVL(line) {
  8995. // Comments
  8996. if (/^\s*\/\//.test(line)) return `<span class="cmt">${line}</span>`;
  8997. // Keywords
  8998. line = line.replace(/\b(SECTION|SERVICE|PUBLIC_SERVICE|EVENT|HANDLER|RETURN|IF|ELSE|FOR|EACH|SET|LET|TRUE|FALSE|NULL|LAYOUT|STYLE|NAVIGATION|DATA|METHODS|COMPUTED|INIT|MOUNTED|WATCH|COMPONENT|PROPS|EMIT|SLOT|ACTION|ASYNC|AWAIT|TRY|CATCH)\b/g, '<span class="kw">$1</span>');
  8999. // Tags like <Section-X> <Component-Y> <ServiceDomain-Z>
  9000. line = line.replace(/(&lt;)([\w-]+)(\/?\s*&gt;|[^&]*&gt;)/g, '<span class="tag">$1$2$3</span>');
  9001. // $variables
  9002. line = line.replace(/(\$\w+)/g, '<span class="var">$1</span>');
  9003. // @events
  9004. line = line.replace(/(@\w+)/g, '<span class="evt">$1</span>');
  9005. // Strings
  9006. line = line.replace(/(&quot;[^&]*&quot;|&#39;[^&]*&#39;|"[^"]*"|'[^']*')/g, '<span class="str">$1</span>');
  9007. // Numbers
  9008. line = line.replace(/\b(\d+\.?\d*)\b/g, '<span class="num">$1</span>');
  9009. return line;
  9010. }
  9011. function highlightJSON(line) {
  9012. // Property keys
  9013. line = line.replace(/(&quot;)([^&]+)(&quot;)\s*:/g, '<span class="prop">$1$2$3</span>:');
  9014. // String values
  9015. line = line.replace(/:\s*(&quot;)([^&]*)(&quot;)/g, ': <span class="str">$1$2$3</span>');
  9016. // Numbers
  9017. line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="num">$1</span>');
  9018. // Booleans / null
  9019. line = line.replace(/:\s*(true|false|null)\b/g, ': <span class="kw">$1</span>');
  9020. return line;
  9021. }
  9022. // renderMarkdown is defined earlier in the file (search for "Simple markdown → HTML renderer")
  9023. // ===================== FLOW EDITOR TOOLBAR =====================
  9024. let currentFlowTab = 'generate'; // 'generate' | 'adjust' | 'autotest'
  9025. // ── AutoTest 3-layer state ──
  9026. let _atApps = []; // [{name, appId, caseCount, status, cases:[{id,name,status}]}]
  9027. let _atPipelineStatus = 'idle';
  9028. let _atActiveLevel = 'pipeline';
  9029. let _atActiveApp = '';
  9030. let _atActiveCase = '';
  9031. /** Load existing autotest app workflows from disk on init */
  9032. async function loadAtAppsFromWorkflows() {
  9033. try {
  9034. // Try loading saved test cases first (has full case info)
  9035. const casesRes = await fetch('/api/file/raw?path=.vl-code/autotest-cases.json');
  9036. if (casesRes.ok) {
  9037. const rawText = await casesRes.text();
  9038. if (rawText) {
  9039. const parsed = JSON.parse(rawText);
  9040. const testCases = parsed.testCases || [];
  9041. if (testCases.length > 0) {
  9042. const appGroups = {};
  9043. for (const tc of testCases) {
  9044. const appId = tc.appId || 'App';
  9045. if (!appGroups[appId]) appGroups[appId] = [];
  9046. appGroups[appId].push({ id: tc.id, name: tc.name || tc.id, status: 'idle', priority: tc.priority, stepsCount: tc.steps?.length || 0 });
  9047. }
  9048. _atApps = [];
  9049. for (const [appId, cases] of Object.entries(appGroups)) {
  9050. const safeName = `autotest-${appId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  9051. _atApps.push({ name: safeName, appId, caseCount: cases.length, status: 'idle', cases });
  9052. }
  9053. if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
  9054. // Restore test results (pass/fail status) from saved results
  9055. await _restoreAtResults();
  9056. updateAtWfList();
  9057. return;
  9058. }
  9059. }
  9060. }
  9061. // Fallback: scan workflow files
  9062. const data = await api('/api/workflows');
  9063. const wfs = (data.workflows || []).filter(w => w.name.startsWith('autotest-') && !w.name.startsWith('autotest-tc-') && w.name !== 'autotest-pipeline');
  9064. if (wfs.length === 0) return;
  9065. _atApps = [];
  9066. for (const wf of wfs) {
  9067. const appId = wf.name.replace('autotest-', '');
  9068. try {
  9069. const wfData = await api(`/api/workflow/${wf.name}`);
  9070. const steps = wfData.workflow?.steps || [];
  9071. const cases = steps.filter(s => s.id && s.meta?.title).map(s => ({
  9072. id: s.id, name: s.meta?.title || s.id, status: 'idle'
  9073. }));
  9074. _atApps.push({ name: wf.name, appId, caseCount: cases.length || wf.stepCount, status: 'idle', cases });
  9075. } catch {
  9076. _atApps.push({ name: wf.name, appId, caseCount: wf.stepCount, status: 'idle', cases: [] });
  9077. }
  9078. }
  9079. if (_atApps.length > 0 && !_atActiveApp) _atActiveApp = _atApps[0].appId;
  9080. await _restoreAtResults();
  9081. updateAtWfList();
  9082. } catch {}
  9083. }
  9084. /** Restore pass/fail status from saved autotest-results.json */
  9085. async function _restoreAtResults() {
  9086. try {
  9087. const res = await fetch('/api/file/raw?path=.vl-code/autotest-results.json');
  9088. if (!res.ok) return;
  9089. const rawText = await res.text();
  9090. if (!rawText) return;
  9091. const results = JSON.parse(rawText);
  9092. const evals = results.evaluations || [];
  9093. if (evals.length === 0) return;
  9094. // Map caseId → status
  9095. const statusMap = {};
  9096. for (const ev of evals) {
  9097. const id = ev.caseId || ev.id;
  9098. if (!id) continue;
  9099. if (ev.evaluation?.pass) statusMap[id] = ev.evaluation?.softPass ? 'soft' : 'done';
  9100. else statusMap[id] = 'error';
  9101. }
  9102. // Apply to _atApps
  9103. let anyResult = false;
  9104. for (const app of _atApps) {
  9105. let appHasError = false, appAllDone = true;
  9106. for (const tc of (app.cases || [])) {
  9107. if (statusMap[tc.id]) { tc.status = statusMap[tc.id]; anyResult = true; }
  9108. if (tc.status === 'error') appHasError = true;
  9109. if (tc.status === 'idle') appAllDone = false;
  9110. }
  9111. if (anyResult) app.status = appHasError ? 'error' : appAllDone ? 'done' : 'idle';
  9112. }
  9113. if (anyResult) {
  9114. const hasError = _atApps.some(a => a.status === 'error');
  9115. _atPipelineStatus = hasError ? 'error' : 'done';
  9116. }
  9117. } catch {}
  9118. }
  9119. function switchFlowTab(tab) {
  9120. currentFlowTab = tab;
  9121. document.querySelectorAll('.flow-sub-tab').forEach(t => t.classList.toggle('active', t.dataset.flow === tab));
  9122. populateFlowWorkflowSelect();
  9123. updateFlowWfList();
  9124. if (tab === 'autotest') {
  9125. // Restore saved test cases from disk if not already loaded
  9126. if (_atApps.length === 0) loadAtAppsFromWorkflows();
  9127. updateAtWfList();
  9128. loadActiveFlowWorkflow();
  9129. } else {
  9130. $('atWfList').classList.remove('visible');
  9131. loadActiveFlowWorkflow();
  9132. }
  9133. }
  9134. /** Render the workflow picker bar for Generate / Adjust sub-tabs */
  9135. function updateFlowWfList() {
  9136. const list = $('flowWfList');
  9137. if (currentFlowTab === 'autotest') { list.classList.remove('visible'); return; }
  9138. list.classList.add('visible');
  9139. const workflows = currentFlowTab === 'generate' ? CODEGEN_WORKFLOWS : ADJUST_WORKFLOWS;
  9140. const sel = $('flowWfSelect');
  9141. const currentFile = sel.value || (currentFlowTab === 'generate'
  9142. ? (workflowBindings.generate || 'parallel-codegen')
  9143. : (workflowBindings.adjust || 'incremental-update'));
  9144. list.innerHTML = '';
  9145. for (const [key, info] of Object.entries(workflows)) {
  9146. const isActive = currentFile === info.file || currentFile === key;
  9147. const div = document.createElement('div');
  9148. div.className = 'flow-wf-item' + (isActive ? ' active' : '');
  9149. div.innerHTML = `<span class="fwi-name">${escapeHtml(info.label)}</span><span class="fwi-desc">${escapeHtml(info.desc)}</span>`;
  9150. div.onclick = () => {
  9151. _setFlowWfSelectOrStore(info.file, sel);
  9152. loadFlowWorkflow(info.file);
  9153. if (currentFlowTab === 'generate') selectCodegenWorkflow(key);
  9154. list.querySelectorAll('.flow-wf-item').forEach(el => el.classList.remove('active'));
  9155. div.classList.add('active');
  9156. };
  9157. list.appendChild(div);
  9158. }
  9159. }
  9160. async function populateFlowWorkflowSelect() {
  9161. const sel = $('flowWfSelect');
  9162. sel.innerHTML = '<option value="">-- Select Workflow --</option>';
  9163. try {
  9164. const data = await api('/api/workflows');
  9165. for (const wf of (data.workflows || [])) {
  9166. const isAutotest = wf.name.startsWith('autotest-');
  9167. if (currentFlowTab === 'autotest' && !isAutotest) continue;
  9168. if (currentFlowTab !== 'autotest' && isAutotest) continue;
  9169. const isDefault = (currentFlowTab === 'generate' && wf.name === (workflowBindings.generate || '3-file-codegen'))
  9170. || (currentFlowTab === 'adjust' && wf.name === (workflowBindings.adjust || 'incremental-update'))
  9171. || (currentFlowTab === 'autotest' && wf.name === (workflowBindings.autotest || 'autotest-pipeline'));
  9172. sel.innerHTML += `<option value="${escapeHtml(wf.name)}"${isDefault ? ' selected' : ''}>${escapeHtml(wf.title || wf.name)} (${wf.stepCount} steps)</option>`;
  9173. }
  9174. } catch {}
  9175. }
  9176. function loadActiveFlowWorkflow() {
  9177. // Skip if a tool (VLGenerate/WorkflowRun) is loading a workflow directly
  9178. if (window._skipFlowAutoLoad) return;
  9179. const sel = $('flowWfSelect');
  9180. const defaults = { generate: workflowBindings.generate || '3-file-codegen', adjust: workflowBindings.adjust || 'incremental-update', autotest: workflowBindings.autotest || 'autotest-pipeline' };
  9181. const name = sel.value || defaults[currentFlowTab] || '';
  9182. if (name) loadFlowWorkflow(name);
  9183. }
  9184. // ── AutoTest 3-layer hierarchy UI ──
  9185. function updateAtWfList() {
  9186. const list = $('atWfList');
  9187. if (currentFlowTab !== 'autotest') { list.classList.remove('visible'); return; }
  9188. list.classList.add('visible');
  9189. if (_atApps.length === 0) {
  9190. if (_atPipelineStatus === 'running') {
  9191. list.innerHTML = '<div style="padding:6px 10px;color:var(--yellow);font-size:10px;"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--yellow);animation:csPulse 1.2s ease-in-out infinite;margin-right:6px;"></span>Generating test workflows...</div>';
  9192. } else {
  9193. list.innerHTML = '<div style="padding:6px 10px;color:var(--text2);font-size:10px;">No test workflows yet. Run autotest-pipeline to generate.</div>';
  9194. }
  9195. return;
  9196. }
  9197. let html = `<div class="at-wf-pipeline${_atActiveLevel === 'pipeline' ? ' active' : ''}" onclick="selectAtLevel('pipeline')">
  9198. <span class="at-wf-dot ${_atPipelineStatus}"></span>
  9199. <span>Pipeline Overview</span>
  9200. <span style="margin-left:auto;font-weight:400;color:var(--text2);">${_atApps.reduce((s,a) => s + (a.caseCount||0), 0)} cases / ${_atApps.length} apps</span>
  9201. </div>`;
  9202. html += '<div class="at-wf-apps">';
  9203. for (const app of _atApps) {
  9204. const isActive = _atActiveLevel === 'app' && _atActiveApp === app.appId;
  9205. html += `<div class="at-wf-app${isActive ? ' active' : ''}" onclick="selectAtLevel('app','${escapeHtml(app.appId)}')">
  9206. <span class="at-wf-dot ${app.status || 'idle'}"></span>
  9207. <span>${escapeHtml(app.appId)}</span><span class="app-count">(${app.caseCount})</span>
  9208. </div>`;
  9209. }
  9210. html += '</div>';
  9211. const selApp = _atApps.find(a => a.appId === _atActiveApp);
  9212. if (selApp && selApp.cases && selApp.cases.length > 0) {
  9213. html += '<div class="at-wf-cases">';
  9214. for (const tc of selApp.cases) {
  9215. const isActive = _atActiveLevel === 'testcase' && _atActiveCase === tc.id;
  9216. const label = tc.name ? (tc.name.length > 28 ? tc.name.slice(0, 26) + '…' : tc.name) : tc.id;
  9217. const stepsInfo = tc.stepsCount ? `${tc.stepsCount} steps` : '';
  9218. const prioTag = tc.priority ? `<span style="font-size:8px;padding:0 3px;border-radius:2px;background:${tc.priority === 'P0' ? 'var(--red)' : 'var(--bg3)'};color:${tc.priority === 'P0' ? '#fff' : 'var(--text2)'};margin-left:4px;">${tc.priority}</span>` : '';
  9219. html += `<div class="at-wf-case${isActive ? ' active' : ''}" onclick="selectAtLevel('testcase','${escapeHtml(selApp.appId)}','${escapeHtml(tc.id)}')" title="${escapeHtml(tc.name || tc.id)}">
  9220. <span class="at-wf-dot ${tc.status || 'idle'}"></span><span>${escapeHtml(label)}</span>${prioTag}
  9221. <span style="margin-left:auto;font-size:9px;color:var(--text2);">${stepsInfo}</span>
  9222. </div>`;
  9223. }
  9224. html += '</div>';
  9225. }
  9226. list.innerHTML = html;
  9227. }
  9228. function selectAtLevel(level, appId, caseId) {
  9229. _atActiveLevel = level;
  9230. const sel = $('flowWfSelect');
  9231. if (level === 'pipeline') {
  9232. _atActiveApp = ''; _atActiveCase = '';
  9233. _setFlowWfSelectOrStore('autotest-pipeline', sel);
  9234. loadFlowWorkflow('autotest-pipeline');
  9235. } else if (level === 'app') {
  9236. _atActiveApp = appId || ''; _atActiveCase = '';
  9237. const app = _atApps.find(a => a.appId === appId);
  9238. if (app) { _setFlowWfSelectOrStore(app.name, sel); loadFlowWorkflow(app.name); }
  9239. } else if (level === 'testcase') {
  9240. _atActiveApp = appId || ''; _atActiveCase = caseId || '';
  9241. const wfName = `autotest-tc-${(caseId || '').replace(/[^a-zA-Z0-9_-]/g, '_')}`;
  9242. _setFlowWfSelectOrStore(wfName, sel);
  9243. loadFlowWorkflow(wfName);
  9244. }
  9245. updateAtWfList();
  9246. }
  9247. /** Set flowWfSelect to name if option exists, otherwise store in data attr for runFlowWorkflow */
  9248. function _setFlowWfSelectOrStore(name, sel) {
  9249. const opt = [...sel.options].find(o => o.value === name);
  9250. if (opt) { sel.value = name; }
  9251. else { sel.dataset.pendingRun = name; }
  9252. }
  9253. // Ensure a live-updating autotest chat block exists for streaming progress
  9254. let _autotestChatBlock = null;
  9255. function _ensureAutotestChatBlock() {
  9256. if (_autotestChatBlock && _autotestChatBlock.isConnected) return;
  9257. const container = $('chatMessages');
  9258. const block = document.createElement('div');
  9259. block.className = 'msg system autotest-live-block';
  9260. block.style.cssText = 'padding:6px 10px;border-radius:6px;background:var(--bg2);border-left:3px solid var(--accent);margin-bottom:8px;max-height:400px;overflow-y:auto;';
  9261. block.innerHTML = '<div style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px;">AutoTest Pipeline</div>';
  9262. container.appendChild(block);
  9263. _autotestChatBlock = block;
  9264. }
  9265. // Load a named workflow into the Flow DAG iframe
  9266. async function loadWorkflowIntoFlowTab(wfName) {
  9267. try {
  9268. await loadFlowWorkflow(wfName);
  9269. } catch {}
  9270. }
  9271. function setAtAppStatus(appId, status) {
  9272. const app = _atApps.find(a => a.appId === appId);
  9273. if (app) { app.status = status; updateAtWfList(); }
  9274. }
  9275. function setAtCaseStatus(caseId, status) {
  9276. for (const app of _atApps) {
  9277. const tc = (app.cases || []).find(c => c.id === caseId);
  9278. if (tc) { tc.status = status; updateAtWfList(); break; }
  9279. }
  9280. }
  9281. // ── AutoTest Result Dialog ──
  9282. function showAutotestResultDialog(passed, failed, softPassed, total, failures) {
  9283. $('autotestResultSummary').innerHTML = `<div style="display:flex;gap:16px;font-size:14px;font-weight:600;">
  9284. <span style="color:var(--green);">✅ ${passed} passed</span>
  9285. ${softPassed > 0 ? `<span style="color:#cc0;">⚠️ ${softPassed} soft-passed</span>` : ''}
  9286. <span style="color:var(--red);">❌ ${failed} failed</span>
  9287. <span style="color:var(--text2);">/ ${total} total</span>
  9288. </div>`;
  9289. const failHtml = (failures || []).map(f =>
  9290. `<div style="margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:4px;">
  9291. <strong>${escapeHtml(f.name || f.caseId)}</strong><br>
  9292. <span style="color:var(--red);">${escapeHtml(f.reason || 'Unknown')}</span>
  9293. </div>`).join('');
  9294. $('autotestResultFailures').innerHTML = failHtml || '<em>No failure details</em>';
  9295. $('autotestResultModal').classList.add('open');
  9296. }
  9297. function autotestAction(action) {
  9298. $('autotestResultModal').classList.remove('open');
  9299. if (action === 'fix') {
  9300. $('chatInput').value = '/test-fix';
  9301. sendMessage();
  9302. } else if (action === 'report') {
  9303. // Direct AutoTestPipeline report call — reliable, no LLM drift
  9304. $('chatInput').value = 'Call AutoTestPipeline with action "report" and display the result.';
  9305. sendMessage();
  9306. } else if (action === 'skip') {
  9307. // Abort current session so any running fix/debug loop stops
  9308. fetch('/api/abort', {
  9309. method: 'POST',
  9310. headers: { 'Content-Type': 'application/json' },
  9311. body: JSON.stringify({ chatId: activeConvId }),
  9312. }).catch(() => {});
  9313. }
  9314. }
  9315. function onFlowWfSelectChange(val) {
  9316. loadFlowWorkflow(val);
  9317. // Persist selection to server for multi-window sync
  9318. try { fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ flowWorkflow: val, flowTab: currentFlowTab }) }); } catch {}
  9319. }
  9320. async function loadFlowWorkflow(name) {
  9321. if (!name) return;
  9322. try {
  9323. const data = await api(`/api/workflow/${encodeURIComponent(name)}`);
  9324. // API returns workflow JSON directly (has .steps), or wrapped in .workflow
  9325. const wf = data.workflow || (data.steps ? data : null);
  9326. if (wf) {
  9327. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9328. return { type: 'loadWorkflow', data: wf, workflowName: name };
  9329. });
  9330. }
  9331. } catch { setStatus('Failed to load workflow', 'red'); }
  9332. }
  9333. function importFlowJson() { $('flowJsonInput').click(); }
  9334. $('flowJsonInput').addEventListener('change', (e) => {
  9335. const file = e.target.files[0];
  9336. if (!file) return;
  9337. const reader = new FileReader();
  9338. reader.onload = () => {
  9339. try {
  9340. const json = JSON.parse(reader.result);
  9341. const name = file.name.replace('.json', '');
  9342. // Show in the DAG viewer
  9343. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9344. return { type: 'loadWorkflow', data: json, workflowName: name };
  9345. });
  9346. // Also save to server
  9347. fetch(`/api/workflow/${encodeURIComponent(name)}`, {
  9348. method: 'POST',
  9349. headers: { 'Content-Type': 'application/json' },
  9350. body: JSON.stringify(json),
  9351. }).then(() => {
  9352. populateFlowWorkflowSelect();
  9353. setStatus(`Loaded workflow: ${name}`, 'green');
  9354. }).catch(() => {});
  9355. } catch { setStatus('Invalid JSON file', 'red'); }
  9356. };
  9357. reader.readAsText(file);
  9358. e.target.value = '';
  9359. });
  9360. // Drag-drop workflow JSON onto flow editor area
  9361. document.addEventListener('dragover', (e) => {
  9362. if (currentMode === 'flow') e.preventDefault();
  9363. });
  9364. document.addEventListener('drop', (e) => {
  9365. if (currentMode !== 'flow') return;
  9366. e.preventDefault();
  9367. const file = e.dataTransfer.files[0];
  9368. if (!file || !file.name.endsWith('.json')) return;
  9369. const reader = new FileReader();
  9370. reader.onload = () => {
  9371. try {
  9372. const json = JSON.parse(reader.result);
  9373. const name = file.name.replace('.json', '');
  9374. showModeIframe('workflow', '/workflow-editor.html', async () => {
  9375. return { type: 'loadWorkflow', data: json, workflowName: name };
  9376. });
  9377. fetch(`/api/workflow/${encodeURIComponent(name)}`, {
  9378. method: 'POST',
  9379. headers: { 'Content-Type': 'application/json' },
  9380. body: JSON.stringify(json),
  9381. }).then(() => {
  9382. populateFlowWorkflowSelect();
  9383. setStatus(`Dropped workflow: ${name}`, 'green');
  9384. }).catch(() => {});
  9385. } catch { setStatus('Invalid JSON file', 'red'); }
  9386. };
  9387. reader.readAsText(file);
  9388. });
  9389. // ===================== RUN WORKFLOW =====================
  9390. async function runFlowWorkflow() {
  9391. const sel = $('flowWfSelect');
  9392. const wfName = sel.value || sel.dataset.pendingRun || '';
  9393. if (!wfName) { setStatus('Select a workflow first', 'yellow'); return; }
  9394. if (flowRunning) return;
  9395. // Autotest tab: run tests directly via AutoTestPipeline tool (not chat)
  9396. // This gives us real-time node highlighting + step-by-step progress via SSE
  9397. if (currentFlowTab === 'autotest') {
  9398. const isCase = wfName.startsWith('autotest-tc-');
  9399. const isPipeline = wfName === 'autotest-pipeline';
  9400. const isApp = wfName.startsWith('autotest-') && !isCase && !isPipeline;
  9401. // Determine which case IDs to run
  9402. let caseIds = null; // null = run all
  9403. if (isCase) {
  9404. caseIds = [wfName.replace('autotest-tc-', '')];
  9405. } else if (isApp) {
  9406. const appId = wfName.replace('autotest-', '');
  9407. const app = _atApps.find(a => a.appId === appId);
  9408. if (app?.cases) caseIds = app.cases.map(c => c.id);
  9409. }
  9410. await runAutotestDirect(caseIds);
  9411. return;
  9412. }
  9413. // Generate / Adjust tab: prompt for description (null = user pressed Cancel → abort)
  9414. const userRequest = prompt('Describe what to generate or modify (leave blank to use defaults):', '');
  9415. if (userRequest === null) return;
  9416. flowRunning = true;
  9417. const btn = $('flowRunBtn');
  9418. const statusEl = $('flowRunStatus');
  9419. btn.disabled = true;
  9420. btn.classList.add('running');
  9421. btn.innerHTML = '&#9881; Running...';
  9422. statusEl.textContent = 'Starting...';
  9423. setStatus('Workflow running...', 'yellow');
  9424. // Clear previous node statuses in DAG iframe
  9425. sendToWorkflowIframe({ type: 'clearStatus' });
  9426. let filesCount = 0;
  9427. let lastError = null;
  9428. try {
  9429. const res = await fetch('/api/workflow/execute', {
  9430. method: 'POST',
  9431. headers: { 'Content-Type': 'application/json' },
  9432. body: JSON.stringify({ workflowName: wfName, params: { userRequest, targetLang: 'en' } }),
  9433. });
  9434. const reader = res.body.getReader();
  9435. const decoder = new TextDecoder();
  9436. let buffer = '';
  9437. while (true) {
  9438. const { done, value } = await reader.read();
  9439. if (done) break;
  9440. buffer += decoder.decode(value, { stream: true });
  9441. // Parse SSE: "event: type\ndata: json\n\n"
  9442. const blocks = buffer.split('\n\n');
  9443. buffer = blocks.pop(); // keep incomplete block
  9444. for (const block of blocks) {
  9445. let evtType = 'message', evtData = null;
  9446. for (const line of block.split('\n')) {
  9447. if (line.startsWith('event: ')) evtType = line.slice(7).trim();
  9448. else if (line.startsWith('data: ')) {
  9449. try { evtData = JSON.parse(line.slice(6)); } catch {}
  9450. }
  9451. }
  9452. if (!evtData) continue;
  9453. switch (evtType) {
  9454. case 'node_start':
  9455. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'running', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9456. statusEl.textContent = evtData.title || evtData.nodeId;
  9457. break;
  9458. case 'node_done':
  9459. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'done', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9460. break;
  9461. case 'node_error':
  9462. sendToWorkflowIframe({ type: 'updateNodeStatus', nodeId: evtData.nodeId, status: 'error', runID: evtData.runID || null, clientRunToken: evtData.clientRunToken || null });
  9463. lastError = evtData.error;
  9464. break;
  9465. case 'screenshot':
  9466. debugLog('screenshot', evtData);
  9467. break;
  9468. case 'file_written':
  9469. filesCount++;
  9470. statusEl.textContent = `${filesCount} files written`;
  9471. break;
  9472. case 'done': {
  9473. const realFiles = (evtData.filesWritten || []).filter(p => p && p !== '/');
  9474. const realCount = realFiles.length || filesCount;
  9475. if (realCount === 0) {
  9476. lastError = 'Workflow completed but no source files were written';
  9477. statusEl.textContent = 'Done (0 files — check metadata has filePath fields)';
  9478. } else {
  9479. statusEl.textContent = `Done! ${realCount} files`;
  9480. }
  9481. break;
  9482. }
  9483. case 'error':
  9484. lastError = evtData.message;
  9485. statusEl.textContent = 'Error: ' + (evtData.message || '').slice(0, 60);
  9486. break;
  9487. }
  9488. }
  9489. }
  9490. } catch (e) {
  9491. lastError = e.message;
  9492. statusEl.textContent = 'Error';
  9493. }
  9494. // Reset button state
  9495. flowRunning = false;
  9496. btn.disabled = false;
  9497. btn.classList.remove('running');
  9498. btn.innerHTML = '&#9654; Run';
  9499. await loadFileTree();
  9500. setStatus(lastError ? 'Workflow finished with errors' : `Done: ${filesCount} files generated`, lastError ? 'red' : 'green');
  9501. }
  9502. /**
  9503. * Run autotest directly via AutoTestPipeline tool (not through chat).
  9504. * Gives us SSE events for real-time DAG node highlighting + step progress.
  9505. * @param {string[]|null} caseIds - null = run all, array = run specific cases
  9506. */
  9507. async function runAutotestDirect(caseIds) {
  9508. const btn = $('flowRunBtn');
  9509. const statusEl = $('flowRunStatus');
  9510. const selectedWorkflow = $('flowWfSelect').value || workflowBindings.autotest || 'autotest-pipeline';
  9511. flowRunning = true;
  9512. btn.disabled = true;
  9513. btn.classList.add('running');
  9514. btn.innerHTML = '&#9881; Running...';
  9515. statusEl.textContent = caseIds ? `Running ${caseIds.length} test(s)...` : 'Running all tests...';
  9516. setStatus('AutoTest running...', 'yellow');
  9517. // Reset case statuses to running
  9518. _atPipelineStatus = selectedWorkflow === 'autotest-pipeline' ? 'running' : _atPipelineStatus;
  9519. for (const app of _atApps) {
  9520. for (const tc of (app.cases || [])) {
  9521. if (!caseIds || caseIds.includes(tc.id)) tc.status = 'running';
  9522. }
  9523. app.status = 'running';
  9524. }
  9525. updateAtWfList();
  9526. // Clear DAG node statuses
  9527. sendToWorkflowIframe({ type: 'clearStatus' });
  9528. // Open detail panel for live progress
  9529. if (!$('detailPanel').classList.contains('open')) toggleDetailPanel();
  9530. try {
  9531. const toolInput = { action: selectedWorkflow === 'autotest-pipeline' ? 'full' : 'run' };
  9532. if (caseIds) toolInput.caseIds = caseIds;
  9533. const res = await fetch('/api/tools/execute', {
  9534. method: 'POST',
  9535. headers: { 'Content-Type': 'application/json' },
  9536. body: JSON.stringify({ name: 'AutoTestPipeline', input: toolInput }),
  9537. });
  9538. const data = await res.json();
  9539. if (data.error) {
  9540. statusEl.textContent = 'Error: ' + (data.error || '').slice(0, 80);
  9541. setStatus('AutoTest failed', 'red');
  9542. } else {
  9543. const summaryText = typeof data.result === 'string'
  9544. ? data.result
  9545. : (data.result?.result || data.result || 'Done');
  9546. statusEl.textContent = String(summaryText).slice(0, 120);
  9547. setStatus('AutoTest complete', 'green');
  9548. }
  9549. } catch (e) {
  9550. statusEl.textContent = 'Error: ' + e.message;
  9551. setStatus('AutoTest failed', 'red');
  9552. }
  9553. flowRunning = false;
  9554. btn.disabled = false;
  9555. btn.classList.remove('running');
  9556. btn.innerHTML = '&#9654; Run';
  9557. // Refresh state from saved results
  9558. await loadAtAppsFromWorkflows();
  9559. }
  9560. /** Send a postMessage to the workflow-editor iframe */
  9561. function sendToWorkflowIframe(msg) {
  9562. const container = $('iframeContainer');
  9563. const iframe = container?.querySelector('iframe[data-tab="__mode_workflow__"]');
  9564. if (iframe?.contentWindow) iframe.contentWindow.postMessage(msg, '*');
  9565. }
  9566. function forwardWorkflowEventToIframe(type, payload) {
  9567. sendToWorkflowIframe({ type: 'workflowEvent', event: { ...(payload || {}), type } });
  9568. }
  9569. // ===================== EXPORT ZIP =====================
  9570. // Import files into current project (adds to file tree)
  9571. async function importFiles() { loadFolder(); }
  9572. // Create new project from ZIP: extract to parent/newdir with helper files
  9573. async function importZipAsProject() { importZip(); }
  9574. // Export all files (VL + Process/ + .vl-code/ + helpers)
  9575. async function exportAll() {
  9576. setStatus('Exporting all files...', 'yellow');
  9577. try {
  9578. const res = await fetch('/api/export-zip?scope=all');
  9579. if (!res.ok) { setStatus('Export failed', 'red'); return; }
  9580. const blob = await res.blob();
  9581. const url = URL.createObjectURL(blob);
  9582. const a = document.createElement('a');
  9583. a.href = url;
  9584. a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '.zip';
  9585. document.body.appendChild(a);
  9586. a.click();
  9587. a.remove();
  9588. URL.revokeObjectURL(url);
  9589. setStatus('Exported all files', 'green');
  9590. } catch { setStatus('Export failed', 'red'); }
  9591. }
  9592. // Export VL files only (.vx, .sc, .cp, .vs, .vdb, .vth)
  9593. async function exportVLOnly() {
  9594. setStatus('Exporting VL files...', 'yellow');
  9595. try {
  9596. const res = await fetch('/api/export-zip?scope=vl');
  9597. if (!res.ok) { setStatus('Export failed', 'red'); return; }
  9598. const blob = await res.blob();
  9599. const url = URL.createObjectURL(blob);
  9600. const a = document.createElement('a');
  9601. a.href = url;
  9602. a.download = (currentWorkDir ? currentWorkDir.split('/').pop() : 'vl-project') + '_VL.zip';
  9603. document.body.appendChild(a);
  9604. a.click();
  9605. a.remove();
  9606. URL.revokeObjectURL(url);
  9607. setStatus('Exported VL files', 'green');
  9608. } catch { setStatus('Export failed', 'red'); }
  9609. }
  9610. // Legacy alias
  9611. async function exportZip() { return exportAll(); }
  9612. // ===================== COMPILE & PREVIEW =====================
  9613. async function compileProject() {
  9614. // Guard: check if current workspace is a VL project
  9615. try {
  9616. const proj = await api('/api/project');
  9617. if (!proj.isVL) {
  9618. setStatus('Cannot compile — no VL files in workspace', 'red');
  9619. addMsg('assistant', '**Cannot compile:** Current workspace has no VL source files (.vx, .sc, .cp, .vs, .vdb). Please switch to a VL project workspace first.');
  9620. return;
  9621. }
  9622. } catch {}
  9623. const btn = $('compileBtn');
  9624. btn.disabled = true;
  9625. btn.innerHTML = '&#9203; Compiling...';
  9626. btn.style.opacity = '0.6';
  9627. setStatus('Compiling project...', 'yellow');
  9628. addMsg('assistant', 'Compiling project... Uploading VL files to cloud platform.');
  9629. addDetailEntry('compile', 'Compile started — packaging VL files', null, 'info');
  9630. const compileStart = Date.now();
  9631. try {
  9632. // Step 1: Push files to cloud (if we have a GID)
  9633. let gid = $('cloudGid').value.trim();
  9634. if (!gid) {
  9635. // Try reading from project config
  9636. try {
  9637. const cfgRes = await fetch('/api/file?path=Config/ProjectConfig');
  9638. if (cfgRes.ok) { const d = await cfgRes.json(); gid = (d.content || '').trim(); }
  9639. } catch {}
  9640. if (!gid) {
  9641. try {
  9642. const profile = normalizeProjectProfile(await api('/api/profile'));
  9643. gid = getProfileGid(profile);
  9644. } catch {}
  9645. }
  9646. }
  9647. if (gid) {
  9648. addDetailEntry('compile', `Pushing files to cloud (GID: ${gid})...`, null, 'info');
  9649. try {
  9650. const pushRes = await fetch('/api/cloud/sync/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gid }) });
  9651. const pushData = await pushRes.json();
  9652. if (pushData.error) addDetailEntry('compile', 'Push warning: ' + pushData.error, null, 'warn');
  9653. else addDetailEntry('compile', `Pushed ${pushData.pushed || pushData.total || 0} files`, null, 'success');
  9654. } catch (pushErr) {
  9655. addDetailEntry('compile', 'Push failed: ' + pushErr.message, null, 'warn');
  9656. }
  9657. }
  9658. // Step 2: Compile via VLCompile tool (returns JSON)
  9659. const res = await fetch('/api/cloud/compile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetGid: gid ? Number(gid) : undefined }) });
  9660. if (res.status === 401) {
  9661. setStatus('Login required to compile', 'yellow');
  9662. addMsg('assistant', 'Compile requires cloud login. Opening login dialog...');
  9663. addDetailEntry('compile', 'Not authenticated — opening login', null, 'warn');
  9664. openCloudLogin();
  9665. return;
  9666. }
  9667. const elapsed = ((Date.now() - compileStart) / 1000).toFixed(1);
  9668. const raw = await res.text();
  9669. let data;
  9670. try { data = JSON.parse(raw); } catch { data = { error: raw.substring(0, 200) }; }
  9671. // VLCompile returns result as JSON string inside "result" field
  9672. if (typeof data === 'string') { try { data = JSON.parse(data); } catch {} }
  9673. if (data.result && typeof data.result === 'string') { try { data = JSON.parse(data.result); } catch { data = { error: data.result }; } }
  9674. if (data.error && !data.success) {
  9675. setStatus('Compile failed: ' + data.error, 'red');
  9676. addMsg('assistant', `**Compile failed** (${elapsed}s): ${data.error}`);
  9677. addDetailEntry('compile', 'Compile failed: ' + data.error, null, 'error');
  9678. return;
  9679. }
  9680. // Track GID for reuse — persist to Config/ProjectConfig so next compile uses same project
  9681. if (data.gid) {
  9682. $('cloudGid').value = data.gid;
  9683. fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'Config/ProjectConfig', content: String(data.gid) }) }).catch(() => {});
  9684. }
  9685. // Show results
  9686. const urls = data.previewUrls || {};
  9687. const keys = Object.keys(urls);
  9688. const errList = data.errList || [];
  9689. const errCount = data.errCount || errList.length;
  9690. addDetailEntry('compile', `Compile response (${elapsed}s) — GID: ${data.gid || gid || 'none'}, errors: ${errCount}`, null, errCount > 0 ? 'warn' : 'success');
  9691. if (keys.length > 0) {
  9692. activatePreview(urls);
  9693. const urlList = keys.map(k => ` - [${k}](${urls[k]})`).join('\n');
  9694. const summary = errCount > 0
  9695. ? `**Compile completed** in ${elapsed}s — ${errCount} error(s), ${keys.length} app(s)`
  9696. : `**Compile success** in ${elapsed}s — ${keys.length} app(s) ready`;
  9697. addMsg('assistant', `${summary} (GID: ${data.gid || gid})\n\n**Preview URLs:**\n${urlList}`);
  9698. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled — preview ready', errCount > 0 ? 'yellow' : 'green');
  9699. } else if (!data.gid && !gid) {
  9700. // Syntax check only — Lambda ran without a cloud workspace
  9701. setStatus(errCount > 0 ? `Syntax errors (${errCount})` : 'Syntax OK — no cloud project', errCount > 0 ? 'yellow' : 'yellow');
  9702. addMsg('assistant', `**Syntax check** in ${elapsed}s — ${errCount} error(s). No preview URL generated.\n\n> To deploy and get a preview URL, open the **Cloud** panel → create or select a workspace → compile again.`);
  9703. } else {
  9704. setStatus(errCount > 0 ? `Compiled — ${errCount} error(s)` : 'Compiled (no preview URLs)', errCount > 0 ? 'yellow' : 'green');
  9705. addMsg('assistant', `**Compile done** in ${elapsed}s (GID: ${data.gid || gid}) — ${errCount} error(s).`);
  9706. }
  9707. if (errCount > 0 && errList.length > 0) {
  9708. const errLines = errList.map((e, i) => {
  9709. if (typeof e === 'string') return ` ${i + 1}. ${e}`;
  9710. if (typeof e === 'object') return ` ${i + 1}. **${e.file || e.type || 'Error'}**: ${e.message || e.msg || JSON.stringify(e)}`;
  9711. return ` ${i + 1}. ${JSON.stringify(e)}`;
  9712. }).join('\n');
  9713. addMsg('assistant', `**Compile Errors (${errCount}):**\n${errLines}`);
  9714. addDetailEntry('compile', `${errCount} compile error(s):\n${errLines}`, null, 'error');
  9715. }
  9716. } catch (e) {
  9717. setStatus('Compile error', 'red');
  9718. addMsg('assistant', `**Compile error:** ${e.message}`);
  9719. } finally {
  9720. btn.disabled = false;
  9721. btn.innerHTML = '&#9654; Compile';
  9722. btn.style.opacity = '1';
  9723. }
  9724. }
  9725. autoResizeChatInput(true);
  9726. init();
  9727. </script>
  9728. </body>
  9729. </html>