feat: 将项目从nano重命名为beaver并更新相关配置
- 将所有环境变量前缀从NANO_改为BEAVER_ - 更新README.md文档内容,包括项目介绍、组件说明和快速开始指南 - 修改.gitignore文件,添加auth-portal运行时路径排除规则 - 更新app-instance镜像标签从nano/app-instance改为beaver/app-instance - 增强技能安全检查器,支持工具前缀白名单功能 - 添加技能草稿重新检查安全性API端点 - 扩展证据选择器,收集工具调用名称用于技能学习 - 改进技能合成器,基于实际调用的工具生成工具提示 - 优化路由超时处理机制,增加重试逻辑 - 更新后端架构文档,添加可视化入口和基础概念说明 - 实现在WebSocket消息中传递工具迭代次数信息
28
.env.example
@ -1,18 +1,18 @@
|
|||||||
# Shared values used by the root deployment flow in README.md
|
# Shared values used by the root deployment flow in README.md
|
||||||
|
|
||||||
PROJECT_ROOT=/home/ivan/xuan/nano_project
|
PROJECT_ROOT=/home/ivan/xuan/beaver_project
|
||||||
NANO_NET=nano-instance-edge
|
BEAVER_NET=beaver-instance-edge
|
||||||
|
|
||||||
NANO_DEPLOY_TOKEN=change-me
|
BEAVER_DEPLOY_TOKEN=change-me
|
||||||
NANO_AUTHZ_INTERNAL_TOKEN=change-me
|
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
|
||||||
|
|
||||||
NANO_SERVER_IP=203.0.113.10
|
BEAVER_SERVER_IP=203.0.113.10
|
||||||
NANO_BASE_DOMAIN=203.0.113.10.nip.io
|
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io
|
||||||
|
|
||||||
NANO_PROVIDER=openai
|
BEAVER_PROVIDER=openai
|
||||||
NANO_MODEL=openai/gpt-5
|
BEAVER_MODEL=openai/gpt-5
|
||||||
NANO_API_KEY=sk-xxxxxxxx
|
BEAVER_API_KEY=sk-xxxxxxxx
|
||||||
NANO_API_BASE=
|
BEAVER_API_BASE=
|
||||||
|
|
||||||
# Per-instance Beaver backend config. In Docker app-instance this should point
|
# Per-instance Beaver backend config. In Docker app-instance this should point
|
||||||
# to the mounted single-user sandbox config, not to frontend env.
|
# to the mounted single-user sandbox config, not to frontend env.
|
||||||
@ -21,9 +21,9 @@ BEAVER_CONFIG_PATH=/root/.beaver/config.json
|
|||||||
BEAVER_WORKSPACE=/root/.beaver/workspace
|
BEAVER_WORKSPACE=/root/.beaver/workspace
|
||||||
|
|
||||||
# Must be reachable from app-instance containers.
|
# Must be reachable from app-instance containers.
|
||||||
NANO_AUTHZ_URL=http://nano-authz-service:19090
|
BEAVER_AUTHZ_URL=http://beaver-authz-service:19090
|
||||||
NANO_OUTLOOK_MCP_URL=
|
BEAVER_OUTLOOK_MCP_URL=
|
||||||
NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
|
||||||
# Must be reachable from auth-portal and authz-service containers.
|
# Must be reachable from auth-portal and authz-service containers.
|
||||||
NANO_DEPLOY_URL=http://nano-deploy-control:8090
|
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
||||||
|
|||||||
2
.gitignore
vendored
@ -5,6 +5,8 @@ app-instance/runtime/instances/
|
|||||||
app-instance/runtime/registry/
|
app-instance/runtime/registry/
|
||||||
router-proxy/runtime/conf.d/
|
router-proxy/runtime/conf.d/
|
||||||
runtime/
|
runtime/
|
||||||
|
!auth-portal/src/app/api/runtime/
|
||||||
|
!auth-portal/src/app/api/runtime/**
|
||||||
sessions/
|
sessions/
|
||||||
**/sessions/state.db
|
**/sessions/state.db
|
||||||
**/runtime/**/*.lock
|
**/runtime/**/*.lock
|
||||||
|
|||||||
460
README.md
@ -1,22 +1,30 @@
|
|||||||
https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65
|
# Beaver Project
|
||||||
# nano_project
|
|
||||||
|
|
||||||
单机部署版运行结构:
|
`Beaver Project` 是一套单机 Docker 部署的多实例运行环境:
|
||||||
|
|
||||||
- `auth-portal`
|
- 用户先进入独立的 `auth-portal` 完成注册或登录。
|
||||||
- 用户入口页,提供登录、注册、跳转。
|
- 注册会触发 `authz-service` 调用 `deploy-control`。
|
||||||
- `authz-service`
|
- `deploy-control` 在同一台机器上创建一个独立的 `app-instance` 容器。
|
||||||
- AuthZ 服务。
|
- `router-proxy` 按实例域名把流量转发到对应容器。
|
||||||
- 现在负责注册编排:`auth-portal -> authz-service -> deploy-control -> app-instance -> authz-service`
|
|
||||||
- `deploy-control`
|
当前推荐的最小部署方式是一台 Linux / WSL2 Ubuntu 机器加 Docker。生产域名和 HTTPS 可以放在项目外层的 Nginx、Caddy、Traefik 或云负载均衡上。
|
||||||
- 部署机控制面。
|
|
||||||
- 负责创建实例、解析实例、刷新反向代理。
|
## 组件
|
||||||
- `router-proxy`
|
|
||||||
- 统一入口代理。
|
| 目录 | 职责 | 默认端口 |
|
||||||
- 按 Host 把 `<slug>.<base_domain>` 转发到对应实例容器。
|
| --- | --- | --- |
|
||||||
- `app-instance`
|
| `auth-portal/` | 用户登录、注册、模型配置引导入口 | `3081` |
|
||||||
- 真正的单用户实例。
|
| `authz-service/` | AuthZ 服务,负责账号和 backend 身份编排 | `19090` |
|
||||||
- 一个容器里同时包含前端、后端和 Nginx。
|
| `deploy-control/` | 部署控制面,调用 Docker 创建和管理实例 | `8090` |
|
||||||
|
| `router-proxy/` | 统一实例入口代理,按 Host 分发到实例容器 | `8088` |
|
||||||
|
| `app-instance/` | 单用户运行实例,容器内包含前端、后端和 Nginx | 容器内 `8080` |
|
||||||
|
|
||||||
|
公网环境通常只暴露:
|
||||||
|
|
||||||
|
- `auth-portal`: `3081`,或外层代理后的 `https://portal.example.com`
|
||||||
|
- `router-proxy`: `8088`,或外层代理后的 `https://<slug>.apps.example.com`
|
||||||
|
|
||||||
|
不要直接把 `deploy-control:8090` 和 `authz-service:19090` 暴露到公网。
|
||||||
|
|
||||||
## 请求链路
|
## 请求链路
|
||||||
|
|
||||||
@ -27,9 +35,11 @@ Browser
|
|||||||
-> auth-portal
|
-> auth-portal
|
||||||
-> authz-service POST /portal/register
|
-> authz-service POST /portal/register
|
||||||
-> deploy-control POST /api/instances/register
|
-> deploy-control POST /api/instances/register
|
||||||
-> create-instance.sh
|
-> app-instance/create-instance.sh
|
||||||
-> app-instance POST /api/auth/register
|
-> app-instance POST /api/auth/register
|
||||||
-> authz-service /oauth/register or /backends/register
|
-> authz-service /oauth/register or /backends/register
|
||||||
|
-> auth-portal provider onboarding
|
||||||
|
-> deploy-control POST /api/instances/configure-provider
|
||||||
```
|
```
|
||||||
|
|
||||||
登录:
|
登录:
|
||||||
@ -39,378 +49,134 @@ Browser
|
|||||||
-> auth-portal
|
-> auth-portal
|
||||||
-> deploy-control POST /api/instances/resolve
|
-> deploy-control POST /api/instances/resolve
|
||||||
-> app-instance POST /api/auth/login
|
-> app-instance POST /api/auth/login
|
||||||
|
-> app-instance frontend URL
|
||||||
```
|
```
|
||||||
|
|
||||||
## 这份部署指南的前提
|
## 快速开始
|
||||||
|
|
||||||
这份 README 只覆盖一套基准方案:
|
本机完整流程见:
|
||||||
|
|
||||||
- 一台 Linux 服务器
|
- [部署指南.md](./部署指南.md)
|
||||||
- 用 Docker 运行 `auth-portal`、`authz-service`、`deploy-control`、`router-proxy`
|
|
||||||
- `deploy-control` 通过 Docker socket 在同一台机器上创建 `app-instance`
|
|
||||||
- 所有容器共用一个 Docker network:`nano-instance-edge`
|
|
||||||
- 测试域名先用 `nip.io`
|
|
||||||
|
|
||||||
如果你后面要接 HTTPS、外部 LB、Kubernetes、真实 DNS,这份流程仍然适合作为最小可运行基线。
|
域名、HTTPS、公网反向代理说明见:
|
||||||
|
|
||||||
可直接参考这些模板文件:
|
- [域名配置指引.md](./域名配置指引.md)
|
||||||
|
|
||||||
- [`.env.example`](/home/ivan/xuan/nano_project/.env.example)
|
最小配置变量:
|
||||||
- [`auth-portal/src/.env.example`](/home/ivan/xuan/nano_project/auth-portal/src/.env.example)
|
|
||||||
- [`authz-service/.env.example`](/home/ivan/xuan/nano_project/authz-service/.env.example)
|
|
||||||
- [`deploy-control/.env.example`](/home/ivan/xuan/nano_project/deploy-control/.env.example)
|
|
||||||
- [`router-proxy/.env.example`](/home/ivan/xuan/nano_project/router-proxy/.env.example)
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 这些文件是模板,不会被现有脚本自动加载
|
|
||||||
- 你可以手动 `export`,或者在 `docker run` 时使用 `--env-file`
|
|
||||||
|
|
||||||
## 部署前必须先定好的值
|
|
||||||
|
|
||||||
先准备这些值:
|
|
||||||
|
|
||||||
- `PROJECT_ROOT`
|
|
||||||
- 仓库根目录。
|
|
||||||
- `NANO_NET`
|
|
||||||
- Docker network 名。
|
|
||||||
- 推荐固定成 `nano-instance-edge`。
|
|
||||||
- `NANO_DEPLOY_TOKEN`
|
|
||||||
- `auth-portal` / `authz-service` 调 `deploy-control` 时用的 Bearer token。
|
|
||||||
- `NANO_AUTHZ_INTERNAL_TOKEN`
|
|
||||||
- AuthZ 内部接口 token。
|
|
||||||
- `NANO_SERVER_IP`
|
|
||||||
- 服务器公网 IP,供 `nip.io` 测试使用。
|
|
||||||
- `NANO_BASE_DOMAIN`
|
|
||||||
- 实例基域名。
|
|
||||||
- 测试环境推荐 `<SERVER_IP>.nip.io`
|
|
||||||
- `NANO_PROVIDER`
|
|
||||||
- 默认 provider,例如 `openai`
|
|
||||||
- `NANO_MODEL`
|
|
||||||
- 默认模型,例如 `openai/gpt-5`
|
|
||||||
- `NANO_API_KEY`
|
|
||||||
- 默认分发给新实例的 provider API key
|
|
||||||
- `NANO_API_BASE`
|
|
||||||
- 可空,自定义 provider base URL
|
|
||||||
- `NANO_AUTHZ_URL`
|
|
||||||
- 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址
|
|
||||||
- `NANO_OUTLOOK_MCP_URL`
|
|
||||||
- 可空。
|
|
||||||
- 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。
|
|
||||||
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
|
||||||
- Outlook MCP 默认 server id。
|
|
||||||
- 推荐固定成 `outlook_mcp`。
|
|
||||||
- `NANO_DEPLOY_URL`
|
|
||||||
- `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址
|
|
||||||
|
|
||||||
直接导出一套最小配置:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PROJECT_ROOT=/home/ivan/xuan/nano_project
|
export PROJECT_ROOT=/home/ivan/xuan/beaver_project
|
||||||
export NANO_NET=nano-instance-edge
|
export BEAVER_NET=beaver-instance-edge
|
||||||
|
|
||||||
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||||
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
export NANO_SERVER_IP=203.0.113.10
|
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
export NANO_BASE_DOMAIN="${NANO_SERVER_IP}.nip.io"
|
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
||||||
|
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
||||||
|
|
||||||
export NANO_PROVIDER=openai
|
export BEAVER_OUTLOOK_MCP_URL=''
|
||||||
export NANO_MODEL=openai/gpt-5
|
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||||
export NANO_API_KEY='sk-xxxxxxxx'
|
|
||||||
export NANO_API_BASE=''
|
|
||||||
|
|
||||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
|
||||||
export NANO_OUTLOOK_MCP_URL=''
|
|
||||||
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
|
||||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 变量到底是什么
|
启动顺序:
|
||||||
|
|
||||||
最容易混淆的是下面这几组:
|
1. 创建运行目录。
|
||||||
|
2. 构建四个镜像。
|
||||||
|
3. 创建共享 Docker network。
|
||||||
|
4. 启动 `router-proxy`。
|
||||||
|
5. 启动 `authz-service`。
|
||||||
|
6. 启动 `deploy-control`。
|
||||||
|
7. 启动 `auth-portal`。
|
||||||
|
8. 打开 `http://127.0.0.1:3081/register` 测试注册。
|
||||||
|
|
||||||
- `DEPLOY_API_TOKEN`
|
## 关键配置关系
|
||||||
- 调用方带出去的 token。
|
|
||||||
- `auth-portal` 和 `authz-service` 会用它请求 `deploy-control`。
|
|
||||||
- `DEPLOY_CONTROL_API_TOKEN`
|
|
||||||
- `deploy-control` 服务端校验的 token。
|
|
||||||
- 它必须和 `DEPLOY_API_TOKEN` 相等。
|
|
||||||
- `AUTHZ_ISSUER`
|
|
||||||
- 当前实现里它既是 AuthZ 的 issuer,也是新实例要访问的 AuthZ base URL。
|
|
||||||
- 所以不要乱写 `127.0.0.1`,要写成实例容器能访问到的地址。
|
|
||||||
- `APP_INSTANCE_PROVIDER`
|
|
||||||
- 新实例默认 provider。
|
|
||||||
- `APP_INSTANCE_MODEL`
|
|
||||||
- 新实例默认模型。
|
|
||||||
- `APP_INSTANCE_API_KEY`
|
|
||||||
- 新实例默认 API key。
|
|
||||||
- `APP_INSTANCE_API_BASE`
|
|
||||||
- 新实例默认 API base。
|
|
||||||
- `DEFAULT_AUTHZ_BASE_URL`
|
|
||||||
- `deploy-control` 在没收到显式 `authz_base_url` 时,给新实例写入的兜底 AuthZ 地址。
|
|
||||||
|
|
||||||
当前版本里,provider 配置不是从 AuthZ setting 下发的。
|
`DEPLOY_API_TOKEN` 和 `DEPLOY_CONTROL_API_TOKEN` 必须相等:
|
||||||
它是在创建实例时由 `deploy-control` 写入 `app-instance` 的 `config.json`。
|
|
||||||
|
|
||||||
## 目录持久化
|
- `auth-portal` / `authz-service` 用 `DEPLOY_API_TOKEN` 请求 `deploy-control`。
|
||||||
|
- `deploy-control` 用 `DEPLOY_CONTROL_API_TOKEN` 校验请求。
|
||||||
|
|
||||||
至少保留这几个目录:
|
`AUTHZ_ISSUER` 在这套单机部署里要写容器网络地址:
|
||||||
|
|
||||||
- `authz-service/runtime/data`
|
|
||||||
- `app-instance/runtime`
|
|
||||||
- `router-proxy/runtime`
|
|
||||||
|
|
||||||
建议先创建:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p \
|
|
||||||
"$PROJECT_ROOT/authz-service/runtime/data" \
|
|
||||||
"$PROJECT_ROOT/app-instance/runtime/instances" \
|
|
||||||
"$PROJECT_ROOT/app-instance/runtime/registry" \
|
|
||||||
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. 构建镜像
|
|
||||||
|
|
||||||
先把四个镜像都构建好。虽然 `deploy-control` 在镜像缺失时也能触发构建,但上线前先显式构建更容易排错。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
docker build -t nano/app-instance:latest app-instance
|
|
||||||
docker build -t nano/authz-service:latest authz-service
|
|
||||||
docker build -t nano/deploy-control:latest deploy-control
|
|
||||||
docker build -t nano/auth-portal:latest auth-portal/src
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 创建共享网络
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 启动 router-proxy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
PROXY_NETWORK_NAME="$NANO_NET" \
|
|
||||||
PROXY_HTTP_PORT=8088 \
|
|
||||||
./router-proxy/start-proxy.sh --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
默认对外入口:
|
|
||||||
|
|
||||||
- `router-proxy`: `http://<slug>.<base_domain>:8088`
|
|
||||||
|
|
||||||
## 4. 启动 authz-service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker rm -f nano-authz-service >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name nano-authz-service \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network "$NANO_NET" \
|
|
||||||
-p 19090:19090 \
|
|
||||||
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
|
||||||
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
|
|
||||||
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
|
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
|
||||||
nano/authz-service:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
这里最重要的是:
|
|
||||||
|
|
||||||
- `AUTHZ_ISSUER` 现在不能只按“外部访问地址”理解
|
|
||||||
- 它必须是后续 `app-instance` 容器也能访问到的地址
|
|
||||||
- 这套单机 Docker 方案里直接用 `http://nano-authz-service:19090`
|
|
||||||
|
|
||||||
## 5. 启动 deploy-control
|
|
||||||
|
|
||||||
`deploy-control` 需要高权限:
|
|
||||||
|
|
||||||
- 读写 Docker socket
|
|
||||||
- 访问 `app-instance/`
|
|
||||||
- 访问 `router-proxy/`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker rm -f nano-deploy-control >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name nano-deploy-control \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network "$NANO_NET" \
|
|
||||||
-p 8090:8090 \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
|
||||||
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
|
||||||
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
|
||||||
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
|
||||||
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
|
||||||
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
|
||||||
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
|
||||||
-e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \
|
|
||||||
-e APP_INSTANCE_MODEL="$NANO_MODEL" \
|
|
||||||
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
|
||||||
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
|
||||||
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
|
||||||
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
|
||||||
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
|
||||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
|
||||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
|
||||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
|
||||||
-e DEPLOY_AUTO_START_PROXY="1" \
|
|
||||||
nano/deploy-control:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`。
|
|
||||||
|
|
||||||
原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为:
|
|
||||||
|
|
||||||
- 注册接口超时
|
|
||||||
- `app-instance` 容器反复重启
|
|
||||||
- 日志里出现 `Missing Boardware Genius config: /root/.beaver/config.json`
|
|
||||||
|
|
||||||
当前版本里,新实例的默认大模型配置就是从这里分发的:
|
|
||||||
|
|
||||||
- `APP_INSTANCE_PROVIDER`
|
|
||||||
- `APP_INSTANCE_MODEL`
|
|
||||||
- `APP_INSTANCE_API_KEY`
|
|
||||||
- `APP_INSTANCE_API_BASE`
|
|
||||||
|
|
||||||
如果 `APP_INSTANCE_API_KEY` 没配,新用户注册时创建实例会直接失败。
|
|
||||||
|
|
||||||
## 6. 启动 auth-portal
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name nano-auth-portal \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network "$NANO_NET" \
|
|
||||||
-p 3081:3081 \
|
|
||||||
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
|
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
|
||||||
nano/auth-portal:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
当前页面入口:
|
|
||||||
|
|
||||||
- `http://<server-ip>:3081`
|
|
||||||
|
|
||||||
## 7. 上线前健康检查
|
|
||||||
|
|
||||||
先确认四个基础组件都起来了:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:19090/healthz
|
|
||||||
curl http://127.0.0.1:8090/healthz
|
|
||||||
curl -I http://127.0.0.1:3081
|
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
再确认 `router-proxy` 在跑:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs --tail=50 nano-router-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 首次注册验收
|
|
||||||
|
|
||||||
打开:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<server-ip>:3081/register
|
http://beaver-authz-service:19090
|
||||||
```
|
```
|
||||||
|
|
||||||
注册一个新用户后,预期结果是:
|
不要写成 `http://127.0.0.1:19090`,因为新创建的 `app-instance` 容器里的 `127.0.0.1` 指向它自己,不是 AuthZ 容器。
|
||||||
|
|
||||||
1. `auth-portal` 调 `authz-service /portal/register`
|
`DEPLOY_PUBLIC_*` 决定新实例展示给用户的 URL:
|
||||||
2. `authz-service` 调 `deploy-control /api/instances/register`
|
|
||||||
3. `deploy-control` 创建一个新的 `app-instance`
|
|
||||||
4. `app-instance` 回调 AuthZ 完成 backend 身份初始化
|
|
||||||
5. 浏览器被跳转到该实例自己的 URL
|
|
||||||
|
|
||||||
同时你应该能看到:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$PROJECT_ROOT"
|
DEPLOY_PUBLIC_SCHEME=http
|
||||||
./app-instance/list-instances.sh --json
|
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
|
DEPLOY_PUBLIC_PORT=8088
|
||||||
```
|
```
|
||||||
|
|
||||||
新实例 URL 形如:
|
本机测试时实例 URL 形如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<instance-slug>.<base-domain>:8088
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你前面用的是:
|
正式 HTTPS 域名通常改成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_PUBLIC_SCHEME=https
|
||||||
|
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
|
||||||
|
DEPLOY_PUBLIC_PORT=443
|
||||||
|
```
|
||||||
|
|
||||||
|
实例 URL 形如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
NANO_BASE_DOMAIN=<server-ip>.nip.io
|
https://alice.apps.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
那么实例地址会像:
|
前提是你已经在项目外层把 `*.apps.example.com` 的 `80/443` 流量转发到 `router-proxy:8088`。
|
||||||
|
|
||||||
|
## 模型配置方式
|
||||||
|
|
||||||
|
当前版本不会在注册创建实例时写入模型 provider、model 或 API key。
|
||||||
|
|
||||||
|
流程是:
|
||||||
|
|
||||||
|
1. 注册先创建一个不含模型凭证的实例。
|
||||||
|
2. `auth-portal` 进入模型配置引导页。
|
||||||
|
3. 用户确认后,Portal 调用 `deploy-control /api/instances/configure-provider`。
|
||||||
|
4. `deploy-control` 写入该实例的 `config.json` 并重启对应容器。
|
||||||
|
|
||||||
|
如果用户跳过引导,实例仍会创建成功,但后续需要在实例内补齐 provider 配置后才能正常调用模型。
|
||||||
|
|
||||||
|
## 持久化目录
|
||||||
|
|
||||||
|
至少保留:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://alice.203.0.113.10.nip.io:8088
|
authz-service/runtime/data
|
||||||
|
app-instance/runtime/instances
|
||||||
|
app-instance/runtime/registry
|
||||||
|
router-proxy/runtime/conf.d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. 常见问题
|
不要在需要保留账号、实例或配置时删除这些目录。
|
||||||
|
|
||||||
### 1. 为什么不要把 `AUTHZ_ISSUER` 写成 `http://127.0.0.1:19090`
|
## 模板文件
|
||||||
|
|
||||||
因为 `app-instance` 容器里访问 `127.0.0.1` 只会打到它自己,不会打到 AuthZ 容器。
|
可参考这些环境变量模板:
|
||||||
在当前实现里,`AUTHZ_ISSUER` 会被直接传给新实例当作 `authz_base_url`。
|
|
||||||
|
|
||||||
### 2. `DEPLOY_API_TOKEN` 和 `DEPLOY_CONTROL_API_TOKEN` 为什么要一样
|
- [`.env.example`](./.env.example)
|
||||||
|
- [`auth-portal/src/.env.example`](./auth-portal/src/.env.example)
|
||||||
|
- [`authz-service/.env.example`](./authz-service/.env.example)
|
||||||
|
- [`deploy-control/.env.example`](./deploy-control/.env.example)
|
||||||
|
- [`router-proxy/.env.example`](./router-proxy/.env.example)
|
||||||
|
|
||||||
因为一个是客户端发出去的 token,一个是服务端拿来校验的 token。
|
这些模板不会被脚本自动加载。你可以手动 `export`,也可以在 `docker run` 时使用 `--env-file`。
|
||||||
|
|
||||||
### 3. 这些 provider 配置是写到哪里
|
## 子项目文档
|
||||||
|
|
||||||
写到每个实例自己的:
|
- [`app-instance/README.md`](./app-instance/README.md)
|
||||||
|
- [`auth-portal/src/README.md`](./auth-portal/src/README.md)
|
||||||
```text
|
- [`authz-service/README.md`](./authz-service/README.md)
|
||||||
app-instance/runtime/instances/<slug>/beaver-home/config.json
|
- [`deploy-control/README.md`](./deploy-control/README.md)
|
||||||
```
|
- [`router-proxy/README.md`](./router-proxy/README.md)
|
||||||
|
|
||||||
不是写在 AuthZ 的某个 setting 里。
|
|
||||||
|
|
||||||
### 4. 现在支持每个用户注册时填自己的 API key 吗
|
|
||||||
|
|
||||||
后端请求模型已经支持 `provider/model/api_key/api_base` 字段,但当前 `auth-portal` 页面没有把这些字段暴露出来。
|
|
||||||
当前上线流程默认是:所有新实例先继承 `deploy-control` 上配置的默认 provider 凭证。
|
|
||||||
|
|
||||||
### 5. 现在内置 HTTPS 吗
|
|
||||||
|
|
||||||
没有。
|
|
||||||
当前内置 `router-proxy` 是 HTTP 入口。如果你要公网 HTTPS:
|
|
||||||
|
|
||||||
- 在外面再放一层 Nginx / Caddy / LB 做 TLS 终止
|
|
||||||
- 再把流量转给 `router-proxy:8088` 和 `auth-portal:3081`
|
|
||||||
|
|
||||||
## 10. 仓库结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
/home/ivan/xuan/nano_project
|
|
||||||
├── README.md
|
|
||||||
├── app-instance/
|
|
||||||
├── auth-portal/
|
|
||||||
├── authz-service/
|
|
||||||
├── deploy-control/
|
|
||||||
└── router-proxy/
|
|
||||||
```
|
|
||||||
|
|
||||||
各子目录更细的实现说明见:
|
|
||||||
|
|
||||||
- [`app-instance/README.md`](/home/ivan/xuan/nano_project/app-instance/README.md)
|
|
||||||
- [`auth-portal/src/README.md`](/home/ivan/xuan/nano_project/auth-portal/src/README.md)
|
|
||||||
- [`authz-service/README.md`](/home/ivan/xuan/nano_project/authz-service/README.md)
|
|
||||||
- [`deploy-control/README.md`](/home/ivan/xuan/nano_project/deploy-control/README.md)
|
|
||||||
- [`router-proxy/README.md`](/home/ivan/xuan/nano_project/router-proxy/README.md)
|
|
||||||
|
|||||||
@ -45,14 +45,14 @@ runtime/registry/instances.json
|
|||||||
### 1. 构建镜像
|
### 1. 构建镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t nano/app-instance:latest .
|
docker build -t beaver/app-instance:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 创建实例
|
### 2. 创建实例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./create-instance.sh \
|
./create-instance.sh \
|
||||||
--image nano/app-instance:latest \
|
--image beaver/app-instance:latest \
|
||||||
--instance-id demo-001 \
|
--instance-id demo-001 \
|
||||||
--auth-username admin \
|
--auth-username admin \
|
||||||
--auth-password 123456 \
|
--auth-password 123456 \
|
||||||
|
|||||||
@ -260,7 +260,12 @@ class EngineLoader:
|
|||||||
review_service=review_service,
|
review_service=review_service,
|
||||||
publisher=skill_publisher,
|
publisher=skill_publisher,
|
||||||
safety_checker=SkillDraftSafetyChecker(
|
safety_checker=SkillDraftSafetyChecker(
|
||||||
allowed_tool_names={spec.name for spec in tool_registry.list_specs()}
|
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
|
||||||
|
allowed_tool_prefixes={
|
||||||
|
f"mcp_{server_id}_"
|
||||||
|
for server_id in self.config.tools.mcp_servers
|
||||||
|
if str(server_id).strip()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
evaluator=SkillDraftEvaluator(run_memory_store),
|
evaluator=SkillDraftEvaluator(run_memory_store),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1437,6 +1437,15 @@ def create_app(
|
|||||||
raise HTTPException(status_code=404, detail="Safety report not found")
|
raise HTTPException(status_code=404, detail="Safety report not found")
|
||||||
return report.to_dict()
|
return report.to_dict()
|
||||||
|
|
||||||
|
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/safety")
|
||||||
|
async def recheck_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
loaded = get_agent_service(request).create_loop().boot()
|
||||||
|
try:
|
||||||
|
report = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _skill_draft_http_error(exc) from exc
|
||||||
|
return report.to_dict()
|
||||||
|
|
||||||
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
|
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
|
||||||
async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
|
async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
|
||||||
loaded = get_agent_service(request).create_loop().boot()
|
loaded = get_agent_service(request).create_loop().boot()
|
||||||
@ -1831,6 +1840,7 @@ def create_app(
|
|||||||
"model": _clean_text(payload.get("model")) or None,
|
"model": _clean_text(payload.get("model")) or None,
|
||||||
"provider_name": _clean_text(payload.get("provider_name")) or None,
|
"provider_name": _clean_text(payload.get("provider_name")) or None,
|
||||||
"embedding_model": _clean_text(payload.get("embedding_model")) or None,
|
"embedding_model": _clean_text(payload.get("embedding_model")) or None,
|
||||||
|
"max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")),
|
||||||
}
|
}
|
||||||
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
|
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
|
||||||
if websocket_thinking_enabled is not None:
|
if websocket_thinking_enabled is not None:
|
||||||
@ -1844,6 +1854,7 @@ def create_app(
|
|||||||
"content": f"Run failed before completion: {exc}",
|
"content": f"Run failed before completion: {exc}",
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"finish_reason": "error",
|
"finish_reason": "error",
|
||||||
|
"tool_iterations": 0,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"input_metadata": _websocket_input_metadata(payload),
|
"input_metadata": _websocket_input_metadata(payload),
|
||||||
@ -2403,6 +2414,15 @@ def _bool_or_none(value: Any) -> bool | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
|
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
validation_result = getattr(result, "validation_result", None)
|
validation_result = getattr(result, "validation_result", None)
|
||||||
task_id = getattr(result, "task_id", None)
|
task_id = getattr(result, "task_id", None)
|
||||||
@ -2414,6 +2434,7 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) ->
|
|||||||
"session_id": getattr(result, "session_id", None),
|
"session_id": getattr(result, "session_id", None),
|
||||||
"run_id": getattr(result, "run_id", None),
|
"run_id": getattr(result, "run_id", None),
|
||||||
"finish_reason": getattr(result, "finish_reason", None),
|
"finish_reason": getattr(result, "finish_reason", None),
|
||||||
|
"tool_iterations": getattr(result, "tool_iterations", 0),
|
||||||
"provider_name": getattr(result, "provider_name", None),
|
"provider_name": getattr(result, "provider_name", None),
|
||||||
"model": getattr(result, "model", None),
|
"model": getattr(result, "model", None),
|
||||||
"usage": dict(getattr(result, "usage", {}) or {}),
|
"usage": dict(getattr(result, "usage", {}) or {}),
|
||||||
|
|||||||
@ -42,6 +42,8 @@ class EvidenceSelector:
|
|||||||
resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or []))
|
resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or []))
|
||||||
task_summaries: list[str] = []
|
task_summaries: list[str] = []
|
||||||
session_excerpts: list[str] = []
|
session_excerpts: list[str] = []
|
||||||
|
tool_names: list[str] = []
|
||||||
|
selected_tool_names: list[str] = []
|
||||||
for run_id in run_ids:
|
for run_id in run_ids:
|
||||||
record = runs_by_id.get(run_id)
|
record = runs_by_id.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
@ -56,12 +58,19 @@ class EvidenceSelector:
|
|||||||
excerpt = self._session_excerpt(record.session_id, run_id)
|
excerpt = self._session_excerpt(record.session_id, run_id)
|
||||||
if excerpt:
|
if excerpt:
|
||||||
session_excerpts.append(excerpt)
|
session_excerpts.append(excerpt)
|
||||||
|
run_tool_names, run_selected_tool_names = self._run_tool_names(record.session_id, run_id)
|
||||||
|
tool_names.extend(run_tool_names)
|
||||||
|
selected_tool_names.extend(run_selected_tool_names)
|
||||||
return EvidencePacket(
|
return EvidencePacket(
|
||||||
run_ids=resolved_run_ids,
|
run_ids=resolved_run_ids,
|
||||||
session_ids=resolved_session_ids,
|
session_ids=resolved_session_ids,
|
||||||
task_summaries=task_summaries[:8],
|
task_summaries=task_summaries[:8],
|
||||||
session_excerpts=session_excerpts[:6],
|
session_excerpts=session_excerpts[:6],
|
||||||
metadata={"bounded": True},
|
metadata={
|
||||||
|
"bounded": True,
|
||||||
|
"tool_names": _unique_strings(tool_names),
|
||||||
|
"selected_tool_names": _unique_strings(selected_tool_names),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _session_excerpt(self, session_id: str, run_id: str) -> str:
|
def _session_excerpt(self, session_id: str, run_id: str) -> str:
|
||||||
@ -74,3 +83,37 @@ class EvidenceSelector:
|
|||||||
continue
|
continue
|
||||||
visible.append(f"{event.role}: {event.content.strip()}")
|
visible.append(f"{event.role}: {event.content.strip()}")
|
||||||
return "\n".join(visible[:12])[:2000]
|
return "\n".join(visible[:12])[:2000]
|
||||||
|
|
||||||
|
def _run_tool_names(self, session_id: str, run_id: str) -> tuple[list[str], list[str]]:
|
||||||
|
if self.session_manager is None:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
names: list[str] = []
|
||||||
|
selected_names: list[str] = []
|
||||||
|
for event in self.session_manager.get_run_event_records(session_id, run_id):
|
||||||
|
if event.tool_name:
|
||||||
|
names.append(event.tool_name)
|
||||||
|
if event.tool_calls:
|
||||||
|
for call in event.tool_calls:
|
||||||
|
if not isinstance(call, dict):
|
||||||
|
continue
|
||||||
|
name = call.get("name")
|
||||||
|
function = call.get("function")
|
||||||
|
if not name and isinstance(function, dict):
|
||||||
|
name = function.get("name")
|
||||||
|
if name:
|
||||||
|
names.append(str(name))
|
||||||
|
if event.event_type == "tool_selection_snapshotted" and isinstance(event.event_payload, dict):
|
||||||
|
selected = event.event_payload.get("tool_names")
|
||||||
|
if isinstance(selected, list):
|
||||||
|
selected_names.extend(str(item) for item in selected if str(item).strip())
|
||||||
|
return _unique_strings(names), _unique_strings(selected_names)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_strings(values: list[str]) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
cleaned = str(value).strip()
|
||||||
|
if cleaned and cleaned not in result:
|
||||||
|
result.append(cleaned)
|
||||||
|
return result
|
||||||
|
|||||||
@ -32,8 +32,14 @@ class SkillDraftSafetyChecker:
|
|||||||
"credentials",
|
"credentials",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *, allowed_tool_names: set[str] | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
allowed_tool_names: set[str] | None = None,
|
||||||
|
allowed_tool_prefixes: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
self.allowed_tool_names = allowed_tool_names
|
self.allowed_tool_names = allowed_tool_names
|
||||||
|
self.allowed_tool_prefixes = allowed_tool_prefixes or set()
|
||||||
|
|
||||||
def check(self, draft: SkillDraft) -> SkillDraftSafetyReport:
|
def check(self, draft: SkillDraft) -> SkillDraftSafetyReport:
|
||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
@ -50,7 +56,7 @@ class SkillDraftSafetyChecker:
|
|||||||
|
|
||||||
tool_hints = _tool_hints(frontmatter)
|
tool_hints = _tool_hints(frontmatter)
|
||||||
if self.allowed_tool_names is not None:
|
if self.allowed_tool_names is not None:
|
||||||
unknown = [name for name in tool_hints if name not in self.allowed_tool_names]
|
unknown = [name for name in tool_hints if not self._is_allowed_tool_hint(name)]
|
||||||
if unknown:
|
if unknown:
|
||||||
blocked.append(f"unknown tool hints: {', '.join(sorted(unknown))}")
|
blocked.append(f"unknown tool hints: {', '.join(sorted(unknown))}")
|
||||||
dangerous = sorted({name for name in tool_hints if name.lower() in self._DANGEROUS_TOOL_HINTS})
|
dangerous = sorted({name for name in tool_hints if name.lower() in self._DANGEROUS_TOOL_HINTS})
|
||||||
@ -80,6 +86,11 @@ class SkillDraftSafetyChecker:
|
|||||||
created_at=_utc_now(),
|
created_at=_utc_now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_allowed_tool_hint(self, name: str) -> bool:
|
||||||
|
if self.allowed_tool_names is not None and name in self.allowed_tool_names:
|
||||||
|
return True
|
||||||
|
return any(name.startswith(prefix) and len(name) > len(prefix) for prefix in self.allowed_tool_prefixes)
|
||||||
|
|
||||||
|
|
||||||
def _tool_hints(frontmatter: dict) -> list[str]:
|
def _tool_hints(frontmatter: dict) -> list[str]:
|
||||||
raw = frontmatter.get("tools")
|
raw = frontmatter.get("tools")
|
||||||
|
|||||||
@ -65,19 +65,29 @@ class SkillDraftSynthesizer:
|
|||||||
)
|
)
|
||||||
payload = self._parse_payload(response.content or "")
|
payload = self._parse_payload(response.content or "")
|
||||||
if payload:
|
if payload:
|
||||||
return payload
|
return self._normalize_payload(payload, evidence_packet)
|
||||||
return self._fallback_payload(candidate, evidence_packet, action)
|
return self._fallback_payload(candidate, evidence_packet, action)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_prompt(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> str:
|
def _build_prompt(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> str:
|
||||||
|
tool_names = _coerce_string_list(evidence_packet.metadata.get("tool_names"))
|
||||||
|
tool_section = ", ".join(tool_names) if tool_names else "none observed"
|
||||||
|
selected_tool_names = _coerce_string_list(evidence_packet.metadata.get("selected_tool_names"))
|
||||||
|
selected_tool_section = ", ".join(selected_tool_names) if selected_tool_names else "none recorded"
|
||||||
return (
|
return (
|
||||||
f"Action: {action}\n"
|
f"Action: {action}\n"
|
||||||
f"Candidate kind: {candidate.kind}\n"
|
f"Candidate kind: {candidate.kind}\n"
|
||||||
f"Reason: {candidate.reason}\n"
|
f"Reason: {candidate.reason}\n"
|
||||||
f"Related skills: {candidate.related_skill_names}\n"
|
f"Related skills: {candidate.related_skill_names}\n"
|
||||||
|
f"Called tool names: {tool_section}\n"
|
||||||
|
f"Run-selected tool names: {selected_tool_section}\n"
|
||||||
f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries)
|
f"Task summaries:\n- " + "\n- ".join(evidence_packet.task_summaries)
|
||||||
+ "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts)
|
+ "\n\nSession excerpts:\n" + "\n\n".join(evidence_packet.session_excerpts)
|
||||||
+ "\n\nReturn JSON only."
|
+ "\n\nReturn JSON only. The frontmatter object must include:"
|
||||||
|
+ "\n- description: a concise skill description"
|
||||||
|
+ "\n- tools: an explicit JSON array of exact tool names this skill needs. "
|
||||||
|
+ "Prefer called tool names when the workflow depends on them; use run-selected tool names only when clearly required. "
|
||||||
|
+ "Use [] only when no tool is required."
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -103,6 +113,19 @@ class SkillDraftSynthesizer:
|
|||||||
"change_reason": str(payload.get("change_reason") or ""),
|
"change_reason": str(payload.get("change_reason") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_payload(payload: dict[str, Any], evidence_packet: EvidencePacket) -> dict[str, Any]:
|
||||||
|
frontmatter = dict(payload.get("frontmatter") or {})
|
||||||
|
tool_hints = _coerce_string_list(frontmatter.get("tools"))
|
||||||
|
if not tool_hints:
|
||||||
|
tool_hints = _coerce_string_list(evidence_packet.metadata.get("tool_names"))
|
||||||
|
frontmatter["tools"] = tool_hints
|
||||||
|
return {
|
||||||
|
"frontmatter": frontmatter,
|
||||||
|
"content": str(payload.get("content") or "").strip(),
|
||||||
|
"change_reason": str(payload.get("change_reason") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]:
|
def _fallback_payload(candidate: SkillLearningCandidate, evidence_packet: EvidencePacket, action: str) -> dict[str, Any]:
|
||||||
related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill"
|
related = candidate.related_skill_names[0] if candidate.related_skill_names else "generated-skill"
|
||||||
@ -111,8 +134,25 @@ class SkillDraftSynthesizer:
|
|||||||
return {
|
return {
|
||||||
"frontmatter": {
|
"frontmatter": {
|
||||||
"description": candidate.reason or f"Auto-generated {action} draft for {title}.",
|
"description": candidate.reason or f"Auto-generated {action} draft for {title}.",
|
||||||
"tools": [],
|
"tools": _coerce_string_list(evidence_packet.metadata.get("tool_names")),
|
||||||
},
|
},
|
||||||
"content": f"# {title}\n\n## Evidence\n\n{content}\n",
|
"content": f"# {title}\n\n## Evidence\n\n{content}\n",
|
||||||
"change_reason": candidate.reason or f"Fallback {action} synthesis.",
|
"change_reason": candidate.reason or f"Fallback {action} synthesis.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_string_list(value: Any) -> list[str]:
|
||||||
|
raw_items: list[Any]
|
||||||
|
if isinstance(value, list):
|
||||||
|
raw_items = value
|
||||||
|
elif isinstance(value, str):
|
||||||
|
raw_items = value.split(",")
|
||||||
|
else:
|
||||||
|
raw_items = []
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
for item in raw_items:
|
||||||
|
cleaned = str(item).strip()
|
||||||
|
if cleaned and cleaned not in result:
|
||||||
|
result.append(cleaned)
|
||||||
|
return result
|
||||||
|
|||||||
@ -26,38 +26,42 @@ class MainAgentRouter:
|
|||||||
) -> MainAgentDecision:
|
) -> MainAgentDecision:
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
|
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
|
||||||
try:
|
chat_kwargs: dict[str, Any] = {
|
||||||
chat_kwargs: dict[str, Any] = {
|
"messages": [
|
||||||
"messages": [
|
{
|
||||||
{
|
"role": "system",
|
||||||
"role": "system",
|
"content": (
|
||||||
"content": (
|
"You are Beaver's Intent Agent. Your only job is to route the user's "
|
||||||
"You are Beaver's Intent Agent. Your only job is to route the user's "
|
"message to simple chat or internal Task mode. Return only compact JSON. "
|
||||||
"message to simple chat or internal Task mode. Return only compact JSON. "
|
"Do not answer the user. Do not explain."
|
||||||
"Do not answer the user. Do not explain."
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
"role": "user",
|
||||||
"role": "user",
|
"content": self._prompt(
|
||||||
"content": self._prompt(
|
message=message,
|
||||||
message=message,
|
active_task=active_task,
|
||||||
active_task=active_task,
|
recent_messages=recent_messages or [],
|
||||||
recent_messages=recent_messages or [],
|
intent_skill=intent_skill,
|
||||||
intent_skill=intent_skill,
|
),
|
||||||
),
|
},
|
||||||
},
|
],
|
||||||
],
|
"tools": None,
|
||||||
"tools": None,
|
"model": model,
|
||||||
"model": model,
|
"max_tokens": 256,
|
||||||
"max_tokens": 256,
|
"temperature": 0.0,
|
||||||
"temperature": 0.0,
|
}
|
||||||
}
|
if thinking_enabled is not None:
|
||||||
if thinking_enabled is not None:
|
chat_kwargs["thinking_enabled"] = thinking_enabled
|
||||||
chat_kwargs["thinking_enabled"] = thinking_enabled
|
|
||||||
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
|
last_error: Exception | None = None
|
||||||
return self.from_json(response.content or "", active_task=active_task)
|
for attempt_timeout in (timeout_seconds, 12.0):
|
||||||
except Exception as exc:
|
try:
|
||||||
return self._fallback(active_task=active_task, reason=f"router_failed: {exc}")
|
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=attempt_timeout)
|
||||||
|
return self.from_json(response.content or "", active_task=active_task)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
return self._fallback(active_task=active_task, reason=f"router_failed: {last_error}")
|
||||||
|
|
||||||
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
|
def from_json(self, text: str, *, active_task: TaskRecord | None = None) -> MainAgentDecision:
|
||||||
payload = self._parse_json_object(text)
|
payload = self._parse_json_object(text)
|
||||||
|
|||||||
@ -2,10 +2,46 @@
|
|||||||
|
|
||||||
这是新 `Beaver` 后端的架构入口文档。
|
这是新 `Beaver` 后端的架构入口文档。
|
||||||
|
|
||||||
|
可视化入口:
|
||||||
|
|
||||||
|
- [Beaver Backend 可视化](backend-visualization.html)
|
||||||
|
|
||||||
|
## 给零基础读者的版本
|
||||||
|
|
||||||
|
可以先把这个后端理解成一个“帮用户完成任务的后台工厂”:
|
||||||
|
|
||||||
|
1. 用户在前端发一句话。
|
||||||
|
2. 后端入口把这句话接住。
|
||||||
|
3. 服务层判断它是闲聊,还是一个需要执行和跟踪的任务。
|
||||||
|
4. 如果是任务,系统会创建或继续一个 `Task`。
|
||||||
|
5. 运行内核 `AgentLoop` 准备上下文、选择技能、选择工具、调用模型。
|
||||||
|
6. 如果模型需要查文件、写文件、搜索或调用外部系统,就通过 `tools` 执行。
|
||||||
|
7. 执行结果会写回会话、任务记录和运行记录,后续可以继续追踪、验证和学习。
|
||||||
|
|
||||||
|
这套结构里最重要的原则是:**所有 agent 共用同一个运行内核 `engine`**。也就是说,主 agent 和被拆出去的小 agent 不是两套系统,它们最终都会回到 `AgentLoop`,使用同一套上下文、工具、技能和记录方式。
|
||||||
|
|
||||||
|
## 先认识几个词
|
||||||
|
|
||||||
|
- `interfaces`:入口层。负责接收 Web、CLI、Gateway、MCP 等不同来源的请求。
|
||||||
|
- `services`:应用服务层。负责把入口请求转成系统内部要做的事情。
|
||||||
|
- `engine`:运行内核。真正组织 prompt、调用模型、执行 tool loop 的地方。
|
||||||
|
- `coordinator`:多 agent 编排层。负责把复杂任务拆成 sequence、parallel 或 DAG。
|
||||||
|
- `skills`:技能层。可以理解成给 agent 的专项说明书。
|
||||||
|
- `tools`:工具层。可以理解成 agent 能按需调用的动作,例如读文件、写文件、搜索、执行命令。
|
||||||
|
- `memory`:记忆层。保存会话、任务结果、运行记录、反馈和技能学习数据。
|
||||||
|
- `permissions`:权限与治理层。负责约束哪些能力能用、怎么用。
|
||||||
|
|
||||||
|
## 一句话请求的流转
|
||||||
|
|
||||||
|
典型路径是:
|
||||||
|
|
||||||
|
`interfaces` -> `AgentService` -> `MainAgentRouter` -> `TaskService` / `TaskExecutionPlanner` -> `AgentLoop` -> `skills` / `tools` / `memory` -> 返回用户。
|
||||||
|
|
||||||
|
如果任务很简单,可能只走单 agent。如果任务更复杂,`TaskExecutionPlanner` 可能先生成一个 team plan,让 `coordinator` 安排多个 sub-agent 分别处理,最后再由主 agent 综合输出。
|
||||||
|
|
||||||
当前约束:
|
当前约束:
|
||||||
|
|
||||||
1. 所有 agent 共用 `engine`。
|
1. 所有 agent 共用 `engine`。
|
||||||
2. 多 agent 编排进入 `coordinator`。
|
2. 多 agent 编排进入 `coordinator`。
|
||||||
3. skills、memory、permissions 独立成能力层。
|
3. skills、memory、permissions 独立成能力层。
|
||||||
4. `interfaces` 只做薄入口。
|
4. `interfaces` 只做薄入口。
|
||||||
|
|
||||||
|
|||||||
1388
app-instance/backend/docs/architecture/backend-visualization.html
Normal file
1071
app-instance/backend/docs/architecture/project-comparison.html
Normal file
@ -158,7 +158,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
|
|||||||
},
|
},
|
||||||
"authz": {
|
"authz": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"baseUrl": "http://nano-authz-service:19090",
|
"baseUrl": "http://beaver-authz-service:19090",
|
||||||
},
|
},
|
||||||
"backend_identity": {
|
"backend_identity": {
|
||||||
"backend_id": "stevenli",
|
"backend_id": "stevenli",
|
||||||
@ -180,7 +180,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
|
|||||||
assert server.sensitive is True
|
assert server.sensitive is True
|
||||||
|
|
||||||
assert config.authz.enabled is True
|
assert config.authz.enabled is True
|
||||||
assert config.authz.base_url == "http://nano-authz-service:19090"
|
assert config.authz.base_url == "http://beaver-authz-service:19090"
|
||||||
assert config.backend_identity.backend_id == "stevenli"
|
assert config.backend_identity.backend_id == "stevenli"
|
||||||
assert config.backend_identity.client_id == "stevenli"
|
assert config.backend_identity.client_id == "stevenli"
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,39 @@ class RouterProvider(LLMProvider):
|
|||||||
return "stub-model"
|
return "stub-model"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceRouterProvider(LLMProvider):
|
||||||
|
def __init__(self, responses: list[str | Exception]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
"model": model,
|
||||||
|
"thinking_enabled": thinking_enabled,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = self.responses.pop(0)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return LLMResponse(content=response, finish_reason="stop", provider_name="stub", model="stub-model")
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "stub-model"
|
||||||
|
|
||||||
|
|
||||||
def _task() -> TaskRecord:
|
def _task() -> TaskRecord:
|
||||||
return TaskRecord(
|
return TaskRecord(
|
||||||
task_id="task-1",
|
task_id="task-1",
|
||||||
@ -133,3 +166,38 @@ def test_router_fallback_keeps_active_task_but_not_new_task() -> None:
|
|||||||
|
|
||||||
assert active.is_task
|
assert active.is_task
|
||||||
assert not inactive.is_task
|
assert not inactive.is_task
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_retries_once_after_provider_failure() -> None:
|
||||||
|
provider = SequenceRouterProvider(
|
||||||
|
[
|
||||||
|
TimeoutError(),
|
||||||
|
'{"action":"new_task","reason":"needs search","short_title":"中美会面"}',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = asyncio.run(
|
||||||
|
MainAgentRouter().classify(
|
||||||
|
"帮我看看昨天的中美会面都谈了什么?",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision.is_task
|
||||||
|
assert decision.action == "create_task"
|
||||||
|
assert len(provider.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_fallback_after_two_provider_failures() -> None:
|
||||||
|
provider = SequenceRouterProvider([TimeoutError(), RuntimeError("provider down")])
|
||||||
|
|
||||||
|
decision = asyncio.run(
|
||||||
|
MainAgentRouter().classify(
|
||||||
|
"帮我看看昨天的中美会面都谈了什么?",
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not decision.is_task
|
||||||
|
assert decision.reason == "router_failed: provider down"
|
||||||
|
assert len(provider.calls) == 2
|
||||||
|
|||||||
@ -15,7 +15,12 @@ from beaver.skills.reviews import ReviewService
|
|||||||
from beaver.skills.specs import SkillSpecStore
|
from beaver.skills.specs import SkillSpecStore
|
||||||
|
|
||||||
|
|
||||||
def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> SkillLearningPipelineService:
|
def _pipeline(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
allowed_tools: set[str] | None = None,
|
||||||
|
allowed_prefixes: set[str] | None = None,
|
||||||
|
) -> SkillLearningPipelineService:
|
||||||
spec_store = SkillSpecStore(tmp_path)
|
spec_store = SkillSpecStore(tmp_path)
|
||||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||||
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
learning_store = SkillLearningStore(tmp_path / "memory" / "skills")
|
||||||
@ -32,7 +37,10 @@ def _pipeline(tmp_path: Path, *, allowed_tools: set[str] | None = None) -> Skill
|
|||||||
draft_service=drafts,
|
draft_service=drafts,
|
||||||
review_service=ReviewService(spec_store),
|
review_service=ReviewService(spec_store),
|
||||||
publisher=SkillPublisher(spec_store),
|
publisher=SkillPublisher(spec_store),
|
||||||
safety_checker=SkillDraftSafetyChecker(allowed_tool_names=allowed_tools),
|
safety_checker=SkillDraftSafetyChecker(
|
||||||
|
allowed_tool_names=allowed_tools,
|
||||||
|
allowed_tool_prefixes=allowed_prefixes,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -106,3 +114,53 @@ def test_safety_blocks_unknown_tool_hint(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert report.passed is False
|
assert report.passed is False
|
||||||
assert "unknown tool hints" in report.blocked_reasons[0]
|
assert "unknown tool hints" in report.blocked_reasons[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_safety_allows_configured_mcp_tool_prefix(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(
|
||||||
|
tmp_path,
|
||||||
|
allowed_tools={"echo"},
|
||||||
|
allowed_prefixes={"mcp_officebench_"},
|
||||||
|
)
|
||||||
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
|
skill_name="officebench-excel",
|
||||||
|
proposed_content="# OfficeBench Excel\n\nUse the configured OfficeBench MCP tools.",
|
||||||
|
proposed_frontmatter={
|
||||||
|
"description": "officebench",
|
||||||
|
"tools": [
|
||||||
|
"mcp_officebench_shell_list_directory",
|
||||||
|
"mcp_officebench_excel_read_file",
|
||||||
|
"mcp_officebench_excel_set_cell",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created_by="test",
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
|
||||||
|
assert report.passed is True
|
||||||
|
assert report.blocked_reasons == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_safety_blocks_unconfigured_mcp_tool_prefix(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(
|
||||||
|
tmp_path,
|
||||||
|
allowed_tools={"echo"},
|
||||||
|
allowed_prefixes={"mcp_outlook_mcp_"},
|
||||||
|
)
|
||||||
|
draft = pipeline.draft_service.create_new_skill_draft(
|
||||||
|
skill_name="wrong-mcp",
|
||||||
|
proposed_content="# Wrong MCP\n\nUse an unconfigured MCP namespace.",
|
||||||
|
proposed_frontmatter={
|
||||||
|
"description": "wrong mcp",
|
||||||
|
"tools": ["mcp_officebench_excel_set_cell"],
|
||||||
|
},
|
||||||
|
created_by="test",
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = pipeline.check_safety(draft.skill_name, draft.draft_id)
|
||||||
|
|
||||||
|
assert report.passed is False
|
||||||
|
assert "mcp_officebench_excel_set_cell" in report.blocked_reasons[0]
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
from beaver.engine.providers.base import LLMProvider, LLMResponse
|
||||||
from beaver.engine.providers.factory import ProviderBundle
|
from beaver.engine.providers.factory import ProviderBundle
|
||||||
|
from beaver.engine.session import SessionManager
|
||||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||||
from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore
|
from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore
|
||||||
from beaver.skills.drafts import DraftService
|
from beaver.skills.drafts import DraftService
|
||||||
@ -125,6 +126,78 @@ def test_worker_retries_and_marks_failed_after_limit(tmp_path: Path) -> None:
|
|||||||
assert "provider failed" in (candidate.last_error or "")
|
assert "provider failed" in (candidate.last_error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesizer_fills_missing_tools_from_evidence(tmp_path: Path) -> None:
|
||||||
|
pipeline = _pipeline(tmp_path)
|
||||||
|
candidate = pipeline.get_candidate("candidate-1")
|
||||||
|
provider = JsonProvider(
|
||||||
|
payload={
|
||||||
|
"frontmatter": {"description": "Generated skill"},
|
||||||
|
"content": "# Generated\n\nUse the observed workflow.",
|
||||||
|
"change_reason": "learned",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
packet = EvidenceSelector(pipeline.learning_service.run_store).build_evidence_packet(
|
||||||
|
candidate.source_run_ids,
|
||||||
|
candidate.source_session_ids,
|
||||||
|
)
|
||||||
|
packet.metadata["tool_names"] = ["web_fetch", "memory"]
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
SkillDraftSynthesizer().synthesize_new_skill(candidate, packet, provider, "stub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["frontmatter"]["tools"] == ["web_fetch", "memory"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_selector_records_run_tool_names(tmp_path: Path) -> None:
|
||||||
|
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||||
|
run_store.append_run_record(
|
||||||
|
RunRecord(
|
||||||
|
run_id="run-1",
|
||||||
|
session_id="session-1",
|
||||||
|
task_text="research latest docs",
|
||||||
|
started_at="start",
|
||||||
|
ended_at="end",
|
||||||
|
success=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session_manager = SessionManager(tmp_path)
|
||||||
|
session_manager.ensure_session("session-1")
|
||||||
|
session_manager.append_message(
|
||||||
|
"session-1",
|
||||||
|
run_id="run-1",
|
||||||
|
role="system",
|
||||||
|
event_type="tool_selection_snapshotted",
|
||||||
|
event_payload={"tool_names": ["memory", "web_fetch"]},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
session_manager.append_message(
|
||||||
|
"session-1",
|
||||||
|
run_id="run-1",
|
||||||
|
role="assistant",
|
||||||
|
tool_calls=[{"id": "call-1", "function": {"name": "web_search"}}],
|
||||||
|
)
|
||||||
|
session_manager.append_message(
|
||||||
|
"session-1",
|
||||||
|
run_id="run-1",
|
||||||
|
role="tool",
|
||||||
|
tool_name="web_fetch",
|
||||||
|
content="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
packet = EvidenceSelector(run_store, session_manager).build_evidence_packet(
|
||||||
|
["run-1"],
|
||||||
|
["session-1"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session_manager.close()
|
||||||
|
|
||||||
|
assert packet.metadata["tool_names"] == ["web_search", "web_fetch"]
|
||||||
|
assert packet.metadata["selected_tool_names"] == ["memory", "web_fetch"]
|
||||||
|
|
||||||
|
|
||||||
def test_worker_supersedes_candidate_when_active_draft_exists(tmp_path: Path) -> None:
|
def test_worker_supersedes_candidate_when_active_draft_exists(tmp_path: Path) -> None:
|
||||||
pipeline = _pipeline(tmp_path)
|
pipeline = _pipeline(tmp_path)
|
||||||
pipeline.learning_store.record_learning_candidate(
|
pipeline.learning_store.record_learning_candidate(
|
||||||
|
|||||||
@ -78,6 +78,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
|
|||||||
"model": None,
|
"model": None,
|
||||||
"provider_name": None,
|
"provider_name": None,
|
||||||
"embedding_model": None,
|
"embedding_model": None,
|
||||||
|
"max_tool_iterations": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert message["type"] == "message"
|
assert message["type"] == "message"
|
||||||
@ -128,5 +129,6 @@ def test_websocket_runtime_error_returns_assistant_error_message() -> None:
|
|||||||
assert message["role"] == "assistant"
|
assert message["role"] == "assistant"
|
||||||
assert message["session_id"] == "web:alpha"
|
assert message["session_id"] == "web:alpha"
|
||||||
assert message["finish_reason"] == "error"
|
assert message["finish_reason"] == "error"
|
||||||
|
assert message["tool_iterations"] == 0
|
||||||
assert "boom" in message["content"]
|
assert "boom" in message["content"]
|
||||||
assert pong == {"type": "pong"}
|
assert pong == {"type": "pong"}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
||||||
|
|
||||||
IMAGE_NAME="${IMAGE_NAME:-nano/app-instance:latest}"
|
IMAGE_NAME="${IMAGE_NAME:-beaver/app-instance:latest}"
|
||||||
INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances"
|
INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances"
|
||||||
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
||||||
KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine "
|
KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine "
|
||||||
@ -25,6 +25,7 @@ MODEL="openai/gpt-5"
|
|||||||
PROVIDER="openai"
|
PROVIDER="openai"
|
||||||
API_KEY="${API_KEY:-}"
|
API_KEY="${API_KEY:-}"
|
||||||
API_BASE="${API_BASE:-}"
|
API_BASE="${API_BASE:-}"
|
||||||
|
SKIP_PROVIDER_CONFIG=0
|
||||||
AUTH_USERNAME=""
|
AUTH_USERNAME=""
|
||||||
AUTH_PASSWORD=""
|
AUTH_PASSWORD=""
|
||||||
USERNAME=""
|
USERNAME=""
|
||||||
@ -42,22 +43,23 @@ REPLACE=0
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 --api-key sk-xxx [options]
|
./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 [options]
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
--instance-id <id> Unique instance id.
|
--instance-id <id> Unique instance id.
|
||||||
--auth-username <name> Initial web login username.
|
--auth-username <name> Initial web login username.
|
||||||
--auth-password <password> Initial web login password.
|
--auth-password <password> Initial web login password.
|
||||||
--api-key <key> Provider API key for Boardware Genius.
|
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
--image <name> Docker image tag. Default: nano/app-instance:latest
|
--image <name> Docker image tag. Default: beaver/app-instance:latest
|
||||||
--container-name <name> Docker container name. Default: app-instance-<slug>
|
--container-name <name> Docker container name. Default: app-instance-<slug>
|
||||||
--host-port <port> Host port to publish. Default: auto-pick from 20000-29999.
|
--host-port <port> Host port to publish. Default: auto-pick from 20000-29999.
|
||||||
--public-url <url> Public URL exposed to users. Default: http://127.0.0.1:<host-port>
|
--public-url <url> Public URL exposed to users. Default: http://127.0.0.1:<host-port>
|
||||||
--provider <name> Provider key in config.json. Default: openai
|
--provider <name> Provider key in config.json. Default: openai
|
||||||
--api-base <url> Optional custom provider base URL.
|
--api-base <url> Optional custom provider base URL.
|
||||||
|
--api-key <key> Provider API key for Boardware Genius.
|
||||||
--model <name> Model name. Default: openai/gpt-5
|
--model <name> Model name. Default: openai/gpt-5
|
||||||
|
--skip-provider-config Create the instance without model/provider/API key settings.
|
||||||
--authz-base-url <url> AuthZ service base URL.
|
--authz-base-url <url> AuthZ service base URL.
|
||||||
--authz-outlook-mcp-url <url>
|
--authz-outlook-mcp-url <url>
|
||||||
Managed Outlook MCP URL for AuthZ mode.
|
Managed Outlook MCP URL for AuthZ mode.
|
||||||
@ -134,6 +136,7 @@ render_config_json() {
|
|||||||
PROVIDER="$PROVIDER" \
|
PROVIDER="$PROVIDER" \
|
||||||
API_KEY="$API_KEY" \
|
API_KEY="$API_KEY" \
|
||||||
API_BASE="$API_BASE" \
|
API_BASE="$API_BASE" \
|
||||||
|
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
|
||||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||||
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
||||||
@ -151,11 +154,20 @@ target = Path(os.environ["TARGET_PATH"])
|
|||||||
provider = os.environ["PROVIDER"]
|
provider = os.environ["PROVIDER"]
|
||||||
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
||||||
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
||||||
|
skip_provider_config = os.environ["SKIP_PROVIDER_CONFIG"].strip() == "1"
|
||||||
|
|
||||||
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
providers = {}
|
||||||
api_base = os.environ["API_BASE"].strip()
|
agent_defaults = {
|
||||||
if api_base:
|
"workspace": "/root/.beaver/workspace",
|
||||||
provider_cfg["apiBase"] = api_base
|
}
|
||||||
|
if not skip_provider_config:
|
||||||
|
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
||||||
|
api_base = os.environ["API_BASE"].strip()
|
||||||
|
if api_base:
|
||||||
|
provider_cfg["apiBase"] = api_base
|
||||||
|
providers[provider] = provider_cfg
|
||||||
|
agent_defaults["provider"] = provider
|
||||||
|
agent_defaults["model"] = os.environ["MODEL"]
|
||||||
|
|
||||||
outlook_tool_names = [
|
outlook_tool_names = [
|
||||||
"auth_status",
|
"auth_status",
|
||||||
@ -193,14 +205,9 @@ if outlook_mcp_url:
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": agent_defaults
|
||||||
"workspace": "/root/.beaver/workspace",
|
|
||||||
"model": os.environ["MODEL"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
provider: provider_cfg,
|
|
||||||
},
|
},
|
||||||
|
"providers": providers,
|
||||||
"tools": {
|
"tools": {
|
||||||
"restrictToWorkspace": True,
|
"restrictToWorkspace": True,
|
||||||
"mcpServers": default_mcp_servers,
|
"mcpServers": default_mcp_servers,
|
||||||
@ -345,6 +352,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MODEL="${2:-}"
|
MODEL="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--skip-provider-config)
|
||||||
|
SKIP_PROVIDER_CONFIG=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--auth-username)
|
--auth-username)
|
||||||
AUTH_USERNAME="${2:-}"
|
AUTH_USERNAME="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@ -438,7 +449,9 @@ done
|
|||||||
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
|
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
|
||||||
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required"
|
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required"
|
||||||
[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required"
|
[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required"
|
||||||
[[ -n "$API_KEY" ]] || die "--api-key is required"
|
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
||||||
|
[[ -n "$API_KEY" ]] || die "--api-key is required unless --skip-provider-config is set"
|
||||||
|
fi
|
||||||
|
|
||||||
INSTANCE_SLUG="$(slugify "$INSTANCE_ID")"
|
INSTANCE_SLUG="$(slugify "$INSTANCE_ID")"
|
||||||
USERNAME="${USERNAME:-$AUTH_USERNAME}"
|
USERNAME="${USERNAME:-$AUTH_USERNAME}"
|
||||||
@ -469,10 +482,12 @@ if [[ -z "$INSTANCE_HOST" ]]; then
|
|||||||
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
|
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$KNOWN_PROVIDERS" in
|
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
||||||
*" ${PROVIDER} "*) ;;
|
case "$KNOWN_PROVIDERS" in
|
||||||
*) die "unsupported provider '${PROVIDER}'" ;;
|
*" ${PROVIDER} "*) ;;
|
||||||
esac
|
*) die "unsupported provider '${PROVIDER}'" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
|
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
|
||||||
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
|
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
|
||||||
@ -550,9 +565,9 @@ RUN_ARGS=(
|
|||||||
-e "APP_FRONTEND_PORT=3000"
|
-e "APP_FRONTEND_PORT=3000"
|
||||||
-e "APP_BACKEND_PORT=18080"
|
-e "APP_BACKEND_PORT=18080"
|
||||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||||
--label "nano.instance.id=${INSTANCE_ID}"
|
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||||
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||||
--label "nano.instance.public_url=${PUBLIC_URL}"
|
--label "beaver.instance.public_url=${PUBLIC_URL}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ -n "$NETWORK_NAME" ]]; then
|
if [[ -n "$NETWORK_NAME" ]]; then
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
listSkillDrafts,
|
listSkillDrafts,
|
||||||
listSkills,
|
listSkills,
|
||||||
publishSkillDraft,
|
publishSkillDraft,
|
||||||
|
recheckSkillDraftSafety,
|
||||||
regenerateSkillDraft,
|
regenerateSkillDraft,
|
||||||
rejectSkillDraft,
|
rejectSkillDraft,
|
||||||
rollbackPublishedSkill,
|
rollbackPublishedSkill,
|
||||||
@ -412,6 +413,11 @@ export default function SkillsPage() {
|
|||||||
rejectSkillDraft(draft.skill_name, draft.draft_id)
|
rejectSkillDraft(draft.skill_name, draft.draft_id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onRecheckSafety={() =>
|
||||||
|
runAction(`safety:${draft.draft_id}`, () =>
|
||||||
|
recheckSkillDraftSafety(draft.skill_name, draft.draft_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
onPublish={(confirmHighRisk) =>
|
onPublish={(confirmHighRisk) =>
|
||||||
runAction(`publish:${draft.draft_id}`, () =>
|
runAction(`publish:${draft.draft_id}`, () =>
|
||||||
publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk)
|
publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk)
|
||||||
@ -697,6 +703,7 @@ function DraftCard({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
|
onRecheckSafety,
|
||||||
onPublish,
|
onPublish,
|
||||||
}: {
|
}: {
|
||||||
draft: SkillDraft;
|
draft: SkillDraft;
|
||||||
@ -704,6 +711,7 @@ function DraftCard({
|
|||||||
onSubmit: () => Promise<unknown>;
|
onSubmit: () => Promise<unknown>;
|
||||||
onApprove: () => Promise<unknown>;
|
onApprove: () => Promise<unknown>;
|
||||||
onReject: () => Promise<unknown>;
|
onReject: () => Promise<unknown>;
|
||||||
|
onRecheckSafety: () => Promise<unknown>;
|
||||||
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
|
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
@ -814,6 +822,10 @@ function DraftCard({
|
|||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
{t('拒绝', 'Reject')}
|
{t('拒绝', 'Reject')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
{t('复检', 'Recheck')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
|
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
|
||||||
<Rocket className="mr-2 h-4 w-4" />
|
<Rocket className="mr-2 h-4 w-4" />
|
||||||
{t('发布', 'Publish')}
|
{t('发布', 'Publish')}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, Trash2, User, XCircle } from 'lucide-react';
|
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, Loader2, MessageSquare, RefreshCw, ThumbsUp, Trash2, User, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
|
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -17,6 +17,14 @@ import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runti
|
|||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
|
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
|
||||||
|
|
||||||
|
type TaskFeedbackType = 'satisfied' | 'revise' | 'abandon';
|
||||||
|
type TaskFeedbackItem = {
|
||||||
|
feedback_type?: unknown;
|
||||||
|
comment?: unknown;
|
||||||
|
created_at?: unknown;
|
||||||
|
run_id?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
|
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
|
||||||
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
||||||
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||||
@ -53,11 +61,13 @@ export default function TaskDetailPage() {
|
|||||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
||||||
const [revision, setRevision] = useState('');
|
const [revision, setRevision] = useState('');
|
||||||
|
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedRunId(task?.rootRunId ?? null);
|
setSelectedRunId(task?.rootRunId ?? null);
|
||||||
|
setRuntimeFeedback(null);
|
||||||
}, [task?.rootRunId]);
|
}, [task?.rootRunId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -138,6 +148,8 @@ export default function TaskDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||||
|
|
||||||
if (!task && backendTask) {
|
if (!task && backendTask) {
|
||||||
const validation = backendTask.validation_result;
|
const validation = backendTask.validation_result;
|
||||||
const accepted = Boolean(validation?.accepted);
|
const accepted = Boolean(validation?.accepted);
|
||||||
@ -185,6 +197,26 @@ export default function TaskDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<TaskFeedbackPanel
|
||||||
|
sessionId={backendTask.session_id}
|
||||||
|
runId={backendFeedbackRunId}
|
||||||
|
taskStatus={backendTask.status}
|
||||||
|
feedbackItems={feedbackItems}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
onSubmit={(feedbackType, comment) =>
|
||||||
|
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||||
|
await submitChatFeedback({
|
||||||
|
sessionId: backendTask.session_id,
|
||||||
|
runId: backendFeedbackRunId!,
|
||||||
|
feedbackType,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
const refreshed = await getBackendTask(backendTask.task_id);
|
||||||
|
setBackendTask(refreshed);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||||
@ -424,37 +456,33 @@ export default function TaskDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<TaskFeedbackPanel
|
||||||
<CardHeader>
|
sessionId={task.sessionId || 'web:default'}
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '修订意见', 'Revision')}</CardTitle>
|
runId={task.rootRunId}
|
||||||
</CardHeader>
|
taskStatus={task.status}
|
||||||
<CardContent className="space-y-3">
|
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
|
||||||
<Textarea
|
actionBusy={actionBusy}
|
||||||
value={revision}
|
revision={revision}
|
||||||
onChange={(event) => setRevision(event.target.value)}
|
onRevisionChange={setRevision}
|
||||||
placeholder={pickAppText(locale, '直接写下需要调整的地方...', 'Describe what should change...')}
|
onSubmit={(feedbackType, comment) =>
|
||||||
/>
|
runAction(`runtime-feedback-${feedbackType}`, async () => {
|
||||||
<Button
|
updateMessageFeedback(task.rootRunId, feedbackType);
|
||||||
className="w-full"
|
await submitChatFeedback({
|
||||||
disabled={!revision.trim() || Boolean(actionBusy)}
|
sessionId: task.sessionId || 'web:default',
|
||||||
onClick={() =>
|
runId: task.rootRunId,
|
||||||
void runAction('revision', async () => {
|
feedbackType,
|
||||||
updateMessageFeedback(task.rootRunId, 'revise');
|
comment,
|
||||||
await submitChatFeedback({
|
});
|
||||||
sessionId: task.sessionId || 'web:default',
|
setRuntimeFeedback({
|
||||||
runId: task.rootRunId,
|
feedback_type: feedbackType,
|
||||||
feedbackType: 'revise',
|
comment: comment || '',
|
||||||
comment: revision.trim(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
run_id: task.rootRunId,
|
||||||
setRevision('');
|
});
|
||||||
})
|
setRevision('');
|
||||||
}
|
})
|
||||||
>
|
}
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
/>
|
||||||
{pickAppText(locale, '提交修订', 'Submit revision')}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -521,6 +549,136 @@ function Metric({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaskFeedbackPanel({
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
taskStatus,
|
||||||
|
feedbackItems,
|
||||||
|
actionBusy,
|
||||||
|
revision,
|
||||||
|
onRevisionChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
runId: string | null;
|
||||||
|
taskStatus: string;
|
||||||
|
feedbackItems: TaskFeedbackItem[];
|
||||||
|
actionBusy: string | null;
|
||||||
|
revision?: string;
|
||||||
|
onRevisionChange?: (value: string) => void;
|
||||||
|
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const [localComment, setLocalComment] = React.useState('');
|
||||||
|
const comment = revision ?? localComment;
|
||||||
|
const setComment = onRevisionChange ?? setLocalComment;
|
||||||
|
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||||
|
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||||
|
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy;
|
||||||
|
|
||||||
|
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||||
|
if (!runId || !canSubmit) return;
|
||||||
|
void onSubmit(feedbackType, nextComment);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{pickAppText(locale, '任务反馈', 'Task feedback')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{recordedFeedback ? (
|
||||||
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||||
|
{pickAppText(locale, '已提交反馈', 'Feedback submitted')}: {humanFeedback(String(recordedFeedback.feedback_type || ''), locale)}
|
||||||
|
</div>
|
||||||
|
{recordedFeedback.comment ? (
|
||||||
|
<p className="mt-2 text-muted-foreground">{String(recordedFeedback.comment)}</p>
|
||||||
|
) : null}
|
||||||
|
{recordedFeedback.created_at ? (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : isFinalized ? (
|
||||||
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||||
|
{pickAppText(locale, '任务已结束,不能再提交新的反馈。', 'This task is finalized and cannot accept new feedback.')}
|
||||||
|
</div>
|
||||||
|
) : !runId ? (
|
||||||
|
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||||
|
{pickAppText(locale, '暂无可反馈的运行记录。', 'No run is available for feedback yet.')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<FeedbackButton
|
||||||
|
type="satisfied"
|
||||||
|
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||||
|
label={pickAppText(locale, '满意', 'Satisfied')}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => submit('satisfied', comment.trim() || undefined)}
|
||||||
|
/>
|
||||||
|
<FeedbackButton
|
||||||
|
type="revise"
|
||||||
|
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||||
|
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
disabled={!canSubmit || !comment.trim()}
|
||||||
|
onClick={() => submit('revise', comment.trim())}
|
||||||
|
/>
|
||||||
|
<FeedbackButton
|
||||||
|
type="abandon"
|
||||||
|
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||||
|
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => submit('abandon', comment.trim() || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
||||||
|
placeholder={pickAppText(locale, '需要修改时写下具体要求;满意或放弃可选填说明。', 'Describe requested changes; notes are optional for satisfied or abandon.')}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, '反馈将记录到当前任务运行:', 'Feedback will be recorded on run: ')}
|
||||||
|
<span className="font-mono">{runId || '-'}</span>
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
{pickAppText(locale, '会话:', 'Session: ')}
|
||||||
|
<span className="font-mono">{sessionId}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackButton({
|
||||||
|
type,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
actionBusy,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
type: TaskFeedbackType;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
actionBusy: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const isBusy = Boolean(actionBusy?.endsWith(type));
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||||
|
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
return (
|
return (
|
||||||
@ -597,6 +755,24 @@ function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
|||||||
return reason;
|
return reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickFeedbackRunId(task: BackendTask): string | null {
|
||||||
|
const runIds = task.run_ids.filter(Boolean);
|
||||||
|
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||||||
|
const runs = task.runs ?? [];
|
||||||
|
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||||
|
if (!runId) return null;
|
||||||
|
const ordered = [...items].reverse();
|
||||||
|
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||||
|
return [...items].reverse()[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function arrayOfStrings(value: unknown): string[] {
|
function arrayOfStrings(value: unknown): string[] {
|
||||||
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
|
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -777,6 +777,13 @@ export async function getSkillDraftSafety(skillName: string, draftId: string): P
|
|||||||
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/safety`);
|
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/safety`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recheckSkillDraftSafety(skillName: string, draftId: string): Promise<SkillDraftSafetyReport> {
|
||||||
|
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/safety`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSkillDraftEval(skillName: string, draftId: string): Promise<SkillDraftEvalReport> {
|
export async function getSkillDraftEval(skillName: string, draftId: string): Promise<SkillDraftEvalReport> {
|
||||||
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/eval`);
|
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/eval`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# auth-portal server-side runtime config
|
# auth-portal server-side runtime config
|
||||||
|
|
||||||
AUTHZ_API_BASE_URL=http://nano-authz-service:19090
|
AUTHZ_API_BASE_URL=http://beaver-authz-service:19090
|
||||||
DEPLOY_API_BASE_URL=http://nano-deploy-control:8090
|
DEPLOY_API_BASE_URL=http://beaver-deploy-control:8090
|
||||||
DEPLOY_API_TOKEN=change-me
|
DEPLOY_API_TOKEN=change-me
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Registration now goes through AuthZ, while login/runtime lookup still uses deplo
|
|||||||
|
|
||||||
See also:
|
See also:
|
||||||
|
|
||||||
- [`.env.example`](/home/ivan/xuan/nano_project/auth-portal/src/.env.example)
|
- [`.env.example`](/home/ivan/xuan/beaver_project/auth-portal/src/.env.example)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AUTHZ_API_BASE_URL=http://127.0.0.1:19090
|
AUTHZ_API_BASE_URL=http://127.0.0.1:19090
|
||||||
|
|||||||
120
auth-portal/src/app/api/runtime/provider-onboarding/route.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { TokenResponse } from '@/types/auth';
|
||||||
|
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||||
|
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
|
||||||
|
|
||||||
|
const PROVIDER_ONBOARDING_TIMEOUT_MS = 120000;
|
||||||
|
const KNOWN_PROVIDERS = new Set([
|
||||||
|
'anthropic',
|
||||||
|
'openai',
|
||||||
|
'openrouter',
|
||||||
|
'deepseek',
|
||||||
|
'groq',
|
||||||
|
'zhipu',
|
||||||
|
'dashscope',
|
||||||
|
'vllm',
|
||||||
|
'gemini',
|
||||||
|
'moonshot',
|
||||||
|
'minimax',
|
||||||
|
'aihubmix',
|
||||||
|
'siliconflow',
|
||||||
|
'volcengine',
|
||||||
|
]);
|
||||||
|
const API_KEY_OPTIONAL_PROVIDERS = new Set(['vllm']);
|
||||||
|
|
||||||
|
function errorStatus(error: unknown): number {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return error.status;
|
||||||
|
}
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorDetail(error: unknown): string {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : 'provider onboarding failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const locale = normalizePortalLocale(
|
||||||
|
request.cookies.get('beaver_locale')?.value ||
|
||||||
|
request.headers.get('accept-language')
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
api_key?: string;
|
||||||
|
api_base?: string;
|
||||||
|
};
|
||||||
|
const username = body.username?.trim() || '';
|
||||||
|
const password = body.password || '';
|
||||||
|
const provider = body.provider?.trim() || '';
|
||||||
|
const model = body.model?.trim() || '';
|
||||||
|
const apiKey = body.api_key?.trim() || '';
|
||||||
|
const apiBase = body.api_base?.trim() || '';
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({
|
||||||
|
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!KNOWN_PROVIDERS.has(provider)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
detail: pickPortalText(locale, '请选择有效的模型提供商', 'Choose a valid model provider'),
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!model) {
|
||||||
|
return NextResponse.json({
|
||||||
|
detail: pickPortalText(locale, '模型名称不能为空', 'Model name is required'),
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!API_KEY_OPTIONAL_PROVIDERS.has(provider) && !apiKey) {
|
||||||
|
return NextResponse.json({
|
||||||
|
detail: pickPortalText(locale, 'API Key 不能为空', 'API key is required'),
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
if (API_KEY_OPTIONAL_PROVIDERS.has(provider) && !apiKey && !apiBase) {
|
||||||
|
return NextResponse.json({
|
||||||
|
detail: pickPortalText(locale, '本地模型至少需要 API Base', 'Local models require at least an API base URL'),
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialRouting = await callDeployControl<{
|
||||||
|
api_base_url?: string;
|
||||||
|
frontend_base_url?: string;
|
||||||
|
public_url?: string;
|
||||||
|
}>('/api/instances/resolve', { username });
|
||||||
|
|
||||||
|
await callInstanceApi<TokenResponse>(initialRouting.api_base_url || '', '/api/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configuredRouting = await callDeployControl<{
|
||||||
|
api_base_url?: string;
|
||||||
|
frontend_base_url?: string;
|
||||||
|
public_url?: string;
|
||||||
|
}>('/api/instances/configure-provider', {
|
||||||
|
username,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
api_key: apiKey,
|
||||||
|
api_base: apiBase,
|
||||||
|
}, PROVIDER_ONBOARDING_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await callInstanceApi<TokenResponse>(configuredRouting.api_base_url || '', '/api/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(normalizeTokenResponse(response, configuredRouting));
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ detail: errorDetail(error) }, { status: errorStatus(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,26 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f4efe6;
|
--background: #f5f3f1;
|
||||||
--bg-strong: #e6d8bf;
|
--foreground: #0b0b0b;
|
||||||
--panel: rgba(23, 26, 31, 0.88);
|
--primary: #1d1715;
|
||||||
--panel-border: rgba(255, 255, 255, 0.1);
|
--secondary: #e5e2df;
|
||||||
--text: #f7f1e7;
|
--muted: #ddd9d6;
|
||||||
--muted: rgba(247, 241, 231, 0.72);
|
--accent: #cac5c0;
|
||||||
--accent: #ff8d3a;
|
--zinc-50: #f7f5f4;
|
||||||
--accent-strong: #ff6b00;
|
--zinc-100: #ece8e5;
|
||||||
--danger: #ff8787;
|
--zinc-200: #d8d2ce;
|
||||||
--input: rgba(255, 255, 255, 0.08);
|
--zinc-300: #b8aea8;
|
||||||
--input-focus: rgba(255, 141, 58, 0.28);
|
--zinc-400: #8b7e77;
|
||||||
--shadow: 0 40px 90px rgba(26, 24, 21, 0.28);
|
--zinc-500: #6a5e58;
|
||||||
|
--zinc-600: #4f4642;
|
||||||
|
--zinc-700: #342e2b;
|
||||||
|
--sage-100: #e3e8e2;
|
||||||
|
--sage-500: #869683;
|
||||||
|
--slate-100: #e4e7eb;
|
||||||
|
--danger: #a8433f;
|
||||||
|
--shadow-soft:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||||
|
0 6px 24px rgba(0, 0, 0, 0.03);
|
||||||
|
--shadow-floating: 0 12px 40px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -24,12 +34,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
color: var(--foreground);
|
||||||
color: var(--text);
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 141, 58, 0.28), transparent 28%),
|
linear-gradient(90deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
|
||||||
radial-gradient(circle at right center, rgba(19, 104, 93, 0.18), transparent 24%),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
|
||||||
linear-gradient(135deg, var(--bg) 0%, #d6c09b 45%, var(--bg-strong) 100%);
|
var(--background);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
font-family: "Public Sans", Inter, "Avenir Next", "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@ -38,145 +49,366 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-page {
|
.portal-page {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 32px 18px;
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-page:has(.auth-page) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: clamp(24px, 5vh, 56px) clamp(24px, 8vw, 128px);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.08) 48%, rgba(255, 255, 255, 0.58) 100%),
|
||||||
|
url("/login-background.png"),
|
||||||
|
radial-gradient(circle at 24% 50%, rgba(255, 255, 255, 0.88), rgba(245, 243, 241, 0.52) 54%, rgba(245, 243, 241, 0.9) 100%);
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .portal-panel {
|
||||||
|
width: clamp(360px, 34vw, 560px);
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-icon,
|
||||||
|
.ghost-icon-button svg,
|
||||||
|
.button-arrow {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-icon-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: #a29d99;
|
||||||
|
background: transparent;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-icon-button:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .error-text {
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: -4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .primary-button {
|
||||||
|
min-height: 58px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
|
||||||
|
#1d1d1d;
|
||||||
|
box-shadow: 0 8px 18px rgba(29, 23, 21, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .primary-button:hover {
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-arrow {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
position: relative;
|
||||||
|
margin: 34px 0 28px;
|
||||||
|
color: #8f8984;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: calc(50% - 28px);
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(202, 197, 192, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::after {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #9a9692;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--zinc-200);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(247, 245, 244, 0.82);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher span {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--zinc-500);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher button {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--zinc-600);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher button:hover,
|
||||||
|
.language-switcher button.is-active {
|
||||||
|
color: var(--zinc-50);
|
||||||
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-shell {
|
.portal-shell {
|
||||||
width: min(980px, 100%);
|
width: min(1080px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.05fr 0.95fr;
|
grid-template-columns: minmax(0, 1.02fr) minmax(420px, 0.98fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 28px;
|
border: 1px solid rgba(184, 174, 168, 0.42);
|
||||||
box-shadow: var(--shadow);
|
border-radius: 24px;
|
||||||
background: rgba(255, 250, 241, 0.45);
|
background: rgba(247, 245, 244, 0.72);
|
||||||
backdrop-filter: blur(12px);
|
box-shadow: var(--shadow-floating);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-brand {
|
.portal-brand {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 620px;
|
min-height: 620px;
|
||||||
padding: 48px 42px;
|
display: flex;
|
||||||
color: #26180d;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 56px;
|
||||||
|
color: var(--foreground);
|
||||||
background:
|
background:
|
||||||
linear-gradient(160deg, rgba(255, 240, 217, 0.82), rgba(255, 220, 174, 0.64)),
|
linear-gradient(135deg, rgba(227, 232, 226, 0.72), transparent 48%),
|
||||||
linear-gradient(135deg, #ffd7a3, #f3b15f);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.62), rgba(229, 226, 223, 0.58)),
|
||||||
|
var(--zinc-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-brand::after {
|
.portal-brand::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 22px;
|
inset: 24px;
|
||||||
border-radius: 22px;
|
border: 1px solid rgba(184, 174, 168, 0.36);
|
||||||
border: 1px solid rgba(38, 24, 13, 0.08);
|
border-radius: 18px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-logo-lockup {
|
.portal-logo-lockup {
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 112px;
|
padding: 12px;
|
||||||
height: 112px;
|
border: 1px solid var(--zinc-200);
|
||||||
padding: 10px;
|
border-radius: 24px;
|
||||||
border-radius: 28px;
|
background: rgba(247, 245, 244, 0.78);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
box-shadow: var(--shadow-soft);
|
||||||
border: 1px solid rgba(38, 24, 13, 0.1);
|
|
||||||
box-shadow:
|
|
||||||
0 18px 36px rgba(38, 24, 13, 0.1),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-logo-image {
|
.portal-logo-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-kicker {
|
.portal-kicker {
|
||||||
display: inline-flex;
|
width: fit-content;
|
||||||
align-items: center;
|
margin-top: 34px;
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--zinc-200);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(38, 24, 13, 0.08);
|
color: var(--zinc-600);
|
||||||
|
background: rgba(255, 255, 255, 0.46);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-title {
|
.portal-title {
|
||||||
margin: 26px 0 14px;
|
max-width: 520px;
|
||||||
font-size: clamp(36px, 5vw, 60px);
|
margin: 22px 0 16px;
|
||||||
line-height: 0.95;
|
color: var(--primary);
|
||||||
letter-spacing: -0.06em;
|
font-family: "Lora", Georgia, serif;
|
||||||
|
font-size: clamp(42px, 6vw, 68px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.04;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-copy {
|
.portal-copy {
|
||||||
max-width: 460px;
|
max-width: 500px;
|
||||||
font-size: 16px;
|
margin: 0;
|
||||||
line-height: 1.65;
|
color: var(--zinc-600);
|
||||||
color: rgba(38, 24, 13, 0.78);
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-notes {
|
.portal-notes {
|
||||||
|
width: min(100%, 500px);
|
||||||
margin-top: 34px;
|
margin-top: 34px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-note {
|
.portal-note {
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 18px;
|
border: 1px solid rgba(184, 174, 168, 0.42);
|
||||||
background: rgba(255, 255, 255, 0.45);
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(38, 24, 13, 0.08);
|
color: var(--zinc-600);
|
||||||
|
background: rgba(255, 255, 255, 0.48);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-note strong {
|
.portal-note strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
color: var(--primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-note code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--zinc-100);
|
||||||
|
color: var(--zinc-700);
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-panel {
|
.portal-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 32px;
|
padding: 48px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(23, 26, 31, 0.96), rgba(12, 14, 17, 0.94));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(236, 232, 229, 0.66)),
|
||||||
|
var(--zinc-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
width: min(440px, 100%);
|
width: min(456px, 100%);
|
||||||
padding: 34px;
|
padding: 34px;
|
||||||
border-radius: 24px;
|
border: 1px solid rgba(184, 174, 168, 0.5);
|
||||||
background: var(--panel);
|
border-radius: 16px;
|
||||||
border: 1px solid var(--panel-border);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card h1 {
|
.auth-card h1 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 34px;
|
color: var(--primary);
|
||||||
letter-spacing: -0.05em;
|
font-family: "Lora", Georgia, serif;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card p {
|
.auth-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--zinc-500);
|
||||||
line-height: 1.6;
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-form {
|
.auth-form {
|
||||||
margin-top: 26px;
|
margin-top: 28px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@ -187,91 +419,294 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
font-size: 14px;
|
color: var(--zinc-600);
|
||||||
color: var(--muted);
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input {
|
.field input,
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 16px;
|
min-height: 48px;
|
||||||
border: 1px solid transparent;
|
padding: 13px 14px;
|
||||||
border-radius: 14px;
|
border: 1px solid var(--zinc-200);
|
||||||
color: var(--text);
|
border-radius: 12px;
|
||||||
background: var(--input);
|
color: var(--foreground);
|
||||||
|
background: rgba(247, 245, 244, 0.76);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
|
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus {
|
.field input::placeholder {
|
||||||
border-color: rgba(255, 141, 58, 0.65);
|
color: var(--zinc-400);
|
||||||
background: rgba(255, 255, 255, 0.11);
|
}
|
||||||
box-shadow: 0 0 0 5px var(--input-focus);
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--zinc-400);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(202, 197, 192, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
font-size: 14px;
|
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: transform 140ms ease, background 140ms ease, color 140ms ease, opacity 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
width: 100%;
|
border: 1px solid var(--primary);
|
||||||
padding: 14px 18px;
|
color: var(--zinc-50);
|
||||||
border: none;
|
background: var(--primary);
|
||||||
border-radius: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #26180d;
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
transition: transform 140ms ease, filter 140ms ease, opacity 140ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover {
|
.primary-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
filter: brightness(1.02);
|
background: var(--zinc-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:disabled {
|
.secondary-button {
|
||||||
|
border: 1px solid var(--zinc-200);
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled,
|
||||||
|
.secondary-button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.68;
|
opacity: 0.62;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-footer {
|
.auth-footer {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
color: var(--muted);
|
color: var(--zinc-500);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-footer a {
|
.auth-footer a {
|
||||||
color: #ffd7a3;
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-panel {
|
.status-panel {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgba(134, 150, 131, 0.28);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
color: var(--zinc-600);
|
||||||
color: var(--muted);
|
background: rgba(227, 232, 226, 0.54);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - clamp(48px, 10vh, 112px));
|
||||||
|
padding: clamp(30px, 5vh, 54px) clamp(24px, 3.2vw, 44px) clamp(26px, 4vh, 40px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-color: rgba(216, 210, 206, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.05),
|
||||||
|
0 18px 48px rgba(29, 23, 21, 0.12);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.register-card {
|
||||||
|
padding-top: clamp(24px, 3.4vh, 42px);
|
||||||
|
padding-bottom: clamp(22px, 3vh, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-logo {
|
||||||
|
width: clamp(76px, 8vw, 112px);
|
||||||
|
height: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
margin-bottom: clamp(14px, 2.4vh, 24px);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.login-card h1 {
|
||||||
|
margin: 0 0 clamp(22px, 4vh, 38px);
|
||||||
|
color: #1b1b1b;
|
||||||
|
font-family: "Public Sans", Inter, "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
font-size: clamp(26px, 2.4vw, 38px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.register-card h1 {
|
||||||
|
margin-bottom: clamp(18px, 2.6vh, 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-card .auth-form {
|
||||||
|
margin-top: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: clamp(12px, 1.9vh, 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field input,
|
||||||
|
.auth-page .login-field select {
|
||||||
|
min-height: clamp(50px, 6vh, 60px);
|
||||||
|
padding: 14px 52px 14px 18px;
|
||||||
|
border-color: rgba(202, 197, 192, 0.72);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
color: #1d1715;
|
||||||
|
font-size: clamp(15px, 1.1vw, 17px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field .field-icon + input {
|
||||||
|
padding-left: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field input::placeholder {
|
||||||
|
color: #9e9a96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field input:focus,
|
||||||
|
.auth-page .login-field select:focus {
|
||||||
|
border-color: rgba(139, 126, 119, 0.72);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 0 0 4px rgba(216, 210, 206, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-card .error-text {
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: -4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-card .primary-button {
|
||||||
|
min-height: clamp(52px, 6vh, 58px);
|
||||||
|
margin-top: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
|
||||||
|
#1d1d1d;
|
||||||
|
box-shadow: 0 8px 18px rgba(29, 23, 21, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-card .primary-button:hover {
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-card .secondary-button {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
|
.portal-page {
|
||||||
|
align-items: start;
|
||||||
|
padding-top: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-page:has(.auth-page) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 96px 24px 32px;
|
||||||
|
background-position: 38% center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .portal-panel {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.portal-shell {
|
.portal-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-brand {
|
.portal-brand {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
padding-bottom: 30px;
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-brand::after {
|
||||||
|
inset: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.portal-page {
|
.portal-page {
|
||||||
padding: 16px;
|
padding: 76px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-page:has(.auth-page) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
padding: 84px 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.login-card {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 34px 22px 28px;
|
||||||
|
max-height: calc(100vh - 104px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-logo {
|
||||||
|
width: 86px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .auth-card.login-card h1 {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page .login-field input,
|
||||||
|
.auth-page .login-field select {
|
||||||
|
min-height: 54px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
row-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-brand,
|
.portal-brand,
|
||||||
@ -279,6 +714,10 @@ input {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-title {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: 24px 20px;
|
padding: 24px 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { PortalI18nProvider } from '@/lib/i18n/provider';
|
|||||||
import { getServerPortalLocale } from '@/lib/i18n/server';
|
import { getServerPortalLocale } from '@/lib/i18n/server';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Boardware Agent Sandbox Auth Portal',
|
title: 'Boardware Agent Sandbox',
|
||||||
description: 'Boardware Agent Sandbox Auth Portal',
|
description: 'Boardware Agent Sandbox sign-in',
|
||||||
icons: {
|
icons: {
|
||||||
icon: '/boardware-logo.jpg',
|
icon: '/boardware-logo.jpg',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@ -37,74 +38,56 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="portal-page">
|
<main className="portal-page">
|
||||||
<div className="absolute right-5 top-5 z-10">
|
<div className="portal-toolbar">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<section className="portal-shell">
|
<section className="auth-page">
|
||||||
<div className="portal-brand">
|
<div className="portal-panel">
|
||||||
<div className="portal-logo-lockup">
|
<div className="auth-card login-card">
|
||||||
<Image
|
<Image
|
||||||
src="/boardware-logo.jpg"
|
src="/boardware-logo.jpg"
|
||||||
alt="Boardware logo"
|
alt="Boardware logo"
|
||||||
width={128}
|
width={120}
|
||||||
height={128}
|
height={120}
|
||||||
className="portal-logo-image"
|
className="login-logo"
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
<h1>Beaver Agentsandbox</h1>
|
||||||
<div className="portal-kicker">Auth Portal</div>
|
|
||||||
<h1 className="portal-title">Boardware Agent Sandbox</h1>
|
|
||||||
<p className="portal-copy">
|
|
||||||
{pickPortalText(
|
|
||||||
locale,
|
|
||||||
'这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL,后续前后端请求都留在那套容器里。',
|
|
||||||
'This portal only handles authentication. After sign-in, you are redirected to your dedicated runtime URL and all later requests stay inside that container.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="portal-notes">
|
|
||||||
<div className="portal-note">
|
|
||||||
<strong>{pickPortalText(locale, '容器边界', 'Container boundary')}</strong>
|
|
||||||
{pickPortalText(
|
|
||||||
locale,
|
|
||||||
'登录注册先经过独立 auth portal,再跳到专属实例。一用户一套前后端容器不变。',
|
|
||||||
'Authentication happens in this standalone portal first, then the browser jumps into the dedicated runtime. Each user keeps an isolated frontend/backend container pair.'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="portal-note">
|
|
||||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
|
||||||
{pickPortalText(locale, '当前登录完成后将回到:', 'After sign-in you will return to:')} <code>{nextPath}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="portal-panel">
|
|
||||||
<div className="auth-card">
|
|
||||||
<h1>{pickPortalText(locale, '登录', 'Sign In')}</h1>
|
|
||||||
<p>{pickPortalText(locale, '输入已有账号,认证完成后直接进入目标容器前端。', 'Use an existing account and continue straight into the target runtime UI.')}</p>
|
|
||||||
|
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '邮箱或用户名', 'Email or username')}</label>
|
||||||
|
<MailIcon />
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
placeholder={pickPortalText(locale, '邮箱', 'Email')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
<label className="visually-hidden" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||||
|
<LockIcon />
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
|
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
className="ghost-icon-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
aria-label={pickPortalText(locale, showPassword ? '隐藏密码' : '显示密码', showPassword ? 'Hide password' : 'Show password')}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="error-text">{error}</div>
|
<div className="error-text">{error}</div>
|
||||||
@ -112,12 +95,17 @@ export default function LoginPage() {
|
|||||||
<button className="primary-button" type="submit" disabled={loading}>
|
<button className="primary-button" type="submit" disabled={loading}>
|
||||||
{loading
|
{loading
|
||||||
? pickPortalText(locale, '登录中...', 'Signing in...')
|
? pickPortalText(locale, '登录中...', 'Signing in...')
|
||||||
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
|
: <ArrowRightIcon />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="auth-footer">
|
<div className="login-divider">
|
||||||
{pickPortalText(locale, '还没有账号?', "Don't have an account yet?")} <Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '去注册', 'Create one')}</Link>
|
<span>{pickPortalText(locale, '或', 'or')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-footer login-footer">
|
||||||
|
<span>{pickPortalText(locale, '还没有账号?', "Don't have an account yet?")}</span>
|
||||||
|
<Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '注册', 'Create one')} <span aria-hidden="true">›</span></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -125,3 +113,40 @@ export default function LoginPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MailIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4.75 6.75h14.5v10.5H4.75z" />
|
||||||
|
<path d="m5.25 7.25 6.75 5.5 6.75-5.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="field-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M7.25 10.25h9.5v8H7.25z" />
|
||||||
|
<path d="M9 10.25V8a3 3 0 0 1 6 0v2.25" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3.75 12s2.8-5.25 8.25-5.25S20.25 12 20.25 12s-2.8 5.25-8.25 5.25S3.75 12 3.75 12Z" />
|
||||||
|
<path d="m4.75 4.75 14.5 14.5" />
|
||||||
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowRightIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="button-arrow" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M5 12h13" />
|
||||||
|
<path d="m13 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,9 +6,24 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client';
|
import { buildFrontendHandoffUrl, configureProviderOnboarding, register, withNext } from '@/lib/auth-client';
|
||||||
import { pickPortalText } from '@/lib/i18n/core';
|
import { pickPortalText } from '@/lib/i18n/core';
|
||||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||||
|
import type { TokenResponse } from '@/types/auth';
|
||||||
|
|
||||||
|
const PROVIDER_OPTIONS = [
|
||||||
|
{ id: 'openrouter', label: 'OpenRouter', model: 'openrouter/anthropic/claude-sonnet-4.5' },
|
||||||
|
{ id: 'openai', label: 'OpenAI', model: 'openai/gpt-5' },
|
||||||
|
{ id: 'anthropic', label: 'Anthropic', model: 'claude-sonnet-4.5' },
|
||||||
|
{ id: 'dashscope', label: 'DashScope', model: 'qwen-plus' },
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek', model: 'deepseek-chat' },
|
||||||
|
{ id: 'gemini', label: 'Gemini', model: 'gemini/gemini-2.5-pro' },
|
||||||
|
{ id: 'moonshot', label: 'Moonshot', model: 'moonshot/kimi-k2.5' },
|
||||||
|
{ id: 'minimax', label: 'MiniMax', model: 'minimax/minimax-m1' },
|
||||||
|
{ id: 'siliconflow', label: 'SiliconFlow', model: 'Qwen/Qwen3-32B' },
|
||||||
|
{ id: 'volcengine', label: 'VolcEngine', model: 'volcengine/deepseek-v3' },
|
||||||
|
{ id: 'vllm', label: 'vLLM / Local', model: 'hosted_vllm/local-model' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { locale } = usePortalI18n();
|
const { locale } = usePortalI18n();
|
||||||
@ -19,8 +34,18 @@ export default function RegisterPage() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [registrationResponse, setRegistrationResponse] = useState<TokenResponse | null>(null);
|
||||||
|
const [provider, setProvider] = useState(PROVIDER_OPTIONS[0].id);
|
||||||
|
const [model, setModel] = useState(PROVIDER_OPTIONS[0].model);
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [apiBase, setApiBase] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [onboardingLoading, setOnboardingLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [onboardingError, setOnboardingError] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -32,7 +57,7 @@ export default function RegisterPage() {
|
|||||||
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
|
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
|
||||||
}
|
}
|
||||||
const response = await register(username, email, password);
|
const response = await register(username, email, password);
|
||||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
setRegistrationResponse(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
|
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
@ -40,122 +65,248 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProviderChange = (value: string) => {
|
||||||
|
const previousDefault = PROVIDER_OPTIONS.find((item) => item.id === provider)?.model;
|
||||||
|
const selected = PROVIDER_OPTIONS.find((item) => item.id === value);
|
||||||
|
setProvider(value);
|
||||||
|
if (selected && (!model.trim() || model === previousDefault)) {
|
||||||
|
setModel(selected.model);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueWithoutProvider = () => {
|
||||||
|
if (!registrationResponse) return;
|
||||||
|
window.location.replace(buildFrontendHandoffUrl(registrationResponse, nextPath));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProviderSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!registrationResponse) return;
|
||||||
|
setOnboardingLoading(true);
|
||||||
|
setOnboardingError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await configureProviderOnboarding({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
api_key: apiKey,
|
||||||
|
api_base: apiBase,
|
||||||
|
});
|
||||||
|
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||||
|
} catch (err) {
|
||||||
|
setOnboardingError(err instanceof Error ? err.message : pickPortalText(locale, '模型配置失败,请检查后重试', 'Provider setup failed. Check the values and try again.'));
|
||||||
|
} finally {
|
||||||
|
setOnboardingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="portal-page">
|
<main className="portal-page">
|
||||||
<div className="absolute right-5 top-5 z-10">
|
<div className="portal-toolbar">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<section className="portal-shell">
|
<section className="auth-page">
|
||||||
<div className="portal-brand">
|
|
||||||
<div className="portal-logo-lockup">
|
|
||||||
<Image
|
|
||||||
src="/boardware-logo.jpg"
|
|
||||||
alt="Boardware logo"
|
|
||||||
width={128}
|
|
||||||
height={128}
|
|
||||||
className="portal-logo-image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="portal-kicker">Auth Portal</div>
|
|
||||||
<h1 className="portal-title">Create Runtime</h1>
|
|
||||||
<p className="portal-copy">
|
|
||||||
{pickPortalText(
|
|
||||||
locale,
|
|
||||||
'注册不仅建立登录账号,还会触发专属实例创建和 backend 身份分配。认证完成后会直接进入你的专属 URL。',
|
|
||||||
'Sign-up not only creates a login account, it also provisions your dedicated runtime and backend identity. After authentication, you go straight into your own URL.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="portal-notes">
|
|
||||||
<div className="portal-note">
|
|
||||||
<strong>{pickPortalText(locale, '注册结果', 'Provisioning result')}</strong>
|
|
||||||
{pickPortalText(
|
|
||||||
locale,
|
|
||||||
'AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化,auth portal 最后把你转交到该实例前端。',
|
|
||||||
'AuthZ coordinates deploy-control to create the runtime, initialize backend identity, and then the portal hands the browser over to that frontend.'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="portal-note">
|
|
||||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
|
||||||
{pickPortalText(locale, '当前注册完成后将回到:', 'After sign-up you will return to:')} <code>{nextPath}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="portal-panel">
|
<div className="portal-panel">
|
||||||
<div className="auth-card">
|
{registrationResponse ? (
|
||||||
<h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
|
<div className="auth-card login-card register-card">
|
||||||
<p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
|
<BrandHeader title={pickPortalText(locale, '配置模型', 'Model Setup')} />
|
||||||
|
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleProviderSubmit}>
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
<label className="visually-hidden" htmlFor="provider">{pickPortalText(locale, '模型提供商', 'Model provider')}</label>
|
||||||
<input
|
<select
|
||||||
id="username"
|
id="provider"
|
||||||
value={username}
|
value={provider}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => handleProviderChange(event.target.value)}
|
||||||
autoComplete="username"
|
disabled={onboardingLoading}
|
||||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
>
|
||||||
required
|
{PROVIDER_OPTIONS.map((item) => (
|
||||||
/>
|
<option key={item.id} value={item.id}>{item.label}</option>
|
||||||
</div>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
|
<label className="visually-hidden" htmlFor="model">{pickPortalText(locale, '模型', 'Model')}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="model"
|
||||||
type="email"
|
value={model}
|
||||||
value={email}
|
onChange={(event) => setModel(event.target.value)}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
placeholder={pickPortalText(locale, '模型', 'Model')}
|
||||||
autoComplete="email"
|
required
|
||||||
placeholder={pickPortalText(locale, '例如:steven@example.com', 'Example: steven@example.com')}
|
disabled={onboardingLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
<label className="visually-hidden" htmlFor="apiKey">API Key</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="apiKey"
|
||||||
type="password"
|
type={showApiKey ? 'text' : 'password'}
|
||||||
value={password}
|
value={apiKey}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="off"
|
||||||
placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
|
placeholder={provider === 'vllm' ? pickPortalText(locale, 'API Key 可选', 'API key optional') : 'API Key'}
|
||||||
required
|
required={provider !== 'vllm'}
|
||||||
/>
|
disabled={onboardingLoading}
|
||||||
</div>
|
/>
|
||||||
|
<VisibilityButton
|
||||||
|
active={showApiKey}
|
||||||
|
onClick={() => setShowApiKey((value) => !value)}
|
||||||
|
label={pickPortalText(locale, showApiKey ? '隐藏 API Key' : '显示 API Key', showApiKey ? 'Hide API key' : 'Show API key')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field login-field">
|
||||||
<label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
|
<label className="visually-hidden" htmlFor="apiBase">API Base</label>
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="apiBase"
|
||||||
type="password"
|
value={apiBase}
|
||||||
value={confirmPassword}
|
onChange={(event) => setApiBase(event.target.value)}
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
placeholder="API Base"
|
||||||
autoComplete="new-password"
|
disabled={onboardingLoading}
|
||||||
placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
|
/>
|
||||||
required
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="error-text">{error}</div>
|
<div className="error-text">{onboardingError}</div>
|
||||||
|
|
||||||
<button className="primary-button" type="submit" disabled={loading}>
|
<button className="primary-button" type="submit" disabled={onboardingLoading}>
|
||||||
{loading
|
{onboardingLoading ? pickPortalText(locale, '写入中...', 'Saving...') : <ArrowRightIcon />}
|
||||||
? pickPortalText(locale, '注册中...', 'Creating account...')
|
</button>
|
||||||
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
|
<button className="secondary-button" type="button" onClick={continueWithoutProvider} disabled={onboardingLoading}>
|
||||||
</button>
|
{pickPortalText(locale, '跳过', 'Skip')}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
<div className="auth-footer">
|
|
||||||
{pickPortalText(locale, '已有账号?', 'Already have an account?')} <Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '去登录', 'Sign in')}</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="auth-card login-card register-card">
|
||||||
|
<BrandHeader title="Beaver Agentsandbox" />
|
||||||
|
|
||||||
<div className="status-panel">
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
{pickPortalText(locale, 'Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。', 'The portal first calls the deployment controller to create the runtime, then redirects the browser into the instance URL.')}
|
<div className="field login-field">
|
||||||
|
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder={pickPortalText(locale, '用户名', 'Username')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field login-field">
|
||||||
|
<label className="visually-hidden" htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder={pickPortalText(locale, '邮箱', 'Email')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field login-field">
|
||||||
|
<label className="visually-hidden" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={pickPortalText(locale, '密码', 'Password')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<VisibilityButton
|
||||||
|
active={showPassword}
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
label={pickPortalText(locale, showPassword ? '隐藏密码' : '显示密码', showPassword ? 'Hide password' : 'Show password')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field login-field">
|
||||||
|
<label className="visually-hidden" htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={pickPortalText(locale, '确认密码', 'Confirm password')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<VisibilityButton
|
||||||
|
active={showConfirmPassword}
|
||||||
|
onClick={() => setShowConfirmPassword((value) => !value)}
|
||||||
|
label={pickPortalText(locale, showConfirmPassword ? '隐藏确认密码' : '显示确认密码', showConfirmPassword ? 'Hide confirm password' : 'Show confirm password')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="error-text">{error}</div>
|
||||||
|
|
||||||
|
<button className="primary-button" type="submit" disabled={loading}>
|
||||||
|
{loading ? pickPortalText(locale, '注册中...', 'Creating...') : <ArrowRightIcon />}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-divider">
|
||||||
|
<span>{pickPortalText(locale, '或', 'or')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-footer login-footer">
|
||||||
|
<span>{pickPortalText(locale, '已有账号?', 'Already have an account?')}</span>
|
||||||
|
<Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '登录', 'Sign in')} <span aria-hidden="true">›</span></Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BrandHeader({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src="/boardware-logo.jpg"
|
||||||
|
alt="Boardware logo"
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="login-logo"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisibilityButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className="ghost-icon-button" type="button" onClick={onClick} aria-label={label} aria-pressed={active}>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3.75 12s2.8-5.25 8.25-5.25S20.25 12 20.25 12s-2.8 5.25-8.25 5.25S3.75 12 3.75 12Z" />
|
||||||
|
<path d="m4.75 4.75 14.5 14.5" />
|
||||||
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowRightIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="button-arrow" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M5 12h13" />
|
||||||
|
<path d="m13 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -11,18 +11,14 @@ export function LanguageSwitcher() {
|
|||||||
const { locale, setLocale } = usePortalI18n();
|
const { locale, setLocale } = usePortalI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/25 p-1 backdrop-blur">
|
<div className="language-switcher">
|
||||||
<span className="ml-1 text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">Lang</span>
|
<span>Lang</span>
|
||||||
{OPTIONS.map((option) => (
|
{OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLocale(option.value)}
|
onClick={() => setLocale(option.value)}
|
||||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
|
className={locale === option.value ? 'is-active' : undefined}
|
||||||
locale === option.value
|
|
||||||
? 'bg-white text-slate-900'
|
|
||||||
: 'text-white/75 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,6 +5,16 @@ import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
|
|||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 8000;
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||||
|
const PROVIDER_ONBOARDING_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
|
export interface ProviderOnboardingPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
api_key: string;
|
||||||
|
api_base?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value?: string | null): string | null {
|
function normalizeBaseUrl(value?: string | null): string | null {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
@ -82,6 +92,13 @@ export async function register(username: string, email: string, password: string
|
|||||||
}, REGISTER_REQUEST_TIMEOUT_MS);
|
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function configureProviderOnboarding(payload: ProviderOnboardingPayload): Promise<TokenResponse> {
|
||||||
|
return fetchJSON('/api/runtime/provider-onboarding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}, PROVIDER_ONBOARDING_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||||
const locale = getCurrentPortalLocale();
|
const locale = getCurrentPortalLocale();
|
||||||
const frontendBaseUrl = getFrontendBaseUrl(response);
|
const frontendBaseUrl = getFrontendBaseUrl(response);
|
||||||
|
|||||||
@ -69,7 +69,7 @@ async function fetchJson<T>(url: string, init?: RequestInit, timeoutMs = REQUEST
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callDeployControl<T>(path: string, payload: JsonObject): Promise<T> {
|
export async function callDeployControl<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (DEPLOY_API_TOKEN) {
|
if (DEPLOY_API_TOKEN) {
|
||||||
headers.Authorization = `Bearer ${DEPLOY_API_TOKEN}`;
|
headers.Authorization = `Bearer ${DEPLOY_API_TOKEN}`;
|
||||||
@ -78,7 +78,7 @@ export async function callDeployControl<T>(path: string, payload: JsonObject): P
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
}, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callAuthzService<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
export async function callAuthzService<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||||
|
|||||||
BIN
auth-portal/src/public/login-background.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@ -1,9 +1,9 @@
|
|||||||
# authz-service runtime config
|
# authz-service runtime config
|
||||||
|
|
||||||
AUTHZ_ISSUER=http://nano-authz-service:19090
|
AUTHZ_ISSUER=http://beaver-authz-service:19090
|
||||||
AUTHZ_INTERNAL_TOKEN=change-me
|
AUTHZ_INTERNAL_TOKEN=change-me
|
||||||
AUTHZ_ACCESS_TOKEN_TTL_SECONDS=3600
|
AUTHZ_ACCESS_TOKEN_TTL_SECONDS=3600
|
||||||
AUTHZ_UPSTREAM_TIMEOUT_SECONDS=15
|
AUTHZ_UPSTREAM_TIMEOUT_SECONDS=15
|
||||||
|
|
||||||
DEPLOY_API_BASE_URL=http://nano-deploy-control:8090
|
DEPLOY_API_BASE_URL=http://beaver-deploy-control:8090
|
||||||
DEPLOY_API_TOKEN=change-me
|
DEPLOY_API_TOKEN=change-me
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/ivan/xuan/nano_project/authz-service
|
cd /home/ivan/xuan/beaver_project/authz-service
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
set -a
|
set -a
|
||||||
. ./.env
|
. ./.env
|
||||||
@ -57,4 +57,4 @@ curl http://127.0.0.1:19090/.well-known/jwks.json
|
|||||||
|
|
||||||
接口说明仍然看:
|
接口说明仍然看:
|
||||||
|
|
||||||
- `/home/ivan/xuan/nano_project/authz-service/src/README.md`
|
- `/home/ivan/xuan/beaver_project/authz-service/src/README.md`
|
||||||
|
|||||||
@ -434,10 +434,6 @@ async def portal_register(req: PortalRegisterRequest) -> dict[str, Any]:
|
|||||||
optional_fields = {
|
optional_fields = {
|
||||||
"instance_id": _clean_optional(req.instance_id),
|
"instance_id": _clean_optional(req.instance_id),
|
||||||
"backend_name": _clean_optional(req.backend_name),
|
"backend_name": _clean_optional(req.backend_name),
|
||||||
"provider": _clean_optional(req.provider),
|
|
||||||
"model": _clean_optional(req.model),
|
|
||||||
"api_key": _clean_optional(req.api_key),
|
|
||||||
"api_base": _clean_optional(req.api_base),
|
|
||||||
"image_name": _clean_optional(req.image_name),
|
"image_name": _clean_optional(req.image_name),
|
||||||
}
|
}
|
||||||
for key, value in optional_fields.items():
|
for key, value in optional_fields.items():
|
||||||
|
|||||||
@ -140,10 +140,6 @@ class PortalRegisterRequest(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
instance_id: str | None = None
|
instance_id: str | None = None
|
||||||
backend_name: str | None = None
|
backend_name: str | None = None
|
||||||
provider: str | None = None
|
|
||||||
model: str | None = None
|
|
||||||
api_key: str | None = None
|
|
||||||
api_base: str | None = None
|
|
||||||
image_name: str | None = None
|
image_name: str | None = None
|
||||||
replace: bool = False
|
replace: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
IMAGE_NAME="${IMAGE_NAME:-nano/authz-service:latest}"
|
IMAGE_NAME="${IMAGE_NAME:-beaver/authz-service:latest}"
|
||||||
CONTAINER_NAME="${CONTAINER_NAME:-nano-authz-service}"
|
CONTAINER_NAME="${CONTAINER_NAME:-beaver-authz-service}"
|
||||||
DATA_ROOT="${DATA_ROOT:-${SCRIPT_DIR}/runtime/data}"
|
DATA_ROOT="${DATA_ROOT:-${SCRIPT_DIR}/runtime/data}"
|
||||||
HOST_PORT="${HOST_PORT:-19090}"
|
HOST_PORT="${HOST_PORT:-19090}"
|
||||||
HOST_BIND_IP="${HOST_BIND_IP:-0.0.0.0}"
|
HOST_BIND_IP="${HOST_BIND_IP:-0.0.0.0}"
|
||||||
|
|||||||
@ -4,8 +4,8 @@ DEPLOY_CONTROL_HOST=0.0.0.0
|
|||||||
DEPLOY_CONTROL_PORT=8090
|
DEPLOY_CONTROL_PORT=8090
|
||||||
DEPLOY_CONTROL_API_TOKEN=change-me
|
DEPLOY_CONTROL_API_TOKEN=change-me
|
||||||
|
|
||||||
APP_INSTANCE_IMAGE=nano/app-instance:latest
|
APP_INSTANCE_IMAGE=beaver/app-instance:latest
|
||||||
APP_INSTANCE_NETWORK_NAME=nano-instance-edge
|
APP_INSTANCE_NETWORK_NAME=beaver-instance-edge
|
||||||
|
|
||||||
APP_INSTANCE_PROVIDER=openai
|
APP_INSTANCE_PROVIDER=openai
|
||||||
APP_INSTANCE_MODEL=openai/gpt-5
|
APP_INSTANCE_MODEL=openai/gpt-5
|
||||||
@ -13,7 +13,7 @@ APP_INSTANCE_API_KEY=sk-xxxxxxxx
|
|||||||
APP_INSTANCE_API_BASE=
|
APP_INSTANCE_API_BASE=
|
||||||
|
|
||||||
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
||||||
DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090
|
DEFAULT_AUTHZ_BASE_URL=http://beaver-authz-service:19090
|
||||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
||||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
|
||||||
|
|||||||
@ -11,12 +11,12 @@
|
|||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
- `POST /api/instances/register`
|
- `POST /api/instances/register`
|
||||||
- `POST /api/instances/resolve`
|
- `POST /api/instances/resolve`
|
||||||
|
- `POST /api/instances/configure-provider`
|
||||||
- `DELETE /api/instances/{instance_id}`
|
- `DELETE /api/instances/{instance_id}`
|
||||||
|
|
||||||
## 关键环境变量
|
## 关键环境变量
|
||||||
|
|
||||||
- `DEPLOY_CONTROL_API_TOKEN`
|
- `DEPLOY_CONTROL_API_TOKEN`
|
||||||
- `APP_INSTANCE_API_KEY`
|
|
||||||
- `DEFAULT_AUTHZ_BASE_URL`
|
- `DEFAULT_AUTHZ_BASE_URL`
|
||||||
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
|
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
|
||||||
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
|
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
|
||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
建议直接参考:
|
建议直接参考:
|
||||||
|
|
||||||
- [`.env.example`](/home/ivan/xuan/nano_project/deploy-control/.env.example)
|
- [`.env.example`](/home/ivan/xuan/beaver_project/deploy-control/.env.example)
|
||||||
|
|
||||||
默认实例 URL 形如:
|
默认实例 URL 形如:
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
|||||||
## 本机启动
|
## 本机启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/ivan/xuan/nano_project/deploy-control
|
cd /home/ivan/xuan/beaver_project/deploy-control
|
||||||
uv run server.py
|
uv run server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -58,8 +58,8 @@ uv run server.py
|
|||||||
如果要容器化运行,需要挂载:
|
如果要容器化运行,需要挂载:
|
||||||
|
|
||||||
- Docker socket:`/var/run/docker.sock`
|
- Docker socket:`/var/run/docker.sock`
|
||||||
- `/home/ivan/xuan/nano_project/app-instance`
|
- `/home/ivan/xuan/beaver_project/app-instance`
|
||||||
- `/home/ivan/xuan/nano_project/router-proxy`
|
- `/home/ivan/xuan/beaver_project/router-proxy`
|
||||||
|
|
||||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||||
|
|
||||||
@ -72,20 +72,21 @@ uv run server.py
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name nano-deploy-control \
|
--name beaver-deploy-control \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--network nano-instance-edge \
|
--network beaver-instance-edge \
|
||||||
-p 8090:8090 \
|
-p 8090:8090 \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v /home/ivan/xuan/nano_project/app-instance:/home/ivan/xuan/nano_project/app-instance \
|
-v /home/ivan/xuan/beaver_project/app-instance:/home/ivan/xuan/beaver_project/app-instance \
|
||||||
-v /home/ivan/xuan/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \
|
-v /home/ivan/xuan/beaver_project/router-proxy:/home/ivan/xuan/beaver_project/router-proxy \
|
||||||
-e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \
|
-e APP_INSTANCE_DIR=/home/ivan/xuan/beaver_project/app-instance \
|
||||||
-e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \
|
-e ROUTER_PROXY_DIR=/home/ivan/xuan/beaver_project/router-proxy \
|
||||||
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
||||||
-e APP_INSTANCE_IMAGE=nano/app-instance:latest \
|
-e APP_INSTANCE_IMAGE=beaver/app-instance:latest \
|
||||||
-e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \
|
-e APP_INSTANCE_NETWORK_NAME=beaver-instance-edge \
|
||||||
-e APP_INSTANCE_API_KEY=sk-xxxxxxxx \
|
beaver/deploy-control:latest
|
||||||
nano/deploy-control:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
||||||
|
|
||||||
|
新实例注册时不会写入模型 provider/API key。注册后由 `auth-portal` 引导页调用 `POST /api/instances/configure-provider`,在用户确认后写入该实例配置并重启实例容器。
|
||||||
|
|||||||
@ -34,12 +34,8 @@ PROXY_RELOAD_SCRIPT = Path(
|
|||||||
).resolve()
|
).resolve()
|
||||||
|
|
||||||
API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
|
API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
|
||||||
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "nano/app-instance:latest").strip()
|
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "beaver/app-instance:latest").strip()
|
||||||
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "nano-instance-edge").strip()
|
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "beaver-instance-edge").strip()
|
||||||
DEFAULT_PROVIDER = os.environ.get("APP_INSTANCE_PROVIDER", "openai").strip()
|
|
||||||
DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
|
|
||||||
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
|
||||||
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
|
||||||
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
||||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
||||||
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
||||||
@ -53,6 +49,23 @@ HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS",
|
|||||||
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
|
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
|
||||||
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
||||||
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
||||||
|
KNOWN_PROVIDERS = {
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"openrouter",
|
||||||
|
"deepseek",
|
||||||
|
"groq",
|
||||||
|
"zhipu",
|
||||||
|
"dashscope",
|
||||||
|
"vllm",
|
||||||
|
"gemini",
|
||||||
|
"moonshot",
|
||||||
|
"minimax",
|
||||||
|
"aihubmix",
|
||||||
|
"siliconflow",
|
||||||
|
"volcengine",
|
||||||
|
}
|
||||||
|
API_KEY_OPTIONAL_PROVIDERS = {"vllm"}
|
||||||
|
|
||||||
|
|
||||||
class ApiError(Exception):
|
class ApiError(Exception):
|
||||||
@ -87,6 +100,13 @@ def run_command(args: list[str], *, cwd: Path | None = None, extra_env: dict[str
|
|||||||
return completed.stdout.strip()
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
|
||||||
def load_registry() -> dict[str, Any]:
|
def load_registry() -> dict[str, Any]:
|
||||||
if not REGISTRY_PATH.exists():
|
if not REGISTRY_PATH.exists():
|
||||||
return {"instances": []}
|
return {"instances": []}
|
||||||
@ -210,10 +230,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
ensure_network()
|
ensure_network()
|
||||||
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
|
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
|
||||||
public_url = build_public_url(public_host)
|
public_url = build_public_url(public_host)
|
||||||
provider = str(payload.get("provider", "") or DEFAULT_PROVIDER).strip() or DEFAULT_PROVIDER
|
|
||||||
model = str(payload.get("model", "") or DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
|
||||||
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
|
||||||
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
|
||||||
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
||||||
authz_outlook_mcp_url = str(
|
authz_outlook_mcp_url = str(
|
||||||
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
|
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
|
||||||
@ -221,9 +237,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
||||||
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
raise ApiError(HTTPStatus.BAD_REQUEST, "api key is required for new instances")
|
|
||||||
|
|
||||||
command = [
|
command = [
|
||||||
str(CREATE_INSTANCE_SCRIPT),
|
str(CREATE_INSTANCE_SCRIPT),
|
||||||
"--image",
|
"--image",
|
||||||
@ -238,12 +251,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
username,
|
username,
|
||||||
"--email",
|
"--email",
|
||||||
email,
|
email,
|
||||||
"--provider",
|
"--skip-provider-config",
|
||||||
provider,
|
|
||||||
"--model",
|
|
||||||
model,
|
|
||||||
"--api-key",
|
|
||||||
api_key,
|
|
||||||
"--backend-name",
|
"--backend-name",
|
||||||
backend_name,
|
backend_name,
|
||||||
"--public-url",
|
"--public-url",
|
||||||
@ -253,8 +261,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"--network",
|
"--network",
|
||||||
INSTANCE_NETWORK_NAME,
|
INSTANCE_NETWORK_NAME,
|
||||||
]
|
]
|
||||||
if api_base:
|
|
||||||
command.extend(["--api-base", api_base])
|
|
||||||
if authz_base_url:
|
if authz_base_url:
|
||||||
command.extend(["--authz-base-url", authz_base_url])
|
command.extend(["--authz-base-url", authz_base_url])
|
||||||
if authz_outlook_mcp_url:
|
if authz_outlook_mcp_url:
|
||||||
@ -282,6 +288,109 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_instance_provider(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
instance_id = str(payload.get("instance_id", "") or "").strip()
|
||||||
|
username = str(payload.get("username", "") or "").strip()
|
||||||
|
if not instance_id and not username:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "instance_id or username is required")
|
||||||
|
|
||||||
|
record = None
|
||||||
|
if instance_id:
|
||||||
|
record = get_registry_record(instance_id=instance_id)
|
||||||
|
if record is None and username:
|
||||||
|
record = get_registry_record(username=username)
|
||||||
|
if record is None:
|
||||||
|
raise ApiError(HTTPStatus.NOT_FOUND, "instance not found")
|
||||||
|
|
||||||
|
if payload.get("skip") is True:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"skipped": True,
|
||||||
|
"instance": record,
|
||||||
|
"public_url": str(record.get("public_url", "") or ""),
|
||||||
|
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
||||||
|
"api_base_url": build_internal_api_base_url(record),
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = str(payload.get("provider", "") or "").strip()
|
||||||
|
model = str(payload.get("model", "") or "").strip()
|
||||||
|
api_key = str(payload.get("api_key", "") or "").strip()
|
||||||
|
api_base = str(payload.get("api_base", "") or "").strip()
|
||||||
|
|
||||||
|
if provider not in KNOWN_PROVIDERS:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, f"unsupported provider: {provider or '(empty)'}")
|
||||||
|
if not model:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "model is required")
|
||||||
|
if provider not in API_KEY_OPTIONAL_PROVIDERS and not api_key:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "api key is required")
|
||||||
|
if provider in API_KEY_OPTIONAL_PROVIDERS and not (api_key or api_base):
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "api key or api base is required")
|
||||||
|
|
||||||
|
raw_config_path = str(record.get("config_path", "") or "").strip()
|
||||||
|
config_path = Path(raw_config_path).expanduser()
|
||||||
|
if not raw_config_path:
|
||||||
|
beaver_home = Path(str(record.get("beaver_home", "") or "")).expanduser()
|
||||||
|
config_path = beaver_home / "config.json"
|
||||||
|
if not config_path.is_absolute():
|
||||||
|
config_path = (APP_INSTANCE_DIR / config_path).resolve()
|
||||||
|
if not config_path.exists():
|
||||||
|
raise ApiError(HTTPStatus.NOT_FOUND, f"instance config not found: {config_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ApiError(HTTPStatus.BAD_GATEWAY, f"invalid instance config: {config_path}") from exc
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance config must be an object: {config_path}")
|
||||||
|
|
||||||
|
agents = raw.get("agents")
|
||||||
|
if not isinstance(agents, dict):
|
||||||
|
agents = {}
|
||||||
|
raw["agents"] = agents
|
||||||
|
defaults = agents.get("defaults")
|
||||||
|
if not isinstance(defaults, dict):
|
||||||
|
defaults = {}
|
||||||
|
agents["defaults"] = defaults
|
||||||
|
providers = raw.get("providers")
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
providers = {}
|
||||||
|
raw["providers"] = providers
|
||||||
|
|
||||||
|
provider_payload: dict[str, Any] = {}
|
||||||
|
if api_key:
|
||||||
|
provider_payload["apiKey"] = api_key
|
||||||
|
if api_base:
|
||||||
|
provider_payload["apiBase"] = api_base
|
||||||
|
providers.clear()
|
||||||
|
providers[provider] = provider_payload
|
||||||
|
defaults["workspace"] = str(defaults.get("workspace", "") or "/root/.beaver/workspace")
|
||||||
|
defaults["provider"] = provider
|
||||||
|
defaults["model"] = model
|
||||||
|
|
||||||
|
write_json_file(config_path, raw)
|
||||||
|
|
||||||
|
container_name = str(record.get("container_name", "") or "").strip()
|
||||||
|
if not container_name:
|
||||||
|
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance container name is missing")
|
||||||
|
run_command(["docker", "restart", container_name])
|
||||||
|
wait_for_backend(record)
|
||||||
|
ensure_proxy()
|
||||||
|
|
||||||
|
updated = get_registry_record(instance_id=str(record.get("instance_id", "") or instance_id))
|
||||||
|
if updated is None:
|
||||||
|
updated = record
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"skipped": False,
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"instance": updated,
|
||||||
|
"public_url": str(updated.get("public_url", "") or ""),
|
||||||
|
"frontend_base_url": str(updated.get("frontend_base_url", "") or updated.get("public_url", "") or ""),
|
||||||
|
"api_base_url": build_internal_api_base_url(updated),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _upsert_registry_record(record: dict[str, Any]) -> dict[str, Any]:
|
def _upsert_registry_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
instance_id = str(record.get("instance_id", "") or "").strip()
|
instance_id = str(record.get("instance_id", "") or "").strip()
|
||||||
if not instance_id:
|
if not instance_id:
|
||||||
@ -467,6 +576,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
payload = self._read_json_body()
|
payload = self._read_json_body()
|
||||||
self._json_response(HTTPStatus.OK, resolve_instance(payload))
|
self._json_response(HTTPStatus.OK, resolve_instance(payload))
|
||||||
return
|
return
|
||||||
|
if self.path == "/api/instances/configure-provider":
|
||||||
|
payload = self._read_json_body()
|
||||||
|
self._json_response(HTTPStatus.OK, configure_instance_provider(payload))
|
||||||
|
return
|
||||||
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
|
raise ApiError(HTTPStatus.NOT_FOUND, "not found")
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
self._json_response(exc.status_code, {"detail": exc.detail})
|
self._json_response(exc.status_code, {"detail": exc.detail})
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
# router-proxy startup config
|
# router-proxy startup config
|
||||||
|
|
||||||
PROXY_IMAGE=nginx:1.27-alpine
|
PROXY_IMAGE=nginx:1.27-alpine
|
||||||
PROXY_CONTAINER_NAME=nano-router-proxy
|
PROXY_CONTAINER_NAME=beaver-router-proxy
|
||||||
PROXY_NETWORK_NAME=nano-instance-edge
|
PROXY_NETWORK_NAME=beaver-instance-edge
|
||||||
PROXY_HTTP_PORT=8088
|
PROXY_HTTP_PORT=8088
|
||||||
|
|
||||||
# Optional host-side overrides.
|
# Optional host-side overrides.
|
||||||
|
|||||||
@ -19,13 +19,13 @@
|
|||||||
|
|
||||||
## 默认约定
|
## 默认约定
|
||||||
|
|
||||||
- 容器名:`nano-router-proxy`
|
- 容器名:`beaver-router-proxy`
|
||||||
- Docker network:`nano-instance-edge`
|
- Docker network:`beaver-instance-edge`
|
||||||
- 对外端口:`8088`
|
- 对外端口:`8088`
|
||||||
|
|
||||||
建议直接参考:
|
建议直接参考:
|
||||||
|
|
||||||
- [`.env.example`](/home/ivan/xuan/nano_project/router-proxy/.env.example)
|
- [`.env.example`](/home/ivan/xuan/beaver_project/router-proxy/.env.example)
|
||||||
|
|
||||||
`REGISTRY_PATH` 和 `OUTPUT_PATH` 一般不需要配。
|
`REGISTRY_PATH` 和 `OUTPUT_PATH` 一般不需要配。
|
||||||
这两个是主机侧脚本路径,默认值会按 `start-proxy.sh` 自己所在目录推导,比写死某台机器上的绝对路径更稳。
|
这两个是主机侧脚本路径,默认值会按 `start-proxy.sh` 自己所在目录推导,比写死某台机器上的绝对路径更稳。
|
||||||
@ -33,14 +33,14 @@
|
|||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/ivan/xuan/nano_project/router-proxy
|
cd /home/ivan/xuan/beaver_project/router-proxy
|
||||||
./start-proxy.sh
|
./start-proxy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 重载
|
## 重载
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/ivan/xuan/nano_project/router-proxy
|
cd /home/ivan/xuan/beaver_project/router-proxy
|
||||||
./reload-proxy.sh --start-if-missing
|
./reload-proxy.sh --start-if-missing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
|
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
|
||||||
START_SCRIPT="${SCRIPT_DIR}/start-proxy.sh"
|
START_SCRIPT="${SCRIPT_DIR}/start-proxy.sh"
|
||||||
|
|
||||||
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-nano-router-proxy}"
|
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-beaver-router-proxy}"
|
||||||
REGISTRY_PATH="${REGISTRY_PATH:-${SCRIPT_DIR}/../app-instance/runtime/registry/instances.json}"
|
REGISTRY_PATH="${REGISTRY_PATH:-${SCRIPT_DIR}/../app-instance/runtime/registry/instances.json}"
|
||||||
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"
|
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"
|
||||||
START_IF_MISSING=0
|
START_IF_MISSING=0
|
||||||
|
|||||||
@ -5,8 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
|
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
|
||||||
|
|
||||||
PROXY_IMAGE="${PROXY_IMAGE:-nginx:1.27-alpine}"
|
PROXY_IMAGE="${PROXY_IMAGE:-nginx:1.27-alpine}"
|
||||||
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-nano-router-proxy}"
|
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-beaver-router-proxy}"
|
||||||
PROXY_NETWORK_NAME="${PROXY_NETWORK_NAME:-nano-instance-edge}"
|
PROXY_NETWORK_NAME="${PROXY_NETWORK_NAME:-beaver-instance-edge}"
|
||||||
PROXY_HTTP_PORT="${PROXY_HTTP_PORT:-8088}"
|
PROXY_HTTP_PORT="${PROXY_HTTP_PORT:-8088}"
|
||||||
REGISTRY_PATH="${REGISTRY_PATH:-${SCRIPT_DIR}/../app-instance/runtime/registry/instances.json}"
|
REGISTRY_PATH="${REGISTRY_PATH:-${SCRIPT_DIR}/../app-instance/runtime/registry/instances.json}"
|
||||||
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"
|
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 363 KiB |
574
域名配置指引.md
@ -1,65 +1,34 @@
|
|||||||
# nano_project 域名配置指引
|
# Beaver Project 域名配置指引
|
||||||
|
|
||||||
这份文档专门解释一件事:
|
这份文档说明如何从本机测试域名 `127.0.0.1.nip.io` 切换到正式域名。
|
||||||
|
|
||||||
- 如果你不用 `127.0.0.1.nip.io`
|
核心结论:
|
||||||
- 想换成自己的正式域名
|
|
||||||
- 应该怎么理解、怎么配、该改哪些地方
|
|
||||||
|
|
||||||
先说最重要的结论:
|
- DNS 只负责把域名解析到 IP。
|
||||||
|
- DNS 不负责端口。
|
||||||
|
- `auth-portal` 和用户实例建议使用不同域名。
|
||||||
|
- 正式环境建议用外层 Nginx、Caddy、Traefik 或云负载均衡监听 `80/443`。
|
||||||
|
- `router-proxy` 必须收到原始 `Host` 头,才能按实例域名转发。
|
||||||
|
|
||||||
- `DNS` 只管把域名解析到 `IP`
|
## 1. 默认端口职责
|
||||||
- `端口` 不归 DNS 管
|
|
||||||
- 所以“域名配到哪个端口”本质上是反向代理或公网入口层在处理
|
|
||||||
|
|
||||||
也就是说:
|
| 端口 | 组件 | 是否建议公网直接暴露 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `3081` | `auth-portal`,用户登录和注册入口 | 可以,或由外层代理转发 |
|
||||||
|
| `8088` | `router-proxy`,所有实例统一入口 | 可以,或由外层代理转发 |
|
||||||
|
| `8090` | `deploy-control`,内部部署控制面 | 不建议 |
|
||||||
|
| `19090` | `authz-service`,内部鉴权服务 | 不建议 |
|
||||||
|
|
||||||
- 域名解析本身,是项目外部的事情
|
正式部署时,通常由外层入口暴露 `80/443`,再转发到本机端口:
|
||||||
- 但项目里生成出来的实例地址、门户地址,又会依赖你填的域名
|
|
||||||
|
|
||||||
所以这件事是:
|
```text
|
||||||
|
portal.example.com -> 127.0.0.1:3081
|
||||||
|
*.apps.example.com -> 127.0.0.1:8088
|
||||||
|
```
|
||||||
|
|
||||||
- 一半在系统外
|
## 2. 推荐域名规划
|
||||||
- 一半和系统配置有关
|
|
||||||
|
|
||||||
---
|
推荐拆成两个域名空间:
|
||||||
|
|
||||||
## 1. 先理解这套系统里每个端口是干什么的
|
|
||||||
|
|
||||||
当前默认端口职责:
|
|
||||||
|
|
||||||
- `3081`
|
|
||||||
- `auth-portal`
|
|
||||||
- 用户注册、登录入口
|
|
||||||
- `8088`
|
|
||||||
- `router-proxy`
|
|
||||||
- 所有用户实例统一入口
|
|
||||||
- `8090`
|
|
||||||
- `deploy-control`
|
|
||||||
- 内部控制面
|
|
||||||
- `19090`
|
|
||||||
- `authz-service`
|
|
||||||
- 内部鉴权服务
|
|
||||||
|
|
||||||
正常公网暴露建议:
|
|
||||||
|
|
||||||
- 暴露 `3081`
|
|
||||||
- 暴露 `8088`
|
|
||||||
- 不要直接暴露 `8090`
|
|
||||||
- 不要直接暴露 `19090`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 推荐的域名规划
|
|
||||||
|
|
||||||
最推荐这样分:
|
|
||||||
|
|
||||||
- `portal.example.com`
|
|
||||||
- 给登录/注册页
|
|
||||||
- `*.apps.example.com`
|
|
||||||
- 给用户实例
|
|
||||||
|
|
||||||
这样用户最终访问会像:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://portal.example.com
|
https://portal.example.com
|
||||||
@ -67,168 +36,98 @@ https://alice.apps.example.com
|
|||||||
https://bob.apps.example.com
|
https://bob.apps.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
其中:
|
含义:
|
||||||
|
|
||||||
- `portal.example.com` 走 `auth-portal`
|
- `portal.example.com` 给 `auth-portal`
|
||||||
- `alice.apps.example.com` 走 `router-proxy`
|
- `*.apps.example.com` 给 `router-proxy`
|
||||||
|
- 每个实例使用一个子域名,例如 `alice.apps.example.com`
|
||||||
|
|
||||||
---
|
不要把门户和实例混在同一个 Host 上。`router-proxy` 是实例入口,`auth-portal` 是认证入口,两者职责不同。
|
||||||
|
|
||||||
## 3. 只配 DNS 还不够
|
## 3. DNS 要怎么配
|
||||||
|
|
||||||
很多人最容易误解的是:
|
假设服务器公网 IP 是 `203.0.113.10`。
|
||||||
|
|
||||||
“我把域名解析到服务器 IP,就等于已经配好了”
|
DNS 记录:
|
||||||
|
|
||||||
这不对。
|
|
||||||
|
|
||||||
你还要解决:
|
|
||||||
|
|
||||||
- 用户访问 `80/443` 时,流量先进谁
|
|
||||||
- 谁把流量转到 `3081`
|
|
||||||
- 谁把流量转到 `8088`
|
|
||||||
|
|
||||||
所以正式域名一般至少要有两层:
|
|
||||||
|
|
||||||
### 第一层:DNS
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- `portal.example.com` -> 服务器公网 IP
|
|
||||||
- `apps.example.com` -> 服务器公网 IP
|
|
||||||
- `*.apps.example.com` -> 服务器公网 IP
|
|
||||||
|
|
||||||
### 第二层:公网反向代理
|
|
||||||
|
|
||||||
例如用:
|
|
||||||
|
|
||||||
- Nginx
|
|
||||||
- Caddy
|
|
||||||
- Traefik
|
|
||||||
- 云负载均衡
|
|
||||||
|
|
||||||
它负责:
|
|
||||||
|
|
||||||
- 监听公网 `80/443`
|
|
||||||
- 根据域名把请求转发到本机不同端口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 最直接的映射关系
|
|
||||||
|
|
||||||
如果你先不做 HTTPS,只做最基础的 HTTP:
|
|
||||||
|
|
||||||
- `portal.example.com` -> 转发到 `127.0.0.1:3081`
|
|
||||||
- `*.apps.example.com` -> 转发到 `127.0.0.1:8088`
|
|
||||||
|
|
||||||
也就是:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
portal.example.com -> auth-portal -> 3081
|
portal.example.com A 203.0.113.10
|
||||||
*.apps.example.com -> router-proxy -> 8088
|
apps.example.com A 203.0.113.10
|
||||||
|
*.apps.example.com A 203.0.113.10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果你的 DNS 服务商支持 CNAME,也可以让通配子域名 CNAME 到一个已有 A 记录,但最终结果仍然必须能解析到服务器入口 IP。
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
|
|
||||||
- `router-proxy` 是靠 `Host` 头识别具体实例的
|
- `*.apps.example.com` 用于实例子域名。
|
||||||
- 所以必须把原始 Host 透传过去
|
- `apps.example.com` 本身不是必须给用户访问,但建议也解析到同一入口,方便证书和排查。
|
||||||
|
- DNS 不会决定 `3081`、`8088`、`443` 这些端口。
|
||||||
|
|
||||||
---
|
## 4. 外层反向代理要做什么
|
||||||
|
|
||||||
## 5. 这个项目内部哪些值要改
|
外层代理负责:
|
||||||
|
|
||||||
如果你要从 `127.0.0.1.nip.io` 换成正式域名,至少要改这些:
|
- 监听公网 `80/443`
|
||||||
|
- 处理 TLS 证书
|
||||||
|
- 按 Host 转发请求
|
||||||
|
- 透传原始 Host
|
||||||
|
- 支持 WebSocket upgrade
|
||||||
|
|
||||||
### 本机部署变量里
|
最小映射:
|
||||||
|
|
||||||
把:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
|
||||||
```
|
|
||||||
|
|
||||||
改成:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export NANO_BASE_DOMAIN=apps.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
这样以后新创建的实例 URL 才会变成:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://alice.apps.example.com:8088
|
portal.example.com -> http://127.0.0.1:3081
|
||||||
|
*.apps.example.com -> http://127.0.0.1:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你后面还有外层 `80/443` 代理,不想让用户看到 `:8088`,那还需要额外调整入口层做无端口访问转发。
|
`*.apps.example.com` 转发到 `router-proxy` 时必须保留原始 Host,例如:
|
||||||
|
|
||||||
### `deploy-control` 里实际影响实例地址的变量
|
```nginx
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
```
|
||||||
|
|
||||||
它们是:
|
否则 `router-proxy` 无法知道请求属于哪个实例。
|
||||||
|
|
||||||
- `DEPLOY_PUBLIC_SCHEME`
|
## 5. 项目内部要改哪些变量
|
||||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
|
||||||
- `DEPLOY_PUBLIC_PORT`
|
|
||||||
|
|
||||||
例如:
|
实例公网地址由 `deploy-control` 里的这些变量决定:
|
||||||
|
|
||||||
|
| 变量 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `DEPLOY_PUBLIC_SCHEME` | 对外协议,`http` 或 `https` |
|
||||||
|
| `DEPLOY_PUBLIC_BASE_DOMAIN` | 实例基域名,例如 `apps.example.com` |
|
||||||
|
| `DEPLOY_PUBLIC_HOST_TEMPLATE` | Host 生成模板,默认 `{slug}.{base_domain}` |
|
||||||
|
| `DEPLOY_PUBLIC_PORT` | 对外端口,`80` / `443` 会在生成 URL 时省略 |
|
||||||
|
|
||||||
|
本机测试:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
-e DEPLOY_PUBLIC_SCHEME="https" \
|
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
|
||||||
-e DEPLOY_PUBLIC_PORT="443" \
|
|
||||||
```
|
```
|
||||||
|
|
||||||
或者如果你暂时还是明文 HTTP:
|
`deploy-control`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \
|
||||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
生成实例地址:
|
||||||
|
|
||||||
## 6. 什么时候可以把端口从 URL 里去掉
|
|
||||||
|
|
||||||
如果你希望用户访问:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://alice.apps.example.com
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
而不是:
|
正式 HTTPS:
|
||||||
|
|
||||||
```text
|
|
||||||
http://alice.apps.example.com:8088
|
|
||||||
```
|
|
||||||
|
|
||||||
那你需要满足这两个条件:
|
|
||||||
|
|
||||||
1. 外层已经有监听 `80/443` 的反向代理
|
|
||||||
2. 它已经把 `*.apps.example.com` 转发到本机 `8088`
|
|
||||||
|
|
||||||
这时项目内部就应该写:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DEPLOY_PUBLIC_SCHEME=https
|
export BEAVER_BASE_DOMAIN=apps.example.com
|
||||||
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
|
|
||||||
DEPLOY_PUBLIC_PORT=443
|
|
||||||
```
|
```
|
||||||
|
|
||||||
或者很多时候你也可以直接在显示层隐藏默认端口概念,让用户只看标准 `https` 地址。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 一套推荐的正式域名方案
|
|
||||||
|
|
||||||
假设你有:
|
|
||||||
|
|
||||||
- 门户域名:`portal.example.com`
|
|
||||||
- 实例根域名:`apps.example.com`
|
|
||||||
|
|
||||||
推荐这样做:
|
|
||||||
|
|
||||||
### 项目内部
|
|
||||||
|
|
||||||
`deploy-control`:
|
`deploy-control`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -237,159 +136,200 @@ DEPLOY_PUBLIC_PORT=443
|
|||||||
-e DEPLOY_PUBLIC_PORT="443" \
|
-e DEPLOY_PUBLIC_PORT="443" \
|
||||||
```
|
```
|
||||||
|
|
||||||
本机部署变量:
|
生成实例地址:
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
export NANO_BASE_DOMAIN=apps.example.com
|
https://alice.apps.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### 项目外部
|
前提是外层代理已经把 `*.apps.example.com:443` 转发到 `router-proxy:8088`。
|
||||||
|
|
||||||
|
## 6. 什么时候 URL 里可以不带端口
|
||||||
|
|
||||||
|
浏览器默认端口:
|
||||||
|
|
||||||
|
- `http` 默认 `80`
|
||||||
|
- `https` 默认 `443`
|
||||||
|
|
||||||
|
所以:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_PUBLIC_SCHEME=https
|
||||||
|
DEPLOY_PUBLIC_PORT=443
|
||||||
|
```
|
||||||
|
|
||||||
|
生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://alice.apps.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
而不是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://alice.apps.example.com:443
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_PUBLIC_SCHEME=http
|
||||||
|
DEPLOY_PUBLIC_PORT=8088
|
||||||
|
```
|
||||||
|
|
||||||
|
生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://alice.apps.example.com:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
因为 `8088` 不是 HTTP 默认端口。
|
||||||
|
|
||||||
|
## 7. 一套推荐生产配置
|
||||||
|
|
||||||
|
假设:
|
||||||
|
|
||||||
|
- 门户域名:`portal.example.com`
|
||||||
|
- 实例基域名:`apps.example.com`
|
||||||
|
- 外层代理负责 HTTPS
|
||||||
|
- 项目基础容器仍在同一台机器上通过 Docker 运行
|
||||||
|
|
||||||
|
部署变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BEAVER_BASE_DOMAIN=apps.example.com
|
||||||
|
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
||||||
|
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy-control`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-e DEPLOY_PUBLIC_SCHEME="https" \
|
||||||
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||||
|
-e DEPLOY_PUBLIC_PORT="443" \
|
||||||
|
```
|
||||||
|
|
||||||
|
外层代理:
|
||||||
|
|
||||||
|
```text
|
||||||
|
portal.example.com -> 127.0.0.1:3081
|
||||||
|
*.apps.example.com -> 127.0.0.1:8088
|
||||||
|
```
|
||||||
|
|
||||||
DNS:
|
DNS:
|
||||||
|
|
||||||
- `portal.example.com` -> 服务器 IP
|
```text
|
||||||
- `apps.example.com` -> 服务器 IP
|
portal.example.com -> 服务器 IP
|
||||||
- `*.apps.example.com` -> 服务器 IP
|
apps.example.com -> 服务器 IP
|
||||||
|
*.apps.example.com -> 服务器 IP
|
||||||
公网代理:
|
|
||||||
|
|
||||||
- `portal.example.com` -> `127.0.0.1:3081`
|
|
||||||
- `*.apps.example.com` -> `127.0.0.1:8088`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 一个常见误区
|
|
||||||
|
|
||||||
### 误区 1
|
|
||||||
|
|
||||||
“我把 `portal.example.com` 配给 `8088` 可以吗?”
|
|
||||||
|
|
||||||
技术上能转,但不推荐。
|
|
||||||
|
|
||||||
因为:
|
|
||||||
|
|
||||||
- `8088` 是实例入口
|
|
||||||
- `3081` 才是门户入口
|
|
||||||
|
|
||||||
更清晰的职责划分应该是:
|
|
||||||
|
|
||||||
- 门户 -> `3081`
|
|
||||||
- 实例 -> `8088`
|
|
||||||
|
|
||||||
### 误区 2
|
|
||||||
|
|
||||||
“我能不能把 `8090` 和 `19090` 也直接开放给公网?”
|
|
||||||
|
|
||||||
不建议。
|
|
||||||
|
|
||||||
因为:
|
|
||||||
|
|
||||||
- `8090` 是内部部署控制面
|
|
||||||
- `19090` 是内部鉴权服务
|
|
||||||
|
|
||||||
这两个应该尽量只允许容器网络或内网访问。
|
|
||||||
|
|
||||||
### 误区 3
|
|
||||||
|
|
||||||
“DNS 能不能直接决定端口?”
|
|
||||||
|
|
||||||
不能。
|
|
||||||
|
|
||||||
DNS 只能决定:
|
|
||||||
|
|
||||||
- 域名 -> IP
|
|
||||||
|
|
||||||
端口是:
|
|
||||||
|
|
||||||
- 浏览器默认端口规则
|
|
||||||
- URL 里显式写端口
|
|
||||||
- 反向代理转发规则
|
|
||||||
|
|
||||||
共同决定的。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 最简单的理解方式
|
|
||||||
|
|
||||||
把它拆成两件事就不容易乱:
|
|
||||||
|
|
||||||
### 系统内的事
|
|
||||||
|
|
||||||
这个项目要知道:
|
|
||||||
|
|
||||||
- 实例公网地址长什么样
|
|
||||||
- 新实例生成什么域名
|
|
||||||
- 对外协议是 `http` 还是 `https`
|
|
||||||
|
|
||||||
所以它关心:
|
|
||||||
|
|
||||||
- `DEPLOY_PUBLIC_SCHEME`
|
|
||||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
|
||||||
- `DEPLOY_PUBLIC_PORT`
|
|
||||||
|
|
||||||
### 系统外的事
|
|
||||||
|
|
||||||
你的服务器或云环境要负责:
|
|
||||||
|
|
||||||
- 域名解析
|
|
||||||
- TLS 证书
|
|
||||||
- 80/443 入口
|
|
||||||
- 把请求转给 `3081` 或 `8088`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 如果你现在只是本机测试
|
|
||||||
|
|
||||||
那你可以完全先不管正式域名。
|
|
||||||
|
|
||||||
继续用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
|
||||||
```
|
```
|
||||||
|
|
||||||
这已经足够验证整个系统:
|
## 8. Nginx 外层代理示例
|
||||||
|
|
||||||
|
示例只展示关键转发逻辑,证书路径和自动签发方式按你的环境调整。
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name portal.example.com *.apps.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name portal.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/portal.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/portal.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://127.0.0.1:3081;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name *.apps.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/apps.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/apps.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_pass http://127.0.0.1:8088;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
证书注意:
|
||||||
|
|
||||||
|
- `portal.example.com` 可以用普通单域名证书。
|
||||||
|
- `*.apps.example.com` 需要通配符证书,通常要用 DNS-01 验证。
|
||||||
|
- 也可以用支持自动证书的 Caddy / Traefik 简化这层。
|
||||||
|
|
||||||
|
## 9. 常见误区
|
||||||
|
|
||||||
|
### DNS 不能配置端口
|
||||||
|
|
||||||
|
DNS 只能做:
|
||||||
|
|
||||||
|
```text
|
||||||
|
域名 -> IP
|
||||||
|
```
|
||||||
|
|
||||||
|
端口来自:
|
||||||
|
|
||||||
|
- URL 里显式端口
|
||||||
|
- 协议默认端口
|
||||||
|
- 外层代理监听和转发规则
|
||||||
|
|
||||||
|
### 不要把 portal 转到 8088
|
||||||
|
|
||||||
|
`8088` 是实例入口,不是认证门户入口。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
portal.example.com -> 3081
|
||||||
|
*.apps.example.com -> 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不要公开 8090 和 19090
|
||||||
|
|
||||||
|
`8090` 是部署控制面,`19090` 是内部 AuthZ 服务。它们应该只允许容器网络或可信内网访问。
|
||||||
|
|
||||||
|
### 修改 DEPLOY_PUBLIC_* 后旧实例不会自动改 URL
|
||||||
|
|
||||||
|
这些变量影响新创建实例的 `public_url` 和 `instance_host`。旧实例已经写入注册表,需要重新创建或手动更新注册表和代理配置。
|
||||||
|
|
||||||
|
## 10. 本机测试不需要正式域名
|
||||||
|
|
||||||
|
如果只是本机验证完整链路,继续使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
|
```
|
||||||
|
|
||||||
|
它已经足够测试:
|
||||||
|
|
||||||
- 注册
|
- 注册
|
||||||
- 登录
|
- 登录
|
||||||
- 创建实例
|
- 自动创建实例
|
||||||
- 跳转个人实例
|
- 跳转个人实例
|
||||||
|
|
||||||
等你准备真正对外给别人访问时,再处理正式域名和 HTTPS。
|
等准备对外访问时,再切换正式 DNS、HTTPS 和外层代理。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 一句话结论
|
|
||||||
|
|
||||||
如果你问:
|
|
||||||
|
|
||||||
“域名应该配到什么端口上?”
|
|
||||||
|
|
||||||
最实用的答案是:
|
|
||||||
|
|
||||||
- 门户域名 -> `3081`
|
|
||||||
- 实例泛域名 -> `8088`
|
|
||||||
- `8090` 和 `19090` 不建议直接公开
|
|
||||||
|
|
||||||
但更准确地说:
|
|
||||||
|
|
||||||
- 域名解析本身不带端口
|
|
||||||
- 真正的端口转发,是由外层反向代理做的
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 你后面最可能要补的东西
|
|
||||||
|
|
||||||
如果你准备上正式域名,下一步通常是补下面其中一个:
|
|
||||||
|
|
||||||
- `Nginx` 反向代理配置
|
|
||||||
- `Caddy` 配置
|
|
||||||
- 云负载均衡转发规则
|
|
||||||
- HTTPS 证书配置
|
|
||||||
|
|
||||||
如果你要,我下一步可以继续给你补:
|
|
||||||
|
|
||||||
- `Nginx 域名反代示例.md`
|
|
||||||
- 或者 `Caddy 域名反代示例.md`
|
|
||||||
|
|
||||||
都可以直接按这个项目的端口结构来写。
|
|
||||||
|
|||||||
686
部署指南.md
@ -1,42 +1,29 @@
|
|||||||
# nano_project 本机一步步部署指南
|
# Beaver Project 本机部署指南
|
||||||
|
|
||||||
这份文档适合第一次在本机把整个项目跑起来的人,目标是:
|
这份文档用于在一台 Linux 或 WSL2 Ubuntu 机器上跑完整链路:
|
||||||
|
|
||||||
- 在一台 `Linux` 或 `WSL2 Ubuntu` 机器上
|
|
||||||
- 用 `Docker` 跑完整链路
|
|
||||||
- 最后能在浏览器里注册账号,并自动创建你的专属实例
|
|
||||||
|
|
||||||
这套项目当前的推荐本机测试方式是:
|
|
||||||
|
|
||||||
- `auth-portal`
|
- `auth-portal`
|
||||||
- `authz-service`
|
- `authz-service`
|
||||||
- `deploy-control`
|
- `deploy-control`
|
||||||
- `router-proxy`
|
- `router-proxy`
|
||||||
- `app-instance`
|
- 自动创建出来的 `app-instance`
|
||||||
|
|
||||||
全部一起跑。
|
目标结果:
|
||||||
|
|
||||||
如果你只单独跑某个前端页面,页面能打开,但注册、登录、创建实例这些核心能力不一定会通。
|
- 浏览器能打开 `http://127.0.0.1:3081/register`
|
||||||
|
- 注册账号后自动创建专属实例
|
||||||
|
- 浏览器跳转到 `http://<slug>.127.0.0.1.nip.io:8088`
|
||||||
|
|
||||||
---
|
如果你只单独启动某个前端页面,页面可以打开,但注册、登录、创建实例这些动作不一定能通。
|
||||||
|
|
||||||
## 0. 先说前提
|
## 0. 前提
|
||||||
|
|
||||||
### 适合的环境
|
推荐环境:
|
||||||
|
|
||||||
推荐:
|
|
||||||
|
|
||||||
- Linux
|
- Linux
|
||||||
- WSL2 Ubuntu
|
- WSL2 Ubuntu
|
||||||
|
|
||||||
不推荐直接按这份文档在纯 Windows 命令行里照抄,因为这里依赖:
|
需要工具:
|
||||||
|
|
||||||
- Docker
|
|
||||||
- Bash 脚本
|
|
||||||
- Docker Socket 挂载
|
|
||||||
- 宿主机目录挂载
|
|
||||||
|
|
||||||
### 你需要先装好的工具
|
|
||||||
|
|
||||||
- `docker`
|
- `docker`
|
||||||
- `git`
|
- `git`
|
||||||
@ -44,7 +31,7 @@
|
|||||||
- `openssl`
|
- `openssl`
|
||||||
- `python3`
|
- `python3`
|
||||||
|
|
||||||
先检查:
|
检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker --version
|
docker --version
|
||||||
@ -54,143 +41,89 @@ openssl version
|
|||||||
curl --version
|
curl --version
|
||||||
```
|
```
|
||||||
|
|
||||||
如果 `docker ps` 报错,先把 Docker 启动起来。
|
如果 `docker ps` 报错,先启动 Docker。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 进入项目根目录
|
## 1. 进入项目根目录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/ivan/xuan/nano_project
|
cd /home/ivan/xuan/beaver_project
|
||||||
```
|
|
||||||
|
|
||||||
你执行完以后,建议顺手确认一下当前目录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwd
|
pwd
|
||||||
```
|
```
|
||||||
|
|
||||||
你应该看到:
|
预期目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/ivan/xuan/nano_project
|
/home/ivan/xuan/beaver_project
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 2. 准备本机测试变量
|
||||||
|
|
||||||
## 2. 准备一套本机测试变量
|
本机测试推荐用 `127.0.0.1.nip.io`。例如:
|
||||||
|
|
||||||
### 为什么这里用 `127.0.0.1.nip.io`
|
```text
|
||||||
|
alice.127.0.0.1.nip.io -> 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
因为这是最省事的本机测试域名方案。
|
这样 `router-proxy` 可以按子域名区分不同实例。
|
||||||
|
|
||||||
它的作用是:
|
直接执行:
|
||||||
|
|
||||||
- `alice.127.0.0.1.nip.io`
|
|
||||||
- 自动解析到 `127.0.0.1`
|
|
||||||
|
|
||||||
这样 `router-proxy` 就能按子域名区分不同实例。
|
|
||||||
|
|
||||||
### 直接复制执行
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PROJECT_ROOT=/home/ivan/xuan/nano_project
|
export PROJECT_ROOT=/home/ivan/xuan/beaver_project
|
||||||
export NANO_NET=nano-instance-edge
|
export BEAVER_NET=beaver-instance-edge
|
||||||
|
|
||||||
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||||
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
export NANO_SERVER_IP=127.0.0.1
|
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
|
||||||
|
|
||||||
export NANO_PROVIDER=openai
|
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
|
||||||
export NANO_MODEL=openai/gpt-5
|
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
|
||||||
export NANO_API_KEY='把这里换成你自己的模型 API Key'
|
|
||||||
export NANO_API_BASE=''
|
|
||||||
|
|
||||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
export BEAVER_OUTLOOK_MCP_URL=''
|
||||||
export NANO_OUTLOOK_MCP_URL=''
|
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||||
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
|
||||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 这里每个变量大概是干什么的
|
变量说明:
|
||||||
|
|
||||||
- `PROJECT_ROOT`
|
| 变量 | 作用 |
|
||||||
- 仓库根目录
|
| --- | --- |
|
||||||
- `NANO_NET`
|
| `PROJECT_ROOT` | 仓库根目录 |
|
||||||
- 所有容器共用的 Docker 网络
|
| `BEAVER_NET` | 所有容器共用的 Docker network |
|
||||||
- `NANO_DEPLOY_TOKEN`
|
| `BEAVER_DEPLOY_TOKEN` | `auth-portal` / `authz-service` 调 `deploy-control` 的 token |
|
||||||
- `auth-portal` / `authz-service` 调 `deploy-control` 时的鉴权 token
|
| `BEAVER_AUTHZ_INTERNAL_TOKEN` | AuthZ 内部接口 token |
|
||||||
- `NANO_AUTHZ_INTERNAL_TOKEN`
|
| `BEAVER_BASE_DOMAIN` | 新实例的基域名 |
|
||||||
- AuthZ 内部接口 token
|
| `BEAVER_AUTHZ_URL` | 容器网络内访问 AuthZ 的地址 |
|
||||||
- `NANO_BASE_DOMAIN`
|
| `BEAVER_DEPLOY_URL` | 容器网络内访问 deploy-control 的地址 |
|
||||||
- 实例基础域名
|
| `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 |
|
||||||
- `NANO_PROVIDER`
|
| `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id,默认 `outlook_mcp` |
|
||||||
- 新实例默认模型提供商
|
|
||||||
- `NANO_MODEL`
|
|
||||||
- 新实例默认模型
|
|
||||||
- `NANO_API_KEY`
|
|
||||||
- 新实例默认模型 API Key
|
|
||||||
- `NANO_API_BASE`
|
|
||||||
- 自定义模型网关地址,没有就留空
|
|
||||||
- `NANO_AUTHZ_URL`
|
|
||||||
- 容器网络内访问 AuthZ 的地址
|
|
||||||
- `NANO_DEPLOY_URL`
|
|
||||||
- 容器网络内访问 deploy-control 的地址
|
|
||||||
- `NANO_OUTLOOK_MCP_URL`
|
|
||||||
- 可选;如果你有独立 Outlook MCP 服务,可以在这里填
|
|
||||||
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
|
||||||
- Outlook MCP 默认 server id,当前推荐固定 `outlook_mcp`
|
|
||||||
|
|
||||||
### 一个特别重要的提醒
|
`BEAVER_AUTHZ_URL` 和 `BEAVER_DEPLOY_URL` 必须带协议头。正确写法:
|
||||||
|
|
||||||
`NANO_API_KEY` 不能空着。
|
|
||||||
|
|
||||||
如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`。
|
|
||||||
|
|
||||||
`NANO_AUTHZ_URL` 和 `NANO_DEPLOY_URL` 也不能留空,而且必须带协议头。
|
|
||||||
|
|
||||||
正确写法:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://nano-authz-service:19090
|
http://beaver-authz-service:19090
|
||||||
http://nano-deploy-control:8090
|
http://beaver-deploy-control:8090
|
||||||
```
|
```
|
||||||
|
|
||||||
错误写法:
|
错误写法:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
nano-authz-service:19090
|
beaver-authz-service:19090
|
||||||
nano-deploy-control:8090
|
beaver-deploy-control:8090
|
||||||
172.19.207.13:19090
|
127.0.0.1:19090
|
||||||
172.19.207.13:8090
|
127.0.0.1:8090
|
||||||
```
|
```
|
||||||
|
|
||||||
如果这里漏了 `http://`,注册页很容易直接报:
|
如果漏了 `http://`,注册页可能报:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
502: Request URL is missing an 'http://' or 'https://' protocol.
|
502: Request URL is missing an 'http://' or 'https://' protocol.
|
||||||
```
|
```
|
||||||
|
|
||||||
还有一个很容易忽略的点:
|
如果你改了 shell 里的变量,已经运行的容器不会自动更新。改完这些变量后,至少要重建:
|
||||||
|
|
||||||
- 你在 shell 里重新 `export NANO_DEPLOY_URL=...`
|
- `beaver-authz-service`
|
||||||
- 不会自动修改已经在运行中的 `nano-authz-service` 和 `nano-auth-portal`
|
- `beaver-auth-portal`
|
||||||
|
|
||||||
也就是说:
|
|
||||||
|
|
||||||
- 变量改对了
|
|
||||||
- 但容器没重建
|
|
||||||
|
|
||||||
注册页还是会继续报同一个 502。
|
|
||||||
|
|
||||||
改完变量以后,至少要重建这些容器:
|
|
||||||
|
|
||||||
- `nano-authz-service`
|
|
||||||
- `nano-auth-portal`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 创建运行目录
|
## 3. 创建运行目录
|
||||||
|
|
||||||
@ -202,257 +135,169 @@ mkdir -p \
|
|||||||
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
|
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
|
||||||
```
|
```
|
||||||
|
|
||||||
这一步的作用是给下面几个东西留持久化空间:
|
这些目录保存:
|
||||||
|
|
||||||
- AuthZ 数据
|
- AuthZ 数据
|
||||||
- 实例注册表
|
- 实例注册表
|
||||||
- 每个用户实例的配置目录
|
- 每个用户实例的配置和数据
|
||||||
- router-proxy 生成出来的路由文件
|
- `router-proxy` 生成的路由文件
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 构建镜像
|
## 4. 构建镜像
|
||||||
|
|
||||||
第一次构建会比较久,正常情况要等几分钟。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
docker build -t nano/app-instance:latest app-instance
|
docker build -t beaver/app-instance:latest app-instance
|
||||||
docker build -t nano/authz-service:latest authz-service
|
docker build -t beaver/authz-service:latest authz-service
|
||||||
docker build -t nano/deploy-control:latest deploy-control
|
docker build -t beaver/deploy-control:latest deploy-control
|
||||||
docker build -t nano/auth-portal:latest auth-portal/src
|
docker build -t beaver/auth-portal:latest auth-portal/src
|
||||||
```
|
```
|
||||||
|
|
||||||
如果中间有某个镜像失败,不要继续往下跑,先把失败那一步修掉。
|
如果某个镜像构建失败,先修构建错误,不要继续往下跑。
|
||||||
|
|
||||||
常见失败原因:
|
|
||||||
|
|
||||||
- Docker 没启动
|
|
||||||
- 网络拉镜像失败
|
|
||||||
- 你的本机磁盘空间不够
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 创建共享 Docker 网络
|
## 5. 创建共享 Docker 网络
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET"
|
docker network inspect "$BEAVER_NET" >/dev/null 2>&1 || docker network create "$BEAVER_NET"
|
||||||
|
docker network ls | grep "$BEAVER_NET"
|
||||||
```
|
```
|
||||||
|
|
||||||
执行完后可确认:
|
预期能看到:
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network ls | grep "$NANO_NET"
|
|
||||||
```
|
|
||||||
|
|
||||||
应该能看到:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
nano-instance-edge
|
beaver-instance-edge
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 6. 启动 router-proxy
|
||||||
|
|
||||||
## 6. 启动统一入口代理 `router-proxy`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
PROXY_NETWORK_NAME="$NANO_NET" \
|
PROXY_NETWORK_NAME="$BEAVER_NET" \
|
||||||
PROXY_HTTP_PORT=8088 \
|
PROXY_HTTP_PORT=8088 \
|
||||||
./router-proxy/start-proxy.sh --replace
|
./router-proxy/start-proxy.sh --replace
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后,统一入口走:
|
实例统一入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<你的实例slug>.127.0.0.1.nip.io:8088
|
http://<slug>.127.0.0.1.nip.io:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
例如:
|
示例:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://alice.127.0.0.1.nip.io:8088
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 7. 启动 authz-service
|
||||||
|
|
||||||
## 7. 启动 `authz-service`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker rm -f nano-authz-service >/dev/null 2>&1 || true
|
docker rm -f beaver-authz-service >/dev/null 2>&1 || true
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name nano-authz-service \
|
--name beaver-authz-service \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--network "$NANO_NET" \
|
--network "$BEAVER_NET" \
|
||||||
-p 19090:19090 \
|
-p 19090:19090 \
|
||||||
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
||||||
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
|
-e AUTHZ_ISSUER="$BEAVER_AUTHZ_URL" \
|
||||||
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
|
-e AUTHZ_INTERNAL_TOKEN="$BEAVER_AUTHZ_INTERNAL_TOKEN" \
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
-e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
-e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
|
||||||
nano/authz-service:latest
|
beaver/authz-service:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### 这里有个很关键的坑
|
重点:
|
||||||
|
|
||||||
`AUTHZ_ISSUER` 这里不能写成:
|
- `AUTHZ_ISSUER` 在当前部署里要写 `http://beaver-authz-service:19090`
|
||||||
|
- 不要写 `http://127.0.0.1:19090`
|
||||||
|
- 新创建的 `app-instance` 容器要通过 Docker network 访问 AuthZ
|
||||||
|
|
||||||
```text
|
检查关键环境变量:
|
||||||
http://127.0.0.1:19090
|
|
||||||
```
|
|
||||||
|
|
||||||
因为后面新创建的 `app-instance` 也是通过 Docker 网络去访问 AuthZ 的。
|
|
||||||
|
|
||||||
所以这里要写容器网络里可访问的地址:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://nano-authz-service:19090
|
|
||||||
```
|
|
||||||
|
|
||||||
`DEPLOY_API_BASE_URL` 也一样,不能空,不能只写 `host:port`。
|
|
||||||
|
|
||||||
这里如果传成空字符串,或者写成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
nano-deploy-control:8090
|
|
||||||
```
|
|
||||||
|
|
||||||
注册页会在 `authz-service -> deploy-control` 这一步直接报:
|
|
||||||
|
|
||||||
```text
|
|
||||||
502: Request URL is missing an 'http://' or 'https://' protocol.
|
|
||||||
```
|
|
||||||
|
|
||||||
启动完可以立刻确认:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)='
|
docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)='
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 8. 启动 deploy-control
|
||||||
|
|
||||||
## 8. 启动 `deploy-control`
|
`deploy-control` 会挂载 Docker socket,再创建新的 `app-instance` 容器。这里最容易错的是路径挂载:
|
||||||
|
|
||||||
这一步最容易配错的点是挂载目录。
|
- 要把宿主机真实路径按原路径挂进容器。
|
||||||
|
- 不要把 `app-instance` 挂到容器里的 `/app-instance` 这种短路径。
|
||||||
一定要注意:
|
- `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` 要和挂载路径一致。
|
||||||
|
|
||||||
- `app-instance` 和 `router-proxy` 的宿主机路径,要按原路径挂进容器
|
|
||||||
- 不能偷懒挂到容器里的另一个短路径比如 `/app-instance`
|
|
||||||
- 同时要把 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` 也明确传进去
|
|
||||||
|
|
||||||
直接执行:
|
直接执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker rm -f nano-deploy-control >/dev/null 2>&1 || true
|
docker rm -f beaver-deploy-control >/dev/null 2>&1 || true
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name nano-deploy-control \
|
--name beaver-deploy-control \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--network "$NANO_NET" \
|
--network "$BEAVER_NET" \
|
||||||
-p 8090:8090 \
|
-p 8090:8090 \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
||||||
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
||||||
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
||||||
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
||||||
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
-e DEPLOY_CONTROL_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
|
||||||
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
-e APP_INSTANCE_IMAGE="beaver/app-instance:latest" \
|
||||||
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
-e APP_INSTANCE_NETWORK_NAME="$BEAVER_NET" \
|
||||||
-e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \
|
-e DEFAULT_AUTHZ_BASE_URL="$BEAVER_AUTHZ_URL" \
|
||||||
-e APP_INSTANCE_MODEL="$NANO_MODEL" \
|
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \
|
||||||
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \
|
||||||
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
|
||||||
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
|
||||||
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
|
||||||
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
|
||||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \
|
||||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||||
-e DEPLOY_AUTO_START_PROXY="1" \
|
-e DEPLOY_AUTO_START_PROXY="1" \
|
||||||
nano/deploy-control:latest
|
beaver/deploy-control:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### 这一步在做什么
|
当前版本创建实例时会传 `--skip-provider-config`,也就是先不写 provider、model 或 API key。注册成功后,`auth-portal` 会进入模型配置引导页,再调用 `deploy-control /api/instances/configure-provider` 写入该实例的 `config.json` 并重启容器。
|
||||||
|
|
||||||
`deploy-control` 会负责:
|
## 9. 启动 auth-portal
|
||||||
|
|
||||||
- 收到“创建实例”的请求
|
|
||||||
- 调用 `app-instance/create-instance.sh`
|
|
||||||
- 通过 Docker 创建对应用户实例
|
|
||||||
- 刷新 `router-proxy`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 启动 `auth-portal`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
|
docker rm -f beaver-auth-portal >/dev/null 2>&1 || true
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name nano-auth-portal \
|
--name beaver-auth-portal \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--network "$NANO_NET" \
|
--network "$BEAVER_NET" \
|
||||||
-p 3081:3081 \
|
-p 3081:3081 \
|
||||||
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
|
-e AUTHZ_API_BASE_URL="$BEAVER_AUTHZ_URL" \
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
-e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
-e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
|
||||||
nano/auth-portal:latest
|
beaver/auth-portal:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
这个页面就是用户看到的登录/注册入口。
|
检查关键环境变量:
|
||||||
|
|
||||||
虽然注册入口主要依赖 `AUTHZ_API_BASE_URL`,这里还是建议把 `DEPLOY_API_BASE_URL` 一起带上并确认非空,避免后面运行态调用 deploy-control 时再踩同一个坑。
|
|
||||||
|
|
||||||
启动完可以确认:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
|
docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 10. 健康检查
|
||||||
|
|
||||||
## 10. 做健康检查
|
|
||||||
|
|
||||||
### 先检查接口
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:19090/healthz
|
curl http://127.0.0.1:19090/healthz
|
||||||
curl http://127.0.0.1:8090/healthz
|
curl http://127.0.0.1:8090/healthz
|
||||||
curl -I http://127.0.0.1:3081
|
curl -I http://127.0.0.1:3081
|
||||||
```
|
|
||||||
|
|
||||||
你应该大致看到:
|
|
||||||
|
|
||||||
- `authz-service` 返回健康 JSON
|
|
||||||
- `deploy-control` 返回健康 JSON
|
|
||||||
- `auth-portal` 返回 `HTTP/1.1 200 OK`
|
|
||||||
|
|
||||||
### 再看容器状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
docker logs --tail=50 beaver-router-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
你至少应该能看到这些容器:
|
至少应该看到这些容器:
|
||||||
|
|
||||||
- `nano-authz-service`
|
- `beaver-authz-service`
|
||||||
- `nano-deploy-control`
|
- `beaver-deploy-control`
|
||||||
- `nano-auth-portal`
|
- `beaver-auth-portal`
|
||||||
- `nano-router-proxy`
|
- `beaver-router-proxy`
|
||||||
|
|
||||||
### 再看一下代理日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs --tail=50 nano-router-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
如果这一步没有明显报错,就可以开始浏览器测试了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 浏览器首次测试
|
## 11. 浏览器首次测试
|
||||||
|
|
||||||
@ -462,316 +307,177 @@ docker logs --tail=50 nano-router-proxy
|
|||||||
http://127.0.0.1:3081/register
|
http://127.0.0.1:3081/register
|
||||||
```
|
```
|
||||||
|
|
||||||
然后按顺序操作:
|
预期流程:
|
||||||
|
|
||||||
1. 注册一个新账号
|
1. 注册一个新账号。
|
||||||
2. 注册成功后,系统会自动创建一个你的专属实例
|
2. Portal 创建不含模型凭证的实例。
|
||||||
3. 浏览器应该跳到你的实例地址
|
3. 页面进入模型配置引导。
|
||||||
|
4. 填 provider、model、API key 后确认,或暂时跳过。
|
||||||
|
5. 浏览器跳到你的实例地址。
|
||||||
|
|
||||||
跳转目标一般长这样:
|
跳转目标示例:
|
||||||
|
|
||||||
```text
|
|
||||||
http://你的slug.127.0.0.1.nip.io:8088
|
|
||||||
```
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://alice.127.0.0.1.nip.io:8088
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 12. 确认实例已创建
|
||||||
|
|
||||||
## 12. 确认实例真的被创建出来了
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$PROJECT_ROOT/app-instance"
|
cd "$PROJECT_ROOT/app-instance"
|
||||||
./list-instances.sh
|
./list-instances.sh
|
||||||
./list-instances.sh --json
|
./list-instances.sh --json
|
||||||
|
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||||
```
|
```
|
||||||
|
|
||||||
你应该能看到类似:
|
注册表里应包含:
|
||||||
|
|
||||||
- `instance_id`
|
- `instance_id`
|
||||||
- `instance_slug`
|
- `instance_slug`
|
||||||
- `container_name`
|
- `container_name`
|
||||||
- `public_url`
|
- `public_url`
|
||||||
|
- `instance_host`
|
||||||
|
|
||||||
以及对应的 `app-instance-<slug>` 容器。
|
## 13. 只看 auth-portal 页面
|
||||||
|
|
||||||
你还可以继续查:
|
如果只想看 Portal 页面,不跑全链路:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
cd /home/ivan/xuan/beaver_project/auth-portal/src
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 如果你只是想单独看前端页面
|
|
||||||
|
|
||||||
如果你只是想看 `auth-portal` 页面样子,不跑全链路,也可以单独启动它的前端开发模式:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/ivan/xuan/nano_project/auth-portal/src
|
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
然后打开:
|
打开:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:3081
|
http://127.0.0.1:3081
|
||||||
```
|
```
|
||||||
|
|
||||||
但是要注意:
|
注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service` 和 `deploy-control`。
|
||||||
|
|
||||||
- 这只能看页面
|
## 14. 常用排错命令
|
||||||
- 注册、登录、创建实例这些动作是否成功,仍然取决于 `authz-service` 和 `deploy-control` 有没有另外启动
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 一键排错命令
|
|
||||||
|
|
||||||
如果你感觉“不对劲”,先跑这几条:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
|
||||||
docker logs --tail=100 nano-authz-service
|
docker logs --tail=100 beaver-authz-service
|
||||||
docker logs --tail=100 nano-deploy-control
|
docker logs --tail=100 beaver-deploy-control
|
||||||
docker logs --tail=100 nano-auth-portal
|
docker logs --tail=100 beaver-auth-portal
|
||||||
docker logs --tail=100 nano-router-proxy
|
docker logs --tail=100 beaver-router-proxy
|
||||||
|
|
||||||
curl http://127.0.0.1:19090/healthz
|
curl http://127.0.0.1:19090/healthz
|
||||||
curl http://127.0.0.1:8090/healthz
|
curl http://127.0.0.1:8090/healthz
|
||||||
curl -I http://127.0.0.1:3081
|
curl -I http://127.0.0.1:3081
|
||||||
```
|
```
|
||||||
|
|
||||||
如果是实例创建失败,再加两条:
|
实例创建失败时再看:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$PROJECT_ROOT/app-instance"
|
cd "$PROJECT_ROOT/app-instance"
|
||||||
./list-instances.sh --json
|
./list-instances.sh --json
|
||||||
|
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||||
```
|
```
|
||||||
|
|
||||||
如果注册页弹出:
|
排查 URL 变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)='
|
||||||
|
|
||||||
|
docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
|
||||||
|
```
|
||||||
|
|
||||||
|
它们都必须是完整 URL,不能是空字符串,也不能是裸 `host:port`。
|
||||||
|
|
||||||
|
## 15. 常见问题
|
||||||
|
|
||||||
|
### 注册页报 URL 缺少协议
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
502: Request URL is missing an 'http://' or 'https://' protocol.
|
502: Request URL is missing an 'http://' or 'https://' protocol.
|
||||||
```
|
```
|
||||||
|
|
||||||
优先查这两条:
|
优先检查:
|
||||||
|
|
||||||
```bash
|
- `beaver-authz-service` 里的 `DEPLOY_API_BASE_URL`
|
||||||
docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)='
|
- `beaver-auth-portal` 里的 `AUTHZ_API_BASE_URL`
|
||||||
docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
|
- `beaver-auth-portal` 里的 `DEPLOY_API_BASE_URL`
|
||||||
```
|
|
||||||
|
|
||||||
重点看:
|
如果你只是改了当前 shell 变量,但没有重建容器,旧值还会继续生效。
|
||||||
|
|
||||||
- `nano-authz-service` 里的 `DEPLOY_API_BASE_URL`
|
### `AUTHZ_ISSUER` 写成了 `127.0.0.1`
|
||||||
- `nano-auth-portal` 里的 `AUTHZ_API_BASE_URL`
|
|
||||||
|
|
||||||
它们都必须是完整 URL,不能是空字符串,也不能是裸 `host:port`。
|
错误:
|
||||||
|
|
||||||
如果你已经改过 `export NANO_DEPLOY_URL=...`,但这里查出来还是空,说明你只是改了当前 shell 变量,没有把容器重建掉。
|
|
||||||
|
|
||||||
这时直接按下面重建:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
|
||||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
|
||||||
|
|
||||||
export NANO_DEPLOY_TOKEN="$(docker inspect nano-deploy-control --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DEPLOY_CONTROL_API_TOKEN=//p')"
|
|
||||||
export NANO_AUTHZ_INTERNAL_TOKEN="$(docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^AUTHZ_INTERNAL_TOKEN=//p')"
|
|
||||||
|
|
||||||
docker rm -f nano-authz-service >/dev/null 2>&1 || true
|
|
||||||
docker run -d \
|
|
||||||
--name nano-authz-service \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network "$NANO_NET" \
|
|
||||||
-p 19090:19090 \
|
|
||||||
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
|
||||||
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
|
|
||||||
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
|
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
|
||||||
nano/authz-service:latest
|
|
||||||
|
|
||||||
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
|
|
||||||
docker run -d \
|
|
||||||
--name nano-auth-portal \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network "$NANO_NET" \
|
|
||||||
-p 3081:3081 \
|
|
||||||
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
|
|
||||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
|
||||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
|
||||||
nano/auth-portal:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
重建后再确认:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)='
|
|
||||||
docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
|
|
||||||
```
|
|
||||||
|
|
||||||
你必须看到:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DEPLOY_API_BASE_URL=http://nano-deploy-control:8090
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. 最常见的坑
|
|
||||||
|
|
||||||
### 1. API Key 没填
|
|
||||||
|
|
||||||
现象:
|
|
||||||
|
|
||||||
- 注册页面提交后创建实例失败
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- `APP_INSTANCE_API_KEY` 没有有效值
|
|
||||||
|
|
||||||
### 2. Docker 没启动
|
|
||||||
|
|
||||||
现象:
|
|
||||||
|
|
||||||
- `deploy-control` 无法创建实例
|
|
||||||
- 或 `docker ps` 本身就报错
|
|
||||||
|
|
||||||
### 3. `AUTHZ_ISSUER` 写成了 `127.0.0.1`
|
|
||||||
|
|
||||||
错误写法:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:19090
|
http://127.0.0.1:19090
|
||||||
```
|
```
|
||||||
|
|
||||||
正确写法:
|
正确:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://nano-authz-service:19090
|
http://beaver-authz-service:19090
|
||||||
```
|
```
|
||||||
|
|
||||||
原因:
|
原因是 `app-instance` 容器里的 `127.0.0.1` 指向它自己。
|
||||||
|
|
||||||
- 新实例容器里访问不到宿主机自己的 `127.0.0.1:19090`
|
### deploy-control 路径挂载写错
|
||||||
|
|
||||||
### 4. `deploy-control` 的路径挂载写错
|
|
||||||
|
|
||||||
错误思路:
|
错误思路:
|
||||||
|
|
||||||
- 把宿主机的 `app-instance` 挂到容器里的 `/app-instance`
|
```text
|
||||||
|
宿主机 app-instance -> 容器 /app-instance
|
||||||
|
```
|
||||||
|
|
||||||
正确思路:
|
正确思路:
|
||||||
|
|
||||||
- 宿主机原路径挂进去
|
|
||||||
- 并设置:
|
|
||||||
- `APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance"`
|
|
||||||
- `ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy"`
|
|
||||||
|
|
||||||
### 5. `NANO_DEPLOY_URL` 或 `NANO_AUTHZ_URL` 没带 `http://`
|
|
||||||
|
|
||||||
典型现象:
|
|
||||||
|
|
||||||
- 注册页直接弹:
|
|
||||||
- `502: Request URL is missing an 'http://' or 'https://' protocol.`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- `authz-service` 用 `DEPLOY_API_BASE_URL` 去调 `deploy-control`
|
|
||||||
- `auth-portal` 用 `AUTHZ_API_BASE_URL` 去调 `authz-service`
|
|
||||||
- 这些值如果是空,或者写成 `nano-deploy-control:8090` 这种不带协议的字符串,请求会直接失败
|
|
||||||
- 就算你后来在 shell 里改对了,如果没重建相关容器,老的错误值仍然会继续生效
|
|
||||||
|
|
||||||
正确写法:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://nano-authz-service:19090
|
$PROJECT_ROOT/app-instance -> $PROJECT_ROOT/app-instance
|
||||||
http://nano-deploy-control:8090
|
$PROJECT_ROOT/router-proxy -> $PROJECT_ROOT/router-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. `nip.io` 解析失败
|
因为 `deploy-control` 会通过宿主机 Docker socket 再创建新容器,传给 Docker 的 bind mount 源路径必须是宿主机真实路径。
|
||||||
|
|
||||||
如果实例跳转地址打不开,先试:
|
### `nip.io` 解析失败
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ping 127.0.0.1.nip.io
|
ping 127.0.0.1.nip.io
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你本地网络把 `nip.io` 拦了,这套子域名测试方式就会失效。
|
如果本地网络屏蔽了 `nip.io`,子域名测试会失败。可以临时换成本机 hosts 或正式域名。
|
||||||
|
|
||||||
### 7. 端口被占用
|
### 端口被占用
|
||||||
|
|
||||||
默认会用到这些端口:
|
默认端口:
|
||||||
|
|
||||||
- `3081`
|
- `3081`
|
||||||
|
- `8088`
|
||||||
- `8090`
|
- `8090`
|
||||||
- `19090`
|
- `19090`
|
||||||
- `8088`
|
|
||||||
|
|
||||||
你可以先查:
|
检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ss -ltnp | grep -E '3081|8090|19090|8088'
|
ss -ltnp | grep -E '3081|8088|8090|19090'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 16. 重新部署基础容器
|
||||||
|
|
||||||
## 16. 如果你要重新来一遍
|
只重建基础四个容器:
|
||||||
|
|
||||||
如果你只是想“重新部署这四个基础容器”,可以先停掉它们:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker rm -f \
|
docker rm -f \
|
||||||
nano-auth-portal \
|
beaver-auth-portal \
|
||||||
nano-authz-service \
|
beaver-authz-service \
|
||||||
nano-deploy-control \
|
beaver-deploy-control \
|
||||||
nano-router-proxy 2>/dev/null || true
|
beaver-router-proxy 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你还想把旧实例容器也一起清掉,再额外处理 `app-instance-*`。
|
这不会自动删除实例数据。如果你还需要旧账号、旧实例或模型配置,不要删除 `runtime/` 目录。
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 不要在你还需要旧数据的时候乱删 `runtime/`
|
|
||||||
- `authz-service/runtime/data` 和 `app-instance/runtime/instances` 里都有持久化数据
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 本机部署成功后的结果应该是什么
|
|
||||||
|
|
||||||
如果整个流程正常,最后你会得到:
|
|
||||||
|
|
||||||
- 一个可以打开的注册页:
|
|
||||||
- `http://127.0.0.1:3081/register`
|
|
||||||
- 一个统一实例入口代理:
|
|
||||||
- `http://<slug>.127.0.0.1.nip.io:8088`
|
|
||||||
- 一个能自动创建用户专属容器的部署控制面
|
|
||||||
- 一份实例注册表:
|
|
||||||
- `app-instance/runtime/registry/instances.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. 你下一步最建议做什么
|
|
||||||
|
|
||||||
第一次建议这样测:
|
|
||||||
|
|
||||||
1. 用全新用户名注册一个测试账号
|
|
||||||
2. 确认浏览器跳到了你的实例 URL
|
|
||||||
3. 再执行 `./app-instance/list-instances.sh --json`
|
|
||||||
4. 确认注册表里真的有这条实例记录
|
|
||||||
|
|
||||||
如果你后面想要,我还可以继续补两份文档:
|
|
||||||
|
|
||||||
- `服务器部署指南.md`
|
|
||||||
- 面向公网服务器、固定 IP、长期运行
|
|
||||||
- `常见报错排查.md`
|
|
||||||
- 专门收集 502、超时、实例起不来、MCP 鉴权失败这类问题
|
|
||||||
|
|||||||