Compare commits

...

25 Commits

Author SHA1 Message Date
john 16b1a90edf fix(release): gate memind-lib DeepSeek catalog on deepseek-v4 models
Refuse memindadm production releases when bundled llm-providers.mjs still
carries the legacy deepseek-chat catalog instead of deepseek-v4-pro/flash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:13:34 +08:00
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
23 changed files with 2358 additions and 16 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": {
+78 -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,48 @@ 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
}
[[ -f "${MEMIND_LIB}/llm-providers.mjs" ]] || {
echo "memind-lib 缺少 llm-providers.mjs" >&2
exit 1
}
grep -q "'deepseek-v4-pro'" "${MEMIND_LIB}/llm-providers.mjs" || {
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-pro 模型目录,拒绝发布旧 DeepSeek catalog" >&2
exit 1
}
grep -q "'deepseek-v4-flash'" "${MEMIND_LIB}/llm-providers.mjs" || {
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-flash 模型目录" >&2
exit 1
}
if grep -q "'deepseek-chat'" "${MEMIND_LIB}/llm-providers.mjs" && ! grep -q "LEGACY_DEEPSEEK_CHAT" "${MEMIND_LIB}/llm-providers.mjs"; then
echo "memind-lib/llm-providers.mjs 仍含 legacy deepseek-chat 且无 LEGACY_DEEPSEEK_CHAT 标记,目录可能未升级" >&2
exit 1
fi
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
@@ -158,6 +233,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"
+165
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';
@@ -98,6 +99,9 @@ export function createAdminApp(services) {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -217,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,
@@ -322,6 +352,7 @@ 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 });
});
@@ -366,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: '系统测试账号服务未启用' });
+22
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,9 @@ 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');
@@ -93,6 +97,13 @@ export async function bootstrapAdminServices() {
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,
});
@@ -109,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',
@@ -145,6 +164,9 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+6
View File
@@ -105,6 +105,9 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -128,6 +131,9 @@ ready
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+7
View File
@@ -8,6 +8,7 @@ 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';
@@ -16,6 +17,8 @@ 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';
@@ -120,7 +123,10 @@ 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 />} />
@@ -168,6 +174,7 @@ 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')
+3
View File
@@ -28,7 +28,10 @@ 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: '技能' },
+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>;
}
+43 -2
View File
@@ -12,6 +12,7 @@ type PluginDraft = {
provider: string;
llmProviderKeyId: string;
llmModel: string;
purposes: Record<string, boolean>;
};
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
@@ -20,6 +21,7 @@ function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
provider: plugin.provider ?? '',
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
llmModel: plugin.llmModel ?? '',
purposes: { ...(plugin.purposes ?? {}) },
};
}
@@ -61,6 +63,16 @@ export function AssetGatewayPage() {
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 {
@@ -81,6 +93,7 @@ export function AssetGatewayPage() {
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)])));
@@ -126,11 +139,21 @@ export function AssetGatewayPage() {
</label>
</div>
<div className="asset-plugin-fields">
<label> Provider<select value={draft.provider} onChange={(event) => updateDraft(plugin.pluginId, { provider: event.target.value })} disabled={!draft.enabled}>
<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.supportsLlm ? <>
{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>)}
@@ -140,6 +163,24 @@ export function AssetGatewayPage() {
{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>;
+1
View File
@@ -12,6 +12,7 @@ const QUICK_LINKS = [
{ 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;
+436 -3
View File
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useState, type ChangeEvent } from 'react';
import {
getMemoryV2AdminConfig,
getMemoryV2RuntimeStatus,
listPersonalMemoryCandidates,
listMemoryV2ModelOptions,
updateMemoryV2AdminConfig,
reviewPersonalMemoryCandidate,
} from '../../api/client';
import type {
LlmGlobalSettings,
@@ -10,6 +13,8 @@ import type {
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
PersonalMemoryCandidateListResponse,
} from '../../types';
type BackendKey =
@@ -24,6 +29,189 @@ type BackendKey =
type ModelBackendKey = 'mem0' | 'letta' | 'langgraph';
type CapabilityKey =
| 'candidateMemory'
| 'runtimeControl'
| 'policy'
| 'retriever'
| 'lifecycle'
| 'persona'
| 'graph'
| 'userMemory'
| 'pluginHealth';
type CapabilityField = {
key: string;
label: string;
type: 'boolean' | 'number' | 'select' | 'text';
options?: Array<{ value: string; label: string }>;
};
const CAPABILITIES: Array<{
key: CapabilityKey;
label: string;
icon: string;
description: string;
fields: CapabilityField[];
}> = [
{
key: 'candidateMemory',
label: '候选记忆',
icon: 'C',
description: '控制候选提取模式、自动接纳阈值与待处理数量。',
fields: [
{ key: 'enabled', label: '启用候选记忆', type: 'boolean' },
{ key: 'mode', label: '运行模式', type: 'select', options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'active', label: 'Active · 规则通过后自动接纳(推荐)' },
{ value: 'canary', label: 'Canary · 仅高置信度自动接纳' },
{ value: 'shadow', label: 'Shadow · 全部人工审核' },
] },
{ key: 'minImportance', label: '最低重要度', type: 'number' },
{ key: 'minConfidence', label: '最低置信度', type: 'number' },
{ key: 'maxPending', label: '最大待处理数', type: 'number' },
{ key: 'persistenceEnabled', label: '持久化到 MySQL', type: 'boolean' },
],
},
{
key: 'runtimeControl',
label: 'Runtime 控制',
icon: 'A',
description: '独立于 Memory V2 总开关,控制 Agent 读取、注入、晋升与后续生命周期能力。',
fields: [
{ key: 'agentResolveEnabled', label: '允许 Agent Resolve', type: 'boolean' },
{ key: 'agentInjectionMode', label: 'Agent 注入模式', type: 'select', options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'shadow', label: 'Shadow · 只观察不注入' },
{ value: 'canary', label: 'Canary · 灰度注入' },
{ value: 'active', label: 'Active · 正式注入' },
] },
{ key: 'agentCanaryUserIds', label: 'Canary 用户 ID(逗号分隔)', type: 'text' },
{ key: 'agentResolveLimit', label: 'Agent 召回条数', type: 'number' },
{ key: 'agentResolveTimeoutMs', label: 'Agent Resolve 超时 Ms', type: 'number' },
{ key: 'promotionEnabled', label: '允许候选晋升正式记忆', type: 'boolean' },
{ key: 'compactionV2Enabled', label: '启用 Compact V2', type: 'boolean' },
{ key: 'reflectionEnabled', label: '启用 Reflection', type: 'boolean' },
{ key: 'lifecycleWorkerEnabled', label: '启用 Lifecycle Worker', type: 'boolean' },
{ key: 'lifecycleRolloutMode', label: 'Lifecycle 灰度模式', type: 'select', options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'canary', label: 'Canary · 仅指定用户' },
{ value: 'active', label: 'Active · 全量' },
] },
{ key: 'lifecycleRolloutUserIds', label: 'Lifecycle 用户 ID(逗号分隔)', type: 'text' },
],
},
{
key: 'policy',
label: 'Policy',
icon: 'P',
description: '管理证据、敏感信息、显式记忆与保留期限。',
fields: [
{ key: 'enabled', label: '启用 Policy', type: 'boolean' },
{ key: 'saveExplicit', label: '允许显式“记住”', type: 'boolean' },
{ key: 'rejectSensitive', label: '拒绝敏感内容', type: 'boolean' },
{ key: 'requireEvidence', label: '长期记忆必须有证据', type: 'boolean' },
{ key: 'retentionDays', label: '默认保留天数', type: 'number' },
],
},
{
key: 'retriever',
label: 'Retriever',
icon: 'R',
description: '组合事件、语义、偏好和目标召回,并限制上下文预算。',
fields: [
{ key: 'enabled', label: '启用组合召回', type: 'boolean' },
{ key: 'episodicEnabled', label: '事件记忆', type: 'boolean' },
{ key: 'episodicMode', label: '历史会话召回模式', type: 'select', options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'canary', label: 'Canary · 仅指定用户' },
{ value: 'active', label: 'Active · 全量' },
] },
{ key: 'episodicCanaryUserIds', label: '历史会话 Canary 用户 ID(逗号分隔)', type: 'text' },
{ key: 'semanticEnabled', label: '语义记忆', type: 'boolean' },
{ key: 'preferenceEnabled', label: '用户偏好', type: 'boolean' },
{ key: 'goalEnabled', label: '活跃目标', type: 'boolean' },
{ key: 'limit', label: '召回条数', type: 'number' },
{ key: 'tokenBudget', label: 'Token 预算', type: 'number' },
{ key: 'timeoutMs', label: '超时毫秒', type: 'number' },
],
},
{
key: 'lifecycle',
label: 'Lifecycle',
icon: 'L',
description: '控制去重、冲突审核、衰减、遗忘与压缩周期。',
fields: [
{ key: 'enabled', label: '启用生命周期管理', type: 'boolean' },
{ key: 'dedupeEnabled', label: '自动去重', type: 'boolean' },
{ key: 'conflictReview', label: '冲突进入审核', type: 'boolean' },
{ key: 'decayEnabled', label: '启用记忆衰减', type: 'boolean' },
{ key: 'forgettingEnabled', label: '启用遗忘处理', type: 'boolean' },
{ key: 'compactIntervalHours', label: 'Compact 周期小时', type: 'number' },
],
},
{
key: 'persona',
label: 'Persona',
icon: 'A',
description: '管理用户画像来源、Shadow 注入、缓存和上下文上限。',
fields: [
{ key: 'enabled', label: '启用 Persona', type: 'boolean' },
{ key: 'provider', label: '画像来源', type: 'select', options: [
{ value: 'none', label: '未配置' },
{ value: 'ai-mind', label: 'AI Mind Bridge' },
{ value: 'internal', label: '内部画像' },
] },
{ key: 'shadowMode', label: '仅 Shadow 不注入', type: 'boolean' },
{ key: 'maxTokens', label: '最大 Token', type: 'number' },
{ key: 'cacheTtlSeconds', label: '缓存秒数', type: 'number' },
],
},
{
key: 'graph',
label: 'Graph',
icon: 'G',
description: '配置关系检索 Provider、最大深度和关系数量。',
fields: [
{ key: 'enabled', label: '启用关系检索', type: 'boolean' },
{ key: 'provider', label: 'Graph Provider', type: 'select', options: [
{ value: 'postgres', label: 'PostgreSQL 关系表' },
{ value: 'neo4j', label: 'Neo4j' },
{ value: 'none', label: '未配置' },
] },
{ key: 'maxDepth', label: '最大关系深度', type: 'number' },
{ key: 'relationLimit', label: '最大关系数', type: 'number' },
],
},
{
key: 'userMemory',
label: '用户记忆',
icon: 'U',
description: '管理用户查看、纠正、固定、遗忘和删除传播权限。',
fields: [
{ key: 'enabled', label: '启用用户记忆管理', type: 'boolean' },
{ key: 'reviewEnabled', label: '允许用户查看', type: 'boolean' },
{ key: 'correctionEnabled', label: '允许用户纠正', type: 'boolean' },
{ key: 'pinEnabled', label: '允许固定记忆', type: 'boolean' },
{ key: 'forgetEnabled', label: '允许遗忘记忆', type: 'boolean' },
{ key: 'deletePropagation', label: '删除传播至索引和缓存', type: 'boolean' },
],
},
{
key: 'pluginHealth',
label: '插件健康',
icon: 'H',
description: '配置健康检查、失败阈值和自动回退行为。',
fields: [
{ key: 'enabled', label: '启用插件健康检查', type: 'boolean' },
{ key: 'intervalSeconds', label: '检查间隔秒', type: 'number' },
{ key: 'timeoutMs', label: '检查超时毫秒', type: 'number' },
{ key: 'failureThreshold', label: '失败阈值', type: 'number' },
{ key: 'autoFallback', label: '异常时自动回退', type: 'boolean' },
],
},
];
const BACKENDS: Array<{
key: BackendKey;
label: string;
@@ -161,6 +349,15 @@ function defaultConfig(): MemoryV2AdminConfig {
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
candidateMemory: { enabled: false, mode: 'active', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false },
runtimeControl: { agentResolveEnabled: false, agentInjectionMode: 'off', agentCanaryUserIds: '', agentResolveLimit: '3', agentResolveTimeoutMs: '1200', promotionEnabled: false, compactionV2Enabled: false, reflectionEnabled: false, lifecycleWorkerEnabled: false, lifecycleRolloutMode: 'off', lifecycleRolloutUserIds: '' },
policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' },
retriever: { enabled: false, episodicEnabled: true, episodicMode: 'off', episodicCanaryUserIds: '', semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
lifecycle: { enabled: false, dedupeEnabled: true, conflictReview: true, decayEnabled: false, forgettingEnabled: true, compactIntervalHours: '24' },
persona: { enabled: false, provider: 'none', shadowMode: true, maxTokens: '400', cacheTtlSeconds: '300' },
graph: { enabled: false, provider: 'postgres', maxDepth: '2', relationLimit: '20' },
userMemory: { enabled: false, reviewEnabled: true, correctionEnabled: true, pinEnabled: true, forgetEnabled: true, deletePropagation: true },
pluginHealth: { enabled: false, intervalSeconds: '60', timeoutMs: '1500', failureThreshold: '3', autoFallback: true },
pgvector: {},
qdrant: {},
weaviate: {},
@@ -177,6 +374,15 @@ function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2
return {
global: { ...base.global, ...(config?.global ?? {}) },
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
candidateMemory: { ...base.candidateMemory, ...(config?.candidateMemory ?? {}) },
runtimeControl: { ...base.runtimeControl, ...(config?.runtimeControl ?? {}) },
policy: { ...base.policy, ...(config?.policy ?? {}) },
retriever: { ...base.retriever, ...(config?.retriever ?? {}) },
lifecycle: { ...base.lifecycle, ...(config?.lifecycle ?? {}) },
persona: { ...base.persona, ...(config?.persona ?? {}) },
graph: { ...base.graph, ...(config?.graph ?? {}) },
userMemory: { ...base.userMemory, ...(config?.userMemory ?? {}) },
pluginHealth: { ...base.pluginHealth, ...(config?.pluginHealth ?? {}) },
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
@@ -234,12 +440,16 @@ function describeEffectiveModelSource(
export function MemoryV2Page() {
const [payload, setPayload] = useState<MemoryV2AdminConfigResponse | null>(null);
const [runtimeStatus, setRuntimeStatus] = useState<MemoryV2RuntimeStatusResponse | null>(null);
const [candidatePayload, setCandidatePayload] = useState<PersonalMemoryCandidateListResponse | null>(null);
const [candidateError, setCandidateError] = useState<string | null>(null);
const [candidateBusyId, setCandidateBusyId] = useState<string | null>(null);
const [draft, setDraft] = useState<MemoryV2AdminConfig>(defaultConfig());
const [providerKeys, setProviderKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
const [loading, setLoading] = useState(true);
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null);
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | CapabilityKey | BackendKey | 'reload' | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [expandedBackends, setExpandedBackends] = useState<Record<string, boolean>>({});
@@ -250,15 +460,24 @@ export function MemoryV2Page() {
setError(null);
setMessage(null);
try {
const [configResult, modelOptions] = await Promise.all([
const [configResult, modelOptions, statusResult] = await Promise.all([
getMemoryV2AdminConfig(),
listMemoryV2ModelOptions(),
getMemoryV2RuntimeStatus().catch(() => null),
]);
setPayload(configResult);
setDraft(normalizeConfig(configResult.config));
setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active'));
setGlobalModelSettings(modelOptions.global);
setSupportedApiTypes(modelOptions.supportedApiTypes);
setRuntimeStatus(statusResult);
try {
setCandidatePayload(await listPersonalMemoryCandidates());
setCandidateError(null);
} catch (candidateErr) {
setCandidatePayload(null);
setCandidateError(candidateErr instanceof Error ? candidateErr.message : '候选记忆加载失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
} finally {
@@ -309,6 +528,22 @@ export function MemoryV2Page() {
}));
};
const updateCapabilityFlag = (capability: CapabilityKey, key: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
[capability]: { ...current[capability], [key]: event.target.checked },
}));
};
const updateCapabilityValue = (capability: CapabilityKey, key: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[capability]: { ...current[capability], [key]: event.target.value },
}));
};
const updateBackendValue = (backend: BackendKey, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
@@ -368,7 +603,7 @@ export function MemoryV2Page() {
const saveConfigSection = async (
patch: Partial<MemoryV2AdminConfig>,
scope: 'global' | 'chatIntentRouter' | BackendKey,
scope: 'global' | 'chatIntentRouter' | CapabilityKey | BackendKey,
successMessage: string,
) => {
setBusyScope(scope);
@@ -408,6 +643,28 @@ export function MemoryV2Page() {
);
};
const saveCapabilityConfig = async (capability: CapabilityKey) => {
await saveConfigSection(
{ [capability]: draft[capability] },
capability,
`${CAPABILITIES.find((item) => item.key === capability)?.label ?? capability} 配置已保存。`,
);
};
const reviewCandidate = async (id: string, action: 'accept' | 'reject') => {
setCandidateBusyId(id);
setCandidateError(null);
try {
await reviewPersonalMemoryCandidate(id, action);
setCandidatePayload(await listPersonalMemoryCandidates());
setMessage(action === 'accept' ? '候选记忆已接纳。' : '候选记忆已拒绝。');
} catch (err) {
setCandidateError(err instanceof Error ? err.message : '候选记忆处理失败');
} finally {
setCandidateBusyId(null);
}
};
const current = payload?.config ? normalizeConfig(payload.config) : draft;
const routerSection = draft.chatIntentRouter;
const currentRouterSection = current.chatIntentRouter;
@@ -658,6 +915,182 @@ export function MemoryV2Page() {
</section>
</div>
<h3 className="model-center-section-title">Personal Memory </h3>
<p className="model-center-card-note memory-v2-backend-intro">
Memory V2
</p>
<div className="asset-plugin-grid memory-v2-capability-grid">
{CAPABILITIES.map((capability, index) => {
const section = draft[capability.key];
const currentSection = current[capability.key];
const enabled = Boolean(section.enabled);
const accent = BACKEND_ACCENTS[index % BACKEND_ACCENTS.length];
return (
<section
key={capability.key}
className={`asset-plugin-card asset-plugin-card--${accent} model-center-card memory-v2-card`}
>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{capability.icon}</div>
<div>
<h3>{capability.label}</h3>
<p>{capability.description}</p>
</div>
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
{enabled ? '配置已启用' : '配置未启用'}
</span>
</div>
<div className="admin-form-grid memory-v2-fields">
{capability.fields.map((field) => {
if (field.type === 'boolean') {
return (
<label key={field.key} className="inline-check">
<input
type="checkbox"
checked={Boolean(section[field.key])}
onChange={updateCapabilityFlag(capability.key, field.key)}
/>
{field.label}
</label>
);
}
if (field.type === 'select') {
return (
<label key={field.key}>
<span>{field.label}</span>
<select
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, field.key)}
>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
);
}
if (field.type === 'text') {
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type="text"
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, field.key)}
/>
</label>
);
}
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type="number"
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, field.key)}
/>
</label>
);
})}
</div>
<div className="asset-plugin-footer">
{capability.key === 'pluginHealth' && (
<div className="memory-v2-runtime-health">
<strong></strong>
{runtimeStatus?.memory?.personalMemory ? (
<span>
{String(runtimeStatus.memory.personalMemory.health?.state ?? 'unknown')}
{' · '} {runtimeStatus.memory.personalMemory.effectiveMode ?? 'off'}
{runtimeStatus.memory.personalMemory.autoReviewEnabled ? ' · 自动审核' : ' · 人工审核'}
{' · '} {runtimeStatus.memory.personalMemory.pendingCandidates ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.accepted ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.autoReviewed ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.rejected ?? 0}
{' · '} {runtimeStatus.memory.personalMemory.health?.deduped ?? 0}
</span>
) : (
<span> Shadow Pipeline</span>
)}
</div>
)}
<span className={`asset-status ${Boolean(currentSection.enabled) ? 'is-on' : ''}`}>
{Boolean(currentSection.enabled) ? '启用' : '关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveCapabilityConfig(capability.key)}
>
{busyScope === capability.key ? '保存中...' : `保存${capability.label}`}
</button>
</div>
</section>
);
})}
</div>
<h3 className="model-center-section-title"></h3>
<section className="asset-plugin-card model-center-card memory-v2-card memory-v2-candidate-review">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">C</div>
<div>
<h3></h3>
<p>
{candidatePayload?.counts?.candidate ?? 0}
{' · '} {candidatePayload?.counts?.accepted ?? 0}
{' · '} {candidatePayload?.counts?.rejected ?? 0}
</p>
<p className="model-center-card-note">
Active Shadow / Canary
</p>
</div>
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={loading || candidateBusyId !== null}>
</button>
</div>
{candidateError && <p className="banner banner-error">{candidateError}</p>}
{!candidateError && (candidatePayload?.items.length ?? 0) === 0 && (
<p className="model-center-card-note">Active Shadow / Canary </p>
)}
<div className="memory-v2-candidate-list">
{(candidatePayload?.items ?? []).map((candidate) => (
<article key={candidate.id} className="memory-v2-candidate-item">
<div className="memory-v2-candidate-meta">
<span className="asset-status is-on">{candidate.memoryType}</span>
<span> {candidate.userId}</span>
<span> {candidate.importance.toFixed(2)}</span>
<span> {candidate.confidence.toFixed(2)}</span>
<span>{new Date(candidate.createdAt).toLocaleString('zh-CN', { hour12: false })}</span>
</div>
<p>{candidate.content}</p>
<small>Policy: {candidate.policyReason} · Evidence: {String(candidate.evidence?.sourceId ?? 'unknown')}</small>
<div className="asset-plugin-footer">
<button
type="button"
className="ghost-btn"
disabled={candidateBusyId !== null}
onClick={() => void reviewCandidate(candidate.id, 'reject')}
>
{candidateBusyId === candidate.id ? '处理中...' : '拒绝'}
</button>
<button
type="button"
className="send-btn"
disabled={candidateBusyId !== null}
onClick={() => void reviewCandidate(candidate.id, 'accept')}
>
{candidateBusyId === candidate.id ? '处理中...' : '接纳'}
</button>
</div>
</article>
))}
</div>
</section>
<h3 className="model-center-section-title">Backend </h3>
<p className="model-center-card-note memory-v2-backend-intro">
backend backend
+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() {
+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>
);
}
+69 -1
View File
@@ -25,13 +25,19 @@ import type {
LlmProviderKeyRow,
PlanDefinition,
PlanSyncResult,
PersonalMemoryCandidateListResponse,
PolicyDefinition,
PolicyMap,
PortalUser,
MindSpaceAdminConfig,
MindSpaceAnalyticsUpdate,
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
@@ -42,6 +48,8 @@ import type {
WechatScheduleLlmConfig,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
} from '../types';
export class ApiError extends Error {
@@ -300,6 +308,7 @@ export async function updateAssetPluginConfig(
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)}`, {
@@ -313,8 +322,18 @@ export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
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',
@@ -327,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> {
@@ -336,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[];
@@ -611,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[]> {
+156 -2
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;
}
@@ -1080,6 +1125,60 @@ body,
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;
@@ -1217,6 +1316,10 @@ body,
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;
@@ -1322,18 +1425,69 @@ body,
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-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.memory-v2-backend-grid {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+145
View File
@@ -208,6 +208,12 @@ export type AssetPluginConfig = {
llmProviderId: string | null;
llmModel: string | null;
updatedAt: number | null;
purposes?: Record<string, boolean>;
purposeCatalog?: Array<{
id: string;
label: string;
presetId: string;
}>;
};
export type AssetGatewayConfig = {
@@ -281,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>;
@@ -296,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;
@@ -312,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 = {
@@ -516,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;
};