diff --git a/.gitignore b/.gitignore index e62d213c..3d13b443 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json +skills/ +hermes-agent-main/ +.gitignore diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 1296d82f..2edad9b5 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -61,8 +61,10 @@ "ogl": "^1.0.11", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^4.4.1", "react-router-dom": "^7.9.4", + "react-syntax-highlighter": "^16.1.1", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -71,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", @@ -1665,6 +1668,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", @@ -4615,6 +4627,11 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==" + }, "node_modules/@types/react": { "version": "19.2.13", "license": "MIT", @@ -4630,6 +4647,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 +4668,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 +5123,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 +5810,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", @@ -5940,6 +5986,18 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5957,6 +6015,14 @@ } } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -6264,6 +6330,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==" + }, + "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 +6495,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 +6881,30 @@ "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/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6801,7 +6916,8 @@ }, "node_modules/lucide-react": { "version": "0.562.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -7706,6 +7822,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 +7918,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 +8047,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", @@ -8005,11 +8139,34 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "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 +8176,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 +8201,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 +8355,40 @@ } } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "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 +8403,26 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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", @@ -8242,7 +8471,8 @@ }, "node_modules/rehype-raw": { "version": "7.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", @@ -8255,7 +8485,8 @@ }, "node_modules/remark-gfm": { "version": "4.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", @@ -8457,6 +8688,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 +8814,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 +9187,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..a9319e1d 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -63,8 +63,10 @@ "ogl": "^1.0.11", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^4.4.1", "react-router-dom": "^7.9.4", + "react-syntax-highlighter": "^16.1.1", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -73,6 +75,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/App.tsx b/frontend-web/src/App.tsx index c1122c19..66fda5ba 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -5,6 +5,8 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"; import { QueryClientProvider } from "@/components/query-client-provider"; import { I18nProvider } from "@/core/i18n/context"; import { detectLocale } from "@/core/i18n/locale"; +import { ChatProvider } from "@/contexts/ChatContext"; +import { ThemeProvider } from "@/contexts/ThemeContext"; import { HashRouter, useLocation } from "react-router-dom"; @@ -26,7 +28,11 @@ function AppProviders({ children }: { children: React.ReactNode }) { forcedTheme={forcedTheme} > - {children} + + + {children} + + ); diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index b5d48c71..3c39dc32 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { ElementType } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; import { BarChart3, Bot, @@ -38,6 +39,7 @@ type MenuItem = { id: string; label: string; icon: ElementType; + path?: string; children?: MenuItem[]; }; @@ -49,173 +51,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, path: "/page/strategy/qa/general" }, + { 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, path: "/page/strategy/agents" }, + { 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 }, ], }, ]; @@ -233,6 +235,8 @@ export function PageSidebar({ onToggleCollapsed, }: PageSidebarProps) { const [expandedIds, setExpandedIds] = useState>(defaultExpandedIds); + const navigate = useNavigate(); + const { pathname } = useLocation(); const iconButtonClass = "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600"; @@ -256,14 +260,20 @@ export function PageSidebar({ const renderLeaf = (item: MenuItem, depth = 0) => { const Icon = item.icon; + const isActive = !!item.path && pathname === item.path; return ( + ))} + + )} + {showFollowups && (
@@ -464,7 +495,7 @@ export function InputBox({ )} - {extraHeader && ( -
-
- {extraHeader} -
-
- )} {(attachment) => } - + {/* TODO: Add more connectors here @@ -505,7 +529,7 @@ export function InputBox({ /> */} - + - +
{context.mode === "flash" && } {context.mode === "thinking" && ( @@ -679,7 +703,7 @@ export function InputBox({ {supportReasoningEffort && context.mode !== "flash" && ( - +
{t.inputBox.reasoningEffort}: {context.reasoning_effort === "minimal" && @@ -800,7 +824,7 @@ export function InputBox({ onOpenChange={setModelDialogOpen} > - +
{selectedModel?.display_name} @@ -834,7 +858,7 @@ export function InputBox({ {!isNewThread && ( -
+
)} - {isNewThread && searchParams.get("mode") !== "skill" && ( -
- -
- )} - diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 4a37f957..b27775be 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -68,7 +68,7 @@ export function MessageList({ - + {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { @@ -454,6 +454,7 @@ function ClarificationCard({ const payload = getClarificationPayload(message); const [customAnswer, setCustomAnswer] = useState(""); const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); const options = useMemo( () => @@ -473,16 +474,17 @@ function ClarificationCard({ ); } - const disabled = isLoading || submitting || !onSubmit; + const disabled = false; async function submitAnswer(answer: string) { const trimmed = answer.trim(); - if (!trimmed || disabled) { + if (!trimmed) { return; } setSubmitting(true); try { await onSubmit?.(trimmed); + setSubmitted(true); } finally { setSubmitting(false); } diff --git a/frontend-web/src/components/workspace/messages/skeleton.tsx b/frontend-web/src/components/workspace/messages/skeleton.tsx index 66601b92..77fce907 100644 --- a/frontend-web/src/components/workspace/messages/skeleton.tsx +++ b/frontend-web/src/components/workspace/messages/skeleton.tsx @@ -24,7 +24,7 @@ function SkeletonBar({ export function MessageListSkeleton() { let index = 0; return ( -
+
{searchParams.get("mode") === "skill" ? (
- {t.welcome.createYourOwnSkillDescription.includes("\n") ? ( + {/* {t.welcome.createYourOwnSkillDescription.includes("\n") ? (
               {t.welcome.createYourOwnSkillDescription}
             
) : (

{t.welcome.createYourOwnSkillDescription}

- )} + )} */}
) : (
- {t.welcome.description.includes("\n") ? ( + {/* {t.welcome.description.includes("\n") ? (
               {t.welcome.description}
             
) : (

{t.welcome.description}

- )} + )} */}
)}
diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 14ceb474..c04f7a26 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronsUpDown, Settings2Icon, SettingsIcon } from "lucide-react"; +import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { @@ -17,9 +17,12 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { NotificationBell } from "./notification-bell"; +import { useNavigate } from "react-router-dom"; + import { SettingsDialog } from "./settings"; function NavMenuButtonContent({ @@ -43,9 +46,11 @@ function NavMenuButtonContent({ } export function WorkspaceNavMenu() { + const navigate = useNavigate(); + const isAdmin = getStoredAuth()?.system_role === "admin"; const [settingsOpen, setSettingsOpen] = useState(false); const [settingsDefaultSection, setSettingsDefaultSection] = useState< - "appearance" | "memory" | "tools" | "skills" | "notification" | "about" + "appearance" | "memory" | "tools" | "notification" | "about" >("appearance"); const [mounted, setMounted] = useState(false); const { open: isSidebarOpen } = useSidebar(); @@ -92,6 +97,16 @@ export function WorkspaceNavMenu() { {t.common.settings} + {isAdmin && ( + + navigate("/page/workspace/settings/recommended-questions") + } + > + + 推荐问题配置 + + )} diff --git a/frontend-web/src/config/defaultConfig.js b/frontend-web/src/config/defaultConfig.js new file mode 100644 index 00000000..182b7f17 --- /dev/null +++ b/frontend-web/src/config/defaultConfig.js @@ -0,0 +1,158 @@ +/** + * 后端 API 默认地址 + */ +export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030'; + +/** + * 默认前端配置 + * + * 包含: + * - coordinator_config: 协调者各阶段策略提示 + * - agents: 默认四智能体(背景研判师、目的策略师、受众分析师、叙事策划师) + * - report_schema: 六字段报告结构定义 + * - report_examples: 各字段的示例输出(问界 M7 事故场景) + * - field_requirements: 字段依赖关系定义 + */ +export const DEFAULT_CONFIG = { + task_context: "", + llm_preset_id: "", + consistency_mode: "soft", + coordinator_mode: "auto", + coordinator_config: { + system_prompt_prefix: "", + round1_guidance: `这是发散阶段第1轮,草稿刚起步。 +策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧—— +这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`, + middle_round_guidance: `这是发散阶段的中间轮次。 +策略:审查草稿现状,找出仍然太模糊或有冲突的字段, +选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`, + final_round_guidance: `这是发散阶段最后一轮。 +策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包, +包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`, + speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。", + focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。", + revise_prompt_prefix: "" + }, + agents: [ + { + id: "situation", + name: "背景研判师", + dimension: "背景研判", + expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。", + }, + { + id: "purpose", + name: "目的策略师", + dimension: "传播目标", + expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。", + }, + { + id: "audience", + name: "受众分析师", + dimension: "受众图谱", + expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。", + }, + { + id: "narrative", + name: "叙事策划师", + dimension: "叙事方案", + expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。", + } + ], + phase_config: { diverge_rounds: 3 }, + template_id: "", + template_name: "", + template_brief: "", + report_schema: { + situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。", + communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。", + audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。", + narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。", + action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。", + risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。" + }, + report_examples: { + situation_assessment: `事故发生后,舆论场中存在三条并行叙事: +- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品 +- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化 +- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大 + +核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。 + +此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`, + + communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况 +> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。" + +目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车 +> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。" + +目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作 +> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。" + +目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑 +> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`, + + audience_map: `受众一:现有问界M7车主(最优先) +- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主 +- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法" +- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群 + +受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体) +- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权 +- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由" +- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`, + + narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题 + +具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。 + +为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。 + +预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。 + +风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`, + + action_plan: `第一阶段:止血(D+0 至 D+3) +- D+0:余承东删除原有争议微博。AITO官方发布声明,核心措辞:承认断电门锁释放机制有改进空间,公布将与第三方机构联合调查,暂停全部品牌宣传广告 +- D+1:通过APP+服务顾问向全体问界M7车主发送一对一信息,提供免费安全检测预约和免费代步车 +- D+2:联系@42号车库、@老司机测评,提供完整的碰撞技术资料(不要求立场,只提供信息) + +第二阶段:叙事建立(第1-4周) +- 第1周:推动@42号车库发布《追尾高速起火,燃油车和电动车谁更危险?》技术向内容,问界提供数据但不参与内容制作 +- 第2周:举办技术说明会,邀请10家垂直媒体,公布OTA升级方案和物理把手加装方案详细规格 +- 第3周:中国汽研开始改进后车辆的独立测试,全程开放媒体记者跟拍`, + + risk_playbook: `风险一:12周内发生第二起相似事故 +- 触发条件:同类门锁失效导致的伤亡事故再次出现 +- 应对步骤:①立即暂停全部销售;②主动召回所有已售问界M7,不等监管要求;③停止所有叙事支柱三(竞品对比)内容的推进 +- 明确禁止:不得发布任何暗示"概率极低"的安抚性内容 + +风险二:数据核查显示燃油车追尾起火比例低于电动车 +- 触发条件:第三方核查发现叙事支柱一中引用的数据不成立 +- 应对步骤:①立即停止涉及数据比较的传播;②调整叙事回归"我们改进了"主线,放弃工况比较维度 +- 明确禁止:不得继续引用已被质疑的数据,不得删除原文(会被截图放大)` + }, + field_requirements: { + communication_objectives: { + title: "传播目标", + dependencies: ["situation_assessment"], + }, + audience_map: { + title: "核心受众", + dependencies: ["communication_objectives"], + }, + narrative_plan: { + title: "叙事方案", + dependencies: ["communication_objectives", "audience_map"], + }, + action_plan: { + title: "分阶段行动计划", + dependencies: ["communication_objectives", "audience_map", "narrative_plan"], + }, + risk_playbook: { + title: "风险预案", + dependencies: ["narrative_plan", "action_plan"], + }, + } +}; diff --git a/frontend-web/src/config/templateStore.js b/frontend-web/src/config/templateStore.js new file mode 100644 index 00000000..5085f05d --- /dev/null +++ b/frontend-web/src/config/templateStore.js @@ -0,0 +1,96 @@ +/** localStorage 存储前缀,用于模板覆盖数据的 key */ +const OVERRIDE_PREFIX = 'mope_template_override:'; + +/** + * 深拷贝对象 + * 使用 JSON 序列化/反序列化实现简单深拷贝, + * 适用于纯数据的配置对象(不含函数、循环引用等)。 + */ +export function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +/** + * 从后端加载模板索引列表 + * @returns {Promise} 模板摘要数组 [{ template_id, template_name, file }] + */ +export async function loadTemplateIndex() { + const response = await fetch('/templates/index.json'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + return data.templates || []; +} + +/** + * 加载完整模板数据 + * 优先使用 localStorage 中的用户覆盖版本, + * 否则从后端静态文件加载。 + * + * @param {string|Object} metaOrId - 模板 ID 字符串或包含 template_id/file 的元数据对象 + * @returns {Promise} 完整模板对象 + */ +export async function loadTemplateByMeta(metaOrId) { + const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id; + const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file; + const response = await fetch(file || `/templates/${templateId}.json`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const template = await response.json(); + const override = getTemplateOverride(template.template_id || templateId); + return override ? clone(override) : template; +} + +/** + * 加载原始模板(忽略用户覆盖) + * @param {string|Object} metaOrId - 模板标识 + * @returns {Promise} 后端静态模板 + */ +export async function loadOriginalTemplate(metaOrId) { + const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id; + const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file; + const response = await fetch(file || `/templates/${templateId}.json`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + +/** + * 获取用户在 localStorage 中保存的模板覆盖数据 + * @param {string} templateId - 模板 ID + * @returns {Object|null} 覆盖的模板对象,未覆盖时返回 null + */ +export function getTemplateOverride(templateId) { + if (!templateId) return null; + try { + const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** + * 判断指定模板是否已被用户覆盖修改 + * @param {string} templateId - 模板 ID + * @returns {boolean} + */ +export function hasTemplateOverride(templateId) { + return Boolean(getTemplateOverride(templateId)); +} + +/** + * 保存模板覆盖到 localStorage + * @param {string} templateId - 模板 ID + * @param {Object} template - 修改后的模板对象 + */ +export function saveTemplateOverride(templateId, template) { + if (!templateId) throw new Error('缺少 template_id'); + localStorage.setItem(`${OVERRIDE_PREFIX}${templateId}`, JSON.stringify(template)); +} + +/** + * 清除指定模板的覆盖数据,恢复为后端原始版本 + * @param {string} templateId - 模板 ID + */ +export function clearTemplateOverride(templateId) { + if (!templateId) return; + localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`); +} diff --git a/frontend-web/src/contexts/ChatContext.tsx b/frontend-web/src/contexts/ChatContext.tsx new file mode 100644 index 00000000..67682896 --- /dev/null +++ b/frontend-web/src/contexts/ChatContext.tsx @@ -0,0 +1,81 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; + type?: 'general' | 'agent' | 'compare'; + agentName?: string; +} + +interface ChatContextType { + messages: ChatMessage[]; + addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void; + clearMessages: () => void; + deleteMessage: (id: string) => void; +} + +const ChatContext = createContext(undefined); + +export function ChatProvider({ children }: { children: ReactNode }) { + const [messages, setMessages] = useState(() => { + try { + const stored = localStorage.getItem('qa-chat-history'); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }); + + const saveToStorage = useCallback((newMessages: ChatMessage[]) => { + try { + localStorage.setItem('qa-chat-history', JSON.stringify(newMessages)); + } catch (error) { + console.error('Failed to save chat history:', error); + } + }, []); + + const addMessage = useCallback((role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => { + const newMessage: ChatMessage = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + role, + content, + timestamp: Date.now(), + type, + agentName, + }; + setMessages((prev) => { + const updated = [...prev, newMessage]; + saveToStorage(updated); + return updated; + }); + }, [saveToStorage]); + + const clearMessages = useCallback(() => { + setMessages([]); + localStorage.removeItem('qa-chat-history'); + }, []); + + const deleteMessage = useCallback((id: string) => { + setMessages((prev) => { + const updated = prev.filter((msg) => msg.id !== id); + saveToStorage(updated); + return updated; + }); + }, [saveToStorage]); + + return ( + + {children} + + ); +} + +export function useChatContext() { + const context = useContext(ChatContext); + if (context === undefined) { + throw new Error('useChatContext must be used within a ChatProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx new file mode 100644 index 00000000..a4042ae7 --- /dev/null +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -0,0 +1,145 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +// 主题类型 +export type ThemeType = 'dark' | 'light'; + +// 主题配置 +export interface ThemeConfig { + type: ThemeType; + name: string; + colors: { + background: string; + backgroundSecondary: string; + text: string; + textSecondary: string; + primary: string; + primaryHover: string; + border: string; + accent: string; + success: string; + warning: string; + error: string; + card: string; + cardHover: string; + input: string; + inputBorder: string; + overlay: string; + }; +} + +// 主题配置 +const themes: Record = { + dark: { + type: 'dark', + name: '深色主题', + colors: { + background: 'slate-900', + backgroundSecondary: 'slate-800', + text: 'white', + textSecondary: 'slate-400', + primary: 'blue-500', + primaryHover: 'blue-400', + border: 'slate-700', + accent: 'blue-600', + success: 'emerald-500', + warning: 'amber-500', + error: 'rose-500', + card: 'slate-800', + cardHover: 'slate-700', + input: 'slate-800', + inputBorder: 'slate-600', + overlay: 'slate-900/80', + }, + }, + light: { + type: 'light', + name: '红白主题', + colors: { + background: 'white', + backgroundSecondary: 'red-50', + text: 'gray-900', + textSecondary: 'gray-600', + primary: 'red-600', + primaryHover: 'red-500', + border: 'red-200', + accent: 'red-700', + success: 'green-600', + warning: 'amber-500', + error: 'red-600', + card: 'white', + cardHover: 'red-50', + input: 'white', + inputBorder: 'red-200', + overlay: 'black/30', + }, + }, +}; + +// 主题上下文 +interface ThemeContextType { + theme: ThemeType; + themeConfig: ThemeConfig; + toggleTheme: () => void; + setTheme: (theme: ThemeType) => void; +} + +const ThemeContext = createContext(undefined); + +// 主题提供者 +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setThemeState] = useState(() => { + // 从 localStorage 读取保存的主题 + const saved = localStorage.getItem('strategy-theme'); + return (saved as ThemeType) || 'dark'; + }); + + // 切换主题 + const toggleTheme = () => { + setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + }; + + // 设置特定主题 + const setTheme = (newTheme: ThemeType) => { + setThemeState(newTheme); + }; + + // 保存到 localStorage + useEffect(() => { + localStorage.setItem('strategy-theme', theme); + // 更新文档类名 + document.documentElement.classList.remove('dark', 'light'); + document.documentElement.classList.add(theme); + }, [theme]); + + const themeConfig = themes[theme]; + + return ( + + {children} + + ); +}; + +// 使用主题的 Hook +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +// 主题工具函数 - 生成动态类名 +export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string => { + const { themeConfig } = useTheme(); + return themeConfig.colors[colorKey]; +}; + +// 组合类名工具函数 +export const cn = (...classes: (string | undefined | null | false)[]): string => { + return classes.filter(Boolean).join(' '); +}; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 6106bc0b..ae98d042 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -59,7 +59,7 @@ export const zhCN: Translations = { // Welcome welcome: { - greeting: "你好,欢迎回来!", + greeting: "你好,欢迎使用CM助手", description: "欢迎使用 CM 助手", diff --git a/frontend-web/src/core/recommended-questions/api.ts b/frontend-web/src/core/recommended-questions/api.ts new file mode 100644 index 00000000..0d72dd89 --- /dev/null +++ b/frontend-web/src/core/recommended-questions/api.ts @@ -0,0 +1,66 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +import type { + CreateQuestionRequest, + RecommendedQuestion, + UpdateQuestionRequest, +} from "./index"; + +const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`; + +export async function listRecommendedQuestions(): Promise { + const res = await apiFetch(BASE()); + if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`); + const data = (await res.json()) as { questions: RecommendedQuestion[] }; + return data.questions; +} + +export async function createRecommendedQuestion( + body: CreateQuestionRequest, +): Promise { + const res = await apiFetch(BASE(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to create question: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function updateRecommendedQuestion( + id: string, + body: UpdateQuestionRequest, +): Promise { + const res = await apiFetch(`${BASE()}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { detail?: string }; + throw new Error(err.detail ?? `Failed to update question: ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function deleteRecommendedQuestion(id: string): Promise { + const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete question: ${res.statusText}`); +} + +export async function reorderRecommendedQuestions( + ids: string[], +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/recommended-questions-reorder`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error(`Failed to reorder questions: ${res.statusText}`); + const data = (await res.json()) as { questions: RecommendedQuestion[] }; + return data.questions; +} diff --git a/frontend-web/src/core/recommended-questions/hooks.ts b/frontend-web/src/core/recommended-questions/hooks.ts new file mode 100644 index 00000000..538f4a33 --- /dev/null +++ b/frontend-web/src/core/recommended-questions/hooks.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createRecommendedQuestion, + deleteRecommendedQuestion, + listRecommendedQuestions, + reorderRecommendedQuestions, + updateRecommendedQuestion, +} from "./api"; +import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index"; + +const QK = ["recommended-questions"] as const; + +export function useRecommendedQuestions() { + const { data, isLoading, error } = useQuery({ + queryKey: QK, + queryFn: listRecommendedQuestions, + }); + return { questions: data ?? [], isLoading, error }; +} + +export function useCreateRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateQuestionRequest) => createRecommendedQuestion(body), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); +} + +export function useUpdateRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: UpdateQuestionRequest }) => + updateRecommendedQuestion(id, body), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); +} + +export function useDeleteRecommendedQuestion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteRecommendedQuestion(id), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); +} + +export function useReorderRecommendedQuestions() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ids: string[]) => reorderRecommendedQuestions(ids), + onSuccess: () => void qc.invalidateQueries({ queryKey: QK }), + }); +} diff --git a/frontend-web/src/core/recommended-questions/index.ts b/frontend-web/src/core/recommended-questions/index.ts new file mode 100644 index 00000000..a7a5b286 --- /dev/null +++ b/frontend-web/src/core/recommended-questions/index.ts @@ -0,0 +1,20 @@ +export interface RecommendedQuestion { + id: string; + title: string; + content: string; + sort_order: number; + created_at?: string; + updated_at?: string; +} + +export interface CreateQuestionRequest { + title: string; + content: string; + sort_order?: number; +} + +export interface UpdateQuestionRequest { + title?: string; + content?: string; + sort_order?: number; +} diff --git a/frontend-web/src/core/route-base.tsx b/frontend-web/src/core/route-base.tsx new file mode 100644 index 00000000..8790a83e --- /dev/null +++ b/frontend-web/src/core/route-base.tsx @@ -0,0 +1,24 @@ +import { createContext, useCallback, useContext } from "react"; +import { useNavigate, type NavigateOptions } from "react-router-dom"; + +export const RouteBaseContext = createContext("/page/workspace"); +export const RouteBaseProvider = RouteBaseContext.Provider; + +export function useRouteBase() { + return useContext(RouteBaseContext); +} + +export function useRouteNavigate() { + const navigate = useNavigate(); + const base = useRouteBase(); + return useCallback( + (path: string | number, opts?: NavigateOptions) => { + if (typeof path === "string") { + navigate(path.replace("/page/workspace", base), opts); + } else { + navigate(path); + } + }, + [navigate, base], + ); +} diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 1c2b7ab5..8877eda3 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -1,5 +1,7 @@ +import { useContext } from "react"; import type { Message } from "@langchain/langgraph-sdk"; +import { RouteBaseContext } from "@/core/route-base"; import type { AgentThread, AgentThreadContext } from "./types"; type ThreadAgentRef = Pick; @@ -28,6 +30,7 @@ function pickAgentRef( export function pathOfThread( thread: ThreadRouteTarget, context?: ThreadAgentRef | null, + base = "/page/workspace", ) { const threadId = typeof thread === "string" ? thread : thread.thread_id; const agentRef = @@ -36,8 +39,14 @@ export function pathOfThread( : pickAgentRef(thread.context, thread.metadata); return agentRef - ? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` - : `/page/workspace/chats/${threadId}`; + ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` + : `${base}/chats/${threadId}`; +} + +export function usePathOfThread() { + const base = useContext(RouteBaseContext); + return (thread: ThreadRouteTarget, context?: ThreadAgentRef | null) => + pathOfThread(thread, context, base); } export function textOfMessage(message: Message) { diff --git a/frontend-web/src/hooks/index.ts b/frontend-web/src/hooks/index.ts new file mode 100644 index 00000000..bb2d9d1b --- /dev/null +++ b/frontend-web/src/hooks/index.ts @@ -0,0 +1,9 @@ +export { useLocalStorage } from './useLocalStorage'; +export { useWindowSize, useIsMobile } from './useWindowSize'; +export { useDebounce, useDebounceCallback } from './useDebounce'; +export { useToggle } from './useToggle'; +export { useAsync } from './useAsync'; +export { useChatHistory } from './useChatHistory'; +export { useExpertAuthBootstrap } from './useExpertAuthBootstrap'; +export type { LlmItem, CurrentUser } from './useExpertAuthBootstrap'; +export type { ChatMessage } from './useChatHistory'; \ No newline at end of file diff --git a/frontend-web/src/hooks/use-mobile.tsx b/frontend-web/src/hooks/use-mobile.tsx new file mode 100644 index 00000000..2b0fe1df --- /dev/null +++ b/frontend-web/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/frontend-web/src/hooks/useAsync.ts b/frontend-web/src/hooks/useAsync.ts new file mode 100644 index 00000000..792fd967 --- /dev/null +++ b/frontend-web/src/hooks/useAsync.ts @@ -0,0 +1,37 @@ +import { useState, useCallback } from 'react'; + +interface AsyncState { + data: T | null; + loading: boolean; + error: Error | null; +} + +export function useAsync() { + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }); + + const execute = useCallback(async (asyncFunction: () => Promise) => { + setState({ data: null, loading: true, error: null }); + try { + const data = await asyncFunction(); + setState({ data, loading: false, error: null }); + return data; + } catch (error) { + setState({ data: null, loading: false, error: error as Error }); + throw error; + } + }, []); + + const reset = useCallback(() => { + setState({ data: null, loading: false, error: null }); + }, []); + + return { + ...state, + execute, + reset, + }; +} \ No newline at end of file diff --git a/frontend-web/src/hooks/useChatHistory.ts b/frontend-web/src/hooks/useChatHistory.ts new file mode 100644 index 00000000..f3ba37ce --- /dev/null +++ b/frontend-web/src/hooks/useChatHistory.ts @@ -0,0 +1,80 @@ +import { useState, useCallback } from 'react'; + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +interface UseChatHistoryReturn { + messages: ChatMessage[]; + addMessage: (role: 'user' | 'assistant', content: string) => void; + clearMessages: () => void; + deleteMessage: (id: string) => void; + updateMessage: (id: string, content: string) => void; +} + +export function useChatHistory(storageKey: string = 'chat-history'): UseChatHistoryReturn { + const [messages, setMessages] = useState(() => { + try { + const stored = localStorage.getItem(storageKey); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }); + + const saveToStorage = useCallback((newMessages: ChatMessage[]) => { + try { + localStorage.setItem(storageKey, JSON.stringify(newMessages)); + } catch (error) { + console.error('Failed to save chat history:', error); + } + }, [storageKey]); + + const addMessage = useCallback((role: 'user' | 'assistant', content: string) => { + const newMessage: ChatMessage = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + role, + content, + timestamp: Date.now(), + }; + setMessages((prev) => { + const updated = [...prev, newMessage]; + saveToStorage(updated); + return updated; + }); + }, [saveToStorage]); + + const clearMessages = useCallback(() => { + setMessages([]); + localStorage.removeItem(storageKey); + }, [storageKey]); + + const deleteMessage = useCallback((id: string) => { + setMessages((prev) => { + const updated = prev.filter((msg) => msg.id !== id); + saveToStorage(updated); + return updated; + }); + }, [saveToStorage]); + + const updateMessage = useCallback((id: string, content: string) => { + setMessages((prev) => { + const updated = prev.map((msg) => + msg.id === id ? { ...msg, content } : msg + ); + saveToStorage(updated); + return updated; + }); + }, [saveToStorage]); + + return { + messages, + addMessage, + clearMessages, + deleteMessage, + updateMessage, + }; +} \ No newline at end of file diff --git a/frontend-web/src/hooks/useDebounce.ts b/frontend-web/src/hooks/useDebounce.ts new file mode 100644 index 00000000..5f8d8254 --- /dev/null +++ b/frontend-web/src/hooks/useDebounce.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +export function useDebounceCallback any>( + callback: T, + delay: number = 300 +): T { + const callbackRef = useRef(callback); + const timeoutRef = useRef(); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + const debouncedCallback = useCallback((...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }, [delay]) as T; + + return debouncedCallback; +} \ No newline at end of file diff --git a/frontend-web/src/hooks/useExpertAuthBootstrap.ts b/frontend-web/src/hooks/useExpertAuthBootstrap.ts new file mode 100644 index 00000000..5dfa0b9d --- /dev/null +++ b/frontend-web/src/hooks/useExpertAuthBootstrap.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from 'react'; + +export interface LlmItem { + id: string; + name: string; +} + +export interface CurrentUser { + id: string; + email: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; + pages_limit: number; + pages_used: number; +} + +type RequestInfoLike = Parameters[0]; +type RequestInitLike = Parameters[1]; + +const LOGIN_URL = 'http://47.88.25.99:8000/auth/jwt/login'; +const LLMS_URL = 'http://47.88.25.99:8000/api/v1/expert-agents/llms'; +const ME_URL = 'http://47.88.25.99:8000/users/me'; + +export function useExpertAuthBootstrap() { + const [llmList, setLlmList] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(false); + + const authedFetch = useCallback(async (input: RequestInfoLike, init?: RequestInitLike) => { + const token = localStorage.getItem('access_token'); + const headers = new Headers(init?.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return fetch(input, { ...init, headers }); + }, []); + + const login = useCallback(async () => { + const formData = new FormData(); + formData.append('username', 'dhk@qq.com'); + formData.append('password', '1qaz@WSX'); + formData.append('grant_type', 'password'); + + const res = await fetch(LOGIN_URL, { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + throw new Error(`登录失败: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + if (data?.access_token) { + localStorage.setItem('access_token', data.access_token); + } + }, []); + + const fetchLlmList = useCallback(async () => { + const res = await authedFetch(LLMS_URL, { method: 'GET' }); + if (!res.ok) { + throw new Error(`llms 接口失败: ${res.status} ${res.statusText}`); + } + + const data: unknown = await res.json(); + const list = Array.isArray(data) ? (data as LlmItem[]) : []; + setLlmList(list); + return list; + }, [authedFetch]); + + const fetchCurrentUser = useCallback(async () => { + const res = await authedFetch(ME_URL, { method: 'GET' }); + if (!res.ok) { + throw new Error(`users/me 接口失败: ${res.status} ${res.statusText}`); + } + + const data: unknown = await res.json(); + const user = (data as CurrentUser) ?? null; + setCurrentUser(user); + return user; + }, [authedFetch]); + + const bootstrap = useCallback(async () => { + setLoading(true); + try { + await login(); + await Promise.all([fetchLlmList(), fetchCurrentUser()]); + } finally { + setLoading(false); + } + }, [fetchCurrentUser, fetchLlmList, login]); + + useEffect(() => { + void bootstrap().catch((error) => { + console.error('专家中心初始化失败:', error); + }); + }, [bootstrap]); + + return { + llmList, + currentUser, + loading, + authedFetch, + login, + fetchLlmList, + fetchCurrentUser, + bootstrap, + }; +} diff --git a/frontend-web/src/hooks/useLocalStorage.ts b/frontend-web/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..b262b521 --- /dev/null +++ b/frontend-web/src/hooks/useLocalStorage.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from 'react'; + +export function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error('Error reading from localStorage:', error); + return initialValue; + } + }); + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error('Error writing to localStorage:', error); + } + }; + + return [storedValue, setValue] as const; +} \ No newline at end of file diff --git a/frontend-web/src/hooks/useSession.js b/frontend-web/src/hooks/useSession.js new file mode 100644 index 00000000..d4adce26 --- /dev/null +++ b/frontend-web/src/hooks/useSession.js @@ -0,0 +1,852 @@ +import { useState, useRef, useCallback } from 'react'; + +/** + * 会话初始状态 + * + * status 枚举: + * - 'idle': 未启动 + * - 'running': 正在运行(流式输出中) + * - 'waiting_human': 等待人工确认(仅 human_review 模式) + * - 'done': 分析完成 + * - 'error': 发生错误 + * + * phase 枚举: + * - 'starting': 启动阶段(第 1 轮,协调者分配初始任务) + * - 'diverging': 辩论阶段(多智能体并行输出) + * - 'closing': 收敛阶段(协调者整合草稿) + * - 'done': 完成 + */ +const initialState = { + status: 'idle', + sessionId: null, + backendUrl: '', + coordinatorMode: 'auto', + phase: null, + currentRound: 0, + totalRounds: 0, + divergeRounds: 0, + reportSchema: {}, + agents: [], + agentStates: {}, + coordinatorUpdates: [], + latestDraft: {}, + sharedMemory: null, + backgroundRetrieval: null, + finalReport: null, + finalReportPartial: {}, + currentFinalField: null, + pendingReview: null, + reviewSubmitting: false, + reviewRevising: false, + errors: [], + timeline: [], +}; + +/** + * 规范化运行时智能体配置 + * + * 将模板中的原始 agents 数组转换为运行时格式, + * 确保每个 agent 都包含所有必需字段(id、name、dimension、expertise 等)。 + * + * @param {Array} agents - 原始智能体配置数组 + * @param {Object} reportSchema - 报告结构定义,用于推断 relevant_fields + * @returns {Array} 规范化后的智能体数组 + */ +function normalizeRuntimeAgents(agents = [], reportSchema = {}) { + const allFields = Object.keys(reportSchema || {}); + + return agents.map((agent, index) => { + const id = agent.id || `agent_${index + 1}`; + const description = agent.description || agent.expertise || agent.dimension || ''; + const skill = agent.skill || agent.expertise || description; + + return { + id, + name: agent.name || id, + description, + skill, + dimension: agent.dimension || agent.name || id, + scenario_role: agent.scenario_role || agent.expertise || description, + relevant_fields: Array.isArray(agent.relevant_fields) && agent.relevant_fields.length > 0 + ? agent.relevant_fields + : allFields, + analysis_contract: Array.isArray(agent.analysis_contract) + ? agent.analysis_contract + : [ + '输出自然语言分析,不输出 JSON。', + '充分展开材料、依据、反例、分歧和边界条件,最终收缩由协调者完成。', + ], + retrieval_mode: agent.retrieval_mode || 'always', + allowed_source_ids: agent.allowed_source_ids || [], + retrieval_instruction: agent.retrieval_instruction || '优先检索与本轮任务相关的最新新闻、公开事实和可核验线索。', + }; + }); +} + +/** + * 构建发送给后端的模板对象 + */ +function buildTemplate(config) { + return { + template_id: config.template_id, + template_name: config.template_name || '', + template_brief: config.template_brief || '', + consistency_mode: config.consistency_mode || 'free', + report_schema: config.report_schema || {}, + report_examples: config.report_examples || {}, + field_requirements: config.field_requirements || {}, + coordinator_config: config.coordinator_config || {}, + agent_adaptation_policy: config.agent_adaptation_policy || {}, + }; +} + +/** + * 构建发送给后端的完整 session 请求体 + */ +function buildSessionPayload(config) { + const reportSchema = config.report_schema || {}; + const reviewMode = config.coordinator_mode || config.review_mode || 'auto'; + + return { + task_context: config.task_context || '', + template: buildTemplate(config), + agents: normalizeRuntimeAgents(config.agents || [], reportSchema), + llm_preset_id: config.llm_preset_id || '', + phase_config: config.phase_config || { diverge_rounds: 3 }, + review_mode: reviewMode, + retrieval: config.retrieval || config.retrieve, + }; +} + +/** + * 将后端 proposal 数据转换为前端 CoordinatorCard 所需格式 + * + * @param {Object} proposal - 后端提案对象 + * @param {Object} previousDraft - 上一轮的草稿(用于合并增量更新) + * @returns {Object} 前端协调者数据 + */ +function proposalToCoordData(proposal, previousDraft = {}) { + const draftUpdates = proposal?.draft_updates || {}; + return { + round: proposal?.round_index, + divergeRound: proposal?.diverge_round, + totalDivergeRounds: proposal?.total_diverge_rounds, + nextSpeakers: proposal?.next_speakers || [], + focus: proposal?.focus || {}, + draft: { ...previousDraft, ...draftUpdates }, + draftUpdates, + gaps: proposal?.gaps || [], + fieldAssessment: proposal?.field_assessment || {}, + sharedMemory: proposal?.shared_memory || null, + reasoning: proposal?.reasoning || '', + proposal, + }; +} + +/** + * 解析 SSE (Server-Sent Events) 数据行 + * + * 格式: "data: {...}" 或 "data: [DONE]" + * @param {string} line - SSE 行文本 + * @returns {Object|null} 解析后的 JSON 对象,无效行返回 null + */ +function parseSsePayload(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) return null; + const jsonStr = trimmed.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') return null; + return JSON.parse(jsonStr); +} + +/** + * useSession hook + * + * 管理整个分析会话的生命周期: + * - startSession: 发送 POST /sessions/start 创建会话,然后连接 SSE 事件流 + * - stopSession: 中断当前连接 + * - reset: 重置所有状态 + * - approveCoordinator / reviseCoordinator: 人工确认 / 修改提案 + * + * SSE 事件类型及处理: + * - session_started: 初始化阶段、智能体、轮次 + * - agent_started: 在 timeline 中插入 agent_contribution(streaming=true) + * - agent_token: 追加 token 到对应 agent_contribution 的 content/thinking + * - agent_done: 将对应 agent_contribution 的 streaming 标记为 false + * - coordinator_started: 插入 coordinator_streaming 卡片 + * - coordinator_token: 追加 coordinator 的思考过程或内容 token + * - coordinator_status: 更新状态横幅 + * - coordinator_proposal: 替换 coordinator_streaming 为 coordinator 完成卡片 + * - human_review_required: 切换状态为 waiting_human + * - final_report_token: 追加最终报告 token + * - final_report_done: 插入 final_report 卡片,状态切换为 done + * - error: 追加错误到 errors 数组 + * + * @returns {Object} 会话状态 + 操作方法 + */ +export function useSession() { + const [state, setState] = useState(initialState); + const abortControllerRef = useRef(null); + const timelineRef = useRef([]); // 用 ref 保存 timeline,避免闭包过期 + const configRef = useRef(null); // 保存启动时的配置引用 + + /** 重置所有状态 */ + const reset = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + timelineRef.current = []; + configRef.current = null; + setState(initialState); + }, []); + + /** + * 从历史记录 JSON 恢复会话展示状态。 + * + * 后端历史接口未来会直接返回保存时的 snapshot;这里把 snapshot 写回 state 和 timelineRef, + * 让用户进入页面后无需重新生成,也能直接看到 Timeline、左右侧进度和最终报告。 + */ + const hydrateFromSnapshot = useCallback((snapshot = {}) => { + const nextTimeline = Array.isArray(snapshot.timeline) ? snapshot.timeline : []; + timelineRef.current = nextTimeline; + configRef.current = snapshot.config || null; + setState({ + ...initialState, + ...snapshot, + status: snapshot.status || (nextTimeline.length > 0 ? 'done' : 'idle'), + phase: snapshot.phase || (nextTimeline.length > 0 ? 'done' : null), + timeline: nextTimeline, + errors: Array.isArray(snapshot.errors) ? snapshot.errors : [], + agentStates: snapshot.agentStates || {}, + agents: Array.isArray(snapshot.agents) ? snapshot.agents : [], + reportSchema: snapshot.reportSchema || snapshot.config?.report_schema || {}, + finalReportPartial: snapshot.finalReportPartial || snapshot.finalReport?.report || {}, + pendingReview: null, + reviewSubmitting: false, + reviewRevising: false, + }); + }, []); + + /** 停止当前会话(中断 SSE 连接) */ + const stopSession = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setState(prev => ({ ...prev, status: 'idle' })); + }, []); + + /** + * SSE 事件处理器 + * + * 根据 event 类型分发处理,修改 timelineRef 和 state。 + * 使用 timelineRef(而非 state.timeline)进行链表式修改, + * 避免在快速 token 到来时因 React 批量更新导致丢帧。 + */ + const handleEvent = useCallback((parsed, backendUrl) => { + const { event, data } = parsed; + if (!event) return; + + switch (event) { + // ====== 会话启动 ====== + case 'session_started': { + const agents = data.agents || []; + const agentStates = {}; + agents.forEach(agent => { + agentStates[agent.id] = { + id: agent.id, + name: agent.name, + dimension: agent.dimension, + status: 'idle', + rounds: [], + }; + }); + setState(prev => ({ + ...prev, + sessionId: prev.sessionId, + backendUrl, + phase: 'starting', + agents, + agentStates, + totalRounds: (configRef.current?.phase_config?.diverge_rounds || 3) + 1, + divergeRounds: configRef.current?.phase_config?.diverge_rounds || 3, + reportSchema: configRef.current?.report_schema || {}, + })); + break; + } + + // ====== 智能体开始输出 ====== + case 'agent_started': { + const agentId = data.agent_id; + const round = data.round_index || 0; + const phase = data.phase || 'starting'; + + // 检查是否已有该轮次的 round_start,没有则插入 + const hasRoundStart = timelineRef.current.some( + item => item.type === 'round_start' && item.round === round && item.phase === phase + ); + + if (!hasRoundStart) { + timelineRef.current = [ + ...timelineRef.current, + { type: 'round_start', round, phase, agentIds: [agentId] }, + ]; + } else { + timelineRef.current = timelineRef.current.map(item => + item.type === 'round_start' && item.round === round && item.phase === phase + ? { ...item, agentIds: [...new Set([...(item.agentIds || []), agentId])] } + : item + ); + } + + // 插入流式 agent_contribution + timelineRef.current = [ + ...timelineRef.current, + { + type: 'agent_contribution', + agentId, + round, + phase, + streaming: true, + data: { + round, + phase, + content: '', + thinking: '', + retrievalLog: [], + retrievalCount: 0, + referenceMap: {}, + }, + }, + ]; + + setState(prev => { + const updatedAgentStates = { ...prev.agentStates }; + updatedAgentStates[agentId] = { + ...(updatedAgentStates[agentId] || { id: agentId, name: data.agent_name, dimension: agentId, rounds: [] }), + status: 'thinking', + }; + return { + ...prev, + phase, + currentRound: Math.max(prev.currentRound, round), + agentStates: updatedAgentStates, + timeline: timelineRef.current, + }; + }); + break; + } + + // ====== 智能体完成输出 ====== + case 'agent_done': { + const agentId = data.agent_id; + const round = data.round_index || 0; + const phase = data.phase || 'starting'; + const retrievalLog = data.retrieval_log || []; + const roundData = { + round, + phase, + content: data.content || '', + thinking: data.thinking || '', + retrievalLog, + retrievalCount: retrievalLog.length, + referenceMap: data.reference_map || {}, + metadata: data.metadata || {}, + }; + + // 将对应的流式条目转为完成状态 + timelineRef.current = timelineRef.current.map(item => + item.type === 'agent_contribution' && item.agentId === agentId && item.round === round && item.streaming + ? { ...item, streaming: false, data: roundData } + : item + ); + + setState(prev => { + const updatedAgentStates = { ...prev.agentStates }; + const existing = updatedAgentStates[agentId] || { + id: agentId, + name: data.agent_name, + dimension: agentId, + status: 'idle', + rounds: [], + }; + updatedAgentStates[agentId] = { + ...existing, + name: data.agent_name, + status: 'done', + rounds: [...(existing.rounds || []), roundData], + }; + return { ...prev, agentStates: updatedAgentStates, timeline: timelineRef.current }; + }); + break; + } + + // ====== 智能体 token(流式追加) ====== + case 'agent_token': { + const agentId = data.agent_id; + const round = data.round_index || 0; + const token = data.token || ''; + const tokenType = data.token_type || 'content'; + + // 找到对应的 streaming agent_contribution 并追加 token + timelineRef.current = timelineRef.current.map(item => { + if (item.type !== 'agent_contribution' || item.agentId !== agentId || item.round !== round || !item.streaming) { + return item; + } + if (tokenType === 'thinking') { + return { ...item, data: { ...item.data, thinking: (item.data.thinking || '') + token } }; + } + return { ...item, data: { ...item.data, content: (item.data.content || '') + token } }; + }); + setState(prev => ({ ...prev, timeline: timelineRef.current })); + break; + } + + // ====== 协调员开始流式输出 ====== + case 'coordinator_started': { + timelineRef.current = [ + ...timelineRef.current, + { + type: 'coordinator_streaming', + phase: data.phase || 'diverging', + divergeRound: data.diverge_round, + totalDivergeRounds: data.total_diverge_rounds, + thinking: '', + content: '协调者正在评估字段完成度、更新草稿并选择下一轮发言者。', + statusMessage: '协调草案生成中…', + currentField: null, + reportPartial: {}, + }, + ]; + setState(prev => ({ ...prev, phase: data.phase || prev.phase, timeline: timelineRef.current })); + break; + } + + // ====== 协调员 token(流式追加) ====== + case 'coordinator_token': { + const token = data.token || ''; + const tokenType = data.token_type || 'thinking'; + const phase = data.phase || 'diverging'; + + // 找到最后一个 coordinator_streaming 项并追加 + const lastIdx = timelineRef.current.reduce((last, item, idx) => ( + item.type === 'coordinator_streaming' ? idx : last + ), -1); + + if (lastIdx === -1) { + // 尚未创建,新建一个 + timelineRef.current = [ + ...timelineRef.current, + { + type: 'coordinator_streaming', + phase, + divergeRound: data.diverge_round, + totalDivergeRounds: data.total_diverge_rounds, + thinking: tokenType === 'thinking' ? token : '', + content: tokenType === 'content' ? token : '', + statusMessage: '', + currentField: data.field || null, + reportPartial: {}, + }, + ]; + } else { + timelineRef.current = timelineRef.current.map((item, idx) => { + if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item; + return { + ...item, + currentField: data.field || item.currentField, + thinking: tokenType === 'thinking' ? (item.thinking || '') + token : item.thinking, + content: tokenType === 'content' ? (item.content || '') + token : item.content, + statusMessage: '', + }; + }); + } + setState(prev => ({ ...prev, timeline: timelineRef.current })); + break; + } + + // ====== 协调员状态更新 ====== + case 'coordinator_status': { + const message = data.message || ''; + const phase = data.phase || 'diverging'; + + const lastIdx = timelineRef.current.reduce((last, item, idx) => ( + item.type === 'coordinator_streaming' ? idx : last + ), -1); + + if (lastIdx === -1) { + timelineRef.current = [ + ...timelineRef.current, + { + type: 'coordinator_streaming', + phase, + divergeRound: data.diverge_round, + totalDivergeRounds: data.total_diverge_rounds, + thinking: '', + content: '', + statusMessage: message, + currentField: null, + reportPartial: {}, + }, + ]; + } else { + timelineRef.current = timelineRef.current.map((item, idx) => { + if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item; + return { ...item, statusMessage: message }; + }); + } + + setState(prev => ({ ...prev, timeline: timelineRef.current })); + break; + } + + // ====== 协调员提案完成 ====== + case 'coordinator_proposal': { + setState(prev => { + const coordData = proposalToCoordData(data.proposal, prev.latestDraft); + let replaced = false; + + // 将最后一个 coordinator_streaming 替换为 coordinator 完成卡片 + timelineRef.current = timelineRef.current.map((item, idx) => { + if (item.type !== 'coordinator_streaming' || replaced) return item; + const lastIdx = timelineRef.current.reduce((last, it, i) => it.type === 'coordinator_streaming' ? i : last, -1); + if (idx !== lastIdx) return item; + replaced = true; + return { + type: 'coordinator', + data: coordData, + thinking: coordData.reasoning, + }; + }); + + if (!replaced) { + timelineRef.current = [...timelineRef.current, { type: 'coordinator', data: coordData, thinking: coordData.reasoning }]; + } + + return { + ...prev, + status: data.revised ? 'running' : prev.status, + coordinatorUpdates: [...prev.coordinatorUpdates, coordData], + latestDraft: coordData.draft, + sharedMemory: coordData.sharedMemory, + pendingReview: data.revised ? null : prev.pendingReview, + reviewRevising: false, + timeline: timelineRef.current, + }; + }); + break; + } + + // ====== 需要人工确认(仅 human_review 模式) ====== + case 'human_review_required': { + setState(prev => { + const coordData = proposalToCoordData(data.proposal, prev.latestDraft); + return { + ...prev, + status: 'waiting_human', + pendingReview: { + ...coordData, + proposal: data.proposal, + }, + sharedMemory: coordData.sharedMemory, + reviewSubmitting: false, + reviewRevising: false, + }; + }); + break; + } + + // ====== 最终报告生成完成 ====== + case 'final_report_done': { + const output = { + report: data.report || {}, + dimensions: data.dimensions || {}, + meta: data.metadata || {}, + }; + timelineRef.current = [...timelineRef.current, { type: 'final_report', data: output }]; + setState(prev => ({ + ...prev, + status: 'done', + finalReport: output, + finalReportPartial: output.report || {}, + currentFinalField: null, + phase: 'done', + pendingReview: null, + reviewSubmitting: false, + reviewRevising: false, + timeline: timelineRef.current, + })); + break; + } + + // ====== 最终报告 token(流式追加) ====== + case 'final_report_token': { + const token = data.token || ''; + const field = data.field || null; + const reportPartial = data.report_partial || {}; + + const lastIdx = timelineRef.current.reduce((last, item, idx) => ( + item.type === 'coordinator_streaming' ? idx : last + ), -1); + + if (lastIdx === -1) { + timelineRef.current = [ + ...timelineRef.current, + { + type: 'coordinator_streaming', + phase: 'final_report', + thinking: '', + content: token, + statusMessage: '', + currentField: field, + reportPartial, + }, + ]; + } else { + timelineRef.current = timelineRef.current.map((item, idx) => { + if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item; + const fieldChanged = field && field !== item.currentField; + return { + ...item, + phase: 'final_report', + content: fieldChanged ? token : (item.content || '') + token, + currentField: field || item.currentField, + reportPartial, + }; + }); + } + setState(prev => ({ + ...prev, + finalReportPartial: reportPartial, + currentFinalField: field, + timeline: timelineRef.current, + })); + break; + } + + // ====== 错误 ====== + case 'error': { + setState(prev => ({ + ...prev, + status: prev.status === 'waiting_human' ? prev.status : 'error', + errors: [...prev.errors, { error: data.message || data.error || JSON.stringify(data), ...data }], + })); + break; + } + + default: + break; + } + }, []); + + /** + * 读取 SSE 事件流 + * + * 使用 fetch + ReadableStream reader 逐行解析, + * 支持 AbortController 中断。 + * + * @param {string} url - SSE 端点 URL + * @param {string} backendUrl - 后端基础地址 + * @param {AbortController} abortController - 中断控制器 + */ + const readEventStream = useCallback(async (url, backendUrl, abortController) => { + const response = await fetch(url, { signal: abortController.signal }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done || abortController.signal.aborted) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + try { + const parsed = parseSsePayload(line); + if (parsed) handleEvent(parsed, backendUrl); + } catch (error) { + console.warn('Failed to parse SSE line:', line, error); + } + } + } + }, [handleEvent]); + + /** + * 启动分析会话 + * + * 1. 发送 POST /sessions/start 创建会话 + * 2. 获取 session_id + * 3. 通过 GET /sessions/{id}/events 订阅 SSE 事件流 + * + * @param {Object} config - 页面配置(任务背景、模板、智能体等) + * @param {string} backendUrl - 后端 API 地址 + */ + const startSession = useCallback(async (config, backendUrl) => { + timelineRef.current = []; + configRef.current = config; + setState({ + ...initialState, + status: 'running', + backendUrl, + coordinatorMode: config.coordinator_mode || 'auto', + reportSchema: config.report_schema || {}, + }); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const payload = buildSessionPayload(config); + const response = await fetch(`${backendUrl}/sessions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: abortController.signal, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + const result = await response.json(); + const sessionId = result.session_id; + setState(prev => ({ + ...prev, + sessionId, + status: result.status === 'created' ? 'running' : result.status, + })); + + await readEventStream(`${backendUrl}/sessions/${sessionId}/events`, backendUrl, abortController); + + // SSE 流结束后,设置最终状态(除非已是 error/waiting_human/done) + setState(prev => ({ + ...prev, + status: prev.status === 'error' || prev.status === 'waiting_human' || prev.status === 'done' + ? prev.status + : 'done', + })); + } catch (err) { + if (err.name === 'AbortError') return; + console.error('Session error:', err); + setState(prev => ({ + ...prev, + status: 'error', + errors: [...prev.errors, { error: err.message }], + })); + } + }, [readEventStream]); + + /** + * 人工确认协调者提案 + * + * 在 human_review 模式下,用户修改提案内容后提交确认。 + * 发送 POST /sessions/{id}/coordinator/approve。 + * + * @param {Object} payload - { nextSpeakers, focus, draft, gaps } + */ + const approveCoordinator = useCallback(async (payload) => { + if (!state.sessionId || !state.backendUrl) { + throw new Error('session 未初始化'); + } + + const baseProposal = state.pendingReview?.proposal || {}; + const proposal = { + ...baseProposal, + next_speakers: payload.nextSpeakers, + focus: payload.focus, + draft_updates: payload.draft, + gaps: payload.gaps, + }; + + setState(prev => ({ ...prev, reviewSubmitting: true })); + try { + const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ proposal }), + }); + + if (!response.ok) { + const msg = await response.text(); + throw new Error(msg || `HTTP ${response.status}`); + } + + const result = await response.json(); + setState(prev => ({ + ...prev, + status: 'running', + pendingReview: null, + reviewSubmitting: false, + })); + } catch (err) { + console.error('approve error:', err); + setState(prev => ({ + ...prev, + reviewSubmitting: false, + errors: [...prev.errors, { error: err.message }], + })); + } + }, [state.sessionId, state.backendUrl, state.pendingReview]); + + /** + * 发送修改指令,让协调员重新生成提案 + * + * @param {string} instruction - 用户的修改指令(自然语言) + */ + const reviseCoordinator = useCallback(async (instruction) => { + if (!state.sessionId || !state.backendUrl) { + throw new Error('session 未初始化'); + } + + setState(prev => ({ ...prev, reviewRevising: true })); + try { + const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/revise`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instruction }), + }); + + if (!response.ok) { + const msg = await response.text(); + throw new Error(msg || `HTTP ${response.status}`); + } + + // 后端会在修订完成后通过 SSE 推送新的 coordinator_proposal + setState(prev => ({ + ...prev, + reviewRevising: false, + pendingReview: null, + status: 'running', + })); + } catch (err) { + console.error('revise error:', err); + setState(prev => ({ + ...prev, + reviewRevising: false, + errors: [...prev.errors, { error: err.message }], + })); + } + }, [state.sessionId, state.backendUrl]); + + return { + ...state, + hydrateFromSnapshot, + reset, + startSession, + stopSession, + approveCoordinator, + reviseCoordinator, + }; +} diff --git a/frontend-web/src/hooks/useToggle.ts b/frontend-web/src/hooks/useToggle.ts new file mode 100644 index 00000000..e581c28a --- /dev/null +++ b/frontend-web/src/hooks/useToggle.ts @@ -0,0 +1,15 @@ +import { useState, useCallback } from 'react'; + +export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] { + const [value, setValue] = useState(initialValue); + + const toggle = useCallback(() => { + setValue((prev) => !prev); + }, []); + + const setToggle = useCallback((newValue: boolean) => { + setValue(newValue); + }, []); + + return [value, toggle, setToggle]; +} \ No newline at end of file diff --git a/frontend-web/src/hooks/useWindowSize.ts b/frontend-web/src/hooks/useWindowSize.ts new file mode 100644 index 00000000..c55fc3d7 --- /dev/null +++ b/frontend-web/src/hooks/useWindowSize.ts @@ -0,0 +1,32 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface WindowSize { + width: number; + height: number; +} + +export function useWindowSize() { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + + const handleResize = useCallback(() => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }, []); + + useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [handleResize]); + + return windowSize; +} + +export function useIsMobile() { + const { width } = useWindowSize(); + return width < 768; +} \ No newline at end of file diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 19bd0918..9f4679de 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -2,6 +2,7 @@ import { BotIcon, PlusSquare } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; +import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -32,6 +33,7 @@ import { env } from "@/env"; import { cn } from "@/lib/utils"; export default function AgentChatPage() { + const routeBase = useRouteBase(); const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); @@ -66,7 +68,7 @@ export default function AgentChatPage() { history.replaceState( null, "", - `${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`, + `${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`, ); }, onFinish: (state) => { @@ -98,6 +100,14 @@ export default function AgentChatPage() { [sendMessage, threadId, agent_id], ); + const handleClarificationSubmit = useCallback( + (answer: string) => { + setHasStartedConversation(true); + void sendMessage(threadId, { text: answer, files: [] }, { agent_id }); + }, + [sendMessage, threadId, agent_id], + ); + const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); @@ -160,6 +170,7 @@ export default function AgentChatPage() { thread={thread} paddingBottom={messageListPaddingBottom} tokenUsageEnabled={tokenUsageEnabled} + onClarificationSubmit={handleClarificationSubmit} /> diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 8a2cd504..66c75403 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -26,11 +26,13 @@ import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useThreadSettings } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; +import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { cn } from "@/lib/utils"; export default function ChatPage() { + const routeBase = useRouteBase(); const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); @@ -64,7 +66,7 @@ export default function ChatPage() { history.replaceState( null, "", - `${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`, + `${window.location.pathname}${window.location.search}#${routeBase}/chats/${createdThreadId}`, ); }, onFinish: (state) => { @@ -95,6 +97,14 @@ export default function ChatPage() { }, [sendMessage, threadId], ); + + const handleClarificationSubmit = useCallback( + (answer: string) => { + setHasStartedConversation(true); + void sendMessage(threadId, { text: answer, files: [] }); + }, + [sendMessage, threadId], + ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); @@ -137,6 +147,7 @@ export default function ChatPage() { thread={thread} paddingBottom={messageListPaddingBottom} tokenUsageEnabled={tokenUsageEnabled} + onClarificationSubmit={handleClarificationSubmit} />
@@ -145,8 +156,8 @@ export default function ChatPage() { "relative w-full", isLandingState && "-translate-y-[calc(50vh-96px)]", isLandingState - ? "max-w-(--container-width-sm)" - : "max-w-(--container-width-md)", + ? "max-w-(--container-width-md)" + : "max-w-(--container-width-lg)", )} >
diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index ceb8228d..e2990a5f 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -12,10 +12,11 @@ import { } from "@/components/workspace/workspace-container"; import { useI18n } from "@/core/i18n/hooks"; import { useThreads } from "@/core/threads/hooks"; -import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { usePathOfThread, titleOfThread } from "@/core/threads/utils"; import { formatTimeAgo } from "@/core/utils/datetime"; export default function ChatsPage() { + const pathOfThread = usePathOfThread(); const { t } = useI18n(); const { data: threads } = useThreads(); const [search, setSearch] = useState(""); diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 75e76e0b..e64de2bc 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -17,6 +17,13 @@ import { } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { @@ -30,37 +37,18 @@ import { AgentNameCheckError, checkAgentName, createAgent, - getAgent, updateAgent, } from "@/core/agents/api"; import { useI18n } from "@/core/i18n/hooks"; +import { useModels } from "@/core/models/hooks"; import type { Skill } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; import { cn } from "@/lib/utils"; -const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000]; const DEFAULT_SELECTED_SKILL_COUNT = 3; -function wait(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -async function getAgentWithRetry(agentId: string) { - for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) { - if (delay > 0) { - await wait(delay); - } - try { - return await getAgent(agentId); - } catch { - // Retry until the file/database write settles. - } - } - return null; -} - function getCreateAgentErrorMessage( error: unknown, networkErrorMessage: string, @@ -83,6 +71,8 @@ export default function NewAgentPage() { const [nameInput, setNameInput] = useState(""); const [descriptionInput, setDescriptionInput] = useState(""); const [soulInput, setSoulInput] = useState(""); + const [modelInput, setModelInput] = useState(""); + const [chatModelInput, setChatModelInput] = useState(""); const [formError, setFormError] = useState(""); const [agentId, setAgentId] = useState(""); const [agent, setAgent] = useState(null); @@ -92,6 +82,7 @@ export default function NewAgentPage() { const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false); const { skills, isLoading: isSkillsLoading } = useSkills(); + const { models, isLoading: isModelsLoading } = useModels(); const enabledSkills = useMemo( () => skills.filter((skill) => skill.enabled), @@ -109,26 +100,30 @@ export default function NewAgentPage() { ); }, [enabledSkills, isSkillSelectionTouched]); + // Seed both model selectors with the first available model the first time + // models load. The user can still change either independently afterwards. + useEffect(() => { + if (models.length === 0) { + return; + } + const first = models[0]!.name; + setModelInput((prev) => prev || first); + setChatModelInput((prev) => prev || first); + }, [models]); + const [thread, sendMessage] = useThreadStream({ threadId, assistantId: agentId || undefined, context: { mode: "flash", is_bootstrap: true, + model_name: chatModelInput || undefined, }, - onToolEnd({ name }) { - if (name !== "setup_agent" || !agentId) { - return; - } - void getAgentWithRetry(agentId).then((fetched) => { - if (fetched) { - setAgent(fetched); - setDescriptionInput(fetched.description ?? ""); - setSoulInput(fetched.soul ?? ""); - setSelectedSkills(fetched.skills ?? []); - toast.success("专家配置已创建"); - } - }); + onToolEnd() { + // setup_agent never actually executes in this flow — it gets + // intercepted by SetupAgentApprovalMiddleware and the form is filled + // from the approval ToolMessage payload (handleApproveSetupAgent). + // No agent record is fetched/refreshed during chat. }, }); @@ -159,12 +154,17 @@ export default function NewAgentPage() { } let name = provided; if (!name) { - // Names are display-only and may collide, so a timestamped placeholder is - // safe. Reflect it in the form so the user sees what got created. + // Names are display-only and may collide, so a timestamped placeholder + // is safe to send to the backend just to satisfy the required-field + // validation. Intentionally NOT written back into the visible input — + // the user wants the name box to stay blank while they chat, and we + // refill it later (with the model's proposed name) on setup_agent + // approval. name = `未命名专家-${Date.now().toString(36)}`; - setNameInput(name); } + const modelOverride = modelInput.trim() || null; + if (agentId) { setIsSavingDraft(true); try { @@ -173,6 +173,7 @@ export default function NewAgentPage() { description: descriptionInput, soul: soulInput, skills: selectedSkills, + model: modelOverride, }); setAgent(updated); return updated; @@ -192,6 +193,7 @@ export default function NewAgentPage() { description: descriptionInput, soul: soulInput, skills: selectedSkills, + model: modelOverride, }); setAgentId(created.id); setAgent(created); @@ -218,6 +220,7 @@ export default function NewAgentPage() { }, [ agentId, descriptionInput, + modelInput, selectedSkills, soulInput, t.agents.nameStepCheckError, @@ -239,63 +242,37 @@ export default function NewAgentPage() { if (!trimmed || thread.isLoading) { return; } - // Lazily create the agent on first message so the user can just start - // typing — ensureAgent generates a placeholder name when none is provided. - let activeAgentId = agentId; - if (!activeAgentId) { - const created = await ensureAgent(); - if (!created) { - return; - } - activeAgentId = created.id; - } + // Chat is purely a discussion channel. The agent record is only + // created when the user explicitly clicks Save (top-right). If the + // model proposes a config via setup_agent, our approval handler + // prefills the form locally — it does NOT persist anything. await ensureThreadExists(); - await sendMessage( - threadId, - { text: trimmed, files: [] }, - { agent_id: activeAgentId }, - ); + await sendMessage(threadId, { text: trimmed, files: [] }); }, - [agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId], + [ensureThreadExists, sendMessage, thread.isLoading, threadId], ); const handleApproveSetupAgent = useCallback( async (payload: SetupAgentApprovalPayload) => { - if (!agentId) { - return; - } - // Approval persists directly to the backend — no LLM round-trip. The - // backend's PUT /api/agents/{id} writes both the DB record and SOUL.md / - // config.yaml on disk, which is exactly what setup_agent_tool would have - // done if we'd let the model re-invoke it. + // Approving the model's setup_agent proposal **does not** persist + // anything — it only mirrors the proposal into the form so the user + // can review it. Creation/update happens later when the user clicks + // the top-right Save button. const proposedName = payload.name?.trim() ?? ""; const newDescription = payload.description ?? ""; const newSoul = payload.soul ?? ""; const newSkills = payload.skills ?? []; - // Prefer the model's proposed name when present; fall back to whatever - // the user already typed, or the existing agent record. - const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId; - setNameInput(resolvedName); + if (proposedName) { + setNameInput(proposedName); + } setDescriptionInput(newDescription); setSoulInput(newSoul); setSelectedSkills(newSkills); setIsSkillSelectionTouched(true); - - try { - const updated = await updateAgent(agentId, { - name: resolvedName, - description: newDescription, - soul: newSoul, - skills: newSkills, - }); - setAgent(updated); - toast.success("专家配置已保存"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "保存失败"); - } + toast.success("已套用模型建议,可继续编辑或点保存"); }, - [agent, agentId, nameInput], + [], ); const handleRejectSetupAgent = useCallback( @@ -356,7 +333,28 @@ export default function NewAgentPage() { onSetupAgentReject={handleRejectSetupAgent} />
-
+
+
+ + 当前对话模型 + + +
void handleChatSubmit(text)} > @@ -405,13 +403,36 @@ export default function NewAgentPage() { />
+
+ + +

+ 该专家被使用时调用的模型。 +

+
+