Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40fe362489 | |||
| 2f78fbb436 | |||
| 816af97bb0 | |||
| 280cae028b | |||
| 2ef6cefeda | |||
| 7f20c4ce87 | |||
| e08eb50775 | |||
| a49bb8c8b0 | |||
| 1969071cf4 | |||
| 427a31ba21 | |||
| 790c5794e9 |
@@ -1 +0,0 @@
|
||||
26747
|
||||
@@ -1 +0,0 @@
|
||||
25645
|
||||
@@ -0,0 +1,51 @@
|
||||
name: memind_adm CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: memind-adm-ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, build, and release script checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Check out exact commit
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin https://git.tkmind.cn/tkmind/memind_adm.git
|
||||
git fetch --depth=2 origin "${{ gitea.sha }}"
|
||||
git checkout --detach FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "${{ gitea.sha }}"
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Check patch formatting
|
||||
run: git diff --check HEAD^ HEAD
|
||||
|
||||
- name: Run Admin API tests
|
||||
run: npm test
|
||||
|
||||
- name: Build production frontend
|
||||
run: VITE_BASE_PATH=/ops npm run build
|
||||
|
||||
- name: Check release scripts
|
||||
run: |
|
||||
bash -n scripts/release-prod.sh
|
||||
bash -n scripts/check-gray-ready.sh
|
||||
bash -n scripts/deploy-gray-103.sh
|
||||
|
||||
- name: Confirm build leaves the worktree clean
|
||||
run: test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
@@ -9,3 +9,8 @@ memind-lib/
|
||||
.vite/
|
||||
*.log
|
||||
logs/
|
||||
.adm-api.pid
|
||||
.adm-dev.pid
|
||||
.adm-preview.pid
|
||||
*.pid
|
||||
release-candidates/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
41863
|
||||
+34
-2
@@ -83,10 +83,42 @@ nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth
|
||||
## 唯一入口
|
||||
|
||||
```bash
|
||||
bash scripts/release-prod.sh --dry-run
|
||||
# 只生成持久候选包;不连接、不修改 103
|
||||
TEST_MEMIND_ROOT=<clean-Memind-worktree> \
|
||||
IMAGE_MAKE_RELEASE_ID=20260720-standalone-rc1 \
|
||||
bash scripts/release-prod.sh --package-only
|
||||
|
||||
# 候选包生成后,只读检查 103 灰度前置条件
|
||||
bash scripts/check-gray-ready.sh --candidate-dir release-candidates/<release-id>
|
||||
|
||||
# 明确批准后,部署 103 loopback-only 灰度实例;不切换 live
|
||||
bash scripts/deploy-gray-103.sh \
|
||||
--candidate-dir release-candidates/<release-id> \
|
||||
--yes
|
||||
|
||||
# 正式生产发布;必须在灰度通过并得到明确许可后执行
|
||||
bash scripts/release-prod.sh
|
||||
```
|
||||
|
||||
`--dry-run` 是 `--package-only` 的兼容别名。候选包默认保存在
|
||||
`release-candidates/<release-id>/`,包含 tar 包、manifest 和 SHA-256 校验文件。
|
||||
|
||||
发包时 `Memind` 共享源码必须满足:
|
||||
|
||||
1. 是 Git 工作区且完全干净。
|
||||
2. `HEAD` 精确匹配 `MEMIND_BASE_REF`(默认 `origin/main`)。
|
||||
3. 包含 `asset-gateway.mjs`、`image-make-admin-config.mjs`、`image-make-client.mjs`。
|
||||
4. manifest 明确记录 `IMAGE_MAKE_RELEASE_ID`,避免后台候选与错误的 image_make 版本配套。
|
||||
|
||||
灰度分两层:
|
||||
|
||||
1. `check-gray-ready.sh` 默认只做后台候选包、103 当前健康状态、灰度端口和 image_make 版本的只读预检。
|
||||
2. `--require-image-integration` 额外要求 Portal runtime-config 路由、image_make 配置 URL 和 worker 均已就绪。该条件涉及生产运行态,未经单独许可不得开启。
|
||||
3. `deploy-gray-103.sh` 只在 `127.0.0.1:15174/18085` 启动独立灰度实例,不替换 live,不重启 `5174/8085`,并设置 `ADM_READ_ONLY_MODE=1` 阻断管理后台与 Plaza API 写操作。
|
||||
4. 灰度页面默认只能通过 SSH 端口转发访问,不增加 105 nginx/Caddy 路由,也不产生公网入口。
|
||||
|
||||
`release-prod.sh` 没有“灰度即生产”的隐式行为:不带 `--package-only` 时就是正式 live 原子切换,必须等灰度验收通过后再执行。
|
||||
|
||||
## 产物发布流程
|
||||
|
||||
1. 本地生成 `memind-adm-<release-id>.tar.gz` 和发布清单。
|
||||
@@ -94,7 +126,7 @@ bash scripts/release-prod.sh
|
||||
3. 103 备份当前 `/Users/john/Project/memind_adm` 为时间戳压缩包。
|
||||
4. 在 `/Users/john/Project/releases/` 下解包新版本。
|
||||
5. 从当前 live 目录继承运行态配置与日志文件。
|
||||
6. 在 release 目录安装依赖、构建。
|
||||
6. 在 release 目录安装锁定依赖;前端 `dist/` 已在本地构建完成,103 不执行前端构建。
|
||||
7. 原子切换 `memind_adm` live 目录。
|
||||
8. 调用 `scripts/remote_restart.sh` 重启 `8085` 和 `5174`。
|
||||
9. 检查 `127.0.0.1:8085/health` 与 `127.0.0.1:5174/`。
|
||||
|
||||
Generated
-614
@@ -21,7 +21,6 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"redis": "^4.7.1",
|
||||
"sharp": "0.35.2",
|
||||
"undici": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -787,554 +786,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
|
||||
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
|
||||
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
|
||||
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
|
||||
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
|
||||
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
|
||||
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
|
||||
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
|
||||
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
|
||||
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2780,15 +2231,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
@@ -4268,62 +3710,6 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
|
||||
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.2",
|
||||
"@img/sharp-darwin-x64": "0.35.2",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
|
||||
"@img/sharp-linux-arm": "0.35.2",
|
||||
"@img/sharp-linux-arm64": "0.35.2",
|
||||
"@img/sharp-linux-ppc64": "0.35.2",
|
||||
"@img/sharp-linux-riscv64": "0.35.2",
|
||||
"@img/sharp-linux-s390x": "0.35.2",
|
||||
"@img/sharp-linux-x64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.2",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.2",
|
||||
"@img/sharp-win32-arm64": "0.35.2",
|
||||
"@img/sharp-win32-ia32": "0.35.2",
|
||||
"@img/sharp-win32-x64": "0.35.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -7,6 +7,8 @@
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:web": "vite",
|
||||
"dev:server": "node server/index.mjs",
|
||||
"test": "node --test server/*.test.mjs",
|
||||
"test:image-make-admin": "node --test server/app.image-make.test.mjs",
|
||||
"admin:init": "node scripts/init-admin.mjs",
|
||||
"release:prod": "bash scripts/release-prod.sh",
|
||||
"local_restart": "bash scripts/local_restart.sh",
|
||||
@@ -29,7 +31,6 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"redis": "^4.7.1",
|
||||
"sharp": "0.35.2",
|
||||
"undici": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.git
|
||||
.gitea
|
||||
.gitignore
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -7,10 +8,12 @@
|
||||
.adm-api.pid
|
||||
.adm-dev.pid
|
||||
.adm-preview.pid
|
||||
*.pid
|
||||
.claude
|
||||
.mindops
|
||||
node_modules
|
||||
coverage
|
||||
release-candidates
|
||||
*.log
|
||||
adm-preview.log
|
||||
adm-api.log
|
||||
@@ -33,6 +36,8 @@ scripts/local_restart.sh
|
||||
scripts/pro_restart.sh
|
||||
scripts/rsync_to_server.sh
|
||||
scripts/audit-103-state.sh
|
||||
scripts/check-gray-ready.sh
|
||||
scripts/deploy-gray-103.sh
|
||||
scripts/gadm-nginx-105.conf
|
||||
scripts/gadm-nginx.conf.example
|
||||
scripts/gadm-lb.Caddyfile
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOST="${STUDIO_HOST:-10.10.0.2}"
|
||||
GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}"
|
||||
GRAY_API_PORT="${GRAY_API_PORT:-18085}"
|
||||
REQUIRE_IMAGE_INTEGRATION=0
|
||||
CANDIDATE_DIR=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/check-gray-ready.sh --candidate-dir DIR [--require-image-integration]
|
||||
|
||||
Read-only checks only. This script never uploads, restarts, or changes 103.
|
||||
|
||||
Checks:
|
||||
- candidate checksum and traceability manifest
|
||||
- current memind_adm production health
|
||||
- reserved gray ports are free on 103
|
||||
- expected image_make release is healthy on 103
|
||||
- optional end-to-end Portal/image_make runtime wiring readiness
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--candidate-dir)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; }
|
||||
CANDIDATE_DIR="$1"
|
||||
;;
|
||||
--require-image-integration) REQUIRE_IMAGE_INTEGRATION=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; }
|
||||
if [[ "${CANDIDATE_DIR}" != /* ]]; then
|
||||
CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}"
|
||||
fi
|
||||
[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; }
|
||||
|
||||
manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)"
|
||||
sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)"
|
||||
bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)"
|
||||
[[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || {
|
||||
echo "Candidate bundle, manifest, or checksum file missing in ${CANDIDATE_DIR}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
(
|
||||
cd "${CANDIDATE_DIR}"
|
||||
shasum -a 256 -c "$(basename "${sha_file}")"
|
||||
)
|
||||
|
||||
if tar -tzf "${bundle}" | grep -Eq '(^|/)(\.env($|\.)|[^/]*\.pid$|src/|release-candidates/)'; then
|
||||
echo "Candidate contains forbidden runtime or development assets" >&2
|
||||
tar -tzf "${bundle}" | grep -E '(^|/)(\.env($|\.)|[^/]*\.pid$|src/|release-candidates/)' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
manifest_value() {
|
||||
local key="$1"
|
||||
sed -n "s/^${key}=//p" "${manifest}" | head -n 1
|
||||
}
|
||||
|
||||
release_id="$(manifest_value release_id)"
|
||||
git_head="$(manifest_value git_head)"
|
||||
memind_head="$(manifest_value memind_git_head)"
|
||||
memind_status="$(manifest_value memind_git_status)"
|
||||
image_make_release_id="$(manifest_value image_make_release_id)"
|
||||
|
||||
[[ -n "${release_id}" && -n "${git_head}" && -n "${memind_head}" ]] || {
|
||||
echo "Candidate manifest is missing release traceability fields" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "${memind_status}" == "clean" ]] || {
|
||||
echo "Candidate manifest does not prove a clean Memind shared source" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "${image_make_release_id}" != "not-declared" && -n "${image_make_release_id}" ]] || {
|
||||
echo "Candidate manifest must declare IMAGE_MAKE_RELEASE_ID" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "candidate_release=${release_id}"
|
||||
echo "candidate_git_head=${git_head}"
|
||||
echo "candidate_memind_head=${memind_head}"
|
||||
echo "candidate_image_make_release=${image_make_release_id}"
|
||||
|
||||
set +e
|
||||
remote_result="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' IMAGE_MAKE_RELEASE_ID='${image_make_release_id}' REQUIRE_IMAGE_INTEGRATION='${REQUIRE_IMAGE_INTEGRATION}' /bin/bash" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
api_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
|
||||
web_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
|
||||
image_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/health || true)"
|
||||
[[ "${api_code}" == "200" && "${web_code}" == "200" && "${image_code}" == "200" ]] || {
|
||||
echo "health_failed admin_api=${api_code} admin_web=${web_code} image_make=${image_code}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do
|
||||
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then
|
||||
echo "gray_port_in_use=${port}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
export DOCKER_HOST=unix:///Users/john/.colima/default/docker.sock
|
||||
docker_bin=/usr/local/bin/docker
|
||||
container="$(${docker_bin} ps --format '{{.Names}} {{.Ports}}' | awk '/18083/ {print $1; exit}')"
|
||||
[[ -n "${container}" ]] || { echo "image_make_container_missing" >&2; exit 1; }
|
||||
image="$(${docker_bin} inspect --format '{{.Config.Image}}' "${container}")"
|
||||
health="$(${docker_bin} inspect --format '{{.State.Health.Status}}' "${container}")"
|
||||
[[ "${image}" == *"${IMAGE_MAKE_RELEASE_ID}"* && "${health}" == "healthy" ]] || {
|
||||
echo "image_make_release_mismatch image=${image} health=${health}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
worker_enabled="$(${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | sed -n 's/^WORKER_ENABLED=//p' | head -n 1)"
|
||||
config_url_present=0
|
||||
if ${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | grep -q '^IMAGE_MAKE_MEMIND_CONFIG_URL=.'; then
|
||||
config_url_present=1
|
||||
fi
|
||||
portal_route_present=0
|
||||
if grep -q '/internal/image-make/runtime-config' /Users/john/Project/Memind/server.mjs 2>/dev/null; then
|
||||
portal_route_present=1
|
||||
fi
|
||||
|
||||
echo "admin_api=200 admin_web=200 image_make=200"
|
||||
echo "gray_ports_free=${GRAY_WEB_PORT},${GRAY_API_PORT}"
|
||||
echo "image_make_image=${image}"
|
||||
echo "image_make_worker_enabled=${worker_enabled:-unset}"
|
||||
echo "image_make_config_url_present=${config_url_present}"
|
||||
echo "portal_runtime_route_present=${portal_route_present}"
|
||||
|
||||
if [[ "${REQUIRE_IMAGE_INTEGRATION}" == "1" ]]; then
|
||||
[[ "${worker_enabled}" == "true" && "${config_url_present}" == "1" && "${portal_route_present}" == "1" ]] || {
|
||||
echo "image_make_integration_not_ready" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
REMOTE
|
||||
)"
|
||||
remote_status=$?
|
||||
set -e
|
||||
|
||||
printf '%s\n' "${remote_result}"
|
||||
if [[ "${remote_status}" -ne 0 ]]; then
|
||||
echo "gray_preflight=failed" >&2
|
||||
exit "${remote_status}"
|
||||
fi
|
||||
echo "gray_preflight=passed"
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOST="${STUDIO_HOST:-10.10.0.2}"
|
||||
GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}"
|
||||
GRAY_API_PORT="${GRAY_API_PORT:-18085}"
|
||||
REMOTE_GRAY_ROOT="${REMOTE_GRAY_ROOT:-/Users/john/Project/gray/memind_adm}"
|
||||
CANDIDATE_DIR=""
|
||||
AUTO_YES=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/deploy-gray-103.sh --candidate-dir DIR [--yes]
|
||||
|
||||
Deploys a loopback-only, read-only memind_adm gray instance on 103.
|
||||
It does not replace /Users/john/Project/memind_adm and does not restart live ports 5174/8085.
|
||||
|
||||
Defaults:
|
||||
gray web: 127.0.0.1:15174/ops/
|
||||
gray API: 127.0.0.1:18085
|
||||
|
||||
The --yes flag is mandatory because this writes a separate gray release to 103.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--candidate-dir)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; }
|
||||
CANDIDATE_DIR="$1"
|
||||
;;
|
||||
--yes|-y) AUTO_YES=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; }
|
||||
if [[ "${CANDIDATE_DIR}" != /* ]]; then
|
||||
CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}"
|
||||
fi
|
||||
[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; }
|
||||
|
||||
manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)"
|
||||
sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)"
|
||||
bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)"
|
||||
[[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || {
|
||||
echo "Candidate bundle, manifest, or checksum file missing" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
release_id="$(sed -n 's/^release_id=//p' "${manifest}" | head -n 1)"
|
||||
[[ "${release_id}" =~ ^[A-Za-z0-9._-]+$ ]] || {
|
||||
echo "Invalid release_id in manifest: ${release_id}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
bash "${ROOT}/scripts/check-gray-ready.sh" --candidate-dir "${CANDIDATE_DIR}"
|
||||
|
||||
if [[ "${AUTO_YES}" -ne 1 ]]; then
|
||||
echo
|
||||
echo "Gray deployment is ready but was not started."
|
||||
echo "Target: ${HOST} ${REMOTE_GRAY_ROOT}/releases/${release_id}"
|
||||
echo "Ports: 127.0.0.1:${GRAY_WEB_PORT}, 127.0.0.1:${GRAY_API_PORT}"
|
||||
echo "Re-run with --yes after explicit approval."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
remote_incoming="${REMOTE_GRAY_ROOT}/incoming/${release_id}"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "mkdir -p '${remote_incoming}'"
|
||||
scp -q "${bundle}" "${manifest}" "${sha_file}" "${HOST}:${remote_incoming}/"
|
||||
|
||||
ssh -o BatchMode=yes "${HOST}" \
|
||||
"RELEASE_ID='${release_id}' GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' REMOTE_GRAY_ROOT='${REMOTE_GRAY_ROOT}' /bin/bash" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
LIVE_DIR=/Users/john/Project/memind_adm
|
||||
INCOMING_DIR="${REMOTE_GRAY_ROOT}/incoming/${RELEASE_ID}"
|
||||
RELEASE_DIR="${REMOTE_GRAY_ROOT}/releases/${RELEASE_ID}"
|
||||
BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz"
|
||||
MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt"
|
||||
SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256"
|
||||
|
||||
stop_gray_ports() {
|
||||
lsof -ti "TCP:${GRAY_WEB_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
||||
lsof -ti "TCP:${GRAY_API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
||||
}
|
||||
|
||||
rollback_gray() {
|
||||
stop_gray_ports
|
||||
echo "Gray startup failed; live memind_adm was not changed." >&2
|
||||
}
|
||||
trap rollback_gray ERR
|
||||
|
||||
live_api_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
|
||||
live_web_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
|
||||
[[ "${live_api_before}" == "200" && "${live_web_before}" == "200" ]] || {
|
||||
echo "Live memind_adm is not healthy before gray deployment" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do
|
||||
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then
|
||||
echo "Gray port is already in use: ${port}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
cd "${INCOMING_DIR}"
|
||||
shasum -a 256 -c "$(basename "${SHA_FILE}")"
|
||||
|
||||
rm -rf "${RELEASE_DIR}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}"
|
||||
cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt"
|
||||
|
||||
[[ -f "${LIVE_DIR}/.env" ]] || { echo "Live .env is missing" >&2; exit 1; }
|
||||
cp "${LIVE_DIR}/.env" "${RELEASE_DIR}/.env"
|
||||
chmod 600 "${RELEASE_DIR}/.env"
|
||||
|
||||
[[ -d "${RELEASE_DIR}/dist" ]] || { echo "Gray release missing dist/" >&2; exit 1; }
|
||||
[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "Gray release contains src/" >&2; exit 1; }
|
||||
[[ -f "${RELEASE_DIR}/server/index.mjs" ]] || { echo "Gray release missing Admin API" >&2; exit 1; }
|
||||
|
||||
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
|
||||
cd "${RELEASE_DIR}"
|
||||
if [[ -f package-lock.json ]]; then
|
||||
npm ci --no-audit --no-fund
|
||||
else
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${RELEASE_DIR}/.env"
|
||||
set +a
|
||||
export ADM_API_PORT="${GRAY_API_PORT}"
|
||||
export ADM_PORT="${GRAY_WEB_PORT}"
|
||||
export ADM_API_HOST=127.0.0.1
|
||||
export ADM_WEB_HOST=127.0.0.1
|
||||
export ADM_DEV_BACKEND="http://127.0.0.1:${GRAY_API_PORT}"
|
||||
export VITE_BASE_PATH=/ops
|
||||
export ADM_READ_ONLY_MODE=1
|
||||
|
||||
nohup node server/index.mjs >>"${RELEASE_DIR}/adm-gray-api.log" 2>&1 &
|
||||
echo $! >"${RELEASE_DIR}/.adm-gray-api.pid"
|
||||
|
||||
api_ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:${GRAY_API_PORT}/health" >/dev/null; then
|
||||
api_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "${api_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-api.log" >&2 || true; exit 1; }
|
||||
|
||||
nohup ./node_modules/.bin/vite preview --config scripts/vite-preview.config.mjs \
|
||||
>>"${RELEASE_DIR}/adm-gray-web.log" 2>&1 &
|
||||
echo $! >"${RELEASE_DIR}/.adm-gray-web.pid"
|
||||
|
||||
web_ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:${GRAY_WEB_PORT}/ops/" >/dev/null; then
|
||||
web_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "${web_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-web.log" >&2 || true; exit 1; }
|
||||
|
||||
live_api_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)"
|
||||
live_web_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)"
|
||||
[[ "${live_api_after}" == "200" && "${live_web_after}" == "200" ]] || {
|
||||
echo "Live memind_adm health changed during gray startup" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat >"${RELEASE_DIR}/GRAY_DEPLOYMENT.txt" <<EOF
|
||||
release_id=${RELEASE_ID}
|
||||
deployed_at=$(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/
|
||||
api=http://127.0.0.1:${GRAY_API_PORT}
|
||||
read_only=true
|
||||
live_switched=false
|
||||
EOF
|
||||
|
||||
trap - ERR
|
||||
echo "gray_release_dir=${RELEASE_DIR}"
|
||||
echo "gray_web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/"
|
||||
echo "gray_api=http://127.0.0.1:${GRAY_API_PORT}"
|
||||
echo "gray_read_only=true"
|
||||
echo "live_memind_adm_unchanged=true"
|
||||
REMOTE
|
||||
|
||||
echo
|
||||
echo "Gray instance deployed without switching live."
|
||||
echo "Open through SSH forwarding:"
|
||||
echo " ssh -L ${GRAY_WEB_PORT}:127.0.0.1:${GRAY_WEB_PORT} -L ${GRAY_API_PORT}:127.0.0.1:${GRAY_API_PORT} john@${HOST}"
|
||||
+88
-24
@@ -22,7 +22,10 @@ MANIFEST_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.manifest.txt"
|
||||
SHA_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.sha256"
|
||||
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../Memind}"
|
||||
MEMIND_LIB="${ROOT}/memind-lib"
|
||||
DRY_RUN=0
|
||||
MEMIND_BASE_REF="${MEMIND_BASE_REF:-origin/main}"
|
||||
IMAGE_MAKE_RELEASE_ID="${IMAGE_MAKE_RELEASE_ID:-not-declared}"
|
||||
PACKAGE_ONLY=0
|
||||
OUTPUT_DIR=""
|
||||
SKIP_BUILD=0
|
||||
AUTO_YES=0
|
||||
|
||||
@@ -34,15 +37,19 @@ trap cleanup EXIT
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
bash scripts/release-prod.sh [--dry-run] [--skip-build] [--yes]
|
||||
bash scripts/release-prod.sh [--package-only|--dry-run] [--output-dir DIR] [--skip-build] [--yes]
|
||||
|
||||
说明:
|
||||
1. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码)
|
||||
2. 上传到 103
|
||||
3. 103 全量备份当前 memind_adm
|
||||
4. 解包、安装依赖(不在 103 构建前端)
|
||||
5. 原子切换 live 目录并重启验证
|
||||
6. 旧 live 目录移入 archives,103 不再保留可编辑源码树
|
||||
1. 校验 memind_adm 与 Memind 共享源码均来自干净、可追溯的 commit
|
||||
2. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码)
|
||||
3. --package-only / --dry-run 只生成持久候选包,不连接或修改 103
|
||||
4. 正式模式上传到 103,完成全量备份、原子切换、重启和健康检查
|
||||
5. 旧 live 目录移入 archives,103 不再保留可编辑源码树
|
||||
|
||||
环境:
|
||||
TEST_MEMIND_ROOT 干净的 Memind 共享源码目录,默认 ../Memind
|
||||
MEMIND_BASE_REF Memind 共享源码必须精确匹配的 ref,默认 origin/main
|
||||
IMAGE_MAKE_RELEASE_ID 写入 manifest 的 image_make 依赖版本
|
||||
|
||||
禁止:
|
||||
- 直接 rsync 到 103
|
||||
@@ -53,7 +60,12 @@ EOF
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--dry-run|--package-only) PACKAGE_ONLY=1 ;;
|
||||
--output-dir)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || { echo "--output-dir 缺少目录参数" >&2; exit 2; }
|
||||
OUTPUT_DIR="$1"
|
||||
;;
|
||||
--skip-build) SKIP_BUILD=1 ;;
|
||||
--yes|-y) AUTO_YES=1 ;;
|
||||
-h|--help)
|
||||
@@ -85,7 +97,6 @@ need_cmd scp
|
||||
need_cmd rsync
|
||||
need_cmd tar
|
||||
need_cmd shasum
|
||||
need_cmd node
|
||||
|
||||
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
|
||||
bash "${ROOT}/scripts/check-release-ready.sh"
|
||||
@@ -94,14 +105,52 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "${MEMIND_SRC}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
|
||||
echo "Memind 共享源码不是可追溯 Git 工作区: ${MEMIND_SRC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$(git -C "${MEMIND_SRC}" status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
echo "Memind 共享源码工作区不干净,禁止打包: ${MEMIND_SRC}" >&2
|
||||
git -C "${MEMIND_SRC}" status --short --untracked-files=all >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "${MEMIND_SRC}" rev-parse --verify --quiet "${MEMIND_BASE_REF}" >/dev/null || {
|
||||
echo "Memind 共享源码缺少基线 ref: ${MEMIND_BASE_REF}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD)"
|
||||
MEMIND_BASE_HEAD="$(git -C "${MEMIND_SRC}" rev-parse "${MEMIND_BASE_REF}")"
|
||||
if [[ "${MEMIND_GIT_HEAD}" != "${MEMIND_BASE_HEAD}" ]]; then
|
||||
echo "Memind 共享源码必须精确匹配 ${MEMIND_BASE_REF}" >&2
|
||||
echo " HEAD=${MEMIND_GIT_HEAD}" >&2
|
||||
echo " ${MEMIND_BASE_REF}=${MEMIND_BASE_HEAD}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required_shared_file in \
|
||||
user-auth.mjs \
|
||||
asset-gateway.mjs \
|
||||
image-make-admin-config.mjs \
|
||||
image-make-client.mjs; do
|
||||
[[ -f "${MEMIND_SRC}/${required_shared_file}" ]] || {
|
||||
echo "Memind 共享源码缺少发布依赖: ${required_shared_file}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
[[ -f "${IGNORE_FILE}" ]] || {
|
||||
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
say "本地预检查"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null
|
||||
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null
|
||||
if [[ "${PACKAGE_ONLY}" -ne 1 ]]; then
|
||||
say "103 正式发布预检查"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null
|
||||
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||||
say "本地构建 dist(103 不再执行前端构建)"
|
||||
@@ -111,12 +160,6 @@ if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||||
)
|
||||
fi
|
||||
|
||||
say "验证共享模块图像运行依赖"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
node --input-type=module -e "const { default: sharp } = await import('sharp'); await sharp({ create: { width: 1, height: 1, channels: 4, background: '#000000' } }).png().toBuffer();"
|
||||
)
|
||||
|
||||
[[ -d "${ROOT}/dist" ]] || {
|
||||
echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2
|
||||
exit 1
|
||||
@@ -127,7 +170,6 @@ say "验证共享模块图像运行依赖"
|
||||
exit 1
|
||||
}
|
||||
|
||||
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current 2>/dev/null || echo detached)"
|
||||
|
||||
say "打包 Memind 共享模块到 memind-lib"
|
||||
@@ -157,6 +199,14 @@ fi
|
||||
echo "memind-lib 缺少 user-auth.mjs" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f "${MEMIND_LIB}/image-make-admin-config.mjs" ]] || {
|
||||
echo "memind-lib 缺少 image_make 管理配置模块" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f "${MEMIND_LIB}/image-make-client.mjs" ]] || {
|
||||
echo "memind-lib 缺少 image_make 客户端模块" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f "${MEMIND_LIB}/skills/excel-analyst/SKILL.md" ]] || {
|
||||
echo "memind-lib 缺少 Excel Analyst 技能" >&2
|
||||
exit 1
|
||||
@@ -170,7 +220,7 @@ grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||
if [[ "${AUTO_YES}" -ne 1 && "${PACKAGE_ONLY}" -ne 1 ]]; then
|
||||
say "发布确认"
|
||||
echo "目标主机: ${HOST}"
|
||||
echo "目标目录: ${APP_DIR}"
|
||||
@@ -192,6 +242,10 @@ say "生成发布清单"
|
||||
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
|
||||
echo "memind_git_head=${MEMIND_GIT_HEAD}"
|
||||
echo "memind_git_branch=${MEMIND_GIT_BRANCH}"
|
||||
echo "memind_base_ref=${MEMIND_BASE_REF}"
|
||||
echo "memind_base_head=${MEMIND_BASE_HEAD}"
|
||||
echo "memind_git_status=clean"
|
||||
echo "image_make_release_id=${IMAGE_MAKE_RELEASE_ID}"
|
||||
echo "git_status_begin"
|
||||
git -C "${ROOT}" status --short --branch || true
|
||||
echo "git_status_end"
|
||||
@@ -206,9 +260,19 @@ tar \
|
||||
checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')"
|
||||
printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||
say "dry-run 完成"
|
||||
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
|
||||
if [[ "${PACKAGE_ONLY}" -eq 1 ]]; then
|
||||
if [[ -z "${OUTPUT_DIR}" ]]; then
|
||||
OUTPUT_DIR="${ROOT}/release-candidates/${RELEASE_ID}"
|
||||
elif [[ "${OUTPUT_DIR}" != /* ]]; then
|
||||
OUTPUT_DIR="${ROOT}/${OUTPUT_DIR}"
|
||||
fi
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
cp "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${OUTPUT_DIR}/"
|
||||
say "候选包生成完成;未连接或修改 103"
|
||||
echo "candidate_dir=${OUTPUT_DIR}"
|
||||
ls -lh "${OUTPUT_DIR}/$(basename "${BUNDLE_PATH}")" \
|
||||
"${OUTPUT_DIR}/$(basename "${MANIFEST_PATH}")" \
|
||||
"${OUTPUT_DIR}/$(basename "${SHA_PATH}")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -34,19 +34,6 @@ export ADM_WEB_HOST="${WEB_HOST}"
|
||||
export ADM_PORT="${PORT}"
|
||||
export VITE_BASE_PATH="${BASE_PATH%/}"
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local attempts="${2:-30}"
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
|
||||
if curl -sf --max-time 2 "${url}" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -d dist ]]; then
|
||||
echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
|
||||
exit 1
|
||||
@@ -74,8 +61,9 @@ sleep 1
|
||||
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
|
||||
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
|
||||
echo $! >"${API_PID_FILE}"
|
||||
sleep 2
|
||||
|
||||
if ! wait_for_http "http://127.0.0.1:${API_PORT}/health" 30; then
|
||||
if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then
|
||||
echo "❌ Admin API 启动失败,最近日志:" >&2
|
||||
tail -n 40 "${API_LOG}" >&2 || true
|
||||
exit 1
|
||||
@@ -84,10 +72,11 @@ fi
|
||||
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
|
||||
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
|
||||
echo $! >"${PID_FILE}"
|
||||
sleep 2
|
||||
|
||||
preview_url="http://127.0.0.1:${PORT}${BASE_PATH}"
|
||||
login_probe="http://127.0.0.1:${PORT}/auth/status"
|
||||
if wait_for_http "${preview_url}" 30 && wait_for_http "${login_probe}" 15; then
|
||||
if curl -sf "${preview_url}" >/dev/null && curl -sf "${login_probe}" >/dev/null; then
|
||||
echo "✅ memind_adm 已就绪: ${preview_url}"
|
||||
curl -sf "${preview_url}" >/dev/null && echo " 首页 OK"
|
||||
echo " /auth 反代 OK"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAdminApp } from './app.mjs';
|
||||
|
||||
function createServices({ role = 'admin', imageMakeAdminConfigService } = {}) {
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
parseCookies: () => ({ adm_session: 'test-token' }),
|
||||
USER_COOKIE: 'adm_session',
|
||||
userLoginCookies: () => ['adm_session=test-token; Path=/; HttpOnly'],
|
||||
clearUserSessionCookie: () => ['adm_session=; Path=/; Max-Age=0'],
|
||||
resolveCookieDomainForRequest: () => null,
|
||||
userAuth: {
|
||||
getMe: async () => ({ id: 'admin-user-id', username: 'admin', role }),
|
||||
},
|
||||
imageMakeAdminConfigService,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(services, callback) {
|
||||
const app = createAdminApp(services);
|
||||
const server = await new Promise((resolve) => {
|
||||
const instance = app.listen(0, '127.0.0.1', () => resolve(instance));
|
||||
});
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
try {
|
||||
await callback(baseUrl);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
const adminHeaders = {
|
||||
cookie: 'adm_session=test-token',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
test('image_make admin routes read and update config with the admin identity', async () => {
|
||||
let updateCall = null;
|
||||
const service = {
|
||||
getAdminConfig: async () => ({
|
||||
source: 'default',
|
||||
config: {
|
||||
defaultProvider: 'mock',
|
||||
jobDefaultTimeoutSeconds: 120,
|
||||
providers: { mock: { enabled: true }, aliyun_bailian: { enabled: false }, comfyui: { enabled: false } },
|
||||
},
|
||||
}),
|
||||
updateAdminConfig: async (patch, context) => {
|
||||
updateCall = { patch, context };
|
||||
return { ok: true, source: 'admin-db', config: patch };
|
||||
},
|
||||
getRuntimeConfig: async () => ({
|
||||
ok: true,
|
||||
fingerprint: '1234567890abcdef',
|
||||
defaultProvider: 'aliyun_bailian',
|
||||
jobDefaultTimeoutSeconds: 120,
|
||||
providers: {
|
||||
mock: { enabled: true },
|
||||
aliyun_bailian: {
|
||||
enabled: true,
|
||||
model: 'qwen-image-plus',
|
||||
apiBase: 'https://dashscope.aliyuncs.com/api/v1',
|
||||
apiKey: 'must-not-leak',
|
||||
},
|
||||
comfyui: { enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await withServer(createServices({ imageMakeAdminConfigService: service }), async (baseUrl) => {
|
||||
const configResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
|
||||
assert.equal(configResponse.status, 200);
|
||||
assert.equal((await configResponse.json()).config.defaultProvider, 'mock');
|
||||
|
||||
const patch = { defaultProvider: 'mock', jobDefaultTimeoutSeconds: 180 };
|
||||
const updateResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, {
|
||||
method: 'PATCH',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
assert.equal(updateResponse.status, 200);
|
||||
assert.deepEqual(updateCall, { patch, context: { updatedBy: 'admin-user-id' } });
|
||||
|
||||
const runtimeResponse = await fetch(`${baseUrl}/admin-api/image-make/runtime`, { headers: adminHeaders });
|
||||
assert.equal(runtimeResponse.status, 200);
|
||||
const runtime = await runtimeResponse.json();
|
||||
assert.equal(runtime.providers.aliyun_bailian.apiKeyConfigured, true);
|
||||
assert.equal('apiKey' in runtime.providers.aliyun_bailian, false);
|
||||
assert.equal(JSON.stringify(runtime).includes('must-not-leak'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('image_make admin config validation failures are returned as 400', async () => {
|
||||
const service = {
|
||||
updateAdminConfig: async () => ({ ok: false, message: '默认 Provider 必须处于启用状态' }),
|
||||
};
|
||||
await withServer(createServices({ imageMakeAdminConfigService: service }), async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/admin-api/image-make/config`, {
|
||||
method: 'PATCH',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({ defaultProvider: 'comfyui' }),
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal((await response.json()).message, '默认 Provider 必须处于启用状态');
|
||||
});
|
||||
});
|
||||
|
||||
test('image_make admin routes require an admin user and an enabled service', async () => {
|
||||
await withServer(createServices({ role: 'user', imageMakeAdminConfigService: {} }), async (baseUrl) => {
|
||||
const forbidden = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
|
||||
assert.equal(forbidden.status, 403);
|
||||
});
|
||||
|
||||
await withServer(createServices({ imageMakeAdminConfigService: {} }), async (baseUrl) => {
|
||||
const unavailable = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
|
||||
assert.equal(unavailable.status, 503);
|
||||
});
|
||||
});
|
||||
|
||||
test('gray read-only mode blocks admin writes while keeping reads available', async () => {
|
||||
let updateCalled = false;
|
||||
const service = {
|
||||
getAdminConfig: async () => ({ config: { defaultProvider: 'mock', providers: {} } }),
|
||||
updateAdminConfig: async () => {
|
||||
updateCalled = true;
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
...createServices({ imageMakeAdminConfigService: service }),
|
||||
readOnlyMode: true,
|
||||
};
|
||||
|
||||
await withServer(services, async (baseUrl) => {
|
||||
const readResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders });
|
||||
assert.equal(readResponse.status, 200);
|
||||
|
||||
const writeResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, {
|
||||
method: 'PATCH',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({ defaultProvider: 'mock' }),
|
||||
});
|
||||
assert.equal(writeResponse.status, 423);
|
||||
assert.match((await writeResponse.json()).message, /灰度只读模式/);
|
||||
assert.equal(updateCalled, false);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,7 @@ export function createAdminApp(services) {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
pool,
|
||||
ready,
|
||||
wechatAdmin,
|
||||
@@ -112,6 +113,8 @@ export function createAdminApp(services) {
|
||||
} = services;
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
const readOnlyMode = services.readOnlyMode
|
||||
?? /^(1|true|yes|on)$/i.test(String(process.env.ADM_READ_ONLY_MODE ?? ''));
|
||||
|
||||
const isSecureRequest = (req) =>
|
||||
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
|
||||
@@ -149,6 +152,13 @@ export function createAdminApp(services) {
|
||||
);
|
||||
}
|
||||
|
||||
const requireWritableMode = (req, res, next) => {
|
||||
if (readOnlyMode && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
return res.status(423).json({ message: '当前为灰度只读模式,禁止修改生产数据' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'memind_adm' });
|
||||
});
|
||||
@@ -205,6 +215,8 @@ export function createAdminApp(services) {
|
||||
next();
|
||||
}));
|
||||
|
||||
adminApi.use(requireWritableMode);
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (!req.currentUser || req.currentUser.role !== 'admin') {
|
||||
res.status(403).json({ message: '需要管理员权限' });
|
||||
@@ -369,6 +381,53 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/image-make/config', requireAdmin, async (_req, res) => {
|
||||
if (!imageMakeAdminConfigService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
res.json(await imageMakeAdminConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateImageMakeConfig = async (req, res) => {
|
||||
if (!imageMakeAdminConfigService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
const result = await imageMakeAdminConfigService.updateAdminConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (result.ok === false) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
};
|
||||
|
||||
adminApi.put('/image-make/config', requireAdmin, updateImageMakeConfig);
|
||||
adminApi.patch('/image-make/config', requireAdmin, updateImageMakeConfig);
|
||||
|
||||
adminApi.get('/image-make/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!imageMakeAdminConfigService?.getRuntimeConfig) {
|
||||
return res.status(503).json({ message: 'image_make 配置服务未启用' });
|
||||
}
|
||||
const runtime = await imageMakeAdminConfigService.getRuntimeConfig();
|
||||
if (!runtime.ok) return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' });
|
||||
const { providers, ...rest } = runtime;
|
||||
res.json({
|
||||
...rest,
|
||||
providers: {
|
||||
mock: { enabled: providers.mock.enabled },
|
||||
aliyun_bailian: providers.aliyun_bailian.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
model: providers.aliyun_bailian.model,
|
||||
apiBase: providers.aliyun_bailian.apiBase,
|
||||
apiKeyConfigured: Boolean(providers.aliyun_bailian.apiKey),
|
||||
}
|
||||
: { enabled: false },
|
||||
comfyui: providers.comfyui.enabled
|
||||
? { enabled: true, ...providers.comfyui }
|
||||
: { enabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
res.json(await memoryV2ConfigService.getAdminConfig());
|
||||
@@ -1075,6 +1134,7 @@ export function createAdminApp(services) {
|
||||
req.currentUser = me;
|
||||
next();
|
||||
}));
|
||||
opsApi.use(requireWritableMode);
|
||||
opsApi.use(resolveSessionUser);
|
||||
opsApi.use('/ops/v1', services.createOpsApi({ jsonBody, plazaOps: services.plazaOps }));
|
||||
app.use('/api', opsApi);
|
||||
|
||||
@@ -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 { initializePersonalMemoryCandidateStore } from './personal-memory-candidate-store.mjs';
|
||||
import { createSystemTestAccountService, ensureSystemTestAccountSchema } from './system-test-accounts.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
@@ -30,9 +31,9 @@ export async function bootstrapAdminServices() {
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
|
||||
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||
const { createImageMakeAdminConfigService } = await importMemind('image-make-admin-config.mjs');
|
||||
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
|
||||
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
|
||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||
@@ -93,12 +94,17 @@ export async function bootstrapAdminServices() {
|
||||
});
|
||||
await ensureAssetGatewaySchema(pool);
|
||||
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||
const imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
llmProviderService,
|
||||
});
|
||||
await imageMakeAdminConfigService.ensureSchema();
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
|
||||
await mindSearchConfigService.ensureSchema();
|
||||
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
|
||||
const personalMemoryCandidateStore = await initializePersonalMemoryCandidateStore(pool);
|
||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
|
||||
env: process.env,
|
||||
h5Root,
|
||||
@@ -148,6 +154,7 @@ export async function bootstrapAdminServices() {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
|
||||
@@ -99,6 +99,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -125,6 +126,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { importMemind } from './lib-path.mjs';
|
||||
|
||||
export async function initializePersonalMemoryCandidateStore(
|
||||
pool,
|
||||
{ importMemindModule = importMemind } = {},
|
||||
) {
|
||||
const {
|
||||
createPersonalMemoryCandidateStore,
|
||||
ensurePersonalMemoryCandidateSchema,
|
||||
} = await importMemindModule('memory-v2-personal-store.mjs');
|
||||
|
||||
await ensurePersonalMemoryCandidateSchema(pool);
|
||||
return createPersonalMemoryCandidateStore(pool);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializePersonalMemoryCandidateStore } from './personal-memory-candidate-store.mjs';
|
||||
|
||||
test('admin initializes the candidate schema before creating its store', async () => {
|
||||
const calls = [];
|
||||
const pool = { query() {} };
|
||||
const store = { listCandidates() {} };
|
||||
|
||||
const result = await initializePersonalMemoryCandidateStore(pool, {
|
||||
async importMemindModule(subpath) {
|
||||
assert.equal(subpath, 'memory-v2-personal-store.mjs');
|
||||
return {
|
||||
async ensurePersonalMemoryCandidateSchema(receivedPool) {
|
||||
assert.equal(receivedPool, pool);
|
||||
calls.push('ensure');
|
||||
},
|
||||
createPersonalMemoryCandidateStore(receivedPool) {
|
||||
assert.equal(receivedPool, pool);
|
||||
calls.push('create');
|
||||
return store;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, store);
|
||||
assert.deepEqual(calls, ['ensure', 'create']);
|
||||
});
|
||||
|
||||
test('admin fails bootstrap when the candidate schema cannot be initialized', async () => {
|
||||
await assert.rejects(
|
||||
initializePersonalMemoryCandidateStore({}, {
|
||||
async importMemindModule() {
|
||||
return {
|
||||
async ensurePersonalMemoryCandidateSchema() {
|
||||
throw new Error('candidate ddl failed');
|
||||
},
|
||||
createPersonalMemoryCandidateStore() {
|
||||
throw new Error('store must not be created');
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
/candidate ddl failed/,
|
||||
);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/skills', label: '技能' },
|
||||
{ to: '/policies', label: '策略' },
|
||||
{ to: '/providers', label: '统一模型中心' },
|
||||
{ to: '/asset-gateway', label: '资产能力' },
|
||||
{ to: '/asset-gateway', label: '图片生成' },
|
||||
{ to: '/blocked-words', label: '违禁词管理' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
getAssetGatewayConfig,
|
||||
listLlmProviderKeys,
|
||||
getImageMakeAdminConfig,
|
||||
getImageMakeRuntimeStatus,
|
||||
getLlmVisionSettings,
|
||||
updateAssetGatewayConfig,
|
||||
updateAssetPluginConfig,
|
||||
updateImageMakeAdminConfig,
|
||||
} from '../../api/client';
|
||||
import type { AssetGatewayConfig, AssetPluginConfig, LlmProviderKeyRow } from '../../types';
|
||||
import type { AssetGatewayConfig, AssetPluginConfig, ImageMakeAdminConfig, LlmVisionSettings } from '../../types';
|
||||
|
||||
type PluginDraft = {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
llmProviderKeyId: string;
|
||||
llmModel: string;
|
||||
purposes: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
||||
@@ -20,6 +25,7 @@ function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
||||
provider: plugin.provider ?? '',
|
||||
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
|
||||
llmModel: plugin.llmModel ?? '',
|
||||
purposes: { ...(plugin.purposes ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,24 +36,91 @@ const pluginVisuals: Record<string, { icon: string; accent: string }> = {
|
||||
'asset-analyze': { icon: '◉', accent: 'green' },
|
||||
};
|
||||
|
||||
const defaultImageMakeConfig: ImageMakeAdminConfig = {
|
||||
defaultProvider: 'mock',
|
||||
jobDefaultTimeoutSeconds: 120,
|
||||
providers: {
|
||||
mock: { enabled: true },
|
||||
aliyun_bailian: {
|
||||
enabled: false,
|
||||
model: 'qwen-image-plus',
|
||||
apiBase: 'https://dashscope.aliyuncs.com/api/v1',
|
||||
apiKeyConfigured: false,
|
||||
apiKeyMasked: '',
|
||||
},
|
||||
comfyui: {
|
||||
enabled: false,
|
||||
apiBase: 'http://127.0.0.1:8188',
|
||||
checkpoint: '',
|
||||
workflowPath: './config/comfyui/workflows/core_txt2img_v1.json',
|
||||
outputNodeId: '9',
|
||||
timeoutSeconds: 600,
|
||||
steps: 20,
|
||||
cfg: 7,
|
||||
sampler: 'euler',
|
||||
scheduler: 'normal',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function AssetGatewayPage() {
|
||||
const [config, setConfig] = useState<AssetGatewayConfig | null>(null);
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, PluginDraft>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const activeKeys = useMemo(() => keys.filter((key) => key.status === 'active'), [keys]);
|
||||
const [imageMakeConfig, setImageMakeConfig] = useState<ImageMakeAdminConfig>(defaultImageMakeConfig);
|
||||
const [imageMakeMeta, setImageMakeMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
||||
const [imageMakeRuntime, setImageMakeRuntime] = useState<string>('');
|
||||
const [visionSettings, setVisionSettings] = useState<LlmVisionSettings | null>(null);
|
||||
const orderedPlugins = useMemo(() => config?.plugins.slice().sort((left, right) => {
|
||||
if (left.pluginId === 'asset-generate') return -1;
|
||||
if (right.pluginId === 'asset-generate') return 1;
|
||||
return left.pluginId.localeCompare(right.pluginId);
|
||||
}) ?? [], [config]);
|
||||
const imageGeneratePlugin = config?.plugins.find((plugin) => plugin.pluginId === 'asset-generate') ?? null;
|
||||
const imageGenerateDraft = imageGeneratePlugin
|
||||
? drafts[imageGeneratePlugin.pluginId] ?? draftFromPlugin(imageGeneratePlugin)
|
||||
: null;
|
||||
const imageMakeControlsEnabled = Boolean(
|
||||
imageGenerateDraft?.enabled && imageGenerateDraft.provider === 'image_make',
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextConfig, nextKeys] = await Promise.all([getAssetGatewayConfig(), listLlmProviderKeys()]);
|
||||
const [nextConfig, nextVisionSettings, nextImageMakeConfig, nextImageMakeRuntime] = await Promise.all([
|
||||
getAssetGatewayConfig(),
|
||||
getLlmVisionSettings().catch(() => null),
|
||||
getImageMakeAdminConfig(),
|
||||
getImageMakeRuntimeStatus().catch(() => null),
|
||||
]);
|
||||
setConfig(nextConfig);
|
||||
setKeys(nextKeys);
|
||||
setVisionSettings(nextVisionSettings);
|
||||
setDrafts(Object.fromEntries(nextConfig.plugins.map((plugin) => [plugin.pluginId, draftFromPlugin(plugin)])));
|
||||
setImageMakeConfig({
|
||||
...defaultImageMakeConfig,
|
||||
...nextImageMakeConfig.config,
|
||||
providers: {
|
||||
...defaultImageMakeConfig.providers,
|
||||
...nextImageMakeConfig.config.providers,
|
||||
mock: { ...defaultImageMakeConfig.providers.mock, ...nextImageMakeConfig.config.providers.mock },
|
||||
aliyun_bailian: {
|
||||
...defaultImageMakeConfig.providers.aliyun_bailian,
|
||||
...nextImageMakeConfig.config.providers.aliyun_bailian,
|
||||
},
|
||||
comfyui: {
|
||||
...defaultImageMakeConfig.providers.comfyui,
|
||||
...nextImageMakeConfig.config.providers.comfyui,
|
||||
},
|
||||
},
|
||||
});
|
||||
setImageMakeMeta(nextImageMakeConfig);
|
||||
setImageMakeRuntime(nextImageMakeRuntime?.ok
|
||||
? `默认 Provider:${nextImageMakeRuntime.defaultProvider} · 指纹 ${nextImageMakeRuntime.fingerprint?.slice(0, 12) ?? '—'}`
|
||||
: nextImageMakeRuntime?.message ?? '运行时未就绪');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载资产能力配置失败');
|
||||
} finally {
|
||||
@@ -61,6 +134,16 @@ export function AssetGatewayPage() {
|
||||
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
|
||||
};
|
||||
|
||||
const updatePurpose = (pluginId: string, purposeId: string, enabled: boolean) => {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[pluginId]: {
|
||||
...current[pluginId],
|
||||
purposes: { ...(current[pluginId]?.purposes ?? {}), [purposeId]: enabled },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const saveGlobal = async (enabled: boolean) => {
|
||||
setBusy('global'); setMessage(null); setError(null);
|
||||
try {
|
||||
@@ -79,8 +162,7 @@ export function AssetGatewayPage() {
|
||||
const result = await updateAssetPluginConfig(plugin.pluginId, {
|
||||
enabled: draft.enabled,
|
||||
provider: draft.provider || null,
|
||||
llmProviderKeyId: draft.llmProviderKeyId || null,
|
||||
llmModel: draft.llmModel || null,
|
||||
purposes: draft.purposes,
|
||||
});
|
||||
setConfig(result.config);
|
||||
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
|
||||
@@ -89,32 +171,532 @@ export function AssetGatewayPage() {
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const saveImageMakeConfig = async () => {
|
||||
setBusy('image-make-config'); setMessage(null); setError(null);
|
||||
try {
|
||||
const payload: Parameters<typeof updateImageMakeAdminConfig>[0] = {
|
||||
defaultProvider: imageMakeConfig.defaultProvider,
|
||||
jobDefaultTimeoutSeconds: imageMakeConfig.jobDefaultTimeoutSeconds,
|
||||
providers: {
|
||||
mock: { enabled: imageMakeConfig.providers.mock.enabled },
|
||||
aliyun_bailian: {
|
||||
enabled: imageMakeConfig.providers.aliyun_bailian.enabled,
|
||||
model: imageMakeConfig.providers.aliyun_bailian.model,
|
||||
apiBase: imageMakeConfig.providers.aliyun_bailian.apiBase,
|
||||
},
|
||||
comfyui: {
|
||||
enabled: imageMakeConfig.providers.comfyui.enabled,
|
||||
apiBase: imageMakeConfig.providers.comfyui.apiBase,
|
||||
checkpoint: imageMakeConfig.providers.comfyui.checkpoint,
|
||||
workflowPath: imageMakeConfig.providers.comfyui.workflowPath,
|
||||
outputNodeId: imageMakeConfig.providers.comfyui.outputNodeId,
|
||||
timeoutSeconds: imageMakeConfig.providers.comfyui.timeoutSeconds,
|
||||
steps: imageMakeConfig.providers.comfyui.steps,
|
||||
cfg: imageMakeConfig.providers.comfyui.cfg,
|
||||
sampler: imageMakeConfig.providers.comfyui.sampler,
|
||||
scheduler: imageMakeConfig.providers.comfyui.scheduler,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await updateImageMakeAdminConfig(payload);
|
||||
if (result.ok === false) throw new Error(result.message ?? '保存 image_make 配置失败');
|
||||
setImageMakeConfig({
|
||||
...defaultImageMakeConfig,
|
||||
...result.config,
|
||||
providers: {
|
||||
...defaultImageMakeConfig.providers,
|
||||
...result.config.providers,
|
||||
mock: { ...defaultImageMakeConfig.providers.mock, ...result.config.providers.mock },
|
||||
aliyun_bailian: {
|
||||
...defaultImageMakeConfig.providers.aliyun_bailian,
|
||||
...result.config.providers.aliyun_bailian,
|
||||
},
|
||||
comfyui: {
|
||||
...defaultImageMakeConfig.providers.comfyui,
|
||||
...result.config.providers.comfyui,
|
||||
},
|
||||
},
|
||||
});
|
||||
setImageMakeMeta(result);
|
||||
const nextVisionSettings = await getLlmVisionSettings().catch(() => null);
|
||||
if (nextVisionSettings) setVisionSettings(nextVisionSettings);
|
||||
const runtime = await getImageMakeRuntimeStatus().catch(() => null);
|
||||
setImageMakeRuntime(runtime?.ok
|
||||
? `默认 Provider:${runtime.defaultProvider} · 指纹 ${runtime.fingerprint?.slice(0, 12) ?? '—'}`
|
||||
: runtime?.message ?? '运行时未就绪');
|
||||
setMessage('image_make 生图模型配置已保存;服务会在数秒内自动拉取新配置。');
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '保存 image_make 配置失败'); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="admin-page"><p className="muted">加载资产能力配置…</p></div>;
|
||||
if (!config) return <div className="admin-page"><p className="banner banner-error">{error ?? '无法加载配置'}</p></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>资产能力</h2>
|
||||
<p className="muted">可插拔图片、素材与处理能力。默认关闭,不会影响现有聊天或页面生成流程。</p>
|
||||
<h2>图片生成</h2>
|
||||
<p className="muted">分别控制正文图片、页面头图、卡片封面和信息流缩略图;未勾选的 AI 图片项不会执行。</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>
|
||||
<h3>图片与素材能力总开关</h3>
|
||||
<p>关闭时不调用图片模型;页面仍保留现有无图样式和基础缩略图流程。</p>
|
||||
</div>
|
||||
<label className="asset-toggle">
|
||||
<input type="checkbox" checked={config.enabled} disabled={busy === 'global'} onChange={(event) => void saveGlobal(event.target.checked)} />
|
||||
<span>{config.enabled ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
</section>
|
||||
{imageGeneratePlugin && imageGenerateDraft ? (
|
||||
<>
|
||||
<div className="model-center-section-title">
|
||||
<h3>图片生成范围</h3>
|
||||
<p>控制 Memind 是否调用 image_make,以及各输出位置是否启用 AI 图片。</p>
|
||||
</div>
|
||||
<div className="asset-plugin-grid asset-plugin-grid--balanced">
|
||||
<section className="asset-plugin-card asset-plugin-card--violet">
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">✦</div>
|
||||
<div>
|
||||
<h3>{imageGeneratePlugin.label}</h3>
|
||||
<p>勾选才会在对应位置使用 AI 图片;卡片和缩略图默认复用页面头图。</p>
|
||||
</div>
|
||||
<label className="asset-mini-toggle" title="启用图片生成">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imageGenerateDraft.enabled}
|
||||
onChange={(event) => updateDraft(imageGeneratePlugin.pluginId, { enabled: event.target.checked })}
|
||||
/>
|
||||
<span>{imageGenerateDraft.enabled ? '已开启' : '已关闭'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
运行 Provider
|
||||
<select
|
||||
value={imageGenerateDraft.provider}
|
||||
disabled={!imageGenerateDraft.enabled}
|
||||
onChange={(event) => {
|
||||
const provider = event.target.value;
|
||||
updateDraft(imageGeneratePlugin.pluginId, {
|
||||
provider,
|
||||
...(provider === 'image_make' ? { llmProviderKeyId: '', llmModel: '' } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">选择 Provider</option>
|
||||
{imageGeneratePlugin.providers.map((provider) => (
|
||||
<option key={provider} value={provider}>{provider}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset
|
||||
className="asset-purpose-fieldset"
|
||||
disabled={!imageGenerateDraft.enabled || imageGenerateDraft.provider !== 'image_make'}
|
||||
>
|
||||
<legend>按输出位置独立控制</legend>
|
||||
<div className="asset-purpose-grid">
|
||||
{imageGeneratePlugin.purposeCatalog?.map((purpose) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
inline_image: '正文确有需要时单独生成',
|
||||
hero: '页面主视觉,最多生成一次',
|
||||
card_cover: '从页面头图派生',
|
||||
feed_cover: '从页面头图派生',
|
||||
};
|
||||
return (
|
||||
<label key={purpose.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(imageGenerateDraft.purposes[purpose.id])}
|
||||
onChange={(event) => updatePurpose(
|
||||
imageGeneratePlugin.pluginId,
|
||||
purpose.id,
|
||||
event.target.checked,
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{purpose.label}
|
||||
<small>{descriptions[purpose.id] ?? purpose.presetId}</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${imageGenerateDraft.enabled ? 'is-on' : ''}`}>
|
||||
{imageGenerateDraft.enabled ? '待保存为启用' : '默认安全关闭'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy === imageGeneratePlugin.pluginId}
|
||||
onClick={() => void savePlugin(imageGeneratePlugin)}
|
||||
>
|
||||
{busy === imageGeneratePlugin.pluginId ? '保存中…' : '保存图片生成配置'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="model-center-section-title">
|
||||
<h3>生图模型与 Provider</h3>
|
||||
<p>image_make 生图后端参数;DashScope Key 与 Qwen VL 审图共用,在「统一模型中心 → 图片任务模型」配置。</p>
|
||||
</div>
|
||||
<div className="asset-plugin-grid">
|
||||
<section className={`asset-plugin-card asset-plugin-card--blue${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon asset-plugin-icon--blue" aria-hidden="true">⟡</div>
|
||||
<div>
|
||||
<h3>运行时调度</h3>
|
||||
<p>默认 Provider 与任务超时;image_make 按此处配置选择生图后端。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
{!imageMakeControlsEnabled ? (
|
||||
<div className="image-make-runtime-note">
|
||||
请先在左侧卡片启用图片生成,并将运行 Provider 设为 image_make。
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
默认生图 Provider
|
||||
<select
|
||||
value={imageMakeConfig.defaultProvider}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
defaultProvider: event.target.value as ImageMakeAdminConfig['defaultProvider'],
|
||||
}))}
|
||||
>
|
||||
<option value="aliyun_bailian">百炼 Qwen-Image</option>
|
||||
<option value="comfyui">本地 ComfyUI</option>
|
||||
<option value="mock">Mock(仅开发)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
任务超时(秒)
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={1800}
|
||||
value={imageMakeConfig.jobDefaultTimeoutSeconds}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
jobDefaultTimeoutSeconds: Number(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-make-runtime-note">
|
||||
{imageMakeRuntime || '运行时状态未知'}
|
||||
{' · '}
|
||||
配置来源:{imageMakeMeta.source === 'admin-db' ? '数据库' : '默认值'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${imageMakeControlsEnabled ? 'is-on' : ''}`}>
|
||||
{imageMakeConfig.defaultProvider}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
|
||||
onClick={() => void saveImageMakeConfig()}
|
||||
>
|
||||
{busy === 'image-make-config' ? '保存中…' : '保存生图模型配置'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`asset-plugin-card asset-plugin-card--amber${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon asset-plugin-icon--amber" aria-hidden="true">云</div>
|
||||
<div>
|
||||
<h3>百炼 Qwen-Image</h3>
|
||||
<p>阿里云 DashScope 文生图;与 Qwen VL 审图共用同一 DashScope Key,模型名不同。</p>
|
||||
</div>
|
||||
<label className="asset-mini-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imageMakeConfig.providers.aliyun_bailian.enabled}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
aliyun_bailian: { ...current.providers.aliyun_bailian, enabled: event.target.checked },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<span>{imageMakeConfig.providers.aliyun_bailian.enabled ? '已启用' : '未启用'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
模型
|
||||
<select
|
||||
value={imageMakeConfig.providers.aliyun_bailian.model ?? 'qwen-image-plus'}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
aliyun_bailian: { ...current.providers.aliyun_bailian, model: event.target.value },
|
||||
},
|
||||
}))}
|
||||
>
|
||||
<option value="qwen-image-plus">qwen-image-plus</option>
|
||||
<option value="qwen-image">qwen-image</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
API Base
|
||||
<input
|
||||
value={imageMakeConfig.providers.aliyun_bailian.apiBase ?? ''}
|
||||
placeholder="https://dashscope.aliyuncs.com/api/v1"
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
aliyun_bailian: { ...current.providers.aliyun_bailian, apiBase: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-make-runtime-note">
|
||||
DashScope API Key 与 Qwen VL 审图共用,请在
|
||||
{' '}
|
||||
<Link to="/providers">统一模型中心</Link>
|
||||
{' '}
|
||||
的「图片任务模型」配置 Qwen Provider。
|
||||
{imageMakeConfig.providers.aliyun_bailian.apiKeyConfigured ? (
|
||||
<>
|
||||
{' '}
|
||||
当前已绑定:
|
||||
{imageMakeConfig.providers.aliyun_bailian.dashScopeKeyName
|
||||
?? visionSettings?.keyName
|
||||
?? 'DashScope'}
|
||||
{' '}
|
||||
(
|
||||
{imageMakeConfig.providers.aliyun_bailian.apiKeyMasked
|
||||
?? visionSettings?.apiKeyMasked
|
||||
?? '已配置'}
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<> 当前未检测到 DashScope Key。</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${imageMakeConfig.providers.aliyun_bailian.enabled ? 'is-on' : ''}`}>
|
||||
{imageMakeConfig.providers.aliyun_bailian.model ?? 'qwen-image-plus'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
|
||||
onClick={() => void saveImageMakeConfig()}
|
||||
>
|
||||
{busy === 'image-make-config' ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`asset-plugin-card asset-plugin-card--green${imageMakeControlsEnabled ? '' : ' is-disabled'}`}>
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon asset-plugin-icon--green" aria-hidden="true">⌘</div>
|
||||
<div>
|
||||
<h3>本地 ComfyUI</h3>
|
||||
<p>本地 SD / Flux 工作流;适合开发调试与离线降级。</p>
|
||||
</div>
|
||||
<label className="asset-mini-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imageMakeConfig.providers.comfyui.enabled}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, enabled: event.target.checked },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<span>{imageMakeConfig.providers.comfyui.enabled ? '已启用' : '未启用'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>
|
||||
ComfyUI 地址
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.apiBase ?? ''}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, apiBase: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Checkpoint 文件名
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.checkpoint ?? ''}
|
||||
placeholder="v1-5-pruned-emaonly-fp16.safetensors"
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, checkpoint: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Workflow 路径
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.workflowPath ?? ''}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, workflowPath: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
输出节点 ID
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.outputNodeId ?? '9'}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, outputNodeId: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-generation-scope-grid">
|
||||
<label>
|
||||
Steps
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={imageMakeConfig.providers.comfyui.steps ?? 20}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, steps: Number(event.target.value) },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CFG
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={imageMakeConfig.providers.comfyui.cfg ?? 7}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, cfg: Number(event.target.value) },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Sampler
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.sampler ?? 'euler'}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, sampler: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Scheduler
|
||||
<input
|
||||
value={imageMakeConfig.providers.comfyui.scheduler ?? 'normal'}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, scheduler: event.target.value },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
超时(秒)
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={1800}
|
||||
value={imageMakeConfig.providers.comfyui.timeoutSeconds ?? 600}
|
||||
disabled={!imageMakeControlsEnabled}
|
||||
onChange={(event) => setImageMakeConfig((current) => ({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
comfyui: { ...current.providers.comfyui, timeoutSeconds: Number(event.target.value) },
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-plugin-footer">
|
||||
<span className={`asset-status ${imageMakeConfig.providers.comfyui.enabled ? 'is-on' : ''}`}>
|
||||
{imageMakeConfig.providers.comfyui.checkpoint || '未配置 checkpoint'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={busy === 'image-make-config' || !imageMakeControlsEnabled}
|
||||
onClick={() => void saveImageMakeConfig()}
|
||||
>
|
||||
{busy === 'image-make-config' ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="model-center-section-title">
|
||||
<h3>其他资产能力</h3>
|
||||
<p>素材检索、图片处理和图片理解仅配置运行 Provider;大模型统一在「统一模型中心」管理。</p>
|
||||
</div>
|
||||
<div className="asset-plugin-grid asset-plugin-grid--balanced">
|
||||
{config.plugins.map((plugin) => {
|
||||
{orderedPlugins.filter((plugin) => plugin.pluginId !== 'asset-generate').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">
|
||||
@@ -126,20 +708,44 @@ export function AssetGatewayPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="asset-plugin-fields">
|
||||
<label>运行 Provider<select value={draft.provider} onChange={(event) => updateDraft(plugin.pluginId, { provider: event.target.value })} disabled={!draft.enabled}>
|
||||
<label>运行 Provider<select value={draft.provider} onChange={(event) => {
|
||||
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>}
|
||||
{plugin.supportsLlm ? (
|
||||
<div className="asset-image-make-note">
|
||||
大模型不在此单独配置;请在
|
||||
{' '}
|
||||
<Link to="/providers">统一模型中心</Link>
|
||||
{' '}
|
||||
设置默认模型与图片任务模型(Qwen VL)。
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-no-llm">确定性处理 · 不调用 LLM</div>
|
||||
)}
|
||||
{plugin.pluginId === 'asset-generate' && plugin.purposeCatalog?.length ? (
|
||||
<fieldset className="asset-purpose-fieldset" disabled={!draft.enabled || draft.provider !== 'image_make'}>
|
||||
<legend>AI 图片输出项</legend>
|
||||
<div className="asset-purpose-grid">
|
||||
{plugin.purposeCatalog.map((purpose) => (
|
||||
<label key={purpose.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(draft.purposes[purpose.id])}
|
||||
onChange={(event) => updatePurpose(plugin.pluginId, purpose.id, event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{purpose.label}
|
||||
<small>{purpose.strategy === 'derive' ? '从页面头图派生 · 不重复生图' : purpose.presetId}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p>页面头图最多生成一次;卡片封面和信息流缩略图复用同一张主图裁剪。关闭某项后,该位置不使用 AI 图片;基础页面缩略图仍保留。</p>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
|
||||
</section>;
|
||||
|
||||
@@ -9,7 +9,7 @@ const QUICK_LINKS = [
|
||||
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
|
||||
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
||||
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
|
||||
{ to: '/asset-gateway', label: '图片生成', desc: '配置正文图、页面头图、卡片封面与信息流缩略图' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
||||
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
|
||||
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
|
||||
|
||||
@@ -122,12 +122,6 @@ const CAPABILITIES: Array<{
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用组合召回', type: 'boolean' },
|
||||
{ key: 'episodicEnabled', label: '事件记忆', type: 'boolean' },
|
||||
{ key: 'episodicMode', label: '历史会话召回模式', type: 'select', options: [
|
||||
{ value: 'off', label: 'Off · 关闭' },
|
||||
{ value: 'canary', label: 'Canary · 仅指定用户' },
|
||||
{ value: 'active', label: 'Active · 全量' },
|
||||
] },
|
||||
{ key: 'episodicCanaryUserIds', label: '历史会话 Canary 用户 ID(逗号分隔)', type: 'text' },
|
||||
{ key: 'semanticEnabled', label: '语义记忆', type: 'boolean' },
|
||||
{ key: 'preferenceEnabled', label: '用户偏好', type: 'boolean' },
|
||||
{ key: 'goalEnabled', label: '活跃目标', type: 'boolean' },
|
||||
@@ -352,7 +346,7 @@ function defaultConfig(): MemoryV2AdminConfig {
|
||||
candidateMemory: { enabled: false, mode: 'active', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false },
|
||||
runtimeControl: { agentResolveEnabled: false, agentInjectionMode: 'off', agentCanaryUserIds: '', agentResolveLimit: '3', agentResolveTimeoutMs: '1200', promotionEnabled: false, compactionV2Enabled: false, reflectionEnabled: false, lifecycleWorkerEnabled: false, lifecycleRolloutMode: 'off', lifecycleRolloutUserIds: '' },
|
||||
policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' },
|
||||
retriever: { enabled: false, episodicEnabled: true, episodicMode: 'off', episodicCanaryUserIds: '', semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
|
||||
retriever: { enabled: false, episodicEnabled: true, semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
|
||||
lifecycle: { enabled: false, dedupeEnabled: true, conflictReview: true, decayEnabled: false, forgettingEnabled: true, compactIntervalHours: '24' },
|
||||
persona: { enabled: false, provider: 'none', shadowMode: true, maxTokens: '400', cacheTtlSeconds: '300' },
|
||||
graph: { enabled: false, provider: 'postgres', maxDepth: '2', relationLimit: '20' },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
setLlmVisionKey,
|
||||
stopLlmExecutor,
|
||||
syncLlmProviderToGoosed,
|
||||
testLlmProviderDraft,
|
||||
testLlmProviderKey,
|
||||
updateLlmProviderKey,
|
||||
} from '../../api/client';
|
||||
@@ -34,6 +35,11 @@ import type {
|
||||
} from '../../types';
|
||||
|
||||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
const KIMI_DIRECT_PRESET = {
|
||||
apiUrl: 'https://api.moonshot.cn/v1',
|
||||
modelsText: 'kimi-k3\nkimi-k2.6',
|
||||
defaultModel: 'kimi-k3',
|
||||
};
|
||||
|
||||
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
|
||||
goose: { icon: 'G', accent: 'blue' },
|
||||
@@ -62,6 +68,14 @@ function parseModelsText(text: string) {
|
||||
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatProviderConnectionError(message?: string) {
|
||||
const detail = String(message ?? '未知错误').trim();
|
||||
if (/not found the model|permission denied|resource_not_found_error/i.test(detail)) {
|
||||
return `模型不存在或当前 API Key 没有该模型权限,请检查模型 ID。上游响应:${detail}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function statusText(status?: LlmExecutorLaunchState | null) {
|
||||
if (!status) return '未启动';
|
||||
if (status.running) return `运行中${status.pid ? ` · pid ${status.pid}` : ''}`;
|
||||
@@ -170,6 +184,24 @@ function ProviderDialog({
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<div className="form-span-all provider-preset-row">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy}
|
||||
onClick={() => onChange({
|
||||
...form,
|
||||
name: form.name || 'Kimi',
|
||||
apiUrl: KIMI_DIRECT_PRESET.apiUrl,
|
||||
modelsText: KIMI_DIRECT_PRESET.modelsText,
|
||||
defaultModel: KIMI_DIRECT_PRESET.defaultModel,
|
||||
relayProvider: '',
|
||||
})}
|
||||
>
|
||||
使用 Kimi 直连预设
|
||||
</button>
|
||||
<span className="muted">自动填写官方 API 地址和模型 ID;直连 Moonshot 时 Relay Provider 必须留空。</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>API 地址</span>
|
||||
<input placeholder="https://api.example.com/v1/chat/completions" value={form.apiUrl} onChange={(event) => onChange({ ...form, apiUrl: event.target.value })} />
|
||||
@@ -177,6 +209,7 @@ function ProviderDialog({
|
||||
<label>
|
||||
<span>Relay Provider</span>
|
||||
<input placeholder="可选,例如 ollama" value={form.relayProvider} onChange={(event) => onChange({ ...form, relayProvider: event.target.value })} />
|
||||
<small className="muted">仅供中转/聚合接口路由使用;OpenAI、Moonshot、DeepSeek 等官方直连接口请留空。</small>
|
||||
</label>
|
||||
<label className="form-span-all">
|
||||
<span>模型列表</span>
|
||||
@@ -415,8 +448,8 @@ function VisionSection({
|
||||
<div className="asset-plugin-card-head">
|
||||
<div className="asset-plugin-icon" aria-hidden="true">◉</div>
|
||||
<div>
|
||||
<h3>图片任务模型</h3>
|
||||
<p>发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
|
||||
<h3>图片任务模型(DashScope)</h3>
|
||||
<p>Qwen VL 审图、聊天识图与 Qwen-Image 生图共用同一 DashScope API Key;此处只需选择视觉模型。</p>
|
||||
</div>
|
||||
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
|
||||
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
|
||||
@@ -544,6 +577,21 @@ export function ProvidersPage() {
|
||||
setError(null);
|
||||
try {
|
||||
if (providerDialog.mode === 'create') {
|
||||
if (isCustom) {
|
||||
const connection = await testLlmProviderDraft({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
testModel: form.defaultModel || customModels[0],
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
if (!connection.ok) {
|
||||
throw new Error(`保存前联通校验失败:${formatProviderConnectionError(connection.message)}`);
|
||||
}
|
||||
}
|
||||
await createLlmProviderKey(
|
||||
isCustom
|
||||
? {
|
||||
@@ -581,7 +629,13 @@ export function ProvidersPage() {
|
||||
},
|
||||
);
|
||||
}
|
||||
setMessage(providerDialog.mode === 'create' ? 'Provider 已添加' : 'Provider 已保存');
|
||||
setMessage(
|
||||
providerDialog.mode === 'create' && isCustom
|
||||
? 'Provider 已添加,保存前联通校验通过'
|
||||
: providerDialog.mode === 'create'
|
||||
? 'Provider 已添加'
|
||||
: 'Provider 已保存',
|
||||
);
|
||||
setProviderDialog(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -640,7 +694,7 @@ export function ProvidersPage() {
|
||||
try {
|
||||
const result = await testLlmProviderKey(row.id, row.defaultModel);
|
||||
if (result.ok) setMessage(`「${row.name}」联通成功`);
|
||||
else setError(`「${row.name}」联通失败:${result.message ?? '未知错误'}`);
|
||||
else setError(`「${row.name}」联通失败:${formatProviderConnectionError(result.message)}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '联通测试失败');
|
||||
} finally {
|
||||
@@ -894,7 +948,7 @@ export function ProvidersPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 className="model-center-section-title">图片任务模型</h3>
|
||||
<h3 className="model-center-section-title">图片任务模型(DashScope)</h3>
|
||||
<VisionSection
|
||||
keys={activeKeys}
|
||||
settings={visionSettings}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AssetGatewayConfig,
|
||||
ImageMakeAdminConfig,
|
||||
ImageMakeAdminConfigResponse,
|
||||
ImageMakeRuntimeResponse,
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
@@ -307,6 +310,7 @@ export async function updateAssetPluginConfig(
|
||||
provider?: string | null;
|
||||
llmProviderKeyId?: string | null;
|
||||
llmModel?: string | null;
|
||||
purposes?: Record<string, boolean>;
|
||||
},
|
||||
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
|
||||
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
|
||||
@@ -315,6 +319,27 @@ export async function updateAssetPluginConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getImageMakeAdminConfig(): Promise<ImageMakeAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/image-make/config');
|
||||
}
|
||||
|
||||
export async function updateImageMakeAdminConfig(
|
||||
payload: Partial<ImageMakeAdminConfig> & {
|
||||
providers?: Partial<ImageMakeAdminConfig['providers']> & {
|
||||
aliyun_bailian?: Partial<ImageMakeAdminConfig['providers']['aliyun_bailian']> & { apiKey?: string };
|
||||
};
|
||||
},
|
||||
): Promise<ImageMakeAdminConfigResponse & { ok?: boolean; message?: string }> {
|
||||
return portalFetch('/admin-api/image-make/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getImageMakeRuntimeStatus(): Promise<ImageMakeRuntimeResponse> {
|
||||
return portalFetch('/admin-api/image-make/runtime');
|
||||
}
|
||||
|
||||
export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
|
||||
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config');
|
||||
return result.config;
|
||||
|
||||
+192
@@ -620,6 +620,13 @@ body,
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.provider-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
@@ -1005,6 +1012,96 @@ body,
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.image-generation-settings {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
padding: 20px;
|
||||
border: 1px solid color-mix(in srgb, #9374ff 62%, var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, rgba(106, 76, 196, .16), var(--color-bg-surface) 48%);
|
||||
box-shadow: 0 12px 34px rgba(0, 0, 0, .14);
|
||||
}
|
||||
|
||||
.image-generation-settings-head,
|
||||
.image-generation-settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.image-generation-settings-head h3 {
|
||||
margin: 5px 0 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.image-generation-settings-head p,
|
||||
.image-generation-settings-footer span {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.image-generation-provider {
|
||||
display: grid;
|
||||
max-width: 360px;
|
||||
gap: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-generation-scope-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.image-generation-scope-fieldset legend {
|
||||
padding: 0 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-generation-scope-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.image-generation-scope-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, .025);
|
||||
}
|
||||
|
||||
.image-generation-scope-item input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.image-generation-scope-item span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.image-generation-scope-item strong {
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-generation-scope-item small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.asset-plugin-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1026,6 +1123,32 @@ body,
|
||||
.asset-plugin-card--amber { border-top: 3px solid #e8af55; }
|
||||
.asset-plugin-card--green { border-top: 3px solid #55c79b; }
|
||||
|
||||
.asset-plugin-card.is-disabled {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.asset-plugin-card.is-disabled .asset-plugin-fields,
|
||||
.asset-plugin-card.is-disabled .asset-plugin-footer button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.asset-plugin-card.is-disabled .asset-plugin-card-head .asset-mini-toggle {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.asset-plugin-icon--blue { background: rgba(84, 168, 255, .17); color: #9fd0ff; }
|
||||
.asset-plugin-icon--amber { background: rgba(232, 175, 85, .17); color: #f0cc84; }
|
||||
.asset-plugin-icon--green { background: rgba(85, 199, 155, .17); color: #8fe0bc; }
|
||||
|
||||
.image-make-runtime-note {
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(84, 168, 255, .1);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.asset-plugin-card-head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1080,6 +1203,65 @@ body,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-image-make-note,
|
||||
.asset-no-llm {
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(132, 110, 255, .1);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.asset-purpose-fieldset {
|
||||
min-width: 0;
|
||||
margin: 2px 0 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.asset-purpose-fieldset legend {
|
||||
padding: 0 5px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.asset-purpose-fieldset > p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.asset-purpose-grid small {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.asset-purpose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.asset-purpose-grid label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 7px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: rgba(255, 255, 255, .035);
|
||||
}
|
||||
|
||||
.asset-purpose-grid small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 9px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.asset-plugin-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1404,6 +1586,16 @@ body,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-generation-settings-head,
|
||||
.image-generation-settings-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image-generation-scope-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.asset-plugin-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -120,6 +120,7 @@ export type LlmVisionSettings = {
|
||||
providerLabel: string | null;
|
||||
visionModel: string | null;
|
||||
availableModels: string[];
|
||||
apiKeyMasked?: string | null;
|
||||
};
|
||||
|
||||
export type LlmConnectionTestResult = {
|
||||
@@ -208,6 +209,14 @@ export type AssetPluginConfig = {
|
||||
llmProviderId: string | null;
|
||||
llmModel: string | null;
|
||||
updatedAt: number | null;
|
||||
purposes?: Record<string, boolean>;
|
||||
purposeCatalog?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
presetId: string;
|
||||
strategy?: 'generate' | 'derive';
|
||||
sourcePurpose?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AssetGatewayConfig = {
|
||||
@@ -216,6 +225,52 @@ export type AssetGatewayConfig = {
|
||||
plugins: AssetPluginConfig[];
|
||||
};
|
||||
|
||||
export type ImageMakeProviderConfig = {
|
||||
enabled: boolean;
|
||||
model?: string;
|
||||
apiBase?: string;
|
||||
apiKeyConfigured?: boolean;
|
||||
apiKeyMasked?: string;
|
||||
dashScopeSource?: 'vision' | 'global' | null;
|
||||
dashScopeKeyName?: string | null;
|
||||
checkpoint?: string;
|
||||
workflowPath?: string;
|
||||
outputNodeId?: string;
|
||||
timeoutSeconds?: number;
|
||||
steps?: number;
|
||||
cfg?: number;
|
||||
sampler?: string;
|
||||
scheduler?: string;
|
||||
};
|
||||
|
||||
export type ImageMakeAdminConfig = {
|
||||
defaultProvider: 'mock' | 'aliyun_bailian' | 'comfyui';
|
||||
jobDefaultTimeoutSeconds: number;
|
||||
providers: {
|
||||
mock: ImageMakeProviderConfig;
|
||||
aliyun_bailian: ImageMakeProviderConfig;
|
||||
comfyui: ImageMakeProviderConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageMakeAdminConfigResponse = {
|
||||
config: ImageMakeAdminConfig;
|
||||
source?: string;
|
||||
updatedAt?: number | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type ImageMakeRuntimeResponse = {
|
||||
ok: boolean;
|
||||
fingerprint?: string;
|
||||
source?: string;
|
||||
updatedAt?: number | null;
|
||||
defaultProvider?: string;
|
||||
jobDefaultTimeoutSeconds?: number;
|
||||
providers?: ImageMakeAdminConfig['providers'];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
|
||||
|
||||
export type AdminServiceRestartResult = {
|
||||
|
||||
Reference in New Issue
Block a user