feat: 将项目从nano重命名为beaver并更新相关配置

- 将所有环境变量前缀从NANO_改为BEAVER_
- 更新README.md文档内容,包括项目介绍、组件说明和快速开始指南
- 修改.gitignore文件,添加auth-portal运行时路径排除规则
- 更新app-instance镜像标签从nano/app-instance改为beaver/app-instance
- 增强技能安全检查器,支持工具前缀白名单功能
- 添加技能草稿重新检查安全性API端点
- 扩展证据选择器,收集工具调用名称用于技能学习
- 改进技能合成器,基于实际调用的工具生成工具提示
- 优化路由超时处理机制,增加重试逻辑
- 更新后端架构文档,添加可视化入口和基础概念说明
- 实现在WebSocket消息中传递工具迭代次数信息
This commit is contained in:
2026-05-20 18:01:06 +08:00
parent 3b0af173cc
commit 9d6cde2d23
63 changed files with 4894 additions and 1596 deletions

View File

@ -1,18 +1,18 @@
# Shared values used by the root deployment flow in README.md
PROJECT_ROOT=/home/ivan/xuan/nano_project
NANO_NET=nano-instance-edge
PROJECT_ROOT=/home/ivan/xuan/beaver_project
BEAVER_NET=beaver-instance-edge
NANO_DEPLOY_TOKEN=change-me
NANO_AUTHZ_INTERNAL_TOKEN=change-me
BEAVER_DEPLOY_TOKEN=change-me
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
NANO_SERVER_IP=203.0.113.10
NANO_BASE_DOMAIN=203.0.113.10.nip.io
BEAVER_SERVER_IP=203.0.113.10
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io
NANO_PROVIDER=openai
NANO_MODEL=openai/gpt-5
NANO_API_KEY=sk-xxxxxxxx
NANO_API_BASE=
BEAVER_PROVIDER=openai
BEAVER_MODEL=openai/gpt-5
BEAVER_API_KEY=sk-xxxxxxxx
BEAVER_API_BASE=
# Per-instance Beaver backend config. In Docker app-instance this should point
# 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
# Must be reachable from app-instance containers.
NANO_AUTHZ_URL=http://nano-authz-service:19090
NANO_OUTLOOK_MCP_URL=
NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp
BEAVER_AUTHZ_URL=http://beaver-authz-service:19090
BEAVER_OUTLOOK_MCP_URL=
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
# 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
View File

@ -5,6 +5,8 @@ app-instance/runtime/instances/
app-instance/runtime/registry/
router-proxy/runtime/conf.d/
runtime/
!auth-portal/src/app/api/runtime/
!auth-portal/src/app/api/runtime/**
sessions/
**/sessions/state.db
**/runtime/**/*.lock

460
README.md
View File

@ -1,22 +1,30 @@
https://d3qpg7p2n3hazf.cloudfront.net/api/v1/client/subscribe?token=2185761c5925a800c2d2c1ec44449b65
# nano_project
# Beaver Project
单机部署版运行结构
`Beaver Project` 是一套单机 Docker 部署的多实例运行环境
- `auth-portal`
- 用户入口页,提供登录、注册、跳转
- `authz-service`
- AuthZ 服务
- 现在负责注册编排:`auth-portal -> authz-service -> deploy-control -> app-instance -> authz-service`
- `deploy-control`
- 部署机控制面。
- 负责创建实例、解析实例、刷新反向代理。
- `router-proxy`
- 统一入口代理。
- 按 Host 把 `<slug>.<base_domain>` 转发到对应实例容器。
- `app-instance`
- 真正的单用户实例。
- 一个容器里同时包含前端、后端和 Nginx。
- 用户先进入独立的 `auth-portal` 完成注册或登录。
- 注册会触发 `authz-service` 调用 `deploy-control`
- `deploy-control` 在同一台机器上创建一个独立的 `app-instance` 容器。
- `router-proxy` 按实例域名把流量转发到对应容器
当前推荐的最小部署方式是一台 Linux / WSL2 Ubuntu 机器加 Docker。生产域名和 HTTPS 可以放在项目外层的 Nginx、Caddy、Traefik 或云负载均衡上。
## 组件
| 目录 | 职责 | 默认端口 |
| --- | --- | --- |
| `auth-portal/` | 用户登录、注册、模型配置引导入口 | `3081` |
| `authz-service/` | AuthZ 服务,负责账号和 backend 身份编排 | `19090` |
| `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
-> authz-service POST /portal/register
-> deploy-control POST /api/instances/register
-> create-instance.sh
-> app-instance/create-instance.sh
-> app-instance POST /api/auth/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
-> deploy-control POST /api/instances/resolve
-> app-instance POST /api/auth/login
-> app-instance frontend URL
```
## 这份部署指南的前提
## 快速开始
这份 README 只覆盖一套基准方案
本机完整流程见
- 一台 Linux 服务器
- 用 Docker 运行 `auth-portal``authz-service``deploy-control``router-proxy`
- `deploy-control` 通过 Docker socket 在同一台机器上创建 `app-instance`
- 所有容器共用一个 Docker network`nano-instance-edge`
- 测试域名先用 `nip.io`
- [部署指南.md](./部署指南.md)
如果你后面要接 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
export PROJECT_ROOT=/home/ivan/xuan/nano_project
export NANO_NET=nano-instance-edge
export PROJECT_ROOT=/home/ivan/xuan/beaver_project
export BEAVER_NET=beaver-instance-edge
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export NANO_SERVER_IP=203.0.113.10
export NANO_BASE_DOMAIN="${NANO_SERVER_IP}.nip.io"
export BEAVER_BASE_DOMAIN=127.0.0.1.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 NANO_MODEL=openai/gpt-5
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'
export BEAVER_OUTLOOK_MCP_URL=''
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
```
## 变量到底是什么
启动顺序:
最容易混淆的是下面这几组:
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-control` 写入 `app-instance``config.json`
`DEPLOY_API_TOKEN``DEPLOY_CONTROL_API_TOKEN` 必须相等:
## 目录持久化
- `auth-portal` / `authz-service``DEPLOY_API_TOKEN` 请求 `deploy-control`
- `deploy-control``DEPLOY_CONTROL_API_TOKEN` 校验请求。
至少保留这几个目录
- `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. 首次注册验收
打开:
`AUTHZ_ISSUER` 在这套单机部署里要写容器网络地址
```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`
2. `authz-service``deploy-control /api/instances/register`
3. `deploy-control` 创建一个新的 `app-instance`
4. `app-instance` 回调 AuthZ 完成 backend 身份初始化
5. 浏览器被跳转到该实例自己的 URL
同时你应该能看到:
`DEPLOY_PUBLIC_*` 决定新实例展示给用户的 URL
```bash
cd "$PROJECT_ROOT"
./app-instance/list-instances.sh --json
DEPLOY_PUBLIC_SCHEME=http
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io
DEPLOY_PUBLIC_PORT=8088
```
实例 URL 形如:
本机测试时实例 URL 形如:
```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
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
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 配置是写到哪里
## 子项目文档
写到每个实例自己的:
```text
app-instance/runtime/instances/<slug>/beaver-home/config.json
```
不是写在 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)
- [`app-instance/README.md`](./app-instance/README.md)
- [`auth-portal/src/README.md`](./auth-portal/src/README.md)
- [`authz-service/README.md`](./authz-service/README.md)
- [`deploy-control/README.md`](./deploy-control/README.md)
- [`router-proxy/README.md`](./router-proxy/README.md)

View File

