feat: 提交 task-045 至 task-058 收口产物

- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态

- 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留

- 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
lix-2026
2026-04-16 15:24:37 +08:00
parent 98db79b301
commit 2ff10fa86c
47 changed files with 6494 additions and 2674 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ export async function requireUserId(ctx: any): Promise<string> {
return String(userId);
}
if (isDevAuthEnabled()) {
return readEnv("DEV_USER_ID") ?? "dev-user";
const devUserId = readEnv("DEV_USER_ID");
if (isDevAuthEnabled() || devUserId) {
return devUserId ?? "dev-user";
}
throw new Error("未登录");
+2 -2
View File
@@ -15,7 +15,7 @@
"@blocknote/core": "^0.42.0",
"@blocknote/mantine": "0.42.0",
"@blocknote/react": "^0.42.0",
"@convex-dev/auth": "^0.0.90",
"@convex-dev/auth": "^0.0.91",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -43,7 +43,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.31.4",
"convex": "^1.35.1",
"jszip": "3.10.1",
"lucide-react": "^0.554.0",
"next": "16.0.3",
+138 -33
View File
@@ -21,8 +21,8 @@ importers:
specifier: ^0.42.0
version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@convex-dev/auth':
specifier: ^0.0.90
version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)
specifier: ^0.0.91
version: 0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -105,8 +105,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
convex:
specifier: ^1.31.4
version: 1.31.4(react@19.2.0)
specifier: ^1.35.1
version: 1.35.1(react@19.2.0)
jszip:
specifier: 3.10.1
version: 3.10.1
@@ -290,6 +290,10 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -411,8 +415,8 @@ packages:
cpu: [x64]
os: [win32]
'@convex-dev/auth@0.0.90':
resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==}
'@convex-dev/auth@0.0.91':
resolution: {integrity: sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==}
hasBin: true
peerDependencies:
'@auth/core': ^0.37.0
@@ -486,6 +490,9 @@ packages:
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -993,24 +1000,45 @@ packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.27.16':
resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@floating-ui/react@0.27.19':
resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@hello-pangea/dnd@18.0.1':
resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
peerDependencies:
@@ -2048,8 +2076,8 @@ packages:
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.14':
resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -2859,19 +2887,22 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convex@1.31.4:
resolution: {integrity: sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA==}
convex@1.35.1:
resolution: {integrity: sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
hasBin: true
peerDependencies:
'@auth0/auth0-react': ^2.0.1
'@clerk/clerk-react': ^4.12.8 || ^5.0.0
'@clerk/react': ^6.0.0
react: ^18.0.0 || ^19.0.0-0 || ^19.0.0
peerDependenciesMeta:
'@auth0/auth0-react':
optional: true
'@clerk/clerk-react':
optional: true
'@clerk/react':
optional: true
react:
optional: true
@@ -3746,8 +3777,8 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
is-network-error@1.3.0:
resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==}
is-network-error@1.3.1:
resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==}
engines: {node: '>=16'}
is-number-object@1.1.1:
@@ -4326,6 +4357,9 @@ packages:
oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
oauth4webapi@3.8.5:
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -4465,8 +4499,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.0:
resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
@@ -4625,8 +4659,8 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-number-format@5.4.4:
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
react-number-format@5.4.5:
resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
peerDependencies:
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -4663,6 +4697,16 @@ packages:
'@types/react':
optional: true
react-remove-scroll@2.7.2:
resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4818,6 +4862,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
@@ -4997,6 +5046,9 @@ packages:
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -5665,6 +5717,8 @@ snapshots:
'@babel/runtime@7.28.4': {}
'@babel/runtime@7.29.2': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -5849,18 +5903,18 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20251210.0':
optional: true
'@convex-dev/auth@0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)':
'@convex-dev/auth@0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)':
dependencies:
'@auth/core': 0.37.0
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
convex: 1.31.4(react@19.2.0)
convex: 1.35.1(react@19.2.0)
cookie: 1.1.1
is-network-error: 1.3.0
is-network-error: 1.3.1
jose: 5.10.0
jwt-decode: 4.0.0
lucia: 3.2.2
oauth4webapi: 3.8.3
oauth4webapi: 3.8.5
path-to-regexp: 6.3.0
server-only: 0.0.1
optionalDependencies:
@@ -5928,6 +5982,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
@@ -6215,17 +6274,32 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@floating-ui/react-dom@2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@floating-ui/react@0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -6234,8 +6308,18 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
tabbable: 6.3.0
'@floating-ui/react@0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/utils': 0.2.11
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tabbable: 6.4.0
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.11': {}
'@hello-pangea/dnd@18.0.1(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@babel/runtime': 7.28.4
@@ -6424,7 +6508,7 @@ snapshots:
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.9.2
optional: true
'@img/sharp-wasm32@0.34.5':
@@ -6475,13 +6559,13 @@ snapshots:
'@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/react': 0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@mantine/hooks': 8.3.8(react@19.2.0)
clsx: 2.1.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-number-format: 5.4.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-remove-scroll: 2.7.1(@types/react@19.2.5)(react@19.2.0)
react-number-format: 5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-remove-scroll: 2.7.2(@types/react@19.2.5)(react@19.2.0)
react-textarea-autosize: 8.5.9(@types/react@19.2.5)(react@19.2.0)
type-fest: 4.41.0
transitivePeerDependencies:
@@ -7116,7 +7200,7 @@ snapshots:
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.14': {}
'@speed-highlight/core@1.2.15': {}
'@standard-schema/spec@1.0.0': {}
@@ -7963,12 +8047,16 @@ snapshots:
convert-source-map@2.0.0: {}
convex@1.31.4(react@19.2.0):
convex@1.35.1(react@19.2.0):
dependencies:
esbuild: 0.27.0
prettier: 3.8.0
prettier: 3.8.3
ws: 8.18.0
optionalDependencies:
react: 19.2.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
cookie@0.5.0: {}
@@ -9074,7 +9162,7 @@ snapshots:
is-negative-zero@2.0.3: {}
is-network-error@1.3.0: {}
is-network-error@1.3.1: {}
is-number-object@1.1.1:
dependencies:
@@ -9911,6 +9999,8 @@ snapshots:
oauth4webapi@3.8.3: {}
oauth4webapi@3.8.5: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -10057,7 +10147,7 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.8.0: {}
prettier@3.8.3: {}
pretty-format@3.8.0: {}
@@ -10230,7 +10320,7 @@ snapshots:
react-is@16.13.1: {}
react-number-format@5.4.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
react-number-format@5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -10263,6 +10353,17 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.5
react-remove-scroll@2.7.2(@types/react@19.2.5)(react@19.2.0):
dependencies:
react: 19.2.0
react-remove-scroll-bar: 2.3.8(@types/react@19.2.5)(react@19.2.0)
react-style-singleton: 2.2.3(@types/react@19.2.5)(react@19.2.0)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@19.2.5)(react@19.2.0)
use-sidecar: 1.1.3(@types/react@19.2.5)(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.5
react-style-singleton@2.2.3(@types/react@19.2.5)(react@19.2.0):
dependencies:
get-nonce: 1.0.1
@@ -10273,7 +10374,7 @@ snapshots:
react-textarea-autosize@8.5.9(@types/react@19.2.5)(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@babel/runtime': 7.29.2
react: 19.2.0
use-composed-ref: 1.4.0(@types/react@19.2.5)(react@19.2.0)
use-latest: 1.3.0(@types/react@19.2.5)(react@19.2.0)
@@ -10487,6 +10588,8 @@ snapshots:
semver@7.7.3: {}
semver@7.7.4: {}
server-only@0.0.1: {}
set-cookie-parser@2.7.2: {}
@@ -10519,7 +10622,7 @@ snapshots:
dependencies:
color: 4.2.3
detect-libc: 2.1.2
semver: 7.7.3
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
@@ -10769,6 +10872,8 @@ snapshots:
tabbable@6.3.0: {}
tabbable@6.4.0: {}
tailwind-merge@3.4.0: {}
tailwindcss@4.1.17: {}
@@ -11292,7 +11397,7 @@ snapshots:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.14
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
-2
View File
@@ -1,7 +1,6 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar";
import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost";
import { Breadcrumb } from "@/components/breadcrumb";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import { SearchPalette } from "@/components/search/search-palette";
@@ -30,7 +29,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
<GlobalAiAgentHost />
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -1110,7 +1110,7 @@ export async function POST(request: Request) {
}
const finalOutline = plan
? plan.chapters.flatMap((chapter, chapterIndex) => [
? plan.chapters.flatMap((chapter) => [
{
title: chapter.title,
level: 1,
@@ -1144,7 +1144,8 @@ export async function POST(request: Request) {
pageLinkPattern,
outline: finalOutline,
};
const result = await executeRustBridgeTool<{
// 说明:导图树构建已经交给 Rust runtime,这里只保留 PDF 提取与 transport 壳。
const rustResult = await executeRustBridgeTool<{
ok: boolean;
data?: unknown;
meta?: { title?: string | null; outlineCount?: number | null } | null;
@@ -1156,10 +1157,16 @@ export async function POST(request: Request) {
data: payload,
mode: "result",
});
const mindmapData = (result as any)?.data ?? {
data: { text: pdfTitle },
children: [],
};
const mindmapData =
rustResult.result && typeof rustResult.result === "object" && !Array.isArray(rustResult.result) && "data" in rustResult.result
? ((rustResult.result as { data?: unknown }).data ?? {
data: { text: pdfTitle },
children: [],
})
: {
data: { text: pdfTitle },
children: [],
};
return NextResponse.json({
mindmapData,
@@ -1,9 +1,15 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -13,39 +19,47 @@ interface EmptyTrashPayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "service",
actorId: "mindmap-trash-empty",
sessionId: null,
},
workspaceId,
source: {
channel: "mindmap-trash-route",
client: "wolai-frontend",
},
});
await executeRustBridgeTool({
context,
toolName: "mindmap_empty_trash",
invocationKind: "command",
args: { workspaceId },
data: { workspaceId, source: "mindmap-trash-empty" },
mode: "result",
});
try {
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId,
source: {
channel: "mindmap-trash-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.emptyTrashByWorkspace",
payload: { workspaceId },
context,
target: {
workspaceId,
},
reason: "mindmap-trash:empty",
refs: ["task-047", "mindmap-trash"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
deletedCount?: number;
}>({
client,
plan,
});
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
return documentBridgeErrorResponse(error);
}
}
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
import {
buildDocumentBridgeContextWithActor,
@@ -172,7 +171,43 @@ export async function DELETE(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.delete",
payload: {
documentId: docId,
mindmapId,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:delete",
refs: ["task-047", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
deleted_at?: string | null;
}>({
client,
plan,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
@@ -186,7 +221,7 @@ export async function DELETE(
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
return documentBridgeErrorResponse(error);
}
}
@@ -207,19 +242,43 @@ export async function PATCH(
}
try {
if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
return NextResponse.json({
...(result ?? { ok: true }),
meta: buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
});
}
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: action === "purge" ? "mindmaps.purge" : "mindmaps.restore",
payload: {
documentId: docId,
mindmapId,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: `mindmap-route:${action}`,
refs: ["task-047", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
updated_at?: string | null;
}>({
client,
plan,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
@@ -229,13 +288,11 @@ export async function PATCH(
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
updatedAt: action === "restore" ? result?.updated_at ?? null : null,
},
});
} catch (error) {
const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400;
return NextResponse.json({ error: msg }, { status });
return documentBridgeErrorResponse(error);
}
}
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
@@ -179,19 +178,59 @@ export async function DELETE(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
try {
const context = buildDocumentBridgeContextWithActor({
request,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
workspaceId: null,
source: {
channel: "mindmap-route",
client: "wolai-frontend",
},
});
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.delete",
payload: {
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:delete",
refs: ["task-047", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
deleted_at?: string | null;
}>({
client,
plan,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -2,7 +2,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bot,
Command,
DatabaseZap,
FileSearch,
@@ -12,16 +11,16 @@ import {
RefreshCcw,
Search,
SendHorizontal,
Sparkles,
SquareStop,
Trash2,
X,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { AiBridgePanel } from "./AiBridgePanel";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "./panelShared";
type ChatMsg = { role: "user" | "assistant"; content: string };
type ToolLog =
@@ -44,6 +43,7 @@ type ToolSetChip = {
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
const MIN_PANEL_AGENT_STEPS = 1;
const MAX_PANEL_AGENT_STEPS = 24;
const DEFAULT_PREFS = { provider: "online" as AiProvider, model: "", maxSteps: 10 };
const TOOLSET_CHIPS: ToolSetChip[] = [
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
@@ -54,62 +54,13 @@ const TOOLSET_CHIPS: ToolSetChip[] = [
];
const CAPABILITY_ITEMS: CapabilityItem[] = [
{
title: "联网检索",
description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。",
icon: Search,
},
{
title: "LightRAG",
description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。",
icon: DatabaseZap,
},
{
title: "跨页面文档",
description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。",
icon: FileSearch,
},
{
title: "图片 OCR",
description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。",
icon: Image,
},
{
title: "斜杠命令",
description: "执行受控写操作,例如创建文档或改名,需要明确确认。",
icon: Command,
},
{ title: "联网检索", description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。", icon: Search },
{ title: "LightRAG", description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。", icon: DatabaseZap },
{ title: "跨页面文档", description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。", icon: FileSearch },
{ title: "图片 OCR", description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。", icon: Image },
{ title: "斜杠命令", description: "执行受控写操作,例如创建文档或改名,需要明确确认。", icon: Command },
];
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const formatJson = (value: unknown) => {
try {
return JSON.stringify(value, null, 2);
@@ -125,14 +76,10 @@ const isAbortLikeError = (error: unknown) => {
);
};
const clampStep = (value: number) => {
return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
};
const clampStep = (value: number) => Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
const getLogToneClass = (log: ToolLog) => {
if (log.type === "error") {
return "border-red-500/30 bg-red-500/10";
}
if (log.type === "error") return "border-red-500/30 bg-red-500/10";
if (log.type === "tool_result") {
return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10";
}
@@ -145,13 +92,28 @@ const getLogLabel = (log: ToolLog) => {
return "运行错误";
};
const getProviderLabel = (provider: AiProvider) => {
switch (provider) {
case "local":
return "本地";
case "ollama":
return "Ollama";
case "codex":
return "Codex";
default:
return "Hermes";
}
};
export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
const [input, setInput] = useState(DEFAULT_PROMPT);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [logs, setLogs] = useState<ToolLog[]>([]);
const [running, setRunning] = useState(false);
const [maxSteps, setMaxSteps] = useState(10);
const [showLogs, setShowLogs] = useState(true);
const [aiProvider, setAiProvider] = useState<AiProvider>(DEFAULT_PREFS.provider);
const [aiModel, setAiModel] = useState(DEFAULT_PREFS.model);
const [maxSteps, setMaxSteps] = useState(DEFAULT_PREFS.maxSteps);
const abortRef = useRef<AbortController | null>(null);
const messageEndRef = useRef<HTMLDivElement | null>(null);
@@ -161,6 +123,17 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
const assistantCount = messages.filter((message) => message.role === "assistant").length;
const userCount = messages.length - assistantCount;
useEffect(() => {
const prefs = readAiPanelPrefs("global_ai", DEFAULT_PREFS);
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(clampStep(prefs.maxSteps));
}, []);
useEffect(() => {
writeAiPanelPrefs("global_ai", { provider: aiProvider, model: aiModel, maxSteps: clampStep(maxSteps) });
}, [aiModel, aiProvider, maxSteps]);
useEffect(() => {
messageEndRef.current?.scrollIntoView({ block: "end" });
}, [messages, logs, showLogs]);
@@ -171,14 +144,10 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
setRunning(false);
};
const restoreDefaultPrompt = () => {
setInput(DEFAULT_PROMPT);
};
const restoreDefaultPrompt = () => setInput(DEFAULT_PROMPT);
const clearConversation = () => {
if (running) {
stop();
}
if (running) stop();
setMessages([]);
setLogs([]);
};
@@ -210,13 +179,18 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
mode: "auto",
toolSets: TOOLSET_CHIPS.map((chip) => chip.id),
},
options: { searxng: true, ai: { provider: "online" } },
options: {
searxng: true,
ai: {
provider: aiProvider,
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => null)) as unknown;
const err =
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
const err = typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
throw new Error(err || `HTTP ${res.status}`);
}
@@ -282,9 +256,7 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
}
});
} catch (error) {
if (controller.signal.aborted || isAbortLikeError(error)) {
return;
}
if (controller.signal.aborted || isAbortLikeError(error)) return;
const message = error instanceof Error ? error.message : String(error);
setLogs((prev) => [...prev, { type: "error", message }]);
} finally {
@@ -293,340 +265,274 @@ export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
}
};
return (
<section className="flex h-[calc(100vh-112px)] min-h-[720px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
<span className="h-1 w-1 rounded-full bg-white/25" />
<span className="text-sm text-white/60"> AI</span>
const logSidebar = showLogs ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-white/8 px-4 py-4">
<div className="text-sm font-medium text-white"></div>
<div className="mt-1 text-xs text-white/45">退</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-4 py-4">
{logs.length === 0 ? <div className="text-sm text-white/45"></div> : null}
{logs.map((log, index) => (
<article key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium tracking-wide text-white/82">{getLogLabel(log)}</div>
{log.type !== "error" ? <div className="text-[11px] text-white/40">{log.id}</div> : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-white"> AI</h1>
<Badge className="border-sky-400/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/10" variant="outline">
<div className="mt-2 text-sm font-medium text-white">{log.type === "error" ? log.message : log.tool}</div>
{log.type === "tool_result" ? (
<div className="mt-1 text-xs text-white/45">{log.ok ? "成功" : "失败"} · {log.ms}ms</div>
) : null}
{log.type === "tool_call" ? (
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.args)}</pre>
) : null}
{log.type === "tool_result" ? (
<pre className="mt-3 whitespace-pre-wrap text-xs leading-6 text-white/70">{formatJson(log.result)}</pre>
) : null}
</article>
))}
</div>
</div>
) : undefined;
return (
<AiBridgePanel
title="全局 AI"
subtitle="实验入口"
status={`${running ? "运行中" : "待命"} · ${getProviderLabel(aiProvider)}`}
onClose={onClose}
secondaryActions={
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="切换工具活动面板"
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={() => setShowLogs((prev) => !prev)}
>
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="恢复示例问题"
title="恢复示例问题"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={restoreDefaultPrompt}
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="清空对话"
title="清空对话"
disabled={!canClear}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={clearConversation}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="停止运行"
title="停止本轮运行"
disabled={!running}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={stop}
>
<SquareStop className="h-4 w-4" />
</Button>
</>
}
sidebar={logSidebar}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
>
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-white/8 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-white/45"></div>
<div className="mt-2 text-sm text-white/65"> MNOTE </div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{userCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
AI {assistantCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{logs.length}
</Badge>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{running ? "运行中" : "待命"}
</Badge>
{onClose ? (
<div className="mt-4 flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<Badge
key={chip.id}
variant="outline"
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
title={chip.description}
>
{chip.title}
</Badge>
))}
</div>
</div>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
{messages.length === 0 ? (
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
<div className="text-base font-semibold text-white"> MNOTE AI</div>
<div className="mt-2 text-sm leading-7 text-white/72"></div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CAPABILITY_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<Icon className="h-4 w-4 text-sky-200/80" />
{item.title}
</div>
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
</div>
);
})}
</div>
<div className="mt-4 text-sm text-white/70"></div>
</div>
) : null}
{messages.map((message, index) => {
const isUser = message.role === "user";
return (
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
isUser ? "border-sky-400/25 bg-sky-500/15 text-sky-50" : "border-white/10 bg-white/[0.04] text-white/90"
}`}
>
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
</div>
</article>
);
})}
<div ref={messageEndRef} />
</div>
</ScrollArea>
</div>
<div className="border-t border-white/8 px-5 py-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<div key={`${chip.id}-summary`} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">
<span className="text-xs font-medium text-white/82">{chip.title}</span>
<span className="text-xs text-white/42">{chip.description}</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
<input
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
type="number"
min={MIN_PANEL_AGENT_STEPS}
max={MAX_PANEL_AGENT_STEPS}
step={1}
value={maxSteps}
onChange={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
setMaxSteps(clampStep(Math.floor(value)));
}}
disabled={running}
/>
</label>
<select
className="h-9 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none"
value={aiProvider}
onChange={(e) => {
const value = String(e.target.value || "").trim();
if (value === "online" || value === "local" || value === "ollama" || value === "codex") {
setAiProvider(value);
return;
}
setAiProvider("online");
}}
disabled={running}
>
<option value="online">Hermes</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
<input
className="h-9 w-44 rounded-full border border-white/10 bg-white/[0.03] px-3 text-xs text-white outline-none placeholder:text-white/30"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder={aiProvider === "codex" ? "Codex 无需模型" : "模型(可选)"}
disabled={running || aiProvider === "codex"}
/>
</div>
</div>
<div className="relative">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter Shift+Enter </div>
{running ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="关闭全局 AI"
title="关闭"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={onClose}
variant="secondary"
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
onClick={stop}
>
<X className="h-4 w-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="切换工具活动面板"
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={() => setShowLogs((prev) => !prev)}
disabled={!canSend}
aria-label="发送消息"
title="发送"
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
onClick={() => void send()}
>
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="恢复示例问题"
title="恢复示例问题"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={restoreDefaultPrompt}
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="清空对话"
title="清空对话"
disabled={!canClear}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={clearConversation}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="停止运行"
title="停止本轮运行"
disabled={!running}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={stop}
>
<SquareStop className="h-4 w-4" />
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
<div className="border-b border-white/8 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-white/45">
<Sparkles className="h-3.5 w-3.5" />
</div>
<div className="mt-2 text-sm text-white/65"> MNOTE </div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{userCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
AI {assistantCount}
</Badge>
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
{logs.length}
</Badge>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<Badge
key={chip.id}
variant="outline"
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
title={chip.description}
>
{chip.title}
</Badge>
))}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
{messages.length === 0 ? (
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
<div className="flex items-start gap-3">
<div className="mt-1 inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-white/85">
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white"> MNOTE AI</div>
<div className="mt-2 text-sm leading-7 text-white/72"></div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CAPABILITY_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<Icon className="h-4 w-4 text-sky-200/80" />
{item.title}
</div>
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
</div>
);
})}
</div>
<div className="mt-4 text-sm text-white/70"></div>
</div>
</div>
</div>
) : null}
{messages.map((message, index) => {
const isUser = message.role === "user";
return (
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-sm font-semibold text-white/88">
AI
</div>
) : null}
<div
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
isUser
? "border-sky-400/25 bg-sky-500/15 text-sky-50"
: "border-white/10 bg-white/[0.04] text-white/90"
}`}
>
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
</div>
{isUser ? (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-500/18 text-sm font-semibold text-sky-50">
</div>
) : null}
</article>
);
})}
<div ref={messageEndRef} />
</div>
</ScrollArea>
<div className="border-t border-white/8 px-5 py-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{TOOLSET_CHIPS.map((chip) => (
<div
key={`${chip.id}-summary`}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5"
>
<span className="text-xs font-medium text-white/82">{chip.title}</span>
<span className="text-xs text-white/42">{chip.description}</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
<input
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
type="number"
min={MIN_PANEL_AGENT_STEPS}
max={MAX_PANEL_AGENT_STEPS}
step={1}
value={maxSteps}
onChange={(e) => {
const value = Number(e.target.value);
if (!Number.isFinite(value)) return;
setMaxSteps(clampStep(Math.floor(value)));
}}
disabled={running}
/>
</label>
<Badge className="border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/70 hover:bg-white/[0.04]" variant="outline">
{running ? "状态:生成中…" : "状态:等待输入"}
</Badge>
</div>
</div>
<div className="relative">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter Shift+Enter </div>
{running ? (
<Button
type="button"
variant="secondary"
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
onClick={stop}
>
</Button>
) : null}
<Button
type="button"
disabled={!canSend}
aria-label="发送消息"
title="发送"
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
onClick={() => void send()}
>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{showLogs ? (
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
<div className="border-b border-white/8 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-white/48">{running ? "AI 正在调度工具与生成回答" : "等待发起下一轮任务"}</div>
</div>
<Badge className="border-white/10 bg-white/[0.04] text-white/72 hover:bg-white/[0.04]" variant="outline">
{logs.length}
</Badge>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-4">
{logs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-7 text-white/45">
</div>
) : null}
{logs.map((log, index) => (
<div key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/42">{getLogLabel(log)}</div>
{"id" in log ? <div className="text-xs text-white/35">{log.id || "no-id"}</div> : null}
</div>
{log.type === "error" ? (
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-red-100/90">{log.message}</div>
) : null}
{log.type === "tool_call" ? (
<>
<div className="mt-3 flex items-center gap-2 text-sm font-medium text-white">
<Search className="h-4 w-4 text-sky-200/80" />
{log.tool}
</div>
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
{formatJson(log.args)}
</pre>
</>
) : null}
{log.type === "tool_result" ? (
<>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<Search className="h-4 w-4 text-emerald-200/80" />
{log.tool}
</div>
<div className="text-xs text-white/45">{log.ms}ms</div>
</div>
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
{formatJson(log.result)}
</pre>
</>
) : null}
</div>
))}
</div>
</ScrollArea>
</aside>
) : null}
</div>
</div>
</section>
</AiBridgePanel>
);
}
@@ -0,0 +1,103 @@
'use client';
import type { ReactNode } from "react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type AiBridgePanelProps = {
title: string;
subtitle: string;
status?: string;
onClose?: () => void;
primaryAction?: ReactNode;
secondaryActions?: ReactNode;
children: ReactNode;
sidebar?: ReactNode;
className?: string;
scrollBody?: boolean;
scrollSidebar?: boolean;
};
// 通用 AI 面板壳:统一视觉和双栏结构,具体状态由各场景 adapter 提供。
export function AiBridgePanel({
title,
subtitle,
status,
onClose,
primaryAction,
secondaryActions,
children,
sidebar,
className,
scrollBody = true,
scrollSidebar = true,
}: AiBridgePanelProps) {
return (
<section
className={cn(
"flex h-[calc(100vh-112px)] min-h-[640px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]",
className,
)}
>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
<span className="h-1 w-1 rounded-full bg-white/25" />
<span className="text-sm text-white/60">{subtitle}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-white">{title}</h1>
{status ? (
<span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-xs text-sky-100">
{status}
</span>
) : null}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
{secondaryActions}
{primaryAction}
{onClose ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="关闭"
title="关闭"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={onClose}
>
×
</Button>
) : null}
</div>
</header>
<div className={cn("grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]", !sidebar && "xl:grid-cols-1")}>
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
{scrollBody ? <ScrollArea className="min-h-0 flex-1">{children}</ScrollArea> : <div className="min-h-0 flex-1">{children}</div>}
</div>
{sidebar ? (
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
{scrollSidebar ? (
<ScrollArea className="min-h-0 flex-1">{sidebar}</ScrollArea>
) : (
<div className="min-h-0 flex-1">{sidebar}</div>
)}
</aside>
) : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,82 @@
"use client";
export type AiProvider = "online" | "local" | "ollama" | "codex";
export type AiPanelPrefs = {
provider: AiProvider;
model: string;
maxSteps: number;
};
export const parseSseChunks = async (
res: Response,
onEvent: (event: string, dataText: string) => void,
) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
export const readAiPanelPrefs = (
storageKeyPrefix: string,
defaults: AiPanelPrefs,
): AiPanelPrefs => {
if (typeof window === "undefined") return defaults;
try {
const providerRaw = (window.localStorage.getItem(`${storageKeyPrefix}_provider`) || "").trim();
const model = window.localStorage.getItem(`${storageKeyPrefix}_model`) || defaults.model;
const stepsRaw = window.localStorage.getItem(`${storageKeyPrefix}_max_steps`) || "";
const parsedSteps = Number(stepsRaw);
const provider: AiProvider =
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex"
? providerRaw
: defaults.provider;
return {
provider,
model,
maxSteps: Number.isFinite(parsedSteps) ? Math.floor(parsedSteps) : defaults.maxSteps,
};
} catch {
return defaults;
}
};
export const writeAiPanelPrefs = (storageKeyPrefix: string, prefs: AiPanelPrefs) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(`${storageKeyPrefix}_provider`, prefs.provider);
window.localStorage.setItem(`${storageKeyPrefix}_model`, prefs.model);
window.localStorage.setItem(`${storageKeyPrefix}_max_steps`, String(prefs.maxSteps));
} catch {
// ignore
}
};
+2 -16
View File
@@ -26,8 +26,6 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
const showInspector = usePageLayoutStore((state) => state.showInspector);
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
const backendStatus = useBackendHealth();
const globalAgentOpen = useAiAgentUiStore((s) => s.globalAgentOpen);
const toggleGlobalAgentOpen = useAiAgentUiStore((s) => s.toggleGlobalAgentOpen);
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
const isStarred = useQuery(
@@ -88,28 +86,16 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
type="button"
className={cn(
"rounded-full px-3 py-1 text-sm transition-colors",
globalAgentOpen
documentAgentAvailable
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
)}
onClick={() => toggleGlobalAgentOpen()}
title="打开全局 AI"
>
<Sparkles className="mr-1 inline h-4 w-4" />
AI
</button>
<button
type="button"
className={cn(
"hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors",
!documentAgentAvailable && "cursor-not-allowed opacity-50",
)}
onClick={() => toggleDocumentAgentOpen()}
disabled={!documentAgentAvailable}
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
>
<Sparkles className="mr-1 inline h-4 w-4" />
AI
AI
</button>
<button
type="button"
@@ -7,19 +7,15 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
@@ -116,41 +112,6 @@ const normalizeSessions = (sessions: ChatSession[]) => {
.sort((a, b) => b.updatedAt - a.updatedAt);
};
const parseSseChunks = async (
res: Response,
onEvent: (event: string, dataText: string) => void,
) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const safeJsonStringify = (value: unknown) => {
try {
return JSON.stringify(value);
@@ -177,7 +138,6 @@ export function DocumentAiAgentPanel({
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
@@ -212,29 +172,18 @@ export function DocumentAiAgentPanel({
}, [setAvailable, setOpen]);
useEffect(() => {
try {
const stepsRaw = window.localStorage.getItem("doc_ai_max_steps") || "";
const p = (window.localStorage.getItem("doc_ai_provider") || "").trim();
const m = window.localStorage.getItem("doc_ai_model") || "";
const parsed = Number(stepsRaw);
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
}
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
} catch {
// ignore
}
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
setAiProvider(prefs.provider);
setAiModel(prefs.model);
}, []);
useEffect(() => {
try {
window.localStorage.setItem("doc_ai_max_steps", String(maxSteps));
window.localStorage.setItem("doc_ai_provider", aiProvider);
window.localStorage.setItem("doc_ai_model", aiModel);
} catch {
// ignore
}
writeAiPanelPrefs("doc_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
});
}, [aiModel, aiProvider, maxSteps]);
useEffect(() => {
@@ -676,73 +625,89 @@ export function DocumentAiAgentPanel({
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" showCloseButton={false} className="p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<SheetContent
side="right"
showCloseButton={false}
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
>
<SheetTitle className="sr-only"> AI</SheetTitle>
<AiBridgePanel
title={pageTitle}
subtitle="页面 AI"
status={loading ? "运行中" : "待命"}
onClose={() => setOpen(false)}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
<Sparkles className="h-4 w-4 text-white/75" />
) : (
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("chat")}
disabled={loading}
title="返回对话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{pageTitle}
</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Button
variant="ghost"
size="icon-sm"
onClick={startNewSession}
disabled={loading}
title="新建会话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("tools")}
disabled={loading}
title="工具(代替 MCP"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("history")}
disabled={loading}
title="历史"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("account")}
disabled={loading}
title="账户/模型"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<User className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={() => setPage("settings")}
disabled={loading}
title="设置"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</SheetTitle>
</SheetHeader>
<div className="flex flex-1 flex-col overflow-hidden">
</>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className={page === "chat" ? "" : "hidden"}>
<div className="hidden">
<div className="flex items-center gap-2">
@@ -766,17 +731,6 @@ export function DocumentAiAgentPanel({
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
{!toolAuto && (
<button
type="button"
className="inline-flex items-center gap-1 rounded border bg-white px-2 py-1"
onClick={() => setToolPickerOpen((v) => !v)}
disabled={loading}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-2">
@@ -1446,6 +1400,7 @@ export function DocumentAiAgentPanel({
</div>
</DialogContent>
</Dialog>
</AiBridgePanel>
</SheetContent>
</Sheet>
);
@@ -3,9 +3,11 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkles, User, Wrench, X, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
@@ -18,7 +20,6 @@ type AgentAssetItem = {
};
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
@@ -183,7 +184,6 @@ export function MindmapAiAgentPanel({
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
@@ -210,29 +210,18 @@ export function MindmapAiAgentPanel({
}, [flightMode]);
useEffect(() => {
try {
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
const m = window.localStorage.getItem("mindmap_ai_model") || "";
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
if (typeof m === "string") setAiModel(m);
const parsed = Number(stepsRaw);
if (Number.isFinite(parsed) && parsed >= 1) {
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
}
} catch {
// ignore
}
const prefs = readAiPanelPrefs("mindmap_ai", { provider: "online", model: "", maxSteps: 10 });
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(Math.max(1, Math.min(24, Math.floor(prefs.maxSteps))));
}, []);
useEffect(() => {
try {
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
window.localStorage.setItem("mindmap_ai_model", aiModel);
window.localStorage.setItem("mindmap_ai_max_steps", String(maxSteps));
} catch {
// ignore
}
writeAiPanelPrefs("mindmap_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: Math.max(1, Math.min(24, Math.floor(maxSteps))),
});
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
@@ -369,38 +358,6 @@ export function MindmapAiAgentPanel({
return false;
};
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const selectedNodes = useMemo(() => {
const list = Array.isArray(activeNodes) ? activeNodes : [];
@@ -562,13 +519,6 @@ export function MindmapAiAgentPanel({
}, 0);
};
const toggleTool = (tool: ToolName) => {
setSelectedTools((prev) => {
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
return [...prev, tool];
});
};
const uploadFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
if (!workspaceId || !documentId) {
@@ -979,42 +929,82 @@ export function MindmapAiAgentPanel({
};
return (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{page === "chat" ? (
<Sparkles className="h-4 w-4" />
) : (
<Button variant="ghost" size="icon" onClick={() => setPage("chat")} disabled={loading} title="返回对话">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">{pageTitle}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={startNewSession} disabled={loading} title="新建会话">
<Plus className="h-4 w-4" />
<AiBridgePanel
title={pageTitle}
subtitle="思维导图 AI"
status={loading ? "运行中" : "待命"}
onClose={onClose}
scrollBody={false}
className="h-full min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
{page === "chat" ? (
<Sparkles className="h-4 w-4 text-white/75" />
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("chat")}
disabled={loading}
title="返回对话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("tools")} disabled={loading} title="工具(代替 MCP">
<Wrench className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("history")} disabled={loading} title="历史">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("account")} disabled={loading} title="账户/模型">
<User className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setPage("settings")} disabled={loading} title="设置">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onClose?.()} disabled={loading} title="关闭">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={startNewSession}
disabled={loading}
title="新建会话"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("tools")}
disabled={loading}
title="工具(代替 MCP"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("history")}
disabled={loading}
title="历史"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("account")}
disabled={loading}
title="账户/模型"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<User className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("settings")}
disabled={loading}
title="设置"
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
</>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{page === "chat" ? (
<>
@@ -1435,374 +1425,6 @@ export function MindmapAiAgentPanel({
) : null}
</div>
{false ? (
<div className="hidden">
<div className="flex items-center justify-between gap-2 pb-3">
<div
className="text-xs text-gray-500"
title={
selectedNodes.length
? selectedNodes
.map((n) => (n.text ? `${n.text}${n.uid}` : n.uid))
.slice(0, 3)
.join("")
: ""
}
>
{selectedNodes.length
? `选中节点:${selectedNodes[0]?.text || selectedNodes[0]?.uid}${selectedNodes.length > 1 ? `+${selectedNodes.length - 1}` : ""}`
: "未选中节点(将以整图为上下文)"}
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
title="联网检索(SearxNG"
onClick={() => setNetworkOn((v) => !v)}
>
<Network className="h-3 w-3" />
</button>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
title="选择允许使用的工具"
onClick={() => setToolPickerOpen((v) => !v)}
>
<Settings2 className="h-3 w-3" />
</button>
</div>
</div>
{toolPickerOpen && (
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium text-gray-700"></div>
<button
type="button"
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
onClick={() => setToolAuto((v) => !v)}
title="自动:AI 自行选择;手动:仅允许勾选工具"
>
{toolAuto ? "自动" : "手动"}
</button>
</div>
<div className="space-y-1">
{DEFAULT_TOOLS.map((t) => (
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
<input
type="checkbox"
disabled={toolAuto}
checked={selectedTools.includes(t)}
onChange={() => toggleTool(t)}
/>
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
</label>
))}
</div>
<div className="mt-2 text-[11px] text-gray-400">
AI
</div>
</div>
)}
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
<div className="space-y-2">
{messages.map((m, idx) => (
<div
key={idx}
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
>
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
<div>{m.content}</div>
</div>
))}
</div>
</div>
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{attachments.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
title={a.fileUrl}
>
@{a.title}
<button
type="button"
className="text-gray-400 hover:text-gray-700"
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
<div className="flex flex-wrap items-center gap-3">
<div className="text-gray-500">AI</div>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-online"
checked={aiProvider === "online"}
onChange={() => setAiProvider("online")}
/>
线
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
data-testid="mindmap-ai-provider-local"
checked={aiProvider === "local"}
onChange={() => setAiProvider("local")}
/>
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "ollama"}
onChange={() => setAiProvider("ollama")}
/>
Ollama
</label>
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="radio"
name="mindmap-ai-provider"
checked={aiProvider === "codex"}
onChange={() => setAiProvider("codex")}
/>
Codex
</label>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
{aiProvider === "codex" ? (
<div className="text-[11px] text-gray-500">
<code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code> <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
data-testid="mindmap-ai-model-select"
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
{ONLINE_MODELS.map((m) => (
<option key={m || "__default__"} value={m}>
{m ? m : "默认(ai.md"}
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
data-testid="mindmap-ai-model-input"
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
list="mindmap-local-model-suggestions-bottom"
/>
)}
<datalist id="mindmap-local-model-suggestions-bottom">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
<div className="flex items-center gap-2">
<div className="text-gray-500"></div>
<input
className="w-[72px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
}}
title="工具调用/推理最大步数(上限 24)"
disabled={loading}
/>
</div>
</div>
{aiProvider === "local" ? (
<div className="mt-1 text-[11px] text-gray-400">
AI `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` `ai.local.md` / `ai-local.md`
</div>
) : null}
</div>
<div className="relative mt-2">
{mentionOpen && filteredAssets.length > 0 && (
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
{filteredAssets.map((a) => (
<button
key={a.id}
type="button"
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() => insertMention(a)}
>
<span className="truncate text-gray-800">{a.title}</span>
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
data-testid="mindmap-ai-input"
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
rows={4}
value={input}
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
onChange={(e) => {
const v = e.target.value;
setInput(v);
updateMentionState(v, e.target.selectionStart ?? v.length);
}}
onKeyDown={(e) => {
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
e.stopPropagation();
if (e.key === "Escape") {
setMentionOpen(false);
setToolPickerOpen(false);
return;
}
if (e.key === "Enter") {
// Enter 发送;Shift+Enter 换行
const isComposing = Boolean((e.nativeEvent as unknown as { isComposing?: boolean })?.isComposing);
if (!e.shiftKey && !isComposing) {
e.preventDefault();
if (!loading) void send();
}
return;
}
}}
onClick={(e) => {
const el = e.currentTarget;
updateMentionState(el.value, el.selectionStart ?? el.value.length);
}}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
onClick={() => fileInputRef.current?.click()}
title="上传文件到当前页面附件"
disabled={loading}
>
<Paperclip className="h-3 w-3" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void uploadFiles(e.target.files);
}}
accept="*/*"
/>
<div className="text-[11px] text-gray-400">Enter · Shift+Enter </div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
disabled={!loading}
onClick={() => abortRef.current?.abort()}
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC" : "停止本次执行"}
>
<X className="h-4 w-4" />
{aiProvider === "codex" ? "暂停" : "停止"}
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading || !input.trim()}
onClick={() => void send()}
>
<Send className="h-4 w-4" />
{loading ? "执行中..." : "发送"}
</button>
</div>
</div>
</div>
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none"></summary>
<div className="mt-2 space-y-2">
{toolLogs.length === 0 ? <div className="text-gray-400"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-[11px] text-gray-400">tool_call · {l.id}</div>
<div className="font-medium text-gray-800">{l.tool}</div>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-[11px] text-gray-400">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium text-gray-800">{l.tool}</div>
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap">{JSON.stringify(l.result, null, 2)}</pre>
</div>
);
})}
</div>
</details>
{debug ? (
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
<summary className="cursor-pointer select-none">SSE </summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
</details>
) : null}
</div>
) : null}
</div>
</AiBridgePanel>
);
}
@@ -1,15 +1,16 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bot, Settings, Wrench, X } from "lucide-react";
import { Bot, Settings, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { clamp } from "@/lib/constants";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AiProvider = "online" | "local" | "ollama" | "codex";
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
@@ -44,37 +45,6 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
if (!res.body) throw new Error("响应不支持流式读取");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
// 注释/心跳:以 ":" 开头
if (raw.trimStart().startsWith(":")) continue;
const lines = raw.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice("event:".length).trim() || "message";
if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trimStart());
}
onEvent(event, dataLines.join("\n"));
}
}
};
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -133,27 +103,18 @@ export function OnlyOfficeAiAgentPanel({
}, [flightMode]);
useEffect(() => {
try {
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
const parsed = Number(stepsRaw);
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
if (typeof modelRaw === "string") setAiModel(modelRaw);
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
} catch {
// ignore
}
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
}, []);
useEffect(() => {
try {
window.localStorage.setItem("onlyoffice_ai_provider", aiProvider);
window.localStorage.setItem("onlyoffice_ai_model", aiModel);
window.localStorage.setItem("onlyoffice_ai_max_steps", String(maxSteps));
} catch {
// ignore
}
writeAiPanelPrefs("onlyoffice_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
});
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
@@ -458,41 +419,43 @@ export function OnlyOfficeAiAgentPanel({
</div>
{open ? (
<div className="fixed right-0 top-0 z-[70] h-screen w-[420px] border-l bg-background shadow-xl">
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="text-sm font-semibold">OnlyOffice AI</div>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-1 border-b px-2 py-2">
<Button
variant={page === "chat" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("chat")}
>
<Bot className="mr-2 h-4 w-4" />
</Button>
<Button
variant={page === "tools" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("tools")}
>
<Wrench className="mr-2 h-4 w-4" />
</Button>
<Button
variant={page === "settings" ? "default" : "secondary"}
size="sm"
onClick={() => setPage("settings")}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
<AiBridgePanel
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
subtitle="OnlyOffice AI"
status={loading ? "运行中" : "待命"}
onClose={() => setOpen(false)}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("chat")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Bot className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("tools")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("settings")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
</>
}
>
{page === "tools" ? (
<div className="space-y-3 p-3 text-sm">
<div className="flex items-center justify-between">
@@ -687,6 +650,7 @@ export function OnlyOfficeAiAgentPanel({
</div>
</div>
) : null}
</AiBridgePanel>
</div>
) : null}
</>
@@ -0,0 +1,115 @@
import { isPlainObject } from "@/lib/type-guards";
export type HermesBridgeConfig = {
baseUrl: string;
apiKey: string | null;
};
export type HermesRunRequest = {
input: Array<{ role: string; content: string }> | string;
instructions?: string;
conversation_history?: Array<{ role: string; content: string }>;
session_id?: string;
};
export type HermesRunStarted = {
runId: string;
};
export type HermesRunEvent =
| { event: "tool.started"; tool: string; preview?: string | null }
| { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
| { event: "message.delta"; delta: string }
| { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
| { event: "run.failed"; error?: string }
| { event: string; [key: string]: unknown };
const DEFAULT_BASE_URL = "http://127.0.0.1:8642";
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
export const readHermesBridgeConfig = (): HermesBridgeConfig => ({
baseUrl: trimTrailingSlash((process.env.MNOTE_HERMES_API_BASE_URL || "").trim() || DEFAULT_BASE_URL),
apiKey: (process.env.MNOTE_HERMES_API_KEY || "").trim() || null,
});
export const buildHermesHeaders = (config: HermesBridgeConfig, init?: HeadersInit) => {
const headers = new Headers(init);
headers.set("Content-Type", "application/json");
if (config.apiKey) {
headers.set("Authorization", `Bearer ${config.apiKey}`);
}
return headers;
};
export const startHermesRun = async (payload: HermesRunRequest): Promise<HermesRunStarted> => {
const config = readHermesBridgeConfig();
const response = await fetch(`${config.baseUrl}/v1/runs`, {
method: "POST",
headers: buildHermesHeaders(config),
body: JSON.stringify(payload),
cache: "no-store",
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `Hermes run 启动失败:HTTP ${response.status}`);
}
const json = (await response.json().catch(() => null)) as unknown;
const runId = isPlainObject(json) ? String(json.run_id ?? "").trim() : "";
if (!runId) throw new Error("Hermes run 响应缺少 run_id");
return { runId };
};
export const streamHermesRunEvents = async (
runId: string,
onEvent: (event: HermesRunEvent) => Promise<void> | void,
) => {
const config = readHermesBridgeConfig();
const response = await fetch(`${config.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events`, {
method: "GET",
headers: buildHermesHeaders(config),
cache: "no-store",
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `Hermes 事件流连接失败:HTTP ${response.status}`);
}
if (!response.body) throw new Error("Hermes 事件流不支持 body");
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const sep = buffer.indexOf("\n\n");
if (sep === -1) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
if (raw.trimStart().startsWith(":")) continue;
const dataLines = raw
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (dataLines.length === 0) continue;
let parsed: unknown = null;
try {
parsed = JSON.parse(dataLines.join("\n"));
} catch {
continue;
}
if (!isPlainObject(parsed)) continue;
await onEvent(parsed as HermesRunEvent);
}
}
};
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from "vitest";
import { runAiAgent } from "./runAgent";
import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat";
describe("runAiAgent", () => {
it("按顺序执行 docs_search 和 docs_read 工具链", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const chatCalls: OpenAiCompatibleChatMessage[][] = [];
const runTool = vi.fn(async (toolId: string, toolArgs: Record<string, unknown>) => {
if (toolId === "docs_search") {
expect(toolArgs).toEqual({
query: "Rust runtime 收口",
limit: 5,
});
return {
query: "Rust runtime 收口",
results: [
{
id: "page_1",
title: "Rust 文档",
snippet: "这里记录 rust runtime 收口",
},
],
source: "convex",
};
}
if (toolId === "docs_read") {
expect(toolArgs).toEqual({
documentId: "page_1",
maxChars: 200,
});
return {
documentId: "page_1",
title: "Rust 文档",
rawText: "这里记录 rust runtime 收口",
rawTextLength: 24,
source: "convex",
};
}
throw new Error(`未知工具: ${toolId}`);
});
let chatStep = 0;
const chat = vi.fn(async (messages: OpenAiCompatibleChatMessage[]) => {
chatCalls.push(messages);
chatStep += 1;
if (chatStep === 1) {
return {
text: '<docs_search>{"query":"Rust runtime 收口","limit":5}</docs_search>',
raw: null,
};
}
if (chatStep === 2) {
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_search">');
expect(messages.at(-1)?.content).toContain('"id":"page_1"');
return {
text: '<docs_read>{"documentId":"page_1","maxChars":200}</docs_read>',
raw: null,
};
}
expect(messages.at(-1)?.content).toContain('<tool_result tool="docs_read">');
expect(messages.at(-1)?.content).toContain("rust runtime 收口");
return {
text: "已找到目标文档并读取原文。",
raw: null,
};
});
const result = await runAiAgent({
userMessages: [{ role: "user", content: "请帮我查找 Rust runtime 收口的相关文档" }],
cfg: {
baseUrl: "http://127.0.0.1:11434/v1",
apiKey: "",
model: "test-model",
},
chat,
allowedToolIds: new Set(["docs_search", "docs_read"]),
runTool,
maxSteps: 4,
onEvent: (event) => events.push(event),
});
expect(result).toEqual({
ok: true,
text: "已找到目标文档并读取原文。",
steps: 3,
});
expect(chat).toHaveBeenCalledTimes(3);
expect(runTool).toHaveBeenCalledTimes(2);
expect(runTool).toHaveBeenNthCalledWith(1, "docs_search", {
query: "Rust runtime 收口",
limit: 5,
});
expect(runTool).toHaveBeenNthCalledWith(2, "docs_read", {
documentId: "page_1",
maxChars: 200,
});
expect(chatCalls[0]?.[0]?.role).toBe("system");
expect(chatCalls[0]?.[1]?.content).toBe("请帮我查找 Rust runtime 收口的相关文档");
expect(events).toEqual([
{
type: "tool_call",
data: {
id: expect.any(String),
tool: "docs_search",
args: {
query: "Rust runtime 收口",
limit: 5,
},
},
},
{
type: "tool_result",
data: {
id: expect.any(String),
tool: "docs_search",
ok: true,
ms: expect.any(Number),
result: {
query: "Rust runtime 收口",
results: [
{
id: "page_1",
title: "Rust 文档",
snippet: "这里记录 rust runtime 收口",
},
],
source: "convex",
},
},
},
{
type: "tool_call",
data: {
id: expect.any(String),
tool: "docs_read",
args: {
documentId: "page_1",
maxChars: 200,
},
},
},
{
type: "tool_result",
data: {
id: expect.any(String),
tool: "docs_read",
ok: true,
ms: expect.any(Number),
result: {
documentId: "page_1",
title: "Rust 文档",
rawText: "这里记录 rust runtime 收口",
rawTextLength: 24,
source: "convex",
},
},
},
{
type: "assistant_message",
data: {
text: "已找到目标文档并读取原文。",
},
},
]);
});
});
@@ -1,3 +1,4 @@
// 遗留兼容:runAiAgent 已退出 /api/ai-agent/run 主链,保留此文件仅用于历史测试与渐进清理。
import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@/lib/ai/openaiCompatibleChat";
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
@@ -48,7 +48,8 @@ export const createDocsServerTools = (args: {
supabase?: DocsSupabaseClient;
ctx: DocsToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase
// 说明:该文件现在主要服务于非 Convex 模式或兼容兜底;Convex 主链下 docs_* 已改由 Rust runtime 产出结果
// 说明:如果提供该能力,则走本地兼容 transport,不依赖 Supabase。
searchDocs?: (args: {
userId: string;
query: string;
@@ -446,10 +446,10 @@ export const createMindmapServerTools = (args: {
invocationKind: "command",
toolArgs: withMindmapIds({ ops: normalized, reason }),
data: base,
target: mindmapTarget(doc),
target: mindmapTarget(loaded.doc),
reason,
});
await persistResultData(doc, result);
await persistResultData(loaded.doc, result);
return result;
}
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
@@ -460,7 +460,7 @@ export const createMindmapServerTools = (args: {
opCount: normalized.length,
});
}
await persistMindmap(doc, nextData);
await persistMindmap(loaded.doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -1,5 +1,7 @@
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
type SupabaseRouteClient = {
from: (table: string) => any;
@@ -229,6 +231,21 @@ export const createOnlyOfficeServerTools = (args: {
ctx: OnlyOfficeToolContext;
allowedToolIds: Set<string>;
}) => {
const buildRustContext = () =>
buildDocumentBridgeContextWithActor({
request: new Request("http://localhost"),
actor: {
actorType: "service",
actorId: "onlyoffice-asset-to-mindmap",
sessionId: null,
},
workspaceId: null,
source: {
channel: "onlyoffice-asset-to-mindmap",
client: "wolai-frontend",
},
});
const asset_extract_outline = async (toolArgs: Record<string, unknown>) => {
const assetId = String(toolArgs.assetId ?? "").trim();
const attachmentRef = String(toolArgs.attachmentRef ?? "").trim();
@@ -308,11 +325,13 @@ export const createOnlyOfficeServerTools = (args: {
};
const asset_to_mindmap = async (toolArgs: Record<string, unknown>) => {
// 说明:附件大纲提取仍在 TS/MinerU 侧,真正的导图写入由 Rust mindmap_apply_ops 负责。
const mindmapId = String(toolArgs.mindmapId ?? "").trim();
if (!mindmapId) throw new Error("缺少 mindmapId");
const parentUidArg = String(toolArgs.parentUid ?? "").trim();
const maxItemsRaw = Number(toolArgs.maxItems ?? 120);
const maxItems = Number.isFinite(maxItemsRaw) ? Math.max(10, Math.min(600, Math.floor(maxItemsRaw))) : 120;
const reason = String(toolArgs.reason ?? "").trim() || null;
const documentId = String(args.ctx.documentId ?? "").trim();
if (!documentId) throw new Error("缺少 documentId 上下文(OnlyOffice 工具需要落盘到指定文档)");
@@ -356,8 +375,39 @@ export const createOnlyOfficeServerTools = (args: {
if (!findNodeByUid(base, parentUid)) throw new Error("未找到 parentUid 对应节点");
const ops = buildOutlineOps({ parentUid, items, attachment });
const { data: nextData, applied, errors } = applyMindmapOps(base, ops);
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图");
const rustContext = buildRustContext();
const rustResult = await executeRustBridgeTool<{
ok: boolean;
applied?: number;
errors?: string[];
data?: unknown;
meta?: { reason?: string | null } | null;
}>({
context: rustContext,
toolName: "mindmap_apply_ops",
invocationKind: "command",
args: {
ops,
reason,
},
data: base,
target: {
pageId: documentId,
workspaceId: null,
blockId: mindmapId,
},
reason,
});
const resultData = rustResult.result && typeof rustResult.result === "object" ? (rustResult.result as Record<string, unknown>).data : null;
const nextData = resultData && typeof resultData === "object" && !Array.isArray(resultData) ? (resultData as MindmapTreeNode) : base;
await writeMindmapLocal(documentId, mindmapId, nextData, "OnlyOffice 生成导图(Rust");
const applied = Number((rustResult.result as Record<string, unknown> | undefined)?.applied ?? ops.length) || 0;
const errors = Array.isArray((rustResult.result as Record<string, unknown> | undefined)?.errors)
? ((rustResult.result as Record<string, unknown>).errors as string[])
: [];
return {
ok: true,
@@ -371,6 +421,8 @@ export const createOnlyOfficeServerTools = (args: {
fileName: attachment.title,
items: items.length,
strategy: String(outlineResult.strategy ?? ""),
rustOwner: "mindmap_apply_ops",
rustReason: reason,
},
};
};
@@ -1,3 +1,4 @@
// 遗留兼容:builtin 声明仍保留给历史兼容与文档对照,前端主链不再从这里继续扩展 Hermes 前置编排。
import type { AiAgentTool, AiAgentToolSet } from "../types";
export const builtinTools: AiAgentTool[] = [
@@ -409,6 +410,18 @@ export type BuiltinRustCutoverBinding = {
* 完整的一一对应矩阵见 `design/ai-tool-cutover-matrix.md`。
*/
export const builtinRustCutoverBindings: Record<string, BuiltinRustCutoverBinding> = {
docs_search: {
rustToolsetId: "toolset.docs_read",
rustToolName: "docs_search",
status: "rust",
note: "跨页文档搜索已切到 Rust runtime,TS 仅负责拉取搜索数据集 transport。",
},
docs_read: {
rustToolsetId: "toolset.docs_read",
rustToolName: "docs_read",
status: "rust",
note: "跨页文档读取结果已由 Rust runtime 统一裁剪与归一化,TS 仅负责读取目标文档 transport。",
},
search_web: {
rustToolsetId: "toolset.readonly",
rustToolName: "search_web",
@@ -1,3 +1,4 @@
// 遗留兼容:当前 AI 主链已转到 Hermes bridge,这里的 registry 仅保留给历史测试/兼容调用,不再作为前端主编排入口。
import type { AiAgentTool, AiAgentToolSet, ToolPermissions } from "./types";
export type ToolRegistry = {
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
@@ -18,7 +17,7 @@ import {
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { extractBlocksFromContent } from "@/lib/document-content";
type BlockLike = {
id: string;
@@ -91,20 +90,6 @@ function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unk
return { ok: true, nextBlocks: nextTop };
}
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
return {
id: randomUUID(),
type: "blockReference",
props: {
sourceDocumentId,
targetBlockId: blockId,
display: "embed",
},
content: [],
children: [],
};
}
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
return await buildDocumentBridgeContext({ request, workspaceId });
}
@@ -183,10 +168,6 @@ export async function executeBlockPatchBridgeCommand(input: {
client,
plan,
});
await client.mutation(api.documents.updateContent, {
id: input.sourceDocumentId,
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
});
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -220,9 +201,6 @@ export async function executeBlockMoveBridgeCommand(input: {
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
if (!removedRes.removed) throw new Error("源块不存在或无权限");
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.move",
@@ -240,8 +218,6 @@ export async function executeBlockMoveBridgeCommand(input: {
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -276,18 +252,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
const hit = findBlockInTree(sourceBlocks, input.blockId);
if (!hit) throw new Error("源块不存在或无权限");
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
? extractBlocksFromContent(target.content).findIndex((block) => String((block as BlockLike).id ?? "") === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
const nextTargetBlocks = [
...targetBlocks.slice(0, insertIndex),
buildReferenceBlock(input.sourceDocumentId, input.blockId),
...targetBlocks.slice(insertIndex),
];
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
void targetBlocks;
void anchorIndex;
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.embed",
@@ -305,10 +277,6 @@ export async function executeBlockEmbedBridgeCommand(input: {
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await client.mutation(api.documents.updateContent, {
id: input.targetDocumentId,
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
});
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
+100 -17
View File
@@ -12,16 +12,74 @@ type DocumentMetaResponse<T> = {
meta: BridgeMeta;
};
function getServerRequestOrigin(headerList: Headers): string {
const forwardedProto = headerList.get("x-forwarded-proto")?.split(",")[0]?.trim();
const forwardedHost = headerList.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = forwardedHost || headerList.get("host");
function getHeaderFirstValue(value: string | null): string {
return String(value || "")
.split(",")[0]
.trim();
}
function parseHostParts(host: string): { hostname: string; port: string } {
try {
const parsed = new URL(`http://${host}`);
return {
hostname: parsed.hostname,
port: parsed.port,
};
} catch {
return {
hostname: host,
port: "",
};
}
}
function isLocalHostname(hostname: string): boolean {
const normalized = hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "0.0.0.0" ||
normalized === "::"
);
}
function buildServerRequestOriginCandidates(headerList: Headers): string[] {
const forwardedProto = getHeaderFirstValue(headerList.get("x-forwarded-proto"));
const forwardedHost = getHeaderFirstValue(headerList.get("x-forwarded-host"));
const host = forwardedHost || getHeaderFirstValue(headerList.get("host"));
if (!host) {
throw new Error("缺少 host 头,无法构造 bridge 请求地址");
}
return `${forwardedProto || "http"}://${host}`;
const { hostname, port } = parseHostParts(host);
const candidates = new Set<string>();
const addCandidate = (proto: string, candidateHost: string) => {
const normalizedProto = String(proto || "http").trim().toLowerCase() || "http";
const normalizedHost = String(candidateHost || "").trim();
if (!normalizedHost) return;
candidates.add(`${normalizedProto}://${normalizedHost}`);
};
// 说明:优先保留浏览器实际请求的 origin,用于正常的同源自调用。
addCandidate(forwardedProto || "http", host);
// 说明:某些反代会把 x-forwarded-proto 设成 https,但本机 dev server 实际只监听 http。
// 这里补一个 http 候选,避免 SSR 自请求被错误协议直接打挂。
if ((forwardedProto || "").trim().toLowerCase() === "https") {
addCandidate("http", host);
}
// 说明:localhost / 0.0.0.0 / ::1 这类本机地址在 Node 侧自调用时最容易踩解析差异,
// 统一补 127.0.0.1 与 localhost 两个稳定候选,避免 fetch 因回环地址选择失败。
if (isLocalHostname(hostname)) {
const portSuffix = port ? `:${port}` : "";
addCandidate("http", `127.0.0.1${portSuffix}`);
addCandidate("http", `localhost${portSuffix}`);
}
return [...candidates];
}
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
@@ -31,19 +89,21 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
}
}
async function fetchDocumentMetaResponse(url: URL, headersToSend: Headers): Promise<Response> {
return await fetch(url, {
method: "GET",
headers: headersToSend,
cache: "no-store",
});
}
export async function fetchDocumentMetaViaBridge<T>(input: {
documentId: string;
workspaceId?: string | null;
}): Promise<DocumentMetaResponse<T> | null> {
const headerList = await headers();
const requestHeaders = new Headers();
const origin = getServerRequestOrigin(headerList);
const url = new URL("/api/documents/meta", origin);
url.searchParams.set("documentId", input.documentId);
if (input.workspaceId?.trim()) {
url.searchParams.set("workspaceId", input.workspaceId.trim());
}
const originCandidates = buildServerRequestOriginCandidates(headerList);
copyHeaderIfPresent(requestHeaders, headerList, "cookie");
copyHeaderIfPresent(requestHeaders, headerList, "authorization");
@@ -54,11 +114,34 @@ export async function fetchDocumentMetaViaBridge<T>(input: {
copyHeaderIfPresent(requestHeaders, headerList, "x-source-client");
copyHeaderIfPresent(requestHeaders, headerList, "user-agent");
const response = await fetch(url, {
method: "GET",
headers: requestHeaders,
cache: "no-store",
});
let response: Response | null = null;
let lastError: unknown = null;
const errorMessages: string[] = [];
for (const origin of originCandidates) {
const url = new URL("/api/documents/meta", origin);
url.searchParams.set("documentId", input.documentId);
if (input.workspaceId?.trim()) {
url.searchParams.set("workspaceId", input.workspaceId.trim());
}
try {
response = await fetchDocumentMetaResponse(url, requestHeaders);
break;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
errorMessages.push(`${url.toString()} => ${message}`);
}
}
if (!response) {
const message =
errorMessages.length > 0
? `bridge 自请求失败:${errorMessages.join(" | ")}`
: "bridge 自请求失败:未生成可用的请求地址";
throw new Error(message, { cause: lastError instanceof Error ? lastError : undefined });
}
if (response.status === 404) {
return null;
@@ -123,6 +123,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.restore": "documents:restore",
"documents.duplicate": "documents:duplicateWithMindmaps",
"documents.copy_tree": "documents:copyTree",
"documents.template": "documents:setTemplate",
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
"documents.purge": "documents:purge",
"blocks.patch": "documents:updateContent",
"blocks.move": "documents:updateContent",
"blocks.embed": "documents:updateContent",
@@ -130,6 +133,10 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"mindmaps.delete": "mindmaps:softDelete",
"mindmaps.restore": "mindmaps:restore",
"mindmaps.purge": "mindmaps:purge",
"mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace",
"media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload",
} as const satisfies Record<string, string>;
@@ -20,6 +20,8 @@ import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type DocumentCreatePayload = {
@@ -64,6 +66,19 @@ export type DocumentCopyTreePayload = {
}>;
};
export type DocumentTemplatePayload = {
documentId: string;
isTemplate: boolean;
};
export type DocumentEmptyTrashPayload = {
workspaceId: string;
};
export type DocumentPurgePayload = {
documentId: string;
};
export type PageCommandExecutionResult<TResult> = {
requestId: string;
traceId: string;
@@ -103,52 +118,37 @@ async function buildRuntimeContext(request: Request, workspaceId: string | null)
});
}
async function recordLifecycleArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client: ConvexHttpClient;
}) {
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client: input.client,
});
}
async function recordLifecycleFailureArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client: ConvexHttpClient;
error: unknown;
}) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client: input.client,
error: input.error,
});
}
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
client?: ConvexHttpClient;
}): Promise<PageCommandExecutionResult<TResult>> {
const client = input.client ?? (await getAuthedConvexClient()).client;
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const result = await executeRustBridgeMutationTransport<TResult>({
client,
plan,
});
let result;
try {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
result = await executeRustBridgeMutationTransport<TResult>({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
throw error;
}
return {
requestId: input.context.requestId,
@@ -200,7 +200,7 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
const pageId = safeRandomId();
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.createChild",
name: "documents.create",
payload: {
documentId: pageId,
workspaceId,
@@ -216,40 +216,23 @@ export async function executeDocumentCreateChildBridgeCommand(request: Request):
},
});
let created;
try {
created = await client.mutation(api.documents.create, {
id: pageId,
workspaceId,
parentId,
title: resolvedTitle,
accessScope,
content: contentPayload,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const created = await executePageLifecycleBridgeCommand<DocumentCreatePayload, {
id: string;
title?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
pageId: created.id,
title: created.title ?? resolvedTitle,
pageId: created.result.id,
title: created.result.title ?? resolvedTitle,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: created.requestId,
traceId: created.traceId,
commandId: created.commandId,
commandName: created.commandName,
},
});
} catch (error) {
@@ -304,12 +287,23 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const context = await buildRuntimeContext(request, workspaceId);
const savePayload = buildDocumentSavePayload({
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: payload,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.embed",
payload: {
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
},
name: "documents.save",
payload: savePayload,
context,
target: {
workspaceId,
@@ -317,34 +311,18 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
},
});
try {
await client.mutation(api.documents.updateContent, {
id: normalizedTargetId,
content: payload,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executeSaveBridgeCommand({
context,
envelope,
});
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -384,34 +362,22 @@ export async function executeDocumentTemplateBridgeCommand(request: Request): Pr
},
});
try {
await client.mutation(api.documents.setTemplate, {
id: normalizedDocumentId,
isTemplate: payload.isTemplate,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executePageLifecycleBridgeCommand<DocumentTemplatePayload, {
ok?: boolean;
updated_at?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -442,31 +408,23 @@ export async function executeDocumentEmptyTrashBridgeCommand(request: Request):
},
});
try {
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executePageLifecycleBridgeCommand<DocumentEmptyTrashPayload, {
ok?: boolean;
deletedCount?: number;
}>({
context,
envelope,
client,
});
return NextResponse.json({
success: true,
removed: typeof result.result?.deletedCount === "number" ? result.result.deletedCount : 0,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -501,31 +459,24 @@ export async function executeDocumentPurgeBridgeCommand(request: Request): Promi
},
});
try {
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
const result = await executePageLifecycleBridgeCommand<DocumentPurgePayload, {
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
success: true,
purged: result.result?.purged ?? true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
@@ -606,6 +606,19 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
newId: assertStringArg(input.plan.argsJson, "newId"),
title: readOptionalStringArg(input.plan.argsJson, "title"),
});
case "documents:setTemplate":
return mutation(api.documents.setTemplate, {
id: assertStringArg(input.plan.argsJson, "id"),
isTemplate: Boolean(input.plan.argsJson.isTemplate),
});
case "documents:emptyTrashByWorkspace":
return mutation(api.documents.emptyTrashByWorkspace, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
});
case "documents:purge":
return mutation(api.documents.purge, {
id: assertStringArg(input.plan.argsJson, "id"),
});
case "documents:updateTitle":
return mutation(api.documents.updateTitle, {
id: assertStringArg(input.plan.argsJson, "id"),
@@ -628,6 +641,25 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
? input.plan.argsJson.createOnly
: undefined,
});
case "mindmaps:softDelete":
return mutation(api.mindmaps.softDelete, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:restore":
return mutation(api.mindmaps.restore, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:purge":
return mutation(api.mindmaps.purge, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "mindmaps:emptyTrashByWorkspace":
return mutation(api.mindmaps.emptyTrashByWorkspace, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
});
default:
throw new DocumentBridgeError(
`未注册的 Rust mutation transport: ${input.plan.functionName}`,