Răsfoiți Sursa

[Feat 0000] AI助手新增消息重新编辑和重新生成的功能

wangkeyi 5 zile în urmă
părinte
comite
821687a59d

+ 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 - 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>

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

@@ -124,6 +124,8 @@ 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-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

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

@@ -132,6 +132,8 @@ 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-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

+ 230 - 114
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -53,6 +53,8 @@
             @create-task="createNewTask"
             @save-title="handleSaveTitle"
             @download-word="downloadWordReport"
+            @export-md="handleExportMarkdown"
+            @export-word="handleExportWord"
           />
 
           <!-- 附件列表面板 -->
@@ -70,6 +72,8 @@
             @agent-detail-click="handleAgentDetailClick"
             @open-markdown-tab="openMarkdownPreviewTab"
             @delete-message="handleDeleteMessage"
+            @regenerate="handleRegenerate"
+            @edit-resend="handleEditResend"
           />
 
           <!-- 任务进度条 -->
@@ -185,6 +189,7 @@
   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 { buildConversationMarkdown, markdownToDocxBlob } from './chatModal/exportDoc';
 
   interface Props {
     visible: boolean;
@@ -1651,20 +1656,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',
@@ -1686,19 +1682,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);
@@ -1706,7 +1716,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) {
@@ -1761,9 +1772,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 {
@@ -1774,6 +1801,7 @@
     const userMsg: Message = {
       type: 'user',
       content: displayContent || userInput || `上传了文件:${file.name}`,
+      rawInput: userInput,
       time: now.format('HH:mm'),
       createdAt: now.toISOString(),
       attachedFile: file,
@@ -1782,104 +1810,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 失败');
     }
   };
 
@@ -2326,7 +2442,7 @@
         top: 0 !important;
       }
       .zxm-modal-close,
-      .maximize-btn { 
+      .maximize-btn {
         top: 40px;
       }
 

+ 110 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatHeader.vue

@@ -32,6 +32,23 @@
       </template>
     </div>
 
+    <!-- 会话导出 -->
+    <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,6 +58,7 @@
 
 <script setup lang="ts">
   import { ref, nextTick } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
   import type { Task } from './types';
 
   interface Props {
@@ -60,8 +78,28 @@
     // 保存标题时触发,参数为新标题(父组件负责 API 调用和状态更新)
     'save-title': [newTitle: string];
     'download-word': [];
+    'export-md': [];
+    'export-word': [];
   }>();
 
+  // 导出气泡状态
+  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('');
@@ -200,6 +238,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;

+ 304 - 8
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">
@@ -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>
@@ -463,8 +498,175 @@
     (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;
+  };
+
+  // 该 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) => {
@@ -1141,6 +1343,10 @@
     .message-wrapper {
       // margin-bottom: 20px;
 
+      &.answer-hidden {
+        display: none;
+      }
+
       &.nav-located-highlight {
         background: rgba(10, 132, 255, 0.08);
         border-radius: 6px;
@@ -2139,6 +2345,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 {
@@ -2157,6 +2395,64 @@
     }
   }
 
+  .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;

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

@@ -0,0 +1,178 @@
+import { marked } from 'marked';
+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.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);
+};

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

@@ -85,6 +85,8 @@ export interface Message {
   id?: number;
   type: 'user' | 'ai';
   content: string;
+  // 原始用户输入(不含注入的技能/会话引用 <img> HTML),用于编辑重发/重新生成时回填模型输入
+  rawInput?: string;
   thinkingContent?: string;
   time: string;
   createdAt?: string;