Compare commits

..

31 Commits

Author SHA1 Message Date
john dd3fed5b14 feat: add Rybbit admin SSO entry 2026-07-24 17:12:31 +08:00
tkmind b009f31aae merge: production-ready Deep Search admin
Merge the verified MindSearch admin configuration and hardened shared-source release gates.
2026-07-24 02:18:48 +00:00
john 4d7bcbe948 fix: harden admin production source gates 2026-07-24 09:53:06 +08:00
john 1b5bbdf467 fix: expose new built-in search services 2026-07-23 22:51:44 +08:00
john 6977f9fc24 feat: select deep search llm in admin 2026-07-23 22:46:14 +08:00
john ce8247743e feat: register local deep search service 2026-07-23 22:27:11 +08:00
john 04859defeb feat: manage pluggable search services 2026-07-23 21:38:41 +08:00
john bacc31c83a Merge branch 'release/episodic-memory-admin-20260722-r3' 2026-07-23 18:23:12 +08:00
john 693fc8a712 Merge remote-tracking branch 'origin/main' 2026-07-23 18:23:09 +08:00
john 00a4855715 fix: wire admin recharge WeChat notifications 2026-07-23 18:14:10 +08:00
tkmind 4f708d10bd Merge pull request 'fix: 等待后台服务真正就绪' (#3) from codex/fix-admin-startup-readiness into main 2026-07-22 05:22:37 +00:00
john 65970f383d fix: wait for admin startup readiness 2026-07-22 13:19:29 +08:00
tkmind f6624936d6 Merge pull request 'fix: 补齐后台共享模块图像运行依赖' (#2) from codex/fix-admin-sharp-runtime into main 2026-07-22 05:17:17 +00:00
john c0d74b9486 fix: include sharp in admin runtime 2026-07-22 13:15:50 +08:00
tkmind 75057bd285 Merge pull request 'feat: 增加历史会话召回灰度控制' (#1) from codex/episodic-memory-rollout-admin into main
Admin build and release gate passed.
2026-07-22 04:59:45 +00:00
john 6e874c7a8e feat: add episodic recall rollout controls 2026-07-22 12:36:30 +08:00
john 790c5794e9 feat: add image_make controls to admin 2026-07-19 21:28:00 +08:00
john 75b7c07a4d fix: keep skill sync on Portal workspace root 2026-07-17 13:58:55 +08:00
john d864bcd6fb fix: package Excel skill controls in admin runtime 2026-07-17 13:54:25 +08:00
john 7c2af3306a feat: add Memind analytics admin and Umami SSO 2026-07-16 14:09:12 +08:00
john 23a5f147fd merge: integrate MindSearch admin controls 2026-07-15 13:47:19 +08:00
john 05681d758e feat: add MindSearch admin controls 2026-07-15 13:46:22 +08:00
john 7df3ae35f1 feat(admin): surface Memory V2 candidate auto-review in management UI
Clarify active/canary/shadow modes and show auto-review metrics so operators do not need to manually approve every user memory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:16:57 +08:00
john 731d93843d feat(admin): add Memory V2 personal controls and Skill Runtime page
Wire admin UI and API routes to restored Memind personal memory and skill
runtime modules, including candidate review and runtime status display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:00:20 +08:00
john 4cbacb8529 Merge branch 'codex/space-quota-release'
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:18:42 +08:00
john a019da24b0 Merge branch 'codex/asset-gateway-controls' 2026-07-12 12:18:01 +08:00
john 6bd0faac40 refactor(admin): unify model center and Memory V2 as asset plugin cards
Align Providers and Memory V2 pages with asset-gateway card layout; fix
Memory V2 root layout and balanced grid on Asset Gateway.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:17:47 +08:00
john 0358484942 feat: present asset plugins as control cards 2026-07-12 10:00:30 +08:00
john 90cbcbba7c feat: bridge asset gateway controls to admin api 2026-07-12 09:54:47 +08:00
john a8a130c08f feat: add asset gateway admin controls 2026-07-12 09:51:20 +08:00
john 4b4bc898bd Support editing user space quota in admin 2026-06-25 23:48:29 +08:00
24 changed files with 3481 additions and 355 deletions
-1
View File
@@ -1 +0,0 @@
26747
-1
View File
@@ -1 +0,0 @@
25645
+9
View File
@@ -38,5 +38,14 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# 超管页「返回对话」跳转主 H5(可选)
VITE_MAIN_APP_URL=https://h5.tkmind.cn
# Umami 仅允许从 MemindAdm 单点登录进入(必须与 memind-analytics 一致)
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
UMAMI_SSO_USERNAME=admin
# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致)
MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit
RYBBIT_SSO_EMAIL=admin@tkmind.cn
RYBBIT_URL=https://rybbit.tkmind.cn
# Plaza 帖子预览链接
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
+1
View File
@@ -8,4 +8,5 @@ dist-ssr/
memind-lib/
.vite/
*.log
*.pid
logs/
+614
View File
@@ -21,6 +21,7 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"redis": "^4.7.1",
"sharp": "0.35.2",
"undici": "^6.26.0"
},
"devDependencies": {
@@ -786,6 +787,554 @@
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.1"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
"integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.2"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.1"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.1"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.1"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
"integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
"integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.2"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2231,6 +2780,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -3710,6 +4268,62 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.2",
"@img/sharp-darwin-x64": "0.35.2",
"@img/sharp-freebsd-wasm32": "0.35.2",
"@img/sharp-libvips-darwin-arm64": "1.3.1",
"@img/sharp-libvips-darwin-x64": "1.3.1",
"@img/sharp-libvips-linux-arm": "1.3.1",
"@img/sharp-libvips-linux-arm64": "1.3.1",
"@img/sharp-libvips-linux-ppc64": "1.3.1",
"@img/sharp-libvips-linux-riscv64": "1.3.1",
"@img/sharp-libvips-linux-s390x": "1.3.1",
"@img/sharp-libvips-linux-x64": "1.3.1",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
"@img/sharp-linux-arm": "0.35.2",
"@img/sharp-linux-arm64": "0.35.2",
"@img/sharp-linux-ppc64": "0.35.2",
"@img/sharp-linux-riscv64": "0.35.2",
"@img/sharp-linux-s390x": "0.35.2",
"@img/sharp-linux-x64": "0.35.2",
"@img/sharp-linuxmusl-arm64": "0.35.2",
"@img/sharp-linuxmusl-x64": "0.35.2",
"@img/sharp-webcontainers-wasm32": "0.35.2",
"@img/sharp-win32-arm64": "0.35.2",
"@img/sharp-win32-ia32": "0.35.2",
"@img/sharp-win32-x64": "0.35.2"
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+1
View File
@@ -29,6 +29,7 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"redis": "^4.7.1",
"sharp": "0.35.2",
"undici": "^6.26.0"
},
"devDependencies": {
+62 -1
View File
@@ -85,14 +85,27 @@ need_cmd scp
need_cmd rsync
need_cmd tar
need_cmd shasum
need_cmd node
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
bash "${ROOT}/scripts/check-release-ready.sh"
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh"
else
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
exit 1
fi
ROOT_GIT_BRANCH="$(git -C "${ROOT}" branch --show-current)"
ROOT_GIT_HEAD="$(git -C "${ROOT}" rev-parse HEAD)"
ROOT_ORIGIN_HEAD="$(git -C "${ROOT}" rev-parse origin/main)"
[[ "${ROOT_GIT_BRANCH}" == "main" ]] || {
echo "memindadm 生产发布必须从 main 执行,当前分支: ${ROOT_GIT_BRANCH:-detached}" >&2
exit 1
}
[[ "${ROOT_GIT_HEAD}" == "${ROOT_ORIGIN_HEAD}" ]] || {
echo "memindadm main 必须与 origin/main 完全一致" >&2
exit 1
}
[[ -f "${IGNORE_FILE}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
@@ -110,6 +123,12 @@ if [[ "${SKIP_BUILD}" -ne 1 ]]; then
)
fi
say "验证共享模块图像运行依赖"
(
cd "${ROOT}"
node --input-type=module -e "const { default: sharp } = await import('sharp'); await sharp({ create: { width: 1, height: 1, channels: 4, background: '#000000' } }).png().toBuffer();"
)
[[ -d "${ROOT}/dist" ]] || {
echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2
exit 1
@@ -120,6 +139,24 @@ fi
exit 1
}
git -C "${MEMIND_SRC}" fetch origin main --quiet
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD)"
MEMIND_ORIGIN_HEAD="$(git -C "${MEMIND_SRC}" rev-parse origin/main)"
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current)"
[[ "${MEMIND_GIT_BRANCH}" == "main" ]] || {
echo "Memind 共享模块必须从 main 打包,当前分支: ${MEMIND_GIT_BRANCH:-detached}" >&2
exit 1
}
[[ "${MEMIND_GIT_HEAD}" == "${MEMIND_ORIGIN_HEAD}" ]] || {
echo "Memind main 必须与 origin/main 完全一致" >&2
exit 1
}
[[ -z "$(git -C "${MEMIND_SRC}" status --porcelain=v1 --untracked-files=all)" ]] || {
echo "Memind 共享模块工作区必须干净" >&2
git -C "${MEMIND_SRC}" status --short --untracked-files=all >&2
exit 1
}
say "打包 Memind 共享模块到 memind-lib"
rm -rf "${MEMIND_LIB}"
mkdir -p "${MEMIND_LIB}"
@@ -133,10 +170,32 @@ rsync -a \
--include '*.js' \
--exclude '*' \
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
if [[ -d "${MEMIND_SRC}/skills" ]]; then
say "打包 Memind 平台技能目录到 memind-lib"
mkdir -p "${MEMIND_LIB}/skills"
rsync -a \
--exclude '.DS_Store' \
--exclude '__pycache__/' \
"${MEMIND_SRC}/skills/" "${MEMIND_LIB}/skills/"
fi
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
echo "memind-lib 缺少 user-auth.mjs" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/skills/excel-analyst/SKILL.md" ]] || {
echo "memind-lib 缺少 Excel Analyst 技能" >&2
exit 1
}
grep -q 'EXCEL_ANALYST_SKILL_NAME' "${MEMIND_LIB}/skills-registry.mjs" || {
echo "memind-lib 缺少 Excel Analyst 技能授权映射" >&2
exit 1
}
grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
exit 1
}
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
@@ -158,6 +217,8 @@ say "生成发布清单"
echo "root=${ROOT}"
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
echo "memind_git_head=${MEMIND_GIT_HEAD}"
echo "memind_git_branch=${MEMIND_GIT_BRANCH}"
echo "git_status_begin"
git -C "${ROOT}" status --short --branch || true
echo "git_status_end"
+15 -4
View File
@@ -34,6 +34,19 @@ export ADM_WEB_HOST="${WEB_HOST}"
export ADM_PORT="${PORT}"
export VITE_BASE_PATH="${BASE_PATH%/}"
wait_for_http() {
local url="$1"
local attempts="${2:-30}"
local attempt
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
if curl -sf --max-time 2 "${url}" >/dev/null; then
return 0
fi
sleep 1
done
return 1
}
if [[ ! -d dist ]]; then
echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
exit 1
@@ -61,9 +74,8 @@ sleep 1
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
echo $! >"${API_PID_FILE}"
sleep 2
if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then
if ! wait_for_http "http://127.0.0.1:${API_PORT}/health" 30; then
echo "❌ Admin API 启动失败,最近日志:" >&2
tail -n 40 "${API_LOG}" >&2 || true
exit 1
@@ -72,11 +84,10 @@ fi
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 2
preview_url="http://127.0.0.1:${PORT}${BASE_PATH}"
login_probe="http://127.0.0.1:${PORT}/auth/status"
if curl -sf "${preview_url}" >/dev/null && curl -sf "${login_probe}" >/dev/null; then
if wait_for_http "${preview_url}" 30 && wait_for_http "${login_probe}" 15; then
echo "✅ memind_adm 已就绪: ${preview_url}"
curl -sf "${preview_url}" >/dev/null && echo " 首页 OK"
echo " /auth 反代 OK"
+193
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -91,12 +92,16 @@ export function createAdminApp(services) {
const {
userAuth,
llmProviderService,
assetGatewayConfigService,
pool,
ready,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -216,6 +221,32 @@ export function createAdminApp(services) {
next();
}
adminApi.get('/analytics/sso', requireAdmin, async (req, res) => {
const sharedSecret = process.env.MEMIND_UMAMI_SSO_SECRET?.trim();
if (!sharedSecret) return res.status(503).json({ message: '未配置 Umami 单点登录密钥' });
const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null;
const baseUrl = String(config?.analytics?.analyticsUrl || process.env.UMAMI_URL || 'http://127.0.0.1:3100').replace(/\/$/, '');
const username = process.env.UMAMI_SSO_USERNAME?.trim() || 'admin';
const encoded = Buffer.from(JSON.stringify({ username, exp: Math.floor(Date.now() / 1000) + 60, nonce: crypto.randomUUID() })).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
});
adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => {
const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim();
if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' });
const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase();
if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' });
const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, '');
const encoded = Buffer.from(JSON.stringify({
email,
exp: Math.floor(Date.now() / 1000) + 60,
nonce: crypto.randomUUID(),
})).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` });
});
adminApi.get('/users', requireAdmin, async (req, res) => {
const result = await userAuth.listUsers({
page: Number(req.query.page) || 1,
@@ -321,10 +352,38 @@ export function createAdminApp(services) {
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
const result = await updateMindSpaceConfig(pool, {
publicPageLimit: req.body?.publicPageLimit,
analytics: req.body?.analytics,
});
res.json({ config: result });
});
adminApi.get('/asset-gateway/config', requireAdmin, async (_req, res) => {
if (!assetGatewayConfigService?.getConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
res.json(await assetGatewayConfigService.getConfig());
});
adminApi.put('/asset-gateway/config', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updateGlobalConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
res.json(await assetGatewayConfigService.updateGlobalConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
}));
});
adminApi.put('/asset-gateway/plugins/:pluginId', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updatePluginConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
const result = await assetGatewayConfigService.updatePluginConfig(req.params.pluginId, req.body ?? {}, {
updatedBy: req.currentUser.id,
});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
res.json(await memoryV2ConfigService.getAdminConfig());
@@ -338,6 +397,140 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getAdminConfig());
});
adminApi.patch('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.put('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getRuntimeState());
});
adminApi.post('/mindsearch/services/:serviceId/test', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.testService) return res.status(503).json({ message: 'MindSearch 服务测试未启用' });
try {
return res.json(await mindSearchConfigService.testService(req.params.serviceId));
} catch (error) {
if (error?.code === 'SEARCH_SERVICE_NOT_FOUND') return res.status(404).json({ message: error.message });
throw error;
}
});
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
try {
const response = await fetch(`${portalBaseUrl}/api/runtime/status`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
return res.status(502).json({
ok: false,
message: `Memory V2 运行时状态返回 HTTP ${response.status}`,
});
}
const payload = await response.json();
return res.json({
ok: true,
checkedAt: Date.now(),
memory: payload?.memory ?? null,
});
} catch (err) {
return res.status(503).json({
ok: false,
checkedAt: Date.now(),
message: err instanceof Error ? err.message : 'Memory V2 运行时不可用',
});
}
});
adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.listCandidates) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
try {
const status = String(req.query.status ?? 'candidate');
const userId = String(req.query.userId ?? '').trim() || null;
const limit = Number(req.query.limit ?? 50);
const [items, counts] = await Promise.all([
personalMemoryCandidateStore.listCandidates({ status, userId, limit }),
personalMemoryCandidateStore.countByStatus(),
]);
return res.json({ items, counts });
} catch (err) {
const missingTable = err?.code === 'ER_NO_SUCH_TABLE';
return res.status(missingTable ? 503 : 400).json({
message: missingTable ? '候选记忆表尚未执行本地迁移' : (err instanceof Error ? err.message : '候选记忆读取失败'),
});
}
});
adminApi.post('/memory-v2/candidates/:id/accept', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'accepted', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.post('/memory-v2/candidates/:id/reject', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'rejected', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getAdminConfig());
});
const updateSkillRuntimeConfig = async (req, res) => {
if (!skillRuntimeConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
updatedBy: req.currentUser.id,
});
res.json(result);
};
adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.listCatalogSummary) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() });
});
adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
if (!systemTestAccountService) {
return res.status(503).json({ message: '系统测试账号服务未启用' });
+27
View File
@@ -18,6 +18,7 @@ export async function bootstrapAdminServices() {
const createLlmProviderService = await loadCreateLlmProviderService();
const { createWechatAdminService } = await importMemind('wechat-admin.mjs');
const { createWechatMpService, loadWechatMpConfig } = await importMemind('wechat-mp.mjs');
const { createNotificationDispatcher } = await importMemind('notification-dispatcher.mjs');
const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs');
const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs');
const { createPlazaOpsService } = await importMemind('plaza-ops.mjs');
@@ -29,6 +30,11 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
const { createOpsApi } = await importMemind('admin-routes.mjs');
@@ -86,9 +92,18 @@ export async function bootstrapAdminServices() {
apiTarget,
apiSecret,
});
await ensureAssetGatewaySchema(pool);
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
await mindSearchConfigService.ensureSchema();
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
h5Root,
});
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
env: process.env,
});
@@ -105,6 +120,14 @@ export async function bootstrapAdminServices() {
throw new Error('admin api does not proxy WeChat chat sessions');
},
});
const notificationDispatcher = createNotificationDispatcher({
sendWechatTextToUser: wechatMpService?.enabled
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
: null,
});
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey });
});
const wechatAdmin = createWechatAdminService(pool, {
config: wechatMpConfig,
scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1',
@@ -133,6 +156,7 @@ export async function bootstrapAdminServices() {
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -140,6 +164,9 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+8
View File
@@ -98,12 +98,16 @@ ready
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -120,12 +124,16 @@ ready
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+10
View File
@@ -8,13 +8,17 @@ import { DashboardPage } from './admin/pages/DashboardPage';
import { PoliciesPage } from './admin/pages/PoliciesPage';
import { MindSpacePage } from './admin/pages/MindSpacePage';
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
import { MindSearchPage } from './admin/pages/MindSearchPage';
import { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
import { UserDetailPage } from './admin/pages/UserDetailPage';
import { UsersPage } from './admin/pages/UsersPage';
import { WechatPage } from './admin/pages/WechatPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
import { defaultHomePath } from './lib/routes';
import { OpsLayout } from './ops/components/OpsLayout';
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
@@ -119,9 +123,13 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="system-tests" element={<SystemTestsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="analytics" element={<AnalyticsConfigPage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
@@ -166,8 +174,10 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
) {
return role === 'admin' || role === undefined ? pathname : '/ops';
}
+4
View File
@@ -28,12 +28,16 @@ const NAV_SECTIONS: NavSection[] = [
items: [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/analytics', label: 'Analytics 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
{ to: '/providers', label: '统一模型中心' },
{ to: '/asset-gateway', label: '资产能力' },
{ to: '/blocked-words', label: '违禁词管理' },
],
},
+103
View File
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
const initial = {
enabled: false,
websiteId: '',
analyticsUrl: 'http://127.0.0.1:3100',
domains: '127.0.0.1,localhost',
idSecretConfigured: false,
};
export function AnalyticsConfigPage() {
const [config, setConfig] = useState(initial);
const [secret, setSecret] = useState('');
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [openingUmami, setOpeningUmami] = useState(false);
const [openingRybbit, setOpeningRybbit] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getMindSpaceAdminConfig();
setConfig({ ...initial, ...result.analytics });
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Analytics 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const save = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
setMessage(null);
try {
const result = await updateMindSpaceAdminConfig({
analytics: { enabled: config.enabled, websiteId: config.websiteId, analyticsUrl: config.analyticsUrl, domains: config.domains, ...(secret.trim() ? { idSecret: secret.trim() } : {}) },
});
setConfig({ ...initial, ...result.analytics });
setSecret('');
setMessage('Analytics 配置已保存;密钥不会在后台回显。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存 Analytics 配置失败');
} finally {
setBusy(false);
}
};
const openUmami = async () => {
setOpeningUmami(true);
setError(null);
try {
window.location.assign(await getUmamiSsoUrl());
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开 Umami');
setOpeningUmami(false);
}
};
const openRybbit = async () => {
setOpeningRybbit(true);
setError(null);
try {
window.location.assign(await getRybbitSsoUrl());
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开 Rybbit');
setOpeningRybbit(false);
}
};
return <div className="admin-page">
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">进入统计后台,或配置 Memind 生成页面的 Umami 统计。</p></div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<h2>Rybbit 行为分析</h2>
<p className="muted">查看页面访问、用户会话、停留时间、点击和会话回放等行为明细。</p>
<div className="admin-actions">
<button type="button" className="send-btn" onClick={() => void openRybbit()} disabled={openingRybbit}>
{openingRybbit ? '正在进入 Rybbit...' : '打开 Rybbit 分析后台'}
</button>
</div>
</section>
<section className="admin-card">
<h2>Umami</h2>
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={openingUmami || loading}>{openingUmami ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
<form className="admin-form" onSubmit={save}>
<label className="admin-form-row"><span>启用页面统计</span><input type="checkbox" checked={config.enabled} disabled={loading || busy} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} /></label>
<label className="admin-form-row"><span>Website ID</span><input value={config.websiteId} disabled={loading || busy} onChange={(e) => setConfig({ ...config, websiteId: e.target.value })} placeholder="Umami Website ID" required={config.enabled} /></label>
<label className="admin-form-row"><span>Umami 地址</span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
<label className="admin-form-row"><span>统计域名</span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
<label className="admin-form-row"><span>匿名化密钥 {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>重新加载</button></div>
</form>
</section>
</div>;
}
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getAssetGatewayConfig,
listLlmProviderKeys,
updateAssetGatewayConfig,
updateAssetPluginConfig,
} from '../../api/client';
import type { AssetGatewayConfig, AssetPluginConfig, LlmProviderKeyRow } from '../../types';
type PluginDraft = {
enabled: boolean;
provider: string;
llmProviderKeyId: string;
llmModel: string;
purposes: Record<string, boolean>;
};
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
return {
enabled: plugin.enabled,
provider: plugin.provider ?? '',
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
llmModel: plugin.llmModel ?? '',
purposes: { ...(plugin.purposes ?? {}) },
};
}
const pluginVisuals: Record<string, { icon: string; accent: string }> = {
'asset-search': { icon: '⌕', accent: 'blue' },
'asset-generate': { icon: '✦', accent: 'violet' },
'asset-transform': { icon: '✧', accent: 'amber' },
'asset-analyze': { icon: '◉', accent: 'green' },
};
export function AssetGatewayPage() {
const [config, setConfig] = useState<AssetGatewayConfig | null>(null);
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [drafts, setDrafts] = useState<Record<string, PluginDraft>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const activeKeys = useMemo(() => keys.filter((key) => key.status === 'active'), [keys]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextConfig, nextKeys] = await Promise.all([getAssetGatewayConfig(), listLlmProviderKeys()]);
setConfig(nextConfig);
setKeys(nextKeys);
setDrafts(Object.fromEntries(nextConfig.plugins.map((plugin) => [plugin.pluginId, draftFromPlugin(plugin)])));
} catch (err) {
setError(err instanceof Error ? err.message : '加载资产能力配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const updateDraft = (pluginId: string, patch: Partial<PluginDraft>) => {
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
};
const updatePurpose = (pluginId: string, purposeId: string, enabled: boolean) => {
setDrafts((current) => ({
...current,
[pluginId]: {
...current[pluginId],
purposes: { ...(current[pluginId]?.purposes ?? {}), [purposeId]: enabled },
},
}));
};
const saveGlobal = async (enabled: boolean) => {
setBusy('global'); setMessage(null); setError(null);
try {
const next = await updateAssetGatewayConfig({ enabled });
setConfig(next);
setMessage(`资产能力总开关已${enabled ? '开启' : '关闭'}。`);
} catch (err) { setError(err instanceof Error ? err.message : '保存总开关失败'); }
finally { setBusy(null); }
};
const savePlugin = async (plugin: AssetPluginConfig) => {
const draft = drafts[plugin.pluginId];
if (!draft) return;
setBusy(plugin.pluginId); setMessage(null); setError(null);
try {
const result = await updateAssetPluginConfig(plugin.pluginId, {
enabled: draft.enabled,
provider: draft.provider || null,
llmProviderKeyId: draft.llmProviderKeyId || null,
llmModel: draft.llmModel || null,
purposes: draft.purposes,
});
setConfig(result.config);
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
setMessage(`${plugin.label}配置已保存。`);
} catch (err) { setError(err instanceof Error ? err.message : '保存插件配置失败'); }
finally { setBusy(null); }
};
if (loading) return <div className="admin-page"><p className="muted">加载资产能力配置…</p></div>;
if (!config) return <div className="admin-page"><p className="banner banner-error">{error ?? '无法加载配置'}</p></div>;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>资产能力</h2>
<p className="muted">可插拔图片、素材与处理能力。默认关闭,不会影响现有聊天或页面生成流程。</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-success">{message}</p>}
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">OPTIONAL CAPABILITY</div>
<h3>资产能力 Gateway</h3>
<p>关闭时所有调用自动降级为现有流程;配置保留,现有聊天和页面生成不受影响。</p>
</div>
<label className="asset-toggle">
<input type="checkbox" checked={config.enabled} disabled={busy === 'global'} onChange={(event) => void saveGlobal(event.target.checked)} />
<span>{config.enabled ? '已启用' : '已关闭'}</span>
</label>
</section>
<div className="asset-plugin-grid asset-plugin-grid--balanced">
{config.plugins.map((plugin) => {
const draft = drafts[plugin.pluginId] ?? draftFromPlugin(plugin);
const selectedKey = activeKeys.find((key) => key.id === draft.llmProviderKeyId);
const visual = pluginVisuals[plugin.pluginId] ?? { icon: '✦', accent: 'blue' };
return <section className={`asset-plugin-card asset-plugin-card--${visual.accent}`} key={plugin.pluginId}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
<div><h3>{plugin.label}</h3><p>{plugin.description}</p></div>
<label className="asset-mini-toggle" title={`启用${plugin.label}`}>
<input type="checkbox" checked={draft.enabled} onChange={(event) => updateDraft(plugin.pluginId, { enabled: event.target.checked })} />
<span>{draft.enabled ? '开启' : '关闭'}</span>
</label>
</div>
<div className="asset-plugin-fields">
<label>运行 Provider<select value={draft.provider} onChange={(event) => {
const provider = event.target.value;
updateDraft(plugin.pluginId, {
provider,
...(provider === 'image_make' ? { llmProviderKeyId: '', llmModel: '' } : {}),
});
}} disabled={!draft.enabled}>
<option value="">选择 Provider</option>
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select></label>
{plugin.pluginId === 'asset-generate' && draft.provider === 'image_make' ? (
<div className="asset-image-make-note">
image_make 独立管理本地模型,不读取统一模型中心密钥。
</div>
) : plugin.supportsLlm ? <>
<label>专属 LLM<select value={draft.llmProviderKeyId} onChange={(event) => updateDraft(plugin.pluginId, { llmProviderKeyId: event.target.value, llmModel: '' })} disabled={!draft.enabled}>
<option value="">不使用 LLM</option>
{activeKeys.map((key) => <option key={key.id} value={key.id}>{key.name}</option>)}
</select></label>
<label>模型<select value={draft.llmModel} onChange={(event) => updateDraft(plugin.pluginId, { llmModel: event.target.value })} disabled={!draft.enabled || !selectedKey}>
<option value="">选择模型</option>
{selectedKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
</select></label>
</> : <div className="asset-no-llm">确定性处理 · 不调用 LLM</div>}
{plugin.pluginId === 'asset-generate' && plugin.purposeCatalog?.length ? (
<fieldset className="asset-purpose-fieldset" disabled={!draft.enabled || draft.provider !== 'image_make'}>
<legend>image_make 生效范围</legend>
<div className="asset-purpose-grid">
{plugin.purposeCatalog.map((purpose) => (
<label key={purpose.id}>
<input
type="checkbox"
checked={Boolean(draft.purposes[purpose.id])}
onChange={(event) => updatePurpose(plugin.pluginId, purpose.id, event.target.checked)}
/>
<span>{purpose.label}<small>{purpose.presetId}</small></span>
</label>
))}
</div>
<p>信息流缩略图仍由 MindSpace 从封面源图派生,不会重复调用生图模型。</p>
</fieldset>
) : null}
</div>
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
</section>;
})}
</div>
</div>
);
}
+2
View File
@@ -9,8 +9,10 @@ const QUICK_LINKS = [
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react';
import {
getMindSearchConfig,
listLlmProviderKeys,
testLlmProviderKey,
testMindSearchService,
updateMindSearchConfig,
} from '../../api/client';
import type {
LlmProviderKeyRow,
MindSearchConfig,
MindSearchRoutes,
MindSearchService,
} from '../../types';
const builtInServices: MindSearchService[] = [
{ id: 'searxng', name: 'SearXNG', kind: 'provider', adapter: 'searxng', capabilities: ['search.web', 'search.news'], endpoint: '', healthPath: '/config', enabled: false, timeoutMs: 8000, priority: 100 },
{ id: 'github', name: 'GitHub Code', kind: 'provider', adapter: 'github', capabilities: ['search.code'], endpoint: 'https://api.github.com/search/code', healthPath: '', enabled: false, timeoutMs: 8000, priority: 90 },
{ id: 'reader', name: 'Safe Reader', kind: 'reader', adapter: 'reader', capabilities: ['search.read'], endpoint: '', healthPath: '', enabled: false, timeoutMs: 8000, priority: 80 },
{ id: 'deep-search', name: 'TKMind Deep Search', kind: 'orchestrator', adapter: 'research-http', capabilities: ['search.web', 'research.plan', 'research.execute'], endpoint: 'http://127.0.0.1:20100', healthPath: '/health', enabled: false, timeoutMs: 120000, priority: 100, llmProviderKeyId: '', llmModel: '' },
];
const initial: MindSearchConfig = {
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
services: builtInServices,
routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: 'deep-search' },
};
const routeLabels: Record<keyof MindSearchRoutes, string> = {
web: '普通网页搜索',
news: '新闻搜索',
code: '代码搜索',
read: '网页读取',
research: '深度研究',
};
function mergeConfig(config: MindSearchConfig): MindSearchConfig {
const configuredServices = config.services ?? [];
const configuredServiceIds = new Set(configuredServices.map((service) => service.id));
const services = configuredServices.length
? [
...configuredServices,
...builtInServices.filter((service) => !configuredServiceIds.has(service.id)),
]
: builtInServices;
return {
...initial,
...config,
settings: { ...initial.settings, ...config.settings },
services,
routes: { ...initial.routes, ...config.routes },
};
}
export function MindSearchPage() {
const [config, setConfig] = useState(initial);
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
const [message, setMessage] = useState('加载配置中…');
useEffect(() => {
Promise.all([
getMindSearchConfig(),
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
])
.then(([result, keys]) => {
setConfig(mergeConfig(result.config));
setLlmKeys(keys.filter((key) => key.status === 'active'));
setMeta(result);
setMessage('');
})
.catch((error) => setMessage(error instanceof Error ? error.message : '配置加载失败'));
}, []);
const save = async () => {
setMessage('保存中…');
try {
const result = await updateMindSearchConfig(config);
setConfig(mergeConfig(result.config));
setMeta(result);
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
} catch (error) {
setMessage(error instanceof Error ? error.message : '保存失败');
}
};
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
const updateService = (id: string, patch: Partial<MindSearchService>) =>
setConfig((current) => ({
...current,
services: current.services.map((service) => service.id === id ? { ...service, ...patch } : service),
}));
const addService = () => {
const existingIds = new Set(config.services.map((service) => service.id));
let suffix = config.services.length + 1;
while (existingIds.has(`research-${suffix}`)) suffix += 1;
const id = `research-${suffix}`;
setConfig((current) => ({
...current,
services: [...current.services, {
id,
name: 'Research Engine',
kind: 'orchestrator',
adapter: 'research-http',
capabilities: ['search.web', 'research.plan', 'research.execute'],
endpoint: 'http://127.0.0.1:20100',
healthPath: '/health',
enabled: false,
timeoutMs: 30000,
priority: 100,
llmProviderKeyId: '',
llmModel: '',
}],
}));
};
const removeService = (id: string) =>
setConfig((current) => ({
...current,
services: current.services.filter((service) => service.id !== id),
routes: Object.fromEntries(
Object.entries(current.routes).map(([key, value]) => [key, value === id ? '' : value]),
) as unknown as MindSearchRoutes,
}));
const runServiceTest = async (id: string) => {
setMessage(`测试 ${id} 中…`);
try {
const result = await testMindSearchService(id);
setMessage(`${result.ok ? '通过' : '失败'}:${id};${result.message};${result.latencyMs}ms${result.resultCount == null ? '' : `;${result.resultCount} 条结果`}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : '服务测试失败');
}
};
const runLlmTest = async (service: MindSearchService) => {
if (!service.llmProviderKeyId || !service.llmModel) {
setMessage('请先选择 Deep Search 的 LLM 配置和模型');
return;
}
setMessage(`测试 ${service.llmModel} 中…`);
try {
const result = await testLlmProviderKey(service.llmProviderKeyId, service.llmModel);
setMessage(result.ok
? `LLM 通过:${result.model ?? service.llmModel}${result.latencyMs == null ? '' : `;${result.latencyMs}ms`}`
: `LLM 失败:${result.message ?? '模型连接失败'}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'LLM 测试失败');
}
};
return (
<section className="admin-page">
<div className="admin-page-header">
<div>
<h1>MindSearch</h1>
<p>可插拔搜索与研究服务。关闭时保持原有搜索、记忆和上下文链路。</p>
</div>
</div>
<div className="admin-card">
<h2>总开关与模式</h2>
<div className="admin-form">
<label className="search-service-toggle">
<input
type="checkbox"
checked={config.enabled}
onChange={(event) => setConfig({
...config,
enabled: event.target.checked,
mode: event.target.checked ? (config.mode === 'off' ? 'shadow' : config.mode) : 'off',
})}
/>
<span>启用 MindSearch</span>
</label>
<label>
<span>运行模式</span>
<select value={config.mode} onChange={(event) => setConfig({ ...config, mode: event.target.value as MindSearchConfig['mode'] })}>
<option value="off">关闭</option>
<option value="shadow">Shadow(只记录,不注入)</option>
<option value="assist">Assist(补充工具)</option>
</select>
</label>
</div>
<h2>服务注册表</h2>
<p>服务独立部署;后台只管理连接、路由和健康检查,不直接操作 Docker 或生产进程。测试使用已保存配置。</p>
<div className="search-service-grid">
{config.services.map((service) => {
const selectedLlmKey = llmKeys.find((key) => key.id === service.llmProviderKeyId);
return (
<fieldset key={service.id} className="search-service-card">
<legend>{service.name}({service.id})</legend>
<label className="search-service-toggle"><input type="checkbox" checked={service.enabled} onChange={(event) => updateService(service.id, { enabled: event.target.checked })} /><span>启用服务</span></label>
<div className="admin-form">
<label><span>名称</span><input value={service.name} onChange={(event) => updateService(service.id, { name: event.target.value })} /></label>
<label>
<span>类型</span>
<select value={service.kind} onChange={(event) => updateService(service.id, { kind: event.target.value as MindSearchService['kind'] })}>
<option value="provider">Provider</option>
<option value="reader">Reader</option>
<option value="orchestrator">Orchestrator</option>
</select>
</label>
<label>
<span>适配器</span>
<select value={service.adapter} onChange={(event) => updateService(service.id, { adapter: event.target.value as MindSearchService['adapter'] })}>
<option value="searxng">SearXNG</option>
<option value="github">GitHub</option>
<option value="reader">Reader</option>
<option value="research-http">Research HTTP</option>
</select>
</label>
<label><span>健康检查路径</span><input value={service.healthPath} placeholder="/health" onChange={(event) => updateService(service.id, { healthPath: event.target.value })} /></label>
<label className="form-span-all"><span>Endpoint</span><input value={service.endpoint} placeholder="http://127.0.0.1:20100" onChange={(event) => updateService(service.id, { endpoint: event.target.value })} /></label>
<label className="form-span-all"><span>Capabilities</span><input value={service.capabilities.join(', ')} onChange={(event) => updateService(service.id, { capabilities: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) })} /></label>
<label><span>超时(毫秒)</span><input type="number" min={1000} max={120000} value={service.timeoutMs} onChange={(event) => updateService(service.id, { timeoutMs: Number(event.target.value) })} /></label>
<label><span>优先级</span><input type="number" min={1} max={1000} value={service.priority} onChange={(event) => updateService(service.id, { priority: Number(event.target.value) })} /></label>
{service.adapter === 'research-http' && <>
<label className="form-span-all">
<span>规划与报告 LLM(可选)</span>
<select
value={service.llmProviderKeyId ?? ''}
onChange={(event) => {
const key = llmKeys.find((item) => item.id === event.target.value);
updateService(service.id, {
llmProviderKeyId: event.target.value,
llmModel: key?.defaultModel ?? '',
});
}}
>
<option value="">不使用 LLM(确定性降级)</option>
{service.llmProviderKeyId && !selectedLlmKey
&& <option value={service.llmProviderKeyId}>原 LLM 配置已停用或删除</option>}
{llmKeys.map((key) => (
<option key={key.id} value={key.id}>{key.name} · {key.providerLabel}</option>
))}
</select>
</label>
<label className="form-span-all">
<span>模型</span>
<select
value={service.llmModel ?? ''}
disabled={!selectedLlmKey}
onChange={(event) => updateService(service.id, { llmModel: event.target.value })}
>
{!selectedLlmKey && <option value="">请先选择 LLM 配置</option>}
{selectedLlmKey && service.llmModel && !selectedLlmKey.models.includes(service.llmModel)
&& <option value={service.llmModel}>原模型已不可用:{service.llmModel}</option>}
{selectedLlmKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
</select>
</label>
<p className="search-llm-hint form-span-all">密钥复用“统一模型中心”,不会保存到 MindSearch 配置或下发给 Deep Search 服务。</p>
</>}
</div>
<div className="search-service-actions">
<button className="send-btn" type="button" onClick={() => runServiceTest(service.id)}>测试已保存配置</button>
{service.adapter === 'research-http' && service.llmProviderKeyId && service.llmModel
&& <button type="button" onClick={() => runLlmTest(service)}>测试 LLM</button>}
{!['searxng', 'github', 'reader', 'deep-search'].includes(service.id) && <button type="button" onClick={() => removeService(service.id)}>移除</button>}
</div>
</fieldset>
);
})}
</div>
<button className="send-btn" type="button" onClick={addService}>添加 Research Service</button>
<h2>能力路由</h2>
<div className="admin-form">
{(Object.keys(routeLabels) as Array<keyof MindSearchRoutes>).map((route) => (
<label key={route}>
<span>{routeLabels[route]}</span>
<select
value={config.routes[route]}
onChange={(event) => setConfig((current) => ({ ...current, routes: { ...current.routes, [route]: event.target.value } }))}
>
<option value="">未配置</option>
{config.services.map((service) => <option key={service.id} value={service.id}>{service.name}({service.id})</option>)}
</select>
</label>
))}
</div>
<h2>运行参数</h2>
<div className="admin-form">
<label><span>最大结果数</span><input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
<label><span>默认 Provider 超时(毫秒)</span><input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
<label><span>Reader 最大字符数</span><input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
</div>
<button className="send-btn" type="button" onClick={save}>保存配置</button>
{message && <p role="status">{message}</p>}
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
</div>
</section>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/c
import type { MindSpaceAdminConfig } from '../../types';
function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig {
return config ?? { publicPageLimit: 10 };
return config ?? { publicPageLimit: 10, analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false } };
}
export function MindSpacePage() {
+215 -160
View File
@@ -35,6 +35,14 @@ import type {
const CUSTOM_PROVIDER_ID = '__custom__';
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
goose: { icon: 'G', accent: 'blue' },
aider: { icon: 'A', accent: 'amber' },
openhands: { icon: 'O', accent: 'violet' },
};
const PROVIDER_ACCENTS = ['blue', 'violet', 'green', 'amber'] as const;
const defaultProviderForm = {
providerId: CUSTOM_PROVIDER_ID,
name: '',
@@ -270,40 +278,41 @@ function ExecutorCard({
}
}, [model, selectedKey]);
const visual = EXECUTOR_VISUALS[binding.executor] ?? { icon: '✦', accent: 'blue' };
return (
<article className="executor-card">
<div className="admin-card-head">
<section className={`asset-plugin-card asset-plugin-card--${visual.accent} model-center-card`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
<div>
<h3>{binding.executorLabel}</h3>
<p className="muted">{binding.executorDescription}</p>
<p>{binding.executorDescription}</p>
</div>
<label className="inline-check">
<label className="asset-mini-toggle" aria-label={`${binding.executorLabel} 启用状态`}>
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
启用
<span>{enabled ? '启用' : '关闭'}</span>
</label>
</div>
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || (enabled && (!keyId || !model))}
onClick={() => void onSave(binding, keyId || null, model, enabled)}
>
保存绑定
</button>
<div className="asset-plugin-fields">
<label>
Provider
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
</label>
<label>
模型
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
</div>
<div className="executor-footer">
<div className="model-center-card-body">
<div className="executor-meta">
<span>{statusText(launchStatus)}</span>
{runtime?.ok ? <span>{runtime.providerName} · {runtime.model}</span> : <span>{runtime?.message ?? '运行配置未就绪'}</span>}
@@ -348,10 +357,24 @@ function ExecutorCard({
</div>
</>
)}
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
</div>
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
</article>
<div className="asset-plugin-footer">
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
{enabled ? '绑定启用中' : '绑定已关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={busy || (enabled && (!keyId || !model))}
onClick={() => void onSave(binding, keyId || null, model, enabled)}
>
保存绑定
</button>
</div>
</section>
);
}
@@ -387,45 +410,61 @@ function VisionSection({
}, [model, selectedKey]);
return (
<section className="admin-card providers-panel">
<div className="admin-card-head providers-panel-head">
<div>
<h3>图片任务模型</h3>
<p className="muted">发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
<div className="asset-plugin-grid model-center-vision-grid">
<section className="asset-plugin-card asset-plugin-card--green model-center-card">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">◉</div>
<div>
<h3>图片任务模型</h3>
<p>发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
</div>
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
</span>
</div>
{settings?.keyId ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
清除
</button>
<div className="asset-plugin-fields">
<label>
Provider
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
</label>
<label>
视觉模型
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择视觉模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</label>
</div>
{!settings?.keyId ? (
<p className="model-center-card-note">未配置时,图片消息将由默认模型处理。</p>
) : null}
</div>
{settings?.keyId ? (
<p className="muted" style={{ marginBottom: '0.75rem' }}>
当前:<strong>{settings.keyName}</strong> · <span className="mono">{settings.visionModel}</span>
</p>
) : (
<p className="muted" style={{ marginBottom: '0.75rem' }}>未配置,图片消息将由默认模型处理</p>
)}
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择视觉模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || !keyId || !model}
onClick={() => void onSave(keyId, model)}
>
保存
</button>
</div>
</section>
<div className="asset-plugin-footer">
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
{settings?.keyId ? '已绑定视觉模型' : '等待配置'}
</span>
<div className="model-center-card-actions">
{settings?.keyId ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
清除
</button>
) : null}
<button
type="button"
className="send-btn"
disabled={busy || !keyId || !model}
onClick={() => void onSave(keyId, model)}
>
保存
</button>
</div>
</div>
</section>
</div>
);
}
@@ -723,123 +762,139 @@ export function ProvidersPage() {
}
};
const activeProviderCount = keys.filter((item) => item.status === 'active').length;
return (
<div className="admin-page providers-page">
<div className="admin-page-head providers-hero">
<div>
<h2>统一模型中心</h2>
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
</div>
<div className="providers-hero-meta">
<div className="providers-hero-stat">
<span className="providers-hero-label">当前默认</span>
<strong>{globalSettings?.globalModel ?? '未设置'}</strong>
</div>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
添加 Provider
</button>
</div>
<div className="admin-page">
<div className="admin-page-head">
<h2>统一模型中心</h2>
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
</div>
{message && <p className="banner banner-info">{message}</p>}
{error && <p className="banner banner-error">{error}</p>}
<section className="admin-card providers-panel providers-panel-primary">
<div className="admin-card-head providers-panel-head">
<div>
<h3>执行器绑定</h3>
<p className="muted">把 Provider 和模型绑定到 Goose、Aider、OpenHands</p>
</div>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
同步 Goose 绑定
</button>
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">MODEL CENTER</div>
<h3>默认模型与 Provider 资源池</h3>
<p>
当前默认模型 <strong className="mono">{globalSettings?.globalModel ?? '未设置'}</strong>
{' · '}
Provider 资源池 {activeProviderCount}/{keys.length} 可用
</p>
</div>
{loading ? (
<p className="muted">加载中...</p>
) : (
<div className="executor-grid">
{bindings.map((binding) => (
<ExecutorCard
key={binding.executor}
binding={binding}
keys={activeKeys}
runtime={runtimes.find((item) => item.executor === binding.executor)}
plan={plans.find((item) => item.executor === binding.executor)}
launchStatus={launchStatuses[binding.executor]}
busy={busy}
onSave={handleSaveBinding}
onLaunch={handleLaunch}
onStop={handleStop}
onRestart={handleRestart}
/>
))}
</div>
)}
</section>
<section className="admin-card providers-panel providers-panel-secondary">
<div className="admin-card-head providers-panel-head">
<div>
<h3>Provider 资源池</h3>
<p className="muted">按名称纵向查看,更接近 list</p>
</div>
<div className="model-center-hero-actions">
<button type="button" className="ghost-btn" disabled={busy || loading} onClick={() => void load()}>
刷新
</button>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
添加 Provider
</button>
</div>
</section>
<div className="providers-list">
{keys.map((row) => (
<article key={row.id} className="provider-list-item">
<div className="provider-list-main">
<div className="provider-list-top">
<div className="providers-name">
<span>{row.name}</span>
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
</div>
<span className={`provider-state ${row.status === 'active' ? 'is-active' : 'is-disabled'}`}>
{row.status === 'active' ? '可用' : '禁用'}
</span>
<div className="model-center-section-head">
<h3 className="model-center-section-title">执行器绑定</h3>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
同步 Goose 绑定
</button>
</div>
{loading ? (
<p className="muted">加载中...</p>
) : (
<div className="asset-plugin-grid">
{bindings.map((binding) => (
<ExecutorCard
key={binding.executor}
binding={binding}
keys={activeKeys}
runtime={runtimes.find((item) => item.executor === binding.executor)}
plan={plans.find((item) => item.executor === binding.executor)}
launchStatus={launchStatuses[binding.executor]}
busy={busy}
onSave={handleSaveBinding}
onLaunch={handleLaunch}
onStop={handleStop}
onRestart={handleRestart}
/>
))}
</div>
)}
<h3 className="model-center-section-title">Provider 资源池</h3>
<div className="asset-plugin-grid">
{keys.map((row, index) => {
const accent = PROVIDER_ACCENTS[index % PROVIDER_ACCENTS.length];
return (
<section key={row.id} className={`asset-plugin-card asset-plugin-card--${accent} model-center-card model-center-provider-card`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">✦</div>
<div>
<h3>{row.name}</h3>
<p>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</p>
</div>
<div className="provider-list-meta">
<span>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</span>
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
{row.status === 'active' ? '可用' : '禁用'}
</span>
</div>
<div className="model-center-provider-badges">
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
{row.isVisionSelected ? <span className="risk-pill risk-medium">图片任务</span> : null}
</div>
<div className="asset-plugin-fields model-center-provider-meta">
<div className="model-center-meta-row">
<span>默认模型</span>
<span className="mono">{row.defaultModel}</span>
</div>
<div className="model-center-meta-row">
<span>API Key</span>
<span className="mono">{row.apiKeyMasked}</span>
</div>
</div>
<div className="admin-actions providers-actions provider-list-actions">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
>
编辑
</button>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
测试
</button>
{!row.isSelected && row.status === 'active' ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
设为默认
<div className="asset-plugin-footer">
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
{row.isSelected ? '当前默认 Provider' : '备用配置'}
</span>
<div className="model-center-card-actions">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
>
编辑
</button>
) : null}
<button
type="button"
className="ghost-btn"
disabled={busy || row.isSelected}
onClick={() => void handleToggleProviderStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
删除
</button>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
测试
</button>
{!row.isSelected && row.status === 'active' ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
设为默认
</button>
) : null}
<button
type="button"
className="ghost-btn"
disabled={busy || row.isSelected}
onClick={() => void handleToggleProviderStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
删除
</button>
</div>
</div>
</article>
))}
</div>
</section>
</section>
);
})}
</div>
<h3 className="model-center-section-title">图片任务模型</h3>
<VisionSection
keys={activeKeys}
settings={visionSettings}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react';
import {
getSkillRuntimeAdminConfig,
listSkillRuntimeCatalog,
updateSkillRuntimeAdminConfig,
} from '../../api/client';
import type { SkillRuntimeAdminConfig, SkillRuntimeCatalogItem } from '../../types';
export function SkillRuntimePage() {
const [config, setConfig] = useState<SkillRuntimeAdminConfig>({
router: { v2Enabled: false, manifestRoutingEnabled: false },
});
const [catalog, setCatalog] = useState<SkillRuntimeCatalogItem[]>([]);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [configResult, catalogRows] = await Promise.all([
getSkillRuntimeAdminConfig(),
listSkillRuntimeCatalog(),
]);
setConfig(configResult.config);
setSource(configResult.source ?? 'default');
setUpdatedAt(configResult.updatedAt ?? null);
setCatalog(catalogRows);
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Skill Runtime 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async () => {
setSaving(true);
setError(null);
setNotice(null);
try {
const result = await updateSkillRuntimeAdminConfig(config);
setConfig(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setNotice('Skill Runtime 配置已保存。H5 用户刷新或重新登录后会读取新开关。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
const manifestSkillCount = catalog.filter((item) => item.hasManifest && item.triggerKeywords.length > 0).length;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>Skill Runtime</h2>
<p className="muted">控制 H5 Skill Router v2 与 manifest 关键词路由(默认关闭)</p>
</div>
{loading ? <p className="muted">加载中…</p> : null}
{error ? <p className="banner banner-error">{error}</p> : null}
{notice ? <p className="banner banner-info">{notice}</p> : null}
{!loading ? (
<>
<section className="admin-card">
<h3>Router 开关</h3>
<p className="muted">
当前来源:<strong>{source}</strong>
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<p className="muted">
开启后仅对已授予 skill 的用户生效;manifest 匹配失败会回退 legacy 路由。
</p>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.v2Enabled}
onChange={(event) => {
const v2Enabled = event.target.checked;
setConfig({
router: {
v2Enabled,
manifestRoutingEnabled: v2Enabled ? config.router.manifestRoutingEnabled : false,
},
});
}}
/>
<span>
<strong>启用 Skill Router v2</strong>
<span className="muted">总开关。关闭时 H5 走 legacy regex 路由。</span>
</span>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.manifestRoutingEnabled}
disabled={!config.router.v2Enabled}
onChange={(event) => {
setConfig({
router: {
...config.router,
manifestRoutingEnabled: event.target.checked,
},
});
}}
/>
<span>
<strong>启用 manifest 关键词路由</strong>
<span className="muted">
读取 skills/*/skill.yaml 的 trigger.keywords。当前已配置 manifest 的 skill:{manifestSkillCount}{' '}
个。
</span>
</span>
</label>
<div className="admin-actions">
<button type="button" className="send-btn" disabled={saving} onClick={() => void handleSave()}>
{saving ? '保存中…' : '保存配置'}
</button>
<button
type="button"
className="admin-btn-secondary"
disabled={loading || saving}
onClick={() => void load()}
>
重新加载
</button>
</div>
</section>
<section className="admin-card">
<h3>技能 Catalog 预览</h3>
<p className="muted">
「Manifest」表示该 skill 目录下是否存在 <code>skill.yaml</code>(Router v2 关键词路由配置)。
目前 Phase 1 仅 <strong>product-campaign-page</strong> 做了 manifest 试点;其余 skill 仍只有 legacy{' '}
<code>SKILL.md</code>,因此 Manifest 显示「否」是正常现象。
</p>
<p className="muted">有 skill.yaml 且配置了关键词的条目会在 Router v2 开启后参与匹配。</p>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Skill</th>
<th>Manifest</th>
<th>关键词</th>
<th>Prompt</th>
<th>优先级</th>
</tr>
</thead>
<tbody>
{catalog.map((item) => (
<tr key={item.name}>
<td>
<strong>{item.name}</strong>
<div className="muted">{item.description}</div>
</td>
<td>{item.hasManifest ? '是' : '否'}</td>
<td>{item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'}</td>
<td>{item.routerPromptKey ?? '—'}</td>
<td>{item.routerPriority || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
) : null}
</div>
);
}
+98 -1
View File
@@ -1,4 +1,5 @@
import type {
AssetGatewayConfig,
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
@@ -24,13 +25,19 @@ import type {
LlmProviderKeyRow,
PlanDefinition,
PlanSyncResult,
PersonalMemoryCandidateListResponse,
PolicyDefinition,
PolicyMap,
PortalUser,
MindSpaceAdminConfig,
MindSpaceAnalyticsUpdate,
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
@@ -41,6 +48,8 @@ import type {
WechatScheduleLlmConfig,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
} from '../types';
export class ApiError extends Error {
@@ -279,13 +288,52 @@ export async function updateWechatScheduleLlmConfig(
});
}
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config');
}
export async function updateAssetGatewayConfig(
payload: Pick<AssetGatewayConfig, 'enabled'>,
): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function updateAssetPluginConfig(
pluginId: string,
payload: {
enabled: boolean;
provider?: string | null;
llmProviderKeyId?: string | null;
llmModel?: string | null;
purposes?: Record<string, boolean>;
},
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config');
return result.config;
}
export async function getUmamiSsoUrl(): Promise<string> {
const result = await portalFetch<{ url: string }>('/admin-api/analytics/sso');
return result.url;
}
export async function getRybbitSsoUrl(): Promise<string> {
const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso');
return result.url;
}
export async function updateMindSpaceAdminConfig(
payload: Partial<MindSpaceAdminConfig>,
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
): Promise<MindSpaceAdminConfig> {
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config', {
method: 'PATCH',
@@ -298,6 +346,25 @@ export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigRespo
return portalFetch('/admin-api/memory-v2/config');
}
export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusResponse> {
return portalFetch('/admin-api/memory-v2/status');
}
export async function listPersonalMemoryCandidates(
status = 'candidate',
): Promise<PersonalMemoryCandidateListResponse> {
return portalFetch(`/admin-api/memory-v2/candidates?status=${encodeURIComponent(status)}&limit=50`);
}
export async function reviewPersonalMemoryCandidate(
id: string,
action: 'accept' | 'reject',
): Promise<{ updated: boolean; status: string; reviewedAt: number }> {
return portalFetch(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/${action}`, {
method: 'POST',
});
}
export async function updateMemoryV2AdminConfig(
payload: Partial<MemoryV2AdminConfig>,
): Promise<MemoryV2AdminConfigResponse> {
@@ -307,6 +374,24 @@ export async function updateMemoryV2AdminConfig(
});
}
export async function getSkillRuntimeAdminConfig(): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config');
}
export async function updateSkillRuntimeAdminConfig(
config: SkillRuntimeAdminConfig,
): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config', {
method: 'PUT',
body: JSON.stringify({ config }),
});
}
export async function listSkillRuntimeCatalog(): Promise<SkillRuntimeCatalogItem[]> {
const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog');
return result.catalog ?? [];
}
export async function listMemoryV2ModelOptions(): Promise<{
global: LlmGlobalSettings;
keys: LlmProviderKeyRow[];
@@ -582,6 +667,18 @@ export async function clearUserCapabilityOverrides(userId: string): Promise<void
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
}
export async function getMindSearchConfig(): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config');
}
export async function updateMindSearchConfig(config: Partial<MindSearchConfig>): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
}
export async function testMindSearchService(serviceId: string): Promise<MindSearchServiceTestResult> {
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
}
// ── Policies ──────────────────────────────────────────
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
+559
View File
@@ -577,6 +577,51 @@ body,
justify-self: stretch;
}
.search-service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
gap: 14px;
margin: 14px 0;
}
.search-service-card {
min-width: 0;
padding: 14px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
}
.search-service-card legend {
padding: 0 6px;
font-weight: 600;
}
.search-service-toggle {
display: flex !important;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--color-text-primary) !important;
}
.search-service-toggle input {
width: auto;
margin: 0;
}
.search-service-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.search-llm-hint {
margin: -2px 0 0;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.55;
}
.form-span-all {
grid-column: 1 / -1;
}
@@ -950,6 +995,520 @@ body,
word-break: break-all;
}
/* ── Asset gateway / model center ────────────────────── */
.asset-gateway-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, #7c5cff);
border-radius: var(--radius-lg);
background: linear-gradient(120deg, rgba(75, 55, 146, .28), rgba(21, 27, 38, .78));
}
.asset-kicker {
color: #aaa1ff;
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
}
.asset-gateway-hero h3 {
margin: 5px 0 6px;
font-size: 18px;
}
.asset-gateway-hero p {
max-width: 620px;
margin: 0;
color: var(--color-text-muted);
font-size: 13px;
}
.asset-toggle,
.asset-mini-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
font-size: 13px;
}
.asset-toggle {
padding: 9px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, .07);
}
.asset-plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.asset-plugin-card {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-surface);
box-shadow: 0 10px 28px rgba(0, 0, 0, .12);
}
.asset-plugin-grid--balanced .asset-plugin-card {
min-height: 326px;
}
.asset-plugin-card--violet { border-top: 3px solid #9374ff; }
.asset-plugin-card--blue { border-top: 3px solid #54a8ff; }
.asset-plugin-card--amber { border-top: 3px solid #e8af55; }
.asset-plugin-card--green { border-top: 3px solid #55c79b; }
.asset-plugin-card-head {
display: flex;
gap: 10px;
align-items: flex-start;
}
.asset-plugin-icon {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: 12px;
background: rgba(132, 110, 255, .17);
color: #c4b8ff;
font-size: 22px;
}
.asset-plugin-card-head h3 {
margin: 0 0 4px;
font-size: 15px;
}
.asset-plugin-card-head p {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
word-break: break-word;
}
.asset-mini-toggle {
margin-left: auto;
color: var(--color-text-muted);
font-size: 12px;
}
.asset-plugin-fields {
display: grid;
gap: 9px;
}
.asset-plugin-fields label {
display: grid;
gap: 5px;
color: var(--color-text-muted);
font-size: 11px;
}
.asset-plugin-fields select {
width: 100%;
min-width: 0;
}
.asset-image-make-note,
.asset-no-llm {
padding: 9px 10px;
border-radius: var(--radius-sm);
background: rgba(132, 110, 255, .1);
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.5;
}
.asset-purpose-fieldset {
min-width: 0;
margin: 2px 0 0;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.asset-purpose-fieldset legend {
padding: 0 5px;
color: var(--color-text-muted);
font-size: 11px;
}
.asset-purpose-fieldset > p {
margin: 8px 0 0;
color: var(--color-text-muted);
font-size: 10px;
line-height: 1.45;
}
.asset-purpose-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.asset-purpose-grid label {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 7px;
border-radius: var(--radius-xs);
background: rgba(255, 255, 255, .035);
}
.asset-purpose-grid small {
display: block;
margin-top: 2px;
color: var(--color-text-muted);
font-size: 9px;
word-break: break-all;
}
.asset-plugin-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--color-border);
}
.asset-status {
color: var(--color-text-muted);
font-size: 11px;
}
.asset-status.is-on {
color: #68d8a7;
}
.model-center-hero-actions,
.model-center-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.model-center-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 12px;
}
.model-center-section-title {
margin: 0 0 12px;
font-size: 1rem;
}
.model-center-section-head .model-center-section-title {
margin: 0;
}
.model-center-card {
min-height: 0;
}
.model-center-provider-card {
min-height: 0;
}
.model-center-card-body {
display: grid;
gap: 8px;
}
.model-center-card-note {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.model-center-provider-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.model-center-provider-meta {
gap: 8px;
}
.model-center-meta-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, .04);
color: var(--color-text-muted);
font-size: 12px;
}
.model-center-meta-row .mono {
color: var(--color-text-primary);
text-align: right;
word-break: break-all;
}
.model-center-vision-grid {
grid-template-columns: minmax(0, 1fr);
}
.model-center-card .executor-instruction,
.model-center-card .executor-meta,
.model-center-card .executor-note,
.model-center-card .executor-log {
margin: 0;
}
.model-center-card .executor-actions {
white-space: normal;
}
.memory-v2-page {
display: block;
}
.memory-v2-page > .asset-gateway-hero,
.memory-v2-page > .asset-plugin-grid,
.memory-v2-page > .model-center-section-title,
.memory-v2-page > .model-center-card-note,
.memory-v2-page > .banner {
width: 100%;
max-width: 100%;
}
.memory-v2-page .memory-v2-fields input,
.memory-v2-page .memory-v2-fields select,
.memory-v2-page .asset-plugin-fields select {
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-input);
background: var(--color-bg-base);
color: var(--color-text-primary);
}
.memory-v2-top-grid {
grid-template-columns: minmax(0, 1fr);
}
.memory-v2-backend-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-capability-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-page .asset-plugin-grid {
gap: 12px;
margin-bottom: 12px;
}
.memory-v2-page .asset-plugin-card {
gap: 10px;
padding: 12px 14px;
box-shadow: 0 6px 18px rgba(0, 0, 0, .08);
}
.memory-v2-card {
min-height: 0;
}
.memory-v2-backend-intro {
margin: -4px 0 10px;
}
.memory-v2-backend-details {
display: flex;
flex-direction: column;
}
.memory-v2-backend-summary {
display: flex;
gap: 10px;
align-items: center;
list-style: none;
cursor: pointer;
}
.memory-v2-backend-summary::-webkit-details-marker {
display: none;
}
.memory-v2-backend-summary-main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.memory-v2-backend-summary-main h3 {
margin: 0;
font-size: 15px;
}
.memory-v2-backend-body {
display: grid;
gap: 10px;
padding-top: 10px;
border-top: 1px solid var(--color-border);
}
.memory-v2-advanced-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, .02);
}
.memory-v2-advanced-details > summary {
padding: 8px 10px;
color: var(--color-text-muted);
font-size: 12px;
cursor: pointer;
list-style: none;
}
.memory-v2-advanced-details > summary::-webkit-details-marker {
display: none;
}
.memory-v2-advanced-details .memory-v2-fields {
padding: 0 10px 10px;
}
.memory-v2-toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px 12px;
}
.memory-v2-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 12px;
}
.memory-v2-fields label {
display: grid;
gap: 5px;
}
.memory-v2-fields label > span {
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-card .inline-check {
align-items: center;
color: var(--color-text-muted);
font-size: 12px;
}
.memory-v2-runtime-health {
display: grid;
gap: 3px;
flex: 1 1 100%;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-runtime-health strong {
color: var(--color-text-primary);
font-size: 12px;
}
.memory-v2-candidate-review {
margin-bottom: 12px;
}
.memory-v2-candidate-list {
display: grid;
gap: 10px;
}
.memory-v2-candidate-item {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, .02);
}
.memory-v2-candidate-item p {
margin: 0;
line-height: 1.6;
}
.memory-v2-candidate-item small {
color: var(--color-text-muted);
}
.memory-v2-candidate-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
align-items: center;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-page .model-center-card-note {
margin: 0;
}
@media (min-width: 1280px) {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.asset-gateway-hero,
.model-center-section-head {
align-items: flex-start;
flex-direction: column;
}
.asset-plugin-grid {
grid-template-columns: 1fr;
}
.asset-plugin-footer {
align-items: flex-start;
flex-direction: column;
}
}
/* ── Capability settings ─────────────────────────────── */
.capability-intro {
+166
View File
@@ -195,6 +195,33 @@ export type LlmExecutorLaunchState = {
instruction: string | null;
};
export type AssetPluginConfig = {
pluginId: string;
label: string;
description: string;
providers: string[];
supportsLlm: boolean;
enabled: boolean;
provider: string | null;
llmProviderKeyId: string | null;
llmProviderName: string | null;
llmProviderId: string | null;
llmModel: string | null;
updatedAt: number | null;
purposes?: Record<string, boolean>;
purposeCatalog?: Array<{
id: string;
label: string;
presetId: string;
}>;
};
export type AssetGatewayConfig = {
enabled: boolean;
updatedAt: number | null;
plugins: AssetPluginConfig[];
};
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
export type AdminServiceRestartResult = {
@@ -260,6 +287,21 @@ export type WechatScheduleLlmConfig = {
export type MindSpaceAdminConfig = {
publicPageLimit: number;
analytics: {
enabled: boolean;
websiteId: string;
analyticsUrl: string;
domains: string;
idSecretConfigured: boolean;
};
};
export type MindSpaceAnalyticsUpdate = {
enabled?: boolean;
websiteId?: string;
analyticsUrl?: string;
domains?: string;
idSecret?: string;
};
export type MemoryV2AdminSection = Record<string, string | boolean>;
@@ -275,6 +317,15 @@ export type MemoryV2AdminConfig = {
failOpen?: boolean;
};
chatIntentRouter: MemoryV2AdminSection;
candidateMemory: MemoryV2AdminSection;
runtimeControl: MemoryV2AdminSection;
policy: MemoryV2AdminSection;
retriever: MemoryV2AdminSection;
lifecycle: MemoryV2AdminSection;
persona: MemoryV2AdminSection;
graph: MemoryV2AdminSection;
userMemory: MemoryV2AdminSection;
pluginHealth: MemoryV2AdminSection;
pgvector: MemoryV2AdminSection;
qdrant: MemoryV2AdminSection;
weaviate: MemoryV2AdminSection;
@@ -291,6 +342,79 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type MemoryV2RuntimeStatusResponse = {
ok: boolean;
checkedAt?: number;
message?: string;
memory?: {
enabled?: boolean;
backend?: string;
selectedBackend?: string | null;
configSource?: string;
configUpdatedAt?: number | null;
personalMemory?: {
enabled?: boolean;
requestedMode?: string;
effectiveMode?: string;
autoReviewEnabled?: boolean;
phase?: string;
injectionEnabled?: boolean;
persistence?: string;
pendingCandidates?: number;
health?: Record<string, string | number | boolean | null>;
policy?: Record<string, string | number | boolean | null>;
};
} | null;
};
export type PersonalMemoryCandidate = {
id: string;
userId: string;
sessionId: string | null;
memoryType: string;
content: string;
importance: number;
confidence: number;
status: string;
policyReason: string;
evidence: Record<string, unknown>;
reviewedBy: string | null;
reviewedAt: number | null;
createdAt: number;
updatedAt: number;
};
export type PersonalMemoryCandidateListResponse = {
items: PersonalMemoryCandidate[];
counts: Record<string, number>;
};
export type SkillRuntimeAdminConfig = {
router: {
v2Enabled: boolean;
manifestRoutingEnabled: boolean;
};
};
export type SkillRuntimeAdminConfigResponse = {
config: SkillRuntimeAdminConfig;
updatedAt: number | null;
updatedBy: string | null;
source?: string;
};
export type SkillRuntimeCatalogItem = {
name: string;
dirName: string;
description: string;
version: string | null;
executors: string[];
hasManifest: boolean;
triggerKeywords: string[];
routerPromptKey: string | null;
routerPriority: number;
};
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
export type AdminSystemTestStep = {
@@ -495,3 +619,45 @@ export type AdminSubscription = {
note: string | null;
createdAt: number;
};
export type MindSearchConfig = {
enabled: boolean;
mode: 'off' | 'shadow' | 'assist';
providers: { searxng: boolean; github: boolean; reader: boolean };
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
services: MindSearchService[];
routes: MindSearchRoutes;
};
export type MindSearchService = {
id: string;
name: string;
kind: 'provider' | 'reader' | 'orchestrator';
adapter: 'searxng' | 'github' | 'reader' | 'research-http';
capabilities: string[];
endpoint: string;
healthPath: string;
enabled: boolean;
timeoutMs: number;
priority: number;
llmProviderKeyId?: string;
llmModel?: string;
};
export type MindSearchRoutes = {
web: string;
news: string;
code: string;
read: string;
research: string;
};
export type MindSearchServiceTestResult = {
ok: boolean;
serviceId: string;
adapter: MindSearchService['adapter'];
status: number | null;
latencyMs: number;
resultCount?: number | null;
message: string;
};