From 6b1f17d6a80bac59d929faa45d472f972df61eef Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Thu, 14 May 2026 16:51:32 +0800
Subject: [PATCH] =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=AF=BC=E5=85=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend-web/package-lock.json | 219 +-
frontend-web/package.json | 1 +
frontend-web/src/components/page-sidebar.tsx | 150 +-
frontend-web/src/pages/PageRoutes.tsx | 4 +-
frontend-web/src/pages/StrategyRoutes.tsx | 29 +
.../components/qa/AgentQA.tsx | 575 +++++
.../components/qa/CollaborativeQA.tsx | 185 ++
.../components/qa/GeneralQA.mock.ts | 584 +++++
.../components/qa/GeneralQA.tsx | 2202 +++++++++++++++++
.../components/qa/GeneralQA.types.ts | 74 +
.../components/qa/GeneralQAConfigSidebar.tsx | 249 ++
.../components/qa/GeneralQAHeader.tsx | 80 +
.../components/qa/MopeChat.css | 410 +++
.../components/qa/MopeChat.jsx | 215 ++
.../components/qa/MopeChat.mock.ts | 437 ++++
.../components/qa/MopeChat.tsx | 309 +++
.../strategy-components/components/qa/QA.tsx | 2184 ++++++++++++++++
.../components/qa/QACenterPage.tsx | 9 +
.../components/qa/QACommon.tsx | 782 ++++++
.../components/qa/QALayout.tsx | 5 +
.../components/qa/QATypeSwitch.tsx | 121 +
.../components/qa/Template6BEditor.tsx | 783 ++++++
.../components/qa/TemplateEditor.tsx | 1520 ++++++++++++
.../components/qa/components/AgentCard.css | 235 ++
.../components/qa/components/AgentCard.jsx | 204 ++
.../components/qa/components/AgentCard.tsx | 193 ++
.../components/qa/components/ConfigPanel.css | 467 ++++
.../components/qa/components/ConfigPanel.jsx | 459 ++++
.../components/qa/components/ConfigPanel.tsx | 413 ++++
.../components/qa/components/FinalReport.css | 167 ++
.../components/qa/components/FinalReport.jsx | 114 +
.../components/qa/components/FinalReport.tsx | 98 +
.../qa/components/MarkdownContent.css | 131 +
.../qa/components/MarkdownContent.jsx | 84 +
.../qa/components/MarkdownContent.tsx | 68 +
.../qa/components/ProgressSidebar.css | 123 +
.../qa/components/ProgressSidebar.jsx | 156 ++
.../qa/components/ProgressSidebar.tsx | 141 ++
.../qa/components/ScenarioConfigPage.css | 337 +++
.../qa/components/ScenarioConfigPage.jsx | 469 ++++
.../qa/components/ScenarioConfigPage.tsx | 417 ++++
.../components/qa/components/Sidebar.css | 238 ++
.../components/qa/components/Sidebar.jsx | 187 ++
.../components/qa/components/Sidebar.tsx | 174 ++
.../components/qa/components/Timeline.css | 670 +++++
.../components/qa/components/Timeline.jsx | 641 +++++
.../components/qa/components/Timeline.tsx | 567 +++++
47 files changed, 17802 insertions(+), 78 deletions(-)
create mode 100644 frontend-web/src/pages/StrategyRoutes.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/AgentQA.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/CollaborativeQA.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/GeneralQA.mock.ts
create mode 100644 frontend-web/src/strategy-components/components/qa/GeneralQA.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/GeneralQA.types.ts
create mode 100644 frontend-web/src/strategy-components/components/qa/GeneralQAConfigSidebar.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/GeneralQAHeader.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/MopeChat.css
create mode 100644 frontend-web/src/strategy-components/components/qa/MopeChat.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/MopeChat.mock.ts
create mode 100644 frontend-web/src/strategy-components/components/qa/MopeChat.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/QA.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/QACenterPage.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/QACommon.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/QALayout.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/QATypeSwitch.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/Template6BEditor.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/TemplateEditor.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/AgentCard.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/AgentCard.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/AgentCard.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ConfigPanel.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ConfigPanel.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ConfigPanel.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/FinalReport.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/FinalReport.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/FinalReport.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/MarkdownContent.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/MarkdownContent.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/MarkdownContent.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Sidebar.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Sidebar.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Sidebar.tsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Timeline.css
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Timeline.jsx
create mode 100644 frontend-web/src/strategy-components/components/qa/components/Timeline.tsx
diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json
index 1296d82f..5317a272 100644
--- a/frontend-web/package-lock.json
+++ b/frontend-web/package-lock.json
@@ -71,6 +71,7 @@
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
+ "tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
@@ -1665,6 +1666,15 @@
"node": ">=8.0.0"
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4630,6 +4640,16 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/sortablejs": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
+ "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="
+ },
+ "node_modules/@types/tinycolor2": {
+ "version": "1.4.6",
+ "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
+ "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4641,6 +4661,11 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
+ "node_modules/@types/validator": {
+ "version": "13.15.10",
+ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
+ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="
+ },
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
@@ -5091,6 +5116,11 @@
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
},
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
+ },
"node_modules/clsx": {
"version": "2.1.1",
"license": "MIT",
@@ -5773,6 +5803,15 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dompurify": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -6264,6 +6303,19 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6403,8 +6455,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/jsesc": {
"version": "3.1.0",
@@ -6790,6 +6841,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -7706,6 +7768,11 @@
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
+ },
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -7797,6 +7864,14 @@
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"dev": true
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ogl": {
"version": "1.0.11",
"license": "Unlicense"
@@ -7918,6 +7993,11 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8010,6 +8090,21 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -8019,6 +8114,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/react": {
"version": "19.2.4",
"license": "MIT",
@@ -8036,6 +8139,16 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
+ },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -8180,6 +8293,21 @@
}
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -8194,6 +8322,11 @@
"util-deprecate": "~1.0.1"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+ },
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -8457,6 +8590,11 @@
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
+ "node_modules/sortablejs": {
+ "version": "1.15.7",
+ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
+ "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8578,6 +8716,75 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tdesign-icons-react": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/tdesign-icons-react/-/tdesign-icons-react-0.5.0.tgz",
+ "integrity": "sha512-Gpl1Kxkb8+YXYjMsSW5W3kWy/drVB/huFLIHWhmJdUnEwUAW0Il+nDyb/BsRi51jLnalBwjYbOELEggH2qp6FQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.16.5",
+ "classnames": "^2.2.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1",
+ "react-dom": ">=16.13.1"
+ }
+ },
+ "node_modules/tdesign-react": {
+ "version": "1.12.3",
+ "resolved": "https://registry.npmjs.org/tdesign-react/-/tdesign-react-1.12.3.tgz",
+ "integrity": "sha512-zpGPUrxoGvK3ZMFIAM1GmFI2LEYzPfQSALK0egR/RSGuVaQmNSqgonGATz0N1/quAqlZjdRqVH5w6IOJ5ruyKA==",
+ "dependencies": {
+ "@babel/runtime": "~7.26.7",
+ "@popperjs/core": "~2.11.2",
+ "@types/sortablejs": "^1.10.7",
+ "@types/tinycolor2": "^1.4.3",
+ "@types/validator": "^13.1.3",
+ "classnames": "~2.5.1",
+ "dayjs": "1.11.10",
+ "hoist-non-react-statics": "~3.3.2",
+ "lodash-es": "^4.17.21",
+ "mitt": "^3.0.0",
+ "raf": "~3.4.1",
+ "react-fast-compare": "^3.2.2",
+ "react-is": "^18.2.0",
+ "react-transition-group": "~4.4.1",
+ "sortablejs": "^1.15.0",
+ "tdesign-icons-react": "0.5.0",
+ "tinycolor2": "^1.4.2",
+ "tslib": "~2.3.1",
+ "validator": "~13.15.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1",
+ "react-dom": ">=16.13.1"
+ }
+ },
+ "node_modules/tdesign-react/node_modules/@babel/runtime": {
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
+ "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/tdesign-react/node_modules/dayjs": {
+ "version": "1.11.10",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
+ "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
+ },
+ "node_modules/tdesign-react/node_modules/tslib": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+ "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+ },
+ "node_modules/tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+ },
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@@ -8882,6 +9089,14 @@
"uuid": "dist-node/bin/uuid"
}
},
+ "node_modules/validator": {
+ "version": "13.15.35",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
+ "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
diff --git a/frontend-web/package.json b/frontend-web/package.json
index 368ae519..41f0baaa 100644
--- a/frontend-web/package.json
+++ b/frontend-web/package.json
@@ -73,6 +73,7 @@
"sonner": "^2.0.7",
"streamdown": "1.4.0",
"tailwind-merge": "^3.4.0",
+ "tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx
index b5d48c71..6f9f8078 100644
--- a/frontend-web/src/components/page-sidebar.tsx
+++ b/frontend-web/src/components/page-sidebar.tsx
@@ -49,173 +49,173 @@ type PageSidebarProps = {
const menuItems: MenuItem[] = [
{
id: "qa-center",
- label: "QA Center",
+ label: "问答中心",
icon: HelpCircle,
children: [
- { id: "new-qa", label: "New QA", icon: Plus },
- { id: "general-qa", label: "General QA", icon: CircleHelp },
- { id: "agent-qa", label: "Agent QA", icon: Bot },
- { id: "multi-agent-qa", label: "Multi Agent", icon: Layers },
- { id: "sixstep-chat", label: "Six Step Chat", icon: Workflow },
- { id: "qa-records", label: "QA Records", icon: History },
- { id: "template-editor", label: "Template Config", icon: Settings },
+ { id: "new-qa", label: "新建问答", icon: Plus },
+ { id: "general-qa", label: "通用问答", icon: CircleHelp },
+ { id: "agent-qa", label: "智能体问答", icon: Bot },
+ { id: "multi-agent-qa", label: "多智能体", icon: Layers },
+ { id: "sixstep-chat", label: "六步对话", icon: Workflow },
+ { id: "qa-records", label: "问答记录", icon: History },
+ { id: "template-editor", label: "模板配置", icon: Settings },
],
},
{
id: "light-app-center",
- label: "Light Apps",
+ label: "轻应用",
icon: Grid3X3,
children: [
- { id: "light-app-square", label: "App Square", icon: Layout },
- { id: "light-app-records", label: "Run Records", icon: History },
+ { id: "light-app-square", label: "应用广场", icon: Layout },
+ { id: "light-app-records", label: "运行记录", icon: History },
],
},
{
id: "task-management",
- label: "Task Management",
+ label: "任务管理",
icon: Target,
children: [
- { id: "dashboard", label: "Dashboard", icon: BarChart3 },
+ { id: "dashboard", label: "数据看板", icon: BarChart3 },
{
id: "xk-task",
- label: "XK Tasks",
+ label: "XK任务",
icon: Folder,
children: [
- { id: "xk-task-jc", label: "Task JC", icon: ListTodo },
- { id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo },
+ { id: "xk-task-jc", label: "任务JC", icon: ListTodo },
+ { id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
],
},
{
id: "plan-task",
- label: "Planning Tasks",
+ label: "规划任务",
icon: Folder,
children: [
- { id: "plan-task-fx", label: "Task Analysis", icon: ListTodo },
- { id: "plan-task-action", label: "Action Strategy", icon: ListTodo },
+ { id: "plan-task-fx", label: "任务分析", icon: ListTodo },
+ { id: "plan-task-action", label: "行动策略", icon: ListTodo },
],
},
{
id: "mnty-task",
- label: "MNTY Tasks",
+ label: "MNTY任务",
icon: Folder,
children: [
- { id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo },
- { id: "mnty-task-plan", label: "Plan Task", icon: ListTodo },
- { id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo },
+ { id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
+ { id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
+ { id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
],
},
{
id: "effect-task",
- label: "Effect PG Tasks",
+ label: "效果PG任务",
icon: Folder,
children: [
- { id: "effect-task-pg", label: "Task PG", icon: ListTodo },
- { id: "effect-action-pg", label: "Action PG", icon: ListTodo },
- { id: "effect-info-pg", label: "Info PG", icon: ListTodo },
- { id: "effect-account-pg", label: "Account PG", icon: ListTodo },
+ { id: "effect-task-pg", label: "任务PG", icon: ListTodo },
+ { id: "effect-action-pg", label: "行动PG", icon: ListTodo },
+ { id: "effect-info-pg", label: "信息PG", icon: ListTodo },
+ { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
],
},
- { id: "task-create", label: "Create Task", icon: Plus },
- { id: "task-single", label: "Single Agent Task", icon: Bot },
- { id: "task-scene", label: "Scene Task", icon: Workflow },
- { id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare },
- { id: "final-plan", label: "Final Output", icon: FileOutput },
+ { id: "task-create", label: "创建任务", icon: Plus },
+ { id: "task-single", label: "单智能体任务", icon: Bot },
+ { id: "task-scene", label: "场景任务", icon: Workflow },
+ { id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
+ { id: "final-plan", label: "最终输出", icon: FileOutput },
],
},
- { id: "scene-list", label: "Scene Management", icon: Layers },
+ { id: "scene-list", label: "场景管理", icon: Layers },
{
id: "agent-list",
- label: "Agent Management",
+ label: "智能体管理",
icon: Bot,
children: [
- { id: "agent-overview", label: "Agent List", icon: Users },
- { id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle },
+ { id: "agent-overview", label: "智能体列表", icon: Users },
+ { id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
],
},
{
id: "result-management",
- label: "Result Management",
+ label: "结果管理",
icon: BarChart3,
children: [
{
id: "result-planning",
- label: "Planning Results",
+ label: "规划结果",
icon: Target,
children: [
- { id: "result-planning-all", label: "All", icon: BarChart3 },
- { id: "result-planning-goal", label: "Goal Analysis", icon: Target },
- { id: "result-planning-brand", label: "Brand Strategy", icon: Target },
- { id: "result-planning-content", label: "Content Planning", icon: Target },
- { id: "result-planning-channel", label: "Channel Planning", icon: Target },
- { id: "result-planning-narrative", label: "Narrative Strategy", icon: Target },
+ { id: "result-planning-all", label: "全部", icon: BarChart3 },
+ { id: "result-planning-goal", label: "目标分析", icon: Target },
+ { id: "result-planning-brand", label: "品牌策略", icon: Target },
+ { id: "result-planning-content", label: "内容规划", icon: Target },
+ { id: "result-planning-channel", label: "渠道规划", icon: Target },
+ { id: "result-planning-narrative", label: "叙事策略", icon: Target },
],
},
{
id: "result-mnty",
- label: "MNTY Results",
+ label: "MNTY结果",
icon: Brain,
children: [
- { id: "result-mnty-all", label: "All", icon: BarChart3 },
- { id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain },
- { id: "result-mnty-plan", label: "Plan Design", icon: Brain },
- { id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain },
+ { id: "result-mnty-all", label: "全部", icon: BarChart3 },
+ { id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
+ { id: "result-mnty-plan", label: "规划设计", icon: Brain },
+ { id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
],
},
{
id: "result-effect-pg",
- label: "Effect PG Results",
+ label: "效果PG结果",
icon: TrendingUp,
children: [
- { id: "result-effect-pg-all", label: "All", icon: BarChart3 },
- { id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp },
- { id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp },
- { id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp },
- { id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp },
+ { id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
+ { id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
+ { id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
+ { id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
+ { id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
],
},
{
id: "result-xk",
- label: "XK Results",
+ label: "XK结果",
icon: Compass,
children: [
- { id: "result-xk-all", label: "All", icon: BarChart3 },
- { id: "result-xk-jc", label: "Task JC", icon: Compass },
- { id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass },
+ { id: "result-xk-all", label: "全部", icon: BarChart3 },
+ { id: "result-xk-jc", label: "任务JC", icon: Compass },
+ { id: "result-xk-dispatch", label: "任务调度", icon: Compass },
],
},
{
id: "result-dq",
- label: "DQ Results",
+ label: "DQ结果",
icon: Database,
children: [
- { id: "result-dq-all", label: "All", icon: BarChart3 },
- { id: "result-dq-audience", label: "Audience Analysis", icon: Database },
- { id: "result-dq-kb", label: "Knowledge Research", icon: Database },
- { id: "result-dq-trend", label: "Trend Research", icon: Database },
+ { id: "result-dq-all", label: "全部", icon: BarChart3 },
+ { id: "result-dq-audience", label: "受众分析", icon: Database },
+ { id: "result-dq-kb", label: "知识研究", icon: Database },
+ { id: "result-dq-trend", label: "趋势研究", icon: Database },
],
},
{
id: "result-other",
- label: "Other Results",
+ label: "其他结果",
icon: MoreHorizontal,
children: [
- { id: "result-other-all", label: "All", icon: BarChart3 },
- { id: "result-other-general", label: "General Analysis", icon: MoreHorizontal },
- { id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal },
- { id: "result-other-other", label: "Other", icon: MoreHorizontal },
+ { id: "result-other-all", label: "全部", icon: BarChart3 },
+ { id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
+ { id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
+ { id: "result-other-other", label: "其他", icon: MoreHorizontal },
],
},
],
},
{
id: "resource-management",
- label: "Resource Management",
+ label: "资源管理",
icon: Database,
children: [
- { id: "resource-zz", label: "ZZ Resources", icon: FileBox },
- { id: "resource-network-actor", label: "Network Actor", icon: Network },
- { id: "resource-knowledge-base", label: "Knowledge Base", icon: Library },
- { id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale },
+ { id: "resource-zz", label: "ZZ资源", icon: FileBox },
+ { id: "resource-network-actor", label: "网络达人", icon: Network },
+ { id: "resource-knowledge-base", label: "知识库", icon: Library },
+ { id: "resource-evaluation-index", label: "评估指标", icon: Scale },
],
},
];
diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx
index 78f2445e..6e174624 100644
--- a/frontend-web/src/pages/PageRoutes.tsx
+++ b/frontend-web/src/pages/PageRoutes.tsx
@@ -4,10 +4,11 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes";
+import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
-const SIDEBAR_ENABLED = false;
+const SIDEBAR_ENABLED = true;
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -38,6 +39,7 @@ function PageLayout() {
/>
}
/>
+ } />
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
new file mode 100644
index 00000000..54839e33
--- /dev/null
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -0,0 +1,29 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+
+import QA from "@/strategy-components/components/qa/QA";
+import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
+import AgentQA from "@/strategy-components/components/qa/AgentQA";
+import CollaborativeQA from "@/strategy-components/components/qa/CollaborativeQA";
+import MopeChat from "@/strategy-components/components/qa/MopeChat";
+import TemplateEditor from "@/strategy-components/components/qa/TemplateEditor";
+
+export default function StrategyRoutes() {
+ return (
+
+
+ } />
+
+ {/* 问答中心 */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/AgentQA.tsx b/frontend-web/src/strategy-components/components/qa/AgentQA.tsx
new file mode 100644
index 00000000..4a0e626d
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/AgentQA.tsx
@@ -0,0 +1,575 @@
+import React, { useState } from 'react';
+import {
+ Search,
+ History,
+ Bot,
+ Sparkles,
+ Database,
+ Layers,
+ FileText,
+ ChevronRight,
+ ChevronLeft,
+ Cpu,
+ Target,
+ Zap,
+ CheckCircle,
+} from 'lucide-react';
+import { useTheme } from '../../../contexts/ThemeContext';
+import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
+
+interface Agent {
+ id: string;
+ name: string;
+ category: string;
+ tags: string[];
+ description: string;
+ skills: string[];
+ dataSources: string[];
+ functions: string[];
+ outputFields: string[];
+}
+
+interface Message {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ agentName?: string;
+ skills?: string[];
+ dataSources?: string[];
+ functions?: string[];
+ structuredOutput?: Record;
+}
+
+/**
+ * 智能体问答组件
+ * 基于特定智能体的专业能力进行问答,支持选择不同领域的智能体
+ */
+const AgentQA: React.FC = () => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ // 模拟智能体列表
+ const agents: Agent[] = [
+ {
+ id: 'audience-agent',
+ name: 'AudienceAgent',
+ category: '受众分析',
+ tags: ['受众分层', '画像抽取', '行为预测'],
+ description: '专业的受众分析智能体,能够识别目标受众特征并生成画像',
+ skills: ['受众分层', '核心受众识别', '画像抽取', '行为预测'],
+ dataSources: ['知识库', 'Y系统', 'A系统'],
+ functions: ['信息抽取器', '标签聚类器', '结构化输出器'],
+ outputFields: ['core_audience', 'key_traits', 'barriers', 'opportunities'],
+ },
+ {
+ id: 'brand-agent',
+ name: 'BrandAgent',
+ category: '品牌分析',
+ tags: ['品牌定位', '传播策略', '调性分析'],
+ description: '专注品牌建设与传播策略的智能体',
+ skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
+ dataSources: ['知识库', '行业数据库'],
+ functions: ['品牌诊断器', '策略生成器'],
+ outputFields: ['brand_position', 'communication_path', 'tone_guide'],
+ },
+ {
+ id: 'content-agent',
+ name: 'ContentAgent',
+ category: '内容创作',
+ tags: ['文案撰写', '创意生成', '多形式内容'],
+ description: '智能内容创作助手,支持多种形式的内容生成',
+ skills: ['文案撰写', '创意生成', '多形式内容', 'A/B测试文案'],
+ dataSources: ['知识库', '优秀案例库'],
+ functions: ['文案生成器', '风格适配器', '质量评估器'],
+ outputFields: ['headlines', 'body_copy', 'call_to_action', 'variants'],
+ },
+ {
+ id: 'channel-agent',
+ name: 'ChannelAgent',
+ category: '渠道规划',
+ tags: ['渠道选择', '投放策略', '效果评估'],
+ description: '专业的渠道分析与规划智能体',
+ skills: ['渠道选择', '投放策略规划', '效果评估', '预算分配'],
+ dataSources: ['Y系统', 'A系统', '行业数据'],
+ functions: ['渠道分析器', '投放优化器', 'ROI预测器'],
+ outputFields: ['recommended_channels', 'budget_allocation', 'expected_roi'],
+ },
+ ];
+
+ // 状态管理
+ // 当前选中的智能体
+ const [selectedAgent, setSelectedAgent] = useState(agents[0]);
+ // 智能体搜索关键词
+ const [searchQuery, setSearchQuery] = useState('');
+ // 聊天消息列表
+ const [messages, setMessages] = useState([]);
+ // 输入框内容
+ const [inputValue, setInputValue] = useState('');
+ // 加载状态
+ const [isLoading, setIsLoading] = useState(false);
+ // 左侧侧边栏折叠状态
+ const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
+
+ // 过滤智能体
+ const filteredAgents = agents.filter(agent =>
+ agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ agent.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ agent.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
+ );
+
+ /**
+ * 处理发送消息
+ * 发送用户消息并模拟智能体响应
+ */
+ const handleSend = () => {
+ if (!inputValue.trim()) return;
+
+ setIsLoading(true);
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ role: 'user',
+ content: inputValue,
+ timestamp: new Date(),
+ };
+
+ setMessages([...messages, userMessage]);
+ setInputValue('');
+
+ // 模拟AI响应
+ setTimeout(() => {
+ const response: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'assistant',
+ content: `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n**可能存在的阻力**:\n- 价格敏感度较高\n- 对品牌忠诚度有限\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`,
+ timestamp: new Date(),
+ agentName: selectedAgent.name,
+ skills: selectedAgent.skills,
+ dataSources: selectedAgent.dataSources,
+ functions: selectedAgent.functions,
+ structuredOutput: {
+ core_audience: '25-35岁都市白领',
+ key_traits: ['追求个性化', '注重社交认同', '品质导向'],
+ barriers: ['价格敏感', '品牌忠诚度有限'],
+ opportunities: ['社交媒体影响大', '口碑传播效果好'],
+ },
+ };
+
+ setMessages(prev => [...prev, response]);
+ setIsLoading(false);
+ }, 1500);
+ };
+
+ /**
+ * 处理停止操作
+ * 停止当前正在进行的智能体响应
+ */
+ const handleStop = () => {
+ setIsLoading(false);
+ };
+
+ /**
+ * 处理键盘事件
+ * 按Enter键发送消息(不按Shift)
+ */
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ /**
+ * 保存问答记录
+ */
+ const handleSaveRecord = () => {
+ alert('问答记录已保存');
+ };
+
+ /**
+ * 收藏当前对话
+ */
+ const handleCollect = () => {
+ alert('已添加到收藏');
+ };
+
+ return (
+
+ {/* 顶部工具栏 */}
+
+
+
+ 智能体问答
+
+
+
+
+ 当前智能体:
+
+
+ {selectedAgent.name}
+
+
+
+
+
+
+
+
+
+ {/* 主体内容区 */}
+
+ {/* 左侧智能体区 */}
+
+
+ {!isLeftSidebarCollapsed && (
+
+ 智能体配置
+
+ )}
+
+
+
+ {!isLeftSidebarCollapsed && (<>
+ {/* 智能体搜索 */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="搜索智能体..."
+ className={`w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none ${
+ isDark
+ ? 'bg-transparent text-white placeholder-slate-500'
+ : 'bg-transparent text-gray-800 placeholder-gray-400'
+ }`}
+ />
+
+
+
+ {/* 智能体列表 */}
+
+
+ 智能体列表
+
+
+ {filteredAgents.map((agent) => (
+
+ ))}
+
+
+
+ {/* 当前智能体能力摘要 */}
+
+
+ 能力摘要
+
+
+ {/* 核心职责 */}
+
+
+
+ 核心职责
+
+
+ {selectedAgent.description}
+
+
+
+ {/* 已启用技能 */}
+
+
+
+ 已启用技能
+
+
+ {selectedAgent.skills.map((skill, idx) => (
+
+ {skill}
+
+ ))}
+
+
+
+ {/* 可访问数据源 */}
+
+
+
+ 可访问数据源
+
+
+ {selectedAgent.dataSources.map((source, idx) => (
+
+ {source}
+
+ ))}
+
+
+
+ {/* 可调用功能 */}
+
+
+
+ 可调用功能
+
+
+ {selectedAgent.functions.map((func, idx) => (
+
+ {func}
+
+ ))}
+
+
+
+ >)}
+
+
+ {/* 中间对话区 */}
+
+ {/* 消息列表 */}
+
+ {messages.length === 0 ? (
+
}
+ title={`开始与${selectedAgent.name}对话`}
+ description="基于智能体专业能力获取精准回答"
+ />
+ ) : (
+ messages.map((msg) => (
+
(
+
+
+
本轮启用技能
+
+ {assistantMessage.skills?.map((skill, idx) => (
+
+ {skill}
+
+ ))}
+
+
+
+
+
本轮访问数据源
+
+ {assistantMessage.dataSources?.map((source, idx) => (
+
+ {source}
+
+ ))}
+
+
+
+
+
本轮调用功能
+
+ {assistantMessage.functions?.map((func, idx) => (
+
+ {func}
+
+ ))}
+
+
+
+ {assistantMessage.structuredOutput && (
+
+
结构化输出
+
+ {Object.entries(assistantMessage.structuredOutput).map(([key, value]) => (
+
+ {key}:
+
+ {Array.isArray(value) ? value.join(', ') : String(value)}
+
+
+ ))}
+
+
+ )}
+
+ )}
+ />
+ ))
+ )}
+
+ {isLoading && }
+
+
+ {/* 底部输入区 */}
+
+
+
+ {/* 右侧执行详情区 */}
+
+
+ 执行详情
+
+
+ {/* 本轮启用技能 */}
+
+
+
+ 本轮启用技能
+
+
+
+ {selectedAgent.skills.map((skill, idx) => (
+
+
+ {skill}
+
+ ))}
+
+
+
+
+ {/* 本轮访问数据源 */}
+
+
+
+ 本轮访问数据源
+
+
+
+ {selectedAgent.dataSources.map((source, idx) => (
+
+
+ {source}
+
+ ))}
+
+
+
+
+ {/* 本轮调用功能 */}
+
+
+
+ 本轮调用功能
+
+
+
+ {selectedAgent.functions.map((func, idx) => (
+
+
+ {func}
+
+ ))}
+
+
+
+
+ {/* 结构化输出字段 */}
+
+
+
+ 输出字段
+
+
+
+ {selectedAgent.outputFields.map((field, idx) => (
+
+
+ {field}
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default AgentQA;
diff --git a/frontend-web/src/strategy-components/components/qa/CollaborativeQA.tsx b/frontend-web/src/strategy-components/components/qa/CollaborativeQA.tsx
new file mode 100644
index 00000000..5d6bc91d
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/CollaborativeQA.tsx
@@ -0,0 +1,185 @@
+import React, { useState } from 'react';
+import { CheckCircle, ChevronLeft, ChevronRight, History, MessageCircle, Users } from 'lucide-react';
+import { useTheme } from '../../../contexts/ThemeContext';
+import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
+
+interface CollaborativeQAProps {}
+
+interface CollaborativeMessage {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ agentName?: string;
+}
+
+const collaborativeAgents = [
+ { id: 'goal-agent', name: 'GoalAgent', category: '目标规划', color: 'blue' },
+ { id: 'audience-agent', name: 'AudienceAgent', category: '受众分析', color: 'green' },
+ { id: 'risk-agent', name: 'RiskAgent', category: '风险识别', color: 'purple' },
+];
+
+/**
+ * 多智能体协同问答组件
+ * 多个智能体从不同视角联合分析问题,生成综合建议
+ * @param onNavigate - 页面导航函数
+ */
+const CollaborativeQA: React.FC = () => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const [messages, setMessages] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedAgents, setSelectedAgents] = useState(collaborativeAgents.map((agent) => agent.id));
+ const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
+
+ const activeAgents = collaborativeAgents.filter((agent) => selectedAgents.includes(agent.id));
+
+ const handleSend = () => {
+ if (!inputValue.trim()) return;
+
+ const userMessage: CollaborativeMessage = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ content: inputValue,
+ timestamp: new Date(),
+ };
+
+ setMessages((prev) => [...prev, userMessage]);
+ setInputValue('');
+ setIsLoading(true);
+
+ setTimeout(() => {
+ const agentNames = activeAgents.map((agent) => agent.name).join('、');
+ const assistantMessage: CollaborativeMessage = {
+ id: `assistant-${Date.now()}`,
+ role: 'assistant',
+ content: `已组织 ${agentNames || '默认协同组'} 完成一轮联合研判:\n\n1. GoalAgent 建议先明确任务目标与阶段性 KPI,避免讨论发散。\n2. AudienceAgent 认为应优先锁定核心受众画像,并补充行为触点数据。\n3. RiskAgent 提醒需要同步评估内容合规、舆情扩散和资源约束。\n\n综合建议:先形成“目标-受众-风险”三列表,再进入方案细化与执行拆解。`,
+ timestamp: new Date(),
+ agentName: '协同编排助手',
+ };
+ setMessages((prev) => [...prev, assistantMessage]);
+ setIsLoading(false);
+ }, 1800);
+ };
+
+ const handleStop = () => {
+ setIsLoading(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ const toggleAgent = (agentId: string) => {
+ setSelectedAgents((prev) =>
+ prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
+ );
+ };
+
+ return (
+
+
+
+
多智能体协同问答
+ 多角色联合研判、共识汇总与风险补充
+
+
+
+
+
+
+
+
+
+ {messages.length === 0 ? (
+ }
+ title="发起一次协同会商"
+ description="输入任务背景,多个智能体会从不同视角生成联合建议"
+ />
+ ) : (
+ messages.map((message) => (
+
+ ))
+ )}
+ {isLoading && }
+
+
+ alert('问答记录已保存')}
+ onCollect={() => alert('已添加到收藏')}
+ isLoading={isLoading}
+ placeholder="输入协同研判主题或任务背景..."
+ showUpload={false}
+ />
+
+
+
+ );
+};
+
+export default CollaborativeQA;
diff --git a/frontend-web/src/strategy-components/components/qa/GeneralQA.mock.ts b/frontend-web/src/strategy-components/components/qa/GeneralQA.mock.ts
new file mode 100644
index 00000000..e16d54e4
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/GeneralQA.mock.ts
@@ -0,0 +1,584 @@
+import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
+import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
+
+/**
+ * 解析单轮 RAG 接口的 `...` 流式文本。
+ *
+ * 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
+ * UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
+ * 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
+ */
+export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
+ let thinkingMarkdown = '';
+ let answerMarkdown = '';
+ let inThink = false;
+ let thinkingComplete = false;
+
+ const appendToken = (token: string) => {
+ let remaining = token;
+ while (remaining) {
+ if (inThink) {
+ const endIdx = remaining.indexOf('');
+ if (endIdx === -1) {
+ thinkingMarkdown += remaining;
+ break;
+ }
+ thinkingMarkdown += remaining.slice(0, endIdx);
+ inThink = false;
+ thinkingComplete = true;
+ remaining = remaining.slice(endIdx + ''.length);
+ } else {
+ const startIdx = remaining.indexOf('');
+ if (startIdx === -1) {
+ answerMarkdown += remaining;
+ break;
+ }
+ answerMarkdown += remaining.slice(0, startIdx);
+ inThink = true;
+ remaining = remaining.slice(startIdx + ''.length);
+ }
+ }
+ };
+
+ chunks.forEach((chunk) => appendToken(chunk.answer));
+
+ return {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown,
+ answerMarkdown,
+ thinkingComplete,
+ };
+};
+
+/**
+ * 构造单轮 RAG 的演示 trace。
+ *
+ * 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
+ * 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
+ */
+export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
+ { answer: '' },
+ { answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
+ { answer: '先根据知识库召回内容组织核心流程:\n\n' },
+ { answer: '- 检索阶段:查找相关知识片段\n' },
+ { answer: '- 增强阶段:将证据注入提示词\n' },
+ { answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
+ { answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
+ { answer: '' },
+ { answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
+ { answer: '### 工作流程\n\n' },
+ { answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
+ { answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
+ { answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
+ { answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
+ { answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
+]);
+
+/**
+ * 构造多轮/深度检索的演示思考链。
+ *
+ * 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
+ * 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
+ */
+export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
+ // 初始化轮次数组,第一轮总是基础检索
+ const rounds = [
+ {
+ id: 'round-1', // 第一轮唯一标识
+ title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
+ thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
+ query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
+ retrievals: [ // 第一轮检索返回的资料列表
+ {
+ id: 'r1-1',
+ title: 'RAG技术白皮书',
+ content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
+ source: '知识库 / RAG技术白皮书.pdf',
+ },
+ {
+ id: 'r1-2',
+ title: 'LLM应用实践:检索增强生成',
+ content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
+ source: '知识库 / LLM应用实践.docx',
+ },
+ ],
+ },
+ ];
+
+ // 多轮模式和深度检索模式需要第二轮检索,补充更多细节
+ if (mode !== 'single-round') {
+ // 添加第二轮检索:补充多轮检索与重排机制的差异
+ rounds.push({
+ id: 'round-2', // 第二轮的唯一标识
+ title: '补充多轮检索与重排机制', // 本轮检索的主题
+ thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
+ query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
+ // 本轮检索返回的资料列表
+ retrievals: [
+ {
+ id: 'r2-1',
+ title: '高级RAG技术指南',
+ content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
+ source: '资料库 / 高级RAG技术指南.md',
+ },
+ {
+ id: 'r2-2',
+ title: 'RAG召回与重排最佳实践',
+ content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
+ source: '行业资料 / RAG最佳实践.html',
+ },
+ {
+ id: 'r2-3',
+ title: '检索增强系统评测报告',
+ content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
+ source: '评测库 / 检索增强系统评测报告.xlsx',
+ },
+ ],
+ });
+ }
+
+ // 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
+ if (mode === 'deep-search') {
+ rounds.push({
+ id: 'round-3', // 第三轮的唯一标识
+ title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
+ thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
+ query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
+ retrievals: [
+ {
+ id: 'r3-1',
+ title: '企业知识问答系统建设方案',
+ content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
+ source: '方案库 / 企业知识问答系统建设方案.pptx',
+ },
+ {
+ id: 'r3-2',
+ title: '生成式AI落地风险控制',
+ content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
+ source: '风控库 / 生成式AI落地风险控制.pdf',
+ },
+ ],
+ });
+ }
+
+ return {
+ title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
+ rounds,
+ finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
+ };
+};
+
+/**
+ * 构造流式多轮检索的初始空 trace。
+ *
+ * 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
+ * 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
+ */
+export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
+ title: '多轮检索思考过程',
+ rounds: [{
+ id: `round-${Date.now()}`,
+ title: '正在准备检索',
+ thoughtMarkdown: '',
+ query: '',
+ queryLoading: true,
+ retrievals: [],
+ }],
+ finalAnswerMarkdown: '',
+});
+
+/**
+ * 为对比模式创建左右面板的初始状态。
+ *
+ * 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
+ * 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
+ */
+export const createComparePane = (mode: QAMode): ComparePane => ({
+ mode,
+ loading: true,
+ singleRagTrace: mode === 'single-round'
+ ? {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '',
+ answerMarkdown: '',
+ thinkingComplete: false,
+ }
+ : undefined,
+ thinkingTrace: mode === 'single-round' ? undefined : createMultiRoundStreamTrace(),
+ sources: [],
+});
+
+/**
+ * 首屏演示消息。
+ *
+ * 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
+ * 拆分时完整保留原始 mock 数据,确保首屏体验不变。
+ */
+export const createMockSingleRagMessages = (): Message[] => ([
+ {
+ id: 'mock-user-code-preview',
+ role: 'user',
+ content: '帮我看一下 Markdown 代码块的展示效果',
+ timestamp: new Date(),
+ },
+ {
+ id: 'mock-assistant-code-preview',
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ singleRagTrace: {
+ title: '代码块渲染预览',
+ thinkingMarkdown: '这条模拟数据用于检查 MarkdownRenderer 的代码块样式:多行代码应该展示为带语言标签和复制按钮的彩色代码块,短单行代码例如 `const ok = true` 应该保持行内展示。',
+ answerMarkdown: [
+ '下面是几段模拟代码,用来预览不同语言的渲染效果。',
+ '',
+ '短单行代码:`const count = 1`,这一段应该显示为行内代码,而不是大代码块。',
+ '',
+ '### TSX 示例',
+ '',
+ '```tsx',
+ 'type UserCardProps = {',
+ ' name: string;',
+ ' role: string;',
+ '};',
+ '',
+ 'export function UserCard({ name, role }: UserCardProps) {',
+ ' return (',
+ ' ',
+ ' {name}
',
+ ' {role}
',
+ ' ',
+ ' );',
+ '}',
+ '```',
+ '',
+ '### Vue 示例',
+ '',
+ '```vue',
+ '',
+ '',
+ '',
+ ' ',
+ '',
+ '```',
+ '',
+ '### Java 示例',
+ '',
+ '```java',
+ 'public class StrategyScore {',
+ ' public static int normalize(int value) {',
+ ' return Math.max(0, Math.min(100, value));',
+ ' }',
+ '',
+ ' public static void main(String[] args) {',
+ ' System.out.println(normalize(128));',
+ ' }',
+ '}',
+ '```',
+ '',
+ '引用按钮也顺便保留测试:[[1]]',
+ ].join('\n'),
+ thinkingComplete: true,
+ },
+ sources: [
+ {
+ title: 'MarkdownRenderer mock preview',
+ type: '本地模拟数据',
+ snippet: '用于预览 tsx、vue、java 代码块高亮,以及短单行代码的行内展示。',
+ source: 'GeneralQA.mock.ts',
+ time: '2026-04-30',
+ content: '这是一条本地模拟引用数据,用于测试 [[1]] 引用按钮点击后的详情展示。',
+ },
+ ],
+ },
+ {
+ id: 'mock-user-1',
+ role: 'user',
+ content: 'RAG 在企业知识问答里具体怎么落地?',
+ timestamp: new Date(),
+ },
+ {
+ id: 'mock-assistant-1',
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ singleRagTrace: {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
+ answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
+ thinkingComplete: true,
+ },
+ sources: [
+ {
+ title: '企业知识问答平台建设指南',
+ type: '知识库',
+ snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
+ source: '内部架构组',
+ time: '2026-03-18',
+ url: 'https://example.com/rag-enterprise-guide',
+ content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
+ },
+ {
+ title: 'RAG 重排实践报告',
+ type: '技术报告',
+ snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
+ source: 'NLP 团队',
+ time: '2026-01-09',
+ url: 'https://example.com/rag-rerank-report',
+ content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
+ },
+ {
+ title: '可追溯生成规范',
+ type: '规范',
+ snippet: '回答输出应绑定证据编号,便于用户核验。',
+ source: 'AI 治理委员会',
+ time: '2025-12-22',
+ url: 'https://example.com/traceable-generation-spec',
+ content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
+ },
+ ],
+ },
+ {
+ id: 'mock-user-2',
+ role: 'user',
+ content: '那单轮和多轮检索的差异是什么?',
+ timestamp: new Date(),
+ },
+ {
+ id: 'mock-assistant-2',
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ singleRagTrace: {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
+ answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
+ thinkingComplete: true,
+ },
+ sources: [
+ {
+ title: 'RAG 模式选型对比',
+ type: '白皮书',
+ snippet: '单轮偏性能,多轮偏完整性。',
+ source: '智能搜索实验室',
+ time: '2026-02-11',
+ url: 'https://example.com/rag-mode-comparison',
+ content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
+ },
+ {
+ title: '检索系统时延评测',
+ type: '评测',
+ snippet: '多轮策略会增加查询与重排调用次数。',
+ source: '平台性能组',
+ time: '2026-02-27',
+ url: 'https://example.com/rag-latency-benchmark',
+ content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
+ },
+ ],
+ },
+ {
+ id: 'mock-user-multi-1',
+ role: 'user',
+ content: '我想了解多轮检索的工作机制,以及它在大规模企业知识库中的实际表现如何?',
+ timestamp: new Date(),
+ },
+ {
+ id: 'mock-assistant-multi-1',
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ thinkingTrace: createThinkingTrace('multi-round'),
+ sources: [
+ {
+ title: '多轮RAG检索白皮书',
+ type: '知识库',
+ snippet: '多轮检索通过逐轮改写查询、逐步聚焦,能在复杂问题上比单轮检索提升30%以上的召回覆盖。',
+ source: '内部架构组',
+ time: '2026-04-15',
+ content: '多轮检索的核心在于每轮检索后根据已召回内容改写查询,逐步缩小语义范围,最终实现精准覆盖。',
+ },
+ {
+ title: '企业知识库检索时延对比',
+ type: '技术报告',
+ snippet: '两轮检索在10万级文档库中平均增加40%延迟,但答案完整度提升显著。',
+ source: 'NLP团队',
+ time: '2026-03-22',
+ content: '实测数据:单轮检索平均时延1.2s,两轮检索平均时延1.7s,三轮检索平均时延2.3s。建议根据SLA选择合适轮次。',
+ },
+ ],
+ },
+ {
+ id: 'mock-user-multi-2',
+ role: 'user',
+ content: '企业知识问答系统的多轮检索需要做哪些工程化优化?',
+ timestamp: new Date(),
+ },
+ {
+ id: 'mock-assistant-multi-2',
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ thinkingTrace: {
+ title: '多轮检索工程化优化思考过程',
+ rounds: [
+ {
+ id: 'round-opt-1',
+ title: '分析工程化优化方向',
+ thoughtMarkdown: '用户关心的是工程层面的优化,需要从以下几个维度回答:\n\n1. 查询改写策略的缓存与复用\n2. 检索结果去重与增量更新\n3. 并发检索与超时控制\n4. 检索质量的在线评估机制',
+ query: '多轮检索 工程化优化 查询改写 缓存 去重 并发',
+ retrievals: [
+ {
+ id: 'ro1-1',
+ title: '查询改写缓存策略实践',
+ content: '对于相似查询,可通过向量相似度匹配复用上一轮的改写结果,减少调用大模型的次数和延迟。',
+ source: '知识库 / 查询优化实践指南.md',
+ },
+ {
+ id: 'ro1-2',
+ title: '检索结果去重方案',
+ content: '多轮检索容易出现重复文档,需基于文档ID或内容哈希进行去重,并在每轮检索时排除已召回文档。',
+ source: '知识库 / 检索去重技术方案.pdf',
+ },
+ ],
+ },
+ {
+ id: 'round-opt-2',
+ title: '检索性能与质量保障',
+ thoughtMarkdown: '第二轮聚焦性能优化:\n\n1. 并发检索多个知识库以降低延迟\n2. 设置合理的检索超时防止长尾请求\n3. 引入在线相关性评分作为质量护栏',
+ query: '并发检索 超时控制 在线评估 知识库评分',
+ retrievals: [
+ {
+ id: 'ro2-1',
+ title: '知识库并发检索架构设计',
+ content: '采用异步并发查询多个索引,配合全局超时(建议3-5s),保证用户体验的同时最大化召回。',
+ source: '架构库 / 并发检索设计文档.docx',
+ },
+ {
+ id: 'ro2-2',
+ title: '检索质量在线评估方法',
+ content: '通过小模型对每轮召回结果做相关性打分,低于阈值时自动触发查询改写或切换检索策略。',
+ source: '评测库 / 在线质量评估方案.md',
+ },
+ {
+ id: 'ro2-3',
+ title: '多轮检索时延优化总结',
+ content: '工程实践中建议两轮为上限,超过时需评估能否合并为单轮。首批结果可先展示,第二轮做增量补充。',
+ source: '性能库 / 时延优化白皮书.pdf',
+ },
+ ],
+ },
+ ],
+ finalAnswerMarkdown: '多轮检索的工程化优化主要围绕四个方向:\n\n### 1. 查询改写优化\n- 缓存相似查询的改写结果,通过向量相似度匹配复用\n- 降低大模型调用频次,减少时延和成本\n\n### 2. 结果去重与增量更新\n- 基于文档ID或内容哈希去重\n- 每轮检索时排除已召回文档,避免重复返回\n- 增量模式:先返回首批结果,后续轮次做补充\n\n### 3. 并发与超时控制\n- 异步并发查询多个知识库索引\n- 设置全局超时(建议3-5秒),避免长尾请求\n- 首批结果到达后立即展示,后续增量追加\n\n### 4. 在线质量评估\n- 用小模型对召回结果做相关性打分\n- 低于阈值时自动触发查询改写或切换策略\n- 持续监控命中率和引用准确率\n\n> 建议:实际工程中两轮检索已能覆盖大多数复杂场景,超过两轮应重新评估是否可以通过更好的查询改写来合并。',
+ },
+ sources: [
+ {
+ title: '多轮检索工程化最佳实践',
+ type: '技术方案',
+ snippet: '两轮检索配合增量更新,可在延迟和完整度之间取得最优平衡。',
+ source: '工程团队',
+ time: '2026-05-01',
+ content: '总结了过去一年的多轮检索优化经验,涵盖查询改写缓存、结果去重、并发检索和在线评估四大方向。',
+ },
+ ],
+ },
+]);
+
+/**
+ * 将多轮检索接口返回的 references 项映射为通用引用来源。
+ */
+export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
+ title: item.title || '未命名来源',
+ type: '参考资料',
+ snippet: item.snippet || item.content || '',
+ content: item.content || item.snippet || '',
+});
+
+/**
+ * 将知识库单轮接口返回的 docs 映射为页面引用来源。
+ */
+export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
+ const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
+
+ return normalizedDocs.map((item, idx) => {
+ if (typeof item === 'string') {
+ return {
+ title: `参考资料 ${idx + 1}`,
+ type: '知识库',
+ snippet: item,
+ source: '',
+ time: '',
+ content: item,
+ };
+ }
+
+ return {
+ title: item.title || item.source || `参考资料 ${idx + 1}`,
+ type: '知识库',
+ snippet: item.snippet || item.content || item.url || '',
+ source: item.source || item.title || '',
+ time: '',
+ url: item.url,
+ content: item.content || item.snippet || '',
+ };
+ });
+};
+
+/**
+ * 从流式文本中提取完整 JSON 对象。
+ *
+ * SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
+ * 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
+ */
+export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
+ const objects: string[] = [];
+ let depth = 0;
+ let stringQuote: '"' | "'" | null = null;
+ let escaped = false;
+ let start = -1;
+
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i];
+
+ if (stringQuote) {
+ if (escaped) {
+ escaped = false;
+ } else if (ch === '\\') {
+ escaped = true;
+ } else if (ch === stringQuote) {
+ stringQuote = null;
+ }
+ continue;
+ }
+
+ if (ch === '"' || ch === "'") {
+ stringQuote = ch;
+ continue;
+ }
+
+ if (ch === '{') {
+ if (depth === 0) start = i;
+ depth += 1;
+ continue;
+ }
+
+ if (ch === '}') {
+ depth -= 1;
+ if (depth === 0 && start !== -1) {
+ objects.push(raw.slice(start, i + 1));
+ start = -1;
+ }
+ }
+ }
+
+ if (depth > 0 && start !== -1) {
+ return { objects, rest: raw.slice(start) };
+ }
+
+ return { objects, rest: '' };
+};
diff --git a/frontend-web/src/strategy-components/components/qa/GeneralQA.tsx b/frontend-web/src/strategy-components/components/qa/GeneralQA.tsx
new file mode 100644
index 00000000..e2542628
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/GeneralQA.tsx
@@ -0,0 +1,2202 @@
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ GitCompare,
+ MessageSquare,
+ CheckCircle,
+ AlertCircle,
+ ArrowDown,
+ ExternalLink,
+ Square,
+ X,
+} from 'lucide-react';
+import { Dialog, Select, Slider } from 'tdesign-react';
+import { useTheme } from '@/contexts/ThemeContext';
+import { ChatMessage, EmptyChatState, QAAvatar, QAComposer, QASingleRagTrace, QAThinkingRound, QAThinkingTrace, RetrievalThinkingTrace, SingleRagAnswer, TypingIndicator } from './QACommon';
+import GeneralQAHeader from './GeneralQAHeader';
+import GeneralQAConfigSidebar from './GeneralQAConfigSidebar';
+import type { ComparePane, CompareResult, KnowledgeBaseChunk, Message, QAMode, SourceItem, ToolCallData, ViewMode } from './GeneralQA.types';
+import {
+ createComparePane,
+ createMockSingleRagMessages,
+ createMultiRoundStreamTrace,
+ createThinkingTrace,
+ extractJsonObjects,
+ mapDocsToSources,
+ mapReferenceToSource,
+ parseSingleRagStream,
+} from './GeneralQA.mock';
+
+/**
+ * 通用问答组件
+ * 支持单轮RAG、多轮RAG、深度检索及结果对比功能
+ * @param onNavigate - 页面导航函数
+ * @param initialMode - 初始模式(可选)
+ */
+const GeneralQA: React.FC = () => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ // 状态管理
+ // 视图模式:单一模式或对比模式
+ const [viewMode, setViewMode] = useState('single');
+ // 问答模式:单轮RAG、多轮RAG、深度检索
+ const [qaMode, setQaMode] = useState('single-round');
+ // 对比模式下的左侧模式
+ const [compareLeftMode, setCompareLeftMode] = useState('single-round');
+ // 对比模式下的右侧模式
+ const [compareRightMode, setCompareRightMode] = useState('multi-round');
+
+ // 知识范围设置
+ const [knowledgeScope, setKnowledgeScope] = useState<'all' | 'specific-kb' | 'specific-domain'>('all');
+
+ // 回答偏好设置
+ const [answerStyle, setAnswerStyle] = useState<'concise' | 'detailed'>('detailed');
+ const [showSources, setShowSources] = useState(true);
+ const [showProcess, setShowProcess] = useState(false);
+
+ // 对比设置
+ const [compareLayout, setCompareLayout] = useState<'horizontal' | 'vertical'>('horizontal');
+ const [autoSummarizeDiff, setAutoSummarizeDiff] = useState(true);
+ const [recommendBetter, setRecommendBetter] = useState(true);
+
+ // 消息和结果状态
+ // `messages` 驱动单一模式聊天区;`compareResult` 驱动对比模式左右面板。
+ // `isPreConnectLoading` 表示请求已发起但还没拿到可展示的 assistant 消息,用于显示“正在连接/生成”的占位状态。
+ const [messages, setMessages] = useState([]);
+ // 用户当前输入的文本内容,用于绑定输入框
+ const [inputValue, setInputValue] = useState('');
+ // 是否正在处理请求(包含真实请求和模拟请求)
+ const [isLoading, setIsLoading] = useState(false);
+ // 是否处于"连接中"状态,表示已发起请求但还未收到可展示的响应
+ const [isPreConnectLoading, setIsPreConnectLoading] = useState(false);
+ // 存储发生的错误信息,用于显示错误提示
+ const [error, setError] = useState(null);
+ // 对比模式下的结果数据,包含左右两侧的回答和来源信息
+ const [compareResult, setCompareResult] = useState(null);
+ // 对比模式下用户输入的问题内容,用于左右对比时的统一输入
+ const [compareUserInput, setCompareUserInput] = useState('');
+ // 是否折叠左侧配置面板,true 为折叠,false 为展开
+ const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
+ // 是否启用自动滚动,当用户手动滚动时会自动关闭
+ const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
+ // 是否显示"滚动到底部"按钮,当有未读消息且用户已滚动时显示
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+ // 当前选中的引用来源,用于显示引用详情弹窗
+ const [selectedCitation, setSelectedCitation] = useState(null);
+ const [editingMessageId, setEditingMessageId] = useState(null);
+ const [editingThinking, setEditingThinking] = useState('');
+ const [editingAnswer, setEditingAnswer] = useState('');
+ // 模型参数设置弹窗状态。
+ // 这些值会进入单轮 RAG 的真实请求体;模拟流仍复用同一套 UI,方便接口恢复后无缝切换。
+ const [isModelSettingsOpen, setIsModelSettingsOpen] = useState(false);
+ const [modelName, setModelName] = useState('OpenAI');
+ const [scoreThreshold, setScoreThreshold] = useState(0.5);
+ const [temperature, setTemperature] = useState(0.3);
+
+ // 当模型正在生成时,如果用户切换模式或跳转页面,先把原动作暂存到 ref,待用户确认停止后再执行。
+ // 使用 ref 而不是 state 可以避免弹窗确认过程触发额外渲染,也不会被闭包中的旧状态影响。
+ const pendingActionRef = useRef<(() => void) | null>(null);
+
+ // 防止 Enter keydown 和按钮 click 在 React 状态更新前重复触发提交。
+ const submitLockRef = useRef(false);
+
+ // 真实流式请求的 reader 句柄,用于“停止生成”时取消浏览器流读取。
+ const readerRef = useRef | null>(null);
+ // 对比模式会同时启动左右两路请求,因此分别保存 reader,保证可以单独停止某一侧生成。
+ const compareReaderRef = useRef<{
+ left: ReadableStreamDefaultReader | null;
+ right: ReadableStreamDefaultReader | null;
+ }>({ left: null, right: null });
+ // 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。
+ const taskIdRef = useRef(null);
+
+ // 本地模拟流没有真实 reader,因此用停止标记在异步循环中主动中断。
+ const mockSingleRoundStoppedRef = useRef(false);
+ const mockMultiRoundStoppedRef = useRef(false);
+ const chatListRef = useRef(null);
+
+ // 检索过程展示状态
+ const [searchProcess, setSearchProcess] = useState({
+ currentMode: '单轮RAG',
+ rounds: 1,
+ hits: 5,
+ queryRewrite: '',
+ });
+
+ /**
+ * 处理单轮 RAG 问答请求
+ *
+ * 单轮 RAG 的处理流程:
+ * 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
+ * 2. API 返回 SSE 流式响应,包含多个 JSON 对象
+ * 3. 逐个解析流式 JSON,提取 answer 和 docs 字段
+ * 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown
+ * 5. 实时更新消息来源(sources)和检索统计(searchProcess)
+ *
+ * @param query - 用户输入的问题文本
+ */
+ const handleSingleRoundRun = async (query: string) => {
+ // 清空之前的错误信息
+ setError(null);
+ // 标记开始加载,禁用提交按钮防止重复点击
+ setIsLoading(true);
+ // 标记"连接中"状态,显示加载占位符
+ setIsPreConnectLoading(true);
+ // 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容
+ let assistantId = '';
+
+ // 标志位:当前处理的 token 是否处于 ... 标签内(解析思考过程)
+ let inThink = false;
+ // 标志位:是否已经遇到过 标签开始解析思考内容
+ let thinkStarted = false;
+
+ /**
+ * 将流式接收到的 token 应用到对应消息的 singleRagTrace 中
+ * 该函数会解析 token 中的 ... 标签,将思考过程和回答分开存储
+ * @param token - 从流中接收到的文本片段
+ */
+ const applyAnswerToken = (token: string) => {
+ setMessages((prev) => prev.map((msg) => {
+ // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息
+ if (msg.id !== assistantId || !msg.singleRagTrace) return msg;
+
+ // 获取当前消息的思考和回答内容
+ let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown;
+ let answerMarkdown = msg.singleRagTrace.answerMarkdown;
+ let thinkingComplete = msg.singleRagTrace.thinkingComplete || false;
+ // 剩余未处理的 token 内容
+ let remaining = token;
+
+ // 循环处理所有 token,直到处理完毕
+ while (remaining) {
+ // 当前处于 标签内(解析思考过程),查找结束标签
+ if (inThink) {
+ const endIdx = remaining.indexOf('');
+ if (endIdx === -1) {
+ thinkingMarkdown += remaining;
+ remaining = '';
+ } else {
+ thinkingMarkdown += remaining.slice(0, endIdx);
+ remaining = remaining.slice(endIdx + ''.length);
+ inThink = false;
+ thinkingComplete = true;
+ }
+ } else {
+ const startIdx = remaining.indexOf('');
+ if (startIdx === -1) {
+ answerMarkdown += remaining;
+ remaining = '';
+ } else {
+ answerMarkdown += remaining.slice(0, startIdx);
+ remaining = remaining.slice(startIdx + ''.length);
+ inThink = true;
+ thinkStarted = true;
+ }
+ }
+ }
+
+ // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段
+ return {
+ ...msg,
+ singleRagTrace: {
+ ...msg.singleRagTrace,
+ // 更新思考和回答的 markdown 内容
+ thinkingMarkdown,
+ answerMarkdown,
+ // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志
+ thinkingComplete: thinkStarted ? thinkingComplete : true,
+ },
+ };
+ }));
+ };
+
+ try {
+ // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
+ const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ // 请求体包含查询内容、知识库名称、检索参数、模型配置等
+ body: JSON.stringify({
+ query, // 用户输入的问题
+ knowledge_base_name: [], // 知识库名称列表,空数组表示使用所有知识库
+ top_k: 5, // 返回最相似的 5 条结果
+ score_threshold: scoreThreshold, // 相似度阈值,过滤低质量检索结果
+ history: [], // 对话历史,用于多轮对话
+ stream: true, // 启用流式响应
+ model_name: modelName, // 使用的模型名称
+ temperature, // 生成温度,控制随机性
+ prompt_name: 'default', // 使用的提示词模板
+ local_doc_url: false, // 是否返回本地文档 URL
+ }),
+ });
+
+ // 检查 HTTP 响应状态
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ // 确保响应体存在(SSE 流需要 body)
+ if (!response.body) {
+ throw new Error('Response body is null');
+ }
+ // 收到有效响应后,关闭"连接中"状态,开始显示实际内容
+ setIsPreConnectLoading(false);
+
+ // 生成唯一 ID 用于标识这条 assistant 消息
+ assistantId = `assistant-${Date.now()}`;
+ // 初始化思考过程追踪对象
+ const initialTrace: QASingleRagTrace = {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '', // 思考过程内容
+ answerMarkdown: '', // 最终回答内容
+ thinkingComplete: false, // 思考是否完成
+ };
+ // 将 assistant 消息添加到消息列表中
+ setMessages((prev) => [...prev, {
+ id: assistantId,
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ singleRagTrace: initialTrace,
+ sources: [], // 初始为空,随着检索结果更新
+ }]);
+
+ // 获取响应体的流式读取器
+ const reader = response.body.getReader();
+ // 保存 reader 引用,用于"停止生成"功能
+ readerRef.current = reader;
+ // 创建文本解码器,将二进制数据转换为字符串
+ const decoder = new TextDecoder();
+ // buffer 用于暂存未完成的 JSON 片段
+ let buffer = '';
+
+ try {
+ // 持续读取流数据,直到流结束
+ while (true) {
+ // 读取下一个数据块
+ const { done, value } = await reader.read();
+ // done 为 true 时表示流已结束
+ if (done) break;
+
+ // 将二进制数据解码并追加到 buffer
+ buffer += decoder.decode(value, { stream: true });
+ // 从 buffer 中提取完整的 JSON 对象
+ const extracted = extractJsonObjects(buffer);
+ // 剩余无法解析的内容保留到下次处理
+ buffer = extracted.rest;
+
+ // 遍历所有提取到的 JSON 对象
+ for (const jsonText of extracted.objects) {
+ try {
+ // 解析知识库返回的 JSON 片段
+ const parsed = parseKnowledgeBaseChunk(jsonText);
+ // 如果有回答内容,追加到消息的 answerMarkdown
+ if (typeof parsed.answer === 'string') {
+ applyAnswerToken(parsed.answer);
+ }
+ // 如果有检索文档,更新消息的来源信息
+ if (parsed.docs) {
+ // 将 docs 转换为 sources 格式
+ const mappedDocs = mapDocsToSources(parsed.docs);
+ // 更新检索统计信息
+ setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length }));
+ // 更新消息的来源列表
+ setMessages((prev) => prev.map((msg) => (
+ msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg
+ )));
+ }
+ } catch (parseError) {
+ // 解析错误不影响主流程,记录日志继续处理
+ console.error('单轮RAG流式JSON解析错误:', parseError);
+ }
+ }
+ }
+ } finally {
+ // 流读取完成后,释放 reader 锁并清空引用
+ if (readerRef.current) {
+ readerRef.current.releaseLock();
+ readerRef.current = null;
+ }
+ }
+ } catch (runError) {
+ // 请求失败处理
+ console.error('单轮RAG请求失败:', runError);
+ setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
+ } finally {
+ // 无论成功还是失败,都要重置加载状态和解锁提交
+ setIsPreConnectLoading(false);
+ setIsLoading(false);
+ submitLockRef.current = false;
+ }
+ };
+
+ /**
+ * 解析类 JavaScript 对象字符串中的字符串值。
+ *
+ * 后端单轮 RAG 流有时不是严格 JSON,而是 `{answer: '...'}` 这种对象片段。
+ * 该函数只负责把单/双引号包裹的 answer 内容还原成普通字符串,保留换行和转义字符。
+ */
+ const parseLooseStringValue = (rawValue: string): string => {
+ try {
+ return JSON.parse(rawValue) as string;
+ } catch {
+ const trimmedValue = rawValue.trim();
+ if (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")) {
+ return trimmedValue
+ .slice(1, -1)
+ .replace(/\\n/g, '\n')
+ .replace(/\\r/g, '\r')
+ .replace(/\\t/g, '\t')
+ .replace(/\\'/g, "'")
+ .replace(/\\\\/g, '\\');
+ }
+ throw new Error(`无法解析字符串值: ${rawValue}`);
+ }
+ };
+
+ const parseLooseValue = (rawValue: string): unknown => {
+ try {
+ return JSON.parse(rawValue);
+ } catch {
+ return parseLooseStringValue(rawValue);
+ }
+ };
+
+ /**
+ * 将单轮知识库流中的一个对象片段转换为统一数据结构。
+ *
+ * 兼容两类返回:
+ * - 严格 JSON:`{"answer":"..."}` / `{"docs":[...]}`
+ * - 宽松对象:`{answer: '...'}` / `{docs: [...]}`
+ *
+ * 真实接口和本地模拟都走同一个解析函数,确保联调时只需要替换数据源,不需要改 UI 更新逻辑。
+ */
+ const parseKnowledgeBaseChunk = (jsonText: string): KnowledgeBaseChunk => {
+ try {
+ return JSON.parse(jsonText) as KnowledgeBaseChunk;
+ } catch {
+ const objectText = jsonText.trim();
+ const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
+ if (answerMatch) {
+ const rawValue = answerMatch[1].trim();
+ return { answer: parseLooseStringValue(rawValue) };
+ }
+
+ const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
+ if (docsMatch) {
+ return { docs: parseLooseValue(docsMatch[1]) as KnowledgeBaseChunk['docs'] };
+ }
+
+ const normalizedText = objectText.replace(/([{,]\s*)(answer|docs)(\s*:)/g, '$1"$2"$3');
+ return JSON.parse(normalizedText) as KnowledgeBaseChunk;
+ }
+ };
+
+ const sleep = (ms: number) => new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+
+ /**
+ * 生成单轮 RAG 的本地模拟原始流。
+ *
+ * 返回值刻意模拟接口的“对象字符串片段”形态,例如:
+ * `{answer: ',并附'}{answer: '带引用'}` 和 `{docs: [...]}`。
+ * 调用方仍通过 `extractJsonObjects` 拆包,再通过 `parseKnowledgeBaseChunk` 解析,
+ * 这样可以覆盖粘包、连续对象和 docs 延迟到达等真实联调场景。
+ */
+ const createMockSingleRoundStream = (query: string): string[] => {
+ const answer = `用户正在使用单轮 RAG 提问:“${query}”。\n\n先从知识库里定位与问题最相关的片段,再把证据组织成可追溯的回答。\n我已基于模拟知识库完成检索,并按单轮 RAG 的方式生成回答。\n\n### 回答要点\n\n1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n当前内容是本地模拟的流式返回,用来验证一个一个 \`{answer: '模型输出'}\` 推送时的打字机效果。`;
+
+ const docs: KnowledgeBaseChunk['docs'] = [
+ {
+ title: '单轮 RAG 接入说明',
+ content: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。',
+ source: '知识库 / RAG 接入说明.md',
+ snippet: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。',
+ },
+ {
+ title: '知识库问答接口协议',
+ content: "流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段;结束或中间节点可携带 docs 参考资料。",
+ source: '接口文档 / knowledge_base_chat.json',
+ snippet: '流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段。',
+ },
+ {
+ title: '引用展示规范',
+ content: '回答中的引用编号应与 docs 顺序保持一致,前端可将 docs 映射为来源卡片供用户查看。',
+ source: '产品规范 / 引用展示规范.docx',
+ snippet: '回答中的引用编号应与 docs 顺序保持一致。',
+ },
+ ];
+
+ const rawChunks: string[] = [];
+ const toSingleQuotedValue = (value: string) => `'${value
+ .replace(/\\/g, '\\\\')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\t/g, '\\t')
+ .replace(/'/g, "\\'")}'`;
+ let pendingObjectText = '';
+ const pushAnswerObject = (answerToken: string) => {
+ const objectText = `{answer: ${toSingleQuotedValue(answerToken)}}`;
+ if (pendingObjectText) {
+ rawChunks.push(`${pendingObjectText}${objectText}`);
+ pendingObjectText = '';
+ return;
+ }
+ if (answerToken === ',并附') {
+ pendingObjectText = objectText;
+ return;
+ }
+ rawChunks.push(objectText);
+ };
+ const thinkStart = '';
+ const thinkEnd = '';
+ const startIndex = answer.indexOf(thinkStart);
+ const endIndex = answer.indexOf(thinkEnd);
+
+ if (startIndex === -1 || endIndex === -1) {
+ for (let index = 0; index < answer.length; index += 3) {
+ pushAnswerObject(answer.slice(index, index + 3));
+ }
+ } else {
+ const beforeThink = answer.slice(0, startIndex);
+ const thinkingText = answer.slice(startIndex + thinkStart.length, endIndex);
+
+ if (beforeThink) pushAnswerObject(beforeThink);
+ pushAnswerObject(thinkStart);
+ for (let index = 0; index < thinkingText.length; index += 3) {
+ pushAnswerObject(thinkingText.slice(index, index + 3));
+ }
+ pushAnswerObject(thinkEnd);
+ const finalAnswerTokens = [
+ '我已基于模拟知识库完成检索',
+ ',并附',
+ '带引用',
+ '带引用',
+ '。\n\n### 回答要点\n\n',
+ '1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n',
+ '2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n',
+ '3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n',
+ '当前内容是本地模拟的对象字符串流式返回,用来验证连续多个 `{answer: \'模型输出\'}` 和 `{docs: ...}` 的处理效果。',
+ ];
+ finalAnswerTokens.forEach(pushAnswerObject);
+ }
+
+ if (pendingObjectText) {
+ rawChunks.push(pendingObjectText);
+ }
+ rawChunks.push(`{docs: ${JSON.stringify(docs)}}`);
+ return rawChunks;
+ };
+
+ /**
+ * 执行单轮 RAG 本地模拟。
+ *
+ * 模拟过程与真实接口保持一致:先创建空 assistant 消息,再持续解析对象字符串流,
+ * answer 片段进入打字机效果,docs 片段映射为引用来源。
+ */
+ const handleMockSingleRoundRun = async (query: string) => {
+ setError(null);
+ setIsLoading(true);
+ setIsPreConnectLoading(true);
+ mockSingleRoundStoppedRef.current = false;
+
+ const assistantId = `assistant-${Date.now()}`;
+ let inThink = false;
+ let thinkStarted = false;
+
+ const applyAnswerToken = (token: string) => {
+ setMessages((prev) => prev.map((msg) => {
+ if (msg.id !== assistantId || !msg.singleRagTrace) return msg;
+
+ let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown;
+ let answerMarkdown = msg.singleRagTrace.answerMarkdown;
+ let thinkingComplete = msg.singleRagTrace.thinkingComplete || false;
+ let remaining = token;
+
+ while (remaining) {
+ if (inThink) {
+ const endIdx = remaining.indexOf('');
+ if (endIdx === -1) {
+ thinkingMarkdown += remaining;
+ remaining = '';
+ } else {
+ thinkingMarkdown += remaining.slice(0, endIdx);
+ remaining = remaining.slice(endIdx + ''.length);
+ inThink = false;
+ thinkingComplete = true;
+ }
+ } else {
+ const startIdx = remaining.indexOf('');
+ if (startIdx === -1) {
+ answerMarkdown += remaining;
+ remaining = '';
+ } else {
+ answerMarkdown += remaining.slice(0, startIdx);
+ remaining = remaining.slice(startIdx + ''.length);
+ inThink = true;
+ thinkStarted = true;
+ }
+ }
+ }
+
+ return {
+ ...msg,
+ singleRagTrace: {
+ ...msg.singleRagTrace,
+ thinkingMarkdown,
+ answerMarkdown,
+ thinkingComplete: thinkStarted ? thinkingComplete : true,
+ },
+ };
+ }));
+ };
+
+ try {
+ await sleep(300);
+ if (mockSingleRoundStoppedRef.current) return;
+ setIsPreConnectLoading(false);
+
+ const initialTrace: QASingleRagTrace = {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '',
+ answerMarkdown: '',
+ thinkingComplete: false,
+ };
+ setMessages((prev) => [...prev, {
+ id: assistantId,
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ singleRagTrace: initialTrace,
+ sources: [],
+ }]);
+
+ const streamChunks = createMockSingleRoundStream(query);
+ let buffer = '';
+ for (const rawChunk of streamChunks) {
+ if (mockSingleRoundStoppedRef.current) break;
+
+ buffer += rawChunk;
+ const extracted = extractJsonObjects(buffer);
+ buffer = extracted.rest;
+
+ for (const jsonText of extracted.objects) {
+ try {
+ const chunk = parseKnowledgeBaseChunk(jsonText);
+ if (typeof chunk.answer === 'string') {
+ applyAnswerToken(chunk.answer);
+ await sleep(45);
+ }
+ if (chunk.docs) {
+ const mappedDocs = mapDocsToSources(chunk.docs);
+ setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length }));
+ setMessages((prev) => prev.map((msg) => (
+ msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg
+ )));
+ }
+ } catch (parseError) {
+ console.error('单轮RAG模拟流式JSON解析错误:', parseError);
+ }
+ }
+ }
+ } catch (runError) {
+ console.error('单轮RAG模拟流失败:', runError);
+ setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
+ } finally {
+ setIsPreConnectLoading(false);
+ setIsLoading(false);
+ mockSingleRoundStoppedRef.current = false;
+ submitLockRef.current = false;
+ }
+ };
+
+ /**
+ * 处理多轮真实接口里的工具调用事件。
+ *
+ * `show_text` 用于追加思考或最终回答;`local_search` / `tavily_search` 用于把检索结果填入最近一轮。
+ * 这里保持事件到 UI trace 的单向映射,避免请求层和展示层互相耦合。
+ */
+ const handleToolCall = (toolData: ToolCallData, assistantId: string) => {
+ setMessages((prevMessages) => prevMessages.map((msg) => {
+ if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg;
+
+ const trace = { ...msg.thinkingTrace, rounds: [...msg.thinkingTrace.rounds] };
+ const lastRound = trace.rounds[trace.rounds.length - 1];
+
+ switch (toolData.tool_name) {
+ case 'show_text': {
+ const chatText = toolData.tool_input.text || '';
+ if (chatText.includes('')) {
+ const thinkContent = chatText.replace('', '').replace('', '').trim();
+ trace.rounds.push({
+ id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ title: '思考中',
+ thoughtMarkdown: thinkContent,
+ query: '',
+ queryLoading: true,
+ retrievals: [],
+ });
+ } else {
+ trace.finalAnswerMarkdown += chatText;
+ }
+ break;
+ }
+ case 'tavily_search':
+ case 'local_search': {
+ if (toolData.tool_input.result && lastRound) {
+ try {
+ const result = JSON.parse(toolData.tool_input.result) as {
+ query?: string;
+ results?: Array<{ title?: string; content?: string; url?: string }>;
+ };
+
+ lastRound.query = result.query || lastRound.query;
+ lastRound.queryLoading = false;
+ lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索';
+ lastRound.retrievals = (result.results || []).map((item, idx) => ({
+ id: `${toolData.tool_name}-${idx}`,
+ title: item.title || '未命名结果',
+ content: item.content || '',
+ source: item.url || '',
+ }));
+ } catch (parseError) {
+ console.error('搜索结果JSON解析错误:', parseError);
+ }
+ }
+ break;
+ }
+ default:
+ break;
+ }
+
+ return {
+ ...msg,
+ thinkingTrace: trace,
+ };
+ }));
+ };
+
+ /**
+ * 构造多轮 RAG 模拟 trace 的初始骨架。
+ *
+ * 每一轮先展示 thought/query/loading,随后 `handleMockMultiRoundRun` 再按轮次填充 retrievals,
+ * 用来模拟“检索过程逐步展开”的体验。
+ */
+ const createMockMultiRoundTrace = (query: string): QAThinkingTrace => ({
+ title: '多轮检索思考过程',
+ rounds: [
+ {
+ id: `mock-round-1-${Date.now()}`,
+ title: '第一轮:理解问题并确定主检索方向',
+ thoughtMarkdown: `用户问题是:“${query}”。\n\n先判断问题核心意图,并抽取关键词:\n\n- 业务对象\n- 关键约束\n- 需要引用的制度或知识片段\n\n第一轮先用原始问题做宽召回,尽量覆盖相关资料。`,
+ query: `${query} 相关制度 知识库 处理流程`,
+ queryLoading: true,
+ retrievals: [],
+ },
+ {
+ id: `mock-round-2-${Date.now()}`,
+ title: '第二轮:基于首轮结果改写 Query',
+ thoughtMarkdown: '首轮检索获得了基础资料,但仍需要补充更精确的执行口径。\n\n本轮将问题改写为更适合检索的短查询,并优先召回流程、规范和案例类资料。',
+ query: `${query} 操作规范 适用条件 案例`,
+ queryLoading: true,
+ retrievals: [],
+ },
+ {
+ id: `mock-round-3-${Date.now()}`,
+ title: '第三轮:补充边界条件并交叉验证',
+ thoughtMarkdown: '前两轮已经覆盖主要结论,最后继续检索边界条件,避免回答过于绝对。\n\n重点确认:\n\n1. 是否存在例外场景\n2. 是否需要人工复核\n3. 引用资料之间是否一致',
+ query: `${query} 例外场景 风险点 人工复核`,
+ queryLoading: true,
+ retrievals: [],
+ },
+ ],
+ finalAnswerMarkdown: '',
+ });
+
+ // 多轮 RAG 每一轮对应一组检索结果;数组下标与 trace.rounds 的下标保持一致。
+ const mockMultiRoundRetrievals: QAThinkingRound['retrievals'][] = [
+ [
+ {
+ id: 'mock-multi-r1-1',
+ title: '业务知识库问答配置指南',
+ content: '多轮检索适合复杂问题,系统会先宽召回,再根据首轮结果继续改写查询并补充证据。',
+ source: '知识库 / 业务知识库问答配置指南.md',
+ },
+ {
+ id: 'mock-multi-r1-2',
+ title: '智能问答流程说明',
+ content: '当用户问题包含多个约束条件时,需要保留原始问题,同时拆分出检索关键词用于后续召回。',
+ source: '流程库 / 智能问答流程说明.pdf',
+ },
+ ],
+ [
+ {
+ id: 'mock-multi-r2-1',
+ title: 'RAG Query 改写最佳实践',
+ content: 'Query 改写应减少口语表达,突出实体、动作、条件和目标资料类型,以提升命中率。',
+ source: '技术资料 / RAG Query 改写最佳实践.docx',
+ },
+ {
+ id: 'mock-multi-r2-2',
+ title: '知识库召回与重排策略',
+ content: '建议先召回 Top50,再通过重排模型筛选 Top5 至 Top8 作为最终上下文。',
+ source: '算法组 / 知识库召回与重排策略.pptx',
+ },
+ {
+ id: 'mock-multi-r2-3',
+ title: '企业知识问答案例集',
+ content: '复杂业务问答通常需要结合制度、FAQ、历史案例三类资料综合判断。',
+ source: '案例库 / 企业知识问答案例集.xlsx',
+ },
+ ],
+ [
+ {
+ id: 'mock-multi-r3-1',
+ title: '回答可信度与引用规范',
+ content: '生成答案时应明确结论依据,并在涉及例外条件时给出风险提示或人工复核建议。',
+ source: '规范库 / 回答可信度与引用规范.md',
+ },
+ {
+ id: 'mock-multi-r3-2',
+ title: '知识库答案质检规则',
+ content: '当多个资料结论不完全一致时,应优先采用发布时间较新、来源更权威的资料。',
+ source: '质检库 / 知识库答案质检规则.pdf',
+ },
+ ],
+ ];
+
+ /**
+ * 执行多轮 RAG 本地模拟。
+ *
+ * 该方法按轮次延迟填充检索结果,最后分段追加最终回答,模拟真实多轮检索的“思考-召回-总结”节奏。
+ * 完成后把所有 retrievals 汇总为 sources,供引用来源折叠面板展示。
+ */
+ const handleMockMultiRoundRun = async (query: string) => {
+ setError(null);
+ setIsLoading(true);
+ setIsPreConnectLoading(true);
+ mockMultiRoundStoppedRef.current = false;
+
+ const assistantId = `assistant-${Date.now()}`;
+
+ try {
+ await sleep(350);
+ if (mockMultiRoundStoppedRef.current) return;
+ setIsPreConnectLoading(false);
+
+ const trace = createMockMultiRoundTrace(query);
+ setMessages((prev) => [...prev, {
+ id: assistantId,
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ thinkingTrace: trace,
+ sources: [],
+ }]);
+
+ for (let roundIndex = 0; roundIndex < trace.rounds.length; roundIndex += 1) {
+ if (mockMultiRoundStoppedRef.current) break;
+ await sleep(700);
+ setMessages((prev) => prev.map((msg) => {
+ if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
+ const rounds = msg.thinkingTrace.rounds.map((round, idx) => (
+ idx === roundIndex
+ ? {
+ ...round,
+ queryLoading: false,
+ retrievals: mockMultiRoundRetrievals[roundIndex],
+ }
+ : round
+ ));
+ return {
+ ...msg,
+ thinkingTrace: {
+ ...msg.thinkingTrace,
+ rounds,
+ },
+ };
+ }));
+ }
+
+ const finalAnswerTokens = [
+ `基于多轮检索,我对“${query}”形成如下结论:\n\n`,
+ '### 综合结论\n\n',
+ '1. **先做宽召回**:第一轮保留原始问题语义,覆盖相关制度、流程和 FAQ。\n',
+ '2. **再做 Query 改写**:第二轮把问题拆成实体、动作、条件,提升命中文档质量。\n',
+ '3. **最后做交叉验证**:第三轮补充边界条件和风险点,避免只依据单一资料下结论。\n\n',
+ '### 建议\n\n',
+ '如果是生产环境,可以把多轮检索结果按“相关性、权威性、时效性”排序,再把 TopK 证据注入模型生成最终回答。',
+ ];
+
+ for (const token of finalAnswerTokens) {
+ if (mockMultiRoundStoppedRef.current) break;
+ setMessages((prev) => prev.map((msg) => {
+ if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
+ return {
+ ...msg,
+ thinkingTrace: {
+ ...msg.thinkingTrace,
+ finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${token}`,
+ },
+ };
+ }));
+ await sleep(180);
+ }
+
+ const sources = mockMultiRoundRetrievals.flat().map((item) => ({
+ title: item.title,
+ type: '参考资料',
+ snippet: item.content,
+ source: item.source,
+ content: item.content,
+ }));
+ setSearchProcess((prev) => ({
+ ...prev,
+ rounds: trace.rounds.length,
+ hits: sources.length,
+ queryRewrite: '原问题 → 宽召回 → Query 改写 → 边界条件补充 → 综合回答',
+ }));
+ setMessages((prev) => prev.map((msg) => (
+ msg.id === assistantId ? { ...msg, sources } : msg
+ )));
+ } catch (runError) {
+ console.error('多轮RAG模拟失败:', runError);
+ setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
+ } finally {
+ setIsPreConnectLoading(false);
+ setIsLoading(false);
+ mockMultiRoundStoppedRef.current = false;
+ submitLockRef.current = false;
+ }
+ };
+
+ /**
+ * 处理真实多轮 RAG 请求。
+ *
+ * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、
+ * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。
+ */
+ const handleRun = async (query: string) => {
+ setError(null);
+ setIsLoading(true);
+ setIsPreConnectLoading(true);
+
+ try {
+ const response = await fetch('http://10.26.128.38:8049/api/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ if (!response.body) {
+ throw new Error('Response body is null');
+ }
+ setIsPreConnectLoading(false);
+
+ const assistantId = `assistant-${Date.now()}`;
+ const assistantMessage: Message = {
+ id: assistantId,
+ role: 'assistant',
+ content: '',
+ timestamp: new Date(),
+ thinkingTrace: createMultiRoundStreamTrace(),
+ };
+ // 接口连通后再展示多轮检索主题与过程
+ setMessages((prev) => [...prev, assistantMessage]);
+
+ const reader = response.body.getReader();
+ readerRef.current = reader;
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ const lastLine = lines.pop();
+ buffer = lastLine || '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+
+ try {
+ const parsedData = JSON.parse(line.slice(6)) as {
+ event: string;
+ data: any;
+ };
+ const { event, data } = parsedData;
+
+ switch (event) {
+ case 'task_created': {
+ taskIdRef.current = data?.task_id || null;
+ break;
+ }
+ case 'tool_call': {
+ handleToolCall(data as ToolCallData, assistantId);
+ break;
+ }
+ case 'message': {
+ if (typeof data === 'string') {
+ setMessages((prevMessages) => prevMessages.map((msg) => {
+ if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
+ return {
+ ...msg,
+ thinkingTrace: {
+ ...msg.thinkingTrace,
+ finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`,
+ },
+ };
+ }));
+ }
+ break;
+ }
+ case 'references': {
+ const refs = Array.isArray(data?.references) ? data.references : [];
+ const mappedRefs = refs.map(mapReferenceToSource);
+ setSearchProcess((prev) => ({
+ ...prev,
+ hits: mappedRefs.length,
+ }));
+ setMessages((prevMessages) => prevMessages.map((msg) => (
+ msg.id === assistantId
+ ? { ...msg, sources: mappedRefs }
+ : msg
+ )));
+ break;
+ }
+ case 'done': {
+ setIsLoading(false);
+ break;
+ }
+ default:
+ console.log(`未知事件类型: ${event}`, data);
+ }
+ } catch (parseError) {
+ console.error('JSON解析错误:', parseError);
+ console.error('原始数据:', line.slice(6));
+ }
+ }
+ }
+ } finally {
+ if (readerRef.current) {
+ readerRef.current.releaseLock();
+ readerRef.current = null;
+ }
+ }
+ } catch (runError) {
+ console.error('处理请求时发生错误:', runError);
+ setIsLoading(false);
+ setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
+ setIsPreConnectLoading(false);
+ } finally {
+ submitLockRef.current = false;
+ }
+ };
+
+ /**
+ * 单一模式下的发送入口。
+ *
+ * 这里只负责创建用户消息、重置输入框和按当前 qaMode 分发到对应执行器;
+ * 具体的真实请求、模拟流和状态回填分别在各自 handler 内维护。
+ */
+ const handleSend = () => {
+ if (submitLockRef.current || isLoading || isPreConnectLoading) return;
+ if (!inputValue.trim()) return;
+ submitLockRef.current = true;
+ const query = inputValue.trim();
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ role: 'user',
+ content: query,
+ timestamp: new Date(),
+ };
+
+ if (qaMode === 'multi-round') {
+ setMessages((prev) => [...prev, userMessage]);
+ setInputValue('');
+ setSearchProcess({
+ currentMode: '多轮RAG',
+ rounds: 0,
+ hits: 0,
+ queryRewrite: '',
+ });
+ void handleRun(query);
+ return;
+ }
+
+ if (qaMode === 'single-round') {
+ setMessages((prev) => [...prev, userMessage]);
+ setInputValue('');
+ setSearchProcess({
+ currentMode: '单轮RAG',
+ rounds: 1,
+ hits: 0,
+ queryRewrite: '',
+ });
+ void handleSingleRoundRun(query);
+ return;
+ }
+
+ setIsLoading(true);
+ setMessages((prev) => [...prev, userMessage]);
+ setInputValue('');
+
+ // 模拟AI响应
+ setTimeout(() => {
+ setMessages(prev => [...prev, {
+ id: `assistant-${Date.now()}`,
+ role: 'assistant',
+ content: '已完成深度检索和综合回答。',
+ timestamp: new Date(),
+ thinkingTrace: createThinkingTrace(qaMode),
+ }]);
+ setIsLoading(false);
+ submitLockRef.current = false;
+ setSearchProcess({
+ currentMode: '深度检索',
+ rounds: 1,
+ hits: 5,
+ queryRewrite: qaMode === 'deep-search' ? '原问题→分解子问题→独立检索→综合回答' : '',
+ });
+ }, 50000);
+ };
+
+ /**
+ * 对比模式发送入口。
+ *
+ * 左右面板共享同一个用户问题,但各自按选择的模式独立运行、独立 loading、独立 sources。
+ * 通过 `updatePane` 将左右面板更新收敛到一个函数,避免并发请求互相覆盖状态。
+ */
+ const handleCompare = () => {
+ if (submitLockRef.current || isLoading || isPreConnectLoading) return;
+ if (!inputValue.trim()) return;
+ submitLockRef.current = true;
+ const query = inputValue.trim();
+ setInputValue('');
+ setCompareUserInput(query);
+ setError(null);
+ setIsLoading(true);
+ setCompareResult({
+ left: createComparePane(compareLeftMode),
+ right: createComparePane(compareRightMode),
+ });
+
+ const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => {
+ setCompareResult((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ [side]: updater(prev[side]),
+ };
+ });
+ };
+
+ const runSingleForCompare = async (side: 'left' | 'right') => {
+ let inThink = false;
+ let thinkStarted = false;
+
+ const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query,
+ knowledge_base_name: [],
+ top_k: 5,
+ score_threshold: scoreThreshold,
+ history: [],
+ stream: true,
+ model_name: modelName,
+ temperature,
+ prompt_name: 'default',
+ local_doc_url: false,
+ }),
+ });
+ if (!response.ok || !response.body) throw new Error(`单轮RAG接口异常: ${response.status}`);
+
+ const reader = response.body.getReader();
+ compareReaderRef.current[side] = reader;
+ const decoder = new TextDecoder();
+ let buffer = '';
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const extracted = extractJsonObjects(buffer);
+ buffer = extracted.rest;
+ for (const jsonText of extracted.objects) {
+ const parsed = parseKnowledgeBaseChunk(jsonText);
+ if (typeof parsed.answer === 'string') {
+ const token = parsed.answer;
+ updatePane(side, (pane) => {
+ const trace = pane.singleRagTrace || {
+ title: '单轮 RAG 思考过程',
+ thinkingMarkdown: '',
+ answerMarkdown: '',
+ thinkingComplete: false,
+ };
+ let thinkingMarkdown = trace.thinkingMarkdown;
+ let answerMarkdown = trace.answerMarkdown;
+ let thinkingComplete = trace.thinkingComplete || false;
+ let remaining = token;
+ while (remaining) {
+ if (inThink) {
+ const endIdx = remaining.indexOf('');
+ if (endIdx === -1) {
+ thinkingMarkdown += remaining;
+ remaining = '';
+ } else {
+ thinkingMarkdown += remaining.slice(0, endIdx);
+ remaining = remaining.slice(endIdx + ''.length);
+ inThink = false;
+ thinkingComplete = true;
+ }
+ } else {
+ const startIdx = remaining.indexOf('');
+ if (startIdx === -1) {
+ answerMarkdown += remaining;
+ remaining = '';
+ } else {
+ answerMarkdown += remaining.slice(0, startIdx);
+ remaining = remaining.slice(startIdx + ''.length);
+ inThink = true;
+ thinkStarted = true;
+ }
+ }
+ }
+ return {
+ ...pane,
+ singleRagTrace: {
+ ...trace,
+ thinkingMarkdown,
+ answerMarkdown,
+ thinkingComplete: thinkStarted ? thinkingComplete : true,
+ },
+ };
+ });
+ }
+ if (parsed.docs) {
+ updatePane(side, (pane) => ({ ...pane, sources: [...pane.sources, ...mapDocsToSources(parsed.docs)] }));
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ if (compareReaderRef.current[side] === reader) {
+ compareReaderRef.current[side] = null;
+ }
+ }
+ };
+
+ const runMultiForCompare = async (side: 'left' | 'right') => {
+ const response = await fetch('http://10.26.128.38:8049/api/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query }),
+ });
+ if (!response.ok || !response.body) throw new Error(`多轮检索接口异常: ${response.status}`);
+
+ const reader = response.body.getReader();
+ compareReaderRef.current[side] = reader;
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ const applyToolCall = (toolData: ToolCallData) => {
+ updatePane(side, (pane) => {
+ const trace = pane.thinkingTrace || createMultiRoundStreamTrace();
+ const rounds = [...trace.rounds];
+ const lastRound = rounds[rounds.length - 1];
+ switch (toolData.tool_name) {
+ case 'show_text': {
+ const chatText = toolData.tool_input.text || '';
+ if (chatText.includes('')) {
+ const thinkContent = chatText.replace('', '').replace('', '').trim();
+ rounds.push({
+ id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ title: '思考中',
+ thoughtMarkdown: thinkContent,
+ query: '',
+ queryLoading: true,
+ retrievals: [],
+ });
+ } else {
+ trace.finalAnswerMarkdown += chatText;
+ }
+ break;
+ }
+ case 'tavily_search':
+ case 'local_search': {
+ if (toolData.tool_input.result && lastRound) {
+ const result = JSON.parse(toolData.tool_input.result) as {
+ query?: string;
+ results?: Array<{ title?: string; content?: string; url?: string }>;
+ };
+ lastRound.query = result.query || lastRound.query;
+ lastRound.queryLoading = false;
+ lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索';
+ lastRound.retrievals = (result.results || []).map((item, idx) => ({
+ id: `${toolData.tool_name}-${idx}`,
+ title: item.title || '未命名结果',
+ content: item.content || '',
+ source: item.url || '',
+ }));
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ return {
+ ...pane,
+ thinkingTrace: { ...trace, rounds },
+ };
+ });
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ const lastLine = lines.pop();
+ buffer = lastLine || '';
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const parsedData = JSON.parse(line.slice(6)) as { event: string; data: any };
+ const { event, data } = parsedData;
+ if (event === 'tool_call') applyToolCall(data as ToolCallData);
+ if (event === 'message' && typeof data === 'string') {
+ updatePane(side, (pane) => {
+ const trace = pane.thinkingTrace || createMultiRoundStreamTrace();
+ return {
+ ...pane,
+ thinkingTrace: {
+ ...trace,
+ finalAnswerMarkdown: `${trace.finalAnswerMarkdown}${data}`,
+ },
+ };
+ });
+ }
+ if (event === 'references') {
+ const refs = Array.isArray(data?.references) ? data.references : [];
+ updatePane(side, (pane) => ({ ...pane, sources: refs.map(mapReferenceToSource) }));
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ if (compareReaderRef.current[side] === reader) {
+ compareReaderRef.current[side] = null;
+ }
+ }
+ };
+
+ const runByMode = (side: 'left' | 'right', mode: QAMode) => {
+ if (mode === 'single-round') return runSingleForCompare(side);
+ return runMultiForCompare(side);
+ };
+
+ Promise.allSettled([
+ runByMode('left', compareLeftMode).finally(() => updatePane('left', (pane) => ({ ...pane, loading: false }))),
+ runByMode('right', compareRightMode).finally(() => updatePane('right', (pane) => ({ ...pane, loading: false }))),
+ ]).finally(() => {
+ setIsLoading(false);
+ submitLockRef.current = false;
+ });
+ };
+
+ const handleStopCompareSide = (side: 'left' | 'right') => {
+ const reader = compareReaderRef.current[side];
+ if (reader) {
+ void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`);
+ compareReaderRef.current[side] = null;
+ }
+ setCompareResult((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ [side]: {
+ ...prev[side],
+ loading: false,
+ },
+ };
+ });
+ };
+
+ /**
+ * 停止当前生成。
+ *
+ * 真实请求通过 reader.cancel() 中断读取;本地模拟通过 ref 标记让异步循环自行退出。
+ * 对比模式需要同时停止左右两侧,单一模式只处理当前主 reader。
+ */
+ const handleStop = () => {
+ if (viewMode === 'compare') {
+ handleStopCompareSide('left');
+ handleStopCompareSide('right');
+ setIsLoading(false);
+ submitLockRef.current = false;
+ return;
+ }
+ mockSingleRoundStoppedRef.current = true;
+ mockMultiRoundStoppedRef.current = true;
+ if (readerRef.current) {
+ void readerRef.current.cancel('用户手动停止');
+ readerRef.current = null;
+ }
+ setIsPreConnectLoading(false);
+ setIsLoading(false);
+ submitLockRef.current = false;
+ };
+
+ /**
+ * 对“生成中切换页面/模式/布局”进行统一保护。
+ *
+ * 如果当前没有生成任务,直接执行目标操作;否则弹出确认框。
+ * 用户确认后先停止生成,再执行原操作,避免生成流继续写入已经切换的 UI 状态。
+ */
+ const runAfterGenerationConfirm = (action: () => void) => {
+ if (!isLoading) {
+ action();
+ return;
+ }
+ pendingActionRef.current = action;
+ Dialog.confirm({
+ header: '模型正在生成',
+ body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。',
+ confirmBtn: '确认停止并继续',
+ cancelBtn: '取消',
+ onConfirm: () => {
+ handleStop();
+ pendingActionRef.current?.();
+ pendingActionRef.current = null;
+ },
+ onClose: () => {
+ pendingActionRef.current = null;
+ },
+ });
+ };
+
+ const handleSafeViewModeChange = (mode: ViewMode) => {
+ if (mode === viewMode) return;
+ runAfterGenerationConfirm(() => setViewMode(mode));
+ };
+
+ const handleSafeNavigate = (page: string) => {
+ // runAfterGenerationConfirm(() => onNavigate(page));
+ };
+
+ const handleSafeReset = () => {
+ runAfterGenerationConfirm(() => {
+ setMessages([]);
+ setCompareResult(null);
+ });
+ };
+
+ const handleSafeModeChange = (mode: QAMode) => {
+ runAfterGenerationConfirm(() => setQaMode(mode));
+ };
+
+ const handleSafeCompareLeftModeChange = (mode: QAMode) => {
+ runAfterGenerationConfirm(() => setCompareLeftMode(mode));
+ };
+
+ const handleSafeCompareRightModeChange = (mode: QAMode) => {
+ runAfterGenerationConfirm(() => setCompareRightMode(mode));
+ };
+
+ const handleSafeCompareLayoutChange = (layout: 'horizontal' | 'vertical') => {
+ runAfterGenerationConfirm(() => setCompareLayout(layout));
+ };
+
+ const handleSaveRecord = () => {
+ alert('问答记录已保存');
+ };
+
+ const handleCollect = () => {
+ alert('已添加到收藏');
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return;
+ if (viewMode === 'compare') {
+ handleCompare();
+ } else {
+ handleSend();
+ }
+ }
+ };
+
+ // 获取模式标签
+ const getModeLabel = (mode: QAMode) => {
+ switch (mode) {
+ case 'single-round': return '单轮RAG';
+ case 'multi-round': return '多轮RAG';
+ case 'deep-search': return '深度检索';
+ }
+ };
+
+ const activeAssistantMessageId = isLoading
+ ? [...messages].reverse().find((msg) => msg.role === 'assistant')?.id
+ : undefined;
+
+ useEffect(() => {
+ setQaMode('single-round');
+ setMessages(createMockSingleRagMessages());
+ setSearchProcess({
+ currentMode: '单轮RAG',
+ rounds: 1,
+ hits: 3,
+ queryRewrite: '',
+ });
+ }, []);
+
+ /**
+ * 滚动到底部。
+ *
+ * 聊天区默认跟随新消息自动滚动;当用户手动上滑查看历史时会暂停自动滚动,避免打断阅读。
+ */
+ const scrollToBottom = (smooth = false) => {
+ if (!chatListRef.current) return;
+ chatListRef.current.scrollTo({
+ top: chatListRef.current.scrollHeight,
+ behavior: smooth ? 'smooth' : 'auto',
+ });
+ };
+
+ useEffect(() => {
+ if (isAutoScrollEnabled) {
+ scrollToBottom(false);
+ }
+ }, [messages, isLoading, isAutoScrollEnabled]);
+
+ const handleChatScroll = () => {
+ if (!chatListRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = chatListRef.current;
+ const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 80;
+ setIsAutoScrollEnabled(isNearBottom);
+ setShowScrollToBottom(!isNearBottom);
+ };
+
+ const getAssistantAnswerText = (message: Message) => (
+ message.singleRagTrace?.answerMarkdown || message.thinkingTrace?.finalAnswerMarkdown || message.content
+ );
+
+ const getAssistantThinkingText = (message: Message) => {
+ if (message.singleRagTrace) return message.singleRagTrace.thinkingMarkdown;
+ if (message.thinkingTrace) {
+ return message.thinkingTrace.rounds.map((round, index) => (
+ `## 第 ${index + 1} 轮:${round.title}\n${round.thoughtMarkdown}`
+ )).join('\n\n');
+ }
+ return '';
+ };
+
+ const handleCopyAssistantMessage = async (message: Message) => {
+ const answerText = getAssistantAnswerText(message);
+ try {
+ await navigator.clipboard.writeText(answerText);
+ } catch (copyError) {
+ console.error('复制模型回答失败:', copyError);
+ }
+ };
+
+ const handleDeleteAssistantMessage = (message: Message) => {
+ const messageIndex = messages.findIndex((msg) => msg.id === message.id);
+ const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user');
+ setMessages((prev) => prev.filter((msg) => msg.id !== message.id && msg.id !== previousUserMessage?.id));
+ };
+
+ const handleEditAssistantMessage = (message: Message) => {
+ setEditingMessageId(message.id);
+ setEditingThinking(getAssistantThinkingText(message));
+ setEditingAnswer(getAssistantAnswerText(message));
+ };
+
+ const handleCancelAssistantEdit = () => {
+ setEditingMessageId(null);
+ setEditingThinking('');
+ setEditingAnswer('');
+ };
+
+ const handleSaveAssistantEdit = () => {
+ if (!editingMessageId) return;
+ setMessages((prev) => prev.map((msg) => {
+ if (msg.id !== editingMessageId) return msg;
+ if (msg.singleRagTrace) {
+ return {
+ ...msg,
+ content: editingAnswer,
+ singleRagTrace: {
+ ...msg.singleRagTrace,
+ thinkingMarkdown: editingThinking,
+ answerMarkdown: editingAnswer,
+ thinkingComplete: true,
+ },
+ };
+ }
+ if (msg.thinkingTrace) {
+ const rounds = msg.thinkingTrace.rounds.length > 0
+ ? msg.thinkingTrace.rounds.map((round, index) => (
+ index === 0 ? { ...round, thoughtMarkdown: editingThinking } : round
+ ))
+ : msg.thinkingTrace.rounds;
+ return {
+ ...msg,
+ content: editingAnswer,
+ thinkingTrace: {
+ ...msg.thinkingTrace,
+ rounds,
+ finalAnswerMarkdown: editingAnswer,
+ },
+ };
+ }
+ return { ...msg, content: editingAnswer };
+ }));
+ handleCancelAssistantEdit();
+ };
+
+ const handleResendAssistantMessage = (message: Message) => {
+ if (submitLockRef.current || isLoading || isPreConnectLoading) return;
+ const messageIndex = messages.findIndex((msg) => msg.id === message.id);
+ const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user');
+ if (!previousUserMessage) return;
+
+ submitLockRef.current = true;
+ setMessages((prev) => prev.filter((msg) => msg.id !== message.id));
+ setInputValue('');
+
+ if (message.singleRagTrace) {
+ setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '' });
+ void handleSingleRoundRun(previousUserMessage.content);
+ return;
+ }
+
+ setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '' });
+ void handleRun(previousUserMessage.content);
+ };
+
+ /**
+ * 点击答案中的引用编号后打开引用详情弹窗。
+ *
+ * Markdown 中的 `[[1]]` 与 sources 数组采用 1-based 对齐,因此这里需要减 1 取数组下标。
+ */
+ const handleCitationClick = (message: Message, citationIndex: number) => {
+ const source = message.sources?.[citationIndex - 1];
+ if (!source) return;
+ setSelectedCitation(source);
+ };
+
+ const handleCompareCitationClick = (side: 'left' | 'right', citationIndex: number) => {
+ const source = compareResult?.[side]?.sources?.[citationIndex - 1];
+ if (!source) return;
+ setSelectedCitation(source);
+ };
+
+ useEffect(() => {
+ if (viewMode === 'compare') {
+ setCompareLeftMode('single-round');
+ setCompareRightMode('multi-round');
+ }
+ }, [viewMode]);
+
+ useEffect(() => {
+ if (viewMode !== 'compare' || !compareResult) return;
+ setIsLoading(compareResult.left.loading || compareResult.right.loading);
+ }, [viewMode, compareResult]);
+
+ // 离开浏览器页面时无法展示自定义 Dialog,因此使用原生 beforeunload 提醒用户生成会被中断。
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!isLoading) return;
+ event.preventDefault();
+ event.returnValue = '模型正在生成,离开页面将停止当前生成。';
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+ }, [isLoading]);
+
+ return (
+
+
+
+ {/* 主体内容区 */}
+
+
+
+ {/* 中间结果区 */}
+
+ {viewMode === 'single' ? (
+
+ {/* 聊天消息区 */}
+
+ {messages.length === 0 ? (
+
}
+ title="开始你的问答"
+ description="输入问题,获取基于知识库的智能回答"
+ />
+ ) : (
+ messages.map((msg) => (
+
+ ))
+ )}
+
+ {isPreConnectLoading && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {showScrollToBottom && (
+
+ )}
+
+ ) : (
+ <>
+ {/* 对比结果区 */}
+
+ {compareUserInput && (
+
+ )}
+ {compareResult ? (
+ compareLayout === 'horizontal' ? (
+
+ {/* 左侧结果 */}
+
+
+
+
+
+
+ {getModeLabel(compareResult.left.mode)}
+
+
+ {compareResult.left.loading && (
+
+ )}
+
+
+
+
+ {compareResult.left.mode === 'single-round' ? (
+ handleCompareCitationClick('left', idx)} />
+ ) : (
+ handleCompareCitationClick('left', idx)} />
+ )}
+
+
+
+
+ {/* 右侧结果 */}
+
+
+
+
+
+
+ {getModeLabel(compareResult.right.mode)}
+
+
+ {compareResult.right.loading && (
+
+ )}
+
+
+
+
+ {compareResult.right.mode === 'single-round' ? (
+ handleCompareCitationClick('right', idx)} />
+ ) : (
+ handleCompareCitationClick('right', idx)} />
+ )}
+
+
+
+
+ ) : (
+
+ {/* 上方结果 */}
+
+
+
+
+
+
+ {getModeLabel(compareResult.left.mode)}
+
+
+ {compareResult.left.loading && (
+
+ )}
+
+
+
+
+ {compareResult.left.mode === 'single-round' ? (
+ handleCompareCitationClick('left', idx)} />
+ ) : (
+ handleCompareCitationClick('left', idx)} />
+ )}
+
+
+
+
+ {/* 下方结果 */}
+
+
+
+
+
+
+ {getModeLabel(compareResult.right.mode)}
+
+
+ {compareResult.right.loading && (
+
+ )}
+
+
+
+
+ {compareResult.right.mode === 'single-round' ? (
+ handleCompareCitationClick('right', idx)} />
+ ) : (
+ handleCompareCitationClick('right', idx)} />
+ )}
+
+
+
+
+ )
+ ) : (
+
+
+
对比模式
+
输入问题,对比不同模式的回答效果
+
+ )}
+
+ >
+ )}
+
+ {/* 底部输入区 */}
+
setIsModelSettingsOpen(true)}
+ isLoading={isLoading}
+ placeholder={viewMode === 'compare' ? '输入问题进行对比分析...' : '输入你的问题...'}
+ showReset
+ />
+
+
+ {/* 右侧辅助区 */}
+ {viewMode === 'single' && showProcess && (
+
+ {/* 检索过程 */}
+
+
+ 检索过程
+
+
+
+
+ 当前模式
+
+ {searchProcess.currentMode}
+
+
+
+ 检索轮次
+
+ {searchProcess.rounds}
+
+
+
+ 命中数
+
+ {searchProcess.hits}
+
+
+ {searchProcess.queryRewrite && (
+
+
Query改写
+
+ {searchProcess.queryRewrite}
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* 对比模式的差异总结 */}
+ {viewMode === 'compare' && compareResult?.differences && (
+
+
+ 差异总结
+
+
+
+
+
+ 一致观点
+
+
+ {compareResult.differences.consensus}
+
+
+
+
+
+ 差异观点
+
+
+ {compareResult.differences.differences}
+
+
+
+
+ {getModeLabel(compareResult.left.mode)}优势
+
+
+ {compareResult.differences.leftAdvantage}
+
+
+
+
+ {getModeLabel(compareResult.right.mode)}优势
+
+
+ {compareResult.differences.rightAdvantage}
+
+
+
+
+ )}
+
+
+ {editingMessageId && (
+
+
+
+
+
+
编辑模型生成内容
+
+ 可同时修改模型回答和思考过程,多轮检索会把思考过程写回第一轮思考内容。
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {selectedCitation && (
+
+
setSelectedCitation(null)}
+ />
+
+
+
+
+
{selectedCitation.title || '引用内容'}
+
+
来源:{selectedCitation.source || selectedCitation.type || '未知来源'}
+
时间:{selectedCitation.time || '未知'}
+
+
+
+ {selectedCitation.url && (
+
+ )}
+
+
+ {selectedCitation.content || selectedCitation.snippet || '暂无引用内容'}
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default GeneralQA;
diff --git a/frontend-web/src/strategy-components/components/qa/GeneralQA.types.ts b/frontend-web/src/strategy-components/components/qa/GeneralQA.types.ts
new file mode 100644
index 00000000..2e36a2ea
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/GeneralQA.types.ts
@@ -0,0 +1,74 @@
+import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
+
+export type QAMode = 'single-round' | 'multi-round' | 'deep-search';
+export type ViewMode = 'single' | 'compare';
+export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
+export type AnswerStyle = 'concise' | 'detailed';
+export type CompareLayout = 'horizontal' | 'vertical';
+
+export interface SourceItem {
+ title: string;
+ type: string;
+ snippet: string;
+ source?: string;
+ time?: string;
+ url?: string;
+ content?: string;
+}
+
+export interface Message {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ singleRagTrace?: QASingleRagTrace;
+ thinkingTrace?: QAThinkingTrace;
+ sources?: SourceItem[];
+}
+
+export interface ToolCallData {
+ tool_name: string;
+ tool_input: {
+ text?: string;
+ result?: string;
+ };
+}
+
+export interface KnowledgeBaseDoc {
+ title?: string;
+ content?: string;
+ source?: string;
+ url?: string;
+ snippet?: string;
+}
+
+export interface KnowledgeBaseChunk {
+ answer?: string;
+ docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
+}
+
+export interface ComparePane {
+ mode: QAMode;
+ loading: boolean;
+ singleRagTrace?: QASingleRagTrace;
+ thinkingTrace?: QAThinkingTrace;
+ sources: SourceItem[];
+}
+
+export interface CompareResult {
+ left: ComparePane;
+ right: ComparePane;
+ differences?: {
+ consensus: string;
+ differences: string;
+ leftAdvantage: string;
+ rightAdvantage: string;
+ };
+}
+
+export interface SearchProcessState {
+ currentMode: string;
+ rounds: number;
+ hits: number;
+ queryRewrite: string;
+}
diff --git a/frontend-web/src/strategy-components/components/qa/GeneralQAConfigSidebar.tsx b/frontend-web/src/strategy-components/components/qa/GeneralQAConfigSidebar.tsx
new file mode 100644
index 00000000..e01852b1
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/GeneralQAConfigSidebar.tsx
@@ -0,0 +1,249 @@
+import { BookOpen, Brain, ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
+import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, ViewMode } from './GeneralQA.types';
+
+/**
+ * 左侧配置栏 Props。
+ *
+ * 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
+ * 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
+ */
+type Props = {
+ isDark: boolean; // 当前是否为暗黑主题,用于样式适配
+ viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
+ qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
+ compareLeftMode: QAMode; // 对比模式下左侧的问答模式
+ compareRightMode: QAMode; // 对比模式下右侧的问答模式
+ compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
+ knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
+ answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
+ showSources: boolean; // 是否在回答中展示引用来源
+ showProcess: boolean; // 是否展示模型的思考过程
+ autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
+ recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
+ isCollapsed: boolean; // 左侧配置面板是否折叠
+ setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
+ setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
+ setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
+ setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
+ setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
+ setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
+ setShowSources: (show: boolean) => void; // 设置是否展示引用来源
+ setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
+ setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
+ setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
+ setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
+};
+
+/**
+ * 单一模式下可选的问答模式。
+ *
+ * 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
+ */
+const qaModeOptions = [
+ { value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
+ { value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
+ { value: 'deep-search' as const, label: '深度检索', icon: BookOpen, desc: '全面分析' },
+];
+
+/**
+ * 知识范围选项。
+ *
+ * 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
+ */
+const knowledgeScopeOptions = [
+ { value: 'all' as const, label: '全部知识源' },
+ { value: 'specific-kb' as const, label: '指定知识库' },
+ { value: 'specific-domain' as const, label: '指定资源域' },
+];
+
+/**
+ * 左侧问答配置栏。
+ *
+ * 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
+ * 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
+ * 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
+ */
+export default function GeneralQAConfigSidebar({
+ isDark,
+ viewMode,
+ qaMode,
+ compareLeftMode,
+ compareRightMode,
+ compareLayout,
+ knowledgeScope,
+ answerStyle,
+ showSources,
+ showProcess,
+ autoSummarizeDiff,
+ recommendBetter,
+ isCollapsed,
+ setQaMode,
+ setCompareLeftMode,
+ setCompareRightMode,
+ setCompareLayout,
+ setKnowledgeScope,
+ setAnswerStyle,
+ setShowSources,
+ setShowProcess,
+ setAutoSummarizeDiff,
+ setRecommendBetter,
+ setIsCollapsed,
+}: Props): JSX.Element {
+ /**
+ * 自定义勾选图标。
+ *
+ * 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
+ */
+ const renderCheckIcon = () => (
+
+ );
+
+ return (
+
+
+ {!isCollapsed && (
+
+ 问答配置
+
+ )}
+
+
+
+ {!isCollapsed && (
+ <>
+ {viewMode === 'single' ? (
+
+
问答模式
+
+ {qaModeOptions.map((mode) => (
+
+ ))}
+
+
+ ) : (
+ <>
+
+
对比模式设置
+
+
+
+
+
VS
+
+
+
+
+
+
+
+
对比布局
+
+ {(['horizontal', 'vertical'] as const).map((layout) => (
+
+ ))}
+
+
+ >
+ )}
+
+
+
知识范围
+
+ {knowledgeScopeOptions.map((option) => (
+
+ ))}
+
+
+
+
+
+ {viewMode === 'compare' && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/GeneralQAHeader.tsx b/frontend-web/src/strategy-components/components/qa/GeneralQAHeader.tsx
new file mode 100644
index 00000000..7a94364f
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/GeneralQAHeader.tsx
@@ -0,0 +1,80 @@
+import { History } from 'lucide-react';
+import type { ViewMode } from './GeneralQA.types';
+
+/**
+ * 顶部栏 Props。
+ *
+ * `setViewMode` 由父组件传入,父组件会在生成中切换时做确认拦截;
+ * Header 本身不直接感知 loading,保持纯展示和事件转发。
+ */
+type Props = {
+ isDark: boolean; // 当前是否为暗黑主题,用于样式适配
+ viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
+ setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
+};
+
+/**
+ * 通用问答页面顶部栏。
+ *
+ * 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
+ * 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
+ * “保存模板”“导出记录”等入口,也可以只改这个文件。
+ */
+export default function GeneralQAHeader({ isDark, viewMode, setViewMode }: Props): JSX.Element {
+ return (
+
+
+
+ 通用问答
+
+
+ 支持单轮RAG、多轮RAG、深度检索及结果对比
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/MopeChat.css b/frontend-web/src/strategy-components/components/qa/MopeChat.css
new file mode 100644
index 00000000..16df3807
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/MopeChat.css
@@ -0,0 +1,410 @@
+.mope-chat {
+ --bg: #ffffff;
+ --surface: #ffffff;
+ --surface-raised: #ffffff;
+ --card: #ffffff;
+ --border: #fee2e2;
+ --border-strong: #fecaca;
+ --text: #111827;
+ --text-muted: #6b7280;
+ --accent: #dc2626;
+ --accent-hover: #ef4444;
+ --green: #16a34a;
+ --yellow: #f59e0b;
+ --red: #dc2626;
+ --purple: #7c3aed;
+ --button-text: #ffffff;
+ --surface-hover: rgba(220, 38, 38, 0.04);
+ --surface-hover-strong: rgba(220, 38, 38, 0.08);
+ --accent-soft: rgba(220, 38, 38, 0.08);
+ --accent-soft-strong: rgba(220, 38, 38, 0.16);
+ --danger-soft: rgba(220, 38, 38, 0.10);
+ --shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.07);
+ --shadow-card: 0 8px 24px rgba(15, 23, 42, 0.05);
+
+ --text-primary: var(--text);
+ --text-secondary: var(--text-muted);
+ --border-color: var(--border);
+ --bg-secondary: var(--surface);
+ --accent-blue: var(--accent);
+
+ --phase-starting: var(--accent);
+ --phase-diverging: var(--yellow);
+ --phase-closing: var(--green);
+ --phase-done: var(--purple);
+
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text);
+ background: var(--bg);
+}
+
+.mope-chat.mope-chat-dark {
+ --bg: #0f172a;
+ --surface: #1e293b;
+ --surface-raised: #111827;
+ --card: #0f172a;
+ --border: rgba(30, 64, 175, 0.45);
+ --border-strong: rgba(59, 130, 246, 0.45);
+ --text: #f8fafc;
+ --text-muted: #94a3b8;
+ --accent: #3b82f6;
+ --accent-hover: #60a5fa;
+ --green: #10b981;
+ --yellow: #f59e0b;
+ --red: #f43f5e;
+ --purple: #8b5cf6;
+ --button-text: #ffffff;
+ --surface-hover: rgba(255, 255, 255, 0.03);
+ --surface-hover-strong: rgba(255, 255, 255, 0.06);
+ --accent-soft: rgba(59, 130, 246, 0.14);
+ --accent-soft-strong: rgba(59, 130, 246, 0.22);
+ --danger-soft: rgba(244, 63, 94, 0.12);
+ --shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.28);
+ --shadow-card: 0 12px 28px rgba(0, 0, 0, 0.22);
+}
+
+.mope-chat,
+.mope-chat * {
+ box-sizing: border-box;
+}
+
+.mope-chat {
+ color: var(--text);
+ min-height: 100%;
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.mope-chat input,
+.mope-chat textarea,
+.mope-chat select,
+.mope-chat button {
+ font: inherit;
+}
+
+.mope-chat p,
+.mope-chat h1,
+.mope-chat h2,
+.mope-chat h3,
+.mope-chat h4,
+.mope-chat pre {
+ margin-top: 0;
+}
+
+.mope-chat ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+.mope-chat ::-webkit-scrollbar-track {
+ background: var(--surface);
+}
+.mope-chat ::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+.mope-chat ::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* Idle: centered form */
+.mope-chat.app-idle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 96px);
+ padding: 36px 40px 42px;
+ background:
+ radial-gradient(circle at top center, var(--accent-soft) 0, transparent 34%),
+ var(--bg);
+}
+
+.mope-chat.app-idle-scroll {
+ align-items: flex-start;
+ overflow-y: auto;
+}
+
+/* Running / done: two-column */
+.mope-chat.app-running {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 96px);
+ overflow: hidden;
+ background: var(--surface-raised);
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-card);
+}
+
+.mope-chat .app-header {
+ background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
+ border-bottom: 1px solid var(--border);
+ padding: 14px 18px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.mope-chat .app-header-logo {
+ font-weight: 700;
+ font-size: 15px;
+ color: var(--accent);
+ letter-spacing: 0.5px;
+}
+
+.mope-chat .app-header-task {
+ flex: 1;
+ color: var(--text-muted);
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mope-chat .app-header-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.mope-chat .app-body {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+.mope-chat .app-sidebar {
+ width: 250px;
+ flex-shrink: 0;
+ overflow-y: auto;
+ border-right: 1px solid var(--border);
+ background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
+}
+
+.mope-chat .app-main {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+ background: var(--bg);
+}
+
+.mope-chat .app-progress-sidebar {
+ width: 320px;
+ flex-shrink: 0;
+ overflow-y: auto;
+ border-left: 1px solid var(--border);
+ background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
+}
+
+.mope-chat .app-side-panel {
+ position: relative;
+ transition: width 0.22s ease, background 0.2s ease;
+}
+
+.mope-chat .app-side-panel.collapsed {
+ width: 44px;
+ overflow: hidden;
+}
+
+.mope-chat .app-side-toggle {
+ position: sticky;
+ top: 10px;
+ z-index: 2;
+ width: 28px;
+ height: 28px;
+ min-width: 28px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface-raised);
+ color: var(--text-muted);
+ box-shadow: var(--shadow-card);
+}
+
+.mope-chat .app-side-toggle:hover:not(:disabled) {
+ color: var(--accent);
+ border-color: var(--border-strong);
+ background: var(--surface-hover-strong);
+ transform: translateY(-1px);
+}
+
+.mope-chat .app-side-toggle-left {
+ float: right;
+ margin: 10px 10px 0 0;
+}
+
+.mope-chat .app-side-toggle-right {
+ float: left;
+ margin: 10px 0 0 10px;
+}
+
+.mope-chat .app-side-panel.collapsed .app-side-toggle-left,
+.mope-chat .app-side-panel.collapsed .app-side-toggle-right {
+ float: none;
+ margin: 10px auto 0;
+}
+
+/* Buttons */
+.mope-chat button {
+ cursor: pointer;
+ border: none;
+ border-radius: 10px;
+ padding: 8px 16px;
+ font-size: 13px;
+ font-family: inherit;
+ transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.mope-chat button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+.mope-chat button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.mope-chat .btn-primary {
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
+ color: var(--button-text);
+ font-weight: 600;
+ box-shadow: 0 10px 22px var(--accent-soft-strong);
+}
+
+.mope-chat .btn-primary:hover:not(:disabled) {
+ opacity: 0.85;
+}
+
+.mope-chat .btn-danger {
+ background: transparent;
+ color: var(--red);
+ border: 1px solid var(--red);
+}
+
+.mope-chat .btn-danger:hover:not(:disabled) {
+ background: var(--danger-soft);
+}
+
+.mope-chat .btn-secondary {
+ background: var(--surface-raised);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+
+.mope-chat .btn-secondary:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--border-strong);
+ background: var(--surface-hover);
+}
+
+.mope-chat .btn-ghost {
+ background: transparent;
+ color: var(--text-muted);
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+.mope-chat .btn-ghost:hover {
+ color: var(--text);
+}
+
+/* Badges */
+.mope-chat .badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+}
+
+.mope-chat .badge-starting { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
+.mope-chat .badge-diverging { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); }
+.mope-chat .badge-closing { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
+.mope-chat .badge-done { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); }
+.mope-chat .badge-default { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
+
+/* Spinner */
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.mope-chat .spinner {
+ display: inline-block;
+ animation: spin 1s linear infinite;
+}
+
+/* Reference cite */
+.mope-chat .ref-cite {
+ display: inline-block;
+ background: var(--accent-soft-strong);
+ color: var(--accent);
+ border-radius: 4px;
+ padding: 0 3px;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 11px;
+ cursor: pointer;
+ position: relative;
+}
+
+.mope-chat .ref-cite:hover::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: calc(100% + 4px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--card);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ white-space: nowrap;
+ z-index: 100;
+ font-family: inherit;
+ pointer-events: none;
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Error banner */
+.mope-chat .error-banner {
+ background: var(--danger-soft);
+ border: 1px solid var(--red);
+ border-radius: 6px;
+ padding: 8px 12px;
+ color: var(--red);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+
+@media (max-width: 1200px) {
+ .mope-chat .app-progress-sidebar {
+ width: 260px;
+ }
+}
+
+@media (max-width: 960px) {
+ .mope-chat .app-body {
+ flex-direction: column;
+ }
+
+ .mope-chat .app-sidebar,
+ .mope-chat .app-progress-sidebar {
+ width: 100%;
+ border-right: none;
+ border-left: none;
+ border-bottom: 1px solid var(--border);
+ max-height: 260px;
+ }
+
+ .mope-chat .app-main {
+ padding: 14px;
+ }
+}
diff --git a/frontend-web/src/strategy-components/components/qa/MopeChat.jsx b/frontend-web/src/strategy-components/components/qa/MopeChat.jsx
new file mode 100644
index 00000000..7b3287b2
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/MopeChat.jsx
@@ -0,0 +1,215 @@
+import React, { useEffect, useState } from 'react';
+import { useSession } from '@/hooks/useSession';
+import ConfigPanel from './components/ConfigPanel';
+import Sidebar from './components/Sidebar';
+import Timeline from './components/Timeline';
+import ProgressSidebar from './components/ProgressSidebar';
+import ScenarioConfigPage from './components/ScenarioConfigPage';
+import './MopeChat.css';
+
+/**
+ * 从 localStorage 读取用户偏好的主题色,默认为 light
+ */
+function getInitialTheme() {
+ if (typeof window === 'undefined') return 'light';
+ return window.localStorage.getItem('mope-theme') || 'light';
+}
+
+/**
+ * 应用根组件
+ *
+ * 负责整个页面的布局切换:
+ * - idle 状态:显示配置面板或场景配置页(启动前)
+ * - running / done / error 状态:显示三栏分析界面(左侧进度 → 中间时间线 → 右侧状态)
+ *
+ * 通过 useSession hook 管理所有分析会话的状态和事件流
+ */
+export default function MopeChat() {
+ const session = useSession();
+ const [taskContext, setTaskContext] = useState('');
+ const [idlePage, setIdlePage] = useState('home');
+ const [theme, setTheme] = useState(getInitialTheme);
+
+ // 解构 session hook 返回的所有状态和方法
+ // status: 'idle' | 'running' | 'waiting_human' | 'done' | 'error'
+ // phase: 'starting' | 'diverging' | 'closing' | 'done' → 当前分析阶段
+ // coordinatorMode: 'auto' | 'human_review' → 协调者运行模式
+ const {
+ status,
+ phase,
+ coordinatorMode,
+ currentRound,
+ totalRounds,
+ divergeRounds,
+ reportSchema,
+ agents,
+ agentStates,
+ timeline,
+ coordinatorUpdates,
+ latestDraft,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+ pendingReview,
+ reviewSubmitting,
+ reviewRevising,
+ errors,
+ startSession,
+ stopSession,
+ approveCoordinator,
+ reviseCoordinator,
+ reset,
+ } = session;
+
+ // 主题切换时同步到 DOM 和 localStorage
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ window.localStorage.setItem('mope-theme', theme);
+ }, [theme]);
+
+ /**
+ * 用户点击"开始分析"时触发
+ * @param {Object} config - 表单配置(任务背景、模板、智能体等)
+ * @param {string} backendUrl - 后端 API 地址
+ */
+ const handleStart = (config, backendUrl) => {
+ setTaskContext(config.task_context || '');
+ startSession(config, backendUrl);
+ };
+
+ /** 用户点击"停止"时触发,中断当前 SSE 连接 */
+ const handleStop = () => {
+ stopSession();
+ };
+
+ /** 用户点击"重新开始"时触发,重置所有状态回到 idle 页 */
+ const handleReset = () => {
+ reset();
+ setTaskContext('');
+ };
+
+ /** 切换明/暗主题 */
+ const handleThemeToggle = () => {
+ setTheme(current => (current === 'light' ? 'dark' : 'light'));
+ };
+
+ // ========== idle 状态下的页面 ==========
+
+ // 场景配置页(idle 下的二级页面)
+ if (status === 'idle') {
+ if (idlePage === 'scenario_config') {
+ return (
+
+
+ setIdlePage('home')} />
+
+ );
+ }
+
+ // 首页:配置表单
+ return (
+
+
+ setIdlePage('scenario_config')}
+ />
+
+ );
+ }
+
+ // ========== 运行中 / 已完成 / 错误 状态下的三栏布局 ==========
+
+ return (
+
+ {/* 主题切换按钮(运行状态下固定在 header 区域) */}
+
+
+ {/* 顶部状态栏:显示项目名、任务摘要、运行模式、操作按钮 */}
+
+
+ {/* 三栏主体区域 */}
+
+ {/* 左侧栏:阶段进度 + 智能体列表(可点击跳转到对应输出) */}
+
+
+ {/* 中间主区域:错误横幅 + 时间线(所有 agent 输出和协调者指令按时间排列) */}
+
+ {errors.length > 0 && errors.map((e, i) => (
+
+ {e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
+
+ ))}
+
+
+
+ {/* 右侧栏:方案进度、共享记忆、字段/角色记忆 */}
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/MopeChat.mock.ts b/frontend-web/src/strategy-components/components/qa/MopeChat.mock.ts
new file mode 100644
index 00000000..169bb9e5
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/MopeChat.mock.ts
@@ -0,0 +1,437 @@
+export const MOCK_TASK_CONTEXT =
+ '某头部科技公司发生大规模用户数据泄露事件,涉及约 500 万用户信息,已有多家媒体报道,需在 48 小时内完成舆论应对策略制定。';
+
+const REPORT_SCHEMA = {
+ core_strategy: '核心传播策略与定调方向',
+ key_messages: '核心话术与传播口径',
+ audience_map: '目标受众画像与触达路径',
+ risk_warnings: '舆论风险预警与应对预案',
+ action_plan: '具体行动计划与时间轴',
+};
+
+const AGENTS = [
+ { id: 'narrative', name: '叙事分析师', dimension: '叙事框架' },
+ { id: 'audience', name: '受众洞察师', dimension: '受众分析' },
+ { id: 'risk', name: '风险评估师', dimension: '风险研判' },
+ { id: 'channel', name: '渠道策略师', dimension: '渠道传播' },
+];
+
+const AGENT_STATES = {
+ narrative: { status: 'done', name: '叙事分析师', dimension: '叙事框架' },
+ audience: { status: 'done', name: '受众洞察师', dimension: '受众分析' },
+ risk: { status: 'done', name: '风险评估师', dimension: '风险研判' },
+ channel: { status: 'done', name: '渠道策略师', dimension: '渠道传播' },
+};
+
+const SHARED_MEMORY = {
+ stable_claims: [
+ '公众对"企业主动披露"的容忍度明显高于"被动曝光",定调应以主动承担为主旋律',
+ '数据泄露事件中"用户利益优先"叙事比"技术故障"叙事更能有效降低负面评价',
+ '科技公司危机中 KOL 态度在 6 小时内形成,此后难以扭转',
+ ],
+ key_evidence: [
+ '微博相关话题已有 2300 万阅读,评论情绪负向占比 67%',
+ '同类事件(2022 年某平台泄露)采用主动道歉策略后 NPS 下跌幅度仅为被动回应的 40%',
+ '核心受众 25–35 岁用户对隐私保护敏感度评分 8.3/10,为最高优先级群体',
+ ],
+ conflicts: [
+ '风险师认为应延迟发声等待内部核查完毕;叙事师认为 6 小时黄金窗口不可错过',
+ '渠道师建议优先微博大 V 背书;受众师认为私域触达更能稳住核心用户',
+ ],
+ open_questions: [
+ '泄露数据具体范围是否已完全确认?影响最终话术精准度',
+ '监管部门是否已介入?需提前准备合规声明版本',
+ ],
+ field_memory: {
+ core_strategy: {
+ title: '核心策略',
+ status: 'confirmed',
+ current_takeaways: [
+ '以"主动承担、用户优先"为核心定调,避免技术性推诿',
+ '第一时间启动 CEO 署名声明,彰显最高层重视',
+ ],
+ missing_points: [],
+ },
+ risk_warnings: {
+ title: '风险预警',
+ status: 'partial',
+ current_takeaways: ['次生舆情风险:监管追责、竞品借势炒作', '情绪峰值预计在 T+12h 出现'],
+ missing_points: ['灰色产业链二次利用风险尚未评估'],
+ },
+ },
+ agent_memory: {
+ narrative: {
+ name: '叙事分析师',
+ latest_position: '建议采用"危机—承担—改变"三段式叙事结构,第一阶段不超过 200 字',
+ useful_findings: [
+ '历史数据显示"技术故障"措辞会使负面情绪扩大 23%',
+ '"补偿方案"在声明中同步出现可降低 31% 的愤怒情绪',
+ ],
+ open_issues: ['CEO 是否愿意出面公开致歉,将直接影响叙事策略选择'],
+ },
+ risk: {
+ name: '风险评估师',
+ latest_position: '最大风险是监管介入与集体诉讼,需提前准备法务声明独立版本',
+ useful_findings: ['同类事件 80% 在 T+24h 出现监管询问,需预留合规响应通道'],
+ open_issues: [],
+ },
+ },
+};
+
+const TIMELINE = [
+ // ─── 启动阶段 ───────────────────────────────────────
+ { type: 'round_start', round: 1, phase: 'starting' },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'narrative',
+ round: 1,
+ phase: 'starting',
+ data: {
+ thinking:
+ '事件已进入舆论快速扩散阶段,需要判断最佳叙事切入点。从历史案例来看,科技公司数据泄露的舆论走向主要受两个变量影响:一是企业回应速度,二是叙事框架选择。当前已有媒体报道,说明被动曝光已经发生,但官方尚未表态,仍有 6 小时内抢占定性框架的窗口。',
+ content:
+ '## 叙事框架分析\n\n当前事件已进入**被动曝光**阶段,官方沉默每延迟 1 小时将使负面叙事主导权进一步转移至媒体和用户。\n\n**推荐框架:危机承担型**\n\n> 我们深感抱歉,这不应该发生。我们在 [时间] 发现了问题,[具体措施],我们承诺……\n\n此框架相比"技术故障型"在情绪疏导上效率高出 40%。核心要点:\n1. **主语明确**:以企业 / CEO 第一人称,避免被动语态\n2. **事实先行**:已知信息直接披露,未知信息注明"正在调查"\n3. **补偿同步**:补救措施与声明同时发出,不事后补充',
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'audience',
+ round: 1,
+ phase: 'starting',
+ data: {
+ thinking:
+ '需要先切分受众结构。数据泄露涉及 500 万用户,但情绪爆发的核心驱动者通常只占 5% 左右。我需要识别出哪些群体的愤怒会向外扩散,哪些群体是可稳定的沉默多数。',
+ content:
+ '## 受众结构分析\n\n| 受众类型 | 规模估算 | 关注核心 | 应对优先级 |\n|---|---|---|---|\n| 直接受损用户 | ~50 万 | 个人信息安全、补偿 | ★★★★★ |\n| 隐私敏感用户 | ~150 万 | 是否受影响、如何自保 | ★★★★ |\n| 科技媒体 & KOL | < 500 人 | 技术细节、企业诚信 | ★★★★ |\n| 监管与法律关注者 | 小众但高影响 | 合规与处罚 | ★★★ |\n| 普通围观用户 | 多数 | 吃瓜情绪,跟随舆论 | ★★ |\n\n**战略建议**:优先稳住直接受损用户的情绪,通过私域渠道一对一触达;KOL 需在 T+2h 内通过公关渠道给予独家信息简报。',
+ dimensionOutput: {
+ 核心受众: '直接受损用户(25–35 岁,高隐私敏感度)',
+ 情绪驱动因子: '不安全感 > 愤怒 > 补偿诉求',
+ 次级受众: '科技媒体与隐私倡导 KOL',
+ },
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'risk',
+ round: 1,
+ phase: 'starting',
+ data: {
+ content:
+ '## 风险研判(T+0 至 T+48h)\n\n**当前风险等级:高(4/5)**\n\n### 短期风险(T+0 ~ T+6h)\n- 媒体二次跟进,细节失控叙事\n- 竞品借势营销,转化离网用户\n- 用户自发传播截图,情绪放大\n\n### 中期风险(T+6h ~ T+24h)\n- 监管部门询问(概率 ~80%)\n- 集体诉讼舆论造势\n- KOL 发酵,话题进入热搜\n\n### 应对红线\n> ❌ 绝对不能:否认泄露规模、质疑用户截图真实性、强调技术复杂性而回避责任',
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'channel',
+ round: 1,
+ phase: 'starting',
+ data: {
+ content:
+ '## 渠道策略(首 6 小时)\n\n**渠道优先级排序:**\n\n1. **官方微博 / 公众号** — 主声明发布,需 T+2h 内上线\n2. **App 内 Push / 站内信** — 直达受损用户,降低流失率\n3. **客服热线预案** — 预计来电量激增 300%,需扩容预案\n4. **媒体渠道** — 给核心科技媒体记者 1 小时独家简报\n5. **KOL 沟通** — T+3h 前给隐私/科技类 KOL 发送详细说明',
+ },
+ },
+
+ {
+ type: 'coordinator',
+ data: {
+ divergeRound: null,
+ totalDivergeRounds: 3,
+ nextSpeakers: ['narrative', 'risk'],
+ focus: {
+ narrative: '深化叙事结构,给出 CEO 声明草稿(200 字以内)',
+ risk: '量化各风险节点的时间窗口和触发阈值',
+ },
+ draft: {
+ core_strategy: '主动承担型叙事,CEO 第一人称,T+2h 内首发声明',
+ key_messages: null,
+ audience_map: '直接受损用户优先,私域触达 + KOL 独家简报',
+ risk_warnings: null,
+ action_plan: null,
+ },
+ },
+ thinking:
+ '启动阶段各维度已基本就位。叙事师和受众师的框架高度吻合,渠道策略也清晰。接下来最需要深化的是风险时间轴的精确度和 CEO 声明的具体文本。',
+ },
+
+ // ─── 辩论阶段 第 1 轮 ─────────────────────────────
+ { type: 'round_start', round: 2, phase: 'diverging' },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'narrative',
+ round: 2,
+ phase: 'diverging',
+ data: {
+ thinking: '需要给出一个实际可用的 CEO 声明草稿。200 字以内,需要包含:承认事实、表达歉意、说明措施、给出补偿方案。',
+ content:
+ '## CEO 声明草稿(200 字)\n\n---\n\n> 我代表公司,向所有受影响的用户致以最诚挚的歉意。\n>\n> [日期],我们发现系统存在安全漏洞,部分用户数据遭到未经授权的访问,涉及用户约 500 万。我们在确认情况后第一时间启动了应急响应,已封堵漏洞,并向监管部门报告。\n>\n> 我们将在 24 小时内通过站内信向每位受影响用户发出个人通知,提供免费的一年身份保护服务,并设立专项补偿通道。\n>\n> 这是我们的责任。我们会把每一步进展告诉你。\n\n---\n\n**注**:声明中故意不使用"技术故障"措辞,也不使用"不可抗力",全程第一人称承担。',
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'risk',
+ round: 2,
+ phase: 'diverging',
+ data: {
+ content:
+ '## 风险时间轴(精化版)\n\n```\nT+0h ── 事件媒体首报(已触发)\nT+2h ── 官方声明发布窗口(最晚截止)\nT+4h ── 微博热搜触发阈值(~5000 评论)\nT+6h ── KOL 立场形成(此后成本 ×3)\nT+12h ── 情绪峰值预测(负面评论密度最高)\nT+18h ── 监管询问概率 80%(需准备合规版声明)\nT+24h ── 集体诉讼舆论造势窗口\nT+48h ── 舆情自然衰减期(若前期处置得当)\n```\n\n**最关键节点**:T+2h 声明发布。每延迟 1 小时,负面情绪扩散系数约增加 0.3σ。\n\n**法务建议**:声明中"约 500 万用户"措辞需法务确认,避免后续数字出入引发新一轮信任危机。',
+ },
+ },
+
+ {
+ type: 'coordinator',
+ data: {
+ divergeRound: 1,
+ totalDivergeRounds: 3,
+ nextSpeakers: ['audience', 'channel'],
+ focus: {
+ audience: '给出分层受众的具体触达话术,特别是直接受损用户的私域文案',
+ channel: '细化 T+0 至 T+6h 各渠道的具体执行 SOP,要有责任人和时间节点',
+ },
+ draft: {
+ core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
+ key_messages:
+ '第一条:承认事实 + 道歉;第二条:已采取措施;第三条:补偿方案;第四条:持续透明沟通承诺',
+ audience_map: '直接受损用户(私域专属通知)> 媒体 KOL(独家简报)> 公域大众(官方声明)',
+ risk_warnings:
+ 'T+2h 声明是关键节点;T+6h KOL 立场冻结;T+18h 监管接触概率高',
+ action_plan: null,
+ },
+ },
+ thinking:
+ '叙事师的 CEO 草稿质量很高,风险师的时间轴也很精确。现在需要补全受众触达的具体话术和渠道 SOP,才能形成可执行方案。',
+ },
+
+ // ─── 辩论阶段 第 2 轮 ─────────────────────────────
+ { type: 'round_start', round: 3, phase: 'diverging' },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'audience',
+ round: 3,
+ phase: 'diverging',
+ data: {
+ content:
+ '## 分层触达话术\n\n### 直接受损用户(站内信 / Push)\n\n> 您好,您的账号涉及本次安全事件。我们已完成漏洞修复,您的密码和支付信息**未受影响**。您可以在「设置 > 安全中心」查看详细报告,并领取您的专属保障服务。我们为您提前道歉。\n\n### 未受影响用户(公众号推文)\n\n> 关于近期数据安全事件,我们想向您说清楚……(附常见问题 FAQ,覆盖"我受影响吗""我的数据安全吗""我能做什么"三类高频问题)\n\n### 科技媒体 KOL(邮件 / 微信)\n\n> 提供独家:1) 技术修复细节简报;2) 受影响数据字段明细;3) CEO 电话采访机会(限 3 家)',
+ dimensionOutput: {
+ 直接受损用户核心诉求: '我的信息具体泄露了什么?我怎么保护自己?我能得到什么补偿?',
+ 最高情绪风险点: '若发现公司早知道却没告知,信任崩溃将不可逆',
+ 'KOL 沟通关键': '给技术细节,不给"公关说辞",换取客观报道',
+ },
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'channel',
+ round: 3,
+ phase: 'diverging',
+ data: {
+ content:
+ '## 渠道执行 SOP(T+0 ~ T+6h)\n\n| 时间 | 渠道 | 动作 | 责任人 |\n|---|---|---|---|\n| T+0.5h | 内部 | 危机小组集结,信息核查 | CEO / PR 总监 |\n| T+1h | 法务 | 声明文本法务审核 | 法务负责人 |\n| T+1.5h | 媒体 | 独家简报发送至核心媒体 | PR 经理 |\n| T+2h | 官方微博 / 公众号 | 首版声明发布 | PR 总监 |\n| T+2h | App Push / 站内信 | 受损用户专属通知推送 | 产品负责人 |\n| T+3h | KOL 微信群 | 科技 / 隐私类 KOL 定向沟通 | KOL 运营 |\n| T+4h | 客服热线 | 扩容至 2× 正常容量 | 客服总监 |\n| T+6h | 官方微博 | 第二轮进展更新 | PR 总监 |\n\n**备注**:声明发出前严格管控内部信息,防止员工个人账号提前扩散造成口径不一。',
+ },
+ },
+
+ {
+ type: 'coordinator',
+ data: {
+ divergeRound: 2,
+ totalDivergeRounds: 3,
+ nextSpeakers: ['narrative', 'risk', 'audience', 'channel'],
+ focus: {
+ narrative: '结合受众话术,给出完整的多版本话术库(含 FAQ 20 条)',
+ risk: '补充监管应对预案和集体诉讼舆论防御策略',
+ audience: '量化各渠道触达转化率预期,给出效果评估指标',
+ channel: '补充 T+6h 之后的渠道持续运营节奏(T+24h、T+48h)',
+ },
+ draft: {
+ core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明,配合多渠道协同执行',
+ key_messages:
+ '① 承认事实 + 道歉;② 已修复 + 措施说明;③ 补偿方案(1 年身份保护服务);④ 持续透明沟通承诺',
+ audience_map:
+ '直接受损用户(专属 Push/站内信)→ 科技媒体 KOL(独家简报)→ 公域大众(官方声明 + FAQ)',
+ risk_warnings:
+ '关键节点:T+2h 声明、T+6h KOL 立场、T+18h 监管询问。红线:不否认规模、不质疑用户、不推卸技术责任',
+ action_plan: '见渠道 SOP;T+2h 首轮声明,T+6h 进展更新,T+24h 详细报告,T+48h 补偿通道上线',
+ },
+ },
+ thinking:
+ '方案已基本成型,所有五个报告字段都有实质内容。第三轮将进行质量收敛,特别是风险监管预案和效果指标的补全。',
+ },
+
+ // ─── 辩论阶段 第 3 轮 ─────────────────────────────
+ { type: 'round_start', round: 4, phase: 'diverging' },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'risk',
+ round: 4,
+ phase: 'diverging',
+ data: {
+ content:
+ '## 监管应对预案\n\n### 合规声明独立版本\n需准备区别于公众声明的**监管版声明**,包含:\n- 精确影响范围(字段级别)\n- 漏洞技术原因(可向监管披露的版本)\n- 已通知/将通知的时间节点\n- 补救措施完成时间承诺\n\n### 集体诉讼舆论防御\n- 避免"私了"暗示:公开补偿方案优先于私下协商\n- 主动设立补偿通道比等待诉讼更能降低法律风险\n- 关键话术:**"我们的补偿方案对每位用户平等有效,无需诉讼即可申请"**',
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'audience',
+ round: 4,
+ phase: 'diverging',
+ data: {
+ content:
+ '## 效果评估指标体系\n\n| 指标 | 目标值 | 监测节点 |\n|---|---|---|\n| 微博话题情绪负向比 | < 50%(当前 67%) | T+6h、T+12h、T+24h |\n| 官方声明互动率 | > 3%(转发+评论/阅读) | T+4h |\n| 受损用户 App 卸载率 | < 5%(7 日内) | T+168h |\n| KOL 正向报道占比 | > 60% | T+24h |\n| 客服解决率 | > 85% | T+48h |\n\n**预警机制**:若 T+6h 负向情绪比仍 > 60%,需触发 Plan B(CEO 视频道歉 + 补偿方案提级)。',
+ },
+ },
+
+ {
+ type: 'coordinator',
+ data: {
+ divergeRound: 3,
+ totalDivergeRounds: 3,
+ nextSpeakers: [],
+ focus: {},
+ draft: {
+ core_strategy:
+ '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明,配合多渠道协同执行,核心定调:承认→道歉→修复→补偿→持续透明',
+ key_messages:
+ '① 承认事实 + 真诚道歉;② 已修复漏洞 + 措施说明;③ 一年免费身份保护服务;④ 24h 内个人通知;⑤ 持续透明沟通承诺',
+ audience_map:
+ '第一梯队:直接受损 500 万用户(专属 Push/站内信);第二梯队:科技媒体 KOL(独家简报 + CEO 采访);第三梯队:公域大众(官方声明 + FAQ)',
+ risk_warnings:
+ '关键节点:T+2h 声明(最高优先)、T+6h KOL 立场冻结、T+18h 监管询问(概率 80%)。Plan B 触发条件:T+6h 负向情绪 > 60%。红线三条:不否认规模、不质疑用户、不推卸技术责任',
+ action_plan:
+ 'T+0.5h 危机小组集结 → T+1h 法务审核 → T+1.5h 媒体独家简报 → T+2h 官方声明 + Push → T+3h KOL 定向沟通 → T+6h 第二轮更新 → T+24h 详细报告 → T+48h 补偿通道上线',
+ },
+ },
+ thinking: '三轮辩论已完成,所有字段已确认,方案逻辑自洽。进入收敛阶段生成最终报告。',
+ },
+
+ // ─── 收敛阶段 ─────────────────────────────────────
+ { type: 'round_start', round: 5, phase: 'closing' },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'narrative',
+ round: 5,
+ phase: 'closing',
+ data: {
+ content:
+ '## 最终确认:叙事框架\n\n完整话术库已整理,包含:\n- CEO 主声明(200 字正式版)\n- 用户 FAQ(20 条,覆盖 95% 预期问题)\n- KOL 独家简报模板\n- 监管合规声明模板\n\n所有版本均已通过"不否认、不推卸、不沉默"三条红线审核。',
+ },
+ },
+
+ {
+ type: 'agent_contribution',
+ agentId: 'channel',
+ round: 5,
+ phase: 'closing',
+ data: {
+ content:
+ '## 最终确认:渠道执行\n\nSOP 已锁定,T+0 ~ T+48h 全流程节点清晰,责任人到位。补充:T+72h 需发布第三方安全审计结果公告,进一步夯实信任修复。',
+ },
+ },
+
+ // ─── 最终报告 ──────────────────────────────────────
+ {
+ type: 'final_report',
+ data: {
+ report: {
+ core_strategy:
+ '**核心定调:主动承担,用户优先**\n\n本次危机响应采用"危机—承担—改变"三段式叙事结构,以 CEO 第一人称署名声明为核心,在媒体首报后 2 小时内完成官方表态,抢占舆论定性窗口。\n\n核心原则:\n- **承认事实**:不回避泄露规模,不使用"技术故障"等推卸性措辞\n- **承担责任**:以企业主体第一人称,而非被动语态\n- **行动优先**:补偿方案与声明同步发出,不事后补充\n- **持续透明**:承诺 24/48/72 小时三轮进展更新',
+ key_messages:
+ '**核心话术五条(按优先级)**\n\n1. **道歉承担**:"我们深感抱歉,这不应该发生,这是我们的责任。"\n2. **已采取行动**:"漏洞已于 [时间] 修复完毕,您的密码和支付信息未受影响。"\n3. **补偿承诺**:"我们将为每位受影响用户提供一年免费身份保护服务,并设立专项补偿通道。"\n4. **个人通知**:"我们将在 24 小时内通过站内信向每位受影响用户发出个人通知。"\n5. **透明承诺**:"我们会把每一步进展告诉你,包括监管沟通结果和第三方审计报告。"',
+ audience_map:
+ '**受众分层触达策略**\n\n**第一梯队:直接受损用户(约 500 万)**\n- 触达渠道:App Push + 站内信(T+2h 同步发出)\n- 核心内容:个人影响说明 + 补偿申请入口 + 自保指南\n- 效果指标:7 日卸载率 < 5%\n\n**第二梯队:科技媒体 & 隐私 KOL(< 500 人)**\n- 触达渠道:邮件 / 微信独家简报(T+1.5h),CEO 采访机会(限 3 家)\n- 核心内容:技术细节简报(非公关口径)+ 后续透明节奏\n- 效果指标:T+24h 正向报道占比 > 60%\n\n**第三梯队:公域大众**\n- 触达渠道:官方微博 / 公众号 + FAQ 长图文\n- 效果指标:T+12h 情绪负向比 < 50%',
+ risk_warnings:
+ '**关键风险节点与应对预案**\n\n| 时间节点 | 风险 | 触发阈值 | 预案 |\n|---|---|---|---|\n| T+2h | 声明延迟 | 未能按时发出 | 临时简短声明"正在调查,1h内更新" |\n| T+6h | KOL 立场冻结 | 负向情绪 > 60% | 触发 Plan B:CEO 视频道歉 |\n| T+18h | 监管询问 | 概率 80% | 合规版声明已备,法务直接响应 |\n| T+24h | 集体诉讼舆论 | 法律类话题进热搜 | 公开补偿通道,阻断诉讼动机 |\n\n**绝对红线(不可触碰):**\n- ❌ 否认或缩小泄露规模\n- ❌ 质疑用户截图或举报真实性\n- ❌ 以技术复杂性规避问责',
+ action_plan:
+ '**48 小时行动计划**\n\n```\nT+0.5h 危机小组集结(CEO / PR总监 / 法务 / 产品)\nT+1h 声明文本完成法务审核\nT+1.5h 核心媒体独家简报发出(3家)\nT+2h ★ 官方声明发布(微博 + 公众号)\n + App Push / 站内信 推送受损用户\nT+3h KOL 微信定向沟通(20人名单)\nT+4h 客服热线扩容至 2× 正常容量\nT+6h 第二轮进展更新发布\nT+18h 监管询问响应准备就位\nT+24h 详细技术说明 + 影响范围报告发布\nT+48h 专项补偿通道正式上线\nT+72h 第三方安全审计启动公告\n```',
+ },
+ dimensions: {
+ 叙事框架: {
+ label: '叙事分析师',
+ latest_analysis:
+ '采用"危机承担型"框架,CEO 署名,200 字主声明 + 话术库覆盖全场景。关键洞察:补偿与声明同步发出可降低 31% 愤怒情绪。',
+ },
+ 受众分析: {
+ label: '受众洞察师',
+ latest_analysis:
+ '直接受损用户为最高优先级(隐私敏感度 8.3/10),KOL 态度在 6h 内冻结。分层触达策略已量化,7 日卸载率目标 < 5%。',
+ },
+ 风险研判: {
+ label: '风险评估师',
+ latest_analysis:
+ 'T+2h 为最关键窗口,每延迟 1h 负面情绪扩散系数增加 0.3σ。T+18h 监管询问概率 80%,合规预案已备。',
+ },
+ 渠道传播: {
+ label: '渠道策略师',
+ latest_analysis:
+ '五路渠道协同(官方微博/公众号、App Push、媒体简报、KOL 沟通、客服扩容),SOP 已锁定到人到时间。',
+ },
+ },
+ meta: {
+ total_rounds: 5,
+ diverge_rounds: 3,
+ agent_count: 4,
+ },
+ },
+ },
+];
+
+export const MOCK_SESSION = {
+ taskContext: MOCK_TASK_CONTEXT,
+ status: 'waiting_human',
+ phase: 'diverging',
+ coordinatorMode: 'human_review',
+ currentRound: 4,
+ divergeRounds: 3,
+ reportSchema: REPORT_SCHEMA,
+ agents: AGENTS,
+ agentStates: AGENT_STATES,
+ timeline: TIMELINE,
+ sharedMemory: SHARED_MEMORY,
+ finalReportPartial: {
+ core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
+ key_messages: '① 道歉承担;② 已修复;③ 补偿方案;④ 个人通知;⑤ 透明承诺',
+ audience_map: '直接受损用户(私域)> 媒体 KOL(独家)> 公域大众(声明)',
+ risk_warnings: 'T+2h 声明关键节点;Plan B 触发条件;三条红线',
+ action_plan: 'T+2h 声明 → T+6h 更新 → T+24h 报告 → T+48h 补偿通道',
+ },
+ currentFinalField: null,
+ pendingReview: {
+ divergeRound: 2,
+ totalDivergeRounds: 3,
+ nextSpeakers: ['narrative', 'risk'],
+ focus: {
+ narrative: '深化叙事结构,给出 CEO 声明草稿(200 字以内),避免"技术故障"措辞',
+ risk: '量化各风险节点的时间窗口和触发阈值,补充监管应对预案',
+ },
+ draft: {
+ core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
+ key_messages: '① 道歉承担;② 已修复;③ 补偿方案;④ 个人通知;⑤ 透明承诺',
+ audience_map: '直接受损用户(私域)> 媒体 KOL(独家)> 公域大众(声明)',
+ risk_warnings: null,
+ action_plan: null,
+ },
+ gaps: [
+ 'CEO 是否愿意出面公开致歉尚未确认,影响叙事策略选择',
+ '泄露数据的具体字段范围尚未完全核查,影响话术精准度',
+ '监管是否已介入需在声明前确认,需准备合规版本',
+ ],
+ },
+ reviewSubmitting: false,
+ reviewRevising: false,
+ errors: [],
+ startSession: () => {},
+ stopSession: () => {},
+ approveCoordinator: async () => {},
+ reviseCoordinator: async () => {},
+ hydrateFromSnapshot: () => {},
+ reset: () => {},
+};
diff --git a/frontend-web/src/strategy-components/components/qa/MopeChat.tsx b/frontend-web/src/strategy-components/components/qa/MopeChat.tsx
new file mode 100644
index 00000000..abc48e34
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/MopeChat.tsx
@@ -0,0 +1,309 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { useSession } from '@/hooks/useSession';
+import { useTheme } from '../../../contexts/ThemeContext';
+import ConfigPanel from './components/ConfigPanel';
+import Sidebar from './components/Sidebar';
+import Timeline from './components/Timeline';
+import ProgressSidebar from './components/ProgressSidebar';
+import ScenarioConfigPage from './components/ScenarioConfigPage';
+import './MopeChat.css';
+
+type StartConfig = {
+ task_context?: string;
+ [key: string]: any;
+};
+
+type SessionError = {
+ agent_id?: string;
+ error?: string;
+};
+
+type SessionHistoryPayload = {
+ typeName: string;
+ templateName: string;
+ resultJson: Record
;
+};
+
+const MOPE_HISTORY_API_BASE = '/api/mope/history';
+
+/**
+ * 保存模型完整生成结果。
+ *
+ * 接口暂未提供,先把调用方法和请求体结构固定下来:
+ * - typeName:类型名字,目前使用用户输入的任务背景。
+ * - templateName:用户选择的生成模板名称。
+ * - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
+ */
+async function saveMopeHistory(payload: SessionHistoryPayload) {
+ const response = await fetch(MOPE_HISTORY_API_BASE, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
+ }
+
+ return response.json().catch(() => null);
+}
+
+/**
+ * 获取当前用户最近一次 MOPE 生成历史。
+ *
+ * 接口暂未提供,先约定返回值就是保存时的 resultJson。
+ * 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
+ */
+async function fetchMopeHistory(): Promise | null> {
+ const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
+
+ if (response.status === 404 || response.status === 204) return null;
+ if (!response.ok) {
+ throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data?.resultJson || data || null;
+}
+
+export default function MopeChat() {
+ const session = useSession() as any;
+ const { theme } = useTheme() as { theme?: string };
+ const isDark = theme === 'dark';
+ const [taskContext, setTaskContext] = useState('');
+ const [idlePage, setIdlePage] = useState('home');
+ const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
+ const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
+
+ const {
+ status,
+ phase,
+ coordinatorMode,
+ currentRound,
+ divergeRounds,
+ reportSchema,
+ agents,
+ agentStates,
+ timeline,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+ pendingReview,
+ reviewSubmitting,
+ reviewRevising,
+ errors,
+ startSession,
+ stopSession,
+ approveCoordinator,
+ reviseCoordinator,
+ hydrateFromSnapshot,
+ reset,
+ } = session;
+
+ const historyLoadedRef = useRef(false);
+ const savedHistoryKeyRef = useRef(null);
+ const currentConfigRef = useRef(null);
+
+ const buildHistorySnapshot = useCallback(() => ({
+ status,
+ phase,
+ coordinatorMode,
+ currentRound,
+ divergeRounds,
+ reportSchema,
+ agents,
+ agentStates,
+ timeline,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+ config: currentConfigRef.current,
+ }), [
+ status,
+ phase,
+ coordinatorMode,
+ currentRound,
+ divergeRounds,
+ reportSchema,
+ agents,
+ agentStates,
+ timeline,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+ ]);
+
+ const handleStart = (config: StartConfig, backendUrl: string) => {
+ currentConfigRef.current = config;
+ savedHistoryKeyRef.current = null;
+ setTaskContext(config.task_context || '');
+ startSession(config, backendUrl);
+ };
+
+ const handleStop = () => {
+ stopSession();
+ };
+
+ const handleReset = () => {
+ reset();
+ currentConfigRef.current = null;
+ savedHistoryKeyRef.current = null;
+ setTaskContext('');
+ };
+
+ useEffect(() => {
+ if (historyLoadedRef.current) return;
+ historyLoadedRef.current = true;
+
+ void fetchMopeHistory()
+ .then((snapshot) => {
+ if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
+ currentConfigRef.current = snapshot.config || null;
+ setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
+ hydrateFromSnapshot(snapshot);
+ })
+ .catch((error) => {
+ console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
+ });
+ }, [hydrateFromSnapshot]);
+
+ useEffect(() => {
+ if (status !== 'done' || !timeline?.length) return;
+ const snapshot = buildHistorySnapshot();
+ const templateName = currentConfigRef.current?.template_name || '未命名模板';
+ const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
+ const saveKey = `${typeName}::${templateName}::${timeline.length}`;
+ if (savedHistoryKeyRef.current === saveKey) return;
+ savedHistoryKeyRef.current = saveKey;
+
+ void saveMopeHistory({
+ typeName,
+ templateName,
+ resultJson: {
+ ...snapshot,
+ typeName,
+ templateName,
+ savedAt: new Date().toISOString(),
+ },
+ }).catch((error) => {
+ console.warn('MOPE 历史记录保存失败:', error);
+ savedHistoryKeyRef.current = null;
+ });
+ }, [status, timeline, taskContext, buildHistorySnapshot]);
+
+ if (status === 'idle') {
+ if (idlePage === 'scenario_config') {
+ return (
+
+ setIdlePage('home')} />
+
+ );
+ }
+
+ return (
+
+ setIdlePage('scenario_config')}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {errors.length > 0 && errors.map((e: SessionError, i: number) => (
+
+ {e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/QA.tsx b/frontend-web/src/strategy-components/components/qa/QA.tsx
new file mode 100644
index 00000000..1964b56e
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/QA.tsx
@@ -0,0 +1,2184 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ Send,
+ Upload,
+ RotateCcw,
+ Bookmark,
+ History,
+ Sparkles,
+ BookOpen,
+ Brain,
+ GitCompare,
+ MessageSquare,
+ FileText,
+ CheckCircle,
+ AlertCircle,
+ Bot,
+ Database,
+ Layers,
+ Cpu,
+ Target,
+ Search,
+ CircleHelp,
+ ChevronRight,
+ Plus,
+ X,
+ Copy,
+ RefreshCw,
+ ArrowRight,
+ ArrowLeft,
+ Download,
+ Save,
+ ChevronDown,
+ ChevronUp,
+ MessageCircle,
+ Users,
+ FileCheck,
+ AlertTriangle,
+ Handshake,
+ Edit3,
+ ListChecks,
+ Play,
+ Pause,
+ UserCheck,
+ UserX,
+ Hash,
+ Clock,
+ Zap,
+ ClipboardList,
+ TrendingUp,
+} from 'lucide-react';
+import { useTheme } from '../../../contexts/ThemeContext';
+import { useChatContext } from '../../contexts/ChatContext';
+import QATypeSwitch from './QATypeSwitch';
+
+type QAMode = 'single-round' | 'multi-round' | 'deep-search';
+type QAProvider = 'general' | 'agent' | 'multi-agent';
+type ViewMode = 'single' | 'compare';
+
+interface Agent {
+ id: string;
+ name: string;
+ category: string;
+ tags: string[];
+ description: string;
+ skills: string[];
+ dataSources: string[];
+}
+
+interface Message {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ sources?: SourceItem[];
+ agentName?: string;
+ skills?: string[];
+ dataSources?: string[];
+ functions?: string[];
+}
+
+interface SourceItem {
+ title: string;
+ type: string;
+ snippet: string;
+}
+
+interface CompareResult {
+ leftMode: QAMode;
+ rightMode: QAMode;
+ leftContent: string;
+ rightContent: string;
+ differences?: {
+ consensus: string;
+ differences: string;
+ leftAdvantage: string;
+ rightAdvantage: string;
+ };
+}
+
+// 多智能体协同分析相关类型
+interface AgentResponse {
+ id: string;
+ agentId: string;
+ agentName: string;
+ agentCategory: string;
+ status: 'pending' | 'responding' | 'completed' | 'adopted' | 'pending-confirm';
+ coreJudgment: string;
+ content: string;
+ structuredSummary?: {
+ key: string;
+ value: string;
+ }[];
+ timestamp: Date;
+}
+
+// 多智能体消息类型
+interface MultiAgentMessage {
+ id: string;
+ type: 'user' | 'agent';
+ agentName?: string;
+ content: string;
+ timestamp: Date;
+ isFollowUp?: boolean;
+ parentMessageId?: string;
+}
+
+interface AdoptedView {
+ id: string;
+ agentName: string;
+ content: string;
+ timestamp: Date;
+}
+
+interface PendingQuestion {
+ id: string;
+ content: string;
+ relatedAgents: string[];
+ timestamp: Date;
+}
+
+interface SummaryDraft {
+ title: string;
+ sections: {
+ title: string;
+ content: string;
+ }[];
+}
+
+// 场景模板类型
+interface SceneTemplate {
+ id: string;
+ name: string;
+ type: string;
+ description: string;
+ agentIds: string[];
+}
+
+// 场景模板数据
+const sceneTemplates: SceneTemplate[] = [
+ {
+ id: 'scene-1',
+ name: '宣传策略生成',
+ type: '策略生成',
+ description: '生成完整的品牌传播策略方案',
+ agentIds: ['goal-agent', 'audience-agent', 'brand-agent', 'channel-agent', 'content-agent', 'narrative-agent', 'behavior-agent', 'risk-agent'],
+ },
+ {
+ id: 'scene-2',
+ name: '宣传方案推演',
+ type: '方案推演',
+ description: '对现有方案进行多角度推演验证',
+ agentIds: ['goal-agent', 'audience-agent', 'behavior-agent', 'risk-agent', 'content-agent', 'channel-agent'],
+ },
+ {
+ id: 'scene-3',
+ name: '宣传活动复盘',
+ type: '效果复盘',
+ description: '对宣传活动效果进行多维度复盘分析',
+ agentIds: ['goal-agent', 'audience-agent', 'behavior-agent', 'channel-agent', 'risk-agent'],
+ },
+ {
+ id: 'scene-4',
+ name: '舆情应对策略',
+ type: '风险应对',
+ description: '制定舆情风险应对和处置策略',
+ agentIds: ['risk-agent', 'audience-agent', 'brand-agent', 'content-agent', 'narrative-agent', 'channel-agent', 'behavior-agent'],
+ },
+ {
+ id: 'scene-5',
+ name: '多版本策略比较',
+ type: '策略对比',
+ description: '对比分析多个策略方案的优劣',
+ agentIds: ['goal-agent', 'audience-agent', 'brand-agent', 'content-agent'],
+ },
+];
+
+// 智能体列表 - 按五大类别分类
+const agents: Agent[] = [
+ // 规划类智能体
+ {
+ id: 'goal-agent',
+ name: 'GoalAgent',
+ category: '规划类',
+ tags: ['目标拆解', 'KPI设定', '优先级'],
+ description: '专业的目标规划智能体,能够帮助拆解任务目标并设定可衡量的KPI',
+ skills: ['目标拆解', 'KPI设定', '优先级排序'],
+ dataSources: ['知识库', '历史数据分析'],
+ },
+ {
+ id: 'brand-agent',
+ name: 'BrandAgent',
+ category: '规划类',
+ tags: ['品牌定位', '传播策略', '调性设计'],
+ description: '专注品牌建设与传播策略的规划智能体',
+ skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
+ dataSources: ['知识库', '行业数据库'],
+ },
+ {
+ id: 'content-agent',
+ name: 'ContentAgent',
+ category: '规划类',
+ tags: ['文案撰写', '创意生成', '内容规划'],
+ description: '智能内容创作规划助手,支持多种形式的内容生成规划',
+ skills: ['文案撰写', '创意生成', '多形式内容'],
+ dataSources: ['知识库', '优秀案例库'],
+ },
+ {
+ id: 'channel-agent',
+ name: 'ChannelAgent',
+ category: '规划类',
+ tags: ['渠道选择', '投放策略', '媒介规划'],
+ description: '专业的渠道规划智能体,制定最优渠道组合策略',
+ skills: ['渠道选择', '投放策略规划', '效果评估'],
+ dataSources: ['Y系统', 'A系统', '行业数据'],
+ },
+ {
+ id: 'narrative-agent',
+ name: 'NarrativeAgent',
+ category: '规划类',
+ tags: ['故事线', '情感设计', '叙事策略'],
+ description: '专业的叙事规划智能体,构建有感染力的内容故事线',
+ skills: ['故事线设计', '情感共鸣设计', '内容叙事'],
+ dataSources: ['优秀案例库', '内容分析'],
+ },
+ // MNTY类智能体
+ {
+ id: 'mnty-agent',
+ name: 'MNTYAgent',
+ category: 'MNTY类',
+ tags: ['心智模型', '用户认知', '心智定位'],
+ description: '专注用户心智模型分析,挖掘用户深层认知和心智定位',
+ skills: ['心智模型构建', '认知路径分析', '心智定位设计'],
+ dataSources: ['用户研究数据', '心理学理论库'],
+ },
+ // 效果PG类智能体
+ {
+ id: 'behavior-agent',
+ name: 'BehaviorAgent',
+ category: '效果PG类',
+ tags: ['用户行为', '行为路径', '效果预测'],
+ description: '专注用户行为模式分析,预测和优化用户行为效果',
+ skills: ['用户行为分析', '行为路径设计', '转化优化'],
+ dataSources: ['Y系统', '行为数据'],
+ },
+ {
+ id: 'pg-agent',
+ name: 'PGAgent',
+ category: '效果PG类',
+ tags: ['效果评估', 'PG分析', '数据洞察'],
+ description: '专业的效果评估智能体,提供精准的数据分析和洞察',
+ skills: ['效果评估', '数据可视化', '趋势预测'],
+ dataSources: ['数据分析平台', '监控系统'],
+ },
+ // 知识分析类智能体
+ {
+ id: 'audience-agent',
+ name: 'AudienceAgent',
+ category: '知识分析类',
+ tags: ['受众分层', '画像抽取', '知识分析'],
+ description: '专业的受众知识分析智能体,识别目标受众特征并生成画像',
+ skills: ['受众分层', '核心受众识别', '画像抽取'],
+ dataSources: ['知识库', 'Y系统', 'A系统'],
+ },
+ {
+ id: 'knowledge-agent',
+ name: 'KnowledgeAgent',
+ category: '知识分析类',
+ tags: ['知识图谱', '语义分析', '关联挖掘'],
+ description: '专业的知识分析智能体,构建知识图谱并进行深度语义分析',
+ skills: ['知识图谱构建', '语义分析', '关联挖掘'],
+ dataSources: ['知识库', '文献数据库'],
+ },
+ // 协同管理类智能体
+ {
+ id: 'risk-agent',
+ name: 'RiskAgent',
+ category: '协同管理类',
+ tags: ['风险识别', '预案设计', '协同管理'],
+ description: '专注风险识别与协同管理的智能体,制定完善的应急预案',
+ skills: ['风险识别', '影响评估', '预案设计'],
+ dataSources: ['历史案例', '风险数据库'],
+ },
+ {
+ id: 'collab-agent',
+ name: 'CollabAgent',
+ category: '协同管理类',
+ tags: ['协同调度', '资源协调', '进度管理'],
+ description: '专业的协同管理智能体,统筹调度多方资源协调推进',
+ skills: ['协同调度', '资源协调', '进度管理'],
+ dataSources: ['任务管理系统', '资源数据库'],
+ },
+];
+
+interface QAProps {
+ initialMode?: 'new' | 'general' | 'agent' | 'multi-agent';
+}
+
+const QA: React.FC = ({ initialMode }) => {
+ const { theme } = useTheme();
+ const { addMessage } = useChatContext();
+ const isDark = theme === 'dark';
+
+ // 状态管理
+ const [viewMode, setViewMode] = useState('single');
+ const [qaProvider, setQaProvider] = useState('general');
+ const [qaMode, setQaMode] = useState('single-round');
+ const [selectedAgent, setSelectedAgent] = useState(agents[0]);
+ const [compareLeftMode, setCompareLeftMode] = useState('single-round');
+ const [compareRightMode, setCompareRightMode] = useState('deep-search');
+
+ // 新建问答确认对话框
+ const [showNewQADialog, setShowNewQADialog] = useState(false);
+
+ // 处理 initialMode - 新建问答时清空会话
+ useEffect(() => {
+ if (initialMode === 'new') {
+ // 新建问答模式:清空会话,保持通用问答模式
+ setMessages([]);
+ setCompareResult(null);
+ setInputValue('');
+ setQaProvider('general');
+ setViewMode('single');
+ } else if (initialMode === 'agent') {
+ // 智能体问答模式
+ setQaProvider('agent');
+ } else if (initialMode === 'general') {
+ // 通用问答模式
+ setQaProvider('general');
+ } else if (initialMode === 'multi-agent') {
+ // 多智能体协同模式
+ setQaProvider('multi-agent');
+ setMessages([]);
+ setCompareResult(null);
+ setInputValue('');
+ }
+ }, [initialMode]);
+
+ // 知识范围
+ const [knowledgeScope, setKnowledgeScope] = useState<'all' | 'specific-kb' | 'specific-domain'>('all');
+
+ // 回答偏好
+ const [showSources, setShowSources] = useState(true);
+ const [showProcess, setShowProcess] = useState(false);
+
+ // 对比设置
+ const [compareLayout, setCompareLayout] = useState<'horizontal' | 'vertical'>('horizontal');
+ const [autoSummarizeDiff, setAutoSummarizeDiff] = useState(true);
+ const [recommendBetter, setRecommendBetter] = useState(true);
+
+ // 消息和结果
+ const [messages, setMessages] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [compareResult, setCompareResult] = useState(null);
+ const [agentSearchQuery, setAgentSearchQuery] = useState('');
+
+ // 检索过程展示
+ const [searchProcess, setSearchProcess] = useState({
+ currentMode: '单轮RAG',
+ rounds: 1,
+ hits: 5,
+ queryRewrite: '',
+ });
+
+ // 多智能体会商状态
+ const [discussionStatus, setDiscussionStatus] = useState<'idle' | 'in_progress' | 'paused' | 'completed'>('idle');
+ const [currentRound, setCurrentRound] = useState(1);
+ const [currentTopic, setCurrentTopic] = useState('五四青年节宣传方向讨论');
+ const [speakingMode, setSpeakingMode] = useState<'broadcast' | 'targeted' | 'response'>('broadcast');
+ const [selectedTargetAgent, setSelectedTargetAgent] = useState(null);
+
+ // 共识与分歧
+ const [consensusItems, setConsensusItems] = useState<{id: string; content: string; agents: string[]}[]>([]);
+ const [disagreementItems, setDisagreementItems] = useState<{id: string; content: string; agents: string[]; details: string}[]>([]);
+ const [conclusionDraft, setConclusionDraft] = useState('');
+
+ // 消息操作状态
+ const [messageAction, setMessageAction] = useState<'none' | 'adopt' | 'disagree' | 'followup' | 'respond'>('none');
+ const [selectedMessageId, setSelectedMessageId] = useState(null);
+
+ // 原有状态(保持兼容性)
+ const [selectedMultiAgents, setSelectedMultiAgents] = useState([agents[0], agents[4]]);
+ const [agentResponses, setAgentResponses] = useState([]);
+ const [adoptedViews, setAdoptedViews] = useState([]);
+ const [pendingQuestions, setPendingQuestions] = useState([]);
+ const [summaryDraft, setSummaryDraft] = useState({
+ title: '多智能体联合分析报告',
+ sections: [],
+ });
+ const [taskInfo, setTaskInfo] = useState({
+ name: '五四青年节宣传方向预分析',
+ goal: '形成一版多智能体联合分析报告',
+ status: '讨论中',
+ background: '五四青年节品牌传播活动需要明确目标受众和传播策略',
+ keyQuestions: ['核心受众是谁?', '如何设计参与行为?', '叙事方式如何选择?', '有哪些风险需要关注?'],
+ });
+ const [sendTarget, setSendTarget] = useState<'all' | string>('all');
+ const [multiAgentFilter, setMultiAgentFilter] = useState<'all' | 'completed' | 'pending' | 'adopted'>('all');
+ const [isMultiAgentLoading, setIsMultiAgentLoading] = useState(false);
+ const [multiAgentMessages, setMultiAgentMessages] = useState([]);
+ const [rightPanelTab, setRightPanelTab] = useState<'structured' | 'report'>('structured');
+ const [messageFilter, setMessageFilter] = useState<'all' | 'user' | 'adopted'>('all');
+ const [agentMessageFilter, setAgentMessageFilter] = useState('all');
+ const [messageSort, setMessageSort] = useState<'time' | 'agent'>('time');
+
+ // 左侧菜单折叠状态
+ const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false);
+ const [qaConfigCollapsed, setQaConfigCollapsed] = useState(false);
+ const [agentListCollapsed, setAgentListCollapsed] = useState(false);
+
+ // 多智能体协同 - 智能体选择状态
+ const [showAgentSelector, setShowAgentSelector] = useState(false);
+ const [showSceneSelector, setShowSceneSelector] = useState(false);
+ const [selectedSceneTemplate, setSelectedSceneTemplate] = useState(null);
+
+ // 过滤智能体
+ const filteredAgents = agents.filter(agent =>
+ agent.name.toLowerCase().includes(agentSearchQuery.toLowerCase()) ||
+ agent.category.toLowerCase().includes(agentSearchQuery.toLowerCase()) ||
+ agent.tags.some(tag => tag.toLowerCase().includes(agentSearchQuery.toLowerCase()))
+ );
+
+ // 多智能体协同分析处理函数
+ const handleMultiAgentSend = () => {
+ if (!inputValue.trim()) return;
+
+ setIsMultiAgentLoading(true);
+ const targets = sendTarget === 'all' ? selectedMultiAgents : selectedMultiAgents.filter(a => a.id === sendTarget);
+
+ // 添加用户消息到消息流
+ const userMessageId = `user-${Date.now()}`;
+ const userMessage: MultiAgentMessage = {
+ id: userMessageId,
+ type: 'user',
+ content: inputValue,
+ timestamp: new Date(),
+ };
+ setMultiAgentMessages(prev => [...prev, userMessage]);
+
+ // 为每个目标智能体创建响应
+ const newResponses: AgentResponse[] = targets.map((agent, idx) => ({
+ id: `${Date.now()}-${agent.id}`,
+ agentId: agent.id,
+ agentName: agent.name,
+ agentCategory: agent.category,
+ status: 'responding' as const,
+ coreJudgment: '分析中...',
+ content: '',
+ timestamp: new Date(),
+ }));
+
+ setAgentResponses(prev => [...prev, ...newResponses]);
+ setInputValue('');
+
+ // 模拟智能体响应
+ setTimeout(() => {
+ const completedResponses = targets.map((agent, idx) => {
+ const responses: Record = {
+ 'audience-agent': {
+ core: '核心受众应优先聚焦18-25岁基层青年群体',
+ content: '从任务背景和活动语境看,核心传播对象应聚焦能够形成参与、表达与扩散的人群,年轻基层群体更符合当前任务目标。该群体活跃于社交媒体,对节日话题有天然共鸣,是实现传播裂变的关键。',
+ summary: [
+ { key: 'core_audience', value: '18-25岁基层青年' },
+ { key: 'secondary_audience', value: '25-30岁职场新人' },
+ { key: 'barriers', value: '表达门槛、品牌距离感' },
+ ],
+ },
+ 'goal-agent': {
+ core: '目标应分为参与层和传播层两层设计',
+ content: '建议将五四青年节活动目标拆解为两个层次:参与层目标是引导青年参与活动并产生内容;传播层目标是鼓励青年主动分享传播,形成话题裂变。两层目标相互关联,传播层目标的达成依赖于参与层的体验设计。',
+ summary: [
+ { key: '参与目标', value: '引导10000+青年参与' },
+ { key: '传播目标', value: '产生5000+UGC内容' },
+ { key: '核心KPI', value: '话题阅读量突破500万' },
+ ],
+ },
+ 'behavior-agent': {
+ core: '用户行为路径应设计为"触发-参与-分享"三阶段',
+ content: '基于对年轻用户行为模式的分析,建议设计"触发-参与-分享"的完整行为路径。触发阶段通过话题挑战赛引发兴趣;参与阶段通过互动任务降低门槛;分享阶段通过激励机制促进主动传播。',
+ summary: [
+ { key: '触发机制', value: '话题挑战赛' },
+ { key: '参与路径', value: '任务闯关+创作征集' },
+ { key: '分享激励', value: '荣誉体系+实物奖励' },
+ ],
+ },
+ 'narrative-agent': {
+ core: '叙事建议采用"真实青年故事"为主线',
+ content: '建议采用纪实风格的叙事策略,讲述真实青年人物的故事,而非虚构的营销叙事。通过真实故事的感染力和共鸣感,提升青年群体对活动的认同和参与意愿。',
+ summary: [
+ { key: '叙事风格', value: '纪实风格' },
+ { key: '故事原型', value: '真实青年人物' },
+ { key: '情感基调', value: '正向、热血、有共鸣' },
+ ],
+ },
+ 'risk-agent': {
+ core: '主要风险在于话题敏感性和UGC内容合规',
+ content: '需要关注两方面风险:一是政治敏感话题的把控,需严格审核所有内容;二是UGC内容可能带来的舆论风险。建议建立完善的内容审核机制和应急预案。',
+ summary: [
+ { key: '风险类型', value: '内容合规风险' },
+ { key: '风险等级', value: '中等' },
+ { key: '应对策略', value: '前置审核+应急预案' },
+ ],
+ },
+ };
+
+ const mockData = responses[agent.id] || {
+ core: '该智能体已完成分析',
+ content: '基于专业能力进行了深入分析。',
+ summary: [{ key: '分析结论', value: '待补充' }],
+ };
+
+ const responseId = `${Date.now()}-${agent.id}`;
+
+ // 添加智能体回答到消息流
+ const agentMessage: MultiAgentMessage = {
+ id: responseId,
+ type: 'agent',
+ agentName: agent.name,
+ content: `${mockData.core}\n\n${mockData.content}`,
+ timestamp: new Date(),
+ };
+ setMultiAgentMessages(prev => [...prev, agentMessage]);
+
+ return {
+ id: responseId,
+ agentId: agent.id,
+ agentName: agent.name,
+ agentCategory: agent.category,
+ status: 'completed' as const,
+ coreJudgment: mockData.core,
+ content: mockData.content,
+ structuredSummary: mockData.summary,
+ timestamp: new Date(),
+ };
+ });
+
+ setAgentResponses(prev => {
+ const existing = prev.filter(r => !targets.find(t => `${Date.now()}-${t.id}`.includes(r.id)));
+ return [...existing, ...completedResponses];
+ });
+ setIsMultiAgentLoading(false);
+ }, 1500);
+ };
+
+ const handleAdoptResponse = (responseId: string) => {
+ const response = agentResponses.find(r => r.id === responseId);
+ if (response) {
+ setAdoptedViews(prev => [...prev, {
+ id: `adopted-${Date.now()}`,
+ agentName: response.agentName,
+ content: response.coreJudgment,
+ timestamp: new Date(),
+ }]);
+ setAgentResponses(prev => prev.map(r =>
+ r.id === responseId ? { ...r, status: 'adopted' as const } : r
+ ));
+ }
+ };
+
+ const handlePendingConfirm = (responseId: string) => {
+ setAgentResponses(prev => prev.map(r =>
+ r.id === responseId ? { ...r, status: 'pending-confirm' as const } : r
+ ));
+ };
+
+ const handleWriteToReport = (responseId: string) => {
+ const response = agentResponses.find(r => r.id === responseId);
+ if (response) {
+ setSummaryDraft(prev => ({
+ ...prev,
+ sections: [...prev.sections, {
+ title: `${response.agentName}分析结论`,
+ content: response.coreJudgment + '\n\n' + response.content,
+ }],
+ }));
+ }
+ };
+
+ const handleGenerateSummary = () => {
+ const summaryPoints = agentResponses
+ .filter(r => r.status === 'completed' || r.status === 'adopted')
+ .map(r => `- ${r.agentName}:${r.coreJudgment}`);
+
+ setSummaryDraft(prev => ({
+ ...prev,
+ sections: [
+ { title: '任务背景', content: taskInfo.name },
+ { title: '参与智能体', content: selectedMultiAgents.map(a => a.name).join('、') },
+ { title: '综合结论', content: summaryPoints.join('\n') },
+ { title: '待确认问题', content: pendingQuestions.map(q => `- ${q.content}`).join('\n') || '无' },
+ ],
+ }));
+ };
+
+ const handleExportReport = () => {
+ alert('报告导出功能开发中...');
+ };
+
+ const handleSaveMultiAgentRecord = () => {
+ alert('协同分析记录已保存');
+ };
+
+ const handleTransferToScene = () => {
+ alert('转场景任务功能开发中...');
+ };
+
+ const handleMultiAgentFollowUp = (agentId: string) => {
+ setSendTarget(agentId);
+ };
+
+ // 添加智能体到协同分析
+ const handleAddAgent = (agent: Agent) => {
+ if (!selectedMultiAgents.find(a => a.id === agent.id)) {
+ setSelectedMultiAgents([...selectedMultiAgents, agent]);
+ }
+ setShowAgentSelector(false);
+ };
+
+ // 从协同分析中移除智能体
+ const handleRemoveAgent = (agentId: string) => {
+ setSelectedMultiAgents(selectedMultiAgents.filter(a => a.id !== agentId));
+ // 同时清除该智能体的响应
+ setAgentResponses(prev => prev.filter(r => r.agentId !== agentId));
+ setMultiAgentMessages(prev => prev.filter(msg => {
+ const response = agentResponses.find(r => r.agentId === agentId);
+ return !response || msg.agentName !== agents.find(a => a.id === agentId)?.name;
+ }));
+ };
+
+ // 应用场景模板
+ const handleApplySceneTemplate = (template: SceneTemplate) => {
+ const templateAgents = agents.filter(a => template.agentIds.includes(a.id));
+ setSelectedMultiAgents(templateAgents);
+ setSelectedSceneTemplate(template);
+ // 清空当前分析数据
+ setAgentResponses([]);
+ setMultiAgentMessages([]);
+ setAdoptedViews([]);
+ setPendingQuestions([]);
+ setSummaryDraft({ title: `${template.name}分析报告`, sections: [] });
+ setTaskInfo({
+ name: template.name,
+ goal: template.description,
+ status: '准备中',
+ background: template.description,
+ keyQuestions: [],
+ });
+ setShowSceneSelector(false);
+ };
+
+ // 获取未选择的智能体列表
+ const availableAgentsForSelection = agents.filter(
+ agent => !selectedMultiAgents.find(a => a.id === agent.id)
+ );
+
+ // 处理新建问答 - 清空会话并切换到指定类型
+ const handleNewQA = (targetProvider?: QAProvider) => {
+ // 清空对话
+ setMessages([]);
+ setCompareResult(null);
+ setInputValue('');
+
+ // 切换到指定类型
+ if (targetProvider) {
+ setQaProvider(targetProvider);
+ }
+ };
+
+ const handleSend = () => {
+ if (!inputValue.trim()) return;
+
+ setIsLoading(true);
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ role: 'user',
+ content: inputValue,
+ timestamp: new Date(),
+ };
+
+ // 保存用户消息到聊天记录
+ addMessage('user', inputValue, qaProvider === 'agent' ? 'agent' : 'general', qaProvider === 'agent' ? selectedAgent.name : undefined);
+
+ setMessages([...messages, userMessage]);
+ setInputValue('');
+
+ setTimeout(() => {
+ const responseContent = qaProvider === 'agent'
+ ? `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`
+ : `RAG(检索增强生成)技术的工作原理主要包括以下几个步骤:\n\n1. **检索阶段**:系统首先将用户问题转换为查询向量,在知识库中进行相似度搜索。\n\n2. **增强阶段**:将检索到的相关文档与原始问题组合。\n\n3. **生成阶段**:将增强后的提示词发送给大语言模型。`;
+
+ const response: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'assistant',
+ content: responseContent,
+ timestamp: new Date(),
+ sources: qaProvider === 'agent' ? undefined : [
+ { title: 'RAG技术白皮书', type: 'PDF', snippet: 'RAG是一种结合检索和生成的技术架构...' },
+ ],
+ agentName: qaProvider === 'agent' ? selectedAgent.name : undefined,
+ skills: qaProvider === 'agent' ? selectedAgent.skills : undefined,
+ dataSources: qaProvider === 'agent' ? selectedAgent.dataSources : undefined,
+ };
+
+ // 保存助手回复到聊天记录
+ addMessage('assistant', responseContent, qaProvider === 'agent' ? 'agent' : 'general', qaProvider === 'agent' ? selectedAgent.name : undefined);
+
+ setMessages(prev => [...prev, response]);
+ setIsLoading(false);
+ setSearchProcess({
+ currentMode: qaMode === 'single-round' ? '单轮RAG' : qaMode === 'multi-round' ? '多轮RAG' : '深度检索',
+ rounds: qaMode === 'multi-round' ? 3 : 1,
+ hits: 5,
+ queryRewrite: qaMode === 'deep-search' ? '原问题→分解子问题→独立检索→综合回答' : '',
+ });
+ }, 1500);
+ };
+
+ const handleCompare = () => {
+ if (!inputValue.trim()) return;
+
+ setIsLoading(true);
+
+ setTimeout(() => {
+ setCompareResult({
+ leftMode: compareLeftMode,
+ rightMode: compareRightMode,
+ leftContent: '单轮RAG回答:直接检索并生成答案,响应速度快,但可能遗漏跨文档的关联信息。适用于简单事实查询。',
+ rightContent: '深度检索回答:通过问题分解、多轮检索和综合分析,提供更全面的答案。包含详细的推理过程和更多的引用来源。',
+ differences: {
+ consensus: '两种方式都使用向量检索技术,都能引用原始文档',
+ differences: '深度检索采用多轮查询改写,覆盖更全面',
+ leftAdvantage: '响应更快,资源消耗更低',
+ rightAdvantage: '答案更完整,引用更充分',
+ },
+ });
+ setIsLoading(false);
+ }, 2000);
+ };
+
+ const handleSaveRecord = () => {
+ alert('问答记录已保存');
+ };
+
+ // 新建问答 - 通用/智能体模式
+ const handleStartNewQA = () => {
+ // 如果有现有消息,弹出确认对话框
+ if (messages.length > 0 || compareResult) {
+ setShowNewQADialog(true);
+ return;
+ }
+
+ // 直接新建 - 清空会话
+ performNewQA();
+ };
+
+ // 执行新建问答
+ const performNewQA = () => {
+ // 清空对话
+ setMessages([]);
+ setCompareResult(null);
+ setInputValue('');
+ setShowNewQADialog(false);
+ };
+
+ // 保存并新建
+ const handleSaveAndNewQA = () => {
+ handleSaveRecord();
+ performNewQA();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (viewMode === 'compare') {
+ handleCompare();
+ } else {
+ handleSend();
+ }
+ }
+ };
+
+ // 获取模式标签
+ const getModeLabel = (mode: QAMode) => {
+ switch (mode) {
+ case 'single-round': return '单轮RAG';
+ case 'multi-round': return '多轮RAG';
+ case 'deep-search': return '深度检索';
+ }
+ };
+
+ // 空状态快捷入口
+ const QuickStartCards = () => (
+
+
+
+
+
+ );
+
+ return (
+
+ {/* 左侧面板 - MiniMax风格 - 可折叠 */}
+
+ {/* 顶部标题 */}
+
+ {!leftPanelCollapsed && (
+
+
+
+ {qaProvider === 'agent' ? '智能体列表' : '问答配置'}
+
+
+ )}
+
+
+
+ {/* 左侧面板折叠内容 */}
+ {!leftPanelCollapsed && (
+ <>
+ {/* 通用问答配置 */}
+ {qaProvider === 'general' && (
+
+ {/* 问答模式选择 - 卡片式 */}
+
+
+ 问答模式
+
+
+ {[
+ { value: 'single-round', label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
+ { value: 'multi-round', label: '多轮RAG', icon: Brain, desc: '深入理解' },
+ { value: 'deep-search', label: '深度检索', icon: BookOpen, desc: '全面分析' },
+ ].map((mode) => (
+
+ ))}
+
+
+
+ {/* 知识范围 */}
+
+
+ 知识范围
+
+
+ {[
+ { value: 'all', label: '全部知识源' },
+ { value: 'specific-kb', label: '指定知识库' },
+ { value: 'specific-domain', label: '指定资源域' },
+ ].map((option) => (
+
+ ))}
+
+
+
+ {/* 回答偏好 */}
+
+
+ 回答偏好
+
+
+ {[
+ { checked: showSources, onChange: () => setShowSources(!showSources), label: '展示引用来源' },
+ { checked: showProcess, onChange: () => setShowProcess(!showProcess), label: '展示检索过程' },
+ ].map((item, idx) => (
+
+ ))}
+
+
+
+ {/* 检索过程 */}
+ {showProcess && messages.length > 0 && (
+
+
+ 检索过程
+
+
+
+ 当前模式
+
+ {searchProcess.currentMode}
+
+
+
+ 检索轮次
+ {searchProcess.rounds}
+
+
+ 命中数
+ {searchProcess.hits}
+
+
+
+ )}
+
+ )}
+
+ {/* 智能体问答配置 - MiniMax单专家风格优化 */}
+ {qaProvider === 'agent' && (
+
+ {/* 简洁的智能体选择区 */}
+
+ {/* 当前选中智能体 - 大卡片突出显示 */}
+
+
+
+
+
+
+
+ {selectedAgent.name}
+
+
+ {selectedAgent.category}
+
+
+
+
+ {selectedAgent.description}
+
+
+
+ {/* 智能体列表 - MiniMax风格网格 */}
+
+
+ 选择其他智能体
+
+
+ {agents.slice(0, 6).map((agent) => (
+
+ ))}
+
+
+
+ {/* 智能体能力标签 - 底部展示 */}
+
+ {/* 技能标签 */}
+
+
+
+ 核心技能
+
+
+ {selectedAgent.skills.slice(0, 4).map((skill, idx) => (
+
+ {skill}
+
+ ))}
+
+
+ {/* 数据源标签 */}
+
+
+
+ 数据源
+
+
+ {selectedAgent.dataSources.slice(0, 3).map((source, idx) => (
+
+ {source}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* 底部按钮 - 所有模式通用 */}
+
+
+
+ >
+ )}
+
+
+ {/* 右侧面板 - 对话区 */}
+
+ {/* 顶部模式切换 */}
+
+
+
+ {/* 右侧 - 新建问答按钮(仅通用/智能体模式显示) */}
+ {qaProvider !== 'multi-agent' && (
+
+ )}
+
+
+ {/* 消息区域 */}
+
+ {/* 多智能体协同分析面板 - 正确设计 */}
+ {qaProvider === 'multi-agent' && (
+
+ {/* 顶部标题栏 - 简洁版 */}
+
+
+
+
+
+
+
+
+
+
+ 多智能体协同分析
+
+
+ {taskInfo.name}
+ •
+ {taskInfo.goal}
+
+ {taskInfo.status}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 三栏布局主体 */}
+
+ {/* 左侧:参与智能体列表 */}
+
+ {/* 场景模板选择 */}
+
+
+
+ {/* 场景模板选择下拉 */}
+ {showSceneSelector && (
+
+
+ 选择场景模板自动配置智能体
+
+ {sceneTemplates.map((template) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* 智能体列表头部 */}
+
+
+
+ 参与智能体 ({selectedMultiAgents.length})
+
+
+
+
+ {/* 智能体选择下拉 - 按五大类别分组 */}
+ {showAgentSelector && (
+
+
+ 选择要添加的智能体
+
+ {availableAgentsForSelection.length === 0 ? (
+
所有智能体已添加
+ ) : (
+
+ {/* 按类别分组显示 */}
+ {['规划类', 'MNTY类', '效果PG类', '知识分析类', '协同管理类'].map((category) => {
+ const categoryAgents = availableAgentsForSelection.filter(a => a.category === category);
+ if (categoryAgents.length === 0) return null;
+ return (
+
+
+ {category === '规划类' && }
+ {category === 'MNTY类' && }
+ {category === '效果PG类' && }
+ {category === '知识分析类' && }
+ {category === '协同管理类' && }
+ {category}
+
+
+ {categoryAgents.map((agent) => (
+
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* 参与智能体列表 */}
+
+
+ {selectedMultiAgents.map((agent) => {
+ const response = agentResponses.find(r => r.agentId === agent.id);
+ const status = response?.status || 'pending';
+ return (
+
+
+
+
+
+
{agent.name}
+
{agent.category}
+
+
+
+
+ {status === 'pending' ? '待回答' : status === 'responding' ? '回答中' : '已回答'}
+
+
+
+
+
+ );
+ })}
+ {selectedMultiAgents.length === 0 && (
+
+ )}
+
+
+
+ {/* 历史记录入口 */}
+
+
+
+
+
+ {/* 中间:问答消息流 */}
+
+ {/* 工具条 */}
+
+
+
+
+
+
+
+ {/* 消息列表 */}
+
+ {multiAgentMessages.length === 0 ? (
+
+
+
尚未开始提问
+
+ 请在底部输入你希望多个智能体分析的问题
系统将按所选对象返回回答
+
+
+ ) : (
+ multiAgentMessages.map((msg) => {
+ const isAdopted = msg.type === 'agent' && adoptedViews.some(v => v.agentName === msg.agentName);
+ return (
+
+ {msg.type === 'user' ? (
+
+ ) : (
+
+
+
+
+ {msg.agentName}
+ {isAdopted && (
+ 已采纳
+ )}
+
+
{msg.timestamp.toLocaleTimeString()}
+
+
{msg.content}
+
+
+
+
+
+
+ )}
+
+ );
+ })
+ )}
+ {isMultiAgentLoading && (
+
+
+ 智能体正在分析中...
+
+ )}
+
+
+ {/* 底部统一输入区 */}
+
+
+
发送对象:
+
+
+ {selectedMultiAgents.map((agent) => (
+
+ ))}
+
+
+
+
+
+
+ {/* 右侧:分析结果 */}
+
+ {/* 标签切换 */}
+
+
+
+
+
+ {/* 内容区 */}
+
+ {rightPanelTab === 'structured' ? (
+
+ {/* 汇总结论 */}
+
+
汇总结论
+
+ {agentResponses.filter(r => r.status === 'completed' || r.status === 'adopted').length === 0 ? (
+
暂无分析结论
+ ) : (
+
+ {agentResponses.filter(r => r.status === 'completed' || r.status === 'adopted').map(r => (
+
+ {r.agentName}:
+ {r.coreJudgment}
+
+ ))}
+
+ )}
+
+
+
+ {/* 已采纳观点 */}
+
+
已采纳观点 ({adoptedViews.length})
+ {adoptedViews.length === 0 ? (
+
暂无采纳观点
+ ) : (
+
+ {adoptedViews.map(view => (
+
+
{view.agentName}
+
{view.content}
+
+ ))}
+
+ )}
+
+
+ {/* 待确认问题 */}
+
+
待确认问题
+
+ {pendingQuestions.length === 0 ? (
+
无待确认问题
+ ) : (
+
+ {pendingQuestions.map(q => (
+
+ •
+ {q.content}
+
+ ))}
+
+ )}
+
+
+
+ ) : (
+
+
{summaryDraft.title || '五四青年节宣传方向分析报告'}
+
+
背景
+
{taskInfo.background}
+
+
+
参与智能体
+
{selectedMultiAgents.map(a => a.name).join(' / ')}
+
+
+
联合结论
+
+ {summaryDraft.sections.length > 0 ? summaryDraft.sections.map(s => s.content).join('\n') : '点击"生成汇总"自动生成联合结论'}
+
+
+
+ )}
+
+
+
+
+ )}
+ {viewMode === 'single' && (
+ messages.length === 0 ? (
+
+
+
+ {qaProvider === 'agent' ? `开始与${selectedAgent.name}对话` : '开始你的问答'}
+
+
+ {qaProvider === 'agent'
+ ? '基于智能体专业能力获取精准回答'
+ : '输入问题,获取基于知识库的智能回答'}
+
+
+ {/* 快捷入口 - 仅在通用问答且无消息时显示 */}
+ {qaProvider === 'general' && (
+
+ )}
+
+ ) : (
+
+ {/* 消息列表 - 居中显示,MiniMax风格 */}
+
+
+ {messages.map((msg) => (
+
+ {/* 用户消息 - 右侧显示 */}
+ {msg.role === 'user' && (
+
+ )}
+
+ {/* AI/智能体回复 - 左侧显示 */}
+ {msg.role === 'assistant' && (
+
+
+ {qaProvider === 'agent' ? : }
+
+
+ {/* 智能体名称标识 - 仅智能体模式 */}
+ {qaProvider === 'agent' && (
+
+ {selectedAgent.name}
+
+ )}
+
+
+ {/* 来源信息 - MiniMax风格折叠 */}
+ {msg.sources && showSources && (
+
+
+
+ 引用来源 ({msg.sources.length})
+
+
+ {msg.sources.slice(0, 3).map((source, idx) => (
+
+
+
+ {source.title}
+
+
+ {source.type}
+
+
+
+ {source.snippet.substring(0, 100)}...
+
+
+ ))}
+
+
+ )}
+
+ {/* 智能体执行详情 */}
+ {qaProvider === 'agent' && msg.skills && (
+
+
+
+
+
+ 启用技能
+
+
+ {msg.skills.map((skill, idx) => (
+
+ {skill}
+
+ ))}
+
+
+
+
+
+ 数据源
+
+
+ {msg.dataSources?.map((source, idx) => (
+
+ {source}
+
+ ))}
+
+
+
+
+ )}
+
+
+ )}
+
+ ))}
+
+
+
+ {/* 加载动画 */}
+ {isLoading && (
+
+
+ {qaProvider === 'agent' ? : }
+
+
+
+ )}
+
+ )
+ )}
+
+
+ {/* 对比模式视图 */}
+ {viewMode === 'compare' && (
+
+ {compareResult ? (
+ compareLayout === 'horizontal' ? (
+
+
+
+
+ {getModeLabel(compareResult.leftMode)}
+
+
+
+
+ {compareResult.leftContent}
+
+
+
+
+
+
+ {getModeLabel(compareResult.rightMode)}
+
+
+
+
+ {compareResult.rightContent}
+
+
+
+
+ ) : (
+
+
+
+
+ {getModeLabel(compareResult.leftMode)}
+
+
+
+
+ {compareResult.leftContent}
+
+
+
+
+
+
+ {getModeLabel(compareResult.rightMode)}
+
+
+
+
+ {compareResult.rightContent}
+
+
+
+
+ )
+ ) : (
+
+
+
对比模式
+
输入问题,对比不同模式的回答效果
+
+ )}
+
+ )}
+
+ {/* 新建问答确认对话框 */}
+ {showNewQADialog && (
+
+
+
+
+
+
+
+ 是否保存当前会话?
+
+
+ 当前有 {messages.length} 条对话记录
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 底部输入区 - 仅在非多智能体模式显示 */}
+ {qaProvider !== 'multi-agent' && (
+
+ )}
+
+
+ {/* 自定义滚动条样式 */}
+
+
+ );
+};
+
+export default QA;
diff --git a/frontend-web/src/strategy-components/components/qa/QACenterPage.tsx b/frontend-web/src/strategy-components/components/qa/QACenterPage.tsx
new file mode 100644
index 00000000..5390d0ab
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/QACenterPage.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from 'react-router-dom';
+
+export default function QACenterPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/QACommon.tsx b/frontend-web/src/strategy-components/components/qa/QACommon.tsx
new file mode 100644
index 00000000..831e7bbc
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/QACommon.tsx
@@ -0,0 +1,782 @@
+import React, { useState } from 'react';
+import { Textarea } from 'tdesign-react';
+import {
+ Bookmark,
+ BrainCircuit,
+ Bot,
+ ChevronDown,
+ FileSearch,
+ Loader2,
+ RotateCcw,
+ Search,
+ Send,
+ Settings,
+ Sparkles,
+ Square,
+ Copy,
+ Trash2,
+ Edit3,
+ Upload,
+ UserRound,
+} from 'lucide-react';
+import { useTheme } from '../../../contexts/ThemeContext';
+import MarkdownRenderer from '../MarkdownRenderer';
+
+/**
+ * 单条检索结果。
+ *
+ * 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
+ */
+export interface QARetrievalItem {
+ id: string;
+ title: string;
+ content: string;
+ source: string;
+}
+
+/**
+ * 多轮检索中的单轮思考节点。
+ *
+ * `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
+ */
+export interface QAThinkingRound {
+ id: string; // 唯一标识符,用于列表渲染时的 key
+ title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
+ thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
+ query: string; // 该轮发送给检索系统的查询语句
+ queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
+ retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
+}
+
+/**
+ * 多轮/深度检索的完整思考链。
+ *
+ * rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
+ */
+export interface QAThinkingTrace {
+ title?: string; // 整体思考链的标题
+ rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
+ finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
+}
+
+/**
+ * 单轮 RAG 的回答结构。
+ *
+ * 后端可能把 `...` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
+ */
+export interface QASingleRagTrace {
+ title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
+ thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
+ answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
+ thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
+}
+
+export interface QACommonMessage {
+ id: string; // 消息唯一标识符
+ role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
+ content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
+ timestamp?: Date; // 消息发送时间
+ thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
+ sources?: { // 消息引用的参考资料列表
+ title: string; // 参考资料标题
+ type: string; // 资料类型,如"文档"、"网页"等
+ snippet: string; // 资料摘要内容
+ source?: string; // 来源名称或 URL
+ time?: string; // 发布时间
+ url?: string; // 资料链接
+ content?: string; // 完整内容(可能用于详情弹窗)
+ }[];
+}
+
+export interface QACommonMessage {
+ singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
+}
+
+interface QAAvatarProps {
+ role: 'user' | 'assistant';
+ isActive?: boolean;
+ label?: string;
+ variant?: 'default' | 'blue' | 'purple';
+}
+
+const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
+ switch (variant) {
+ case 'blue':
+ return isDark
+ ? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
+ : 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
+ case 'purple':
+ return isDark
+ ? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
+ : 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
+ default:
+ return isDark
+ ? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
+ : 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
+ }
+};
+
+/**
+ * 统一头像组件。
+ *
+ * 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
+ * `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
+ */
+export const QAAvatar: React.FC = ({ role, isActive = false, label, variant = 'default' }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const Icon = role === 'assistant' ? Bot : UserRound;
+
+ return (
+
+
+
+ {role === 'assistant' && isActive && (
+
+ )}
+
+ );
+};
+
+interface TypingIndicatorProps {
+ label?: string;
+ assistantLabel?: string;
+}
+
+/**
+ * 助手预连接/生成中的占位提示。
+ *
+ * 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
+ */
+export const TypingIndicator: React.FC = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+
+
+
+
+
+ {label}
+
+
+ {[0, 1, 2].map((idx) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+interface ChatMessageProps {
+ message: T;
+ showSources?: boolean;
+ assistantLabel?: string;
+ assistantActive?: boolean;
+ onCitationClick?: (message: T, citationIndex: number) => void;
+ onCopyAssistant?: (message: T) => void;
+ onDeleteAssistant?: (message: T) => void;
+ onEditAssistant?: (message: T) => void;
+ onResendAssistant?: (message: T) => void;
+ renderAssistantExtra?: (message: T) => React.ReactNode;
+}
+
+/**
+ * 聊天消息渲染器。
+ *
+ * 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
+ * sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
+ */
+export function ChatMessage({
+ message,
+ showSources = true,
+ assistantLabel = 'AI 助手',
+ assistantActive = false,
+ onCitationClick,
+ onCopyAssistant,
+ onDeleteAssistant,
+ onEditAssistant,
+ onResendAssistant,
+ renderAssistantExtra,
+}: ChatMessageProps) {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const isUser = message.role === 'user';
+ const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
+
+ return (
+
+
+ {!isUser &&
}
+
+ {message.singleRagTrace && !isUser ? (
+
onCitationClick?.(message, citationIndex)}
+ />
+ ) : message.thinkingTrace && !isUser ? (
+ onCitationClick?.(message, citationIndex)}
+ />
+ ) : (
+ {message.content}
+ )}
+ {!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
+
+ {onCopyAssistant && (
+
+ )}
+ {onDeleteAssistant && (
+
+ )}
+ {onEditAssistant && (
+
+ )}
+ {onResendAssistant && (
+
+ )}
+
+ )}
+
+ {isUser &&
}
+
+
+
+ {!isUser && message.sources && showSources && (
+
+
+ 引用来源
+ ({message.sources.length})
+
+ )}
+ >
+
+ {message.sources.map((source, idx) => (
+
+
+
+ {source.title}
+
+ {source.type}
+
+
+
{source.snippet}
+
+ ))}
+
+
+ )}
+
+ {!isUser && renderAssistantExtra?.(message)}
+
+ );
+}
+
+interface CollapsibleSectionProps {
+ title: React.ReactNode;
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+ className?: string;
+ headerClassName?: string;
+}
+
+/**
+ * 通用折叠面板。
+ *
+ * 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
+ */
+const CollapsibleSection: React.FC = ({
+ title,
+ children,
+ defaultOpen = true,
+ className = '',
+ headerClassName = '',
+}) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+
+
+
+
+ );
+};
+
+interface SingleRagAnswerProps {
+ trace: QASingleRagTrace;
+ onCitationClick?: (citationIndex: number) => void;
+}
+
+/**
+ * 单轮 RAG 回答展示。
+ *
+ * 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
+ * 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
+ */
+export const SingleRagAnswer: React.FC = ({ trace, onCitationClick }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+
+
+
+
+
+
{trace.title || '思考过程'}
+ {trace.thinkingComplete ? (
+
+ 思考已完成,可展开查看
+
+ ) : (
+
+ 正在接收 <think> 流式思考内容
+
+ )}
+
+
+ }
+ >
+ {trace.thinkingMarkdown ? (
+
+ ) : (
+
+
+ 等待模型输出思考内容...
+
+ )}
+
+
+
+
生成的回答
+ {trace.answerMarkdown ? (
+
+ ) : (
+
+
+ {trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
+
+ )}
+
+
+ );
+};
+
+interface RetrievalListProps {
+ items: QARetrievalItem[];
+}
+
+/**
+ * 检索结果列表。
+ *
+ * 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
+ */
+const RetrievalList: React.FC = ({ items }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+ {items.map((item, index) => (
+
+ {index + 1}
+
+
{item.title}
+
来源:{item.source}
+
+
+ }
+ >
+ {item.content}
+
+ ))}
+
+ );
+};
+
+interface SearchQueryBadgeProps {
+ query: string;
+ loading?: boolean;
+}
+
+/**
+ * 检索词徽标。
+ *
+ * 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
+ */
+const SearchQueryBadge: React.FC = ({ query, loading = false }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+ {loading ? : }
+ 检索词
+
+ {query}
+
+
+ );
+};
+
+interface RetrievalThinkingTraceProps {
+ trace: QAThinkingTrace;
+ onCitationClick?: (citationIndex: number) => void;
+}
+
+/**
+ * 多轮/深度检索思考链展示。
+ *
+ * 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
+ * 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
+ */
+export const RetrievalThinkingTrace: React.FC = ({ trace, onCitationClick }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+
+
+
+
+
+
+ {trace.title || '思考过程'}
+
+
+ 共 {trace.rounds.length} 轮推理与检索,可展开查看细节
+
+
+
+ }
+ >
+
+ {trace.rounds.map((round, index) => (
+
+ {index < trace.rounds.length - 1 && (
+
+ )}
+
+
+ 第 {index + 1} 轮
+ {round.title}
+
+ }
+ >
+
+
+
+ 思考内容
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ 检索内容
+ {round.retrievals.length} 条
+
+ }
+ >
+ {round.queryLoading ? (
+
+
+ 正在召回相关内容...
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+interface EmptyChatStateProps {
+ icon?: React.ReactNode;
+ title: string;
+ description: string;
+}
+
+/**
+ * 空状态展示。
+ *
+ * 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
+ */
+export const EmptyChatState: React.FC = ({ icon, title, description }) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+
+ {icon ?? }
+
+
{title}
+
{description}
+
+ );
+};
+
+interface QAComposerProps {
+ value: string;
+ placeholder: string;
+ isLoading: boolean;
+ canSubmit?: boolean;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ onStop: () => void;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+ onUpload?: () => void;
+ onReset?: () => void;
+ onSave?: () => void;
+ onCollect?: () => void;
+ onOpenSettings?: () => void;
+ showUpload?: boolean;
+ showReset?: boolean;
+ showSave?: boolean;
+ showCollect?: boolean;
+}
+
+/**
+ * 问答输入区。
+ *
+ * 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
+ * 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
+ */
+export const QAComposer: React.FC = ({
+ value,
+ placeholder,
+ isLoading,
+ canSubmit,
+ onChange,
+ onSubmit,
+ onStop,
+ onKeyDown,
+ onUpload,
+ onReset,
+ onSave,
+ onCollect,
+ onOpenSettings,
+ showUpload = true,
+ showReset = false,
+ showSave = true,
+ showCollect = true,
+}) => {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+ const submitEnabled = canSubmit ?? Boolean(value.trim());
+ const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
+
+ return (
+
+ );
+};
diff --git a/frontend-web/src/strategy-components/components/qa/QALayout.tsx b/frontend-web/src/strategy-components/components/qa/QALayout.tsx
new file mode 100644
index 00000000..0e01ea8d
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/QALayout.tsx
@@ -0,0 +1,5 @@
+import { Outlet, Navigate } from 'react-router-dom';
+
+export default function QALayout() {
+ return ;
+}
diff --git a/frontend-web/src/strategy-components/components/qa/QATypeSwitch.tsx b/frontend-web/src/strategy-components/components/qa/QATypeSwitch.tsx
new file mode 100644
index 00000000..e80aa805
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/QATypeSwitch.tsx
@@ -0,0 +1,121 @@
+import { CircleHelp, Bot, Layers } from 'lucide-react';
+import { useTheme } from '../../../contexts/ThemeContext';
+
+type QAProvider = 'general' | 'agent' | 'multi-agent';
+type ViewMode = 'single' | 'compare';
+
+interface QATypeSwitchProps {
+ qaProvider: QAProvider; // 当前问答模式:'general' 通用问答 / 'agent' 智能体问答 / 'multi-agent' 协同分析
+ setQaProvider: (provider: QAProvider) => void; // 设置问答模式的回调函数
+ selectedAgentName?: string; // 当选择智能体问答时,当前选中的智能体名称
+ viewMode?: ViewMode; // 当前视图模式(仅通用问答支持):'single' 单一模式 或 'compare' 对比模式
+ setViewMode?: (mode: ViewMode) => void; // 设置视图模式的回调函数(仅通用问答可用)
+}
+
+export default function QATypeSwitch({
+ qaProvider,
+ setQaProvider,
+ selectedAgentName,
+ viewMode = 'single',
+ setViewMode,
+}: QATypeSwitchProps) {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+ {/* 问答模式切换 */}
+
+
+
+
+
+
+ {/* 当前智能体指示 */}
+ {qaProvider === 'agent' && selectedAgentName && (
+
+
+
+ {selectedAgentName}
+
+
+ )}
+
+ {/* 对比模式切换 - 仅通用问答 */}
+ {qaProvider === 'general' && setViewMode && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/Template6BEditor.tsx b/frontend-web/src/strategy-components/components/qa/Template6BEditor.tsx
new file mode 100644
index 00000000..6ac6afd8
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/Template6BEditor.tsx
@@ -0,0 +1,783 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { useTheme } from '../../../contexts/ThemeContext';
+import {
+ Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
+ Target, Users, Activity, BookOpen, Zap, AlertTriangle,
+ Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
+} from 'lucide-react';
+
+interface FieldRequirement {
+ title: string;
+ goal: string;
+ must_cover: string[];
+ done_criteria: string[];
+ blockers: string[];
+ dependencies: string[];
+ evidence_needs: string[];
+ preferred_agents: string[];
+ style_notes: string;
+ min_chars?: number;
+ min_items?: number;
+}
+
+interface AgentData {
+ id: string;
+ name: string;
+ dimension: string;
+ expertise: string;
+ relevant_fields: string[];
+ analysis_contract: string[];
+}
+
+interface CoordinatorConfig {
+ system_prompt_prefix?: string;
+ round1_guidance?: string;
+ middle_round_guidance?: string;
+ final_round_guidance?: string;
+ speaker_selection_rules?: string;
+ focus_instruction_rules?: string;
+ dependency_driving_rules?: string[];
+ consistency_check_rules?: string[];
+ revise_prompt_prefix?: string;
+}
+
+interface TemplateData {
+ template_id: string;
+ template_name: string;
+ template_brief: string;
+ usage_notes: string[];
+ report_schema: Record;
+ report_examples: Record;
+ field_requirements: Record;
+ agents: AgentData[];
+ coordinator_config: CoordinatorConfig;
+}
+
+const FIELD_LABELS: Record = {
+ template_id: '模板ID',
+ template_name: '模板名称',
+ template_brief: '模板简介',
+ usage_notes: '使用说明',
+ report_schema: '报告字段结构',
+ report_examples: '报告字段示例',
+ field_requirements: '字段要求配置',
+ agents: '智能体配置',
+ coordinator_config: '协调者配置',
+ title: '字段标题',
+ goal: '字段目标',
+ must_cover: '必须覆盖内容',
+ done_criteria: '完成标准',
+ blockers: '阻塞条件',
+ dependencies: '依赖字段',
+ evidence_needs: '证据需求',
+ preferred_agents: '推荐智能体',
+ style_notes: '风格说明',
+ min_chars: '最小字符数',
+ min_items: '最小条目数',
+ id: '智能体ID',
+ name: '智能体名称',
+ dimension: '分析维度',
+ expertise: '专业能力',
+ relevant_fields: '关联字段',
+ analysis_contract: '分析契约',
+ system_prompt_prefix: '系统提示词前缀',
+ round1_guidance: '第一轮指导',
+ middle_round_guidance: '中间轮指导',
+ final_round_guidance: '最终轮指导',
+ speaker_selection_rules: '发言者选择规则',
+ focus_instruction_rules: '焦点指令规则',
+ dependency_driving_rules: '依赖驱动规则',
+ consistency_check_rules: '一致性检查规则',
+ revise_prompt_prefix: '修改提示前缀',
+};
+
+const REPORT_SCHEMA_LABELS: Record = {
+ objective_analysis: '一、目的分析',
+ target_actor_analysis: '二、目标行为体分析',
+ expected_behavior_drivers: '三、预期行为及行为驱动因素分析',
+ narrative_theme_design: '四、叙事主题设计',
+ action_plan_list: '五、认知行动方案列表',
+};
+
+interface AgentVisual {
+ Icon: React.ElementType;
+ lightCard: string;
+ darkCard: string;
+ iconLight: string;
+ iconDark: string;
+ tagLight: string;
+ tagDark: string;
+}
+
+const AGENT_VISUALS: Record = {
+ objective: {
+ Icon: Target,
+ lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
+ darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-blue-100 text-blue-600',
+ iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
+ tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
+ tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
+ },
+ actor: {
+ Icon: Users,
+ lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
+ darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-emerald-100 text-emerald-600',
+ iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
+ tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
+ tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
+ },
+ behavior: {
+ Icon: Activity,
+ lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
+ darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-amber-100 text-amber-600',
+ iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
+ tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
+ tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
+ },
+ narrative: {
+ Icon: BookOpen,
+ lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
+ darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-purple-100 text-purple-600',
+ iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
+ tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
+ tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
+ },
+ action: {
+ Icon: Zap,
+ lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
+ darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-indigo-100 text-indigo-600',
+ iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
+ tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
+ tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
+ },
+ risk: {
+ Icon: AlertTriangle,
+ lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
+ darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-rose-100 text-rose-600',
+ iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
+ tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
+ tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
+ },
+};
+
+const DEFAULT_VISUAL: AgentVisual = {
+ Icon: Brain,
+ lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
+ darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
+ iconLight: 'bg-gray-100 text-gray-600',
+ iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
+ tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
+ tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
+};
+
+const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
+
+function getValueType(value: unknown) {
+ if (Array.isArray(value)) return 'array';
+ if (value === null) return 'null';
+ return typeof value;
+}
+
+function compareJsonShape(reference: unknown, current: unknown, path = 'root') {
+ const issues: string[] = [];
+ const refType = getValueType(reference);
+ const curType = getValueType(current);
+
+ if (refType !== curType) {
+ issues.push(`${path}: 类型不一致,期望 ${refType},实际 ${curType}`);
+ return issues;
+ }
+
+ if (refType === 'array') {
+ const refArr = reference as unknown[];
+ const curArr = current as unknown[];
+ if (refArr.length > 0 && curArr.length > 0) {
+ issues.push(...compareJsonShape(refArr[0], curArr[0], `${path}[]`));
+ }
+ return issues;
+ }
+
+ if (refType === 'object') {
+ const refObj = reference as Record;
+ const curObj = current as Record;
+ const refKeys = Object.keys(refObj);
+ const curKeys = Object.keys(curObj);
+
+ refKeys.filter((key) => !(key in curObj)).forEach((key) => issues.push(`${path}.${key}: 缺少字段`));
+ curKeys.filter((key) => !(key in refObj)).forEach((key) => issues.push(`${path}.${key}: 多余字段`));
+
+ refKeys
+ .filter((key) => key in curObj)
+ .forEach((key) => issues.push(...compareJsonShape(refObj[key], curObj[key], `${path}.${key}`)));
+ }
+
+ return issues;
+}
+
+function EditableText({
+ value,
+ onChange,
+ isDark,
+ multiline = false,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ isDark: boolean;
+ multiline?: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ const confirm = () => { onChange(draft); setEditing(false); };
+ const cancel = () => { setDraft(value); setEditing(false); };
+
+ const inputCls = isDark
+ ? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
+ : 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+ { setDraft(value); setEditing(true); }}>
+
+ {value || (空)}
+
+
+
+ );
+}
+
+function EditableMarkdown({
+ value,
+ onChange,
+ isDark,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+ {
+ setDraft(value);
+ setEditing(true);
+ }}
+ >
+
+
+ {value}
+
+
+ );
+}
+
+function TagList({
+ tags,
+ onChange,
+ isDark,
+ tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
+ tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
+}: {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ isDark: boolean;
+ tagLight?: string;
+ tagDark?: string;
+}) {
+ const [editIdx, setEditIdx] = useState(null);
+ const [editVal, setEditVal] = useState('');
+ const [adding, setAdding] = useState(false);
+ const [addVal, setAddVal] = useState('');
+
+ const tagCls = isDark ? tagDark : tagLight;
+ const inputCls = isDark
+ ? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
+ : 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
+
+ const confirmEdit = () => {
+ if (editIdx === null) return;
+ const next = [...tags];
+ if (editVal.trim()) next[editIdx] = editVal.trim();
+ else next.splice(editIdx, 1);
+ onChange(next);
+ setEditIdx(null);
+ };
+
+ const confirmAdd = () => {
+ if (addVal.trim()) onChange([...tags, addVal.trim()]);
+ setAdding(false);
+ setAddVal('');
+ };
+
+ return (
+
+ );
+}
+
+function AgentCard({
+ agent,
+ onUpdate,
+ onDelete,
+ isDark,
+}: {
+ agent: AgentData;
+ onUpdate: (a: AgentData) => void;
+ onDelete: () => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
+ const { Icon } = vis;
+ const cardCls = isDark ? vis.darkCard : vis.lightCard;
+ const iconCls = isDark ? vis.iconDark : vis.iconLight;
+ const tagCls = isDark ? vis.tagDark : vis.tagLight;
+
+ const handleFieldChange = (field: keyof AgentData, value: unknown) => {
+ onUpdate({ ...agent, [field]: value });
+ };
+
+ return (
+ <>
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
+ >
+
+
+
+
+
+
+
{agent.name}
+
{agent.dimension}
+
+
+
+
+
+
+
+
+
{agent.id}
+
+
+
{agent.expertise.slice(0, 80)}
+
+
+ {agent.relevant_fields.length > 0 && (
+
+
{FIELD_LABELS.relevant_fields}
+
+ {agent.relevant_fields.map(f => (
+ {f}
+ ))}
+
+
+ )}
+
+
+ {editing && (
+ setEditing(false)}>
+
+
e.stopPropagation()}
+ >
+
+
编辑智能体
+
+
+
+ {(['id', 'name', 'dimension'] as const).map(k => (
+
+
+ handleFieldChange(k, v)} isDark={isDark} />
+
+ ))}
+
+
+ handleFieldChange('expertise', v)} isDark={isDark} multiline />
+
+
+
+ handleFieldChange('relevant_fields', v)} isDark={isDark} tagLight="bg-blue-50 text-blue-700 border-blue-200" tagDark="bg-slate-800/70 text-blue-200 border-blue-500/25" />
+
+
+
+ handleFieldChange('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+function SectionWrapper({
+ title,
+ icon: Icon,
+ children,
+ isDark,
+ defaultOpen = true,
+}: {
+ title: string;
+ icon: React.ElementType;
+ children: React.ReactNode;
+ isDark: boolean;
+ defaultOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+ return (
+
+
+
+
+ );
+}
+
+export default function Template6BEditor() {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ const [template, setTemplate] = useState(null);
+ const [originalTemplate, setOriginalTemplate] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dirty, setDirty] = useState(false);
+
+ useEffect(() => {
+ fetch('/templates/6b_template.json')
+ .then(r => r.json())
+ .then((data: TemplateData) => { setTemplate(data); setOriginalTemplate(data); setLoading(false); })
+ .catch(e => { setError(e.message); setLoading(false); });
+ }, []);
+
+ const update = (key: K, value: TemplateData[K]) => {
+ setTemplate(prev => prev ? { ...prev, [key]: value } : null);
+ setDirty(true);
+ };
+
+ const handleSave = () => {
+ if (!template || !originalTemplate) return;
+
+ const issues = compareJsonShape(originalTemplate, template);
+ const valid = issues.length === 0;
+ const result = {
+ valid,
+ message: valid ? '保存校验通过:字段和类型与 public/templates/6b_template.json 一致。' : '保存校验失败:字段或类型不一致。',
+ issues,
+ data: template,
+ };
+
+ console.log('[Template6BEditor 保存结果]', result);
+ console.log('[Template6BEditor 保存 JSON]', JSON.stringify(template, null, 2));
+ window.alert(result.message + ' 详情已打印到控制台。');
+ if (valid) setDirty(false);
+ };
+
+ if (loading) {
+ return (
+
+
+ 加载模板中…
+
+ );
+ }
+
+ if (error || !template) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
模板配置编辑器
+
六步行动方案 · {template.template_id}
+
+
+ {dirty && (
+
+
+ 已有未保存修改
+
+ )}
+
+
+
+
+
+
+
+ {(['template_id', 'template_name'] as const).map(k => (
+
+ {FIELD_LABELS[k]}
+ update(k, v)} isDark={isDark} />
+
+ ))}
+
+
+ {FIELD_LABELS.template_brief}
+ update('template_brief', v)} isDark={isDark} multiline />
+
+
+
+
+ update('usage_notes', v)}
+ isDark={isDark}
+ tagLight="bg-amber-50 text-amber-700 border-amber-200"
+ tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
+ />
+
+
+
+
+ {Object.entries(template.report_schema).map(([key, desc]) => (
+
+
+ {key}
+ {REPORT_SCHEMA_LABELS[key] || key}
+
+
update('report_schema', { ...template.report_schema, [key]: v })}
+ isDark={isDark}
+ multiline
+ />
+
+ ))}
+
+
+
+
+
+ {Object.entries(template.report_examples).map(([key, example]) => (
+
+
+ {key}
+ {REPORT_SCHEMA_LABELS[key] || key}
+
+
+ update('report_examples', { ...template.report_examples, [key]: v })}
+ isDark={isDark}
+ />
+
+
+ ))}
+
+
+
+
+
+ {template.agents.map((agent, i) => (
+
{
+ const next = [...template.agents];
+ next[i] = updated;
+ update('agents', next);
+ }}
+ onDelete={() => {
+ if (template.agents.length <= 1) {
+ window.alert('智能体最少必须保留 1 个');
+ return;
+ }
+ update('agents', template.agents.filter((_, j) => j !== i));
+ }}
+ isDark={isDark}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/TemplateEditor.tsx b/frontend-web/src/strategy-components/components/qa/TemplateEditor.tsx
new file mode 100644
index 00000000..eb69ecb4
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/TemplateEditor.tsx
@@ -0,0 +1,1520 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { useTheme } from '../../../contexts/ThemeContext';
+import {
+ Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
+ Target, Package, Globe, BookOpen, MessageSquare, Zap,
+ Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
+} from 'lucide-react';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface FieldRequirement {
+ title: string;
+ goal: string;
+ must_cover: string[];
+ done_criteria: string[];
+ blockers: string[];
+ dependencies: string[];
+ evidence_needs: string[];
+ preferred_agents: string[];
+ style_notes: string;
+ min_chars?: number;
+ min_items?: number;
+}
+
+interface AgentData {
+ id: string;
+ name: string;
+ dimension: string;
+ expertise: string;
+ relevant_fields: string[];
+ analysis_contract: string[];
+}
+
+interface CoordinatorConfig {
+ system_prompt_prefix?: string;
+ round1_guidance?: string;
+ middle_round_guidance?: string;
+ final_round_guidance?: string;
+ speaker_selection_rules?: string;
+ focus_instruction_rules?: string;
+ dependency_driving_rules?: string[];
+ consistency_check_rules?: string[];
+ revise_prompt_prefix?: string;
+}
+
+interface TemplateData {
+ template_id: string;
+ template_name: string;
+ template_brief: string;
+ usage_notes: string[];
+ report_schema: Record;
+ report_examples: Record;
+ field_requirements: Record;
+ agents: AgentData[];
+ coordinator_config: CoordinatorConfig;
+}
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const FIELD_LABELS: Record = {
+ template_id: '模板ID',
+ template_name: '模板名称',
+ template_brief: '模板简介',
+ usage_notes: '使用说明',
+ report_schema: '报告字段结构',
+ report_examples: '报告字段示例',
+ field_requirements: '字段要求配置',
+ agents: '智能体配置',
+ coordinator_config: '协调者配置',
+ title: '字段标题',
+ goal: '字段目标',
+ must_cover: '必须覆盖内容',
+ done_criteria: '完成标准',
+ blockers: '阻塞条件',
+ dependencies: '依赖字段',
+ evidence_needs: '证据需求',
+ preferred_agents: '推荐智能体',
+ style_notes: '风格说明',
+ min_chars: '最小字符数',
+ min_items: '最小条目数',
+ id: '智能体ID',
+ name: '智能体名称',
+ dimension: '分析维度',
+ expertise: '专业能力',
+ relevant_fields: '关联字段',
+ analysis_contract: '分析契约',
+ system_prompt_prefix: '系统提示词前缀',
+ round1_guidance: '第一轮指导',
+ middle_round_guidance: '中间轮指导',
+ final_round_guidance: '最终轮指导',
+ speaker_selection_rules: '发言者选择规则',
+ focus_instruction_rules: '焦点指令规则',
+ dependency_driving_rules: '依赖驱动规则',
+ consistency_check_rules: '一致性检查规则',
+ revise_prompt_prefix: '修改提示前缀',
+};
+
+const REPORT_SCHEMA_LABELS: Record = {
+ task_background: '任务背景与领导指示研判',
+ resource_assessment: '各方口径与己方资源研判',
+ information_environment: '战场/舆论场环境分析',
+ historical_cultural_context: '历史与人文环境分析',
+ actor_dynamics: '敌我双方动态与优劣势',
+ opponent_narrative_logic: '对方核心叙事逻辑梳理',
+ strategic_weakness: '核心战略薄弱点研判',
+ assessment_conclusion: '研判结论',
+};
+
+interface AgentVisual {
+ Icon: React.ElementType;
+ lightCard: string;
+ darkCard: string;
+ iconLight: string;
+ iconDark: string;
+ tagLight: string;
+ tagDark: string;
+}
+
+const AGENT_VISUALS: Record = {
+ background: {
+ Icon: Target,
+ lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
+ darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-blue-100 text-blue-600',
+ iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
+ tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
+ tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
+ },
+ resource: {
+ Icon: Package,
+ lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
+ darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-emerald-100 text-emerald-600',
+ iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
+ tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
+ tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
+ },
+ information_environment: {
+ Icon: Globe,
+ lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
+ darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-purple-100 text-purple-600',
+ iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
+ tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
+ tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
+ },
+ history_culture: {
+ Icon: BookOpen,
+ lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
+ darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-amber-100 text-amber-600',
+ iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
+ tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
+ tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
+ },
+ opponent_narrative: {
+ Icon: MessageSquare,
+ lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
+ darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-rose-100 text-rose-600',
+ iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
+ tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
+ tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
+ },
+ strategy: {
+ Icon: Zap,
+ lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
+ darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
+ iconLight: 'bg-indigo-100 text-indigo-600',
+ iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
+ tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
+ tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
+ },
+};
+
+const DEFAULT_VISUAL: AgentVisual = {
+ Icon: Brain,
+ lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
+ darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
+ iconLight: 'bg-gray-100 text-gray-600',
+ iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
+ tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
+ tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
+};
+
+const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
+
+function parseExpertiseConfig(expertise: string, fields: string[]) {
+ const match = expertise.match(/(你的分析应服务于?|你服务于)\s*/);
+ if (!match || match.index === undefined) {
+ return {
+ prefix: expertise,
+ marker: '你的分析应服务于',
+ fields: fields.filter(f => expertise.includes(f)),
+ suffix: '。',
+ };
+ }
+
+ const prefix = expertise.slice(0, match.index);
+ const marker = match[1] === '你服务于' ? '你的分析应服务于' : match[1];
+ const rest = expertise.slice(match.index + match[0].length);
+ const endIndex = rest.indexOf('。');
+ const serviceText = endIndex >= 0 ? rest.slice(0, endIndex) : rest;
+ const suffix = endIndex >= 0 ? rest.slice(endIndex) : '。';
+
+ return {
+ prefix,
+ marker,
+ fields: fields.filter(f => serviceText.includes(f)),
+ suffix,
+ };
+}
+
+function buildExpertise(prefix: string, fields: string[], suffix: string) {
+ const normalizedPrefix = prefix.trim();
+ const prefixSeparator = normalizedPrefix && !/[。!?.!?]$/.test(normalizedPrefix) ? '。' : '';
+ const serviceFields = fields.join('、');
+ const normalizedSuffix = suffix.trim() || '。';
+ return `${normalizedPrefix}${prefixSeparator}你的分析应服务于 ${serviceFields}${normalizedSuffix.startsWith('。') ? normalizedSuffix : `。${normalizedSuffix}`}`;
+}
+
+function replaceFieldKeysWithLabels(text: string, fields: string[]) {
+ return fields.reduce((next, field) => next.replaceAll(field, mapFieldLabel(field)), text);
+}
+
+function replaceFieldLabelsWithKeys(text: string, fields: string[]) {
+ return fields.reduce((next, field) => next.replaceAll(mapFieldLabel(field), field), text);
+}
+
+function extractFieldTokens(text: string, fields: string[]) {
+ return fields.filter(field => text.includes(field));
+}
+
+function removeFieldToken(text: string, field: string) {
+ return text
+ .replaceAll(field, '')
+ .replace(/、{2,}/g, '、')
+ .replace(/\s{2,}/g, ' ')
+ .replace(/、([,。;;,.])/g, '$1')
+ .replace(/([、,;;,])\s*([。,.])/g, '$2')
+ .trim();
+}
+
+function appendFieldTokens(text: string, fields: string[]) {
+ const missing = fields.filter(field => !text.includes(field));
+ if (!missing.length) return text;
+ const separator = text.trim().endsWith('。') ? '' : '。';
+ return `${text.trim()}${separator}相关字段:${missing.join('、')}。`;
+}
+
+function getValueType(value: unknown) {
+ if (Array.isArray(value)) return 'array';
+ if (value === null) return 'null';
+ return typeof value;
+}
+
+function compareJsonShape(reference: unknown, current: unknown, path = 'root') {
+ const issues: string[] = [];
+ const refType = getValueType(reference);
+ const curType = getValueType(current);
+
+ if (refType !== curType) {
+ issues.push(`${path}: 类型不一致,期望 ${refType},实际 ${curType}`);
+ return issues;
+ }
+
+ if (refType === 'array') {
+ const refArr = reference as unknown[];
+ const curArr = current as unknown[];
+ if (refArr.length > 0 && curArr.length > 0) {
+ issues.push(...compareJsonShape(refArr[0], curArr[0], `${path}[]`));
+ }
+ return issues;
+ }
+
+ if (refType === 'object') {
+ const refObj = reference as Record;
+ const curObj = current as Record;
+ const refKeys = Object.keys(refObj);
+ const curKeys = Object.keys(curObj);
+
+ refKeys.filter(key => !(key in curObj)).forEach(key => issues.push(`${path}.${key}: 缺少字段`));
+ curKeys.filter(key => !(key in refObj)).forEach(key => issues.push(`${path}.${key}: 多余字段`));
+
+ refKeys
+ .filter(key => key in curObj)
+ .forEach(key => issues.push(...compareJsonShape(refObj[key], curObj[key], `${path}.${key}`)));
+ }
+
+ return issues;
+}
+
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function RestoreAgentModal({
+ agents,
+ onClose,
+ onRestore,
+ isDark,
+}: {
+ agents: AgentData[];
+ onClose: () => void;
+ onRestore: (agent: AgentData) => void;
+ isDark: boolean;
+}) {
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
恢复已删除智能体
+
+
+
+ {agents.map(agent => (
+
+ ))}
+
+
+
+ );
+}
+
+function CoordinatorRuleList({
+ rules,
+ fields,
+ onChange,
+ isDark,
+}: {
+ rules: string[];
+ fields: string[];
+ onChange: (rules: string[]) => void;
+ isDark: boolean;
+}) {
+ return (
+
+ {rules.map((rule, index) => (
+
+ onChange(rules.map((item, i) => i === index ? nextRule : item))}
+ isDark={isDark}
+ />
+
+
+ ))}
+
+
+ );
+}
+
+function CoordinatorFieldText({
+ value,
+ fields,
+ onChange,
+ isDark,
+}: {
+ value: string;
+ fields: string[];
+ onChange: (v: string) => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(replaceFieldKeysWithLabels(value, fields));
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const selectedFields = extractFieldTokens(value, fields);
+ const fieldOptions = fields.map(f => ({ value: f, label: mapFieldLabel(f) }));
+ const inputCls = isDark
+ ? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
+ : 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
+
+ const confirm = () => {
+ onChange(replaceFieldLabelsWithKeys(draft, fields));
+ setEditing(false);
+ };
+
+ const removeField = (field: string) => {
+ onChange(removeFieldToken(value, field));
+ setDraft(replaceFieldKeysWithLabels(removeFieldToken(value, field), fields));
+ };
+
+ const addFields = (nextFields: string[]) => {
+ const next = appendFieldTokens(value, nextFields);
+ onChange(next);
+ setDraft(replaceFieldKeysWithLabels(next, fields));
+ setPickerOpen(false);
+ };
+
+ return (
+
+ {editing ? (
+
+ ) : (
+
{ setDraft(replaceFieldKeysWithLabels(value, fields)); setEditing(true); }}>
+ {replaceFieldKeysWithLabels(value, fields)}
+
+
+ )}
+
+ {selectedFields.map(field => (
+
+ {mapFieldLabel(field)}
+
+
+ ))}
+
+
+ {pickerOpen && (
+
setPickerOpen(false)}
+ onSave={addFields}
+ isDark={isDark}
+ />
+ )}
+
+ );
+}
+
+function MultiSelectModal({
+ title,
+ selected,
+ options,
+ minKeep = 0,
+ onClose,
+ onSave,
+ isDark,
+}: {
+ title: string;
+ selected: string[];
+ options: { value: string; label: string }[];
+ minKeep?: number;
+ onClose: () => void;
+ onSave: (v: string[]) => void;
+ isDark: boolean;
+}) {
+ const [draft, setDraft] = useState([...selected]);
+
+ const toggle = (value: string) => {
+ const has = draft.includes(value);
+ if (has && draft.length <= minKeep) {
+ window.alert(`最少必须保留 ${minKeep} 个`);
+ return;
+ }
+ setDraft(prev => has ? prev.filter(v => v !== value) : [...prev, value]);
+ };
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
{title}
+
+
+
+ {options.map(op => {
+ const active = draft.includes(op.value);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
+
+function TagList({
+ tags,
+ onChange,
+ isDark,
+ tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
+ tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
+}: {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ isDark: boolean;
+ tagLight?: string;
+ tagDark?: string;
+}) {
+ const [editIdx, setEditIdx] = useState(null);
+ const [editVal, setEditVal] = useState('');
+ const [adding, setAdding] = useState(false);
+ const [addVal, setAddVal] = useState('');
+
+ const tagCls = isDark ? tagDark : tagLight;
+ const inputCls = isDark
+ ? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
+ : 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
+
+ const confirmEdit = () => {
+ if (editIdx === null) return;
+ const next = [...tags];
+ if (editVal.trim()) next[editIdx] = editVal.trim();
+ else next.splice(editIdx, 1);
+ onChange(next);
+ setEditIdx(null);
+ };
+
+ const confirmAdd = () => {
+ if (addVal.trim()) onChange([...tags, addVal.trim()]);
+ setAdding(false);
+ setAddVal('');
+ };
+
+ return (
+
+ );
+}
+
+function EditableText({
+ value,
+ onChange,
+ isDark,
+ multiline = false,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ isDark: boolean;
+ multiline?: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ const confirm = () => { onChange(draft); setEditing(false); };
+ const cancel = () => { setDraft(value); setEditing(false); };
+
+ const inputCls = isDark
+ ? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
+ : 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+ { setDraft(value); setEditing(true); }}>
+
+ {value || (空)}
+
+
+
+ );
+}
+
+function EditableMarkdown({
+ value,
+ onChange,
+ isDark,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+ { setDraft(value); setEditing(true); }}
+ >
+
+
+ {value}
+
+
+ );
+}
+
+function EditableNumber({
+ value,
+ onChange,
+ isDark,
+}: {
+ value: number | undefined;
+ onChange: (v: number | undefined) => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(String(value ?? ''));
+
+ const confirm = () => {
+ const n = parseInt(draft, 10);
+ onChange(isNaN(n) ? undefined : n);
+ setEditing(false);
+ };
+
+ if (editing) {
+ return (
+
+ setDraft(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); }}
+ className={`w-20 px-2 py-0.5 text-sm rounded border outline-none ${isDark ? 'bg-slate-800 border-blue-500/60 text-slate-100' : 'bg-white border-blue-300 text-gray-800'}`}
+ />
+
+
+
+ );
+ }
+
+ return (
+ { setDraft(String(value ?? '')); setEditing(true); }}
+ className={`group/num inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded cursor-pointer transition-all duration-200 hover:scale-105 ${isDark ? 'text-blue-300 bg-blue-900/30 hover:bg-blue-900/50' : 'text-blue-600 bg-blue-50 hover:bg-blue-100'}`}
+ >
+ {value ?? 未设置}
+
+
+ );
+}
+
+function SectionWrapper({
+ title,
+ icon: Icon,
+ children,
+ isDark,
+ defaultOpen = true,
+}: {
+ title: string;
+ icon: React.ElementType;
+ children: React.ReactNode;
+ isDark: boolean;
+ defaultOpen?: boolean;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+ return (
+
+
+
+
+ );
+}
+
+function LabelRow({
+ label,
+ children,
+ isDark,
+}: {
+ label: string;
+ children: React.ReactNode;
+ isDark: boolean;
+}) {
+ return (
+
+ );
+}
+
+// ─── Agent Edit Modal ─────────────────────────────────────────────────────────
+
+function AgentEditModal({
+ agent,
+ existingFields,
+ onSave,
+ onClose,
+ isDark,
+}: {
+ agent: AgentData;
+ existingFields: string[];
+ onSave: (a: AgentData) => void;
+ onClose: () => void;
+ isDark: boolean;
+}) {
+ const [form, setForm] = useState({ ...agent, relevant_fields: [...agent.relevant_fields], analysis_contract: [...agent.analysis_contract] });
+ const initialExpertise = useMemo(() => parseExpertiseConfig(agent.expertise, existingFields), [agent.expertise, existingFields]);
+ const [expertisePrefix, setExpertisePrefix] = useState(initialExpertise.prefix);
+ const [expertiseFields, setExpertiseFields] = useState(initialExpertise.fields.length ? initialExpertise.fields : existingFields.slice(0, 1));
+ const [expertiseSuffix] = useState(initialExpertise.suffix);
+ const [fieldPickerOpen, setFieldPickerOpen] = useState(false);
+ const [expertisePickerOpen, setExpertisePickerOpen] = useState(false);
+
+ const set = (k: keyof AgentData, v: unknown) => setForm(p => ({ ...p, [k]: v }));
+
+ const inputCls = `w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors ${isDark ? 'bg-slate-800 border-slate-600 text-slate-100 focus:border-blue-400' : 'bg-white border-gray-300 text-gray-800 focus:border-blue-400'}`;
+ const labelCls = `text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`;
+ const fieldOptions = existingFields.map(f => ({ value: f, label: mapFieldLabel(f) }));
+
+ const removeRelevantField = (field: string) => {
+ if (form.relevant_fields.length <= 1) {
+ window.alert('关联字段最少必须保留 1 个');
+ return;
+ }
+ set('relevant_fields', form.relevant_fields.filter(x => x !== field));
+ };
+
+ const removeExpertiseField = (field: string) => {
+ if (expertiseFields.length <= 1) {
+ window.alert('专业能力服务字段最少必须保留 1 个');
+ return;
+ }
+ setExpertiseFields(prev => prev.filter(x => x !== field));
+ };
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
编辑智能体
+
+
+
+ {(['id', 'name', 'dimension'] as const).map(k => (
+
+
+ set(k, e.target.value)} className={inputCls} />
+
+ ))}
+
+
+
+ 分析职责描述(“你的分析应服务于”前面的内容)
+
+
+
你的分析应服务于
+
+ {expertiseFields.map(f => (
+
+ {mapFieldLabel(f)}
+
+
+ ))}
+
+
+
+
+
+
+
+ {form.relevant_fields.map(f => (
+
+ {mapFieldLabel(f)}
+
+
+ ))}
+
+
+
+
+
+ set('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
+
+
+
+
+
+
+
+ {fieldPickerOpen && (
+
setFieldPickerOpen(false)}
+ onSave={v => { set('relevant_fields', v); setFieldPickerOpen(false); }}
+ isDark={isDark}
+ />
+ )}
+ {expertisePickerOpen && (
+ setExpertisePickerOpen(false)}
+ onSave={v => { setExpertiseFields(v); setExpertisePickerOpen(false); }}
+ isDark={isDark}
+ />
+ )}
+
+ );
+}
+
+// ─── Agent Card ───────────────────────────────────────────────────────────────
+
+function AgentCard({
+ agent,
+ existingFields,
+ onUpdate,
+ onDelete,
+ isDark,
+}: {
+ agent: AgentData;
+ existingFields: string[];
+ onUpdate: (a: AgentData) => void;
+ onDelete: () => void;
+ isDark: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
+ const { Icon } = vis;
+ const cardCls = isDark ? vis.darkCard : vis.lightCard;
+ const iconCls = isDark ? vis.iconDark : vis.iconLight;
+ const tagCls = isDark ? vis.tagDark : vis.tagLight;
+ const expertiseConfig = parseExpertiseConfig(agent.expertise, existingFields);
+
+ return (
+ <>
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
+ >
+ {/* Header */}
+
+
+
+
+
+
+
{agent.name}
+
{agent.dimension}
+
+
+
+
+
+
+
+
+ {/* ID badge */}
+
{agent.id}
+
+ {/* Expertise */}
+
+
{expertiseConfig.prefix}
+
+ {expertiseConfig.fields.map(f => (
+ {mapFieldLabel(f)}
+ ))}
+
+
+
+ {/* Relevant fields */}
+ {agent.relevant_fields.length > 0 && (
+
+
{FIELD_LABELS.relevant_fields}
+
+ {agent.relevant_fields.map(f => (
+ {mapFieldLabel(f)}
+ ))}
+
+
+ )}
+
+ {/* Analysis contract */}
+ {agent.analysis_contract.length > 0 && (
+
+
{FIELD_LABELS.analysis_contract}
+
+ {agent.analysis_contract.map((c, i) => (
+ -
+
+ {c}
+
+ ))}
+
+
+ )}
+
+
+ {editing && (
+ { onUpdate(updated); setEditing(false); }}
+ onClose={() => setEditing(false)}
+ isDark={isDark}
+ />
+ )}
+ >
+ );
+}
+
+// ─── Field Requirement Card ───────────────────────────────────────────────────
+
+function FieldRequirementCard({
+ fieldKey,
+ req,
+ onChange,
+ isDark,
+ fieldOptions,
+ agentOptions,
+}: {
+ fieldKey: string;
+ req: FieldRequirement;
+ onChange: (r: FieldRequirement) => void;
+ isDark: boolean;
+ fieldOptions: { value: string; label: string }[];
+ agentOptions: { value: string; label: string }[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [picker, setPicker] = useState<'dependencies' | 'preferred_agents' | null>(null);
+ const label = REPORT_SCHEMA_LABELS[fieldKey] || fieldKey;
+
+ const setField = (k: K, v: FieldRequirement[K]) =>
+ onChange({ ...req, [k]: v });
+
+ const ARRAY_FIELDS: (keyof FieldRequirement)[] = ['must_cover', 'done_criteria', 'blockers', 'evidence_needs'];
+
+ const removeRequiredItem = (key: 'dependencies' | 'preferred_agents', value: string) => {
+ const current = req[key];
+ if (current.length <= 1) {
+ window.alert(key === 'dependencies' ? '依赖字段最少必须保留 1 个' : '推荐智能体最少必须保留 1 个');
+ return;
+ }
+ setField(key, current.filter(x => x !== value));
+ };
+
+ return (
+
+
+
+
+
+
+ {(['title', 'goal', 'style_notes'] as const).map(k => (
+
+ setField(k, v)}
+ isDark={isDark}
+ multiline={k === 'style_notes'}
+ />
+
+ ))}
+
+
+ {(['min_chars', 'min_items'] as const).map(k => (
+
+ setField(k, v)} isDark={isDark} />
+
+ ))}
+
+
+ {ARRAY_FIELDS.map((k, index) => (
+
+ setField(k as keyof FieldRequirement, v as FieldRequirement[typeof k])}
+ isDark={isDark}
+ tagLight={index % 2 === 0 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : 'bg-amber-50 text-amber-700 border-amber-200'}
+ tagDark={index % 2 === 0 ? 'bg-slate-800/70 text-emerald-200 border-emerald-500/25' : 'bg-slate-800/70 text-amber-200 border-amber-500/25'}
+ />
+
+ ))}
+
+
+
+ {req.dependencies.map(v => (
+
+ {fieldOptions.find(x => x.value === v)?.label || v}
+
+
+ ))}
+
+
+
+
+
+
+ {req.preferred_agents.map(v => (
+
+ {agentOptions.find(x => x.value === v)?.label || v}
+
+
+ ))}
+
+
+
+
+
+
+
+ {picker === 'dependencies' && (
+
setPicker(null)}
+ onSave={v => { setField('dependencies', v); setPicker(null); }}
+ isDark={isDark}
+ />
+ )}
+ {picker === 'preferred_agents' && (
+ setPicker(null)}
+ onSave={v => { setField('preferred_agents', v); setPicker(null); }}
+ isDark={isDark}
+ />
+ )}
+
+ );
+}
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+export default function TemplateEditor() {
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
+
+ const [template, setTemplate] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dirty, setDirty] = useState(false);
+ const [addingAgent, setAddingAgent] = useState(false);
+ const [fixedAgents, setFixedAgents] = useState([]);
+ const [originalTemplate, setOriginalTemplate] = useState(null);
+
+ useEffect(() => {
+ fetch('/templates/3q_template.json')
+ .then(r => r.json())
+ .then((data: TemplateData) => { setTemplate(data); setOriginalTemplate(data); setFixedAgents(data.agents); setLoading(false); })
+ .catch(e => { setError(e.message); setLoading(false); });
+ }, []);
+
+ const update = (key: K, value: TemplateData[K]) => {
+ setTemplate(prev => prev ? { ...prev, [key]: value } : null);
+ setDirty(true);
+ };
+
+ const handleSave = () => {
+ if (!template || !originalTemplate) return;
+
+ const issues = compareJsonShape(originalTemplate, template);
+ const valid = issues.length === 0;
+ const result = {
+ valid,
+ message: valid ? '保存校验通过:字段和类型与 public/templates/3q_template.json 一致。' : '保存校验失败:字段或类型不一致。',
+ issues,
+ data: template,
+ };
+
+ console.log('[TemplateEditor 保存结果]', result);
+ console.log('[TemplateEditor 保存 JSON]', JSON.stringify(template, null, 2));
+ window.alert(result.message + ' 详情已打印到控制台。');
+
+ if (valid) setDirty(false);
+ };
+
+ const existingFields = template ? Object.keys(template.report_schema) : [];
+ const fieldOptions = template ? existingFields.map(f => ({ value: f, label: mapFieldLabel(f) })) : [];
+ const agentOptions = template ? template.agents.map(a => ({ value: a.id, label: `${a.name || a.id}(${a.id})` })) : [];
+ const deletedFixedAgents = template ? fixedAgents.filter(agent => !template.agents.some(current => current.id === agent.id)) : [];
+
+ // ── Loading / Error states ─────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+
+ 加载模板中…
+
+ );
+ }
+
+ if (error || !template) {
+ return (
+
+ );
+ }
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+
模板配置编辑器
+
三Q研判报告 · {template.template_id}
+
+
+ {dirty && (
+
+
+ 已有未保存修改(仅本地预览)
+
+ )}
+
+
+
+
+
+
+ {/* ── 1. Basic Info ────────────────────────────────────────────────────── */}
+
+
+ {(['template_id', 'template_name'] as const).map(k => (
+
+ update(k, v)} isDark={isDark} />
+
+ ))}
+
+
+ update('template_brief', v)} isDark={isDark} multiline />
+
+
+
+ {/* ── 2. Usage Notes ───────────────────────────────────────────────────── */}
+
+ update('usage_notes', v)}
+ isDark={isDark}
+ tagLight="bg-amber-50 text-amber-700 border-amber-200"
+ tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
+ />
+
+
+ {/* ── 3. Report Schema ─────────────────────────────────────────────────── */}
+
+
+ {Object.entries(template.report_schema).map(([key, desc]) => (
+
+
+ {key}
+ {REPORT_SCHEMA_LABELS[key] || key}
+
+
update('report_schema', { ...template.report_schema, [key]: v })}
+ isDark={isDark}
+ multiline
+ />
+
+ ))}
+
+
+
+ {/* ── 4. Field Requirements ────────────────────────────────────────────── */}
+
+
+ {Object.entries(template.field_requirements).map(([key, req]) => (
+ update('field_requirements', { ...template.field_requirements, [key]: updated })}
+ fieldOptions={fieldOptions}
+ agentOptions={agentOptions}
+ isDark={isDark}
+ />
+ ))}
+
+
+
+ {/* ── 5. Agents ────────────────────────────────────────────────────────── */}
+
+
+ {template.agents.map((agent, i) => (
+
{
+ const next = [...template.agents];
+ next[i] = updated;
+ update('agents', next);
+ }}
+ onDelete={() => {
+ if (template.agents.length <= 1) {
+ window.alert('智能体最少必须保留 1 个');
+ return;
+ }
+ update('agents', template.agents.filter((_, j) => j !== i));
+ }}
+ isDark={isDark}
+ />
+ ))}
+
+ {deletedFixedAgents.length > 0 && (
+
+ )}
+
+
+
+ {/* ── 6. Coordinator Config ─────────────────────────────────────────────── */}
+
+
+ {(Object.entries(template.coordinator_config) as [keyof CoordinatorConfig, unknown][]).map(([key, value]) => (
+
+ {Array.isArray(value) ? (
+ key === 'dependency_driving_rules' ? (
+ update('coordinator_config', { ...template.coordinator_config, [key]: v })}
+ isDark={isDark}
+ />
+ ) : (
+ update('coordinator_config', { ...template.coordinator_config, [key]: v })}
+ isDark={isDark}
+ tagLight="bg-teal-50 text-teal-700 border-teal-200"
+ tagDark="bg-slate-800/70 text-teal-200 border-teal-500/25"
+ />
+ )
+ ) : (
+ update('coordinator_config', { ...template.coordinator_config, [key]: v })}
+ isDark={isDark}
+ />
+ )}
+
+ ))}
+
+
+
+ {/* ── 7. Report Examples ───────────────────────────────────────────────── */}
+
+
+ {Object.entries(template.report_examples).map(([key, example]) => (
+
+
+ {key}
+ {REPORT_SCHEMA_LABELS[key] || key}
+
+
+ update('report_examples', { ...template.report_examples, [key]: v })}
+ isDark={isDark}
+ />
+
+
+ ))}
+
+
+
+ {/* Restore Agent Modal */}
+
+
+ {addingAgent && (
+
{
+ update('agents', [...template.agents, agent]);
+ setAddingAgent(false);
+ }}
+ onClose={() => setAddingAgent(false)}
+ isDark={isDark}
+ />
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/AgentCard.css b/frontend-web/src/strategy-components/components/qa/components/AgentCard.css
new file mode 100644
index 00000000..0b01460f
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/AgentCard.css
@@ -0,0 +1,235 @@
+.agent-card {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ box-shadow: var(--shadow-card);
+}
+
+.agent-card-header {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-hover);
+}
+
+.agent-card-name {
+ font-weight: 700;
+ font-size: 14px;
+ color: var(--text);
+}
+
+.agent-card-badges {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-left: auto;
+}
+
+/* Card sections */
+.agent-card-section {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.agent-card-section:last-child {
+ border-bottom: none;
+}
+
+.agent-card-section-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+}
+
+/* Streaming card highlight */
+.agent-card-streaming {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
+/* Streaming badge */
+.badge-streaming {
+ background: var(--accent-soft);
+ color: var(--accent);
+ border: 1px solid var(--accent-soft-strong);
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-size: 10px;
+ font-weight: 600;
+ animation: pulse-badge 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-badge {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* Streaming plain-text area */
+.streaming-text {
+ font-size: 13px;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.7;
+}
+
+/* Blinking cursor */
+.streaming-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 0.9em;
+ background: var(--accent);
+ margin-left: 2px;
+ vertical-align: text-bottom;
+ animation: blink-cursor 0.9s step-end infinite;
+}
+
+@keyframes blink-cursor {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* Content */
+.agent-card-content-text {
+ font-size: 13px;
+ color: var(--text);
+}
+
+/* Thinking section */
+.agent-card-thinking-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: none;
+ padding: 0;
+ color: var(--text-muted);
+ font-size: 12px;
+ cursor: pointer;
+ margin-bottom: 8px;
+}
+
+.agent-card-thinking-toggle:hover {
+ color: var(--text);
+}
+
+.agent-card-thinking-text {
+ font-size: 12px;
+ color: var(--text-muted);
+ background: rgba(139, 148, 158, 0.06);
+ border-left: 2px solid var(--border);
+ padding: 8px 12px;
+ border-radius: 0 4px 4px 0;
+}
+
+.agent-card-thinking-text .markdown-body {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-style: italic;
+}
+
+.agent-card-thinking-text .markdown-body strong {
+ color: var(--text-muted);
+}
+
+/* Dimension output */
+.dim-output-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.dim-output-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.dim-output-key {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--accent);
+}
+
+.dim-output-value {
+ font-size: 13px;
+ color: var(--text);
+}
+
+/* Retrieval */
+.retrieval-source-group {
+ margin-bottom: 10px;
+}
+
+.retrieval-source-group:last-child {
+ margin-bottom: 0;
+}
+
+.retrieval-query-label {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.retrieval-adopted-badge {
+ background: rgba(63, 185, 80, 0.15);
+ color: var(--green);
+ border-radius: 3px;
+ padding: 1px 5px;
+ font-size: 10px;
+}
+
+.retrieval-not-adopted-badge {
+ background: rgba(139, 148, 158, 0.1);
+ color: var(--text-muted);
+ border-radius: 3px;
+ padding: 1px 5px;
+ font-size: 10px;
+}
+
+.retrieval-item {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 4px;
+ align-items: flex-start;
+}
+
+.retrieval-item-num {
+ flex-shrink: 0;
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-radius: 3px;
+ padding: 0 4px;
+ font-size: 10px;
+ font-family: monospace;
+ line-height: 18px;
+ margin-top: 1px;
+}
+
+.retrieval-item-text {
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.retrieval-source-label {
+ font-size: 10px;
+ color: var(--text-muted);
+ opacity: 0.7;
+ margin-left: 4px;
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/AgentCard.jsx b/frontend-web/src/strategy-components/components/qa/components/AgentCard.jsx
new file mode 100644
index 00000000..3912aa52
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/AgentCard.jsx
@@ -0,0 +1,204 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './AgentCard.css';
+import MarkdownContent from './MarkdownContent';
+
+/**
+ * 根据阶段返回对应的 CSS badge class
+ */
+function getPhaseBadgeClass(phase) {
+ switch (phase) {
+ case 'starting': return 'badge badge-starting';
+ case 'diverging': return 'badge badge-diverging';
+ case 'closing': return 'badge badge-closing';
+ case 'done': return 'badge badge-done';
+ default: return 'badge badge-default';
+ }
+}
+
+/** 阶段中文映射表 */
+const PHASE_LABELS = {
+ starting: '启动',
+ diverging: '辩论',
+ closing: '收敛',
+ done: '完成',
+};
+
+/**
+ * 智能体输出卡片
+ *
+ * 展示单个智能体在一轮中的完整输出,包含以下区块:
+ * 1. 头部:智能体名称、分析维度、轮次、阶段、流式状态
+ * 2. 思考过程(可折叠):流式时自动展开显示实时 token,完成后用 Markdown 渲染
+ * 3. 分析内容:流式时显示原始 token + 光标动画,完成后用 Markdown 渲染(带引用高亮)
+ * 4. 维度输出:结构化数据展示
+ * 5. 检索信息:该智能体使用的搜索/检索结果及来源
+ *
+ * @param {string} agentId - 智能体唯一 ID
+ * @param {string} agentName - 显示名称
+ * @param {string} dimension - 分析维度
+ * @param {number} round - 当前轮次
+ * @param {string} phase - 当前阶段
+ * @param {string} content - 分析文本(流式时为累积 token)
+ * @param {string} thinking - 思考过程文本
+ * @param {Object} dimensionOutput - 结构化维度输出
+ * @param {Array} retrievalLog - 检索日志
+ * @param {number} retrievalCount - 检索条目数
+ * @param {Object} referenceMap - 引用编号到源 ID 的映射
+ * @param {boolean} streaming - 是否正在流式输出中
+ */
+export default function AgentCard({
+ agentId,
+ agentName,
+ dimension,
+ round,
+ phase,
+ content,
+ thinking,
+ dimensionOutput,
+ retrievalLog,
+ retrievalCount,
+ referenceMap,
+ streaming = false,
+}) {
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+ const autoOpenedRef = useRef(false);
+
+ // 当 thinking 在流式过程中开始有内容时自动展开一次
+ useEffect(() => {
+ if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
+ setThinkingOpen(true);
+ autoOpenedRef.current = true;
+ }
+ }, [streaming, thinking]);
+
+ // 流式输出阶段判断:有 content → 正在输出分析内容;否则正在输出思考过程
+ const streamingPhase = streaming
+ ? (content && content.length > 0 ? 'content' : 'thinking')
+ : null;
+
+ const hasThinking = thinking && thinking.trim().length > 0;
+ const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
+ const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
+
+ return (
+
+ {/* ====== 头部信息 ====== */}
+
+
{agentName || agentId}
+
+ {dimension && (
+ {dimension}
+ )}
+ 第 {round} 轮
+
+ {PHASE_LABELS[phase] || phase}
+
+ {streaming && (
+ 生成中
+ )}
+
+
+
+ {/* ====== 思考过程(可折叠) ====== */}
+ {/* 流式时自动展开显示纯文本;完成后可折叠,用 Markdown 渲染 */}
+ {(hasThinking || streamingPhase === 'thinking') && (
+
+
+ {thinkingOpen && (
+
+ {streaming ? (
+
+ {thinking}
+
+ ) : (
+
+ )}
+
+ )}
+
+ )}
+
+ {/* ====== 分析内容 ====== */}
+ {(content || streamingPhase === 'content') && (
+
+
分析内容
+
+ {streaming ? (
+
+ {content}
+ {streamingPhase === 'content' && }
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {/* ====== 维度输出 ====== */}
+ {hasDimOutput && (
+
+
维度输出
+
+ {Object.entries(dimensionOutput).map(([key, value]) => (
+
+ ))}
+
+
+ )}
+
+ {/* ====== 检索信息 ====== */}
+ {hasRetrieval && (
+
+
检索信息 ({retrievalCount})
+ {retrievalLog.map((log, logIdx) => (
+
+
+ "{log.query}"
+ {(log.source_name || log.source_id || log.source) && (
+ {log.source_name || log.source_id || log.source}
+ )}
+ {log.adopted !== undefined && (
+
+ {log.adopted ? '已采用' : '未采用'}
+
+ )}
+
+ {/* 检索结果条目,关联引用编号 */}
+ {log.content && log.content.map((item, itemIdx) => {
+ const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
+ const refKey = refNum ? Object.entries(referenceMap || {}).find(([k, v]) => v === refNum)?.[0] : null;
+ return (
+
+ {refKey &&
[{refKey}]}
+
+ {typeof item === 'string'
+ ? (item.length > 200 ? item.slice(0, 200) + '…' : item)
+ : JSON.stringify(item).slice(0, 200)}
+
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/AgentCard.tsx b/frontend-web/src/strategy-components/components/qa/components/AgentCard.tsx
new file mode 100644
index 00000000..0a068b79
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/AgentCard.tsx
@@ -0,0 +1,193 @@
+import React, { useState, useEffect, useRef } from 'react';
+import './AgentCard.css';
+import MarkdownContent from './MarkdownContent';
+
+function getPhaseBadgeClass(phase?: string): string {
+ switch (phase) {
+ case 'starting': return 'badge badge-starting';
+ case 'diverging': return 'badge badge-diverging';
+ case 'closing': return 'badge badge-closing';
+ case 'done': return 'badge badge-done';
+ default: return 'badge badge-default';
+ }
+}
+
+const PHASE_LABELS: Record = {
+ starting: '启动',
+ diverging: '辩论',
+ closing: '收敛',
+ done: '完成',
+};
+
+type RetrievalLogItem = {
+ query?: string;
+ source_name?: string;
+ source_id?: string;
+ source?: string;
+ adopted?: boolean;
+ content?: any[];
+ content_ids?: string[];
+};
+
+type Props = {
+ agentId: string;
+ agentName?: string;
+ dimension?: string;
+ round: number;
+ phase: string;
+ content?: string;
+ thinking?: string;
+ dimensionOutput?: Record;
+ retrievalLog?: RetrievalLogItem[];
+ retrievalCount?: number;
+ referenceMap?: Record;
+ streaming?: boolean;
+};
+
+export default function AgentCard({
+ agentId,
+ agentName,
+ dimension,
+ round,
+ phase,
+ content,
+ thinking,
+ dimensionOutput,
+ retrievalLog,
+ retrievalCount = 0,
+ referenceMap,
+ streaming = false,
+}: Props): JSX.Element {
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+ const autoOpenedRef = useRef(false);
+
+ useEffect(() => {
+ if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
+ setThinkingOpen(true);
+ autoOpenedRef.current = true;
+ }
+ }, [streaming, thinking]);
+
+ const streamingPhase = streaming
+ ? (content && content.length > 0 ? 'content' : 'thinking')
+ : null;
+
+ const hasThinking = thinking && thinking.trim().length > 0;
+ const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
+ const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
+
+ return (
+
+
+
{agentName || agentId}
+
+ {dimension && (
+ {dimension}
+ )}
+ 第 {round} 轮
+
+ {PHASE_LABELS[phase] || phase}
+
+ {streaming && (
+ 生成中
+ )}
+
+
+
+ {(hasThinking || streamingPhase === 'thinking') && (
+
+
+ {thinkingOpen && (
+
+ {streaming ? (
+
+ {thinking}
+
+ ) : (
+
+ )}
+
+ )}
+
+ )}
+
+ {(content || streamingPhase === 'content') && (
+
+
分析内容
+
+ {streaming ? (
+
+ {content}
+ {streamingPhase === 'content' && }
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {hasDimOutput && (
+
+
维度输出
+
+ {Object.entries(dimensionOutput).map(([key, value]) => (
+
+ ))}
+
+
+ )}
+
+ {hasRetrieval && (
+
+
检索信息 ({retrievalCount})
+ {retrievalLog.map((log, logIdx) => (
+
+
+ "{log.query}"
+ {(log.source_name || log.source_id || log.source) && (
+ {log.source_name || log.source_id || log.source}
+ )}
+ {log.adopted !== undefined && (
+
+ {log.adopted ? '已采用' : '未采用'}
+
+ )}
+
+ {log.content && log.content.map((item, itemIdx) => {
+ const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
+ const refKey = refNum ? Object.entries(referenceMap || {}).find(([, v]) => v === refNum)?.[0] : null;
+ return (
+
+ {refKey &&
[{refKey}]}
+
+ {typeof item === 'string'
+ ? (item.length > 200 ? item.slice(0, 200) + '…' : item)
+ : JSON.stringify(item).slice(0, 200)}
+
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.css b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.css
new file mode 100644
index 00000000..50c24349
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.css
@@ -0,0 +1,467 @@
+.config-panel {
+ width: 100%;
+ max-width: 1080px;
+ margin: 0 auto;
+}
+
+.config-panel-title {
+ text-align: center;
+ margin-bottom: 24px;
+ padding-top: 4px;
+}
+
+.config-title-eyebrow {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 10px;
+ padding: 4px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface-raised);
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+}
+
+.config-panel-title h1 {
+ font-size: 32px;
+ line-height: 1.1;
+ font-weight: 800;
+ color: var(--accent);
+ letter-spacing: 1px;
+ margin-bottom: 8px;
+}
+
+.config-panel-title p {
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.config-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.config-task-section,
+.config-card,
+.config-advanced-section {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ box-shadow: var(--shadow-card);
+}
+
+.config-task-section {
+ padding: 24px;
+}
+
+.config-task-section label {
+ display: block;
+ font-weight: 700;
+ font-size: 13px;
+ color: var(--text);
+ margin-bottom: 10px;
+}
+
+.config-task-textarea,
+.config-textarea,
+.config-input,
+.config-json-editor {
+ width: 100%;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 13px;
+ outline: none;
+ box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.03);
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
+}
+
+.config-task-textarea,
+.config-textarea,
+.config-json-editor {
+ padding: 14px 16px;
+ resize: vertical;
+ line-height: 1.7;
+}
+
+.config-task-textarea {
+ min-height: 150px;
+}
+
+.config-textarea {
+ min-height: 108px;
+}
+
+.config-textarea-sm {
+ min-height: 78px;
+}
+
+.config-input {
+ padding: 11px 13px;
+}
+
+.config-json-editor {
+ min-height: 320px;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+}
+
+.config-task-textarea:focus,
+.config-textarea:focus,
+.config-input:focus,
+.config-json-editor:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ background: var(--surface-raised);
+}
+
+.config-submit-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: flex-end;
+ margin-top: 18px;
+ flex-wrap: wrap;
+ padding-top: 4px;
+}
+
+.config-mode-switch {
+ display: inline-flex;
+ background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+ padding: 3px;
+ flex-shrink: 0;
+}
+
+.config-mode-option {
+ background: transparent;
+ color: var(--text-muted);
+ border: none;
+ border-radius: 9px;
+ padding: 8px 14px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.config-mode-option.active {
+ background: var(--accent);
+ color: var(--button-text);
+ box-shadow: 0 8px 18px var(--accent-soft-strong);
+}
+
+.config-mode-option:not(.active):hover:not(:disabled) {
+ background: var(--surface-hover-strong);
+ color: var(--text);
+}
+
+.config-run-mode-row {
+ display: grid;
+ grid-template-columns: auto minmax(260px, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ margin-top: 16px;
+}
+
+.config-run-mode-row .config-field-hint {
+ margin-top: 0;
+ min-width: 0;
+}
+
+.config-mode-hint {
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: var(--surface-hover);
+}
+
+.config-scenario-button {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.config-run-options-row {
+ display: grid;
+ grid-template-columns: 80px minmax(120px, 1fr) minmax(260px, 1.2fr);
+ align-items: center;
+ gap: 12px;
+ margin-top: 14px;
+}
+
+.config-run-options-row label {
+ margin-bottom: 0;
+ min-width: 0;
+ color: var(--text-muted);
+}
+
+.config-number-input {
+ width: 100%;
+ max-width: 96px;
+}
+
+.config-run-options-row .config-field-hint {
+ margin-top: 0;
+ min-width: 0;
+ padding-left: 2px;
+}
+
+.btn-start {
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover));
+ color: var(--button-text);
+ font-weight: 800;
+ font-size: 14px;
+ padding: 11px 30px;
+ border-radius: 12px;
+ letter-spacing: 0.3px;
+ box-shadow: 0 12px 26px var(--accent-soft-strong);
+}
+
+.btn-start:hover:not(:disabled) {
+ opacity: 0.85;
+}
+
+.config-card-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.config-card {
+ padding: 22px 24px;
+}
+
+.config-card-header {
+ margin-bottom: 14px;
+}
+
+.config-card-header h3 {
+ font-size: 18px;
+ color: var(--text);
+ margin-bottom: 6px;
+}
+
+.config-card-header p {
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.6;
+}
+
+.config-field {
+ margin-top: 14px;
+}
+
+.config-field:first-child {
+ margin-top: 0;
+}
+
+.config-field label {
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.config-field-hint {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.6;
+}
+
+.config-task-textarea::placeholder,
+.config-textarea::placeholder,
+.config-input::placeholder,
+.config-json-editor::placeholder {
+ color: color-mix(in srgb, var(--text-muted) 72%, transparent);
+}
+
+.config-fixed-mode {
+ margin-right: auto;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.config-agents,
+.config-report-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.config-agent-card,
+.config-report-card {
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 16px;
+}
+
+.config-agents.readonly,
+.config-report-grid.readonly {
+ margin-top: 10px;
+}
+
+.config-report-card p,
+.config-agent-card p {
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.65;
+ margin: 0;
+}
+
+.config-report-card pre,
+.config-readonly-block pre {
+ margin: 12px 0 0;
+ max-height: 260px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-muted);
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 11px;
+ line-height: 1.55;
+ padding: 10px;
+}
+
+.config-readonly-block {
+ margin-top: 18px;
+}
+
+.config-readonly-block:first-child {
+ margin-top: 0;
+}
+
+.config-readonly-title {
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 700;
+ margin-bottom: 10px;
+}
+
+.config-readonly-content {
+ color: var(--text-muted);
+}
+
+.config-readonly-meta {
+ margin-top: 8px !important;
+ color: var(--accent) !important;
+}
+
+.config-empty-detail {
+ color: var(--text-muted);
+ font-size: 13px;
+ padding: 12px 0 0;
+}
+
+.config-agent-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.config-agent-head span {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.config-agent-head code {
+ color: var(--accent);
+ background: var(--accent-soft);
+ border-radius: 999px;
+ padding: 3px 8px;
+ font-size: 11px;
+}
+
+.config-subgrid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+}
+
+.config-advanced-section {
+ overflow: hidden;
+}
+
+.config-advanced-toggle {
+ width: 100%;
+ background: transparent;
+ border: none;
+ padding: 16px 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+ text-align: left;
+ border-radius: 0;
+}
+
+.config-advanced-toggle:hover {
+ color: var(--accent);
+ background: var(--surface-hover);
+}
+
+.config-advanced-chevron {
+ margin-left: auto;
+ transition: transform 0.2s;
+}
+
+.config-advanced-chevron.open {
+ transform: rotate(180deg);
+}
+
+.config-advanced-body {
+ padding: 0 20px 20px;
+ border-top: 1px solid var(--border);
+}
+
+@media (max-width: 900px) {
+ .config-task-section,
+ .config-card {
+ padding: 18px;
+ }
+
+ .config-run-mode-row,
+ .config-run-options-row {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+
+ .config-mode-switch {
+ width: 100%;
+ }
+
+ .config-mode-option {
+ flex: 1;
+ }
+
+ .config-scenario-button,
+ .config-number-input {
+ width: 100%;
+ max-width: none;
+ }
+
+ .config-card-grid,
+ .config-agents,
+ .config-report-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.jsx b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.jsx
new file mode 100644
index 00000000..266927ef
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.jsx
@@ -0,0 +1,459 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
+import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
+import './ConfigPanel.css';
+
+/**
+ * 卡片布局子组件
+ * 带标题和可选描述的通用卡片容器
+ */
+function Card({ title, description, children }) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {children}
+
+ );
+}
+
+/**
+ * 只读内容块子组件
+ * 用于展示模板的只读配置(字段定义、智能体信息等)
+ */
+function ReadOnlyBlock({ title, children }) {
+ return (
+
+ );
+}
+
+/**
+ * 配置面板组件
+ *
+ * 应用首页的核心表单,用户在启动分析前进行配置:
+ * 1. 任务背景:描述事件背景、传播目标和限制条件
+ * 2. 运行模式:自动完成 / 人工逐轮确认
+ * 3. 讨论轮次:控制辩论轮数(1-8)
+ * 4. 运行模型:从后端 /config/options 获取 LLM 预设列表
+ * 5. 一致性模式:free(自由)或 soft(软约束)
+ * 6. 报告模板:从后端 /templates/index.json 加载模板列表
+ *
+ * 提交时调用 onStart(config, backendUrl) 启动 SSE 分析会话。
+ *
+ * @param {Function} onStart - 开始分析回调 (config, backendUrl)
+ * @param {boolean} isRunning - 是否正在运行中(禁用表单)
+ * @param {Function} onStop - 停止分析回调
+ * @param {Function} onOpenScenarioConfig - 打开场景配置页回调
+ */
+export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
+ const [taskContext, setTaskContext] = useState('');
+ const backendUrl = DEFAULT_BACKEND_URL;
+ const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
+ const [rawOpen, setRawOpen] = useState(false);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [templates, setTemplates] = useState([]);
+ const [templateLoading, setTemplateLoading] = useState(false);
+ const [templateError, setTemplateError] = useState('');
+ const [llmPresets, setLlmPresets] = useState([]);
+ const [llmLoading, setLlmLoading] = useState(false);
+ const [llmError, setLlmError] = useState('');
+
+ /** 安全更新配置对象(深拷贝后修改,避免直接 mutate) */
+ const updateConfig = (updater) => {
+ setConfig(prev => {
+ const next = clone(prev);
+ updater(next);
+ return next;
+ });
+ };
+
+ const setCoordinatorMode = (mode) => {
+ updateConfig(next => {
+ next.coordinator_mode = mode;
+ });
+ };
+
+ /** 设置辩论轮数(限制范围 1-8) */
+ const setDivergeRounds = (value) => {
+ const parsed = Number.parseInt(value, 10);
+ const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
+ updateConfig(next => {
+ next.phase_config = {
+ ...(next.phase_config || {}),
+ diverge_rounds: rounds,
+ };
+ });
+ };
+
+ const setLlmPreset = (presetId) => {
+ updateConfig(next => {
+ next.llm_preset_id = presetId;
+ });
+ };
+
+ const setConsistencyMode = (mode) => {
+ updateConfig(next => {
+ next.consistency_mode = mode;
+ });
+ };
+
+ /** 提交表单:验证任务背景和模板 ID 后启动分析 */
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!taskContext.trim() || !config.template_id) return;
+ onStart({
+ ...config,
+ task_context: taskContext.trim(),
+ coordinator_mode: config.coordinator_mode || 'auto',
+ }, backendUrl);
+ };
+
+ /** 从后端加载模板列表 */
+ const loadTemplates = async () => {
+ setTemplateLoading(true);
+ setTemplateError('');
+ try {
+ setTemplates(await loadTemplateIndex());
+ } catch (error) {
+ setTemplateError(error.message || '模板列表加载失败');
+ } finally {
+ setTemplateLoading(false);
+ }
+ };
+
+ /** 从后端 /config/options 加载 LLM 预设和默认值 */
+ const loadConfigOptions = async () => {
+ setLlmLoading(true);
+ setLlmError('');
+ try {
+ const response = await fetch(`${backendUrl}/config/options`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ const presets = data.llm_presets || [];
+ setLlmPresets(presets);
+ updateConfig(next => {
+ if (!next.llm_preset_id) {
+ next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
+ }
+ });
+ } catch (error) {
+ setLlmError(error.message || '模型配置加载失败');
+ } finally {
+ setLlmLoading(false);
+ }
+ };
+
+ /** 应用选中的模板:将模板的字段、智能体、协调者策略写入 config */
+ const applyTemplate = async (templateId) => {
+ if (!templateId) return;
+ const meta = templates.find(item => item.template_id === templateId);
+ setTemplateLoading(true);
+ setTemplateError('');
+ try {
+ const template = await loadTemplateByMeta(meta || templateId);
+ updateConfig(next => {
+ next.template_id = template.template_id || templateId;
+ next.template_name = template.template_name || '';
+ next.template_brief = template.template_brief || '';
+ next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
+ next.report_schema = clone(template.report_schema || {});
+ next.report_examples = clone(template.report_examples || {});
+ next.field_requirements = clone(template.field_requirements || {});
+ next.agents = clone(template.agents || next.agents || []);
+ next.coordinator_mode = next.coordinator_mode || 'auto';
+ next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
+ next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
+ });
+ } catch (error) {
+ setTemplateError(error.message || '模板加载失败');
+ } finally {
+ setTemplateLoading(false);
+ }
+ };
+
+ // 初始化:加载模板列表和 LLM 预设
+ useEffect(() => {
+ if (!backendUrl) return;
+ loadTemplates();
+ loadConfigOptions();
+ }, [backendUrl]);
+
+ /** 当前选中的 LLM 预设对象 */
+ const selectedLlmPreset = useMemo(
+ () => llmPresets.find(item => item.id === config.llm_preset_id) || null,
+ [llmPresets, config.llm_preset_id]
+ );
+
+ /** 原始配置 JSON 预览 */
+ const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
+ const canStart = taskContext.trim() && config.template_id;
+
+ return (
+
+ {/* 页面标题 */}
+
+
MOPE
+
多智能体公关规划系统
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.tsx b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.tsx
new file mode 100644
index 00000000..f083462b
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ConfigPanel.tsx
@@ -0,0 +1,413 @@
+// @ts-nocheck
+import React, { useEffect, useMemo, useState } from 'react';
+import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
+import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
+import './ConfigPanel.css';
+
+function Card({ title, description, children }) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {children}
+
+ );
+}
+
+function ReadOnlyBlock({ title, children }) {
+ return (
+
+ );
+}
+
+export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
+ const [taskContext, setTaskContext] = useState('');
+ const backendUrl = DEFAULT_BACKEND_URL;
+ const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
+ const [rawOpen, setRawOpen] = useState(false);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [templates, setTemplates] = useState([]);
+ const [templateLoading, setTemplateLoading] = useState(false);
+ const [templateError, setTemplateError] = useState('');
+ const [llmPresets, setLlmPresets] = useState([]);
+ const [llmLoading, setLlmLoading] = useState(false);
+ const [llmError, setLlmError] = useState('');
+
+ const updateConfig = (updater) => {
+ setConfig(prev => {
+ const next = clone(prev);
+ updater(next);
+ return next;
+ });
+ };
+
+ const setCoordinatorMode = (mode) => {
+ updateConfig(next => {
+ next.coordinator_mode = mode;
+ });
+ };
+
+ const setDivergeRounds = (value) => {
+ const parsed = Number.parseInt(value, 10);
+ const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
+ updateConfig(next => {
+ next.phase_config = {
+ ...(next.phase_config || {}),
+ diverge_rounds: rounds,
+ };
+ });
+ };
+
+ const setLlmPreset = (presetId) => {
+ updateConfig(next => {
+ next.llm_preset_id = presetId;
+ });
+ };
+
+ const setConsistencyMode = (mode) => {
+ updateConfig(next => {
+ next.consistency_mode = mode;
+ });
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!taskContext.trim() || !config.template_id) return;
+ onStart({
+ ...config,
+ task_context: taskContext.trim(),
+ coordinator_mode: config.coordinator_mode || 'auto',
+ }, backendUrl);
+ };
+
+ const loadTemplates = async () => {
+ setTemplateLoading(true);
+ setTemplateError('');
+ try {
+ setTemplates(await loadTemplateIndex());
+ } catch (error) {
+ setTemplateError(error.message || '模板列表加载失败');
+ } finally {
+ setTemplateLoading(false);
+ }
+ };
+
+ const loadConfigOptions = async () => {
+ setLlmLoading(true);
+ setLlmError('');
+ try {
+ const response = await fetch(`${backendUrl}/config/options`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ const presets = data.llm_presets || [];
+ setLlmPresets(presets);
+ updateConfig(next => {
+ if (!next.llm_preset_id) {
+ next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
+ }
+ });
+ } catch (error) {
+ setLlmError(error.message || '模型配置加载失败');
+ } finally {
+ setLlmLoading(false);
+ }
+ };
+
+ const applyTemplate = async (templateId) => {
+ if (!templateId) return;
+ const meta = templates.find(item => item.template_id === templateId);
+ setTemplateLoading(true);
+ setTemplateError('');
+ try {
+ const template = await loadTemplateByMeta(meta || templateId);
+ updateConfig(next => {
+ next.template_id = template.template_id || templateId;
+ next.template_name = template.template_name || '';
+ next.template_brief = template.template_brief || '';
+ next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
+ next.report_schema = clone(template.report_schema || {});
+ next.report_examples = clone(template.report_examples || {});
+ next.field_requirements = clone(template.field_requirements || {});
+ next.agents = clone(template.agents || next.agents || []);
+ next.coordinator_mode = next.coordinator_mode || 'auto';
+ next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
+ next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
+ });
+ } catch (error) {
+ setTemplateError(error.message || '模板加载失败');
+ } finally {
+ setTemplateLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!backendUrl) return;
+ loadTemplates();
+ loadConfigOptions();
+ }, [backendUrl]);
+
+ const selectedLlmPreset = useMemo(
+ () => llmPresets.find(item => item.id === config.llm_preset_id) || null,
+ [llmPresets, config.llm_preset_id]
+ );
+
+ const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
+ const canStart = taskContext.trim() && config.template_id;
+
+ return (
+
+
+
Multi-Agent Opinion Planning Engine
+
MOPE
+
多智能体公共舆论研判系统
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/FinalReport.css b/frontend-web/src/strategy-components/components/qa/components/FinalReport.css
new file mode 100644
index 00000000..f0fa05d2
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/FinalReport.css
@@ -0,0 +1,167 @@
+.final-report {
+ background: var(--surface-raised);
+ border: 1px solid var(--green);
+ border-radius: 16px;
+ overflow: hidden;
+ margin-top: 24px;
+ box-shadow: var(--shadow-card);
+}
+
+.final-report-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(63, 185, 80, 0.05);
+}
+
+.final-report-icon {
+ font-size: 20px;
+}
+
+.final-report-title {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--green);
+}
+
+.final-report-meta {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+/* Report sections */
+.final-report-sections {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.final-report-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.final-report-section-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--green);
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(63, 185, 80, 0.2);
+}
+
+.final-report-section-content {
+ font-size: 14px;
+ color: var(--text);
+}
+
+.final-report-section-content .markdown-body {
+ font-size: 14px;
+}
+
+/* Dimensions section */
+.final-report-dimensions-toggle {
+ width: 100%;
+ background: transparent;
+ border: none;
+ border-top: 1px solid var(--border);
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ text-align: left;
+ border-radius: 0;
+ transition: color 0.15s, background 0.15s;
+}
+
+.final-report-dimensions-toggle:hover {
+ color: var(--text);
+ background: var(--surface-hover);
+}
+
+.final-report-dimensions-chevron {
+ margin-left: auto;
+ transition: transform 0.2s;
+}
+
+.final-report-dimensions-chevron.open {
+ transform: rotate(180deg);
+}
+
+.final-report-dimensions-body {
+ padding: 16px 20px 20px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.dim-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.dim-detail-label {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--accent);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dim-detail-sublabel {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.dim-detail-analysis {
+ font-size: 13px;
+ color: var(--text);
+}
+
+.dim-detail-output {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 4px;
+ padding: 8px 12px;
+ background: var(--surface-hover);
+ border-radius: 6px;
+ border-left: 2px solid var(--accent);
+}
+
+.dim-detail-output-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.dim-detail-output-key {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+}
+
+.dim-detail-output-value {
+ font-size: 12px;
+ color: var(--text);
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/FinalReport.jsx b/frontend-web/src/strategy-components/components/qa/components/FinalReport.jsx
new file mode 100644
index 00000000..f7802418
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/FinalReport.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react';
+import './FinalReport.css';
+import MarkdownContent from './MarkdownContent';
+
+/** 报告字段中文标签映射表 */
+const REPORT_FIELD_LABELS = {
+ core_strategy: '核心策略',
+ action_plan: '行动计划',
+ key_messages: '核心话术',
+ risk_warnings: '风险预警',
+};
+
+/**
+ * 最终报告组件
+ *
+ * 在所有分析轮次结束后显示完整的最终报告。
+ * 包含两部分:
+ * 1. 报告主体:按 reportSchema 定义的字段顺序渲染各章节,支持 Markdown
+ * 2. 维度详情(可折叠):每个智能体分析维度的详细输出
+ *
+ * @param {Object} output - 报告数据 { report: { field: content }, dimensions: { dim: data }, meta: { total_rounds } }
+ * @param {Object} reportSchema - 报告字段定义,用于获取字段顺序和中文标签
+ */
+export default function FinalReport({ output, reportSchema }) {
+ const [dimOpen, setDimOpen] = useState(false);
+
+ if (!output) return null;
+
+ const { report, dimensions, meta } = output;
+
+ // 优先使用 reportSchema 的 key 顺序,否则用 report 自身的 key
+ const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
+ ? Object.keys(reportSchema)
+ : (report ? Object.keys(report) : []);
+
+ return (
+
+ {/* 报告标题区 */}
+
+ ✦
+ 最终方案
+ {meta && meta.total_rounds != null && (
+ 共 {meta.total_rounds} 轮分析
+ )}
+
+
+ {/* 报告主体:按字段顺序渲染 */}
+ {report && reportKeys.length > 0 && (
+
+ {reportKeys.map(key => {
+ const content = report[key];
+ if (content == null) return null;
+ // 优先用 reportSchema 中的字段描述作为标题,其次用中文映射表,兜底用 key
+ const label = (reportSchema && reportSchema[key])
+ ? reportSchema[key]
+ : (REPORT_FIELD_LABELS[key] || key);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {/* 维度详情(可折叠) */}
+ {dimensions && Object.keys(dimensions).length > 0 && (
+ <>
+
+
+ {dimOpen && (
+
+ {Object.entries(dimensions).map(([dim, dimData]) => (
+
+
+ {dim}
+ {typeof dimData === 'object' && dimData?.label && (
+ {dimData.label}
+ )}
+
+
+ {/* 字符串类型的维度分析 */}
+ {typeof dimData === 'string' && (
+
+
+
+ )}
+
+ {/* 对象类型的维度分析,取 latest_analysis 字段 */}
+ {typeof dimData === 'object' && dimData?.latest_analysis && (
+
+
+
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/FinalReport.tsx b/frontend-web/src/strategy-components/components/qa/components/FinalReport.tsx
new file mode 100644
index 00000000..f4e117e9
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/FinalReport.tsx
@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+import './FinalReport.css';
+import MarkdownContent from './MarkdownContent';
+
+const REPORT_FIELD_LABELS: Record = {
+ core_strategy: '核心策略',
+ action_plan: '行动计划',
+ key_messages: '核心话术',
+ risk_warnings: '风险预警',
+};
+
+type Props = {
+ output?: any;
+ reportSchema?: Record;
+};
+
+export default function FinalReport({ output, reportSchema }: Props): JSX.Element | null {
+ const [dimOpen, setDimOpen] = useState(false);
+ if (!output) return null;
+
+ const { report, dimensions, meta } = output;
+ const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
+ ? Object.keys(reportSchema)
+ : (report ? Object.keys(report) : []);
+
+ return (
+
+
+ ✦
+ 最终方案
+ {meta && meta.total_rounds != null && (
+ 共 {meta.total_rounds} 轮分析
+ )}
+
+
+ {report && reportKeys.length > 0 && (
+
+ {reportKeys.map(key => {
+ const content = report[key];
+ if (content == null) return null;
+ const label = (reportSchema && reportSchema[key])
+ ? reportSchema[key]
+ : (REPORT_FIELD_LABELS[key] || key);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {dimensions && Object.keys(dimensions).length > 0 && (
+ <>
+
+
+ {dimOpen && (
+
+ {Object.entries(dimensions).map(([dim, dimData]: [string, any]) => (
+
+
+ {dim}
+ {typeof dimData === 'object' && dimData?.label && (
+ {dimData.label}
+ )}
+
+
+ {typeof dimData === 'string' && (
+
+
+
+ )}
+
+ {typeof dimData === 'object' && dimData?.latest_analysis && (
+
+
+
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.css b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.css
new file mode 100644
index 00000000..9de1755b
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.css
@@ -0,0 +1,131 @@
+.markdown-body {
+ line-height: 1.7;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.markdown-body > *:first-child { margin-top: 0; }
+.markdown-body > *:last-child { margin-bottom: 0; }
+
+/* Headings */
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+ margin: 1em 0 0.4em;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--text-primary);
+}
+.markdown-body h1 { font-size: 1.3em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
+.markdown-body h2 { font-size: 1.15em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; }
+.markdown-body h3 { font-size: 1.05em; }
+.markdown-body h4 { font-size: 0.95em; }
+
+/* Paragraphs */
+.markdown-body p {
+ margin: 0.5em 0;
+}
+
+/* Lists */
+.markdown-body ul,
+.markdown-body ol {
+ margin: 0.5em 0;
+ padding-left: 1.6em;
+}
+.markdown-body li {
+ margin: 0.25em 0;
+}
+.markdown-body li > ul,
+.markdown-body li > ol {
+ margin: 0.2em 0;
+}
+
+/* Bold & italic */
+.markdown-body strong { font-weight: 600; color: var(--text-primary); }
+.markdown-body em { font-style: italic; color: var(--text-secondary); }
+
+/* Inline code */
+.markdown-body code {
+ background: rgba(110, 118, 129, 0.15);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 0.1em 0.4em;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 0.875em;
+ color: var(--red);
+}
+
+/* Code blocks */
+.markdown-body pre {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 1em;
+ overflow-x: auto;
+ margin: 0.6em 0;
+}
+.markdown-body pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.85em;
+ color: var(--text-primary);
+}
+
+/* Blockquote */
+.markdown-body blockquote {
+ border-left: 3px solid var(--accent-blue);
+ margin: 0.5em 0;
+ padding: 0.3em 0.8em;
+ color: var(--text-secondary);
+ background: rgba(88, 166, 255, 0.05);
+ border-radius: 0 4px 4px 0;
+}
+.markdown-body blockquote p { margin: 0; }
+
+/* Horizontal rule */
+.markdown-body hr {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: 1em 0;
+}
+
+/* Links */
+.markdown-body a {
+ color: var(--accent-blue);
+ text-decoration: none;
+}
+.markdown-body a:hover {
+ text-decoration: underline;
+}
+
+/* Tables (remark-gfm) */
+.markdown-body table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 0.6em 0;
+ font-size: 0.9em;
+}
+.markdown-body th,
+.markdown-body td {
+ border: 1px solid var(--border-color);
+ padding: 0.4em 0.7em;
+ text-align: left;
+}
+.markdown-body th {
+ background: var(--bg-secondary);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.markdown-body tr:nth-child(even) td {
+ background: var(--surface-hover);
+}
+
+/* Task lists (remark-gfm) */
+.markdown-body input[type='checkbox'] {
+ margin-right: 0.4em;
+ accent-color: var(--accent-blue);
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.jsx b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.jsx
new file mode 100644
index 00000000..2783f04f
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import './MarkdownContent.css';
+
+/**
+ * Rehype 插件:高亮 Markdown 文本中的引用标记 [n]
+ *
+ * 遍历 hast 树中的所有文本节点,将匹配 [数字] 格式的片段
+ * 替换为带 tooltip 的 元素。
+ * 在 AgentCard 的最终输出中使用,帮助追溯检索到的源材料。
+ *
+ * @param {Object} referenceMap - 引用编号到源 ID 的映射,如 { "1": "src_abc123", "2": "src_def456" }
+ * @returns {Function} rehype 插件函数
+ */
+function rehypeHighlightRefs(referenceMap) {
+ return function (tree) {
+ function walk(node) {
+ if (!node.children) return;
+ const next = [];
+ for (const child of node.children) {
+ if (child.type === 'text') {
+ const parts = child.value.split(/(\[\d+\])/g);
+ if (parts.length > 1) {
+ for (const part of parts) {
+ if (!part) continue;
+ const m = part.match(/^\[(\d+)\]$/);
+ if (m) {
+ const num = m[1];
+ const itemId = referenceMap?.[num];
+ next.push({
+ type: 'element',
+ tagName: 'span',
+ properties: {
+ className: 'ref-cite',
+ 'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
+ },
+ children: [{ type: 'text', value: part }],
+ });
+ } else {
+ next.push({ type: 'text', value: part });
+ }
+ }
+ } else {
+ next.push(child);
+ }
+ } else {
+ walk(child);
+ next.push(child);
+ }
+ }
+ node.children = next;
+ }
+ walk(tree);
+ };
+}
+
+/**
+ * Markdown 渲染组件
+ *
+ * 封装 react-markdown,提供统一的 Markdown 渲染体验:
+ * - 支持 GFM(表格、删除线、任务列表等)
+ * - 自动高亮引用标记 [n] 为可点击的引用标签
+ *
+ * @param {string} content - Markdown 文本内容
+ * @param {Object} referenceMap - 引用编号映射(可选)
+ * @param {string} className - 额外的 CSS 类名
+ */
+export default function MarkdownContent({ content, referenceMap, className = '' }) {
+ if (!content) return null;
+
+ const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
+
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.tsx b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.tsx
new file mode 100644
index 00000000..1d676adb
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/MarkdownContent.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import './MarkdownContent.css';
+
+type Props = {
+ content?: string;
+ referenceMap?: Record;
+ className?: string;
+};
+
+function rehypeHighlightRefs(referenceMap?: Record) {
+ return function (tree: any) {
+ function walk(node: any) {
+ if (!node.children) return;
+ const next = [];
+ for (const child of node.children) {
+ if (child.type === 'text') {
+ const parts = child.value.split(/(\[\d+\])/g);
+ if (parts.length > 1) {
+ for (const part of parts) {
+ if (!part) continue;
+ const m = part.match(/^\[(\d+)\]$/);
+ if (m) {
+ const num = m[1];
+ const itemId = referenceMap?.[num];
+ next.push({
+ type: 'element',
+ tagName: 'span',
+ properties: {
+ className: 'ref-cite',
+ 'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
+ },
+ children: [{ type: 'text', value: part }],
+ });
+ } else {
+ next.push({ type: 'text', value: part });
+ }
+ }
+ } else {
+ next.push(child);
+ }
+ } else {
+ walk(child);
+ next.push(child);
+ }
+ }
+ node.children = next;
+ }
+ walk(tree);
+ };
+}
+
+export default function MarkdownContent({ content, referenceMap, className = '' }: Props): JSX.Element | null {
+ if (!content) return null;
+ const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
+
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.css b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.css
new file mode 100644
index 00000000..316ce281
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.css
@@ -0,0 +1,123 @@
+.progress-sidebar {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.progress-card {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 12px;
+ box-shadow: var(--shadow-card);
+}
+
+.progress-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: 8px;
+}
+
+.progress-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.progress-phase,
+.progress-status,
+.progress-text,
+.field-current,
+.empty-hint,
+.memory-subtitle,
+.field-memory-status {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.progress-line {
+ height: 6px;
+ border-radius: 999px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ overflow: hidden;
+}
+
+.progress-line-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent), var(--green));
+ transition: width 0.25s ease;
+}
+
+.progress-text,
+.field-current {
+ margin-top: 8px;
+}
+
+.field-current {
+ color: var(--accent);
+}
+
+.memory-list,
+.field-memory-list,
+.agent-memory-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.memory-item,
+.agent-memory-card,
+.field-memory-card {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 8px;
+}
+
+.memory-item {
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.55;
+}
+
+.memory-subtitle {
+ margin: 8px 0 6px;
+}
+
+.field-memory-card.status-filled {
+ border-left: 2px solid var(--green);
+}
+
+.field-memory-card.status-partial {
+ border-left: 2px solid var(--yellow);
+}
+
+.field-memory-card.status-blocked {
+ border-left: 2px solid var(--red);
+}
+
+.field-memory-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.field-memory-title,
+.agent-memory-name {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.agent-memory-position {
+ font-size: 11px;
+ color: var(--text);
+ line-height: 1.55;
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.jsx b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.jsx
new file mode 100644
index 00000000..96c60fc8
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.jsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import './ProgressSidebar.css';
+
+/**
+ * 通用列表子组件
+ * 当 items 为空时显示 emptyText 提示,否则逐条渲染。
+ */
+function MemoryList({ items, emptyText }) {
+ if (!items || items.length === 0) {
+ return {emptyText}
;
+ }
+ return (
+
+ {items.map((item, index) => (
+
{item}
+ ))}
+
+ );
+}
+
+/**
+ * 右侧进度侧边栏
+ *
+ * 显示分析过程中的实时进度和共享记忆:
+ * - 方案进度:阶段标识 + 最终报告字段填充进度条
+ * - 稳定判断:协调者确认的跨智能体一致结论
+ * - 关键依据:沉淀下来的核心证据
+ * - 冲突与待核:已识别的分歧和待验证问题
+ * - 字段记忆:每个报告字段的当前判断和待补内容
+ * - 角色记忆:每个智能体的最新立场和有效发现
+ *
+ * @param {string} phase - 当前阶段
+ * @param {string} status - 会话状态
+ * @param {number} currentRound - 当前轮次
+ * @param {number} divergeRounds - 辩论轮总数
+ * @param {Object} reportSchema - 报告字段定义
+ * @param {Object} sharedMemory - 共享记忆对象
+ * @param {Object} finalReportPartial - 最终报告的部分字段内容
+ * @param {string} currentFinalField - 当前正在生成的最终报告字段
+ */
+export default function ProgressSidebar({
+ phase,
+ status,
+ currentRound,
+ divergeRounds,
+ reportSchema,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+}) {
+ const fields = Object.keys(reportSchema || {});
+ // 统计已填充(内容非空)的字段数
+ const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
+ const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
+ const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
+ const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
+
+ return (
+
+ {/* 方案进度卡片 */}
+
+
方案进度
+
+ {phase || 'starting'}
+ {status}
+
+
+
第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮
+
最终报告已生成字段 {finalFilled}/{fields.length || 0}
+ {currentFinalField &&
当前字段:{currentFinalField}
}
+
+
+ {/* 稳定判断卡片 */}
+
+
+ {/* 关键依据卡片 */}
+
+
+ {/* 冲突与待核卡片 */}
+
+
+ {/* 字段记忆卡片 */}
+
+
字段记忆
+ {fieldMemoryEntries.length === 0 ? (
+
尚未形成字段记忆
+ ) : (
+
+ {fieldMemoryEntries.map(([field, item]) => (
+
+
+ {item.title || field}
+ {item.status || 'partial'}
+
+ {item.current_takeaways?.length > 0 && (
+ <>
+
当前判断
+
+ >
+ )}
+ {item.missing_points?.length > 0 && (
+ <>
+
待补内容
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* 角色记忆卡片 */}
+
+
角色记忆
+ {agentMemoryEntries.length === 0 ? (
+
尚未形成角色记忆
+ ) : (
+
+ {agentMemoryEntries.map(([agentId, item]) => (
+
+
{item.name || agentId}
+ {item.latest_position &&
{item.latest_position}
}
+ {item.useful_findings?.length > 0 && (
+ <>
+
有效发现
+
+ >
+ )}
+ {item.open_issues?.length > 0 && (
+ <>
+
待继续澄清
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.tsx b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.tsx
new file mode 100644
index 00000000..b7bba84c
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ProgressSidebar.tsx
@@ -0,0 +1,141 @@
+import './ProgressSidebar.css';
+
+type MemoryListProps = {
+ items?: any[];
+ emptyText: string;
+};
+
+type Props = {
+ phase?: string;
+ status?: string;
+ currentRound?: number;
+ divergeRounds?: number;
+ reportSchema?: Record;
+ sharedMemory?: Record;
+ finalReportPartial?: Record;
+ currentFinalField?: string;
+};
+
+function MemoryList({ items, emptyText }: MemoryListProps): JSX.Element {
+ if (!items || items.length === 0) {
+ return {emptyText}
;
+ }
+ return (
+
+ {items.map((item, index) => (
+
{item}
+ ))}
+
+ );
+}
+
+export default function ProgressSidebar({
+ phase,
+ status,
+ currentRound,
+ divergeRounds,
+ reportSchema,
+ sharedMemory,
+ finalReportPartial,
+ currentFinalField,
+}: Props): JSX.Element {
+ const fields = Object.keys(reportSchema || {});
+ const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
+ const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
+ const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
+ const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
+ const displayStatus = status || '等待中';
+
+ return (
+
+
+
方案进度
+
+ {phase || 'starting'}
+ {displayStatus}
+
+
+
第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮
+
最终报告已生成字段 {finalFilled}/{fields.length || 0}
+ {currentFinalField &&
当前字段:{currentFinalField}
}
+
+
+
+
+
+
+
+
+
+
字段记忆
+ {fieldMemoryEntries.length === 0 ? (
+
尚未形成字段记忆
+ ) : (
+
+ {fieldMemoryEntries.map(([field, item]: [string, any]) => (
+
+
+ {item.title || field}
+ {item.status || 'partial'}
+
+ {item.current_takeaways?.length > 0 && (
+ <>
+
当前判断
+
+ >
+ )}
+ {item.missing_points?.length > 0 && (
+ <>
+
待补内容
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+
+
+
角色记忆
+ {agentMemoryEntries.length === 0 ? (
+
尚未形成角色记忆
+ ) : (
+
+ {agentMemoryEntries.map(([agentId, item]: [string, any]) => (
+
+
{item.name || agentId}
+ {item.latest_position &&
{item.latest_position}
}
+ {item.useful_findings?.length > 0 && (
+ <>
+
有效发现
+
+ >
+ )}
+ {item.open_issues?.length > 0 && (
+ <>
+
待继续澄清
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.css b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.css
new file mode 100644
index 00000000..0bddf850
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.css
@@ -0,0 +1,337 @@
+.scenario-page {
+ width: 100%;
+ max-width: 1280px;
+ min-height: 100vh;
+ padding: 28px;
+}
+
+.scenario-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+
+.scenario-kicker {
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+}
+
+.scenario-header h1 {
+ color: var(--text);
+ font-size: 30px;
+ margin: 4px 0 6px;
+}
+
+.scenario-header p,
+.scenario-card-desc {
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+.scenario-header-actions {
+ display: flex;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.scenario-toolbar {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 14px;
+ margin-bottom: 14px;
+}
+
+.scenario-select,
+.scenario-input,
+.scenario-textarea,
+.scenario-json {
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 13px;
+ outline: none;
+}
+
+.scenario-select {
+ min-width: 280px;
+ padding: 9px 12px;
+ flex: 1;
+}
+
+.scenario-select-inline {
+ min-width: 0;
+ width: 100%;
+ flex: 0 0 auto;
+ margin-top: 6px;
+}
+
+.scenario-input {
+ width: 100%;
+ padding: 10px 12px;
+}
+
+.scenario-textarea,
+.scenario-json {
+ width: 100%;
+ padding: 12px;
+ line-height: 1.65;
+ resize: vertical;
+}
+
+.scenario-textarea {
+ min-height: 120px;
+}
+
+.scenario-textarea-sm {
+ min-height: 80px;
+}
+
+.scenario-textarea-lg {
+ min-height: 180px;
+}
+
+.scenario-json {
+ min-height: 460px;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 12px;
+}
+
+.scenario-select:focus,
+.scenario-input:focus,
+.scenario-textarea:focus,
+.scenario-json:focus {
+ border-color: var(--accent);
+}
+
+.scenario-message,
+.scenario-error,
+.scenario-empty {
+ border-radius: 10px;
+ padding: 10px 14px;
+ margin-bottom: 14px;
+ font-size: 13px;
+}
+
+.scenario-message {
+ background: rgba(63, 185, 80, 0.1);
+ border: 1px solid rgba(63, 185, 80, 0.45);
+ color: var(--green);
+}
+
+.scenario-error {
+ background: rgba(248, 81, 73, 0.1);
+ border: 1px solid rgba(248, 81, 73, 0.45);
+ color: var(--red);
+}
+
+.scenario-empty {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+}
+
+.scenario-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.scenario-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 18px;
+}
+
+.scenario-card-title {
+ color: var(--text);
+ font-size: 16px;
+ font-weight: 700;
+ margin-bottom: 12px;
+}
+
+.scenario-card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 14px;
+}
+
+.scenario-card-head .scenario-card-title {
+ margin-bottom: 2px;
+}
+
+.scenario-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+}
+
+.scenario-card label {
+ display: block;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 700;
+ margin-top: 14px;
+}
+
+.scenario-card label:first-child {
+ margin-top: 0;
+}
+
+.scenario-card label > input,
+.scenario-card label > textarea {
+ margin-top: 6px;
+}
+
+.scenario-inline-hint {
+ margin-top: 6px;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.55;
+}
+
+.scenario-field-key {
+ color: var(--accent);
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 11px;
+ font-weight: 500;
+ margin-left: 8px;
+}
+
+.scenario-coordinator-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+}
+
+.scenario-agent-layout {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 16px;
+}
+
+.scenario-agent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.scenario-agent-tab {
+ background: var(--card);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 10px 12px;
+ text-align: left;
+}
+
+.scenario-agent-tab.active {
+ border-color: var(--accent);
+ background: rgba(88, 166, 255, 0.1);
+ color: var(--text);
+}
+
+.scenario-agent-tab span {
+ font-weight: 700;
+}
+
+.scenario-agent-tab code {
+ color: var(--accent);
+ font-size: 11px;
+}
+
+.scenario-agent-editor {
+ background: rgba(255,255,255,0.02);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 14px;
+}
+
+.scenario-field-picker {
+ margin-top: 6px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.scenario-field-option {
+ display: flex !important;
+ gap: 10px;
+ align-items: flex-start;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px;
+ margin-top: 0 !important;
+ cursor: pointer;
+}
+
+.scenario-field-option.selected {
+ border-color: var(--accent);
+ background: rgba(88, 166, 255, 0.08);
+}
+
+.scenario-field-option input[type='checkbox'] {
+ margin-top: 2px;
+ accent-color: var(--accent);
+}
+
+.scenario-field-option-body {
+ min-width: 0;
+}
+
+.scenario-field-option-title {
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.scenario-field-option-key {
+ color: var(--accent);
+ font-size: 11px;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ margin-top: 2px;
+}
+
+.scenario-field-option-desc {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.5;
+ margin-top: 5px;
+}
+
+@media (max-width: 960px) {
+ .scenario-page {
+ padding: 18px;
+ }
+
+ .scenario-header,
+ .scenario-card-head {
+ flex-direction: column;
+ }
+
+ .scenario-grid-2,
+ .scenario-coordinator-grid,
+ .scenario-agent-layout,
+ .scenario-field-picker {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.jsx b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.jsx
new file mode 100644
index 00000000..217847ce
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.jsx
@@ -0,0 +1,469 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ clearTemplateOverride,
+ clone,
+ hasTemplateOverride,
+ loadOriginalTemplate,
+ loadTemplateByMeta,
+ loadTemplateIndex,
+ saveTemplateOverride,
+} from '@/config/templateStore';
+import './ScenarioConfigPage.css';
+
+/**
+ * 协调者配置字段定义
+ * 每个字段包含 [key, 中文标签],对应 coordinator_config 对象中的属性
+ */
+const COORDINATOR_FIELDS = [
+ ['system_prompt_prefix', '协调者全局规则'],
+ ['round1_guidance', '首轮策略'],
+ ['middle_round_guidance', '中间轮策略'],
+ ['final_round_guidance', '末轮策略'],
+ ['speaker_selection_rules', '选人规则'],
+ ['focus_instruction_rules', '任务指令规则'],
+ ['dependency_driving_rules', '依赖推进规则'],
+ ['consistency_check_rules', '一致性/冲突检查规则'],
+ ['revise_prompt_prefix', '人工修改解释规则'],
+];
+
+/**
+ * 不可变更新数组中的指定索引项
+ * @param {Array} items - 原数组
+ * @param {number} index - 目标索引
+ * @param {Function} updater - 接收克隆后的项,就地修改
+ * @returns {Array} 新数组
+ */
+function updateAtIndex(items, index, updater) {
+ return items.map((item, current) => current === index ? updater(clone(item)) : item);
+}
+
+/**
+ * 将多行文本按行分割为数组(过滤空行)
+ */
+function linesToList(value) {
+ return value
+ .split('\n')
+ .map(item => item.trim())
+ .filter(Boolean);
+}
+
+/**
+ * 场景配置页面
+ *
+ * 用于查看和编辑模板中的提示资产(Prompt Assets):
+ * - 模板信息:ID、名称、说明、一致性模式
+ * - 协调者提示:各阶段的协调者策略文本
+ * - 推荐智能体:每个智能体的名称、维度、专家提示、分析契约、关联字段等
+ * - 完整模板 JSON:原始 JSON 的直接编辑
+ *
+ * 修改后的模板通过 localStorage 覆盖保存('mope_template_override:' 前缀),
+ * 不影响后端静态文件。可在首页选择"已修改"标记的模板使用。
+ *
+ * 暂不支持新增模板和新增智能体。
+ *
+ * @param {Function} onBack - 返回首页回调
+ */
+export default function ScenarioConfigPage({ onBack }) {
+ const [templates, setTemplates] = useState([]);
+ const [selectedId, setSelectedId] = useState('');
+ const [draft, setDraft] = useState(null);
+ const [rawJson, setRawJson] = useState('');
+ const [activeAgentIndex, setActiveAgentIndex] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+
+ /** 当前选中模板的元数据 */
+ const selectedMeta = useMemo(
+ () => templates.find(item => item.template_id === selectedId),
+ [templates, selectedId],
+ );
+
+ /** 从 report_schema 和 field_requirements 生成字段选项列表 */
+ const fieldOptions = useMemo(() => {
+ const schema = draft?.report_schema || {};
+ const requirements = draft?.field_requirements || {};
+ return Object.keys(schema).map(field => ({
+ key: field,
+ title: requirements?.[field]?.title || field,
+ description: schema[field] || '',
+ }));
+ }, [draft]);
+
+ /** 加载模板索引列表 */
+ const loadTemplates = async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const list = await loadTemplateIndex();
+ setTemplates(list);
+ if (!selectedId && list[0]) {
+ setSelectedId(list[0].template_id);
+ }
+ } catch (err) {
+ setError(err.message || '模板列表加载失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ /** 加载完整模板(含用户覆盖) */
+ const loadTemplate = async (templateId) => {
+ if (!templateId) return;
+ const meta = templates.find(item => item.template_id === templateId) || templateId;
+ setLoading(true);
+ setError('');
+ setMessage('');
+ try {
+ const template = await loadTemplateByMeta(meta);
+ setDraft(template);
+ setRawJson(JSON.stringify(template, null, 2));
+ setActiveAgentIndex(0);
+ } catch (err) {
+ setError(err.message || '模板加载失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ useEffect(() => {
+ if (selectedId && templates.length > 0) {
+ loadTemplate(selectedId);
+ }
+ }, [selectedId, templates.length]);
+
+ /**
+ * 同步更新 draft 状态
+ * 深拷贝后通过 updater 修改,并同步更新 rawJson 预览
+ */
+ const syncDraft = (updater) => {
+ setDraft(prev => {
+ const next = clone(prev);
+ updater(next);
+ setRawJson(JSON.stringify(next, null, 2));
+ return next;
+ });
+ setMessage('');
+ };
+
+ const setCoordinatorField = (key, value) => {
+ syncDraft(next => {
+ next.coordinator_config = {
+ ...(next.coordinator_config || {}),
+ [key]: value,
+ };
+ });
+ };
+
+ const setAgentField = (index, key, value) => {
+ syncDraft(next => {
+ next.agents = updateAtIndex(next.agents || [], index, agent => ({
+ ...agent,
+ [key]: value,
+ }));
+ });
+ };
+
+ /** 从 rawJson 文本框解析并应用 JSON */
+ const applyRawJson = () => {
+ setError('');
+ setMessage('');
+ try {
+ const parsed = JSON.parse(rawJson);
+ if (!parsed.template_id) throw new Error('JSON 中缺少 template_id');
+ setDraft(parsed);
+ setSelectedId(parsed.template_id);
+ setMessage('完整 JSON 已应用,保存后生效。');
+ } catch (err) {
+ setError(err.message || 'JSON 解析失败');
+ }
+ };
+
+ /** 保存当前修改到 localStorage */
+ const saveDraft = () => {
+ if (!draft?.template_id) return;
+ try {
+ saveTemplateOverride(draft.template_id, draft);
+ setMessage('已保存到浏览器本地覆盖。返回首页选择该模板即可使用修改后的提示。');
+ setError('');
+ } catch (err) {
+ setError(err.message || '保存失败');
+ }
+ };
+
+ /** 恢复模板为后端原始版本 */
+ const resetTemplate = async () => {
+ if (!selectedId) return;
+ setLoading(true);
+ setMessage('');
+ setError('');
+ try {
+ clearTemplateOverride(selectedId);
+ const original = await loadOriginalTemplate(selectedMeta || selectedId);
+ setDraft(original);
+ setRawJson(JSON.stringify(original, null, 2));
+ setMessage('已恢复为静态模板原始内容。');
+ } catch (err) {
+ setError(err.message || '恢复失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const activeAgent = draft?.agents?.[activeAgentIndex];
+ const activeRelevantFields = new Set(activeAgent?.relevant_fields || []);
+
+ return (
+
+ {/* 页面头部 */}
+
+
+ {/* 工具栏:模板选择 + 操作按钮 */}
+
+
+
+
+
+
+
+
+ {message &&
{message}
}
+ {error &&
{error}
}
+
+ {!draft ? (
+
请选择一个模板。
+ ) : (
+
+ {/* ====== 模板信息 ====== */}
+
+
+ {/* ====== 协调者提示 ====== */}
+
+ 协调者提示
+
+ {COORDINATOR_FIELDS.map(([key, label]) => (
+
+ ))}
+
+
+
+ {/* ====== 推荐智能体及提示 ====== */}
+
+
+
+
推荐智能体及提示
+
当前支持修改已有智能体,不支持新增或删除。
+
+
+
+
+
+ {/* 智能体列表(Tab 切换) */}
+
+ {(draft.agents || []).map((agent, index) => (
+
+ ))}
+
+
+ {/* 智能体编辑表单 */}
+ {activeAgent && (
+
+
+
+
+
+
+ {/* 智能体专家角色提示 */}
+
+ {/* 分析契约 */}
+
+ {/* 关联字段选择器 */}
+
+ {/* 检索补充说明 */}
+
+ 检索补充说明
+
+
+ )}
+
+
+
+ {/* ====== 完整模板 JSON ====== */}
+
+
+
+
完整模板 JSON
+
用于修改页面暂未拆出的提示资产,例如字段要求、样例和报告结构。
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.tsx b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.tsx
new file mode 100644
index 00000000..9fe50de8
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/ScenarioConfigPage.tsx
@@ -0,0 +1,417 @@
+// @ts-nocheck
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ clearTemplateOverride,
+ clone,
+ hasTemplateOverride,
+ loadOriginalTemplate,
+ loadTemplateByMeta,
+ loadTemplateIndex,
+ saveTemplateOverride,
+} from '@/config/templateStore';
+import './ScenarioConfigPage.css';
+
+const COORDINATOR_FIELDS = [
+ ['system_prompt_prefix', '协调者全局规则'],
+ ['round1_guidance', '首轮策略'],
+ ['middle_round_guidance', '中间轮策略'],
+ ['final_round_guidance', '末轮策略'],
+ ['speaker_selection_rules', '选人规则'],
+ ['focus_instruction_rules', '任务指令规则'],
+ ['dependency_driving_rules', '依赖推进规则'],
+ ['consistency_check_rules', '一致性/冲突检查规则'],
+ ['revise_prompt_prefix', '人工修改解释规则'],
+];
+
+function updateAtIndex(items, index, updater) {
+ return items.map((item, current) => current === index ? updater(clone(item)) : item);
+}
+
+function linesToList(value) {
+ return value
+ .split('\n')
+ .map(item => item.trim())
+ .filter(Boolean);
+}
+
+export default function ScenarioConfigPage({ onBack }) {
+ const [templates, setTemplates] = useState([]);
+ const [selectedId, setSelectedId] = useState('');
+ const [draft, setDraft] = useState(null);
+ const [rawJson, setRawJson] = useState('');
+ const [activeAgentIndex, setActiveAgentIndex] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+
+ const selectedMeta = useMemo(
+ () => templates.find(item => item.template_id === selectedId),
+ [templates, selectedId],
+ );
+
+ const fieldOptions = useMemo(() => {
+ const schema = draft?.report_schema || {};
+ const requirements = draft?.field_requirements || {};
+ return Object.keys(schema).map(field => ({
+ key: field,
+ title: requirements?.[field]?.title || field,
+ description: schema[field] || '',
+ }));
+ }, [draft]);
+
+ const loadTemplates = async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const list = await loadTemplateIndex();
+ setTemplates(list);
+ if (!selectedId && list[0]) {
+ setSelectedId(list[0].template_id);
+ }
+ } catch (err) {
+ setError(err.message || '模板列表加载失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadTemplate = async (templateId) => {
+ if (!templateId) return;
+ const meta = templates.find(item => item.template_id === templateId) || templateId;
+ setLoading(true);
+ setError('');
+ setMessage('');
+ try {
+ const template = await loadTemplateByMeta(meta);
+ setDraft(template);
+ setRawJson(JSON.stringify(template, null, 2));
+ setActiveAgentIndex(0);
+ } catch (err) {
+ setError(err.message || '模板加载失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadTemplates();
+ }, []);
+
+ useEffect(() => {
+ if (selectedId && templates.length > 0) {
+ loadTemplate(selectedId);
+ }
+ }, [selectedId, templates.length]);
+
+ const syncDraft = (updater) => {
+ setDraft(prev => {
+ const next = clone(prev);
+ updater(next);
+ setRawJson(JSON.stringify(next, null, 2));
+ return next;
+ });
+ setMessage('');
+ };
+
+ const setCoordinatorField = (key, value) => {
+ syncDraft(next => {
+ next.coordinator_config = {
+ ...(next.coordinator_config || {}),
+ [key]: value,
+ };
+ });
+ };
+
+ const setAgentField = (index, key, value) => {
+ syncDraft(next => {
+ next.agents = updateAtIndex(next.agents || [], index, agent => ({
+ ...agent,
+ [key]: value,
+ }));
+ });
+ };
+
+ const applyRawJson = () => {
+ setError('');
+ setMessage('');
+ try {
+ const parsed = JSON.parse(rawJson);
+ if (!parsed.template_id) throw new Error('JSON 中缺少 template_id');
+ setDraft(parsed);
+ setSelectedId(parsed.template_id);
+ setMessage('完整 JSON 已应用,保存后生效。');
+ } catch (err) {
+ setError(err.message || 'JSON 解析失败');
+ }
+ };
+
+ const saveDraft = () => {
+ if (!draft?.template_id) return;
+ try {
+ saveTemplateOverride(draft.template_id, draft);
+ setMessage('已保存到浏览器本地覆盖。返回首页选择该模板即可使用修改后的提示。');
+ setError('');
+ } catch (err) {
+ setError(err.message || '保存失败');
+ }
+ };
+
+ const resetTemplate = async () => {
+ if (!selectedId) return;
+ setLoading(true);
+ setMessage('');
+ setError('');
+ try {
+ clearTemplateOverride(selectedId);
+ const original = await loadOriginalTemplate(selectedMeta || selectedId);
+ setDraft(original);
+ setRawJson(JSON.stringify(original, null, 2));
+ setMessage('已恢复为静态模板原始内容。');
+ } catch (err) {
+ setError(err.message || '恢复失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const activeAgent = draft?.agents?.[activeAgentIndex];
+ const activeRelevantFields = new Set(activeAgent?.relevant_fields || []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {message &&
{message}
}
+ {error &&
{error}
}
+
+ {!draft ? (
+
请选择一个模板。
+ ) : (
+
+
+
+
+ 协调者提示
+
+ {COORDINATOR_FIELDS.map(([key, label]) => (
+
+ {label}
+ {key}
+
+ ))}
+
+
+
+
+
+
+
推荐智能体及提示
+
当前支持修改已有智能体,不支持新增或删除。
+
+
+
+
+
+
+ {(draft.agents || []).map((agent, index) => (
+
+ ))}
+
+
+ {activeAgent && (
+
+
+
+ ID
+
+
+
+ 名称
+ setAgentField(activeAgentIndex, 'name', event.target.value)}
+ />
+
+
+
+ 分析维度
+ setAgentField(activeAgentIndex, 'dimension', event.target.value)}
+ />
+
+
+ 智能体提示 / 专家角色
+
+
+ 分析契约(每行一条)
+
+
+ 关联字段
+
+ {fieldOptions.map(option => (
+
+ {
+ const current = activeAgent.relevant_fields || [];
+ const next = event.target.checked
+ ? [...current, option.key]
+ : current.filter(item => item !== option.key);
+ setAgentField(activeAgentIndex, 'relevant_fields', next);
+ }}
+ />
+
+
{option.title}
+
{option.key}
+ {option.description && (
+
{option.description}
+ )}
+
+
+ ))}
+
+
+
+ 检索补充说明
+
+
+ )}
+
+
+
+
+
+
+
完整模板 JSON
+
用于修改页面暂未拆出的提示资产,例如字段要求、样例和报告结构。
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Sidebar.css b/frontend-web/src/strategy-components/components/qa/components/Sidebar.css
new file mode 100644
index 00000000..cc75df5b
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Sidebar.css
@@ -0,0 +1,238 @@
+.sidebar {
+ padding: 18px 14px;
+}
+
+.sidebar-section {
+ margin-bottom: 24px;
+}
+
+.sidebar-section-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+ padding: 0 4px;
+}
+
+/* Phase stepper */
+.phase-stepper {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.phase-step {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ position: relative;
+}
+
+.phase-step:not(:last-child) .phase-step-line {
+ position: absolute;
+ left: 11px;
+ top: 24px;
+ bottom: 0;
+ width: 2px;
+ background: var(--border);
+}
+
+.phase-step-dot-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+ padding-top: 3px;
+}
+
+.phase-step-dot {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 2px solid var(--border);
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 700;
+ transition: all 0.2s;
+ position: relative;
+ z-index: 1;
+}
+
+.phase-step.active .phase-step-dot {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.phase-step.active.phase-diverging .phase-step-dot {
+ border-color: var(--yellow);
+ background: rgba(210, 153, 34, 0.15);
+ color: var(--yellow);
+}
+
+.phase-step.active.phase-closing .phase-step-dot {
+ border-color: var(--green);
+ background: rgba(63, 185, 80, 0.15);
+ color: var(--green);
+}
+
+.phase-step.done .phase-step-dot {
+ border-color: var(--green);
+ background: rgba(63, 185, 80, 0.15);
+ color: var(--green);
+}
+
+.phase-step-content {
+ flex: 1;
+ padding-bottom: 16px;
+}
+
+.phase-step-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-muted);
+ line-height: 22px;
+ transition: color 0.2s;
+}
+
+.phase-step.active .phase-step-label {
+ color: var(--text);
+}
+
+.phase-step.done .phase-step-label {
+ color: var(--text-muted);
+}
+
+.phase-step-sublabel {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 2px;
+}
+
+/* Diverge round sub-steps */
+.diverge-rounds {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.diverge-round-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--text-muted);
+ padding: 2px 0;
+}
+
+.diverge-round-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--border);
+ flex-shrink: 0;
+}
+
+.diverge-round-item.active .diverge-round-dot {
+ background: var(--yellow);
+ box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
+}
+
+.diverge-round-item.done .diverge-round-dot {
+ background: var(--green);
+}
+
+.diverge-round-item.active {
+ color: var(--yellow);
+}
+
+.diverge-round-item.done {
+ color: var(--text-muted);
+}
+
+/* Connector line between phase steps */
+.phase-connector {
+ width: 2px;
+ height: 8px;
+ background: var(--border);
+ margin-left: 10px;
+}
+
+/* Agent roster */
+.agent-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.agent-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 10px;
+ border-radius: 10px;
+ transition: background 0.15s, color 0.15s, transform 0.15s;
+ background: transparent;
+}
+
+.agent-item-clickable {
+ cursor: pointer;
+}
+
+.agent-item-clickable:hover {
+ background: var(--surface-raised);
+ transform: translateX(2px);
+}
+
+.agent-item-active {
+ background: var(--accent-soft);
+ border-left: 2px solid var(--accent);
+ padding-left: 6px;
+}
+
+.agent-status-icon {
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+}
+
+.agent-status-idle {
+ color: var(--border);
+}
+
+.agent-status-thinking {
+ color: var(--yellow);
+}
+
+.agent-status-done {
+ color: var(--green);
+}
+
+.agent-item-info {
+ flex: 1;
+ overflow: hidden;
+}
+
+.agent-item-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.agent-item-dim {
+ font-size: 10px;
+ color: var(--text-muted);
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Sidebar.jsx b/frontend-web/src/strategy-components/components/qa/components/Sidebar.jsx
new file mode 100644
index 00000000..bb217228
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Sidebar.jsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import './Sidebar.css';
+
+/**
+ * 分析阶段定义
+ * - starting: 启动轮,协调者分配初始任务
+ * - diverging: 辩论轮,智能体并行输出
+ * - closing: 收敛轮,协调者整合草稿
+ * - done: 完成
+ */
+const PHASES = [
+ { id: 'starting', label: '启动', dotLabel: '1' },
+ { id: 'diverging', label: '辩论', dotLabel: '2' },
+ { id: 'closing', label: '收敛', dotLabel: '3' },
+ { id: 'done', label: '完成', dotLabel: '✓' },
+];
+
+/** 阶段顺序数组,用于判断当前阶段索引 */
+const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
+
+/**
+ * 获取阶段在 PHASE_ORDER 中的索引,用于判断"已完成"状态
+ */
+function getPhaseIndex(phase) {
+ return PHASE_ORDER.indexOf(phase);
+}
+
+/**
+ * 在 .app-main 中查找目标元素并平滑滚动到其位置
+ *
+ * 使用 getBoundingClientRect() 计算视口相对位置,
+ * 避免因 DOM 层级嵌套导致的 offsetTop 参照系不一致问题。
+ * 滚动前设置 window.__mopeProgrammaticScroll 计数器,
+ * 防止 Timeline 的 scroll 监听器误判为用户手动滚动。
+ *
+ * @param {string} selector - CSS 选择器,如 '[data-phase="diverging"][data-round="2"]'
+ */
+function scrollToTimelineElement(selector) {
+ const appMain = document.querySelector('.app-main');
+ if (!appMain) return;
+ const el = appMain.querySelector(selector);
+ if (!el) return;
+ const targetRect = el.getBoundingClientRect();
+ const containerRect = appMain.getBoundingClientRect();
+ const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
+ window.__mopeProgrammaticScroll = (window.__mopeProgrammaticScroll || 0) + 1;
+ appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
+}
+
+/**
+ * 左侧边栏组件
+ *
+ * 显示两大部分:
+ * 1. 阶段进度条 — 当前分析走到了哪个阶段(启动/辩论/收敛/完成),
+ * 辩论阶段展开显示每轮的进度(已完成/进行中/待进行)。
+ * 每个阶段和轮次均可点击,自动滚动中间 Timeline 到对应位置。
+ * 2. 智能体列表 — 显示所有参与分析的智能体及其状态,
+ * 点击可跳转到该智能体最新的输出位置。
+ *
+ * @param {Array} agents - 智能体配置列表
+ * @param {Object} agentStates - 智能体运行时状态 { agentId: { name, dimension, status, rounds } }
+ * @param {string} phase - 当前阶段
+ * @param {number} currentRound - 当前轮次编号
+ * @param {number} divergeRounds - 辩论轮总数
+ * @param {number} totalRounds - 总轮数
+ */
+export default function Sidebar({
+ agents,
+ agentStates,
+ phase,
+ currentRound,
+ divergeRounds,
+ totalRounds,
+}) {
+ const currentPhaseIdx = getPhaseIndex(phase);
+
+ return (
+
+ {/* ====== 阶段进度条 ====== */}
+
+
进度
+
+ {PHASES.map((p, idx) => {
+ const isActive = phase === p.id;
+ const isDone = currentPhaseIdx > idx;
+ const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
+
+ return (
+
scrollToTimelineElement(`[data-phase="${p.id}"]`)}
+ title={`点击跳转到${p.label}阶段`}
+ style={{ cursor: 'pointer' }}
+ >
+ {/* 阶段连接线 */}
+ {idx < PHASES.length - 1 &&
}
+
+
+ {isDone ? '✓' : p.dotLabel}
+
+
+
+
{p.label}
+
+ {/* 辩论阶段的轮次展开 */}
+ {p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
+
+ {Array.from({ length: divergeRounds }, (_, i) => {
+ const roundNum = i + 1;
+ // 辩论轮从轮次 2 开始(轮次 1 是启动轮)
+ const isActiveDiverge = isActive && roundNum === (currentRound - 1);
+ const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
+ const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
+ return (
+
{
+ e.stopPropagation(); // 防止冒泡触发父级阶段点击
+ scrollToTimelineElement(`[data-phase="diverging"][data-round="${roundNum + 1}"]`);
+ }}
+ title={`点击跳转到辩论轮 ${roundNum}`}
+ style={{ cursor: 'pointer' }}
+ >
+
+
辩论轮 {roundNum}
+
+ );
+ })}
+
+ )}
+
+ {/* 辩论阶段尚未开始时只显示轮次数 */}
+ {p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
+
{divergeRounds} 轮
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* ====== 智能体列表 ====== */}
+ {agents && agents.length > 0 && (
+
+
智能体
+
+ {agents.map(agent => {
+ const agentState = agentStates[agent.id] || {};
+ const status = agentState.status || 'idle';
+
+ /** 点击跳转到该智能体最新的输出卡片 */
+ const handleClick = () => {
+ const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
+ if (cards.length > 0) {
+ cards[cards.length - 1].scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ return (
+
+ {/* 状态图标:○ 空闲 / ⟳ 思考中 / ✓ 完成 */}
+
+ {status === 'idle' && ○}
+ {status === 'thinking' && ⟳}
+ {status === 'done' && ✓}
+
+
+
{agent.name || agent.id}
+
{agent.dimension}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Sidebar.tsx b/frontend-web/src/strategy-components/components/qa/components/Sidebar.tsx
new file mode 100644
index 00000000..d8aac3f7
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Sidebar.tsx
@@ -0,0 +1,174 @@
+import './Sidebar.css';
+
+type Agent = {
+ id: string;
+ name?: string;
+ dimension?: string;
+};
+
+type AgentState = {
+ status?: string;
+ name?: string;
+ dimension?: string;
+};
+
+type Props = {
+ agents: Agent[];
+ agentStates: Record;
+ phase: string;
+ currentRound: number;
+ divergeRounds: number;
+};
+
+const PHASES = [
+ { id: 'starting', label: '启动', dotLabel: '1' },
+ { id: 'diverging', label: '辩论', dotLabel: '2' },
+ { id: 'closing', label: '收敛', dotLabel: '3' },
+ { id: 'done', label: '完成', dotLabel: '✓' },
+];
+
+const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
+
+function getPhaseIndex(phase: string): number {
+ return PHASE_ORDER.indexOf(phase);
+}
+
+function scrollToTimelineElement(selector: string): void {
+ const appMain = document.querySelector('.app-main') as HTMLElement | null;
+ if (!appMain) return;
+ const el = appMain.querySelector(selector) as HTMLElement | null;
+ if (!el) return;
+ const targetRect = el.getBoundingClientRect();
+ const containerRect = appMain.getBoundingClientRect();
+ const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
+ (window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
+ appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
+}
+
+function scrollToDivergeRound(roundNum: number): void {
+ const appMain = document.querySelector('.app-main') as HTMLElement | null;
+ if (!appMain) return;
+
+ const divergeRounds = appMain.querySelectorAll('[data-phase="diverging"][data-round]');
+ const target = divergeRounds[roundNum - 1] as HTMLElement | undefined;
+ if (!target) return;
+
+ const targetRect = target.getBoundingClientRect();
+ const containerRect = appMain.getBoundingClientRect();
+ const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
+ (window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
+ appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
+}
+
+export default function Sidebar({
+ agents,
+ agentStates,
+ phase,
+ currentRound,
+ divergeRounds,
+}: Props): JSX.Element {
+ const currentPhaseIdx = getPhaseIndex(phase);
+
+ return (
+
+
+
进度
+
+ {PHASES.map((p, idx) => {
+ const isActive = phase === p.id;
+ const isDone = currentPhaseIdx > idx;
+ const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
+
+ return (
+
scrollToTimelineElement(`[data-phase="${p.id}"]`)}
+ title={`点击跳转到${p.label}阶段`}
+ style={{ cursor: 'pointer' }}
+ >
+ {idx < PHASES.length - 1 &&
}
+
+
+ {isDone ? '✓' : p.dotLabel}
+
+
+
+
{p.label}
+
+ {p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
+
+ {Array.from({ length: divergeRounds }, (_, i) => {
+ const roundNum = i + 1;
+ const isActiveDiverge = isActive && roundNum === (currentRound - 1);
+ const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
+ const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
+ return (
+
{
+ e.stopPropagation();
+ scrollToDivergeRound(roundNum);
+ }}
+ title={`点击跳转到辩论轮 ${roundNum}`}
+ style={{ cursor: 'pointer' }}
+ >
+
+
辩论轮 {roundNum}
+
+ );
+ })}
+
+ )}
+
+ {p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
+
{divergeRounds} 轮
+ )}
+
+
+ );
+ })}
+
+
+
+ {agents && agents.length > 0 && (
+
+
智能体
+
+ {agents.map(agent => {
+ const agentState = agentStates[agent.id] || {};
+ const status = agentState.status || 'idle';
+
+ const handleClick = () => {
+ const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
+ if (cards.length > 0) {
+ (cards[cards.length - 1] as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ return (
+
+
+ {status === 'idle' && ○}
+ {status === 'thinking' && ⟳}
+ {status === 'done' && ✓}
+
+
+
{agent.name || agent.id}
+
{agent.dimension}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Timeline.css b/frontend-web/src/strategy-components/components/qa/components/Timeline.css
new file mode 100644
index 00000000..25c7e529
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Timeline.css
@@ -0,0 +1,670 @@
+.timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ max-width: 920px;
+ margin: 0 auto;
+ padding-bottom: 16px;
+}
+
+/* Empty state */
+.timeline-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ color: var(--text-muted);
+ gap: 12px;
+}
+
+.timeline-empty-spinner {
+ font-size: 28px;
+ animation: spin 1.2s linear infinite;
+}
+
+/* Round header */
+.round-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 20px 0 12px;
+ padding: 0 2px;
+}
+
+.round-header:first-child {
+ margin-top: 0;
+}
+
+.round-header-line {
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+
+.round-header-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ white-space: nowrap;
+ letter-spacing: 0.3px;
+}
+
+.round-header-badge {
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+/* Background retrieval card */
+.bg-retrieval-card {
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ margin-bottom: 18px;
+ overflow: hidden;
+ box-shadow: var(--shadow-card);
+}
+
+.bg-retrieval-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ background: var(--surface-hover);
+ user-select: none;
+}
+
+.bg-retrieval-header:hover {
+ background: var(--surface-hover-strong);
+}
+
+.bg-retrieval-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.bg-retrieval-meta {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+
+.bg-retrieval-chevron {
+ color: var(--text-muted);
+ font-size: 12px;
+ transition: transform 0.2s;
+}
+
+.bg-retrieval-chevron.open {
+ transform: rotate(180deg);
+}
+
+.bg-retrieval-body {
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.bg-retrieval-dim {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.bg-retrieval-dim-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--accent);
+}
+
+.bg-retrieval-result {
+ margin-bottom: 8px;
+}
+
+.bg-retrieval-query {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+ font-style: italic;
+}
+
+.bg-retrieval-items {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.bg-retrieval-item {
+ font-size: 12px;
+ color: var(--text);
+ line-height: 1.5;
+ padding: 4px 8px;
+ background: var(--surface-hover);
+ border-radius: 4px;
+ border-left: 2px solid var(--border);
+}
+
+/* Coordinator card */
+.coordinator-card {
+ background: rgba(137, 87, 229, 0.06);
+ border: 1px solid rgba(137, 87, 229, 0.25);
+ border-radius: 14px;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+ box-shadow: var(--shadow-card);
+}
+
+.coordinator-card-title {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--purple);
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.coordinator-speakers {
+ font-size: 13px;
+ color: var(--text);
+ margin-bottom: 8px;
+}
+
+.coordinator-speaker-tag {
+ display: inline-block;
+ background: rgba(137, 87, 229, 0.15);
+ color: var(--purple);
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-size: 12px;
+ margin: 0 3px;
+ font-weight: 600;
+}
+
+.coordinator-focus {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.coordinator-focus-item {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ font-size: 12px;
+}
+
+.coordinator-focus-agent {
+ font-weight: 700;
+ color: var(--purple);
+ flex-shrink: 0;
+ min-width: 60px;
+}
+
+.coordinator-focus-directive {
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+
+.coordinator-round-badge {
+ font-weight: 400;
+ color: var(--text-muted);
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: 11px;
+}
+
+.coordinator-speakers-label {
+ color: var(--text-muted);
+ margin-right: 4px;
+}
+
+/* 草稿进展区块 */
+.coordinator-draft-section {
+ margin-bottom: 10px;
+ border: 1px solid rgba(137, 87, 229, 0.15);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.coordinator-draft-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ background: rgba(137, 87, 229, 0.08);
+ cursor: pointer;
+ user-select: none;
+}
+
+.coordinator-draft-header:hover {
+ background: rgba(137, 87, 229, 0.13);
+}
+
+.coordinator-draft-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--purple);
+}
+
+.coordinator-draft-progress {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+
+.coordinator-draft-chevron {
+ font-size: 12px;
+ color: var(--text-muted);
+ transition: transform 0.15s;
+}
+
+.coordinator-draft-chevron.open {
+ transform: rotate(180deg);
+}
+
+.coordinator-draft-body {
+ padding: 8px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.coordinator-draft-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 5px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.coordinator-draft-field.filled {
+ background: rgba(34, 197, 94, 0.06);
+ border-left: 2px solid rgba(34, 197, 94, 0.5);
+}
+
+.coordinator-draft-field.empty {
+ background: rgba(239, 68, 68, 0.05);
+ border-left: 2px solid rgba(239, 68, 68, 0.3);
+}
+
+.coordinator-draft-field-name {
+ font-weight: 600;
+ color: var(--text-muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.coordinator-draft-field-content {
+ color: var(--text);
+ line-height: 1.5;
+}
+
+.coordinator-draft-field-empty {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* 协调员流式动画点 */
+.coordinator-streaming-dot {
+ color: var(--purple);
+ font-size: 10px;
+ animation: pulse 1s ease-in-out infinite;
+ margin-left: 6px;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+/* 协调员思考过程 */
+.coordinator-thinking-section {
+ margin-bottom: 10px;
+ border: 1px solid rgba(137, 87, 229, 0.15);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.coordinator-thinking-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background: rgba(137, 87, 229, 0.06);
+ cursor: pointer;
+ user-select: none;
+}
+
+.coordinator-thinking-header:hover {
+ background: rgba(137, 87, 229, 0.1);
+}
+
+.coordinator-thinking-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--purple);
+}
+
+.coordinator-thinking-body {
+ padding: 8px 10px;
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.coordinator-status-banner {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 7px 10px;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.coordinator-status-banner-tail {
+ display: flex;
+ width: 100%;
+ margin-top: 10px;
+ margin-bottom: 0;
+ padding: 10px 12px;
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
+ box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.coordinator-status-spinner {
+ display: inline-block;
+ animation: spin 1s linear infinite;
+}
+
+/* 活跃字段(正在生成)高亮 */
+.coordinator-draft-field.active {
+ background: rgba(137, 87, 229, 0.08);
+ border-left: 2px solid rgba(137, 87, 229, 0.6);
+}
+
+/* 缺口列表 */
+.coordinator-gaps {
+ margin-bottom: 10px;
+}
+
+.coordinator-gaps-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: #f59e0b;
+ margin-bottom: 4px;
+}
+
+.coordinator-gap-item {
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.5;
+ padding: 1px 0;
+}
+
+.human-review-card {
+ background: rgba(88, 166, 255, 0.08);
+ border: 1px solid rgba(88, 166, 255, 0.28);
+ border-radius: 10px;
+ padding: 14px;
+ margin-bottom: 16px;
+}
+
+.human-review-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ margin-bottom: 8px;
+}
+
+.human-review-hint {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-bottom: 12px;
+}
+
+.human-review-chatbox {
+ min-height: 120px;
+}
+
+.human-review-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.human-review-field {
+ margin-bottom: 12px;
+}
+
+.human-review-field label {
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.human-review-input,
+.human-review-textarea {
+ width: 100%;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ padding: 10px;
+ font-family: inherit;
+ font-size: 13px;
+ outline: none;
+}
+
+.human-review-speaker-select {
+ width: 100%;
+}
+
+.human-review-speaker-select .t-select__wrap,
+.human-review-speaker-select .t-input,
+.human-review-speaker-select .t-tag-input {
+ width: 100%;
+}
+
+.human-review-speaker-select .t-input,
+.human-review-speaker-select .t-tag-input {
+ border-color: var(--border);
+ border-radius: 6px;
+ background: var(--card);
+ color: var(--text);
+}
+
+.human-review-speaker-select .t-input:hover,
+.human-review-speaker-select .t-tag-input:hover,
+.human-review-speaker-select .t-input.t-is-focused,
+.human-review-speaker-select .t-tag-input.t-is-focused {
+ border-color: var(--accent);
+}
+
+.human-review-speaker-select .t-tag {
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-color: var(--border);
+}
+
+.human-review-textarea {
+ min-height: 220px;
+ resize: vertical;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ line-height: 1.5;
+}
+
+.human-review-textarea-sm {
+ min-height: 120px;
+}
+
+.human-review-input:focus,
+.human-review-textarea:focus {
+ border-color: var(--accent);
+}
+
+.human-review-meta {
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+
+.human-review-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.human-review-actions-left {
+ justify-content: flex-start;
+ margin-top: 8px;
+}
+
+.human-review-error {
+ color: var(--red);
+ font-size: 12px;
+ margin-bottom: 10px;
+}
+
+@media (max-width: 900px) {
+ .human-review-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 下滑按钮 */
+.scroll-to-bottom-button {
+ position: fixed;
+ bottom: 34px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.34);
+ background:
+ radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 34%),
+ linear-gradient(135deg, rgba(59, 130, 246, 0.46), rgba(124, 58, 237, 0.42));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ box-shadow:
+ 0 16px 40px rgba(37, 99, 235, 0.28),
+ 0 6px 18px rgba(15, 23, 42, 0.16),
+ inset 0 1px 0 rgba(255, 255, 255, 0.24);
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease,
+ background 0.2s ease,
+ border-color 0.2s ease;
+ z-index: 1000;
+ backdrop-filter: blur(16px) saturate(1.2);
+ -webkit-backdrop-filter: blur(16px) saturate(1.2);
+ animation: scrollBtnIn 0.26s ease-out;
+ pointer-events: auto;
+}
+
+.scroll-to-bottom-button::before {
+ content: '';
+ position: absolute;
+ inset: -6px;
+ border-radius: inherit;
+ background: rgba(99, 102, 241, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ pointer-events: none;
+}
+
+.scroll-to-bottom-button::after {
+ content: '';
+ position: absolute;
+ inset: 4px;
+ border-radius: inherit;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ pointer-events: none;
+}
+
+.scroll-to-bottom-button:hover {
+ background:
+ radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.55), transparent 34%),
+ linear-gradient(135deg, rgba(59, 130, 246, 0.62), rgba(124, 58, 237, 0.58));
+ transform: translateX(-50%) translateY(-3px) scale(1.04);
+ box-shadow:
+ 0 20px 48px rgba(37, 99, 235, 0.36),
+ 0 10px 26px rgba(15, 23, 42, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.32);
+ border-color: rgba(255, 255, 255, 0.52);
+}
+
+.scroll-to-bottom-button:active {
+ transform: translateX(-50%) translateY(-1px) scale(0.97);
+ box-shadow:
+ 0 10px 28px rgba(37, 99, 235, 0.28),
+ 0 4px 14px rgba(15, 23, 42, 0.18),
+ inset 0 1px 0 rgba(255, 255, 255, 0.22);
+}
+
+@keyframes scrollBtnIn {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(12px) scale(0.86);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
+.scroll-to-bottom-button .scroll-arrow {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ font-size: 24px;
+ line-height: 1;
+ font-weight: 800;
+ text-shadow: 0 2px 8px rgba(15, 23, 42, 0.32);
+ animation: scrollArrowBounce 1.35s ease-in-out infinite;
+}
+
+@keyframes scrollArrowBounce {
+ 0%, 100% { transform: translateY(-1px); }
+ 50% { transform: translateY(3px); }
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Timeline.jsx b/frontend-web/src/strategy-components/components/qa/components/Timeline.jsx
new file mode 100644
index 00000000..43a92099
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Timeline.jsx
@@ -0,0 +1,641 @@
+import React, { useEffect, useRef, useState } from 'react';
+import AgentCard from './AgentCard';
+import FinalReport from './FinalReport';
+import './Timeline.css';
+
+/**
+ * 阶段中文映射表
+ * - starting: 启动轮,协调者/智能体初始化
+ * - diverging: 辩论轮,多智能体并行输出、互相交锋
+ * - closing: 收敛轮,协调者整合草稿
+ * - done: 完成,最终报告已生成
+ */
+const PHASE_LABELS = {
+ starting: '启动',
+ diverging: '辩论',
+ closing: '收敛',
+ done: '完成',
+};
+
+/**
+ * 根据阶段返回对应的 CSS badge class
+ */
+function getPhaseBadgeClass(phase) {
+ switch (phase) {
+ case 'starting': return 'badge badge-starting';
+ case 'diverging': return 'badge badge-diverging';
+ case 'closing': return 'badge badge-closing';
+ case 'done': return 'badge badge-done';
+ default: return 'badge badge-default';
+ }
+}
+
+/**
+ * 背景检索卡片
+ *
+ * 显示 session 启动时从知识库/搜索引擎检索到的背景资料。
+ * 按维度分组展示,每个维度下有多条检索结果,点击标题可展开/折叠。
+ *
+ * @param {Object} data - 包含 results、dimensions、total_records
+ */
+function BackgroundRetrievalCard({ data }) {
+ const [open, setOpen] = useState(false);
+ const { results, dimensions, total_records } = data;
+
+ return (
+
+
setOpen(o => !o)}>
+ 背景检索完成
+
+ {dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
+ {total_records != null ? ` · ${total_records} 条` : ''}
+
+ ▾
+
+
+ {open && results && (
+
+ {Object.entries(results).map(([dim, entries]) => (
+
+
{dim}
+ {Array.isArray(entries) && entries.map((entry, i) => (
+
+ {entry.query && (
+
查询: "{entry.query}"
+ )}
+
+ {Array.isArray(entry.content) && entry.content.map((item, j) => (
+
+ {typeof item === 'string'
+ ? (item.length > 250 ? item.slice(0, 250) + '…' : item)
+ : JSON.stringify(item).slice(0, 250)}
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * 协调员流式卡片
+ *
+ * 显示协调者正在生成的内容(思考过程、状态消息、最终报告字段实时填充)。
+ * 与 CoordinatorCard 的区别:此卡片在流式输出期间使用,带脉冲动画点。
+ *
+ * @param {Object} item - 包含 phase、divergeRound、thinking、content、statusMessage、currentField、reportPartial
+ * @param {Object} reportSchema - 报告结构定义
+ */
+function CoordinatorStreamingCard({ item, reportSchema }) {
+ const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+ const isFinalReport = phase === 'final_report';
+ const reportFields = reportSchema ? Object.keys(reportSchema) : [];
+
+ return (
+
+
+ 协调员
+ {!isFinalReport && divergeRound != null && (
+
+ 辩论轮 {divergeRound}/{totalDivergeRounds}
+
+ )}
+ {isFinalReport && (
+ 生成最终方案
+ )}
+ ●
+
+
+ {/* 思考过程(可折叠,带脉冲动画) */}
+ {thinking && (
+
+
setThinkingOpen(o => !o)}>
+ 思考中
+ ▾
+
+ {thinkingOpen && (
+
{thinking}
+ )}
+
+ )}
+
+ {/* 最终报告字段实时生成状态 */}
+ {isFinalReport && reportFields.length > 0 && (
+
+ {reportFields.map(field => {
+ const done = !!reportPartial[field];
+ const isActive = currentField === field && !done;
+ return (
+
+
+ {field}
+ {isActive && ●}
+
+ {done
+ ? {reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}
+ : isActive && content
+ ? {content.slice(-400)}
+ : {isActive ? '生成中…' : '等待中'}
+ }
+
+ );
+ })}
+
+ )}
+
+ {/* 非最终报告阶段的流式内容 */}
+ {!isFinalReport && content && (
+
{content.slice(-600)}
+ )}
+
+ {/* 状态横幅(如"正在评估字段完成度…") */}
+ {statusMessage && !isFinalReport && (
+
+ ⟳
+ {statusMessage}
+
+ )}
+
+ );
+}
+
+/**
+ * 协调员完成卡片
+ *
+ * 在一轮辩论结束后显示协调者的完整提案:
+ * - 思考过程(可折叠)
+ * - 草稿进展(各字段填充情况)
+ * - 下一轮发言者名单
+ * - 各智能体的具体任务指令(focus)
+ *
+ * @param {Object} data - 协调者提案数据(round、divergeRound、nextSpeakers、focus、draft)
+ * @param {Object} agentStates - 智能体状态映射
+ * @param {string} thinking - 协调者推理文本
+ */
+function CoordinatorCard({ data, agentStates, thinking }) {
+ const { round, divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
+ const [draftOpen, setDraftOpen] = useState(false);
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+
+ const draftEntries = draft ? Object.entries(draft) : [];
+ const filledCount = draftEntries.filter(([, v]) => v && v.trim()).length;
+
+ return (
+
+
+ 协调员
+ {divergeRound != null && totalDivergeRounds != null && (
+
+ 辩论轮 {divergeRound}/{totalDivergeRounds}
+
+ )}
+
+
+ {/* 协调员思考过程(可折叠) */}
+ {thinking && thinking.trim() && (
+
+
setThinkingOpen(o => !o)}>
+ 思考过程
+ ▾
+
+ {thinkingOpen && (
+
{thinking}
+ )}
+
+ )}
+
+ {/* 草稿进展 */}
+ {draftEntries.length > 0 && (
+
+
setDraftOpen(o => !o)}
+ >
+ 方案草稿
+
+ {filledCount}/{draftEntries.length} 字段已填写
+
+ ▾
+
+ {draftOpen && (
+
+ {draftEntries.map(([field, content]) => (
+
+ {field}
+ {content
+ ? {content.slice(0, 300)}{content.length > 300 ? '…' : ''}
+ : 空缺
+ }
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* 下一轮发言者 */}
+ {nextSpeakers && nextSpeakers.length > 0 && (
+
+ 下一轮:
+ {nextSpeakers.map(id => {
+ const name = agentStates?.[id]?.name || id;
+ return {name};
+ })}
+
+ )}
+
+ {/* 具体任务指令 */}
+ {focus && Object.keys(focus).length > 0 && (
+
+ {Object.entries(focus).map(([agentId, directive]) => {
+ const name = agentStates?.[agentId]?.name || agentId;
+ return (
+
+ {name}
+ {directive}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/**
+ * 人工确认卡片
+ *
+ * 当 coordinatorMode === 'human_review' 时,每轮协调者提案完成后,
+ * 用户需在此卡片中确认或修改提案内容后再继续。
+ * 提供两种操作:
+ * - "让协调员修改":用自然语言告诉协调员怎么改,后端重新生成
+ * - "确认并继续":直接应用当前提案进入下一轮
+ *
+ * @param {Object} pendingReview - 待审核的协调者提案数据
+ * @param {Object} agentStates - 可选智能体列表
+ * @param {Function} onApprove - 确认回调
+ * @param {Function} onRevise - 修改回调
+ * @param {boolean} submitting - 是否正在提交
+ * @param {boolean} revising - 是否正在修改
+ */
+function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }) {
+ const [nextSpeakersText, setNextSpeakersText] = useState((pendingReview.nextSpeakers || []).join(', '));
+ const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
+ const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
+ const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
+ const [instruction, setInstruction] = useState('');
+ const [error, setError] = useState('');
+
+ // 当 pendingReview 变化时同步表单状态
+ useEffect(() => {
+ setNextSpeakersText((pendingReview.nextSpeakers || []).join(', '));
+ setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
+ setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
+ setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
+ }, [pendingReview]);
+
+ /** 确认提案:解析用户修改的 JSON,组装后提交 */
+ const handleSubmit = async () => {
+ try {
+ const parsedFocus = JSON.parse(focusText || '{}');
+ const parsedDraft = JSON.parse(draftText || '{}');
+ const parsedGaps = JSON.parse(gapsText || '[]');
+ const nextSpeakers = nextSpeakersText
+ .split(',')
+ .map(item => item.trim())
+ .filter(Boolean);
+
+ setError('');
+ await onApprove({
+ nextSpeakers,
+ focus: parsedFocus,
+ draft: parsedDraft,
+ gaps: parsedGaps,
+ });
+ } catch (err) {
+ setError(err.message || '提交失败');
+ }
+ };
+
+ /** 发送修改指令给后端,让协调员重新生成提案 */
+ const handleRevise = async () => {
+ if (!instruction.trim()) {
+ setError('请先输入你希望协调员如何修改');
+ return;
+ }
+ try {
+ setError('');
+ await onRevise(instruction.trim());
+ setInstruction('');
+ } catch (err) {
+ setError(err.message || '修改失败');
+ }
+ };
+
+ return (
+
+
+ 人工确认
+
+ 辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
+
+
+
+
+ 优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
+
+
+ {/* 自然语言修改指令 */}
+
+
对协调员说
+
+
+ {/* 下一轮发言者(逗号分隔) */}
+
+
下一轮发言者
+
setNextSpeakersText(e.target.value)}
+ placeholder="用逗号分隔 agent id,例如 audience, narrative"
+ disabled={submitting || revising}
+ />
+
+ 可选 agent: {Object.entries(agentStates || {}).map(([id, agent]) => `${agent.name || id}(${id})`).join(' / ')}
+
+
+
+ {/* 任务指令 + 草稿(并排布局) */}
+
+
+ 任务指令 focus(JSON)
+
+
+
+ 草稿 draft(JSON)
+
+
+
+ {/* 缺口列表 */}
+
+ 当前缺口 gaps(JSON 数组)
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ );
+}
+
+/**
+ * 时间线组件
+ *
+ * 应用的核心展示区域,按时间顺序渲染分析过程中的所有事件:
+ * - background_retrieval: 背景检索结果
+ * - round_start: 新轮次开始的分隔标题
+ * - agent_contribution: 智能体输出卡片(包含流式 token 追加)
+ * - coordinator_streaming: 协调员流式输出
+ * - coordinator: 协调员完成的提案
+ * - final_report: 最终报告
+ *
+ * 滚动行为:
+ * - 默认自动滚动到底部(跟随流式输出)
+ * - 用户手动滚动后停止自动滚动,出现下滑按钮
+ * - 点击下滑按钮恢复自动滚动
+ * - 使用 window.__mopeProgrammaticScroll 计数器区分程序化滚动与用户滚动
+ *
+ * @param {Array} timeline - 时间线条目数组
+ * @param {Object} agentStates - 智能体状态映射 { agentId: { name, status, rounds } }
+ * @param {Object} reportSchema - 报告结构定义
+ * @param {Object|null} pendingReview - 待审核的协调者提案(非 null 表示等待人工确认)
+ * @param {Function} onApproveReview - 确认提案回调
+ * @param {Function} onReviseReview - 修改提案回调
+ * @param {boolean} reviewSubmitting - 是否正在提交审核
+ * @param {boolean} reviewRevising - 是否正在修改中
+ */
+export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }) {
+ const endRef = useRef(null);
+ const [showScrollButton, setShowScrollButton] = useState(false);
+ const [isAutoScrolling, setIsAutoScrolling] = useState(true);
+
+ /**
+ * 程序化滚动到底部
+ * 设置 window.__mopeProgrammaticScroll 计数器,
+ * 每次 scroll 事件触发时递减,防止被误判为用户手动滚动
+ */
+ const doScrollToBottom = () => {
+ const appMainElement = document.querySelector('.app-main');
+ if (!appMainElement) return;
+ window.__mopeProgrammaticScroll = (window.__mopeProgrammaticScroll || 0) + 1;
+ appMainElement.scrollTop = appMainElement.scrollHeight;
+ };
+
+ // 监听 .app-main 的滚动事件
+ // 当用户手动滚动时:显示下滑按钮 + 关闭自动滚动
+ // 程序化滚动时:跳过,不改变自动滚动状态
+ useEffect(() => {
+ const handleScroll = () => {
+ const appMainElement = document.querySelector('.app-main');
+ if (!appMainElement) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = appMainElement;
+ const distanceToBottom = scrollHeight - scrollTop - clientHeight;
+
+ setShowScrollButton(distanceToBottom > 50);
+
+ // 程序化滚动标记有效,跳过本次事件
+ if (window.__mopeProgrammaticScroll > 0) {
+ window.__mopeProgrammaticScroll--;
+ return;
+ }
+
+ // 真正的用户手动滚动 → 关闭自动滚动
+ setIsAutoScrolling(false);
+ };
+
+ const appMainElement = document.querySelector('.app-main');
+ if (appMainElement) {
+ appMainElement.addEventListener('scroll', handleScroll);
+ }
+
+ return () => {
+ if (appMainElement) {
+ appMainElement.removeEventListener('scroll', handleScroll);
+ }
+ };
+ }, []);
+
+ // 当 timeline 或 pendingReview 变化(新内容到达)时自动滚动到底部
+ useEffect(() => {
+ if (isAutoScrolling) {
+ doScrollToBottom();
+ }
+ }, [timeline, pendingReview, isAutoScrolling]);
+
+ /** 点击下滑按钮:滚动到底部并恢复自动滚动 */
+ const scrollToBottom = () => {
+ doScrollToBottom();
+ setIsAutoScrolling(true);
+ setShowScrollButton(false);
+ };
+
+ // 空状态:等待后端启动
+ if (!timeline || timeline.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {timeline.map((item, idx) => {
+ // 背景检索卡片
+ if (item.type === 'background_retrieval') {
+ return
;
+ }
+
+ // 轮次分隔标题(如"第 1 轮 · 辩论")
+ if (item.type === 'round_start') {
+ return (
+
+
+
第 {item.round} 轮
+
+ {PHASE_LABELS[item.phase] || item.phase}
+
+
+
+ );
+ }
+
+ // 智能体输出卡片
+ if (item.type === 'agent_contribution') {
+ const { agentId, round, phase, data, streaming } = item;
+ const agentState = agentStates[agentId] || {};
+ return (
+
+ );
+ }
+
+ // 协调员流式输出卡片
+ if (item.type === 'coordinator_streaming') {
+ return (
+
+
+
+ );
+ }
+
+ // 协调员完成提案卡片
+ if (item.type === 'coordinator') {
+ return (
+
+
+
+ );
+ }
+
+ // 最终报告
+ if (item.type === 'final_report') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })}
+
+ {/* 人工确认卡片(仅在等待确认时渲染) */}
+ {pendingReview && (
+
+ )}
+
+ {/* 滚动锚点:用于 scrollIntoView / 距离计算 */}
+
+
+ {/* 下滑按钮:用户滚动出底部时出现,点击后恢复自动滚动 */}
+ {showScrollButton && (
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/qa/components/Timeline.tsx b/frontend-web/src/strategy-components/components/qa/components/Timeline.tsx
new file mode 100644
index 00000000..dc6b4d24
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/qa/components/Timeline.tsx
@@ -0,0 +1,567 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Select } from 'tdesign-react';
+import AgentCard from './AgentCard';
+import FinalReport from './FinalReport';
+import './Timeline.css';
+
+const PHASE_LABELS: Record = {
+ starting: '启动',
+ diverging: '辩论',
+ closing: '收敛',
+ done: '完成',
+};
+
+function getPhaseBadgeClass(phase?: string): string {
+ switch (phase) {
+ case 'starting': return 'badge badge-starting';
+ case 'diverging': return 'badge badge-diverging';
+ case 'closing': return 'badge badge-closing';
+ case 'done': return 'badge badge-done';
+ default: return 'badge badge-default';
+ }
+}
+
+function BackgroundRetrievalCard({ data }: { data: any }): JSX.Element {
+ const [open, setOpen] = useState(false);
+ const { results, dimensions, total_records } = data;
+
+ return (
+
+
setOpen(o => !o)}>
+ 背景检索完成
+
+ {dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
+ {total_records != null ? ` · ${total_records} 条` : ''}
+
+ ▾
+
+
+ {open && results && (
+
+ {Object.entries(results).map(([dim, entries]) => (
+
+
{dim}
+ {Array.isArray(entries) && entries.map((entry: any, i: number) => (
+
+ {entry.query && (
+
查询: "{entry.query}"
+ )}
+
+ {Array.isArray(entry.content) && entry.content.map((item: any, j: number) => (
+
+ {typeof item === 'string'
+ ? (item.length > 250 ? item.slice(0, 250) + '…' : item)
+ : JSON.stringify(item).slice(0, 250)}
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function CoordinatorStreamingCard({ item, reportSchema }: { item: any; reportSchema: Record }): JSX.Element {
+ const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+ const isFinalReport = phase === 'final_report';
+ const reportFields = reportSchema ? Object.keys(reportSchema) : [];
+ const thinkingBodyRef = useRef(null);
+ const contentBodyRef = useRef(null);
+
+ useEffect(() => {
+ if (thinkingBodyRef.current && thinkingOpen) {
+ thinkingBodyRef.current.scrollTop = thinkingBodyRef.current.scrollHeight;
+ }
+ }, [thinking, thinkingOpen]);
+
+ useEffect(() => {
+ if (contentBodyRef.current && !isFinalReport) {
+ contentBodyRef.current.scrollTop = contentBodyRef.current.scrollHeight;
+ }
+ }, [content, isFinalReport]);
+
+ return (
+
+
+ 协调员
+ {!isFinalReport && divergeRound != null && (
+
+ 辩论轮 {divergeRound}/{totalDivergeRounds}
+
+ )}
+ {isFinalReport && (
+ 生成最终方案
+ )}
+ ●
+
+
+ {thinking && (
+
+
setThinkingOpen(o => !o)}>
+ 思考中
+ ▾
+
+ {thinkingOpen && (
+
{thinking}
+ )}
+
+ )}
+
+ {isFinalReport && reportFields.length > 0 && (
+
+ {reportFields.map(field => {
+ const done = !!reportPartial[field];
+ const isActive = currentField === field && !done;
+ return (
+
+
+ {field}
+ {isActive && ●}
+
+ {done
+ ? {reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}
+ : isActive && content
+ ? {content}
+ : {isActive ? '生成中…' : '等待中'}
+ }
+
+ );
+ })}
+
+ )}
+
+ {!isFinalReport && content && (
+
{content}
+ )}
+
+ {statusMessage && !isFinalReport && (
+
+ ⟳
+ {statusMessage}
+
+ )}
+
+ );
+}
+
+function CoordinatorCard({ data, agentStates, thinking }: { data: any; agentStates: Record; thinking?: string }): JSX.Element {
+ const { divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
+ const [draftOpen, setDraftOpen] = useState(false);
+ const [thinkingOpen, setThinkingOpen] = useState(false);
+
+ const draftEntries = draft ? Object.entries(draft) : [];
+ const filledCount = draftEntries.filter(([, v]: [string, any]) => v && String(v).trim()).length;
+
+ return (
+
+
+ 协调员
+ {divergeRound != null && totalDivergeRounds != null && (
+
+ 辩论轮 {divergeRound}/{totalDivergeRounds}
+
+ )}
+
+
+ {thinking && thinking.trim() && (
+
+
setThinkingOpen(o => !o)}>
+ 思考过程
+ ▾
+
+ {thinkingOpen && (
+
{thinking}
+ )}
+
+ )}
+
+ {draftEntries.length > 0 && (
+
+
setDraftOpen(o => !o)}
+ >
+ 方案草稿
+
+ {filledCount}/{draftEntries.length} 字段已填写
+
+ ▾
+
+ {draftOpen && (
+
+ {draftEntries.map(([field, content]: [string, any]) => (
+
+ {field}
+ {content
+ ? {String(content).slice(0, 300)}{String(content).length > 300 ? '…' : ''}
+ : 空缺
+ }
+
+ ))}
+
+ )}
+
+ )}
+
+ {nextSpeakers && nextSpeakers.length > 0 && (
+
+ 下一轮:
+ {nextSpeakers.map((id: string) => {
+ const name = agentStates?.[id]?.name || id;
+ return {name};
+ })}
+
+ )}
+
+ {focus && Object.keys(focus).length > 0 && (
+
+ {Object.entries(focus).map(([agentId, directive]) => {
+ const name = agentStates?.[agentId]?.name || agentId;
+ return (
+
+ {name}
+ {String(directive)}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }: any) {
+ const [nextSpeakers, setNextSpeakers] = useState(pendingReview.nextSpeakers || []);
+ const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
+ const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
+ const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
+ const [instruction, setInstruction] = useState('');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setNextSpeakers(pendingReview.nextSpeakers || []);
+ setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
+ setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
+ setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
+ }, [pendingReview]);
+
+ const agentOptions = Object.entries(agentStates || {}).map(([id, agent]: [string, any]) => ({
+ label: agent.name || id,
+ value: id,
+ }));
+
+ const handleSpeakerChange = (value: any) => {
+ const values = Array.isArray(value) ? value : [];
+ setNextSpeakers(values.map(item => String(item)));
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const parsedFocus = JSON.parse(focusText || '{}');
+ const parsedDraft = JSON.parse(draftText || '{}');
+ const parsedGaps = JSON.parse(gapsText || '[]');
+ const nextSpeakerIds = nextSpeakers.map(item => item.trim()).filter(Boolean);
+
+ setError('');
+ await onApprove({
+ nextSpeakers: nextSpeakerIds,
+ focus: parsedFocus,
+ draft: parsedDraft,
+ gaps: parsedGaps,
+ });
+ } catch (err: any) {
+ setError(err?.message || '提交失败');
+ }
+ };
+
+ const handleRevise = async () => {
+ if (!instruction.trim()) {
+ setError('请先输入你希望协调员如何修改');
+ return;
+ }
+ try {
+ setError('');
+ await onRevise(instruction.trim());
+ setInstruction('');
+ } catch (err: any) {
+ setError(err?.message || '修改失败');
+ }
+ };
+
+ return (
+
+
+ 人工确认
+
+ 辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
+
+
+
+
+ 优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
+
+
+
+
对协调员说
+
+
+
+
下一轮发言者
+
+
+ 可选 agent: {agentOptions.map(option => `${option.label}(${option.value})`).join(' / ')}
+
+
+
+
+
+ 任务指令 focus(JSON)
+
+
+
+ 草稿 draft(JSON)
+
+
+
+
+ 当前缺口 gaps(JSON 数组)
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ );
+}
+
+type Props = {
+ timeline: any[];
+ agentStates: Record;
+ reportSchema: Record;
+ pendingReview: any;
+ onApproveReview: (payload: any) => Promise;
+ onReviseReview: (instruction: string) => Promise;
+ reviewSubmitting: boolean;
+ reviewRevising: boolean;
+};
+
+export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }: Props): JSX.Element {
+ const endRef = useRef(null);
+ const programmaticScrollUntilRef = useRef(0);
+ const [showScrollButton, setShowScrollButton] = useState(false);
+ const [isAutoScrolling, setIsAutoScrolling] = useState(true);
+
+ const doScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
+ const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
+ if (!appMainElement) return;
+
+ programmaticScrollUntilRef.current = Date.now() + (behavior === 'smooth' ? 700 : 120);
+ appMainElement.scrollTo({ top: appMainElement.scrollHeight, behavior });
+ }, []);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
+ if (!appMainElement) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = appMainElement;
+ const distanceToBottom = scrollHeight - scrollTop - clientHeight;
+ const isNearBottom = distanceToBottom < 24;
+ const isProgrammaticScroll = Date.now() < programmaticScrollUntilRef.current;
+
+ setShowScrollButton(!isNearBottom);
+
+ if (isNearBottom) {
+ setIsAutoScrolling(true);
+ return;
+ }
+
+ if (!isProgrammaticScroll) {
+ setIsAutoScrolling(false);
+ }
+ };
+
+ const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
+ if (appMainElement) {
+ appMainElement.addEventListener('scroll', handleScroll, { passive: true });
+ }
+
+ return () => {
+ if (appMainElement) {
+ appMainElement.removeEventListener('scroll', handleScroll);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (isAutoScrolling) {
+ doScrollToBottom('auto');
+ }
+ }, [timeline, pendingReview, isAutoScrolling, doScrollToBottom]);
+
+ const scrollToBottom = () => {
+ setIsAutoScrolling(true);
+ setShowScrollButton(false);
+ doScrollToBottom('smooth');
+ };
+
+ if (!timeline || timeline.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {timeline.map((item, idx) => {
+ if (item.type === 'background_retrieval') {
+ return
;
+ }
+
+ if (item.type === 'round_start') {
+ return (
+
+
+
第 {item.round} 轮
+
+ {PHASE_LABELS[item.phase] || item.phase}
+
+
+
+ );
+ }
+
+ if (item.type === 'agent_contribution') {
+ const { agentId, round, phase, data, streaming } = item;
+ const agentState = agentStates[agentId] || {};
+ return (
+
+ );
+ }
+
+ if (item.type === 'coordinator_streaming') {
+ return (
+
+
+
+ );
+ }
+
+ if (item.type === 'coordinator') {
+ return (
+
+
+
+ );
+ }
+
+ if (item.type === 'final_report') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })}
+
+ {pendingReview && (
+
+ )}
+
+
+
+ {showScrollButton && (
+
+ )}
+
+ );
+}