Demo 站 VPS 部署手册
把 Familiars 的在线演示站(
demo.familiars.cn)部署到一台 CPU-only VPS 的完整手册。 演示站跑完整的 Electron 渲染 SPA 的VITE_DEMO_MODEweb 构建 + 只读的 CPU-only bridge(不加载本地模型),通过 nginx 同源/api反代到 bridge(proxy_buffering off保 SSE 聊天流)。
架构
浏览器 → Cloudflare → Caddy/nginx :443 (TLS)
└── / → 静态 SPA(electron/dist,`VITE_DEMO_MODE=1` 构建)
└── /api/* → 反代到 127.0.0.1:18711(bridge,FAMILIARS_HTTP_INSECURE=1)
proxy_buffering off(SSE 聊天/状态流)- 演示 bridge 是独立进程,专用
WORKSPACE_ROUTE+FAMILIARS_DB_PATH(与生产/你本机完全隔离)。 - 只开
FAMILIARS_APPROVAL_DISABLED=1仅当需要跳过工具审批(演示默认开着审批,展示真实流程)。 - 绝不暴露
POST /api/demo/token给非演示环境 —— 该端点会返回当前 workspace 的 bridge token。
前置
- 一台 VPS(2C4G 即可;无需 GPU),Ubuntu 22.04/24.04。
- 域名:
demo.familiars.cn(加一条 A 记录到 VPS IP;若用 Cloudflare 则橙色云 + Caddy)。 - 本机仓库里有
electron/dist(VITE_DEMO_MODE=1构建)与src/。
第 1 步:构建演示 SPA
在本机(仓库根):
cd electron
VITE_DEMO_MODE=1 npm run build # Windows: set VITE_DEMO_MODE=1 && npm run build产物:electron/dist/(纯静态 Vue SPA,base: './',无 electronAPI 时自动走 window.location.origin, 启动时 POST /api/demo/token 拿公共演示 token)。
第 2 步:准备服务器
# 系统依赖(sqlite 由 Python 内置,主要是 python3.11 + venv)
apt update && apt install -y python3.11 python3.11-venv nginx创建目录并拷贝:
mkdir -p /opt/familiars-demo/{app,spa,data}
# 从本机把 src/ 与 requirements.txt 复制过去
scp -r src/ root@<VPS>:/opt/familiars-demo/app/
scp requirements.txt root@<VPS>:/opt/familiars-demo/
scp -r electron/dist root@<VPS>:/opt/familiars-demo/spa/第 3 步:装依赖 + 写环境
cd /opt/familiars-demo
python3.11 -m venv .venv
.venv/bin/pip install -r requirements.txt.env(演示专用;LLM 端点按你熟悉的配):
# LLM(演示也要用到 —— agent 的对话/生成都走这个 OpenAI 兼容端点)
BASE_URL=https://你的-llm-网关/v1
API_KEY=sk-xxxx
DEFAULT_MODEL=你的模型名
MANAGER_MODEL=你的模型名
# 演示隔离(关键)
FAMILIARS_DB_PATH=/opt/familiars-demo/data/demo.db
WORKSPACE_ROUTE=/opt/familiars-demo/data/workspace
# 桥接回退到明文 HTTP(nginx 在同一台机器上做 /api 反代,不需要 HTTPS 动机)
FAMILIARS_HTTP_INSECURE=1
# 每租户:官方桥把 WORKSPACE_ROUTE/FAMILIARS_DB_PATH 按用户 $HOME 分开即可
# (见本手册末尾“每租户隔离”)
WORKSPACE_ROUTE必须与src.base.config的默认 workspace 布局兼容 —— 它会在此目录下生成agents/、familiars.db(若未指定FAMILIARS_DB_PATH)等。Demo 各用户分开则另起进程。
第 4 步:起 bridge(CPU-only)
cd /opt/familiars-demo
WORKSPACE_ROUTE=/opt/familiars-demo/data/workspace \
FAMILIARS_DB_PATH=/opt/familiars-demo/data/demo.db \
FAMILIARS_HTTP_INSECURE=1 \
FAMILIARS_BROKER_BIND=0.0.0.0 \
FAMILIARS_APPROVAL_DISABLED=0 \
.venv/bin/python -m src.bridge.run
FAMILIARS_BROKER_BIND=0.0.0.0必须设。bridge 默认只监听127.0.0.1(src/bridge/run.py:_bind_host),而 nginx/Caddy 是另一个容器,它只能 通过宿主机网桥(172.18.0.1/host.docker.internal)回连 —— 若 bridge 绑在 loopback,反代必然 502。绑0.0.0.0后请务必确保防火墙 / 安全组不把 18711 暴露到公网 (POST /api/demo/token会返回该 workspace 的 bridge token,公网可达等于 完全接管演示数据的权限)。
系统服务(systemd,/etc/systemd/system/familiars-demo.service):
[Unit]
Description=Familiars Demo Bridge
After=network.target
[Service]
User=root
WorkingDirectory=/opt/familiars-demo
EnvironmentFile=/opt/familiars-demo/.env
Environment=FAMILIARS_BROKER_BIND=0.0.0.0
ExecStart=/opt/familiars-demo/.venv/bin/python -m src.bridge.run
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload && systemctl enable --now familiars-demo
curl http://127.0.0.1:18711/api/demo/status # 期望: {"ok":true,...}
curl http://172.18.0.1:18711/api/demo/status # 容器侧验证(与 Caddy 反代同路径)第 5 步:nginx 同源反代 + 静态站点
/etc/nginx/sites-available/familiars-demo:
server {
listen 80;
server_name demo.familiars.cn;
root /opt/familiars-demo/spa;
# —— SPA ——
location / {
try_files $uri $uri/ /index.html;
}
# —— /api 反代到 bridge(SSE 必须关闭 buffering)——
location /api/ {
proxy_pass http://127.0.0.1:18711;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
client_max_body_size 50m;
}ln -s /etc/nginx/sites-available/familiars-demo /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx若套 Cloudflare 且想上 HTTPS,把
listen 80加证书即可;或加一层 Caddy。核心不变: 同源/api反代 +proxy_buffering off。
验证(期望看到的直播状态)
浏览器打开 https://demo.familiars.cn/ 应看到 SPA:
- 顶部横幅提示“共享云端演示实例 · 只读沙箱”,第二行说明这里只有一个共享的云上 agent、 不提供 apps / 汇报 / 数字人 / 本机文件;
- 首屏
POST /api/demo/token→ 拿到demo_session_*token →X-Bridge-Token装好;
演示 token 只在
/api/demo/*与/api/chat/*放行,其余/api/*一律 401 —— 公共 token 拿不到 workspace 里任何其他数据(不会被当成 bridge 全局 token 用)。
- 给 agent 0 发一条消息 → SSE
/api/chat/stream逐字回复(观察 nginx 日志无 buffering 卡顿); - 点一个写型工具(如
write/exec)→ 拿到的是一条明确的「云端演示沙箱:只读演示,不会真实写入」 的模拟返回,而不是卡死或报错。
会话隔离("一个共享工作区 + 独立 token")
演示采用 shared-workspace + per-session token 隔离,而不是每用户一进程:
- 所有访问者连到同一个演示 bridge 进程、同一个只读工作区(
WORKSPACE_ROUTE)。 - 每人调用
/token领到独立的demo_session_*token(6 小时过期); 这个 token 只放行/api/demo/*(状态/反馈)与/api/chat/*(聊天 SSE)。 - 聊天、待办都是真实后端在各自的沙箱工作区里跑 —— 每个访问者有独立的沙箱目录 (
sessions/<账户>/workspace),文件写入互不干扰;记忆在演示中整体关闭;危险写型工具 被 gate 拦截,不会执行。
权衡:solo-agent 聊天用 DemoWorklogScope 是纯样本的;如果未来要「每个用户真有自己的记忆空间」, 就得回到每租户进程(独立 WORKSPACE_ROUTE + FAMILIARS_DB_PATH),把第 4 步包进 run-user.sh 按用户分开 —— 见下一节。
计费与并发(demo_seats 固定席位池)
演示桥会真实消耗云端 LLM 费用,因此有两道代码级限额(src/bridge/demo_seats.py 固定席位池 + src/bridge/demo_accounts.py 每账户额度):
| 限额 | 默认值 | 环境变量 | 说明 |
|---|---|---|---|
| 每账户成本 | ¥20 (2000 分) | MAX_COST_CENTS_PER_ACCOUNT(代码常量) | 按浏览器指纹累计 LLM 成本,跨会话保留 |
| 并发席位 | 15 | DEMO_MAX_SEATS | 同一时间最多活跃会话数,满席返回 503 |
| 席位闲置回收 | 1800s | DEMO_SEAT_IDLE_TTL | 闲置超时自动释放席位 |
| 每 IP 席位上限 | 2 | DEMO_MAX_SEATS_PER_IP | 防一人独占全部席位 |
工作方式:
POST /api/demo/token先过席位门(lease_seat):有席位才签发 token;满席 →503 + Retry-After: 30; 每 IP 超限 →429。- 每次 demo 请求由中间件
touch_seat(account_id)保活,活跃会话不丢席位;touch是 no-op, 不会为一个没有席位的请求新造席位。 - 席位是内存态(
_seatsdict),bridge 重启即全部释放 —— 对并发池这是正确语义。 GET /api/demo/status返回seats: {total, leased, available, per_ip},前端状态卡据此显示 “余 N/15 席”。
客户端 IP 判定(XFF 防伪造)
demo.py:_client_ip 的信任链是 Cloudflare → Caddy → socat → bridge,因此判定顺序为:
CF-Connecting-IP(Cloudflare 边缘写入,客户端无法伪造)→ 优先信任;- 仅当 socket 对端是私有/回环地址(即我们自己的反代)才信任
X-Forwarded-For,且取最后一位 (反代追加的那一跳)—— 客户端直连时伪造 XFF 首位不会生效; - 否则回退 socket 对端地址。
部署注意:
X-Forwarded-For只在私有代理对端下被信任。若你的反向代理不在本机 (socket 对端是公网),请改用CF-Connecting-IP或给 Caddy/nginx 配trusted_proxies, 否则 per-IP 限流会被伪造 XFF 绕过。
验证满席
# 用 20 个不同指纹(≥32 字符)请求 token,前 15 个成功、第 16 个起 503 满席:
for i in $(seq 1 20); do
FP="fp_test_$(printf '%030d' $i)"
curl -s -X POST http://127.0.0.1:18711/api/demo/token \
-H "Content-Type: application/json" \
-d "{\"fingerprint\":\"$FP\"}" | head -c 200; echo
done
# 同一 IP 下第 3 个起返回 429 per_ip_cap(若 MAX_SEATS_PER_IP=2)
# 状态端点应显示余席:
curl -s http://127.0.0.1:18711/api/demo/status | python -m json.tool | grep -A 5 seats若从 127.0.0.1 发起(socket 对端是回环=受信代理),可用
X-Forwarded-For: 10.$i.0.1模拟不同 IP 验证全局 15 席上限。
每租户隔离(多用户版)
当前演示已经是 shared-workspace + demo token(见上节)—— 两个都想要"每个用户独立的 记忆/任务空间"时,才需要切到这下面的每进程模型。
想要每个演示用户一个独立数据库(避免互相看到对话/任务),把第 4 步的启动命令包进一个 /opt/familiars-demo/run-user.sh,按 $1(用户名)分开:
#!/usr/bin/env bash
USER="$1"
DB="/opt/familiars-demo/data/users/${USER}/demo.db"
WS="/opt/familiars-demo/data/users/${USER}/workspace"
mkdir -p "$(dirname "$DB")" "$WS"
WORKSPACE_ROUTE="$WS" FAMILIARS_DB_PATH="$DB" FAMILIARS_HTTP_INSECURE=1 FAMILIARS_BROKER_BIND=0.0.0.0 \
/opt/familiars-demo/.venv/bin/python -m src.bridge.run然后为每个用户起一个 systemd instance 或再套一层进程管理(supervisord 的 program 组、@ service 模板均可)。每个进程独立端口,nginx 用不同 location/子路径(或子域名)分发。
演示数据引导(demo_bootstrap)
对话框真正要用的公会屋子需要在演示数据里预置。首次启动后可执行:
WORKSPACE_ROUTE=/opt/familiars-demo/data/workspace FAMILIARS_HTTP_INSECURE=1 \
FAMILIARS_DB_PATH=/opt/familiars-demo/data/demo.db \
.venv/bin/python -c "
from src.bridge.deps import get_broker_hub
from src.broker.workshop import repos as wr
conn = get_broker_hub().conn
# 示例:为反馈/演示各建一屋(room_id 固定,`FAMILIAR` 屋作为公开反馈接收处)
wr.create_room(conn, broker_id='<broker_id>', name='Familiar', room_id='FAMILIAR-DEMO',
created_by_cluster_id='demo')
print('rooms ok')
"两个固定 room(Familiar=公开反馈/演示;feedback-official=仅官方 bot owner 看到)在部署文档里用 bot 钱包预置 ≥20 VTP(auto_reward_feedback 每任务奖励 20,余额不足会标 reward_pending,不阻断发布)。
运维速查
| 操作 | 命令 |
|---|---|
| 重启 bridge | systemctl restart familiars-demo |
| 看日志 | journalctl -u familiars-demo -f |
| 更新 SPA | 本机 VITE_DEMO_MODE=1 npm run build → scp -r electron/dist/* 到 /opt/familiars-demo/spa/ |
| 更新 bridge 代码 | scp -r src/ 到 /opt/familiars-demo/app/ → 重启服务 |
| 备份 | 快照 /opt/familiars-demo/data/ |
相关
- Demo 页:/demo/
- 反馈入库(Ventaris 任务 + 20 VTP):/feedback/