@ -45,14 +45,14 @@ runtime/registry/instances.json
### 1. 构建镜像
```bash
docker build -t nano/app-instance:latest .
docker build -t beaver/app-instance:latest .
```
### 2. 创建实例
```bash
./create-instance.sh \
--image nano/app-instance:latest \
--image beaver/app-instance:latest \
--instance-id demo-001 \
--auth-username admin \
--auth-password 123456 \

View File

@ -260,7 +260,12 @@ class EngineLoader:
review_service=review_service,
publisher=skill_publisher,
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),
)

View File

@ -1437,6 +1437,15 @@ def create_app(
raise HTTPException(status_code=404, detail="Safety report not found")
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")
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()
@ -1831,6 +1840,7 @@ def create_app(
"model": _clean_text(payload.get("model")) or None,
"provider_name": _clean_text(payload.get("provider_name")) 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"))
if websocket_thinking_enabled is not None:
@ -1844,6 +1854,7 @@ def create_app(
"content": f"Run failed before completion: {exc}",
"session_id": session_id,
"finish_reason": "error",
"tool_iterations": 0,
"metadata": {
"error": str(exc),
"input_metadata": _websocket_input_metadata(payload),
@ -2403,6 +2414,15 @@ def _bool_or_none(value: Any) -> bool | 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]:
validation_result = getattr(result, "validation_result", 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),
"run_id": getattr(result, "run_id", None),
"finish_reason": getattr(result, "finish_reason", None),
"tool_iterations": getattr(result, "tool_iterations", 0),
"provider_name": getattr(result, "provider_name", None),
"model": getattr(result, "model", None),
"usage": dict(getattr(result, "usage", {}) or {}),

View File

@ -42,6 +42,8 @@ class EvidenceSelector:
resolved_session_ids: list[str] = list(dict.fromkeys(session_ids or []))
task_summaries: list[str] = []
session_excerpts: list[str] = []
tool_names: list[str] = []
selected_tool_names: list[str] = []
for run_id in run_ids:
record = runs_by_id.get(run_id)
if record is None:
@ -56,12 +58,19 @@ class EvidenceSelector:
excerpt = self._session_excerpt(record.session_id, run_id)
if 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(
run_ids=resolved_run_ids,
session_ids=resolved_session_ids,
task_summaries=task_summaries[:8],
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:
@ -74,3 +83,37 @@ class EvidenceSelector:
continue
visible.append(f"{event.role}: {event.content.strip()}")
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

View File

@ -32,8 +32,14 @@ class SkillDraftSafetyChecker:
"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_prefixes = allowed_tool_prefixes or set()
def check(self, draft: SkillDraft) -> SkillDraftSafetyReport:
issues: list[str] = []
@ -50,7 +56,7 @@ class SkillDraftSafetyChecker:
tool_hints = _tool_hints(frontmatter)
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:
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})
@ -80,6 +86,11 @@ class SkillDraftSafetyChecker:
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]:
raw = frontmatter.get("tools")

View File

@ -65,19 +65,29 @@ class SkillDraftSynthesizer:
)
payload = self._parse_payload(response.content or "")
if payload:
return payload
return self._normalize_payload(payload, evidence_packet)
return self._fallback_payload(candidate, evidence_packet, action)
@staticmethod
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 (
f"Action: {action}\n"
f"Candidate kind: {candidate.kind}\n"
f"Reason: {candidate.reason}\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)
+ "\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
@ -103,6 +113,19 @@ class SkillDraftSynthesizer:
"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
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"
@ -111,8 +134,25 @@ class SkillDraftSynthesizer:
return {
"frontmatter": {
"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",
"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

View File

@ -26,38 +26,42 @@ class MainAgentRouter:
) -> MainAgentDecision:
if provider is None:
return self._fallback(active_task=active_task, reason="router_provider_unavailable")
try:
chat_kwargs: dict[str, Any] = {
"messages": [
{
"role": "system",
"content": (
"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. "
"Do not answer the user. Do not explain."
),
},
{
"role": "user",
"content": self._prompt(
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
intent_skill=intent_skill,
),
},
],
"tools": None,
"model": model,
"max_tokens": 256,
"temperature": 0.0,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
response = await asyncio.wait_for(provider.chat(**chat_kwargs), timeout=timeout_seconds)
return self.from_json(response.content or "", active_task=active_task)
except Exception as exc:
return self._fallback(active_task=active_task, reason=f"router_failed: {exc}")
chat_kwargs: dict[str, Any] = {
"messages": [
{
"role": "system",
"content": (
"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. "
"Do not answer the user. Do not explain."
),
},
{
"role": "user",
"content": self._prompt(
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
intent_skill=intent_skill,
),
},
],
"tools": None,
"model": model,
"max_tokens": 256,
"temperature": 0.0,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
last_error: Exception | None = None
for attempt_timeout in (timeout_seconds, 12.0):
try:
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:
payload = self._parse_json_object(text)

View File

@ -2,10 +2,46 @@
这是新 `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`
2. 多 agent 编排进入 `coordinator`
3. skills、memory、permissions 独立成能力层。
4. `interfaces` 只做薄入口。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -158,7 +158,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
},
"authz": {
"enabled": True,
"baseUrl": "http://nano-authz-service:19090",
"baseUrl": "http://beaver-authz-service:19090",
},
"backend_identity": {
"backend_id": "stevenli",
@ -180,7 +180,7 @@ def test_load_config_reads_mcp_authz_identity(tmp_path) -> None:
assert server.sensitive 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.client_id == "stevenli"

View File

@ -38,6 +38,39 @@ class RouterProvider(LLMProvider):
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:
return TaskRecord(
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 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

View File

@ -15,7 +15,12 @@ from beaver.skills.reviews import ReviewService
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)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
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,
review_service=ReviewService(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 "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]

View File

@ -7,6 +7,7 @@ from types import SimpleNamespace
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle
from beaver.engine.session import SessionManager
from beaver.memory.runs import RunMemoryStore, RunRecord
from beaver.memory.skills import SkillLearningCandidate, SkillLearningStore
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 "")
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:
pipeline = _pipeline(tmp_path)
pipeline.learning_store.record_learning_candidate(

View File

@ -78,6 +78,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
"model": None,
"provider_name": None,
"embedding_model": None,
"max_tool_iterations": None,
}
]
assert message["type"] == "message"
@ -128,5 +129,6 @@ def test_websocket_runtime_error_returns_assistant_error_message() -> None:
assert message["role"] == "assistant"
assert message["session_id"] == "web:alpha"
assert message["finish_reason"] == "error"
assert message["tool_iterations"] == 0
assert "boom" in message["content"]
assert pong == {"type": "pong"}

View File

@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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"
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 "
@ -25,6 +25,7 @@ MODEL="openai/gpt-5"
PROVIDER="openai"
API_KEY="${API_KEY:-}"
API_BASE="${API_BASE:-}"
SKIP_PROVIDER_CONFIG=0
AUTH_USERNAME=""
AUTH_PASSWORD=""
USERNAME=""
@ -42,22 +43,23 @@ REPLACE=0
usage() {
cat <<'EOF'
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:
--instance-id <id> Unique instance id.
--auth-username <name> Initial web login username.
--auth-password <password> Initial web login password.
--api-key <key> Provider API key for Boardware Genius.
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>
--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>
--provider <name> Provider key in config.json. Default: openai
--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
--skip-provider-config Create the instance without model/provider/API key settings.
--authz-base-url <url> AuthZ service base URL.
--authz-outlook-mcp-url <url>
Managed Outlook MCP URL for AuthZ mode.
@ -134,6 +136,7 @@ render_config_json() {
PROVIDER="$PROVIDER" \
API_KEY="$API_KEY" \
API_BASE="$API_BASE" \
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
@ -151,11 +154,20 @@ target = Path(os.environ["TARGET_PATH"])
provider = os.environ["PROVIDER"]
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
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"]}
api_base = os.environ["API_BASE"].strip()
if api_base:
provider_cfg["apiBase"] = api_base
providers = {}
agent_defaults = {
"workspace": "/root/.beaver/workspace",
}
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 = [
"auth_status",
@ -193,14 +205,9 @@ if outlook_mcp_url:
data = {
"agents": {
"defaults": {
"workspace": "/root/.beaver/workspace",
"model": os.environ["MODEL"],
}
},
"providers": {
provider: provider_cfg,
"defaults": agent_defaults
},
"providers": providers,
"tools": {
"restrictToWorkspace": True,
"mcpServers": default_mcp_servers,
@ -345,6 +352,10 @@ while [[ $# -gt 0 ]]; do
MODEL="${2:-}"
shift 2
;;
--skip-provider-config)
SKIP_PROVIDER_CONFIG=1
shift
;;
--auth-username)
AUTH_USERNAME="${2:-}"
shift 2
@ -438,7 +449,9 @@ done
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username 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")"
USERNAME="${USERNAME:-$AUTH_USERNAME}"
@ -469,10 +482,12 @@ if [[ -z "$INSTANCE_HOST" ]]; then
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
fi
case "$KNOWN_PROVIDERS" in
*" ${PROVIDER} "*) ;;
*) die "unsupported provider '${PROVIDER}'" ;;
esac
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
case "$KNOWN_PROVIDERS" in
*" ${PROVIDER} "*) ;;
*) die "unsupported provider '${PROVIDER}'" ;;
esac
fi
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"
@ -550,9 +565,9 @@ RUN_ARGS=(
-e "APP_FRONTEND_PORT=3000"
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
--label "nano.instance.id=${INSTANCE_ID}"
--label "nano.instance.slug=${INSTANCE_SLUG}"
--label "nano.instance.public_url=${PUBLIC_URL}"
--label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}"
--label "beaver.instance.public_url=${PUBLIC_URL}"
)
if [[ -n "$NETWORK_NAME" ]]; then

View File

@ -41,6 +41,7 @@ import {
listSkillDrafts,
listSkills,
publishSkillDraft,
recheckSkillDraftSafety,
regenerateSkillDraft,
rejectSkillDraft,
rollbackPublishedSkill,
@ -412,6 +413,11 @@ export default function SkillsPage() {
rejectSkillDraft(draft.skill_name, draft.draft_id)
)
}
onRecheckSafety={() =>
runAction(`safety:${draft.draft_id}`, () =>
recheckSkillDraftSafety(draft.skill_name, draft.draft_id)
)
}
onPublish={(confirmHighRisk) =>
runAction(`publish:${draft.draft_id}`, () =>
publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk)
@ -697,6 +703,7 @@ function DraftCard({
onSubmit,
onApprove,
onReject,
onRecheckSafety,
onPublish,
}: {
draft: SkillDraft;
@ -704,6 +711,7 @@ function DraftCard({
onSubmit: () => Promise<unknown>;
onApprove: () => Promise<unknown>;
onReject: () => Promise<unknown>;
onRecheckSafety: () => Promise<unknown>;
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
}) {
const { locale } = useAppI18n();
@ -814,6 +822,10 @@ function DraftCard({
<XCircle className="mr-2 h-4 w-4" />
{t('拒绝', 'Reject')}
</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}>
<Rocket className="mr-2 h-4 w-4" />
{t('发布', 'Publish')}

View File

@ -3,7 +3,7 @@
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
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 { Badge } from '@/components/ui/badge';
@ -17,6 +17,14 @@ import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runti
import { useChatStore } from '@/lib/store';
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') {
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
@ -53,11 +61,13 @@ export default function TaskDetailPage() {
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
const [revision, setRevision] = useState('');
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState<string | null>(null);
React.useEffect(() => {
setSelectedRunId(task?.rootRunId ?? null);
setRuntimeFeedback(null);
}, [task?.rootRunId]);
React.useEffect(() => {
@ -138,6 +148,8 @@ export default function TaskDetailPage() {
});
};
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
if (!task && backendTask) {
const validation = backendTask.validation_result;
const accepted = Boolean(validation?.accepted);
@ -185,6 +197,26 @@ export default function TaskDetailPage() {
</CardContent>
</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>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
@ -424,37 +456,33 @@ export default function TaskDetailPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '修订意见', 'Revision')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={revision}
onChange={(event) => setRevision(event.target.value)}
placeholder={pickAppText(locale, '直接写下需要调整的地方...', 'Describe what should change...')}
/>
<Button
className="w-full"
disabled={!revision.trim() || Boolean(actionBusy)}
onClick={() =>
void runAction('revision', async () => {
updateMessageFeedback(task.rootRunId, 'revise');
await submitChatFeedback({
sessionId: task.sessionId || 'web:default',
runId: task.rootRunId,
feedbackType: 'revise',
comment: revision.trim(),
});
setRevision('');
})
}
>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '提交修订', 'Submit revision')}
</Button>
</CardContent>
</Card>
<TaskFeedbackPanel
sessionId={task.sessionId || 'web:default'}
runId={task.rootRunId}
taskStatus={task.status}
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
actionBusy={actionBusy}
revision={revision}
onRevisionChange={setRevision}
onSubmit={(feedbackType, comment) =>
runAction(`runtime-feedback-${feedbackType}`, async () => {
updateMessageFeedback(task.rootRunId, feedbackType);
await submitChatFeedback({
sessionId: task.sessionId || 'web:default',
runId: task.rootRunId,
feedbackType,
comment,
});
setRuntimeFeedback({
feedback_type: feedbackType,
comment: comment || '',
created_at: new Date().toISOString(),
run_id: task.rootRunId,
});
setRevision('');
})
}
/>
<Card>
<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 }) {
const { locale } = useAppI18n();
return (
@ -597,6 +755,24 @@ function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
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[] {
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
}

View File

@ -777,6 +777,13 @@ export async function getSkillDraftSafety(skillName: string, draftId: string): P
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> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/eval`);
}

