Chat.vue 233 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773
  1. <template>
  2. <div class="chat-container" @click="handleContainerClick">
  3. <!-- 最左侧边栏 -->
  4. <Sidebar />
  5. <!-- 中间历史记录区域 -->
  6. <div class="history-sidebar">
  7. <div class="new-chat-container">
  8. <button class="new-chat-btn-full" @click="createNewChat" :disabled="isSending || hasTypingMessage">
  9. <span class="plus-icon">+</span>
  10. 新建任务
  11. </button>
  12. </div>
  13. <div class="history-header">
  14. <span class="section-title">历史记录</span>
  15. </div>
  16. <div class="history-list">
  17. <!-- 历史记录加载状态 -->
  18. <div v-if="isLoadingHistory" class="history-loading">
  19. <div class="loading-spinner"></div>
  20. <div class="loading-text">正在加载历史记录...</div>
  21. </div>
  22. <!-- 有历史记录时显示 -->
  23. <div
  24. v-else-if="historyTotal > 0"
  25. v-for="(item, index) in historyData"
  26. :key="index"
  27. :class="['history-item', { active: item.isActive }]"
  28. @click="item.isActive ? null : ((isSending || hasTypingMessage) ? null : handleHistoryItem(item))"
  29. :style="{
  30. cursor: item.isActive ? 'default' : ((isSending || hasTypingMessage) ? 'not-allowed' : 'pointer')
  31. }"
  32. >
  33. <div class="history-content">
  34. <div class="history-title">{{ item.title }}</div>
  35. <div class="history-time">{{ item.time }}</div>
  36. </div>
  37. <div
  38. class="delete-btn"
  39. @click.stop="deleteHistoryItem(item, index)"
  40. :class="{ 'always-visible': item.isActive }"
  41. >
  42. <img src="/src/assets/AIWriting/8.png" alt="删除" class="delete-icon" />
  43. </div>
  44. </div>
  45. <!-- 无历史记录时显示空状态 -->
  46. <div v-else class="empty-history">
  47. <img :src="emptyIcon" alt="暂无数据" class="empty-icon">
  48. <div class="empty-text">暂无数据</div>
  49. </div>
  50. </div>
  51. </div>
  52. <div class="chat-main-area">
  53. <!-- 右侧AI问答区域 -->
  54. <div class="main-chat" :class="{ 'sidebar-open': webSearchSidebarVisible }">
  55. <!-- 聊天头部 -->
  56. <div class="chat-header" v-if="currentMode !== 'exam-workshop' && currentQuestion">
  57. <div class="question-title-card">
  58. <h2>{{ currentQuestion }}</h2>
  59. </div>
  60. </div>
  61. <!-- 考试工坊内容区域 -->
  62. <div v-if="currentMode === 'exam-workshop'" class="exam-workshop-wrapper" style="height: 100%; flex: 1;">
  63. <ExamWorkshop :hideSidebar="true" @return-to-ai="setMode('ai-qa')" />
  64. </div>
  65. <!-- 聊天内容区域 -->
  66. <div v-else class="chat-content">
  67. <!-- 初始状态:AI助手介绍和功能卡片 -->
  68. <div v-if="!showChat" class="initial-content">
  69. <!-- AI助手介绍 -->
  70. <div class="ai-intro">
  71. <div class="ai-avatar">
  72. <img :src="aiAvatarIcon" alt="AI头像" class="ai-avatar-img">
  73. </div>
  74. <div class="ai-greeting">
  75. <h3>我是蜀道安全管理AI智能助手,您的得力帮手</h3>
  76. <p>我可以帮您处理这些事情</p>
  77. </div>
  78. </div>
  79. <!-- 功能卡片 -->
  80. <div class="function-cards">
  81. <div
  82. v-for="(card, index) in functionCards"
  83. :key="card.id || index"
  84. class="function-card"
  85. @click="handleFunctionCard(card.function_title)"
  86. >
  87. <div class="card-header">
  88. <div class="card-icon">
  89. <img :src="getFunctionCardIcon(card.function_title)" :alt="card.function_title" class="card-icon-img">
  90. </div>
  91. <h4>{{ card.function_title }}</h4>
  92. </div>
  93. <div class="card-description">
  94. <p>{{ card.function_content }}</p>
  95. </div>
  96. </div>
  97. <!-- 如果没有数据,显示默认卡片 -->
  98. <template v-if="functionCards.length === 0">
  99. <div
  100. v-for="(card, index) in defaultFunctionCards"
  101. :key="`default-card-${index}`"
  102. class="function-card"
  103. @click="handleFunctionCard(card.title)"
  104. >
  105. <div class="card-header">
  106. <div class="card-icon">
  107. <img :src="card.icon" :alt="card.title" class="card-icon-img">
  108. </div>
  109. <h4>{{ card.title }}</h4>
  110. </div>
  111. <div class="card-description">
  112. <p>{{ card.description }}</p>
  113. </div>
  114. </div>
  115. </template>
  116. </div>
  117. </div>
  118. <!-- 聊天对话区域 -->
  119. <div v-else class="chat-messages">
  120. <div
  121. v-for="(message, index) in chatMessages"
  122. :key="index"
  123. :class="['message-item', message.type]"
  124. >
  125. <!-- 用户消息 -->
  126. <div v-if="message.type === 'user'" class="user-message">
  127. <div class="message-content">
  128. <!-- 文件显示 -->
  129. <div v-if="message.file" class="message-file">
  130. <div class="file-display">
  131. <div class="file-icon">
  132. <img v-if="message.file.icon !== '📎'" :src="message.file.icon" alt="文件图标" class="file-icon-img">
  133. <span v-else>{{ message.file.icon }}</span>
  134. </div>
  135. <div class="file-details">
  136. <div class="file-name">{{ message.file.name }}</div>
  137. <div class="file-size">{{ formatFileSize(message.file.size) }}</div>
  138. </div>
  139. </div>
  140. </div>
  141. <!-- 文本内容 -->
  142. <div v-if="message.content" class="message-text">{{ message.content }}</div>
  143. </div>
  144. <div class="message-actions">
  145. <button class="action-btn copy-btn" @click="copyUserMessage(message)">
  146. <img :src="copyIcon" alt="复制" class="action-icon">
  147. 复制
  148. </button>
  149. <button class="action-btn edit-btn" @click="editUserMessage(message)">
  150. <img :src="editIcon" alt="编辑" class="action-icon">
  151. 编辑
  152. </button>
  153. </div>
  154. </div>
  155. <!-- AI消息 -->
  156. <div v-else-if="message.type === 'ai'" class="ai-message">
  157. <!-- 网络搜索胶囊 - 在输出框上方 -->
  158. <div v-if="message.webSearchRaw && message.webSearchRaw.total > 0" class="web-search-capsule-outer">
  159. <WebSearchCapsule
  160. :total="message.webSearchRaw.total"
  161. :results="message.webSearchRaw.results"
  162. :isExpanded="webSearchSidebarVisible"
  163. @toggle="handleWebSearchToggle(index)"
  164. />
  165. </div>
  166. <div class="ai-message-main">
  167. <div class="ai-avatar-small">
  168. <img :src="aiAvatarIcon" alt="AI" class="ai-icon">
  169. </div>
  170. <div class="message-content" :data-message-index="index" :ref="el => messageContentRefs[index] = el">
  171. <!-- 完整的AI回复内容 -->
  172. <div class="ai-response-content">
  173. <!-- 状态统计卡片 - 优先显示(不等待后端数据) -->
  174. <div v-if="message.showStats"
  175. class="files-stats-white"
  176. :class="{ 'sticky': messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing }"
  177. :style="(messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing && messageScrollStates[index]?.initialLeft > 0 && messageScrollStates[index]?.initialWidth > 0) ? {
  178. position: 'fixed',
  179. top: '56px',
  180. left: messageScrollStates[index].initialLeft + 'px',
  181. width: messageScrollStates[index].initialWidth + 'px',
  182. zIndex: 999
  183. } : {}"
  184. :data-message-index="index">
  185. <div class="stats-left">
  186. <StatusAvatar
  187. :status="getAvatarStatus(message.currentStatus, message.progress)"
  188. :size="36"
  189. class="stats-avatar"
  190. />
  191. <span v-html="message.statusMessage"></span>
  192. </div>
  193. <!-- 进度条胶囊 - 在白色卡片内 -->
  194. <div v-if="message.progress < 100" class="progress-capsule-inline">
  195. <div class="progress-bar-mini">
  196. <div class="progress-fill" :style="{ width: message.progress + '%' }"></div>
  197. <div class="progress-dot" :style="{ left: message.progress + '%' }"></div>
  198. </div>
  199. <span class="progress-percentage">{{ message.progress }}%</span>
  200. </div>
  201. <div v-if="message.progress === 100 && message.reports && message.reports.length > 0" class="stats-right">
  202. <ExportButton
  203. :reports="message.reports.filter(r => r.status === 'completed' && r.type !== 'category_title')"
  204. :disabled="false"
  205. :title="exportTitle"
  206. />
  207. </div>
  208. </div>
  209. <!-- 问题总结 -->
  210. <div v-if="message.summary" class="question-summary">
  211. <StreamMarkdown :content="message.summary" :streaming="false" />
  212. </div>
  213. <!-- 报告生成中的Loading动画 - 当还没有报告时显示 -->
  214. <div v-if="message.isTyping && (!message.reports || message.reports.length === 0) && message.progress < 100" class="report-loading">
  215. <span class="loading-text">AI正在思考中...</span>
  216. <div class="thinking-animation">
  217. <span class="dot"></span>
  218. <span class="dot"></span>
  219. <span class="dot"></span>
  220. </div>
  221. </div>
  222. <!-- 报告列表 -->
  223. <div v-if="message.reports && message.reports.length > 0" class="reports-list">
  224. <template v-for="(report, rIndex) in message.reports" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
  225. <!-- 类别标题 -->
  226. <CategoryTitle
  227. v-if="report.type === 'category_title'"
  228. :category="report.category"
  229. :number="report.number"
  230. :count="report.count"
  231. @toggle="(data) => handleCategoryToggle(index, data)"
  232. />
  233. <!-- 文件报告 -->
  234. <FileReportCard
  235. v-else-if="!report.type || report.type !== 'category_title'"
  236. v-show="isCategoryExpanded(index, report.metadata?._displayCategory || report.metadata?.primary_category)"
  237. :report="report"
  238. @preview-file="handleFilePreview"
  239. />
  240. </template>
  241. <!-- 分类下的Loading动画 - 当有分类但还在等待报告时 -->
  242. <div v-if="message.isTyping && message.progress < 100 && hasOnlyCategoryTitles(message.reports)" class="report-loading">
  243. <span class="loading-text">AI正在思考中...</span>
  244. <div class="thinking-animation">
  245. <span class="dot"></span>
  246. <span class="dot"></span>
  247. <span class="dot"></span>
  248. </div>
  249. </div>
  250. </div>
  251. <!-- 网络搜索总结 -->
  252. <div v-if="message.hasWebSearchResults && message.webSearchSummary">
  253. <WebSearchSummary :summary="message.webSearchSummary" />
  254. </div>
  255. <!-- 安全培训文档卡片 -->
  256. <div v-if="message.isDocument" class="safety-doc-card" @click="handleSafetyDocClick(message)">
  257. <div class="doc-icon-wrapper">
  258. <img src="../assets/Chat/26.png" alt="文档" class="doc-icon">
  259. </div>
  260. <div class="doc-info">
  261. <div class="doc-title">{{ currentQuestion || '安全培训文档' }}</div>
  262. <div class="doc-time">刚刚生成</div>
  263. </div>
  264. <div class="doc-action">
  265. 查看详情 >
  266. </div>
  267. </div>
  268. <!-- AI写作:状态文字 + 文件卡片 -->
  269. <div v-if="message.isAIWriting" class="ai-writing-block">
  270. <div class="ai-writing-status-text">
  271. {{ message.aiWritingStatusText || 'AI智能助手正在为您输出...' }}
  272. </div>
  273. <div class="ai-writing-file-card" @click="openAIWritingSidebar(message)">
  274. <div class="file-card-icon">
  275. <span class="word-icon">W</span>
  276. </div>
  277. <div class="file-card-info">
  278. <div class="file-card-title">{{ message.aiWritingTitle || currentQuestion || 'AI写作文档' }}</div>
  279. <div class="file-card-time">创建于 {{ formatTime(message.timestamp) }}</div>
  280. </div>
  281. </div>
  282. </div>
  283. <!-- 原有的AI文本内容(如果没有报告数据时显示) -->
  284. <div v-if="!message.reports || message.reports.length === 0" class="ai-text">
  285. <div v-if="message.displayContent && message.displayContent.length > 0 && !message.isDocument" class="ai-markdown-content">
  286. <div v-html="message.displayContent"></div>
  287. </div>
  288. </div>
  289. </div>
  290. <div v-show="!message.isTyping && ((message.displayContent && message.displayContent.length > 0) || message.summary)" class="divider"></div>
  291. <div v-show="!message.isTyping && ((message.displayContent && message.displayContent.length > 0) || message.summary)" class="message-actions">
  292. <div class="left-actions">
  293. <button class="action-btn copy-btn" @click="copyAIMessage(message)">
  294. <img :src="copyIcon" alt="复制" class="action-icon">
  295. 复制
  296. </button>
  297. <button class="action-btn regenerate-btn" @click="regenerateResponse(index)" :disabled="isSending || hasTypingMessage">
  298. <img :src="regenerateIcon" alt="重新生成" class="action-icon">
  299. 重新生成
  300. </button>
  301. <!-- 删除按钮 -->
  302. <button class="action-btn delete-btn" @click="handleDeleteClick(index)">
  303. <img src="../assets/AIWriting/8.png" alt="删除" class="action-icon">
  304. 删除
  305. </button>
  306. <button class="action-btn voice-btn" @click="handleVoiceRead(message)" :class="{ speaking: isSpeaking(message.id) }">
  307. <img :src="voiceIcon" alt="语音朗读" class="action-icon">
  308. {{ isSpeaking(message.id) ? '停止朗读' : '语音朗读' }}
  309. </button>
  310. </div>
  311. <div class="right-actions">
  312. <button
  313. class="action-btn thumbs-up-btn"
  314. :class="{ active: message.userFeedback === 'like' }"
  315. @click="handleThumbsUp(message)"
  316. :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
  317. >
  318. <img :src="likeIcon" alt="点赞" class="action-icon">
  319. </button>
  320. <button
  321. class="action-btn thumbs-down-btn"
  322. :class="{ active: message.userFeedback === 'dislike' }"
  323. @click="handleThumbsDown(message)"
  324. :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
  325. >
  326. <img :src="dislikeIcon" alt="踩" class="action-icon">
  327. </button>
  328. </div>
  329. </div>
  330. </div>
  331. </div>
  332. <!-- 搜索来源(仅在没有联网搜索结果时显示) -->
  333. <div v-show="!message.isTyping && ((message.displayContent && message.displayContent.length > 0) || message.summary) && message.searchSources && message.searchSources.length > 0 && !onlineSearchResults[message.id]" class="search-sources">
  334. <div class="search-sources-header" @click="toggleSearchSources(message.id)">
  335. <span>基于{{ message.searchSources?.length || 0 }}个搜索来源</span>
  336. <div class="expand-icon" :class="{ expanded: expandedSearchSources[message.id] }">
  337. <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
  338. <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  339. </svg>
  340. </div>
  341. </div>
  342. <div v-show="expandedSearchSources[message.id]" class="search-sources-content">
  343. <div class="sources-section">
  344. <!-- <div class="section-header">
  345. <div class="section-icon">🔍</div>
  346. <span class="section-title">找到{{ message.searchSources?.length || 0 }}个搜索来源</span>
  347. </div> -->
  348. <div class="sources-grid">
  349. <div class="source-card" v-for="(source, index) in (message.searchSources || [])" :key="index" @click="openSearchSource(source.url)">
  350. <div class="source-title">{{ source.title }}</div>
  351. <div class="source-description">{{ source.content }}</div>
  352. <div class="source-badge">{{ index + 1 }}</div>
  353. </div>
  354. </div>
  355. </div>
  356. </div>
  357. </div>
  358. <!-- 推荐问题Loading -->
  359. <div v-show="!message.isTyping && ((message.displayContent && message.displayContent.length > 0) || message.summary) && isGettingRelatedQuestions && (relatedQuestionsMessageId === message.id || relatedQuestionsMessageId === message.ai_message_id) && aiRelatedQuestions.length === 0" class="related-questions-loading">
  360. <div class="thinking-animation">
  361. <span class="dot"></span>
  362. <span class="dot"></span>
  363. <span class="dot"></span>
  364. </div>
  365. </div>
  366. <!-- 提问联想 -->
  367. <div v-show="!message.isTyping && ((message.displayContent && message.displayContent.length > 0) || message.summary) && (relatedQuestionsMessageId === message.id || relatedQuestionsMessageId === message.ai_message_id) && aiRelatedQuestions.length > 0" class="related-questions">
  368. <div
  369. v-for="(question, index) in aiRelatedQuestions"
  370. :key="index"
  371. class="related-question-item"
  372. @click="handleRelatedQuestion(question)"
  373. >
  374. <span>{{ question }}</span>
  375. <svg class="arrow-icon" viewBox="0 0 16 16" fill="none">
  376. <path d="M6 4L10 8L6 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  377. </svg>
  378. </div>
  379. </div>
  380. </div>
  381. </div>
  382. </div>
  383. </div>
  384. <!-- 底部输入区域 -->
  385. <div class="chat-input-section" v-show="currentMode !== 'exam-workshop'">
  386. <!-- 推荐问题 (移至输入框上方) -->
  387. <div v-if="!showChat && !selectedFile" class="recommended-questions">
  388. <div
  389. v-for="(question, index) in hotQuestions"
  390. :key="question.id || index"
  391. class="question-tag"
  392. @click="handleRecommendedQuestion(question.question)"
  393. >
  394. <img :src="getQuestionIcon(question.question)" alt="问题" class="question-icon">
  395. {{ question.question }}
  396. </div>
  397. <!-- 如果没有数据,显示默认问题 -->
  398. <template v-if="hotQuestions.length === 0">
  399. <div
  400. v-for="(question, index) in defaultHotQuestions"
  401. :key="`default-${index}`"
  402. class="question-tag"
  403. @click="handleRecommendedQuestion(question)"
  404. >
  405. <img :src="getQuestionIcon(question)" alt="问题" class="question-icon">
  406. {{ question }}
  407. </div>
  408. </template>
  409. </div>
  410. <!-- 用户推荐问题区域 -->
  411. <div v-if="userRecommendQuestions.length > 0" class="user-recommend-questions" @click.stop>
  412. <div class="recommend-list">
  413. <div
  414. v-for="(question, index) in userRecommendQuestions"
  415. :key="index"
  416. class="recommend-item"
  417. @click="handleUserRecommendQuestion(question)"
  418. >
  419. {{ question }}
  420. </div>
  421. </div>
  422. </div>
  423. <div class="input-container">
  424. <!-- 文件预览区域 -->
  425. <div v-if="selectedFile" class="file-preview-section">
  426. <div class="file-preview">
  427. <div class="file-icon">
  428. <img v-if="selectedFile.icon !== '📎'" :src="selectedFile.icon" alt="文件图标" class="file-icon-img">
  429. <span v-else>{{ selectedFile.icon }}</span>
  430. </div>
  431. <div class="file-info">
  432. <div class="file-name">{{ selectedFile.name }}</div>
  433. <div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
  434. </div>
  435. <button class="remove-file-btn" @click="removeSelectedFile">
  436. <span class="remove-icon">×</span>
  437. </button>
  438. </div>
  439. </div>
  440. <div class="input-box">
  441. <!-- 上半部分:输入区 -->
  442. <textarea
  443. placeholder="给AI智能助手发消息(按Enter进行发送)"
  444. class="message-input"
  445. v-model="messageText"
  446. @keyup.enter.exact="handleSendMessage"
  447. @input="handleInput"
  448. :disabled="isSending || hasTypingMessage"
  449. maxlength="2000"
  450. rows="3"
  451. ></textarea>
  452. <!-- 下半部分:工具栏 -->
  453. <div class="input-toolbar">
  454. <div class="toolbar-left">
  455. <!-- 语音输入 -->
  456. <button class="voice-btn" @click="handleVoiceClick" :disabled="isSending || hasTypingMessage" :class="{ 'recording': isListening }">
  457. <div class="icon-container">
  458. <img :src="voiceInputIcon" alt="语音" class="action-icon">
  459. <div v-if="isListening" class="recording-indicator"></div>
  460. </div>
  461. </button>
  462. <!-- 模式标签组 -->
  463. <div class="mode-chips">
  464. <div
  465. v-if="currentMode === 'ai-qa' || currentMode === 'ai-writing'"
  466. class="mode-chip"
  467. :class="{ active: currentMode === 'ai-writing' }"
  468. @click="setMode('ai-writing')"
  469. >
  470. <img src="../assets/chat/13.png" alt="AI写作" class="chip-icon">
  471. <span>AI写作</span>
  472. </div>
  473. <div
  474. v-if="currentMode === 'ai-qa' || currentMode === 'safety-training'"
  475. class="mode-chip"
  476. :class="{ active: currentMode === 'safety-training' }"
  477. @click="setMode('safety-training')"
  478. >
  479. <img src="../assets/chat/14.png" alt="安全培训" class="chip-icon">
  480. <span>安全培训</span>
  481. </div>
  482. <div
  483. v-if="currentMode === 'ai-qa' || currentMode === 'exam-workshop'"
  484. class="mode-chip"
  485. :class="{ active: currentMode === 'exam-workshop' }"
  486. @click="setMode('exam-workshop')"
  487. >
  488. <img src="../assets/chat/19.png" alt="考试工坊" class="chip-icon">
  489. <span>考试工坊</span>
  490. </div>
  491. </div>
  492. </div>
  493. <div class="toolbar-right">
  494. <!-- 在线大模型链接按钮 (左侧) -->
  495. <button
  496. class="model-link-btn"
  497. :class="{ 'active': isOnlineModel }"
  498. @click="toggleModelType"
  499. :disabled="isSending || hasTypingMessage"
  500. :title="isOnlineModel ? '当前:在线大模型 (glm-4-plus)' : '当前:本地模型'"
  501. >
  502. <div class="icon-container">
  503. <img :src="isOnlineModel ? networkSearchIconOn : networkSearchIconOff" alt="在线大模型" class="action-icon network-search-icon" :class="{ 'active-icon': isOnlineModel }">
  504. </div>
  505. </button>
  506. <!-- 附件图标 (右侧) -->
  507. <button class="attach-btn" @click="triggerFileUpload" :disabled="isSending || hasTypingMessage">
  508. <div class="icon-container">
  509. <img :src="attachmentIcon" alt="附件" class="action-icon">
  510. </div>
  511. </button>
  512. <button
  513. class="send-btn"
  514. @click="(isSending || hasTypingMessage) ? handleStopGeneration() : handleSendMessage()"
  515. :disabled="!(isSending || hasTypingMessage) && !messageText.trim()"
  516. >
  517. <span v-if="isSending || hasTypingMessage" class="stop-text">停止</span>
  518. <div v-else class="send-icon-circle" :class="{ 'active': messageText.trim() }">
  519. <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
  520. <path d="M12 19V5M12 5L5 12M12 5L19 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  521. </svg>
  522. </div>
  523. </button>
  524. </div>
  525. </div>
  526. </div>
  527. </div>
  528. </div>
  529. </div>
  530. <!-- AI写作侧边栏 -->
  531. <transition name="sidebar-slide">
  532. <div v-if="aiWritingSidebarVisible" class="ai-writing-sidebar">
  533. <div class="sidebar-header">
  534. <h3 class="sidebar-title">{{ aiWritingSidebarTitle }}</h3>
  535. <div class="sidebar-header-actions">
  536. <button class="sidebar-close-btn" @click="closeAIWritingSidebar">
  537. <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
  538. <path d="M12 4L4 12M4 4L12 12" stroke="#909399" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  539. </svg>
  540. </button>
  541. </div>
  542. </div>
  543. <div class="sidebar-toolbar" v-if="!aiWritingIsGenerating">
  544. <span class="toolbar-label">正文</span>
  545. <span class="toolbar-font-size">15px</span>
  546. <div class="toolbar-divider"></div>
  547. <button class="toolbar-btn"><b>B</b></button>
  548. <button class="toolbar-btn"><s>S</s></button>
  549. <button class="toolbar-btn"><u>U</u></button>
  550. <button class="toolbar-btn"><i>I</i></button>
  551. </div>
  552. <div class="sidebar-content">
  553. <div class="sidebar-doc-content" v-text="aiWritingSidebarContent"></div>
  554. <div v-if="aiWritingIsGenerating" class="sidebar-generating-hint">
  555. <span class="generating-text-hint">AI智能助手正在输出</span>
  556. <span class="generating-dots">
  557. <span class="dot">.</span>
  558. <span class="dot">.</span>
  559. <span class="dot">.</span>
  560. </span>
  561. </div>
  562. </div>
  563. </div>
  564. </transition>
  565. </div>
  566. <!-- 隐藏的文件输入框 -->
  567. <input
  568. ref="fileInput"
  569. type="file"
  570. accept=".doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,image/*,text/plain"
  571. style="display: none"
  572. @change="handleFileSelect"
  573. />
  574. <!-- Toast提示组件 -->
  575. <Toast
  576. ref="toastRef"
  577. :message="toastMessage"
  578. :type="toastType"
  579. :duration="toastDuration"
  580. />
  581. <!-- 删除确认弹窗 -->
  582. <DeleteConfirmModal
  583. :visible="showDeleteModal"
  584. :title="deleteConfirmTitle"
  585. :message="deleteConfirmMessage"
  586. @confirm="confirmDelete"
  587. @cancel="cancelDelete"
  588. @close="cancelDelete"
  589. />
  590. <!-- 文件预览抽屉 -->
  591. <FilePreviewDrawer
  592. v-model="showFilePreview"
  593. :file-path="previewFilePath"
  594. :file-name="previewFileName"
  595. />
  596. <!-- 网络搜索侧边栏 -->
  597. <WebSearchSidebar
  598. :visible="webSearchSidebarVisible"
  599. :results="currentWebSearchData.results"
  600. :keywords="currentWebSearchData.keywords"
  601. :total="currentWebSearchData.total"
  602. @update:visible="webSearchSidebarVisible = $event"
  603. />
  604. </div>
  605. </template>
  606. <script setup>
  607. import { ref, nextTick, reactive, triggerRef, markRaw, onMounted, onBeforeUnmount, computed, watch, onActivated } from 'vue'
  608. import { useRoute, useRouter } from 'vue-router'
  609. import Sidebar from '@/components/Sidebar.vue'
  610. import ExamWorkshop from '@/views/ExamWorkshop.vue'
  611. import * as mammoth from 'mammoth'
  612. // 导入Element Plus组件
  613. import { ElMessage } from 'element-plus'
  614. import DeleteConfirmModal from '@/components/DeleteConfirmModal.vue'
  615. import Toast from '@/components/Toast.vue'
  616. import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
  617. // ===== 已删除:getUserId - 不再需要,改用token =====
  618. // import { getUserId } from '@/utils/userManager.js'
  619. import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
  620. import { getToken, getTokenType } from '@/utils/auth.js'
  621. import { renderMarkdown } from '@/utils/markdown'
  622. import 'katex/dist/katex.min.css'
  623. // 导入ReportGenerator相关组件
  624. import QuestionInput from '@/components/QuestionInput.vue'
  625. import CategoryTitle from '@/components/CategoryTitle.vue'
  626. import FileReportCard from '@/components/FileReportCard.vue'
  627. import ExportButton from '@/components/ExportButton.vue'
  628. import StreamMarkdown from '@/components/StreamMarkdown.vue'
  629. import FilePreviewDrawer from '@/components/FilePreviewDrawer.vue'
  630. import WebSearchCapsule from '@/components/WebSearchCapsule.vue'
  631. import WebSearchSidebar from '@/components/WebSearchSidebar.vue'
  632. import WebSearchSummary from '@/components/WebSearchSummary.vue'
  633. import StatusAvatar from '@/components/StatusAvatar.vue'
  634. import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
  635. import { getApiPrefix } from '@/utils/apiConfig'
  636. import { Document } from '@element-plus/icons-vue'
  637. // 导入发送按钮图标
  638. import sendIconEmpty from '@/assets/chat/15.png'
  639. import sendIconFilled from '@/assets/chat/16.png'
  640. // 导入其他图片资源
  641. import newChatIcon from '@/assets/chat/2.png'
  642. import emptyIcon from '@/assets/Chat/22.png'
  643. import aiAvatarIcon from '@/assets/Chat/29.png'
  644. import bridgeIcon from '@/assets/chat/4.png'
  645. import constructionIcon from '@/assets/chat/5.png'
  646. import materialIcon from '@/assets/chat/6.png'
  647. import standardIcon from '@/assets/chat/7.png'
  648. import copyIcon from '@/assets/AIWriting/5.png'
  649. import editIcon from '@/assets/AIWriting/6.png'
  650. import regenerateIcon from '@/assets/AIWriting/7.png'
  651. import deleteIcon from '@/assets/AIWriting/8.png'
  652. import voiceIcon from '@/assets/AIWriting/9.png'
  653. import likeIcon from '@/assets/AIWriting/10.png'
  654. import dislikeIcon from '@/assets/AIWriting/11.png'
  655. import questionIcon1 from '@/assets/chat/12.png'
  656. import questionIcon2 from '@/assets/chat/10.png'
  657. import questionIcon3 from '@/assets/chat/11.png'
  658. import attachmentIcon from '@/assets/chat/9.png'
  659. import voiceInputIcon from '@/assets/Chat/18.png'
  660. import networkSearchIconOn from '@/assets/Chat/24.png'
  661. import networkSearchIconOff from '@/assets/Chat/25.png'
  662. import wordDocIcon from '@/assets/Chat/26.png'
  663. import { Link } from '@element-plus/icons-vue'
  664. import { apis } from '@/request/apis.js'
  665. // 路由实例
  666. const route = useRoute()
  667. const router = useRouter()
  668. // 响应式数据
  669. const messageText = ref('')
  670. const selectedHistoryItem = ref(null) // 选中的历史记录项
  671. const showChat = ref(false) // 控制是否显示聊天界面
  672. const isSending = ref(false) // 控制发送状态
  673. const isOnlineModel = ref(true) // 是否使用在线大模型
  674. // 切换模型类型
  675. const toggleModelType = () => {
  676. isOnlineModel.value = !isOnlineModel.value
  677. showToast(
  678. isOnlineModel.value ? '启动在线大模型 (glm-4-plus)' : '已禁用在线大模型'
  679. )
  680. }
  681. const currentMode = ref('ai-qa')
  682. const hasUserSelectedMode = ref(false)
  683. const defaultHotQuestionsByMode = {
  684. 'ai-qa': [
  685. '施工安全生产责任的规定',
  686. '工程建设质量的要求',
  687. '公路桥梁加固设计规范'
  688. ],
  689. 'ai-writing': [
  690. '生成施工安全培训通知',
  691. '编写安全生产责任制范本',
  692. '生成隐患排查整改方案'
  693. ],
  694. 'safety-training': [
  695. '新员工安全培训内容有哪些',
  696. '特种作业人员安全培训要求',
  697. '安全培训档案应包含哪些内容'
  698. ],
  699. 'exam-workshop': [
  700. '生成安全培训考试题库',
  701. '安全生产法规知识点整理',
  702. '常见隐患排查考试题'
  703. ]
  704. }
  705. const defaultHotQuestions = computed(
  706. () => defaultHotQuestionsByMode[currentMode.value] || defaultHotQuestionsByMode['ai-qa']
  707. )
  708. const defaultFunctionCardsByMode = {
  709. 'ai-qa': [
  710. {
  711. title: '桥梁结构设计问题',
  712. description: '各类桥梁结构设计,计算与分析',
  713. icon: bridgeIcon
  714. },
  715. {
  716. title: '施工技术咨询',
  717. description: '桥梁施工方法,工艺与技术要点',
  718. icon: constructionIcon
  719. },
  720. {
  721. title: '材料与力学问题',
  722. description: '建筑材料性能与结构力学分析',
  723. icon: materialIcon
  724. },
  725. {
  726. title: '规范标准查询',
  727. description: '行业规范,标准解读与应用',
  728. icon: standardIcon
  729. }
  730. ],
  731. 'ai-writing': [
  732. {
  733. title: '安全培训通知',
  734. description: '生成培训通知、计划与执行安排',
  735. icon: bridgeIcon
  736. },
  737. {
  738. title: '制度与方案编写',
  739. description: '生成制度、方案、流程与模板',
  740. icon: constructionIcon
  741. },
  742. {
  743. title: '隐患整改方案',
  744. description: '生成整改措施与闭环流程',
  745. icon: materialIcon
  746. },
  747. {
  748. title: '会议纪要模板',
  749. description: '生成会议纪要与行动项清单',
  750. icon: standardIcon
  751. }
  752. ],
  753. 'safety-training': [
  754. {
  755. title: '新员工安全培训',
  756. description: '入职培训要点与必修内容',
  757. icon: bridgeIcon
  758. },
  759. {
  760. title: '特种作业培训',
  761. description: '特种作业上岗培训与复训要求',
  762. icon: constructionIcon
  763. },
  764. {
  765. title: '安全教育制度',
  766. description: '安全教育制度与实施流程',
  767. icon: materialIcon
  768. },
  769. {
  770. title: '培训档案管理',
  771. description: '培训记录与档案规范管理',
  772. icon: standardIcon
  773. }
  774. ],
  775. 'exam-workshop': [
  776. {
  777. title: '考试题库生成',
  778. description: '按主题生成题库与解析',
  779. icon: bridgeIcon
  780. },
  781. {
  782. title: '法规知识点',
  783. description: '法规标准要点梳理与归纳',
  784. icon: constructionIcon
  785. },
  786. {
  787. title: '隐患排查题',
  788. description: '隐患识别与处置题型',
  789. icon: materialIcon
  790. },
  791. {
  792. title: '考试大纲',
  793. description: '生成考试大纲与评分标准',
  794. icon: standardIcon
  795. }
  796. ]
  797. }
  798. const defaultFunctionCards = computed(
  799. () => defaultFunctionCardsByMode[currentMode.value] || defaultFunctionCardsByMode['ai-qa']
  800. )
  801. const setMode = (mode, options = {}) => {
  802. const { allowToggle = true, source = 'user' } = options
  803. if (source === 'user') {
  804. hasUserSelectedMode.value = true
  805. }
  806. if (allowToggle && currentMode.value === mode) {
  807. currentMode.value = 'ai-qa' // 再次点击取消选中,回到默认问答
  808. } else {
  809. currentMode.value = mode
  810. }
  811. // 切换回AI问答时,确保输入框和聊天区域正常显示
  812. if (currentMode.value === 'ai-qa' && !chatMessages.value.length) {
  813. showChat.value = false
  814. } else if (currentMode.value === 'ai-qa' && chatMessages.value.length > 0) {
  815. showChat.value = true
  816. }
  817. }
  818. // ReportGenerator相关状态
  819. const categoryExpandStates = ref({})
  820. const currentQuestion = ref('')
  821. let sseConnection = null
  822. const streamingReports = ref(new Map())
  823. // 删除确认弹窗状态
  824. const showDeleteModal = ref(false)
  825. const deleteTargetItem = ref(null) // 要删除的历史记录项
  826. const deleteType = ref('') // 删除类型:'history' 或 'message'
  827. // 功能卡片和热点问题数据
  828. const functionCards = ref([])
  829. const hotQuestions = ref([])
  830. // 聊天消息数据
  831. const chatMessages = ref([])
  832. const messageContentRefs = ref({})
  833. const messageScrollStates = ref({})
  834. // Toast相关
  835. const toastRef = ref(null)
  836. const toastMessage = ref('')
  837. const toastType = ref('success')
  838. const toastDuration = ref(2000)
  839. // 显示Toast提示
  840. const showToast = (message, type = 'success', duration = 2000) => {
  841. toastMessage.value = message
  842. toastType.value = type
  843. toastDuration.value = duration
  844. // 显示Toast
  845. if (toastRef.value) {
  846. toastRef.value.show()
  847. }
  848. }
  849. // 语音功能
  850. const {
  851. isSupported: speechSupported,
  852. isListening,
  853. isSpeaking: speechIsSpeaking,
  854. transcript,
  855. error: speechError,
  856. startListening,
  857. stopListening,
  858. speakText,
  859. stopSpeaking
  860. } = useSpeechRecognition()
  861. // 记录正在朗读的消息ID
  862. const speakingMessageId = ref(null)
  863. // 页面卸载事件处理函数
  864. const handlePageUnload = () => {
  865. if (speakingMessageId.value) {
  866. stopAllAudio()
  867. speakingMessageId.value = null
  868. }
  869. }
  870. // 页面可见性变化处理函数
  871. const handleVisibilityChange = () => {
  872. if (document.hidden && speakingMessageId.value) {
  873. stopAllAudio()
  874. speakingMessageId.value = null
  875. }
  876. }
  877. // 文件上传相关
  878. const fileInput = ref(null)
  879. const selectedFile = ref(null)
  880. const isUploadingFile = ref(false)
  881. const fileContent = ref('') // 存储文件内容
  882. // 用户推荐问题相关
  883. const userRecommendQuestions = ref([]) // 用户输入时获取的推荐问题
  884. const isGettingRecommendQuestions = ref(false) // 是否正在获取推荐问题
  885. const recommendQuestionsTimer = ref(null) // 防抖定时器
  886. // AI回复相关推荐问题
  887. const aiRelatedQuestions = ref([]) // AI回复后的相关推荐问题
  888. const isGettingRelatedQuestions = ref(false) // 是否正在获取相关推荐问题
  889. const relatedQuestionsMessageId = ref(null) // 关联的消息ID
  890. const isAIReplyProcessComplete = ref(true) // AI回复流程是否完成(包括联网搜索和推荐问题)
  891. // 联网搜索相关
  892. const isNetworkSearchEnabled = ref(true) // 联网搜索是否启用,默认为true
  893. const onlineSearchResults = ref({}) // 存储每个消息的联网搜索结果
  894. const isGettingOnlineSearch = ref(false) // 是否正在获取联网搜索结果
  895. const onlineSearchLoadingMessages = ref(new Set()) // 记录正在加载联网搜索的消息ID
  896. // 搜索来源展开状态
  897. const expandedSearchSources = ref({}) // 记录每个消息的搜索来源展开状态
  898. const expandedOnlineSearchResults = ref({}) // 记录每个消息的联网搜索结果展开状态
  899. // 网络搜索结果数据(新增)
  900. const webSearchSidebarVisible = ref(false) // 网络搜索侧边栏显示状态
  901. // AI写作侧边栏状态
  902. const aiWritingSidebarVisible = ref(false)
  903. const aiWritingSidebarTitle = ref('')
  904. const aiWritingSidebarContent = ref('')
  905. const aiWritingIsGenerating = ref(false)
  906. const aiWritingFullContent = ref('') // 保存完整内容,用于点击文件卡片时重新打开
  907. const normalizeAiWritingText = (content) => {
  908. if (!content) return ''
  909. return String(content).replace(/\r\n/g, '\n')
  910. }
  911. // 关闭AI写作侧边栏
  912. const closeAIWritingSidebar = () => {
  913. aiWritingSidebarVisible.value = false
  914. }
  915. // 打开AI写作侧边栏(点击文件卡片时)
  916. const openAIWritingSidebar = (message) => {
  917. aiWritingSidebarTitle.value = message.aiWritingTitle || currentQuestion.value || 'AI写作文档'
  918. aiWritingSidebarContent.value = normalizeAiWritingText(message.fullContent || message.content || '')
  919. aiWritingIsGenerating.value = message.aiWritingStatus === 'generating' || message.isTyping
  920. aiWritingSidebarVisible.value = true
  921. }
  922. const currentWebSearchData = ref({
  923. results: [],
  924. keywords: [],
  925. total: 0
  926. }) // 当前查看的网络搜索数据
  927. // 搜索来源数据现在从历史记录中动态获取,不再使用模拟数据
  928. // 文件处理配置
  929. const fileConfig = reactive({
  930. maxSize: 20 * 1024 * 1024, // 20MB
  931. allowedTypes: ['.docx'] // 只允许.docx格式的Word文档
  932. })
  933. // 文件预览相关
  934. const showFilePreview = ref(false)
  935. const previewFilePath = ref('')
  936. const previewFileName = ref('')
  937. // 计算属性 - 是否有正在打字的AI消息或AI回复流程未完成
  938. const hasTypingMessage = computed(() => {
  939. return chatMessages.value.some(message => message.type === 'ai' && message.isTyping) || !isAIReplyProcessComplete.value
  940. })
  941. // ReportGenerator相关计算属性
  942. const exportTitle = computed(() => {
  943. if (currentQuestion.value) {
  944. return currentQuestion.value.substring(0, 20) + (currentQuestion.value.length > 20 ? '...' : '')
  945. }
  946. return 'AI问题分析报告'
  947. })
  948. // 计算属性 - 是否有正在加载的AI消息(内容为空且正在打字)
  949. const hasLoadingMessage = computed(() => {
  950. return chatMessages.value.some(message =>
  951. message.type === 'ai' &&
  952. message.isTyping &&
  953. (!message.content || message.content.trim() === '')
  954. )
  955. })
  956. // 计算属性 - 是否有正在打字的AI消息(有内容)
  957. const hasTypingWithContent = computed(() => {
  958. return chatMessages.value.some(message =>
  959. message.type === 'ai' &&
  960. message.isTyping &&
  961. message.content &&
  962. message.content.trim() !== ''
  963. )
  964. })
  965. // 删除确认消息
  966. const deleteConfirmMessage = computed(() => {
  967. if (deleteType.value === 'history') {
  968. const title = deleteTargetItem.value?.item?.title || ''
  969. return `确定要删除历史记录"${title}"吗?删除后将无法恢复。`
  970. } else if (deleteType.value === 'message') {
  971. return '确定要删除这条消息吗?删除后将无法恢复。'
  972. }
  973. return '确定要删除吗?删除后将无法恢复。'
  974. })
  975. // 删除确认标题
  976. const deleteConfirmTitle = computed(() => {
  977. if (deleteType.value === 'history') {
  978. return '删除历史记录'
  979. } else if (deleteType.value === 'message') {
  980. return '删除消息'
  981. }
  982. return '删除确认'
  983. })
  984. // 处理AI回复中的特殊字符和表情符号
  985. const processAIResponse = (text) => {
  986. if (!text) return text
  987. try {
  988. // URL解码
  989. if (text.includes('%')) {
  990. text = decodeURIComponent(text)
  991. }
  992. // Unicode转义序列
  993. if (text.includes('\\u')) {
  994. text = text.replace(/\\u[\dA-F]{4}/gi, (match) => {
  995. return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16))
  996. })
  997. }
  998. // HTML实体解码
  999. if (text.includes('&')) {
  1000. const textarea = document.createElement('textarea')
  1001. textarea.innerHTML = text
  1002. text = textarea.value
  1003. }
  1004. // LaTeX公式转换
  1005. return convertLatexToDisplay(text)
  1006. } catch (error) {
  1007. console.warn('文本处理失败:', error)
  1008. return text
  1009. }
  1010. }
  1011. // 打字机效果函数
  1012. const typewriterIntervals = new Map() // 存储每个消息的打字机定时器
  1013. const reportTypewriters = new Map() // 存储每个报告字段的打字机定时器
  1014. const startTypewriterEffect = (message, fullContent, speed = 30) => {
  1015. return new Promise((resolve) => {
  1016. // 如果已经有打字机在运行,先清除
  1017. if (typewriterIntervals.has(message.id)) {
  1018. clearInterval(typewriterIntervals.get(message.id))
  1019. typewriterIntervals.delete(message.id)
  1020. }
  1021. let currentIndex = 0
  1022. message.displayContent = ''
  1023. message.isTyping = true
  1024. const interval = setInterval(() => {
  1025. if (currentIndex < fullContent.length) {
  1026. // 每次增加的字符数(速度越快,每次增加越多)
  1027. const charsToAdd = Math.max(1, Math.floor(speed / 10))
  1028. currentIndex = Math.min(currentIndex + charsToAdd, fullContent.length)
  1029. // 更新显示内容
  1030. message.displayContent = fullContent.substring(0, currentIndex)
  1031. } else {
  1032. // 打字完成
  1033. clearInterval(interval)
  1034. typewriterIntervals.delete(message.id)
  1035. message.isTyping = false
  1036. message.displayContent = fullContent
  1037. // 确保状态更新为完成
  1038. if (message.showStats && message.progress < 100) {
  1039. updateMessageStatus(message, 'completed')
  1040. }
  1041. resolve()
  1042. }
  1043. }, 1000 / 60) // 60fps
  1044. typewriterIntervals.set(message.id, interval)
  1045. })
  1046. }
  1047. // 为报告字段添加打字机效果
  1048. const startReportFieldTypewriter = (report, field, fullContent, speed = 50) => {
  1049. return new Promise((resolve) => {
  1050. const key = `${report.file_index}_${field}`
  1051. // 如果已经有打字机在运行,先清除
  1052. if (reportTypewriters.has(key)) {
  1053. clearInterval(reportTypewriters.get(key))
  1054. reportTypewriters.delete(key)
  1055. }
  1056. // 初始化打字机状态
  1057. if (!report._typewriterStates) {
  1058. report._typewriterStates = {}
  1059. }
  1060. let currentIndex = 0
  1061. report._typewriterStates[field] = { currentIndex: 0, isTyping: true }
  1062. const interval = setInterval(() => {
  1063. if (currentIndex < fullContent.length) {
  1064. const charsToAdd = Math.max(1, Math.floor(speed / 10))
  1065. currentIndex = Math.min(currentIndex + charsToAdd, fullContent.length)
  1066. // 更新显示内容
  1067. report.report[field] = fullContent.substring(0, currentIndex)
  1068. report._typewriterStates[field].currentIndex = currentIndex
  1069. } else {
  1070. // 打字完成
  1071. clearInterval(interval)
  1072. reportTypewriters.delete(key)
  1073. report._typewriterStates[field].isTyping = false
  1074. report.report[field] = fullContent
  1075. resolve()
  1076. }
  1077. }, 1000 / 60) // 60fps
  1078. reportTypewriters.set(key, interval)
  1079. })
  1080. }
  1081. // 清除所有打字机定时器
  1082. const clearAllTypeIntervals = () => {
  1083. typewriterIntervals.forEach((interval, messageId) => {
  1084. clearInterval(interval)
  1085. })
  1086. typewriterIntervals.clear()
  1087. reportTypewriters.forEach((interval, key) => {
  1088. clearInterval(interval)
  1089. })
  1090. reportTypewriters.clear()
  1091. }
  1092. // 处理文件标签格式的回显
  1093. const processFileDisplay = (text, file) => {
  1094. if (!file) {
  1095. // 如果没有文件对象,尝试从文本中提取文件信息
  1096. return processFileDisplayFromText(text)
  1097. }
  1098. // 查找文件标签并替换为显示格式
  1099. const fileDisplay = `
  1100. 📄 文件信息:
  1101. 文件名:${file.name}
  1102. 文件大小:${formatFileSize(file.size)}
  1103. 文件类型:${file.type}
  1104. 📝 文件内容:
  1105. ${file.content}
  1106. ---
  1107. `
  1108. // 替换文件标签为显示格式
  1109. let processedText = text
  1110. .replace(/<word>.*?<\/word>/gs, fileDisplay)
  1111. .replace(/<filename>.*?<\/filename>/g, '')
  1112. .replace(/<filesize>.*?<\/filesize>/g, '')
  1113. return processedText
  1114. }
  1115. // 从文本中提取文件信息并转换为显示格式
  1116. const processFileDisplayFromText = (text) => {
  1117. // 提取文件名
  1118. const filenameMatch = text.match(/<filename>(.*?)<\/filename>/)
  1119. const filename = filenameMatch ? filenameMatch[1] : '未知文件'
  1120. // 提取文件大小
  1121. const filesizeMatch = text.match(/<filesize>(.*?)<\/filesize>/)
  1122. const filesize = filesizeMatch ? parseInt(filesizeMatch[1]) : 0
  1123. // 提取文件内容
  1124. const wordMatch = text.match(/<word>(.*?)<\/word>/s)
  1125. const fileContent = wordMatch ? wordMatch[1].trim() : '无内容'
  1126. // 创建文件显示格式
  1127. const fileDisplay = `
  1128. 📄 文件信息:
  1129. 文件名:${filename}
  1130. 文件大小:${formatFileSize(filesize)}
  1131. 文件类型:${filename.endsWith('.docx') ? '.docx' : filename.endsWith('.doc') ? '.doc' : '未知'}
  1132. 📝 文件内容:
  1133. ${fileContent}
  1134. ---
  1135. `
  1136. // 替换文件标签为显示格式
  1137. let processedText = text
  1138. .replace(/<word>.*?<\/word>/gs, fileDisplay)
  1139. .replace(/<filename>.*?<\/filename>/g, '')
  1140. .replace(/<filesize>.*?<\/filesize>/g, '')
  1141. return processedText
  1142. }
  1143. // 使用markdown.js渲染Markdown内容
  1144. const renderMarkdownContent = (content) => {
  1145. try {
  1146. let html = renderMarkdown(content)
  1147. html = processStandardReferences(html)
  1148. nextTick(() => {
  1149. bindStandardReferenceEvents()
  1150. })
  1151. return html
  1152. } catch (error) {
  1153. console.error('Markdown渲染失败:', error)
  1154. return content.replace(/\n/g, '<br>')
  1155. }
  1156. }
  1157. // LaTeX公式转换函数
  1158. const convertLatexToDisplay = (text) => {
  1159. if (!text) return text
  1160. let convertedText = text
  1161. // 希腊字母转换
  1162. const greekLetters = {
  1163. '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε',
  1164. '\\varepsilon': 'ε', '\\zeta': 'ζ', '\\eta': 'η', '\\theta': 'θ', '\\iota': 'ι',
  1165. '\\kappa': 'κ', '\\lambda': 'λ', '\\mu': 'μ', '\\nu': 'ν', '\\xi': 'ξ',
  1166. '\\pi': 'π', '\\rho': 'ρ', '\\sigma': 'σ', '\\tau': 'τ', '\\upsilon': 'υ',
  1167. '\\phi': 'φ', '\\chi': 'χ', '\\psi': 'ψ', '\\omega': 'ω',
  1168. '\\Gamma': 'Γ', '\\Delta': 'Δ', '\\Theta': 'Θ', '\\Lambda': 'Λ', '\\Xi': 'Ξ',
  1169. '\\Pi': 'Π', '\\Sigma': 'Σ', '\\Upsilon': 'Υ', '\\Phi': 'Φ', '\\Psi': 'Ψ', '\\Omega': 'Ω'
  1170. }
  1171. // 转换希腊字母
  1172. for (const [latex, symbol] of Object.entries(greekLetters)) {
  1173. const regex = new RegExp(latex.replace(/\\/g, '\\\\'), 'g')
  1174. convertedText = convertedText.replace(regex, symbol)
  1175. }
  1176. // 分数转换 \frac{a}{b} → a/b
  1177. convertedText = convertedText.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '$1/$2')
  1178. // 上标转换 a^b → a^b (保持原样,因为前端可以显示)
  1179. // convertedText = convertedText.replace(/\^(\w+)/g, '^$1')
  1180. // 下标转换 a_b → a_b (保持原样,因为前端可以显示)
  1181. // convertedText = convertedText.replace(/_(\w+)/g, '_$1')
  1182. // 根号转换 \sqrt{a} → √a
  1183. convertedText = convertedText.replace(/\\sqrt\{([^}]+)\}/g, '√$1')
  1184. // 积分符号转换
  1185. convertedText = convertedText.replace(/\\int/g, '∫')
  1186. convertedText = convertedText.replace(/\\sum/g, '∑')
  1187. convertedText = convertedText.replace(/\\prod/g, '∏')
  1188. convertedText = convertedText.replace(/\\partial/g, '∂')
  1189. convertedText = convertedText.replace(/\\nabla/g, '∇')
  1190. convertedText = convertedText.replace(/\\infty/g, '∞')
  1191. // 其他数学符号
  1192. convertedText = convertedText.replace(/\\pm/g, '±')
  1193. convertedText = convertedText.replace(/\\times/g, '×')
  1194. convertedText = convertedText.replace(/\\div/g, '÷')
  1195. convertedText = convertedText.replace(/\\leq/g, '≤')
  1196. convertedText = convertedText.replace(/\\geq/g, '≥')
  1197. convertedText = convertedText.replace(/\\neq/g, '≠')
  1198. convertedText = convertedText.replace(/\\approx/g, '≈')
  1199. convertedText = convertedText.replace(/\\equiv/g, '≡')
  1200. convertedText = convertedText.replace(/\\propto/g, '∝')
  1201. // 集合符号
  1202. convertedText = convertedText.replace(/\\in/g, '∈')
  1203. convertedText = convertedText.replace(/\\notin/g, '∉')
  1204. convertedText = convertedText.replace(/\\subset/g, '⊂')
  1205. convertedText = convertedText.replace(/\\supset/g, '⊃')
  1206. convertedText = convertedText.replace(/\\cup/g, '∪')
  1207. convertedText = convertedText.replace(/\\cap/g, '∩')
  1208. convertedText = convertedText.replace(/\\emptyset/g, '∅')
  1209. // 逻辑符号
  1210. convertedText = convertedText.replace(/\\land/g, '∧')
  1211. convertedText = convertedText.replace(/\\lor/g, '∨')
  1212. convertedText = convertedText.replace(/\\neg/g, '¬')
  1213. convertedText = convertedText.replace(/\\rightarrow/g, '→')
  1214. convertedText = convertedText.replace(/\\leftarrow/g, '←')
  1215. convertedText = convertedText.replace(/\\leftrightarrow/g, '↔')
  1216. convertedText = convertedText.replace(/\\forall/g, '∀')
  1217. convertedText = convertedText.replace(/\\exists/g, '∃')
  1218. return convertedText
  1219. }
  1220. // 处理规范引用 - 将<file></file>标签内容转换为可点击的规范引用
  1221. const processStandardReferences = (html) => {
  1222. if (!html) return html
  1223. const refStyle = 'background-color: #EAEAEE; color: #616161; font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; cursor: pointer; display: inline-block; margin: 4px 2px; border: 1px solid #EAEAEE; font-weight: 500; transition: all 0.2s ease; line-height: 1.4; max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'
  1224. return html.replace(/<file>(.*?)<\/file>/g, (match, content) => {
  1225. if (/^<span\s+class="standard-reference"/i.test(content)) {
  1226. return match
  1227. }
  1228. const standardMatch = content.match(/^([《「『【]?[\s\S]*?[》」』】]?)[\s]*\(([^)]+)\)$/)
  1229. if (standardMatch) {
  1230. return `<span class="standard-reference" data-standard="${content}" title="点击查看标准详情" style="${refStyle}">${content}</span>`
  1231. }
  1232. return `<span class="standard-reference" data-reference="${content}" title="点击查看详情" style="${refStyle}">${content}</span>`
  1233. })
  1234. }
  1235. // 历史记录数据
  1236. const historyData = ref([])
  1237. const historyTotal = ref(0) // 历史记录总数
  1238. const isLoadingHistory = ref(false) // 是否正在加载历史记录
  1239. // 获取历史记录列表 (合并所有模块)
  1240. const getHistoryRecordList = async () => {
  1241. try {
  1242. isLoadingHistory.value = true
  1243. const response = await apis.getHistoryRecord({
  1244. ai_conversation_id: 0
  1245. })
  1246. let allRecords = []
  1247. let totalCount = 0
  1248. if (response.statusCode === 200 && response.data) {
  1249. totalCount = response.total || response.data.length || 0
  1250. allRecords = response.data
  1251. }
  1252. // 按时间降序排序
  1253. allRecords.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
  1254. historyTotal.value = totalCount
  1255. historyData.value = allRecords.map(conversation => ({
  1256. id: conversation.id,
  1257. title: conversation.title || generateConversationTitle(conversation.content),
  1258. time: formatTime(conversation.updated_at),
  1259. businessType: conversation.business_type,
  1260. isActive: false,
  1261. rawData: conversation
  1262. }))
  1263. } catch (error) {
  1264. console.error('获取历史记录失败:', error)
  1265. } finally {
  1266. isLoadingHistory.value = false
  1267. }
  1268. }
  1269. // 获取指定对话的详细消息
  1270. const getConversationMessages = async (conversationId) => {
  1271. try {
  1272. const response = await apis.getHistoryRecord({
  1273. // ===== 已删除:user_id - 后端从token解析 =====
  1274. ai_conversation_id: conversationId,
  1275. business_type: 0
  1276. })
  1277. if (response.statusCode === 200) {
  1278. if (!response.data || !Array.isArray(response.data)) {
  1279. console.error('响应数据格式错误')
  1280. return false
  1281. }
  1282. const messages = await Promise.all(response.data.map(async (message, index) => {
  1283. const userFeedback = convertUserFeedback(message.user_feedback)
  1284. // 如果是用户消息且包含文件标签,提取文件信息
  1285. let file = null
  1286. let userContent = message.content
  1287. // 获取前一条用户消息作为AI消息的问题上下文
  1288. let userQuestion = null
  1289. if (message.type === 'ai' && index > 0) {
  1290. const prevMessage = response.data[index - 1]
  1291. if (prevMessage && prevMessage.type === 'user') {
  1292. // 提取用户实际问题(去除文件标签)
  1293. if (prevMessage.content.includes('</filesize>')) {
  1294. const userMessageMatch = prevMessage.content.split('</filesize>')[1]
  1295. userQuestion = userMessageMatch ? userMessageMatch.trim() : prevMessage.content
  1296. } else {
  1297. userQuestion = prevMessage.content
  1298. }
  1299. }
  1300. }
  1301. if (message.type === 'user' && message.content.includes('</filesize>')) {
  1302. // 提取文件信息
  1303. const filenameMatch = message.content.match(/<filename>(.*?)<\/filename>/)
  1304. const filesizeMatch = message.content.match(/<filesize>(.*?)<\/filesize>/)
  1305. const wordMatch = message.content.match(/<word>(.*?)<\/word>/s)
  1306. if (filenameMatch && filesizeMatch) {
  1307. const filename = filenameMatch[1]
  1308. const filesize = parseInt(filesizeMatch[1])
  1309. const fileContent = wordMatch ? wordMatch[1].trim() : ''
  1310. // 创建文件对象
  1311. file = {
  1312. name: filename,
  1313. size: filesize,
  1314. type: filename.endsWith('.docx') ? '.docx' : filename.endsWith('.doc') ? '.doc' : '.docx',
  1315. icon: getFileIcon(filename.endsWith('.docx') ? '.docx' : filename.endsWith('.doc') ? '.doc' : '.docx'),
  1316. content: fileContent
  1317. }
  1318. // 提取用户实际说的话(</filesize>标签后的内容)
  1319. const userMessageMatch = message.content.split('</filesize>')[1]
  1320. userContent = userMessageMatch ? userMessageMatch.trim() : ''
  1321. }
  1322. }
  1323. // 为AI消息准备displayContent
  1324. let displayContent = userContent || ''
  1325. let reports = []
  1326. let summary = message.summary || '' // 从后端恢复summary字段
  1327. if (message.type === 'ai') {
  1328. try {
  1329. const contentStr = message.content || ''
  1330. // 尝试解析content为JSON
  1331. if (contentStr.trim().startsWith('[') || contentStr.trim().startsWith('{')) {
  1332. try {
  1333. const parsedContent = JSON.parse(contentStr)
  1334. // 检查是否是新格式(包含reports和webSearch数据)
  1335. if (parsedContent.reports && Array.isArray(parsedContent.reports)) {
  1336. reports = parsedContent.reports
  1337. // 恢复网络搜索数据
  1338. if (parsedContent.webSearchRaw) {
  1339. message.webSearchRaw = parsedContent.webSearchRaw
  1340. }
  1341. if (parsedContent.webSearchSummary) {
  1342. message.webSearchSummary = parsedContent.webSearchSummary
  1343. message.hasWebSearchResults = parsedContent.hasWebSearchResults || false
  1344. }
  1345. // ===== 🔧 修复:从content JSON中恢复summary =====
  1346. if (parsedContent.summary) {
  1347. summary = parsedContent.summary
  1348. }
  1349. } else if (Array.isArray(parsedContent)) {
  1350. // 旧格式,直接是reports数组
  1351. reports = parsedContent
  1352. } else {
  1353. throw new Error('Not an array or valid format')
  1354. }
  1355. } catch (jsonError) {
  1356. // JSON解析失败,当作普通Markdown文本处理
  1357. let processedContent = contentStr
  1358. .replace(/\\n/g, '\n')
  1359. .replace(/\\t/g, '\t')
  1360. .replace(/\\r/g, '\r')
  1361. displayContent = renderMarkdownContent(processedContent)
  1362. }
  1363. } else {
  1364. // 不是JSON格式,直接按Markdown处理
  1365. let processedContent = contentStr
  1366. .replace(/\\n/g, '\n')
  1367. .replace(/\\t/g, '\t')
  1368. .replace(/\\r/g, '\r')
  1369. displayContent = renderMarkdownContent(processedContent)
  1370. }
  1371. } catch (error) {
  1372. console.error('历史记录处理失败:', error)
  1373. displayContent = message.content || ''
  1374. }
  1375. }
  1376. // 如果有reports,计算相关统计信息
  1377. let totalFiles = 0
  1378. let completedCount = 0
  1379. let progress = 100
  1380. if (reports.length > 0) {
  1381. // 过滤掉category_title类型,只统计实际报告
  1382. const actualReports = reports.filter(r => r.type !== 'category_title')
  1383. totalFiles = actualReports.length
  1384. completedCount = actualReports.filter(r => r.status === 'completed').length
  1385. progress = totalFiles > 0 ? Math.round((completedCount / totalFiles) * 100) : 100
  1386. }
  1387. return {
  1388. type: message.type, // 'user' 或 'ai'
  1389. content: userContent, // 使用提取的用户消息
  1390. displayContent: displayContent,
  1391. reports: reports, // 添加reports数组
  1392. summary: summary, // 添加summary字段
  1393. totalFiles: totalFiles, // 总文件数
  1394. completedCount: completedCount, // 完成数
  1395. progress: progress, // 进度
  1396. file: file, // 添加文件对象
  1397. isTyping: false,
  1398. id: message.id,
  1399. userFeedback: userFeedback,
  1400. userQuestion: userQuestion, // AI消息对应的用户问题
  1401. searchSources: message.type === 'ai' && message.search_source ? parseSearchSources(message.search_source) : null, // 解析搜索来源
  1402. // 保存原始数据
  1403. rawData: message,
  1404. // 网络搜索数据(如果有)
  1405. webSearchRaw: message.webSearchRaw || null,
  1406. webSearchSummary: message.webSearchSummary || null,
  1407. hasWebSearchResults: message.hasWebSearchResults || false,
  1408. webSearchTotal: message.webSearchRaw?.total || 0,
  1409. // 状态管理(历史记录默认完成状态)
  1410. showStats: totalFiles > 0,
  1411. currentStatus: 'completed',
  1412. statusMessage: totalFiles > 0
  1413. ? (message.webSearchRaw?.total > 0
  1414. ? ` <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${totalFiles}</span> 个知识库文件,以及 <span class="file-count">${message.webSearchRaw.total}</span> 个相关网络资源`
  1415. : ` <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${totalFiles}</span> 个知识库文件`)
  1416. : ''
  1417. }
  1418. }))
  1419. chatMessages.value = messages
  1420. ai_conversation_id.value = conversationId
  1421. const lastAIMessage = messages.filter(msg => msg.type === 'ai').pop()
  1422. // 恢复所有AI消息的联网搜索结果、推荐问题和分类展开状态
  1423. messages.forEach((message, index) => {
  1424. if (message.type === 'ai' && message.rawData) {
  1425. // 恢复联网搜索结果
  1426. if (message.rawData.search_source) {
  1427. try {
  1428. const searchResults = JSON.parse(message.rawData.search_source)
  1429. if (Array.isArray(searchResults) && searchResults.length > 0) {
  1430. onlineSearchResults.value[message.id] = searchResults
  1431. }
  1432. } catch (error) {
  1433. console.error('解析搜索结果失败:', error)
  1434. }
  1435. }
  1436. // 恢复reports的分类展开状态
  1437. if (message.reports && message.reports.length > 0) {
  1438. if (!categoryExpandStates.value[index]) {
  1439. categoryExpandStates.value[index] = {}
  1440. }
  1441. const categories = message.reports
  1442. .filter(r => r.type === 'category_title')
  1443. .map(r => r.category)
  1444. categories.forEach(category => {
  1445. categoryExpandStates.value[index][category] = false
  1446. })
  1447. }
  1448. // 恢复推荐问题(只处理最后一条AI消息)
  1449. if (message === lastAIMessage && message.rawData.guess_you_want) {
  1450. try {
  1451. const questions = message.rawData.guess_you_want.trim()
  1452. .split('\n')
  1453. .map(q => q.trim())
  1454. .filter(q => q.length > 0)
  1455. .filter((q, index, arr) => arr.indexOf(q) === index)
  1456. .slice(0, 3)
  1457. if (questions.length > 0) {
  1458. aiRelatedQuestions.value = questions
  1459. relatedQuestionsMessageId.value = message.id
  1460. }
  1461. } catch (error) {
  1462. console.error('解析推荐问题失败:', error)
  1463. }
  1464. }
  1465. }
  1466. })
  1467. // 等待DOM更新后绑定规范引用事件
  1468. await nextTick()
  1469. setTimeout(() => bindStandardReferenceEvents(), 100)
  1470. return true
  1471. } else {
  1472. console.error('获取对话消息失败:', response.statusCode)
  1473. return false
  1474. }
  1475. } catch (error) {
  1476. console.error('获取对话消息失败:', error)
  1477. return false
  1478. }
  1479. }
  1480. // 生成对话标题(从内容中提取)
  1481. const generateConversationTitle = (content) => {
  1482. if (!content) return '未知对话'
  1483. // 检查是否包含文件标签格式
  1484. if (content.includes('</filesize>')) {
  1485. // 提取</filesize>标签后面的内容
  1486. const userMessage = content.split('</filesize>')[1]
  1487. if (userMessage && userMessage.trim()) {
  1488. // 清理多余的空格和换行
  1489. const cleanMessage = userMessage.replace(/\s+/g, ' ').trim()
  1490. // 提取第一句话作为标题,但排除AI回复的开头
  1491. let firstSentence = cleanMessage.split(/[。!?\n]/)[0]
  1492. // 如果包含AI回复的开头,只取用户问题部分
  1493. if (firstSentence.includes('基于您好') || firstSentence.includes('蜀道安全管理AI智能助手')) {
  1494. // 提取"问题描述:"后面的内容
  1495. const problemMatch = firstSentence.match(/问题描述[::](.+?)(?=分析结果|$)/)
  1496. if (problemMatch) {
  1497. firstSentence = problemMatch[1].trim()
  1498. } else {
  1499. // 如果没有找到问题描述,取前20个字符
  1500. firstSentence = firstSentence.substring(0, 20)
  1501. }
  1502. }
  1503. // 限制标题长度
  1504. if (firstSentence.length > 30) {
  1505. return firstSentence.substring(0, 30) + '...'
  1506. }
  1507. return firstSentence || '新对话'
  1508. }
  1509. }
  1510. // 如果没有文件标签,使用原来的方法
  1511. let cleanContent = content.replace(/<[^>]*>/g, '')
  1512. cleanContent = cleanContent.replace(/\s+/g, ' ').trim()
  1513. // 提取第一句话作为标题,但排除AI回复的开头
  1514. let firstSentence = cleanContent.split(/[。!?\n]/)[0]
  1515. // 如果包含AI回复的开头,只取用户问题部分
  1516. if (firstSentence.includes('基于您好') || firstSentence.includes('蜀道安全管理AI智能助手')) {
  1517. // 提取"问题描述:"后面的内容
  1518. const problemMatch = firstSentence.match(/问题描述[::](.+?)(?=查询结果|$)/)
  1519. if (problemMatch) {
  1520. firstSentence = problemMatch[1].trim()
  1521. } else {
  1522. // 如果没有找到问题描述,取前20个字符
  1523. firstSentence = firstSentence.substring(0, 20)
  1524. }
  1525. }
  1526. // 限制标题长度
  1527. if (firstSentence.length > 30) {
  1528. return firstSentence.substring(0, 30) + '...'
  1529. }
  1530. return firstSentence || '新对话'
  1531. }
  1532. // 格式化时间
  1533. const formatTime = (timestamp) => {
  1534. if (!timestamp) return '未知时间'
  1535. let date
  1536. if (typeof timestamp === 'string') {
  1537. date = new Date(timestamp)
  1538. } else {
  1539. let timestampMs = timestamp
  1540. if (timestamp.toString().length === 10 || timestamp.toString().length === 11) {
  1541. timestampMs = timestamp * 1000
  1542. }
  1543. date = new Date(timestampMs)
  1544. }
  1545. const now = new Date()
  1546. const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
  1547. const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
  1548. if (date >= todayStart) {
  1549. return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  1550. }
  1551. if (date >= yesterdayStart && date < todayStart) {
  1552. return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  1553. }
  1554. const month = date.getMonth() + 1
  1555. const day = date.getDate()
  1556. const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
  1557. return `${month}月${day}日 ${time}`
  1558. }
  1559. // 转换用户反馈状态
  1560. const convertUserFeedback = (feedback) => {
  1561. switch (parseInt(feedback)) {
  1562. case 2: return 'like'
  1563. case 3: return 'dislike'
  1564. default: return null
  1565. }
  1566. }
  1567. // 转换前端反馈状态为后端格式
  1568. const convertFeedbackToBackend = (feedback) => {
  1569. switch (feedback) {
  1570. case 'like': return 2
  1571. case 'dislike': return 3
  1572. default: return 0
  1573. }
  1574. }
  1575. // 解析搜索来源数据
  1576. const parseSearchSources = (searchSourceStr) => {
  1577. try {
  1578. if (!searchSourceStr || typeof searchSourceStr !== 'string' || !searchSourceStr.trim()) {
  1579. return null
  1580. }
  1581. const sources = JSON.parse(searchSourceStr)
  1582. if (!Array.isArray(sources)) {
  1583. return null
  1584. }
  1585. const validSources = sources.filter(source =>
  1586. source && typeof source === 'object' && source.title && source.content
  1587. )
  1588. return validSources.length > 0 ? validSources : null
  1589. } catch (error) {
  1590. console.error('解析搜索来源失败:', error)
  1591. return null
  1592. }
  1593. }
  1594. //对话id
  1595. const ai_conversation_id = ref(0)
  1596. //业务类型
  1597. const business_type = ref(0)
  1598. // 清理新对话状态
  1599. const clearNewConversationState = () => {
  1600. if (chatMessages.value.length === 0) {
  1601. historyData.value.forEach(item => item.isActive = false)
  1602. expandedSearchSources.value = {}
  1603. expandedOnlineSearchResults.value = {}
  1604. onlineSearchResults.value = {}
  1605. }
  1606. }
  1607. // 根据当前模式分发发送请求
  1608. const submitQuestionByCurrentMode = async (question, options = {}) => {
  1609. const { windowSize = 3, nResults = 10 } = options
  1610. const normalizedQuestion = typeof question === 'string' ? question.trim() : ''
  1611. if (!normalizedQuestion) return
  1612. if (currentMode.value === 'ai-writing' || currentMode.value === 'safety-training') {
  1613. await handleNonStreamingSubmit({
  1614. question: normalizedQuestion,
  1615. businessType: currentMode.value === 'ai-writing' ? 2 : 1
  1616. })
  1617. return
  1618. }
  1619. await handleReportGeneratorSubmit({
  1620. question: normalizedQuestion,
  1621. windowSize,
  1622. nResults
  1623. })
  1624. }
  1625. // 发送消息
  1626. const handleSendMessage = async () => {
  1627. if (!messageText.value.trim() || isSending.value) return
  1628. clearRecommendQuestions()
  1629. aiRelatedQuestions.value = []
  1630. relatedQuestionsMessageId.value = null
  1631. isSending.value = true
  1632. showChat.value = true
  1633. clearNewConversationState()
  1634. await submitQuestionByCurrentMode(messageText.value, {
  1635. windowSize: 3,
  1636. nResults: 10
  1637. })
  1638. messageText.value = ''
  1639. clearRecommendQuestions()
  1640. if (selectedFile.value) {
  1641. removeSelectedFile()
  1642. }
  1643. scrollToBottom()
  1644. }
  1645. // 处理非流式请求 (AI写作 和 安全培训)
  1646. const handleAIWritingStream = async (data) => {
  1647. if (!isSending.value) {
  1648. isSending.value = true
  1649. }
  1650. currentQuestion.value = data.question
  1651. chatMessages.value.push({
  1652. id: Date.now(),
  1653. type: 'user',
  1654. content: data.question,
  1655. timestamp: new Date().toISOString()
  1656. })
  1657. const aiMessageIndex = chatMessages.value.length
  1658. const aiMsgTimestamp = new Date().toISOString()
  1659. chatMessages.value.push({
  1660. id: Date.now() + 1,
  1661. type: 'ai',
  1662. userQuestion: data.question,
  1663. isTyping: true,
  1664. content: '',
  1665. displayContent: '',
  1666. timestamp: aiMsgTimestamp,
  1667. reports: [],
  1668. isAIWriting: true,
  1669. aiWritingTitle: data.question,
  1670. aiWritingStatusText: 'AI智能助手正在为您输出...',
  1671. aiWritingStatus: 'generating',
  1672. ai_message_id: null
  1673. })
  1674. aiWritingSidebarTitle.value = data.question
  1675. aiWritingSidebarContent.value = ''
  1676. aiWritingIsGenerating.value = true
  1677. aiWritingSidebarVisible.value = true
  1678. const aiMessage = chatMessages.value[aiMessageIndex]
  1679. let fullContent = ''
  1680. try {
  1681. const apiPrefix = getApiPrefix()
  1682. const url = `${apiPrefix}/stream/chat-with-db`
  1683. const token = getToken()
  1684. const headers = {
  1685. 'Content-Type': 'application/json'
  1686. }
  1687. if (token) {
  1688. headers['Authorization'] = `Bearer ${token}`
  1689. }
  1690. const response = await fetch(url, {
  1691. method: 'POST',
  1692. headers,
  1693. body: JSON.stringify({
  1694. message: data.question,
  1695. ai_conversation_id: ai_conversation_id.value,
  1696. business_type: data.businessType
  1697. })
  1698. })
  1699. if (!response.ok) {
  1700. throw new Error(`请求失败: ${response.status} ${response.statusText}`)
  1701. }
  1702. const reader = response.body.getReader()
  1703. const decoder = new TextDecoder('utf-8')
  1704. let buffer = ''
  1705. let isDone = false
  1706. while (!isDone) {
  1707. const { done, value } = await reader.read()
  1708. if (done) break
  1709. buffer += decoder.decode(value, { stream: true })
  1710. const lines = buffer.split('\n')
  1711. buffer = lines.pop() || ''
  1712. for (const line of lines) {
  1713. if (!line.startsWith('data:')) continue
  1714. const payload = line.slice(5).trim()
  1715. if (!payload) continue
  1716. if (payload === '[DONE]') {
  1717. isDone = true
  1718. break
  1719. }
  1720. let parsed = null
  1721. if (payload.startsWith('{') && payload.endsWith('}')) {
  1722. try {
  1723. parsed = JSON.parse(payload)
  1724. } catch (e) {
  1725. parsed = null
  1726. }
  1727. }
  1728. const isMeta =
  1729. parsed &&
  1730. (parsed.type ||
  1731. parsed.ai_conversation_id ||
  1732. parsed.ai_message_id ||
  1733. parsed.error)
  1734. if (isMeta) {
  1735. if (parsed.type === 'initial') {
  1736. if (parsed.ai_conversation_id) {
  1737. ai_conversation_id.value = parsed.ai_conversation_id
  1738. }
  1739. if (parsed.ai_message_id) {
  1740. aiMessage.ai_message_id = parsed.ai_message_id
  1741. }
  1742. }
  1743. if (parsed.error) {
  1744. throw new Error(parsed.error)
  1745. }
  1746. continue
  1747. }
  1748. const chunk = parsed && typeof parsed.content === 'string' ? parsed.content : payload
  1749. const normalizedChunk = chunk.replace(/\\n/g, '\n')
  1750. if (normalizedChunk) {
  1751. fullContent += normalizedChunk
  1752. aiMessage.fullContent = fullContent
  1753. aiMessage.content = fullContent
  1754. aiWritingSidebarContent.value = normalizeAiWritingText(fullContent)
  1755. }
  1756. }
  1757. }
  1758. aiMessage.isTyping = false
  1759. aiMessage.aiWritingStatus = 'completed'
  1760. aiMessage.aiWritingStatusText = '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
  1761. aiWritingIsGenerating.value = false
  1762. aiWritingFullContent.value = fullContent
  1763. aiWritingSidebarVisible.value = false
  1764. getHistoryRecordList()
  1765. } catch (error) {
  1766. console.error('AI写作流式请求失败:', error)
  1767. aiMessage.isTyping = false
  1768. aiMessage.aiWritingStatus = 'error'
  1769. aiMessage.aiWritingStatusText = '生成失败,请重试'
  1770. aiWritingIsGenerating.value = false
  1771. aiWritingSidebarContent.value = normalizeAiWritingText(fullContent || '生成失败,请重试。')
  1772. ElMessage.error(`请求失败: ${error.message}`)
  1773. } finally {
  1774. isSending.value = false
  1775. }
  1776. }
  1777. const handleNonStreamingSubmit = async (data) => {
  1778. if (data.businessType === 2) {
  1779. await handleAIWritingStream(data)
  1780. return
  1781. }
  1782. if (!isSending.value) {
  1783. isSending.value = true
  1784. }
  1785. currentQuestion.value = data.question
  1786. // 添加用户消息
  1787. chatMessages.value.push({
  1788. id: Date.now(),
  1789. type: 'user',
  1790. content: data.question,
  1791. timestamp: new Date().toISOString()
  1792. })
  1793. // 添加AI消息占位符
  1794. const aiMessageIndex = chatMessages.value.length
  1795. const aiMsgTimestamp = new Date().toISOString()
  1796. chatMessages.value.push({
  1797. id: Date.now() + 1,
  1798. type: 'ai',
  1799. userQuestion: data.question,
  1800. isTyping: true, // 显示 loading 状态
  1801. content: '',
  1802. displayContent: '',
  1803. timestamp: aiMsgTimestamp,
  1804. reports: [],
  1805. // AI写作模式下,立即显示文件卡片和状态文字
  1806. ...(data.businessType === 2 ? {
  1807. isAIWriting: true,
  1808. aiWritingTitle: data.question,
  1809. aiWritingStatusText: 'AI智能助手正在为您输出...',
  1810. aiWritingStatus: 'generating'
  1811. } : {})
  1812. })
  1813. // AI写作模式:立即打开侧边栏,显示生成中状态
  1814. if (data.businessType === 2) {
  1815. aiWritingSidebarTitle.value = data.question
  1816. aiWritingSidebarContent.value = ''
  1817. aiWritingIsGenerating.value = true
  1818. aiWritingSidebarVisible.value = true
  1819. }
  1820. try {
  1821. const response = await apis.sendDeepseekMessage({
  1822. message: data.question,
  1823. business_type: data.businessType,
  1824. enable_online_model: isOnlineModel.value,
  1825. conversation_id: ai_conversation_id.value
  1826. })
  1827. if (response.statusCode === 200) {
  1828. const aiMessage = chatMessages.value[aiMessageIndex]
  1829. const responseData = response?.data || {}
  1830. const aiReply =
  1831. responseData.reply ||
  1832. responseData.response ||
  1833. responseData.content ||
  1834. response.reply ||
  1835. response.content ||
  1836. ''
  1837. const conversationId =
  1838. responseData.ai_conversation_id ||
  1839. responseData.conversation_id ||
  1840. response.ai_conversation_id ||
  1841. response.conversation_id
  1842. // 如果用户已经点击了停止,则不再继续输出
  1843. if (aiMessage._stopped) {
  1844. return
  1845. }
  1846. if (!aiReply || typeof aiReply !== 'string') {
  1847. throw new Error('AI返回内容为空')
  1848. }
  1849. aiMessage.isTyping = false
  1850. aiMessage.content = aiReply
  1851. // 更新 conversation ID
  1852. if (conversationId) {
  1853. ai_conversation_id.value = conversationId
  1854. }
  1855. // 添加打字机效果显示
  1856. if (data.businessType === 1) {
  1857. // 安全培训: 只展示一个输出文档
  1858. aiMessage.isDocument = true
  1859. aiMessage.displayContent = `<div class="safety-training-doc-card">
  1860. <div class="doc-header">
  1861. <span class="doc-icon">📄</span>
  1862. <span class="doc-title">安全培训生成文档</span>
  1863. </div>
  1864. <div class="doc-actions">
  1865. <button class="view-doc-btn">查看详情</button>
  1866. </div>
  1867. </div>`
  1868. // 实际内容保存起来,点击查看详情时可以使用
  1869. aiMessage.fullContent = aiReply
  1870. } else if (data.businessType === 2) {
  1871. // AI写作: 显示文件卡片 + 打开侧边栏输出
  1872. aiMessage.isAIWriting = true
  1873. aiMessage.aiWritingTitle = data.question
  1874. aiMessage.fullContent = aiReply
  1875. aiMessage.aiWritingStatus = 'completed' // 标记为已完成
  1876. aiMessage.aiWritingStatusText = '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
  1877. // 关闭侧边栏(生成完成)
  1878. aiWritingSidebarVisible.value = false
  1879. aiWritingIsGenerating.value = false
  1880. } else {
  1881. // 其他: 正常打字机输出
  1882. startTypewriterEffect(aiMessage, aiReply, 30)
  1883. }
  1884. // 刷新历史记录
  1885. getHistoryRecordList()
  1886. } else {
  1887. throw new Error(response.msg || '请求失败')
  1888. }
  1889. } catch (error) {
  1890. console.error('发送请求失败:', error)
  1891. const aiMessage = chatMessages.value[aiMessageIndex]
  1892. if (aiMessage._stopped) {
  1893. return
  1894. }
  1895. aiMessage.isTyping = false
  1896. aiMessage.displayContent = '抱歉,生成内容时发生错误,请重试。'
  1897. ElMessage.error(`请求失败: ${error.message}`)
  1898. } finally {
  1899. isSending.value = false
  1900. }
  1901. }
  1902. // 功能卡片图标计数器
  1903. let functionCardIconIndex = 0
  1904. // 推荐问题图标计数器
  1905. let questionIconIndex = 0
  1906. // 根据功能卡片标题返回对应的图标
  1907. const getFunctionCardIcon = (title) => {
  1908. // 按顺序循环使用4个图标
  1909. const icons = [bridgeIcon, constructionIcon, materialIcon, standardIcon]
  1910. const icon = icons[functionCardIconIndex % icons.length]
  1911. functionCardIconIndex++
  1912. return icon
  1913. }
  1914. // 根据问题标题返回对应的图标
  1915. const getQuestionIcon = (question) => {
  1916. // 按顺序循环使用3个图标
  1917. const icons = [questionIcon1, questionIcon2, questionIcon3]
  1918. const icon = icons[questionIconIndex % icons.length]
  1919. questionIconIndex++
  1920. return icon
  1921. }
  1922. const getModeRecommendationType = (mode) => {
  1923. if (mode === 'ai-writing') return 2
  1924. if (mode === 'safety-training') return 1
  1925. if (mode === 'exam-workshop') return 3
  1926. return 0
  1927. }
  1928. const pickScopedRecommendationItems = (items, expectedType, typeField) => {
  1929. if (!Array.isArray(items)) return []
  1930. if (expectedType === 0) return items
  1931. const hasTypeField = items.some(
  1932. item => item && Object.prototype.hasOwnProperty.call(item, typeField)
  1933. )
  1934. if (!hasTypeField) {
  1935. return null
  1936. }
  1937. return items.filter(item => Number(item?.[typeField]) === expectedType)
  1938. }
  1939. // 点击功能卡片
  1940. // ReportGenerator相关方法
  1941. const handleCategoryToggle = (messageIndex, data) => {
  1942. if (!categoryExpandStates.value[messageIndex]) {
  1943. categoryExpandStates.value[messageIndex] = {}
  1944. }
  1945. const matchedCategory = findCategoryStateKey(messageIndex, data.category)
  1946. categoryExpandStates.value[messageIndex][matchedCategory || data.category] = data.expanded
  1947. }
  1948. const normalizeCategoryName = (category) => {
  1949. return typeof category === 'string' ? category.trim() : ''
  1950. }
  1951. const findCategoryStateKey = (messageIndex, category) => {
  1952. const stateMap = categoryExpandStates.value[messageIndex]
  1953. if (!stateMap) return ''
  1954. const normalizedCategory = normalizeCategoryName(category)
  1955. if (!normalizedCategory) return ''
  1956. if (Object.prototype.hasOwnProperty.call(stateMap, category)) {
  1957. return category
  1958. }
  1959. const keys = Object.keys(stateMap)
  1960. const exactKey = keys.find(key => normalizeCategoryName(key) === normalizedCategory)
  1961. if (exactKey) return exactKey
  1962. return keys.find(key => {
  1963. const normalizedKey = normalizeCategoryName(key)
  1964. return normalizedKey && (
  1965. normalizedKey.includes(normalizedCategory) ||
  1966. normalizedCategory.includes(normalizedKey)
  1967. )
  1968. }) || ''
  1969. }
  1970. const isCategoryExpanded = (messageIndex, category) => {
  1971. if (!category) return true
  1972. if (!categoryExpandStates.value[messageIndex]) {
  1973. categoryExpandStates.value[messageIndex] = {}
  1974. return false
  1975. }
  1976. const matchedCategory = findCategoryStateKey(messageIndex, category)
  1977. if (!matchedCategory) return false
  1978. return categoryExpandStates.value[messageIndex][matchedCategory] === true
  1979. }
  1980. // 处理安全培训文档点击
  1981. const handleSafetyDocClick = (message) => {
  1982. if (message.rawData && message.rawData.ai_conversation_id) {
  1983. router.push({ path: '/safety-hazard', query: { id: message.rawData.ai_conversation_id } })
  1984. } else if (ai_conversation_id.value) {
  1985. router.push({ path: '/safety-hazard', query: { id: ai_conversation_id.value } })
  1986. }
  1987. }
  1988. // 检查reports数组是否只包含分类标题,没有实际报告
  1989. const hasOnlyCategoryTitles = (reports) => {
  1990. if (!reports || reports.length === 0) return false
  1991. // 检查是否所有报告都是category_title类型
  1992. return reports.every(r => r.type === 'category_title')
  1993. }
  1994. // 将消息状态映射到头像状态
  1995. const getAvatarStatus = (currentStatus, progress) => {
  1996. if (progress === 100) return 'completed'
  1997. switch (currentStatus) {
  1998. case 'querying_kb':
  1999. case 'web_searching':
  2000. return 'searching'
  2001. case 'analyzing_files':
  2002. case 'analyzing_web':
  2003. return 'analyzing'
  2004. case 'deep_thinking':
  2005. return 'thinking'
  2006. case 'outputting':
  2007. return 'analyzing'
  2008. case 'completed':
  2009. return 'completed'
  2010. case 'error':
  2011. return 'error'
  2012. default:
  2013. return 'idle'
  2014. }
  2015. }
  2016. // 更新AI消息状态和进度
  2017. const updateMessageStatus = (aiMessage, status, customMessage = null) => {
  2018. const statusConfig = {
  2019. querying_kb: {
  2020. message: '🔍 <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析知识库……',
  2021. progress: 10
  2022. },
  2023. web_searching: {
  2024. message: '🌐 <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您联网分析……',
  2025. progress: 15
  2026. },
  2027. data_retrieved: {
  2028. message: null, // 动态生成
  2029. progress: 30
  2030. },
  2031. analyzing_files: {
  2032. message: '😊 <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析文件内容……',
  2033. progress: 45
  2034. },
  2035. analyzing_web: {
  2036. message: '😉 <span class="ai-name">蜀道安全管理AI智能助手</span>正在分析联网数据……',
  2037. progress: 70
  2038. },
  2039. deep_thinking: {
  2040. message: '🤔 <span class="ai-name">蜀道安全管理AI智能助手</span>正在深度思考中,请您稍等片刻……',
  2041. progress: 75
  2042. },
  2043. outputting: {
  2044. message: '😄 <span class="ai-name">蜀道安全管理AI智能助手</span>正在整理分析中!',
  2045. progress: 90
  2046. },
  2047. completed: {
  2048. message: null, // 动态生成
  2049. progress: 100
  2050. }
  2051. }
  2052. const config = statusConfig[status]
  2053. if (config) {
  2054. aiMessage.currentStatus = status
  2055. // 对于data_retrieved状态,生成动态消息
  2056. if (status === 'data_retrieved') {
  2057. const kbCount = aiMessage.totalFiles || 0
  2058. const webCount = aiMessage.webSearchTotal || 0
  2059. if (webCount > 0) {
  2060. aiMessage.statusMessage = `<span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${kbCount}</span> 个知识库文件,以及 <span class="file-count">${webCount}</span> 个相关网络资源`
  2061. } else {
  2062. aiMessage.statusMessage = `<span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析 <span class="file-count">${kbCount}</span> 个知识库文件`
  2063. }
  2064. } else if (status === 'completed') {
  2065. // 对于completed状态,生成动态完成消息
  2066. const kbCount = aiMessage.totalFiles || 0
  2067. const webCount = aiMessage.webSearchTotal || 0
  2068. if (webCount > 0) {
  2069. aiMessage.statusMessage = `✅ <span class="ai-name">蜀道安全管理AI智能助手</span>已为您检索到 <span class="file-count">${kbCount}</span> 个知识库文件,以及 <span class="file-count">${webCount}</span> 个相关网络资源`
  2070. } else {
  2071. aiMessage.statusMessage = `✅ <span class="ai-name">蜀道安全管理AI智能助手</span>已为您检索到 <span class="file-count">${kbCount}</span> 个知识库文件`
  2072. }
  2073. } else {
  2074. aiMessage.statusMessage = customMessage || config.message
  2075. }
  2076. aiMessage.progress = config.progress
  2077. }
  2078. }
  2079. const handleSSEMessage = (data, aiMessageIndex) => {
  2080. const aiMessage = chatMessages.value[aiMessageIndex]
  2081. if (!aiMessage) return
  2082. // 🔍 调试:打印SSE返回的完整数据对象
  2083. console.log('🔍 SSE事件完整数据:', {
  2084. type: data.type,
  2085. conversation_id: data.conversation_id,
  2086. message_id: data.message_id,
  2087. ai_conversation_id: data.ai_conversation_id,
  2088. ai_message_id: data.ai_message_id,
  2089. allKeys: Object.keys(data)
  2090. })
  2091. // 捕获conversation_id(后端返回的字段名,可能是conversation_id或ai_conversation_id)
  2092. const conversationId = data.conversation_id || data.ai_conversation_id
  2093. if (conversationId) {
  2094. if (ai_conversation_id.value === 0) {
  2095. ai_conversation_id.value = conversationId
  2096. console.log('✅ SSE收到conversation_id并赋值:', conversationId)
  2097. }
  2098. }
  2099. // 捕获message_id(后端返回的字段名,可能是message_id或ai_message_id)
  2100. const messageId = data.message_id || data.ai_message_id
  2101. if (messageId) {
  2102. if (!aiMessage.ai_message_id) {
  2103. aiMessage.ai_message_id = messageId
  2104. console.log('✅ SSE收到message_id并赋值:', messageId)
  2105. console.log(' - aiMessage.id (前端临时ID):', aiMessage.id)
  2106. console.log(' - aiMessage.ai_message_id (后端ID):', aiMessage.ai_message_id)
  2107. // 同时更新rawData对象,确保操作按钮能够使用正确的ID
  2108. if (!aiMessage.rawData) {
  2109. aiMessage.rawData = {}
  2110. }
  2111. aiMessage.rawData.id = messageId
  2112. console.log(' - aiMessage.rawData.id:', aiMessage.rawData.id)
  2113. }
  2114. }
  2115. switch (data.type) {
  2116. case 'intent':
  2117. // 检查是否为专业问题
  2118. if (data.is_professional_question === false) {
  2119. // 非专业问题:立即隐藏状态显示组件
  2120. aiMessage.showStats = false
  2121. // 非专业问题,只输出summary字段内容并终止流程
  2122. const summaryContent = data.summary || '抱歉,我暂时无法回答您的问题。'
  2123. // 先缓存summary作为兜底内容,但继续等待online_answer/completed事件
  2124. aiMessage.summary = summaryContent
  2125. aiMessage._fullSummary = summaryContent
  2126. break
  2127. }
  2128. // 专业问题:意图识别完成,更新为查询知识库状态
  2129. updateMessageStatus(aiMessage, 'querying_kb')
  2130. // 如果启用联网搜索,稍后会更新为web_searching状态
  2131. // (当收到web_search_raw事件时)
  2132. // 保存问题总结并使用打字机效果
  2133. if (data.summary) {
  2134. const fullSummary = data.summary
  2135. aiMessage._fullSummary = fullSummary
  2136. aiMessage.summary = ''
  2137. // 使用打字机效果显示问题总结
  2138. startReportFieldTypewriter(
  2139. { file_index: 'summary', report: aiMessage, _typewriterStates: {} },
  2140. 'summary',
  2141. fullSummary,
  2142. 50
  2143. ).catch(err => {
  2144. console.error('问题总结打字机效果失败:', err)
  2145. aiMessage.summary = fullSummary
  2146. })
  2147. }
  2148. break
  2149. case 'online_answer': {
  2150. aiMessage.showStats = false
  2151. aiMessage.summary = ''
  2152. const finalContent = data.content || ''
  2153. aiMessage.content = finalContent
  2154. if (!finalContent.trim()) {
  2155. aiMessage.displayContent = ''
  2156. aiMessage.isTyping = false
  2157. break
  2158. }
  2159. const processedReply = processAIResponse(finalContent)
  2160. const renderedReply = renderMarkdownContent(processedReply)
  2161. startTypewriterEffect(aiMessage, renderedReply, 200)
  2162. .catch(err => {
  2163. console.error('在线回答打字机效果失败:', err)
  2164. aiMessage.displayContent = renderedReply
  2165. aiMessage.isTyping = false
  2166. })
  2167. break
  2168. }
  2169. case 'online_error':
  2170. aiMessage.showStats = false
  2171. aiMessage.content = data.message || '在线模型服务暂不可用'
  2172. aiMessage.displayContent = renderMarkdownContent(processAIResponse(aiMessage.content))
  2173. aiMessage.isTyping = false
  2174. break
  2175. case 'documents':
  2176. aiMessage.totalFiles = data.total
  2177. aiMessage.completedCount = 0
  2178. // 收到文档数据后的状态处理
  2179. if (isNetworkSearchEnabled.value) {
  2180. // 如果启用了联网搜索,更新状态为联网搜索中
  2181. updateMessageStatus(aiMessage, 'web_searching')
  2182. // 等待web_search_raw事件后再更新为data_retrieved
  2183. } else {
  2184. // 如果没有联网搜索,直接显示检索结果
  2185. updateMessageStatus(aiMessage, 'data_retrieved')
  2186. }
  2187. // 文档数量更新后,触发滚动检测以初始化进度卡片
  2188. nextTick(() => {
  2189. handleProgressCardScroll()
  2190. })
  2191. break
  2192. case 'category_title':
  2193. // 第一个分类标题时,说明开始分析文件
  2194. if (aiMessage.reports.length === 0) {
  2195. // 只有在当前状态进度 >= data_retrieved的进度时,才更新为analyzing_files
  2196. // 这样确保data_retrieved状态能被看到
  2197. if (aiMessage.progress >= 30) {
  2198. updateMessageStatus(aiMessage, 'analyzing_files')
  2199. }
  2200. }
  2201. const categoryTitle = {
  2202. type: 'category_title',
  2203. category: data.category,
  2204. number: data.number,
  2205. count: data.count,
  2206. source_file: `【${data.number}、${data.category}】(共${data.count}个文件)`,
  2207. file_index: -1,
  2208. status: 'category'
  2209. }
  2210. aiMessage.reports.push(categoryTitle)
  2211. // 初始化该分类为展开状态
  2212. if (!categoryExpandStates.value[aiMessageIndex]) {
  2213. categoryExpandStates.value[aiMessageIndex] = {}
  2214. }
  2215. categoryExpandStates.value[aiMessageIndex][data.category] = false
  2216. // 保存当前分类名称,用于后续报告匹配
  2217. aiMessage.currentCategory = data.category
  2218. break
  2219. case 'report_start':
  2220. // 调试日志:查看完整的数据结构
  2221. console.log('🔍 [DEBUG] report_start 数据:', {
  2222. file_index: data.file_index,
  2223. source_file: data.source_file,
  2224. file_path: data.file_path,
  2225. metadata: data.metadata,
  2226. 完整data: data
  2227. })
  2228. const placeholderReport = {
  2229. file_index: data.file_index,
  2230. total_files: aiMessage.totalFiles,
  2231. source_file: data.source_file,
  2232. file_path: data.file_path,
  2233. similarity: data.similarity,
  2234. metadata: {
  2235. ...data.metadata,
  2236. _displayCategory: data.metadata?.primary_category || aiMessage.currentCategory // 存储当前显示的分类名
  2237. },
  2238. report: {
  2239. display_name: '',
  2240. summary: '',
  2241. analysis: '',
  2242. clauses: ''
  2243. },
  2244. status: 'streaming'
  2245. }
  2246. aiMessage.reports.push(placeholderReport)
  2247. streamingReports.value.set(data.file_index, aiMessage.reports.length - 1)
  2248. break
  2249. case 'report_chunk':
  2250. // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
  2251. // 这样可以避免与打字机效果冲突
  2252. break
  2253. case 'report':
  2254. // 第一个报告开始时,更新到深度思考状态
  2255. if (aiMessage.reports.filter(r => r.status === 'completed').length === 0) {
  2256. updateMessageStatus(aiMessage, 'deep_thinking')
  2257. }
  2258. const reportData = data.data || data
  2259. // 调试日志:查看完整的report数据
  2260. console.log('🔍 [DEBUG] report 数据:', {
  2261. file_index: reportData.file_index,
  2262. source_file: reportData.source_file,
  2263. file_path: reportData.file_path,
  2264. metadata: reportData.metadata,
  2265. 完整reportData: reportData
  2266. })
  2267. const idx = streamingReports.value.get(reportData.file_index)
  2268. let targetReport
  2269. if (idx !== undefined) {
  2270. const displayCategory = reportData.metadata?.primary_category ||
  2271. aiMessage.reports[idx].metadata?._displayCategory ||
  2272. aiMessage.currentCategory
  2273. const fullSummary = reportData.report?.summary || ''
  2274. const fullAnalysis = reportData.report?.analysis || ''
  2275. const fullClauses = reportData.report?.clauses || ''
  2276. const fullDisplayName = reportData.report?.display_name || ''
  2277. // 创建带空内容的报告对象,保留所有原始字段
  2278. aiMessage.reports[idx] = {
  2279. ...reportData, // 保留所有原始字段,包括可能的链接字段
  2280. report: {
  2281. display_name: fullDisplayName, // 直接显示
  2282. summary: '',
  2283. analysis: '',
  2284. clauses: ''
  2285. },
  2286. status: 'completed',
  2287. metadata: {
  2288. ...reportData.metadata, // 保留所有metadata字段
  2289. _displayCategory: displayCategory
  2290. },
  2291. _fullContent: {
  2292. display_name: fullDisplayName,
  2293. summary: fullSummary,
  2294. analysis: fullAnalysis,
  2295. clauses: fullClauses
  2296. }
  2297. }
  2298. targetReport = aiMessage.reports[idx]
  2299. streamingReports.value.delete(reportData.file_index)
  2300. } else {
  2301. const fullSummary = reportData.report?.summary || ''
  2302. const fullAnalysis = reportData.report?.analysis || ''
  2303. const fullClauses = reportData.report?.clauses || ''
  2304. const fullDisplayName = reportData.report?.display_name || ''
  2305. const newReport = {
  2306. ...reportData, // 保留所有原始字段,包括可能的链接字段
  2307. report: {
  2308. display_name: fullDisplayName, // 直接显示
  2309. summary: '',
  2310. analysis: '',
  2311. clauses: ''
  2312. },
  2313. status: 'completed',
  2314. metadata: {
  2315. ...reportData.metadata, // 保留所有metadata字段
  2316. _displayCategory: reportData.metadata?.primary_category || aiMessage.currentCategory
  2317. },
  2318. _fullContent: {
  2319. display_name: fullDisplayName,
  2320. summary: fullSummary,
  2321. analysis: fullAnalysis,
  2322. clauses: fullClauses
  2323. }
  2324. }
  2325. aiMessage.reports.push(newReport)
  2326. targetReport = newReport
  2327. }
  2328. // 使用顺序打字机效果:概述 -> 解析 -> 相关条款
  2329. if (targetReport._fullContent && !targetReport._typewriterCompleted) {
  2330. // 标记打字机已启动,防止重复触发
  2331. targetReport._typewriterStarted = true
  2332. // 先打概述(速度200 = 每次20个字符,极快)
  2333. startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
  2334. .then(() => {
  2335. // 概述完成后打解析
  2336. return startReportFieldTypewriter(targetReport, 'analysis', targetReport._fullContent.analysis || '', 200)
  2337. })
  2338. .then(() => {
  2339. // 解析完成后打相关条款
  2340. if (targetReport._fullContent.clauses) {
  2341. return startReportFieldTypewriter(targetReport, 'clauses', targetReport._fullContent.clauses || '', 200)
  2342. }
  2343. })
  2344. .then(() => {
  2345. // 全部完成,标记为已完成
  2346. targetReport._typewriterCompleted = true
  2347. })
  2348. .catch(err => {
  2349. console.error('报告打字机效果失败:', err)
  2350. // 失败时直接显示完整内容
  2351. targetReport.report.summary = targetReport._fullContent.summary || ''
  2352. targetReport.report.analysis = targetReport._fullContent.analysis || ''
  2353. targetReport.report.clauses = targetReport._fullContent.clauses || ''
  2354. targetReport._typewriterCompleted = true
  2355. })
  2356. console.log('📝 [DEBUG] 报告打字机已启动:', {
  2357. file_index: targetReport.file_index,
  2358. summary_length: targetReport._fullContent.summary?.length || 0,
  2359. analysis_length: targetReport._fullContent.analysis?.length || 0,
  2360. clauses_length: targetReport._fullContent.clauses?.length || 0
  2361. })
  2362. }
  2363. // 更新进度
  2364. aiMessage.completedCount = aiMessage.reports.filter(r => r.status === 'completed' && r.type !== 'category_title').length
  2365. const reportProgress = aiMessage.totalFiles > 0 ? (aiMessage.completedCount / aiMessage.totalFiles) : 0
  2366. // 根据报告完成度更新状态
  2367. if (reportProgress >= 1) {
  2368. // 所有报告完成,进入输出阶段
  2369. updateMessageStatus(aiMessage, 'outputting')
  2370. } else if (reportProgress >= 0.5) {
  2371. // 过半,深度思考
  2372. updateMessageStatus(aiMessage, 'deep_thinking')
  2373. } else {
  2374. // 继续分析文件
  2375. const currentProgress = 30 + Math.round(reportProgress * 30) // 30%~60%
  2376. aiMessage.progress = currentProgress
  2377. }
  2378. break
  2379. case 'web_search_raw':
  2380. // 处理网络搜索原始数据
  2381. if (data.results && data.results.length > 0) {
  2382. aiMessage.webSearchRaw = {
  2383. results: data.results || [],
  2384. keywords: data.keywords || [],
  2385. total: data.total || 0
  2386. }
  2387. aiMessage.webSearchTotal = data.total || 0
  2388. // 更新状态显示:已检索到数据(联网搜索完成)
  2389. const statusMsg = `<span class="ai-name">蜀道安全管理AI智能助手</span>已为您检索到 <span class="file-count">${aiMessage.totalFiles || 0}</span> 个知识库文件,以及 <span class="file-count">${aiMessage.webSearchTotal}</span> 个相关网络资源`
  2390. updateMessageStatus(aiMessage, 'data_retrieved', statusMsg)
  2391. console.log(`[网络搜索] 收到原始数据: ${data.total} 条结果`)
  2392. } else {
  2393. // 如果没有搜索结果,也要更新状态,但只显示知识库文件数
  2394. const statusMsg = `<span class="ai-name">蜀道安全管理AI智能助手</span>已为您检索到 <span class="file-count">${aiMessage.totalFiles || 0}</span> 个知识库文件`
  2395. updateMessageStatus(aiMessage, 'data_retrieved', statusMsg)
  2396. console.log('[网络搜索] 无搜索结果,仅使用知识库')
  2397. }
  2398. break
  2399. case 'web_search_summary':
  2400. // 处理网络搜索AI总结 - 使用更快的打字机效果
  2401. if (data.has_results && data.summary) {
  2402. // 检查是否已经有内容,避免重复触发
  2403. if (aiMessage._webSearchSummaryCompleted) {
  2404. console.log('[网络搜索] 总结已完成,跳过重复打字机')
  2405. break
  2406. }
  2407. // 保存完整内容
  2408. aiMessage._fullWebSearchSummary = data.summary
  2409. aiMessage.webSearchSummary = '' // 先置空,通过打字机填充
  2410. aiMessage.hasWebSearchResults = true
  2411. // 使用更快的打字机效果(速度200 = 每次20个字符)
  2412. const summaryReport = {
  2413. file_index: 'web_search_summary',
  2414. report: aiMessage,
  2415. _typewriterStates: {}
  2416. }
  2417. startReportFieldTypewriter(summaryReport, 'webSearchSummary', data.summary, 200)
  2418. .then(() => {
  2419. // 标记为已完成,防止重复触发
  2420. aiMessage._webSearchSummaryCompleted = true
  2421. })
  2422. .catch(err => {
  2423. console.error('网络搜索总结打字机效果失败:', err)
  2424. aiMessage.webSearchSummary = data.summary
  2425. aiMessage._webSearchSummaryCompleted = true
  2426. })
  2427. console.log('[网络搜索] 收到AI总结,长度:', data.summary.length)
  2428. // 网络搜索总结已接收,准备保存并更新为完成状态
  2429. updateMessageStatus(aiMessage, 'outputting')
  2430. // 保存完整数据到后端 - 使用完整的summary而不是打字机过程中的部分内容
  2431. if (aiMessage.ai_message_id) {
  2432. const contentData = {
  2433. reports: aiMessage.reports || [],
  2434. webSearchRaw: aiMessage.webSearchRaw || null,
  2435. webSearchSummary: aiMessage._fullWebSearchSummary || data.summary, // 使用完整的webSearchSummary
  2436. hasWebSearchResults: true,
  2437. // ===== 🔧 修复:将summary也包含在content JSON中 =====
  2438. summary: aiMessage.summary || aiMessage._fullSummary || ''
  2439. }
  2440. const collectedContent = JSON.stringify(contentData)
  2441. // 同时保存summary字段(作为单独字段)
  2442. const summaryToSave = aiMessage.summary || aiMessage._fullSummary || ''
  2443. updateAIMessageContent(aiMessage.ai_message_id, collectedContent, summaryToSave)
  2444. .then((response) => {
  2445. console.log('[网络搜索] AI消息保存成功,更新为完成状态')
  2446. // 保存成功后,更新状态为completed
  2447. updateMessageStatus(aiMessage, 'completed')
  2448. aiMessage.isTyping = false
  2449. isSending.value = false
  2450. streamingReports.value.clear()
  2451. // 重置AI回复流程状态
  2452. isAIReplyProcessComplete.value = true
  2453. })
  2454. .catch(err => {
  2455. console.error('[网络搜索] AI消息保存失败:', err)
  2456. // 即使保存失败,也更新为完成状态
  2457. updateMessageStatus(aiMessage, 'completed')
  2458. aiMessage.isTyping = false
  2459. isSending.value = false
  2460. })
  2461. } else {
  2462. // 没有ai_message_id时,直接更新为完成状态
  2463. updateMessageStatus(aiMessage, 'completed')
  2464. aiMessage.isTyping = false
  2465. isSending.value = false
  2466. }
  2467. }
  2468. break
  2469. case 'error':
  2470. ElMessage.error(data.message)
  2471. isSending.value = false
  2472. break
  2473. case 'completed':
  2474. console.log('[SSE] 收到completed事件')
  2475. isSending.value = false
  2476. streamingReports.value.clear()
  2477. aiMessage.isTyping = false
  2478. // 只有在还未完成时才更新状态
  2479. if (aiMessage.progress < 100) {
  2480. updateMessageStatus(aiMessage, 'completed')
  2481. }
  2482. ElMessage.success('报告生成完成')
  2483. break
  2484. case 'interrupted':
  2485. isSending.value = false
  2486. streamingReports.value.clear()
  2487. aiMessage.isTyping = false
  2488. ElMessage.warning(data.message || '报告生成已中断')
  2489. break
  2490. }
  2491. }
  2492. const handleSSEError = (error) => {
  2493. console.error('❌ SSE连接异常断开:', error)
  2494. // 清理SSE连接
  2495. if (sseConnection) {
  2496. closeSSEConnection(sseConnection)
  2497. sseConnection = null
  2498. }
  2499. isSending.value = false
  2500. streamingReports.value.clear()
  2501. // 将所有正在打字的AI消息标记为完成
  2502. chatMessages.value.forEach(message => {
  2503. if (message.type === 'ai' && message.isTyping) {
  2504. message.isTyping = false
  2505. }
  2506. })
  2507. // 重置AI回复流程状态
  2508. isAIReplyProcessComplete.value = true
  2509. // 这是非预期的连接断开(网络错误、后端崩溃等)
  2510. ElMessage.error('连接已断开')
  2511. }
  2512. const handleSSEComplete = () => {
  2513. isSending.value = false
  2514. chatMessages.value.forEach(message => {
  2515. if (message.type === 'ai' && message.isTyping) {
  2516. message.isTyping = false
  2517. if (message.ai_message_id) {
  2518. // 构建完整的内容数据,包含报告、网络搜索结果和summary
  2519. const contentData = {
  2520. reports: message.reports || [],
  2521. webSearchRaw: message.webSearchRaw || null,
  2522. // 使用完整的webSearchSummary,而不是打字机过程中的部分内容
  2523. webSearchSummary: message._fullWebSearchSummary || message.webSearchSummary || null,
  2524. hasWebSearchResults: message.hasWebSearchResults || false,
  2525. // ===== 🔧 修复:将summary也包含在content JSON中 =====
  2526. summary: message.summary || message._fullSummary || ''
  2527. }
  2528. const collectedContent = message.reports && message.reports.length > 0
  2529. ? JSON.stringify(contentData)
  2530. : (message.content || message._fullSummary || message.summary || '')
  2531. if (collectedContent) {
  2532. // 同时保存summary字段(作为单独字段)
  2533. const summaryToSave = message.summary || message._fullSummary || ''
  2534. updateAIMessageContent(message.ai_message_id, collectedContent, summaryToSave)
  2535. .catch(err => console.error('回写AI消息失败:', err))
  2536. }
  2537. }
  2538. }
  2539. })
  2540. isAIReplyProcessComplete.value = true
  2541. // ===== 🎯 更新历史记录列表并高亮 =====
  2542. console.log('📝 AI回复完成,准备更新历史记录')
  2543. console.log(' - ai_conversation_id:', ai_conversation_id.value)
  2544. if (ai_conversation_id.value && ai_conversation_id.value !== 0) {
  2545. console.log('✅ 开始更新历史记录列表')
  2546. // 先清除所有高亮
  2547. historyData.value.forEach((item) => {
  2548. item.isActive = false
  2549. })
  2550. // 获取第一条用户消息作为标题
  2551. const firstUserMessage = chatMessages.value.find(msg => msg.type === 'user')
  2552. const title = firstUserMessage ? firstUserMessage.content.substring(0, 20) + '...' : '新对话'
  2553. console.log('📝 生成的标题:', title)
  2554. // 检查是否已存在
  2555. const existingIndex = historyData.value.findIndex(item => item.id === ai_conversation_id.value)
  2556. console.log('🔍 检查是否已存在,索引:', existingIndex)
  2557. if (existingIndex === -1) {
  2558. // 不存在,在最前面插入新项
  2559. const newItem = {
  2560. id: ai_conversation_id.value,
  2561. title: title,
  2562. time: formatTime(new Date().toISOString()),
  2563. businessType: 0,
  2564. isActive: true,
  2565. rawData: {
  2566. id: ai_conversation_id.value,
  2567. content: firstUserMessage?.content || '',
  2568. updated_at: new Date().toISOString()
  2569. }
  2570. }
  2571. console.log('📦 准备插入的新项:', newItem)
  2572. historyData.value.unshift(newItem)
  2573. console.log('✅ 已在列表最前面插入新历史记录并设为高亮')
  2574. console.log('📊 更新后的历史记录数量:', historyData.value.length)
  2575. } else {
  2576. // 已存在,设为高亮并移到最前面
  2577. console.log('🔄 历史记录已存在,将其移到最前面')
  2578. const existingItem = historyData.value.splice(existingIndex, 1)[0]
  2579. existingItem.isActive = true
  2580. existingItem.time = formatTime(new Date().toISOString())
  2581. historyData.value.unshift(existingItem)
  2582. console.log('✅ 已将现有历史记录移到最前面并设为高亮')
  2583. }
  2584. // 更新历史记录总数
  2585. historyTotal.value = historyData.value.length
  2586. console.log('📊 最终历史记录总数:', historyTotal.value)
  2587. } else {
  2588. console.warn('⚠️ ai_conversation_id 为 0 或未设置,跳过历史记录更新')
  2589. }
  2590. // ===== 🎯 获取AI相关推荐问题 =====
  2591. console.log('🎯 准备获取AI相关推荐问题')
  2592. // 找到最后一条用户消息和最后一条AI消息
  2593. const lastUserMessage = chatMessages.value.filter(msg => msg.type === 'user').pop()
  2594. const lastAIMessage = chatMessages.value.filter(msg => msg.type === 'ai').pop()
  2595. if (lastUserMessage && lastAIMessage && lastAIMessage.ai_message_id) {
  2596. console.log('📝 找到最后一条用户消息和AI消息')
  2597. console.log(' - 用户消息:', lastUserMessage.content)
  2598. console.log(' - AI消息ID:', lastAIMessage.ai_message_id)
  2599. // 构建AI回复内容用于生成推荐问题
  2600. let aiReplyContent = ''
  2601. // 优先使用summary字段
  2602. if (lastAIMessage.summary) {
  2603. aiReplyContent = lastAIMessage.summary
  2604. }
  2605. // 其次使用content字段
  2606. else if (lastAIMessage.content) {
  2607. aiReplyContent = lastAIMessage.content
  2608. }
  2609. // 如果有reports,使用reports的summary
  2610. else if (lastAIMessage.reports && lastAIMessage.reports.length > 0) {
  2611. const summaries = lastAIMessage.reports
  2612. .filter(r => r.report && r.report.summary)
  2613. .map(r => r.report.summary)
  2614. .slice(0, 3) // 只取前3个
  2615. aiReplyContent = summaries.join('\n\n')
  2616. }
  2617. if (aiReplyContent && aiReplyContent.trim()) {
  2618. console.log('📝 AI回复内容长度:', aiReplyContent.length)
  2619. // 获取AI相关推荐问题
  2620. getAIRelatedQuestions(
  2621. lastUserMessage.content,
  2622. aiReplyContent,
  2623. lastAIMessage.ai_message_id
  2624. )
  2625. } else {
  2626. console.warn('⚠️ AI回复内容为空,跳过推荐问题获取')
  2627. }
  2628. } else {
  2629. console.warn('⚠️ 未找到有效的用户消息或AI消息,跳过推荐问题获取')
  2630. }
  2631. }
  2632. const handleSSEInterrupted = (data) => {
  2633. if (sseConnection) {
  2634. closeSSEConnection(sseConnection)
  2635. sseConnection = null
  2636. }
  2637. isSending.value = false
  2638. streamingReports.value.clear()
  2639. chatMessages.value.forEach(message => {
  2640. if (message.type === 'ai' && message.isTyping) {
  2641. message.isTyping = false
  2642. // 中断时设置为完成状态
  2643. updateMessageStatus(message, 'completed')
  2644. // 保留已接收的网络搜索数据
  2645. // 即使中断,也要确保已接收的webSearchRaw和webSearchSummary能够显示
  2646. if (message.webSearchRaw || message.webSearchSummary) {
  2647. console.log('✅ 保留已接收的网络搜索数据')
  2648. }
  2649. if (message.ai_message_id) {
  2650. // 构建完整的内容数据,包含报告、网络搜索结果和summary
  2651. const contentData = {
  2652. reports: message.reports || [],
  2653. webSearchRaw: message.webSearchRaw || null,
  2654. // 使用完整的webSearchSummary,而不是打字机过程中的部分内容
  2655. webSearchSummary: message._fullWebSearchSummary || message.webSearchSummary || null,
  2656. hasWebSearchResults: message.hasWebSearchResults || false,
  2657. // ===== 🔧 修复:将summary也包含在content JSON中 =====
  2658. summary: message.summary || message._fullSummary || ''
  2659. }
  2660. const collectedContent = message.reports && message.reports.length > 0
  2661. ? JSON.stringify(contentData)
  2662. : (message.content || message._fullSummary || message.summary || '')
  2663. if (collectedContent) {
  2664. // 同时保存summary字段(作为单独字段)
  2665. const summaryToSave = message.summary || message._fullSummary || ''
  2666. updateAIMessageContent(message.ai_message_id, collectedContent, summaryToSave)
  2667. .catch(err => console.error('回写AI消息失败:', err))
  2668. }
  2669. }
  2670. }
  2671. })
  2672. isAIReplyProcessComplete.value = true
  2673. ElMessage.warning(data.message || '报告生成已中断')
  2674. }
  2675. const handleCancel = () => {
  2676. if (sseConnection) {
  2677. closeSSEConnection(sseConnection)
  2678. sseConnection = null
  2679. }
  2680. isSending.value = false
  2681. ElMessage.warning('已取消生成')
  2682. }
  2683. // 停止报告生成
  2684. const handleStopGeneration = async () => {
  2685. if (ai_conversation_id.value === undefined || ai_conversation_id.value === null) {
  2686. return
  2687. }
  2688. if (sseConnection) {
  2689. closeSSEConnection(sseConnection)
  2690. sseConnection = null
  2691. }
  2692. isSending.value = false
  2693. streamingReports.value.clear()
  2694. clearAllTypeIntervals()
  2695. chatMessages.value.forEach(message => {
  2696. if (message.type === 'ai' && message.isTyping) {
  2697. message.isTyping = false
  2698. message._stopped = true
  2699. // 停止时设置为完成状态
  2700. updateMessageStatus(message, 'completed')
  2701. // 保留已接收的网络搜索数据
  2702. // 即使停止,也要确保已接收的webSearchRaw和webSearchSummary能够显示
  2703. if (message.webSearchRaw || message.webSearchSummary) {
  2704. console.log('✅ 停止时保留已接收的网络搜索数据')
  2705. }
  2706. // 回写数据到后端,包含网络搜索结果和summary
  2707. if (message.ai_message_id) {
  2708. const contentData = {
  2709. reports: message.reports || [],
  2710. webSearchRaw: message.webSearchRaw || null,
  2711. // 使用完整的webSearchSummary,而不是打字机过程中的部分内容
  2712. webSearchSummary: message._fullWebSearchSummary || message.webSearchSummary || null,
  2713. hasWebSearchResults: message.hasWebSearchResults || false,
  2714. // ===== 🔧 修复:将summary也包含在content JSON中 =====
  2715. summary: message.summary || message._fullSummary || ''
  2716. }
  2717. const collectedContent = message.reports && message.reports.length > 0
  2718. ? JSON.stringify(contentData)
  2719. : (message.content || message._fullSummary || message.summary || '')
  2720. if (collectedContent) {
  2721. // 同时保存summary字段(作为单独字段)
  2722. const summaryToSave = message.summary || message._fullSummary || ''
  2723. updateAIMessageContent(message.ai_message_id, collectedContent, summaryToSave)
  2724. .catch(err => console.error('回写AI消息失败:', err))
  2725. }
  2726. }
  2727. }
  2728. })
  2729. isAIReplyProcessComplete.value = true
  2730. ElMessage.success('已停止生成')
  2731. // ===== 已删除:getUserId() - stopSSEStream 函数需要更新 =====
  2732. stopSSEStream(null, ai_conversation_id.value)
  2733. .catch(error => console.warn('停止请求失败:', error))
  2734. }
  2735. // ReportGenerator提交处理
  2736. const handleReportGeneratorSubmit = async (data) => {
  2737. isSending.value = true
  2738. currentQuestion.value = data.question
  2739. // 添加用户消息
  2740. chatMessages.value.push({
  2741. id: Date.now(),
  2742. type: 'user',
  2743. content: data.question,
  2744. timestamp: new Date().toISOString()
  2745. })
  2746. // 添加AI消息占位符
  2747. const aiMessageIndex = chatMessages.value.length
  2748. chatMessages.value.push({
  2749. id: Date.now() + 1,
  2750. type: 'ai',
  2751. userQuestion: data.question, // 用户问题
  2752. summary: '',
  2753. totalFiles: 0,
  2754. webSearchTotal: 0,
  2755. progress: 0,
  2756. completedCount: 0,
  2757. reports: [],
  2758. isTyping: true,
  2759. content: '',
  2760. displayContent: '',
  2761. timestamp: new Date().toISOString(),
  2762. // 新增:状态管理
  2763. currentStatus: 'querying_kb', // 当前状态
  2764. statusMessage: '🔍 <span class="ai-name">蜀道安全管理AI智能助手</span>正在从知识库中学习相关信息……', // 状态消息
  2765. showStats: true, // 是否显示统计卡片
  2766. // 新增:后端消息ID(初始为null,从SSE接收后赋值)
  2767. ai_message_id: null,
  2768. rawData: null,
  2769. userFeedback: null
  2770. })
  2771. // 等待DOM更新后触发滚动检测,初始化进度卡片状态
  2772. nextTick(() => {
  2773. setTimeout(() => {
  2774. handleProgressCardScroll()
  2775. }, 100)
  2776. })
  2777. try {
  2778. const apiPrefix = getApiPrefix()
  2779. const url = `${apiPrefix}/report/complete-flow`
  2780. // 构建 POST 请求体
  2781. const requestBody = {
  2782. user_question: data.question,
  2783. window_size: data.windowSize,
  2784. n_results: 10,
  2785. ai_conversation_id: ai_conversation_id.value,
  2786. is_network_search_enabled: isNetworkSearchEnabled.value,
  2787. enable_online_model: isOnlineModel.value
  2788. }
  2789. console.log('📤 发起 SSE POST 请求:', {
  2790. url,
  2791. body: requestBody
  2792. })
  2793. sseConnection = createSSEConnection(url, {
  2794. onMessage: (eventData) => handleSSEMessage(eventData, aiMessageIndex),
  2795. onError: handleSSEError,
  2796. onComplete: handleSSEComplete,
  2797. onInterrupted: handleSSEInterrupted
  2798. }, {
  2799. body: requestBody
  2800. })
  2801. } catch (error) {
  2802. console.error('启动失败:', error)
  2803. ElMessage.error(`启动失败: ${error.message}`)
  2804. isSending.value = false
  2805. }
  2806. }
  2807. const handleFunctionCard = async (cardType) => {
  2808. clearAllTypeIntervals()
  2809. chatMessages.value = []
  2810. ai_conversation_id.value = 0
  2811. showChat.value = true
  2812. await submitQuestionByCurrentMode(`请详细介绍${cardType}的相关内容`, {
  2813. windowSize: 3,
  2814. nResults: 10
  2815. })
  2816. }
  2817. // 点击推荐问题
  2818. const handleRecommendedQuestion = async (question) => {
  2819. clearAllTypeIntervals()
  2820. chatMessages.value = []
  2821. ai_conversation_id.value = 0
  2822. showChat.value = true
  2823. await submitQuestionByCurrentMode(question, {
  2824. windowSize: 3,
  2825. nResults: 10
  2826. })
  2827. }
  2828. // 点击用户推荐问题
  2829. const handleUserRecommendQuestion = async (question) => {
  2830. clearRecommendQuestions()
  2831. showChat.value = true
  2832. if (chatMessages.value.length === 0) {
  2833. clearNewConversationState()
  2834. }
  2835. await submitQuestionByCurrentMode(question, {
  2836. windowSize: 3,
  2837. nResults: 10
  2838. })
  2839. }
  2840. // 点击历史记录
  2841. const handleHistoryItem = async (historyItem) => {
  2842. if (isSending.value) return
  2843. if (speakingMessageId.value) {
  2844. stopAllAudio()
  2845. speakingMessageId.value = null
  2846. }
  2847. clearAllTypeIntervals()
  2848. expandedSearchSources.value = {}
  2849. expandedOnlineSearchResults.value = {}
  2850. onlineSearchResults.value = {}
  2851. isAIReplyProcessComplete.value = true
  2852. selectedHistoryItem.value = historyItem
  2853. // 设置当前问题为历史记录的标题
  2854. currentQuestion.value = historyItem.title
  2855. historyData.value.forEach(item => {
  2856. item.isActive = item.id === historyItem.id
  2857. })
  2858. showChat.value = true
  2859. const loadingMessage = {
  2860. type: 'ai',
  2861. content: '正在加载历史对话...',
  2862. displayContent: '正在加载历史对话...',
  2863. isTyping: true,
  2864. id: Date.now() + 1,
  2865. userFeedback: null
  2866. }
  2867. chatMessages.value = [loadingMessage]
  2868. try {
  2869. const success = await getConversationMessages(historyItem.id)
  2870. if (success) {
  2871. chatMessages.value = chatMessages.value.filter(msg => msg.id !== loadingMessage.id)
  2872. // 根据历史记录的业务类型进行跳转或模式切换
  2873. const bType = Number(historyItem.businessType)
  2874. if (bType === 3) {
  2875. router.push({ path: '/exam-workshop', query: { historyId: historyItem.id } })
  2876. return
  2877. } else if (bType === 2) {
  2878. currentMode.value = 'ai-writing'
  2879. } else if (bType === 1) {
  2880. currentMode.value = 'safety-training'
  2881. } else {
  2882. currentMode.value = 'ai-qa'
  2883. }
  2884. await nextTick()
  2885. scrollToBottom()
  2886. if (aiRelatedQuestions.value.length > 0) {
  2887. setTimeout(() => scrollToBottom(), 200)
  2888. }
  2889. } else {
  2890. chatMessages.value = [{
  2891. type: 'ai',
  2892. content: '抱歉,加载历史对话失败,请稍后重试。',
  2893. displayContent: '抱歉,加载历史对话失败,请稍后重试。',
  2894. isTyping: false,
  2895. id: Date.now() + 1,
  2896. userFeedback: null
  2897. }]
  2898. await nextTick()
  2899. scrollToBottom()
  2900. }
  2901. } catch (error) {
  2902. console.error('加载历史对话失败:', error)
  2903. chatMessages.value = [{
  2904. type: 'ai',
  2905. content: '抱歉,加载历史对话时发生错误,请稍后重试。',
  2906. displayContent: '抱歉,加载历史对话时发生错误,请稍后重试。',
  2907. isTyping: false,
  2908. id: Date.now() + 1,
  2909. userFeedback: null
  2910. }]
  2911. await nextTick()
  2912. scrollToBottom()
  2913. }
  2914. }
  2915. // 删除历史记录
  2916. const deleteHistoryItem = (historyItem, index) => {
  2917. deleteTargetItem.value = { item: historyItem, index: index }
  2918. deleteType.value = 'history'
  2919. showDeleteModal.value = true
  2920. }
  2921. // 统一的确认删除函数
  2922. const confirmDelete = async () => {
  2923. if (!deleteTargetItem.value) return
  2924. if (deleteType.value === 'history') {
  2925. await confirmDeleteHistory()
  2926. } else if (deleteType.value === 'message') {
  2927. await confirmDeleteMessage()
  2928. }
  2929. }
  2930. // 确认删除历史记录
  2931. const confirmDeleteHistory = async () => {
  2932. const { item: historyItem, index } = deleteTargetItem.value
  2933. try {
  2934. // 调用删除接口
  2935. const response = await apis.deleteHistoryRecord({
  2936. ai_conversation_id: historyItem.id
  2937. })
  2938. if (response.statusCode === 200) {
  2939. historyData.value.splice(index, 1)
  2940. // 更新历史记录总数
  2941. historyTotal.value = historyData.value.length
  2942. if (historyItem.isActive) {
  2943. await createNewChat()
  2944. }
  2945. ElMessage.success('删除成功')
  2946. } else {
  2947. ElMessage.error(response.msg || '删除失败')
  2948. }
  2949. } catch (error) {
  2950. console.error('删除失败:', error)
  2951. ElMessage.error('删除失败,请稍后重试')
  2952. } finally {
  2953. showDeleteModal.value = false
  2954. deleteTargetItem.value = null
  2955. deleteType.value = ''
  2956. }
  2957. }
  2958. // 确认删除消息
  2959. const confirmDeleteMessage = async () => {
  2960. const { messageIndex } = deleteTargetItem.value
  2961. try {
  2962. const aiMessage = chatMessages.value[messageIndex]
  2963. // 优先使用ai_message_id(后端消息ID),其次使用rawData.id
  2964. const messageId = aiMessage.ai_message_id || (aiMessage.rawData && aiMessage.rawData.id)
  2965. if (aiMessage && messageId) {
  2966. const response = await apis.deleteConversation({
  2967. ai_message_id: messageId
  2968. })
  2969. if (response.statusCode === 200) {
  2970. chatMessages.value.splice(messageIndex, 1)
  2971. if (messageIndex > 0) {
  2972. chatMessages.value.splice(messageIndex - 1, 1)
  2973. }
  2974. ElMessage.success('删除成功')
  2975. } else {
  2976. ElMessage.error('删除失败,请稍后重试')
  2977. }
  2978. } else {
  2979. chatMessages.value.splice(messageIndex, 1)
  2980. ElMessage.success('删除成功')
  2981. }
  2982. } catch (error) {
  2983. console.error('删除失败:', error)
  2984. ElMessage.error('删除失败,请稍后重试')
  2985. } finally {
  2986. showDeleteModal.value = false
  2987. deleteTargetItem.value = null
  2988. deleteType.value = ''
  2989. }
  2990. }
  2991. // 取消删除
  2992. const cancelDelete = () => {
  2993. showDeleteModal.value = false
  2994. deleteTargetItem.value = null
  2995. deleteType.value = ''
  2996. }
  2997. // 新建聊天
  2998. const createNewChat = async () => {
  2999. if (isSending.value) return
  3000. if (speakingMessageId.value) {
  3001. stopAllAudio()
  3002. speakingMessageId.value = null
  3003. }
  3004. clearAllTypeIntervals()
  3005. ai_conversation_id.value = 0
  3006. chatMessages.value = []
  3007. messageText.value = ''
  3008. currentQuestion.value = '' // 清空当前问题
  3009. selectedFile.value = null
  3010. showChat.value = false
  3011. aiRelatedQuestions.value = []
  3012. relatedQuestionsMessageId.value = null
  3013. onlineSearchResults.value = {}
  3014. expandedOnlineSearchResults.value = {}
  3015. expandedSearchSources.value = {}
  3016. historyData.value.forEach(item => item.isActive = false)
  3017. await getHistoryRecordList()
  3018. }
  3019. // 语音输入
  3020. const handleVoiceClick = () => {
  3021. isListening.value ? stopVoiceInput() : startVoiceInput()
  3022. }
  3023. const startVoiceInput = () => {
  3024. const success = startListening()
  3025. if (!success) {
  3026. ElMessage.error('语音识别启动失败,请检查麦克风权限')
  3027. }
  3028. }
  3029. const stopVoiceInput = () => {
  3030. stopListening()
  3031. if (transcript.value.trim()) {
  3032. messageText.value = transcript.value
  3033. }
  3034. }
  3035. // 当前播放的音频对象
  3036. const currentAudio = ref(null)
  3037. // 音频队列管理
  3038. const audioQueue = ref([])
  3039. const isPlayingQueue = ref(false)
  3040. // 获取TTS服务地址(自动判断是否使用代理)
  3041. const getTTSUrl = () => {
  3042. // 在开发环境中使用代理,生产环境中使用直接地址
  3043. const isDevelopment = import.meta.env.DEV
  3044. if (isDevelopment) {
  3045. return '/api/tts/voice' // 使用Vite代理
  3046. } else {
  3047. return window.location.origin + '/tts/voice' // 生产环境直接地址
  3048. }
  3049. }
  3050. // 测试TTS服务连接
  3051. const testTTSConnection = async () => {
  3052. const ttsUrl = getTTSUrl()
  3053. try {
  3054. const controller = new AbortController()
  3055. const timeoutId = setTimeout(() => controller.abort(), 8000)
  3056. // 准备请求头,添加认证 Token
  3057. const headers = { 'Content-Type': 'application/json' }
  3058. const token = getToken()
  3059. const tokenType = getTokenType()
  3060. if (token && tokenType) {
  3061. // 确保 Bearer 首字母大写
  3062. const bearerType = tokenType.charAt(0).toUpperCase() + tokenType.slice(1).toLowerCase()
  3063. headers['Authorization'] = `${bearerType} ${token}`
  3064. }
  3065. const response = await fetch(ttsUrl, {
  3066. method: 'POST',
  3067. headers,
  3068. body: JSON.stringify({ text: '测试' }),
  3069. signal: controller.signal
  3070. })
  3071. clearTimeout(timeoutId)
  3072. if (response.ok) {
  3073. await response.blob()
  3074. return { success: true, message: 'TTS服务连接正常' }
  3075. } else {
  3076. return { success: false, message: `TTS服务响应错误: ${response.status}` }
  3077. }
  3078. } catch (error) {
  3079. let message = 'TTS服务连接失败'
  3080. if (error.name === 'AbortError') {
  3081. message = 'TTS服务连接超时'
  3082. } else if (error.message.includes('Failed to fetch')) {
  3083. message = '无法连接到TTS服务'
  3084. }
  3085. return { success: false, message }
  3086. }
  3087. }
  3088. // 调用TTS接口进行语音合成(带重试机制)
  3089. const callTTSAPI = async (text, retryCount = 0) => {
  3090. const ttsUrl = getTTSUrl()
  3091. const maxRetries = 2
  3092. try {
  3093. const controller = new AbortController()
  3094. const timeoutId = setTimeout(() => controller.abort(), 15000)
  3095. // 准备请求头,添加认证 Token
  3096. const headers = { 'Content-Type': 'application/json' }
  3097. const token = getToken()
  3098. const tokenType = getTokenType()
  3099. if (token && tokenType) {
  3100. // 确保 Bearer 首字母大写
  3101. const bearerType = tokenType.charAt(0).toUpperCase() + tokenType.slice(1).toLowerCase()
  3102. headers['Authorization'] = `${bearerType} ${token}`
  3103. }
  3104. const response = await fetch(ttsUrl, {
  3105. method: 'POST',
  3106. headers,
  3107. body: JSON.stringify({ text }),
  3108. signal: controller.signal
  3109. })
  3110. clearTimeout(timeoutId)
  3111. if (!response.ok) {
  3112. throw new Error(`TTS接口调用失败: ${response.status}`)
  3113. }
  3114. const audioBlob = await response.blob()
  3115. if (audioBlob.size === 0) {
  3116. throw new Error('TTS接口返回的音频数据为空')
  3117. }
  3118. return URL.createObjectURL(audioBlob)
  3119. } catch (error) {
  3120. // 重试逻辑
  3121. if (retryCount < maxRetries && (
  3122. error.name === 'AbortError' ||
  3123. error.message.includes('Failed to fetch') ||
  3124. error.message.includes('NetworkError')
  3125. )) {
  3126. await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000))
  3127. return callTTSAPI(text, retryCount + 1)
  3128. }
  3129. // 错误信息
  3130. let errorMessage = '语音合成失败'
  3131. if (error.name === 'AbortError') {
  3132. errorMessage = '语音合成请求超时'
  3133. } else if (error.message.includes('Failed to fetch')) {
  3134. errorMessage = '无法连接到语音合成服务'
  3135. } else if (error.message.includes('TTS接口调用失败')) {
  3136. errorMessage = error.message
  3137. }
  3138. const detailedError = new Error(errorMessage)
  3139. detailedError.originalError = error
  3140. throw detailedError
  3141. }
  3142. }
  3143. // 清理文本内容,移除HTML标签和非文本字符
  3144. const cleanTextForTTS = (text) => {
  3145. if (!text) return ''
  3146. // 移除HTML标签
  3147. let cleanText = text.replace(/<[^>]*>/g, '')
  3148. // 移除多余的空白字符
  3149. cleanText = cleanText.replace(/\s+/g, ' ').trim()
  3150. // 移除特殊字符,保留中文、英文、数字和基本标点
  3151. cleanText = cleanText.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s,。!?、;:""''()【】《》]/g, '')
  3152. return cleanText
  3153. }
  3154. // 将长文本分块处理(第一块较小,后续块较大)
  3155. const splitTextIntoChunks = (text) => {
  3156. if (text.length <= 60) {
  3157. return [text]
  3158. }
  3159. const chunks = []
  3160. let currentChunk = ''
  3161. let isFirstChunk = true
  3162. // 按句子分割
  3163. const sentences = text.split(/[。!?]/)
  3164. for (const sentence of sentences) {
  3165. if (sentence.trim().length === 0) continue
  3166. const sentenceWithPunctuation = sentence + (sentence.match(/[。!?]$/) ? '' : '。')
  3167. // 第一块限制为60字,后续块限制为200字
  3168. const maxLength = isFirstChunk ? 60 : 200
  3169. if (currentChunk.length + sentenceWithPunctuation.length <= maxLength) {
  3170. currentChunk += sentenceWithPunctuation
  3171. } else {
  3172. if (currentChunk.length > 0) {
  3173. chunks.push(currentChunk.trim())
  3174. currentChunk = sentenceWithPunctuation
  3175. isFirstChunk = false // 第一块已完成
  3176. } else {
  3177. // 如果单个句子就超过最大长度,强制分割
  3178. const maxLen = isFirstChunk ? 60 : 200
  3179. chunks.push(sentenceWithPunctuation.substring(0, maxLen))
  3180. currentChunk = sentenceWithPunctuation.substring(maxLen)
  3181. isFirstChunk = false
  3182. }
  3183. }
  3184. }
  3185. if (currentChunk.trim().length > 0) {
  3186. chunks.push(currentChunk.trim())
  3187. }
  3188. return chunks
  3189. }
  3190. // 播放音频
  3191. const playAudio = (audioUrl) => {
  3192. return new Promise((resolve, reject) => {
  3193. const audio = new Audio(audioUrl)
  3194. currentAudio.value = audio
  3195. audio.onended = () => {
  3196. currentAudio.value = null
  3197. resolve()
  3198. }
  3199. audio.onerror = (error) => {
  3200. currentAudio.value = null
  3201. reject(error)
  3202. }
  3203. audio.play().catch(error => {
  3204. currentAudio.value = null
  3205. reject(error)
  3206. })
  3207. })
  3208. }
  3209. // 停止所有音频播放
  3210. const stopAllAudio = () => {
  3211. try {
  3212. if (currentAudio.value) {
  3213. currentAudio.value.pause()
  3214. currentAudio.value.currentTime = 0
  3215. currentAudio.value = null
  3216. }
  3217. audioQueue.value = []
  3218. isPlayingQueue.value = false
  3219. window.speechSynthesis && window.speechSynthesis.cancel()
  3220. } catch (e) {
  3221. console.warn('停止音频播放失败:', e)
  3222. }
  3223. }
  3224. // 优化后的并行预加载音频并播放队列
  3225. const playAudioQueue = async (chunks, messageId) => {
  3226. if (speakingMessageId.value !== messageId) return
  3227. try {
  3228. const firstAudioUrl = await callTTSAPI(chunks[0])
  3229. if (speakingMessageId.value !== messageId) {
  3230. URL.revokeObjectURL(firstAudioUrl)
  3231. return
  3232. }
  3233. const remainingChunks = chunks.slice(1)
  3234. const remainingPromises = remainingChunks.map(async (chunk, index) => {
  3235. try {
  3236. const audioUrl = await callTTSAPI(chunk)
  3237. return { index: index + 1, audioUrl }
  3238. } catch (error) {
  3239. return { index: index + 1, audioUrl: null }
  3240. }
  3241. })
  3242. await playAudio(firstAudioUrl)
  3243. URL.revokeObjectURL(firstAudioUrl)
  3244. if (speakingMessageId.value !== messageId) {
  3245. const remainingResults = await Promise.allSettled(remainingPromises)
  3246. remainingResults.forEach(result => {
  3247. if (result.status === 'fulfilled' && result.value.audioUrl) {
  3248. URL.revokeObjectURL(result.value.audioUrl)
  3249. }
  3250. })
  3251. return
  3252. }
  3253. const remainingResults = await Promise.allSettled(remainingPromises)
  3254. if (speakingMessageId.value !== messageId) {
  3255. remainingResults.forEach(result => {
  3256. if (result.status === 'fulfilled' && result.value.audioUrl) {
  3257. URL.revokeObjectURL(result.value.audioUrl)
  3258. }
  3259. })
  3260. return
  3261. }
  3262. isPlayingQueue.value = true
  3263. for (const result of remainingResults) {
  3264. if (speakingMessageId.value !== messageId) break
  3265. if (result.status === 'fulfilled' && result.value.audioUrl) {
  3266. try {
  3267. await playAudio(result.value.audioUrl)
  3268. URL.revokeObjectURL(result.value.audioUrl)
  3269. } catch (error) {
  3270. console.error('音频播放失败:', error)
  3271. }
  3272. }
  3273. }
  3274. isPlayingQueue.value = false
  3275. } catch (error) {
  3276. console.error('音频队列播放失败:', error)
  3277. isPlayingQueue.value = false
  3278. }
  3279. }
  3280. // 语音朗读相关方法
  3281. const handleVoiceRead = async (message) => {
  3282. if (speakingMessageId.value === message.id) {
  3283. // 如果正在朗读这条消息,则停止
  3284. stopAllAudio()
  3285. speakingMessageId.value = null
  3286. } else {
  3287. // 如果朗读其他消息,先停止当前朗读
  3288. if (speakingMessageId.value) {
  3289. stopAllAudio()
  3290. speakingMessageId.value = null
  3291. }
  3292. const textToRead = message.displayContent || message.content
  3293. if (textToRead && textToRead.trim()) {
  3294. try {
  3295. const cleanText = cleanTextForTTS(textToRead)
  3296. if (cleanText.length === 0) {
  3297. ElMessage.warning('文本内容为空,无法进行语音合成')
  3298. return
  3299. }
  3300. speakingMessageId.value = message.id
  3301. if (cleanText.length <= 60) {
  3302. const audioUrl = await callTTSAPI(cleanText)
  3303. await playAudio(audioUrl)
  3304. URL.revokeObjectURL(audioUrl)
  3305. } else {
  3306. const chunks = splitTextIntoChunks(cleanText)
  3307. await playAudioQueue(chunks, message.id)
  3308. }
  3309. } catch (error) {
  3310. console.error('语音合成失败:', error)
  3311. ElMessage.error('语音合成失败,请稍后重试')
  3312. } finally {
  3313. speakingMessageId.value = null
  3314. isPlayingQueue.value = false
  3315. }
  3316. }
  3317. }
  3318. }
  3319. // 检查消息是否正在朗读
  3320. const isSpeaking = (messageId) => {
  3321. return speakingMessageId.value === messageId
  3322. }
  3323. // 删除弹窗相关方法
  3324. const handleDeleteClick = (messageIndex) => {
  3325. console.log('点击删除按钮,消息索引:', messageIndex)
  3326. // 检查是否只剩一句AI回复和一句用户发言
  3327. if (chatMessages.value.length === 2) {
  3328. ElMessage.warning('第一句话无法删除')
  3329. return
  3330. }
  3331. // 设置删除类型和要删除的消息索引
  3332. deleteType.value = 'message'
  3333. deleteTargetItem.value = { messageIndex: messageIndex }
  3334. showDeleteModal.value = true
  3335. }
  3336. // 处理输入框输入,限制2000字
  3337. const handleInput = () => {
  3338. if (messageText.value.length > 2000) {
  3339. messageText.value = messageText.value.substring(0, 2000)
  3340. ElMessage.warning('消息长度不能超过2000字')
  3341. }
  3342. // 防抖获取推荐问题
  3343. if (recommendQuestionsTimer.value) {
  3344. clearTimeout(recommendQuestionsTimer.value)
  3345. }
  3346. // 如果输入内容为空,清空推荐问题
  3347. if (!messageText.value.trim()) {
  3348. userRecommendQuestions.value = []
  3349. return
  3350. }
  3351. // 延迟500ms后获取推荐问题,避免频繁调用
  3352. recommendQuestionsTimer.value = setTimeout(() => {
  3353. getUserRecommendQuestions(messageText.value)
  3354. }, 100)
  3355. }
  3356. // 触发文件上传
  3357. const triggerFileUpload = () => {
  3358. if (selectedFile.value) {
  3359. ElMessage.warning('只能上传一个文件,请先删除当前文件')
  3360. return
  3361. }
  3362. fileInput.value?.click()
  3363. }
  3364. // 读取Word文件内容
  3365. const readWordFile = async (file) => {
  3366. try {
  3367. if (file.size === 0) throw new Error('Word文件为空')
  3368. if (!mammoth) throw new Error('Word文档解析库未正确加载')
  3369. const fileExtension = file.name.toLowerCase().split('.').pop()
  3370. const arrayBuffer = await file.arrayBuffer()
  3371. // 检查文件格式
  3372. if (fileExtension === 'docx') {
  3373. const uint8Array = new Uint8Array(arrayBuffer.slice(0, 2))
  3374. if (uint8Array[0] !== 0x50 || uint8Array[1] !== 0x4B) {
  3375. throw new Error('文件不是有效的.docx格式,可能已损坏')
  3376. }
  3377. } else if (fileExtension === 'doc') {
  3378. throw new Error('检测到.doc格式文件。请将文件另存为.docx格式后重新上传。')
  3379. }
  3380. const result = await mammoth.extractRawText({ arrayBuffer })
  3381. return result.value
  3382. } catch (error) {
  3383. console.error('Word文件读取失败:', error)
  3384. if (error.message.includes('Can\'t find end of central directory')) {
  3385. throw new Error('文件格式错误:这不是一个有效的Word文档,或者文件已损坏。')
  3386. } else if (error.message.includes('Invalid file format')) {
  3387. throw new Error('Word文件格式无效或已损坏')
  3388. } else {
  3389. throw error
  3390. }
  3391. }
  3392. }
  3393. // 处理文件选择
  3394. const handleFileSelect = async (event) => {
  3395. const file = event.target.files[0]
  3396. if (!file) return
  3397. try {
  3398. const fileExtension = validateFile(file)
  3399. if (!mammoth) {
  3400. throw new Error('Word文档解析库未正确加载,请刷新页面重试')
  3401. }
  3402. isUploadingFile.value = true
  3403. const extractedContent = await readWordFile(file)
  3404. selectedFile.value = {
  3405. file,
  3406. name: file.name,
  3407. size: file.size,
  3408. type: fileExtension,
  3409. icon: getFileIcon(fileExtension),
  3410. content: extractedContent
  3411. }
  3412. ElMessage.success(`文件读取成功,共提取 ${extractedContent.length} 个字符`)
  3413. } catch (error) {
  3414. console.error('文件读取失败:', error)
  3415. ElMessage.error(error.message || '文件读取失败,请重试')
  3416. } finally {
  3417. isUploadingFile.value = false
  3418. event.target.value = ''
  3419. }
  3420. }
  3421. // 删除选中的文件
  3422. const removeSelectedFile = () => {
  3423. if (selectedFile.value) {
  3424. selectedFile.value = null
  3425. }
  3426. }
  3427. // 获取功能卡片数据
  3428. const getFunctionCards = async () => {
  3429. try {
  3430. console.log('开始获取功能卡片...')
  3431. const requestMode = currentMode.value
  3432. const functionType = getModeRecommendationType(requestMode)
  3433. const response = await apis.getFunctionCard({ function_type: functionType })
  3434. console.log('功能卡片响应:', response)
  3435. if (response.statusCode === 200) {
  3436. if (currentMode.value !== requestMode) {
  3437. console.log('模式已切换,忽略旧的功能卡片结果:', requestMode, '->', currentMode.value)
  3438. return
  3439. }
  3440. const scopedCards = pickScopedRecommendationItems(
  3441. response.data,
  3442. functionType,
  3443. 'function_type'
  3444. )
  3445. if (scopedCards === null) {
  3446. console.warn('功能卡片接口未返回类型字段,当前模式继续使用默认卡片:', requestMode)
  3447. return
  3448. }
  3449. functionCards.value = scopedCards
  3450. console.log('功能卡片数据已设置:', functionCards.value)
  3451. } else {
  3452. console.error('获取功能卡片失败:', response.statusCode)
  3453. }
  3454. } catch (error) {
  3455. console.error('获取功能卡片失败:', error)
  3456. }
  3457. }
  3458. // 获取热点问题数据
  3459. const getHotQuestions = async () => {
  3460. try {
  3461. console.log('开始获取热点问题...')
  3462. const requestMode = currentMode.value
  3463. const questionType = getModeRecommendationType(requestMode)
  3464. const response = await apis.getHotQuestion({ question_type: questionType })
  3465. console.log('热点问题响应:', response)
  3466. if (response.statusCode === 200) {
  3467. if (currentMode.value !== requestMode) {
  3468. console.log('模式已切换,忽略旧的热点问题结果:', requestMode, '->', currentMode.value)
  3469. return
  3470. }
  3471. const scopedQuestions = pickScopedRecommendationItems(
  3472. response.data,
  3473. questionType,
  3474. 'question_type'
  3475. )
  3476. if (scopedQuestions === null) {
  3477. console.warn('热点问题接口未返回类型字段,当前模式继续使用默认问题:', requestMode)
  3478. return
  3479. }
  3480. hotQuestions.value = scopedQuestions
  3481. console.log('热点问题数据已设置:', hotQuestions.value)
  3482. } else {
  3483. console.error('获取热点问题失败:', response.statusCode)
  3484. }
  3485. } catch (error) {
  3486. console.error('获取热点问题失败:', error)
  3487. }
  3488. }
  3489. // 监听模式变化,重新获取卡片和问题
  3490. watch(currentMode, () => {
  3491. functionCards.value = []
  3492. hotQuestions.value = []
  3493. getFunctionCards()
  3494. getHotQuestions()
  3495. })
  3496. // 清除推荐问题
  3497. const clearRecommendQuestions = () => {
  3498. userRecommendQuestions.value = []
  3499. if (recommendQuestionsTimer.value) {
  3500. clearTimeout(recommendQuestionsTimer.value)
  3501. recommendQuestionsTimer.value = null
  3502. }
  3503. }
  3504. // 处理容器点击事件
  3505. const handleContainerClick = (event) => {
  3506. // 如果点击的不是推荐问题区域,则清除推荐问题
  3507. if (!event.target.closest('.user-recommend-questions')) {
  3508. clearRecommendQuestions()
  3509. }
  3510. // 如果点击的不是网络搜索胶囊或侧边栏,则关闭侧边栏
  3511. if (webSearchSidebarVisible.value &&
  3512. !event.target.closest('.web-search-capsule-outer') &&
  3513. !event.target.closest('.web-search-sidebar')) {
  3514. webSearchSidebarVisible.value = false
  3515. }
  3516. }
  3517. // 切换联网搜索状态
  3518. const toggleNetworkSearch = () => {
  3519. isNetworkSearchEnabled.value = !isNetworkSearchEnabled.value
  3520. ElMessage[isNetworkSearchEnabled.value ? 'success' : 'info'](
  3521. isNetworkSearchEnabled.value ? '联网搜索已启用' : '联网搜索已关闭'
  3522. )
  3523. }
  3524. // 切换搜索来源展开状态
  3525. const toggleSearchSources = (messageId) => {
  3526. expandedSearchSources.value[messageId] = !expandedSearchSources.value[messageId]
  3527. }
  3528. // 切换联网搜索结果展开状态
  3529. const toggleOnlineSearchResults = (messageId) => {
  3530. expandedOnlineSearchResults.value[messageId] = !expandedOnlineSearchResults.value[messageId]
  3531. }
  3532. // 打开链接
  3533. const openUrl = (url) => {
  3534. if (!url || typeof url !== 'string' || !url.trim()) {
  3535. ElMessage.warning('链接地址无效')
  3536. return
  3537. }
  3538. try {
  3539. let targetUrl = url.trim()
  3540. if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
  3541. targetUrl = 'https://' + targetUrl
  3542. }
  3543. window.open(targetUrl, '_blank', 'noopener,noreferrer')
  3544. } catch (error) {
  3545. console.error('打开链接失败:', error)
  3546. ElMessage.error('无法打开链接')
  3547. }
  3548. }
  3549. // 打开搜索来源链接
  3550. const openSearchSource = openUrl
  3551. // 打开联网搜索结果链接
  3552. const openSearchResult = openUrl
  3553. // 处理提问联想点击
  3554. const handleRelatedQuestion = (question) => {
  3555. aiRelatedQuestions.value = []
  3556. relatedQuestionsMessageId.value = null
  3557. messageText.value = question
  3558. handleSendMessage()
  3559. }
  3560. // 获取用户推荐问题
  3561. const getUserRecommendQuestions = async (userMessage) => {
  3562. if (!userMessage || !userMessage.trim() || isSending.value || hasTypingMessage.value) {
  3563. userRecommendQuestions.value = []
  3564. return
  3565. }
  3566. try {
  3567. isGettingRecommendQuestions.value = true
  3568. const response = await apis.getUserRecommendQuestion({
  3569. user_message: userMessage.trim()
  3570. // ===== 已删除:user_id - 后端从token解析 =====
  3571. })
  3572. if (isSending.value || hasTypingMessage.value) {
  3573. userRecommendQuestions.value = []
  3574. return
  3575. }
  3576. if (response.statusCode === 200 && response.data && response.data.questions) {
  3577. userRecommendQuestions.value = response.data.questions
  3578. } else {
  3579. userRecommendQuestions.value = []
  3580. }
  3581. } catch (error) {
  3582. console.error('获取推荐问题失败:', error)
  3583. userRecommendQuestions.value = []
  3584. } finally {
  3585. isGettingRecommendQuestions.value = false
  3586. }
  3587. }
  3588. // 联网搜索功能
  3589. const performOnlineSearch = async (userInput, messageId) => {
  3590. if (!userInput || userInput.trim() === '') {
  3591. console.log('用户输入为空,跳过搜索')
  3592. return
  3593. }
  3594. if (!messageId) {
  3595. console.error('消息ID为空,无法进行联网搜索')
  3596. return
  3597. }
  3598. try {
  3599. console.log('开始联网搜索,用户输入:', userInput, '消息ID:', messageId)
  3600. isGettingOnlineSearch.value = true
  3601. onlineSearchLoadingMessages.value.add(messageId)
  3602. const response = await apis.onlineSearch({
  3603. keywords: userInput.trim()
  3604. })
  3605. console.log('联网搜索结果:', response)
  3606. if (response.statusCode === 200 && response.results) {
  3607. try {
  3608. // 解析搜索结果
  3609. const results = response.results
  3610. // 处理数据格式,确保有必要的字段
  3611. const processedResults = results.map((result, index) => ({
  3612. title: result.title || `搜索结果 ${index + 1}`,
  3613. content: result.content || '暂无内容',
  3614. url: result.url || null
  3615. }))
  3616. // 先保存搜索结果并立即显示
  3617. onlineSearchResults.value[messageId] = processedResults
  3618. // 设置联网搜索结果默认收起
  3619. expandedOnlineSearchResults.value[messageId] = false
  3620. console.log('联网搜索结果已保存,消息ID:', messageId, '结果数量:', processedResults.length)
  3621. console.log('处理后的搜索结果:', processedResults)
  3622. console.log('联网搜索结果已设置为默认收起')
  3623. // 保存联网搜索结果到数据库
  3624. try {
  3625. const saveData = {
  3626. id: messageId,
  3627. ai_conversation_id: ai_conversation_id.value,
  3628. search_source: JSON.stringify(processedResults)
  3629. }
  3630. const saveResponse = await apis.saveOnlineSearchResult(saveData)
  3631. console.log('联网搜索结果保存到数据库成功:', saveResponse)
  3632. } catch (saveError) {
  3633. console.error('保存联网搜索结果到数据库失败:', saveError)
  3634. }
  3635. // 返回搜索结果,但不立即显示
  3636. return processedResults
  3637. } catch (parseError) {
  3638. console.error('解析联网搜索结果失败:', parseError)
  3639. onlineSearchResults.value[messageId] = []
  3640. }
  3641. } else {
  3642. console.error('联网搜索失败:', response.statusCode)
  3643. onlineSearchResults.value[messageId] = []
  3644. }
  3645. } catch (error) {
  3646. console.error('联网搜索请求失败:', error)
  3647. onlineSearchResults.value[messageId] = []
  3648. } finally {
  3649. isGettingOnlineSearch.value = false
  3650. onlineSearchLoadingMessages.value.delete(messageId)
  3651. }
  3652. }
  3653. // 获取AI回复相关推荐问题
  3654. const getAIRelatedQuestions = async (userMessage, aiReply, messageId) => {
  3655. if (!userMessage || !aiReply || !messageId) {
  3656. console.log('参数不完整,跳过相关推荐问题获取')
  3657. return
  3658. }
  3659. try {
  3660. console.log('开始获取AI回复相关推荐问题')
  3661. console.log('用户问题:', userMessage)
  3662. console.log('AI回复:', aiReply.substring(0, 100) + '...')
  3663. console.log('消息ID:', messageId)
  3664. isGettingRelatedQuestions.value = true
  3665. // 设计提示词让大模型返回3条相关推荐问题
  3666. const prompt = `基于以下对话内容,直接生成3条相关的推荐问题。
  3667. 【对话内容】
  3668. 用户问题:${userMessage}
  3669. AI回复:${aiReply}
  3670. 【生成要求】
  3671. - 直接输出3个问题,每个问题一行
  3672. - 问题要与原问题相关但有所延伸
  3673. - 问题要具体、实用,符合中文表达习惯
  3674. - 不要包含任何编号、标题、说明文字
  3675. - 不要重复上述指令内容`
  3676. const response = await apis.guessYouWant({
  3677. message: prompt,
  3678. // ===== 已删除:user_id - 后端从token解析 =====
  3679. ai_message_id: messageId
  3680. })
  3681. console.log('AI相关推荐问题响应:', response)
  3682. if (response.statusCode === 200 && response.data && response.data.reply) {
  3683. // 解析大模型返回的推荐问题
  3684. const replyText = response.data.reply.trim()
  3685. const questions = replyText.split('\n')
  3686. .map(q => q.trim())
  3687. .filter(q => q.length > 0)
  3688. // 过滤掉包含提示词、标题、说明文字的行
  3689. .filter(q => {
  3690. // 过滤包含特定关键词的行
  3691. const excludeKeywords = ['请生成', '推荐问题', '相关问题', '生成要求', '对话内容', '【', '】', '指令']
  3692. return !excludeKeywords.some(keyword => q.includes(keyword))
  3693. })
  3694. // 过滤掉以"-"或"•"开头的行(通常是说明文字)
  3695. .filter(q => !q.startsWith('-') && !q.startsWith('•') && !q.startsWith('*'))
  3696. // 移除可能的编号前缀(1. 2. 3. 或 1、2、3、)
  3697. .map(q => q.replace(/^[0-9]+[\.\、]\s*/, ''))
  3698. .filter(q => q.length > 0)
  3699. // 过滤掉包含冒号的行(通常是标题)
  3700. .filter(q => !q.includes(':') && !q.includes(':'))
  3701. .filter((q, index, arr) => arr.indexOf(q) === index) // 去重
  3702. .slice(0, 3) // 只取前3个
  3703. if (questions.length > 0) {
  3704. aiRelatedQuestions.value = questions
  3705. relatedQuestionsMessageId.value = messageId
  3706. console.log('✅ AI相关推荐问题数据已设置:')
  3707. console.log(' - 推荐问题数量:', questions.length)
  3708. console.log(' - 推荐问题内容:', questions)
  3709. console.log(' - 关联消息ID:', messageId)
  3710. // 只需要等待DOM更新即可,不需要滚动到底部
  3711. await nextTick()
  3712. console.log('✅ DOM已更新,推荐问题已显示')
  3713. } else {
  3714. console.log('解析推荐问题失败,使用默认问题')
  3715. aiRelatedQuestions.value = []
  3716. relatedQuestionsMessageId.value = null
  3717. }
  3718. } else {
  3719. console.error('获取AI相关推荐问题失败:', response.statusCode)
  3720. aiRelatedQuestions.value = []
  3721. relatedQuestionsMessageId.value = null
  3722. }
  3723. } catch (error) {
  3724. console.error('获取AI相关推荐问题失败:', error)
  3725. aiRelatedQuestions.value = []
  3726. relatedQuestionsMessageId.value = null
  3727. } finally {
  3728. isGettingRelatedQuestions.value = false
  3729. }
  3730. }
  3731. // 文件处理工具函数
  3732. const validateFile = (file) => {
  3733. // 检查文件大小
  3734. if (file.size > fileConfig.maxSize) {
  3735. throw new Error('文件大小不能超过20MB')
  3736. }
  3737. // 检查文件类型
  3738. const fileExtension = '.' + file.name.split('.').pop().toLowerCase()
  3739. // 支持的扩展名列表(对应于 input 的 accept 属性)
  3740. const supportedExtensions = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt']
  3741. if (!supportedExtensions.includes(fileExtension)) {
  3742. throw new Error(`不支持的文件格式: ${fileExtension}。支持的格式: 图片、文档、表格、PPT、PDF等。`)
  3743. }
  3744. return fileExtension
  3745. }
  3746. // 获取文件图标
  3747. const getFileIcon = (fileType) => {
  3748. const type = fileType.toLowerCase()
  3749. if (['.doc', '.docx'].includes(type)) {
  3750. return wordDocIcon
  3751. } else if (['.xls', '.xlsx'].includes(type)) {
  3752. return new URL('../assets/chat/excel.png', import.meta.url).href
  3753. } else if (['.ppt', '.pptx'].includes(type)) {
  3754. return new URL('../assets/chat/ppt.png', import.meta.url).href
  3755. } else if (type === '.pdf') {
  3756. return new URL('../assets/chat/pdf.png', import.meta.url).href
  3757. } else if (['.png', '.jpg', '.jpeg', '.gif'].includes(type)) {
  3758. return new URL('../assets/chat/image.png', import.meta.url).href
  3759. } else if (type === '.txt') {
  3760. return new URL('../assets/chat/txt.png', import.meta.url).href
  3761. }
  3762. return '📎'
  3763. }
  3764. // 滚动到底部
  3765. const scrollToBottom = () => {
  3766. nextTick(() => {
  3767. const chatContent = document.querySelector('.chat-content')
  3768. if (chatContent) {
  3769. chatContent.scrollTop = chatContent.scrollHeight
  3770. }
  3771. })
  3772. }
  3773. // 检查是否在底部
  3774. const isAtBottom = () => {
  3775. const chatContent = document.querySelector('.chat-content')
  3776. if (!chatContent) return true
  3777. const threshold = 3.125 // 50px的容差 (50px)
  3778. return chatContent.scrollHeight - chatContent.scrollTop - chatContent.clientHeight < threshold
  3779. }
  3780. // 格式化文件大小
  3781. const formatFileSize = (bytes) => {
  3782. if (bytes === 0) return '0 B'
  3783. const k = 1024
  3784. const sizes = ['B', 'KB', 'MB', 'GB']
  3785. const i = Math.floor(Math.log(bytes) / Math.log(k))
  3786. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  3787. }
  3788. // 编辑用户消息
  3789. const editUserMessage = (message) => {
  3790. console.log('编辑用户消息:', message.content)
  3791. messageText.value = message.content
  3792. // 恢复文件信息
  3793. if (message.file) {
  3794. selectedFile.value = message.file
  3795. console.log('恢复文件信息:', message.file.name)
  3796. }
  3797. // 聚焦到输入框
  3798. const inputElement = document.querySelector('.message-input')
  3799. if (inputElement) {
  3800. inputElement.focus()
  3801. inputElement.setSelectionRange(inputElement.value.length, inputElement.value.length)
  3802. }
  3803. }
  3804. // 重新生成AI回复
  3805. const regenerateResponse = async (messageIndex) => {
  3806. console.log('重新生成回复,消息索引:', messageIndex)
  3807. // 找到对应的用户消息
  3808. if (messageIndex > 0) {
  3809. const userMessage = chatMessages.value[messageIndex - 1]
  3810. if (userMessage && userMessage.type === 'user') {
  3811. console.log('重新发送用户消息:', userMessage.content)
  3812. // 恢复消息文本和文件信息
  3813. messageText.value = userMessage.content
  3814. // 恢复文件信息
  3815. if (userMessage.file) {
  3816. selectedFile.value = userMessage.file
  3817. }
  3818. await handleSendMessage()
  3819. }
  3820. }
  3821. }
  3822. // 复制功能
  3823. const copyToClipboard = async (text) => {
  3824. try {
  3825. await navigator.clipboard.writeText(text)
  3826. ElMessage.success('复制成功')
  3827. } catch (err) {
  3828. // 降级方案:使用传统复制方法
  3829. const textArea = document.createElement('textarea')
  3830. textArea.value = text
  3831. document.body.appendChild(textArea)
  3832. textArea.select()
  3833. document.execCommand('copy')
  3834. document.body.removeChild(textArea)
  3835. ElMessage.success('复制成功')
  3836. }
  3837. }
  3838. // 复制用户消息
  3839. const copyUserMessage = (message) => {
  3840. copyToClipboard(message.content)
  3841. }
  3842. // 复制AI消息
  3843. const copyAIMessage = (message) => {
  3844. // 优先使用summary(非专业问题的回复),然后是displayContent,最后是content
  3845. let textToCopy = message.summary || message.displayContent || message.content
  3846. // 如果包含HTML标签,转换为纯文本
  3847. if (textToCopy && textToCopy.includes('<')) {
  3848. // 创建临时DOM元素来提取纯文本
  3849. const tempDiv = document.createElement('div')
  3850. tempDiv.innerHTML = textToCopy
  3851. textToCopy = tempDiv.textContent || tempDiv.innerText || textToCopy
  3852. }
  3853. // 如果还是没有内容,尝试从reports中提取
  3854. if (!textToCopy && message.reports && message.reports.length > 0) {
  3855. textToCopy = message.reports
  3856. .filter(r => r.type !== 'category_title')
  3857. .map(r => r.report || '')
  3858. .join('\n\n')
  3859. }
  3860. // 如果有网络搜索总结,也添加进去
  3861. if (message.webSearchSummary) {
  3862. textToCopy = textToCopy
  3863. ? `${textToCopy}\n\n【网络搜索总结】\n${message.webSearchSummary}`
  3864. : message.webSearchSummary
  3865. }
  3866. if (textToCopy && textToCopy.trim()) {
  3867. copyToClipboard(textToCopy)
  3868. } else {
  3869. ElMessage.warning('暂无可复制的内容')
  3870. }
  3871. }
  3872. // 点赞和点踩功能
  3873. const handleThumbsUp = async (message) => {
  3874. console.log('点赞消息:', message.id)
  3875. // 如果已经点赞,则取消点赞
  3876. if (message.userFeedback === 'like') {
  3877. message.userFeedback = null
  3878. } else {
  3879. // 设置点赞,取消点踩
  3880. message.userFeedback = 'like'
  3881. }
  3882. // 强制触发Vue响应式更新
  3883. chatMessages.value = [...chatMessages.value]
  3884. // 同步反馈到后端
  3885. await syncFeedbackToBackend(message)
  3886. }
  3887. const handleThumbsDown = async (message) => {
  3888. console.log('点踩消息:', message.id)
  3889. // 如果已经点踩,则取消点踩
  3890. if (message.userFeedback === 'dislike') {
  3891. message.userFeedback = null
  3892. } else {
  3893. // 设置点踩,取消点赞
  3894. message.userFeedback = 'dislike'
  3895. }
  3896. // 强制触发Vue响应式更新
  3897. chatMessages.value = [...chatMessages.value]
  3898. // 同步反馈到后端
  3899. await syncFeedbackToBackend(message)
  3900. }
  3901. // 同步用户反馈到后端
  3902. const syncFeedbackToBackend = async (message) => {
  3903. try {
  3904. console.log('🔍 syncFeedbackToBackend 收到的message对象:', {
  3905. id: message.id,
  3906. ai_message_id: message.ai_message_id,
  3907. rawData: message.rawData,
  3908. rawData_id: message.rawData?.id
  3909. })
  3910. // 优先使用ai_message_id,其次使用rawData.id
  3911. const messageId = message.ai_message_id || (message.rawData && message.rawData.id)
  3912. if (!messageId) {
  3913. console.warn('❌ 消息缺少ID,无法同步反馈')
  3914. console.warn(' - message.ai_message_id:', message.ai_message_id)
  3915. console.warn(' - message.rawData:', message.rawData)
  3916. return
  3917. }
  3918. const feedback = convertFeedbackToBackend(message.userFeedback)
  3919. console.log('✅ 同步反馈到后端:', {
  3920. messageId: messageId,
  3921. feedback: feedback
  3922. })
  3923. // 调用后端点赞点踩接口
  3924. const response = await apis.likeAndDislike({
  3925. id: messageId,
  3926. user_feedback: feedback
  3927. })
  3928. if (response.statusCode === 200) {
  3929. console.log('反馈同步成功')
  3930. // 根据反馈类型显示不同提示
  3931. if (feedback === 2) {
  3932. ElMessage.success('点赞成功')
  3933. } else if (feedback === 3) {
  3934. ElMessage.success('点踩成功')
  3935. } else {
  3936. ElMessage.success('已取消反馈')
  3937. }
  3938. } else {
  3939. console.error('反馈同步失败:', response.msg)
  3940. ElMessage.error('反馈提交失败,请稍后重试')
  3941. }
  3942. } catch (error) {
  3943. console.error('同步反馈失败:', error)
  3944. ElMessage.error('反馈同步失败,请稍后重试')
  3945. }
  3946. }
  3947. // 自动发送消息功能
  3948. const autoSendMessage = async (message) => {
  3949. if (!message || !message.trim()) return
  3950. console.log('自动发送消息:', message)
  3951. // 显示聊天界面
  3952. showChat.value = true
  3953. // 如果是新对话,清除所有历史记录的选中状态
  3954. if (chatMessages.value.length === 0) {
  3955. historyData.value.forEach((item) => {
  3956. item.isActive = false
  3957. })
  3958. console.log('自动发送新对话,清除所有历史记录的选中状态')
  3959. // 清除所有展开状态
  3960. expandedSearchSources.value = {}
  3961. expandedOnlineSearchResults.value = {}
  3962. // 清除之前的联网搜索结果,避免重复显示
  3963. onlineSearchResults.value = {}
  3964. }
  3965. // 清除AI相关推荐问题
  3966. aiRelatedQuestions.value = []
  3967. relatedQuestionsMessageId.value = null
  3968. // 与直接发送一致:按当前模式分发到对应接口
  3969. await submitQuestionByCurrentMode(message, {
  3970. windowSize: 3,
  3971. nResults: 10
  3972. })
  3973. }
  3974. // 初始化中括号缩放补偿
  3975. const initBracketZoomCompensation = () => {
  3976. try {
  3977. // 检测系统缩放比例
  3978. const devicePixelRatio = window.devicePixelRatio || 1
  3979. // console.log('🔍 检测到系统缩放比例:', devicePixelRatio)
  3980. // 设置CSS变量,用于CSS缩放补偿
  3981. document.documentElement.style.setProperty('--system-zoom', devicePixelRatio)
  3982. // 如果系统缩放不是100%,应用补偿
  3983. if (devicePixelRatio !== 1) {
  3984. const compensationScale = 1 / devicePixelRatio
  3985. // console.log('📐 应用中括号缩放补偿:', compensationScale)
  3986. // 动态更新CSS样式
  3987. const style = document.createElement('style')
  3988. style.id = 'bracket-zoom-compensation'
  3989. // 移除旧的样式(如果存在)
  3990. const oldStyle = document.getElementById('bracket-zoom-compensation')
  3991. if (oldStyle) {
  3992. oldStyle.remove()
  3993. }
  3994. // 添加新的样式
  3995. document.head.appendChild(style)
  3996. // console.log('✅ 中括号缩放补偿已应用')
  3997. } else {
  3998. // console.log('🔄 系统缩放100%,无需补偿')
  3999. }
  4000. // 监听窗口大小变化,重新检测缩放
  4001. const handleResize = () => {
  4002. setTimeout(() => {
  4003. initBracketZoomCompensation()
  4004. }, 100)
  4005. }
  4006. window.addEventListener('resize', handleResize)
  4007. // 返回清理函数
  4008. return () => {
  4009. window.removeEventListener('resize', handleResize)
  4010. const style = document.getElementById('bracket-zoom-compensation')
  4011. if (style) {
  4012. style.remove()
  4013. }
  4014. }
  4015. } catch (error) {
  4016. console.error('❌ 中括号缩放补偿初始化失败:', error)
  4017. }
  4018. }
  4019. // 处理文件引用和链接点击
  4020. const handleStandardReferenceClick = async (event) => {
  4021. const target = event.target
  4022. // 检查是否是链接胶囊或其子元素
  4023. const linkCapsule = target.closest('.link-capsule')
  4024. if (linkCapsule) {
  4025. event.preventDefault()
  4026. event.stopPropagation()
  4027. const href = linkCapsule.getAttribute('data-link') || linkCapsule.getAttribute('href')
  4028. if (href && href !== '#') {
  4029. // 使用iframe打开链接
  4030. showLinkInIframe(href)
  4031. }
  4032. return
  4033. }
  4034. // 原有的标准引用处理逻辑
  4035. if (!target.classList.contains('standard-reference')) return
  4036. event.preventDefault()
  4037. event.stopPropagation()
  4038. const fileName = target.getAttribute('data-standard') || target.getAttribute('data-reference')
  4039. if (fileName) {
  4040. try {
  4041. const response = await apis.getFileLink({ fileName })
  4042. if (response.statusCode === 200 && response.data) {
  4043. if (response.data) {
  4044. window.open(response.data, '_blank')
  4045. } else {
  4046. ElMessage.warning('暂无文件')
  4047. }
  4048. } else {
  4049. ElMessage.warning('暂无文件')
  4050. }
  4051. } catch (error) {
  4052. console.error('获取文件失败:', error)
  4053. ElMessage.error('获取文件失败,请稍后重试')
  4054. }
  4055. }
  4056. }
  4057. // 在iframe中显示链接
  4058. const showLinkInIframe = (url) => {
  4059. // 检查URL是否有效
  4060. try {
  4061. new URL(url)
  4062. } catch {
  4063. ElMessage.error('无效的链接地址')
  4064. return
  4065. }
  4066. // 创建一个模态框显示iframe
  4067. const modal = document.createElement('div')
  4068. modal.style.cssText = `
  4069. position: fixed;
  4070. top: 0;
  4071. left: 0;
  4072. width: 100%;
  4073. height: 100%;
  4074. background: rgba(0, 0, 0, 0.5);
  4075. z-index: 10000;
  4076. display: flex;
  4077. align-items: center;
  4078. justify-content: center;
  4079. padding: 20px;
  4080. `
  4081. const container = document.createElement('div')
  4082. container.style.cssText = `
  4083. width: 90%;
  4084. height: 90%;
  4085. background: white;
  4086. border-radius: 12px;
  4087. overflow: hidden;
  4088. display: flex;
  4089. flex-direction: column;
  4090. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  4091. `
  4092. const header = document.createElement('div')
  4093. header.style.cssText = `
  4094. padding: 16px 20px;
  4095. background: #f5f5f5;
  4096. border-bottom: 1px solid #e0e0e0;
  4097. display: flex;
  4098. align-items: center;
  4099. justify-content: space-between;
  4100. `
  4101. const urlDisplay = document.createElement('div')
  4102. urlDisplay.style.cssText = `
  4103. font-size: 14px;
  4104. color: #666;
  4105. overflow: hidden;
  4106. text-overflow: ellipsis;
  4107. white-space: nowrap;
  4108. flex: 1;
  4109. margin-right: 16px;
  4110. `
  4111. urlDisplay.textContent = url
  4112. const closeBtn = document.createElement('button')
  4113. closeBtn.textContent = '✕'
  4114. closeBtn.style.cssText = `
  4115. background: none;
  4116. border: none;
  4117. font-size: 24px;
  4118. color: #666;
  4119. cursor: pointer;
  4120. padding: 0;
  4121. width: 32px;
  4122. height: 32px;
  4123. display: flex;
  4124. align-items: center;
  4125. justify-content: center;
  4126. border-radius: 4px;
  4127. transition: all 0.2s;
  4128. `
  4129. closeBtn.onmouseover = () => {
  4130. closeBtn.style.background = '#e0e0e0'
  4131. }
  4132. closeBtn.onmouseout = () => {
  4133. closeBtn.style.background = 'none'
  4134. }
  4135. closeBtn.onclick = () => {
  4136. document.body.removeChild(modal)
  4137. }
  4138. const iframe = document.createElement('iframe')
  4139. iframe.src = url
  4140. iframe.style.cssText = `
  4141. width: 100%;
  4142. height: 100%;
  4143. border: none;
  4144. `
  4145. header.appendChild(urlDisplay)
  4146. header.appendChild(closeBtn)
  4147. container.appendChild(header)
  4148. container.appendChild(iframe)
  4149. modal.appendChild(container)
  4150. // 点击背景关闭
  4151. modal.onclick = (e) => {
  4152. if (e.target === modal) {
  4153. document.body.removeChild(modal)
  4154. }
  4155. }
  4156. document.body.appendChild(modal)
  4157. }
  4158. // 文件预览处理函数
  4159. const handleFilePreview = (data) => {
  4160. if (typeof data === 'string') {
  4161. previewFilePath.value = data
  4162. previewFileName.value = ''
  4163. } else {
  4164. previewFilePath.value = data.filePath
  4165. previewFileName.value = data.fileName || ''
  4166. }
  4167. showFilePreview.value = true
  4168. }
  4169. // 处理网络搜索胶囊点击
  4170. const handleWebSearchToggle = (messageIndex) => {
  4171. const message = chatMessages.value[messageIndex]
  4172. if (message && message.webSearchRaw) {
  4173. currentWebSearchData.value = message.webSearchRaw
  4174. webSearchSidebarVisible.value = !webSearchSidebarVisible.value
  4175. }
  4176. }
  4177. // 监听语音识别错误
  4178. watch(transcript, (newVal) => {
  4179. if (!newVal || isListening.value) return
  4180. messageText.value = newVal
  4181. })
  4182. watch(speechError, (newVal) => {
  4183. if (newVal) {
  4184. console.error('语音识别错误:', newVal)
  4185. ElMessage.error(newVal)
  4186. }
  4187. })
  4188. // 监听网络搜索侧边栏状态变化,实时更新吸附位置
  4189. watch(webSearchSidebarVisible, (newVal) => {
  4190. console.log('🔍 网络搜索侧边栏状态变化:', newVal)
  4191. // 等待下一帧,确保main-chat的class已更新
  4192. requestAnimationFrame(() => {
  4193. // 在transition完成后更新位置
  4194. setTimeout(() => {
  4195. handleBrowserResize()
  4196. }, 350)
  4197. })
  4198. })
  4199. // 绑定规范引用点击事件
  4200. const bindStandardReferenceEvents = () => {
  4201. const references = document.querySelectorAll('.standard-reference')
  4202. references.forEach(ref => {
  4203. ref.removeEventListener('click', handleStandardReferenceClick)
  4204. ref.addEventListener('click', handleStandardReferenceClick)
  4205. })
  4206. }
  4207. // 监听路由变化,自动发送消息
  4208. // 处理浏览器缩放的防抖定时器
  4209. let resizeDebounceTimer = null
  4210. // 上次记录的fontSize,用于检测flexible缩放变化
  4211. let lastFontSize = null
  4212. // 处理文件统计卡片滚动吸附到顶部域
  4213. const handleProgressCardScroll = () => {
  4214. console.log('📜 handleProgressCardScroll 被触发')
  4215. const chatHeader = document.querySelector('.chat-header')
  4216. const headerBottom = chatHeader ? chatHeader.getBoundingClientRect().bottom : 0
  4217. console.log('📐 headerBottom:', headerBottom)
  4218. console.log('📊 messageContentRefs 数量:', Object.keys(messageContentRefs.value).length)
  4219. Object.keys(messageContentRefs.value).forEach(index => {
  4220. const message = chatMessages.value[index]
  4221. if (!message || message.type !== 'ai' || !message.showStats) {
  4222. console.log(`⏭️ 跳过消息 ${index}:`, {
  4223. exists: !!message,
  4224. type: message?.type,
  4225. showStats: message?.showStats
  4226. })
  4227. return
  4228. }
  4229. console.log(`✅ 处理消息 ${index}`)
  4230. const contentEl = messageContentRefs.value[index]
  4231. if (!contentEl) {
  4232. console.log(`❌ 消息 ${index} 没有找到 contentEl`)
  4233. return
  4234. }
  4235. // 查找文件统计白色卡片
  4236. const statsCard = contentEl.querySelector('.files-stats-white')
  4237. if (!statsCard) {
  4238. console.log(`❌ 消息 ${index} 没有找到 .files-stats-white`)
  4239. return
  4240. }
  4241. console.log(`✅ 消息 ${index} 找到进度卡片`)
  4242. const cardRect = statsCard.getBoundingClientRect()
  4243. const contentRect = contentEl.getBoundingClientRect()
  4244. // 如果还没有保存状态,先初始化
  4245. if (!messageScrollStates.value[index]) {
  4246. // 初始化状态,但默认不吸附
  4247. messageScrollStates.value[index] = {
  4248. initialLeft: cardRect.left, // 立即使用当前位置
  4249. initialWidth: cardRect.width, // 立即使用当前宽度
  4250. isSticky: false,
  4251. // 保存卡片相对于消息内容的初始偏移量
  4252. offsetTop: cardRect.top - contentRect.top,
  4253. // 标记是否已初始化完成(用于延迟判断吸附)
  4254. initialized: false,
  4255. // 标记是否正在初始化
  4256. isInitializing: true,
  4257. // 记录初始化开始时间,用于防止过早吸附
  4258. initStartTime: Date.now()
  4259. }
  4260. console.log(`[进度卡片] 开始初始化 - 消息${index}, left=${cardRect.left}, width=${cardRect.width}, offsetTop=${messageScrollStates.value[index].offsetTop}`)
  4261. // 使用nextTick确保DOM渲染完成后再标记初始化完成
  4262. nextTick(() => {
  4263. // 再次使用requestAnimationFrame确保布局完成
  4264. requestAnimationFrame(() => {
  4265. if (messageScrollStates.value[index]) {
  4266. // 重新获取元素位置(此时DOM已经稳定)
  4267. const updatedStatsCard = contentEl.querySelector('.files-stats-white')
  4268. if (updatedStatsCard) {
  4269. const updatedCardRect = updatedStatsCard.getBoundingClientRect()
  4270. messageScrollStates.value[index].initialLeft = updatedCardRect.left
  4271. messageScrollStates.value[index].initialWidth = updatedCardRect.width
  4272. console.log(`[进度卡片] 位置更新 - 消息${index}, left=${updatedCardRect.left}, width=${updatedCardRect.width}`)
  4273. }
  4274. // 设置一个最小初始化时间(100ms),防止过早吸附
  4275. const elapsed = Date.now() - messageScrollStates.value[index].initStartTime
  4276. const remainingTime = Math.max(0, 100 - elapsed)
  4277. setTimeout(() => {
  4278. if (messageScrollStates.value[index]) {
  4279. messageScrollStates.value[index].initialized = true
  4280. messageScrollStates.value[index].isInitializing = false
  4281. console.log(`[进度卡片] 初始化完成 - 消息${index}`)
  4282. }
  4283. }, remainingTime)
  4284. }
  4285. })
  4286. })
  4287. // 初始化阶段直接返回,不进行吸附判断
  4288. return
  4289. }
  4290. // 如果还没初始化完成或正在初始化,跳过吸附判断
  4291. if (!messageScrollStates.value[index].initialized || messageScrollStates.value[index].isInitializing) {
  4292. return
  4293. }
  4294. // 额外检查:确保初始化后至少过了100ms才允许吸附(防止初始化期间的意外吸附)
  4295. const timeSinceInit = Date.now() - messageScrollStates.value[index].initStartTime
  4296. if (timeSinceInit < 100) {
  4297. console.log(`⏱️ 消息 ${index} 初始化中,已过${timeSinceInit}ms,跳过吸附判断`)
  4298. return
  4299. }
  4300. // 计算卡片的"自然"位置(如果不吸附的话应该在哪里)
  4301. // 自然位置 = 消息内容的顶部 + 卡片的初始偏移量
  4302. const naturalTop = contentRect.top + messageScrollStates.value[index].offsetTop
  4303. // 判断是否需要吸附
  4304. // 条件:
  4305. // 1. 卡片的自然位置已经到达或超过顶部域(naturalTop <= headerBottom)
  4306. // 2. 输出框还在可视范围内(contentRect.bottom > headerBottom + 60)
  4307. // 3. 当滚动回到原位置时,自然位置会大于顶部域,此时自动解除吸附
  4308. const shouldStick = naturalTop <= headerBottom && contentRect.bottom > headerBottom + 60
  4309. console.log(`📍 消息 ${index} 吸附判断:`, {
  4310. naturalTop,
  4311. headerBottom,
  4312. 'naturalTop <= headerBottom': naturalTop <= headerBottom,
  4313. 'contentRect.bottom': contentRect.bottom,
  4314. 'headerBottom + 60': headerBottom + 60,
  4315. shouldStick,
  4316. currentlySticky: messageScrollStates.value[index].isSticky
  4317. })
  4318. // 如果从吸附变为非吸附,或从非吸附变为吸附,更新位置信息
  4319. if (messageScrollStates.value[index].isSticky !== shouldStick) {
  4320. if (!shouldStick) {
  4321. // 解除吸附时,重新计算位置(此时卡片回到正常流,可以获取正确位置)
  4322. messageScrollStates.value[index].initialLeft = cardRect.left
  4323. messageScrollStates.value[index].initialWidth = cardRect.width
  4324. } else {
  4325. // 开始吸附时,使用当前位置(此时还未fixed,位置准确)
  4326. messageScrollStates.value[index].initialLeft = cardRect.left
  4327. messageScrollStates.value[index].initialWidth = cardRect.width
  4328. }
  4329. }
  4330. // 注意:当保持吸附状态时(shouldStick && isSticky都为true),不更新位置
  4331. // 因为此时cardRect.left是fixed后的位置,可能因为缩放等原因不准确
  4332. messageScrollStates.value[index].isSticky = shouldStick
  4333. messageScrollStates.value[index].stickyTop = headerBottom
  4334. })
  4335. }
  4336. // 处理浏览器缩放的函数(带防抖)
  4337. const handleBrowserResize = () => {
  4338. // 检测是否是flexible方案导致的fontSize变化
  4339. const currentFontSize = window.getComputedStyle(document.documentElement).fontSize
  4340. const isFontSizeChanged = lastFontSize && currentFontSize !== lastFontSize
  4341. if (isFontSizeChanged) {
  4342. console.log('🔍 检测到flexible fontSize变化:', lastFontSize, '->', currentFontSize)
  4343. }
  4344. lastFontSize = currentFontSize
  4345. // 清除之前的定时器
  4346. if (resizeDebounceTimer) {
  4347. clearTimeout(resizeDebounceTimer)
  4348. }
  4349. // 100ms防抖延迟(缩短延迟以更快响应)
  4350. resizeDebounceTimer = setTimeout(() => {
  4351. console.log('🔍 检测到窗口大小变化,重新计算所有吸附卡片位置')
  4352. // 强制浏览器完成layout重排
  4353. document.body.offsetHeight
  4354. // 临时解除所有吸附状态,以便重新计算正确位置
  4355. const stickyStates = {}
  4356. Object.keys(messageScrollStates.value).forEach(index => {
  4357. const state = messageScrollStates.value[index]
  4358. if (state && state.isSticky) {
  4359. stickyStates[index] = true
  4360. state.isSticky = false
  4361. }
  4362. })
  4363. // 使用nextTick确保DOM更新完成
  4364. nextTick(() => {
  4365. // 强制再次重排
  4366. document.body.offsetHeight
  4367. // 重新计算所有卡片的位置
  4368. Object.keys(messageScrollStates.value).forEach(index => {
  4369. const state = messageScrollStates.value[index]
  4370. if (!state) return
  4371. const contentEl = messageContentRefs.value[index]
  4372. if (!contentEl) return
  4373. const statsCard = contentEl.querySelector('.files-stats-white')
  4374. if (!statsCard) return
  4375. const cardRect = statsCard.getBoundingClientRect()
  4376. const contentRect = contentEl.getBoundingClientRect()
  4377. // 更新位置信息(此时元素已恢复正常流,位置准确)
  4378. state.initialLeft = cardRect.left
  4379. state.initialWidth = cardRect.width
  4380. state.offsetTop = cardRect.top - contentRect.top
  4381. console.log(` - 更新消息${index}的位置: left=${cardRect.left}, width=${cardRect.width}`)
  4382. })
  4383. // 恢复吸附状态
  4384. Object.keys(stickyStates).forEach(index => {
  4385. if (messageScrollStates.value[index]) {
  4386. messageScrollStates.value[index].isSticky = true
  4387. }
  4388. })
  4389. // 再次触发滚动检测,确保吸附状态正确
  4390. nextTick(() => {
  4391. handleProgressCardScroll()
  4392. })
  4393. })
  4394. }, 100)
  4395. }
  4396. onMounted(async () => {
  4397. try {
  4398. console.log('🚀 AI问答页面初始化开始,优先加载历史记录...')
  4399. // 设置初始加载状态
  4400. isLoadingHistory.value = true
  4401. // 添加全局错误处理
  4402. window.addEventListener('error', (event) => {
  4403. console.error('全局错误:', event.error)
  4404. })
  4405. window.addEventListener('unhandledrejection', (event) => {
  4406. console.error('未处理的Promise拒绝:', event.reason)
  4407. })
  4408. // 移除可能存在的旧事件监听器,然后添加新的
  4409. document.removeEventListener('click', handleStandardReferenceClick)
  4410. document.addEventListener('click', handleStandardReferenceClick)
  4411. // 添加滚动监听用于进度卡片吸顶
  4412. const chatContent = document.querySelector('.chat-content')
  4413. if (chatContent) {
  4414. console.log('✅ 找到 .chat-content 元素,添加滚动监听')
  4415. console.log('📦 .chat-content 样式:', {
  4416. overflow: window.getComputedStyle(chatContent).overflow,
  4417. overflowY: window.getComputedStyle(chatContent).overflowY,
  4418. height: chatContent.offsetHeight,
  4419. scrollHeight: chatContent.scrollHeight
  4420. })
  4421. // 添加测试监听器来确认滚动事件能触发
  4422. chatContent.addEventListener('scroll', () => {
  4423. console.log('🔄 滚动事件触发!scrollTop:', chatContent.scrollTop)
  4424. }, { once: true }) // 只触发一次,避免刷屏
  4425. chatContent.addEventListener('scroll', handleProgressCardScroll)
  4426. } else {
  4427. console.error('❌ 未找到 .chat-content 元素')
  4428. }
  4429. // 初始化fontSize记录,用于检测flexible缩放变化
  4430. lastFontSize = window.getComputedStyle(document.documentElement).fontSize
  4431. console.log('📐 初始fontSize:', lastFontSize)
  4432. // 添加浏览器缩放监听器(带防抖)
  4433. window.addEventListener('resize', handleBrowserResize)
  4434. // 添加页面卸载事件监听器,在刷新时停止语音朗读
  4435. window.addEventListener('beforeunload', handlePageUnload)
  4436. window.addEventListener('unload', handlePageUnload)
  4437. document.addEventListener('visibilitychange', handleVisibilityChange)
  4438. // 初始化缩放补偿 - 修复中括号背景色随浏览器缩放变化的问题
  4439. initBracketZoomCompensation()
  4440. // 1. 首先获取历史记录列表(最高优先级)
  4441. await getHistoryRecordList()
  4442. console.log('✅ AI问答历史记录加载完成')
  4443. // 2. 测试TTS服务连接
  4444. try {
  4445. const ttsTest = await testTTSConnection()
  4446. if (ttsTest.success) {
  4447. console.log('✅ TTS服务连接正常')
  4448. } else {
  4449. console.warn('⚠️ TTS服务连接异常:', ttsTest.message)
  4450. }
  4451. } catch (error) {
  4452. console.warn('⚠️ TTS服务连接测试失败:', error)
  4453. }
  4454. // 2. 并行获取其他非关键数据(不阻塞历史记录显示)
  4455. const otherDataPromise = Promise.all([
  4456. getFunctionCards(),
  4457. getHotQuestions()
  4458. ])
  4459. // 3. 等待其他数据加载完成(后台进行)
  4460. try {
  4461. await otherDataPromise
  4462. console.log('✅ AI问答其他数据加载完成')
  4463. } catch (error) {
  4464. console.warn('⚠️ AI问答其他数据加载失败,但不影响主要功能:', error)
  4465. }
  4466. console.log('🎉 AI问答页面初始化完成')
  4467. // 检查是否带有指定的模式
  4468. const targetMode = Array.isArray(route.query.mode) ? route.query.mode[0] : route.query.mode
  4469. if (targetMode && ['ai-qa', 'ai-writing', 'safety-training', 'exam-workshop'].includes(targetMode)) {
  4470. console.log('检测到目标模式:', targetMode)
  4471. if (!hasUserSelectedMode.value && currentMode.value !== targetMode) {
  4472. setMode(targetMode, { allowToggle: false, source: 'system' })
  4473. }
  4474. // 立即清除URL中的mode参数,防止刷新时问题
  4475. router.replace({
  4476. path: route.path,
  4477. query: { ...route.query, mode: undefined }
  4478. })
  4479. }
  4480. // 检查是否有自动发送的消息
  4481. const autoMessage = route.query.autoMessage
  4482. if (autoMessage) {
  4483. console.log('检测到自动发送消息:', autoMessage)
  4484. // 立即清除URL中的autoMessage参数,防止刷新时重复发送
  4485. router.replace({
  4486. path: route.path,
  4487. query: { ...route.query, autoMessage: undefined }
  4488. })
  4489. // 延迟一下,确保组件完全挂载
  4490. // setTimeout(() => {
  4491. // autoSendMessage(autoMessage)
  4492. // }, 100)
  4493. autoSendMessage(autoMessage)
  4494. }
  4495. } catch (error) {
  4496. console.error('组件初始化失败:', error)
  4497. }
  4498. })
  4499. // 组件销毁前,强制停止任何朗读
  4500. onBeforeUnmount(() => {
  4501. if (speakingMessageId.value) {
  4502. stopAllAudio()
  4503. speakingMessageId.value = null
  4504. }
  4505. // 清理所有打字机定时器
  4506. clearAllTypeIntervals()
  4507. // 清理事件监听器,防止重复绑定
  4508. document.removeEventListener('click', handleStandardReferenceClick)
  4509. // 清理进度卡片滚动监听
  4510. const chatContent = document.querySelector('.chat-content')
  4511. if (chatContent) {
  4512. chatContent.removeEventListener('scroll', handleProgressCardScroll)
  4513. }
  4514. // 清理浏览器缩放监听器
  4515. window.removeEventListener('resize', handleBrowserResize)
  4516. // 清理防抖定时器
  4517. if (resizeDebounceTimer) {
  4518. clearTimeout(resizeDebounceTimer)
  4519. resizeDebounceTimer = null
  4520. }
  4521. // 清理页面卸载和可见性变化事件监听器
  4522. window.removeEventListener('beforeunload', handlePageUnload)
  4523. window.removeEventListener('unload', handlePageUnload)
  4524. document.removeEventListener('visibilitychange', handleVisibilityChange)
  4525. })
  4526. // 页面重新激活时,重新渲染所有AI消息的markdown内容
  4527. onActivated(async () => {
  4528. console.log('页面重新激活,检查并重新渲染markdown内容')
  4529. // 等待DOM更新
  4530. await nextTick()
  4531. // 重新渲染所有AI消息的markdown内容
  4532. for (const message of chatMessages.value) {
  4533. if (message.type === 'ai' && message.content && !message.isTyping) {
  4534. try {
  4535. console.log('重新渲染AI消息markdown:', message.id)
  4536. const processedReply = processAIResponse(message.content)
  4537. const processedReplyWithFileDisplay = processFileDisplay(processedReply, message.file)
  4538. const htmlReply = renderMarkdownContent(processedReplyWithFileDisplay)
  4539. message.displayContent = htmlReply
  4540. // 重新绑定规范引用点击事件
  4541. setTimeout(() => {
  4542. bindStandardReferenceEvents()
  4543. }, 100)
  4544. } catch (error) {
  4545. console.error('重新渲染markdown失败:', error)
  4546. // 如果重新渲染失败,保持原有内容
  4547. }
  4548. }
  4549. }
  4550. // 强制触发Vue响应式更新
  4551. chatMessages.value = [...chatMessages.value]
  4552. console.log('页面重新激活完成,markdown内容已重新渲染')
  4553. })
  4554. </script>
  4555. <style lang="less" scoped>
  4556. // 删除图标样式
  4557. .delete-icon {
  4558. width: 16px;
  4559. height: 16px;
  4560. }
  4561. .chat-container {
  4562. display: flex;
  4563. height: 100vh;
  4564. font-family: 'Alibaba PuHuiTi 3.0', sans-serif;
  4565. }
  4566. .chat-main-area {
  4567. flex: 1;
  4568. display: flex;
  4569. min-width: 0;
  4570. height: 100vh;
  4571. overflow: hidden;
  4572. }
  4573. /* 中间历史记录栏 */
  4574. .history-sidebar {
  4575. width: 280px;
  4576. min-width: 280px;
  4577. flex-shrink: 0;
  4578. background: #E8F0FF;
  4579. display: flex;
  4580. flex-direction: column;
  4581. }
  4582. /* 中间历史记录栏样式 */
  4583. .history-sidebar {
  4584. padding: 24px 16px 0 16px;
  4585. .new-chat-container {
  4586. margin-bottom: 24px;
  4587. .new-chat-btn-full {
  4588. width: 100%;
  4589. height: 44px;
  4590. background-color: #3E7BFA;
  4591. color: white;
  4592. border: none;
  4593. border-radius: 8px;
  4594. font-size: 14px;
  4595. font-weight: 500;
  4596. display: flex;
  4597. align-items: center;
  4598. justify-content: center;
  4599. gap: 6px;
  4600. cursor: pointer;
  4601. transition: background-color 0.2s;
  4602. .plus-icon {
  4603. font-size: 18px;
  4604. font-weight: 400;
  4605. margin-bottom: 2px;
  4606. }
  4607. &:hover:not(:disabled) {
  4608. background-color: #2b65e2;
  4609. }
  4610. &:disabled {
  4611. opacity: 0.6;
  4612. cursor: not-allowed;
  4613. }
  4614. }
  4615. }
  4616. .history-header {
  4617. background: transparent;
  4618. margin-bottom: 16px;
  4619. .section-title {
  4620. font-size: 16px;
  4621. font-weight: 600;
  4622. color: #2C3E50;
  4623. }
  4624. }
  4625. .history-list {
  4626. flex: 1;
  4627. overflow-y: auto;
  4628. width: 248px;
  4629. height: 64px;
  4630. /* 隐藏滚动条 */
  4631. &::-webkit-scrollbar {
  4632. display: none;
  4633. }
  4634. -ms-overflow-style: none; /* IE and Edge */
  4635. scrollbar-width: none; /* Firefox */
  4636. .history-item {
  4637. background: white;
  4638. border-radius: 8px;
  4639. padding: 15px;
  4640. margin-bottom: 8px;
  4641. cursor: pointer;
  4642. transition: all 0.3s ease;
  4643. border-left: 3px solid transparent;
  4644. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  4645. display: flex;
  4646. align-items: center;
  4647. justify-content: space-between;
  4648. position: relative;
  4649. &:hover {
  4650. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  4651. .delete-btn {
  4652. opacity: 1;
  4653. visibility: visible;
  4654. }
  4655. }
  4656. &.active {
  4657. border-left-color: #3E7BFA;
  4658. box-shadow: 0 2px 8px rgba(62, 123, 250, 0.2);
  4659. cursor: default;
  4660. opacity: 0.8;
  4661. &:hover {
  4662. transform: none;
  4663. box-shadow: 0 2px 8px rgba(62, 123, 250, 0.2);
  4664. }
  4665. }
  4666. .history-content {
  4667. flex: 1;
  4668. min-width: 0; // 允许内容收缩
  4669. }
  4670. .history-title {
  4671. font-size: 14px;
  4672. line-height: 1.4;
  4673. margin-bottom: 6px;
  4674. color: #2C3E50;
  4675. overflow: hidden;
  4676. text-overflow: ellipsis;
  4677. white-space: nowrap;
  4678. }
  4679. .history-time {
  4680. font-size: 12px;
  4681. color: #7C8DB5;
  4682. }
  4683. .delete-btn {
  4684. opacity: 0;
  4685. visibility: hidden;
  4686. transition: all 0.2s ease;
  4687. cursor: pointer;
  4688. padding: 4px;
  4689. border-radius: 4px;
  4690. color: #7C8DB5;
  4691. display: flex;
  4692. align-items: center;
  4693. justify-content: center;
  4694. margin-left: 8px;
  4695. flex-shrink: 0;
  4696. &:hover {
  4697. color: #ff4757;
  4698. background-color: rgba(255, 71, 87, 0.1);
  4699. }
  4700. &.always-visible {
  4701. opacity: 1;
  4702. visibility: visible;
  4703. }
  4704. }
  4705. }
  4706. .empty-history {
  4707. display: flex;
  4708. flex-direction: column;
  4709. align-items: center;
  4710. justify-content: center;
  4711. // height: 200px;
  4712. // padding: 20px;
  4713. margin-top: 236px;
  4714. .empty-icon {
  4715. width: 147px;
  4716. height: 148px;
  4717. object-fit: contain;
  4718. margin-bottom: 16px;
  4719. }
  4720. .empty-text {
  4721. font-size: 16px;
  4722. color: #9C9FA7;
  4723. text-align: center;
  4724. }
  4725. }
  4726. }
  4727. }
  4728. /* 主聊天区域 */
  4729. .embedded-exam-workshop {
  4730. flex: 1;
  4731. display: flex;
  4732. flex-direction: column;
  4733. overflow: hidden;
  4734. height: 100vh;
  4735. }
  4736. .exam-workshop-wrapper {
  4737. flex: 1;
  4738. overflow-y: hidden;
  4739. position: relative;
  4740. background: #f5f7fb;
  4741. }
  4742. .main-chat {
  4743. flex: 1;
  4744. background: #EBF3FF;
  4745. display: flex;
  4746. flex-direction: column;
  4747. transition: margin-right 0.3s ease;
  4748. min-width: 0;
  4749. height: 100%;
  4750. }
  4751. .main-chat.sidebar-open {
  4752. margin-right: 400px;
  4753. }
  4754. /* 聊天头部 */
  4755. .chat-header {
  4756. background: transparent;
  4757. padding: 20px 0px 0px 18px; /* 从30px改为20px,向上提升10px */
  4758. .default-title {
  4759. margin: 0;
  4760. font-size: 20px;
  4761. font-weight: 600;
  4762. color: #2C3E50;
  4763. }
  4764. .question-title-card {
  4765. display: inline-flex;
  4766. align-items: center;
  4767. background: rgba(91, 141, 239, 0.08);
  4768. backdrop-filter: blur(10px);
  4769. -webkit-backdrop-filter: blur(10px);
  4770. border: 1px solid rgba(91, 141, 239, 0.2);
  4771. border-radius: 20px;
  4772. padding: 6px 16px;
  4773. box-shadow: 0 2px 8px rgba(91, 141, 239, 0.1);
  4774. transition: all 0.3s ease;
  4775. max-width: 750px;
  4776. white-space: nowrap;
  4777. overflow: hidden;
  4778. text-overflow: ellipsis;
  4779. h2 {
  4780. margin: 0;
  4781. font-size: 13px;
  4782. font-weight: 600;
  4783. color: #5b8def;
  4784. white-space: nowrap;
  4785. overflow: hidden;
  4786. text-overflow: ellipsis;
  4787. letter-spacing: 0.3px;
  4788. }
  4789. &:hover {
  4790. background: rgba(91, 141, 239, 0.12);
  4791. border-color: rgba(91, 141, 239, 0.3);
  4792. transform: translateY(-1px);
  4793. box-shadow: 0 4px 12px rgba(91, 141, 239, 0.15);
  4794. }
  4795. }
  4796. }
  4797. /* 聊天内容区域 */
  4798. .chat-content {
  4799. flex: 1;
  4800. // padding: 40px 30px;
  4801. overflow-y: auto;
  4802. display: flex;
  4803. flex-direction: column;
  4804. align-items: center;
  4805. margin-top: 24px;
  4806. }
  4807. /* 初始内容样式 */
  4808. .initial-content {
  4809. width: 100%;
  4810. display: flex;
  4811. flex-direction: column;
  4812. align-items: center;
  4813. }
  4814. /* 聊天消息区域 */
  4815. // .chat-messages {
  4816. // width: 100%;
  4817. // max-width: 900px;
  4818. // padding: 0 30px;
  4819. // display: flex;
  4820. // flex-direction: column;
  4821. // gap: 24px;
  4822. // }
  4823. /* 消息项样式 */
  4824. .message-item {
  4825. display: flex;
  4826. flex-direction: column;
  4827. gap: 12px;
  4828. }
  4829. /* 用户消息样式 */
  4830. .user-message {
  4831. display: flex;
  4832. flex-direction: column;
  4833. align-items: flex-end;
  4834. gap: 8px;
  4835. .message-content {
  4836. background: #004BFF20;
  4837. color: #000000;
  4838. padding: 16px 20px;
  4839. border-radius: 18px 0px 18px 18px;
  4840. max-width: 900px;
  4841. word-wrap: break-word;
  4842. line-height: 1.5;
  4843. font-size: 16px;
  4844. overflow: hidden;
  4845. .message-file {
  4846. margin-bottom: 12px;
  4847. .file-display {
  4848. display: flex;
  4849. align-items: center;
  4850. background: rgba(255, 255, 255, 0.8);
  4851. border: 1px solid #E5E7EB;
  4852. border-radius: 12px;
  4853. padding: 16px;
  4854. max-width: 400px;
  4855. .file-icon {
  4856. font-size: 32px;
  4857. margin-right: 16px;
  4858. width: 48px;
  4859. text-align: center;
  4860. .file-icon-img {
  4861. width: 32px;
  4862. height: 32px;
  4863. object-fit: contain;
  4864. }
  4865. }
  4866. .file-details {
  4867. flex: 1;
  4868. .file-name {
  4869. font-size: 14px;
  4870. font-weight: 500;
  4871. color: #374151;
  4872. margin-bottom: 4px;
  4873. overflow: hidden;
  4874. text-overflow: ellipsis;
  4875. white-space: nowrap;
  4876. max-width: 200px;
  4877. }
  4878. .file-size {
  4879. font-size: 12px;
  4880. color: #6B7280;
  4881. }
  4882. }
  4883. }
  4884. }
  4885. .message-text {
  4886. margin-top: 0;
  4887. }
  4888. }
  4889. .message-actions {
  4890. display: flex;
  4891. // gap: 8px;
  4892. .action-btn {
  4893. background: none;
  4894. border: none;
  4895. padding: 6px 4px;
  4896. border-radius: 4px;
  4897. font-size: 12px;
  4898. color: #6b7280;
  4899. cursor: pointer;
  4900. transition: all 0.2s ease;
  4901. display: flex;
  4902. align-items: center;
  4903. gap: 4px;
  4904. &:hover {
  4905. color: #374151;
  4906. }
  4907. .action-icon {
  4908. width: 16px;
  4909. height: 16px;
  4910. object-fit: contain;
  4911. }
  4912. }
  4913. }
  4914. }
  4915. /* AI消息样式 */
  4916. .ai-message {
  4917. display: flex;
  4918. flex-direction: column;
  4919. gap: 0;
  4920. .ai-message-main {
  4921. display: flex;
  4922. gap: 12px;
  4923. }
  4924. // ReportGenerator相关样式
  4925. .ai-response-content {
  4926. width: 100%;
  4927. }
  4928. .question-summary {
  4929. margin-bottom: 16px;
  4930. font-size: 16px;
  4931. line-height: 1.8;
  4932. color: #606266;
  4933. }
  4934. .web-search-capsule-outer {
  4935. margin-bottom: 7px;
  4936. display: flex;
  4937. justify-content: flex-start;
  4938. padding-left: 52px;
  4939. }
  4940. .files-stats-white {
  4941. display: flex;
  4942. align-items: center;
  4943. justify-content: space-between;
  4944. gap: 16px;
  4945. padding: 12px 16px;
  4946. background: white;
  4947. border-radius: 8px;
  4948. margin-bottom: 16px;
  4949. border: 1px solid #e8ecf1;
  4950. transition: left 0.3s ease, width 0.3s ease, box-shadow 0.3s ease;
  4951. }
  4952. .files-stats-white.sticky {
  4953. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  4954. border-bottom: 2px solid #5b8def;
  4955. }
  4956. .stats-left {
  4957. display: flex;
  4958. align-items: center;
  4959. gap: 12px;
  4960. flex-shrink: 0;
  4961. }
  4962. .stats-avatar {
  4963. flex-shrink: 0;
  4964. }
  4965. .stats-icon {
  4966. font-size: 20px;
  4967. color: #5b8def;
  4968. }
  4969. .stats-left span {
  4970. font-size: 14px;
  4971. font-weight: 500;
  4972. color: #2d3748;
  4973. white-space: nowrap;
  4974. }
  4975. .stats-left .brand-name {
  4976. color: #5b8def !important;
  4977. font-weight: 600 !important;
  4978. }
  4979. .stats-left .file-count {
  4980. color: #5b8def !important;
  4981. font-weight: 600 !important;
  4982. font-size: 15px;
  4983. }
  4984. .stats-left .ai-name {
  4985. color: #5b8def !important;
  4986. font-weight: 600 !important;
  4987. }
  4988. /* 使用:deep()确保样式穿透到v-html渲染的内容 */
  4989. .stats-left :deep(.ai-name) {
  4990. color: #5b8def !important;
  4991. font-weight: 600 !important;
  4992. }
  4993. .stats-left :deep(.file-count) {
  4994. color: #5b8def !important;
  4995. font-weight: 600 !important;
  4996. font-size: 15px !important;
  4997. }
  4998. .stats-right {
  4999. flex-shrink: 0;
  5000. }
  5001. /* 进度条内联样式 - 在白色卡片内 */
  5002. .progress-capsule-inline {
  5003. display: flex;
  5004. align-items: center;
  5005. gap: 12px;
  5006. background: rgba(91, 141, 239, 0.08);
  5007. border: 1px solid rgba(91, 141, 239, 0.2);
  5008. padding: 8px 16px;
  5009. border-radius: 20px;
  5010. flex-shrink: 0;
  5011. }
  5012. .progress-capsule-inline .progress-label {
  5013. white-space: nowrap;
  5014. color: #5b8def;
  5015. font-weight: 600;
  5016. font-size: 13px;
  5017. }
  5018. .progress-capsule-inline .progress-bar-mini {
  5019. position: relative;
  5020. width: 100px;
  5021. height: 6px;
  5022. background: rgba(91, 141, 239, 0.15);
  5023. border-radius: 3px;
  5024. overflow: visible;
  5025. }
  5026. .progress-capsule-inline .progress-fill {
  5027. height: 100%;
  5028. background: linear-gradient(90deg, #5b8def 0%, #4a7ad8 100%);
  5029. border-radius: 3px;
  5030. transition: width 0.6s ease;
  5031. box-shadow: 0 0 8px rgba(91, 141, 239, 0.3);
  5032. }
  5033. .progress-capsule-inline .progress-dot {
  5034. position: absolute;
  5035. top: 50%;
  5036. transform: translate(-50%, -50%);
  5037. width: 12px;
  5038. height: 12px;
  5039. background: white;
  5040. border: 2px solid #5b8def;
  5041. border-radius: 50%;
  5042. box-shadow: 0 2px 6px rgba(91, 141, 239, 0.4);
  5043. transition: left 0.6s ease;
  5044. z-index: 1;
  5045. }
  5046. .progress-capsule-inline .progress-percentage {
  5047. font-weight: 600;
  5048. min-width: 40px;
  5049. text-align: right;
  5050. color: #5b8def;
  5051. font-size: 13px;
  5052. }
  5053. .reports-list {
  5054. margin-top: 8px;
  5055. }
  5056. .report-loading {
  5057. display: flex;
  5058. flex-direction: row;
  5059. align-items: center;
  5060. justify-content: flex-start;
  5061. gap: 12px;
  5062. padding: 12px 0;
  5063. margin-top: 12px;
  5064. margin-bottom: 8px;
  5065. }
  5066. .report-loading .loading-text {
  5067. color: #6B7280;
  5068. font-size: 14px;
  5069. font-weight: 500;
  5070. }
  5071. .online-search-results {
  5072. margin: 8px 0 12px 0;
  5073. margin-top: 10px;
  5074. background: white;
  5075. border-radius: 12px;
  5076. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  5077. margin-left: 52px;
  5078. overflow: hidden;
  5079. width: fit-content;
  5080. .online-search-header {
  5081. display: flex;
  5082. align-items: center;
  5083. justify-content: center;
  5084. padding: 12px;
  5085. cursor: pointer;
  5086. transition: background-color 0.2s ease;
  5087. font-size: 14px;
  5088. color: #37415180;
  5089. &:hover {
  5090. background-color: #f9fafb;
  5091. }
  5092. .expand-icon {
  5093. margin-left: 8px;
  5094. transition: transform 0.2s ease;
  5095. color: #6b7280;
  5096. &.expanded {
  5097. transform: rotate(180deg);
  5098. }
  5099. }
  5100. }
  5101. .online-search-content {
  5102. border-top: 1px solid #f3f4f6;
  5103. padding: 16px;
  5104. width: 735px;
  5105. max-height: 400px;
  5106. overflow-y: auto;
  5107. /* 自定义滚动条样式 */
  5108. &::-webkit-scrollbar {
  5109. width: 6px;
  5110. }
  5111. &::-webkit-scrollbar-track {
  5112. background: #f1f5f9;
  5113. border-radius: 3px;
  5114. }
  5115. &::-webkit-scrollbar-thumb {
  5116. background: #cbd5e1;
  5117. border-radius: 3px;
  5118. &:hover {
  5119. background: #94a3b8;
  5120. }
  5121. }
  5122. .search-results-section {
  5123. .section-header {
  5124. display: flex;
  5125. align-items: center;
  5126. margin-bottom: 16px;
  5127. .section-icon {
  5128. width: 24px;
  5129. height: 24px;
  5130. background: #f3f4f6;
  5131. border-radius: 50%;
  5132. display: flex;
  5133. align-items: center;
  5134. justify-content: center;
  5135. margin-right: 8px;
  5136. font-size: 12px;
  5137. }
  5138. .section-title {
  5139. font-size: 14px;
  5140. color: #374151;
  5141. font-weight: 500;
  5142. }
  5143. }
  5144. .search-results-grid {
  5145. display: grid;
  5146. grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  5147. gap: 12px;
  5148. .search-result-card {
  5149. background: white;
  5150. border: 1px solid #e5e7eb;
  5151. border-radius: 8px;
  5152. padding: 12px;
  5153. position: relative;
  5154. cursor: pointer;
  5155. transition: all 0.2s ease;
  5156. min-height: 80px;
  5157. height: auto;
  5158. &:hover {
  5159. border-color: #3b82f6;
  5160. box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
  5161. }
  5162. .result-title {
  5163. font-size: 13px;
  5164. color: #1f2937;
  5165. font-weight: 500;
  5166. line-height: 1.4;
  5167. margin-bottom: 6px;
  5168. display: -webkit-box;
  5169. -webkit-line-clamp: 1;
  5170. line-clamp: 1;
  5171. -webkit-box-orient: vertical;
  5172. overflow: hidden;
  5173. text-overflow: ellipsis;
  5174. white-space: nowrap;
  5175. padding-right: 30px;
  5176. }
  5177. .result-description {
  5178. font-size: 12px;
  5179. color: #6b7280;
  5180. line-height: 1.4;
  5181. display: -webkit-box;
  5182. -webkit-line-clamp: 3;
  5183. line-clamp: 3;
  5184. -webkit-box-orient: vertical;
  5185. overflow: hidden;
  5186. word-break: break-word;
  5187. text-overflow: ellipsis;
  5188. padding-right: 30px;
  5189. }
  5190. .result-badge {
  5191. position: absolute;
  5192. top: 8px;
  5193. right: 8px;
  5194. width: 20px;
  5195. height: 20px;
  5196. background: #3b82f6;
  5197. color: white;
  5198. border-radius: 50%;
  5199. display: flex;
  5200. align-items: center;
  5201. justify-content: center;
  5202. font-size: 10px;
  5203. font-weight: 600;
  5204. z-index: 1;
  5205. }
  5206. }
  5207. }
  5208. }
  5209. }
  5210. }
  5211. .search-sources {
  5212. margin: 8px 0 12px 0;
  5213. margin-top: 10px;
  5214. background: white;
  5215. border-radius: 12px;
  5216. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  5217. margin-left: 52px;
  5218. overflow: hidden;
  5219. width: fit-content;
  5220. .search-sources-header {
  5221. display: flex;
  5222. align-items: center;
  5223. justify-content: center;
  5224. padding: 12px;
  5225. cursor: pointer;
  5226. transition: background-color 0.2s ease;
  5227. font-size: 14px;
  5228. color: #37415180;
  5229. &:hover {
  5230. background-color: #f9fafb;
  5231. }
  5232. .expand-icon {
  5233. margin-left: 8px;
  5234. transition: transform 0.2s ease;
  5235. color: #6b7280;
  5236. &.expanded {
  5237. transform: rotate(180deg);
  5238. }
  5239. }
  5240. }
  5241. .search-sources-content {
  5242. border-top: 1px solid #f3f4f6;
  5243. padding: 16px;
  5244. width: 735px;
  5245. // max-width: 900px;
  5246. max-height: 400px; /* 改为最大高度,允许动态调整 */
  5247. overflow-y: auto;
  5248. /* 自定义滚动条样式 */
  5249. &::-webkit-scrollbar {
  5250. width: 6px;
  5251. }
  5252. &::-webkit-scrollbar-track {
  5253. background: #f1f5f9;
  5254. border-radius: 3px;
  5255. }
  5256. &::-webkit-scrollbar-thumb {
  5257. background: #cbd5e1;
  5258. border-radius: 3px;
  5259. &:hover {
  5260. background: #94a3b8;
  5261. }
  5262. }
  5263. .sources-section {
  5264. .section-header {
  5265. display: flex;
  5266. align-items: center;
  5267. margin-bottom: 16px;
  5268. .section-icon {
  5269. width: 24px;
  5270. height: 24px;
  5271. background: #f3f4f6;
  5272. border-radius: 50%;
  5273. display: flex;
  5274. align-items: center;
  5275. justify-content: center;
  5276. margin-right: 8px;
  5277. font-size: 12px;
  5278. }
  5279. .section-title {
  5280. font-size: 14px;
  5281. color: #374151;
  5282. font-weight: 500;
  5283. }
  5284. }
  5285. .sources-grid {
  5286. display: grid;
  5287. grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  5288. gap: 12px;
  5289. .source-card {
  5290. background: white;
  5291. border: 1px solid #e5e7eb;
  5292. border-radius: 8px;
  5293. padding: 12px;
  5294. position: relative;
  5295. cursor: pointer;
  5296. transition: all 0.2s ease;
  5297. min-height: 80px; /* 设置最小高度 */
  5298. height: auto; /* 允许高度自适应 */
  5299. &:hover {
  5300. border-color: #3b82f6;
  5301. box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
  5302. }
  5303. .source-title {
  5304. font-size: 13px;
  5305. color: #1f2937;
  5306. font-weight: 500;
  5307. line-height: 1.4;
  5308. margin-bottom: 6px;
  5309. display: -webkit-box;
  5310. -webkit-line-clamp: 1;
  5311. line-clamp: 1;
  5312. -webkit-box-orient: vertical;
  5313. overflow: hidden;
  5314. text-overflow: ellipsis;
  5315. white-space: nowrap;
  5316. padding-right: 30px; /* 为右侧数字留出空间 */
  5317. }
  5318. .source-description {
  5319. font-size: 12px;
  5320. color: #6b7280;
  5321. line-height: 1.4;
  5322. display: -webkit-box;
  5323. -webkit-line-clamp: 3;
  5324. line-clamp: 3;
  5325. -webkit-box-orient: vertical;
  5326. overflow: hidden;
  5327. word-break: break-word;
  5328. text-overflow: ellipsis;
  5329. padding-right: 30px; /* 为右侧数字留出空间 */
  5330. }
  5331. .source-badge {
  5332. position: absolute;
  5333. top: 8px;
  5334. right: 8px;
  5335. width: 20px;
  5336. height: 20px;
  5337. background: #3b82f6;
  5338. color: white;
  5339. border-radius: 50%;
  5340. display: flex;
  5341. align-items: center;
  5342. justify-content: center;
  5343. font-size: 10px;
  5344. font-weight: 600;
  5345. z-index: 1; /* 确保在文字上方 */
  5346. }
  5347. }
  5348. }
  5349. }
  5350. }
  5351. }
  5352. .related-questions {
  5353. margin-top: 20px;
  5354. padding: 0;
  5355. margin-left: 52px;
  5356. display: flex;
  5357. flex-direction: column;
  5358. gap: 8px;
  5359. .related-question-item {
  5360. display: flex;
  5361. align-items: center;
  5362. padding: 12px 16px;
  5363. background: rgba(0,0,0,.05);
  5364. border: 1px solid #e9ecef;
  5365. border-radius: 12px;
  5366. cursor: pointer;
  5367. transition: all 0.2s ease;
  5368. font-size: 16px;
  5369. color: #212529;
  5370. line-height: 1.4;
  5371. width: fit-content;
  5372. &:hover {
  5373. background: #E2EBFE;
  5374. border-color: #dee2e6;
  5375. }
  5376. .arrow-icon {
  5377. width: 16px;
  5378. height: 16px;
  5379. color: #6c757d;
  5380. flex-shrink: 0;
  5381. margin-left: 8px;
  5382. }
  5383. }
  5384. }
  5385. .ai-avatar-small {
  5386. width: 80px;
  5387. height: 80px;
  5388. animation: avatar-pulse 2s ease-in-out infinite;
  5389. flex-shrink: 0;
  5390. /* 位置调整:向上和向左移动,使输出框与搜索结果胶囊对齐 */
  5391. margin-top: -40px;
  5392. margin-left: -45px;
  5393. .ai-icon {
  5394. width: 100%;
  5395. height: 100%;
  5396. object-fit: contain;
  5397. filter: drop-shadow(0 4px 16px rgba(91, 141, 239, 0.15));
  5398. }
  5399. }
  5400. @keyframes avatar-pulse {
  5401. 0%, 100% {
  5402. transform: scale(1);
  5403. }
  5404. 50% {
  5405. transform: scale(1.05);
  5406. }
  5407. }
  5408. .message-content {
  5409. background: white;
  5410. color: #374151;
  5411. padding: 16px 20px;
  5412. border-radius: 0px 18px 18px 18px;
  5413. max-width: 900px;
  5414. width: 900px;
  5415. word-wrap: break-word;
  5416. line-height: 1.5;
  5417. font-size: 16px;
  5418. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  5419. white-space: normal;
  5420. transition: width 0.3s ease;
  5421. overflow: hidden;
  5422. .ai-text {
  5423. min-height: 20px;
  5424. line-height: 1.5;
  5425. margin-bottom: 16px;
  5426. .typing-indicator {
  5427. color: #9CA3AF;
  5428. font-style: italic;
  5429. font-size: 14px;
  5430. display: flex;
  5431. align-items: center;
  5432. gap: 8px;
  5433. white-space: nowrap;
  5434. .thinking-animation {
  5435. display: flex;
  5436. gap: 4px;
  5437. .dot {
  5438. width: 6px;
  5439. height: 6px;
  5440. background: #9CA3AF;
  5441. border-radius: 50%;
  5442. animation: thinking 1.4s infinite ease-in-out;
  5443. &:nth-child(1) { animation-delay: -0.32s; }
  5444. &:nth-child(2) { animation-delay: -0.16s; }
  5445. &:nth-child(3) { animation-delay: 0s; }
  5446. }
  5447. }
  5448. }
  5449. .safety-doc-card {
  5450. display: flex;
  5451. align-items: center;
  5452. background: white;
  5453. border: 1px solid #e2e8f0;
  5454. border-radius: 12px;
  5455. padding: 16px 20px;
  5456. margin-bottom: 16px;
  5457. cursor: pointer;
  5458. transition: all 0.3s ease;
  5459. box-shadow: 0 2px 8px rgba(0,0,0,0.04);
  5460. max-width: 400px;
  5461. &:hover {
  5462. box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  5463. transform: translateY(-2px);
  5464. border-color: #3e7bfa;
  5465. }
  5466. .doc-icon-wrapper {
  5467. width: 48px;
  5468. height: 48px;
  5469. background: #f0f5ff;
  5470. border-radius: 10px;
  5471. display: flex;
  5472. align-items: center;
  5473. justify-content: center;
  5474. margin-right: 16px;
  5475. .doc-icon {
  5476. width: 24px;
  5477. height: 24px;
  5478. object-fit: contain;
  5479. }
  5480. }
  5481. .doc-info {
  5482. flex: 1;
  5483. .doc-title {
  5484. font-size: 16px;
  5485. font-weight: 600;
  5486. color: #1f2937;
  5487. margin-bottom: 4px;
  5488. }
  5489. .doc-time {
  5490. font-size: 12px;
  5491. color: #9ca3af;
  5492. }
  5493. }
  5494. .doc-action {
  5495. font-size: 14px;
  5496. color: #3e7bfa;
  5497. font-weight: 500;
  5498. }
  5499. }
  5500. .ai-markdown-content {
  5501. /* Markdown样式 - 与StreamMarkdown保持一致 */
  5502. :deep(h2) {
  5503. font-size: 16px;
  5504. font-weight: 600;
  5505. color: #303133;
  5506. margin: 8px 0 4px 0;
  5507. padding-bottom: 4px;
  5508. border-bottom: 1px solid #dcdfe6;
  5509. }
  5510. :deep(h3) {
  5511. font-size: 15px;
  5512. font-weight: 600;
  5513. color: #606266;
  5514. margin: 8px 0 4px 0;
  5515. }
  5516. :deep(p) {
  5517. margin: 8px 0;
  5518. line-height: 1.8;
  5519. }
  5520. :deep(strong) {
  5521. font-weight: 600;
  5522. color: #303133;
  5523. }
  5524. :deep(ul),
  5525. :deep(ol) {
  5526. margin: 8px 0;
  5527. padding-left: 24px;
  5528. }
  5529. :deep(li) {
  5530. margin: 4px 0;
  5531. }
  5532. :deep(blockquote) {
  5533. margin: 12px 0;
  5534. padding: 8px 16px;
  5535. border-left: 4px solid #5b8def;
  5536. background-color: #f7f9fc;
  5537. color: #606266;
  5538. }
  5539. :deep(code) {
  5540. padding: 2px 6px;
  5541. background-color: #f4f4f5;
  5542. border-radius: 3px;
  5543. font-family: 'Courier New', monospace;
  5544. font-size: 13px;
  5545. }
  5546. :deep(pre) {
  5547. margin: 12px 0;
  5548. padding: 12px;
  5549. background-color: #f4f4f5;
  5550. border-radius: 4px;
  5551. overflow-x: auto;
  5552. }
  5553. :deep(pre code) {
  5554. padding: 0;
  5555. background-color: transparent;
  5556. }
  5557. :deep(a) {
  5558. color: #5b8def;
  5559. text-decoration: none;
  5560. cursor: pointer;
  5561. }
  5562. :deep(a:hover) {
  5563. text-decoration: underline;
  5564. color: #4a7de8;
  5565. }
  5566. :deep(hr) {
  5567. margin: 16px 0;
  5568. border: none;
  5569. border-top: 1px solid #dcdfe6;
  5570. }
  5571. :deep(table) {
  5572. border-collapse: collapse;
  5573. width: 100%;
  5574. margin: 12px 0;
  5575. }
  5576. :deep(th),
  5577. :deep(td) {
  5578. border: 1px solid #dcdfe6;
  5579. padding: 8px 12px;
  5580. text-align: left;
  5581. }
  5582. :deep(th) {
  5583. background-color: #f4f4f5;
  5584. font-weight: 600;
  5585. }
  5586. }
  5587. }
  5588. .divider {
  5589. height: 1px;
  5590. background: #e5e7eb;
  5591. margin: 16px 0 8px 0;
  5592. }
  5593. .message-actions {
  5594. display: flex;
  5595. justify-content: space-between;
  5596. align-items: center;
  5597. .left-actions {
  5598. display: flex;
  5599. gap: 8px;
  5600. flex-wrap: wrap;
  5601. }
  5602. .right-actions {
  5603. display: flex;
  5604. }
  5605. .action-btn {
  5606. background: none;
  5607. border: none;
  5608. padding: 6px 5px;
  5609. border-radius: 4px;
  5610. cursor: pointer;
  5611. transition: all 0.2s ease;
  5612. display: flex;
  5613. align-items: center;
  5614. gap: 4px;
  5615. font-size: 12px;
  5616. color: #6b7280;
  5617. &:hover:not(:disabled) {
  5618. color: #374151;
  5619. background: rgba(59, 130, 246, 0.1);
  5620. }
  5621. &:disabled {
  5622. cursor: not-allowed;
  5623. }
  5624. .action-icon {
  5625. width: 16px;
  5626. height: 16px;
  5627. object-fit: contain;
  5628. }
  5629. &.voice-btn.speaking {
  5630. color: #dc2626;
  5631. background: rgba(239, 68, 68, 0.08);
  5632. .action-icon {
  5633. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%);
  5634. }
  5635. }
  5636. // 点赞和点踩按钮的特殊样式
  5637. &.thumbs-up-btn {
  5638. transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  5639. &:hover {
  5640. color: #059669;
  5641. transform: scale(1.1);
  5642. }
  5643. &.active {
  5644. color: #059669;
  5645. transform: scale(1.2);
  5646. .action-icon {
  5647. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%);
  5648. }
  5649. }
  5650. &:active {
  5651. transform: scale(0.9);
  5652. transition: all 0.1s ease;
  5653. }
  5654. }
  5655. &.thumbs-down-btn {
  5656. transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  5657. &:hover {
  5658. color: #dc2626;
  5659. transform: scale(1.1);
  5660. }
  5661. &.active {
  5662. color: #dc2626;
  5663. transform: scale(1.2);
  5664. .action-icon {
  5665. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%) !important;
  5666. }
  5667. }
  5668. &:active {
  5669. transform: scale(0.9);
  5670. transition: all 0.1s ease;
  5671. }
  5672. }
  5673. }
  5674. }
  5675. }
  5676. }
  5677. /* 打字动画 */
  5678. @keyframes thinking {
  5679. 0%, 80%, 100% {
  5680. transform: scale(0.8);
  5681. opacity: 0.5;
  5682. }
  5683. 40% {
  5684. transform: scale(1);
  5685. opacity: 1;
  5686. }
  5687. }
  5688. /* 初始内容样式 */
  5689. .initial-content {
  5690. width: 100%;
  5691. display: flex;
  5692. flex-direction: column;
  5693. align-items: center;
  5694. }
  5695. /* 聊天消息区域 */
  5696. .chat-messages {
  5697. width: 100%;
  5698. max-width: 1060px;
  5699. padding: 0 75px 0 35px;
  5700. display: flex;
  5701. flex-direction: column;
  5702. gap: 24px;
  5703. padding-bottom: 40px; /* 添加底部间距 */
  5704. }
  5705. /* 消息项样式 */
  5706. // .message-item {
  5707. // display: flex;
  5708. // flex-direction: column;
  5709. // gap: 12px;
  5710. // }
  5711. /* 打字动画 */
  5712. @keyframes fadeIn {
  5713. from {
  5714. opacity: 0;
  5715. transform: translateY(2px);
  5716. }
  5717. to {
  5718. opacity: 1;
  5719. transform: translateY(0);
  5720. }
  5721. }
  5722. /* AI助手介绍 */
  5723. .ai-intro {
  5724. display: flex;
  5725. flex-direction: column;
  5726. align-items: center;
  5727. margin-bottom: 40px;
  5728. .ai-avatar {
  5729. width: 160px;
  5730. height: 160px;
  5731. border-radius: 40px;
  5732. display: flex;
  5733. align-items: center;
  5734. justify-content: center;
  5735. margin-bottom: 20px;
  5736. animation: avatar-float 3s ease-in-out infinite;
  5737. .ai-avatar-img {
  5738. width: 100%;
  5739. height: 100%;
  5740. object-fit: contain;
  5741. filter: drop-shadow(0 8px 24px rgba(91, 141, 239, 0.2));
  5742. }
  5743. }
  5744. @keyframes avatar-float {
  5745. 0%, 100% {
  5746. transform: translateY(0);
  5747. }
  5748. 50% {
  5749. transform: translateY(-8px);
  5750. }
  5751. }
  5752. .ai-greeting {
  5753. text-align: center;
  5754. h3 {
  5755. font-size: 24px;
  5756. font-weight: 600;
  5757. color: #2C3E50;
  5758. margin: 0 0 8px 0;
  5759. }
  5760. p {
  5761. font-size: 16px;
  5762. color: #7C8DB5;
  5763. margin: 0;
  5764. }
  5765. }
  5766. }
  5767. /* 功能卡片 */
  5768. .function-cards {
  5769. display: grid;
  5770. grid-template-columns: repeat(2, 1fr);
  5771. gap: 16px;
  5772. margin-top: 56px;
  5773. max-width: 592px;
  5774. .function-card {
  5775. background: white;
  5776. width: 288px;
  5777. height: 112px;
  5778. padding: 20px 0px;
  5779. padding-left: 20px;
  5780. border-radius: 12px;
  5781. border: 1px solid #E5E8EB;
  5782. cursor: pointer;
  5783. transition: all 0.3s ease;
  5784. display: flex;
  5785. flex-direction: column;
  5786. box-sizing: border-box;
  5787. &:hover {
  5788. border-color: #667eea;
  5789. transform: translateY(-2px);
  5790. box-shadow: 0 4px 20px rgba(102, 126, 234, 0.1);
  5791. }
  5792. .card-header {
  5793. display: flex;
  5794. align-items: center;
  5795. gap: 16px;
  5796. margin-bottom: 8px;
  5797. .card-icon {
  5798. width: 40px;
  5799. height: 40px;
  5800. display: flex;
  5801. align-items: center;
  5802. justify-content: center;
  5803. background: #F3F5FF;
  5804. border-radius: 8px;
  5805. flex-shrink: 0;
  5806. .card-icon-img {
  5807. width: 40px;
  5808. height: 40px;
  5809. object-fit: contain;
  5810. }
  5811. }
  5812. h4 {
  5813. font-size: 16px;
  5814. font-weight: 600;
  5815. color: #2C3E50;
  5816. margin: 0;
  5817. line-height: 1.5;
  5818. flex: 1;
  5819. }
  5820. }
  5821. .card-description {
  5822. margin-left: 55px;
  5823. p {
  5824. font-size: 14px;
  5825. color: #7C8DB5;
  5826. margin: 0;
  5827. line-height: 1.4;
  5828. }
  5829. }
  5830. }
  5831. }
  5832. /* 推荐问题 */
  5833. .recommended-questions {
  5834. display: flex;
  5835. flex-wrap: wrap;
  5836. gap: 12px;
  5837. justify-content: center;
  5838. max-width: 900px;
  5839. margin: 0 auto 12px auto;
  5840. padding: 0;
  5841. .question-tag {
  5842. background: white;
  5843. padding: 8px 16px;
  5844. border-radius: 20px;
  5845. border: 1px solid #E5E8EB;
  5846. font-size: 14px;
  5847. color: #5A6C7D;
  5848. cursor: pointer;
  5849. transition: all 0.3s ease;
  5850. display: flex;
  5851. align-items: center;
  5852. gap: 8px;
  5853. white-space: nowrap;
  5854. box-shadow: 0 2px 4px rgba(0,0,0,0.02);
  5855. &:hover {
  5856. border-color: #3E7BFA;
  5857. color: #3E7BFA;
  5858. transform: translateY(-1px);
  5859. box-shadow: 0 4px 8px rgba(0,0,0,0.05);
  5860. }
  5861. .question-icon {
  5862. width: 16px;
  5863. height: 16px;
  5864. object-fit: contain;
  5865. flex-shrink: 0;
  5866. }
  5867. }
  5868. }
  5869. /* 用户推荐问题 */
  5870. .user-recommend-questions {
  5871. position: absolute;
  5872. bottom: 75px; /* 位于输入框上方 */
  5873. left: 360px; /* 左侧边栏宽度 */
  5874. right: 0;
  5875. max-width: 900px; /* 与输入框容器的最大宽度一致 */
  5876. margin: 0 auto; /* 居中显示 */
  5877. background: white;
  5878. border-radius: 12px;
  5879. padding: 16px 20px;
  5880. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  5881. border: 1px solid #E5E8EB;
  5882. z-index: 1; /* 降低z-index,避免覆盖输入框按钮 */
  5883. .recommend-list {
  5884. display: flex;
  5885. flex-direction: column;
  5886. gap: 8px;
  5887. max-height: 280px; /* 限制高度,大约5条内容的高度 */
  5888. overflow-y: auto; /* 超过高度时显示滚动条 */
  5889. /* 自定义滚动条样式 */
  5890. &::-webkit-scrollbar {
  5891. width: 6px;
  5892. }
  5893. &::-webkit-scrollbar-track {
  5894. background: #f1f5f9;
  5895. border-radius: 3px;
  5896. }
  5897. &::-webkit-scrollbar-thumb {
  5898. background: #cbd5e1;
  5899. border-radius: 3px;
  5900. &:hover {
  5901. background: #94a3b8;
  5902. }
  5903. }
  5904. .recommend-item {
  5905. padding: 8px 10px;
  5906. font-size: 16px;
  5907. color: #000;
  5908. cursor: pointer;
  5909. transition: color 0.2s ease;
  5910. line-height: 1.4;
  5911. // border-bottom: 1px solid #F3F4F6;
  5912. &:last-child {
  5913. border-bottom: none;
  5914. }
  5915. &:hover {
  5916. // color: #3E7BFA;
  5917. background-color: #F5F6F9;
  5918. border-radius: 10px;
  5919. }
  5920. }
  5921. }
  5922. }
  5923. /* 文件预览区域 */
  5924. .file-preview-section {
  5925. margin-bottom: 12px;
  5926. .file-preview {
  5927. position: relative;
  5928. display: flex;
  5929. align-items: center;
  5930. background: rgba(255, 255, 255, 0.9);
  5931. border: 1px solid #E5E7EB;
  5932. border-radius: 12px;
  5933. padding: 12px;
  5934. max-width: 400px;
  5935. .file-icon {
  5936. font-size: 32px;
  5937. margin-right: 12px;
  5938. width: 48px;
  5939. text-align: center;
  5940. .file-icon-img {
  5941. width: 32px;
  5942. height: 32px;
  5943. object-fit: contain;
  5944. }
  5945. }
  5946. .file-info {
  5947. flex: 1;
  5948. .file-name {
  5949. font-size: 14px;
  5950. font-weight: 500;
  5951. color: #374151;
  5952. margin-bottom: 4px;
  5953. overflow: hidden;
  5954. text-overflow: ellipsis;
  5955. white-space: nowrap;
  5956. max-width: 200px;
  5957. }
  5958. .file-size {
  5959. font-size: 12px;
  5960. color: #6B7280;
  5961. margin-bottom: 4px;
  5962. }
  5963. }
  5964. .remove-file-btn {
  5965. width: 24px;
  5966. height: 24px;
  5967. border: none;
  5968. background: rgba(239, 68, 68, 0.1);
  5969. color: #DC2626;
  5970. border-radius: 50%;
  5971. cursor: pointer;
  5972. display: flex;
  5973. align-items: center;
  5974. justify-content: center;
  5975. transition: all 0.2s ease;
  5976. &:hover {
  5977. background: rgba(239, 68, 68, 0.2);
  5978. transform: scale(1.1);
  5979. }
  5980. .remove-icon {
  5981. font-size: 16px;
  5982. font-weight: bold;
  5983. }
  5984. }
  5985. }
  5986. }
  5987. /* 底部输入区域 */
  5988. .chat-input-section {
  5989. background: transparent;
  5990. padding: 9px 30px;
  5991. .input-container {
  5992. max-width: 900px;
  5993. margin: 0 auto;
  5994. .input-box {
  5995. display: flex;
  5996. flex-direction: column;
  5997. background: white;
  5998. border-radius: 16px;
  5999. padding: 12px 16px;
  6000. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  6001. transition: box-shadow 0.3s ease;
  6002. border: 1px solid transparent;
  6003. min-height: 120px;
  6004. &:focus-within {
  6005. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  6006. border-color: #E5E8EB;
  6007. }
  6008. .attach-btn, .voice-btn, .network-search-btn {
  6009. background: none;
  6010. border: none;
  6011. cursor: pointer;
  6012. padding: 8px;
  6013. border-radius: 6px;
  6014. transition: all 0.3s ease;
  6015. display: flex;
  6016. align-items: center;
  6017. justify-content: center;
  6018. position: relative;
  6019. &:hover:not(:disabled) {
  6020. background: rgba(102, 126, 234, 0.1);
  6021. }
  6022. &:disabled {
  6023. cursor: not-allowed;
  6024. }
  6025. &.recording {
  6026. background: rgba(239, 68, 68, 0.1);
  6027. animation: pulse 1.5s ease-in-out infinite;
  6028. }
  6029. &.active {
  6030. background: rgba(62, 123, 250, 0.15);
  6031. &:hover:not(:disabled) {
  6032. background: rgba(62, 123, 250, 0.2);
  6033. }
  6034. }
  6035. .icon-container {
  6036. width: 20px;
  6037. height: 20px;
  6038. display: flex;
  6039. align-items: center;
  6040. justify-content: center;
  6041. flex-shrink: 0;
  6042. position: relative;
  6043. }
  6044. .recording-indicator {
  6045. position: absolute;
  6046. top: -2px;
  6047. right: -2px;
  6048. width: 8px;
  6049. height: 8px;
  6050. background: #ef4444;
  6051. border-radius: 50%;
  6052. animation: blink 1s ease-in-out infinite;
  6053. }
  6054. .action-icon {
  6055. width: 20px;
  6056. height: 20px;
  6057. height: 20px !important;
  6058. max-width: 20px !important;
  6059. max-height: 20px !important;
  6060. object-fit: contain;
  6061. flex-shrink: 0;
  6062. }
  6063. // 联网搜索SVG图标样式
  6064. .network-search-icon {
  6065. width: 20px;
  6066. height: 20px;
  6067. color: #6B7280;
  6068. transition: color 0.3s ease;
  6069. flex-shrink: 0;
  6070. }
  6071. }
  6072. // 联网搜索按钮激活状态下的图标颜色
  6073. .network-search-btn.active .network-search-icon {
  6074. color: #3E7BFA;
  6075. }
  6076. .mode-chips {
  6077. display: flex;
  6078. gap: 8px;
  6079. margin-right: 12px;
  6080. }
  6081. .mode-chip {
  6082. display: flex;
  6083. align-items: center;
  6084. background: transparent;
  6085. color: #2C3E50;
  6086. padding: 6px 12px;
  6087. border-radius: 20px;
  6088. font-size: 14px;
  6089. font-weight: 500;
  6090. gap: 6px;
  6091. cursor: pointer;
  6092. transition: all 0.2s ease;
  6093. border: 1px solid transparent;
  6094. .chip-icon {
  6095. width: 16px;
  6096. height: 16px;
  6097. object-fit: contain;
  6098. /* 将黑色图标转为灰色以匹配原图 */
  6099. filter: invert(40%) sepia(8%) saturate(500%) hue-rotate(180deg) brightness(95%) contrast(85%);
  6100. transition: filter 0.2s ease;
  6101. }
  6102. &:hover {
  6103. background: #E8F0FE;
  6104. color: #3E7BFA;
  6105. .chip-icon {
  6106. filter: invert(41%) sepia(91%) saturate(3574%) hue-rotate(213deg) brightness(101%) contrast(96%);
  6107. }
  6108. }
  6109. }
  6110. .expand-btn {
  6111. background: none;
  6112. border: none;
  6113. cursor: pointer;
  6114. padding: 8px;
  6115. border-radius: 6px;
  6116. display: flex;
  6117. align-items: center;
  6118. justify-content: center;
  6119. margin-right: 8px;
  6120. &:hover:not(:disabled) {
  6121. background: rgba(102, 126, 234, 0.1);
  6122. }
  6123. &:disabled {
  6124. cursor: not-allowed;
  6125. opacity: 0.5;
  6126. }
  6127. }
  6128. .message-input {
  6129. flex: 1;
  6130. width: 100%;
  6131. border: none;
  6132. background: transparent;
  6133. font-size: 15px;
  6134. color: #2C3E50;
  6135. outline: none;
  6136. resize: none;
  6137. font-family: inherit;
  6138. padding: 0 4px;
  6139. margin-bottom: 12px;
  6140. &::placeholder {
  6141. color: #A0A6B8;
  6142. }
  6143. &:disabled {
  6144. cursor: not-allowed;
  6145. }
  6146. }
  6147. .input-toolbar {
  6148. display: flex;
  6149. align-items: center;
  6150. justify-content: space-between;
  6151. width: 100%;
  6152. .toolbar-left, .toolbar-right {
  6153. display: flex;
  6154. align-items: center;
  6155. gap: 12px;
  6156. }
  6157. .model-link-btn, .attach-btn {
  6158. background: none;
  6159. border: none;
  6160. cursor: pointer;
  6161. padding: 8px;
  6162. border-radius: 6px;
  6163. display: flex;
  6164. align-items: center;
  6165. justify-content: center;
  6166. &:hover:not(:disabled) {
  6167. background: rgba(102, 126, 234, 0.1);
  6168. }
  6169. &:disabled {
  6170. cursor: not-allowed;
  6171. opacity: 0.5;
  6172. }
  6173. &.active {
  6174. background: rgba(102, 126, 234, 0.1);
  6175. }
  6176. .action-icon {
  6177. width: 20px;
  6178. height: 20px;
  6179. object-fit: contain;
  6180. }
  6181. .active-icon {
  6182. filter: invert(41%) sepia(91%) saturate(3574%) hue-rotate(213deg) brightness(101%) contrast(96%);
  6183. }
  6184. }
  6185. }
  6186. .divider {
  6187. width: 1px;
  6188. height: 31px;
  6189. background-color: #D6D5DE;
  6190. margin: 0 4px;
  6191. }
  6192. .send-btn {
  6193. background: none;
  6194. border: none;
  6195. cursor: pointer;
  6196. margin-left: 4px;
  6197. // padding: 8px;
  6198. display: flex;
  6199. align-items: center;
  6200. justify-content: center;
  6201. border-radius: 6px;
  6202. transition: background 0.3s ease;
  6203. display: flex;
  6204. align-items: center;
  6205. justify-content: center;
  6206. &:hover:not(:disabled) {
  6207. background: rgba(102, 126, 234, 0.1);
  6208. }
  6209. &:disabled {
  6210. cursor: not-allowed;
  6211. }
  6212. .send-icon-circle {
  6213. width: 36px;
  6214. height: 36px;
  6215. border-radius: 50%;
  6216. background-color: #B0C4DE; /* Disabled state color */
  6217. display: flex;
  6218. align-items: center;
  6219. justify-content: center;
  6220. transition: background-color 0.3s ease;
  6221. &.active {
  6222. background-color: #7B9AF3; /* Active state color (matches the light blue in the image) */
  6223. }
  6224. svg {
  6225. width: 20px;
  6226. height: 20px;
  6227. }
  6228. }
  6229. .stop-text {
  6230. color: #FF4D4F;
  6231. font-size: 14px;
  6232. font-weight: 500;
  6233. padding: 8px 16px;
  6234. white-space: nowrap;
  6235. }
  6236. }
  6237. }
  6238. }
  6239. }
  6240. /* 点赞和点踩按钮的图标颜色变化 */
  6241. .thumbs-up-btn.active .action-icon {
  6242. /* 绿色图标 */
  6243. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(142deg) brightness(104%) contrast(97%) !important;
  6244. }
  6245. .thumbs-down-btn.active .action-icon {
  6246. /* 红色图标 */
  6247. filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(0deg) brightness(104%) contrast(97%) !important;
  6248. }
  6249. /* 历史记录加载状态样式 */
  6250. .history-loading {
  6251. display: flex;
  6252. flex-direction: column;
  6253. align-items: center;
  6254. justify-content: center;
  6255. padding: 40px 20px;
  6256. min-height: 200px;
  6257. }
  6258. .history-loading .loading-spinner {
  6259. width: 32px;
  6260. height: 32px;
  6261. border: 3px solid #f3f3f3;
  6262. border-top: 3px solid #409eff;
  6263. border-radius: 50%;
  6264. animation: spin 1s linear infinite;
  6265. margin: 0 auto 16px auto;
  6266. }
  6267. .history-loading .loading-text {
  6268. color: #6B7280;
  6269. font-size: 14px;
  6270. margin: 0;
  6271. font-weight: 400;
  6272. }
  6273. @keyframes spin {
  6274. 0% { transform: rotate(0deg); }
  6275. 100% { transform: rotate(360deg); }
  6276. }
  6277. /* 联网搜索Loading样式 */
  6278. .online-search-loading {
  6279. display: flex;
  6280. align-items: center;
  6281. justify-content: center;
  6282. padding: 12px;
  6283. margin-left: 52px;
  6284. width: fit-content;
  6285. }
  6286. .online-search-loading .thinking-animation {
  6287. display: flex;
  6288. gap: 4px;
  6289. }
  6290. .online-search-loading .thinking-animation .dot {
  6291. width: 6px;
  6292. height: 6px;
  6293. background: #9CA3AF;
  6294. border-radius: 50%;
  6295. animation: thinking 1.4s infinite ease-in-out;
  6296. }
  6297. .online-search-loading .thinking-animation .dot:nth-child(1) {
  6298. animation-delay: -0.32s;
  6299. }
  6300. .online-search-loading .thinking-animation .dot:nth-child(2) {
  6301. animation-delay: -0.16s;
  6302. }
  6303. .online-search-loading .thinking-animation .dot:nth-child(3) {
  6304. animation-delay: 0s;
  6305. }
  6306. /* 推荐问题Loading样式 */
  6307. .related-questions-loading {
  6308. display: flex;
  6309. align-items: center;
  6310. justify-content: center;
  6311. padding: 12px;
  6312. margin-left: 52px;
  6313. width: fit-content;
  6314. }
  6315. .related-questions-loading .thinking-animation {
  6316. display: flex;
  6317. gap: 4px;
  6318. }
  6319. .related-questions-loading .thinking-animation .dot {
  6320. width: 6px;
  6321. height: 6px;
  6322. background: #9CA3AF;
  6323. border-radius: 50%;
  6324. animation: thinking 1.4s infinite ease-in-out;
  6325. }
  6326. .related-questions-loading .thinking-animation .dot:nth-child(1) {
  6327. animation-delay: -0.32s;
  6328. }
  6329. .related-questions-loading .thinking-animation .dot:nth-child(2) {
  6330. animation-delay: -0.16s;
  6331. }
  6332. .related-questions-loading .thinking-animation .dot:nth-child(3) {
  6333. animation-delay: 0s;
  6334. }
  6335. @keyframes pulse {
  6336. 0%, 100% {
  6337. transform: scale(1);
  6338. opacity: 1;
  6339. }
  6340. 50% {
  6341. transform: scale(1.05);
  6342. opacity: 0.8;
  6343. }
  6344. }
  6345. @keyframes blink {
  6346. 0%, 50% {
  6347. opacity: 1;
  6348. }
  6349. 51%, 100% {
  6350. opacity: 0.3;
  6351. }
  6352. }
  6353. /* ReportGenerator加载状态指示器样式(在AI回复下方左侧) */
  6354. .report-generator-loading {
  6355. display: flex;
  6356. align-items: center;
  6357. gap: 8px;
  6358. padding: 12px 16px;
  6359. margin-left: 52px; /* 与AI头像对齐,和提问联想一致 */
  6360. margin-top: 20px;
  6361. }
  6362. .report-generator-loading .loading-dots {
  6363. display: flex;
  6364. gap: 4px;
  6365. }
  6366. .report-generator-loading .loading-dots span {
  6367. width: 6px;
  6368. height: 6px;
  6369. border-radius: 50%;
  6370. background: #5b8def;
  6371. animation: thinking 1.4s infinite ease-in-out;
  6372. }
  6373. .report-generator-loading .loading-dots span:nth-child(1) {
  6374. animation-delay: -0.32s;
  6375. }
  6376. .report-generator-loading .loading-dots span:nth-child(2) {
  6377. animation-delay: -0.16s;
  6378. }
  6379. .report-generator-loading .loading-dots span:nth-child(3) {
  6380. animation-delay: 0s;
  6381. }
  6382. .report-generator-loading .loading-text {
  6383. color: #5b8def;
  6384. font-size: 14px;
  6385. font-weight: 500;
  6386. }
  6387. /* 报告列表内Loading样式 */
  6388. .report-loading {
  6389. display: flex;
  6390. align-items: center;
  6391. justify-content: center;
  6392. padding: 20px;
  6393. margin: 12px 0;
  6394. }
  6395. .report-loading .thinking-animation {
  6396. display: flex;
  6397. gap: 6px;
  6398. }
  6399. .report-loading .thinking-animation .dot {
  6400. width: 8px;
  6401. height: 8px;
  6402. border-radius: 50%;
  6403. background: #5b8def;
  6404. animation: thinking 1.4s infinite ease-in-out;
  6405. }
  6406. .report-loading .thinking-animation .dot:nth-child(1) {
  6407. animation-delay: -0.32s;
  6408. }
  6409. .report-loading .thinking-animation .dot:nth-child(2) {
  6410. animation-delay: -0.16s;
  6411. }
  6412. .report-loading .thinking-animation .dot:nth-child(3) {
  6413. animation-delay: 0s;
  6414. }
  6415. /* AI写作文件卡片样式 */
  6416. .ai-writing-block {
  6417. margin-top: 8px;
  6418. .ai-writing-status-text {
  6419. font-size: 14px;
  6420. color: #606266;
  6421. margin-bottom: 12px;
  6422. line-height: 1.6;
  6423. }
  6424. .ai-writing-file-card {
  6425. display: flex;
  6426. align-items: center;
  6427. background: #F5F7FA;
  6428. border: 1px solid #E4E7ED;
  6429. border-radius: 12px;
  6430. padding: 16px 20px;
  6431. cursor: pointer;
  6432. transition: all 0.3s ease;
  6433. max-width: 360px;
  6434. &:hover {
  6435. background: #EDF2FC;
  6436. border-color: #3E7BFA;
  6437. box-shadow: 0 2px 12px rgba(62, 123, 250, 0.15);
  6438. transform: translateY(-1px);
  6439. }
  6440. .file-card-icon {
  6441. width: 40px;
  6442. height: 40px;
  6443. background: #2B5797;
  6444. border-radius: 8px;
  6445. display: flex;
  6446. align-items: center;
  6447. justify-content: center;
  6448. margin-right: 14px;
  6449. flex-shrink: 0;
  6450. .word-icon {
  6451. color: white;
  6452. font-size: 20px;
  6453. font-weight: 700;
  6454. font-family: 'Segoe UI', sans-serif;
  6455. }
  6456. }
  6457. .file-card-info {
  6458. flex: 1;
  6459. min-width: 0;
  6460. .file-card-title {
  6461. font-size: 15px;
  6462. font-weight: 600;
  6463. color: #1F2937;
  6464. margin-bottom: 4px;
  6465. overflow: hidden;
  6466. text-overflow: ellipsis;
  6467. white-space: nowrap;
  6468. }
  6469. .file-card-time {
  6470. font-size: 12px;
  6471. color: #9CA3AF;
  6472. }
  6473. }
  6474. }
  6475. }
  6476. /* AI写作侧边栏样式 */
  6477. .ai-writing-sidebar {
  6478. position: relative;
  6479. flex: 0 0 40%;
  6480. width: 40%;
  6481. min-width: 360px;
  6482. max-width: 520px;
  6483. height: 100%;
  6484. background: white;
  6485. box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
  6486. z-index: 1;
  6487. display: flex;
  6488. flex-direction: column;
  6489. overflow: hidden;
  6490. border-left: 1px solid #E4E7ED;
  6491. .sidebar-header {
  6492. display: flex;
  6493. align-items: center;
  6494. justify-content: space-between;
  6495. padding: 20px 24px 16px 24px;
  6496. border-bottom: 1px solid #E4E7ED;
  6497. flex-shrink: 0;
  6498. .sidebar-title {
  6499. font-size: 18px;
  6500. font-weight: 600;
  6501. color: #1F2937;
  6502. margin: 0;
  6503. overflow: hidden;
  6504. text-overflow: ellipsis;
  6505. white-space: nowrap;
  6506. flex: 1;
  6507. margin-right: 16px;
  6508. }
  6509. .sidebar-header-actions {
  6510. display: flex;
  6511. align-items: center;
  6512. gap: 8px;
  6513. .sidebar-close-btn {
  6514. width: 32px;
  6515. height: 32px;
  6516. border: none;
  6517. background: #F5F7FA;
  6518. border-radius: 8px;
  6519. cursor: pointer;
  6520. display: flex;
  6521. align-items: center;
  6522. justify-content: center;
  6523. transition: all 0.2s ease;
  6524. &:hover {
  6525. background: #E4E7ED;
  6526. }
  6527. }
  6528. }
  6529. }
  6530. .sidebar-toolbar {
  6531. display: flex;
  6532. align-items: center;
  6533. padding: 10px 24px;
  6534. border-bottom: 1px solid #F0F2F5;
  6535. gap: 12px;
  6536. flex-shrink: 0;
  6537. .toolbar-label {
  6538. font-size: 14px;
  6539. color: #606266;
  6540. margin-right: 4px;
  6541. }
  6542. .toolbar-font-size {
  6543. font-size: 13px;
  6544. color: #909399;
  6545. margin-right: 8px;
  6546. }
  6547. .toolbar-divider {
  6548. width: 1px;
  6549. height: 20px;
  6550. background: #E4E7ED;
  6551. margin: 0 4px;
  6552. }
  6553. .toolbar-btn {
  6554. width: 32px;
  6555. height: 32px;
  6556. border: none;
  6557. background: transparent;
  6558. border-radius: 6px;
  6559. cursor: pointer;
  6560. display: flex;
  6561. align-items: center;
  6562. justify-content: center;
  6563. font-size: 15px;
  6564. color: #606266;
  6565. transition: all 0.2s ease;
  6566. &:hover {
  6567. background: #F5F7FA;
  6568. }
  6569. }
  6570. }
  6571. .sidebar-content {
  6572. flex: 1;
  6573. overflow-y: auto;
  6574. padding: 24px;
  6575. .sidebar-doc-content {
  6576. font-size: 15px;
  6577. line-height: 1.8;
  6578. color: #303133;
  6579. white-space: pre-wrap;
  6580. word-break: break-word;
  6581. :deep(h1), :deep(h2), :deep(h3) {
  6582. margin: 16px 0 8px 0;
  6583. color: #1F2937;
  6584. }
  6585. :deep(p) {
  6586. margin: 8px 0;
  6587. }
  6588. :deep(ul), :deep(ol) {
  6589. padding-left: 24px;
  6590. margin: 8px 0;
  6591. }
  6592. :deep(li) {
  6593. margin: 4px 0;
  6594. }
  6595. }
  6596. .sidebar-generating-hint {
  6597. display: flex;
  6598. align-items: center;
  6599. margin-top: 24px;
  6600. color: #3E7BFA;
  6601. font-size: 14px;
  6602. .generating-text-hint {
  6603. margin-right: 4px;
  6604. }
  6605. .generating-dots {
  6606. display: flex;
  6607. gap: 2px;
  6608. .dot {
  6609. animation: dot-blink 1.4s infinite ease-in-out;
  6610. &:nth-child(1) { animation-delay: 0s; }
  6611. &:nth-child(2) { animation-delay: 0.2s; }
  6612. &:nth-child(3) { animation-delay: 0.4s; }
  6613. }
  6614. }
  6615. }
  6616. }
  6617. }
  6618. @media (max-width: 1200px) {
  6619. .chat-main-area {
  6620. flex-direction: column;
  6621. }
  6622. .ai-writing-sidebar {
  6623. flex: 0 0 auto;
  6624. width: 100%;
  6625. min-width: 0;
  6626. max-width: none;
  6627. height: 45vh;
  6628. border-left: none;
  6629. border-top: 1px solid #E4E7ED;
  6630. }
  6631. }
  6632. @keyframes dot-blink {
  6633. 0%, 80%, 100% { opacity: 0.3; }
  6634. 40% { opacity: 1; }
  6635. }
  6636. /* 侧边栏滑入滑出动画 */
  6637. .sidebar-slide-enter-active,
  6638. .sidebar-slide-leave-active {
  6639. transition: transform 0.3s ease;
  6640. }
  6641. .sidebar-slide-enter-from,
  6642. .sidebar-slide-leave-to {
  6643. transform: translateX(100%);
  6644. }
  6645. .sidebar-slide-enter-to,
  6646. .sidebar-slide-leave-from {
  6647. transform: translateX(0);
  6648. }
  6649. /* 规范引用样式 */
  6650. :deep(.standard-reference) {
  6651. background-color: #EAEAEE !important;
  6652. color: #616161 !important;
  6653. font-size: 0.75rem !important;
  6654. padding: 3px 8px !important;
  6655. border-radius: 6px !important;
  6656. cursor: pointer !important;
  6657. display: inline-block !important;
  6658. margin: 4px 2px !important;
  6659. border: 1px solid #EAEAEE !important;
  6660. font-weight: 500 !important;
  6661. transition: all 0.2s ease !important;
  6662. line-height: 1.4 !important;
  6663. max-width: 680px !important;
  6664. white-space: nowrap !important;
  6665. overflow: hidden !important;
  6666. text-overflow: ellipsis !important;
  6667. //向下移动
  6668. transform: translateY(12px) !important;
  6669. }
  6670. :deep(.standard-reference:hover) {
  6671. background-color: #d1d5db !important;
  6672. border-color: #d1d5db !important;
  6673. }
  6674. </style>