Chat.vue 212 KB

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