m-Chat.vue 190 KB

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