Преглед изворни кода

Merge upstream/master into fix/base-agent-token(拉取最新 master:含 AI 助手/主风机弹窗等 6 个提交;不改写本分支历史)

lizuo пре 4 дана
родитељ
комит
ca69d30b28
32 измењених фајлова са 2890 додато и 564 уклоњено
  1. 1 0
      package.json
  2. 98 4
      pnpm-lock.yaml
  3. 1 1
      public/js/config.js
  4. 1 0
      src/assets/images/ventAI/chatModalBlue/2-11.svg
  5. 1 0
      src/assets/images/ventAI/chatModalBlue/2-12.svg
  6. 0 0
      src/assets/images/ventAI/chatModalBlue/2-13.svg
  7. 1 0
      src/assets/images/ventAI/chatModalBlue/2-14.svg
  8. 5 0
      src/components/Application/src/AppLogo.vue
  9. 4 0
      src/design/themify/deepblue.less
  10. 4 0
      src/design/themify/default.less
  11. 7 1
      src/layouts/default/header/components/user-dropdown/HomeStyleSelect.vue
  12. 7 1
      src/layouts/default/index.vue
  13. 1 1
      src/utils/index.ts
  14. 178 182
      src/utils/threejs/parseModal.ts
  15. 17 54
      src/views/vent/home/homeAI/index.vue
  16. 1 1
      src/views/vent/monitorManager/compressor/components/nitrogenHome_bet.vue
  17. 329 0
      src/views/vent/monitorManager/compressor/nitrogen.flex.threejs.ts
  18. 33 0
      src/views/vent/monitorManager/compressor/nitrogen.threejs.ts
  19. 1 4
      src/views/vent/monitorManager/mainFanMonitor/components/entryThree.vue
  20. 39 37
      src/views/vent/monitorManager/mainFanMonitor/main.threejs.ts
  21. 20 14
      src/views/vent/monitorManager/mainFanMonitor/mainWind.li3.threejs.ts
  22. 4 3
      src/views/vent/monitorManager/mainFanMonitor/mainWind.lj.threejs.ts
  23. 22 14
      src/views/vent/monitorManager/mainFanMonitor/mainWind.threejs.ts
  24. 2 0
      src/views/vent/monitorManager/mainFanMonitor/mainWind.xj.threejs.ts
  25. 300 146
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  26. 134 2
      src/views/ventAI/manageAssistent/components/chatModal/ChatHeader.vue
  27. 376 98
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue
  28. 896 0
      src/views/ventAI/manageAssistent/components/chatModal/ResourceCapsule.vue
  29. 109 0
      src/views/ventAI/manageAssistent/components/chatModal/capsuleData.ts
  30. 179 0
      src/views/ventAI/manageAssistent/components/chatModal/exportDoc.ts
  31. 35 0
      src/views/ventAI/manageAssistent/components/chatModal/types.ts
  32. 84 1
      src/views/ventAI/manageAssistent/components/chatModal/utils.ts

+ 1 - 0
package.json

@@ -51,6 +51,7 @@
     "d3": "^7.8.5",
     "dayjs": "^1.11.9",
     "dexie": "^3.2.2",
+    "docx": "^9.7.1",
     "dom-align": "^1.12.4",
     "dompurify": "^3.4.14",
     "echarts": "^6.0.0",

+ 98 - 4
pnpm-lock.yaml

@@ -92,6 +92,9 @@ importers:
       dexie:
         specifier: ^3.2.2
         version: 3.2.7
+      docx:
+        specifier: ^9.7.1
+        version: 9.7.1
       dom-align:
         specifier: ^1.12.4
         version: 1.12.4
@@ -356,7 +359,7 @@ importers:
         version: 11.3.6
       http-server:
         specifier: ^14.1.1
-        version: 14.1.1
+        version: 14.1.1(debug@4.4.3)
       husky:
         specifier: ^8.0.3
         version: 8.0.3
@@ -2043,6 +2046,9 @@ packages:
   '@types/node@20.5.1':
     resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==}
 
+  '@types/node@25.9.6':
+    resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==}
+
   '@types/normalize-package-data@2.4.4':
     resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
 
@@ -3790,6 +3796,10 @@ packages:
     resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
     engines: {node: '>=6.0.0'}
 
+  docx@9.7.1:
+    resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
+    engines: {node: '>=10'}
+
   dom-align@1.12.4:
     resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==}
 
@@ -4858,6 +4868,9 @@ packages:
     resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==}
     engines: {node: '>=0.10.0'}
 
+  hash.js@1.1.7:
+    resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
+
   hasown@2.0.4:
     resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
     engines: {node: '>= 0.4'}
@@ -5010,6 +5023,9 @@ packages:
     resolution: {integrity: sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==}
     engines: {node: '>=8'}
 
+  immediate@3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
   import-fresh@3.3.1:
     resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
     engines: {node: '>=6'}
@@ -5763,6 +5779,9 @@ packages:
   jspdf@2.5.2:
     resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
 
+  jszip@3.10.2:
+    resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
+
   junk@3.1.0:
     resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==}
     engines: {node: '>=8'}
@@ -5816,6 +5835,9 @@ packages:
     resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
     engines: {node: '>= 0.8.0'}
 
+  lie@3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
   lilconfig@2.1.0:
     resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
     engines: {node: '>=10'}
@@ -6129,6 +6151,9 @@ packages:
     resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
     engines: {node: '>=4'}
 
+  minimalistic-assert@1.0.1:
+    resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
+
   minimatch@10.2.5:
     resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
     engines: {node: 18 || 20 || >=22}
@@ -6232,6 +6257,11 @@ packages:
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
     hasBin: true
 
+  nanoid@5.1.16:
+    resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
+    engines: {node: ^18 || >=20}
+    hasBin: true
+
   nanomatch@1.2.13:
     resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==}
     engines: {node: '>=0.10.0'}
@@ -6497,6 +6527,9 @@ packages:
   package-manager-detector@1.7.0:
     resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==}
 
+  pako@1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
   param-case@3.0.4:
     resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
 
@@ -7250,6 +7283,9 @@ packages:
     resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
     engines: {node: '>=0.10.0'}
 
+  setimmediate@1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
   shallow-equal@1.2.1:
     resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
 
@@ -7958,6 +7994,9 @@ packages:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
+  undici-types@7.24.6:
+    resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
   unicode-canonical-property-names-ecmascript@2.0.1:
     resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
     engines: {node: '>=4'}
@@ -8282,6 +8321,7 @@ packages:
 
   vue3-colorpicker@2.3.0:
     resolution: {integrity: sha512-e3lLmBcy7mkRrNQVeUny1DjOd6E11L8H5ok5Bx4MdXmrG+RzyacRF7KkhrEWmRYPhKAsaoUrWsFkmpPAaYnE5A==}
+    deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
     peerDependencies:
       '@aesoper/normal-utils': ^0.1.5
       '@popperjs/core': ^2.11.8
@@ -8503,6 +8543,10 @@ packages:
     engines: {node: '>=0.8'}
     hasBin: true
 
+  xml-js@1.6.11:
+    resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+    hasBin: true
+
   xml-name-validator@3.0.0:
     resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
 
@@ -8510,6 +8554,9 @@ packages:
     resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
     engines: {node: '>=12'}
 
+  xml@1.0.1:
+    resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+
   xmlchars@2.2.0:
     resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
 
@@ -10628,6 +10675,10 @@ snapshots:
 
   '@types/node@20.5.1': {}
 
+  '@types/node@25.9.6':
+    dependencies:
+      undici-types: 7.24.6
+
   '@types/normalize-package-data@2.4.4': {}
 
   '@types/nprogress@0.2.3': {}
@@ -12628,6 +12679,15 @@ snapshots:
     dependencies:
       esutils: 2.0.3
 
+  docx@9.7.1:
+    dependencies:
+      '@types/node': 25.9.6
+      hash.js: 1.1.7
+      jszip: 3.10.2
+      nanoid: 5.1.16
+      xml: 1.0.1
+      xml-js: 1.6.11
+
   dom-align@1.12.4: {}
 
   dom-scroll-into-view@2.0.1: {}
@@ -13913,6 +13973,11 @@ snapshots:
       is-number: 3.0.0
       kind-of: 4.0.0
 
+  hash.js@1.1.7:
+    dependencies:
+      inherits: 2.0.4
+      minimalistic-assert: 1.0.1
+
   hasown@2.0.4:
     dependencies:
       function-bind: 1.1.2
@@ -13991,7 +14056,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  http-proxy@1.18.1:
+  http-proxy@1.18.1(debug@4.4.3):
     dependencies:
       eventemitter3: 4.0.7
       follow-redirects: 1.16.0(debug@4.4.3)
@@ -13999,14 +14064,14 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  http-server@14.1.1:
+  http-server@14.1.1(debug@4.4.3):
     dependencies:
       basic-auth: 2.0.1
       chalk: 4.1.2
       corser: 2.0.1
       he: 1.2.0
       html-encoding-sniffer: 3.0.0
-      http-proxy: 1.18.1
+      http-proxy: 1.18.1(debug@4.4.3)
       mime: 1.6.0
       minimist: 1.2.8
       opener: 1.5.2
@@ -14104,6 +14169,8 @@ snapshots:
       p-pipe: 3.1.0
       replace-ext: 1.0.1
 
+  immediate@3.0.6: {}
+
   import-fresh@3.3.1:
     dependencies:
       parent-module: 1.0.1
@@ -15282,6 +15349,13 @@ snapshots:
       dompurify: 2.5.9
       html2canvas: 1.4.1
 
+  jszip@3.10.2:
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.8
+      setimmediate: 1.0.5
+
   junk@3.1.0: {}
 
   katex@0.16.47:
@@ -15336,6 +15410,10 @@ snapshots:
       prelude-ls: 1.2.1
       type-check: 0.4.0
 
+  lie@3.3.0:
+    dependencies:
+      immediate: 3.0.6
+
   lilconfig@2.1.0: {}
 
   lines-and-columns@1.2.4: {}
@@ -15673,6 +15751,8 @@ snapshots:
 
   min-indent@1.0.1: {}
 
+  minimalistic-assert@1.0.1: {}
+
   minimatch@10.2.5:
     dependencies:
       brace-expansion: 5.0.7
@@ -15764,6 +15844,8 @@ snapshots:
 
   nanoid@3.3.16: {}
 
+  nanoid@5.1.16: {}
+
   nanomatch@1.2.13:
     dependencies:
       arr-diff: 4.0.0
@@ -16051,6 +16133,8 @@ snapshots:
 
   package-manager-detector@1.7.0: {}
 
+  pako@1.0.11: {}
+
   param-case@3.0.4:
     dependencies:
       dot-case: 3.0.4
@@ -16778,6 +16862,8 @@ snapshots:
       is-plain-object: 2.0.4
       split-string: 3.1.0
 
+  setimmediate@1.0.5: {}
+
   shallow-equal@1.2.1: {}
 
   shebang-command@1.2.0:
@@ -17584,6 +17670,8 @@ snapshots:
 
   undici-types@6.21.0: {}
 
+  undici-types@7.24.6: {}
+
   unicode-canonical-property-names-ecmascript@2.0.1: {}
 
   unicode-match-property-ecmascript@2.0.0:
@@ -18250,10 +18338,16 @@ snapshots:
       wmf: 1.0.2
       word: 0.3.0
 
+  xml-js@1.6.11:
+    dependencies:
+      sax: 1.6.0
+
   xml-name-validator@3.0.0: {}
 
   xml-name-validator@4.0.0: {}
 
+  xml@1.0.1: {}
+
   xmlchars@2.2.0: {}
 
   xss@1.0.15:

+ 1 - 1
public/js/config.js

@@ -3,7 +3,7 @@ const VUE_APP_URL = {
   webRtcUrl: '/webRtc', // rtsp服务器IP地址
   wsUrl: window.location.origin + '/modelreq', // [SEC-20260913] WebSocket 地址:同源经 /modelreq 反代至网关(覆盖旧云环境硬编码 IP)
   onlyOfficeUrl: '/documents', // [perf-base-v1] 卡1: OnlyOffice Document Server 反向代理前缀(api.js 按需加载时拼接)
-  autoLoginToken: '', // [SEC-20260913-R2] 跨系统自动登录票据,由部署环境注入(与服务端 SDJT_AUTO_LOGIN_TOKEN 配套);空 = 不携带该头
+  autoLoginToken: 'MTIzNDU2', // [SEC-20260913-R2] 跨系统自动登录票据,由部署环境注入(与服务端 SDJT_AUTO_LOGIN_TOKEN 配套);空 = 不携带该头
 }
 const History_Type = {
   // type: 'remote', // remote、vent  (remote 代表的是历史查询走的装备院的接口,vent是走的咱们的,目前神东的项目都用remote, 其他矿用vent)

+ 1 - 0
src/assets/images/ventAI/chatModalBlue/2-11.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1789522526319" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6269" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M229.7856 197.5296a45.8752 45.8752 0 0 1 13.1072-59.5968A460.8 460.8 0 0 1 972.8 512a458.8032 458.8032 0 0 1-63.488 233.4208 21.6064 21.6064 0 0 1-37.4784-0.4096l-111.616-200.9088a21.6064 21.6064 0 0 1 18.944-32.1024H880.64A368.64 368.64 0 0 0 296.96 212.48a46.08 46.08 0 0 1-67.2256-14.9504z m564.4288 628.9408a45.9776 45.9776 0 0 1-13.056 59.5968A460.8 460.8 0 0 1 51.2 512 458.9056 458.9056 0 0 1 114.688 278.528a21.6064 21.6064 0 0 1 37.4784 0.4608l111.616 200.8576a21.6064 21.6064 0 0 1-18.8416 32.1024H143.36a368.64 368.64 0 0 0 583.5776 299.52 46.08 46.08 0 0 1 67.2256 14.9504z" fill="#ffffff" p-id="6270"></path></svg>

+ 1 - 0
src/assets/images/ventAI/chatModalBlue/2-12.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1789525304921" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8990" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M562.996016 643.229748V72.074369a50.996016 50.996016 0 0 0-101.992032 0v571.155379a50.996016 50.996016 0 0 0 101.992032 0z" fill="#ffffff" p-id="8991"></path><path d="M513.087915 144.080744L802.337317 432.446215a50.996016 50.996016 0 0 0 71.93838-72.210358L513.087915 0 149.588313 362.411687A50.996016 50.996016 0 0 0 221.594688 434.486056L513.087915 144.148738zM53.035857 643.229748v184.537583c0 109.471448 105.255777 192.832935 230.026029 192.832935h457.876228c124.770252 0 230.026029-83.361487 230.026029-192.832935V643.229748a50.996016 50.996016 0 1 0-101.992031 0v184.537583c0 47.256308-55.075697 90.840903-128.033998 90.840903H283.061886c-72.9583 0-128.033997-43.65259-128.033998-90.840903V643.229748a50.996016 50.996016 0 0 0-101.992031 0z" fill="#ffffff" p-id="8992"></path></svg>

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
src/assets/images/ventAI/chatModalBlue/2-13.svg


+ 1 - 0
src/assets/images/ventAI/chatModalBlue/2-14.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1789701287227" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10438" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-128 0a128 128 0 1 0 256 0 128 128 0 1 0-256 0Z" fill="#ffffff" p-id="10439"></path><path d="M554.666667 173.610667V85.333333h-85.333334v88.277334A341.674667 341.674667 0 0 0 173.610667 469.333333H85.333333v85.333334h88.277334A341.632 341.632 0 0 0 469.333333 850.389333V938.666667h85.333334v-88.277334A341.632 341.632 0 0 0 850.389333 554.666667H938.666667v-85.333334h-88.277334A341.674667 341.674667 0 0 0 554.666667 173.610667zM512 768c-141.184 0-256-114.816-256-256s114.816-256 256-256 256 114.816 256 256-114.816 256-256 256z" fill="#ffffff" p-id="10440"></path></svg>

+ 5 - 0
src/components/Application/src/AppLogo.vue

@@ -83,11 +83,16 @@
       color: @white;
     }
 
+    &.vent1 &__title {
+      color: @white;
+    }
+
     &__title {
       font-size: 18px;
       font-weight: 700;
       transition: all 0.5s;
       line-height: normal;
+      color: @white;
     }
   }
 </style>

+ 4 - 0
src/design/themify/deepblue.less

