收口 Rust Web 入口与 AI 写入链

- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。

- 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。

- 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。

- 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
lix-2026
2026-05-06 21:44:20 +08:00
parent 98b6360595
commit e8ba12e461
86 changed files with 8872 additions and 1716 deletions
+2 -2
View File
@@ -46,7 +46,7 @@
"convex": "^1.35.1",
"jszip": "3.10.1",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"next": "16.2.4",
"pdfjs-dist": "^4.10.38",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -69,7 +69,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"eslint-config-next": "16.2.4",
"jose": "^6.1.3",
"jsdom": "^27.2.0",
"tailwindcss": "^4",
+76 -76
View File
@@ -16,10 +16,10 @@ importers:
version: 0.42.0(@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)
'@blocknote/mantine':
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))(@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))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@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)
version: 0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@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))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@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)
'@blocknote/react':
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)
version: 0.42.0(@floating-ui/dom@1.7.6)(@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.91
version: 0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)
@@ -114,8 +114,8 @@ importers:
specifier: ^0.554.0
version: 0.554.0(react@19.2.0)
next:
specifier: 16.0.3
version: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
specifier: 16.2.4
version: 16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pdfjs-dist:
specifier: ^4.10.38
version: 4.10.38
@@ -161,7 +161,7 @@ importers:
devDependencies:
'@cloudflare/next-on-pages':
specifier: ^1.13.16
version: 1.13.16(next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)
version: 1.13.16(next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.17
@@ -178,8 +178,8 @@ importers:
specifier: ^9
version: 9.39.1(jiti@2.6.1)
eslint-config-next:
specifier: 16.0.3
version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
specifier: 16.2.4
version: 16.2.4(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
jose:
specifier: ^6.1.3
version: 6.1.3
@@ -487,9 +487,6 @@ packages:
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
@@ -1424,56 +1421,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.3':
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
'@next/env@16.2.4':
resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
'@next/eslint-plugin-next@16.0.3':
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
'@next/eslint-plugin-next@16.2.4':
resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==}
'@next/swc-darwin-arm64@16.0.3':
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
'@next/swc-darwin-arm64@16.2.4':
resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.0.3':
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
'@next/swc-darwin-x64@16.2.4':
resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.3':
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
'@next/swc-linux-arm64-gnu@16.2.4':
resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.0.3':
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
'@next/swc-linux-arm64-musl@16.2.4':
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.0.3':
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
'@next/swc-linux-x64-gnu@16.2.4':
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.0.3':
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
'@next/swc-linux-x64-musl@16.2.4':
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.0.3':
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
'@next/swc-win32-arm64-msvc@16.2.4':
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.0.3':
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
'@next/swc-win32-x64-msvc@16.2.4':
resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2758,6 +2755,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.8.29:
resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==}
hasBin: true
@@ -3292,8 +3294,8 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@16.0.3:
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
eslint-config-next@16.2.4:
resolution: {integrity: sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -4326,8 +4328,8 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next@16.0.3:
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
next@16.2.4:
resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5794,10 +5796,10 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/mantine@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))(@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))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@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)':
'@blocknote/mantine@0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@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))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@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)':
dependencies:
'@blocknote/core': 0.42.0(@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)
'@blocknote/react': 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)
'@blocknote/react': 0.42.0(@floating-ui/dom@1.7.6)(@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)
'@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)
'@mantine/hooks': 8.3.8(react@19.2.0)
'@mantine/utils': 6.0.22(react@19.2.0)
@@ -5817,14 +5819,14 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@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)':
'@blocknote/react@0.42.0(@floating-ui/dom@1.7.6)(@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)':
dependencies:
'@blocknote/core': 0.42.0(@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)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
'@tiptap/react': 3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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)
'@tiptap/react': 3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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)
emoji-mart: 5.6.0
lodash.merge: 4.6.2
react: 19.2.0
@@ -5847,7 +5849,7 @@ snapshots:
dependencies:
mime: 3.0.0
'@cloudflare/next-on-pages@1.13.16(next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)':
'@cloudflare/next-on-pages@1.13.16(next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)':
dependencies:
acorn: 8.15.0
ast-types: 0.14.2
@@ -5858,7 +5860,7 @@ snapshots:
esbuild: 0.15.18
js-yaml: 4.1.1
miniflare: 3.20250718.3
next: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
package-manager-manager: 0.2.0
pcre-to-regexp: 1.1.0
semver: 7.7.3
@@ -5977,11 +5979,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
@@ -6513,7 +6510,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.9.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -6630,38 +6627,38 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.9.2
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.0.3': {}
'@next/env@16.2.4': {}
'@next/eslint-plugin-next@16.0.3':
'@next/eslint-plugin-next@16.2.4':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.3':
'@next/swc-darwin-arm64@16.2.4':
optional: true
'@next/swc-darwin-x64@16.0.3':
'@next/swc-darwin-x64@16.2.4':
optional: true
'@next/swc-linux-arm64-gnu@16.0.3':
'@next/swc-linux-arm64-gnu@16.2.4':
optional: true
'@next/swc-linux-arm64-musl@16.0.3':
'@next/swc-linux-arm64-musl@16.2.4':
optional: true
'@next/swc-linux-x64-gnu@16.0.3':
'@next/swc-linux-x64-gnu@16.2.4':
optional: true
'@next/swc-linux-x64-musl@16.0.3':
'@next/swc-linux-x64-musl@16.2.4':
optional: true
'@next/swc-win32-arm64-msvc@16.0.3':
'@next/swc-win32-arm64-msvc@16.2.4':
optional: true
'@next/swc-win32-x64-msvc@16.0.3':
'@next/swc-win32-x64-msvc@16.2.4':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -7375,9 +7372,9 @@ snapshots:
dependencies:
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu@3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)':
'@tiptap/extension-floating-menu@3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)':
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
optional: true
@@ -7447,7 +7444,7 @@ snapshots:
prosemirror-transform: 1.10.5
prosemirror-view: 1.41.3
'@tiptap/react@3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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)':
'@tiptap/react@3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@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)':
dependencies:
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
@@ -7460,7 +7457,7 @@ snapshots:
use-sync-external-store: 1.6.0(react@19.2.0)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu': 3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu': 3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
transitivePeerDependencies:
- '@floating-ui/dom'
@@ -7627,7 +7624,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.3
semver: 7.7.4
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -7913,6 +7910,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.8.29: {}
bidi-js@1.0.3:
@@ -8515,9 +8514,9 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-next@16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
eslint-config-next@16.2.4(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.0.3
'@next/eslint-plugin-next': 16.2.4
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
@@ -9121,7 +9120,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.7.4
is-callable@1.2.7: {}
@@ -9970,24 +9969,25 @@ snapshots:
natural-compare@1.4.0: {}
next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.3
'@next/env': 16.2.4
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001755
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.3
'@next/swc-darwin-x64': 16.0.3
'@next/swc-linux-arm64-gnu': 16.0.3
'@next/swc-linux-arm64-musl': 16.0.3
'@next/swc-linux-x64-gnu': 16.0.3
'@next/swc-linux-x64-musl': 16.0.3
'@next/swc-win32-arm64-msvc': 16.0.3
'@next/swc-win32-x64-msvc': 16.0.3
'@next/swc-darwin-arm64': 16.2.4
'@next/swc-darwin-x64': 16.2.4
'@next/swc-linux-arm64-gnu': 16.2.4
'@next/swc-linux-arm64-musl': 16.2.4
'@next/swc-linux-x64-gnu': 16.2.4
'@next/swc-linux-x64-musl': 16.2.4
'@next/swc-win32-arm64-msvc': 16.2.4
'@next/swc-win32-x64-msvc': 16.2.4
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -10648,7 +10648,7 @@ snapshots:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
+15
View File
@@ -15,6 +15,21 @@
const http = require("http");
const net = require("net");
const path = require("path");
// 说明:Next 当前 vendored 的 browserslist/baseline 提示会在进程启动早期输出,
// 即使项目侧依赖已经升级也未必消失。这里默认静音这类“当前不可操作”的噪音;
// 如需排查真实数据新鲜度,可在启动前显式传入 `false` 恢复原始警告。
if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === "false") {
delete process.env.BROWSERSLIST_IGNORE_OLD_DATA;
} else if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === undefined) {
process.env.BROWSERSLIST_IGNORE_OLD_DATA = "true";
}
if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === "false") {
delete process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA;
} else if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === undefined) {
process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA = "true";
}
const next = require("next");
const { parse: parseUrl } = require("url");
const { buildTreeShellRuntime } = require("./build-tree-shell-runtime");
+20 -5
View File
@@ -13,11 +13,26 @@
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const http = require("http");
const net = require("net");
const path = require("path");
// 说明:Next 当前 vendored 的 browserslist/baseline 提示会在进程启动早期输出,
// 即使项目侧依赖已经升级也未必消失。这里默认静音这类“当前不可操作”的噪音;
// 如需排查真实数据新鲜度,可在启动前显式传入 `false` 恢复原始警告。
if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === "false") {
delete process.env.BROWSERSLIST_IGNORE_OLD_DATA;
} else if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === undefined) {
process.env.BROWSERSLIST_IGNORE_OLD_DATA = "true";
}
if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === "false") {
delete process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA;
} else if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === undefined) {
process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA = "true";
}
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
+49 -38
View File
@@ -5,53 +5,64 @@
* 运行:node scripts/register-test-user.js
*/
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
name: "测试用户",
};
const TEST_CREDENTIALS = {
email: "mnote.e2e@example.com",
password: "MnoteE2E123!",
name: "mnote-e2e",
};
async function registerTestUser() {
const baseUrl = "http://localhost:3000";
console.log("正在注册测试账号...");
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
}),
});
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
},
},
}),
});
const result = await response.json();
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else if (response.status === 501) {
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
} else {
console.error("✗ 注册失败:", result.error || result.message);
}
} catch (error) {
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else {
const message = result?.error || result?.message || "";
if (/already exists|已存在|已被使用|已被占用/i.test(message)) {
console.log("ℹ 测试账号已存在,可直接使用:");
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
return;
}
console.log("ℹ 自动注册失败,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"测试账号快速登录"或"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
}
} catch (error) {
console.error("✗ 请求失败:", error.message);
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
}
+4 -4
View File
@@ -11,10 +11,10 @@ import { getUserFacingErrorMessage } from "@/lib/auth/errors";
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
const TEST_CREDENTIALS = {
email: "mnote.e2e@example.com",
password: "MnoteE2E123!",
} as const;
/**
* Convex Auth 登录/注册页面
@@ -4,11 +4,23 @@ const mockSafeGetJsonBody = vi.fn();
const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
const mockStartMnoteCliAgentHostRun = vi.fn();
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
vi.mock("next/server", () => ({
NextResponse: class NextResponse extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
}
},
}));
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -27,21 +39,16 @@ vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
vi.mock("@/lib/auth/authContext", () => ({
requireAuthContext: mockRequireAuthContext,
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: mockGetConvexAuthedHttpClient,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -50,241 +57,23 @@ describe("/api/ai-agent/run route", () => {
mockValidateRequestBody.mockReset();
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
mockStartMnoteCliAgentHostRun.mockReset();
mockRequireAuthContext.mockReset();
mockGetConvexAuthedHttpClient.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
it("文档页在线请求应只进入 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "tool.started",
tool: "slash_run",
preview: '{"text":"/rename doc-1 AI 标题"}',
});
await onEvent({
event: "tool.completed",
tool: "slash_run",
duration: 0.12,
error: false,
});
await onEvent({
event: "run.completed",
output: "已完成",
});
});
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
expect(text).toContain('"tool":"slash_run"');
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
@@ -301,73 +90,19 @@ describe("/api/ai-agent/run route", () => {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
mockRequireAuthContext.mockResolvedValue({
userId: "user-1",
});
mockGetConvexAuthedHttpClient.mockResolvedValue({});
mockStartMnoteCliAgentHostRun.mockResolvedValue(
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"x-mnote-ai-execution-owner": "mnote-cli",
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
@@ -377,8 +112,112 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
scope: "document",
context: expect.objectContaining({
documentId: "doc-1",
pageOptions: expect.objectContaining({
wideLayout: true,
}),
}),
options: expect.objectContaining({
ai: expect.objectContaining({
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
expect(text).toContain("mnote-cli");
});
it.each(["codex", "hermes", "local", "ollama"] as const)(
"provider=%s 也必须统一进入 mnote-cli host",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "处理当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider,
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockRequireAuthContext.mockResolvedValue({
userId: "user-1",
});
mockGetConvexAuthedHttpClient.mockResolvedValue({});
mockStartMnoteCliAgentHostRun.mockResolvedValue(
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"x-mnote-ai-execution-owner": "mnote-cli",
},
}),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledTimes(1);
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
provider,
}),
}),
}),
}),
);
},
);
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
messages: [{ role: "user", content: "总结当前页面" }],
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "",
},
});
mockRequireAuthContext.mockRejectedValue(new Error("未登录,请先登录"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(401);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
});
});
+15 -767
View File
@@ -1,684 +1,15 @@
import { NextResponse } from "next/server";
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
import {
DEFAULT_AGENT_MAX_STEPS,
MAX_AGENT_STEPS,
MIN_AGENT_STEPS,
} from "@/lib/constants";
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
codexMessagesToPrompt,
findWorkspaceRoot,
startCodexJsonRun,
} from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge";
import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
startMnoteCliAgentHostRun,
type MnoteCliAgentRunPayload,
} from "@/lib/server/mnote-cli-agent-host";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
stream?: boolean;
maxSteps?: number;
scope?: AgentScope;
messages: AgentMessage[];
attachments?: Array<{
id: string;
title: string;
fileUrl: string;
mimeType?: string | null;
}>;
toolChoice?: {
mode: "auto" | "manual";
toolSets?: string[];
tools?: string[];
};
context?: {
documentId?: string;
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
};
options?: {
searxng?: boolean;
ai?: {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
type LegacyStreamEvent =
| { type: "assistant_message"; data: { text: string } }
| { type: "tool_call"; data: { id: string; tool: string; args: Record<string, unknown> } }
| { type: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } }
| { type: "completion"; data: { ok: true; text: string; steps: number } }
| { type: "error"; data: { ok: false; message: string } }
| { type: "codex_session"; data: { sessionId: string } };
const sseHeaders = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
} as const;
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const makeRunId = () => {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
};
const serializeContextSnapshot = (label: string, value: unknown, limit: number) => {
if (value === undefined) {
return null;
}
try {
const text = JSON.stringify(value);
return `${label}=${text.slice(0, limit)}`;
} catch {
return `${label}=provided`;
}
};
const clampSteps = (raw: unknown) => {
const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS);
if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS;
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
return raw;
}
return payload.context?.mindmapId ? "mindmap" : payload.context?.documentId ? "document" : "global";
};
const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => {
const value = String(text ?? "");
const match = value.match(/^\s*#(chat|test|dev)\b[\s:\-–—]*/i);
if (!match) return { mode: null, text: value };
const mode = String(match[1] ?? "").toLowerCase() as CodexMode;
return { mode, text: value.slice(match[0].length).trimStart() };
};
const extractCodexModeFromMessages = (messages: AgentMessage[]) => {
const lastUser = [...messages].reverse().find((item) => item.role === "user")?.content ?? "";
const picked = stripCodexModePrefix(lastUser);
const cleanedMessages = messages.map((item) => {
if (item.role !== "user") return item;
const cleaned = stripCodexModePrefix(item.content);
return { ...item, content: cleaned.text };
});
return {
mode: picked.mode ?? "chat",
cleanedMessages,
};
};
const buildHermesInstructions = (
payload: RequestPayload,
userId: string,
scope: AgentScope,
maxSteps: number,
) => {
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
const selectedUids = Array.isArray(payload.context?.selectedUids)
? payload.context?.selectedUids.map((item) => String(item)).filter(Boolean).slice(0, 12)
: [];
const lines: string[] = [
"你当前运行在 mnote Web 前端的 Hermes bridge 后面。请始终使用简体中文。",
"当前前端已经收口为轻桥接层,不要再假设存在旧的前端 builtin registry、toolset 静态映射或 create*ServerTools 编排逻辑。",
"除非工具结果明确表明已完成写入,否则不要声称已经修改页面、思维导图或 OnlyOffice 文档。",
"不要把本机终端、文件系统或其他 Hermes 默认工具当成 mnote 业务真执行面。mnote 业务写入应视为独立桥能力。",
`当前调用用户:${userId}`,
`当前 scope${scope}`,
`本轮最大步数提示:${maxSteps}`,
];
if (payload.context?.documentId) {
lines.push(`documentId=${String(payload.context.documentId).trim()}`);
}
if (payload.context?.mindmapId) {
lines.push(`mindmapId=${String(payload.context.mindmapId).trim()}`);
}
if (selectedUids.length > 0) {
lines.push(`selectedUids=${selectedUids.join(",")}`);
}
if (payload.context?.documentBlocks !== undefined) {
lines.push(serializeContextSnapshot("documentBlocksSnapshot", payload.context.documentBlocks, 4000) ?? "documentBlocksSnapshot=provided");
}
if (payload.context?.node !== undefined) {
lines.push(serializeContextSnapshot("kernelNode", payload.context.node, 1800) ?? "kernelNode=provided");
}
if (payload.context?.subtree !== undefined) {
lines.push(serializeContextSnapshot("kernelSubtree", payload.context.subtree, 5000) ?? "kernelSubtree=provided");
}
if (payload.context?.outline !== undefined) {
lines.push(serializeContextSnapshot("kernelOutline", payload.context.outline, 2500) ?? "kernelOutline=provided");
}
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
"attachments:",
...attachments.map(
(item, index) =>
`${index + 1}. id=${String(item.id)} title=${String(item.title)} mime=${String(item.mimeType ?? "")} url=${String(item.fileUrl)}`,
),
].join("\n"),
);
}
if (payload.toolChoice?.mode === "manual") {
const tools = Array.isArray(payload.toolChoice.tools) ? payload.toolChoice.tools.filter(Boolean) : [];
const toolSets = Array.isArray(payload.toolChoice.toolSets) ? payload.toolChoice.toolSets.filter(Boolean) : [];
if (tools.length > 0 || toolSets.length > 0) {
lines.push(
`前端兼容提示:manual toolChoice tools=[${tools.join(", ")}] toolSets=[${toolSets.join(", ")}]. 这些只是旧前端兼容字段,不代表当前 Hermes 一定具备同名工具。`,
);
}
}
if (scope === "onlyoffice") {
lines.push("OnlyOffice 浏览器专属 client capability 仍在单独桥接;若当前后端没有明确写入结果,请直接说明限制,不要伪造选区修改。\n");
}
return lines.join("\n\n").trim();
};
const buildHermesInput = (messages: AgentMessage[]) => {
return messages
.slice(-50)
.filter((item) => item.role === "user" || item.role === "assistant")
.map((item) => ({ role: item.role, content: String(item.content ?? "") }));
};
type PendingHermesToolCall = {
preview: string;
argsJson: Record<string, unknown> | null;
};
const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
fallbackTraceId: string;
}): Promise<unknown | null> => {
if (!input.argsJson) {
return null;
}
if (
input.tool !== "slash_run" &&
input.tool !== "doc_insert_blocks" &&
input.tool !== "doc_replace_range"
) {
return null;
}
const documentId = String(input.payload.context?.documentId ?? "").trim() || null;
const data =
input.tool === "slash_run"
? { source: "ai-agent-route" }
: input.payload.context?.documentBlocks ?? null;
if ((input.tool === "doc_insert_blocks" || input.tool === "doc_replace_range") && data == null) {
return null;
}
return buildDocumentBridgeContext({
request: input.request,
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent,
}: {
messages: AgentMessage[];
instructions: string;
sessionId: string | null;
request: Request;
payload: RequestPayload;
userId: string;
onEvent: (event: LegacyStreamEvent) => Promise<void> | void;
}) => {
const input = buildHermesInput(messages);
const { runId } = await startHermesRun({
input,
instructions,
...(sessionId ? { session_id: sessionId } : {}),
});
const pendingToolIds = new Map<string, string[]>();
const pendingToolCalls = new Map<string, PendingHermesToolCall>();
let toolCount = 0;
let assistantBuffer = "";
let failureMessage = "";
let completed = false;
await streamHermesRunEvents(runId, async (event: HermesRunEvent) => {
if (event.event === "tool.started") {
const tool = String(event.tool ?? "").trim() || "unknown_tool";
const id = `hermes_${runId}_${++toolCount}`;
const queue = pendingToolIds.get(tool) ?? [];
queue.push(id);
pendingToolIds.set(tool, queue);
const preview = typeof event.preview === "string" ? event.preview : "";
pendingToolCalls.set(id, {
preview,
argsJson: readHermesToolArgsFromEvent(event, tool),
});
await onEvent({
type: "tool_call",
data: {
id,
tool,
args: preview ? { preview } : {},
},
});
return;
}
if (event.event === "tool.completed") {
const tool = String(event.tool ?? "").trim() || "unknown_tool";
const queue = pendingToolIds.get(tool) ?? [];
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
pendingToolIds.set(tool, queue);
const pendingToolCall = pendingToolCalls.get(id) ?? null;
pendingToolCalls.delete(id);
const preview = pendingToolCall?.preview ?? "";
const duration = Number(event.duration ?? 0);
const structuredResultFromEvent = !Boolean(event.error) ? readHermesToolResultFromEvent(event) : null;
const recoveredResult =
structuredResultFromEvent ??
(await recoverStructuredHermesToolResult({
request,
payload,
userId,
tool,
argsJson: pendingToolCall?.argsJson ?? null,
fallbackRequestId: makeRunId(),
fallbackTraceId: makeRunId(),
}));
await onEvent({
type: "tool_result",
data: {
id,
tool,
ok: !Boolean(event.error),
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
result:
recoveredResult ??
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
},
});
return;
}
if (event.event === "message.delta") {
assistantBuffer += typeof event.delta === "string" ? event.delta : "";
return;
}
if (event.event === "run.failed") {
failureMessage = String(event.error ?? "Hermes run 失败");
await onEvent({ type: "error", data: { ok: false, message: failureMessage } });
completed = true;
return;
}
if (event.event === "run.completed") {
const fallbackOutput = typeof event.output === "string" ? event.output : "";
const finalText = (assistantBuffer || fallbackOutput || "(无输出)").trim();
if (finalText) {
await onEvent({ type: "assistant_message", data: { text: finalText } });
}
await onEvent({
type: "completion",
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
});
completed = true;
}
});
if (!completed && !failureMessage) {
const finalText = (assistantBuffer || "(无输出)").trim();
if (finalText) {
await onEvent({ type: "assistant_message", data: { text: finalText } });
}
await onEvent({
type: "completion",
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
});
}
};
const runCodexBridge = async ({
payload,
request,
stream,
}: {
payload: RequestPayload;
request: Request;
stream: boolean;
}) => {
const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50));
const workspaceRoot = await findWorkspaceRoot(process.cwd());
const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
const requestMode = mode;
const sys =
requestMode === "dev"
? "你当前处于 #dev 模式:可在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。"
: requestMode === "test"
? "你当前处于 #test 模式:只做分析与回答,不要执行命令,不要修改文件,不要伪造工具执行。请用简体中文输出。"
: "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。";
const buildPrompt = () => {
if (!sessionIdRaw) {
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
}
const lastUser = [...cleanedMessages].reverse().find((item) => item.role === "user")?.content ?? "";
const nextUserText = String(lastUser || "").trim();
if (!nextUserText) {
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
}
return codexMessagesToPrompt([
{ role: "system", content: sys },
{ role: "user", content: nextUserText },
]);
};
if (!stream) {
const run = startCodexJsonRun({
cwd: workspaceRoot,
sandbox: "workspace-write",
prompt: buildPrompt(),
model: null,
sessionId: sessionIdRaw || null,
});
const result = await run.done;
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 500 });
}
return NextResponse.json({
text: result.text,
steps: 1,
events: [],
sessionId: result.threadId || sessionIdRaw || null,
});
}
const encoder = new TextEncoder();
let killActiveRun: (() => void) | null = null;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
const requestId = makeRunId();
send("ready", { ok: true, requestId });
let sessionSent = false;
let assistantSent = false;
const toolStartAt = new Map<string, number>();
const stopRun = () => {
try {
killActiveRun?.();
} catch {
// ignore
}
};
const onAbort = () => {
stopRun();
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const run = startCodexJsonRun({
cwd: workspaceRoot,
sandbox: "workspace-write",
prompt: buildPrompt(),
model: null,
sessionId: sessionIdRaw || null,
onJsonLine: (line) => {
if (line.type === "thread.started") {
const sid = String((line as { thread_id?: string }).thread_id ?? "").trim();
if (sid && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: sid });
}
return;
}
if (line.type === "item.started" && (line as { item?: { type?: string; id?: string; command?: string } }).item?.type === "command_execution") {
const item = (line as { item?: { type?: string; id?: string; command?: string } }).item;
const id = String(item?.id ?? "").trim();
const command = String(item?.command ?? "");
if (!id) return;
toolStartAt.set(id, Date.now());
send("tool_call", { id, tool: "codex_command", args: { command } });
return;
}
if (line.type === "item.completed" && (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item?.type === "command_execution") {
const item = (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item;
const id = String(item?.id ?? "").trim();
if (!id) return;
const startedAt = toolStartAt.get(id) ?? Date.now();
const ms = Math.max(0, Date.now() - startedAt);
const exitCode = Number(item?.exit_code ?? 0);
send("tool_result", {
id,
tool: "codex_command",
ok: exitCode === 0,
ms,
result: {
exitCode,
output: String(item?.aggregated_output ?? ""),
},
});
return;
}
if (line.type === "item.completed" && (line as { item?: { type?: string; text?: string } }).item?.type === "agent_message") {
const item = (line as { item?: { type?: string; text?: string } }).item;
const text = String(item?.text ?? "").trim();
if (text) {
assistantSent = true;
send("assistant_message", { text });
}
}
},
});
killActiveRun = run.kill;
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error });
return;
}
if (result.threadId && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: result.threadId });
}
if (!assistantSent && result.text) {
assistantSent = true;
send("assistant_message", { text: result.text });
}
send("completion", { ok: true, text: result.text, steps: 1 });
})()
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
send("error", { ok: false, message });
})
.finally(() => {
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
stopRun();
controller.close();
});
},
cancel() {
try {
killActiveRun?.();
} catch {
// ignore
}
},
});
return new Response(body, { headers: sseHeaders });
};
export async function POST(request: Request) {
const payload = await safeGetJsonBody<RequestPayload>(request);
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) {
return errorResponses.badRequest("请求体不能为空");
}
@@ -689,106 +20,23 @@ export async function POST(request: Request) {
}
let userId = "";
let userEmail: string | undefined;
let userName: string | undefined;
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
userEmail = auth.email;
userName = auth.name;
}
if (!userId) {
return errorResponses.unauthorized();
}
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;
if (!stream) {
const events: LegacyStreamEvent[] = [];
await streamHermesLegacyEvents({
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
events.push(event);
},
}).catch((error) => {
throw error instanceof Error ? error : new Error(String(error));
});
const completion = [...events].reverse().find((event) => event.type === "completion") as Extract<LegacyStreamEvent, { type: "completion" }> | undefined;
const assistant = [...events].reverse().find((event) => event.type === "assistant_message") as Extract<LegacyStreamEvent, { type: "assistant_message" }> | undefined;
return NextResponse.json({
text: completion?.data.text ?? assistant?.data.text ?? "",
steps: completion?.data.steps ?? Math.max(1, events.filter((event) => event.type === "tool_call").length || 1),
events,
});
}
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
send("ready", { ok: true, requestId: makeRunId() });
const ping = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
} catch {
// ignore
}
}, 15_000);
(async () => {
await streamHermesLegacyEvents({
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
send(event.type, event.data ?? null);
},
});
})()
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
send("error", { ok: false, message });
})
.finally(() => {
clearInterval(ping);
controller.close();
});
},
return startMnoteCliAgentHostRun({
request,
userId,
userEmail,
userName,
payload,
});
return new Response(body, { headers: sseHeaders });
}
@@ -264,13 +264,13 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
describe("DocumentAiAgentPanel.runtime island contract", () => {
it("AI bridge runtime 固定为 Rust Web/Hermes owned island", () => {
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
expect(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT).toMatchObject({
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
bridgeOwner: "mnote-cli",
runtimeRole: "mnote_cli_host_client",
runEndpoint: "/api/ai-agent/run",
legacyCompatEndpoint: null,
});
});
});
@@ -29,10 +29,10 @@ const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
export const DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT = {
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
bridgeOwner: "mnote-cli",
runtimeRole: "mnote_cli_host_client",
runEndpoint: "/api/ai-agent/run",
legacyCompatEndpoint: null,
} as const;
const extractCodexMode = (text: string): CodexMode => {
@@ -149,10 +149,10 @@ export function buildTreeShellDomFiletreeSelection(activeDocumentId?: string | n
focusedRowId: null as string | null,
};
}
const documentRowId = `doc:${documentId}`;
const indexRowId = `index:${documentId}`;
return {
selectedRowIds: [documentRowId, `index:${documentId}`],
anchorRowId: documentRowId,
focusedRowId: documentRowId,
selectedRowIds: [indexRowId],
anchorRowId: indexRowId,
focusedRowId: indexRowId,
};
}
@@ -512,7 +512,8 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);");
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedRowId":"index:doc_a"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
@@ -849,10 +849,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [\`doc:\${currentActiveDocumentId}\`, \`index:\${currentActiveDocumentId}\`] : [],
: currentActiveDocumentId ? [\`index:\${currentActiveDocumentId}\`] : [],
);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
let visibleFileTreeRowIds = [];
const focusRowElement = (nodeId) => {
@@ -75,4 +75,33 @@ describe("page subtree response helpers", () => {
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
expect(normalized.pageSubtree).toBeNull();
});
it("兼容 mnote-web transport 返回的 result 包装", () => {
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
payload: {
result: {
content: [
{
id: "paragraph_1",
type: "paragraph",
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
},
],
revision: 4,
conflictDetectionKey: "doc_1:4",
},
},
});
expect(normalized.content).toEqual([
{
id: "paragraph_1",
type: "paragraph",
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
},
]);
expect(normalized.revision).toBe(4);
expect(normalized.conflictDetectionKey).toBe("doc_1:4");
});
});
@@ -10,6 +10,7 @@ export type DocumentContentResponseLike = {
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
result?: DocumentContentResponseLike | null;
};
export type NormalizedDocumentContentResponse = {
@@ -24,7 +25,8 @@ export function normalizeDocumentContentResponse(input: {
title?: string | null;
payload?: DocumentContentResponseLike | null;
}): NormalizedDocumentContentResponse {
const payload = input.payload ?? null;
const rawPayload = input.payload ?? null;
const payload = rawPayload?.result ?? rawPayload;
const content = payload?.content ?? null;
const revision =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
@@ -23,6 +23,27 @@ describe("tiptap-content-converter", () => {
]);
});
it("把 BlockNote inline text 数组转换成 TipTap 可编辑文本节点", () => {
expect(
tiptapDocFromBlocks([
{
id: "cli_visible_b_b2",
type: "paragraph",
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
},
] as never),
).toEqual({
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "cli_visible_b_b2" },
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
},
],
});
});
it("round-trips p0.5 block families through editor block document", () => {
const document = editorBlockDocumentFromContent([
{ id: "p1", type: "paragraph", content: "段落" },
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockBuildForwardHeaders } = vi.hoisted(() => ({
mockBuildForwardHeaders: vi.fn(async () => new Headers({ "x-forwarded-for": "127.0.0.1" })),
}));
vi.mock("@/lib/runtime-config", () => ({
getMnoteRuntimeConfig: () => ({ backendUrl: "http://backend.test" }),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: mockBuildForwardHeaders,
}));
import { startDocumentAiOrchestratorRun } from "./document-ai-orchestrator";
describe("startDocumentAiOrchestratorRun", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockBuildForwardHeaders.mockClear();
delete process.env.BACKEND_URL;
delete process.env.BACKEND_INTERNAL_URL;
delete process.env.MNOTE_AI_ORCHESTRATOR_API_KEY;
});
it("应把 leptos-tiptap 的 block 级 AI 上下文透传给 sidecar", async () => {
const fetchMock = vi.fn(async () =>
new Response('event: ready\ndata: {"ok":true}\n\n', {
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await startDocumentAiOrchestratorRun({
userId: "user-1",
payload: {
maxSteps: 8,
messages: [{ role: "user", content: "改写当前块" }],
context: {
source: "leptos-tiptap-island",
action: "ask_ai",
documentId: "doc-1",
workspaceId: "ws-1",
selectedBlockId: "block-1",
selectedUids: ["block-1"],
selectedText: "旧段落",
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
},
options: {
ai: {
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
sessionId: "doc_ai:doc-1:session-1",
},
},
},
});
expect(fetchMock).toHaveBeenCalledWith(
"http://backend.test/api/v1/ai-agent/document/run",
expect.objectContaining({ method: "POST" }),
);
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
expect(body.context).toMatchObject({
source: "leptos-tiptap-island",
action: "ask_ai",
documentId: "doc-1",
workspaceId: "ws-1",
selectedBlockId: "block-1",
selectedUids: ["block-1"],
selectedText: "旧段落",
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
});
});
});
@@ -10,7 +10,16 @@ type RequestPayload = {
maxSteps?: number;
messages: AgentMessage[];
context?: {
source?: string;
action?: string;
documentId?: string;
workspaceId?: string;
selectedBlockId?: string;
selectedBlockIndex?: number;
selectedUids?: string[];
selectedText?: string;
selection?: unknown;
tiptapDocument?: unknown;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
@@ -77,7 +86,16 @@ export async function startDocumentAiOrchestratorRun(input: {
maxSteps: input.payload.maxSteps,
messages: input.payload.messages,
context: {
source: input.payload.context?.source ?? null,
action: input.payload.context?.action ?? null,
documentId: input.payload.context?.documentId ?? null,
workspaceId: input.payload.context?.workspaceId ?? null,
selectedBlockId: input.payload.context?.selectedBlockId ?? null,
selectedBlockIndex: input.payload.context?.selectedBlockIndex ?? null,
selectedUids: input.payload.context?.selectedUids ?? null,
selectedText: input.payload.context?.selectedText ?? null,
selection: input.payload.context?.selection ?? null,
tiptapDocument: input.payload.context?.tiptapDocument ?? null,
documentBlocks: input.payload.context?.documentBlocks ?? null,
node: input.payload.context?.node ?? null,
subtree: input.payload.context?.subtree ?? null,
@@ -0,0 +1,119 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
default: {
spawn: mockSpawn,
},
spawn: mockSpawn,
}));
vi.mock("next/server", () => ({
NextResponse: class NextResponse extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
}
},
}));
import { startMnoteCliAgentHostRun } from "./mnote-cli-agent-host";
function createMockChild(stdoutText: string) {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
setTimeout(() => {
child.stdout.end(stdoutText);
child.stderr.end("");
child.emit("close", 0);
}, 0);
return child;
}
describe("startMnoteCliAgentHostRun", () => {
beforeEach(() => {
mockSpawn.mockReset();
delete process.env.DEV_USER_ID;
delete process.env.DEV_USER_EMAIL;
delete process.env.DEV_USER_NAME;
});
it("启动 mnote-cli 时应把当前 Web 用户作为默认 CLI 写入身份下发", async () => {
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
const response = await startMnoteCliAgentHostRun({
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
userEmail: "dev@mnote.local",
userName: "开发用户",
payload: {
stream: false,
messages: [{ role: "user", content: "写入测试页" }],
context: {
documentId: "doc-1",
workspaceId: "ws_req_1778035004501_15",
},
},
});
expect(response.status).toBe(200);
expect(mockSpawn).toHaveBeenCalledWith(
"cargo",
expect.arrayContaining([
"--args-json",
expect.stringContaining('"workspaceId":"ws_req_1778035004501_15"'),
]),
expect.objectContaining({
env: expect.objectContaining({
DEV_USER_ID: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
DEV_USER_EMAIL: "dev@mnote.local",
DEV_USER_NAME: "开发用户",
MNOTE_CLI_ALLOW_CREATE_PAGE: "1",
MNOTE_CLI_ALLOW_EDIT: "1",
}),
}),
);
});
it("应把当前页面 workspaceId 传入 CLI argsJson,避免 agent 写入错误工作空间", async () => {
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
await startMnoteCliAgentHostRun({
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
userEmail: "dev@mnote.local",
userName: "开发用户",
payload: {
stream: false,
messages: [{ role: "user", content: "在当前空间写测试页" }],
context: {
documentId: "tree_1778036320856_1",
workspaceId: "ws_req_1778035004501_15",
},
},
});
const args = mockSpawn.mock.calls[0]?.[1] as string[];
const argsJson = args[args.indexOf("--args-json") + 1];
expect(JSON.parse(argsJson)).toMatchObject({
documentId: "tree_1778036320856_1",
workspaceId: "ws_req_1778035004501_15",
});
});
});
@@ -0,0 +1,239 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
type AgentMessage = { role: "user" | "assistant"; content: string };
export type MnoteCliAgentRunPayload = {
stream?: boolean;
maxSteps?: number;
scope?: string;
messages: AgentMessage[];
context?: {
documentId?: string;
workspaceId?: string;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
selectedUids?: string[];
};
options?: {
ai?: {
provider?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
model?: string;
};
};
};
type CliRunResult = {
code: number | null;
stdout: string;
stderr: string;
};
const CLI_HOST_TIMEOUT_MS = 30_000;
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
async function pathExists(targetPath: string) {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
async function resolveRepoRoot() {
let dir = path.resolve(process.cwd());
for (let i = 0; i < 8; i += 1) {
if (await pathExists(path.join(dir, "rust", "Cargo.toml"))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(process.cwd(), "..");
}
function buildCliArgs(input: { repoRoot: string; userId: string; payload: MnoteCliAgentRunPayload }) {
const sessionId = String(input.payload.options?.ai?.sessionId ?? "").trim() || `ai-${Date.now()}`;
const documentId = String(input.payload.context?.documentId ?? "").trim() || "current";
const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
const argsJson = JSON.stringify({
pageId: documentId,
documentId,
workspaceId,
provider: input.payload.options?.ai?.provider ?? null,
modelKey: input.payload.options?.ai?.modelKey ?? null,
profileId: input.payload.options?.ai?.profileId ?? null,
selectedUids: input.payload.context?.selectedUids ?? null,
pageOptions: input.payload.context?.pageOptions ?? null,
editorRuntimePageOptions: input.payload.context?.pageOptions
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
: null,
});
return [
"run",
"--quiet",
"--manifest-path",
path.join(input.repoRoot, "rust", "Cargo.toml"),
"-p",
"mnote-cli",
"--",
"--json",
"--validate-only",
"--dry-run",
"--actor-id",
input.userId,
"--actor-type",
"user",
"--session-id",
sessionId,
"--reason",
"ai-agent-run:mnote-cli-host",
"tool",
"run",
"--tool-name",
"doc_get",
"--kind",
"query",
"--mode",
"explain-plan",
"--args-json",
argsJson,
];
}
async function runMnoteCli(input: {
userId: string;
userEmail?: string;
userName?: string;
payload: MnoteCliAgentRunPayload;
}): Promise<CliRunResult> {
const repoRoot = await resolveRepoRoot();
const args = buildCliArgs({ repoRoot, userId: input.userId, payload: input.payload });
return new Promise<CliRunResult>((resolve, reject) => {
const child = spawn("cargo", args, {
cwd: repoRoot,
env: {
...process.env,
CARGO_TERM_COLOR: "never",
RUSTUP_TOOLCHAIN: process.env.RUSTUP_TOOLCHAIN?.trim() || "1.89.0",
DEV_USER_ID: input.userId,
DEV_USER_EMAIL: input.userEmail?.trim() || process.env.DEV_USER_EMAIL || "dev@mnote.local",
DEV_USER_NAME: input.userName?.trim() || process.env.DEV_USER_NAME || "开发用户",
MNOTE_CLI_ALLOW_CREATE_PAGE: process.env.MNOTE_CLI_ALLOW_CREATE_PAGE || "1",
MNOTE_CLI_ALLOW_EDIT: process.env.MNOTE_CLI_ALLOW_EDIT || "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, CLI_HOST_TIMEOUT_MS);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (timedOut) {
reject(new Error("mnote-cli host 执行超时"));
return;
}
resolve({ code, stdout, stderr });
});
});
}
export async function startMnoteCliAgentHostRun(input: {
request: Request;
userId: string;
userEmail?: string;
userName?: string;
payload: MnoteCliAgentRunPayload;
}): Promise<Response> {
void input.request;
const run = runMnoteCli({
userId: input.userId,
userEmail: input.userEmail,
userName: input.userName,
payload: input.payload,
});
if (!input.payload.stream) {
const result = await run;
if (result.code !== 0) {
return NextResponse.json({ error: result.stderr || "mnote-cli 执行失败" }, { status: 500 });
}
return NextResponse.json({
ok: true,
bridgeOwner: "mnote-cli",
text: result.stdout.trim(),
});
}
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
try {
const result = await run;
if (result.code !== 0) {
controller.enqueue(
encoder.encode(toSseFrame("error", { ok: false, message: result.stderr || "mnote-cli 执行失败" })),
);
} else {
const text = result.stdout.trim() || "mnote-cli 无输出";
controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text })));
controller.enqueue(encoder.encode(toSseFrame("completion", { ok: true, text, steps: 1 })));
}
} catch (error) {
controller.enqueue(
encoder.encode(toSseFrame("error", { ok: false, message: error instanceof Error ? error.message : String(error) })),
);
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "mnote-cli",
},
});
}
@@ -31,11 +31,11 @@ const isPublicRoute = createRouteMatcher([
]);
export default convexAuthNextjsMiddleware(async (request, ctx) => {
// 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。
// 公共路由不做拦截(但 proxy 仍会处理 token 刷新/代理等)。
if (isPublicRoute(request)) return;
// 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
// 注意:proxy 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
// 开发用户模式下允许“免登录”访问(主要用于迁移/联调与 E2E 回归)。
@@ -49,6 +49,6 @@ export default convexAuthNextjsMiddleware(async (request, ctx) => {
});
export const config = {
// 说明:排除静态资源,避免无意义的中间件开销。
// 说明:排除静态资源,避免无意义的 proxy 开销。
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};