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

{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 && ( +
+
+
+ +
+

编辑模型生成内容

+

+ 可同时修改模型回答和思考过程,多轮检索会把思考过程写回第一轮思考内容。 +

+
+
+