View File

@ -1,5 +1,5 @@
# auth-portal server-side runtime config
AUTHZ_API_BASE_URL=http://nano-authz-service:19090
DEPLOY_API_BASE_URL=http://nano-deploy-control:8090
AUTHZ_API_BASE_URL=http://beaver-authz-service:19090
DEPLOY_API_BASE_URL=http://beaver-deploy-control:8090
DEPLOY_API_TOKEN=change-me

View File

@ -12,7 +12,7 @@ Registration now goes through AuthZ, while login/runtime lookup still uses deplo
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
AUTHZ_API_BASE_URL=http://127.0.0.1:19090

View 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) });
}
}

View File

@ -1,16 +1,26 @@
:root {
--bg: #f4efe6;
--bg-strong: #e6d8bf;
--panel: rgba(23, 26, 31, 0.88);
--panel-border: rgba(255, 255, 255, 0.1);
--text: #f7f1e7;
--muted: rgba(247, 241, 231, 0.72);
--accent: #ff8d3a;
--accent-strong: #ff6b00;
--danger: #ff8787;
--input: rgba(255, 255, 255, 0.08);
--input-focus: rgba(255, 141, 58, 0.28);
--shadow: 0 40px 90px rgba(26, 24, 21, 0.28);
--background: #f5f3f1;
--foreground: #0b0b0b;
--primary: #1d1715;
--secondary: #e5e2df;
--muted: #ddd9d6;
--accent: #cac5c0;
--zinc-50: #f7f5f4;
--zinc-100: #ece8e5;
--zinc-200: #d8d2ce;
--zinc-300: #b8aea8;
--zinc-400: #8b7e77;
--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 {
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
color: var(--text);
color: var(--foreground);
background:
radial-gradient(circle at top left, rgba(255, 141, 58, 0.28), transparent 28%),
radial-gradient(circle at right center, rgba(19, 104, 93, 0.18), transparent 24%),
linear-gradient(135deg, var(--bg) 0%, #d6c09b 45%, var(--bg-strong) 100%);
linear-gradient(90deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.42) 1px, transparent 1px),
var(--background);
background-size: 44px 44px;
font-family: "Public Sans", Inter, "Avenir Next", "Segoe UI", sans-serif;
}
a {
@ -38,145 +49,366 @@ a {
}
button,
input {
input,
select {
font: inherit;
}
.portal-page {
position: relative;
min-height: 100vh;
display: grid;
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 {
width: min(980px, 100%);
width: min(1080px, 100%);
display: grid;
grid-template-columns: 1.05fr 0.95fr;
grid-template-columns: minmax(0, 1.02fr) minmax(420px, 0.98fr);
overflow: hidden;
border-radius: 28px;
box-shadow: var(--shadow);
background: rgba(255, 250, 241, 0.45);
backdrop-filter: blur(12px);
border: 1px solid rgba(184, 174, 168, 0.42);
border-radius: 24px;
background: rgba(247, 245, 244, 0.72);
box-shadow: var(--shadow-floating);
backdrop-filter: blur(14px);
}
.portal-brand {
position: relative;
min-height: 620px;
padding: 48px 42px;
color: #26180d;
display: flex;
flex-direction: column;
justify-content: center;
padding: 56px;
color: var(--foreground);
background:
linear-gradient(160deg, rgba(255, 240, 217, 0.82), rgba(255, 220, 174, 0.64)),
linear-gradient(135deg, #ffd7a3, #f3b15f);
linear-gradient(135deg, rgba(227, 232, 226, 0.72), transparent 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0.62), rgba(229, 226, 223, 0.58)),
var(--zinc-100);
}
.portal-brand::after {
content: "";
position: absolute;
inset: 22px;
border-radius: 22px;
border: 1px solid rgba(38, 24, 13, 0.08);
inset: 24px;
border: 1px solid rgba(184, 174, 168, 0.36);
border-radius: 18px;
pointer-events: none;
}
.portal-logo-lockup {
width: 104px;
height: 104px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 112px;
height: 112px;
padding: 10px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
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);
padding: 12px;
border: 1px solid var(--zinc-200);
border-radius: 24px;
background: rgba(247, 245, 244, 0.78);
box-shadow: var(--shadow-soft);
}
.portal-logo-image {
width: 100%;
height: auto;
border-radius: 16px;
}
.portal-kicker {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
margin-top: 34px;
padding: 8px 12px;
border: 1px solid var(--zinc-200);
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-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.portal-title {
margin: 26px 0 14px;
font-size: clamp(36px, 5vw, 60px);
line-height: 0.95;
letter-spacing: -0.06em;
max-width: 520px;
margin: 22px 0 16px;
color: var(--primary);
font-family: "Lora", Georgia, serif;
font-size: clamp(42px, 6vw, 68px);
font-weight: 600;
line-height: 1.04;
}
.portal-copy {
max-width: 460px;
font-size: 16px;
line-height: 1.65;
color: rgba(38, 24, 13, 0.78);
max-width: 500px;
margin: 0;
color: var(--zinc-600);
font-size: 17px;
line-height: 1.7;
}
.portal-notes {
width: min(100%, 500px);
margin-top: 34px;
display: grid;
gap: 14px;
gap: 12px;
}
.portal-note {
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.45);
border: 1px solid rgba(38, 24, 13, 0.08);
border: 1px solid rgba(184, 174, 168, 0.42);
border-radius: 16px;
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 {
display: block;
margin-bottom: 6px;
color: var(--primary);
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 {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
padding: 48px;
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 {
width: min(440px, 100%);
width: min(456px, 100%);
padding: 34px;
border-radius: 24px;
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
border: 1px solid rgba(184, 174, 168, 0.5);
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
box-shadow: var(--shadow-soft);
}
.auth-card h1 {
margin: 0 0 10px;
font-size: 34px;
letter-spacing: -0.05em;
color: var(--primary);
font-family: "Lora", Georgia, serif;
font-size: 36px;
font-weight: 600;
line-height: 1.15;
}
.auth-card p {
margin: 0;
color: var(--muted);
line-height: 1.6;
color: var(--zinc-500);
font-size: 15px;
line-height: 1.65;
}
.auth-form {
margin-top: 26px;
margin-top: 28px;
display: grid;
gap: 16px;
}
@ -187,91 +419,294 @@ input {
}
.field label {
font-size: 14px;
color: var(--muted);
color: var(--zinc-600);
font-size: 13px;
font-weight: 700;
}
.field input {
.field input,
.field select {
width: 100%;
padding: 14px 16px;
border: 1px solid transparent;
border-radius: 14px;
color: var(--text);
background: var(--input);
min-height: 48px;
padding: 13px 14px;
border: 1px solid var(--zinc-200);
border-radius: 12px;
color: var(--foreground);
background: rgba(247, 245, 244, 0.76);
outline: none;
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
.field input:focus {
border-color: rgba(255, 141, 58, 0.65);
background: rgba(255, 255, 255, 0.11);
box-shadow: 0 0 0 5px var(--input-focus);
.field input::placeholder {
color: var(--zinc-400);
}
.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 {
min-height: 22px;
font-size: 14px;
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 {
width: 100%;
padding: 14px 18px;
border: none;
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;
border: 1px solid var(--primary);
color: var(--zinc-50);
background: var(--primary);
}
.primary-button:hover {
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;
opacity: 0.68;
opacity: 0.62;
transform: none;
}
.auth-footer {
margin-top: 20px;
color: var(--muted);
color: var(--zinc-500);
font-size: 14px;
}
.auth-footer a {
color: #ffd7a3;
color: var(--primary);
font-weight: 800;
}
.status-panel {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid rgba(134, 150, 131, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
color: var(--muted);
color: var(--zinc-600);
background: rgba(227, 232, 226, 0.54);
font-size: 13px;
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) {
.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 {
grid-template-columns: 1fr;
}
.portal-brand {
min-height: auto;
padding-bottom: 30px;
padding: 40px;
}
.portal-brand::after {
inset: 18px;
}
}
@media (max-width: 640px) {
.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,
@ -279,6 +714,10 @@ input {
padding: 24px;
}
.portal-title {
font-size: 40px;
}
.auth-card {
padding: 24px 20px;
}

View File

@ -4,8 +4,8 @@ import { PortalI18nProvider } from '@/lib/i18n/provider';
import { getServerPortalLocale } from '@/lib/i18n/server';
export const metadata: Metadata = {
title: 'Boardware Agent Sandbox Auth Portal',
description: 'Boardware Agent Sandbox Auth Portal',
title: 'Boardware Agent Sandbox',
description: 'Boardware Agent Sandbox sign-in',
icons: {
icon: '/boardware-logo.jpg',
},

View File

@ -17,6 +17,7 @@ export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@ -37,74 +38,56 @@ export default function LoginPage() {
return (
<main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<div className="portal-toolbar">
<LanguageSwitcher />
</div>
<section className="portal-shell">
<div className="portal-brand">
<div className="portal-logo-lockup">
<section className="auth-page">
<div className="portal-panel">
<div className="auth-card login-card">
<Image
src="/boardware-logo.jpg"
alt="Boardware logo"
width={128}
height={128}
className="portal-logo-image"
width={120}
height={120}
className="login-logo"
priority
/>
</div>
<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>
<h1>Beaver Agentsandbox</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<div className="field login-field">
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '邮箱或用户名', 'Email or username')}</label>
<MailIcon />
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
placeholder={pickPortalText(locale, '邮箱', 'Email')}
required
/>
</div>
<div className="field">
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<div className="field login-field">
<label className="visually-hidden" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<LockIcon />
<input
id="password"
type="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
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 className="error-text">{error}</div>
@ -112,12 +95,17 @@ export default function LoginPage() {
<button className="primary-button" type="submit" disabled={loading}>
{loading
? pickPortalText(locale, '登录中...', 'Signing in...')
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
: <ArrowRightIcon />}
</button>
</form>
<div className="auth-footer">
{pickPortalText(locale, '还没有账号?', "Don't have an account yet?")} <Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '去注册', 'Create one')}</Link>
<div className="login-divider">
<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>
@ -125,3 +113,40 @@ export default function LoginPage() {
</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>
);
}

View File

@ -6,9 +6,24 @@ import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
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 { 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() {
const { locale } = usePortalI18n();
@ -19,8 +34,18 @@ export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = 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 [onboardingLoading, setOnboardingLoading] = useState(false);
const [error, setError] = useState('');
const [onboardingError, setOnboardingError] = useState('');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@ -32,7 +57,7 @@ export default function RegisterPage() {
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
}
const response = await register(username, email, password);
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
setRegistrationResponse(response);
} catch (err) {
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
} 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 (
<main className="portal-page">
<div className="absolute right-5 top-5 z-10">
<div className="portal-toolbar">
<LanguageSwitcher />
</div>
<section className="portal-shell">
<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>
<section className="auth-page">
<div className="portal-panel">
<div className="auth-card">
<h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
<p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
{registrationResponse ? (
<div className="auth-card login-card register-card">
<BrandHeader title={pickPortalText(locale, '配置模型', 'Model Setup')} />
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder={pickPortalText(locale, '例如bwgdi', 'Example: bwgdi')}
required
/>
</div>
<form className="auth-form" onSubmit={handleProviderSubmit}>
<div className="field login-field">
<label className="visually-hidden" htmlFor="provider">{pickPortalText(locale, '模型提供商', 'Model provider')}</label>
<select
id="provider"
value={provider}
onChange={(event) => handleProviderChange(event.target.value)}
disabled={onboardingLoading}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
placeholder={pickPortalText(locale, '例如steven@example.com', 'Example: steven@example.com')}
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="model">{pickPortalText(locale, '模型', 'Model')}</label>
<input
id="model"
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={pickPortalText(locale, '模型', 'Model')}
required
disabled={onboardingLoading}
/>
</div>
<div className="field">
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
required
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="apiKey">API Key</label>
<input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="off"
placeholder={provider === 'vllm' ? pickPortalText(locale, 'API Key 可选', 'API key optional') : 'API Key'}
required={provider !== 'vllm'}
disabled={onboardingLoading}
/>
<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">
<label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
required
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="apiBase">API Base</label>
<input
id="apiBase"
value={apiBase}
onChange={(event) => setApiBase(event.target.value)}
placeholder="API Base"
disabled={onboardingLoading}
/>
</div>
<div className="error-text">{error}</div>
<div className="error-text">{onboardingError}</div>
<button className="primary-button" type="submit" disabled={loading}>
{loading
? pickPortalText(locale, '注册中...', 'Creating account...')
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
</button>
</form>
<div className="auth-footer">
{pickPortalText(locale, '已有账号?', 'Already have an account?')} <Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '去登录', 'Sign in')}</Link>
<button className="primary-button" type="submit" disabled={onboardingLoading}>
{onboardingLoading ? pickPortalText(locale, '写入中...', 'Saving...') : <ArrowRightIcon />}
</button>
<button className="secondary-button" type="button" onClick={continueWithoutProvider} disabled={onboardingLoading}>
{pickPortalText(locale, '跳过', 'Skip')}
</button>
</form>
</div>
) : (
<div className="auth-card login-card register-card">
<BrandHeader title="Beaver Agentsandbox" />
<div className="status-panel">
{pickPortalText(locale, 'Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。', 'The portal first calls the deployment controller to create the runtime, then redirects the browser into the instance URL.')}
<form className="auth-form" onSubmit={handleSubmit}>
<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>
</section>
</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>
);
}

View File

@ -11,18 +11,14 @@ export function LanguageSwitcher() {
const { locale, setLocale } = usePortalI18n();
return (
<div className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/25 p-1 backdrop-blur">
<span className="ml-1 text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">Lang</span>
<div className="language-switcher">
<span>Lang</span>
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLocale(option.value)}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
locale === option.value
? 'bg-white text-slate-900'
: 'text-white/75 hover:text-white'
}`}
className={locale === option.value ? 'is-active' : undefined}
>
{option.label}
</button>

View File

@ -5,6 +5,16 @@ import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
const REQUEST_TIMEOUT_MS = 8000;
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 {
const trimmed = value?.trim();
@ -82,6 +92,13 @@ export async function register(username: string, email: string, password: string
}, 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 {
const locale = getCurrentPortalLocale();
const frontendBaseUrl = getFrontendBaseUrl(response);

View File

@ -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> = {};
if (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',
headers,
body: JSON.stringify(payload),
});
}, timeoutMs);
}
export async function callAuthzService<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,9 +1,9 @@
# authz-service runtime config
AUTHZ_ISSUER=http://nano-authz-service:19090
AUTHZ_ISSUER=http://beaver-authz-service:19090
AUTHZ_INTERNAL_TOKEN=change-me
AUTHZ_ACCESS_TOKEN_TTL_SECONDS=3600
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

View File

@ -28,7 +28,7 @@
## 快速启动
```bash
cd /home/ivan/xuan/nano_project/authz-service
cd /home/ivan/xuan/beaver_project/authz-service
cp .env.example .env
set -a
. ./.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`

View File

@ -434,10 +434,6 @@ async def portal_register(req: PortalRegisterRequest) -> dict[str, Any]:
optional_fields = {
"instance_id": _clean_optional(req.instance_id),
"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),
}
for key, value in optional_fields.items():

View File

@ -140,10 +140,6 @@ class PortalRegisterRequest(BaseModel):
email: str | None = None
instance_id: 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
replace: bool = False

View File

@ -3,8 +3,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE_NAME="${IMAGE_NAME:-nano/authz-service:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-nano-authz-service}"
IMAGE_NAME="${IMAGE_NAME:-beaver/authz-service:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-beaver-authz-service}"
DATA_ROOT="${DATA_ROOT:-${SCRIPT_DIR}/runtime/data}"
HOST_PORT="${HOST_PORT:-19090}"
HOST_BIND_IP="${HOST_BIND_IP:-0.0.0.0}"

View File

@ -4,8 +4,8 @@ DEPLOY_CONTROL_HOST=0.0.0.0
DEPLOY_CONTROL_PORT=8090
DEPLOY_CONTROL_API_TOKEN=change-me
APP_INSTANCE_IMAGE=nano/app-instance:latest
APP_INSTANCE_NETWORK_NAME=nano-instance-edge
APP_INSTANCE_IMAGE=beaver/app-instance:latest
APP_INSTANCE_NETWORK_NAME=beaver-instance-edge
APP_INSTANCE_PROVIDER=openai
APP_INSTANCE_MODEL=openai/gpt-5
@ -13,7 +13,7 @@ APP_INSTANCE_API_KEY=sk-xxxxxxxx
APP_INSTANCE_API_BASE=
# 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_OUTLOOK_MCP_SERVER_ID=outlook_mcp

View File

@ -11,12 +11,12 @@
- `GET /healthz`
- `POST /api/instances/register`
- `POST /api/instances/resolve`
- `POST /api/instances/configure-provider`
- `DELETE /api/instances/{instance_id}`
## 关键环境变量
- `DEPLOY_CONTROL_API_TOKEN`
- `APP_INSTANCE_API_KEY`
- `DEFAULT_AUTHZ_BASE_URL`
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
- `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 形如:
@ -49,7 +49,7 @@ DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
## 本机启动
```bash
cd /home/ivan/xuan/nano_project/deploy-control
cd /home/ivan/xuan/beaver_project/deploy-control
uv run server.py
```
@ -58,8 +58,8 @@ uv run server.py
如果要容器化运行,需要挂载:
- Docker socket`/var/run/docker.sock`
- `/home/ivan/xuan/nano_project/app-instance`
- `/home/ivan/xuan/nano_project/router-proxy`
- `/home/ivan/xuan/beaver_project/app-instance`
- `/home/ivan/xuan/beaver_project/router-proxy`
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
@ -72,20 +72,21 @@ uv run server.py
```bash
docker run -d \
--name nano-deploy-control \
--name beaver-deploy-control \
--restart unless-stopped \
--network nano-instance-edge \
--network beaver-instance-edge \
-p 8090:8090 \
-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/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \
-e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \
-e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \
-v /home/ivan/xuan/beaver_project/app-instance:/home/ivan/xuan/beaver_project/app-instance \
-v /home/ivan/xuan/beaver_project/router-proxy:/home/ivan/xuan/beaver_project/router-proxy \
-e APP_INSTANCE_DIR=/home/ivan/xuan/beaver_project/app-instance \
-e ROUTER_PROXY_DIR=/home/ivan/xuan/beaver_project/router-proxy \
-e DEPLOY_CONTROL_API_TOKEN=change-me \
-e APP_INSTANCE_IMAGE=nano/app-instance:latest \
-e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \
-e APP_INSTANCE_API_KEY=sk-xxxxxxxx \
nano/deploy-control:latest
-e APP_INSTANCE_IMAGE=beaver/app-instance:latest \
-e APP_INSTANCE_NETWORK_NAME=beaver-instance-edge \
beaver/deploy-control:latest
```
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker最终导致实例容器拿不到 `config.json` 并持续重启。
新实例注册时不会写入模型 provider/API key。注册后由 `auth-portal` 引导页调用 `POST /api/instances/configure-provider`,在用户确认后写入该实例配置并重启实例容器。

View File

@ -34,12 +34,8 @@ PROXY_RELOAD_SCRIPT = Path(
).resolve()
API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "nano/app-instance:latest").strip()
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "nano-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()
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "beaver/app-instance:latest").strip()
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "beaver-instance-edge").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_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")
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")
KNOWN_PROVIDERS = {
"anthropic",
"openai",
"openrouter",
"deepseek",
"groq",
"zhipu",
"dashscope",
"vllm",
"gemini",
"moonshot",
"minimax",
"aihubmix",
"siliconflow",
"volcengine",
}
API_KEY_OPTIONAL_PROVIDERS = {"vllm"}
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()
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]:
if not REGISTRY_PATH.exists():
return {"instances": []}
@ -210,10 +230,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
ensure_network()
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
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_outlook_mcp_url = str(
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
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 = [
str(CREATE_INSTANCE_SCRIPT),
"--image",
@ -238,12 +251,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
username,
"--email",
email,
"--provider",
provider,
"--model",
model,
"--api-key",
api_key,
"--skip-provider-config",
"--backend-name",
backend_name,
"--public-url",
@ -253,8 +261,6 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
"--network",
INSTANCE_NETWORK_NAME,
]
if api_base:
command.extend(["--api-base", api_base])
if authz_base_url:
command.extend(["--authz-base-url", authz_base_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]:
instance_id = str(record.get("instance_id", "") or "").strip()
if not instance_id:
@ -467,6 +576,10 @@ class Handler(BaseHTTPRequestHandler):
payload = self._read_json_body()
self._json_response(HTTPStatus.OK, resolve_instance(payload))
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")
except ApiError as exc:
self._json_response(exc.status_code, {"detail": exc.detail})

View File

@ -1,8 +1,8 @@
# router-proxy startup config
PROXY_IMAGE=nginx:1.27-alpine
PROXY_CONTAINER_NAME=nano-router-proxy
PROXY_NETWORK_NAME=nano-instance-edge
PROXY_CONTAINER_NAME=beaver-router-proxy
PROXY_NETWORK_NAME=beaver-instance-edge
PROXY_HTTP_PORT=8088
# Optional host-side overrides.

View File

@ -19,13 +19,13 @@
## 默认约定
- 容器名:`nano-router-proxy`
- Docker network`nano-instance-edge`
- 容器名:`beaver-router-proxy`
- Docker network`beaver-instance-edge`
- 对外端口:`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` 一般不需要配。
这两个是主机侧脚本路径,默认值会按 `start-proxy.sh` 自己所在目录推导,比写死某台机器上的绝对路径更稳。
@ -33,14 +33,14 @@
## 启动
```bash
cd /home/ivan/xuan/nano_project/router-proxy
cd /home/ivan/xuan/beaver_project/router-proxy
./start-proxy.sh
```
## 重载
```bash
cd /home/ivan/xuan/nano_project/router-proxy
cd /home/ivan/xuan/beaver_project/router-proxy
./reload-proxy.sh --start-if-missing
```

View File

@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
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}"
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"
START_IF_MISSING=0

View File

@ -5,8 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RENDER_SCRIPT="${SCRIPT_DIR}/render-routes.py"
PROXY_IMAGE="${PROXY_IMAGE:-nginx:1.27-alpine}"
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-nano-router-proxy}"
PROXY_NETWORK_NAME="${PROXY_NETWORK_NAME:-nano-instance-edge}"
PROXY_CONTAINER_NAME="${PROXY_CONTAINER_NAME:-beaver-router-proxy}"
PROXY_NETWORK_NAME="${PROXY_NETWORK_NAME:-beaver-instance-edge}"
PROXY_HTTP_PORT="${PROXY_HTTP_PORT:-8088}"
REGISTRY_PATH="${REGISTRY_PATH:-${SCRIPT_DIR}/../app-instance/runtime/registry/instances.json}"
OUTPUT_PATH="${OUTPUT_PATH:-${SCRIPT_DIR}/runtime/conf.d/instances.conf}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

View File

@ -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`
- `端口` 不归 DNS 管
- 所以“域名配到哪个端口”本质上是反向代理或公网入口层在处理
## 1. 默认端口职责
也就是说:
| 端口 | 组件 | 是否建议公网直接暴露 |
| --- | --- | --- |
| `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
https://portal.example.com
@ -67,168 +36,98 @@ https://alice.apps.example.com
https://bob.apps.example.com
```
其中
含义
- `portal.example.com` `auth-portal`
- `alice.apps.example.com` `router-proxy`
- `portal.example.com` `auth-portal`
- `*.apps.example.com` `router-proxy`
- 每个实例使用一个子域名,例如 `alice.apps.example.com`
---
不要把门户和实例混在同一个 Host 上。`router-proxy` 是实例入口,`auth-portal` 是认证入口,两者职责不同。
## 3. 只配 DNS 还不够
## 3. DNS 要怎么配
很多人最容易误解的是:
假设服务器公网 IP 是 `203.0.113.10`
“我把域名解析到服务器 IP就等于已经配好了”
这不对。
你还要解决:
- 用户访问 `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`
也就是:
DNS 记录:
```text
portal.example.com -> auth-portal -> 3081
*.apps.example.com -> router-proxy -> 8088
portal.example.com A 203.0.113.10
apps.example.com A 203.0.113.10
*.apps.example.com A 203.0.113.10
```
如果你的 DNS 服务商支持 CNAME也可以让通配子域名 CNAME 到一个已有 A 记录,但最终结果仍然必须能解析到服务器入口 IP。
注意:
- `router-proxy` 是靠 `Host` 头识别具体实例的
- 所以必须把原始 Host 透传过去
- `*.apps.example.com` 用于实例子域名。
- `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
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`
- `DEPLOY_PUBLIC_BASE_DOMAIN`
- `DEPLOY_PUBLIC_PORT`
## 5. 项目内部要改哪些变量
例如
实例公网地址由 `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
-e DEPLOY_PUBLIC_SCHEME="https" \
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
-e DEPLOY_PUBLIC_PORT="443" \
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
```
或者如果你暂时还是明文 HTTP
`deploy-control`
```bash
-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" \
```
---
## 6. 什么时候可以把端口从 URL 里去掉
如果你希望用户访问:
生成实例地址:
```text
https://alice.apps.example.com
http://alice.127.0.0.1.nip.io:8088
```
而不是
```text
http://alice.apps.example.com:8088
```
那你需要满足这两个条件:
1. 外层已经有监听 `80/443` 的反向代理
2. 它已经把 `*.apps.example.com` 转发到本机 `8088`
这时项目内部就应该写:
正式 HTTPS
```bash
DEPLOY_PUBLIC_SCHEME=https
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
DEPLOY_PUBLIC_PORT=443
export BEAVER_BASE_DOMAIN=apps.example.com
```
或者很多时候你也可以直接在显示层隐藏默认端口概念,让用户只看标准 `https` 地址。
---
## 7. 一套推荐的正式域名方案
假设你有:
- 门户域名:`portal.example.com`
- 实例根域名:`apps.example.com`
推荐这样做:
### 项目内部
`deploy-control`
```bash
@ -237,159 +136,200 @@ DEPLOY_PUBLIC_PORT=443
-e DEPLOY_PUBLIC_PORT="443" \
```
本机部署变量
生成实例地址
```bash
export NANO_BASE_DOMAIN=apps.example.com
```text
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
- `portal.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
```text
portal.example.com -> 服务器 IP
apps.example.com -> 服务器 IP
*.apps.example.com -> 服务器 IP
```
这已经足够验证整个系统:
## 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
---
## 11. 一句话结论
如果你问:
“域名应该配到什么端口上?”
最实用的答案是:
- 门户域名 -> `3081`
- 实例泛域名 -> `8088`
- `8090``19090` 不建议直接公开
但更准确地说:
- 域名解析本身不带端口
- 真正的端口转发,是由外层反向代理做的
---
## 12. 你后面最可能要补的东西
如果你准备上正式域名,下一步通常是补下面其中一个:
- `Nginx` 反向代理配置
- `Caddy` 配置
- 云负载均衡转发规则
- HTTPS 证书配置
如果你要,我下一步可以继续给你补:
- `Nginx 域名反代示例.md`
- 或者 `Caddy 域名反代示例.md`
都可以直接按这个项目的端口结构来写。
等准备对外访问时,再切换正式 DNS、HTTPS 和外层代理

View File

@ -1,42 +1,29 @@
# nano_project 本机一步步部署指南
# Beaver Project 本机部署指南
这份文档适合第一次在本机把整个项目跑起来的人,目标是
- 在一台 `Linux``WSL2 Ubuntu` 机器上
-`Docker` 跑完整链路
- 最后能在浏览器里注册账号,并自动创建你的专属实例
这套项目当前的推荐本机测试方式是:
这份文档用于在一台 Linux 或 WSL2 Ubuntu 机器上跑完整链路
- `auth-portal`
- `authz-service`
- `deploy-control`
- `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
- WSL2 Ubuntu
不推荐直接按这份文档在纯 Windows 命令行里照抄,因为这里依赖
- Docker
- Bash 脚本
- Docker Socket 挂载
- 宿主机目录挂载
### 你需要先装好的工具
需要工具
- `docker`
- `git`
@ -44,7 +31,7 @@
- `openssl`
- `python3`
检查:
检查:
```bash
docker --version
@ -54,143 +41,89 @@ openssl version
curl --version
```
如果 `docker ps` 报错,先 Docker 启动起来
---
如果 `docker ps` 报错,先启动 Docker。
## 1. 进入项目根目录
```bash
cd /home/ivan/xuan/nano_project
```
你执行完以后,建议顺手确认一下当前目录:
```bash
cd /home/ivan/xuan/beaver_project
pwd
```
你应该看到
预期目录
```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
export PROJECT_ROOT=/home/ivan/xuan/nano_project
export NANO_NET=nano-instance-edge
export PROJECT_ROOT=/home/ivan/xuan/beaver_project
export BEAVER_NET=beaver-instance-edge
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export NANO_SERVER_IP=127.0.0.1
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
export NANO_PROVIDER=openai
export NANO_MODEL=openai/gpt-5
export NANO_API_KEY='把这里换成你自己的模型 API Key'
export NANO_API_BASE=''
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
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'
export BEAVER_OUTLOOK_MCP_URL=''
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
```
### 这里每个变量大概是干什么的
变量说明:
- `PROJECT_ROOT`
- 仓库根目录
- `NANO_NET`
- 所有容器共用的 Docker 网络
- `NANO_DEPLOY_TOKEN`
- `auth-portal` / `authz-service``deploy-control` 时的鉴权 token
- `NANO_AUTHZ_INTERNAL_TOKEN`
- AuthZ 内部接口 token
- `NANO_BASE_DOMAIN`
- 实例基础域名
- `NANO_PROVIDER`
- 新实例默认模型提供商
- `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`
| 变量 | 作用 |
| --- | --- |
| `PROJECT_ROOT` | 仓库根目录 |
| `BEAVER_NET` | 所有容器共用的 Docker network |
| `BEAVER_DEPLOY_TOKEN` | `auth-portal` / `authz-service``deploy-control` 的 token |
| `BEAVER_AUTHZ_INTERNAL_TOKEN` | AuthZ 内部接口 token |
| `BEAVER_BASE_DOMAIN` | 新实例的基域名 |
| `BEAVER_AUTHZ_URL` | 容器网络内访问 AuthZ 的地址 |
| `BEAVER_DEPLOY_URL` | 容器网络内访问 deploy-control 的地址 |
| `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 |
| `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id默认 `outlook_mcp` |
### 一个特别重要的提醒
`NANO_API_KEY` 不能空着。
如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`
`NANO_AUTHZ_URL``NANO_DEPLOY_URL` 也不能留空,而且必须带协议头。
正确写法:
`BEAVER_AUTHZ_URL``BEAVER_DEPLOY_URL` 必须带协议头。正确写法:
```text
http://nano-authz-service:19090
http://nano-deploy-control:8090
http://beaver-authz-service:19090
http://beaver-deploy-control:8090
```
错误写法:
```text
nano-authz-service:19090
nano-deploy-control:8090
172.19.207.13:19090
172.19.207.13:8090
beaver-authz-service:19090
beaver-deploy-control:8090
127.0.0.1:19090
127.0.0.1:8090
```
如果这里漏了 `http://`,注册页很容易直接报:
如果漏了 `http://`,注册页可能报:
```text
502: Request URL is missing an 'http://' or 'https://' protocol.
```
还有一个很容易忽略的点
如果你改了 shell 里的变量,已经运行的容器不会自动更新。改完这些变量后,至少要重建
- 你在 shell 里重新 `export NANO_DEPLOY_URL=...`
- 不会自动修改已经在运行中的 `nano-authz-service``nano-auth-portal`
也就是说:
- 变量改对了
- 但容器没重建
注册页还是会继续报同一个 502。
改完变量以后,至少要重建这些容器:
- `nano-authz-service`
- `nano-auth-portal`
---
- `beaver-authz-service`
- `beaver-auth-portal`
## 3. 创建运行目录
@ -202,257 +135,169 @@ mkdir -p \
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
```
一步的作用是给下面几个东西留持久化空间
些目录保存
- AuthZ 数据
- 实例注册表
- 每个用户实例的配置目录
- router-proxy 生成出来的路由文件
---
- 每个用户实例的配置和数据
- `router-proxy` 生成的路由文件
## 4. 构建镜像
第一次构建会比较久,正常情况要等几分钟。
```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
docker build -t beaver/app-instance:latest app-instance
docker build -t beaver/authz-service:latest authz-service
docker build -t beaver/deploy-control:latest deploy-control
docker build -t beaver/auth-portal:latest auth-portal/src
```
如果中间有某个镜像失败,不要继续往下跑,先把失败那一步修掉
常见失败原因:
- Docker 没启动
- 网络拉镜像失败
- 你的本机磁盘空间不够
---
如果某个镜像构建失败,先修构建错误,不要继续往下跑。
## 5. 创建共享 Docker 网络
```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
nano-instance-edge
beaver-instance-edge
```
---
## 6. 启动统一入口代理 `router-proxy`
## 6. 启动 router-proxy
```bash
cd "$PROJECT_ROOT"
PROXY_NETWORK_NAME="$NANO_NET" \
PROXY_NETWORK_NAME="$BEAVER_NET" \
PROXY_HTTP_PORT=8088 \
./router-proxy/start-proxy.sh --replace
```
启动后,统一入口
实例统一入口:
```text
http://<你的实例slug>.127.0.0.1.nip.io:8088
http://<slug>.127.0.0.1.nip.io:8088
```
例:
```text
http://alice.127.0.0.1.nip.io:8088
```
---
## 7. 启动 `authz-service`
## 7. 启动 authz-service
```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 \
--name nano-authz-service \
--name beaver-authz-service \
--restart unless-stopped \
--network "$NANO_NET" \
--network "$BEAVER_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
-e AUTHZ_ISSUER="$BEAVER_AUTHZ_URL" \
-e AUTHZ_INTERNAL_TOKEN="$BEAVER_AUTHZ_INTERNAL_TOKEN" \
-e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \
-e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
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
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``router-proxy` 的宿主机路径,要按原路径挂进容器
- 不能偷懒挂到容器里的另一个短路径比如 `/app-instance`
- 同时要把 `APP_INSTANCE_DIR``ROUTER_PROXY_DIR` 也明确传进去
- 要把宿主机真实路径按原路径挂进容器
- 不要把 `app-instance` 挂到容器里的 `/app-instance` 这种短路径。
- `APP_INSTANCE_DIR``ROUTER_PROXY_DIR` 要和挂载路径一致。
直接执行:
```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 \
--name nano-deploy-control \
--name beaver-deploy-control \
--restart unless-stopped \
--network "$NANO_NET" \
--network "$BEAVER_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_CONTROL_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
-e APP_INSTANCE_IMAGE="beaver/app-instance:latest" \
-e APP_INSTANCE_NETWORK_NAME="$BEAVER_NET" \
-e DEFAULT_AUTHZ_BASE_URL="$BEAVER_AUTHZ_URL" \
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \
-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_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` 会负责:
- 收到“创建实例”的请求
- 调用 `app-instance/create-instance.sh`
- 通过 Docker 创建对应用户实例
- 刷新 `router-proxy`
---
## 9. 启动 `auth-portal`
## 9. 启动 auth-portal
```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 \
--name nano-auth-portal \
--name beaver-auth-portal \
--restart unless-stopped \
--network "$NANO_NET" \
--network "$BEAVER_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
-e AUTHZ_API_BASE_URL="$BEAVER_AUTHZ_URL" \
-e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \
-e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
beaver/auth-portal:latest
```
这个页面就是用户看到的登录/注册入口。
虽然注册入口主要依赖 `AUTHZ_API_BASE_URL`,这里还是建议把 `DEPLOY_API_BASE_URL` 一起带上并确认非空,避免后面运行态调用 deploy-control 时再踩同一个坑。
启动完可以确认:
检查关键环境变量:
```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
curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz
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 logs --tail=50 beaver-router-proxy
```
至少应该看到这些容器:
至少应该看到这些容器:
- `nano-authz-service`
- `nano-deploy-control`
- `nano-auth-portal`
- `nano-router-proxy`
### 再看一下代理日志
```bash
docker logs --tail=50 nano-router-proxy
```
如果这一步没有明显报错,就可以开始浏览器测试了。
---
- `beaver-authz-service`
- `beaver-deploy-control`
- `beaver-auth-portal`
- `beaver-router-proxy`
## 11. 浏览器首次测试
@ -462,316 +307,177 @@ docker logs --tail=50 nano-router-proxy
http://127.0.0.1:3081/register
```
然后按顺序操作
预期流程
1. 注册一个新账号
2. 注册成功后,系统会自动创建一个你的专属实例
3. 浏览器应该跳到你的实例地址
1. 注册一个新账号
2. Portal 创建不含模型凭证的实例
3. 页面进入模型配置引导。
4. 填 provider、model、API key 后确认,或暂时跳过。
5. 浏览器跳到你的实例地址。
跳转目标一般长这样
```text
http://你的slug.127.0.0.1.nip.io:8088
```
例如:
跳转目标示例
```text
http://alice.127.0.0.1.nip.io:8088
```
---
## 12. 确认实例真的被创建出来了
## 12. 确认实例已创建
```bash
cd "$PROJECT_ROOT/app-instance"
./list-instances.sh
./list-instances.sh --json
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
```
你应该能看到类似
注册表里应包含
- `instance_id`
- `instance_slug`
- `container_name`
- `public_url`
- `instance_host`
以及对应的 `app-instance-<slug>` 容器。
## 13. 只看 auth-portal 页面
你还可以继续查
如果只想看 Portal 页面,不跑全链路
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
```
---
## 13. 如果你只是想单独看前端页面
如果你只是想看 `auth-portal` 页面样子,不跑全链路,也可以单独启动它的前端开发模式:
```bash
cd /home/ivan/xuan/nano_project/auth-portal/src
cd /home/ivan/xuan/beaver_project/auth-portal/src
npm install
npm run dev
```
然后打开:
打开:
```text
http://127.0.0.1:3081
```
但是要注意:
注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service``deploy-control`
- 这只能看页面
- 注册、登录、创建实例这些动作是否成功,仍然取决于 `authz-service``deploy-control` 有没有另外启动
---
## 14. 一键排错命令
如果你感觉“不对劲”,先跑这几条:
## 14. 常用排错命令
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker logs --tail=100 nano-authz-service
docker logs --tail=100 nano-deploy-control
docker logs --tail=100 nano-auth-portal
docker logs --tail=100 nano-router-proxy
docker logs --tail=100 beaver-authz-service
docker logs --tail=100 beaver-deploy-control
docker logs --tail=100 beaver-auth-portal
docker logs --tail=100 beaver-router-proxy
curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz
curl -I http://127.0.0.1:3081
```
如果是实例创建失败,再加两条
实例创建失败时再看
```bash
cd "$PROJECT_ROOT/app-instance"
./list-instances.sh --json
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
502: Request URL is missing an 'http://' or 'https://' protocol.
```
优先查这两条
优先查:
```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)='
```
- `beaver-authz-service` 里的 `DEPLOY_API_BASE_URL`
- `beaver-auth-portal` 里的 `AUTHZ_API_BASE_URL`
- `beaver-auth-portal` 里的 `DEPLOY_API_BASE_URL`
重点看:
如果你只是改了当前 shell 变量,但没有重建容器,旧值还会继续生效。
- `nano-authz-service` 里的 `DEPLOY_API_BASE_URL`
- `nano-auth-portal` 里的 `AUTHZ_API_BASE_URL`
### `AUTHZ_ISSUER` 写成了 `127.0.0.1`
它们都必须是完整 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
http://127.0.0.1:19090
```
正确写法
正确:
```text
http://nano-authz-service:19090
http://beaver-authz-service:19090
```
原因
原因`app-instance` 容器里的 `127.0.0.1` 指向它自己。
- 新实例容器里访问不到宿主机自己的 `127.0.0.1:19090`
### 4. `deploy-control` 的路径挂载写错
### 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
http://nano-authz-service:19090
http://nano-deploy-control:8090
$PROJECT_ROOT/app-instance -> $PROJECT_ROOT/app-instance
$PROJECT_ROOT/router-proxy -> $PROJECT_ROOT/router-proxy
```
### 6. `nip.io` 解析失败
因为 `deploy-control` 会通过宿主机 Docker socket 再创建新容器,传给 Docker 的 bind mount 源路径必须是宿主机真实路径。
如果实例跳转地址打不开,先试:
### `nip.io` 解析失败
检查:
```bash
ping 127.0.0.1.nip.io
```
如果本地网络 `nip.io` 拦了,这套子域名测试方式就会失效
如果本地网络屏蔽了 `nip.io`,子域名测试会失败。可以临时换成本机 hosts 或正式域名
### 7. 端口被占用
### 端口被占用
默认会用到这些端口:
默认端口:
- `3081`
- `8088`
- `8090`
- `19090`
- `8088`
你可以先查:
查:
```bash
ss -ltnp | grep -E '3081|8090|19090|8088'
ss -ltnp | grep -E '3081|8088|8090|19090'
```
---
## 16. 重新部署基础容器
## 16. 如果你要重新来一遍
如果你只是想“重新部署这四个基础容器”,可以先停掉它们:
只重建基础四个容器:
```bash
docker rm -f \
nano-auth-portal \
nano-authz-service \
nano-deploy-control \
nano-router-proxy 2>/dev/null || true
beaver-auth-portal \
beaver-authz-service \
beaver-deploy-control \
beaver-router-proxy 2>/dev/null || true
```
如果你还想把旧实例容器也一起清掉,再额外处理 `app-instance-*`
注意:
- 不要在你还需要旧数据的时候乱删 `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 鉴权失败这类问题
这不会自动删除实例数据。如果你还需要旧账号、旧实例或模型配置,不要删除 `runtime/` 目录