소스 검색

fix:报告生成修改

韩洋 4 일 전
부모
커밋
6ec1acff5c
67개의 변경된 파일2123개의 추가작업 그리고 309개의 파일을 삭제
  1. BIN
      dist.zip
  2. 17 0
      dist/assets/html2canvas.esm-CBrSDip1.js
  3. 0 8
      dist/assets/index--h7aYXM5.js
  4. 0 8
      dist/assets/index--ho6O6Kg.js
  5. 0 8
      dist/assets/index-1KOQHalf.js
  6. 0 0
      dist/assets/index-3zRr7rNx.css
  7. 0 0
      dist/assets/index-5cH65p7j.css
  8. 0 0
      dist/assets/index-8Di7Vd4e.css
  9. 0 0
      dist/assets/index-B2AT6MuX.css
  10. 0 0
      dist/assets/index-BBKpgKqS.css
  11. 0 8
      dist/assets/index-BE9g0XkS.js
  12. 0 0
      dist/assets/index-BGJzR7bO.css
  13. 0 8
      dist/assets/index-BRHPI7p1.js
  14. 0 8
      dist/assets/index-BRbaVW_b.js
  15. 0 0
      dist/assets/index-BVhdHt2u.css
  16. 0 8
      dist/assets/index-BdwN_Cbc.js
  17. 0 0
      dist/assets/index-Bi1Dpv1n.css
  18. 0 0
      dist/assets/index-Bx-HZr9X.css
  19. 0 8
      dist/assets/index-C0jXhULb.js
  20. 0 8
      dist/assets/index-C35Q_VFj.js
  21. 0 8
      dist/assets/index-C3grXTKS.js
  22. 0 8
      dist/assets/index-C7BLZE9n.js
  23. 0 8
      dist/assets/index-CCN1RHtp.js
  24. 0 0
      dist/assets/index-CCUDalut.css
  25. 0 0
      dist/assets/index-CCk14gi3.css
  26. 0 8
      dist/assets/index-CE4Hxv09.js
  27. 0 0
      dist/assets/index-CJNDpFq6.css
  28. 0 8
      dist/assets/index-CRZ6KAUU.js
  29. 0 8
      dist/assets/index-CZPKjmNq.js
  30. 0 8
      dist/assets/index-C_tRo6pe.js
  31. 0 0
      dist/assets/index-Cb8WaEKa.css
  32. 0 8
      dist/assets/index-Cg6emnox.js
  33. 0 8
      dist/assets/index-CgifawpR.js
  34. 0 8
      dist/assets/index-Ckk64mV8.js
  35. 0 8
      dist/assets/index-CvS9RMHQ.js
  36. 0 8
      dist/assets/index-DCmS4cHY.js
  37. 0 8
      dist/assets/index-DJ6irjXJ.js
  38. 0 8
      dist/assets/index-DLlXY2H8.js
  39. 0 8
      dist/assets/index-DVBZLYJi.js
  40. 0 8
      dist/assets/index-DYgxE38B.js
  41. 0 0
      dist/assets/index-DfNTy5j_.css
  42. 0 0
      dist/assets/index-Dk-3_q1f.css
  43. 0 0
      dist/assets/index-DwxY0ne3.css
  44. 8 0
      dist/assets/index-G6BHhv_D.js
  45. 0 0
      dist/assets/index-KT_Kbj1G.css
  46. 0 0
      dist/assets/index-PqjZUx8o.css
  47. 0 8
      dist/assets/index-V83SpqLc.js
  48. 0 0
      dist/assets/index-WI8pZzOl.css
  49. 0 8
      dist/assets/index-YXzlYvWE.js
  50. 0 0
      dist/assets/index-iqYcRzNm.css
  51. 0 0
      dist/assets/index-mmoUBvDp.css
  52. 0 0
      dist/assets/index-wLd-DUm_.css
  53. 0 0
      dist/assets/index.es-DlWm4aDc.js
  54. 1 0
      dist/assets/jspdf.es.min-2xWNFoxg.js
  55. 1 0
      dist/assets/purify.es-BnINGy_Y.js
  56. 6 0
      dist/config.js
  57. 3 2
      dist/index.html
  58. 1 0
      index.html
  59. 4 0
      package.json
  60. 6 0
      public/config.js
  61. 658 0
      src/App.css
  62. 2 2
      src/components/Layout.jsx
  63. 647 0
      src/components/RichTextEditor.jsx
  64. 3 3
      src/config/agentEmptyStates.json
  65. 634 86
      src/pages/AgentExecute.jsx
  66. 33 0
      src/service/dify.js
  67. 99 0
      src/utils/exportDocx.js

BIN
dist.zip


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 17 - 0
dist/assets/html2canvas.esm-CBrSDip1.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index--h7aYXM5.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index--ho6O6Kg.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-1KOQHalf.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-3zRr7rNx.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-5cH65p7j.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-8Di7Vd4e.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-B2AT6MuX.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-BBKpgKqS.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-BE9g0XkS.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-BGJzR7bO.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-BRHPI7p1.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-BRbaVW_b.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-BVhdHt2u.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-BdwN_Cbc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-Bi1Dpv1n.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-Bx-HZr9X.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-C0jXhULb.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-C35Q_VFj.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-C3grXTKS.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-C7BLZE9n.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CCN1RHtp.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-CCUDalut.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-CCk14gi3.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CE4Hxv09.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-CJNDpFq6.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CRZ6KAUU.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CZPKjmNq.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-C_tRo6pe.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-Cb8WaEKa.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-Cg6emnox.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CgifawpR.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-Ckk64mV8.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-CvS9RMHQ.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-DCmS4cHY.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-DJ6irjXJ.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-DLlXY2H8.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-DVBZLYJi.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-DYgxE38B.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-DfNTy5j_.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-Dk-3_q1f.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-DwxY0ne3.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 8 - 0
dist/assets/index-G6BHhv_D.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-KT_Kbj1G.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-PqjZUx8o.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-V83SpqLc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-WI8pZzOl.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 8
dist/assets/index-YXzlYvWE.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-iqYcRzNm.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-mmoUBvDp.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-wLd-DUm_.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index.es-DlWm4aDc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
dist/assets/jspdf.es.min-2xWNFoxg.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
dist/assets/purify.es-BnINGy_Y.js


+ 6 - 0
dist/config.js

@@ -0,0 +1,6 @@
+// 运行时配置:以 <script> 方式在 index.html 引入,不参与构建打包,部署后可直接修改本文件生效
+window.APP_CONFIG = {
+  // 「内容润色」工作流(Dify 工作流类型应用)的 API Key
+  // 获取方式:进入该工作流应用 → 「访问 API / API Access」→ 创建/复制 API 密钥
+  POLISH_API_KEY: 'app-vSDb4OiGL0QDuQwBT3oumntH',
+}

+ 3 - 2
dist/index.html

@@ -5,8 +5,9 @@
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>智能体使用平台</title>
-    <script type="module" crossorigin src="/assets/index-BE9g0XkS.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DPekV8Of.css">
+    <script src="/config.js"></script>
+    <script type="module" crossorigin src="/assets/index-G6BHhv_D.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DwxY0ne3.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 0
index.html

@@ -5,6 +5,7 @@
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>智能体使用平台</title>
+    <script src="/config.js"></script>
   </head>
   <body>
     <div id="root"></div>

+ 4 - 0
package.json

@@ -12,7 +12,11 @@
   "dependencies": {
     "@arco-design/web-react": "^2.66.16",
     "@emoji-mart/data": "^1.2.1",
+    "html2canvas": "^1.4.1",
+    "jspdf": "^4.2.1",
+    "jszip": "^3.10.1",
     "marked": "^18.0.6",
+    "quill": "^2.0.3",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
     "react-router-dom": "^6.23.1"

+ 6 - 0
public/config.js

@@ -0,0 +1,6 @@
+// 运行时配置:以 <script> 方式在 index.html 引入,不参与构建打包,部署后可直接修改本文件生效
+window.APP_CONFIG = {
+  // 「内容润色」工作流(Dify 工作流类型应用)的 API Key
+  // 获取方式:进入该工作流应用 → 「访问 API / API Access」→ 创建/复制 API 密钥
+  POLISH_API_KEY: 'app-vSDb4OiGL0QDuQwBT3oumntH',
+}

+ 658 - 0
src/App.css

@@ -5051,6 +5051,88 @@
   opacity: 0.55;
 }
 
