Compare commits

..

22 Commits

Author SHA1 Message Date
john 458f291985 docs: clarify Rybbit env requirements on Analytics config page
Explain that Rybbit reuses the stored anonymization secret and needs Portal
MEMIND_RYBBIT_* environment variables.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:10:07 +08:00
john 65a6d7254b merge: gate memind-lib DeepSeek catalog on deepseek-v4 models 2026-07-26 10:13:45 +08:00
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 afc667a9ce merge: 合入任务编排管理页
将 codex/orchestrator-shadow-ui 的 Orchestrator 控制台与路由测试合入主线。
2026-07-25 10:10:14 +08:00
john 10e24be243 test: cover orchestrator admin boundary 2026-07-25 07:20:25 +08:00
john 2596c8f2c6 feat: add orchestrator controls to memindadm 2026-07-25 07:12:53 +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
30 changed files with 2313 additions and 1681 deletions
+5
View File
@@ -42,5 +42,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn
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
-51
View File
@@ -1,51 +0,0 @@
name: memind_adm CI
on:
push:
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: memind-adm-ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
name: Test, build, and release script checks
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out exact commit
run: |
git init .
git remote add origin https://git.tkmind.cn/tkmind/memind_adm.git
git fetch --depth=2 origin "${{ gitea.sha }}"
git checkout --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${{ gitea.sha }}"
- name: Install locked dependencies
run: npm ci --no-audit --no-fund
- name: Check patch formatting
run: git diff --check HEAD^ HEAD
- name: Run Admin API tests
run: npm test
- name: Build production frontend
run: VITE_BASE_PATH=/ops npm run build
- name: Check release scripts
run: |
bash -n scripts/release-prod.sh
bash -n scripts/check-gray-ready.sh
bash -n scripts/deploy-gray-103.sh
- name: Confirm build leaves the worktree clean
run: test -z "$(git status --porcelain=v1 --untracked-files=all)"
+1 -5
View File
@@ -8,9 +8,5 @@ dist-ssr/
memind-lib/
.vite/
*.log
logs/
.adm-api.pid
.adm-dev.pid
.adm-preview.pid
*.pid
release-candidates/
logs/
+1
View File
@@ -0,0 +1 @@
41863
+2 -34
View File
@@ -83,42 +83,10 @@ nginx 示例见 `scripts/gadm-nginx.conf.example``/ops/` → preview`/auth
## 唯一入口
```bash
# 只生成持久候选包;不连接、不修改 103
TEST_MEMIND_ROOT=<clean-Memind-worktree> \
IMAGE_MAKE_RELEASE_ID=20260720-standalone-rc1 \
bash scripts/release-prod.sh --package-only
# 候选包生成后,只读检查 103 灰度前置条件
bash scripts/check-gray-ready.sh --candidate-dir release-candidates/<release-id>
# 明确批准后,部署 103 loopback-only 灰度实例;不切换 live
bash scripts/deploy-gray-103.sh \
--candidate-dir release-candidates/<release-id> \
--yes
# 正式生产发布;必须在灰度通过并得到明确许可后执行
bash scripts/release-prod.sh --dry-run
bash scripts/release-prod.sh
```
`--dry-run` 是 `--package-only` 的兼容别名。候选包默认保存在
`release-candidates/<release-id>/`,包含 tar 包、manifest 和 SHA-256 校验文件。
发包时 `Memind` 共享源码必须满足:
1. 是 Git 工作区且完全干净。
2. `HEAD` 精确匹配 `MEMIND_BASE_REF`(默认 `origin/main`)。
3. 包含 `asset-gateway.mjs`、`image-make-admin-config.mjs`、`image-make-client.mjs`。
4. manifest 明确记录 `IMAGE_MAKE_RELEASE_ID`,避免后台候选与错误的 image_make 版本配套。
灰度分两层:
1. `check-gray-ready.sh` 默认只做后台候选包、103 当前健康状态、灰度端口和 image_make 版本的只读预检。
2. `--require-image-integration` 额外要求 Portal runtime-config 路由、image_make 配置 URL 和 worker 均已就绪。该条件涉及生产运行态,未经单独许可不得开启。
3. `deploy-gray-103.sh` 只在 `127.0.0.1:15174/18085` 启动独立灰度实例,不替换 live,不重启 `5174/8085`,并设置 `ADM_READ_ONLY_MODE=1` 阻断管理后台与 Plaza API 写操作。
4. 灰度页面默认只能通过 SSH 端口转发访问,不增加 105 nginx/Caddy 路由,也不产生公网入口。
`release-prod.sh` 没有“灰度即生产”的隐式行为:不带 `--package-only` 时就是正式 live 原子切换,必须等灰度验收通过后再执行。
## 产物发布流程
1. 本地生成 `memind-adm-<release-id>.tar.gz` 和发布清单。
@@ -126,7 +94,7 @@ bash scripts/release-prod.sh
3. 103 备份当前 `/Users/john/Project/memind_adm` 为时间戳压缩包。
4. 在 `/Users/john/Project/releases/` 下解包新版本。
5. 从当前 live 目录继承运行态配置与日志文件。
6. 在 release 目录安装锁定依赖;前端 `dist/` 已在本地构建完成,103 不执行前端构建。
6. 在 release 目录安装依赖、构建。
7. 原子切换 `memind_adm` live 目录。
8. 调用 `scripts/remote_restart.sh` 重启 `8085` 和 `5174`。
9. 检查 `127.0.0.1:8085/health` 与 `127.0.0.1:5174/`。
+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",
+2 -2
View File
@@ -7,13 +7,12 @@
"dev": "node scripts/dev.mjs",
"dev:web": "vite",
"dev:server": "node server/index.mjs",
"test": "node --test server/*.test.mjs",
"test:image-make-admin": "node --test server/app.image-make.test.mjs",
"admin:init": "node scripts/init-admin.mjs",
"release:prod": "bash scripts/release-prod.sh",
"local_restart": "bash scripts/local_restart.sh",
"pro_restart": "bash scripts/pro_restart.sh",
"build": "vite build",
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
"preview": "node scripts/preview.mjs",
"dev:preview": "vite preview"
},
@@ -31,6 +30,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": {
-5
View File
@@ -1,5 +1,4 @@
.git
.gitea
.gitignore
.DS_Store
.env
@@ -8,12 +7,10 @@
.adm-api.pid
.adm-dev.pid
.adm-preview.pid
*.pid
.claude
.mindops
node_modules
coverage
release-candidates
*.log
adm-preview.log
adm-api.log
@@ -36,8 +33,6 @@ scripts/local_restart.sh
scripts/pro_restart.sh
scripts/rsync_to_server.sh
scripts/audit-103-state.sh
scripts/check-gray-ready.sh
scripts/deploy-gray-103.sh
scripts/gadm-nginx-105.conf
scripts/gadm-nginx.conf.example
scripts/gadm-lb.Caddyfile
-166
View File
@@ -1,166 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-10.10.0.2}"
GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}"
GRAY_API_PORT="${GRAY_API_PORT:-18085}"
REQUIRE_IMAGE_INTEGRATION=0
CANDIDATE_DIR=""
usage() {
cat <<'EOF'
Usage:
bash scripts/check-gray-ready.sh --candidate-dir DIR [--require-image-integration]
Read-only checks only. This script never uploads, restarts, or changes 103.
Checks:
- candidate checksum and traceability manifest
- current memind_adm production health
- reserved gray ports are free on 103
- expected image_make release is healthy on 103
- optional end-to-end Portal/image_make runtime wiring readiness
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--candidate-dir)
shift
[[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; }
CANDIDATE_DIR="$1"
;;
--require-image-integration) REQUIRE_IMAGE_INTEGRATION=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; }
if [[ "${CANDIDATE_DIR}" != /* ]]; then
CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}"
fi
[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; }
manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)"
sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)"
bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)"
[[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || {
echo "Candidate bundle, manifest, or checksum file missing in ${CANDIDATE_DIR}" >&2
exit 1
}
(
cd "${CANDIDATE_DIR}"
shasum -a 256 -c "$(basename "${sha_file}")"
)
if tar -tzf "${bundle}" | grep -Eq '(^|/)(\.env($|\.)|[^/]*\.pid$|src/|release-candidates/)'; then
echo "Candidate contains forbidden runtime or development assets" >&2
tar -tzf "${bundle}" | grep -E '(^|/)(\.env($|\.)|[^/]*\.pid$|src/|release-candidates/)' >&2 || true
exit 1
fi
manifest_value() {
local key="$1"
sed -n "s/^${key}=//p" "${manifest}" | head -n 1
}
release_id="$(manifest_value release_id)"
git_head="$(manifest_value git_head)"
memind_head="$(manifest_value memind_git_head)"
memind_status="$(manifest_value memind_git_status)"
image_make_release_id="$(manifest_value image_make_release_id)"
[[ -n "${release_id}" && -n "${git_head}" && -n "${memind_head}" ]] || {
echo "Candidate manifest is missing release traceability fields" >&2
exit 1
}
[[ "${memind_status}" == "clean" ]] || {
echo "Candidate manifest does not prove a clean Memind shared source" >&2
exit 1
}
[[ "${image_make_release_id}" != "not-declared" && -n "${image_make_release_id}" ]] || {
echo "Candidate manifest must declare IMAGE_MAKE_RELEASE_ID" >&2
exit 1
}
echo "candidate_release=${release_id}"
echo "candidate_git_head=${git_head}"
echo "candidate_memind_head=${memind_head}"
echo "candidate_image_make_release=${image_make_release_id}"
set +e
remote_result="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
"GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' IMAGE_MAKE_RELEASE_ID='${image_make_release_id}' REQUIRE_IMAGE_INTEGRATION='${REQUIRE_IMAGE_INTEGRATION}' /bin/bash" <<'REMOTE'
set -euo pipefail
api_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
web_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
image_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/health || true)"
[[ "${api_code}" == "200" && "${web_code}" == "200" && "${image_code}" == "200" ]] || {
echo "health_failed admin_api=${api_code} admin_web=${web_code} image_make=${image_code}" >&2
exit 1
}
for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then
echo "gray_port_in_use=${port}" >&2
exit 1
fi
done
export DOCKER_HOST=unix:///Users/john/.colima/default/docker.sock
docker_bin=/usr/local/bin/docker
container="$(${docker_bin} ps --format '{{.Names}} {{.Ports}}' | awk '/18083/ {print $1; exit}')"
[[ -n "${container}" ]] || { echo "image_make_container_missing" >&2; exit 1; }
image="$(${docker_bin} inspect --format '{{.Config.Image}}' "${container}")"
health="$(${docker_bin} inspect --format '{{.State.Health.Status}}' "${container}")"
[[ "${image}" == *"${IMAGE_MAKE_RELEASE_ID}"* && "${health}" == "healthy" ]] || {
echo "image_make_release_mismatch image=${image} health=${health}" >&2
exit 1
}
worker_enabled="$(${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | sed -n 's/^WORKER_ENABLED=//p' | head -n 1)"
config_url_present=0
if ${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | grep -q '^IMAGE_MAKE_MEMIND_CONFIG_URL=.'; then
config_url_present=1
fi
portal_route_present=0
if grep -q '/internal/image-make/runtime-config' /Users/john/Project/Memind/server.mjs 2>/dev/null; then
portal_route_present=1
fi
echo "admin_api=200 admin_web=200 image_make=200"
echo "gray_ports_free=${GRAY_WEB_PORT},${GRAY_API_PORT}"
echo "image_make_image=${image}"
echo "image_make_worker_enabled=${worker_enabled:-unset}"
echo "image_make_config_url_present=${config_url_present}"
echo "portal_runtime_route_present=${portal_route_present}"
if [[ "${REQUIRE_IMAGE_INTEGRATION}" == "1" ]]; then
[[ "${worker_enabled}" == "true" && "${config_url_present}" == "1" && "${portal_route_present}" == "1" ]] || {
echo "image_make_integration_not_ready" >&2
exit 1
}
fi
REMOTE
)"
remote_status=$?
set -e
printf '%s\n' "${remote_result}"
if [[ "${remote_status}" -ne 0 ]]; then
echo "gray_preflight=failed" >&2
exit "${remote_status}"
fi
echo "gray_preflight=passed"
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-10.10.0.2}"
GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}"
GRAY_API_PORT="${GRAY_API_PORT:-18085}"
REMOTE_GRAY_ROOT="${REMOTE_GRAY_ROOT:-/Users/john/Project/gray/memind_adm}"
CANDIDATE_DIR=""
AUTO_YES=0
usage() {
cat <<'EOF'
Usage:
bash scripts/deploy-gray-103.sh --candidate-dir DIR [--yes]
Deploys a loopback-only, read-only memind_adm gray instance on 103.
It does not replace /Users/john/Project/memind_adm and does not restart live ports 5174/8085.
Defaults:
gray web: 127.0.0.1:15174/ops/
gray API: 127.0.0.1:18085
The --yes flag is mandatory because this writes a separate gray release to 103.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--candidate-dir)
shift
[[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; }
CANDIDATE_DIR="$1"
;;
--yes|-y) AUTO_YES=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; }
if [[ "${CANDIDATE_DIR}" != /* ]]; then
CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}"
fi
[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; }
manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)"
sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)"
bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)"
[[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || {
echo "Candidate bundle, manifest, or checksum file missing" >&2
exit 1
}
release_id="$(sed -n 's/^release_id=//p' "${manifest}" | head -n 1)"
[[ "${release_id}" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "Invalid release_id in manifest: ${release_id}" >&2
exit 1
}
bash "${ROOT}/scripts/check-gray-ready.sh" --candidate-dir "${CANDIDATE_DIR}"
if [[ "${AUTO_YES}" -ne 1 ]]; then
echo
echo "Gray deployment is ready but was not started."
echo "Target: ${HOST} ${REMOTE_GRAY_ROOT}/releases/${release_id}"
echo "Ports: 127.0.0.1:${GRAY_WEB_PORT}, 127.0.0.1:${GRAY_API_PORT}"
echo "Re-run with --yes after explicit approval."
exit 2
fi
remote_incoming="${REMOTE_GRAY_ROOT}/incoming/${release_id}"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "mkdir -p '${remote_incoming}'"
scp -q "${bundle}" "${manifest}" "${sha_file}" "${HOST}:${remote_incoming}/"
ssh -o BatchMode=yes "${HOST}" \
"RELEASE_ID='${release_id}' GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' REMOTE_GRAY_ROOT='${REMOTE_GRAY_ROOT}' /bin/bash" <<'REMOTE'
set -euo pipefail
LIVE_DIR=/Users/john/Project/memind_adm
INCOMING_DIR="${REMOTE_GRAY_ROOT}/incoming/${RELEASE_ID}"
RELEASE_DIR="${REMOTE_GRAY_ROOT}/releases/${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256"
stop_gray_ports() {
lsof -ti "TCP:${GRAY_WEB_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
lsof -ti "TCP:${GRAY_API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
}
rollback_gray() {
stop_gray_ports
echo "Gray startup failed; live memind_adm was not changed." >&2
}
trap rollback_gray ERR
live_api_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
live_web_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
[[ "${live_api_before}" == "200" && "${live_web_before}" == "200" ]] || {
echo "Live memind_adm is not healthy before gray deployment" >&2
exit 1
}
for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then
echo "Gray port is already in use: ${port}" >&2
exit 1
fi
done
cd "${INCOMING_DIR}"
shasum -a 256 -c "$(basename "${SHA_FILE}")"
rm -rf "${RELEASE_DIR}"
mkdir -p "${RELEASE_DIR}"
tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}"
cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt"
[[ -f "${LIVE_DIR}/.env" ]] || { echo "Live .env is missing" >&2; exit 1; }
cp "${LIVE_DIR}/.env" "${RELEASE_DIR}/.env"
chmod 600 "${RELEASE_DIR}/.env"
[[ -d "${RELEASE_DIR}/dist" ]] || { echo "Gray release missing dist/" >&2; exit 1; }
[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "Gray release contains src/" >&2; exit 1; }
[[ -f "${RELEASE_DIR}/server/index.mjs" ]] || { echo "Gray release missing Admin API" >&2; exit 1; }
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
cd "${RELEASE_DIR}"
if [[ -f package-lock.json ]]; then
npm ci --no-audit --no-fund
else
npm install --no-audit --no-fund
fi
set -a
# shellcheck disable=SC1091
source "${RELEASE_DIR}/.env"
set +a
export ADM_API_PORT="${GRAY_API_PORT}"
export ADM_PORT="${GRAY_WEB_PORT}"
export ADM_API_HOST=127.0.0.1
export ADM_WEB_HOST=127.0.0.1
export ADM_DEV_BACKEND="http://127.0.0.1:${GRAY_API_PORT}"
export VITE_BASE_PATH=/ops
export ADM_READ_ONLY_MODE=1
nohup node server/index.mjs >>"${RELEASE_DIR}/adm-gray-api.log" 2>&1 &
echo $! >"${RELEASE_DIR}/.adm-gray-api.pid"
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${GRAY_API_PORT}/health" >/dev/null; then
api_ready=1
break
fi
sleep 1
done
[[ "${api_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-api.log" >&2 || true; exit 1; }
nohup ./node_modules/.bin/vite preview --config scripts/vite-preview.config.mjs \
>>"${RELEASE_DIR}/adm-gray-web.log" 2>&1 &
echo $! >"${RELEASE_DIR}/.adm-gray-web.pid"
web_ready=0
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${GRAY_WEB_PORT}/ops/" >/dev/null; then
web_ready=1
break
fi
sleep 1
done
[[ "${web_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-web.log" >&2 || true; exit 1; }
live_api_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
live_web_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
[[ "${live_api_after}" == "200" && "${live_web_after}" == "200" ]] || {
echo "Live memind_adm health changed during gray startup" >&2
exit 1
}
cat >"${RELEASE_DIR}/GRAY_DEPLOYMENT.txt" <<EOF
release_id=${RELEASE_ID}
deployed_at=$(date '+%Y-%m-%d %H:%M:%S %z')
web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/
api=http://127.0.0.1:${GRAY_API_PORT}
read_only=true
live_switched=false
EOF
trap - ERR
echo "gray_release_dir=${RELEASE_DIR}"
echo "gray_web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/"
echo "gray_api=http://127.0.0.1:${GRAY_API_PORT}"
echo "gray_read_only=true"
echo "live_memind_adm_unchanged=true"
REMOTE
echo
echo "Gray instance deployed without switching live."
echo "Open through SSH forwarding:"
echo " ssh -L ${GRAY_WEB_PORT}:127.0.0.1:${GRAY_WEB_PORT} -L ${GRAY_API_PORT}:127.0.0.1:${GRAY_API_PORT} john@${HOST}"
+64 -85
View File
@@ -22,10 +22,7 @@ MANIFEST_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.sha256"
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../Memind}"
MEMIND_LIB="${ROOT}/memind-lib"
MEMIND_BASE_REF="${MEMIND_BASE_REF:-origin/main}"
IMAGE_MAKE_RELEASE_ID="${IMAGE_MAKE_RELEASE_ID:-not-declared}"
PACKAGE_ONLY=0
OUTPUT_DIR=""
DRY_RUN=0
SKIP_BUILD=0
AUTO_YES=0
@@ -37,19 +34,15 @@ trap cleanup EXIT
usage() {
cat <<'EOF'
用法:
bash scripts/release-prod.sh [--package-only|--dry-run] [--output-dir DIR] [--skip-build] [--yes]
bash scripts/release-prod.sh [--dry-run] [--skip-build] [--yes]
说明:
1. 校验 memind_adm 与 Memind 共享源码均来自干净、可追溯的 commit
2. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码)
3. --package-only / --dry-run 只生成持久候选包,不连接或修改 103
4. 正式模式上传到 103,完成全量备份、原子切换、重启和健康检查
5. live 目录移入 archives,103 不再保留可编辑源码树
环境:
TEST_MEMIND_ROOT 干净的 Memind 共享源码目录,默认 ../Memind
MEMIND_BASE_REF Memind 共享源码必须精确匹配的 ref,默认 origin/main
IMAGE_MAKE_RELEASE_ID 写入 manifest 的 image_make 依赖版本
1. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码)
2. 上传到 103
3. 103 全量备份当前 memind_adm
4. 解包、安装依赖(不在 103 构建前端)
5. 原子切换 live 目录并重启验证
6. 旧 live 目录移入 archives,103 不再保留可编辑源码树
禁止:
- 直接 rsync 到 103
@@ -60,12 +53,7 @@ EOF
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run|--package-only) PACKAGE_ONLY=1 ;;
--output-dir)
shift
[[ $# -gt 0 ]] || { echo "--output-dir 缺少目录参数" >&2; exit 2; }
OUTPUT_DIR="$1"
;;
--dry-run) DRY_RUN=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--yes|-y) AUTO_YES=1 ;;
-h|--help)
@@ -97,60 +85,35 @@ 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
git -C "${MEMIND_SRC}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "Memind 共享源码不是可追溯 Git 工作区: ${MEMIND_SRC}" >&2
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
}
if [[ -n "$(git -C "${MEMIND_SRC}" status --porcelain=v1 --untracked-files=all)" ]]; then
echo "Memind 共享源码工作区不干净,禁止打包: ${MEMIND_SRC}" >&2
git -C "${MEMIND_SRC}" status --short --untracked-files=all >&2
exit 1
fi
git -C "${MEMIND_SRC}" rev-parse --verify --quiet "${MEMIND_BASE_REF}" >/dev/null || {
echo "Memind 共享源码缺少基线 ref: ${MEMIND_BASE_REF}" >&2
[[ "${ROOT_GIT_HEAD}" == "${ROOT_ORIGIN_HEAD}" ]] || {
echo "memindadm main 必须与 origin/main 完全一致" >&2
exit 1
}
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD)"
MEMIND_BASE_HEAD="$(git -C "${MEMIND_SRC}" rev-parse "${MEMIND_BASE_REF}")"
if [[ "${MEMIND_GIT_HEAD}" != "${MEMIND_BASE_HEAD}" ]]; then
echo "Memind 共享源码必须精确匹配 ${MEMIND_BASE_REF}" >&2
echo " HEAD=${MEMIND_GIT_HEAD}" >&2
echo " ${MEMIND_BASE_REF}=${MEMIND_BASE_HEAD}" >&2
exit 1
fi
for required_shared_file in \
user-auth.mjs \
asset-gateway.mjs \
image-make-admin-config.mjs \
image-make-client.mjs; do
[[ -f "${MEMIND_SRC}/${required_shared_file}" ]] || {
echo "Memind 共享源码缺少发布依赖: ${required_shared_file}" >&2
exit 1
}
done
[[ -f "${IGNORE_FILE}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
}
if [[ "${PACKAGE_ONLY}" -ne 1 ]]; then
say "103 正式发布预检查"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null
fi
say "本地预检查"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "本地构建 dist103 不再执行前端构建)"
@@ -160,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
@@ -170,7 +139,23 @@ fi
exit 1
}
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current 2>/dev/null || echo detached)"
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}"
@@ -199,14 +184,6 @@ fi
echo "memind-lib 缺少 user-auth.mjs" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/image-make-admin-config.mjs" ]] || {
echo "memind-lib 缺少 image_make 管理配置模块" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/image-make-client.mjs" ]] || {
echo "memind-lib 缺少 image_make 客户端模块" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/skills/excel-analyst/SKILL.md" ]] || {
echo "memind-lib 缺少 Excel Analyst 技能" >&2
exit 1
@@ -219,8 +196,24 @@ 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 && "${PACKAGE_ONLY}" -ne 1 ]]; then
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
echo "目标主机: ${HOST}"
echo "目标目录: ${APP_DIR}"
@@ -242,10 +235,6 @@ say "生成发布清单"
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 "memind_base_ref=${MEMIND_BASE_REF}"
echo "memind_base_head=${MEMIND_BASE_HEAD}"
echo "memind_git_status=clean"
echo "image_make_release_id=${IMAGE_MAKE_RELEASE_ID}"
echo "git_status_begin"
git -C "${ROOT}" status --short --branch || true
echo "git_status_end"
@@ -260,19 +249,9 @@ tar \
checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')"
printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
if [[ "${PACKAGE_ONLY}" -eq 1 ]]; then
if [[ -z "${OUTPUT_DIR}" ]]; then
OUTPUT_DIR="${ROOT}/release-candidates/${RELEASE_ID}"
elif [[ "${OUTPUT_DIR}" != /* ]]; then
OUTPUT_DIR="${ROOT}/${OUTPUT_DIR}"
fi
mkdir -p "${OUTPUT_DIR}"
cp "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${OUTPUT_DIR}/"
say "候选包生成完成;未连接或修改 103"
echo "candidate_dir=${OUTPUT_DIR}"
ls -lh "${OUTPUT_DIR}/$(basename "${BUNDLE_PATH}")" \
"${OUTPUT_DIR}/$(basename "${MANIFEST_PATH}")" \
"${OUTPUT_DIR}/$(basename "${SHA_PATH}")"
if [[ "${DRY_RUN}" -eq 1 ]]; then
say "dry-run 完成"
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
exit 0
fi
+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"
-149
View File
@@ -1,149 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdminApp } from './app.mjs';
function createServices({ role = 'admin', imageMakeAdminConfigService } = {}) {
return {
ready: Promise.resolve(),
parseCookies: () => ({ adm_session: 'test-token' }),
USER_COOKIE: 'adm_session',
userLoginCookies: () => ['adm_session=test-token; Path=/; HttpOnly'],
clearUserSessionCookie: () => ['adm_session=; Path=/; Max-Age=0'],
resolveCookieDomainForRequest: () => null,
userAuth: {
getMe: async () => ({ id: 'admin-user-id', username: 'admin', role }),
},
imageMakeAdminConfigService,
};
}
async function withServer(services, callback) {
const app = createAdminApp(services);
const server = await new Promise((resolve) => {
const instance = app.listen(0, '127.0.0.1', () => resolve(instance));
});
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await callback(baseUrl);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
}
const adminHeaders = {
cookie: 'adm_session=test-token',
'content-type': 'application/json',
};
test('image_make admin routes read and update config with the admin identity', async () => {
let updateCall = null;
const service = {
getAdminConfig: async () => ({
source: 'default',
config: {
defaultProvider: 'mock',
jobDefaultTimeoutSeconds: 120,
providers: { mock: { enabled: true }, aliyun_bailian: { enabled: false }, comfyui: { enabled: false } },
},
}),
updateAdminConfig: async (patch, context) => {
updateCall = { patch, context };
return { ok: true, source: 'admin-db', config: patch };
},
getRuntimeConfig: async () => ({
ok: true,
fingerprint: '1234567890abcdef',
defaultProvider: 'aliyun_bailian',
jobDefaultTimeoutSeconds: 120,
providers: {
mock: { enabled: true },
aliyun_bailian: {
enabled: true,
model: 'qwen-image-plus',
apiBase: 'https://dashscope.aliyuncs.com/api/v1',
apiKey: 'must-not-leak',
},
comfyui: { enabled: false },
},
}),
};
await withServer(createServices({ imageMakeAdminConfigService: service }), async (baseUrl) => {
const configResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
assert.equal(configResponse.status, 200);
assert.equal((await configResponse.json()).config.defaultProvider, 'mock');
const patch = { defaultProvider: 'mock', jobDefaultTimeoutSeconds: 180 };
const updateResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, {
method: 'PATCH',
headers: adminHeaders,
body: JSON.stringify(patch),
});
assert.equal(updateResponse.status, 200);
assert.deepEqual(updateCall, { patch, context: { updatedBy: 'admin-user-id' } });
const runtimeResponse = await fetch(`${baseUrl}/admin-api/image-make/runtime`, { headers: adminHeaders });
assert.equal(runtimeResponse.status, 200);
const runtime = await runtimeResponse.json();
assert.equal(runtime.providers.aliyun_bailian.apiKeyConfigured, true);
assert.equal('apiKey' in runtime.providers.aliyun_bailian, false);
assert.equal(JSON.stringify(runtime).includes('must-not-leak'), false);
});
});
test('image_make admin config validation failures are returned as 400', async () => {
const service = {
updateAdminConfig: async () => ({ ok: false, message: '默认 Provider 必须处于启用状态' }),
};
await withServer(createServices({ imageMakeAdminConfigService: service }), async (baseUrl) => {
const response = await fetch(`${baseUrl}/admin-api/image-make/config`, {
method: 'PATCH',
headers: adminHeaders,
body: JSON.stringify({ defaultProvider: 'comfyui' }),
});
assert.equal(response.status, 400);
assert.equal((await response.json()).message, '默认 Provider 必须处于启用状态');
});
});
test('image_make admin routes require an admin user and an enabled service', async () => {
await withServer(createServices({ role: 'user', imageMakeAdminConfigService: {} }), async (baseUrl) => {
const forbidden = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
assert.equal(forbidden.status, 403);
});
await withServer(createServices({ imageMakeAdminConfigService: {} }), async (baseUrl) => {
const unavailable = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
assert.equal(unavailable.status, 503);
});
});
test('gray read-only mode blocks admin writes while keeping reads available', async () => {
let updateCalled = false;
const service = {
getAdminConfig: async () => ({ config: { defaultProvider: 'mock', providers: {} } }),
updateAdminConfig: async () => {
updateCalled = true;
return { ok: true };
},
};
const services = {
...createServices({ imageMakeAdminConfigService: service }),
readOnlyMode: true,
};
await withServer(services, async (baseUrl) => {
const readResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
assert.equal(readResponse.status, 200);
const writeResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, {
method: 'PATCH',
headers: adminHeaders,
body: JSON.stringify({ defaultProvider: 'mock' }),
});
assert.equal(writeResponse.status, 423);
assert.match((await writeResponse.json()).message, /灰度只读模式/);
assert.equal(updateCalled, false);
});
});
+91 -60
View File
@@ -93,7 +93,6 @@ export function createAdminApp(services) {
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
pool,
ready,
wechatAdmin,
@@ -101,6 +100,8 @@ export function createAdminApp(services) {
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
@@ -113,8 +114,6 @@ export function createAdminApp(services) {
} = services;
const app = express();
app.set('trust proxy', 1);
const readOnlyMode = services.readOnlyMode
?? /^(1|true|yes|on)$/i.test(String(process.env.ADM_READ_ONLY_MODE ?? ''));
const isSecureRequest = (req) =>
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
@@ -152,13 +151,6 @@ export function createAdminApp(services) {
);
}
const requireWritableMode = (req, res, next) => {
if (readOnlyMode && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return res.status(423).json({ message: '当前为灰度只读模式,禁止修改生产数据' });
}
next();
};
app.get('/health', (_req, res) => {
res.json({ ok: true, service: 'memind_adm' });
});
@@ -215,8 +207,6 @@ export function createAdminApp(services) {
next();
}));
adminApi.use(requireWritableMode);
const requireAdmin = (req, res, next) => {
if (!req.currentUser || req.currentUser.role !== 'admin') {
res.status(403).json({ message: '需要管理员权限' });
@@ -244,6 +234,21 @@ export function createAdminApp(services) {
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,
@@ -381,53 +386,6 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/image-make/config', requireAdmin, async (_req, res) => {
if (!imageMakeAdminConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
res.json(await imageMakeAdminConfigService.getAdminConfig());
});
const updateImageMakeConfig = async (req, res) => {
if (!imageMakeAdminConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
const result = await imageMakeAdminConfigService.updateAdminConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
});
if (result.ok === false) return res.status(400).json({ message: result.message });
res.json(result);
};
adminApi.put('/image-make/config', requireAdmin, updateImageMakeConfig);
adminApi.patch('/image-make/config', requireAdmin, updateImageMakeConfig);
adminApi.get('/image-make/runtime', requireAdmin, async (_req, res) => {
if (!imageMakeAdminConfigService?.getRuntimeConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
const runtime = await imageMakeAdminConfigService.getRuntimeConfig();
if (!runtime.ok) return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' });
const { providers, ...rest } = runtime;
res.json({
...rest,
providers: {
mock: { enabled: providers.mock.enabled },
aliyun_bailian: providers.aliyun_bailian.enabled
? {
enabled: true,
model: providers.aliyun_bailian.model,
apiBase: providers.aliyun_bailian.apiBase,
apiKeyConfigured: Boolean(providers.aliyun_bailian.apiKey),
}
: { enabled: false },
comfyui: providers.comfyui.enabled
? { enabled: true, ...providers.comfyui }
: { enabled: false },
},
});
});
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
res.json(await memoryV2ConfigService.getAdminConfig());
@@ -460,6 +418,80 @@ export function createAdminApp(services) {
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('/orchestrator/config', requireAdmin, async (_req, res) => {
if (!orchestratorConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
}
return res.json(await orchestratorConfigService.getAdminConfig());
});
const updateOrchestratorConfig = async (req, res) => {
if (!orchestratorConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
}
return res.json(await orchestratorConfigService.updateAdminConfig(
req.body?.config ?? req.body ?? {},
{ updatedBy: req.currentUser.id },
));
};
adminApi.put('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
adminApi.patch('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
adminApi.get('/orchestrator/runtime', requireAdmin, async (_req, res) => {
if (!orchestratorConfigService?.getRuntimeState) {
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
}
return res.json(await orchestratorConfigService.getRuntimeState({ probe: true }));
});
adminApi.get('/orchestrator/shadow-runs', requireAdmin, async (req, res) => {
if (!orchestratorObservabilityService?.listShadowRuns) {
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
}
return res.json(await orchestratorObservabilityService.listShadowRuns({
hours: req.query.hours,
limit: req.query.limit,
status: req.query.status,
}));
});
adminApi.get('/orchestrator/execution-plans', requireAdmin, async (req, res) => {
if (!orchestratorObservabilityService?.listExecutionPlans) {
return res.status(503).json({ message: 'Orchestrator Dry-run 观测服务未启用' });
}
return res.json(await orchestratorObservabilityService.listExecutionPlans({
hours: req.query.hours,
limit: req.query.limit,
selection: req.query.selection,
}));
});
adminApi.get('/orchestrator/canary-readiness', requireAdmin, async (_req, res) => {
if (!orchestratorObservabilityService?.getCanaryReadiness) {
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
}
return res.json(await orchestratorObservabilityService.getCanaryReadiness());
});
adminApi.get('/orchestrator/shadow-runs/:runId', requireAdmin, async (req, res) => {
if (!orchestratorObservabilityService?.getShadowRun) {
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
}
const result = await orchestratorObservabilityService.getShadowRun(req.params.runId);
if (!result) return res.status(404).json({ message: 'Shadow run 不存在' });
return res.json(result);
});
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
@@ -1134,7 +1166,6 @@ export function createAdminApp(services) {
req.currentUser = me;
next();
}));
opsApi.use(requireWritableMode);
opsApi.use(resolveSessionUser);
opsApi.use('/ops/v1', services.createOpsApi({ jsonBody, plazaOps: services.plazaOps }));
app.use('/api', opsApi);
+25 -7
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');
@@ -30,10 +31,15 @@ export async function bootstrapAdminServices() {
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
const { createOrchestratorAdminConfigService } = await importMemind(
'services/orchestrator/admin-config.mjs',
);
const { createOrchestratorObservabilityService } = await importMemind(
'services/orchestrator/observability.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 { createImageMakeAdminConfigService } = await importMemind('image-make-admin-config.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
@@ -94,16 +100,19 @@ export async function bootstrapAdminServices() {
});
await ensureAssetGatewaySchema(pool);
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, {
env: process.env,
llmProviderService,
});
await imageMakeAdminConfigService.ensureSchema();
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
await mindSearchConfigService.ensureSchema();
const orchestratorConfigService = createOrchestratorAdminConfigService(pool, {
env: process.env,
});
await orchestratorConfigService.ensureSchema();
const orchestratorObservabilityService = createOrchestratorObservabilityService({
pool,
configService: orchestratorConfigService,
});
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
@@ -125,6 +134,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',
@@ -154,7 +171,6 @@ export async function bootstrapAdminServices() {
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -163,6 +179,8 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
+4 -2
View File
@@ -99,7 +99,6 @@ ready
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -107,6 +106,8 @@ ready
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
@@ -126,7 +127,6 @@ ready
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -134,6 +134,8 @@ ready
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { createAdminApp } from './app.mjs';
function createServices({ role = 'admin' } = {}) {
const calls = {
update: [],
shadow: [],
plans: [],
detail: [],
};
const configState = {
config: {
mode: 'shadow',
primaryEngine: 'langgraph',
fallbackEngine: 'native',
serviceUrl: 'http://127.0.0.1:8093',
requestTimeoutMs: 5000,
rolloutPercent: 0,
userAllowlist: [],
workflowAllowlist: ['code-run-v1'],
fallbackToNative: true,
requireHealthy: true,
executionEnabled: false,
},
configVersion: 4,
updatedBy: 'admin-user',
updatedAt: 1,
source: 'admin-db',
runtime: {
effective: true,
shadowsLangGraph: true,
executesLangGraph: false,
executionHandoff: { enabled: false },
},
engines: [],
executors: [],
};
return {
calls,
services: {
ready: Promise.resolve(),
parseCookies: () => ({ test_session: 'token' }),
USER_COOKIE: 'test_session',
userLoginCookies: () => [],
clearUserSessionCookie: () => {},
resolveCookieDomainForRequest: () => undefined,
userAuth: {
getMe: async () => ({ id: 'john-id', username: 'john', role }),
},
orchestratorConfigService: {
getAdminConfig: async () => configState,
updateAdminConfig: async (config, context) => {
calls.update.push({ config, context });
return { ...configState, config, updatedBy: context.updatedBy };
},
getRuntimeState: async ({ probe }) => ({
...configState,
serviceHealth: { ok: true, status: 'healthy', probe },
}),
},
orchestratorObservabilityService: {
listShadowRuns: async (query) => {
calls.shadow.push(query);
return { metrics: { observations: 0 }, runs: [] };
},
listExecutionPlans: async (query) => {
calls.plans.push(query);
return { metrics: { decisions: 0 }, plans: [] };
},
getCanaryReadiness: async () => ({
ready: false,
recommendation: 'keep_shadow',
}),
getShadowRun: async (runId) => {
calls.detail.push(runId);
return runId === 'missing' ? null : { native: { runId }, remote: { available: true } };
},
},
},
};
}
async function startApp(options) {
const harness = createServices(options);
const server = createAdminApp(harness.services).listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
return {
...harness,
request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, {
...init,
headers: {
Cookie: 'test_session=token',
'Content-Type': 'application/json',
...init.headers,
},
}),
close: () => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}),
};
}
test('admin can read orchestrator config, runtime and observability endpoints', async (t) => {
const app = await startApp();
t.after(app.close);
const config = await app.request('/admin-api/orchestrator/config');
assert.equal(config.status, 200);
assert.equal((await config.json()).config.mode, 'shadow');
const runtime = await app.request('/admin-api/orchestrator/runtime');
assert.equal(runtime.status, 200);
assert.deepEqual((await runtime.json()).serviceHealth, {
ok: true,
status: 'healthy',
probe: true,
});
const shadow = await app.request(
'/admin-api/orchestrator/shadow-runs?hours=24&limit=50&status=failed',
);
assert.equal(shadow.status, 200);
assert.deepEqual(app.calls.shadow, [{ hours: '24', limit: '50', status: 'failed' }]);
const plans = await app.request(
'/admin-api/orchestrator/execution-plans?hours=168&limit=20&selection=native',
);
assert.equal(plans.status, 200);
assert.deepEqual(app.calls.plans, [{ hours: '168', limit: '20', selection: 'native' }]);
const readiness = await app.request('/admin-api/orchestrator/canary-readiness');
assert.equal(readiness.status, 200);
assert.equal((await readiness.json()).recommendation, 'keep_shadow');
const detail = await app.request('/admin-api/orchestrator/shadow-runs/run-1');
assert.equal(detail.status, 200);
assert.deepEqual(app.calls.detail, ['run-1']);
const missing = await app.request('/admin-api/orchestrator/shadow-runs/missing');
assert.equal(missing.status, 404);
});
test('admin config update records the authenticated administrator', async (t) => {
const app = await startApp();
t.after(app.close);
const response = await app.request('/admin-api/orchestrator/config', {
method: 'PUT',
body: JSON.stringify({ config: { mode: 'off', executionEnabled: false } }),
});
assert.equal(response.status, 200);
assert.deepEqual(app.calls.update, [{
config: { mode: 'off', executionEnabled: false },
context: { updatedBy: 'john-id' },
}]);
});
test('ordinary users cannot access or mutate orchestrator controls', async (t) => {
const app = await startApp({ role: 'user' });
t.after(app.close);
const read = await app.request('/admin-api/orchestrator/config');
assert.equal(read.status, 403);
const update = await app.request('/admin-api/orchestrator/config', {
method: 'PUT',
body: JSON.stringify({ config: { mode: 'active', executionEnabled: true } }),
});
assert.equal(update.status, 403);
assert.equal(app.calls.update.length, 0);
});
+3
View File
@@ -19,6 +19,7 @@ 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 { OrchestratorPage } from './admin/pages/OrchestratorPage';
import { defaultHomePath } from './lib/routes';
import { OpsLayout } from './ops/components/OpsLayout';
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
@@ -127,6 +128,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
@@ -175,6 +177,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/orchestrator')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
+2 -1
View File
@@ -32,12 +32,13 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/orchestrator', label: '任务编排' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
{ to: '/providers', label: '统一模型中心' },
{ to: '/asset-gateway', label: '图片生成' },
{ to: '/asset-gateway', label: '资产能力' },
{ to: '/blocked-words', label: '违禁词管理' },
],
},
+28 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
const initial = {
enabled: false,
@@ -16,7 +16,8 @@ export function AnalyticsConfigPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [opening, setOpening] = useState(false);
const [openingUmami, setOpeningUmami] = useState(false);
const [openingRybbit, setOpeningRybbit] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -52,23 +53,43 @@ export function AnalyticsConfigPage() {
};
const openUmami = async () => {
setOpening(true);
setOpeningUmami(true);
setError(null);
try {
window.location.assign(await getUmamiSsoUrl());
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开 Umami');
setOpening(false);
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 Umami</p></div>
<div className="admin-page-head"><h2>Analytics </h2><p className="muted"> Memind Umami Rybbit Portal MEMIND_RYBBIT_ENABLEDMEMIND_RYBBIT_SITE_ID</p></div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<h2> Umami</h2>
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={opening || loading}>{opening ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
<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>
+34 -599
View File
@@ -1,15 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
getAssetGatewayConfig,
getImageMakeAdminConfig,
getImageMakeRuntimeStatus,
getLlmVisionSettings,
listLlmProviderKeys,
updateAssetGatewayConfig,
updateAssetPluginConfig,
updateImageMakeAdminConfig,
} from '../../api/client';
import type { AssetGatewayConfig, AssetPluginConfig, ImageMakeAdminConfig, LlmVisionSettings } from '../../types';
import type { AssetGatewayConfig, AssetPluginConfig, LlmProviderKeyRow } from '../../types';
type PluginDraft = {
enabled: boolean;
@@ -36,91 +32,24 @@ const pluginVisuals: Record<string, { icon: string; accent: string }> = {
'asset-analyze': { icon: '◉', accent: 'green' },
};
const defaultImageMakeConfig: ImageMakeAdminConfig = {
defaultProvider: 'mock',
jobDefaultTimeoutSeconds: 120,
providers: {
mock: { enabled: true },
aliyun_bailian: {
enabled: false,
model: 'qwen-image-plus',
apiBase: 'https://dashscope.aliyuncs.com/api/v1',
apiKeyConfigured: false,
apiKeyMasked: '',
},
comfyui: {
enabled: false,
apiBase: 'http://127.0.0.1:8188',
checkpoint: '',
workflowPath: './config/comfyui/workflows/core_txt2img_v1.json',
outputNodeId: '9',
timeoutSeconds: 600,
steps: 20,
cfg: 7,
sampler: 'euler',
scheduler: 'normal',
},
},
};
export function AssetGatewayPage() {
const [config, setConfig] = useState<AssetGatewayConfig | null>(null);
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [drafts, setDrafts] = useState<Record<string, PluginDraft>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [imageMakeConfig, setImageMakeConfig] = useState<ImageMakeAdminConfig>(defaultImageMakeConfig);
const [imageMakeMeta, setImageMakeMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
const [imageMakeRuntime, setImageMakeRuntime] = useState<string>('');
const [visionSettings, setVisionSettings] = useState<LlmVisionSettings | null>(null);
const orderedPlugins = useMemo(() => config?.plugins.slice().sort((left, right) => {
if (left.pluginId === 'asset-generate') return -1;
if (right.pluginId === 'asset-generate') return 1;
return left.pluginId.localeCompare(right.pluginId);
}) ?? [], [config]);
const imageGeneratePlugin = config?.plugins.find((plugin) => plugin.pluginId === 'asset-generate') ?? null;
const imageGenerateDraft = imageGeneratePlugin
? drafts[imageGeneratePlugin.pluginId] ?? draftFromPlugin(imageGeneratePlugin)
: null;
const imageMakeControlsEnabled = Boolean(
imageGenerateDraft?.enabled && imageGenerateDraft.provider === 'image_make',
);
const activeKeys = useMemo(() => keys.filter((key) => key.status === 'active'), [keys]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextConfig, nextVisionSettings, nextImageMakeConfig, nextImageMakeRuntime] = await Promise.all([
getAssetGatewayConfig(),
getLlmVisionSettings().catch(() => null),
getImageMakeAdminConfig(),
getImageMakeRuntimeStatus().catch(() => null),
]);
const [nextConfig, nextKeys] = await Promise.all([getAssetGatewayConfig(), listLlmProviderKeys()]);
setConfig(nextConfig);
setVisionSettings(nextVisionSettings);
setKeys(nextKeys);
setDrafts(Object.fromEntries(nextConfig.plugins.map((plugin) => [plugin.pluginId, draftFromPlugin(plugin)])));
setImageMakeConfig({
...defaultImageMakeConfig,
...nextImageMakeConfig.config,
providers: {
...defaultImageMakeConfig.providers,
...nextImageMakeConfig.config.providers,
mock: { ...defaultImageMakeConfig.providers.mock, ...nextImageMakeConfig.config.providers.mock },
aliyun_bailian: {
...defaultImageMakeConfig.providers.aliyun_bailian,
...nextImageMakeConfig.config.providers.aliyun_bailian,
},
comfyui: {
...defaultImageMakeConfig.providers.comfyui,
...nextImageMakeConfig.config.providers.comfyui,
},
},
});
setImageMakeMeta(nextImageMakeConfig);
setImageMakeRuntime(nextImageMakeRuntime?.ok
? `默认 Provider${nextImageMakeRuntime.defaultProvider} · 指纹 ${nextImageMakeRuntime.fingerprint?.slice(0, 12) ?? '—'}`
: nextImageMakeRuntime?.message ?? '运行时未就绪');
} catch (err) {
setError(err instanceof Error ? err.message : '加载资产能力配置失败');
} finally {
@@ -162,6 +91,8 @@ export function AssetGatewayPage() {
const result = await updateAssetPluginConfig(plugin.pluginId, {
enabled: draft.enabled,
provider: draft.provider || null,
llmProviderKeyId: draft.llmProviderKeyId || null,
llmModel: draft.llmModel || null,
purposes: draft.purposes,
});
setConfig(result.config);
@@ -171,532 +102,32 @@ export function AssetGatewayPage() {
finally { setBusy(null); }
};
const saveImageMakeConfig = async () => {
setBusy('image-make-config'); setMessage(null); setError(null);
try {
const payload: Parameters<typeof updateImageMakeAdminConfig>[0] = {
defaultProvider: imageMakeConfig.defaultProvider,
jobDefaultTimeoutSeconds: imageMakeConfig.jobDefaultTimeoutSeconds,
providers: {
mock: { enabled: imageMakeConfig.providers.mock.enabled },
aliyun_bailian: {
enabled: imageMakeConfig.providers.aliyun_bailian.enabled,
model: imageMakeConfig.providers.aliyun_bailian.model,
apiBase: imageMakeConfig.providers.aliyun_bailian.apiBase,
},
comfyui: {
enabled: imageMakeConfig.providers.comfyui.enabled,
apiBase: imageMakeConfig.providers.comfyui.apiBase,
checkpoint: imageMakeConfig.providers.comfyui.checkpoint,
workflowPath: imageMakeConfig.providers.comfyui.workflowPath,
outputNodeId: imageMakeConfig.providers.comfyui.outputNodeId,
timeoutSeconds: imageMakeConfig.providers.comfyui.timeoutSeconds,
steps: imageMakeConfig.providers.comfyui.steps,
cfg: imageMakeConfig.providers.comfyui.cfg,
sampler: imageMakeConfig.providers.comfyui.sampler,
scheduler: imageMakeConfig.providers.comfyui.scheduler,
},
},
};
const result = await updateImageMakeAdminConfig(payload);
if (result.ok === false) throw new Error(result.message ?? '保存 image_make 配置失败');
setImageMakeConfig({
...defaultImageMakeConfig,
...result.config,
providers: {
...defaultImageMakeConfig.providers,
...result.config.providers,
mock: { ...defaultImageMakeConfig.providers.mock, ...result.config.providers.mock },
aliyun_bailian: {
...defaultImageMakeConfig.providers.aliyun_bailian,
...result.config.providers.aliyun_bailian,
},
comfyui: {
...defaultImageMakeConfig.providers.comfyui,
...result.config.providers.comfyui,
},
},
});
setImageMakeMeta(result);
const nextVisionSettings = await getLlmVisionSettings().catch(() => null);
if (nextVisionSettings) setVisionSettings(nextVisionSettings);
const runtime = await getImageMakeRuntimeStatus().catch(() => null);
setImageMakeRuntime(runtime?.ok
? `默认 Provider${runtime.defaultProvider} · 指纹 ${runtime.fingerprint?.slice(0, 12) ?? '—'}`
: runtime?.message ?? '运行时未就绪');
setMessage('image_make 生图模型配置已保存;服务会在数秒内自动拉取新配置。');
} catch (err) { setError(err instanceof Error ? err.message : '保存 image_make 配置失败'); }
finally { setBusy(null); }
};
if (loading) return <div className="admin-page"><p className="muted"></p></div>;
if (!config) return <div className="admin-page"><p className="banner banner-error">{error ?? '无法加载配置'}</p></div>;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted"> AI </p>
<h2></h2>
<p className="muted"></p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-success">{message}</p>}
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">OPTIONAL CAPABILITY</div>
<h3></h3>
<p></p>
<h3> Gateway</h3>
<p></p>
</div>
<label className="asset-toggle">
<input type="checkbox" checked={config.enabled} disabled={busy === 'global'} onChange={(event) => void saveGlobal(event.target.checked)} />
<span>{config.enabled ? '已启用' : '已关闭'}</span>
</label>
</section>
{imageGeneratePlugin && imageGenerateDraft ? (
<>
<div className="model-center-section-title">
<h3></h3>
<p> Memind image_make AI </p>
</div>
<div className="asset-plugin-grid asset-plugin-grid--balanced">
<section className="asset-plugin-card asset-plugin-card--violet">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true"></div>
<div>
<h3>{imageGeneratePlugin.label}</h3>
<p>使 AI </p>
</div>
<label className="asset-mini-toggle" title="启用图片生成">
<input
type="checkbox"
checked={imageGenerateDraft.enabled}
onChange={(event) => updateDraft(imageGeneratePlugin.pluginId, { enabled: event.target.checked })}
/>
<span>{imageGenerateDraft.enabled ? '已开启' : '已关闭'}</span>
</label>
</div>
<div className="asset-plugin-fields">
<label>
Provider
<select
value={imageGenerateDraft.provider}
disabled={!imageGenerateDraft.enabled}
onChange={(event) => {
const provider = event.target.value;
updateDraft(imageGeneratePlugin.pluginId, {
provider,
...(provider === 'image_make' ? { llmProviderKeyId: '', llmModel: '' } : {}),
});
}}
>
<option value=""> Provider</option>
{imageGeneratePlugin.providers.map((provider) => (
<option key={provider} value={provider}>{provider}</option>
))}
</select>
</label>
<fieldset
className="asset-purpose-fieldset"
disabled={!imageGenerateDraft.enabled || imageGenerateDraft.provider !== 'image_make'}
>
<legend></legend>
<div className="asset-purpose-grid">
{imageGeneratePlugin.purposeCatalog?.map((purpose) => {
const descriptions: Record<string, string> = {
inline_image: '正文确有需要时单独生成',
hero: '页面主视觉,最多生成一次',
card_cover: '从页面头图派生',
feed_cover: '从页面头图派生',
};
return (
<label key={purpose.id}>
<input
type="checkbox"
checked={Boolean(imageGenerateDraft.purposes[purpose.id])}
onChange={(event) => updatePurpose(
imageGeneratePlugin.pluginId,
purpose.id,
event.target.checked,
)}
/>
<span>
{purpose.label}
<small>{descriptions[purpose.id] ?? purpose.presetId}</small>
</span>
</label>
);
})}
</div>
</fieldset>
</div>
<div className="asset-plugin-footer">
<span className={`asset-status ${imageGenerateDraft.enabled ? 'is-on' : ''}`}>
{imageGenerateDraft.enabled ? '待保存为启用' : '默认安全关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={busy === imageGeneratePlugin.pluginId}
onClick={() => void savePlugin(imageGeneratePlugin)}
>
{busy === imageGeneratePlugin.pluginId ? '保存中…' : '保存图片生成配置'}
</button>
</div>
</section>
</div>
<div className="model-center-section-title">
<h3> Provider</h3>
<p>image_make DashScope Key Qwen VL </p>
</div>
<div className="asset-plugin-grid">
<section className={`asset-plugin-card asset-plugin-card--blue${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon asset-plugin-icon--blue" aria-hidden="true"></div>
<div>
<h3></h3>
<p> Provider image_make </p>
</div>
</div>
<div className="asset-plugin-fields">
{!imageMakeControlsEnabled ? (
<div className="image-make-runtime-note">
Provider image_make
</div>
) : null}
<label>
Provider
<select
value={imageMakeConfig.defaultProvider}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
defaultProvider: event.target.value as ImageMakeAdminConfig['defaultProvider'],
}))}
>
<option value="aliyun_bailian"> Qwen-Image</option>
<option value="comfyui"> ComfyUI</option>
<option value="mock">Mock</option>
</select>
</label>
<label>
<input
type="number"
min={5}
max={1800}
value={imageMakeConfig.jobDefaultTimeoutSeconds}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
jobDefaultTimeoutSeconds: Number(event.target.value),
}))}
/>
</label>
<div className="image-make-runtime-note">
{imageMakeRuntime || '运行时状态未知'}
{' · '}
{imageMakeMeta.source === 'admin-db' ? '数据库' : '默认值'}
</div>
</div>
<div className="asset-plugin-footer">
<span className={`asset-status ${imageMakeControlsEnabled ? 'is-on' : ''}`}>
{imageMakeConfig.defaultProvider}
</span>
<button
type="button"
className="send-btn"
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
onClick={() => void saveImageMakeConfig()}
>
{busy === 'image-make-config' ? '保存中…' : '保存生图模型配置'}
</button>
</div>
</section>
<section className={`asset-plugin-card asset-plugin-card--amber${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon asset-plugin-icon--amber" aria-hidden="true"></div>
<div>
<h3> Qwen-Image</h3>
<p> DashScope Qwen VL DashScope Key</p>
</div>
<label className="asset-mini-toggle">
<input
type="checkbox"
checked={imageMakeConfig.providers.aliyun_bailian.enabled}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
aliyun_bailian: { ...current.providers.aliyun_bailian, enabled: event.target.checked },
},
}))}
/>
<span>{imageMakeConfig.providers.aliyun_bailian.enabled ? '已启用' : '未启用'}</span>
</label>
</div>
<div className="asset-plugin-fields">
<label>
<select
value={imageMakeConfig.providers.aliyun_bailian.model ?? 'qwen-image-plus'}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
aliyun_bailian: { ...current.providers.aliyun_bailian, model: event.target.value },
},
}))}
>
<option value="qwen-image-plus">qwen-image-plus</option>
<option value="qwen-image">qwen-image</option>
</select>
</label>
<label>
API Base
<input
value={imageMakeConfig.providers.aliyun_bailian.apiBase ?? ''}
placeholder="https://dashscope.aliyuncs.com/api/v1"
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
aliyun_bailian: { ...current.providers.aliyun_bailian, apiBase: event.target.value },
},
}))}
/>
</label>
<div className="image-make-runtime-note">
DashScope API Key Qwen VL
{' '}
<Link to="/providers"></Link>
{' '}
Qwen Provider
{imageMakeConfig.providers.aliyun_bailian.apiKeyConfigured ? (
<>
{' '}
{imageMakeConfig.providers.aliyun_bailian.dashScopeKeyName
?? visionSettings?.keyName
?? 'DashScope'}
{' '}
(
{imageMakeConfig.providers.aliyun_bailian.apiKeyMasked
?? visionSettings?.apiKeyMasked
?? '已配置'}
)
</>
) : (
<> DashScope Key</>
)}
</div>
</div>
<div className="asset-plugin-footer">
<span className={`asset-status ${imageMakeConfig.providers.aliyun_bailian.enabled ? 'is-on' : ''}`}>
{imageMakeConfig.providers.aliyun_bailian.model ?? 'qwen-image-plus'}
</span>
<button
type="button"
className="send-btn"
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
onClick={() => void saveImageMakeConfig()}
>
{busy === 'image-make-config' ? '保存中…' : '保存配置'}
</button>
</div>
</section>
<section className={`asset-plugin-card asset-plugin-card--green${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon asset-plugin-icon--green" aria-hidden="true"></div>
<div>
<h3> ComfyUI</h3>
<p> SD / Flux 线</p>
</div>
<label className="asset-mini-toggle">
<input
type="checkbox"
checked={imageMakeConfig.providers.comfyui.enabled}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, enabled: event.target.checked },
},
}))}
/>
<span>{imageMakeConfig.providers.comfyui.enabled ? '已启用' : '未启用'}</span>
</label>
</div>
<div className="asset-plugin-fields">
<label>
ComfyUI
<input
value={imageMakeConfig.providers.comfyui.apiBase ?? ''}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, apiBase: event.target.value },
},
}))}
/>
</label>
<label>
Checkpoint
<input
value={imageMakeConfig.providers.comfyui.checkpoint ?? ''}
placeholder="v1-5-pruned-emaonly-fp16.safetensors"
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, checkpoint: event.target.value },
},
}))}
/>
</label>
<label>
Workflow
<input
value={imageMakeConfig.providers.comfyui.workflowPath ?? ''}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, workflowPath: event.target.value },
},
}))}
/>
</label>
<label>
ID
<input
value={imageMakeConfig.providers.comfyui.outputNodeId ?? '9'}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, outputNodeId: event.target.value },
},
}))}
/>
</label>
<div className="image-generation-scope-grid">
<label>
Steps
<input
type="number"
min={1}
max={100}
value={imageMakeConfig.providers.comfyui.steps ?? 20}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, steps: Number(event.target.value) },
},
}))}
/>
</label>
<label>
CFG
<input
type="number"
min={0}
max={30}
step={0.5}
value={imageMakeConfig.providers.comfyui.cfg ?? 7}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, cfg: Number(event.target.value) },
},
}))}
/>
</label>
<label>
Sampler
<input
value={imageMakeConfig.providers.comfyui.sampler ?? 'euler'}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, sampler: event.target.value },
},
}))}
/>
</label>
<label>
Scheduler
<input
value={imageMakeConfig.providers.comfyui.scheduler ?? 'normal'}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, scheduler: event.target.value },
},
}))}
/>
</label>
<label>
<input
type="number"
min={2}
max={1800}
value={imageMakeConfig.providers.comfyui.timeoutSeconds ?? 600}
disabled={!imageMakeControlsEnabled}
onChange={(event) => setImageMakeConfig((current) => ({
...current,
providers: {
...current.providers,
comfyui: { ...current.providers.comfyui, timeoutSeconds: Number(event.target.value) },
},
}))}
/>
</label>
</div>
</div>
<div className="asset-plugin-footer">
<span className={`asset-status ${imageMakeConfig.providers.comfyui.enabled ? 'is-on' : ''}`}>
{imageMakeConfig.providers.comfyui.checkpoint || '未配置 checkpoint'}
</span>
<button
type="button"
className="send-btn"
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
onClick={() => void saveImageMakeConfig()}
>
{busy === 'image-make-config' ? '保存中…' : '保存配置'}
</button>
</div>
</section>
</div>
</>
) : null}
<div className="model-center-section-title">
<h3></h3>
<p> Provider</p>
</div>
<div className="asset-plugin-grid asset-plugin-grid--balanced">
{orderedPlugins.filter((plugin) => plugin.pluginId !== 'asset-generate').map((plugin) => {
{config.plugins.map((plugin) => {
const draft = drafts[plugin.pluginId] ?? draftFromPlugin(plugin);
const selectedKey = activeKeys.find((key) => key.id === draft.llmProviderKeyId);
const visual = pluginVisuals[plugin.pluginId] ?? { icon: '✦', accent: 'blue' };
return <section className={`asset-plugin-card asset-plugin-card--${visual.accent}`} key={plugin.pluginId}>
<div className="asset-plugin-card-head">
@@ -709,25 +140,32 @@ export function AssetGatewayPage() {
</div>
<div className="asset-plugin-fields">
<label> Provider<select value={draft.provider} onChange={(event) => {
updateDraft(plugin.pluginId, { provider: event.target.value });
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">
{' '}
<Link to="/providers"></Link>
{' '}
Qwen VL
image_make
</div>
) : (
<div className="asset-no-llm"> · LLM</div>
)}
) : plugin.supportsLlm ? <>
<label> LLM<select value={draft.llmProviderKeyId} onChange={(event) => updateDraft(plugin.pluginId, { llmProviderKeyId: event.target.value, llmModel: '' })} disabled={!draft.enabled}>
<option value="">使 LLM</option>
{activeKeys.map((key) => <option key={key.id} value={key.id}>{key.name}</option>)}
</select></label>
<label><select value={draft.llmModel} onChange={(event) => updateDraft(plugin.pluginId, { llmModel: event.target.value })} disabled={!draft.enabled || !selectedKey}>
<option value=""></option>
{selectedKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
</select></label>
</> : <div className="asset-no-llm"> · LLM</div>}
{plugin.pluginId === 'asset-generate' && plugin.purposeCatalog?.length ? (
<fieldset className="asset-purpose-fieldset" disabled={!draft.enabled || draft.provider !== 'image_make'}>
<legend>AI </legend>
<legend>image_make </legend>
<div className="asset-purpose-grid">
{plugin.purposeCatalog.map((purpose) => (
<label key={purpose.id}>
@@ -736,14 +174,11 @@ export function AssetGatewayPage() {
checked={Boolean(draft.purposes[purpose.id])}
onChange={(event) => updatePurpose(plugin.pluginId, purpose.id, event.target.checked)}
/>
<span>
{purpose.label}
<small>{purpose.strategy === 'derive' ? '从页面头图派生 · 不重复生图' : purpose.presetId}</small>
</span>
<span>{purpose.label}<small>{purpose.presetId}</small></span>
</label>
))}
</div>
<p>使 AI </p>
<p> MindSpace </p>
</fieldset>
) : null}
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ const QUICK_LINKS = [
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/asset-gateway', label: '图片生成', desc: '配置正文图、页面头图、卡片封面与信息流缩略图' },
{ 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 关键词路由开关' },
+7 -1
View File
@@ -122,6 +122,12 @@ const CAPABILITIES: Array<{
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' },
@@ -346,7 +352,7 @@ function defaultConfig(): MemoryV2AdminConfig {
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, semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
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' },
+251 -22
View File
@@ -1,23 +1,75 @@
import { useEffect, useState } from 'react';
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
import type { MindSearchConfig } from '../../types';
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(() => {
getMindSearchConfig()
.then((result) => {
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
Promise.all([
getMindSearchConfig(),
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
])
.then(([result, keys]) => {
setConfig(mergeConfig(result.config));
setLlmKeys(keys.filter((key) => key.status === 'active'));
setMeta(result);
setMessage('');
})
@@ -28,7 +80,7 @@ export function MindSearchPage() {
setMessage('保存中…');
try {
const result = await updateMindSearchConfig(config);
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setConfig(mergeConfig(result.config));
setMeta(result);
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
} catch (error) {
@@ -36,30 +88,207 @@ export function MindSearchPage() {
}
};
const toggleProvider = (key: keyof MindSearchConfig['providers']) =>
setConfig((current) => ({ ...current, providers: { ...current.providers, [key]: !current.providers[key] } }));
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-page-header">
<div>
<h1>MindSearch</h1>
<p></p>
</div>
</div>
<div className="admin-card">
<h2></h2>
<label><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' })} /> MindSearch</label>
<label> <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>
<h2>Provider</h2>
<fieldset><legend> Provider</legend>
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub CodeToken </label>
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader localhost/</label>
</fieldset>
<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>
<label>SearXNG <input value={config.settings.searxngEndpoint} placeholder="http://127.0.0.1:8080/search" onChange={(event) => updateSetting('searxngEndpoint', event.target.value)} /></label>
<label> <input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
<label>Provider <input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
<label>Reader <input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
<button type="button" onClick={save}></button>
<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>
+383
View File
@@ -0,0 +1,383 @@
import { useEffect, useMemo, useState } from 'react';
import {
getOrchestratorCanaryReadiness,
getOrchestratorConfig,
getOrchestratorExecutionPlans,
getOrchestratorRuntime,
getOrchestratorShadowRun,
getOrchestratorShadowRuns,
updateOrchestratorConfig,
} from '../../api/client';
import type {
OrchestratorCanaryReadiness,
OrchestratorConfig,
OrchestratorConfigState,
OrchestratorExecutionPlanList,
OrchestratorServiceHealth,
OrchestratorShadowRunDetail,
OrchestratorShadowRunList,
} from '../../types/orchestrator';
const runtimeReasons: Record<string, string> = {
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
execution_gate_disabled: '执行交接闸门被硬锁定,当前仅生成 Dry-run 决策。',
environment_execution_gate_disabled: 'memindadm 已请求交接,但环境闸门仍关闭。',
};
const readinessLabels: Record<string, string> = {
shadow_mode: '保持 Shadow',
service_healthy: '服务健康',
durable_checkpoint: '持久化 checkpoint',
durable_executor_job_store: '持久化 Executor Job Store',
observe_only: '仅观察不执行',
sample_volume: '有效样本量',
shadow_success_rate: 'Shadow 成功率',
shadow_skip_rate: 'Shadow 跳过率',
latency_coverage: '延迟数据覆盖率',
latency_p95: '延迟 P95',
native_settled_rate: 'Native 终态覆盖率',
session_coverage: '真实会话覆盖',
sample_freshness: '最近样本新鲜度',
complete_window: '统计窗口完整',
};
function listToText(values: string[]) {
return values.join('\n');
}
function textToList(value: string) {
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
}
function formatTime(value?: number | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
function formatPercent(value?: number | null) {
return value == null ? '—' : `${(value * 100).toFixed(1)}%`;
}
function formatLatency(value?: number | null) {
return value == null ? '—' : `${value} ms`;
}
function shortId(value: string) {
return value.length > 16 ? `${value.slice(0, 8)}${value.slice(-6)}` : value;
}
function readinessValue(check: OrchestratorCanaryReadiness['checks'][number]) {
if (check.actual == null) return '无数据';
if (['shadow_success_rate', 'latency_coverage', 'native_settled_rate'].includes(check.id)) {
return formatPercent(Number(check.actual));
}
if (check.id === 'latency_p95') return formatLatency(Number(check.actual));
if (check.id === 'sample_freshness') return `${Number(check.actual).toFixed(1)} 小时`;
if (typeof check.actual === 'boolean') return check.actual ? '是' : '否';
return String(check.actual);
}
export function OrchestratorPage() {
const [state, setState] = useState<OrchestratorConfigState | null>(null);
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
const [userAllowlist, setUserAllowlist] = useState('');
const [shadow, setShadow] = useState<OrchestratorShadowRunList | null>(null);
const [plans, setPlans] = useState<OrchestratorExecutionPlanList | null>(null);
const [readiness, setReadiness] = useState<OrchestratorCanaryReadiness | null>(null);
const [health, setHealth] = useState<OrchestratorServiceHealth | null>(null);
const [detail, setDetail] = useState<OrchestratorShadowRunDetail | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [configState, shadowState, planState, readinessState] = await Promise.all([
getOrchestratorConfig(),
getOrchestratorShadowRuns({ hours: 24, limit: 100 }),
getOrchestratorExecutionPlans({ hours: 24, limit: 100 }),
getOrchestratorCanaryReadiness(),
]);
setState(configState);
setDraft(configState.config);
setWorkflowAllowlist(listToText(configState.config.workflowAllowlist));
setUserAllowlist(listToText(configState.config.userAllowlist));
setShadow(shadowState);
setPlans(planState);
setReadiness(readinessState);
} catch (err) {
setError(err instanceof Error ? err.message : 'Orchestrator 数据加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const normalizedDraft = useMemo(() => draft ? {
...draft,
workflowAllowlist: textToList(workflowAllowlist),
userAllowlist: textToList(userAllowlist),
} : null, [draft, workflowAllowlist, userAllowlist]);
const dirty = Boolean(
state && normalizedDraft && JSON.stringify(state.config) !== JSON.stringify(normalizedDraft),
);
const save = async () => {
if (!normalizedDraft) return;
if (['canary', 'active'].includes(normalizedDraft.mode) && !normalizedDraft.serviceUrl) {
setError('Canary / Active 模式必须先配置 Orchestrator URL。');
return;
}
if (
normalizedDraft.executionEnabled
&& !window.confirm('确认请求执行交接?环境闸门关闭时仍只会 Dry-run。')
) {
return;
}
setSaving(true);
setError(null);
setMessage(null);
try {
const result = await updateOrchestratorConfig(normalizedDraft);
setState(result);
setDraft(result.config);
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
setUserAllowlist(listToText(result.config.userAllowlist));
setMessage(`Orchestrator 配置 v${result.configVersion} 已保存。`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
const checkHealth = async () => {
setChecking(true);
setError(null);
try {
const result = await getOrchestratorRuntime();
setHealth(result.serviceHealth);
} catch (err) {
setError(err instanceof Error ? err.message : '服务检查失败');
} finally {
setChecking(false);
}
};
const openDetail = async (runId: string) => {
setError(null);
try {
setDetail(await getOrchestratorShadowRun(runId));
} catch (err) {
setError(err instanceof Error ? err.message : 'Run 详情加载失败');
}
};
if (loading) return <div className="admin-page"><p> Workflow Orchestrator</p></div>;
if (!state || !draft) {
return <div className="admin-page"><p className="banner banner-error">{error ?? '配置不可用'}</p></div>;
}
const runtimeMessage = state.runtime.reason
? (runtimeReasons[state.runtime.reason] ?? state.runtime.reason)
: state.runtime.shadowsLangGraph
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察。'
: state.runtime.executesLangGraph
? '执行交接已开启。'
: '配置有效。';
return (
<div className="admin-page orchestrator-page">
<div className="admin-page-head">
<h2>Workflow Orchestrator</h2>
<p className="muted">
LangGraph Off Shadow Native Agent Run
</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<div className="orchestrator-summary">
<div><span></span><strong>v{state.configVersion}</strong></div>
<div><span></span><strong>{state.runtime.effective ? '有效' : '安全回退'}</strong></div>
<div><span></span><strong>{state.source}</strong></div>
<div><span></span><strong>{state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'}</strong></div>
<div><span></span><strong>{formatTime(state.updatedAt)}</strong></div>
</div>
<p className={`orchestrator-state ${state.runtime.reason === 'kill_switch' ? 'danger' : ''}`}>
{runtimeMessage}
</p>
</section>
<section className="admin-card">
<div className="orchestrator-section-head">
<div>
<h2>Executor Gateway</h2>
<p className="muted"> Orchestrator </p>
</div>
</div>
<div className="orchestrator-executors">
{state.executors.map((executor) => (
<article key={executor.id}>
<div><strong>{executor.label}</strong><span>{executor.status}</span></div>
<p>{executor.capabilities.join(' · ') || '无能力声明'}</p>
<small>dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'}</small>
</article>
))}
</div>
</section>
<section className="admin-card">
<div className="orchestrator-section-head">
<div>
<h2>Shadow </h2>
<p className="muted"> 24 Native LangGraph </p>
</div>
<button type="button" className="ghost-btn" onClick={() => void load()}></button>
</div>
{readiness && (
<div className={`orchestrator-readiness ${readiness.ready ? 'ready' : ''}`}>
<div>
<strong>Canary </strong>
<b>{readiness.ready ? '达到门槛,等待人工审批' : '继续保持 Shadow'}</b>
</div>
<p>
{readiness.samples.eligibleObservations} / {readiness.thresholds.minObservations}
{' · '} smoke {readiness.samples.excludedSynthetic}
{' · '} {readiness.samples.distinctSessions} / {readiness.thresholds.minDistinctSessions}
</p>
<div className="orchestrator-checks">
{readiness.checks.map((check) => (
<span key={check.id} className={check.passed ? 'passed' : 'blocked'}>
{check.passed ? '通过' : '阻塞'} · {readinessLabels[check.id] ?? check.id}{readinessValue(check)}
</span>
))}
</div>
</div>
)}
<div className="orchestrator-metrics">
<div><span></span><strong>{shadow?.metrics.observations ?? '—'}</strong></div>
<div><span></span><strong>{formatPercent(shadow?.metrics.successRate)}</strong></div>
<div><span></span><strong>{shadow?.metrics.failures ?? '—'}</strong></div>
<div><span> P95</span><strong>{formatLatency(shadow?.metrics.latencyP95Ms)}</strong></div>
<div><span>Native / </span><strong>{shadow ? `${shadow.metrics.nativeSucceeded} / ${shadow.metrics.nativeFailed}` : '—'}</strong></div>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th></th><th>Run</th><th>Shadow</th><th>Native</th><th></th><th> / Adapter</th></tr></thead>
<tbody>
{(shadow?.runs ?? []).map((run) => (
<tr key={run.eventId}>
<td>{formatTime(run.observedAt)}</td>
<td><button type="button" className="ghost-btn" onClick={() => void openDetail(run.runId)}>{shortId(run.runId)}</button></td>
<td>{run.shadowStatus}</td>
<td>{run.nativeStatus}</td>
<td>{formatLatency(run.latencyMs)}</td>
<td>{run.taskType ?? '—'}{run.synthetic ? 'smoke' : ''}<small>{run.executorAdapter ?? '—'}</small></td>
</tr>
))}
{!shadow?.runs.length && <tr><td colSpan={6}> Shadow </td></tr>}
</tbody>
</table>
</div>
{detail && (
<div className="orchestrator-detail">
<div className="orchestrator-section-head">
<div><strong>Run {detail.native.runId}</strong><p className="muted">Native {detail.native.status} · LangGraph {detail.remote.available ? 'available' : 'unavailable'}</p></div>
<button type="button" className="ghost-btn" onClick={() => setDetail(null)}></button>
</div>
<div>
<pre>{JSON.stringify(detail.remote.state, null, 2)}</pre>
<pre>{JSON.stringify(detail.remote.events, null, 2)}</pre>
</div>
</div>
)}
</section>
<section className="admin-card">
<div className="orchestrator-section-head">
<div><h2> Dry-run</h2><p className="muted"> Native</p></div>
</div>
{(plans?.metrics.handoffAllowed ?? 0) > 0 && (
<p className="banner banner-error"> handoffAllowed=true</p>
)}
<div className="orchestrator-metrics">
<div><span></span><strong>{plans?.metrics.decisions ?? '—'}</strong></div>
<div><span></span><strong>{formatPercent(plans?.metrics.candidateSelectionRate)}</strong></div>
<div><span>LangGraph / Native</span><strong>{plans ? `${plans.metrics.candidateSelections} / ${plans.metrics.nativeSelections}` : '—'}</strong></div>
<div><span>Native </span><strong>{formatPercent(plans?.metrics.nativeSettledRate)}</strong></div>
<div><span></span><strong>{plans?.metrics.distinctSessions ?? '—'}</strong></div>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th></th><th>Run</th><th></th><th> </th><th></th><th>Native</th></tr></thead>
<tbody>
{(plans?.plans ?? []).map((plan) => (
<tr key={plan.eventId}>
<td>{formatTime(plan.plannedAt)}</td>
<td className="mono">{shortId(plan.runId)}</td>
<td>{plan.mode ?? '—'}</td>
<td>{plan.candidateEngine} {plan.effectiveEngine}</td>
<td>{plan.candidateReason ?? plan.reason ?? '—'}</td>
<td>{plan.nativeStatus}</td>
</tr>
))}
{!plans?.plans.length && <tr><td colSpan={6}> Dry-run </td></tr>}
</tbody>
</table>
</div>
</section>
<section className="admin-card">
<h2></h2>
<div className="orchestrator-form-grid">
<label><span></span><select value={draft.mode} onChange={(event) => setDraft({ ...draft, mode: event.target.value as OrchestratorConfig['mode'] })}>
<option value="off">Off Native only</option>
<option value="shadow">Shadow Native LangGraph </option>
<option value="canary">Canary /</option>
<option value="active">Active </option>
</select></label>
<label><span></span><select value={draft.primaryEngine} onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}>
{state.engines.map((engine) => <option key={engine.id} value={engine.id}>{engine.label}{engine.configured ? '' : '(未配置)'}</option>)}
</select></label>
<label><span></span><input type="number" min={0} max={100} value={draft.rolloutPercent} onChange={(event) => setDraft({ ...draft, rolloutPercent: Number(event.target.value) })} /></label>
<label><span></span><input type="number" min={500} max={60000} value={draft.requestTimeoutMs} onChange={(event) => setDraft({ ...draft, requestTimeoutMs: Number(event.target.value) })} /></label>
<label className="wide"><span>Orchestrator URL</span><input value={draft.serviceUrl} onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })} placeholder="http://127.0.0.1:8093" /></label>
</div>
<div className="orchestrator-health">
<span>{health ? `${health.ok ? '健康' : '不可用'}${health.status}${health.latencyMs} ms` : '尚未检查服务连接'}</span>
<button type="button" className="ghost-btn" onClick={() => void checkHealth()} disabled={checking}>{checking ? '检查中…' : '测试连接'}</button>
</div>
<div className="orchestrator-toggles">
<label><input type="checkbox" checked={draft.requireHealthy} onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })} /></label>
<label><input type="checkbox" checked={draft.fallbackToNative} onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })} />退 Native</label>
<label className="handoff"><input type="checkbox" checked={draft.executionEnabled} onChange={(event) => setDraft({ ...draft, executionEnabled: event.target.checked })} /> Kill Switch </label>
</div>
<div className="orchestrator-allowlists">
<label><span></span><textarea rows={6} value={workflowAllowlist} onChange={(event) => setWorkflowAllowlist(event.target.value)} /></label>
<label><span></span><textarea rows={6} value={userAllowlist} onChange={(event) => setUserAllowlist(event.target.value)} /></label>
</div>
<div className="admin-actions orchestrator-actions">
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={saving}></button>
<button type="button" className="send-btn" onClick={() => void save()} disabled={saving || !dirty}>{saving ? '保存中…' : '保存配置'}</button>
</div>
</section>
</div>
);
}
+5 -59
View File
@@ -19,7 +19,6 @@ import {
setLlmVisionKey,
stopLlmExecutor,
syncLlmProviderToGoosed,
testLlmProviderDraft,
testLlmProviderKey,
updateLlmProviderKey,
} from '../../api/client';
@@ -35,11 +34,6 @@ import type {
} from '../../types';
const CUSTOM_PROVIDER_ID = '__custom__';
const KIMI_DIRECT_PRESET = {
apiUrl: 'https://api.moonshot.cn/v1',
modelsText: 'kimi-k3\nkimi-k2.6',
defaultModel: 'kimi-k3',
};
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
goose: { icon: 'G', accent: 'blue' },
@@ -68,14 +62,6 @@ function parseModelsText(text: string) {
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
}
function formatProviderConnectionError(message?: string) {
const detail = String(message ?? '未知错误').trim();
if (/not found the model|permission denied|resource_not_found_error/i.test(detail)) {
return `模型不存在或当前 API Key 没有该模型权限,请检查模型 ID。上游响应:${detail}`;
}
return detail;
}
function statusText(status?: LlmExecutorLaunchState | null) {
if (!status) return '未启动';
if (status.running) return `运行中${status.pid ? ` · pid ${status.pid}` : ''}`;
@@ -184,24 +170,6 @@ function ProviderDialog({
{isCustom ? (
<>
<div className="form-span-all provider-preset-row">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => onChange({
...form,
name: form.name || 'Kimi',
apiUrl: KIMI_DIRECT_PRESET.apiUrl,
modelsText: KIMI_DIRECT_PRESET.modelsText,
defaultModel: KIMI_DIRECT_PRESET.defaultModel,
relayProvider: '',
})}
>
使 Kimi
</button>
<span className="muted"> API ID Moonshot Relay Provider </span>
</div>
<label>
<span>API </span>
<input placeholder="https://api.example.com/v1/chat/completions" value={form.apiUrl} onChange={(event) => onChange({ ...form, apiUrl: event.target.value })} />
@@ -209,7 +177,6 @@ function ProviderDialog({
<label>
<span>Relay Provider</span>
<input placeholder="可选,例如 ollama" value={form.relayProvider} onChange={(event) => onChange({ ...form, relayProvider: event.target.value })} />
<small className="muted">/使OpenAIMoonshotDeepSeek </small>
</label>
<label className="form-span-all">
<span></span>
@@ -448,8 +415,8 @@ function VisionSection({
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true"></div>
<div>
<h3>DashScope</h3>
<p>Qwen VL Qwen-Image DashScope API Key</p>
<h3></h3>
<p> Qwen VL</p>
</div>
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
@@ -577,21 +544,6 @@ export function ProvidersPage() {
setError(null);
try {
if (providerDialog.mode === 'create') {
if (isCustom) {
const connection = await testLlmProviderDraft({
providerId: CUSTOM_PROVIDER_ID,
name: form.name,
apiKey: form.apiKey,
apiUrl: form.apiUrl,
models: customModels,
defaultModel: form.defaultModel || customModels[0],
testModel: form.defaultModel || customModels[0],
relayProvider: form.relayProvider || undefined,
});
if (!connection.ok) {
throw new Error(`保存前联通校验失败:${formatProviderConnectionError(connection.message)}`);
}
}
await createLlmProviderKey(
isCustom
? {
@@ -629,13 +581,7 @@ export function ProvidersPage() {
},
);
}
setMessage(
providerDialog.mode === 'create' && isCustom
? 'Provider 已添加,保存前联通校验通过'
: providerDialog.mode === 'create'
? 'Provider 已添加'
: 'Provider 已保存',
);
setMessage(providerDialog.mode === 'create' ? 'Provider 已添加' : 'Provider 已保存');
setProviderDialog(null);
await load();
} catch (err) {
@@ -694,7 +640,7 @@ export function ProvidersPage() {
try {
const result = await testLlmProviderKey(row.id, row.defaultModel);
if (result.ok) setMessage(`${row.name}」联通成功`);
else setError(`${row.name}」联通失败:${formatProviderConnectionError(result.message)}`);
else setError(`${row.name}」联通失败:${result.message ?? '未知错误'}`);
} catch (err) {
setError(err instanceof Error ? err.message : '联通测试失败');
} finally {
@@ -948,7 +894,7 @@ export function ProvidersPage() {
})}
</div>
<h3 className="model-center-section-title">DashScope</h3>
<h3 className="model-center-section-title"></h3>
<VisionSection
keys={activeKeys}
settings={visionSettings}
+76 -24
View File
@@ -1,8 +1,5 @@
import type {
AssetGatewayConfig,
ImageMakeAdminConfig,
ImageMakeAdminConfigResponse,
ImageMakeRuntimeResponse,
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
@@ -52,7 +49,17 @@ import type {
WechatMessage,
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
} from '../types';
import type {
OrchestratorCanaryReadiness,
OrchestratorConfig,
OrchestratorConfigState,
OrchestratorExecutionPlanList,
OrchestratorRuntimeState,
OrchestratorShadowRunDetail,
OrchestratorShadowRunList,
} from '../types/orchestrator';
export class ApiError extends Error {
readonly status: number;
@@ -319,27 +326,6 @@ export async function updateAssetPluginConfig(
});
}
export async function getImageMakeAdminConfig(): Promise<ImageMakeAdminConfigResponse> {
return portalFetch('/admin-api/image-make/config');
}
export async function updateImageMakeAdminConfig(
payload: Partial<ImageMakeAdminConfig> & {
providers?: Partial<ImageMakeAdminConfig['providers']> & {
aliyun_bailian?: Partial<ImageMakeAdminConfig['providers']['aliyun_bailian']> & { apiKey?: string };
};
},
): Promise<ImageMakeAdminConfigResponse & { ok?: boolean; message?: string }> {
return portalFetch('/admin-api/image-make/config', {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function getImageMakeRuntimeStatus(): Promise<ImageMakeRuntimeResponse> {
return portalFetch('/admin-api/image-make/runtime');
}
export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config');
return result.config;
@@ -350,6 +336,11 @@ export async function getUmamiSsoUrl(): Promise<string> {
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> & { analytics?: MindSpaceAnalyticsUpdate },
): Promise<MindSpaceAdminConfig> {
@@ -693,6 +684,67 @@ export async function updateMindSearchConfig(config: Partial<MindSearchConfig>):
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' });
}
// ── Workflow Orchestrator ─────────────────────────────
export async function getOrchestratorConfig() {
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
}
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
method: 'PUT',
body: JSON.stringify({ config }),
});
}
export async function getOrchestratorRuntime() {
return portalFetch<OrchestratorRuntimeState>('/admin-api/orchestrator/runtime');
}
export async function getOrchestratorShadowRuns(params: {
hours?: number;
limit?: number;
status?: 'all' | 'succeeded' | 'failed';
} = {}) {
const query = new URLSearchParams();
if (params.hours) query.set('hours', String(params.hours));
if (params.limit) query.set('limit', String(params.limit));
if (params.status) query.set('status', params.status);
return portalFetch<OrchestratorShadowRunList>(
`/admin-api/orchestrator/shadow-runs?${query}`,
);
}
export async function getOrchestratorExecutionPlans(params: {
hours?: number;
limit?: number;
selection?: 'all' | 'candidate' | 'native';
} = {}) {
const query = new URLSearchParams();
if (params.hours) query.set('hours', String(params.hours));
if (params.limit) query.set('limit', String(params.limit));
if (params.selection) query.set('selection', params.selection);
return portalFetch<OrchestratorExecutionPlanList>(
`/admin-api/orchestrator/execution-plans?${query}`,
);
}
export async function getOrchestratorCanaryReadiness() {
return portalFetch<OrchestratorCanaryReadiness>(
'/admin-api/orchestrator/canary-readiness',
);
}
export async function getOrchestratorShadowRun(runId: string) {
return portalFetch<OrchestratorShadowRunDetail>(
`/admin-api/orchestrator/shadow-runs/${encodeURIComponent(runId)}`,
);
}
// ── Policies ──────────────────────────────────────────
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
+251 -138
View File
@@ -446,6 +446,212 @@ body,
margin-bottom: 16px;
}
/* ── Workflow Orchestrator ─────────────────────────── */
.orchestrator-page {
display: grid;
gap: 16px;
}
.orchestrator-page .admin-card {
margin-bottom: 0;
}
.orchestrator-page h2 {
margin: 0 0 4px;
}
.orchestrator-summary,
.orchestrator-executors,
.orchestrator-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.orchestrator-summary > div,
.orchestrator-metrics > div,
.orchestrator-executors article {
display: grid;
gap: 5px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-elevated);
}
.orchestrator-summary span,
.orchestrator-metrics span {
color: var(--color-text-muted);
font-size: 12px;
}
.orchestrator-state,
.orchestrator-readiness {
margin: 14px 0 0;
padding: 12px;
border: 1px solid rgba(224, 175, 91, 0.35);
border-radius: var(--radius-md);
background: rgba(224, 175, 91, 0.08);
}
.orchestrator-state.danger {
border-color: rgba(224, 86, 86, 0.4);
background: rgba(224, 86, 86, 0.08);
}
.orchestrator-section-head,
.orchestrator-executors article > div,
.orchestrator-readiness > div,
.orchestrator-health {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.orchestrator-executors article p {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.orchestrator-executors article span {
color: var(--color-warning);
font-size: 12px;
}
.orchestrator-readiness.ready {
border-color: rgba(70, 176, 120, 0.4);
background: rgba(70, 176, 120, 0.08);
}
.orchestrator-readiness p {
margin: 10px 0;
}
.orchestrator-checks {
display: grid !important;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: start !important;
justify-content: stretch !important;
gap: 6px !important;
}
.orchestrator-checks span {
font-size: 12px;
}
.orchestrator-checks .passed {
color: var(--color-success);
}
.orchestrator-checks .blocked {
color: var(--color-warning);
}
.orchestrator-metrics {
margin: 14px 0;
}
.orchestrator-table small,
.admin-table td small {
display: block;
color: var(--color-text-muted);
margin-top: 4px;
}
.orchestrator-detail {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--color-border);
}
.orchestrator-detail > div:last-child {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
}
.orchestrator-detail pre {
max-height: 320px;
overflow: auto;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-elevated);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 11px;
}
.orchestrator-form-grid,
.orchestrator-allowlists {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.orchestrator-form-grid label,
.orchestrator-allowlists label {
display: grid;
gap: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
.orchestrator-form-grid .wide {
grid-column: 1 / -1;
}
.orchestrator-health {
margin: 14px 0;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.orchestrator-toggles {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.orchestrator-toggles label {
display: flex;
align-items: center;
gap: 8px;
}
.orchestrator-toggles input {
width: auto;
}
.orchestrator-toggles .handoff {
padding: 10px;
border: 1px solid rgba(224, 175, 91, 0.35);
border-radius: var(--radius-md);
background: rgba(224, 175, 91, 0.08);
}
.orchestrator-actions {
justify-content: flex-end;
margin-top: 14px;
}
@media (max-width: 720px) {
.orchestrator-form-grid,
.orchestrator-allowlists {
grid-template-columns: 1fr;
}
.orchestrator-section-head,
.orchestrator-health {
align-items: flex-start;
flex-direction: column;
}
}
.providers-page {
display: grid;
gap: 14px;
@@ -577,6 +783,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;
}
@@ -620,13 +871,6 @@ body,
padding: 20px;
}
.provider-preset-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.modal-actions {
grid-column: 1 / -1;
display: flex;
@@ -1012,96 +1256,6 @@ body,
margin-bottom: 18px;
}
.image-generation-settings {
display: grid;
gap: 16px;
margin-bottom: 22px;
padding: 20px;
border: 1px solid color-mix(in srgb, #9374ff 62%, var(--color-border));
border-radius: var(--radius-lg);
background: linear-gradient(145deg, rgba(106, 76, 196, .16), var(--color-bg-surface) 48%);
box-shadow: 0 12px 34px rgba(0, 0, 0, .14);
}
.image-generation-settings-head,
.image-generation-settings-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.image-generation-settings-head h3 {
margin: 5px 0 6px;
font-size: 18px;
}
.image-generation-settings-head p,
.image-generation-settings-footer span {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
}
.image-generation-provider {
display: grid;
max-width: 360px;
gap: 6px;
color: var(--color-text-muted);
font-size: 11px;
}
.image-generation-scope-fieldset {
min-width: 0;
margin: 0;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.image-generation-scope-fieldset legend {
padding: 0 6px;
color: var(--color-text-muted);
font-size: 11px;
}
.image-generation-scope-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.image-generation-scope-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, .025);
}
.image-generation-scope-item input {
margin-top: 3px;
}
.image-generation-scope-item span {
display: grid;
gap: 4px;
}
.image-generation-scope-item strong {
color: var(--color-text);
font-size: 13px;
}
.image-generation-scope-item small {
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.4;
}
.asset-plugin-card {
display: flex;
flex-direction: column;
@@ -1123,32 +1277,6 @@ body,
.asset-plugin-card--amber { border-top: 3px solid #e8af55; }
.asset-plugin-card--green { border-top: 3px solid #55c79b; }
.asset-plugin-card.is-disabled {
opacity: 0.68;
}
.asset-plugin-card.is-disabled .asset-plugin-fields,
.asset-plugin-card.is-disabled .asset-plugin-footer button {
pointer-events: none;
}
.asset-plugin-card.is-disabled .asset-plugin-card-head .asset-mini-toggle {
pointer-events: auto;
}
.asset-plugin-icon--blue { background: rgba(84, 168, 255, .17); color: #9fd0ff; }
.asset-plugin-icon--amber { background: rgba(232, 175, 85, .17); color: #f0cc84; }
.asset-plugin-icon--green { background: rgba(85, 199, 155, .17); color: #8fe0bc; }
.image-make-runtime-note {
padding: 9px 10px;
border-radius: var(--radius-sm);
background: rgba(84, 168, 255, .1);
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.5;
}
.asset-plugin-card-head {
display: flex;
gap: 10px;
@@ -1234,11 +1362,6 @@ body,
line-height: 1.45;
}
.asset-purpose-grid small {
color: var(--color-text-muted);
line-height: 1.35;
}
.asset-purpose-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1586,16 +1709,6 @@ body,
grid-template-columns: 1fr;
}
.image-generation-settings-head,
.image-generation-settings-footer {
align-items: flex-start;
flex-direction: column;
}
.image-generation-scope-grid {
grid-template-columns: 1fr;
}
.asset-plugin-footer {
align-items: flex-start;
flex-direction: column;
+35 -49
View File
@@ -120,7 +120,6 @@ export type LlmVisionSettings = {
providerLabel: string | null;
visionModel: string | null;
availableModels: string[];
apiKeyMasked?: string | null;
};
export type LlmConnectionTestResult = {
@@ -214,8 +213,6 @@ export type AssetPluginConfig = {
id: string;
label: string;
presetId: string;
strategy?: 'generate' | 'derive';
sourcePurpose?: string | null;
}>;
};
@@ -225,52 +222,6 @@ export type AssetGatewayConfig = {
plugins: AssetPluginConfig[];
};
export type ImageMakeProviderConfig = {
enabled: boolean;
model?: string;
apiBase?: string;
apiKeyConfigured?: boolean;
apiKeyMasked?: string;
dashScopeSource?: 'vision' | 'global' | null;
dashScopeKeyName?: string | null;
checkpoint?: string;
workflowPath?: string;
outputNodeId?: string;
timeoutSeconds?: number;
steps?: number;
cfg?: number;
sampler?: string;
scheduler?: string;
};
export type ImageMakeAdminConfig = {
defaultProvider: 'mock' | 'aliyun_bailian' | 'comfyui';
jobDefaultTimeoutSeconds: number;
providers: {
mock: ImageMakeProviderConfig;
aliyun_bailian: ImageMakeProviderConfig;
comfyui: ImageMakeProviderConfig;
};
};
export type ImageMakeAdminConfigResponse = {
config: ImageMakeAdminConfig;
source?: string;
updatedAt?: number | null;
updatedBy?: string | null;
};
export type ImageMakeRuntimeResponse = {
ok: boolean;
fingerprint?: string;
source?: string;
updatedAt?: number | null;
defaultProvider?: string;
jobDefaultTimeoutSeconds?: number;
providers?: ImageMakeAdminConfig['providers'];
message?: string;
};
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
export type AdminServiceRestartResult = {
@@ -674,4 +625,39 @@ export type MindSearchConfig = {
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;
};
+237
View File
@@ -0,0 +1,237 @@
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
export type OrchestratorConfig = {
mode: OrchestratorMode;
primaryEngine: string;
fallbackEngine: string;
serviceUrl: string;
requestTimeoutMs: number;
rolloutPercent: number;
userAllowlist: string[];
workflowAllowlist: string[];
fallbackToNative: boolean;
requireHealthy: boolean;
executionEnabled: boolean;
};
export type OrchestratorRuntime = {
killSwitch: boolean;
configured: boolean;
effective: boolean;
reason: string | null;
executesLangGraph: boolean;
plansLangGraph: boolean;
shadowsLangGraph: boolean;
executionHandoff: {
implemented: boolean;
requested: boolean;
enabled: boolean;
reason: string | null;
environmentGate: boolean;
};
};
export type OrchestratorConfigState = {
config: OrchestratorConfig;
configVersion: number;
updatedBy: string | null;
updatedAt: number | null;
source: string;
runtime: OrchestratorRuntime;
engines: Array<{
id: string;
label: string;
kind: string;
configured: boolean;
capabilities: string[];
}>;
executors: Array<{
id: string;
label: string;
kind: string;
enabled: boolean;
dispatchImplemented: boolean;
status: string;
capabilities: string[];
}>;
};
export type OrchestratorServiceHealth = {
checkedAt: number;
ok: boolean;
status: string;
latencyMs: number;
httpStatus: number | null;
details: {
service: string | null;
checkpoint: { kind?: string; durable?: boolean } | null;
execution: string | null;
executorGateway: {
dispatchImplemented: boolean;
executionEnabled: boolean;
store: { kind: string | null; durable: boolean } | null;
} | null;
} | null;
};
export type OrchestratorRuntimeState = OrchestratorConfigState & {
serviceHealth: OrchestratorServiceHealth;
};
export type OrchestratorShadowRun = {
eventId: string;
runId: string;
requestId: string;
userId: string;
sessionId: string | null;
nativeStatus: string;
nativeAttempts: number;
shadowStatus: 'succeeded' | 'failed';
engine: string;
configVersion: number | null;
phase: string | null;
taskType: string | null;
synthetic: boolean;
executorAdapter: string | null;
latencyMs: number | null;
error: { code: string; message: string } | null;
observedAt: number;
nativeCompletedAt: number | null;
};
export type OrchestratorShadowRunList = {
generatedAt: number;
window: { hours: number; from: number };
metrics: {
observations: number;
successes: number;
failures: number;
successRate: number | null;
failureRate: number | null;
latencyP50Ms: number | null;
latencyP95Ms: number | null;
nativeSucceeded: number;
nativeFailed: number;
lastObservedAt: number | null;
sampled: boolean;
};
runs: OrchestratorShadowRun[];
};
export type OrchestratorExecutionPlanList = {
generatedAt: number;
window: { hours: number; from: number };
metrics: {
decisions: number;
candidateSelections: number;
candidateSelectionRate: number | null;
nativeSelections: number;
nativeSucceeded: number;
nativeFailed: number;
nativeSettledRate: number | null;
handoffAllowed: number;
distinctSessions: number;
lastPlannedAt: number | null;
candidateReasons: Array<{ value: string; count: number }>;
taskTypes: Array<{ value: string; count: number }>;
sampled: boolean;
};
plans: Array<{
eventId: string;
runId: string;
requestId: string;
userId: string;
sessionId: string | null;
nativeStatus: string;
nativeAttempts: number;
mode: string | null;
candidateEngine: string;
effectiveEngine: string;
fallbackEngine: string;
reason: string | null;
candidateReason: string | null;
configVersion: number | null;
bucket: number | null;
taskType: string | null;
dryRun: boolean;
handoffAllowed: boolean;
plannedAt: number;
nativeCompletedAt: number | null;
}>;
};
export type OrchestratorCanaryReadinessCheck = {
id: string;
passed: boolean;
actual: string | number | boolean | null;
target: string | number | boolean | null;
};
export type OrchestratorCanaryReadiness = {
generatedAt: number;
ready: boolean;
recommendation: 'keep_shadow' | 'manual_canary_review';
window: { hours: number; from: number };
thresholds: {
hours: number;
minObservations: number;
minSuccessRate: number;
maxP95LatencyMs: number;
minLatencyCoverageRate: number;
minNativeSettledRate: number;
minDistinctSessions: number;
maxHoursSinceLastObservation: number;
};
samples: {
totalObservations: number;
eligibleObservations: number;
excludedSynthetic: number;
successes: number;
failures: number;
successRate: number | null;
latencyCoverageRate: number | null;
latencyP95Ms: number | null;
nativeSettledRate: number | null;
distinctSessions: number;
lastObservedAt: number | null;
hoursSinceLastObservation: number | null;
sampled: boolean;
};
service: {
status: string | null;
latencyMs: number | null;
checkpointKind: string | null;
checkpointDurable: boolean | null;
executorJobStoreKind: string | null;
executorJobStoreDurable: boolean | null;
execution: string | null;
};
checks: OrchestratorCanaryReadinessCheck[];
blockers: string[];
failureCodes: Array<{ code: string; count: number }>;
};
export type OrchestratorShadowRunDetail = {
native: {
runId: string;
requestId: string;
userId: string;
sessionId: string | null;
status: string;
attempts: number;
completedAt: number | null;
};
shadow: OrchestratorShadowRun | null;
remote: {
available: boolean;
state: Record<string, unknown> | null;
events: Array<Record<string, unknown>>;
error: { code: string; message: string } | null;
executorJob: {
available: boolean;
state: Record<string, unknown> | null;
events: Array<Record<string, unknown>>;
error: { code: string; message: string } | null;
};
};
};