Compare commits

...

10 Commits

Author SHA1 Message Date
john ebcc33b367 feat(skills): 在技能页配置联网搜索 web 能力开关
与 web 技能一并保存角色/用户默认,避免 load_skill 后缺少 web_search 工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:06:39 +08:00
john 13485558b5 feat(ops): add plaza post management page
List published and hidden posts with category filters, pagination, and
inline hide/restore actions for the operations console.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:27:21 +08:00
john f63bb894d9 feat: add system test validation and router admin controls 2026-07-04 23:01:30 +08:00
john d2b97491ef fix: add pg dependency for memory v2 runtime 2026-07-03 11:05:15 +08:00
john c183893cdd Merge Memory V2 admin controls 2026-07-03 09:47:21 +08:00
john a5866ec00b fix: default prod release to Memind shared root 2026-07-03 09:44:13 +08:00
john ab33ce06f3 fix: bundle shared Memind modules for prod release 2026-07-03 09:43:52 +08:00
john 65fb8ed2b2 fix: use remote Memind lib in prod release 2026-07-03 09:41:40 +08:00
john 194e5ac1ef feat: add memory v2 admin controls 2026-07-03 09:34:42 +08:00
john aa6a029530 chore: enforce standard branch release workflow 2026-07-01 10:53:11 +08:00
28 changed files with 2598 additions and 29 deletions
+28
View File
@@ -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
```
如需临时绕过,只能在用户明确批准后使用项目文档中记录的显式环境变量。
+21
View File
@@ -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`。
- 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。
+21
View File
@@ -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
```
如果规则与口头指令冲突,先暂停并确认,不要绕过发布闸门。
+40 -4
View File
@@ -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` 的结果为准。
+6 -5
View File
@@ -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、跳过发布包的理由。
+147
View File
@@ -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",
+1
View File
@@ -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",
+70
View File
@@ -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})."
+79
View File
@@ -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}."
+51
View File
@@ -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
View File
@@ -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}"
+81
View File
@@ -96,6 +96,9 @@ export function createAdminApp(services) {
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
planCatalogService,
subscriptionService,
@@ -301,6 +304,84 @@ export function createAdminApp(services) {
res.json({ config: 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('/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));
+16
View File
@@ -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,8 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-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 +85,14 @@ export async function bootstrapAdminServices() {
apiTarget,
apiSecret,
});
const memoryV2ConfigService = createMemoryV2AdminConfigService(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 +118,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}`);
@@ -122,6 +135,9 @@ export async function bootstrapAdminServices() {
mindSpaceConfig,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
planCatalogService,
subscriptionService,
+6
View File
@@ -103,6 +103,9 @@ ready
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
planCatalogService,
subscriptionService,
@@ -121,6 +124,9 @@ ready
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
adminSystemTestService,
systemTestAccountService,
wordFilterService,
planCatalogService,
subscriptionService,
+177
View File
@@ -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,
};
}
+8
View File
@@ -7,8 +7,10 @@ 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 { 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';
@@ -20,6 +22,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,8 +116,10 @@ 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="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
@@ -125,6 +130,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,8 +162,10 @@ 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('/providers')
|| pathname.startsWith('/wechat')
) {
+2
View File
@@ -28,6 +28,8 @@ const NAV_SECTIONS: NavSection[] = [
items: [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
+2
View File
@@ -10,6 +10,8 @@ const QUICK_LINKS = [
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
+708
View File
@@ -0,0 +1,708 @@
import { useCallback, useEffect, useState, type ChangeEvent } from 'react';
import {
getMemoryV2AdminConfig,
listMemoryV2ModelOptions,
updateMemoryV2AdminConfig,
} from '../../api/client';
import type {
LlmGlobalSettings,
LlmProviderKeyRow,
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
} from '../../types';
type BackendKey =
| 'pgvector'
| 'qdrant'
| 'weaviate'
| 'mem0'
| 'letta'
| 'neo4j'
| 'redisStreams'
| 'langgraph';
type ModelBackendKey = 'mem0' | 'letta' | 'langgraph';
const BACKENDS: Array<{
key: BackendKey;
label: string;
fields: Array<{ key: string; label: string; secret?: boolean }>;
}> = [
{
key: 'pgvector',
label: 'pgvector',
fields: [
{ key: 'databaseUrl', label: 'Database URL', secret: true },
{ key: 'table', label: 'Table' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'poolMax', label: 'Pool Max' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'qdrant',
label: 'Qdrant',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'collection', label: 'Collection' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'timeoutMs', label: 'Timeout Ms' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'weaviate',
label: 'Weaviate',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'collection', label: 'Collection' },
{ key: 'embeddingModule', label: 'Embedding Module' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'timeoutMs', label: 'Timeout Ms' },
{ key: 'limit', label: 'Recall Limit' },
],
},
{
key: 'mem0',
label: 'Mem0',
fields: [
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'projectId', label: 'Project ID' },
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'writePath', label: 'Write Path' },
{ key: 'compactPath', label: 'Compact Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'letta',
label: 'Letta',
fields: [
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'projectId', label: 'Project ID' },
{ key: 'agentId', label: 'Agent ID' },
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'resolvePath', label: 'Resolve Path' },
{ key: 'writePath', label: 'Write Path' },
{ key: 'compactPath', label: 'Compact Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'neo4j',
label: 'Neo4j',
fields: [
{ key: 'uri', label: 'Bolt URI' },
{ key: 'httpUrl', label: 'HTTP URL' },
{ key: 'user', label: 'Username' },
{ key: 'password', label: 'Password', secret: true },
{ key: 'database', label: 'Database' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
{
key: 'redisStreams',
label: 'Redis Streams',
fields: [
{ key: 'url', label: 'Redis URL' },
{ key: 'stream', label: 'Stream' },
],
},
{
key: 'langgraph',
label: 'LangGraph',
fields: [
{ key: 'url', label: 'Base URL' },
{ key: 'policyId', label: 'Policy ID' },
{ key: 'apiKey', label: 'API Key', secret: true },
{ key: 'resolvePath', label: 'Resolve Path' },
{ key: 'timeoutMs', label: 'Timeout Ms' },
],
},
];
const MODEL_BACKENDS: ModelBackendKey[] = ['mem0', 'letta', 'langgraph'];
const MODEL_API_TYPES: Array<{ value: MemoryV2ModelApiType; label: string }> = [
{ value: 'chat', label: 'Chat API' },
{ value: 'response', label: 'Response API' },
];
function defaultConfig(): MemoryV2AdminConfig {
return {
global: {
enabled: false,
backend: 'legacy',
profileEnabled: false,
eventLogEnabled: false,
vectorEnabled: false,
failOpen: true,
},
chatIntentRouter: {
enabled: false,
modelProviderKeyId: '',
model: '',
modelApiType: 'chat',
minConfidence: '0.65',
memoryResolveEnabled: true,
memoryResolveLimit: '8',
timeoutMs: '1500',
fallbackRoute: 'agent_orchestration',
},
pgvector: {},
qdrant: {},
weaviate: {},
mem0: {},
letta: {},
neo4j: {},
redisStreams: {},
langgraph: {},
};
}
function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2AdminConfig {
const base = defaultConfig();
return {
global: { ...base.global, ...(config?.global ?? {}) },
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
mem0: { ...base.mem0, ...(config?.mem0 ?? {}) },
letta: { ...base.letta, ...(config?.letta ?? {}) },
neo4j: { ...base.neo4j, ...(config?.neo4j ?? {}) },
redisStreams: { ...base.redisStreams, ...(config?.redisStreams ?? {}) },
langgraph: { ...base.langgraph, ...(config?.langgraph ?? {}) },
};
}
function isModelBackend(backend: BackendKey): backend is ModelBackendKey {
return MODEL_BACKENDS.includes(backend as ModelBackendKey);
}
function dedupeStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function findProviderKey(keys: LlmProviderKeyRow[], keyId: string | null | undefined): LlmProviderKeyRow | null {
const resolvedKeyId = String(keyId ?? '').trim();
if (!resolvedKeyId) return null;
return keys.find((key) => key.id === resolvedKeyId) ?? null;
}
function providerLabel(key: LlmProviderKeyRow): string {
return `${key.name} · ${key.providerLabel}`;
}
function modelOptionsForProvider(
providerKey: LlmProviderKeyRow | null,
globalSettings: LlmGlobalSettings | null,
): string[] {
if (!providerKey) {
return dedupeStrings([globalSettings?.globalModel, ...(globalSettings?.availableModels ?? [])]);
}
return dedupeStrings([providerKey.defaultModel, ...(providerKey.models ?? [])]);
}
function describeEffectiveModelSource(
section: MemoryV2AdminConfig[ModelBackendKey],
providerKeys: LlmProviderKeyRow[],
globalSettings: LlmGlobalSettings | null,
): string {
const providerKey = findProviderKey(providerKeys, section.modelProviderKeyId);
const model = String(section.model ?? '').trim();
const apiType = String(section.modelApiType ?? '').trim();
const providerText = providerKey ? providerLabel(providerKey) : (globalSettings?.keyName
? `${globalSettings.keyName} · ${globalSettings.providerLabel ?? '统一模型中心'}`
: '统一模型中心默认');
const modelText = model || providerKey?.defaultModel || globalSettings?.globalModel || 'provider 默认模型';
const apiText = apiType || '未指定 API 类型';
return `${providerText} / ${modelText} / ${apiText}`;
}
export function MemoryV2Page() {
const [payload, setPayload] = useState<MemoryV2AdminConfigResponse | null>(null);
const [draft, setDraft] = useState<MemoryV2AdminConfig>(defaultConfig());
const [providerKeys, setProviderKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
const [loading, setLoading] = useState(true);
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setBusyScope('reload');
setError(null);
setMessage(null);
try {
const [configResult, modelOptions] = await Promise.all([
getMemoryV2AdminConfig(),
listMemoryV2ModelOptions(),
]);
setPayload(configResult);
setDraft(normalizeConfig(configResult.config));
setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active'));
setGlobalModelSettings(modelOptions.global);
setSupportedApiTypes(modelOptions.supportedApiTypes);
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
} finally {
setLoading(false);
setBusyScope((current) => (current === 'reload' ? null : current));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateGlobalFlag = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [key]: event.target.checked },
}));
};
const updateGlobalText = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent<HTMLSelectElement>) => {
setDraft((current) => ({
...current,
global: { ...current.global, [key]: event.target.value },
}));
};
const updateRouterFlag = (key: string) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.checked },
}));
};
const updateRouterValue = (key: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setDraft((current) => ({
...current,
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.value },
}));
};
const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent<HTMLInputElement>) => {
setDraft((current) => ({
...current,
[backend]: { ...current[backend], enabled: event.target.checked },
}));
};
const updateBackendValue = (backend: BackendKey, field: string) => (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
setDraft((current) => ({
...current,
[backend]: { ...current[backend], [field]: event.target.value },
}));
};
const updateModelProvider = (backend: ModelBackendKey) => (event: ChangeEvent<HTMLSelectElement>) => {
const nextProviderKeyId = event.target.value;
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
setDraft((current) => {
const currentSection = current[backend];
const currentModel = String(currentSection.model ?? '').trim();
const nextModel = nextOptions.includes(currentModel)
? currentModel
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
return {
...current,
[backend]: {
...currentSection,
modelProviderKeyId: nextProviderKeyId,
model: nextModel,
},
};
});
};
const updateRouterModelProvider = (event: ChangeEvent<HTMLSelectElement>) => {
const nextProviderKeyId = event.target.value;
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
setDraft((current) => {
const currentSection = current.chatIntentRouter;
const currentModel = String(currentSection.model ?? '').trim();
const nextModel = nextOptions.includes(currentModel)
? currentModel
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
return {
...current,
chatIntentRouter: {
...currentSection,
modelProviderKeyId: nextProviderKeyId,
model: nextModel,
},
};
});
};
const applySavedConfig = (result: MemoryV2AdminConfigResponse, successMessage: string) => {
setPayload(result);
setDraft(normalizeConfig(result.config));
setMessage(successMessage);
};
const saveConfigSection = async (
patch: Partial<MemoryV2AdminConfig>,
scope: 'global' | 'chatIntentRouter' | BackendKey,
successMessage: string,
) => {
setBusyScope(scope);
setError(null);
setMessage(null);
try {
const result = await updateMemoryV2AdminConfig(patch);
applySavedConfig(result, successMessage);
} catch (err) {
setError(err instanceof Error ? err.message : '保存 Memory V2 配置失败');
} finally {
setBusyScope((current) => (current === scope ? null : current));
}
};
const saveGlobalConfig = async () => {
await saveConfigSection(
{ global: draft.global },
'global',
'全局 Memory V2 开关已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。',
);
};
const saveRouterConfig = async () => {
await saveConfigSection(
{ chatIntentRouter: draft.chatIntentRouter },
'chatIntentRouter',
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
);
};
const saveBackendConfig = async (backend: BackendKey) => {
await saveConfigSection(
{ [backend]: draft[backend] },
backend,
`${BACKENDS.find((item) => item.key === backend)?.label ?? backend} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
);
};
const current = payload?.config ? normalizeConfig(payload.config) : draft;
const routerSection = draft.chatIntentRouter;
const currentRouterSection = current.chatIntentRouter;
const routerProviderKey = findProviderKey(providerKeys, String(routerSection.modelProviderKeyId ?? ''));
const routerAvailableModels = modelOptionsForProvider(routerProviderKey, globalModelSettings);
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>Memory V2 配置</h2>
<p className="muted">这里是 Memory V2 的统一后台控制面。`memind_adm` 负责改配置,主站会从共享数据库读取并自动切换。</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<div className="admin-form">
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2>全局开关</h2>
<p className="muted">更新时间:{payload?.updatedAt ? new Date(payload.updatedAt).toLocaleString('zh-CN', { hour12: false }) : '未保存过数据库配置'}</p>
</div>
<div className="admin-actions">
<button type="button" className="send-btn" disabled={loading || busyScope !== null} onClick={() => void saveGlobalConfig()}>
{busyScope === 'global' ? '保存中...' : '保存全局'}
</button>
</div>
</div>
<div className="admin-form-grid">
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.enabled)} onChange={updateGlobalFlag('enabled')} />
启用 Memory V2
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.profileEnabled)} onChange={updateGlobalFlag('profileEnabled')} />
启用 Profile
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.eventLogEnabled)} onChange={updateGlobalFlag('eventLogEnabled')} />
启用 Event Log
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.vectorEnabled)} onChange={updateGlobalFlag('vectorEnabled')} />
启用 Vector
</label>
<label className="inline-check">
<input type="checkbox" checked={Boolean(draft.global.failOpen)} onChange={updateGlobalFlag('failOpen')} />
Memory 故障时 fail-open
</label>
<label>
<span>默认 Backend</span>
<select value={String(draft.global.backend ?? 'legacy')} onChange={updateGlobalText('backend')}>
<option value="legacy">legacy</option>
{BACKENDS.map((backend) => (
<option key={backend.key} value={backend.key}>{backend.label}</option>
))}
</select>
</label>
</div>
{!loading && (
<p className="muted">
当前数据库配置默认 backend:<strong>{String(current.global.backend ?? 'legacy')}</strong>
</p>
)}
</section>
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2>前置 LLM 意图路由</h2>
<p className="muted">关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM,并可读取 Memory V2 的轻量上下文。</p>
</div>
<div className="admin-actions">
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.enabled)}
onChange={updateRouterFlag('enabled')}
/>
启用
</label>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveRouterConfig()}
>
{busyScope === 'chatIntentRouter' ? '保存中...' : '保存路由'}
</button>
</div>
</div>
<div className="admin-form-grid">
<label>
<span>模型来源</span>
<select
value={String(routerSection.modelProviderKeyId ?? '')}
onChange={updateRouterModelProvider}
>
<option value="">跟随统一模型中心默认</option>
{providerKeys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span>模型</span>
<select
value={String(routerSection.model ?? '')}
onChange={updateRouterValue('model')}
>
<option value="">
{routerProviderKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{routerAvailableModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span>模型类型</span>
<select
value={String(routerSection.modelApiType ?? 'chat')}
onChange={updateRouterValue('modelApiType')}
>
{MODEL_API_TYPES.map((apiType) => (
<option key={apiType.value} value={apiType.value}>{apiType.label}</option>
))}
</select>
</label>
<label>
<span>最低置信度</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={String(routerSection.minConfidence ?? '0.65')}
onChange={updateRouterValue('minConfidence')}
/>
</label>
<label>
<span>记忆条数</span>
<input
type="number"
min="1"
max="50"
value={String(routerSection.memoryResolveLimit ?? '8')}
onChange={updateRouterValue('memoryResolveLimit')}
/>
</label>
<label>
<span>超时 Ms</span>
<input
type="number"
min="0"
max="30000"
value={String(routerSection.timeoutMs ?? '1500')}
onChange={updateRouterValue('timeoutMs')}
/>
</label>
<label>
<span>低置信 / 失败回退</span>
<select
value={String(routerSection.fallbackRoute ?? 'agent_orchestration')}
onChange={updateRouterValue('fallbackRoute')}
>
<option value="agent_orchestration">Agent 编排</option>
<option value="direct_chat">Direct Chat</option>
</select>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(routerSection.memoryResolveEnabled)}
onChange={updateRouterFlag('memoryResolveEnabled')}
/>
路由前读取 Memory V2
</label>
</div>
<p className="muted">
当前数据库配置:<strong>{currentRouterSection.enabled ? '已启用' : '未启用'}</strong>
{' · '}
生效模型链路:<strong>{describeEffectiveModelSource(routerSection, providerKeys, globalModelSettings)}</strong>
</p>
{!providerKeys.length && (
<p className="muted">
统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。
</p>
)}
</section>
{BACKENDS.map((backend) => {
const section = draft[backend.key];
const currentSection = current[backend.key];
const providerKey = isModelBackend(backend.key)
? findProviderKey(providerKeys, section.modelProviderKeyId)
: null;
const availableModels = isModelBackend(backend.key)
? modelOptionsForProvider(providerKey, globalModelSettings)
: [];
return (
<section key={backend.key} className="admin-card">
<div className="admin-card-head">
<div>
<h2>{backend.label}</h2>
<p className="muted">单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。</p>
</div>
<div className="admin-actions">
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(section.enabled)}
onChange={updateBackendFlag(backend.key)}
/>
启用
</label>
<button
type="button"
className="send-btn"
disabled={loading || busyScope !== null}
onClick={() => void saveBackendConfig(backend.key)}
>
{busyScope === backend.key ? '保存中...' : `保存 ${backend.label}`}
</button>
</div>
</div>
<div className="admin-form-grid">
{backend.fields.map((field) => {
const configured = Boolean(currentSection?.[`${field.key}Configured`]);
const masked = String(currentSection?.[`${field.key}Masked`] ?? '');
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type={field.secret ? 'password' : 'text'}
value={String(section[field.key] ?? '')}
placeholder={field.secret && configured ? `已配置:${masked || '******'};留空保持不变` : ''}
onChange={updateBackendValue(backend.key, field.key)}
/>
</label>
);
})}
</div>
{isModelBackend(backend.key) && (
<>
<div className="admin-form-grid" style={{ marginTop: 12 }}>
<label>
<span>模型来源</span>
<select
value={String(section.modelProviderKeyId ?? '')}
onChange={updateModelProvider(backend.key)}
>
<option value="">跟随统一模型中心默认</option>
{providerKeys.map((item) => (
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
))}
</select>
</label>
<label>
<span>模型</span>
<select
value={String(section.model ?? '')}
onChange={updateBackendValue(backend.key, 'model')}
>
<option value="">
{providerKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
</option>
{availableModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label>
<span>模型类型</span>
<select
value={String(section.modelApiType ?? '')}
onChange={updateBackendValue(backend.key, 'modelApiType')}
>
<option value="">未指定</option>
{supportedApiTypes.map((apiType) => (
<option key={apiType} value={apiType}>
{MODEL_API_TYPES.find((item) => item.value === apiType)?.label ?? apiType}
</option>
))}
</select>
</label>
</div>
<p className="muted">
当前生效模型链路:<strong>{describeEffectiveModelSource(section, providerKeys, globalModelSettings)}</strong>
</p>
{!providerKeys.length && (
<p className="muted">
统一模型中心当前没有可用的激活 key,请先到“统一模型中心”配置 provider key 与模型列表。
</p>
)}
</>
)}
</section>
);
})}
<div className="admin-actions">
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busyScope !== null && busyScope !== 'reload'}>
重新加载
</button>
</div>
</div>
</div>
);
}
+421
View File
@@ -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>
);
}
+69
View File
@@ -2,6 +2,8 @@ import type {
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
AdminSystemTestReport,
AdminSystemTestAccount,
AdminSubscription,
AdminUserRow,
AuthStatus,
@@ -26,6 +28,9 @@ import type {
PolicyMap,
PortalUser,
MindSpaceAdminConfig,
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
SkillDefinition,
SkillMap,
UsageRecord,
@@ -278,6 +283,70 @@ export async function updateMindSpaceAdminConfig(
return result.config;
}
export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigResponse> {
return portalFetch('/admin-api/memory-v2/config');
}
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 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;
+170 -16
View File
@@ -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()}>
恢复角色默认
+24
View File
@@ -1649,6 +1649,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;
+12
View File
@@ -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 }>(
+1
View File
@@ -10,6 +10,7 @@ const opsSections = [
{
label: '内容管理',
items: [
{ to: '/ops/posts', label: '帖子管理' },
{ to: '/ops/reports', label: '举报处理' },
{ to: '/ops/featured', label: '精选管理' },
{ to: '/ops/creators', label: '创作者' },
+58
View File
@@ -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);
}
+280
View File
@@ -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>
);
}
+76
View File
@@ -255,6 +255,82 @@ 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;
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 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;