+/* ===== AI 消息图标操作按钮(复制 / 听语音 / 点赞点踩 / 重新生成) ===== */
+.execute-msg-icon-btn {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #86909c;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+
+.execute-msg-icon-btn svg {
+  width: 16px;
+  height: 16px;
+  display: block;
+  pointer-events: none;
+}
+
+.execute-msg-icon-btn:hover:not(:disabled) {
+  background: #f2f3f5;
+  color: #165dff;
+}
+
+.execute-msg-icon-btn.is-active,
+.execute-msg-icon-btn.is-speaking {
+  color: #165dff;
+}
+
+.execute-msg-icon-btn.is-liked {
+  color: #00b42a;
+}
+
+.execute-msg-icon-btn.is-liked:hover:not(:disabled) {
+  color: #00b42a;
+  background: rgba(0, 180, 42, 0.1);
+}
+
+.execute-msg-icon-btn.is-disliked {
+  color: #f53f3f;
+}
+
+.execute-msg-icon-btn.is-disliked:hover:not(:disabled) {
+  color: #f53f3f;
+  background: rgba(245, 63, 63, 0.1);
+}
+
+.execute-msg-icon-btn:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+/* 悬浮提示文本 */
+.execute-msg-icon-btn::after {
+  content: attr(data-tip);
+  position: absolute;
+  left: 50%;
+  bottom: calc(100% + 6px);
+  transform: translateX(-50%) translateY(4px);
+  padding: 5px 8px;
+  border-radius: 4px;
+  background: rgba(29, 33, 41, 0.92);
+  color: #ffffff;
+  font-size: 12px;
+  line-height: 1;
+  white-space: nowrap;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.15s ease, transform 0.15s ease;
+  z-index: 30;
+}
+
+.execute-msg-icon-btn:hover:not(:disabled)::after {
+  opacity: 1;
+  transform: translateX(-50%) translateY(0);
+}
+
 .arco-shell .main-content.arco-shell-content:has(.agent-execute) {
   background:
     radial-gradient(circle at 18% 12%, rgba(22, 93, 255, 0.16), transparent 28%),
@@ -5779,3 +5861,579 @@
   color: #999;
   font-size: 14px;
 }
+
+/* ===== 流式过程提示(<step> 标签 → 工具提示风格状态条,随输出动态替换) ===== */
+.execute-msg-step-hint {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  max-width: 100%;
+  margin-bottom: 8px;
+  padding: 5px 12px;
+  border: 1px solid rgba(22, 93, 255, 0.18);
+  border-radius: 999px;
+  background: #f0f5ff;
+  color: #165dff;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1.4;
+  cursor: default;
+}
+
+.execute-msg-step-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.execute-msg-step-spinner {
+  width: 12px;
+  height: 12px;
+  flex-shrink: 0;
+  border: 2px solid rgba(22, 93, 255, 0.22);
+  border-top-color: #165dff;
+  border-radius: 999px;
+  animation: executeStepSpin 0.8s linear infinite;
+}
+
+/* 与思考过程同时展示时,提示条紧贴思考块上方 */
+.execute-msg-step-hint.is-pending {
+  margin-bottom: 6px;
+}
+
+@keyframes executeStepSpin {
+  to { transform: rotate(360deg); }
+}
+
+/* ===== AI 消息操作条收纳进气泡(白色区域)底部 ===== */
+.execute-msg-assistant .execute-msg-bubble .execute-msg-actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 14px -20px -16px;
+  padding: 10px 20px;
+  border-top: 1px solid rgba(229, 230, 235, 0.9);
+}
+
+/* Word / PDF 下载按钮组靠右 */
+.execute-msg-assistant .execute-msg-bubble .execute-msg-actions .execute-msg-actions-right {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
+}
+
+/* ===== 编辑内容抽屉(报告生成智能体) ===== */
+.execute-editor-drawer {
+  width: min(860px, 94vw);
+}
+
+.execute-editor-body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  padding: 14px 16px;
+}
+
+/* Quill 富文本编辑器适配抽屉 */
+.execute-editor-body .rich-editor {
+  position: relative;
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  background: #ffffff;
+  border-radius: 10px;
+  overflow: hidden;
+}
+
+.execute-editor-body .rich-editor .ql-toolbar {
+  border: 1px solid #e5e6eb;
+  border-bottom: 0;
+  border-radius: 10px 10px 0 0;
+  background: #f7f8fa;
+  font-family: inherit;
+}
+
+.execute-editor-body .rich-editor .ql-container {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  border: 1px solid #e5e6eb;
+  border-radius: 0 0 10px 10px;
+  font-size: 13px;
+  font-family: inherit;
+}
+
+/* 正文内容初始统一 16px(标题按各自级别缩放,显式选中的字号优先) */
+.execute-editor-body .rich-editor .ql-editor,
+.execute-editor-body .rich-editor .ql-editor p,
+.execute-editor-body .rich-editor .ql-editor li,
+.execute-editor-body .rich-editor .ql-editor ol,
+.execute-editor-body .rich-editor .ql-editor ul,
+.execute-editor-body .rich-editor .ql-editor blockquote,
+.execute-editor-body .rich-editor .ql-editor td,
+.execute-editor-body .rich-editor .ql-editor th {
+  font-size: 14px;
+}
+
+.execute-editor-body .rich-editor .ql-editor {
+  line-height: 1.8;
+  /* 左侧预留 AI 按钮槽位(按钮紧挨段落左边缘,不重叠文字) */
+  padding: 14px 16px 14px 48px;
+}
+
+/* 段落 hover 高亮(由 JS 添加 p-ai-active,鼠标移到 AI 按钮上时保持高亮) */
+.execute-editor-body .rich-editor .ql-editor p.p-ai-active {
+  background: rgba(22, 93, 255, 0.06);
+  border-radius: 6px;
+}
+
+/* AI 按钮锚定层:透明命中区,覆盖按钮、按钮与段落的间隙、段落左缘 2px(零缝隙衔接段落) */
+.rich-editor-ai-anchor {
+  position: absolute;
+  z-index: 20;
+  width: 34px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: flex-start;
+}
+
+/* 段前 AI 按钮(在锚定层内,对齐段落首行) */
+.rich-editor-ai-btn {
+  position: relative;
+  top: 2px;
+  width: 26px;
+  height: 26px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #e5e6eb;
+  border-radius: 8px;
+  background: #ffffff;
+  color: #165dff;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(31, 35, 41, 0.08);
+  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
+}
+
+.rich-editor-ai-btn:hover {
+  background: #165dff;
+  border-color: #165dff;
+  color: #ffffff;
+  transform: scale(1.06);
+}
+
+.rich-editor-ai-btn svg {
+  width: 15px;
+  height: 15px;
+}
+
+/* ===== AI 润色弹窗 ===== */
+.rich-editor-ai-panel {
+  position: absolute;
+  z-index: 30;
+  display: flex;
+  flex-direction: column;
+  background: #ffffff;
+  border: 1px solid #e5e6eb;
+  border-radius: 10px;
+  box-shadow: 0 8px 24px rgba(31, 35, 41, 0.12);
+  /* 不设 overflow: hidden——下拉浮层挂在弹窗内,裁切会导致展示不全;圆角由首尾子元素承接 */
+}
+
+.rich-editor-ai-panel-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid #f2f3f5;
+  background: #f7f8fa;
+  border-radius: 10px 10px 0 0;
+}
+
+.rich-editor-ai-panel-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #1d2129;
+}
+
+.rich-editor-ai-panel-title svg {
+  width: 14px;
+  height: 14px;
+  color: #165dff;
+}
+
+/* 结果区「正在生成」状态:居中显示,旋转加载动画 + 文案 */
+.rich-editor-ai-panel-loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+}
+
+.rich-editor-ai-panel-loading-spin {
+  width: 12px;
+  height: 12px;
+  border: 2px solid #d4e3ff;
+  border-top-color: #165dff;
+  border-radius: 50%;
+  animation: rich-editor-ai-spin 0.7s linear infinite;
+}
+
+@keyframes rich-editor-ai-spin {
+  to { transform: rotate(360deg); }
+}
+
+.rich-editor-ai-panel-close {
+  width: 22px;
+  height: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 0;
+  border-radius: 6px;
+  background: transparent;
+  color: #86909c;
+  font-size: 16px;
+  line-height: 1;
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s;
+}
+
+.rich-editor-ai-panel-close:hover {
+  background: #e8f0ff;
+  color: #165dff;
+}
+
+/* Arco Select(润色动作下拉):宽度固定,浮层挂在弹窗内跟随定位 */
+.rich-editor-ai-panel-select {
+  width: 112px;
+  flex: 0 0 auto;
+  font-size: 13px;
+}
+
+/* 下拉浮层 option 的悬浮/选中色统一项目主色(挂在弹窗容器内时类名作用域生效) */
+.rich-editor-ai-panel-select .arco-select-view-single .arco-select-view-input,
+.rich-editor-ai-panel-select .arco-select-view-value {
+  font-size: 13px;
+}
+
+/* 输入框容器:承载 textarea 与右下角内嵌的 生成/暂停 图标按钮 */
+.rich-editor-ai-panel-input-wrap {
+  position: relative;
+}
+
+.rich-editor-ai-panel-input {
+  display: block;
+  height: 64px;
+  width: 100%;
+  box-sizing: border-box;
+  padding: 8px 10px 32px;
+  border: 1px solid #e5e6eb;
+  border-radius: 6px;
+  color: #1d2129;
+  font-size: 13px;
+  line-height: 1.6;
+  font-family: inherit;
+  outline: none;
+  resize: none;
+  transition: border-color 0.15s;
+}
+
+.rich-editor-ai-panel-input:focus {
+  border-color: #165dff;
+}
+
+.rich-editor-ai-panel-input::placeholder {
+  color: #a9aeb8;
+}
+
+/* 右下角内嵌图标按钮:生成(↑)/ 暂停(■) */
+.rich-editor-ai-panel-send {
+  position: absolute;
+  right: 6px;
+  bottom: 6px;
+  width: 22px;
+  height: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 0;
+  border-radius: 6px;
+  background: #165dff;
+  color: #ffffff;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.rich-editor-ai-panel-send svg {
+  width: 13px;
+  height: 13px;
+}
+
+.rich-editor-ai-panel-send:hover {
+  background: #0e4bd8;
+}
+
+/* 生成中切换为「暂停」态(橙色提示可中断) */
+.rich-editor-ai-panel-send.is-pausing {
+  background: #ff7d00;
+}
+
+.rich-editor-ai-panel-send.is-pausing:hover {
+  background: #e66a00;
+}
+
+/* 底部表单:第一行 动作下拉 + 替换按钮,第二行 要求输入 */
+.rich-editor-ai-panel-form {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 10px 12px;
+  border-top: 1px solid #f2f3f5;
+  border-radius: 0 0 10px 10px;
+}
+
+/* 弹窗内下拉浮层:限制最大高度并内部滚动,确保向上弹出时完整可见 */
+.rich-editor-ai-panel .arco-select-dropdown {
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.rich-editor-ai-panel-form-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+/* 结果展示区(流式) */
+.rich-editor-ai-panel-result {
+  flex: 1;
+  min-height: 140px;
+  max-height: 260px;
+  margin: 0 12px;
+  padding: 10px 12px;
+  border: 1px solid #f2f3f5;
+  border-radius: 8px;
+  background: #f7f8fa;
+  overflow-y: auto;
+}
+
+.rich-editor-ai-panel-text {
+  font-size: 13px;
+  line-height: 1.8;
+  color: #1d2129;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.rich-editor-ai-panel-empty {
+  font-size: 13px;
+  color: #a9aeb8;
+  text-align: center;
+  padding-top: 48px;
+}
+
+.rich-editor-ai-panel-error {
+  font-size: 13px;
+  color: #f53f3f;
+  line-height: 1.8;
+  word-break: break-word;
+}
+
+/* 流式输出光标 */
+.rich-editor-ai-cursor {
+  display: inline-block;
+  width: 2px;
+  height: 14px;
+  margin-left: 2px;
+  vertical-align: -2px;
+  background: #165dff;
+  animation: rich-editor-ai-blink 1s step-end infinite;
+}
+
+@keyframes rich-editor-ai-blink {
+  50% { opacity: 0; }
+}
+
+/* 替换按钮:高度对齐 Arco small 下拉(28px),保证与选择器同一行基线一致 */
+.rich-editor-ai-panel-replace {
+  height: 28px;
+  padding: 0 16px;
+  border: 0;
+  border-radius: 6px;
+  background: #165dff;
+  color: #ffffff;
+  font-size: 13px;
+  font-family: inherit;
+  cursor: pointer;
+  transition: background 0.15s, opacity 0.15s;
+}
+
+.rich-editor-ai-panel-replace:hover {
+  background: #0e4bd8;
+}
+
+.rich-editor-ai-panel-replace:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+
+.execute-editor-body .rich-editor .ql-editor.ql-blank::before {
+  color: #a9aeb8;
+  font-style: normal;
+}
+
+/* 头部(段落格式)下拉框文案:Heading 1-5 → H1-H5,Normal → 正文 */
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item::before {
+  content: '正文';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  content: 'H1';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  content: 'H2';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  content: 'H3';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  content: 'H4';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  content: 'H5';
+}
+
+/* 字号下拉框:初始(未选择/默认)显示 14px,与正文基准字号一致 */
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="false"]::before {
+  content: '14px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="false"]::before {
+  content: '默认';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
+  content: '12px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
+  content: '14px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
+  content: '16px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
+  content: '18px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
+  content: '20px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
+  content: '24px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
+  content: '28px';
+}
+
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
+.execute-editor-body .rich-editor .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before {
+  content: '32px';
+}
+
+.execute-editor-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 12px 16px;
+  border-top: 1px solid #e5e7eb;
+}
+
+.execute-editor-cancel,
+.execute-editor-save {
+  height: 32px;
+  padding: 0 18px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.execute-editor-cancel {
+  border: 1px solid #e5e6eb;
+  background: #ffffff;
+  color: #4e5969;
+}
+
+.execute-editor-cancel:hover {
+  border-color: #c9cdd4;
+  background: #f7f8fa;
+}
+
+.execute-editor-save {
+  border: 0;
+  background: linear-gradient(135deg, #165dff 0%, #14c9c9 100%);
+  color: #ffffff;
+  box-shadow: 0 6px 16px rgba(22, 93, 255, 0.22);
+}
+
+.execute-editor-save:hover {
+  background: linear-gradient(135deg, #4080ff 0%, #23d3d3 100%);
+}
+
+/* 导出按钮:居于底部左侧(与取消/保存隔开) */
+.execute-editor-export {
+  height: 32px;
+  padding: 0 14px;
+  margin-right: auto;
+  border: 1px solid #bedaff;
+  border-radius: 8px;
+  background: #f2f7ff;
+  color: #165dff;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+
+.execute-editor-export:hover {
+  border-color: #94bfff;
+  background: #e8f3ff;
+}
+
+.execute-editor-export:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}

+ 2 - 2
src/components/Layout.jsx

@@ -299,7 +299,7 @@ function Layout({ children }) {
             })}
           </SubMenu>
 
-          <SubMenu key="my" title={<><IconUser /><span>我的</span></>}>
+          {/* <SubMenu key="my" title={<><IconUser /><span>我的</span></>}>
             <MenuItem key="history" onClick={() => navigate('/history')}>
               <IconHistory />
               <span>历史记录</span>
@@ -308,7 +308,7 @@ function Layout({ children }) {
               <IconRobot />
               <span>可用智能体</span>
             </MenuItem>
-          </SubMenu>
+          </SubMenu> */}
         </Menu>
 
         <div className="arco-shell-sider-footer">

+ 647 - 0
src/components/RichTextEditor.jsx

@@ -0,0 +1,647 @@
+import { useEffect, useRef, useState } from 'react'
+import Quill from 'quill'
+import 'quill/dist/quill.snow.css'
+import { Select } from '@arco-design/web-react'
+import { runChatMessage, consumeSSE, getDifyAppParameters } from '@/service/dify'
+
+// 字号白名单:把 Quill 的 size 格式改为行内样式(font-size: xxpx)
+const FONT_SIZES = ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px']
+const SizeStyle = Quill.import('attributors/style/size')
+SizeStyle.whitelist = FONT_SIZES
+Quill.register(SizeStyle, true)
+
+// 正文初始统一 14px:载入时移除行内 font-size(标题大小由标题级别样式决定,正文基准字号由 CSS 提供)
+const stripInlineFontSize = (html) => {
+  if (!html) return html
+  const tpl = document.createElement('template')
+  tpl.innerHTML = html
+  tpl.content.querySelectorAll('[style]').forEach((el) => {
+    el.style.removeProperty('font-size')
+    if (!el.getAttribute('style')) el.removeAttribute('style')
+  })
+  return tpl.innerHTML
+}
+
+// AI 图标(lucide 风格 sparkles)
+const AiSparkIcon = () => (
+  <svg
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+  >
+    <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
+    <path d="M20 3v4" />
+    <path d="M22 5h-4" />
+    <path d="M4 17v2" />
+    <path d="M5 18H3" />
+  </svg>
+)
+
+// 生成按钮图标(lucide 风格 arrow-up)
+const SendIcon = () => (
+  <svg
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2.4"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+  >
+    <path d="m5 12 7-7 7 7" />
+    <path d="M12 19V5" />
+  </svg>
+)
+
+// 暂停按钮图标(实心方块 stop)
+const StopIcon = () => (
+  <svg viewBox="0 0 24 24" fill="currentColor">
+    <rect x="7" y="7" width="10" height="10" rx="1.5" />
+  </svg>
+)
+
+// AI 润色动作
+// 润色动作:以「效果」命名(用户关心想要什么效果,而非扩写/缩写这类实现方式)
+const AI_ACTIONS = [
+  { value: 'polish', label: '修复润色', title: '修正语病与标点,优化措辞,不改变篇幅与原意' },
+  { value: 'concise', label: '更简洁', title: '删减冗余、压缩篇幅,保留核心信息' },
+  { value: 'richer', label: '更丰满', title: '补充细节与论据,让内容更充实饱满' },
+  { value: 'professional', label: '更专业', title: '用词书面规范、术语准确、语气正式' },
+  { value: 'vivid', label: '更生动', title: '增强画面感与感染力,表达更鲜活' },
+  { value: 'casual', label: '更口语化', title: '轻松自然、通俗易懂,适合日常阅读' },
+  { value: 'deai', label: '去AI味', title: '改掉机械腔调与套话,让文字像人写的' },
+]
+const DEFAULT_ACTION = 'polish'
+
+// 「内容润色」工作流(Dify 工作流类型应用)的 API Key
+// 定义在 public/config.js 的 window.APP_CONFIG.POLISH_API_KEY(打包后可直接修改该文件调整);
+// 也可通过 <RichTextEditor polishApiKey="app-xxx"> 属性传入,属性优先。
+const getPolishApiKey = (prop) => prop || window.APP_CONFIG?.POLISH_API_KEY || ''
+
+// 润色工作流「开始节点」输入变量映射的兜底默认值。
+// 组件挂载(打开富文本)时会调用 GET /v1/parameters 自动识别实际变量并覆盖此映射。
+const POLISH_INPUT_KEYS = {
+  text: 'article',        // 段落原文
+  action: '',             // 润色动作:扩写 / 缩写 / 重写(工作流未定义则不传)
+  requirement: '',        // 补充要求(工作流未定义则不传)
+}
+
+// 解析 /v1/parameters 返回的 user_input_form,自动识别三类润色输入变量:
+// 正文(article/text/content/文章/正文/内容)、动作(action/动作/方式/模式)、补充要求(requirement/要求/指令)
+const parsePolishParams = (parameters) => {
+  const form = Array.isArray(parameters?.user_input_form) ? parameters.user_input_form : []
+  const fields = form
+    .map((item) => {
+      const typeKey = Object.keys(item || {})[0]
+      const def = typeKey ? item[typeKey] : null
+      if (!def || !def.variable) return null
+      return { variable: String(def.variable), label: String(def.label || ''), type: typeKey }
+    })
+    .filter(Boolean)
+  if (!fields.length) return null
+  const hit = (f, re) => f && re.test(`${f.variable} ${f.label}`.toLowerCase())
+  const textField = fields.find((f) => hit(f, /article|content|\btext\b|文章|正文|内容/)) || fields[0]
+  const actionField = fields.find((f) => f !== textField && hit(f, /action|动作|方式|模式|类型|mode/))
+  const reqField = fields.find(
+    (f) => f !== textField && f !== actionField && hit(f, /requirement|要求|指令|提示|instruction|prompt/)
+  )
+  return {
+    text: textField.variable,
+    action: actionField?.variable || '',
+    requirement: reqField?.variable || '',
+  }
+}
+
+// 剥离思考内容,润色弹窗只展示最终结果
+// 兼容多种标签变体:<think>/<thinking>/<thought>/<reasoning>,允许标签带属性,
+// 并处理流式中尚未闭合的思考段(思考进行中不显示任何思考文字)
+const THINK_TAG = 'think|thinking|thought|reasoning'
+const stripThinkContent = (text) => {
+  const source = String(text || '')
+  let out = source.replace(new RegExp(`<(${THINK_TAG})\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>`, 'gi'), '')
+  out = out.replace(new RegExp(`<(${THINK_TAG})\\b[^>]*>[\\s\\S]*$`, 'i'), '')
+  return out.trim()
+}
+
+// Quill 富文本编辑器封装(基于 Quill 2,snow 主题)
+// - value: 初始 HTML(仅在挂载时载入)
+// - onChange(html): 内容变化时回调完整 HTML
+// - placeholder: 占位文案
+// - polishApiKey: 「内容润色」工作流的 API Key(可选,优先于文件顶部 POLISH_API_KEY 常量)
+// - onAiParagraph({ text, element }): 点击段落前的 AI 按钮时回调
+const RichTextEditor = ({ value, onChange, placeholder = '请输入内容...', polishApiKey, onAiParagraph }) => {
+  const wrapperRef = useRef(null)
+  const quillRef = useRef(null)
+  const aiBtnRef = useRef(null)
+  const activePRef = useRef(null)
+  const hideTimerRef = useRef(null)
+  const clearActivePRef = useRef(null)
+  const onChangeRef = useRef(onChange)
+  const onAiParagraphRef = useRef(onAiParagraph)
+  onChangeRef.current = onChange
+  onAiParagraphRef.current = onAiParagraph
+
+  // ===== AI 润色弹窗 =====
+  const aiPanelRef = useRef(null)      // 弹窗 DOM
+  const aiPanelPRef = useRef(null)     // 弹窗当前作用的段落元素
+  const aiAbortRef = useRef(null)      // 润色请求 AbortController
+  const panelOpenRef = useRef(false)   // 弹窗打开期间锁定段落高亮清除
+  const polishInputsRef = useRef(POLISH_INPUT_KEYS)  // 润色输入变量映射(由 /v1/parameters 动态识别)
+  const [aiPos, setAiPos] = useState(null)                 // AI 按钮锚定层位置(null 隐藏)
+  const [aiPanel, setAiPanel] = useState(null)             // 弹窗位置 { top, left, width }
+  const [aiAction, setAiAction] = useState(DEFAULT_ACTION)  // 润色动作,见 AI_ACTIONS
+  const [aiRequirement, setAiRequirement] = useState('')   // 补充要求
+  const [aiResult, setAiResult] = useState('')             // 润色结果(流式累积)
+  const [aiLoading, setAiLoading] = useState(false)
+  const [aiError, setAiError] = useState('')
+
+  // 取消延迟隐藏(鼠标回到段落 / 进入按钮时调用)
+  const cancelHideP = () => {
+    if (hideTimerRef.current) {
+      clearTimeout(hideTimerRef.current)
+      hideTimerRef.current = null
+    }
+  }
+  // 延迟隐藏:鼠标彻底离开段落及按钮区域后,保留一小段时间再隐藏(兜底保险)
+  const scheduleHideP = () => {
+    if (hideTimerRef.current) return
+    hideTimerRef.current = setTimeout(() => {
+      hideTimerRef.current = null
+      // 隐藏前校验悬停状态:鼠标仍在锚定层(按钮命中区)或当前段落上时,图标与高亮保持显示。
+      // 浏览器派发顺序问题:段落 → 按钮切换时,root 的 mouseleave 可能晚于锚定层 mouseenter,
+      // 导致延迟隐藏被重新挂起,这里在真正隐藏前再兜底校验一次
+      if (aiBtnRef.current?.matches(':hover') || activePRef.current?.matches(':hover')) {
+        scheduleHideP()
+        return
+      }
+      clearActivePRef.current?.()
+    }, 500)
+  }
+
+  // 关闭润色弹窗:终止请求、复位状态
+  const closeAiPanel = () => {
+    aiAbortRef.current?.abort()
+    aiAbortRef.current = null
+    panelOpenRef.current = false
+    // 弹窗关闭时取消段落高亮(并隐藏段前 AI 按钮)
+    clearActivePRef.current?.()
+    aiPanelPRef.current = null
+    setAiPanel(null)
+    setAiLoading(false)
+    setAiResult('')
+    setAiError('')
+  }
+
+  // 暂停:中止流式接收,已生成的内容保留展示(可继续点「生成」重新发起)
+  const pausePolish = () => {
+    aiAbortRef.current?.abort()
+    aiAbortRef.current = null
+    setAiLoading(false)
+  }
+
+  // 组装润色请求:query = 动作 + 补充要求;inputs.article = 段落原文(变量名来自 /parameters)
+  const buildPolishQuery = (actionLabel) => {
+    const req = aiRequirement.trim()
+    return req ? `${actionLabel},${req}` : actionLabel
+  }
+
+  // 发起润色请求(点「生成」后调用 POST /chat-messages,流式累积结果)
+  const runPolish = async () => {
+    const target = aiPanelPRef.current
+    const text = target?.textContent?.trim()
+    if (!target || !text) return
+    const key = getPolishApiKey(polishApiKey)
+    if (!key) {
+      setAiError('未配置润色 API Key(public/config.js 的 POLISH_API_KEY 或 polishApiKey 属性)')
+      return
+    }
+    aiAbortRef.current?.abort()
+    const controller = new AbortController()
+    aiAbortRef.current = controller
+    setAiLoading(true)
+    setAiError('')
+    setAiResult('')
+    let acc = ''
+    const applyResult = (chunk) => {
+      acc += chunk
+      // 展示时剥离思考内容,只保留最终润色结果
+      const visible = stripThinkContent(acc)
+      setAiResult(visible)
+      return visible
+    }
+    try {
+      const actionLabel = (AI_ACTIONS.find((a) => a.value === aiAction) || AI_ACTIONS[0]).label
+      // inputs 严格按 /v1/parameters 识别出的变量传;工作流未定义的变量(映射为空)不传
+      const keys = polishInputsRef.current
+      const inputs = { [keys.text]: text }
+      if (keys.action) inputs[keys.action] = actionLabel
+      if (keys.requirement) inputs[keys.requirement] = aiRequirement.trim()
+      const res = await runChatMessage(
+        key,
+        {
+          query: buildPolishQuery(actionLabel),
+          inputs,
+          conversation_id: '',
+        },
+        undefined,
+        { signal: controller.signal }
+      )
+      await consumeSSE(
+        res,
+        (evt) => {
+          if (controller.signal.aborted) return
+          const type = evt?.event
+          // chat-messages 流式增量:message 事件的 answer 字段
+          if (type === 'message' && evt.answer) {
+            applyResult(evt.answer)
+          } else if (type === 'text_chunk' && evt.data?.text) {
+            // 兼容工作流 text_chunk 事件
+            applyResult(evt.data.text)
+          } else if (type === 'workflow_finished' && !stripThinkContent(acc)) {
+            // 未产生流式块(或全部是思考内容)时,用结束节点 outputs 兜底(取第一个非空字符串字段)
+            const outputs = evt.data?.outputs
+            if (outputs && typeof outputs === 'object') {
+              const first = Object.values(outputs).find((v) => typeof v === 'string' && v.trim())
+              if (first) {
+                acc = ''
+                applyResult(stripThinkContent(first) || first)
+              }
+            }
+          } else if (type === 'error') {
+            setAiError(`润色失败:${evt.data?.message || evt.message || '接口执行出错'}`)
+          }
+        },
+        () => { if (!controller.signal.aborted) setAiLoading(false) },
+        (err) => {
+          if (controller.signal.aborted) return
+          setAiLoading(false)
+          setAiError(`润色失败:${err?.message || '未知错误'}`)
+        },
+        { signal: controller.signal }
+      )
+    } catch (err) {
+      if (controller.signal.aborted) return
+      setAiLoading(false)
+      setAiError(`润色失败:${err?.message || '未知错误'}`)
+    } finally {
+      if (aiAbortRef.current === controller) setAiLoading(false)
+    }
+  }
+
+  // 打开润色弹窗:定位在段落下方(空间不足放上方),并自动生成一次
+  const openAiPanel = (p) => {
+    const wrap = wrapperRef.current
+    if (!wrap) return
+    const wrapRect = wrap.getBoundingClientRect()
+    const pRect = p.getBoundingClientRect()
+    const width = Math.min(520, Math.max(340, wrapRect.width - 16))
+    const panelH = 330
+    let top = pRect.bottom - wrapRect.top + 8
+    if (top + panelH > wrapRect.height - 8) {
+      top = Math.max(8, pRect.top - wrapRect.top - panelH - 8)
+    }
+    const left = Math.max(8, Math.min(pRect.left - wrapRect.left, wrapRect.width - width - 8))
+    // 每次打开都是全新弹窗:终止旧请求并复位上次的结果/动作/要求,不携带任何残留
+    aiAbortRef.current?.abort()
+    aiAbortRef.current = null
+    setAiResult('')
+    setAiError('')
+    setAiLoading(false)
+    setAiAction(DEFAULT_ACTION)
+    setAiRequirement('')
+    aiPanelPRef.current = p
+    panelOpenRef.current = true
+    setAiPanel({ top, left, width })
+    // 打开弹窗不自动调用接口,由用户点「生成」再发起请求
+  }
+
+  // 替换:把润色结果写回原段落(保留段落行内格式,user 触发以回调 onChange)
+  const handleReplace = () => {
+    const quill = quillRef.current
+    const p = aiPanelPRef.current
+    // 写入前再剥离一次思考内容,确保思考绝不进入富文本
+    const text = stripThinkContent(aiResult)
+    if (!quill || !p || !text) {
+      closeAiPanel()
+      return
+    }
+    const blot = Quill.find(p)
+    if (blot) {
+      const index = quill.getIndex(blot)
+      const len = blot.length() // 含段落末尾换行符
+      // 仅保留行内格式(加粗/斜体/颜色等),块级结构由 p 本身保证
+      const fmt = quill.getFormat(index, Math.max(0, len - 1))
+      const inlineFmt = {}
+      ;['bold', 'italic', 'underline', 'strike', 'color', 'background', 'size', 'font', 'code'].forEach((k) => {
+        if (fmt[k] !== undefined) inlineFmt[k] = fmt[k]
+      })
+      quill.deleteText(index, len - 1, Quill.sources.USER)
+      quill.insertText(index, text, inlineFmt, Quill.sources.USER)
+    }
+    closeAiPanel()
+  }
+
+  useEffect(() => {
+    const wrapper = wrapperRef.current
+    if (!wrapper || quillRef.current) return
+    // Quill 挂到内部动态节点上:工具栏与编辑区都收纳在 wrapper 内,
+    // 卸载时清空 wrapper 即可整体移除(兼容 StrictMode 的二次挂载,避免出现双编辑器)
+    const target = document.createElement('div')
+    wrapper.appendChild(target)
+    const quill = new Quill(target, {
+      theme: 'snow',
+      placeholder,
+      modules: {
+        toolbar: {
+          container: [
+            ['undo', 'redo'],
+            [{ header: [1, 2, 3, 4, 5, false] }, { size: [...FONT_SIZES, false] }],
+            ['bold', 'italic', 'underline', 'strike'],
+            [{ color: [] }, { background: [] }],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ align: [] }],
+            ['blockquote', 'code-block'],
+          ],
+          // 撤销/重做非内置格式,转发到 history 模块(支持 Ctrl+Z / Ctrl+Y 同一历史栈)
+          handlers: {
+            undo() { this.quill.getModule('history')?.undo() },
+            redo() { this.quill.getModule('history')?.redo() },
+          },
+        },
+      },
+    })
+    quillRef.current = quill
+    // 撤销/重做按钮图标:Quill 未内置,注入 lucide 风格 SVG(挂 ql-stroke 复用主题描边与 hover 配色)
+    const undoBtn = wrapper.querySelector('button.ql-undo')
+    const redoBtn = wrapper.querySelector('button.ql-redo')
+    if (undoBtn) {
+      undoBtn.innerHTML =
+        '<svg viewBox="0 0 24 24"><path class="ql-stroke" d="M9 14 4 9l5-5" /><path class="ql-stroke" d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11" /></svg>'
+      undoBtn.title = '撤销'
+    }
+    if (redoBtn) {
+      redoBtn.innerHTML =
+        '<svg viewBox="0 0 24 24"><path class="ql-stroke" d="m15 14 5-5-5-5" /><path class="ql-stroke" d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13" /></svg>'
+      redoBtn.title = '重做'
+    }
+    // 初始内容:经 Quill 剪贴板管道规范化解析,避免直接写 innerHTML 结构不兼容
+    if (value) quill.clipboard.dangerouslyPasteHTML(stripInlineFontSize(value))
+    quill.on('text-change', (_delta, _oldContents, source) => {
+      // 仅用户操作触发回调,程序化写入不回传
+      if (source === 'user') onChangeRef.current?.(quill.root.innerHTML)
+    })
+
+    // ===== 段落 hover:高亮当前 p,并在段前显示 AI 按钮 =====
+    const root = quill.root
+    const clearActiveP = () => {
+      // 弹窗打开期间锁定段落与按钮,避免鼠标移入弹窗时被清除
+      if (panelOpenRef.current) return
+      activePRef.current?.classList.remove('p-ai-active')
+      activePRef.current = null
+      setAiPos(null)
+    }
+    clearActivePRef.current = clearActiveP
+    const setActiveP = (p) => {
+      if (activePRef.current !== p) {
+        activePRef.current?.classList.remove('p-ai-active')
+        p.classList.add('p-ai-active')
+        activePRef.current = p
+      }
+      // 锚定层覆盖「按钮 + 与段落的间隙 + 段落左缘 2px」,与 p 零缝隙衔接:
+      // 鼠标从段落移出的瞬间 DOM 命中即落在锚定层上(root 收不到 mousemove),按钮不会被隐藏
+      const wrapRect = wrapper.getBoundingClientRect()
+      const pRect = p.getBoundingClientRect()
+      setAiPos(prev => {
+        const next = {
+          top: pRect.top - wrapRect.top,
+          left: pRect.left - wrapRect.left - 32,
+          height: pRect.height,
+        }
+        if (prev && Math.abs(prev.top - next.top) < 1 && prev.left === next.left && prev.height === next.height) {
+          return prev
+        }
+        return next
+      })
+    }
+    const handleMouseMove = (e) => {
+      // 弹窗打开期间冻结 hover 判定:鼠标穿过邻段(弹窗在上方时的必经间隙)也不切换高亮,
+      // 锚定段落保持高亮直到弹窗关闭
+      if (panelOpenRef.current) {
+        cancelHideP()
+        return
+      }
+      const el = e.target
+      const p = el && el.closest ? el.closest('p') : null
+      if (p && p.parentElement === root && p.textContent.trim()) {
+        cancelHideP()
+        setActiveP(p)
+        return
+      }
+      if (aiBtnRef.current && aiBtnRef.current.contains(el)) {
+        cancelHideP()
+        return
+      }
+      // 坐标命中判定:鼠标在段落矩形、或段落左侧紧邻带(按钮所在区域,纵向外扩 8px)内时,
+      // 无论 DOM target 是谁(如编辑器 padding 区)都保持显示,保证按钮可达
+      const active = activePRef.current
+      if (active && e.clientX != null) {
+        const ar = active.getBoundingClientRect()
+        const inPZone =
+          e.clientX >= ar.left - 40 &&
+          e.clientX <= ar.right &&
+          e.clientY >= ar.top - 8 &&
+          e.clientY <= ar.bottom + 8
+        if (inPZone) {
+          cancelHideP()
+          return
+        }
+      }
+      // 彻底离开段落及按钮区域:延迟隐藏兜底
+      scheduleHideP()
+    }
+    // 注意:不能用「立即清除」——AI 按钮层在 DOM 上不是 .ql-editor 的后代,
+    // 鼠标从段落移到按钮的瞬间 root 就会触发 mouseleave(按 DOM 子树判定,与视觉遮挡无关),
+    // 立即清除会让按钮凭空消失。这里只启动延迟隐藏,进入按钮(mouseenter)会取消。
+    const handleMouseLeave = () => scheduleHideP()
+    root.addEventListener('mousemove', handleMouseMove)
+    root.addEventListener('mouseleave', handleMouseLeave)
+
+    return () => {
+      root.removeEventListener('mousemove', handleMouseMove)
+      root.removeEventListener('mouseleave', handleMouseLeave)
+      cancelHideP()
+      activePRef.current?.classList.remove('p-ai-active')
+      activePRef.current = null
+      clearActivePRef.current = null
+      aiAbortRef.current?.abort()
+      aiAbortRef.current = null
+      panelOpenRef.current = false
+      quillRef.current = null
+      wrapper.innerHTML = ''
+    }
+    // 仅挂载时初始化一次,value 变化不回灌(避免输入时光标跳动)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // 打开富文本时调用 GET /v1/parameters,自动识别润色工作流的输入变量(失败则沿用兜底映射)
+  useEffect(() => {
+    const key = getPolishApiKey(polishApiKey)
+    if (!key) return
+    let cancelled = false
+    getDifyAppParameters(key)
+      .then((params) => {
+        const mapping = parsePolishParams(params)
+        if (!cancelled && mapping) {
+          polishInputsRef.current = mapping
+          if (typeof console !== 'undefined') console.log('[AI润色] /parameters 输入变量映射 →', mapping)
+        }
+      })
+      .catch((err) => {
+        if (typeof console !== 'undefined') console.warn('[AI润色] 获取 /parameters 失败,使用默认映射 →', err?.message || err)
+      })
+    return () => { cancelled = true }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // 弹窗打开期间:点击弹窗与按钮以外区域时关闭
+  useEffect(() => {
+    if (!aiPanel) return
+    const onDocMouseDown = (e) => {
+      const t = e.target
+      if (aiPanelRef.current?.contains(t)) return
+      if (aiBtnRef.current?.contains(t)) return
+      closeAiPanel()
+    }
+    document.addEventListener('mousedown', onDocMouseDown)
+    return () => document.removeEventListener('mousedown', onDocMouseDown)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [aiPanel])
+
+  return (
+    <div ref={wrapperRef} className="rich-editor">
+      {aiPos && (
+        <div
+          ref={aiBtnRef}
+          className="rich-editor-ai-anchor"
+          style={{ top: aiPos.top, left: aiPos.left, height: aiPos.height }}
+          onMouseEnter={cancelHideP}
+          onMouseLeave={() => scheduleHideP()}
+        >
+          <button
+            type="button"
+            className="rich-editor-ai-btn"
+            title="AI 优化"
+            onClick={() => {
+              const p = activePRef.current
+              if (!p) return
+              onAiParagraphRef.current?.({ text: p.textContent.trim(), element: p })
+              openAiPanel(p)
+            }}
+          >
+            <AiSparkIcon />
+          </button>
+        </div>
+      )}
+
+      {aiPanel && (
+        <div
+          ref={aiPanelRef}
+          className="rich-editor-ai-panel"
+          style={{ top: aiPanel.top, left: aiPanel.left, width: aiPanel.width }}
+          onMouseEnter={cancelHideP}
+        >
+          <div className="rich-editor-ai-panel-head">
+            <span className="rich-editor-ai-panel-title">
+              <AiSparkIcon />
+              AI 润色
+            </span>
+            <button
+              type="button"
+              className="rich-editor-ai-panel-close"
+              aria-label="关闭"
+              onClick={closeAiPanel}
+            >
+              ×
+            </button>
+          </div>
+
+          {/* 顶部操作已隐藏:关闭走标题栏 ×,替换按钮移至底部表单行 */}
+
+          <div className="rich-editor-ai-panel-result">
+            {aiError && <div className="rich-editor-ai-panel-error">{aiError}</div>}
+            {!aiError && aiResult && (
+              <div className="rich-editor-ai-panel-text">
+                {aiResult}
+                {aiLoading && <span className="rich-editor-ai-cursor" />}
+              </div>
+            )}
+            {!aiError && !aiResult && aiLoading && (
+              <div className="rich-editor-ai-panel-empty rich-editor-ai-panel-loading">
+                <span className="rich-editor-ai-panel-loading-spin" />
+                正在生成,请稍候…
+              </div>
+            )}
+            {!aiError && !aiResult && !aiLoading && (
+              <div className="rich-editor-ai-panel-empty">点击输入框右下角的「生成」按钮开始润色</div>
+            )}
+          </div>
+
+          {/* 底部表单:第一行 动作下拉 + 替换按钮,第二行 要求输入(右下角内嵌 生成/暂停 图标按钮) */}
+          <div className="rich-editor-ai-panel-form">
+            <div className="rich-editor-ai-panel-form-row">
+              <Select
+                className="rich-editor-ai-panel-select"
+                size="small"
+                value={aiAction}
+                onChange={setAiAction}
+                position="top"
+                getPopupContainer={() => aiPanelRef.current || document.body}
+              >
+                {AI_ACTIONS.map((a) => (
+                  <Select.Option key={a.value} value={a.value} title={a.title}>
+                    {a.label}
+                  </Select.Option>
+                ))}
+              </Select>
+              <button
+                type="button"
+                className="rich-editor-ai-panel-replace"
+                onClick={handleReplace}
+                disabled={!aiResult.trim()}
+              >
+                替换
+              </button>
+            </div>
+            <div className="rich-editor-ai-panel-input-wrap">
+              <textarea
+                className="rich-editor-ai-panel-input"
+                value={aiRequirement}
+                onChange={(e) => setAiRequirement(e.target.value)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' && !e.shiftKey) {
+                    e.preventDefault()
+                    aiLoading ? pausePolish() : runPolish()
+                  }
+                }}
+                placeholder="输入润色要求(可选,Enter 生成,Shift+Enter 换行)"
+              />
+              <button
+                type="button"
+                className={`rich-editor-ai-panel-send${aiLoading ? ' is-pausing' : ''}`}
+                title={aiLoading ? '暂停' : '生成'}
+                aria-label={aiLoading ? '暂停' : '生成'}
+                onClick={() => (aiLoading ? pausePolish() : runPolish())}
+              >
+                {aiLoading ? <StopIcon /> : <SendIcon />}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default RichTextEditor

+ 3 - 3
src/config/agentEmptyStates.json

@@ -10,9 +10,9 @@
       "name": "智能问数",
       "aliases": ["问数", "数据分析", "数据助手"],
       "icon": "⌁",
-      "title": "上传数据,提出分析问题",
-      "description": "请提供数据表、指标口径或业务问题。我会帮你拆解数据关系、生成分析结论,并提炼可落地的洞察建议。",
-      "prompts": ["分析核心指标", "查找异常波动", "生成数据结论"]
+      "title": "直接提问,获取数据洞察",
+      "description": "直接输入你想了解的业务问题或指标,我会帮你拆解数据关系、生成分析结论,并提炼可落地的洞察建议。",
+      "prompts": ["查询核心指标", "分析指标变化", "生成数据结论"]
     },
     {
       "name": "报告生成",

+ 634 - 86
src/pages/AgentExecute.jsx

@@ -1,14 +1,15 @@
 import { useState, useEffect, useRef, useCallback } from 'react'
 import { useLocation, useParams } from 'react-router-dom'
 import { Modal } from '@arco-design/web-react'
-import { getDifyAppParameters, getAppFullInfo, runWorkflow, runChatMessage, consumeSSE, uploadFile } from '@/service/dify'
-import { getBusinessConversations, getBusinessConversationDetail, downloadMessageContent } from '@/service/business'
+import { getDifyAppParameters, getAppFullInfo, runWorkflow, runChatMessage, consumeSSE, uploadFile, getChatMessages, getConversations } from '@/service/dify'
 import { API_BASE_URL } from '@/config'
 import agentEmptyStates from '@/config/agentEmptyStates.json'
 import emojiMartData from '@emoji-mart/data'
 import { marked } from 'marked'
 import wordIcon from '@/assets/icons/word.svg'
 import pdfIcon from '@/assets/icons/pdf.svg'
+import RichTextEditor from '@/components/RichTextEditor'
+import { htmlToDocxBlob } from '@/utils/exportDocx'
 
 // 通过 emoji-mart data 把 emoji 名称转换为 native Unicode 字符
 const emojiNameToNative = (name) => {
@@ -30,6 +31,50 @@ const buildIconUrl = (icon) => {
   return icon
 }
 
+// ===== AI 消息操作图标(内联 SVG,lucide 风格) =====
+const IconCopy = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <rect x="9" y="9" width="12" height="12" rx="2" />
+    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+  </svg>
+)
+const IconVolume = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M11 5 6 9H2v6h4l5 4V5z" />
+    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+    <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
+  </svg>
+)
+const IconStop = () => (
+  <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <rect x="6" y="6" width="12" height="12" rx="2" />
+  </svg>
+)
+const IconThumbUp = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M7 10v12" />
+    <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
+  </svg>
+)
+const IconThumbDown = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M17 14V2" />
+    <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
+  </svg>
+)
+const IconRefresh = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
+    <path d="M21 3v5h-5" />
+  </svg>
+)
+const IconEdit = () => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M12 20h9" />
+    <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
+  </svg>
+)
+
 marked.setOptions({
   gfm: true,
   breaks: true,
@@ -101,13 +146,76 @@ const splitThinkContent = (text) => {
   }
 }
 
+// 提取 AI 消息的正文(剔除思考过程与 <step> 过程提示)
+const getAnswerText = (message) => {
+  const parsed = splitThinkContent(message?.content || '')
+  const answer = parsed.answer || (!parsed.thought ? message?.content || '' : '')
+  return splitStepContent(answer).text.trim()
+}
+
+// 解析流式返回中的 <step>xxx</step> 过程提示(兼容 <step/>xxx</step> 写法与未闭合的进行中状态)
+const splitStepContent = (text) => {
+  const source = String(text || '')
+  const steps = []
+  let rest = source.replace(/<step\/?>([\s\S]*?)<\/step>/gi, (_, s) => {
+    const v = s.trim()
+    if (v) steps.push(v)
+    return ''
+  })
+  // 流式输出中尚未闭合的 step 段,视为进行中的步骤
+  rest = rest.replace(/<step\/?>([\s\S]*)$/i, (_, s) => {
+    const v = s.trim()
+    if (v) steps.push(v)
+    return ''
+  })
+  return { steps, text: rest }
+}
+
+// 流式期间当前展示的过程提示:pending 阶段返回占位文案,之后返回最新的 step 提示
+const getActiveStepHint = (message) => {
+  if (!message?.streaming) return ''
+  if (message.pending) return message.content || STREAM_WAITING_TEXT
+  const parsed = splitThinkContent(message?.content || '')
+  const answer = parsed.answer || (!parsed.thought ? message?.content || '' : '')
+  const { steps } = splitStepContent(answer)
+  return steps.length ? steps[steps.length - 1] : ''
+}
+
+// 复制文本到剪贴板(安全上下文走 Clipboard API,否则降级 execCommand)
+const copyTextToClipboard = async (text) => {
+  if (navigator.clipboard && window.isSecureContext) {
+    try {
+      await navigator.clipboard.writeText(text)
+      return true
+    } catch (_) { /* 失败则继续降级 */ }
+  }
+  const ta = document.createElement('textarea')
+  ta.value = text
+  ta.setAttribute('readonly', '')
+  ta.style.position = 'fixed'
+  ta.style.top = '0'
+  ta.style.left = '-9999px'
+  ta.style.opacity = '0'
+  document.body.appendChild(ta)
+  ta.focus()
+  ta.select()
+  ta.setSelectionRange(0, ta.value.length)
+  let ok = false
+  try { ok = document.execCommand('copy') } catch (_) { ok = false }
+  document.body.removeChild(ta)
+  return ok
+}
+
 const renderAssistantMessage = (message) => {
   return <AssistantMessageContent message={message} />
 }
 
 function AssistantMessageContent({ message }) {
   const parsed = splitThinkContent(message?.content || '')
-  const answer = parsed.answer || (!parsed.thought ? message?.content : '')
+  const { text } = splitStepContent(parsed.answer || (!parsed.thought ? message?.content : ''))
+  const answer = message?.pending ? '' : text
+  // 过程提示始终置顶:pending 占位文案 / 最新 step 提示,随流式输出动态替换
+  const activeStep = getActiveStepHint(message)
   const thinking = message?.thinking || parsed.thinking
   const prevThinkingRef = useRef(thinking)
   const [thinkOpen, setThinkOpen] = useState(thinking)
@@ -123,6 +231,12 @@ function AssistantMessageContent({ message }) {
 
   return (
     <>
+      {activeStep && (
+        <div className={`execute-msg-step-hint ${thinking ? 'is-pending' : ''}`} title={activeStep}>
+          <span className="execute-msg-step-spinner" />
+          <span className="execute-msg-step-label">{activeStep}</span>
+        </div>
+      )}
       {parsed.thought && (
         <details
           className={`execute-msg-think ${thinking ? 'is-thinking' : ''}`}
@@ -172,6 +286,140 @@ const sanitizeDownloadName = (name) => {
   return String(name || '智能体').replace(/[\\/:*?"<>|]/g, '_').trim() || '智能体'
 }
 
+// 导出文档的正文样式(与编辑器基准一致:正文 14px、行高 1.8、标题按级别缩放)
+const EXPORT_DOC_STYLE = [
+  'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.8;color:#1d2129;margin:0;}',
+  'h1,h2,h3,h4,h5{margin:0.6em 0 0.3em;line-height:1.4;}',
+  'h1{font-size:28px}h2{font-size:24px}h3{font-size:20px}h4{font-size:17px}h5{font-size:15px}',
+  'p{margin:0.4em 0;}',
+  'ul,ol{padding-left:1.6em;margin:0.4em 0;}',
+  'blockquote{margin:0.5em 0;padding:0.2em 1em;border-left:3px solid #e5e6eb;color:#4e5969;background:#f7f8fa;}',
+  'pre{background:#f2f3f5;padding:10px 12px;border-radius:6px;white-space:pre-wrap;margin:0.5em 0;}',
+  'code{font-family:Consolas,Menlo,monospace;background:#f2f3f5;padding:1px 4px;border-radius:4px;}',
+  'table{border-collapse:collapse;margin:0.5em 0;}td,th{border:1px solid #e5e6eb;padding:4px 8px;}',
+  'img{max-width:100%;}',
+].join('')
+
+// 组装导出用完整 HTML(DOCX 走 altChunk 需要 head 内样式;PDF 截图渲染同样复用)
+const buildExportHtml = (bodyHtml, extraStyle = '') =>
+  `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${EXPORT_DOC_STYLE}${extraStyle}</style></head><body>${bodyHtml}</body></html>`
+
+// 触发浏览器下载(Blob)
+const downloadBlob = (blob, filename) => {
+  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)
+  setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+// HTML 直接导出为 PDF 文件下载(html2canvas 截图 + jsPDF 按 A4 切页),不再调起浏览器打印
+const exportHtmlToPdf = async (htmlSource, filename) => {
+  const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
+    import('html2canvas'),
+    import('jspdf'),
+  ])
+
+  // 离屏 iframe 渲染导出 HTML:宽度取 A4 @96dpi,保证与打印方案一致的排版基准
+  const A4_W = 794
+  const frame = document.createElement('iframe')
+  frame.setAttribute('aria-hidden', 'true')
+  Object.assign(frame.style, {
+    position: 'fixed', left: '-9999px', top: '0',
+    width: `${A4_W}px`, height: '200px', border: '0',
+  })
+  document.body.appendChild(frame)
+
+  try {
+    await new Promise((resolve, reject) => {
+      frame.onload = resolve
+      frame.onerror = () => reject(new Error('导出内容渲染失败'))
+      frame.srcdoc = htmlSource
+    })
+    const doc = frame.contentDocument
+    // 等待图片等子资源就绪,避免截图内容缺失
+    await Promise.all(Array.from(doc.images || []).map(img =>
+      img.complete ? Promise.resolve() : new Promise(resolve => { img.onload = img.onerror = resolve })
+    ))
+    const canvas = await html2canvas(doc.body, {
+      scale: 2,
+      backgroundColor: '#ffffff',
+      useCORS: true,
+    })
+    if (!canvas.width || !canvas.height) throw new Error('导出内容为空')
+
+    // 收集“安全分页点”:文本行底部 / 块级元素底部,分页优先落在此处,避免一行文字被拦腰切成两半
+    // 原子元素(表格行、图片等)内部不允许切割,否则会破坏其视觉结构
+    const bodyRect = doc.body.getBoundingClientRect()
+    const scaleY = canvas.height / bodyRect.height
+    const breakPoints = (() => {
+      const pts = new Set()
+      const atomics = []
+      doc.querySelectorAll('tr,img,svg,video').forEach((el) => {
+        const r = el.getBoundingClientRect()
+        if (r.height > 0) atomics.push(r)
+      })
+      const isSafe = (cssBottom) =>
+        cssBottom > 0 && cssBottom <= bodyRect.height &&
+        !atomics.some((a) => cssBottom > a.top + 1 && cssBottom < a.bottom - 1)
+      doc.body.querySelectorAll('*').forEach((el) => {
+        const r = el.getBoundingClientRect()
+        if (r.height > 0 && isSafe(r.bottom)) pts.add(Math.round((r.bottom - bodyRect.top) * scaleY))
+      })
+      // 长段落/代码块内部按“文本行”断页
+      const walker = doc.createTreeWalker(doc.body, frame.contentWindow.NodeFilter.SHOW_TEXT)
+      const range = doc.createRange()
+      let node
+      while ((node = walker.nextNode())) {
+        range.selectNodeContents(node)
+        Array.from(range.getClientRects()).forEach((r) => {
+          if (r.height > 0 && isSafe(r.bottom)) pts.add(Math.round((r.bottom - bodyRect.top) * scaleY))
+        })
+      }
+      return Array.from(pts).sort((a, b) => a - b)
+    })()
+
+    const pdf = new jsPDF({ unit: 'mm', format: 'a4', compress: true })
+    const MARGIN = 12
+    const contentW = 210 - MARGIN * 2
+    const contentH = 297 - MARGIN * 2
+    const pxPerMm = canvas.width / contentW
+    const pageHpx = Math.max(1, Math.floor(contentH * pxPerMm))
+    const MIN_SLICE = 8 // 每页最小保留高度,防止产生退化切片
+    let offset = 0
+    let pageIndex = 0
+    while (offset < canvas.height) {
+      let end
+      if (canvas.height - offset <= pageHpx + 4) {
+        // 剩余内容不足一页(含舍入残余)时整页放下,避免切出近空白的尾页
+        end = canvas.height
+      } else {
+        // 在 (offset + MIN_SLICE, offset + pageHpx] 内取最大的安全分页点;找不到(如整页大图)退回硬切
+        end = 0
+        for (let i = breakPoints.length - 1; i >= 0; i--) {
+          if (breakPoints[i] <= offset + pageHpx && breakPoints[i] > offset + MIN_SLICE) { end = breakPoints[i]; break }
+        }
+        if (end <= offset) end = offset + pageHpx
+      }
+      const sliceH = end - offset
+      const slice = document.createElement('canvas')
+      slice.width = canvas.width
+      slice.height = sliceH
+      slice.getContext('2d').drawImage(canvas, 0, offset, canvas.width, sliceH, 0, 0, canvas.width, sliceH)
+      if (pageIndex > 0) pdf.addPage()
+      pdf.addImage(slice.toDataURL('image/jpeg', 0.92), 'JPEG', MARGIN, MARGIN, contentW, sliceH / pxPerMm)
+      offset = end
+      pageIndex++
+    }
+    pdf.save(filename)
+  } finally {
+    frame.remove()
+  }
+}
+
 // 数字格式化(保留以备后续接口扩展时使用)
 // const formatNumber = (num) => {
 //   if (num === null || num === undefined) return '0'
@@ -187,6 +435,15 @@ const formatTime = (ts) => {
   return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
 }
 
+// 会话列表时间(时间戳(秒)→ YYYY-MM-DD HH:mm)
+const formatConvTime = (ts) => {
+  if (!ts) return ''
+  const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
+  if (Number.isNaN(d.getTime())) return ''
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
 // 友好模式名
 const MODE_LABEL = {
   workflow: '工作流',
@@ -211,6 +468,18 @@ const getAgentEmptyState = (agent) => {
   return matched || agentEmptyStates.default
 }
 
+// 是否为「报告生成」智能体(按名称/别名匹配,与空状态配置同源)
+const isReportAgent = (agent) => {
+  const currentName = normalizeAgentName(agent?.name || agent?.title)
+  if (!currentName) return false
+  const reportCfg = agentEmptyStates.agents.find(item => item.name === '报告生成')
+  const names = [reportCfg?.name, ...(reportCfg?.aliases || [])]
+  return names.some(name => {
+    const normalized = normalizeAgentName(name)
+    return normalized && (currentName.includes(normalized) || normalized.includes(currentName))
+  })
+}
+
 function AgentExecute() {
   const { appId } = useParams()
   const location = useLocation()
@@ -257,6 +526,10 @@ function AgentExecute() {
   const [historyList, setHistoryList] = useState([])
   const [historyLoading, setHistoryLoading] = useState(false)
   const [historyError, setHistoryError] = useState('')
+  const [historyHasMore, setHistoryHasMore] = useState(false)      // 是否还有下一页
+  const [historyLoadingMore, setHistoryLoadingMore] = useState(false)
+  const historyListRef = useRef([])                                 // 列表镜像(取 last_id 游标)
+  const historyLoadingRef = useRef(false)                           // 加载中互斥(防滚动重复触发)
   const [resumingConv, setResumingConv] = useState(null)
   const [openDownloadId, setOpenDownloadId] = useState(null)
   const messagesRef = useRef(null)
@@ -286,6 +559,10 @@ function AgentExecute() {
     setHistoryOpen(false)
     setHistoryList([])
     setHistoryError('')
+    setHistoryHasMore(false)
+    setHistoryLoadingMore(false)
+    historyListRef.current = []
+    historyLoadingRef.current = false
     setOpenDownloadId(null)
   }, [appId])
 
@@ -1059,12 +1336,31 @@ function AgentExecute() {
   // ===== 聊天模式(占位,保留旧 UI) =====
   const [input, setInput] = useState('')
   const [messages, setMessages] = useState([])
+  // ===== AI 消息操作:复制 / 听语音 / 点赞点踩 / 编辑 =====
+  const [copiedMsgId, setCopiedMsgId] = useState(null)
+  const [speakingId, setSpeakingId] = useState(null)
+  const [editingMsg, setEditingMsg] = useState(null)   // 正在编辑的消息
+  const [editDraft, setEditDraft] = useState('')       // 编辑中的草稿
+  const canEditMessage = isReportAgent(agent)
   const isChat = mode === 'chat' || mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'agentic' || mode === 'completion' || !mode
 
   useEffect(() => {
     if (messagesAutoScrollRef.current) scrollToBottom(messagesRef.current)
   }, [messages, chatRunning])
 
+  // 组件卸载时停止语音朗读
+  useEffect(() => () => {
+    window.speechSynthesis?.cancel()
+  }, [])
+
+  // 正在朗读的消息不再展示(切换历史会话 / 开启新会话清空)时,停止朗读
+  useEffect(() => {
+    if (speakingId && !messages.some(m => m.id === speakingId)) {
+      window.speechSynthesis?.cancel()
+      setSpeakingId(null)
+    }
+  }, [messages, speakingId])
+
   // ===== API Key 弹框 =====
   const [showApiKeyModal, setShowApiKeyModal] = useState(false)
   const [copied, setCopied] = useState(false)
@@ -1089,29 +1385,60 @@ function AgentExecute() {
     }
   }
 
-  const fetchConversations = useCallback(async () => {
-    setHistoryLoading(true)
-    setHistoryError('')
+  // ===== 历史会话:滚动分页加载(Dify /v1/conversations,sort_by=-updated_at 倒序,首屏返回最新 limit 条) =====
+  const HISTORY_PAGE_SIZE = 20
+
+  // reset=true 拉第一页;否则用上一页最后一条 id 作为 last_id 游标追加下一页
+  const fetchConversations = useCallback(async (reset = false) => {
+    if (historyLoadingRef.current) return
+    historyLoadingRef.current = true
+    if (reset) {
+      setHistoryLoading(true)
+      setHistoryError('')
+    } else {
+      setHistoryLoadingMore(true)
+    }
     try {
-      const res = await getBusinessConversations()
-      const list = Array.isArray(res?.data) ? res.data : (Array.isArray(res?.data?.data) ? res.data.data : [])
-      setHistoryList(list.filter(conv => !conv.app_id || conv.app_id === appId))
+      const lastId = reset ? '' : (historyListRef.current[historyListRef.current.length - 1]?.id || '')
+      const res = await getConversations(apiKey, { lastId, limit: HISTORY_PAGE_SIZE })
+      const list = Array.isArray(res?.data) ? res.data : []
+      historyListRef.current = reset ? list : [...historyListRef.current, ...list]
+      setHistoryList(historyListRef.current)
+      setHistoryHasMore(!!res?.has_more && list.length > 0)
     } catch (e) {
-      setHistoryError(e?.message || '加载历史记录失败')
-      setHistoryList([])
+      if (reset) {
+        setHistoryError(e?.message || '加载历史记录失败')
+        setHistoryList([])
+        historyListRef.current = []
+        setHistoryHasMore(false)
+      } else {
+        setHistoryError(e?.message || '加载更多失败')
+      }
     } finally {
+      historyLoadingRef.current = false
       setHistoryLoading(false)
+      setHistoryLoadingMore(false)
     }
-  }, [appId])
+  }, [apiKey])
 
   const handleToggleHistory = useCallback(() => {
     setHistoryOpen(prev => {
       const next = !prev
-      if (next) fetchConversations()
+      // 每次打开都重新拉第一页(最新 limit 条)
+      if (next) fetchConversations(true)
       return next
     })
   }, [fetchConversations])
 
+  // 抽屉滚动到底部附近时加载下一页
+  const handleHistoryScroll = useCallback((e) => {
+    if (historyLoading || historyLoadingMore || !historyHasMore) return
+    const el = e.currentTarget
+    if (el.scrollHeight - el.scrollTop - el.clientHeight <= 40) {
+      fetchConversations(false)
+    }
+  }, [historyLoading, historyLoadingMore, historyHasMore, fetchConversations])
+
   const handleSelectHistory = useCallback(async (conv) => {
     if (!conv?.id || resumingConv) return
     if (chatRunning && !viewingHistoryRef.current) {
@@ -1126,9 +1453,10 @@ function AgentExecute() {
     }
     setResumingConv(conv.id)
     try {
-      const res = await getBusinessConversationDetail(conv.id)
+      // 恢复会话:直接调 Dify /v1/messages 拉取该会话的历史消息(鉴权用当前智能体 API Key)
+      const res = await getChatMessages(apiKey, conv.id)
       const detail = res?.data ?? res ?? {}
-      const rows = Array.isArray(detail.messages) ? detail.messages : []
+      const rows = Array.isArray(detail.data) ? detail.data : (Array.isArray(detail) ? detail : [])
       const restored = rows
         .filter(m => m && (m.query || m.answer))
         .sort((a, b) => (a.created_at || 0) - (b.created_at || 0))
@@ -1148,7 +1476,7 @@ function AgentExecute() {
     } finally {
       setResumingConv(null)
     }
-  }, [chatConversationId, chatRunError, chatRunning, input, messages, resumingConv])
+  }, [apiKey, chatConversationId, chatRunError, chatRunning, input, messages, resumingConv])
 
   const handleBackToActiveChat = useCallback(() => {
     const snapshot = activeChatSnapshotRef.current
@@ -1165,22 +1493,26 @@ function AgentExecute() {
     setHasActiveChatSnapshot(false)
   }, [])
 
+  // 报告导出(Word / PDF):只导出正文,剔除思考过程与 <step> 过程提示(与页面展示同源 getAnswerText)
   const handleDownloadMessage = useCallback(async (m, format) => {
-    if (!m?.messageId || m.downloading) return
+    if (m?.downloading) return
+    const answer = getAnswerText(m)
+    if (!answer) {
+      setChatRunError('暂无可导出的正文内容')
+      return
+    }
     setMessages(prev => prev.map(x => x.id === m.id ? { ...x, downloading: format } : x))
     try {
-      const { blob } = await downloadMessageContent(m.messageId, format)
-      const url = URL.createObjectURL(blob)
-      const a = document.createElement('a')
-      a.href = url
-      const ext = format === 'pdf' ? 'pdf' : 'docx'
-      a.download = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}.${ext}`
-      document.body.appendChild(a)
-      a.click()
-      document.body.removeChild(a)
-      setTimeout(() => URL.revokeObjectURL(url), 1000)
+      const filename = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}`
+      if (format === 'word') {
+        // HTML 打包为 altChunk .docx(Word/WPS 可直接打开)
+        downloadBlob(await htmlToDocxBlob(buildExportHtml(renderMarkdown(answer))), `${filename}.docx`)
+      } else {
+        // html2canvas + jsPDF 直接生成 PDF 文件下载
+        await exportHtmlToPdf(buildExportHtml(renderMarkdown(answer)), `${filename}.pdf`)
+      }
     } catch (e) {
-      setChatRunError(e?.message || '下载失败')
+      setChatRunError(`导出失败:${e?.message || '未知错误'}`)
     } finally {
       setMessages(prev => prev.map(x => x.id === m.id ? { ...x, downloading: null } : x))
     }
@@ -1215,50 +1547,28 @@ function AgentExecute() {
     })
   }, [chatRunning, handleNewChat])
 
-  const handleSend = async () => {
-    const text = input.trim()
-    if (!text || chatRunning) return
-    const missingFields = getMissingRequiredFields()
-    if (missingFields.length > 0) {
-      setChatRunError(formatRequiredFieldsError(missingFields))
-      setShowChatParamsModal(true)
-      return
-    }
-    if (!apiKey) {
-      setChatRunError('缺少 API Key')
-      return
-    }
+  // 发起一次对话流式请求(发送 & 重新生成共用)
+  const runChatStream = async (query, assistantMsgId) => {
     chatAbortRef.current?.abort()
     const controller = new AbortController()
     chatAbortRef.current = controller
     setChatRunError('')
-
-    // 1) 推入 user 消息
-    const userMsgId = 'u-' + Date.now()
-    const assistantMsgId = 'a-' + Date.now()
-    messagesAutoScrollRef.current = true
-    setMessages(prev => [
-      ...prev,
-      { id: userMsgId, role: 'user', content: text },
-      { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true },
-    ])
-    setInput('')
     setChatStreamIdSafe(assistantMsgId)
     setChatRunning(true)
 
     try {
-      // 2) 调 /chat-messages 接口(带 inputs + conversation_id 续传)
+      // 调 /chat-messages 接口(带 inputs + conversation_id 续传)
       const res = await runChatMessage(
         apiKey,
         {
-          query: text,
+          query,
           inputs: await buildInputsWithUploadedPdfs(formValues, controller.signal),
           conversation_id: chatConversationIdRef.current || chatConversationId || ''
         },
         undefined,
         { signal: controller.signal }
       )
-      // 3) 解析 SSE 流
+      // 解析 SSE 流
       await consumeSSE(
         res,
         handleChatSSEEvent,
@@ -1300,11 +1610,161 @@ function AgentExecute() {
     }
   }
 
+  const handleSend = async () => {
+    const text = input.trim()
+    if (!text || chatRunning) return
+    const missingFields = getMissingRequiredFields()
+    if (missingFields.length > 0) {
+      setChatRunError(formatRequiredFieldsError(missingFields))
+      setShowChatParamsModal(true)
+      return
+    }
+    if (!apiKey) {
+      setChatRunError('缺少 API Key')
+      return
+    }
+
+    // 1) 推入 user 消息
+    const userMsgId = 'u-' + Date.now()
+    const assistantMsgId = 'a-' + Date.now()
+    messagesAutoScrollRef.current = true
+    setMessages(prev => [
+      ...prev,
+      { id: userMsgId, role: 'user', content: text },
+      { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true },
+    ])
+    setInput('')
+
+    // 2) 发起流式请求
+    await runChatStream(text, assistantMsgId)
+  }
+
   // 停止当前对话流
   const handleStopChat = () => {
     chatAbortRef.current?.abort()
   }
 
+  // ===== AI 消息操作 =====
+  // 复制正文
+  const handleCopyMessage = async (m) => {
+    const text = getAnswerText(m)
+    if (!text) return
+    const ok = await copyTextToClipboard(text)
+    if (!ok) {
+      Modal.warning({ title: '复制失败', content: '当前浏览器不支持自动复制,请手动选择文本复制。' })
+      return
+    }
+    setCopiedMsgId(m.id)
+    setTimeout(() => setCopiedMsgId(prev => (prev === m.id ? null : prev)), 1500)
+  }
+
+  // 听语音(Web Speech API,可再次点击停止)
+  const handleToggleSpeak = (m) => {
+    const synth = window.speechSynthesis
+    if (!synth) return
+    if (speakingId === m.id) {
+      synth.cancel()
+      setSpeakingId(null)
+      return
+    }
+    synth.cancel()
+    const raw = getAnswerText(m)
+    if (!raw) return
+    // 去掉代码块与常见 markdown 符号,提升朗读体验
+    const text = raw
+      .replace(/```[\s\S]*?```/g, ' ')
+      .replace(/[#>*_~[\]()!|-]/g, ' ')
+      .replace(/\s+/g, ' ')
+      .trim()
+    if (!text) return
+    const utterance = new SpeechSynthesisUtterance(text)
+    utterance.lang = 'zh-CN'
+    utterance.onend = () => setSpeakingId(prev => (prev === m.id ? null : prev))
+    utterance.onerror = () => setSpeakingId(prev => (prev === m.id ? null : prev))
+    setSpeakingId(m.id)
+    synth.speak(utterance)
+  }
+
+  // 点赞 / 点踩(互斥,再次点击取消)
+  const handleFeedback = (m, type) => {
+    setMessages(prev => prev.map(x =>
+      x.id === m.id ? { ...x, feedback: x.feedback === type ? null : type } : x
+    ))
+  }
+
+  // ===== 编辑 AI 消息(右侧抽屉) =====
+  const handleOpenEditor = (m) => {
+    // 编辑前停止本条语音朗读
+    if (speakingId === m.id) {
+      window.speechSynthesis?.cancel()
+      setSpeakingId(null)
+    }
+    setEditingMsg(m)
+    // 与「复制」按钮同源(getAnswerText):剔除思考过程与 <step> 过程提示后再转 HTML 载入富文本
+    const parsed = splitThinkContent(m.content || '')
+    const answer = parsed.answer || (!parsed.thought ? m.content || '' : '')
+    setEditDraft(renderMarkdown(splitStepContent(answer).text))
+  }
+
+  const handleCloseEditor = () => {
+    setEditingMsg(null)
+    setEditDraft('')
+  }
+
+  const handleSaveEditor = () => {
+    if (!editingMsg) return
+    const draft = editDraft
+    setMessages(prev => prev.map(x =>
+      x.id === editingMsg.id
+        ? { ...x, content: draft, thinking: splitThinkContent(draft).thinking, edited: true }
+        : x
+    ))
+    handleCloseEditor()
+  }
+
+  // ===== 编辑内容导出(导出当前编辑草稿,所见即所得) =====
+  const [editorExporting, setEditorExporting] = useState(false)
+
+  const handleExportDocx = async () => {
+    if (!editDraft || editorExporting) return
+    setEditorExporting(true)
+    try {
+      // HTML 打包为 altChunk .docx(Word/WPS 可直接打开)
+      const filename = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}.docx`
+      downloadBlob(await htmlToDocxBlob(buildExportHtml(editDraft)), filename)
+    } catch (e) {
+      setChatRunError(`导出失败:${e?.message || '未知错误'}`)
+    } finally {
+      setEditorExporting(false)
+    }
+  }
+
+  // 重新生成:用上一条用户提问重新请求
+  const handleRegenerate = async (m) => {
+    if (chatRunning) return
+    const idx = messages.findIndex(x => x.id === m.id)
+    if (idx < 0) return
+    let query = ''
+    for (let j = idx - 1; j >= 0; j--) {
+      if (messages[j].role === 'user') { query = messages[j].content || ''; break }
+    }
+    if (!query) return
+    // 停掉本条正在播放的语音
+    if (speakingId === m.id) {
+      window.speechSynthesis?.cancel()
+      setSpeakingId(null)
+    }
+    const assistantMsgId = 'a-' + Date.now()
+    messagesAutoScrollRef.current = true
+    // 原位替换为新的流式占位消息
+    setMessages(prev => prev.map(x =>
+      x.id === m.id
+        ? { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true }
+        : x
+    ))
+    await runChatStream(query, assistantMsgId)
+  }
+
   return (
     <div className="agent-execute">
       {/* 智能体详情卡片 */}
@@ -1643,9 +2103,7 @@ function AgentExecute() {
                 <div key={m.id || i} className={`execute-msg execute-msg-${m.role}`}>
                   <span className="execute-msg-role">{m.role === 'user' ? '我' : agent.name}</span>
                   <div className="execute-msg-bubble">
-                    {m.pending ? (
-                      <span className="execute-msg-generating">{m.content}</span>
-                    ) : m.role === 'assistant' ? (
+                    {m.role === 'assistant' ? (
                       renderAssistantMessage(m)
                     ) : (
                       <div
@@ -1653,32 +2111,87 @@ function AgentExecute() {
                         dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content) }}
                       />
                     )}
-                    {m.streaming && <span className="wf-step-cursor">▍</span>}
-                  </div>
-                  {!m.streaming && m.role === 'assistant' && m.messageId && m.content && (
+                    {m.streaming && !getActiveStepHint(m) && <span className="wf-step-cursor">▍</span>}
+                    {!m.streaming && m.role === 'assistant' && m.messageId && m.content && (
                     <div className="execute-msg-actions">
                       <button
                         type="button"
-                        className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-word"
-                        disabled={!!m.downloading}
-                        onClick={() => handleDownloadMessage(m, 'word')}
-                        title="下载 Word"
+                        className={`execute-msg-icon-btn${copiedMsgId === m.id ? ' is-active' : ''}`}
+                        data-tip={copiedMsgId === m.id ? '已复制' : '复制'}
+                        onClick={() => handleCopyMessage(m)}
+                      >
+                        <IconCopy />
+                      </button>
+                      {canEditMessage && (
+                        <button
+                          type="button"
+                          className={`execute-msg-icon-btn${m.edited ? ' is-active' : ''}`}
+                          data-tip="编辑"
+                          onClick={() => handleOpenEditor(m)}
+                        >
+                          <IconEdit />
+                        </button>
+                      )}
+                      <button
+                        type="button"
+                        className={`execute-msg-icon-btn${speakingId === m.id ? ' is-active is-speaking' : ''}`}
+                        data-tip={speakingId === m.id ? '停止播放' : '听语音'}
+                        onClick={() => handleToggleSpeak(m)}
+                      >
+                        {speakingId === m.id ? <IconStop /> : <IconVolume />}
+                      </button>
+                      <button
+                        type="button"
+                        className={`execute-msg-icon-btn execute-msg-like-btn${m.feedback === 'like' ? ' is-liked' : ''}`}
+                        data-tip={m.feedback === 'like' ? '取消点赞' : '点赞'}
+                        onClick={() => handleFeedback(m, 'like')}
+                      >
+                        <IconThumbUp />
+                      </button>
+                      <button
+                        type="button"
+                        className={`execute-msg-icon-btn execute-msg-dislike-btn${m.feedback === 'dislike' ? ' is-disliked' : ''}`}
+                        data-tip={m.feedback === 'dislike' ? '取消点踩' : '点踩'}
+                        onClick={() => handleFeedback(m, 'dislike')}
                       >
-                        <img src={wordIcon} alt="" className="execute-msg-download-icon" />
-                        <span className="execute-msg-download-text">{m.downloading === 'word' ? '生成中...' : 'Word'}</span>
+                        <IconThumbDown />
                       </button>
                       <button
                         type="button"
-                        className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-pdf"
-                        disabled={!!m.downloading}
-                        onClick={() => handleDownloadMessage(m, 'pdf')}
-                        title="下载 PDF"
+                        className="execute-msg-icon-btn"
+                        data-tip="重新生成"
+                        disabled={chatRunning || String(m.id || '').startsWith('history-')}
+                        onClick={() => handleRegenerate(m)}
                       >
-                        <img src={pdfIcon} alt="" className="execute-msg-download-icon" />
-                        <span className="execute-msg-download-text">{m.downloading === 'pdf' ? '生成中...' : 'PDF'}</span>
+                        <IconRefresh />
                       </button>
+                      {isReportAgent(agent) && (
+                      <div className="execute-msg-actions-right">
+                        <button
+                          type="button"
+                          className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-word"
+                          disabled={!!m.downloading}
+                          onClick={() => handleDownloadMessage(m, 'word')}
+                          title="下载 Word"
+                        >
+                          <img src={wordIcon} alt="" className="execute-msg-download-icon" />
+                          <span className="execute-msg-download-text">{m.downloading === 'word' ? '生成中...' : 'Word'}</span>
+                        </button>
+                        <button
+                          type="button"
+                          className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-pdf"
+                          disabled={!!m.downloading}
+                          onClick={() => handleDownloadMessage(m, 'pdf')}
+                          title="下载 PDF"
+                        >
+                          <img src={pdfIcon} alt="" className="execute-msg-download-icon" />
+                          <span className="execute-msg-download-text">{m.downloading === 'pdf' ? '生成中...' : 'PDF'}</span>
+                        </button>
+                      </div>
+                      )}
                     </div>
-                  )}
+                    )}
+                  </div>
                 </div>
               ))
             )}
@@ -1697,7 +2210,7 @@ function AgentExecute() {
                       handleSend()
                     }
                   }}
-                  placeholder="请描述报告要求,例如:总结研究背景、核心结论和风险建议,输出为正式汇报口吻..."
+                  placeholder="请输入你的问题或任务,可补充背景信息和期望的输出要求,我会结合当前智能体能力为你解答..."
                   disabled={chatRunning}
                   rows={2}
                 />
@@ -1837,7 +2350,7 @@ function AgentExecute() {
               <button type="button" className="execute-drawer-close" onClick={() => setHistoryOpen(false)} aria-label="关闭">x</button>
               <div className="execute-drawer-title">历史记录</div>
             </div>
-            <div className="execute-drawer-body">
+            <div className="execute-drawer-body" onScroll={handleHistoryScroll}>
               {historyLoading ? (
                 <div className="execute-drawer-status">加载中...</div>
               ) : historyError ? (
@@ -1845,22 +2358,57 @@ function AgentExecute() {
               ) : historyList.length === 0 ? (
                 <div className="execute-drawer-status">暂无历史记录</div>
               ) : (
-                <ul className="execute-drawer-list">
-                  {historyList.map(conv => (
-                    <li key={conv.id} className={`execute-drawer-item ${resumingConv === conv.id ? 'is-loading' : ''}`} onClick={() => handleSelectHistory(conv)}>
-                      <div className="execute-drawer-item-main">
-                        <div className="execute-drawer-item-name">{conv.name || conv.summary || '新会话'}</div>
-                        <div className="execute-drawer-item-meta">{conv.message_count || 0} 条消息</div>
-                      </div>
-                    </li>
-                  ))}
-                </ul>
+                <>
+                  <ul className="execute-drawer-list">
+                    {historyList.map(conv => (
+                      <li key={conv.id} className={`execute-drawer-item ${resumingConv === conv.id ? 'is-loading' : ''}`} onClick={() => handleSelectHistory(conv)}>
+                        <div className="execute-drawer-item-main">
+                          <div className="execute-drawer-item-name">{conv.name || conv.summary || '新会话'}</div>
+                          <div className="execute-drawer-item-meta">{formatConvTime(conv.updated_at)}</div>
+                        </div>
+                      </li>
+                    ))}
+                  </ul>
+                  {historyLoadingMore && <div className="execute-drawer-status">加载中...</div>}
+                  {!historyLoadingMore && !historyHasMore && <div className="execute-drawer-status">没有更多了</div>}
+                </>
               )}
             </div>
           </aside>
         </>
       )}
 
+      {editingMsg && (
+        <>
+          <div className="execute-drawer-mask is-open" onClick={handleCloseEditor} />
+          <aside className="execute-drawer is-open execute-editor-drawer" role="dialog" aria-label="编辑内容">
+            <div className="execute-drawer-head">
+              <button type="button" className="execute-drawer-close" onClick={handleCloseEditor} aria-label="关闭">x</button>
+              <div className="execute-drawer-title">编辑内容</div>
+            </div>
+            <div className="execute-drawer-body execute-editor-body">
+              <RichTextEditor
+                value={editDraft}
+                onChange={setEditDraft}
+                placeholder="请编辑报告内容,支持富文本格式..."
+              />
+            </div>
+            <div className="execute-editor-foot">
+              <button
+                type="button"
+                className="execute-editor-export"
+                disabled={!editDraft || editorExporting}
+                onClick={handleExportDocx}
+              >
+                {editorExporting ? '导出中…' : '导出 DOCX'}
+              </button>
+              <button type="button" className="execute-editor-cancel" onClick={handleCloseEditor}>取消</button>
+              <button type="button" className="execute-editor-save" onClick={handleSaveEditor}>保存</button>
+            </div>
+          </aside>
+        </>
+      )}
+
     </div>
   )
 }

+ 33 - 0
src/service/dify.js

@@ -313,6 +313,39 @@ export const runChatMessage = (apiKey, params = {}, user, options = {}) => {
   })
 }
 
+/**
+ * GET /v1/conversations  —— 当前智能体的会话列表(历史记录抽屉,滚动分页)
+ *
+ * - Authorization:  Bearer <apiKey>
+ * - query:          user(必填,网关要求 user=<登录token>)、limit(页大小)、
+ *                   sort_by=-updated_at(按更新时间倒序:第一页返回最新 limit 条)、
+ *                   last_id(分页游标:传上一页最后一条的 id 取下一页)
+ * - 响应:           { data: [{ id, name, inputs, status, introduction, created_at, updated_at }], has_more, limit, page, total }
+ */
+export const getConversations = (apiKey, { lastId = '', limit = 20 } = {}, options = {}) => {
+  const user = buildWorkflowUser() || 'webapp-user'
+  const params = [
+    `user=${encodeURIComponent(user)}`,
+    `limit=${limit}`,
+    'sort_by=-updated_at',
+  ]
+  if (lastId) params.push(`last_id=${encodeURIComponent(lastId)}`)
+  return difyFetch(`${DIFY_V1_PREFIX}/conversations?${params.join('&')}`, apiKey, { method: 'GET', ...options }).then(r => r.json())
+}
+
+/**
+ * GET /v1/messages  —— 会话历史消息(恢复历史会话时拉取)
+ *
+ * - Authorization:  Bearer <apiKey>
+ * - query:          conversation_id(必填)、user(必填,网关要求 user=<登录token>)、limit
+ * - 响应:           { data: [{ id, conversation_id, query, answer, created_at }], has_more, limit }
+ */
+export const getChatMessages = (apiKey, conversationId, options = {}) => {
+  const user = buildWorkflowUser() || 'webapp-user'
+  const qs = `conversation_id=${encodeURIComponent(conversationId)}&user=${encodeURIComponent(user)}&limit=100`
+  return difyFetch(`${DIFY_V1_PREFIX}/messages?${qs}`, apiKey, { method: 'GET', ...options }).then(r => r.json())
+}
+
 /**
  * 解析 SSE(Server-Sent Events)流
  * 自动忽略 ping 事件,逐个回调解析后的 JSON

+ 99 - 0
src/utils/exportDocx.js

@@ -0,0 +1,99 @@
+import JSZip from 'jszip'
+
+// HTML → .docx(Word altChunk 方案:HTML 作为 MHT 分块嵌入,Word/WPS 打开时按 HTML 渲染并保留样式)
+// 与 html-docx-js 同思路(该库 dist 含 with 语句、Rollup 无法解析,故在此自行实现)
+
+const CONTENT_TYPES_XML = [
+  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+  '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
+  '  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />',
+  '  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" />',
+  '  <Override PartName="/word/afchunk.mht" ContentType="message/rfc822" />',
+  '</Types>',
+].join('\n')
+
+const ROOT_RELS_XML = [
+  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+  '  <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="/word/document.xml" Id="R09c83fafc067488e" />',
+  '</Relationships>',
+].join('\n')
+
+const DOCUMENT_RELS_XML = [
+  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+  '  <Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="/word/afchunk.mht" Id="htmlChunk" />',
+  '</Relationships>',
+].join('\n')
+
+// A4 纵向,四边 1 英寸页边距(单位 twips)
+const DOCUMENT_XML = [
+  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+  '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
+  '  <w:body>',
+  '    <w:altChunk r:id="htmlChunk" />',
+  '    <w:sectPr>',
+  '      <w:pgSz w:w="12240" w:h="15840" w:orient="portrait" />',
+  '      <w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0" />',
+  '    </w:sectPr>',
+  '  </w:body>',
+  '</w:document>',
+].join('\n')
+
+// 组装 MHT 文档:正文为 quoted-printable 的 HTML(= 需转义为 =3D),data URI 图片拆为独立分块
+const buildMht = (htmlSource) => {
+  const imageParts = []
+  htmlSource = htmlSource.replace(/"data:([\w.+-]+\/[\w.+-]+);(\w+),(\S+)"/g, (_m, contentType, encoding, encoded) => {
+    const index = imageParts.length
+    const extension = contentType.split('/')[1]
+    const contentLocation = `file:///C:/fake/image${index}.${extension}`
+    imageParts.push(
+      [
+        '------=mhtDocumentPart',
+        `Content-Type: ${contentType}`,
+        `Content-Transfer-Encoding: ${encoding}`,
+        `Content-Location: ${contentLocation}`,
+        '',
+        encoded,
+      ].join('\n')
+    )
+    return `"${contentLocation}"`
+  })
+  const escapedHtml = htmlSource.replace(/=/g, '=3D')
+  return [
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/related;',
+    '    type="text/html";',
+    '    boundary="----=mhtDocumentPart"',
+    '',
+    '',
+    '------=mhtDocumentPart',
+    'Content-Type: text/html;',
+    '    charset="utf-8"',
+    'Content-Transfer-Encoding: quoted-printable',
+    'Content-Location: file:///C:/fake/document.html',
+    '',
+    escapedHtml,
+    '',
+    imageParts.join('\n'),
+    '',
+    '------=mhtDocumentPart--',
+  ].join('\n')
+}
+
+// HTML 字符串 → .docx Blob
+export const htmlToDocxBlob = async (htmlSource) => {
+  const zip = new JSZip()
+  zip.file('[Content_Types].xml', CONTENT_TYPES_XML)
+  zip.folder('_rels').file('.rels', ROOT_RELS_XML)
+  zip
+    .folder('word')
+    .file('document.xml', DOCUMENT_XML)
+    .file('afchunk.mht', buildMht(String(htmlSource || '')))
+    .folder('_rels')
+    .file('document.xml.rels', DOCUMENT_RELS_XML)
+  const buffer = await zip.generateAsync({ type: 'arraybuffer' })
+  return new Blob([buffer], {
+    type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  })
+}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.