@@ -124,6 +124,10 @@ html[data-theme='deepblue'] {
   --img-chat-mode-auto-icon: url('/src/assets/images/ventAI/chatModalBlue/3-10.svg');
   --img-chat-mode-plan-icon: url('/src/assets/images/ventAI/chatModalBlue/3-11.svg');
   --img-chat-mode-full-icon: url('/src/assets/images/ventAI/chatModalBlue/3-12.svg');
+  --img-chat-regenerate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
+  --img-chat-export-icon: url('/src/assets/images/ventAI/chatModalBlue/2-12.svg');
+  --img-chat-link-icon: url('/src/assets/images/ventAI/chatModalBlue/2-13.svg');
+  --img-chat-locate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-14.svg');
 
   // 全屏模式图片
   --img-chat-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

+ 4 - 0
src/design/themify/default.less

@@ -132,6 +132,10 @@ html {
   --img-chat-mode-auto-icon: url('/src/assets/images/ventAI/chatModalBlue/3-10.svg');
   --img-chat-mode-plan-icon: url('/src/assets/images/ventAI/chatModalBlue/3-11.svg');
   --img-chat-mode-full-icon: url('/src/assets/images/ventAI/chatModalBlue/3-12.svg');
+  --img-chat-regenerate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
+  --img-chat-export-icon: url('/src/assets/images/ventAI/chatModalBlue/2-12.svg');
+  --img-chat-link-icon: url('/src/assets/images/ventAI/chatModalBlue/2-13.svg');
+  --img-chat-locate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-14.svg');
 
   // 全屏模式图片
   --img-chat-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

+ 7 - 1
src/layouts/default/header/components/user-dropdown/HomeStyleSelect.vue

@@ -40,6 +40,7 @@
   import { getPermissionList } from '/@/api/sys/menu';
   import { getSavedHomeStyle, saveHomeStyle } from '/@/utils/homeStyle';
   import type { HomeStyleOption } from '/@/utils/homeStyle';
+  import projectSetting from '/@/settings/projectSetting';
 
   const visible = ref(false);
   const selectedId = ref<string>('');
@@ -51,6 +52,10 @@
   const glob = useGlobSetting();
   const { setDarkMode } = useRootSetting();
 
+  function getSystemDefaultTheme(): ThemeEnum {
+    return (projectSetting.headerSetting?.theme as ThemeEnum) || ThemeEnum.VENT1;
+  }
+
   /** 递归展平菜单树(兼容 children 嵌套) */
   function flattenMenu(nodes: any[], list: any[] = []): any[] {
     nodes?.forEach((node) => {
@@ -164,7 +169,8 @@
     const defaultOption = options.value.find((o) => o.route === glob.homePath);
     saveHomeStyle(null);
     selectedId.value = defaultOption?.id || '';
-    applyTheme(defaultOption ?? { theme: ThemeEnum.VENT1 });
+    // 系统默认首页未在可选列表中注册时,主题退回生效的项目配置,而不是写死 vent1
+    applyTheme(defaultOption || { theme: getSystemDefaultTheme() });
     createMessage.success('已恢复默认首页风格,正在应用系统默认首页…');
     visible.value = false;
     go(glob.homePath || PageEnum.BASE_HOME);

+ 7 - 1
src/layouts/default/index.vue

@@ -24,7 +24,7 @@
     </div>
 
     <!-- AI助手弹窗 -->
-    <AiAssistantModal :visible="aiAssistantVisible" @close="aiAssistantVisible = false" />
+    <AiAssistantModal :visible="aiAssistantVisible" :get-container="getAiModalContainer" @close="aiAssistantVisible = false" />
   </Layout>
 </template>
 
@@ -86,6 +86,11 @@
         aiAssistantVisible.value = true;
       };
 
+      // AI 弹框渲染进 adaptive-container,与页面同坐标系、同缩放;最大化后即与 AI 首页(homeAI)形态一致
+      function getAiModalContainer() {
+        return document.getElementById('adaptive-container') || document.body;
+      }
+
       // 拖动辅助函数
       const BTN_SIZE = 80;
       function getLayoutHeight() {
@@ -178,6 +183,7 @@
         aiAssistantVisible,
         aiBtnPosition,
         openAiAssistant,
+        getAiModalContainer,
         handleDragStart,
       };
     },

+ 1 - 1
src/utils/index.ts

@@ -184,7 +184,7 @@ export function getQueryVariable(url) {
     r,
     i = url.split('?')[1],
     s = {};
-  (t = i.split('&')), (r = null), (n = null);
+  ((t = i.split('&')), (r = null), (n = null));
   for (const o in t) {
     const u = t[o].indexOf('=');
     u !== -1 && ((r = t[o].substr(0, u)), (n = t[o].substr(u + 1)), (s[r] = n));

+ 178 - 182
src/utils/threejs/parseModal.ts

@@ -1,5 +1,5 @@
-import * as THREE from "three";
-import { THREEMaterialType, IBaseProps, IGroupParams, IMeshParams, IPointLight } from "./type/types";
+import * as THREE from 'three';
+import { THREEMaterialType, IBaseProps, IGroupParams, IMeshParams, IPointLight } from './type/types';
 
 /**
  * glb模型并发加载工作原理
@@ -15,226 +15,222 @@ import { THREEMaterialType, IBaseProps, IGroupParams, IMeshParams, IPointLight }
 /**
  * 通过设置attributes index来复刻一个集合体
  */
-const genGeometry = (geometry: IMeshParams["geometry"], IMeshParams) => {
-    // console.log('目标------>',geometry);
-    const geom = new THREE.BufferGeometry();
-    const { 
-        attributes: { position, uv, normal },
-        index,
-    } = geometry;
-
-    
-    //处理几何坐标
-    if(uv){
-        const attributes = {
-            position: new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
-            uv: new THREE.BufferAttribute(uv.array, uv.itemSize, uv.normalized),
-            normal: new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized),
-        };
-        geom.attributes = attributes;
-    }else {
-        const attributes = {
-            position: new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
-            normal: new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized),
-        };
-        // geom.attributes['position'] = new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
-        // geom.attributes['normal'] = new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized)
-        geom.attributes = attributes;
-    }
-    
-    
-    geom.index = index ? new THREE.BufferAttribute(index.array, index.itemSize, index.normalized) : null;
-    return geom;
+const genGeometry = (geometry: IMeshParams['geometry'], IMeshParams) => {
+  // console.log('目标------>',geometry);
+  const geom = new THREE.BufferGeometry();
+  const {
+    attributes: { position, uv, normal },
+    index,
+  } = geometry;
+
+  //处理几何坐标
+  if (uv) {
+    const attributes = {
+      position: new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
+      uv: new THREE.BufferAttribute(uv.array, uv.itemSize, uv.normalized),
+      normal: new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized),
+    };
+    geom.attributes = attributes;
+  } else {
+    const attributes = {
+      position: new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
+      normal: new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized),
+    };
+    // geom.attributes['position'] = new THREE.BufferAttribute(position.array, position.itemSize, position.normalized),
+    // geom.attributes['normal'] = new THREE.BufferAttribute(normal.array, normal.itemSize, normal.normalized)
+    geom.attributes = attributes;
+  }
+
+  geom.index = index ? new THREE.BufferAttribute(index.array, index.itemSize, index.normalized) : null;
+  return geom;
 };
 
-
-
 /**
  * 根据传入纹理的参数生成真正有效的Material类型数据
  */
-const genMaterial = (mate: IMeshParams["material"]) => {
-    if (!mate) return undefined;
-    const multipleMaterial = Array.isArray(mate);
-    const material = multipleMaterial ? ([] as THREE.Material[]) : new THREE[mate.type as THREEMaterialType]();
-    //处理材质
-    //多个材质
-    if (multipleMaterial && Array.isArray(material)) {
-        console.log('有多个材质');
-        
-        for (const m of mate) {
-            const im = new THREE[m.type as THREEMaterialType]();
-
-            material.push(im);
+const genMaterial = (mate: IMeshParams['material']) => {
+  if (!mate) return undefined;
+  const multipleMaterial = Array.isArray(mate);
+  const material = multipleMaterial ? ([] as THREE.Material[]) : new THREE[mate.type as THREEMaterialType]();
+  //处理材质
+  //多个材质
+  if (multipleMaterial && Array.isArray(material)) {
+    console.log('有多个材质');
+
+    for (const m of mate) {
+      const im = new THREE[m.type as THREEMaterialType]();
+
+      material.push(im);
+    }
+  } else if (mate) {
+    //单个材质
+    Object.assign(material, mate);
+    for (const key in mate) {
+      if (Object.prototype.hasOwnProperty.call(mate, key)) {
+        const element = mate[key];
+        if (element && Object.prototype.toString.call(element === '[object Object]')) {
+          if (element.isTexture) {
+            const texture = new THREE.Texture();
+            for (const k in texture) {
+              if (Object.prototype.toString.call(texture[k]) === '[object Object]') {
+                if (k === 'center' || k === 'offset' || k === 'repeat') {
+                  texture[k] = new THREE.Vector2();
+                }
+                if (k === 'matrix') {
+                  texture[k] = new THREE.Matrix3();
+                }
+                if (k === 'source') {
+                  texture[k] = new THREE.Source(null);
+                }
+                Object.assign(texture[k], element[k]);
+              } else {
+                texture[k] = element[k];
+              }
+            }
+            material[key] = texture;
+          }
+          if (element.isColor) {
+            const color = new THREE.Color();
+            Object.assign(color, element);
+            material[key] = color;
+          }
+          if (key === 'normalScale') {
+            const vector2 = new THREE.Vector2();
+            Object.assign(vector2, element);
+            material[key] = vector2;
+          }
         }
-    } else if (mate) {
-        //单个材质
-        Object.assign(material, mate);
-        for (const key in mate) {
-            if (Object.prototype.hasOwnProperty.call(mate, key)) {
-                const element = mate[key];
-                if(element && Object.prototype.toString.call(element === '[object Object]')) {
-                    if( element.isTexture){
-                        const texture = new THREE.Texture()
-                        for (const k in texture) {
-                            if(Object.prototype.toString.call(texture[k]) === '[object Object]'){
-                                if(k==='center' || k==='offset' || k==='repeat'){
-                                    texture[k] = new THREE.Vector2()
-                                }
-                                if(k === 'matrix'){
-                                    texture[k] = new THREE.Matrix3()
-                                }
-                                if(k === 'source'){
-                                    texture[k] = new THREE.Source(null)
-                                }
-                                Object.assign(texture[k] , element[k]);
-                            }else {
-                                texture[k] =  element[k]
-                            }
-                        }
-                        material[key] = texture
-                    }
-                    if(element.isColor){
-                        const color = new THREE.Color()
-                        Object.assign(color, element);
-                        material[key] = color
-                    }
-                    if(key === 'normalScale'){
-                        const vector2 = new THREE.Vector2()
-                        Object.assign(vector2, element);
-                        material[key] = vector2
-                    }
-                }   
-            }   
-        }   
-    }    
-    return material;
+      }
+    }
+  }
+  return material;
 };
 
 /**
  * 处理基本属性转换(Object3D基类上的属性) matrix scale rotate translate position children  animations
  */
 const parseBaseParams = (params: IBaseProps, object: THREE.Object3D) => {
-    const matrix = new THREE.Matrix4();
-    matrix.elements = params.matrix.elements;
-    object.name = params.name;
-    object.matrix = matrix;
-    object.rotation.set(...params.rotation);
-    object.position.set(...params.position);
-    object.scale.set(...params.scale);
-    object.quaternion.set(...params.quaternion);
-    object.up.set(...params.up);
-    object.userData = params.userData;
-    object.visible = params.visible;
-
-    parseChildren(object, params.children);
-    genAnimations(object, params.animations);
-    // deleteObjectKeys(params, [
-    //     "name",
-    //     "matrix",
-    //     "rotation",
-    //     "position",
-    //     "scale",
-    //     "quaternion",
-    //     "up",
-    //     "userData",
-    //     "visible",
-    // ]);
-    // object.scale.x += 0.3;
-    // object.scale.y += 0.3;
-    // object.scale.z += 0.3;
+  const matrix = new THREE.Matrix4();
+  matrix.elements = params.matrix.elements;
+  object.name = params.name;
+  object.matrix = matrix;
+  object.rotation.set(...params.rotation);
+  object.position.set(...params.position);
+  object.scale.set(...params.scale);
+  object.quaternion.set(...params.quaternion);
+  object.up.set(...params.up);
+  object.userData = params.userData;
+  object.visible = params.visible;
+
+  parseChildren(object, params.children);
+  genAnimations(object, params.animations);
+  // deleteObjectKeys(params, [
+  //     "name",
+  //     "matrix",
+  //     "rotation",
+  //     "position",
+  //     "scale",
+  //     "quaternion",
+  //     "up",
+  //     "userData",
+  //     "visible",
+  // ]);
+  // object.scale.x += 0.3;
+  // object.scale.y += 0.3;
+  // object.scale.z += 0.3;
 };
 
 const parseMesh = (IMeshParams: IMeshParams) => {
-    const geometry = genGeometry(IMeshParams.geometry, IMeshParams);
-    const material = genMaterial(IMeshParams.material);
+  const geometry = genGeometry(IMeshParams.geometry, IMeshParams);
+  const material = genMaterial(IMeshParams.material);
 
-    const mesh = new THREE.Mesh(geometry, material);
-    parseBaseParams(IMeshParams, mesh);
-    return mesh;
+  const mesh = new THREE.Mesh(geometry, material);
+  parseBaseParams(IMeshParams, mesh);
+  return mesh;
 };
 
 const parseGroup = (params: IGroupParams) => {
-    const group = new THREE.Group();
-    parseBaseParams(params, group);
-    return group;
+  const group = new THREE.Group();
+  parseBaseParams(params, group);
+  return group;
 };
 
 const parsePointLight = (params: IPointLight) => {
-    const color = new THREE.Color();
-    // 色彩空间
-    // export type ColorSpace = NoColorSpace | SRGBColorSpace | LinearSRGBColorSpace;
-    // export type NoColorSpace = '';
-    // export type SRGBColorSpace = 'srgb';
-    // export type LinearSRGBColorSpace = 'srgb-linear';
-    //glb模型为了亮度恢复 使用srgb格式 所以颜色也使用同样格式 使其颜色模式一致
-    color.setRGB(params.color.r, params.color.g, params.color.b, "srgb-linear");
-
-    const pointLight = new THREE.PointLight(color, params.intensity, params.distance, params.decay);
-    parseBaseParams(params, pointLight);
-    return pointLight;
+  const color = new THREE.Color();
+  // 色彩空间
+  // export type ColorSpace = NoColorSpace | SRGBColorSpace | LinearSRGBColorSpace;
+  // export type NoColorSpace = '';
+  // export type SRGBColorSpace = 'srgb';
+  // export type LinearSRGBColorSpace = 'srgb-linear';
+  //glb模型为了亮度恢复 使用srgb格式 所以颜色也使用同样格式 使其颜色模式一致
+  color.setRGB(params.color.r, params.color.g, params.color.b, 'srgb-linear');
+
+  const pointLight = new THREE.PointLight(color, params.intensity, params.distance, params.decay);
+  parseBaseParams(params, pointLight);
+  return pointLight;
 };
 
 const parseObject3D = (params: IBaseProps) => {
-    const object = new THREE.Object3D();
-    parseBaseParams(params, object);
-    return object;
+  const object = new THREE.Object3D();
+  parseBaseParams(params, object);
+  return object;
 };
 
 const parseChildren = (object3D: THREE.Object3D, children: IBaseProps[]) => {
-    if (!children.length) return;
-    const objectList: THREE.Object3D[] = [];
-    for (const child of children) {
-        const { type } = child;
-        if (type === "Mesh") {
-            objectList.push(parseMesh(child as IMeshParams));
-        } else if (type === "Group") {
-            objectList.push(parseGroup(child));
-        } else if (type === "PointLight") {
-            objectList.push(parsePointLight(child as IPointLight));
-        } else if (type === "Object3D") {
-            objectList.push(parseObject3D(child));
-        } else {
-            throw new Error("出现了未处理的类型:" + type);
-        }
+  if (!children.length) return;
+  const objectList: THREE.Object3D[] = [];
+  for (const child of children) {
+    const { type } = child;
+    if (type === 'Mesh') {
+      objectList.push(parseMesh(child as IMeshParams));
+    } else if (type === 'Group') {
+      objectList.push(parseGroup(child));
+    } else if (type === 'PointLight') {
+      objectList.push(parsePointLight(child as IPointLight));
+    } else if (type === 'Object3D') {
+      objectList.push(parseObject3D(child));
+    } else {
+      throw new Error('出现了未处理的类型:' + type);
     }
+  }
 
-    object3D.add(...objectList);
+  object3D.add(...objectList);
 };
 /**
  * 生成动画
  */
-const genAnimations = (object3D: THREE.Object3D, sceneAnimations: IGroupParams["sceneAnimations"]) => {
-    if (!sceneAnimations) return;
-    const animations: THREE.AnimationClip[] = [];
-
-    for (const animation of sceneAnimations!) {
-        const clip = new THREE.AnimationClip(animation.name, animation.duration, [], animation.blendMode);
-        //@ts-ignore
-        for (const { name, times, values, type } of animation.tracks) {
-            let nreTrack;
-            if(type){
-                if(type === 'vector') nreTrack = new THREE.VectorKeyframeTrack(name, times as any, values as any);
-                if(type === 'quaternion') nreTrack = new THREE.QuaternionKeyframeTrack(name, times as any, values as any);
-            }else {
-                nreTrack = new THREE.QuaternionKeyframeTrack(name, times as any, values as any);
-            }
-
-            clip.tracks.push(nreTrack);
-        }
-
-        animations.push(clip);
+const genAnimations = (object3D: THREE.Object3D, sceneAnimations: IGroupParams['sceneAnimations']) => {
+  if (!sceneAnimations) return;
+  const animations: THREE.AnimationClip[] = [];
+
+  for (const animation of sceneAnimations!) {
+    const clip = new THREE.AnimationClip(animation.name, animation.duration, [], animation.blendMode);
+    //@ts-ignore
+    for (const { name, times, values, type } of animation.tracks) {
+      let nreTrack;
+      if (type) {
+        if (type === 'vector') nreTrack = new THREE.VectorKeyframeTrack(name, times as any, values as any);
+        if (type === 'quaternion') nreTrack = new THREE.QuaternionKeyframeTrack(name, times as any, values as any);
+      } else {
+        nreTrack = new THREE.QuaternionKeyframeTrack(name, times as any, values as any);
+      }
+
+      clip.tracks.push(nreTrack);
     }
 
-    object3D.animations = animations;
+    animations.push(clip);
+  }
+
+  object3D.animations = animations;
 };
 
 /**
  * 解析传入的模型参数生成有效的three.js物体
  */
 export const parseModel = (params: IGroupParams) => {
-    const model = parseGroup(params);
-    // model.position.x += 10;
-    genAnimations(model, params.sceneAnimations);
-    // console.log("解析完:", model);
-    return model;
+  const model = parseGroup(params);
+  // model.position.x += 10;
+  genAnimations(model, params.sceneAnimations);
+  // console.log("解析完:", model);
+  return model;
 };

+ 17 - 54
src/views/vent/home/homeAI/index.vue

@@ -1,46 +1,28 @@
 <!-- eslint-disable vue/multi-word-component-names -->
 <template>
   <div class="home-ai-page">
-    <AiAssistantModal :visible="visible" default-fullscreen :show-controls="false" />
-    <Teleport to="body">
-      <div class="home-menu-layer" :style="menuLayerStyle">
-        <BottomSider />
-        <div class="user-dropdown-wrap">
-          <UserDropDown :theme="getHeaderTheme" />
-        </div>
-      </div>
-    </Teleport>
+    <AiAssistantModal :visible="visible" default-fullscreen :show-controls="false" :get-container="getAiContainer" />
   </div>
 </template>
 
 <script setup lang="ts">
-  import { computed, onMounted, ref } from 'vue';
+  import { onMounted, onUnmounted, ref } from 'vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
-  import BottomSider from '/@/layouts/default/sider/bottomSideder.vue';
-  import UserDropDown from '/@/layouts/default/header/components/user-dropdown/index.vue';
-  import { useAppStore } from '/@/store/modules/app';
-  import { useHeaderSetting } from '/@/hooks/setting/useHeaderSetting';
-
-  const { getHeaderTheme } = useHeaderSetting();
-
   const visible = ref(false);
-  const appStore = useAppStore();
+
+  // AI 弹框渲染进 #adaptive-container,与页面同坐标系、同缩放:
+  // 布局层的 BottomSider 与本页右上用户下拉(z-index 均高于弹框)才能正常盖在弹框之上
+  const getAiContainer = () => document.getElementById('adaptive-container') || document.body;
 
   onMounted(() => {
+    // 标记 AI 首页:隐藏布局层右侧操作栏(天气/语音/模式切换等),仅保留本页内嵌的右上用户下拉
+    document.body.classList.add('ai-home-page');
     // 初始 false 再置 true,触发 AiAssistantModal 的 visible watcher 完成会话列表等初始化加载
     visible.value = true;
   });
 
-  // 布局层被 #adaptive-container 的 transform 层叠上下文锁住,
-  // 无法盖过挂在 body 层的全屏 AI 弹框;将菜单和右上操作栏 teleport 到 body,
-  // 并用与容器一致的尺寸+缩放复刻坐标系,使其显示在弹框之上
-  const menuLayerStyle = computed(() => {
-    const container = document.getElementById('adaptive-container');
-    return {
-      width: `${container?.clientWidth || 1920}px`,
-      height: `${container?.clientHeight || 928}px`,
-      transform: `scale(${appStore.getWidthScale}, ${appStore.getHeightScale})`,
-    };
+  onUnmounted(() => {
+    document.body.classList.remove('ai-home-page');
   });
 </script>
 
@@ -50,32 +32,13 @@
     height: 100%;
   }
 
-  .home-menu-layer {
-    position: fixed;
-    top: 0;
-    left: 0;
-    z-index: 9999999;
-    transform-origin: left top;
-    pointer-events: none;
 
-    > * {
-      pointer-events: auto;
-    }
-  }
-
-  // 右上角用户下拉:fixed 定位复刻原 RightPositionBar 容器(top 44 / right 20),
-  // 并重置 UserDropDown 根节点右侧 padding 以贴齐右边缘
-  .user-dropdown-wrap {
-    position: fixed;
-    top: 16px;
-    right: 48px;
-    z-index: 999999;
-    height: 48px;
-    display: flex;
-    align-items: center;
+</style>
 
-    :deep(.jeecg-header-user-dropdown) {
-      padding: 0 16px 0 0;
-    }
+<!-- <style lang="less">
+  // 全局(非 scoped):AI 首页隐藏布局层 header 的右侧操作栏,改由本页内嵌的右上用户下拉替代,
+  // 避免与布局层重复渲染天气/语音/模式切换及用户下拉等控件
+  body.ai-home-page .jeecg-layout-header-action {
+    display: none !important;
   }
-</style>
+</style> -->

+ 1 - 1
src/views/vent/monitorManager/compressor/components/nitrogenHome_bet.vue

@@ -488,7 +488,7 @@
   watch([monitorDataGroupNum, loading], ([newMonitorDataGroupNum, newLoading]) => {
     nextTick(() => {
       if (newMonitorDataGroupNum && !newLoading) {
-        setModelType(props.modalType, newMonitorDataGroupNum);
+        setModelType('nitrogenFlex', newMonitorDataGroupNum);
       }
     });
   });

+ 329 - 0
src/views/vent/monitorManager/compressor/nitrogen.flex.threejs.ts

@@ -0,0 +1,329 @@
+import * as THREE from 'three';
+import { CSS3DSprite } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
+import { setModalCenter } from '/@/utils/threejs/util';
+
+/**
+ * 注氮机地下管路模型(nitrogenFlex)
+ *
+ * 模型构成:
+ *   - 基底:nitrogen_base(管路 / 底座)
+ *   - 注氮机:nitrogen_ele_1 / nitrogen_ele_2 / nitrogen_ele_3
+ *
+ * ── 重要:三个注氮机模型是「预制好机位」的,必须原位(identity)摆放 ──
+ */
+class NitrogenFlex {
+  model;
+  /** 基底模型名 */
+  baseName = 'nitrogen_base';
+  /** 注氮机模型名,按机数取前 N 个(对应 右 / 中 / 左 机位) */
+  unitNames = ['nitrogen_ele_1', 'nitrogen_ele_2', 'nitrogen_ele_3'];
+  /** 总根节点 */
+  group: THREE.Object3D = new THREE.Object3D();
+  /** 已加载的注氮机模板 */
+  private unitTemplates: (THREE.Object3D | null)[] = [];
+  /** 当前场景中实际显示的注氮机节点 */
+  private unitGroups: THREE.Object3D[] = [];
+  /** 本模型显示的 css 详情元素数组 */
+  cssSprites: CSS3DSprite[] = [];
+  /** 当前机数 */
+  nitrogenNum = 0;
+  /** css 精灵世界缩放 */
+  private spriteScale = 0.0062;
+  /** 模型是否加载完成 */
+  private loaded = false;
+
+  /** 所有机体整体的垂直微调(默认 0 = 模型设计高度) */
+  private unitLiftY = 0;
+  /** 所有机体整体的深度微调(默认 0) */
+  private unitOffsetZ = 0;
+  /** 下风侧监测挂载位置:相对基底包围盒归一化(0=最小边,1=最大边) */
+  private downWindAnchor = { nx: 0.04, ny: 0.75, nz: 0.5 };
+
+  /**
+   * 相机取景:模型宽度占画面的比例(0.92 = 横向占满 92%)。
+   * 调大 = 相机更近 = 模型更大。
+   */
+  private cameraWidthFill = 0.92;
+  /** 相机距离下限(包围球拟合 × 该系数),防止超宽屏时贴太近被裁 */
+  private cameraMinFactor = 0.62;
+  /** 相机观察方向(从前上方俯视),会归一化后乘以上面的距离 */
+  private cameraDir = { x: 0.18, y: 0.85, z: 1.35 };
+  /** 机顶气泡再往上的高度 */
+  private spriteTopOffset = 0.34;
+
+  constructor(model) {
+    this.model = model;
+    this.group.name = 'nitrogenFlex';
+  }
+
+  addLight() {
+    // 灯光按场景尺度推导,避免小场景过曝、大场景欠曝
+    const box = new THREE.Box3().setFromObject(this.group);
+    const size = box.getSize(new THREE.Vector3());
+    const center = box.getCenter(new THREE.Vector3());
+    const maxDim = Math.max(size.x, size.y, size.z) || 1;
+
+    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
+    directionalLight.position.set(center.x + maxDim * 0.6, center.y + maxDim * 1.2, center.z + maxDim * 0.8);
+    directionalLight.target = this.group;
+    this.group.add(directionalLight);
+
+    const pointLight = new THREE.PointLight(0xffffff, 1, maxDim * 3);
+    pointLight.position.set(center.x, center.y + maxDim * 0.9, center.z + maxDim * 0.5);
+    this.group.add(pointLight);
+  }
+
+  /** 加载基底与三台注氮机模型 */
+  mountedThree() {
+    return new Promise((resolve) => {
+      const names = [this.baseName, ...this.unitNames];
+      this.model
+        .setGLTFModel(names)
+        .then((gltf) => {
+          const base = gltf[0];
+          if (base) {
+            base.name = this.baseName;
+            this.group.add(base);
+          } else {
+            console.warn(`[nitrogen-flex] 基底模型 ${this.baseName} 加载失败`);
+          }
+
+          this.unitTemplates = [];
+          for (let i = 0; i < this.unitNames.length; i++) {
+            const unit = gltf[i + 1];
+            if (!unit) {
+              console.warn(`[nitrogen-flex] 注氮机模型 ${this.unitNames[i]} 加载失败`);
+              this.unitTemplates.push(null);
+              continue;
+            }
+            unit.name = 'nitrogenUnitTpl' + i;
+            this.unitTemplates.push(unit);
+          }
+
+          this.loaded = true;
+          resolve(null);
+        })
+        .catch((e) => {
+          console.error('[nitrogen-flex] 模型加载异常', e);
+          this.loaded = true;
+          resolve(null);
+        });
+    });
+  }
+
+  /**
+   * 探测「机体」在模型内的位置。
+   *
+   * 每个 ele 模型除了机体,还含一条从机体延伸到管汇的长管路,
+   * 整体包围盒的中心会被管路拉偏,因此不能直接用整体包围盒做气泡锚点。
+   * 这里按尺寸特征找机体主箱体:X 宽 1.0~2.0 且 Y 高 > 0.4 的网格。
+   */
+  private computeMachineAnchor(unit: THREE.Object3D) {
+    unit.updateMatrixWorld(true);
+
+    const meshes: THREE.Mesh[] = [];
+    unit.traverse((o) => {
+      if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh);
+    });
+
+    let bestBox: THREE.Box3 | undefined;
+    let bestWidth = -1;
+    for (const mesh of meshes) {
+      const box = new THREE.Box3().setFromObject(mesh);
+      if (box.isEmpty()) continue;
+      const s = box.getSize(new THREE.Vector3());
+      if (s.x > 1.0 && s.x < 2.0 && s.y > 0.4 && s.x > bestWidth) {
+        bestBox = box;
+        bestWidth = s.x;
+      }
+    }
+
+    if (bestBox) {
+      const center = bestBox.getCenter(new THREE.Vector3());
+      return { x: center.x, y: bestBox.max.y, z: center.z };
+    }
+
+    // 回退:整体包围盒中心(仅在识别失败时使用)
+    const box = new THREE.Box3().setFromObject(unit);
+    const center = box.getCenter(new THREE.Vector3());
+    return { x: center.x, y: box.max.y, z: center.z };
+  }
+
+  /**
+   * 相机参数:按「包围球 + 相机 FOV + 画面宽高比」推导取景距离。
+   *
+   * 本场景宽而扁(约 8.58 × 1.12 × 1.78),横向才是真正的约束,
+   * 因此以「宽度刚好占满画面 WIDTH_FILL 比例」定距离;
+   * 再设一个包围球拟合的下限,避免超宽屏时相机贴得过近导致上下被裁。
+   *
+   * 相比写死坐标,这样在不同窗口尺寸下取景一致,不会屏幕一宽模型就变小。
+   */
+  getCameraConfig() {
+    const box = new THREE.Box3().setFromObject(this.group);
+    const sphere = box.getBoundingSphere(new THREE.Sphere());
+    const center = sphere.center;
+    const radius = sphere.radius || 1;
+    const size = box.getSize(new THREE.Vector3());
+
+    const cam = this.model?.camera;
+    const vFov = (((cam?.fov as number) ?? 50) * Math.PI) / 180;
+    const aspect = (cam?.aspect as number) || 1.8;
+    const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect);
+
+    // 按宽度装下
+    const dByWidth = size.x / 2 / Math.tan(hFov / 2) / this.cameraWidthFill;
+    // 下限:包围球拟合再乘系数,保证纵向也不会被裁
+    const dFloor = (radius / Math.sin(Math.min(vFov, hFov) / 2)) * this.cameraMinFactor;
+    const distance = Math.max(dByWidth, dFloor);
+
+    const dir = new THREE.Vector3(this.cameraDir.x, this.cameraDir.y, this.cameraDir.z).normalize();
+    const pos = center.clone().add(dir.multiplyScalar(distance));
+
+    return {
+      camera: { x: pos.x, y: pos.y, z: pos.z },
+      target: { x: center.x, y: center.y, z: center.z },
+    };
+  }
+
+  /**
+   * 设置机数并重建机体。
+   * 模型为原位摆放:克隆后不做任何缩放/位移,机位与管路对接由模型自身保证。
+   */
+  setUnitCount(num: number) {
+    if (!this.loaded) return;
+    const count = Math.max(0, Math.min(this.unitNames.length, Number(num) || 0));
+    this.nitrogenNum = count;
+
+    this.clearCssText();
+    this.unitGroups.forEach((u) => this.group.remove(u));
+    this.unitGroups = [];
+
+    for (let i = 0; i < count; i++) {
+      const tpl = this.unitTemplates[i];
+      if (!tpl) {
+        console.warn(`[nitrogen-flex] 第 ${i + 1} 台注氮机模型缺失,跳过`);
+        continue;
+      }
+      const unit = tpl.clone(true);
+      unit.name = 'nitrogenUnit' + i;
+      // 原位摆放:保持模型内置机位,杜绝错位与管路脱开
+      unit.position.set(0, 0, 0);
+      unit.scale.set(1, 1, 1);
+      this.group.add(unit);
+      this.unitGroups.push(unit);
+    }
+
+    // 整体垂直/深度微调(默认 0,即模型设计位置)
+    if (this.unitLiftY || this.unitOffsetZ) {
+      this.unitGroups.forEach((u) => {
+        u.position.set(0, this.unitLiftY, this.unitOffsetZ);
+      });
+    }
+
+    setModalCenter(this.group);
+    this.addCssText();
+  }
+
+  /** 挂载数据气泡:三台机的监测框 + 下风侧监测框 */
+  addCssText = () => {
+    this.unitGroups.forEach((unit, i) => {
+      if (!unit) return;
+      if (unit.getObjectByName('monitorNitrogenText')) return;
+      const element = document.getElementById('nitrogenMonitor' + (i + 1)) as HTMLElement;
+      if (!element) return;
+
+      const anchor = this.computeMachineAnchor(unit);
+      const sprite = new CSS3DSprite(element);
+      sprite.name = 'monitorNitrogenText';
+      // CSS3DObject 构造会把元素设为 pointerEvents:'auto',会遮挡轨道控制器拖拽
+      element.style.pointerEvents = 'none';
+      sprite.scale.set(this.spriteScale, this.spriteScale, this.spriteScale);
+      // 气泡挂在机顶上方(机体位置由模型内部布局决定,故按探测结果定位)
+      sprite.position.set(anchor.x, anchor.y + this.spriteTopOffset, anchor.z);
+      unit.add(sprite);
+      this.cssSprites.push(sprite);
+    });
+
+    this.attachDownWindMonitor();
+  };
+
+  /**
+   * 挂载「下风侧监测」框。
+   *
+   * 若不显式挂载,该元素会以未定位状态留在 DOM 中,表现为跑到页面左上角。
+   * 这里按基底包围盒归一化锚点定位,默认放在管路的下风侧一端。
+   */
+  private attachDownWindMonitor() {
+    if (this.group.getObjectByName('downWindMonitor')) return;
+    const element = document.getElementById('downWindMonitor') as HTMLElement;
+    if (!element) return;
+
+    const base = this.group.getObjectByName(this.baseName);
+    if (!base) return;
+    const box = new THREE.Box3().setFromObject(base);
+    const { nx, ny, nz } = this.downWindAnchor;
+    const x = box.min.x + (box.max.x - box.min.x) * nx;
+    const y = box.min.y + (box.max.y - box.min.y) * ny;
+    const z = box.min.z + (box.max.z - box.min.z) * nz;
+
+    const sprite = new CSS3DSprite(element);
+    sprite.name = 'downWindMonitor';
+    element.style.pointerEvents = 'none';
+    sprite.scale.set(this.spriteScale, this.spriteScale, this.spriteScale);
+    sprite.position.set(x, y, z);
+    // 挂在 group 上而非某台机体,避免机体变换影响该框
+    this.group.add(sprite);
+    this.cssSprites.push(sprite);
+  }
+
+  clearCssText = () => {
+    this.cssSprites.forEach((s) => {
+      s.parent?.remove(s);
+    });
+    this.cssSprites = [];
+    // 兜底清理可能残留的精灵节点
+    this.unitGroups.forEach((u) => {
+      const old = u.getObjectByName('monitorNitrogenText');
+      if (old) u.remove(old);
+    });
+    const dw = this.group.getObjectByName('downWindMonitor');
+    if (dw) this.group.remove(dw);
+  };
+
+  /** 与既有类保持一致的接口(本模型无需额外处理) */
+  setModalPosition() {}
+
+  resetModel() {}
+
+  /**
+   * 布局/取景微调入口(默认值即当前效果,通常无需调整)。
+   * 仅在实物效果需要微调时使用,避免写死进结构里。
+   */
+  setLayoutTuning(opts: {
+    unitLiftY?: number;
+    unitOffsetZ?: number;
+    downWindAnchor?: { nx: number; ny: number; nz: number };
+    cameraWidthFill?: number;
+    spriteTopOffset?: number;
+  }) {
+    if (opts.unitLiftY !== undefined) this.unitLiftY = opts.unitLiftY;
+    if (opts.unitOffsetZ !== undefined) this.unitOffsetZ = opts.unitOffsetZ;
+    if (opts.downWindAnchor !== undefined) this.downWindAnchor = opts.downWindAnchor;
+    // 取景与气泡高度是纯渲染参数,改完直接重排即可生效
+    if (opts.cameraWidthFill !== undefined) this.cameraWidthFill = opts.cameraWidthFill;
+    if (opts.spriteTopOffset !== undefined) this.spriteTopOffset = opts.spriteTopOffset;
+    this.setUnitCount(this.nitrogenNum);
+  }
+
+  destroy() {
+    if (this.model) {
+      this.clearCssText();
+      this.model.clearGroup(this.group);
+      this.unitTemplates = [];
+      this.unitGroups = [];
+      this.model = null;
+    }
+  }
+}
+
+export default NitrogenFlex;

+ 33 - 0
src/views/vent/monitorManager/compressor/nitrogen.threejs.ts

@@ -3,6 +3,7 @@ import { animateCamera, setModalCenter, updateAxisCenter } from '/@/utils/threej
 import UseThree from '../../../../utils/threejs/useThree';
 import NitrogenOverground from './nitrogen.dishang.threejs';
 import NitrogenUnderground from './nitrogen.dixia.threejs';
+import NitrogenFlex from './nitrogen.flex.threejs';
 import useEvent from '../../../../utils/threejs/useEvent';
 // import * as dat from 'dat.gui';
 // const gui = new dat.GUI();
@@ -13,6 +14,7 @@ let model: UseThree | undefined, //
   group: THREE.Object3D | undefined,
   nitrogenObj: NitrogenOverground | undefined,
   nitrogenUndergroundObj: NitrogenUnderground | undefined,
+  nitrogenFlexObj: NitrogenFlex | undefined,
   modalType = 'nitrogen';
 
 const { mouseDownFn } = useEvent();
@@ -127,6 +129,30 @@ export const setModelType = (type, nitrogenNum?) => {
         resolve(null);
         await animateCamera(oldCameraPosition, oldControlsPosition, newCameraPosition, newControlsPosition, model, 0.8);
       }, 300);
+    } else if (modalType === 'nitrogenFlex' && nitrogenFlexObj && nitrogenFlexObj.group) {
+      // 地下管路模型(基底 + 注氮机),与地上模型、地下管网模型三者共存,互不影响
+      if (model?.scene?.getObjectByName('nitrogen') && nitrogenObj && nitrogenObj.group) {
+        model.scene.remove(nitrogenObj.group);
+        nitrogenObj.clearCssText();
+      }
+      if (model?.scene?.getObjectByName('nitrogenUnderground') && nitrogenUndergroundObj && nitrogenUndergroundObj.group) {
+        model.scene.remove(nitrogenUndergroundObj.group);
+        nitrogenUndergroundObj.clearCssText();
+      }
+
+      // 机数由后端决定(1~3 台),动态增减
+      nitrogenFlexObj.setUnitCount(nitrogenNum ?? 3);
+
+      group = nitrogenFlexObj.group;
+      const oldCameraPosition = { x: -1000, y: 100, z: 500 };
+      const oldControlsPosition = { x: -10, y: 10, z: 10 };
+      const { camera, target } = nitrogenFlexObj.getCameraConfig();
+
+      setTimeout(async () => {
+        model?.scene?.add(group as THREE.Object3D);
+        resolve(null);
+        await animateCamera(oldCameraPosition, oldControlsPosition, camera, target, model, 0.8);
+      }, 300);
     }
   });
 };
@@ -143,6 +169,11 @@ export const mountedThree = () => {
 
     nitrogenUndergroundObj = new NitrogenUnderground(model);
     await nitrogenUndergroundObj.mountedThree();
+
+    // 地下管路模型(基底 + 注氮机),作为新增视图与上面两个共存
+    nitrogenFlexObj = new NitrogenFlex(model);
+    await nitrogenFlexObj.mountedThree();
+
     setModelType('nitrogenUnderground');
     resolve(null);
     model.animate();
@@ -157,6 +188,8 @@ export const destroy = () => {
     nitrogenObj = undefined;
     nitrogenUndergroundObj?.destroy();
     nitrogenUndergroundObj = undefined;
+    nitrogenFlexObj?.destroy();
+    nitrogenFlexObj = undefined;
     group = undefined;
     model.destroy();
     model = undefined;

+ 1 - 4
src/views/vent/monitorManager/mainFanMonitor/components/entryThree.vue

@@ -6,10 +6,7 @@
       class="threejs-Object-CSS"
       style="position: absolute; top: 0; z-index: 1; width: 100%; height: 100%; overflow: hidden; pointer-events: none"
     >
-      <div
-        style="position: relative"
-        v-if="hasPermission('mainFan:hideCss3DModal') && selectData['modalTyoe'] && selectData['modalTyoe'] !== 'mainWindRect-Inverse'"
-      >
+      <div style="position: relative; display: none" v-if="selectData['modalTyoe'] && selectData['modalTyoe'] !== 'mainWindRect-Inverse'">
         <template v-if="!fanSwitchoverSimulation">
           <div class="elementTag" id="inputBox1" v-if="backMonitorIsShow">
             <div class="elementContent elementContent-r">

+ 39 - 37
src/views/vent/monitorManager/mainFanMonitor/main.threejs.ts

@@ -101,9 +101,7 @@ const mousemove = () => {
 /* 添加监控数据 */
 export const addText = () => {
   if (!mainWindObj) return;
-  if (modalType.includes('mainWindRect') && mainWindObj) {
-    return mainWindObj.addCssText.call(mainWindObj);
-  } else if (modalType === 'mainXjWindRect' && mainXjWindObj) {
+  if (modalType === 'mainXjWindRect' && mainXjWindObj) {
     return mainXjWindObj.addCssText.call(mainXjWindObj);
   } else if (modalType === 'mainLjWindRect' && mainLjWindObj) {
     return mainLjWindObj.addCssText.call(mainLjWindObj);
@@ -111,20 +109,30 @@ export const addText = () => {
     return mainLj3WindObj.addCssText.call(mainLj3WindObj);
   } else if (modalType === 'mainLjDtWindRect' && mainFanLjDtObj) {
     return mainFanLjDtObj.addCssText.call(mainFanLjDtObj);
+  } else if (modalType.includes('mainWindRect') && mainWindObj) {
+    return mainWindObj.addCssText.call(mainWindObj);
   }
 };
 
+const stopSmoke = () => {
+  mainWindObj?.stopSmoke();
+  mainXjWindObj?.stopSmoke();
+  mainLj3WindObj?.stopSmoke();
+  mainFanLjDtObj?.stopSmoke();
+  mainLjWindObj?.stopSmoke();
+};
+
 /* 刷新echarts曲线图 */
 export const resetEcharts = (selectData) => {
   if (!mainWindObj) return;
-  if (modalType.includes('mainWindRect') && mainWindObj) {
-    return mainWindObj.addEcharts.call(mainWindObj);
-  } else if (modalType === 'mainXjWindRect' && mainXjWindObj) {
+  if (modalType === 'mainXjWindRect' && mainXjWindObj) {
     return mainXjWindObj.addEcharts.call(mainXjWindObj);
   } else if (modalType === 'mainLjWindRect' && mainLjWindObj) {
     return mainLjWindObj.addEcharts.call(mainLjWindObj);
   } else if (modalType === 'mainWindRect3' && mainLj3WindObj) {
     return mainLj3WindObj.addEcharts.call(mainLj3WindObj);
+  } else if (modalType.includes('mainWindRect') && mainWindObj) {
+    return mainWindObj.addEcharts.call(mainWindObj);
   }
 };
 
@@ -250,9 +258,7 @@ export const playAnimate = async (selectData, duration?) => {
 
   let mainObj: mainWindRect | mainXjWindRect | mainWindLj3 | mainWindLjDt | undefined;
 
-  if (modalType.includes('mainWindRect')) {
-    mainObj = mainWindObj;
-  } else if (modalType === 'mainXjWindRect') {
+  if (modalType === 'mainXjWindRect') {
     mainObj = mainXjWindObj;
   } else if (modalType === 'mainLjWindRect') {
     mainObj = mainLjWindObj;
@@ -260,7 +266,10 @@ export const playAnimate = async (selectData, duration?) => {
     mainObj = mainLj3WindObj;
   } else if (modalType === 'mainLjDtWindRect') {
     mainObj = mainFanLjDtObj;
+  } else if (modalType.includes('mainWindRect')) {
+    mainObj = mainWindObj;
   }
+
   if (selectData && mainObj) {
     if (selectData['Fan1FreqHz'] == undefined || selectData['Fan1FreqHz'] == null || selectData['Fan1FreqHz'] == '')
       selectData['Fan1FreqHz'] = VENT_PARAM['fanSwitchoverSimulation'] ? 0 : 50;
@@ -634,36 +643,13 @@ export const setModelType = (type) => {
   modalType = type;
   return new Promise((resolve) => {
     // 停止气流动画
-    mainWindObj?.stopSmoke();
-    mainXjWindObj?.stopSmoke();
-    mainLj3WindObj?.stopSmoke();
-    mainFanLjDtObj?.stopSmoke();
-    mainLjWindObj?.stopSmoke();
+    stopSmoke();
     if (group) model?.scene?.remove(group);
-    if (modalType.includes('mainWindRect') && mainWindObj && mainWindObj.group) {
-      (<UseThree>model).startAnimation = mainWindObj.render.bind(mainWindObj);
-      group = mainWindObj.group;
-      setTimeout(async () => {
-        resolve(null);
-        // const position = mainWindObj.group.position;
-        const position = new THREE.Vector3(-1.0127, -3.4717, -7.864);
-        const oldCameraPosition = { x: -332.39, y: 283.47, z: 438.61 };
-        await animateCamera(
-          oldCameraPosition,
-          { x: -3.41, y: -29.01, z: 8.84 },
-          { x: -1.7927, y: 70.8399, z: 120.8451 },
-          { x: position.x, y: position.y, z: position.z },
-          model,
-          0.8
-        );
-        if (group) model?.scene?.add(group);
-      }, 300);
-    } else if (modalType === 'mainXjWindRect' && mainXjWindObj && mainXjWindObj.group) {
+    if (modalType === 'mainXjWindRect' && mainXjWindObj && mainXjWindObj.group) {
       (<UseThree>model).startAnimation = mainXjWindObj.render.bind(mainXjWindObj);
       group = mainXjWindObj.group;
       setTimeout(async () => {
         resolve(null);
-        // const position = mainWindObj.group.position;
         const position = new THREE.Vector3(12.96, 23.17, -23.16);
         const oldCameraPosition = { x: -332.39, y: 283.47, z: 438.61 };
         await animateCamera(
@@ -681,7 +667,6 @@ export const setModelType = (type) => {
       group = mainLjWindObj.group;
       setTimeout(async () => {
         resolve(null);
-        // const position = mainWindObj.group.position;
         const position = new THREE.Vector3(-32.98, -4.15, -8.14);
         const oldCameraPosition = { x: -332.39, y: 283.47, z: 438.61 };
         await animateCamera(
@@ -699,12 +684,12 @@ export const setModelType = (type) => {
       group = mainLj3WindObj.group;
       setTimeout(async () => {
         resolve(null);
-        const position = new THREE.Vector3(2.815, -7.014, -5.985);
+        const position = new THREE.Vector3(5.31, 1.337, 15.4);
         const oldCameraPosition = { x: -332.39, y: 283.47, z: 438.61 };
         await animateCamera(
           oldCameraPosition,
           { x: -3.41, y: -29.01, z: 8.84 },
-          { x: 5.128, y: 72.363, z: 93.655 },
+          { x: 4.67, y: 95.7, z: 109.5 },
           { x: position.x, y: position.y, z: position.z },
           model,
           0.8
@@ -728,6 +713,23 @@ export const setModelType = (type) => {
         );
         if (group) model?.scene?.add(group);
       }, 300);
+    } else if (modalType.includes('mainWindRect') && mainWindObj && mainWindObj.group) {
+      (<UseThree>model).startAnimation = mainWindObj.render.bind(mainWindObj);
+      group = mainWindObj.group;
+      setTimeout(async () => {
+        resolve(null);
+        const position = new THREE.Vector3(-1.0127, -3.4717, -7.864);
+        const oldCameraPosition = { x: -332.39, y: 283.47, z: 438.61 };
+        await animateCamera(
+          oldCameraPosition,
+          { x: -3.41, y: -29.01, z: 8.84 },
+          { x: -1.7927, y: 70.8399, z: 120.8451 },
+          { x: position.x, y: position.y, z: position.z },
+          model,
+          0.8
+        );
+        if (group) model?.scene?.add(group);
+      }, 300);
     }
   });
 };

+ 20 - 14
src/views/vent/monitorManager/mainFanMonitor/mainWind.li3.threejs.ts

@@ -75,47 +75,55 @@ class mainWindLj3 {
     const fengji3 = fengji?.getObjectByName('Feng3_2');
 
     // #1
+    const inputBox = document.getElementById('inputBox') as HTMLElement;
     if (!this.group.getObjectByName('monitorText1')) {
       const worldPosition = new THREE.Vector3();
       fengji1?.getObjectByName('Feng1_7')?.getWorldPosition(worldPosition);
-      const element = document.getElementById('inputBox') as HTMLElement;
-      if (element) {
-        const mainCSS3D = new CSS3DObject(element);
+
+      if (inputBox) {
+        const mainCSS3D = new CSS3DObject(inputBox);
         mainCSS3D.name = 'monitorText1';
         mainCSS3D.scale.set(0.09, 0.09, 0.09);
-        mainCSS3D.position.set(worldPosition.x + 12, worldPosition.y - 20, worldPosition.z - 20);
-        mainCSS3D.lookAt(worldPosition.x + 12, worldPosition.y - 0, worldPosition.z + 2);
+        mainCSS3D.position.set(worldPosition.x + 15, worldPosition.y - 20, worldPosition.z - 20);
+        mainCSS3D.lookAt(worldPosition.x + 15, worldPosition.y - 0, worldPosition.z + 2);
         this.group.add(mainCSS3D);
       }
+    } else if (!inputBox) {
+      this.group.getObjectByName('monitorText1')?.removeFromParent();
     }
-    debugger;
+
     //#2
+    const inputBox1 = document.getElementById('inputBox1') as HTMLElement;
     if (!this.group.getObjectByName('monitorText2')) {
       const worldPosition = new THREE.Vector3();
       fengji2?.getObjectByName('Feng2_44')?.getWorldPosition(worldPosition);
-      const element = document.getElementById('inputBox1') as HTMLElement;
-      if (element) {
-        const mainCSS3D = new CSS3DObject(element);
+      if (inputBox1) {
+        const mainCSS3D = new CSS3DObject(inputBox1);
         mainCSS3D.name = 'monitorText2';
         mainCSS3D.scale.set(0.09, 0.09, 0.09);
         mainCSS3D.position.set(worldPosition.x + 12, worldPosition.y - 20, worldPosition.z - 20);
         mainCSS3D.lookAt(worldPosition.x + 12, worldPosition.y - 0, worldPosition.z + 2);
         this.group.add(mainCSS3D);
       }
+    } else if (!inputBox1) {
+      this.group.getObjectByName('monitorText2')?.removeFromParent();
     }
     // #3
+    const inputBox2 = document.getElementById('inputBox2') as HTMLElement;
     if (!this.group.getObjectByName('monitorText3')) {
       const worldPosition = new THREE.Vector3();
       fengji3?.getObjectByName('Feng3_43')?.getWorldPosition(worldPosition);
-      const element = document.getElementById('inputBox2') as HTMLElement;
-      if (element) {
-        const mainCSS3D = new CSS3DObject(element);
+
+      if (inputBox2) {
+        const mainCSS3D = new CSS3DObject(inputBox2);
         mainCSS3D.name = 'monitorText3';
         mainCSS3D.scale.set(0.09, 0.09, 0.09);
         mainCSS3D.position.set(worldPosition.x + 12, worldPosition.y - 20, worldPosition.z - 20);
         mainCSS3D.lookAt(worldPosition.x + 12, worldPosition.y - 0, worldPosition.z + 2);
         this.group.add(mainCSS3D);
       }
+    } else if (!inputBox2) {
+      this.group.getObjectByName('monitorText3')?.removeFromParent();
     }
   }
 
@@ -719,8 +727,6 @@ class mainWindLj3 {
     this.group = new THREE.Group();
     return new Promise(async (resolve) => {
       this.model.setGLTFModel(['bg1', 'mainWindLj3'], this.group).then(async () => {
-        // this.group = gltf[0];
-
         console.log(this.group);
         this.group?.position.set(-0.44, 19.88, 22.37);
         const mainWindLj3 = this.group?.getObjectByName('mainWindLj3') as THREE.Group;

+ 4 - 3
src/views/vent/monitorManager/mainFanMonitor/mainWind.lj.threejs.ts

@@ -87,7 +87,6 @@ class mainXjWindRect {
         this.group.add(mainCSS3D);
       }
     }
-
     if (!this.group.getObjectByName('monitorText4')) {
       const worldPosition = new THREE.Vector3();
       const fbmGroup = this.group?.getObjectByName('fbm') as THREE.Group;
@@ -107,10 +106,12 @@ class mainXjWindRect {
   }
   clearCssText() {
     if (this.group) {
-      const mainCSS3D1 = this.group.getObjectByName('monitorText2');
-      const mainCSS3D2 = this.group.getObjectByName('monitorText3');
+      const mainCSS3D1 = this.group.getObjectByName('monitorText1');
+      const mainCSS3D2 = this.group.getObjectByName('monitorText2');
+      const mainCSS3D4 = this.group.getObjectByName('monitorText4');
       if (mainCSS3D1) this.group.remove(mainCSS3D1);
       if (mainCSS3D2) this.group.remove(mainCSS3D2);
+      if (mainCSS3D4) this.group.remove(mainCSS3D4);
     }
   }
 

+ 22 - 14
src/views/vent/monitorManager/mainFanMonitor/mainWind.threejs.ts

@@ -79,51 +79,57 @@ class mainWindRect {
         }
       }
     } else {
+      const inputBox = document.getElementById('inputBox') as HTMLElement;
       if (!this.group.getObjectByName('monitorText1')) {
         const worldPosition = new THREE.Vector3();
         ztfjGroup?.getObjectByName('Cylinder1042')?.getWorldPosition(worldPosition);
-        const element = document.getElementById('inputBox') as HTMLElement;
-        if (element) {
-          const mainCSS3D = new CSS3DObject(element);
+        if (inputBox) {
+          const mainCSS3D = new CSS3DObject(inputBox);
           mainCSS3D.name = 'monitorText1';
           mainCSS3D.scale.set(0.09, 0.09, 0.09);
-          // mainCSS3D.position.set(23.78, 18.18, -6.85);
-          mainCSS3D.position.set(worldPosition.x + 12, worldPosition.y - 10, worldPosition.z - 20);
-          mainCSS3D.lookAt(worldPosition.x + 12, worldPosition.y - 0, worldPosition.z + 2);
+          mainCSS3D.position.set(worldPosition.x + 7, worldPosition.y - 10, worldPosition.z - 20);
+          mainCSS3D.lookAt(worldPosition.x + 7, worldPosition.y - 0, worldPosition.z + 2);
           this.group.add(mainCSS3D);
         }
+      } else if (!inputBox) {
+        this.group.getObjectByName('monitorText1')?.removeFromParent();
       }
+
+      const inputBox1 = document.getElementById('inputBox1') as HTMLElement;
       if (!this.group.getObjectByName('monitorText2')) {
         const worldPosition = new THREE.Vector3();
         ztfjGroup?.getObjectByName('Cylinder396')?.getWorldPosition(worldPosition);
-        const element = document.getElementById('inputBox1') as HTMLElement;
-        if (element) {
-          const mainCSS3D = new CSS3DObject(element);
+        if (inputBox1) {
+          const mainCSS3D = new CSS3DObject(inputBox1);
           mainCSS3D.name = 'monitorText2';
           mainCSS3D.scale.set(0.09, 0.09, 0.09);
-          // mainCSS3D.position.set(23.78, 18.18, 16.82);
           mainCSS3D.position.set(worldPosition.x + 12, worldPosition.y - 10, worldPosition.z - 20);
           mainCSS3D.lookAt(worldPosition.x + 12, worldPosition.y - 0, worldPosition.z + 2);
-          // mainCSS3D.lookAt(23.78, 20, 20.82);
           this.group.add(mainCSS3D);
         }
+      } else if (!inputBox1) {
+        this.group.getObjectByName('monitorText2')?.removeFromParent();
       }
 
+      const fbm = document.getElementById('fbm') as HTMLElement;
       if (!this.group.getObjectByName('monitorText4')) {
         const worldPosition = new THREE.Vector3();
         const fbmGroup = this.group?.getObjectByName('fbm') as THREE.Group;
         if (fbmGroup) {
           fbmGroup?.getObjectByName('Box022')?.getWorldPosition(worldPosition);
-          const element = document.getElementById('fbm') as HTMLElement;
-          if (element) {
-            const mainCSS3D = new CSS3DObject(element);
+          if (fbm) {
+            const mainCSS3D = new CSS3DObject(fbm);
             mainCSS3D.name = 'monitorText4';
             mainCSS3D.scale.set(0.07, 0.07, 0.07);
             mainCSS3D.position.set(worldPosition.x + 20, worldPosition.y - 8, worldPosition.z - 20);
             mainCSS3D.lookAt(worldPosition.x + 20, worldPosition.y - 0, worldPosition.z + 2);
             this.group.add(mainCSS3D);
+          } else {
+            this.group.getObjectByName('monitorText4')?.removeFromParent();
           }
         }
+      } else if (!fbm) {
+        this.group.getObjectByName('monitorText4')?.removeFromParent();
       }
     }
   }
@@ -132,8 +138,10 @@ class mainWindRect {
     if (this.group) {
       const mainCSS3D1 = this.group.getObjectByName('monitorText1');
       const mainCSS3D2 = this.group.getObjectByName('monitorText2');
+      const mainCSS3D4 = this.group.getObjectByName('monitorText4');
       if (mainCSS3D1) this.group.remove(mainCSS3D1);
       if (mainCSS3D2) this.group.remove(mainCSS3D2);
+      if (mainCSS3D4) this.group.remove(mainCSS3D4);
     }
   }
 

+ 2 - 0
src/views/vent/monitorManager/mainFanMonitor/mainWind.xj.threejs.ts

@@ -111,8 +111,10 @@ class mainXjWindRect {
     if (this.group) {
       const mainCSS3D1 = this.group.getObjectByName('monitorText2');
       const mainCSS3D2 = this.group.getObjectByName('monitorText3');
+      const mainCSS3D4 = this.group.getObjectByName('monitorText4');
       if (mainCSS3D1) this.group.remove(mainCSS3D1);
       if (mainCSS3D2) this.group.remove(mainCSS3D2);
+      if (mainCSS3D4) this.group.remove(mainCSS3D4);
     }
   }
 

+ 300 - 146
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -7,6 +7,7 @@
     destroyOnClose
     :closable="showControls"
     :zIndex="props.zIndex"
+    :get-container="getModalContainer"
     :class="['ai-assistant-modal', { fullscreen: isFullscreen }]"
   >
     <template #title>
@@ -51,11 +52,19 @@
             :canGoPrev="canGoPrev"
             :canGoNext="canGoNext"
             :currentWordUrl="currentWordUrl"
+            :capsule-files="capsuleFiles"
+            :capsule-links="capsuleLinks"
+            :capsule-agents="capsuleAgents"
             @go-to-prev="goToPrev"
             @go-to-next="goToNext"
             @create-task="createNewTask"
             @save-title="handleSaveTitle"
             @download-word="downloadWordReport"
+            @export-md="handleExportMarkdown"
+            @export-word="handleExportWord"
+            @file-preview="openFilePreview"
+            @agent-detail-click="handleAgentDetailClick"
+            @locate-message="handleLocateMessage"
           />
 
           <!-- 附件列表面板 -->
@@ -73,6 +82,8 @@
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
             @delete-message="handleDeleteMessage"
+            @regenerate="handleRegenerate"
+            @edit-resend="handleEditResend"
           />
 
           <!-- 任务进度条 -->
@@ -192,6 +203,8 @@
   import type { TodayUsage } from '../api';
   import type { AttachedFile, Message, Task, RightPanelTab, RightPanelState, SseEvent } from './chatModal/types';
   import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
+  import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from './chatModal/capsuleData';
+  import { buildConversationMarkdown, markdownToDocxBlob } from './chatModal/exportDoc';
 
   interface Props {
     visible: boolean;
@@ -200,6 +213,8 @@
     // 是否显示窗口控制按钮(最大化/还原、关闭)
     showControls?: boolean;
     zIndex?: number;
+    // 挂载容器:不传则默认挂到 body;首页等场景传入 adaptive-container,使其与页面同坐标系、同缩放
+    getContainer?: () => HTMLElement;
   }
 
   interface Emits {
@@ -212,6 +227,20 @@
   });
   const emit = defineEmits<Emits>();
 
+  // 弹框真正渲染/被 antd 滚动锁锁定的目标:包一层 size-0 空壳,
+  // 避免 antd Modal 打开时滚动锁直接改写 #adaptive-container 的宽度(导致整页缩放错乱、露出白边)
+  const portalWrapper = ref<HTMLDivElement | null>(null);
+
+  const getModalContainer = (): HTMLElement => {
+    if (portalWrapper.value) return portalWrapper.value;
+    const target = props.getContainer ? props.getContainer() : document.body;
+    const wrapper = document.createElement('div');
+    wrapper.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;';
+    target.appendChild(wrapper);
+    portalWrapper.value = wrapper;
+    return wrapper;
+  };
+
   const taskList = ref<Task[]>([]);
 
   // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
@@ -430,13 +459,6 @@
 
   const isFullscreen = ref(!!props.defaultFullscreen);
 
-  // 响应式视口宽度,用于计算 modal 最大宽度不超过屏幕
-  const viewportWidth = ref(window.innerWidth);
-  const onResize = () => {
-    viewportWidth.value = window.innerWidth;
-  };
-  window.addEventListener('resize', onResize);
-
   const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
 
   const currentRightPanels = computed<RightPanelState>(() => {
@@ -454,10 +476,14 @@
   const isPanelLeaving = ref(false);
   const modalShouldExpand = computed(() => hasRightPanel.value || isPanelLeaving.value);
 
-  // modal 宽度:全屏100vw / 有右侧面板时1600px(不超视口)/ 默认1400px
+  // modal 宽度:全屏时用容器实际宽度(容器自带 scale 统一缩放适配视口),挂 body 时用 100vw;
+  // 有右侧面板时 1600 / 默认 1400(设计稿像素)。高度由 CSS 的 height:100% 撑满容器
   const modalWidth = computed(() => {
-    if (isFullscreen.value) return '100vw';
-    if (modalShouldExpand.value) return Math.min(1600, viewportWidth.value);
+    if (isFullscreen.value) {
+      const container = props.getContainer ? props.getContainer() : document.body;
+      return container === document.body ? '100vw' : `${container.clientWidth}px`;
+    }
+    if (modalShouldExpand.value) return 1600;
     return 1400;
   });
 
@@ -1090,12 +1116,17 @@
     });
   };
 
+  // 胶囊面板"定位到消息":滚动消息列表到该智能体所在的消息
+  const handleLocateMessage = (messageIndex: number) => {
+    chatMessagesRef.value?.scrollToMessage(messageIndex, 'center');
+  };
+
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
     Modal.confirm({
       title: '提示',
-      content: '删除该消息将不再进入模型上下文,是否继续?',
+      content: '是否删除该消息?',
       okText: '确认',
       cancelText: '取消',
       wrapClassName: 'delete-task-confirm-modal',
@@ -1140,13 +1171,7 @@
     const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
     if (msgIndex !== -1) {
       nextTick(() => {
-        const messageElements = chatMessagesRef.value?.messagesRef?.querySelectorAll('.message-wrapper');
-        if (messageElements && messageElements[msgIndex]) {
-          (messageElements[msgIndex] as HTMLElement).scrollIntoView({
-            behavior: 'smooth',
-            block: 'center',
-          });
-        }
+        chatMessagesRef.value?.scrollToMessage(msgIndex, 'center');
       });
     }
   };
@@ -1198,6 +1223,11 @@
     removeTab(task.rightPanels, tabId);
   };
 
+  // 胶囊聚合数据:转换逻辑内聚在 capsuleData 纯函数模块中,此处只做响应式接线
+  const capsuleFiles = computed(() => buildCapsuleFiles(currentTask.value, messages.value));
+  const capsuleLinks = computed(() => buildCapsuleLinks(capsuleFiles.value, messages.value));
+  const capsuleAgents = computed(() => buildCapsuleAgents(messages.value, (i) => chatMessagesRef.value?.isAnswerVisible(i)));
+
   const currentWordUrl = computed(() => {
     return currentTask.value?.wordUrl || '';
   });
@@ -1688,20 +1718,11 @@
     handleSSEMessage(data, aiMsgIndex, taskId);
   };
 
-  const sendTextOnly = async (userInput: string, displayContent?: string) => {
+  // 发送流式 AI 回答的核心:推送一条空的 AI 消息 → 建 AbortController → 调 SSE → 处理 session_id 迁移 → 异常/清理。
+  // 正常发送、编辑重发、重新生成都复用此函数,file 存在且带 originalFile 时走 PDF 审查流,否则走纯文本流。
+  const streamAiAnswer = async (userInput: string, file?: AttachedFile) => {
     const originTaskId = currentTaskId.value;
-
     const now = dayjs();
-    const userMsg: Message = {
-      type: 'user',
-      content: displayContent || userInput,
-      time: now.format('HH:mm'),
-      createdAt: now.toISOString(),
-    };
-    messages.value.push(userMsg);
-    await nextTick();
-    chatMessagesRef.value?.forceScrollToBottom();
-
     const aiMsgIndex = messages.value.length;
     const aiMsg: Message = {
       type: 'ai',
@@ -1723,19 +1744,33 @@
     const controller = new AbortController();
     setTaskAbortController(originTaskId, controller);
 
+    const onChunk = (data: SseEvent) => {
+      parseSSEData(data, aiMsgIndex, originTaskId);
+      if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
+    };
+
     try {
-      const result = await unifiedStream(
-        {
-          message: userInput,
-          session_id: getCurrentSessionId() || undefined,
-          mode: editMode.value,
-          signal: controller.signal,
-        },
-        (data: SseEvent) => {
-          parseSSEData(data, aiMsgIndex, originTaskId);
-          if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
-        }
-      );
+      const originalFile = file?.originalFile;
+      const result = originalFile
+        ? await reviewPdfStream(
+            {
+              file: originalFile,
+              session_id: getCurrentSessionId() || undefined,
+              message: userInput || undefined,
+              mode: editMode.value,
+              signal: controller.signal,
+            },
+            onChunk
+          )
+        : await unifiedStream(
+            {
+              message: userInput,
+              session_id: getCurrentSessionId() || undefined,
+              mode: editMode.value,
+              signal: controller.signal,
+            },
+            onChunk
+          );
 
       if (result.session_id) {
         const originTask = taskList.value.find((t) => t.id === originTaskId);
@@ -1743,7 +1778,8 @@
           originTask.sessionId = result.session_id;
           // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
           if (originTask.pendingSessionId !== result.session_id) {
-            originTask.name = userInput.substring(0, 20) + (userInput.length > 20 ? '...' : '');
+            const taskName = userInput || (file ? `文件:${file.name}` : '');
+            originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
           }
           originTask.pendingSessionId = undefined;
           if (originTask.id !== result.session_id) {
@@ -1798,9 +1834,25 @@
     }
   };
 
+  const sendTextOnly = async (userInput: string, displayContent?: string) => {
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: displayContent || userInput,
+      rawInput: userInput,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+    };
+    messages.value.push(userMsg);
+    await nextTick();
+    chatMessagesRef.value?.forceScrollToBottom();
+
+    await streamAiAnswer(userInput);
+  };
+
   const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
     const originTaskId = currentTaskId.value;
-    let currentTask = taskList.value.find((t) => t.id === originTaskId);
+    const currentTask = taskList.value.find((t) => t.id === originTaskId);
     if (currentTask) {
       currentTask.attachedFiles.push(file);
     } else {
@@ -1811,6 +1863,7 @@
     const userMsg: Message = {
       type: 'user',
       content: displayContent || userInput || `上传了文件:${file.name}`,
+      rawInput: userInput,
       time: now.format('HH:mm'),
       createdAt: now.toISOString(),
       attachedFile: file,
@@ -1819,104 +1872,192 @@
     await nextTick();
     chatMessagesRef.value?.forceScrollToBottom();
 
-    const aiMsgIndex = messages.value.length;
-    const aiMsg: Message = {
-      type: 'ai',
-      content: '',
-      time: now.format('HH:mm'),
-      createdAt: now.toISOString(),
-      isLoading: true,
-      thinkingSteps: [],
-      generateStartTime: Date.now(),
-    };
-    messages.value.push(aiMsg);
-
-    showTodoBar.value = false;
-    currentTodos.value = [];
-
+    // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
     if (!file.originalFile) {
-      throw new Error('缺少原始文件对象');
+      await streamAiAnswer(userInput);
+      return;
     }
 
-    const originTask = taskList.value.find((t) => t.id === originTaskId);
-    if (originTask) originTask.isStreaming = true;
+    await streamAiAnswer(userInput, file);
+  };
 
-    const controller = new AbortController();
-    setTaskAbortController(originTaskId, controller);
+  // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
+  const stripInjectedRefs = (content: string): string =>
+    (content || '')
+      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+      .replace(/<img[^>]*>/g, '')
+      .trim();
 
+  // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
+  // 用 getDetail 返回的列表按尾部对齐回填,避免编辑重发/重新生成删除时后端残留重复记录。
+  const hydrateMessageIds = async () => {
+    const sessionId = getCurrentSessionId();
+    if (!sessionId) return;
     try {
-      const result = await reviewPdfStream(
-        {
-          file: file.originalFile!,
-          session_id: getCurrentSessionId() || undefined,
-          message: userInput || undefined,
-          mode: editMode.value,
-          signal: controller.signal,
-        },
-        (data: SseEvent) => {
-          parseSSEData(data, aiMsgIndex, originTaskId);
-          if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
+      const res = await getDetail(sessionId);
+      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      if (!Array.isArray(raw)) return;
+      // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
+      const history = transformHistoryToMessages(raw);
+      const ids = history.map((m) => m.id).filter((id): id is number => id != null);
+      if (!ids.length) return;
+      const msgs = messages.value;
+      const offset = ids.length - msgs.length;
+      for (let i = 0; i < msgs.length; i++) {
+        const dIdx = offset + i;
+        if (dIdx >= 0 && dIdx < ids.length && msgs[i].id == null) {
+          msgs[i].id = ids[dIdx];
         }
+      }
+    } catch (e) {
+      console.error('补齐消息 id 失败:', e);
+    }
+  };
+
+  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)
+  const removeDuplicateRegenerateUserMessage = async (expectedContent: string) => {
+    const sessionId = getCurrentSessionId();
+    if (!sessionId) return;
+    try {
+      const res = await getDetail(sessionId);
+      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      if (!Array.isArray(raw)) return;
+
+      // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
+      const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
+      const duplicates = userMsgs.filter(
+        (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
       );
+      for (const dup of duplicates) {
+        await deleteMessage(dup.id);
+      }
+    } catch (e) {
+      console.error('删除重发产生的重复用户消息失败:', e);
+    }
+  };
 
-      if (result.session_id) {
-        const originTask = taskList.value.find((t) => t.id === originTaskId);
-        if (originTask && !originTask.sessionId) {
-          originTask.sessionId = result.session_id;
-          if (originTask.pendingSessionId !== result.session_id) {
-            const taskName = userInput || `文件:${file.name}`;
-            originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
-          }
-          originTask.pendingSessionId = undefined;
-          if (originTask.id !== result.session_id) {
-            // 将 AbortController 从旧 ID 迁移到新 ID
-            const controller = taskAbortControllers.get(originTaskId);
-            if (controller) {
-              setTaskAbortController(originTaskId, null);
-              setTaskAbortController(result.session_id, controller);
-            }
-            taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
-            originTask.id = result.session_id;
-            historyLoadedTasks.add(result.session_id);
-            // 迁移审批状态 key
-            const approvalEntry = taskPendingApprovals.value.get(originTaskId);
-            if (approvalEntry) {
-              const map = new Map(taskPendingApprovals.value);
-              map.delete(originTaskId);
-              map.set(result.session_id, approvalEntry);
-              taskPendingApprovals.value = map;
-            }
-          }
-          if (currentTaskId.value === originTaskId) {
-            currentTaskId.value = result.session_id;
-          }
-        }
+  // 重新生成最后一条回答
+  const handleRegenerate = async () => {
+    const task = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (!task || task.isStreaming) return;
+    if (currentPendingApproval.value) {
+      message.warning('请先审批执行计划');
+      return;
+    }
+
+    const msgs = messages.value;
+    const lastAiIdx = msgs.length - 1;
+    if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
+
+    // 找前一条用户消息作为重发内容
+    let userIdx = -1;
+    for (let i = lastAiIdx - 1; i >= 0; i--) {
+      if (msgs[i].type === 'user') {
+        userIdx = i;
+        break;
       }
-    } catch (error) {
-      // 用户主动停止生成
-      if ((error as Error)?.name === 'AbortError') {
-        const originTask = taskList.value.find((t) => t.id === originTaskId);
-        const arr = currentTaskId.value === originTaskId ? messages.value : originTask?.messages;
-        const aiMsg = arr?.[aiMsgIndex];
-        if (aiMsg) {
-          aiMsg.isLoading = false;
-          // 后端未返回耗时,冻结前端伪计时
-          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
-          if (!aiMsg.content) {
-            aiMsg.content = '已停止生成。';
-          }
-        }
-        if (originTask && currentTaskId.value !== originTaskId) {
-          originTask.messages = [...(arr || [])];
-        }
-        return;
+    }
+    if (userIdx < 0) return;
+
+    const userMsg = msgs[userIdx];
+    const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
+    const attachedFile = userMsg.attachedFile;
+
+    // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成
+    await streamAiAnswer(userInput, attachedFile);
+    // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
+    await removeDuplicateRegenerateUserMessage(userInput);
+    // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
+    await hydrateMessageIds();
+  };
+
+  // 编辑用户消息并重发(截断该消息及其后所有消息)
+  const handleEditResend = async (index: number, newContent: string) => {
+    const task = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (!task || task.isStreaming) return;
+    if (currentPendingApproval.value) {
+      message.warning('请先审批执行计划');
+      return;
+    }
+
+    const msgs = messages.value;
+    if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
+    const trimmed = (newContent || '').trim();
+    if (!trimmed) {
+      message.warning('消息内容不能为空');
+      return;
+    }
+
+    const attachedFile = msgs[index].attachedFile;
+
+    await hydrateMessageIds();
+    // 从后往前删除后端记录(有 id 的才调接口)
+    const toDelete = messages.value.slice(index).filter((m) => m.id != null);
+    for (const m of toDelete) {
+      try {
+        await deleteMessage(m.id!);
+      } catch (e) {
+        console.error('删除消息失败:', e);
       }
-      throw error;
-    } finally {
-      // 任务在流结束时可能已被改名为后端 session_id,需要使用最终的 ID 清理状态
-      const finalTaskId = originTask?.id || originTaskId;
-      setTaskAbortController(finalTaskId, null);
-      if (originTask) originTask.isStreaming = false;
+    }
+    // 本地截断到编辑位置
+    messages.value = messages.value.slice(0, index);
+    task.messages = [...messages.value];
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: trimmed,
+      rawInput: trimmed,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      attachedFile,
+    };
+    messages.value.push(userMsg);
+    task.messages = [...messages.value];
+    await nextTick();
+    chatMessagesRef.value?.forceScrollToBottom();
+    await streamAiAnswer(trimmed, attachedFile);
+  };
+
+  const sanitizeFileName = (name: string): string => (name || '会话').replace(/[\\/:*?"<>|]/g, '_');
+
+  const downloadBlob = (blob: Blob, filename: string) => {
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+  };
+
+  // 导出会话为 Markdown
+  const handleExportMarkdown = () => {
+    if (!messages.value.length) {
+      message.warning('当前会话暂无内容');
+      return;
+    }
+    const md = buildConversationMarkdown(messages.value);
+    const filename = `${sanitizeFileName(currentTask.value?.name || '会话')}_${dayjs().format('YYYYMMDD_HHmmss')}.md`;
+    downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8;' }), filename);
+  };
+
+  // 导出会话为 Word (.docx)
+  const handleExportWord = async () => {
+    if (!messages.value.length) {
+      message.warning('当前会话暂无内容');
+      return;
+    }
+    try {
+      const md = buildConversationMarkdown(messages.value);
+      const title = currentTask.value?.name || '会话';
+      const blob = await markdownToDocxBlob(md, title);
+      const filename = `${sanitizeFileName(title)}_${dayjs().format('YYYYMMDD_HHmmss')}.docx`;
+      downloadBlob(blob, filename);
+    } catch (e) {
+      console.error('导出 Word 失败:', e);
+      message.error('导出 Word 失败');
     }
   };
 
@@ -2033,6 +2174,9 @@
   let dragStartY = 0;
   let modalStartLeft = 0;
   let modalStartTop = 0;
+  // 容器 scale 缩放比(视觉尺寸/布局尺寸),用于把鼠标视口坐标换算回布局坐标
+  let dragScaleX = 1;
+  let dragScaleY = 1;
   let draggedEl: HTMLElement | null = null;
 
   const findModalWrap = (target: HTMLElement): HTMLElement | null =>
@@ -2069,17 +2213,20 @@
     draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
     if (!draggedEl) return;
 
+    // 记录容器缩放比,并把鼠标/弹框的视口坐标换算回布局坐标,避免不同分辨率下拖拽位移失真
+    const rect = draggedEl.getBoundingClientRect();
+    dragScaleX = draggedEl.offsetWidth ? rect.width / draggedEl.offsetWidth : 1;
+    dragScaleY = draggedEl.offsetHeight ? rect.height / draggedEl.offsetHeight : 1;
+
     isDragging = true;
     dragStartX = e.clientX;
     dragStartY = e.clientY;
-
-    const rect = draggedEl.getBoundingClientRect();
-    modalStartLeft = rect.left;
-    modalStartTop = rect.top;
+    modalStartLeft = rect.left / dragScaleX;
+    modalStartTop = rect.top / dragScaleY;
 
     draggedEl.style.position = 'fixed';
-    draggedEl.style.left = `${rect.left}px`;
-    draggedEl.style.top = `${rect.top}px`;
+    draggedEl.style.left = `${modalStartLeft}px`;
+    draggedEl.style.top = `${modalStartTop}px`;
     draggedEl.style.margin = '0';
     draggedEl.style.transform = 'none';
 
@@ -2089,8 +2236,8 @@
 
   const onDragMove = (e: MouseEvent) => {
     if (!isDragging || !draggedEl) return;
-    draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX)}px`;
-    draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY)}px`;
+    draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX) / dragScaleX}px`;
+    draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY) / dragScaleY}px`;
   };
 
   const onDragEnd = () => {
@@ -2111,7 +2258,9 @@
   onBeforeUnmount(() => {
     document.removeEventListener('mousemove', onDragMove);
     document.removeEventListener('mouseup', onDragEnd);
-    window.removeEventListener('resize', onResize);
+    // 移除挂载空壳,避免残留
+    portalWrapper.value?.remove();
+    portalWrapper.value = null;
     // 组件卸载时主动断开所有任务的流式连接
     taskAbortControllers.forEach((controller) => controller.abort());
     // Abort in-flight FileReader if any
@@ -2343,8 +2492,9 @@
 
     &.fullscreen {
       top: 0 !important;
+      height: 100% !important;
       padding: 0 !important;
-      max-width: 100vw !important;
+      max-width: none !important;
       margin: 0 !important;
       background-color: #09172c;
 
@@ -2354,11 +2504,15 @@
         margin: 0 !important;
         top: 0 !important;
       }
+      .zxm-modal-close,
+      .maximize-btn {
+        top: 40px;
+      }
 
       .zxm-modal-content {
-        width: 100vw !important;
-        height: 100vh !important;
-        max-width: 100vw !important;
+        width: 100% !important;
+        height: 100% !important;
+        max-width: 100% !important;
         margin: 0;
         border-radius: 0;
         overflow: hidden;
@@ -2379,7 +2533,7 @@
         height: 90px;
       }
       .zxm-modal-body {
-        height: calc(100vh - 120px);
+        height: calc(100% - 120px);
         overflow: hidden;
         padding: 0 24px 12px;
       }
@@ -2506,7 +2660,7 @@
   .delete-task-confirm-modal {
     .zxm-modal-confirm-title,
     .zxm-modal-confirm-content {
-      color: #fff;
+      color: #fff !important;
     }
   }
 </style>

+ 134 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatHeader.vue

@@ -32,6 +32,35 @@
       </template>
     </div>
 
+    <!-- 文件、链接与智能体聚合胶囊 -->
+    <ResourceCapsule
+      v-if="currentTask?.sessionId"
+      :task-id="currentTask?.id"
+      :files="capsuleFiles"
+      :links="capsuleLinks"
+      :agents="capsuleAgents"
+      @file-preview="emit('file-preview', $event)"
+      @agent-detail-click="emit('agent-detail-click', $event)"
+      @locate-message="emit('locate-message', $event)"
+    />
+
+    <!-- 会话导出 -->
+    <div v-if="currentTask?.sessionId" ref="exportWrapRef" class="export-wrapper">
+      <div class="export-bg" title="导出会话" @click="toggleExportPopup">
+        <div class="export-icon"></div>
+      </div>
+      <div v-if="showExportPopup" class="export-popup" @click.stop>
+        <div class="export-item" @click="handleExport('md')">
+          <div class="export-item-icon md-icon"></div>
+          <span class="export-item-text">导出 Markdown (.md)</span>
+        </div>
+        <div class="export-item" @click="handleExport('word')">
+          <div class="export-item-icon word-icon"></div>
+          <span class="export-item-text">导出 Word (.docx)</span>
+        </div>
+      </div>
+    </div>
+
     <!-- Word 报告下载按钮 -->
     <div v-if="currentWordUrl" class="upload-bg" @click="emit('download-word')">
       <div class="download-icon"></div>
@@ -41,7 +70,9 @@
 
 <script setup lang="ts">
   import { ref, nextTick } from 'vue';
-  import type { Task } from './types';
+  import { onClickOutside } from '@vueuse/core';
+  import ResourceCapsule from './ResourceCapsule.vue';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from './types';
 
   interface Props {
     currentTask: Task | undefined;
@@ -49,6 +80,9 @@
     canGoPrev: boolean;
     canGoNext: boolean;
     currentWordUrl: string;
+    capsuleFiles: CapsuleFileItem[];
+    capsuleLinks: CapsuleLinkItem[];
+    capsuleAgents: CapsuleAgentItem[];
   }
 
   const props = defineProps<Props>();
@@ -60,8 +94,34 @@
     // 保存标题时触发,参数为新标题(父组件负责 API 调用和状态更新)
     'save-title': [newTitle: string];
     'download-word': [];
+    'export-md': [];
+    'export-word': [];
+    // 胶囊面板中点击上传附件,交给父组件打开右侧预览
+    'file-preview': [file: AttachedFile];
+    // 胶囊面板中点击智能体条目,交给父组件打开右侧智能体详情
+    'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
+    // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
+    'locate-message': [messageIndex: number];
   }>();
 
+  // 导出气泡状态
+  const exportWrapRef = ref<HTMLElement>();
+  const showExportPopup = ref(false);
+
+  const toggleExportPopup = () => {
+    showExportPopup.value = !showExportPopup.value;
+  };
+
+  const handleExport = (type: 'md' | 'word') => {
+    showExportPopup.value = false;
+    if (type === 'md') emit('export-md');
+    else emit('export-word');
+  };
+
+  onClickOutside(exportWrapRef, () => {
+    showExportPopup.value = false;
+  });
+
   // 标题编辑状态(UI 交互逻辑,内聚在子组件中)
   const editingTitle = ref(false);
   const editTitleValue = ref('');
@@ -94,7 +154,7 @@
 <style scoped lang="less">
   .chat-header {
     display: flex;
-    padding: 12px 20px;
+    padding: 6px 16px;
     flex-shrink: 0;
     align-items: center;
     background: rgba(2, 53, 100, 0.3);
@@ -200,6 +260,78 @@
       }
     }
 
+    .export-wrapper {
+      position: relative;
+      flex-shrink: 0;
+    }
+
+    .export-bg {
+      cursor: pointer;
+      width: 36px;
+      height: 36px;
+      margin-left: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .export-icon {
+        width: 18px;
+        height: 18px;
+        background-image: var(--img-chat-export-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+
+    .export-popup {
+      position: absolute;
+      top: calc(100% + 6px);
+      right: 0;
+      background: #0a2a3f;
+      border: 1px solid #1a4a6f;
+      border-radius: 8px;
+      min-width: 180px;
+      z-index: 1000;
+      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+      padding: 4px;
+
+      .export-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 9px 16px;
+        cursor: pointer;
+        transition: background 0.2s;
+        border-radius: 6px;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        .export-item-icon {
+          width: 18px;
+          height: 18px;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .md-icon {
+          background-image: var(--img-chat-file-md-icon);
+        }
+
+        .word-icon {
+          background-image: var(--img-chat-file-word-icon);
+        }
+
+        .export-item-text {
+          font-size: 13px;
+          color: #e0e6ed;
+          white-space: nowrap;
+        }
+      }
+    }
+
     .upload-bg {
       cursor: pointer;
       width: 36px;

+ 376 - 98
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -36,7 +36,12 @@
       <div v-if="messages.length === 0" class="welcome-empty">
         <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
       </div>
-      <div v-for="(message, index) in messages" :key="index" :class="['message-wrapper', message.type]" @mouseleave="copiedMessageIndex = -1">
+      <div
+        v-for="(message, index) in messages"
+        :key="index"
+        :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
+        @mouseleave="copiedMessageIndex = -1"
+      >
         <!-- AI消息 -->
         <div v-if="message.type === 'ai'" class="ai-message">
           <div class="message-body">
@@ -170,8 +175,8 @@
               <span class="dot"></span>
             </div>
 
-            <!-- ask_user 交互提问 UI(分步向导) -->
-            <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user" class="approval-card">
+            <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
+            <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length" class="approval-card">
               <div class="approval-icon"></div>
               <div class="approval-content">
                 <div class="approval-title">{{ message.pendingApprovalData?.message }}</div>
@@ -301,9 +306,21 @@
             <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
               <img :src="copyIconUrl(index)" width="14" height="14" />
             </div>
+            <div v-if="canRegenerate(index, message)" class="action-btn" @click="emit('regenerate')" title="重新生成">
+              <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
+            </div>
             <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
               <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
             </div>
+            <div v-if="showAnswerSwitcher(index)" class="answer-switcher">
+              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrevAnswer(index) }" @click="prevAnswer(index)" title="上一个回答">
+                <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
+              </div>
+              <span class="answer-switcher-text">{{ answerPositionText(index) }}</span>
+              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNextAnswer(index) }" @click="nextAnswer(index)" title="下一个回答">
+                <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
+              </div>
+            </div>
             <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
           </div>
         </div>
@@ -311,20 +328,38 @@
         <!-- 用户消息 -->
         <div v-else class="user-message">
           <div class="message-body">
-            <div class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
-            <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
-              <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
-              <div class="file-info-card">
-                <div class="file-name-card">{{ message.attachedFile.name }}</div>
-                <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
+            <template v-if="editingIndex === index">
+              <textarea
+                v-model="editDraft"
+                class="message-edit-textarea"
+                spellcheck="false"
+                @keydown.enter.exact.prevent="submitEdit(index)"
+                @keydown.escape.prevent="cancelEdit"
+              ></textarea>
+              <div class="message-edit-actions">
+                <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
+                <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
               </div>
-            </div>
+            </template>
+            <template v-else>
+              <div class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
+              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
+                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
+                <div class="file-info-card">
+                  <div class="file-name-card">{{ message.attachedFile.name }}</div>
+                  <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
+                </div>
+              </div>
+            </template>
           </div>
           <!-- 用户消息操作栏 -->
           <div class="message-actions">
             <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
               <img :src="copyIconUrl(index)" width="14" height="14" />
             </div>
+            <div v-if="canEdit(index, message)" class="action-btn" @click="startEdit(index)" title="编辑">
+              <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+            </div>
             <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
               <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
             </div>
@@ -351,15 +386,14 @@
     tableHtmlToCsv,
     isMdFile,
     isTextFile,
-    isWordFile,
-    isExcelFile,
-    isImageFile,
-    isPdfFile,
     getFileIconVar,
+    fetchPreviewFile,
+    groupStepsByAgent,
   } from './utils';
+  import type { AgentGroup } from './utils';
   import { getToken } from '/@/utils/auth';
   import { message } from 'ant-design-vue';
-  import type { Message, AttachedFile, ThinkingStep } from './types';
+  import type { Message, AttachedFile } from './types';
   // import katex from 'katex';
   // import 'katex/dist/katex.min.css';
 
@@ -425,29 +459,6 @@
     messages: Message[];
   }>();
 
-  // Track the last pendingApprovalData reference to detect new interrupts
-  const lastApprovalDataRef = new Map<number, any>();
-
-  // 自动初始化 ask_user 答案状态
-  watch(
-    () => props.messages,
-    (msgs) => {
-      msgs.forEach((msg, idx) => {
-        if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
-          // Only reinitialize if pendingApprovalData object reference changed (new interrupt)
-          const lastRef = lastApprovalDataRef.get(idx);
-          if (lastRef !== msg.pendingApprovalData) {
-            lastApprovalDataRef.set(idx, msg.pendingApprovalData);
-            initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
-          }
-        } else {
-          lastApprovalDataRef.delete(idx);
-        }
-      });
-    },
-    { immediate: true, deep: true }
-  );
-
   export interface AgentDetailData {
     messageIndex: number;
     agent: string;
@@ -463,8 +474,178 @@
     (e: 'agent-detail-click', data: AgentDetailData): void;
     (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
     (e: 'delete-message', message: Message): void;
+    (e: 'regenerate'): void;
+    (e: 'edit-resend', index: number, newContent: string): void;
   }>();
 
+  // 用户消息内联编辑状态
+  const editingIndex = ref(-1);
+  const editDraft = ref('');
+
+  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
+  const stripInjectedHtml = (content: string): string =>
+    (content || '')
+      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+      .replace(/<img[^>]*>/g, '')
+      .trim();
+
+  // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
+  interface AnswerGroup {
+    indices: number[];
+  }
+
+  // 将连续的 AI 消息划分为若干“回答组”
+  const answerGroups = computed<AnswerGroup[]>(() => {
+    const groups: AnswerGroup[] = [];
+    let current: AnswerGroup | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'ai') {
+        if (!current) {
+          current = { indices: [] };
+          groups.push(current);
+        }
+        current.indices.push(index);
+      } else {
+        current = null;
+      }
+    });
+    return groups;
+  });
+
+  // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
+  const aiGroupMeta = computed(() => {
+    const meta = new Map<number, { groupKey: number; offset: number; groupLen: number }>();
+    for (const group of answerGroups.value) {
+      const groupKey = group.indices[0];
+      const groupLen = group.indices.length;
+      group.indices.forEach((idx, offset) => {
+        meta.set(idx, { groupKey, offset, groupLen });
+      });
+    }
+    return meta;
+  });
+
+  // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
+  const activeAnswerOffset = reactive<Record<number, number>>({});
+
+  const currentAnswerOffset = (groupKey: number, groupLen: number): number => {
+    const val = activeAnswerOffset[groupKey];
+    if (val == null) return groupLen - 1;
+    return Math.min(Math.max(val, 0), groupLen - 1);
+  };
+
+  // 末尾回答组内是否存在正在生成(isLoading)的消息
+  const lastAnswerGroupHasLoading = computed(() => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    let start = msgs.length - 1;
+    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+    for (let i = start; i < msgs.length; i++) {
+      if (msgs[i].isLoading) return true;
+    }
+    return false;
+  });
+
+  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
+  watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
+    if (loading && !wasLoading) {
+      const msgs = props.messages;
+      let start = msgs.length - 1;
+      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+      delete activeAnswerOffset[start];
+    }
+  });
+
+  // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
+  const isHiddenAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
+  };
+
+  // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
+  const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
+
+  // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
+  const showAnswerSwitcher = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
+  };
+
+  const answerPositionText = (index: number): string => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return '';
+    return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
+  };
+
+  const canPrevAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
+  };
+
+  const canNextAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
+  };
+
+  const prevAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
+  };
+
+  const nextAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
+  };
+
+  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
+  const isInLastAnswerGroup = (index: number): boolean => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    if (msgs[index]?.type !== 'ai') return false;
+    let start = msgs.length - 1;
+    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+    return index >= start;
+  };
+
+  const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
+
+  // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
+  const lastUserMessageIndex = computed(() => {
+    for (let i = props.messages.length - 1; i >= 0; i--) {
+      if (props.messages[i].type === 'user') return i;
+    }
+    return -1;
+  });
+
+  const canEdit = (index: number, message: Message): boolean =>
+    message.type === 'user' && index === lastUserMessageIndex.value && editingIndex.value !== index;
+
+  const startEdit = (index: number) => {
+    const msg = props.messages[index];
+    editingIndex.value = index;
+    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
+  };
+
+  const cancelEdit = () => {
+    editingIndex.value = -1;
+    editDraft.value = '';
+  };
+
+  const submitEdit = (index: number) => {
+    const content = editDraft.value;
+    editingIndex.value = -1;
+    editDraft.value = '';
+    emit('edit-resend', index, content);
+  };
+
   const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
 
   const handleDownloadFile = (url: string, filename: string) => {
@@ -511,38 +692,8 @@
   const handlePreviewFile = async (file: { url: string; filename: string }) => {
     if (!file.url) return;
     try {
-      const resp = await fetch(file.url, { headers: { 'X-Access-Token': getToken() } });
-      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-      const ext = file.filename.split('.').pop()?.toLowerCase() || '';
-      const mimeMap: Record<string, string> = {
-        md: 'text/markdown',
-        txt: 'text/plain',
-        json: 'application/json',
-        xml: 'text/xml',
-        yaml: 'text/yaml',
-        yml: 'text/yaml',
-        csv: 'text/csv',
-      };
-      const id = `report-${file.url}`;
-      const uploadTime = new Date().toISOString();
-
-      let previewFile: AttachedFile;
-      if (mimeMap[ext]) {
-        const content = await resp.text();
-        previewFile = { id, name: file.filename, size: content.length, type: mimeMap[ext], content, uploadTime };
-      } else if (isWordFile(file.filename) || isExcelFile(file.filename)) {
-        const arrayBuffer = await resp.arrayBuffer();
-        previewFile = { id, name: file.filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
-      } else if (isImageFile(file.filename) || isPdfFile(file.filename)) {
-        const blob = await resp.blob();
-        const dataUrl = await new Promise<string>((resolve, reject) => {
-          const reader = new FileReader();
-          reader.onload = () => resolve(reader.result as string);
-          reader.onerror = () => reject(reader.error);
-          reader.readAsDataURL(blob);
-        });
-        previewFile = { id, name: file.filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
-      } else {
+      const previewFile = await fetchPreviewFile(file.url, file.filename);
+      if (!previewFile) {
         // 不支持的格式回退为直接下载
         handleDownloadFile(file.url, file.filename);
         return;
@@ -573,6 +724,31 @@
     askUserCurrentStep[index] = 0;
   };
 
+  // Track the last pendingApprovalData reference to detect new interrupts
+  const lastApprovalDataRef = new Map<number, any>();
+
+  // 自动初始化 ask_user 答案状态。
+  // 必须注册在 initAskUserAnswers 等 const 声明之后:immediate watcher 在 setup 阶段同步执行,
+  // 若带审批消息重新挂载(关闭弹框后再打开),回调会访问尚未初始化的变量(TDZ 报错导致整个弹框挂载失败)
+  watch(
+    () => props.messages,
+    (msgs) => {
+      msgs.forEach((msg, idx) => {
+        if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
+          // Only reinitialize if pendingApprovalData object reference changed (new interrupt)
+          const lastRef = lastApprovalDataRef.get(idx);
+          if (lastRef !== msg.pendingApprovalData) {
+            lastApprovalDataRef.set(idx, msg.pendingApprovalData);
+            initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
+          }
+        } else {
+          lastApprovalDataRef.delete(idx);
+        }
+      });
+    },
+    { immediate: true, deep: true }
+  );
+
   const isCurrentStepAnswered = (msgIndex: number, qIndex: number, question: { required?: boolean; type: string }) => {
     const answers = askUserAnswers[msgIndex];
     if (!answers) return false;
@@ -769,7 +945,7 @@
     if (!n) return;
     const el = document.getElementById(`citation-${msgIndex}-${n}`);
     if (el) {
-      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      scrollMessagesTo(el, 'center');
       el.classList.add('citation-highlight');
       setTimeout(() => el.classList.remove('citation-highlight'), 1500);
     }
@@ -787,25 +963,6 @@
     return '';
   };
 
-  interface AgentGroup {
-    agent: string;
-    agentId?: string;
-    steps: ThinkingStep[];
-  }
-
-  const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
-    const groupMap = new Map<string, AgentGroup>();
-    for (const step of steps) {
-      const agentName = step.agent || '系统';
-      const groupKey = step.agentId || agentName;
-      if (!groupMap.has(groupKey)) {
-        groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] });
-      }
-      groupMap.get(groupKey)!.steps.push(step);
-    }
-    return Array.from(groupMap.values());
-  };
-
   const formatFullTime = (createdAt: string) => {
     return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
   };
@@ -939,7 +1096,10 @@
     if (!nav) return;
     // 横线数量变化(新增问答/首次悬浮)时重建坐标
     if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
-    navMouseY.value = e.clientY - nav.getBoundingClientRect().top + nav.scrollTop;
+    // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
+    // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
+    const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
+    navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
     const nearest = nearestLineIndex.value;
     if (nearest >= 0 && nearest !== lastTooltipIndex) {
       lastTooltipIndex = nearest;
@@ -958,17 +1118,42 @@
     if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
   };
 
+  // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
+  const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
+    const container = messagesRef.value;
+    if (!container) return;
+    const top = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
+    const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
+    container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
+  };
+
+  const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
+    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
+    let el = wrappers?.[index] as HTMLElement | undefined;
+    // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
+    if (el && el.offsetParent === null) {
+      el = undefined;
+      for (let i = index - 1; i >= 0; i--) {
+        const w = wrappers?.[i] as HTMLElement | undefined;
+        if (w && w.offsetParent !== null) {
+          el = w;
+          break;
+        }
+      }
+    }
+    if (el) scrollMessagesTo(el, block);
+  };
+
   const scrollToPair = (pair: QaPair) => {
     activeMessage.value = props.messages[pair.startIndex];
+    scrollToMessage(pair.startIndex, 'start');
     const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
     const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
-    if (!el) return;
-    el.scrollIntoView({ behavior: 'smooth', block: 'start' });
-    el.classList.add('nav-located-highlight');
-    setTimeout(() => el.classList.remove('nav-located-highlight'), 1500);
+    el?.classList.add('nav-located-highlight');
+    setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
   };
 
-  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, initAskUserAnswers });
+  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
 </script>
 
 <style scoped lang="less">
@@ -1124,6 +1309,10 @@
     .message-wrapper {
       // margin-bottom: 20px;
 
+      &.answer-hidden {
+        display: none;
+      }
+
       &.nav-located-highlight {
         background: rgba(10, 132, 255, 0.08);
         border-radius: 6px;
@@ -1717,7 +1906,6 @@
         }
 
         .citations-card {
-          margin-bottom: 12px;
           overflow: hidden;
 
           .citations-header {
@@ -2122,6 +2310,38 @@
             font-size: 12px;
             color: #8b949e;
           }
+
+          .answer-switcher {
+            display: flex;
+            align-items: center;
+            gap: 2px;
+            padding: 0 4px;
+            border-radius: 4px;
+            background: rgba(255, 255, 255, 0.04);
+
+            .answer-nav-btn {
+              width: 22px;
+              height: 22px;
+
+              &.is-disabled {
+                opacity: 0.35;
+                cursor: not-allowed;
+
+                &:hover {
+                  background: transparent;
+                  color: #8b949e;
+                }
+              }
+            }
+
+            .answer-switcher-text {
+              font-size: 12px;
+              color: #8b949e;
+              min-width: 32px;
+              text-align: center;
+              user-select: none;
+            }
+          }
         }
 
         &:hover .message-actions {
@@ -2134,12 +2354,70 @@
       }
 
       .user-message .message-actions {
-        padding: 4px 0 0 0;
+        padding: 4px 0;
         justify-content: flex-end;
       }
     }
   }
 
+  .message-edit-textarea {
+    width: 100%;
+    min-width: 500px;
+    min-height: 80px;
+    padding: 8px 12px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: #e0e6ed;
+    background: rgba(0, 0, 0, 0.2);
+    border: 1px solid rgba(10, 132, 255, 0.5);
+    border-radius: 6px;
+    outline: none;
+    resize: vertical;
+    font-family: inherit;
+
+    &:focus {
+      border-color: #0a84ff;
+      background: rgba(0, 0, 0, 0.3);
+    }
+  }
+
+  .message-edit-actions {
+    display: flex;
+    gap: 8px;
+    margin-top: 8px;
+    justify-content: flex-end;
+
+    .message-edit-btn {
+      padding: 3px 14px;
+      font-size: 12px;
+      border-radius: 4px;
+      cursor: pointer;
+      border: 1px solid transparent;
+      transition: all 0.2s;
+
+      &.confirm {
+        color: #fff;
+        background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
+        border-color: rgba(10, 132, 255, 0.5);
+
+        &:hover {
+          background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+        }
+      }
+
+      &.cancel {
+        color: #8b949e;
+        background: rgba(255, 255, 255, 0.06);
+        border-color: rgba(63, 80, 106, 0.4);
+
+        &:hover {
+          color: #c9d1d9;
+          background: rgba(255, 255, 255, 0.1);
+        }
+      }
+    }
+  }
+
   .scroll-to-bottom {
     position: absolute;
     bottom: 20px;

+ 896 - 0
src/views/ventAI/manageAssistent/components/chatModal/ResourceCapsule.vue

@@ -0,0 +1,896 @@
+<template>
+  <div ref="wrapRef" class="resource-capsule">
+    <!-- 胶囊按钮 -->
+    <button type="button" :class="['capsule-btn', { open: isOpen }]" title="会话资源" :aria-expanded="isOpen" @click="isOpen = !isOpen">
+      <div class="capsule-icon"></div>
+      <span class="capsule-text"> </span>
+      <span v-if="totalCount > 0" class="capsule-count">{{ totalCount }}</span>
+      <div class="capsule-arrow"></div>
+    </button>
+
+    <!-- 聚合下拉面板 -->
+    <Transition name="capsule-fade">
+      <div v-if="isOpen" class="capsule-popup" :style="{ width: `${popupWidth}px` }" @click.stop>
+        <div class="popup-header">
+          <div class="popup-title">
+            <div class="popup-title-icon"></div>
+            <span>会话资源</span>
+          </div>
+          <CloseOutlined class="close-icon" @click="isOpen = false" />
+        </div>
+        <div class="popup-body">
+          <template v-if="files.length || links.length || agents.length">
+            <div v-if="files.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedFiles }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedFiles"
+                @click="collapsedFiles = !collapsedFiles"
+                @keydown.enter.prevent="collapsedFiles = !collapsedFiles"
+                @keydown.space.prevent="collapsedFiles = !collapsedFiles"
+              >
+                <span class="section-title-text">文件</span>
+                <span class="section-count">{{ files.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedFiles" class="section-body">
+                  <div
+                    v-for="item in files"
+                    :key="fileKey(item)"
+                    class="popup-item file-item"
+                    :title="`预览 ${item.name}`"
+                    @click="handleFileClick(item)"
+                  >
+                    <div v-if="previewingKey === fileKey(item)" class="row-loading"></div>
+                    <div v-else class="item-icon" :style="{ backgroundImage: getFileIconVar(item.name) }"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="item.name">{{ item.name }}</div>
+                      <div class="item-meta">
+                        <span class="item-tag" :class="item.kind">{{ item.kind === 'upload' ? '上传' : 'AI生成' }}</span>
+                        <span v-if="item.size != null">{{ formatFileSize(item.size) }}</span>
+                      </div>
+                    </div>
+                    <button class="download-btn" :title="`下载 ${item.name}`" @click.stop="downloadFile(item)">
+                      <div class="download-btn-icon"></div>
+                    </button>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+            <div v-if="links.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedLinks }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedLinks"
+                @click="collapsedLinks = !collapsedLinks"
+                @keydown.enter.prevent="collapsedLinks = !collapsedLinks"
+                @keydown.space.prevent="collapsedLinks = !collapsedLinks"
+              >
+                <span class="section-title-text">链接</span>
+                <span class="section-count">{{ links.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedLinks" class="section-body">
+                  <div v-for="link in links" :key="link.href" class="popup-item link-item" @click="openLink(link)">
+                    <div class="link-glyph"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="link.title">{{ link.title }}</div>
+                      <div class="item-meta link-href" :title="link.href">{{ link.href }}</div>
+                    </div>
+                    <div class="link-arrow"></div>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+            <div v-if="agents.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedAgents }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedAgents"
+                @click="collapsedAgents = !collapsedAgents"
+                @keydown.enter.prevent="collapsedAgents = !collapsedAgents"
+                @keydown.space.prevent="collapsedAgents = !collapsedAgents"
+              >
+                <span class="section-title-text">智能体</span>
+                <span class="section-count">{{ agents.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedAgents" class="section-body">
+                  <div v-for="item in agents" :key="agentKey(item)" class="popup-item agent-item" @click="handleAgentClick(item)">
+                    <div class="agent-icon"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="item.agent">{{ item.agent }}</div>
+                      <div class="item-meta">
+                        <span class="agent-status" :class="item.status">
+                          <span class="status-dot"></span>
+                          {{ agentStatusText(item.status) }}
+                        </span>
+                        <span>第{{ item.round }}轮</span>
+                        <span v-if="item.toolCount > 0">{{ item.toolCount }} 次工具调用</span>
+                        <span v-if="item.hiddenAnswer" class="item-tag folded">折叠回答</span>
+                      </div>
+                    </div>
+                    <button class="locate-btn" :title="`定位到第 ${item.round} 轮消息`" @click.stop="handleLocateClick(item)">
+                      <div class="locate-btn-icon"></div>
+                    </button>
+                    <div class="link-arrow"></div>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+          </template>
+          <div v-else class="popup-empty">
+            <div class="popup-empty-title">暂无内容</div>
+            <div class="popup-empty-desc">AI 生成的文件、可跳转链接与智能体调用记录会汇总到这里</div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, watch, nextTick } from 'vue';
+  import { CloseOutlined } from '@ant-design/icons-vue';
+  import { onClickOutside, onKeyStroke } from '@vueuse/core';
+  import { message } from 'ant-design-vue';
+  import { formatFileSize, getFileIconVar, fetchPreviewFile } from './utils';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
+
+  const props = defineProps<{
+    files: CapsuleFileItem[];
+    links: CapsuleLinkItem[];
+    agents: CapsuleAgentItem[];
+    // 当前任务 id:切换任务时收起弹层,避免用户误操作上一个任务的数据
+    taskId?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'file-preview', file: AttachedFile): void;
+    (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
+    (e: 'locate-message', messageIndex: number): void;
+  }>();
+
+  const wrapRef = ref<HTMLElement>();
+  const isOpen = ref(false);
+  // 分区默认折叠,点击标题后才展开
+  const collapsedFiles = ref(true);
+  const collapsedLinks = ref(true);
+  const collapsedAgents = ref(true);
+  // 正在拉取预览内容的文件行(显示加载态、防重复点击)
+  const previewingKey = ref<string | null>(null);
+  const totalCount = computed(() => props.files.length + props.links.length + props.agents.length);
+
+  onClickOutside(wrapRef, () => {
+    isOpen.value = false;
+  });
+
+  // Esc 收起弹层(键盘可达性)
+  onKeyStroke('Escape', () => {
+    isOpen.value = false;
+  });
+
+  watch(
+    () => props.taskId,
+    () => {
+      isOpen.value = false;
+      // 切换任务后分区回到默认折叠状态
+      collapsedFiles.value = true;
+      collapsedLinks.value = true;
+      collapsedAgents.value = true;
+    }
+  );
+
+  // 弹层宽度自适应:窄窗口/拖拽缩放后避免超出聊天面板左边界被裁切。
+  // 容器带 scale 缩放,getBoundingClientRect 是缩放后的视口坐标,需换算回布局坐标
+  const popupWidth = ref(300);
+  const fitPopupWidth = () => {
+    const btn = wrapRef.value;
+    const panel = btn?.closest('.chat-panel') as HTMLElement | null;
+    if (!btn || !panel) return;
+    const btnRect = btn.getBoundingClientRect();
+    const panelRect = panel.getBoundingClientRect();
+    const scale = panel.offsetHeight ? panelRect.height / panel.offsetHeight : 1;
+    const available = (btnRect.right - panelRect.left - 8) / scale;
+    popupWidth.value = Math.max(200, Math.min(300, available));
+  };
+
+  watch(isOpen, (open) => {
+    if (open) nextTick(fitPopupWidth);
+  });
+
+  // 稳定列表 key:文件按来源唯一标识(上传附件 id / 下载地址),智能体按消息×身份标识
+  const fileKey = (item: CapsuleFileItem): string =>
+    item.kind === 'upload' ? `upload-${item.file?.id || item.name}` : `download-${item.url || item.name}`;
+  const agentKey = (item: CapsuleAgentItem): string => `agent-${item.messageIndex}-${item.agentId || item.agent}`;
+
+  // 下载锚点挂在胶囊根节点内而非 body:
+  // 程序化 a.click() 的合成点击(detail === 0)会被 onClickOutside 当作"外部点击"而关闭弹层,
+  // 挂载到组件内部后合成点击的路径包含组件根节点,不会被误判
+  const triggerDownload = (href: string, filename: string) => {
+    const a = document.createElement('a');
+    a.href = href;
+    a.download = filename;
+    const mountEl = wrapRef.value || document.body;
+    mountEl.appendChild(a);
+    a.click();
+    mountEl.removeChild(a);
+  };
+
+  const downloadFile = async (item: CapsuleFileItem) => {
+    // AI 下载链接直接走地址下载
+    if (item.kind === 'download') {
+      if (!item.url) return;
+      triggerDownload(item.url, item.name);
+      return;
+    }
+    // 上传附件:优先用原始文件对象下载(字节保真),否则由本地缓存内容构造 Blob
+    const file = item.file;
+    if (!file) return;
+    if (file.originalFile) {
+      const url = URL.createObjectURL(file.originalFile);
+      triggerDownload(url, file.originalFile.name || file.name);
+      URL.revokeObjectURL(url);
+      return;
+    }
+    let blob: Blob | null = null;
+    if (file.content != null) {
+      blob = new Blob([file.content], { type: file.type || 'text/plain' });
+    } else if (file.arrayBuffer) {
+      blob = new Blob([file.arrayBuffer], { type: file.type || 'application/octet-stream' });
+    } else if (file.preview) {
+      try {
+        blob = await (await fetch(file.preview)).blob();
+      } catch {
+        blob = null;
+      }
+    }
+    if (!blob) {
+      emit('file-preview', file);
+      return;
+    }
+    const url = URL.createObjectURL(blob);
+    triggerDownload(url, file.name);
+    URL.revokeObjectURL(url);
+  };
+
+  // 点击文件行:统一在右侧面板打开预览(上传附件直接用本地数据,下载链接先拉取内容)
+  const handleFileClick = async (item: CapsuleFileItem) => {
+    const key = fileKey(item);
+    // 该行正在拉取预览内容时忽略重复点击
+    if (previewingKey.value === key) return;
+    if (item.kind === 'upload') {
+      if (item.file) emit('file-preview', item.file);
+      return;
+    }
+    if (!item.url) return;
+    previewingKey.value = key;
+    try {
+      const previewFile = await fetchPreviewFile(item.url, item.name);
+      if (!previewFile) {
+        downloadFile(item);
+        return;
+      }
+      emit('file-preview', previewFile);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('文件预览失败,请尝试下载查看');
+    } finally {
+      previewingKey.value = null;
+    }
+  };
+
+  // 与消息正文中 markdown 链接的渲染行为一致:新标签页打开
+  const openLink = (link: CapsuleLinkItem) => {
+    if (!link.href) return;
+    window.open(link.href, '_blank', 'noopener,noreferrer');
+  };
+
+  const agentStatusText = (status: CapsuleAgentItem['status']): string => {
+    if (status === 'done') return '已完成';
+    if (status === 'running') return '运行中';
+    if (status === 'waiting') return '等待确认';
+    return '已中断';
+  };
+
+  // 点击智能体条目:交给父组件打开右侧智能体详情面板(身份与思考卡片分组一致)
+  const handleAgentClick = (item: CapsuleAgentItem) => {
+    emit('agent-detail-click', { messageIndex: item.messageIndex, agent: item.agent, agentId: item.agentId });
+  };
+
+  // 定位到该智能体所在的消息(收起弹层后由父组件滚动消息列表)
+  const handleLocateClick = (item: CapsuleAgentItem) => {
+    isOpen.value = false;
+    emit('locate-message', item.messageIndex);
+  };
+</script>
+
+<style scoped lang="less">
+  .resource-capsule {
+    position: relative;
+    flex-shrink: 0;
+    margin-left: 8px;
+    z-index: 10;
+
+    .capsule-btn {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      height: 36px;
+      padding: 0 12px;
+      border-radius: 18px;
+      background: linear-gradient(135deg, rgba(10, 132, 255, 0.22) 0%, rgba(10, 132, 255, 0.07) 100%);
+      border: 1px solid rgba(10, 132, 255, 0.45);
+      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
+      cursor: pointer;
+      user-select: none;
+      font-family: inherit;
+      transition:
+        background 0.2s,
+        border-color 0.2s,
+        box-shadow 0.2s,
+        transform 0.1s;
+
+      &:hover {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.32) 0%, rgba(10, 132, 255, 0.14) 100%);
+        border-color: rgba(10, 132, 255, 0.65);
+        box-shadow:
+          0 0 10px rgba(10, 132, 255, 0.25),
+          inset 0 1px 0 rgba(255, 255, 255, 0.08);
+      }
+
+      &:active {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.4) 0%, rgba(10, 132, 255, 0.2) 100%);
+        transform: scale(0.97);
+      }
+
+      &:focus-visible {
+        outline: 1px solid rgba(10, 132, 255, 0.8);
+        outline-offset: 1px;
+      }
+
+      &.open {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.32) 0%, rgba(10, 132, 255, 0.14) 100%);
+        border-color: rgba(10, 132, 255, 0.65);
+        box-shadow:
+          0 0 10px rgba(10, 132, 255, 0.25),
+          inset 0 1px 0 rgba(255, 255, 255, 0.08);
+      }
+
+      .capsule-icon {
+        width: 14px;
+        height: 14px;
+        background-image: var(--img-chat-attach-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        filter: drop-shadow(0 0 3px rgba(10, 132, 255, 0.5));
+      }
+
+      .capsule-text {
+        font-size: 12px;
+        color: #e0e6ed;
+        white-space: nowrap;
+      }
+
+      .capsule-count {
+        min-width: 18px;
+        height: 18px;
+        line-height: 18px;
+        padding: 0 5px;
+        border-radius: 9px;
+        background: linear-gradient(135deg, #2f9bff 0%, #0a84ff 100%);
+        box-shadow: 0 1px 4px rgba(10, 132, 255, 0.45);
+        color: #fff;
+        font-size: 11px;
+        text-align: center;
+        flex-shrink: 0;
+      }
+
+      .capsule-arrow {
+        width: 10px;
+        height: 10px;
+        background-image: var(--img-chat-arrow-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        transition: transform 0.2s;
+      }
+
+      &.open .capsule-arrow {
+        transform: rotate(180deg);
+      }
+    }
+
+    .capsule-popup {
+      position: absolute;
+      top: calc(100% + 8px);
+      right: 0;
+      width: 300px;
+      max-height: 400px;
+      background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
+      border: 1px solid #1a4a6f;
+      border-radius: 10px;
+      box-shadow:
+        0 8px 24px rgba(0, 0, 0, 0.5),
+        0 0 14px rgba(10, 132, 255, 0.08);
+      z-index: 1000;
+      display: flex;
+      flex-direction: column;
+
+      // 指向胶囊按钮的小箭头(与会话提示条箭头同一画法)
+      &::before {
+        content: '';
+        position: absolute;
+        top: -6px;
+        right: 26px;
+        width: 10px;
+        height: 10px;
+        transform: rotate(45deg);
+        background: #0d2137;
+        border-left: 1px solid #1a4a6f;
+        border-top: 1px solid #1a4a6f;
+      }
+
+      .popup-header {
+        padding: 12px 14px;
+        border-bottom: 1px solid rgba(26, 74, 111, 0.55);
+        background: linear-gradient(180deg, rgba(10, 132, 255, 0.1) 0%, rgba(10, 132, 255, 0.02) 100%);
+        border-radius: 10px 10px 0 0;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        flex-shrink: 0;
+
+        .popup-title {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          font-size: 13px;
+          font-weight: 500;
+          letter-spacing: 0.3px;
+          color: #e6edf3;
+
+          .popup-title-icon {
+            width: 15px;
+            height: 15px;
+            background-image: var(--img-chat-attach-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            filter: drop-shadow(0 0 3px rgba(10, 132, 255, 0.5));
+            flex-shrink: 0;
+          }
+        }
+
+        .close-icon {
+          cursor: pointer;
+          font-size: 12px;
+          color: #8b949e;
+          width: 22px;
+          height: 22px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          border-radius: 50%;
+          transition:
+            color 0.2s,
+            background 0.2s;
+
+          &:hover {
+            color: #e0e6ed;
+            background: rgba(10, 132, 255, 0.2);
+          }
+        }
+      }
+
+      .popup-body {
+        flex: 1;
+        overflow-y: auto;
+        padding: 6px;
+        scrollbar-width: thin;
+        scrollbar-color: rgba(139, 148, 158, 0.4) transparent;
+
+        &::-webkit-scrollbar {
+          width: 6px;
+        }
+
+        &::-webkit-scrollbar-track {
+          background: transparent;
+        }
+
+        &::-webkit-scrollbar-thumb {
+          background: rgba(139, 148, 158, 0.35);
+          border-radius: 3px;
+
+          &:hover {
+            background: rgba(139, 148, 158, 0.6);
+          }
+        }
+
+        .popup-section {
+          & + .popup-section {
+            margin-top: 4px;
+            padding-top: 6px;
+            border-top: 1px solid rgba(63, 80, 106, 0.35);
+          }
+
+          .section-title {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 6px 10px;
+            border-radius: 6px;
+            font-size: 12px;
+            font-weight: 500;
+            letter-spacing: 0.4px;
+            color: #a9b9c9;
+            cursor: pointer;
+            user-select: none;
+            transition:
+              background 0.2s,
+              color 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.12);
+              color: #e0e6ed;
+            }
+
+            .section-title-text {
+              font-size: inherit;
+              font-weight: inherit;
+              letter-spacing: inherit;
+              color: inherit;
+            }
+
+            .section-count {
+              min-width: 16px;
+              height: 16px;
+              line-height: 16px;
+              padding: 0 5px;
+              border-radius: 8px;
+              background: rgba(10, 132, 255, 0.14);
+              color: #79c0ff;
+              font-size: 11px;
+              text-align: center;
+            }
+
+            .section-arrow {
+              width: 10px;
+              height: 10px;
+              margin-left: auto;
+              background-image: var(--img-chat-arrow-icon);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              opacity: 0.7;
+              transition:
+                transform 0.2s,
+                opacity 0.2s;
+            }
+
+            &:hover .section-arrow {
+              opacity: 1;
+            }
+
+            &.collapsed .section-arrow {
+              transform: rotate(180deg);
+            }
+          }
+        }
+
+        .popup-item {
+          display: flex;
+          align-items: center;
+          gap: 10px;
+          padding: 9px 10px;
+          cursor: pointer;
+          border-radius: 6px;
+          transition: background 0.2s;
+
+          &:hover {
+            background: rgba(10, 132, 255, 0.14);
+          }
+
+          &:active {
+            background: rgba(10, 132, 255, 0.22);
+          }
+
+          .item-icon {
+            width: 26px;
+            height: 26px;
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .row-loading {
+            width: 16px;
+            height: 16px;
+            margin: 5px;
+            border: 2px solid rgba(10, 132, 255, 0.3);
+            border-top-color: #0a84ff;
+            border-radius: 50%;
+            flex-shrink: 0;
+            animation: row-spin 0.8s linear infinite;
+          }
+
+          .item-info {
+            flex: 1;
+            min-width: 0;
+
+            .item-name {
+              font-size: 12px;
+              color: #e0e6ed;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+
+            .item-meta {
+              display: flex;
+              align-items: center;
+              gap: 6px;
+              margin-top: 2px;
+              font-size: 11px;
+              color: #8b949e;
+
+              .item-tag {
+                padding: 0 5px;
+                border-radius: 3px;
+                line-height: 16px;
+
+                &.upload {
+                  color: #79c0ff;
+                  background: rgba(10, 132, 255, 0.15);
+                }
+
+                &.download {
+                  color: #8b949e;
+                  background: rgba(139, 148, 158, 0.15);
+                }
+
+                &.folded {
+                  color: #8b949e;
+                  background: rgba(139, 148, 158, 0.15);
+                }
+              }
+            }
+
+            .link-href {
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+          }
+
+          .agent-icon {
+            width: 26px;
+            height: 26px;
+            background-image: var(--img-chat-agent-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .agent-status {
+            display: inline-flex;
+            align-items: center;
+            gap: 4px;
+            padding: 0 5px;
+            border-radius: 3px;
+            line-height: 16px;
+
+            .status-dot {
+              width: 6px;
+              height: 6px;
+              border-radius: 50%;
+              background: currentColor;
+            }
+
+            &.running {
+              color: #0a84ff;
+              background: rgba(10, 132, 255, 0.15);
+              box-shadow: 0 0 6px rgba(10, 132, 255, 0.25);
+
+              .status-dot {
+                animation: status-pulse 1.2s ease-in-out infinite;
+              }
+            }
+
+            &.waiting {
+              color: #a371f7;
+              background: rgba(163, 113, 247, 0.12);
+            }
+
+            &.done {
+              color: #3fb950;
+              background: rgba(63, 185, 80, 0.12);
+            }
+
+            &.interrupted {
+              color: #d29922;
+              background: rgba(210, 153, 34, 0.12);
+            }
+          }
+
+          .download-btn {
+            width: 24px;
+            height: 24px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 0;
+            border: none;
+            border-radius: 4px;
+            background: transparent;
+            cursor: pointer;
+            flex-shrink: 0;
+            transition:
+              background 0.2s,
+              transform 0.1s;
+
+            .download-btn-icon {
+              width: 14px;
+              height: 14px;
+              background-image: var(--img-chat-download-icon-btn);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+            }
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.25);
+            }
+
+            &:active {
+              background: rgba(10, 132, 255, 0.4);
+              transform: scale(0.88);
+            }
+
+            &:focus-visible {
+              outline: 1px solid rgba(10, 132, 255, 0.8);
+            }
+          }
+
+          .locate-btn {
+            width: 24px;
+            height: 24px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 0;
+            border: none;
+            border-radius: 4px;
+            background: transparent;
+            cursor: pointer;
+            flex-shrink: 0;
+            transition:
+              background 0.2s,
+              transform 0.1s;
+
+            .locate-btn-icon {
+              width: 14px;
+              height: 14px;
+              background-image: var(--img-chat-locate-icon);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              opacity: 0.8;
+              transition: opacity 0.2s;
+            }
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.25);
+
+              .locate-btn-icon {
+                opacity: 1;
+              }
+            }
+
+            &:active {
+              background: rgba(10, 132, 255, 0.4);
+              transform: scale(0.88);
+            }
+
+            &:focus-visible {
+              outline: 1px solid rgba(10, 132, 255, 0.8);
+            }
+          }
+
+          .link-glyph {
+            width: 26px;
+            height: 26px;
+            background-image: var(--img-chat-link-icon);
+            background-repeat: no-repeat;
+            background-size: 14px 14px;
+            background-position: center;
+            flex-shrink: 0;
+            opacity: 0.8;
+            transition: opacity 0.2s;
+          }
+
+          &:hover .link-glyph {
+            opacity: 1;
+          }
+
+          .link-arrow {
+            width: 10px;
+            height: 10px;
+            background-image: var(--img-chat-next-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+        }
+
+        .popup-empty {
+          padding: 20px 12px;
+          text-align: center;
+
+          .popup-empty-title {
+            font-size: 12px;
+            color: #c9d1d9;
+          }
+
+          .popup-empty-desc {
+            margin-top: 4px;
+            font-size: 11px;
+            line-height: 1.5;
+            color: #8b949e;
+          }
+        }
+      }
+    }
+  }
+
+  .capsule-fade-enter-active,
+  .capsule-fade-leave-active {
+    transition:
+      opacity 0.15s ease,
+      transform 0.15s ease;
+  }
+
+  .capsule-fade-enter-from,
+  .capsule-fade-leave-to {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+
+  // 分区折叠过渡(与思考卡片折叠动画同一模式)
+  .collapse-enter-active,
+  .collapse-leave-active {
+    transition: all 0.25s ease;
+    overflow: hidden;
+  }
+
+  .collapse-enter-from,
+  .collapse-leave-to {
+    opacity: 0;
+    max-height: 0;
+  }
+
+  .collapse-enter-to,
+  .collapse-leave-from {
+    opacity: 1;
+    max-height: 600px;
+  }
+
+  @keyframes status-pulse {
+    0%,
+    100% {
+      opacity: 1;
+    }
+    50% {
+      opacity: 0.3;
+    }
+  }
+
+  @keyframes row-spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
+</style>

+ 109 - 0
src/views/ventAI/manageAssistent/components/chatModal/capsuleData.ts

@@ -0,0 +1,109 @@
+import type { Task, Message, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
+import { extractMarkdownLinks, groupStepsByAgent } from './utils';
+
+// 胶囊聚合数据构建(纯函数):由主弹框的 computed 接线调用,
+// 与响应式状态管理解耦,便于单独测试
+
+// 聚合文件:当前对话下用户上传的附件 + AI 返回的下载链接(含 Word 报告,按 url 去重)
+export const buildCapsuleFiles = (task: Task | undefined, messages: Message[]): CapsuleFileItem[] => {
+  if (!task) return [];
+  const items: CapsuleFileItem[] = [];
+  const seenUrls = new Set<string>();
+  task.attachedFiles.forEach((f) => {
+    items.push({ kind: 'upload', name: f.name, size: f.size, file: f });
+  });
+  for (const msg of messages) {
+    if (msg.type !== 'ai') continue;
+    msg.downloadFiles?.forEach((d) => {
+      if (!d.url || seenUrls.has(d.url)) return;
+      seenUrls.add(d.url);
+      items.push({ kind: 'download', name: d.filename || '文件', url: d.url });
+    });
+    if (msg.wordDownloadUrl && !seenUrls.has(msg.wordDownloadUrl)) {
+      seenUrls.add(msg.wordDownloadUrl);
+      items.push({ kind: 'download', name: '报告文件.docx', url: msg.wordDownloadUrl });
+    }
+  }
+  if (task.wordUrl && !seenUrls.has(task.wordUrl)) {
+    seenUrls.add(task.wordUrl);
+    items.push({ kind: 'download', name: '报告文件.docx', url: task.wordUrl });
+  }
+  return items;
+};
+
+// 聚合链接:AI 回复文本中的可跳转链接(与消息内 markdown 渲染共用同一套解析,按 href 去重)。
+// 已在"文件"区展示的下载地址不再重复列入链接区;
+// 除精确匹配外,还按"路径+查询+哈希"匹配,覆盖正文相对路径与后端完整地址写法不一致的情况
+export const buildCapsuleLinks = (files: CapsuleFileItem[], messages: Message[]): CapsuleLinkItem[] => {
+  const normalize = (u: string) => u.trim().replace(/&amp;/g, '&');
+  const pathKey = (u: string): string => {
+    try {
+      const parsed = new URL(u, window.location.href);
+      return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+    } catch {
+      return '';
+    }
+  };
+  const seenDownload = new Set<string>();
+  const seenDownloadPath = new Set<string>();
+  for (const item of files) {
+    if (!item.url) continue;
+    const href = normalize(item.url);
+    seenDownload.add(href);
+    const key = pathKey(href);
+    if (key) seenDownloadPath.add(key);
+  }
+  const seenLink = new Set<string>();
+  const links: CapsuleLinkItem[] = [];
+  for (const msg of messages) {
+    if (msg.type !== 'ai' || !msg.content) continue;
+    for (const link of extractMarkdownLinks(msg.content)) {
+      const href = normalize(link.href);
+      if (!href || seenLink.has(href)) continue;
+      const key = pathKey(href);
+      if (seenDownload.has(href) || (key && seenDownloadPath.has(key))) continue;
+      seenLink.add(href);
+      links.push({ title: link.title, href });
+    }
+  }
+  return links;
+};
+
+// 聚合智能体:thinking 卡片中的智能体调用记录,按(消息、智能体)聚合,
+// 每次调用一个条目、状态独立(start→done 生命周期),条目身份与右侧详情面板对齐。
+// 仅收录有实际执行活动的组(含 start/done/工具调用/执行中),纯思考 token 的"系统"组不进入。
+// isAnswerVisible 由调用方传入(多回答折叠组中该消息是否为当前显示的回答)
+export const buildCapsuleAgents = (messages: Message[], isAnswerVisible?: (index: number) => boolean | undefined): CapsuleAgentItem[] => {
+  const agents: CapsuleAgentItem[] = [];
+  for (let i = 0; i < messages.length; i++) {
+    const msg = messages[i];
+    if (msg.type !== 'ai' || !msg.thinkingSteps?.length) continue;
+    // 该消息所属问答轮次:数到当前消息为止(含本轮提问)的用户消息条数,
+    // 无前置提问的 AI 消息兜底为第 1 轮
+    let round = 0;
+    for (let j = 0; j <= i; j++) {
+      if (messages[j].type === 'user') round++;
+    }
+    round = Math.max(1, round);
+    for (const group of groupStepsByAgent(msg.thinkingSteps)) {
+      const hasActivity = group.steps.some((s) => s.type !== 'token');
+      if (!hasActivity) continue;
+      const done = group.steps.some((s) => s.type === 'agent_done');
+      const toolCount = group.steps.filter((s) => s.type === 'tool_call').length;
+      // 状态优先级:完成 > 审批等待(暂停) > 流式中 > 已中断
+      const status: CapsuleAgentItem['status'] = done ? 'done' : msg.isPendingApproval ? 'waiting' : msg.isLoading ? 'running' : 'interrupted';
+      agents.push({
+        messageIndex: i,
+        agent: group.agent,
+        agentId: group.agentId,
+        status,
+        round,
+        toolCount,
+        stepCount: group.steps.length,
+        // 多回答折叠组中被隐藏的历史回答(重新生成产生),供胶囊面板标注
+        hiddenAnswer: isAnswerVisible ? isAnswerVisible(i) === false : false,
+      });
+    }
+  }
+  return agents;
+};

+ 179 - 0
src/views/ventAI/manageAssistent/components/chatModal/exportDoc.ts

@@ -0,0 +1,179 @@
+import { marked } from 'marked';
+import dayjs from 'dayjs';
+import type { Message } from './types';
+
+/**
+ * 剥离会话导出中不应出现的注入内容:
+ * - 技能/会话引用块 [<img .../> 技能:xxx] / [<img .../> 引用 N 个会话:names]
+ * - 残留的 <img> / <br> 标签
+ */
+const cleanContent = (content: string): string =>
+  (content || '')
+    .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+    .replace(/<img[^>]*>/g, '')
+    .replace(/<br\s*\/?>/gi, '\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+
+/**
+ * 把会话消息列表转成干净的 Markdown 文本(仅问/答正文与时间戳,不含思考过程与引用详情)
+ */
+export const buildConversationMarkdown = (messages: Message[]): string => {
+  const lines: string[] = [];
+  for (const msg of messages) {
+    const time = msg.createdAt ? `(${dayjs(msg.createdAt).format('YYYY-MM-DD HH:mm:ss')})` : msg.time ? `(${msg.time})` : '';
+    if (msg.type === 'user') {
+      lines.push(`### 用户 ${time}`.trimEnd());
+      lines.push('');
+      lines.push(cleanContent(msg.content));
+      if (msg.attachedFile) {
+        lines.push('');
+        lines.push(`> 附件:${msg.attachedFile.name}`);
+      }
+    } else {
+      lines.push(`### 助手 ${time}`.trimEnd());
+      lines.push('');
+      lines.push(cleanContent(msg.content));
+    }
+    lines.push('');
+    lines.push('---');
+    lines.push('');
+  }
+  return lines.join('\n').trimEnd() + '\n';
+};
+
+/**
+ * 把 Markdown 转成标准 .docx 的 Blob。
+ * 依赖 docx(纯前端生成 OOXML),通过动态 import 按需加载,避免拖慢首屏。
+ */
+export const markdownToDocxBlob = async (markdown: string, title?: string): Promise<Blob> => {
+  // docx 的类型是极深的判别联合,直接类型关联会令 TS4.9 爆栈,这里整体断言为 any 绕开深类型检查
+  const { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType, AlignmentType, UnderlineType } =
+    (await import('docx')) as any;
+
+  // 行内 token 递归渲染为 TextRun 序列(加粗/斜体/删除线/行内代码/链接)
+  const renderInline = (tokens: any[], style: { bold?: boolean; italics?: boolean; strike?: boolean } = {}): TextRun[] => {
+    const runs: TextRun[] = [];
+    for (const t of tokens || []) {
+      switch (t.type) {
+        case 'text':
+        case 'escape':
+          runs.push(new TextRun({ text: t.text, bold: style.bold, italics: style.italics, strike: style.strike }));
+          break;
+        case 'strong':
+          runs.push(...renderInline(t.tokens, { ...style, bold: true }));
+          break;
+        case 'em':
+          runs.push(...renderInline(t.tokens, { ...style, italics: true }));
+          break;
+        case 'del':
+          runs.push(...renderInline(t.tokens, { ...style, strike: true }));
+          break;
+        case 'codespan':
+          runs.push(new TextRun({ text: t.text, font: { name: 'Consolas' }, bold: style.bold, italics: style.italics }));
+          break;
+        case 'link':
+          runs.push(new TextRun({ text: t.text || t.href || '', color: '0563C1', underline: { type: UnderlineType.SINGLE } }));
+          break;
+        case 'br':
+          runs.push(new TextRun({ break: 1 }));
+          break;
+        case 'image':
+          runs.push(new TextRun({ text: t.title || '图片', italics: true }));
+          break;
+        case 'html':
+        case 'tag':
+          break;
+        default:
+          if (t.tokens) runs.push(...renderInline(t.tokens, style));
+          else if (t.text) runs.push(new TextRun({ text: t.text }));
+          break;
+      }
+    }
+    return runs.length ? runs : [new TextRun({ text: '' })];
+  };
+
+  const children: (Paragraph | Table)[] = [];
+
+  if (title) {
+    children.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }));
+  }
+
+  const headingLevelMap: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
+    1: HeadingLevel.HEADING_1,
+    2: HeadingLevel.HEADING_2,
+    3: HeadingLevel.HEADING_3,
+    4: HeadingLevel.HEADING_4,
+    5: HeadingLevel.HEADING_5,
+    6: HeadingLevel.HEADING_6,
+  };
+
+  const tokens = marked.lexer(markdown) as any[];
+
+  for (const t of tokens) {
+    switch (t.type) {
+      case 'heading': {
+        const level = Math.min(6, Math.max(1, t.depth));
+        children.push(new Paragraph({ children: renderInline(t.tokens), heading: headingLevelMap[level] }));
+        break;
+      }
+      case 'paragraph':
+        children.push(new Paragraph({ children: renderInline(t.tokens) }));
+        break;
+      case 'list': {
+        (t.items || []).forEach((item: any, idx: number) => {
+          const prefix = t.ordered ? `${(t.start || 1) + idx}. ` : '• ';
+          children.push(
+            new Paragraph({
+              children: [new TextRun({ text: prefix }), ...renderInline(item.tokens)],
+              indent: { left: 360 },
+            })
+          );
+        });
+        break;
+      }
+      case 'code': {
+        const lines = (t.text || '').split('\n');
+        const runs: TextRun[] = [];
+        lines.forEach((line: string, i: number) => {
+          runs.push(new TextRun({ text: line, font: { name: 'Consolas' } }));
+          if (i < lines.length - 1) runs.push(new TextRun({ break: 1 }));
+        });
+        children.push(new Paragraph({ children: runs, shading: { fill: 'F2F2F2' }, spacing: { before: 80, after: 80 } }));
+        break;
+      }
+      case 'table': {
+        const headerRow = new TableRow({
+          tableHeader: true,
+          children: (t.header || []).map(
+            (c: any) =>
+              new TableCell({
+                children: [new Paragraph({ children: [new TextRun({ text: c.text || '', bold: true })] })],
+                shading: { fill: 'D9E2F3' },
+              })
+          ),
+        });
+        const bodyRows = (t.rows || []).map(
+          (row: any[]) =>
+            new TableRow({
+              children: row.map((c: any) => new TableCell({ children: [new Paragraph({ children: renderInline(c.tokens || []) })] })),
+            })
+        );
+        children.push(new Table({ rows: [headerRow, ...bodyRows], width: { size: 100, type: WidthType.PERCENTAGE } }));
+        break;
+      }
+      case 'blockquote':
+        children.push(new Paragraph({ children: renderInline(t.tokens), indent: { left: 360 }, italics: true }));
+        break;
+      case 'hr':
+        children.push(new Paragraph({ children: [new TextRun({ text: '—'.repeat(20), color: 'BFBFBF' })], alignment: AlignmentType.CENTER }));
+        break;
+      case 'space':
+      default:
+        break;
+    }
+  }
+
+  const doc = new Document({ sections: [{ children }] });
+  return Packer.toBlob(doc);
+};

+ 35 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -11,6 +11,39 @@ export interface AttachedFile {
   originalFile?: File;
 }
 
+// 胶囊面板聚合文件项:用户上传的附件 + AI 返回的下载链接
+export interface CapsuleFileItem {
+  kind: 'upload' | 'download';
+  name: string;
+  size?: number;
+  // upload 类型的原始附件(用于打开右侧预览面板)
+  file?: AttachedFile;
+  // download 类型的下载地址
+  url?: string;
+}
+
+// 胶囊面板聚合链接项:从 AI 回复 markdown 文本中解析出的可跳转链接
+export interface CapsuleLinkItem {
+  title: string;
+  href: string;
+}
+
+// 胶囊面板聚合智能体条目:thinking 卡片中按(消息、智能体)聚合的一次智能体调用
+export interface CapsuleAgentItem {
+  messageIndex: number;
+  agent: string;
+  agentId?: string;
+  // running: 有 start 无 done 且消息仍在流式;done: 收到完成事件;
+  // waiting: 消息处于审批等待(智能体暂停待用户确认);interrupted: 未完成但消息已结束
+  status: 'running' | 'done' | 'waiting' | 'interrupted';
+  // 所属问答轮次(从 1 开始)
+  round: number;
+  toolCount: number;
+  stepCount: number;
+  // 是否属于多回答折叠组中被隐藏的历史回答(重新生成产生)
+  hiddenAnswer?: boolean;
+}
+
 export interface AgentAnswer {
   id: string;
   agentName: string;
@@ -85,6 +118,8 @@ export interface Message {
   id?: number;
   type: 'user' | 'ai';
   content: string;
+  // 原始用户输入(不含注入的技能/会话引用 <img> HTML),用于编辑重发/重新生成时回填模型输入
+  rawInput?: string;
   thinkingContent?: string;
   time: string;
   createdAt?: string;

+ 84 - 1
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -1,8 +1,10 @@
-import { marked, Renderer } from 'marked';
+import { marked, Renderer, walkTokens } from 'marked';
 import type { Tokens } from 'marked';
 import katex from 'katex';
 // [perf-base-v1] 卡6: DOMPurify 消毒实现迁到公共 util,本文件转调避免两处漂移
 import { sanitizeHtml } from '/@/utils/markdownSafe';
+import { getToken } from '/@/utils/auth';
+import type { AttachedFile, ThinkingStep } from './types';
 
 const mdRenderer = new Renderer();
 const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
@@ -70,6 +72,48 @@ export const renderMarkdown = (
   return icons ? wrapTables(html, icons) : html;
 };
 
+// 从 markdown 文本中提取链接(与 renderMarkdown 共用同一套 marked 解析,
+// 仅收集 link token,代码块/图片等不会被误收)
+export const extractMarkdownLinks = (text: string): Array<{ title: string; href: string }> => {
+  if (!text) return [];
+  const links: Array<{ title: string; href: string }> = [];
+  try {
+    const tokens = marked.lexer(text);
+    walkTokens(tokens, (token) => {
+      if (token.type === 'link') {
+        const linkToken = token as Tokens.Link;
+        const href = linkToken.href || '';
+        if (href) {
+          links.push({ title: linkToken.text || href, href });
+        }
+      }
+    });
+  } catch {
+    // 文本解析异常时返回已收集到的部分链接
+  }
+  return links;
+};
+
+export interface AgentGroup {
+  agent: string;
+  agentId?: string;
+  steps: ThinkingStep[];
+}
+
+// 按智能体聚合思考步骤(与思考卡片、右侧详情面板的分组语义一致:优先按 agentId 分组)
+export const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
+  const groupMap = new Map<string, AgentGroup>();
+  for (const step of steps) {
+    const agentName = step.agent || '系统';
+    const groupKey = step.agentId || agentName;
+    if (!groupMap.has(groupKey)) {
+      groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] });
+    }
+    groupMap.get(groupKey)!.steps.push(step);
+  }
+  return Array.from(groupMap.values());
+};
+
 export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
   return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
     const encodedTable = encodeURIComponent(match);
@@ -211,3 +255,42 @@ export const getFileIconVar = (filename: string): string => {
   if (isTextFile(filename)) return 'var(--img-chat-file-txt-icon)';
   return 'var(--img-chat-attach-icon)';
 };
+
+// 按文件类型拉取远端文件内容并构造右侧面板预览对象(消息内文件卡片与胶囊面板共用)。
+// 不支持的格式返回 null,由调用方回退为直接下载
+export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
+  const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  const mimeMap: Record<string, string> = {
+    md: 'text/markdown',
+    txt: 'text/plain',
+    json: 'application/json',
+    xml: 'text/xml',
+    yaml: 'text/yaml',
+    yml: 'text/yaml',
+    csv: 'text/csv',
+  };
+  const id = `report-${url}`;
+  const uploadTime = new Date().toISOString();
+
+  if (mimeMap[ext]) {
+    const content = await resp.text();
+    return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
+  }
+  if (isWordFile(filename) || isExcelFile(filename)) {
+    const arrayBuffer = await resp.arrayBuffer();
+    return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
+  }
+  if (isImageFile(filename) || isPdfFile(filename)) {
+    const blob = await resp.blob();
+    const dataUrl = await new Promise<string>((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(reader.result as string);
+      reader.onerror = () => reject(reader.error);
+      reader.readAsDataURL(blob);
+    });
+    return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
+  }
+  return null;
+};

Неке датотеке нису приказане због велике количине промена