Chat.vue 205 KB

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