Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05681d758e | |||
| 7df3ae35f1 | |||
| 731d93843d | |||
| 4cbacb8529 | |||
| a019da24b0 | |||
| 6bd0faac40 | |||
| 0358484942 | |||
| 90cbcbba7c | |||
| a8a130c08f | |||
| 98013eb548 | |||
| b9cadd91fc | |||
| ebcc33b367 | |||
| 13485558b5 | |||
| f63bb894d9 | |||
| d2b97491ef | |||
| c183893cdd | |||
| a5866ec00b | |||
| ab33ce06f3 | |||
| 65fb8ed2b2 | |||
| 194e5ac1ef | |||
| aa6a029530 | |||
| 4b4bc898bd |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: 标准化分支、测试、发布闸门(始终应用)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 标准化开发测试发布闸门
|
||||
|
||||
开始任何开发、修复、发布前,先读:
|
||||
|
||||
- `ENGINEERING_WORKFLOW_RULES.md`
|
||||
- `PRODUCTION_RELEASE_RULES.md`
|
||||
- `AGENTS.md`
|
||||
|
||||
必须遵守:
|
||||
|
||||
- 新建分支必须基于最新 `origin/main`,推荐执行 `bash scripts/new-branch.sh feature/xxx`
|
||||
- 发布前必须重新检查当前分支是否落后 `origin/main`
|
||||
- 发布来源必须是干净、可追溯的 Git commit
|
||||
- 禁止从脏工作区、detached HEAD、落后主线的分支发布
|
||||
- 禁止直接 `rsync` 到 `103/105` 或 SSH 到生产手改源码
|
||||
|
||||
发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
如需临时绕过,只能在用户明确批准后使用项目文档中记录的显式环境变量。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Agent 协作说明(Codex / Cloud / Cursor / Claude / 其它 AI 工具通用)
|
||||
|
||||
本文件供所有在仓库内工作的 AI 编码助手阅读。仓库级开发、测试、发布规范以 [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md) 为准;生产发布规范以 [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md) 为准。
|
||||
|
||||
## 必读:分支与发布闸门
|
||||
|
||||
1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`。
|
||||
2. 发布前必须重新检查当前分支是否落后 `origin/main`。
|
||||
3. 发布来源必须是干净、可追溯的 Git commit。
|
||||
4. 禁止从脏工作区、detached HEAD、落后主线的分支发布。
|
||||
5. 发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
## 仓库边界
|
||||
|
||||
- `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。
|
||||
- 发版须 Git commit,禁止本机直 `rsync` 到 `103/105`。
|
||||
- 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Claude 协作说明
|
||||
|
||||
本仓库的开发、测试、发布规范与 `AGENTS.md` 同源。开始任何代码修改或发布操作前,先读:
|
||||
|
||||
1. [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md)
|
||||
2. [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md)
|
||||
3. [AGENTS.md](AGENTS.md)
|
||||
|
||||
## 强制规则
|
||||
|
||||
- 新建分支必须基于最新 `origin/main`。
|
||||
- 发布前必须重新检查当前分支是否落后 `origin/main`。
|
||||
- 发布来源必须是干净、可追溯的 Git commit。
|
||||
- 禁止从脏工作区、detached HEAD、落后主线的分支发布。
|
||||
- 发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
如果规则与口头指令冲突,先暂停并确认,不要绕过发布闸门。
|
||||
@@ -7,10 +7,38 @@
|
||||
|
||||
## 2. 开发约束
|
||||
|
||||
1. 每次开发都必须形成本地 Git commit。
|
||||
2. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混在一起。
|
||||
3. 不允许长期堆积“只有自己知道用途”的未提交改动。
|
||||
4. `.env`、账号、密钥、日志、pid 不进入 Git。
|
||||
1. 新建开发分支前必须同步远端主线。推荐统一使用:
|
||||
|
||||
```bash
|
||||
bash scripts/new-branch.sh feature/xxx
|
||||
```
|
||||
|
||||
等价手工命令:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
git switch -c feature/xxx
|
||||
```
|
||||
|
||||
也可以直接从远端主线建分支:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git switch -c feature/xxx origin/main
|
||||
```
|
||||
|
||||
2. 每次开发都必须形成本地 Git commit。
|
||||
3. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混在一起。
|
||||
4. 不允许长期堆积“只有自己知道用途”的未提交改动。
|
||||
5. `.env`、账号、密钥、日志、pid 不进入 Git。
|
||||
6. 分支开发中如果主线继续变化,发布前必须重新对齐:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
## 3. 测试约束
|
||||
|
||||
@@ -24,6 +52,13 @@
|
||||
2. 测试与生产统一走“打包发布”。
|
||||
3. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。
|
||||
4. 发布前必须有备份,发布后必须有健康检查和业务验收。
|
||||
5. 发布前必须通过统一闸门:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
6. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。
|
||||
|
||||
## 5. 文档约束
|
||||
|
||||
@@ -35,3 +70,4 @@
|
||||
1. 发布前要求工作区可读:`git status` 不能混入无关改动。
|
||||
2. 为每次正式发布保留 manifest、备份包路径、验证结果。
|
||||
3. 涉及数据库、计费、用户空间、鉴权的改动,要额外保留一份业务验收清单。
|
||||
4. 每个 AI 工具都必须先读本文件;自动化工具、Git hook、发布脚本以 `scripts/check-release-ready.sh` 的结果为准。
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
2. `memind_adm` 生产发布唯一合法路径是:本地已提交代码 -> 本地生成发布包 -> 上传 `103` -> `103` 全量备份当前 `memind_adm` -> 解包到 release 目录 -> 安装依赖/构建 -> 原子切换 live 目录 -> 重启和健康检查。
|
||||
3. `scripts/release-prod.sh` 是当前唯一允许的生产更新入口,`scripts/rsync_to_server.sh` 只保留为禁用提示,不得再用于发布。
|
||||
4. 发布包不得携带运行态资产;`.env`、日志、pid 文件、`.mindops/` 等只能从线上现有 live 目录继承。
|
||||
5. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
|
||||
6. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
|
||||
7. 生产验证至少包含 `http://127.0.0.1:8085/health` 和 `http://127.0.0.1:5174/` 的成功检查,并补充本次功能对应的业务路径验收。
|
||||
8. 共享用户体系、计费体系、策略体系、空间额度改动上线后,必须做一次真实登录和 `/admin-api/users` 的业务校验。
|
||||
9. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
|
||||
5. 每次生产发布前必须先通过 `bash scripts/check-release-ready.sh`,确认分支不落后 `origin/main`、工作区干净、发布来源可追溯。
|
||||
6. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
|
||||
7. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
|
||||
8. 生产验证至少包含 `http://127.0.0.1:8085/health` 和 `http://127.0.0.1:5174/` 的成功检查,并补充本次功能对应的业务路径验收。
|
||||
9. 共享用户体系、计费体系、策略体系、空间额度改动上线后,必须做一次真实登录和 `/admin-api/users` 的业务校验。
|
||||
10. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
|
||||
|
||||
Generated
+147
@@ -15,6 +15,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -3188,6 +3189,95 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3246,6 +3336,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -3663,6 +3792,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
@@ -3953,6 +4091,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
SKIP_FETCH=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/check-branch-baseline.sh [--skip-fetch]
|
||||
|
||||
Checks that the current branch is not behind origin/main.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to compare against. Defaults to origin/main when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-fetch) SKIP_FETCH=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null
|
||||
|
||||
if [[ "${SKIP_FETCH}" -ne 1 ]] && git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "${ROOT}" fetch origin --prune
|
||||
fi
|
||||
|
||||
if [[ -z "${BASE_REF}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
BASE_REF="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
branch="$(git -C "${ROOT}" branch --show-current)"
|
||||
if [[ -z "${branch}" ]]; then
|
||||
echo "Baseline check failed: detached HEAD is not allowed for development or release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null; then
|
||||
echo "Baseline check failed: base ref not found: ${BASE_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r ahead behind < <(git -C "${ROOT}" rev-list --left-right --count "HEAD...${BASE_REF}")
|
||||
|
||||
if [[ "${behind}" -ne 0 ]]; then
|
||||
echo "Baseline check failed: ${branch} is behind ${BASE_REF} by ${behind} commit(s)." >&2
|
||||
echo "Run: git fetch origin --prune && git rebase ${BASE_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Baseline check passed: ${branch} is current with ${BASE_REF} (ahead ${ahead})."
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
SKIP_FETCH=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/check-release-ready.sh [--skip-fetch]
|
||||
|
||||
Blocks releases unless the repository is on a named branch, the branch is not
|
||||
behind origin/main, and the worktree is clean.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to compare against. Defaults to origin/main when available.
|
||||
ALLOW_MAIN_RELEASE=1 Allow publishing directly from main/master.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-fetch) SKIP_FETCH=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null
|
||||
|
||||
branch="$(git -C "${ROOT}" branch --show-current)"
|
||||
if [[ -z "${branch}" ]]; then
|
||||
echo "Release check failed: detached HEAD is not a release source." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${branch}" in
|
||||
main|master)
|
||||
if [[ "${ALLOW_MAIN_RELEASE:-0}" != "1" ]]; then
|
||||
echo "Release check failed: publish from a dedicated release/feature branch, not ${branch}." >&2
|
||||
echo "Set ALLOW_MAIN_RELEASE=1 only for an explicitly approved mainline release." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
echo "Release check failed: worktree has uncommitted or untracked changes." >&2
|
||||
git -C "${ROOT}" status --short --untracked-files=all >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FETCH}" -eq 1 ]]; then
|
||||
bash "${ROOT}/scripts/check-branch-baseline.sh" --skip-fetch
|
||||
else
|
||||
bash "${ROOT}/scripts/check-branch-baseline.sh"
|
||||
fi
|
||||
|
||||
effective_base="${BASE_REF}"
|
||||
if [[ -z "${effective_base}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
effective_base="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
head_sha="$(git -C "${ROOT}" rev-parse --short HEAD)"
|
||||
echo "Release check passed: ${branch} @ ${head_sha} is clean and current with ${effective_base}."
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/new-branch.sh <branch-name>
|
||||
|
||||
Creates a new development branch from the latest origin/main.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to create from. Defaults to origin/main when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -ne 1 ]]; then
|
||||
usage
|
||||
[[ $# -eq 1 ]] && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
branch="$1"
|
||||
|
||||
if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
echo "New branch check failed: current worktree is not clean." >&2
|
||||
git -C "${ROOT}" status --short --untracked-files=all >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "${ROOT}" fetch origin --prune
|
||||
fi
|
||||
|
||||
if [[ -z "${BASE_REF}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
BASE_REF="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null || {
|
||||
echo "New branch check failed: base ref not found: ${BASE_REF}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git -C "${ROOT}" switch -c "${branch}" "${BASE_REF}"
|
||||
echo "Created ${branch} from ${BASE_REF}."
|
||||
+23
-4
@@ -20,7 +20,7 @@ TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memindadm-release.XXXXXX")"
|
||||
BUNDLE_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.tar.gz"
|
||||
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}/../test-memind}"
|
||||
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../Memind}"
|
||||
MEMIND_LIB="${ROOT}/memind-lib"
|
||||
DRY_RUN=0
|
||||
SKIP_BUILD=0
|
||||
@@ -82,9 +82,17 @@ need_cmd() {
|
||||
|
||||
need_cmd ssh
|
||||
need_cmd scp
|
||||
need_cmd rsync
|
||||
need_cmd tar
|
||||
need_cmd shasum
|
||||
|
||||
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
|
||||
bash "${ROOT}/scripts/check-release-ready.sh"
|
||||
else
|
||||
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ -f "${IGNORE_FILE}" ]] || {
|
||||
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
|
||||
exit 1
|
||||
@@ -108,14 +116,23 @@ fi
|
||||
}
|
||||
|
||||
[[ -d "${MEMIND_SRC}" ]] || {
|
||||
echo "缺少 test-memind 共享模块源: ${MEMIND_SRC}" >&2
|
||||
echo "缺少 Memind 共享模块源: ${MEMIND_SRC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
say "打包 Memind 共享模块到 memind-lib"
|
||||
rm -rf "${MEMIND_LIB}"
|
||||
mkdir -p "${MEMIND_LIB}"
|
||||
find "${MEMIND_SRC}" -maxdepth 1 -name '*.mjs' -exec cp -f {} "${MEMIND_LIB}/" \;
|
||||
rsync -a \
|
||||
--prune-empty-dirs \
|
||||
--exclude '.git/' \
|
||||
--exclude 'node_modules/' \
|
||||
--include '*/' \
|
||||
--include '*.mjs' \
|
||||
--include '*.json' \
|
||||
--include '*.js' \
|
||||
--exclude '*' \
|
||||
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
|
||||
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
|
||||
echo "memind-lib 缺少 user-auth.mjs" >&2
|
||||
exit 1
|
||||
@@ -187,7 +204,8 @@ say() {
|
||||
}
|
||||
|
||||
rollback() {
|
||||
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
|
||||
if [[ -d "${OLD_LIVE_DIR}" ]]; then
|
||||
rm -rf "${APP_DIR}"
|
||||
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
|
||||
fi
|
||||
ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/dev/null 2>&1 || true
|
||||
@@ -222,6 +240,7 @@ done
|
||||
|
||||
[[ -d "${RELEASE_DIR}/dist" ]] || { echo "release bundle missing dist/" >&2; exit 1; }
|
||||
[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "release bundle must not contain src/" >&2; exit 1; }
|
||||
[[ -f "${RELEASE_DIR}/memind-lib/user-auth.mjs" ]] || { echo "release bundle missing memind-lib/user-auth.mjs" >&2; exit 1; }
|
||||
|
||||
say "安装运行依赖"
|
||||
cd "${RELEASE_DIR}"
|
||||
|
||||
+259
-1
@@ -91,11 +91,19 @@ export function createAdminApp(services) {
|
||||
const {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
pool,
|
||||
ready,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -282,7 +290,27 @@ export function createAdminApp(services) {
|
||||
|
||||
adminApi.get('/wechat/summary', requireAdmin, async (_req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.getSummary());
|
||||
const summary = await wechatAdmin.getSummary();
|
||||
const scheduleLlmConfig = wechatScheduleLlmConfigService
|
||||
? await wechatScheduleLlmConfigService.getConfig()
|
||||
: { scheduleLlmEnabled: false };
|
||||
res.json({
|
||||
...summary,
|
||||
config: {
|
||||
...summary.config,
|
||||
scheduleLlmEnabled: scheduleLlmConfig.scheduleLlmEnabled,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.patch('/wechat/schedule-llm-config', requireAdmin, async (req, res) => {
|
||||
if (!wechatScheduleLlmConfigService) {
|
||||
return res.status(503).json({ message: '服务号提醒 LLM 配置未启用' });
|
||||
}
|
||||
const result = await wechatScheduleLlmConfigService.updateConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => {
|
||||
@@ -301,6 +329,236 @@ export function createAdminApp(services) {
|
||||
res.json({ config: result });
|
||||
});
|
||||
|
||||
adminApi.get('/asset-gateway/config', requireAdmin, async (_req, res) => {
|
||||
if (!assetGatewayConfigService?.getConfig) {
|
||||
return res.status(503).json({ message: '资产能力配置服务未启用' });
|
||||
}
|
||||
res.json(await assetGatewayConfigService.getConfig());
|
||||
});
|
||||
|
||||
adminApi.put('/asset-gateway/config', requireAdmin, async (req, res) => {
|
||||
if (!assetGatewayConfigService?.updateGlobalConfig) {
|
||||
return res.status(503).json({ message: '资产能力配置服务未启用' });
|
||||
}
|
||||
res.json(await assetGatewayConfigService.updateGlobalConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
}));
|
||||
});
|
||||
|
||||
adminApi.put('/asset-gateway/plugins/:pluginId', requireAdmin, async (req, res) => {
|
||||
if (!assetGatewayConfigService?.updatePluginConfig) {
|
||||
return res.status(503).json({ message: '资产能力配置服务未启用' });
|
||||
}
|
||||
const result = await assetGatewayConfigService.updatePluginConfig(req.params.pluginId, req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
res.json(await memoryV2ConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
adminApi.patch('/memory-v2/config', requireAdmin, async (req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
const result = await memoryV2ConfigService.updateAdminConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
|
||||
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||
res.json(await mindSearchConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
adminApi.patch('/mindsearch/config', requireAdmin, async (req, res) => {
|
||||
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
|
||||
});
|
||||
|
||||
adminApi.put('/mindsearch/config', requireAdmin, async (req, res) => {
|
||||
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
|
||||
});
|
||||
|
||||
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||
res.json(await mindSearchConfigService.getRuntimeState());
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
||||
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||
try {
|
||||
const response = await fetch(`${portalBaseUrl}/api/runtime/status`, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(2500),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({
|
||||
ok: false,
|
||||
message: `Memory V2 运行时状态返回 HTTP ${response.status}`,
|
||||
});
|
||||
}
|
||||
const payload = await response.json();
|
||||
return res.json({
|
||||
ok: true,
|
||||
checkedAt: Date.now(),
|
||||
memory: payload?.memory ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
checkedAt: Date.now(),
|
||||
message: err instanceof Error ? err.message : 'Memory V2 运行时不可用',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => {
|
||||
if (!personalMemoryCandidateStore?.listCandidates) {
|
||||
return res.status(503).json({ message: '候选记忆存储未启用' });
|
||||
}
|
||||
try {
|
||||
const status = String(req.query.status ?? 'candidate');
|
||||
const userId = String(req.query.userId ?? '').trim() || null;
|
||||
const limit = Number(req.query.limit ?? 50);
|
||||
const [items, counts] = await Promise.all([
|
||||
personalMemoryCandidateStore.listCandidates({ status, userId, limit }),
|
||||
personalMemoryCandidateStore.countByStatus(),
|
||||
]);
|
||||
return res.json({ items, counts });
|
||||
} catch (err) {
|
||||
const missingTable = err?.code === 'ER_NO_SUCH_TABLE';
|
||||
return res.status(missingTable ? 503 : 400).json({
|
||||
message: missingTable ? '候选记忆表尚未执行本地迁移' : (err instanceof Error ? err.message : '候选记忆读取失败'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.post('/memory-v2/candidates/:id/accept', requireAdmin, async (req, res) => {
|
||||
if (!personalMemoryCandidateStore?.reviewCandidate) {
|
||||
return res.status(503).json({ message: '候选记忆存储未启用' });
|
||||
}
|
||||
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'accepted', {
|
||||
reviewedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
adminApi.post('/memory-v2/candidates/:id/reject', requireAdmin, async (req, res) => {
|
||||
if (!personalMemoryCandidateStore?.reviewCandidate) {
|
||||
return res.status(503).json({ message: '候选记忆存储未启用' });
|
||||
}
|
||||
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'rejected', {
|
||||
reviewedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
|
||||
if (!skillRuntimeConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||
}
|
||||
res.json(await skillRuntimeConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateSkillRuntimeConfig = async (req, res) => {
|
||||
if (!skillRuntimeConfigService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||
}
|
||||
const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
res.json(result);
|
||||
};
|
||||
|
||||
adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
|
||||
adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
|
||||
|
||||
adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => {
|
||||
if (!skillRuntimeConfigService?.listCatalogSummary) {
|
||||
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||
}
|
||||
res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() });
|
||||
});
|
||||
|
||||
adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) {
|
||||
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||
}
|
||||
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
|
||||
});
|
||||
|
||||
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const accounts = await systemTestAccountService.listAccounts();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/accounts', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.createAccount({
|
||||
label: req.body?.label,
|
||||
username: req.body?.username,
|
||||
password: req.body?.password,
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ message: result.message ?? '保存测试账号失败' });
|
||||
}
|
||||
res.status(201).json({ account: result.account });
|
||||
});
|
||||
|
||||
adminApi.delete('/system-tests/accounts/:accountId', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.deleteAccount(req.params.accountId);
|
||||
if (!result.ok) {
|
||||
return res.status(404).json({ message: result.message ?? '测试账号不存在' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||
if (!adminSystemTestService?.runSkillValidation) {
|
||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||
}
|
||||
const accountId = String(req.body?.accountId ?? '').trim();
|
||||
let username = String(req.body?.username ?? '').trim();
|
||||
let password = String(req.body?.password ?? '');
|
||||
const skillName = String(req.body?.skillName ?? '').trim();
|
||||
if (accountId) {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const account = await systemTestAccountService.getAccountSecret(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({ message: '所选测试账号不存在,请刷新后重试' });
|
||||
}
|
||||
username = account.username;
|
||||
password = account.password;
|
||||
}
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '请输入账号和密码' });
|
||||
}
|
||||
const result = await adminSystemTestService.runSkillValidation({
|
||||
username,
|
||||
password,
|
||||
skillName,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listBindings(req.query));
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
import { createSystemTestAccountService, ensureSystemTestAccountSchema } from './system-test-accounts.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
@@ -27,6 +28,14 @@ export async function bootstrapAdminServices() {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
|
||||
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
||||
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
const {
|
||||
@@ -82,6 +91,26 @@ export async function bootstrapAdminServices() {
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
});
|
||||
await ensureAssetGatewaySchema(pool);
|
||||
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
|
||||
await mindSearchConfigService.ensureSchema();
|
||||
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
|
||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
h5Root,
|
||||
});
|
||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
userAuth,
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
@@ -107,6 +136,8 @@ export async function bootstrapAdminServices() {
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
await ensureSystemTestAccountSchema(pool);
|
||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||
|
||||
console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`);
|
||||
console.log(`Users root: ${usersRoot}`);
|
||||
@@ -116,12 +147,20 @@ export async function bootstrapAdminServices() {
|
||||
pool,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
mindSpaceConfig,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -98,11 +98,19 @@ ready
|
||||
pool,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -116,11 +124,19 @@ ready
|
||||
pool,
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TABLE = 'h5_admin_system_test_accounts';
|
||||
|
||||
function resolveEncryptionKey(explicitKey) {
|
||||
const raw =
|
||||
explicitKey ??
|
||||
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
||||
process.env.TKMIND_SERVER__SECRET_KEY ??
|
||||
'local-dev-secret';
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
function encryptSecret(plaintext, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const plain = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plain.toString('utf8');
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey) {
|
||||
if (!apiKey) return '';
|
||||
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
|
||||
const head = apiKey.slice(0, 4);
|
||||
const tail = apiKey.slice(-4);
|
||||
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function toAccountRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
passwordMasked: maskApiKey(row.password_preview ?? ''),
|
||||
createdAt: Number(row.created_at ?? 0) || 0,
|
||||
updatedAt: Number(row.updated_at ?? 0) || 0,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSystemTestAccountSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
password_iv VARCHAR(255) NOT NULL,
|
||||
password_tag VARCHAR(255) NOT NULL,
|
||||
password_preview VARCHAR(255) NOT NULL DEFAULT '',
|
||||
updated_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uniq_${TABLE}_username (username)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function createSystemTestAccountService(pool) {
|
||||
if (!pool) {
|
||||
throw new Error('system test account service requires pool');
|
||||
}
|
||||
|
||||
async function listAccounts() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
ORDER BY updated_at DESC, created_at DESC`,
|
||||
);
|
||||
return rows.map(toAccountRow);
|
||||
}
|
||||
|
||||
async function getAccountSecret(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_ciphertext, password_iv, password_tag
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
password: decryptSecret({
|
||||
ciphertext: row.password_ciphertext,
|
||||
iv: row.password_iv,
|
||||
tag: row.password_tag,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createAccount({ label, username, password, updatedBy }) {
|
||||
const normalizedUsername = normalizeText(username);
|
||||
const normalizedLabel = normalizeText(label) || normalizedUsername;
|
||||
if (!normalizedUsername || !password) {
|
||||
return { ok: false, message: '账号和密码不能为空' };
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const encrypted = encryptSecret(String(password));
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO ${TABLE}
|
||||
(id, label, username, password_ciphertext, password_iv, password_tag, password_preview, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
normalizedLabel,
|
||||
normalizedUsername,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
maskApiKey(String(password)),
|
||||
updatedBy ?? null,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '该测试账号已存在,请直接使用或先删除旧账号' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const account = await getAccountById(id);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
async function getAccountById(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
return row ? toAccountRow(row) : null;
|
||||
}
|
||||
|
||||
async function deleteAccount(accountId) {
|
||||
const existing = await getAccountById(accountId);
|
||||
if (!existing) return { ok: false, message: '测试账号不存在' };
|
||||
await pool.query(`DELETE FROM ${TABLE} WHERE id = ? LIMIT 1`, [accountId]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
listAccounts,
|
||||
getAccountSecret,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
};
|
||||
}
|
||||
+16
@@ -7,12 +7,17 @@ import { CapabilitiesPage } from './admin/pages/CapabilitiesPage';
|
||||
import { DashboardPage } from './admin/pages/DashboardPage';
|
||||
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||
import { MindSearchPage } from './admin/pages/MindSearchPage';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import { WechatPage } from './admin/pages/WechatPage';
|
||||
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
|
||||
import { defaultHomePath } from './lib/routes';
|
||||
import { OpsLayout } from './ops/components/OpsLayout';
|
||||
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
|
||||
@@ -20,6 +25,7 @@ import { CreatorsPage } from './ops/pages/CreatorsPage';
|
||||
import { FeaturedPage } from './ops/pages/FeaturedPage';
|
||||
import { ReportsPage } from './ops/pages/ReportsPage';
|
||||
import { ReviewPage } from './ops/pages/ReviewPage';
|
||||
import { PostsPage } from './ops/pages/PostsPage';
|
||||
import type { PortalUser } from './types';
|
||||
|
||||
function LegacyAdminRedirect() {
|
||||
@@ -113,10 +119,15 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="system-tests" element={<SystemTestsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="mindspace" element={<MindSpacePage />} />
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
<Route path="mindsearch" element={<MindSearchPage />} />
|
||||
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
||||
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
@@ -125,6 +136,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
)}
|
||||
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<ReviewPage />} />
|
||||
<Route path="posts" element={<PostsPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="featured" element={<FeaturedPage />} />
|
||||
<Route path="creators" element={<CreatorsPage />} />
|
||||
@@ -156,10 +168,14 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
|| pathname.startsWith('/billing')
|
||||
|| pathname.startsWith('/capabilities')
|
||||
|| pathname.startsWith('/skills')
|
||||
|| pathname.startsWith('/system-tests')
|
||||
|| pathname.startsWith('/policies')
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|| pathname.startsWith('/skill-runtime')
|
||||
|| pathname.startsWith('/providers')
|
||||
|| pathname.startsWith('/wechat')
|
||||
|| pathname.startsWith('/asset-gateway')
|
||||
) {
|
||||
return role === 'admin' || role === undefined ? pathname : '/ops';
|
||||
}
|
||||
|
||||
@@ -28,10 +28,15 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: '/wechat', label: '服务号' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/mindsearch', label: 'MindSearch' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
||||
{ to: '/system-tests', label: '系统测试验证' },
|
||||
{ to: '/capabilities', label: '能力' },
|
||||
{ to: '/skills', label: '技能' },
|
||||
{ to: '/policies', label: '策略' },
|
||||
{ to: '/providers', label: '统一模型中心' },
|
||||
{ to: '/asset-gateway', label: '资产能力' },
|
||||
{ to: '/blocked-words', label: '违禁词管理' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
getAssetGatewayConfig,
|
||||
listLlmProviderKeys,
|
||||
updateAssetGatewayConfig,
|
||||
updateAssetPluginConfig,
|
||||
} from '../../api/client';
|
||||
import type { AssetGatewayConfig, AssetPluginConfig, LlmProviderKeyRow } from '../../types';
|
||||
|
||||
type PluginDraft = {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
llmProviderKeyId: string;
|
||||
llmModel: string;
|
||||
};
|
||||
|
||||
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
||||
return {
|
||||
enabled: plugin.enabled,
|
||||
provider: plugin.provider ?? '',
|
||||
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
|
||||
llmModel: plugin.llmModel ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const pluginVisuals: Record<string, { icon: string; accent: string }> = {
|
||||
'asset-search': { icon: '⌕', accent: 'blue' },
|
||||
'asset-generate': { icon: '✦', accent: 'violet' },
|
||||
'asset-transform': { icon: '✧', accent: 'amber' },
|
||||
'asset-analyze': { icon: '◉', accent: 'green' },
|
||||
};
|
||||
|
||||
export function AssetGatewayPage() {
|
||||
const [config, setConfig] = useState<AssetGatewayConfig | null>(null);
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, PluginDraft>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const activeKeys = useMemo(() => keys.filter((key) => key.status === 'active'), [keys]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextConfig, nextKeys] = await Promise.all([getAssetGatewayConfig(), listLlmProviderKeys()]);
|
||||
setConfig(nextConfig);
|
||||
setKeys(nextKeys);
|
||||
setDrafts(Object.fromEntries(nextConfig.plugins.map((plugin) => [plugin.pluginId, draftFromPlugin(plugin)])));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载资产能力配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const updateDraft = (pluginId: string, patch: Partial<PluginDraft>) => {
|
||||
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
|
||||
};
|
||||
|
||||
const saveGlobal = async (enabled: boolean) => {
|
||||
setBusy('global'); setMessage(null); setError(null);
|
||||
try {
|
||||
const next = await updateAssetGatewayConfig({ enabled });
|
||||
setConfig(next);
|
||||
setMessage(`资产能力总开关已${enabled ? '开启' : '关闭'}。`);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '保存总开关失败'); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const savePlugin = async (plugin: AssetPluginConfig) => {
|
||||
const draft = drafts[plugin.pluginId];
|
||||
if (!draft) return;
|
||||
setBusy(plugin.pluginId); setMessage(null); setError(null);
|
||||
try {
|
||||
const result = await updateAssetPluginConfig(plugin.pluginId, {
|
||||
enabled: draft.enabled,
|
||||
provider: draft.provider || null,
|
||||
llmProviderKeyId: draft.llmProviderKeyId || null,
|
||||
llmModel: draft.llmModel || null,
|
||||
});
|
||||
setConfig(result.config);
|
||||
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
|
||||
setMessage(`${plugin.label}配置已保存。`);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '保存插件配置失败'); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="admin-page"><p className="muted">加载资产能力配置…</p></div>;
|
||||
if (!config) return <div className="admin-page"><p className="banner banner-error">{error ?? '无法加载配置'}</p></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>资产能力</h2>
|
||||
<p className="muted">可插拔图片、素材与处理能力。默认关闭,不会影响现有聊天或页面生成流程。</p>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-success">{message}</p>}
|
||||
<section className="asset-gateway-hero">
|
||||
<div>
|
||||
<div className="asset-kicker">OPTIONAL CAPABILITY</div>
|
||||
<h3>资产能力 Gateway</h3>
|
||||
<p>关闭时所有调用自动降级为现有流程;配置保留,现有聊天和页面生成不受影响。</p>
|
||||
</div>
|
||||
<label className="asset-toggle">
|
||||
<input type="checkbox" checked={config.enabled} disabled={busy === 'global'} onChange={(event) => void saveGlobal(event.target.checked)} />
|
||||
<span>{config.enabled ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
</section>
|
||||
<div className="asset-plugin-grid asset-plugin-grid--balanced">
|
||||
{config.plugins.map((plugin) => {
|
||||
const draft = drafts[plugin.pluginId] ?? draftFromPlugin(plugin);
|
||||
const selectedKey = activeKeys.find((key) => key.id === draft.llmProviderKeyId);
|
||||
const visual = pluginVisuals[plugin.pluginId] ?? { icon: '✦', accent: 'blue' };
|
||||
return <section className={`asset-plugin-card asset-plugin-card--${visual.accent}`} key={plugin.pluginId}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
|
||||
<div><h3>{plugin.label}</h3><p>{plugin.description}</p></div>
|
||||
<label className="asset-mini-toggle" title={`启用${plugin.label}`}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(event) => updateDraft(plugin.pluginId, { enabled: event.target.checked })} />
|
||||
<span>{draft.enabled ? '开启' : '关闭'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>运行 Provider<select value={draft.provider} onChange={(event) => updateDraft(plugin.pluginId, { provider: event.target.value })} disabled={!draft.enabled}>
|
||||
<option value="">选择 Provider</option>
|
||||
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select></label>
|
||||
{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>}
|
||||
</div>
|
||||
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,11 @@ const QUICK_LINKS = [
|
||||
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
|
||||
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
||||
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
||||
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
|
||||
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
|
||||
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
||||
] as const;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
|
||||
import type { MindSearchConfig } from '../../types';
|
||||
|
||||
const initial: MindSearchConfig = {
|
||||
enabled: false,
|
||||
mode: 'off',
|
||||
providers: { searxng: false, github: false, reader: false },
|
||||
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
|
||||
};
|
||||
|
||||
export function MindSearchPage() {
|
||||
const [config, setConfig] = useState(initial);
|
||||
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 } });
|
||||
setMeta(result);
|
||||
setMessage('');
|
||||
})
|
||||
.catch((error) => setMessage(error instanceof Error ? error.message : '配置加载失败'));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setMessage('保存中…');
|
||||
try {
|
||||
const result = await updateMindSearchConfig(config);
|
||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
||||
setMeta(result);
|
||||
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
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 } }));
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<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 Code(Token 通过环境变量配置,不在页面回显)</label>
|
||||
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader(阻止 localhost/内网地址)</label>
|
||||
</fieldset>
|
||||
<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>
|
||||
{message && <p role="status">{message}</p>}
|
||||
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+215
-160
@@ -35,6 +35,14 @@ import type {
|
||||
|
||||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
|
||||
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
|
||||
goose: { icon: 'G', accent: 'blue' },
|
||||
aider: { icon: 'A', accent: 'amber' },
|
||||
openhands: { icon: 'O', accent: 'violet' },
|
||||
};
|
||||
|
||||
const PROVIDER_ACCENTS = ['blue', 'violet', 'green', 'amber'] as const;
|
||||
|
||||
const defaultProviderForm = {
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: '',
|
||||
@@ -270,40 +278,41 @@ function ExecutorCard({
|
||||
}
|
||||
}, [model, selectedKey]);
|
||||
|
||||
const visual = EXECUTOR_VISUALS[binding.executor] ?? { icon: '✦', accent: 'blue' };
|
||||
|
||||
return (
|
||||
<article className="executor-card">
|
||||
<div className="admin-card-head">
|
||||
<section className={`asset-plugin-card asset-plugin-card--${visual.accent} model-center-card`}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
|
||||
<div>
|
||||
<h3>{binding.executorLabel}</h3>
|
||||
<p className="muted">{binding.executorDescription}</p>
|
||||
<p>{binding.executorDescription}</p>
|
||||
</div>
|
||||
<label className="inline-check">
|
||||
<label className="asset-mini-toggle" aria-label={`${binding.executorLabel} 启用状态`}>
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
启用
|
||||
<span>{enabled ? '启用' : '关闭'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="executor-form-grid">
|
||||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||||
<option value="">选择模型</option>
|
||||
{modelOptions.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy || (enabled && (!keyId || !model))}
|
||||
onClick={() => void onSave(binding, keyId || null, model, enabled)}
|
||||
>
|
||||
保存绑定
|
||||
</button>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
Provider
|
||||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||||
<option value="">选择模型</option>
|
||||
{modelOptions.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="executor-footer">
|
||||
<div className="model-center-card-body">
|
||||
<div className="executor-meta">
|
||||
<span>{statusText(launchStatus)}</span>
|
||||
{runtime?.ok ? <span>{runtime.providerName} · {runtime.model}</span> : <span>{runtime?.message ?? '运行配置未就绪'}</span>}
|
||||
@@ -348,10 +357,24 @@ function ExecutorCard({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
|
||||
</div>
|
||||
|
||||
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
|
||||
</article>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
|
||||
{enabled ? '绑定启用中' : '绑定已关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy || (enabled && (!keyId || !model))}
|
||||
onClick={() => void onSave(binding, keyId || null, model, enabled)}
|
||||
>
|
||||
保存绑定
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,45 +410,61 @@ function VisionSection({
|
||||
}, [model, selectedKey]);
|
||||
|
||||
return (
|
||||
<section className="admin-card providers-panel">
|
||||
<div className="admin-card-head providers-panel-head">
|
||||
<div>
|
||||
<h3>图片任务模型</h3>
|
||||
<p className="muted">发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
|
||||
<div className="asset-plugin-grid model-center-vision-grid">
|
||||
<section className="asset-plugin-card asset-plugin-card--green model-center-card">
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">◉</div>
|
||||
<div>
|
||||
<h3>图片任务模型</h3>
|
||||
<p>发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
|
||||
</div>
|
||||
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
|
||||
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
|
||||
</span>
|
||||
</div>
|
||||
{settings?.keyId ? (
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
|
||||
清除
|
||||
</button>
|
||||
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
Provider
|
||||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||||
</label>
|
||||
<label>
|
||||
视觉模型
|
||||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||||
<option value="">选择视觉模型</option>
|
||||
{modelOptions.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!settings?.keyId ? (
|
||||
<p className="model-center-card-note">未配置时,图片消息将由默认模型处理。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{settings?.keyId ? (
|
||||
<p className="muted" style={{ marginBottom: '0.75rem' }}>
|
||||
当前:<strong>{settings.keyName}</strong> · <span className="mono">{settings.visionModel}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="muted" style={{ marginBottom: '0.75rem' }}>未配置,图片消息将由默认模型处理</p>
|
||||
)}
|
||||
|
||||
<div className="executor-form-grid">
|
||||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||||
<option value="">选择视觉模型</option>
|
||||
{modelOptions.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy || !keyId || !model}
|
||||
onClick={() => void onSave(keyId, model)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
|
||||
{settings?.keyId ? '已绑定视觉模型' : '等待配置'}
|
||||
</span>
|
||||
<div className="model-center-card-actions">
|
||||
{settings?.keyId ? (
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy || !keyId || !model}
|
||||
onClick={() => void onSave(keyId, model)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -723,123 +762,139 @@ export function ProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeProviderCount = keys.filter((item) => item.status === 'active').length;
|
||||
|
||||
return (
|
||||
<div className="admin-page providers-page">
|
||||
<div className="admin-page-head providers-hero">
|
||||
<div>
|
||||
<h2>统一模型中心</h2>
|
||||
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
|
||||
</div>
|
||||
<div className="providers-hero-meta">
|
||||
<div className="providers-hero-stat">
|
||||
<span className="providers-hero-label">当前默认</span>
|
||||
<strong>{globalSettings?.globalModel ?? '未设置'}</strong>
|
||||
</div>
|
||||
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
|
||||
添加 Provider
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>统一模型中心</h2>
|
||||
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
|
||||
</div>
|
||||
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card providers-panel providers-panel-primary">
|
||||
<div className="admin-card-head providers-panel-head">
|
||||
<div>
|
||||
<h3>执行器绑定</h3>
|
||||
<p className="muted">把 Provider 和模型绑定到 Goose、Aider、OpenHands</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
|
||||
同步 Goose 绑定
|
||||
</button>
|
||||
<section className="asset-gateway-hero">
|
||||
<div>
|
||||
<div className="asset-kicker">MODEL CENTER</div>
|
||||
<h3>默认模型与 Provider 资源池</h3>
|
||||
<p>
|
||||
当前默认模型 <strong className="mono">{globalSettings?.globalModel ?? '未设置'}</strong>
|
||||
{' · '}
|
||||
Provider 资源池 {activeProviderCount}/{keys.length} 可用
|
||||
</p>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中...</p>
|
||||
) : (
|
||||
<div className="executor-grid">
|
||||
{bindings.map((binding) => (
|
||||
<ExecutorCard
|
||||
key={binding.executor}
|
||||
binding={binding}
|
||||
keys={activeKeys}
|
||||
runtime={runtimes.find((item) => item.executor === binding.executor)}
|
||||
plan={plans.find((item) => item.executor === binding.executor)}
|
||||
launchStatus={launchStatuses[binding.executor]}
|
||||
busy={busy}
|
||||
onSave={handleSaveBinding}
|
||||
onLaunch={handleLaunch}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-card providers-panel providers-panel-secondary">
|
||||
<div className="admin-card-head providers-panel-head">
|
||||
<div>
|
||||
<h3>Provider 资源池</h3>
|
||||
<p className="muted">按名称纵向查看,更接近 list</p>
|
||||
</div>
|
||||
<div className="model-center-hero-actions">
|
||||
<button type="button" className="ghost-btn" disabled={busy || loading} onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
|
||||
添加 Provider
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="providers-list">
|
||||
{keys.map((row) => (
|
||||
<article key={row.id} className="provider-list-item">
|
||||
<div className="provider-list-main">
|
||||
<div className="provider-list-top">
|
||||
<div className="providers-name">
|
||||
<span>{row.name}</span>
|
||||
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
|
||||
</div>
|
||||
<span className={`provider-state ${row.status === 'active' ? 'is-active' : 'is-disabled'}`}>
|
||||
{row.status === 'active' ? '可用' : '禁用'}
|
||||
</span>
|
||||
<div className="model-center-section-head">
|
||||
<h3 className="model-center-section-title">执行器绑定</h3>
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
|
||||
同步 Goose 绑定
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中...</p>
|
||||
) : (
|
||||
<div className="asset-plugin-grid">
|
||||
{bindings.map((binding) => (
|
||||
<ExecutorCard
|
||||
key={binding.executor}
|
||||
binding={binding}
|
||||
keys={activeKeys}
|
||||
runtime={runtimes.find((item) => item.executor === binding.executor)}
|
||||
plan={plans.find((item) => item.executor === binding.executor)}
|
||||
launchStatus={launchStatuses[binding.executor]}
|
||||
busy={busy}
|
||||
onSave={handleSaveBinding}
|
||||
onLaunch={handleLaunch}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="model-center-section-title">Provider 资源池</h3>
|
||||
<div className="asset-plugin-grid">
|
||||
{keys.map((row, index) => {
|
||||
const accent = PROVIDER_ACCENTS[index % PROVIDER_ACCENTS.length];
|
||||
return (
|
||||
<section key={row.id} className={`asset-plugin-card asset-plugin-card--${accent} model-center-card model-center-provider-card`}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">✦</div>
|
||||
<div>
|
||||
<h3>{row.name}</h3>
|
||||
<p>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</p>
|
||||
</div>
|
||||
<div className="provider-list-meta">
|
||||
<span>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</span>
|
||||
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
|
||||
{row.status === 'active' ? '可用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="model-center-provider-badges">
|
||||
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
|
||||
{row.isVisionSelected ? <span className="risk-pill risk-medium">图片任务</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="asset-plugin-fields model-center-provider-meta">
|
||||
<div className="model-center-meta-row">
|
||||
<span>默认模型</span>
|
||||
<span className="mono">{row.defaultModel}</span>
|
||||
</div>
|
||||
<div className="model-center-meta-row">
|
||||
<span>API Key</span>
|
||||
<span className="mono">{row.apiKeyMasked}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-actions providers-actions provider-list-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy}
|
||||
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
|
||||
测试
|
||||
</button>
|
||||
{!row.isSelected && row.status === 'active' ? (
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
|
||||
设为默认
|
||||
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
|
||||
{row.isSelected ? '当前默认 Provider' : '备用配置'}
|
||||
</span>
|
||||
<div className="model-center-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy}
|
||||
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || row.isSelected}
|
||||
onClick={() => void handleToggleProviderStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
|
||||
删除
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
|
||||
测试
|
||||
</button>
|
||||
{!row.isSelected && row.status === 'active' ? (
|
||||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
|
||||
设为默认
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || row.isSelected}
|
||||
onClick={() => void handleToggleProviderStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 className="model-center-section-title">图片任务模型</h3>
|
||||
<VisionSection
|
||||
keys={activeKeys}
|
||||
settings={visionSettings}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getSkillRuntimeAdminConfig,
|
||||
listSkillRuntimeCatalog,
|
||||
updateSkillRuntimeAdminConfig,
|
||||
} from '../../api/client';
|
||||
import type { SkillRuntimeAdminConfig, SkillRuntimeCatalogItem } from '../../types';
|
||||
|
||||
export function SkillRuntimePage() {
|
||||
const [config, setConfig] = useState<SkillRuntimeAdminConfig>({
|
||||
router: { v2Enabled: false, manifestRoutingEnabled: false },
|
||||
});
|
||||
const [catalog, setCatalog] = useState<SkillRuntimeCatalogItem[]>([]);
|
||||
const [source, setSource] = useState('default');
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [configResult, catalogRows] = await Promise.all([
|
||||
getSkillRuntimeAdminConfig(),
|
||||
listSkillRuntimeCatalog(),
|
||||
]);
|
||||
setConfig(configResult.config);
|
||||
setSource(configResult.source ?? 'default');
|
||||
setUpdatedAt(configResult.updatedAt ?? null);
|
||||
setCatalog(catalogRows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 Skill Runtime 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await updateSkillRuntimeAdminConfig(config);
|
||||
setConfig(result.config);
|
||||
setSource(result.source ?? 'admin-db');
|
||||
setUpdatedAt(result.updatedAt ?? null);
|
||||
setNotice('Skill Runtime 配置已保存。H5 用户刷新或重新登录后会读取新开关。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const manifestSkillCount = catalog.filter((item) => item.hasManifest && item.triggerKeywords.length > 0).length;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>Skill Runtime</h2>
|
||||
<p className="muted">控制 H5 Skill Router v2 与 manifest 关键词路由(默认关闭)</p>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="muted">加载中…</p> : null}
|
||||
{error ? <p className="banner banner-error">{error}</p> : null}
|
||||
{notice ? <p className="banner banner-info">{notice}</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<h3>Router 开关</h3>
|
||||
<p className="muted">
|
||||
当前来源:<strong>{source}</strong>
|
||||
{updatedAt
|
||||
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||||
: ''}
|
||||
</p>
|
||||
<p className="muted">
|
||||
开启后仅对已授予 skill 的用户生效;manifest 匹配失败会回退 legacy 路由。
|
||||
</p>
|
||||
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.router.v2Enabled}
|
||||
onChange={(event) => {
|
||||
const v2Enabled = event.target.checked;
|
||||
setConfig({
|
||||
router: {
|
||||
v2Enabled,
|
||||
manifestRoutingEnabled: v2Enabled ? config.router.manifestRoutingEnabled : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>启用 Skill Router v2</strong>
|
||||
<span className="muted">总开关。关闭时 H5 走 legacy regex 路由。</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.router.manifestRoutingEnabled}
|
||||
disabled={!config.router.v2Enabled}
|
||||
onChange={(event) => {
|
||||
setConfig({
|
||||
router: {
|
||||
...config.router,
|
||||
manifestRoutingEnabled: event.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>启用 manifest 关键词路由</strong>
|
||||
<span className="muted">
|
||||
读取 skills/*/skill.yaml 的 trigger.keywords。当前已配置 manifest 的 skill:{manifestSkillCount}{' '}
|
||||
个。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="send-btn" disabled={saving} onClick={() => void handleSave()}>
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-secondary"
|
||||
disabled={loading || saving}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<h3>技能 Catalog 预览</h3>
|
||||
<p className="muted">
|
||||
「Manifest」表示该 skill 目录下是否存在 <code>skill.yaml</code>(Router v2 关键词路由配置)。
|
||||
目前 Phase 1 仅 <strong>product-campaign-page</strong> 做了 manifest 试点;其余 skill 仍只有 legacy{' '}
|
||||
<code>SKILL.md</code>,因此 Manifest 显示「否」是正常现象。
|
||||
</p>
|
||||
<p className="muted">有 skill.yaml 且配置了关键词的条目会在 Router v2 开启后参与匹配。</p>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Skill</th>
|
||||
<th>Manifest</th>
|
||||
<th>关键词</th>
|
||||
<th>Prompt</th>
|
||||
<th>优先级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<strong>{item.name}</strong>
|
||||
<div className="muted">{item.description}</div>
|
||||
</td>
|
||||
<td>{item.hasManifest ? '是' : '否'}</td>
|
||||
<td>{item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'}</td>
|
||||
<td>{item.routerPromptKey ?? '—'}</td>
|
||||
<td>{item.routerPriority || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createSystemTestAccount,
|
||||
deleteSystemTestAccount,
|
||||
listSkillCatalog,
|
||||
listSystemTestAccounts,
|
||||
runSystemSkillValidation,
|
||||
} from '../../api/client';
|
||||
import type { AdminSystemTestAccount, AdminSystemTestReport, SkillDefinition } from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
const DEFAULT_SKILL = 'service-integration-smoke';
|
||||
|
||||
function statusLabel(status: 'passed' | 'warning' | 'failed') {
|
||||
if (status === 'passed') return '通过';
|
||||
if (status === 'warning') return '待处理';
|
||||
return '失败';
|
||||
}
|
||||
|
||||
function AddAccountModal({
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (payload: { label: string; username: string; password: string }) => Promise<void>;
|
||||
}) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalizedUsername = username.trim();
|
||||
const normalizedLabel = label.trim() || normalizedUsername;
|
||||
if (!normalizedUsername || !password) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
label: normalizedLabel,
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存测试账号失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<div className="modal-box" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3>添加测试账号</h3>
|
||||
<p className="muted">保存到后台配置,后续管理员都可以直接复用。</p>
|
||||
</div>
|
||||
<button type="button" className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<form className="admin-form plan-form" onSubmit={handleSubmit}>
|
||||
<label className="plan-form-row">
|
||||
<span>显示名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder="例如 John 测试号"
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>账号</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="例如 john"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入该账号密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="plan-form-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={onClose} disabled={saving}>取消</button>
|
||||
<button type="submit" className="send-btn" disabled={saving || !username.trim() || !password}>
|
||||
{saving ? '保存中…' : '保存账号'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemTestsPage() {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [skillName, setSkillName] = useState(DEFAULT_SKILL);
|
||||
const [accounts, setAccounts] = useState<AdminSystemTestAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||
const [accountBusyId, setAccountBusyId] = useState<string | null>(null);
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<AdminSystemTestReport | null>(null);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextCatalog = await listSkillCatalog();
|
||||
setCatalog(nextCatalog);
|
||||
if (nextCatalog.some((item) => item.name === DEFAULT_SKILL)) {
|
||||
setSkillName(DEFAULT_SKILL);
|
||||
} else if (nextCatalog[0]?.name) {
|
||||
setSkillName(nextCatalog[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 skill 列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const nextAccounts = await listSystemTestAccounts();
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId((current) =>
|
||||
nextAccounts.some((item) => item.id === current) ? current : (nextAccounts[0]?.id ?? ''),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const selectedSkill = useMemo(
|
||||
() => catalog.find((item) => item.name === skillName) ?? null,
|
||||
[catalog, skillName],
|
||||
);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((item) => item.id === selectedAccountId) ?? null,
|
||||
[accounts, selectedAccountId],
|
||||
);
|
||||
|
||||
const handleSaveAccount = async (payload: { label: string; username: string; password: string }) => {
|
||||
const account = await createSystemTestAccount(payload);
|
||||
setAccounts((current) => [account, ...current]);
|
||||
setSelectedAccountId(account.id);
|
||||
setShowAddAccount(false);
|
||||
};
|
||||
|
||||
const handleRemoveAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
setAccountBusyId(selectedAccount.id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteSystemTestAccount(selectedAccount.id);
|
||||
const nextAccounts = accounts.filter((item) => item.id !== selectedAccount.id);
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId(nextAccounts[0]?.id ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除测试账号失败');
|
||||
} finally {
|
||||
setAccountBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedAccount) {
|
||||
setError('请先选择一个测试账号,或先添加账号');
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextReport = await runSystemSkillValidation({
|
||||
accountId: selectedAccount.id,
|
||||
username: '',
|
||||
password: '',
|
||||
skillName,
|
||||
});
|
||||
setReport(nextReport);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '系统测试执行失败');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>系统测试验证</h2>
|
||||
<p className="muted">选择测试账号后,按选定 skill 执行整条服务联调,并汇总异常反馈。</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>执行参数</h2>
|
||||
<p className="muted">默认推荐使用 service-integration-smoke,对真实账号跑登录、直连聊天、记忆读取和 skill 输出验证。</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载 skill 列表中…</p>
|
||||
) : (
|
||||
<form className="admin-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>验证 Skill</span>
|
||||
<select value={skillName} onChange={(e) => setSkillName(e.target.value)} className="admin-select">
|
||||
{catalog.map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="form-span-all system-test-account-panel">
|
||||
<label className="system-test-account-select">
|
||||
<span>测试账号</span>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(event) => setSelectedAccountId(event.target.value)}
|
||||
className="admin-select"
|
||||
disabled={accounts.length === 0 || accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<option value="">加载测试账号中…</option>
|
||||
) : accounts.length === 0 ? (
|
||||
<option value="">请先添加测试账号</option>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.label} ({account.username})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="system-test-account-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => setShowAddAccount(true)}>
|
||||
+ 添加账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-secondary"
|
||||
onClick={() => void handleRemoveAccount()}
|
||||
disabled={!selectedAccount || accountBusyId === selectedAccount?.id}
|
||||
>
|
||||
{accountBusyId === selectedAccount?.id ? '删除中…' : '删除当前账号'}
|
||||
</button>
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => void loadAccounts()} disabled={accountsLoading}>
|
||||
{accountsLoading ? '刷新中…' : '刷新账号'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedAccount ? (
|
||||
<div className="system-test-account-summary muted">
|
||||
当前使用: <strong>{selectedAccount.label}</strong> · @{selectedAccount.username} · 密码 {selectedAccount.passwordMasked}
|
||||
</div>
|
||||
) : (
|
||||
<div className="system-test-account-summary muted">
|
||||
还没有可用测试账号,请先添加账号和密码。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSkill && (
|
||||
<p className="muted">
|
||||
当前 skill: <strong>{selectedSkill.label}</strong> · {selectedSkill.description}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="send-btn" disabled={running || loading}>
|
||||
{running ? '验证执行中…' : '开始验证'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showAddAccount && (
|
||||
<AddAccountModal
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onSave={handleSaveAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>验证摘要</h2>
|
||||
<p className="muted">
|
||||
账号 @{report.account.username} · skill {report.selectedSkill} · Portal {report.portalBaseUrl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">总体结果</div>
|
||||
<div className="admin-stat-value">{report.ok ? '通过' : '有问题'}</div>
|
||||
<div className="muted">
|
||||
开始 {formatTime(report.startedAt)} / 完成 {formatTime(report.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">通过</div>
|
||||
<div className="admin-stat-value">{report.summary.passed}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">待处理</div>
|
||||
<div className="admin-stat-value">{report.summary.warnings}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">失败</div>
|
||||
<div className="admin-stat-value">{report.summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>步骤结果</h2>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>步骤</th>
|
||||
<th>状态</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.steps.map((step) => (
|
||||
<tr key={step.key}>
|
||||
<td>{step.label}</td>
|
||||
<td>{statusLabel(step.status)}</td>
|
||||
<td>
|
||||
<div>{step.message}</div>
|
||||
{step.details && (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="muted">查看详情</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{JSON.stringify(step.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>问题反馈</h2>
|
||||
</div>
|
||||
{report.issues.length === 0 ? (
|
||||
<p className="muted">本轮未发现额外问题。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>级别</th>
|
||||
<th>步骤</th>
|
||||
<th>问题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.issues.map((issue, index) => (
|
||||
<tr key={`${issue.stepKey}-${index}`}>
|
||||
<td>{issue.severity === 'error' ? '错误' : '提醒'}</td>
|
||||
<td>{issue.stepKey}</td>
|
||||
<td>
|
||||
<div>{issue.title}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>{issue.detail}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
listWechatMessages,
|
||||
listWechatWebNotifications,
|
||||
resumeWechatDigest,
|
||||
updateWechatScheduleLlmConfig,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
AdminUserRow,
|
||||
@@ -38,6 +39,7 @@ function safeSummary(summary: WechatAdminSummary | null) {
|
||||
bindPath: null,
|
||||
tokenEndpointConfigured: false,
|
||||
customerServiceEndpointConfigured: false,
|
||||
scheduleLlmEnabled: false,
|
||||
},
|
||||
counts: summary?.counts ?? {
|
||||
boundUsers: 0,
|
||||
@@ -88,6 +90,7 @@ export function WechatPage() {
|
||||
const [notifyBody, setNotifyBody] = useState('');
|
||||
const [notifyType, setNotifyType] = useState('manual');
|
||||
const [notifyChannels, setNotifyChannels] = useState<Array<'web' | 'wechat'>>(['web']);
|
||||
const [scheduleLlmEnabled, setScheduleLlmEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -117,6 +120,7 @@ export function WechatPage() {
|
||||
listAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
|
||||
setBindings(nextBindings);
|
||||
setMessages(nextMessages);
|
||||
setDigests(nextDigests);
|
||||
@@ -224,6 +228,13 @@ export function WechatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveScheduleLlm = async () => {
|
||||
await runAction(async () => {
|
||||
await updateWechatScheduleLlmConfig({ scheduleLlmEnabled });
|
||||
setNotice(`提醒待办 LLM 已${scheduleLlmEnabled ? '开启' : '关闭'}。`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
@@ -280,7 +291,28 @@ export function WechatPage() {
|
||||
<dt>推送 worker</dt>
|
||||
<dd>{summary.config.reminderWorkerEnabled ? '已启用' : '未启用'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提醒待办 LLM</dt>
|
||||
<dd>{summary.config.scheduleLlmEnabled ? '已开启' : '已关闭'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="wechat-toolbar" style={{ marginTop: 16, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scheduleLlmEnabled}
|
||||
onChange={(event) => setScheduleLlmEnabled(event.target.checked)}
|
||||
disabled={busy}
|
||||
/>{' '}
|
||||
启用提醒待办 LLM 辅助识别
|
||||
</label>
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleSaveScheduleLlm()} disabled={busy}>
|
||||
{busy ? '保存中…' : '保存开关'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 8 }}>
|
||||
默认关闭。开启后仅在规则未识别出提醒意图时,才调用后台已选中的 LLM 做补充解析;复杂定时提醒仍走原有会话链路。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
AssetGatewayConfig,
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
AdminSystemTestReport,
|
||||
AdminSystemTestAccount,
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
@@ -22,10 +25,18 @@ import type {
|
||||
LlmProviderKeyRow,
|
||||
PlanDefinition,
|
||||
PlanSyncResult,
|
||||
PersonalMemoryCandidateListResponse,
|
||||
PolicyDefinition,
|
||||
PolicyMap,
|
||||
PortalUser,
|
||||
MindSpaceAdminConfig,
|
||||
MemoryV2AdminConfig,
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
MemoryV2RuntimeStatusResponse,
|
||||
SkillRuntimeAdminConfig,
|
||||
SkillRuntimeAdminConfigResponse,
|
||||
SkillRuntimeCatalogItem,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
@@ -33,8 +44,10 @@ import type {
|
||||
WechatBinding,
|
||||
WechatDeliveryLog,
|
||||
WechatDigestSubscription,
|
||||
WechatScheduleLlmConfig,
|
||||
WechatMessage,
|
||||
WechatWebNotification,
|
||||
MindSearchConfig,
|
||||
} from '../types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -249,6 +262,7 @@ export async function getWechatAdminSummary(): Promise<WechatAdminSummary> {
|
||||
bindPath: summary.config?.bindPath ?? null,
|
||||
tokenEndpointConfigured: summary.config?.tokenEndpointConfigured ?? false,
|
||||
customerServiceEndpointConfigured: summary.config?.customerServiceEndpointConfigured ?? false,
|
||||
scheduleLlmEnabled: summary.config?.scheduleLlmEnabled ?? false,
|
||||
},
|
||||
counts: {
|
||||
boundUsers: summary.counts?.boundUsers ?? 0,
|
||||
@@ -263,6 +277,43 @@ export async function getWechatAdminSummary(): Promise<WechatAdminSummary> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWechatScheduleLlmConfig(
|
||||
payload: Pick<WechatScheduleLlmConfig, 'scheduleLlmEnabled'>,
|
||||
): Promise<WechatScheduleLlmConfig> {
|
||||
return portalFetch<WechatScheduleLlmConfig>('/admin-api/wechat/schedule-llm-config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
|
||||
return portalFetch('/admin-api/asset-gateway/config');
|
||||
}
|
||||
|
||||
export async function updateAssetGatewayConfig(
|
||||
payload: Pick<AssetGatewayConfig, 'enabled'>,
|
||||
): Promise<AssetGatewayConfig> {
|
||||
return portalFetch('/admin-api/asset-gateway/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAssetPluginConfig(
|
||||
pluginId: string,
|
||||
payload: {
|
||||
enabled: boolean;
|
||||
provider?: string | null;
|
||||
llmProviderKeyId?: string | null;
|
||||
llmModel?: string | null;
|
||||
},
|
||||
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
|
||||
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
|
||||
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config');
|
||||
return result.config;
|
||||
@@ -278,6 +329,107 @@ export async function updateMindSpaceAdminConfig(
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigResponse> {
|
||||
return portalFetch('/admin-api/memory-v2/config');
|
||||
}
|
||||
|
||||
export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusResponse> {
|
||||
return portalFetch('/admin-api/memory-v2/status');
|
||||
}
|
||||
|
||||
export async function listPersonalMemoryCandidates(
|
||||
status = 'candidate',
|
||||
): Promise<PersonalMemoryCandidateListResponse> {
|
||||
return portalFetch(`/admin-api/memory-v2/candidates?status=${encodeURIComponent(status)}&limit=50`);
|
||||
}
|
||||
|
||||
export async function reviewPersonalMemoryCandidate(
|
||||
id: string,
|
||||
action: 'accept' | 'reject',
|
||||
): Promise<{ updated: boolean; status: string; reviewedAt: number }> {
|
||||
return portalFetch(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/${action}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMemoryV2AdminConfig(
|
||||
payload: Partial<MemoryV2AdminConfig>,
|
||||
): Promise<MemoryV2AdminConfigResponse> {
|
||||
return portalFetch('/admin-api/memory-v2/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSkillRuntimeAdminConfig(): Promise<SkillRuntimeAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/skill-runtime/config');
|
||||
}
|
||||
|
||||
export async function updateSkillRuntimeAdminConfig(
|
||||
config: SkillRuntimeAdminConfig,
|
||||
): Promise<SkillRuntimeAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/skill-runtime/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSkillRuntimeCatalog(): Promise<SkillRuntimeCatalogItem[]> {
|
||||
const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog');
|
||||
return result.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function listMemoryV2ModelOptions(): Promise<{
|
||||
global: LlmGlobalSettings;
|
||||
keys: LlmProviderKeyRow[];
|
||||
supportedApiTypes: MemoryV2ModelApiType[];
|
||||
}> {
|
||||
const [global, keys] = await Promise.all([
|
||||
getLlmGlobalSettings(),
|
||||
listLlmProviderKeys(),
|
||||
]);
|
||||
return {
|
||||
global,
|
||||
keys,
|
||||
supportedApiTypes: ['chat', 'response'],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSystemSkillValidation(payload: {
|
||||
accountId?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
skillName: string;
|
||||
}): Promise<AdminSystemTestReport> {
|
||||
return portalFetch('/admin-api/system-tests/skill-validation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSystemTestAccounts(): Promise<AdminSystemTestAccount[]> {
|
||||
const result = await portalFetch<{ accounts: AdminSystemTestAccount[] }>('/admin-api/system-tests/accounts');
|
||||
return result.accounts ?? [];
|
||||
}
|
||||
|
||||
export async function createSystemTestAccount(payload: {
|
||||
label?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AdminSystemTestAccount> {
|
||||
const result = await portalFetch<{ account: AdminSystemTestAccount }>('/admin-api/system-tests/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.account;
|
||||
}
|
||||
|
||||
export async function deleteSystemTestAccount(accountId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/system-tests/accounts/${encodeURIComponent(accountId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWechatBindings(params?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
@@ -502,6 +654,14 @@ export async function clearUserCapabilityOverrides(userId: string): Promise<void
|
||||
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getMindSearchConfig(): Promise<{ config: MindSearchConfig; source: string }> {
|
||||
return portalFetch('/admin-api/mindsearch/config');
|
||||
}
|
||||
|
||||
export async function updateMindSearchConfig(config: Partial<MindSearchConfig>): Promise<{ config: MindSearchConfig; source: string }> {
|
||||
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
|
||||
}
|
||||
|
||||
// ── Policies ──────────────────────────────────────────
|
||||
|
||||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
clearUserCapabilityOverrides,
|
||||
clearUserSkillOverrides,
|
||||
getRoleCapabilities,
|
||||
getRoleSkills,
|
||||
getUserCapabilities,
|
||||
getUserSkills,
|
||||
listCapabilityCatalog,
|
||||
listSkillCatalog,
|
||||
updateRoleCapabilities,
|
||||
updateRoleSkills,
|
||||
updateUserCapabilities,
|
||||
updateUserSkills,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||||
|
||||
const WEB_CAPABILITY_KEY = 'web';
|
||||
const WEB_SKILL_NAME = 'web';
|
||||
|
||||
function SkillGrid({
|
||||
catalog,
|
||||
values,
|
||||
@@ -51,6 +60,75 @@ function SkillGrid({
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchToolPanel({
|
||||
roleEnabled,
|
||||
userEnabled,
|
||||
showUser,
|
||||
disabled,
|
||||
onRoleChange,
|
||||
onUserChange,
|
||||
}: {
|
||||
roleEnabled: boolean;
|
||||
userEnabled: boolean;
|
||||
showUser: boolean;
|
||||
disabled?: boolean;
|
||||
onRoleChange: (enabled: boolean) => void;
|
||||
onUserChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="capability-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="capability-group">
|
||||
<div className="capability-group-header">
|
||||
<h3>联网搜索工具</h3>
|
||||
<span className="capability-group-count">platform/web</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li className="capability-item">
|
||||
<div className="capability-item-info">
|
||||
<span className="capability-label">
|
||||
网页搜索
|
||||
<code className="skill-name-tag">{WEB_CAPABILITY_KEY}</code>
|
||||
<span className="risk-pill risk-medium">风险 中</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">
|
||||
在 Agent session 挂载 <code>platform/web</code> 扩展,提供{' '}
|
||||
<code>web_search</code> / <code>fetch_url</code>。需同时开启下方{' '}
|
||||
<code>{WEB_SKILL_NAME}</code> 技能;用户策略 <code>network_egress=deny</code>{' '}
|
||||
时会自动禁用。
|
||||
</span>
|
||||
</div>
|
||||
{!showUser ? (
|
||||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onRoleChange(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onUserChange(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSettings({
|
||||
users,
|
||||
userId,
|
||||
@@ -62,8 +140,12 @@ export function SkillSettings({
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
|
||||
const [roleWebEnabled, setRoleWebEnabled] = useState(false);
|
||||
const [hasWebCapabilityCatalog, setHasWebCapabilityCatalog] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||||
const [userWebEnabled, setUserWebEnabled] = useState(false);
|
||||
const [userWebOverride, setUserWebOverride] = useState<boolean | null>(null);
|
||||
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -73,12 +155,16 @@ export function SkillSettings({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
const [catalogItems, roleState, capabilityCatalog, roleCapabilities] = await Promise.all([
|
||||
listSkillCatalog(),
|
||||
getRoleSkills('user'),
|
||||
listCapabilityCatalog(),
|
||||
getRoleCapabilities('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleSkills(roleState.skills);
|
||||
setHasWebCapabilityCatalog(capabilityCatalog.some((item) => item.key === WEB_CAPABILITY_KEY));
|
||||
setRoleWebEnabled(Boolean(roleCapabilities.capabilities[WEB_CAPABILITY_KEY]));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||||
} finally {
|
||||
@@ -89,13 +175,24 @@ export function SkillSettings({
|
||||
const loadUserSkills = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserSkills({});
|
||||
setUserWebEnabled(false);
|
||||
setUserWebOverride(null);
|
||||
setUserOverrides({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserSkills(userId);
|
||||
const [state, capabilityState] = await Promise.all([
|
||||
getUserSkills(userId),
|
||||
getUserCapabilities(userId),
|
||||
]);
|
||||
setUserSkills(state.skills);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserWebEnabled(Boolean(capabilityState.capabilities[WEB_CAPABILITY_KEY]));
|
||||
setUserWebOverride(
|
||||
Object.prototype.hasOwnProperty.call(capabilityState.overrides, WEB_CAPABILITY_KEY)
|
||||
? Boolean(capabilityState.overrides[WEB_CAPABILITY_KEY])
|
||||
: null,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户技能失败');
|
||||
}
|
||||
@@ -119,9 +216,14 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleSkills('user', roleSkills);
|
||||
setRoleSkills(result.skills);
|
||||
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
|
||||
const [skillsResult] = await Promise.all([
|
||||
updateRoleSkills('user', roleSkills),
|
||||
hasWebCapabilityCatalog
|
||||
? updateRoleCapabilities('user', { [WEB_CAPABILITY_KEY]: roleWebEnabled })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
setRoleSkills(skillsResult.skills);
|
||||
setMessage('普通用户默认技能与联网工具已保存,并已同步到各用户工作区');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
@@ -132,10 +234,15 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserSkills(selectedUserId, userSkills);
|
||||
setUserSkills(result.skills);
|
||||
const [skillsResult] = await Promise.all([
|
||||
updateUserSkills(selectedUserId, userSkills),
|
||||
hasWebCapabilityCatalog
|
||||
? updateUserCapabilities(selectedUserId, { [WEB_CAPABILITY_KEY]: userWebEnabled })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
setUserSkills(skillsResult.skills);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
|
||||
setMessage('用户技能与联网工具已保存,并已安装到其 MindSpace 发布目录');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
@@ -146,15 +253,28 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserSkillOverrides(selectedUserId);
|
||||
await Promise.all([
|
||||
clearUserSkillOverrides(selectedUserId),
|
||||
hasWebCapabilityCatalog ? clearUserCapabilityOverrides(selectedUserId) : Promise.resolve(),
|
||||
]);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('已恢复为角色默认技能');
|
||||
setMessage('已恢复为角色默认技能与联网工具');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
const webSkillEnabledForRole = Boolean(roleSkills[WEB_SKILL_NAME]);
|
||||
const webSkillEnabledForUser = Boolean(userSkills[WEB_SKILL_NAME]);
|
||||
const webMismatchRole = useMemo(
|
||||
() => hasWebCapabilityCatalog && webSkillEnabledForRole && !roleWebEnabled,
|
||||
[hasWebCapabilityCatalog, webSkillEnabledForRole, roleWebEnabled],
|
||||
);
|
||||
const webMismatchUser = useMemo(
|
||||
() => hasWebCapabilityCatalog && webSkillEnabledForUser && !userWebEnabled,
|
||||
[hasWebCapabilityCatalog, webSkillEnabledForUser, userWebEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
@@ -166,18 +286,36 @@ export function SkillSettings({
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
勾选后技能会安装到用户 <code>MindSpace/用户名/.agents/skills/</code>,对话中可通过 load_skill
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。联网搜索需在下方同时开启{' '}
|
||||
<strong>网页搜索工具</strong> 与 <code>web</code> 技能。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : !hasWebCapabilityCatalog ? (
|
||||
<p className="banner banner-error">
|
||||
当前 Portal 能力目录缺少 <code>web</code>,请更新 Memind 后端并重启 admin-api。
|
||||
</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p className="muted">暂无平台技能,请在 Memind/skills/ 下添加 SKILL.md</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认</h3>
|
||||
<WebSearchToolPanel
|
||||
roleEnabled={roleWebEnabled}
|
||||
userEnabled={userWebEnabled}
|
||||
showUser={false}
|
||||
onRoleChange={setRoleWebEnabled}
|
||||
onUserChange={setUserWebEnabled}
|
||||
/>
|
||||
{webMismatchRole ? (
|
||||
<p className="banner banner-error">
|
||||
已开启 <code>web</code> 技能但未启用网页搜索工具,Agent 将无法使用 web_search。
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="capability-section-title">普通用户默认技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
@@ -186,13 +324,13 @@ export function SkillSettings({
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认技能
|
||||
保存默认配置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户技能覆盖</h3>}
|
||||
{!userOnly && <h3 className="capability-section-title">单用户覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
@@ -207,11 +345,27 @@ export function SkillSettings({
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userWebOverride != null && (
|
||||
<p className="muted">联网工具已单独覆盖为 {userWebOverride ? '开启' : '关闭'}。</p>
|
||||
)}
|
||||
<WebSearchToolPanel
|
||||
roleEnabled={roleWebEnabled}
|
||||
userEnabled={userWebEnabled}
|
||||
showUser
|
||||
onRoleChange={setRoleWebEnabled}
|
||||
onUserChange={setUserWebEnabled}
|
||||
/>
|
||||
{webMismatchUser ? (
|
||||
<p className="banner banner-error">
|
||||
该用户已开启 <code>web</code> 技能但未启用网页搜索工具。
|
||||
</p>
|
||||
) : null}
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
已设置 {Object.keys(userOverrides).length} 项技能单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<h3 className="capability-section-title">用户技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={userSkills}
|
||||
@@ -221,7 +375,7 @@ export function SkillSettings({
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
|
||||
保存用户技能
|
||||
保存用户配置
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
|
||||
恢复角色默认
|
||||
|
||||
+484
@@ -950,6 +950,466 @@ body,
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Asset gateway / model center ────────────────────── */
|
||||
|
||||
.asset-gateway-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, #7c5cff);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(120deg, rgba(75, 55, 146, .28), rgba(21, 27, 38, .78));
|
||||
}
|
||||
|
||||
.asset-kicker {
|
||||
color: #aaa1ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
.asset-gateway-hero h3 {
|
||||
margin: 5px 0 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.asset-gateway-hero p {
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.asset-toggle,
|
||||
.asset-mini-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.asset-toggle {
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .07);
|
||||
}
|
||||
|
||||
.asset-plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.asset-plugin-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
.asset-plugin-grid--balanced .asset-plugin-card {
|
||||
min-height: 326px;
|
||||
}
|
||||
|
||||
.asset-plugin-card--violet { border-top: 3px solid #9374ff; }
|
||||
.asset-plugin-card--blue { border-top: 3px solid #54a8ff; }
|
||||
.asset-plugin-card--amber { border-top: 3px solid #e8af55; }
|
||||
.asset-plugin-card--green { border-top: 3px solid #55c79b; }
|
||||
|
||||
.asset-plugin-card-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.asset-plugin-icon {
|
||||
display: grid;
|
||||
flex: 0 0 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(132, 110, 255, .17);
|
||||
color: #c4b8ff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.asset-plugin-card-head h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.asset-plugin-card-head p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.asset-mini-toggle {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-plugin-fields {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.asset-plugin-fields label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.asset-plugin-fields select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-plugin-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.asset-status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.asset-status.is-on {
|
||||
color: #68d8a7;
|
||||
}
|
||||
|
||||
.model-center-hero-actions,
|
||||
.model-center-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-center-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
.model-center-section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.model-center-section-head .model-center-section-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-center-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-center-provider-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-center-card-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-center-card-note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-center-provider-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-center-provider-meta {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-center-meta-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .04);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-center-meta-row .mono {
|
||||
color: var(--color-text-primary);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.model-center-vision-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.model-center-card .executor-instruction,
|
||||
.model-center-card .executor-meta,
|
||||
.model-center-card .executor-note,
|
||||
.model-center-card .executor-log {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-center-card .executor-actions {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.memory-v2-page {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.memory-v2-page > .asset-gateway-hero,
|
||||
.memory-v2-page > .asset-plugin-grid,
|
||||
.memory-v2-page > .model-center-section-title,
|
||||
.memory-v2-page > .model-center-card-note,
|
||||
.memory-v2-page > .banner {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.memory-v2-page .memory-v2-fields input,
|
||||
.memory-v2-page .memory-v2-fields select,
|
||||
.memory-v2-page .asset-plugin-fields select {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.memory-v2-top-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-v2-backend-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memory-v2-capability-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memory-v2-page .asset-plugin-grid {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-page .asset-plugin-card {
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, .08);
|
||||
}
|
||||
|
||||
.memory-v2-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memory-v2-backend-intro {
|
||||
margin: -4px 0 10px;
|
||||
}
|
||||
|
||||
.memory-v2-backend-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary-main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-v2-backend-summary-main h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.memory-v2-backend-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details > summary {
|
||||
padding: 8px 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.memory-v2-advanced-details .memory-v2-fields {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.memory-v2-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.memory-v2-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.memory-v2-fields label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.memory-v2-fields label > span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-v2-card .inline-check {
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-runtime-health {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1 1 100%;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-v2-runtime-health strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-review {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.memory-v2-candidate-item small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.memory-v2-candidate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-v2-page .model-center-card-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.memory-v2-backend-grid,
|
||||
.memory-v2-capability-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-v2-backend-grid,
|
||||
.memory-v2-capability-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.asset-gateway-hero,
|
||||
.model-center-section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asset-plugin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.asset-plugin-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Capability settings ─────────────────────────────── */
|
||||
|
||||
.capability-intro {
|
||||
@@ -1649,6 +2109,30 @@ body,
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.system-test-account-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.system-test-account-select {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-test-account-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-test-account-summary {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Plan catalog table extras ──────────────────────────────────── */
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -24,12 +24,24 @@ export type ReviewPost = {
|
||||
title: string;
|
||||
summary: string;
|
||||
cover_url: string;
|
||||
status: string;
|
||||
author: { display_name: string; slug: string };
|
||||
category: { name: string; slug: string; icon: string };
|
||||
published_at: string;
|
||||
sla_warning: boolean;
|
||||
};
|
||||
|
||||
export type PlazaCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export async function fetchPlazaCategories() {
|
||||
return opsFetch<{ categories: PlazaCategory[] }>('/api/ops/v1/categories');
|
||||
}
|
||||
|
||||
export async function fetchReviewQueue(query = 'status=pending_review') {
|
||||
const suffix = query.startsWith('?') ? query : query ? `?${query}` : '';
|
||||
return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>(
|
||||
|
||||
@@ -10,6 +10,7 @@ const opsSections = [
|
||||
{
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ to: '/ops/posts', label: '帖子管理' },
|
||||
{ to: '/ops/reports', label: '举报处理' },
|
||||
{ to: '/ops/featured', label: '精选管理' },
|
||||
{ to: '/ops/creators', label: '创作者' },
|
||||
|
||||
@@ -708,3 +708,61 @@ input[type='checkbox'] {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.ops-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ops-filter-label {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
|
||||
.ops-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ops-chip {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--ops-text);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ops-chip.active {
|
||||
border-color: rgba(120, 170, 255, 0.45);
|
||||
background: rgba(120, 170, 255, 0.14);
|
||||
}
|
||||
|
||||
.ops-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ops-inline-action {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ops-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchPlazaCategories,
|
||||
fetchReviewQueue,
|
||||
reviewPost,
|
||||
type PlazaCategory,
|
||||
type ReviewPost,
|
||||
} from '../api/client';
|
||||
import { plazaPreviewUrl } from '../lib/site';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'published', label: '已发布' },
|
||||
{ key: 'hidden', label: '已下线' },
|
||||
] as const;
|
||||
|
||||
type StatusTab = (typeof STATUS_TABS)[number]['key'];
|
||||
|
||||
function formatPublishedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function PostsPage() {
|
||||
const [tab, setTab] = useState<StatusTab>('published');
|
||||
const [categories, setCategories] = useState<PlazaCategory[]>([]);
|
||||
const [categorySlug, setCategorySlug] = useState<string | null>(null);
|
||||
const [posts, setPosts] = useState<ReviewPost[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageCursors, setPageCursors] = useState<Array<string | null>>([null]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPlazaCategories()
|
||||
.then((data) => setCategories(data.categories))
|
||||
.catch(() => {
|
||||
setCategories([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async ({
|
||||
status = tab,
|
||||
cursor = pageCursors[pageIndex] ?? null,
|
||||
category = categorySlug,
|
||||
search = keyword,
|
||||
nextPageIndex = pageIndex,
|
||||
}: {
|
||||
status?: StatusTab;
|
||||
cursor?: string | null;
|
||||
category?: string | null;
|
||||
search?: string;
|
||||
nextPageIndex?: number;
|
||||
} = {}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
status,
|
||||
limit: String(PAGE_SIZE),
|
||||
});
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
if (category) query.set('category', category);
|
||||
if (search.trim()) query.set('keyword', search.trim());
|
||||
const data = await fetchReviewQueue(query.toString());
|
||||
setPosts(data.posts);
|
||||
setHasMore(data.has_more);
|
||||
setPageIndex(nextPageIndex);
|
||||
if (data.has_more && data.next_cursor) {
|
||||
setPageCursors((current) => {
|
||||
const next = current.slice(0, nextPageIndex + 1);
|
||||
next[nextPageIndex + 1] = data.next_cursor;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tab, pageCursors, pageIndex, categorySlug, keyword],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
setPageCursors([null]);
|
||||
void loadPage({ cursor: null, nextPageIndex: 0 });
|
||||
}, [tab, categorySlug]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPageIndex(0);
|
||||
setPageCursors([null]);
|
||||
void loadPage({ cursor: null, nextPageIndex: 0, search: keyword });
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pageIndex <= 0 || loading) return;
|
||||
const nextPageIndex = pageIndex - 1;
|
||||
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (!hasMore || loading) return;
|
||||
const nextPageIndex = pageIndex + 1;
|
||||
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (id: string, action: 'hide' | 'approve') => {
|
||||
const label = action === 'hide' ? '下线' : '上线';
|
||||
if (!window.confirm(`确认${label}该帖子?`)) return;
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await reviewPost(id, action);
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `${label}失败`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card stack">
|
||||
<div className="ops-summary-row">
|
||||
<div className="toolbar">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={tab === item.key ? 'btn' : 'btn secondary'}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="caption ops-cell-muted">每页 {PAGE_SIZE} 条,下线后用户端 Feed 与详情页同步不可见</p>
|
||||
</div>
|
||||
|
||||
<div className="ops-filter-row">
|
||||
<span className="ops-filter-label">分类</span>
|
||||
<div className="ops-chip-row">
|
||||
<button
|
||||
type="button"
|
||||
className={categorySlug === null ? 'ops-chip active' : 'ops-chip'}
|
||||
onClick={() => setCategorySlug(null)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{categories.length === 0 ? (
|
||||
<span className="caption ops-cell-muted">分类暂不可用,请重启 Admin API 后刷新</span>
|
||||
) : null}
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={categorySlug === category.slug ? 'ops-chip active' : 'ops-chip'}
|
||||
onClick={() => setCategorySlug(category.slug)}
|
||||
>
|
||||
{category.icon ? `${category.icon} ` : ''}
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索标题 / 作者"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') handleSearch();
|
||||
}}
|
||||
className="search-input"
|
||||
/>
|
||||
<button type="button" className="btn secondary" onClick={handleSearch}>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
{loading && posts.length === 0 ? <div className="card empty-state">加载中…</div> : null}
|
||||
{!loading && posts.length === 0 ? (
|
||||
<div className="card empty-state">暂无{tab === 'published' ? '已发布' : '已下线'}帖子</div>
|
||||
) : null}
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="card ops-table-wrap">
|
||||
<table className="ops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题 / 操作</th>
|
||||
<th>分类 / 作者</th>
|
||||
<th>摘要</th>
|
||||
<th>发布时间</th>
|
||||
<th>查看</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id}>
|
||||
<td>
|
||||
<div className="ops-title-row">
|
||||
<strong>{post.title}</strong>
|
||||
{tab === 'published' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary ops-inline-action"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleToggleVisibility(post.id, 'hide')}
|
||||
>
|
||||
{busyId === post.id ? '处理中…' : '下线'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ops-inline-action"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleToggleVisibility(post.id, 'approve')}
|
||||
>
|
||||
{busyId === post.id ? '处理中…' : '上线'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ops-stack-mini">
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => setCategorySlug(post.category.slug)}
|
||||
>
|
||||
{post.category.icon} {post.category.name}
|
||||
</button>
|
||||
<div className="ops-cell-muted">@{post.author.slug}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ops-cell-muted">{post.summary || '无摘要'}</div>
|
||||
</td>
|
||||
<td>{formatPublishedAt(post.published_at)}</td>
|
||||
<td>
|
||||
<a className="btn secondary" href={plazaPreviewUrl(post.id)} target="_blank" rel="noreferrer">
|
||||
预览
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="ops-pagination">
|
||||
<button type="button" className="btn secondary" disabled={pageIndex <= 0 || loading} onClick={handlePrevPage}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="ops-cell-muted">
|
||||
第 {pageIndex + 1} 页 · 本页 {posts.length} 条
|
||||
</span>
|
||||
<button type="button" className="btn secondary" disabled={!hasMore || loading} onClick={handleNextPage}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+192
@@ -195,6 +195,27 @@ export type LlmExecutorLaunchState = {
|
||||
instruction: string | null;
|
||||
};
|
||||
|
||||
export type AssetPluginConfig = {
|
||||
pluginId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
providers: string[];
|
||||
supportsLlm: boolean;
|
||||
enabled: boolean;
|
||||
provider: string | null;
|
||||
llmProviderKeyId: string | null;
|
||||
llmProviderName: string | null;
|
||||
llmProviderId: string | null;
|
||||
llmModel: string | null;
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
export type AssetGatewayConfig = {
|
||||
enabled: boolean;
|
||||
updatedAt: number | null;
|
||||
plugins: AssetPluginConfig[];
|
||||
};
|
||||
|
||||
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
|
||||
|
||||
export type AdminServiceRestartResult = {
|
||||
@@ -241,6 +262,7 @@ export type WechatAdminSummary = {
|
||||
bindPath: string | null;
|
||||
tokenEndpointConfigured: boolean;
|
||||
customerServiceEndpointConfigured: boolean;
|
||||
scheduleLlmEnabled: boolean;
|
||||
};
|
||||
counts: {
|
||||
boundUsers: number;
|
||||
@@ -251,10 +273,173 @@ export type WechatAdminSummary = {
|
||||
};
|
||||
};
|
||||
|
||||
export type WechatScheduleLlmConfig = {
|
||||
scheduleLlmEnabled: boolean;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type MindSpaceAdminConfig = {
|
||||
publicPageLimit: number;
|
||||
};
|
||||
|
||||
export type MemoryV2AdminSection = Record<string, string | boolean>;
|
||||
export type MemoryV2ModelApiType = 'chat' | 'response';
|
||||
|
||||
export type MemoryV2AdminConfig = {
|
||||
global: {
|
||||
enabled?: boolean;
|
||||
backend?: string;
|
||||
profileEnabled?: boolean;
|
||||
eventLogEnabled?: boolean;
|
||||
vectorEnabled?: boolean;
|
||||
failOpen?: boolean;
|
||||
};
|
||||
chatIntentRouter: MemoryV2AdminSection;
|
||||
candidateMemory: MemoryV2AdminSection;
|
||||
policy: MemoryV2AdminSection;
|
||||
retriever: MemoryV2AdminSection;
|
||||
lifecycle: MemoryV2AdminSection;
|
||||
persona: MemoryV2AdminSection;
|
||||
graph: MemoryV2AdminSection;
|
||||
userMemory: MemoryV2AdminSection;
|
||||
pluginHealth: MemoryV2AdminSection;
|
||||
pgvector: MemoryV2AdminSection;
|
||||
qdrant: MemoryV2AdminSection;
|
||||
weaviate: MemoryV2AdminSection;
|
||||
mem0: MemoryV2AdminSection;
|
||||
letta: MemoryV2AdminSection;
|
||||
neo4j: MemoryV2AdminSection;
|
||||
redisStreams: MemoryV2AdminSection;
|
||||
langgraph: MemoryV2AdminSection;
|
||||
};
|
||||
|
||||
export type MemoryV2AdminConfigResponse = {
|
||||
config: MemoryV2AdminConfig;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type MemoryV2RuntimeStatusResponse = {
|
||||
ok: boolean;
|
||||
checkedAt?: number;
|
||||
message?: string;
|
||||
memory?: {
|
||||
enabled?: boolean;
|
||||
backend?: string;
|
||||
selectedBackend?: string | null;
|
||||
configSource?: string;
|
||||
configUpdatedAt?: number | null;
|
||||
personalMemory?: {
|
||||
enabled?: boolean;
|
||||
requestedMode?: string;
|
||||
effectiveMode?: string;
|
||||
autoReviewEnabled?: boolean;
|
||||
phase?: string;
|
||||
injectionEnabled?: boolean;
|
||||
persistence?: string;
|
||||
pendingCandidates?: number;
|
||||
health?: Record<string, string | number | boolean | null>;
|
||||
policy?: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PersonalMemoryCandidate = {
|
||||
id: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
memoryType: string;
|
||||
content: string;
|
||||
importance: number;
|
||||
confidence: number;
|
||||
status: string;
|
||||
policyReason: string;
|
||||
evidence: Record<string, unknown>;
|
||||
reviewedBy: string | null;
|
||||
reviewedAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type PersonalMemoryCandidateListResponse = {
|
||||
items: PersonalMemoryCandidate[];
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
|
||||
export type SkillRuntimeAdminConfig = {
|
||||
router: {
|
||||
v2Enabled: boolean;
|
||||
manifestRoutingEnabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillRuntimeAdminConfigResponse = {
|
||||
config: SkillRuntimeAdminConfig;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type SkillRuntimeCatalogItem = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
description: string;
|
||||
version: string | null;
|
||||
executors: string[];
|
||||
hasManifest: boolean;
|
||||
triggerKeywords: string[];
|
||||
routerPromptKey: string | null;
|
||||
routerPriority: number;
|
||||
};
|
||||
|
||||
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
|
||||
|
||||
export type AdminSystemTestStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: AdminSystemTestStepStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AdminSystemTestIssue = {
|
||||
stepKey: string;
|
||||
severity: 'warning' | 'error';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type AdminSystemTestReport = {
|
||||
ok: boolean;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
portalBaseUrl: string;
|
||||
selectedSkill: string;
|
||||
account: {
|
||||
userId?: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
summary: {
|
||||
passed: number;
|
||||
warnings: number;
|
||||
failed: number;
|
||||
};
|
||||
steps: AdminSystemTestStep[];
|
||||
issues: AdminSystemTestIssue[];
|
||||
};
|
||||
|
||||
export type AdminSystemTestAccount = {
|
||||
id: string;
|
||||
label: string;
|
||||
username: string;
|
||||
passwordMasked: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type WechatBinding = {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -412,3 +597,10 @@ export type AdminSubscription = {
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type MindSearchConfig = {
|
||||
enabled: boolean;
|
||||
mode: 'off' | 'shadow' | 'assist';
|
||||
providers: { searxng: boolean; github: boolean; reader: boolean };
|
||||
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user