mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-30 16:16:07 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b03e3395f | |||
| d3c443a2c8 | |||
| 4b2a628db6 | |||
| 610915b9c5 | |||
| e8d90c4259 | |||
| 2dfbe78271 | |||
| c89e6f3bd2 | |||
| e52d6880f5 | |||
| aa342d9347 | |||
| ae85ac2b16 | |||
| 32abbb636f | |||
| f247a9d183 | |||
| 624a197655 | |||
| 712f79ed77 | |||
| 602e10649b | |||
| d71bd571b1 | |||
| e90a1546de | |||
| ff068564ab | |||
| 94ff4fcd2d | |||
| 97b3e58884 | |||
| 66a1ceac25 | |||
| 8a445bfb22 | |||
| 276791e7af | |||
| baf7e86335 | |||
| 741c20af07 | |||
| 5ac1ab3eac | |||
| f96116a050 | |||
| 40abb03928 | |||
| 59f68b8fb4 | |||
| 7c64cd9d51 | |||
| 9ea1a81048 | |||
| d3f08a90b1 | |||
| 64e772e32d | |||
| 84440df47f | |||
| c860159446 | |||
| f977629a90 | |||
| ff13d52602 | |||
| 5beab49577 | |||
| e8a09b7537 | |||
| 98f45aa88e | |||
| d7cdd206c2 | |||
| ac72563664 | |||
| 64dc887b20 | |||
| 627eb6b8ef | |||
| 3f01ffe63b | |||
| d7adbeec1e | |||
| abf77cecfa | |||
| 270622ae9d | |||
| 30f414a534 | |||
| 8b7ce77cec | |||
| 37099ddf7e | |||
| ee59e2d3fd | |||
| a4550350c0 |
@@ -1,78 +0,0 @@
|
||||
name: Build and deploy production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [deploy/prod]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: langbot-production
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
|
||||
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
|
||||
SPACE_REF: e1b261dac45e886efc667b1096a4ec493c6a6111
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build exact Core image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
||||
${{ env.CORE_IMAGE }}:deploy-prod
|
||||
cache-from: type=gha,scope=core-prod
|
||||
cache-to: type=gha,mode=max,scope=core-prod
|
||||
- name: Checkout production Cloud adapter
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: langbot-app/langbot-space
|
||||
ref: ${{ env.SPACE_REF }}
|
||||
token: ${{ secrets.CLA_PAT }}
|
||||
path: .space
|
||||
- name: Build exact Cloud Core image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .space
|
||||
file: .space/Dockerfile.cloud
|
||||
push: true
|
||||
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
|
||||
${{ env.CLOUD_IMAGE }}:deploy-prod
|
||||
cache-from: type=gha,scope=cloud-core-prod
|
||||
cache-to: type=gha,mode=max,scope=cloud-core-prod
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.JP09_SSH_KEY }}
|
||||
KNOWN_HOSTS: ${{ secrets.JP09_KNOWN_HOSTS }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
install -m 600 /dev/null ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
- name: Upload release manifest and deploy
|
||||
env:
|
||||
HOST: ${{ secrets.JP09_HOST }}
|
||||
USER: ${{ secrets.JP09_USER }}
|
||||
PORT: ${{ secrets.JP09_PORT }}
|
||||
run: |
|
||||
remote="$USER@$HOST"
|
||||
ssh -p "$PORT" "$remote" 'install -d -m 700 /opt/langbot-cloud-prod'
|
||||
scp -P "$PORT" deploy/prod/docker-compose.yml deploy/prod/deploy.sh "$remote:/opt/langbot-cloud-prod/"
|
||||
ssh -p "$PORT" "$remote" "chmod 700 /opt/langbot-cloud-prod/deploy.sh && /opt/langbot-cloud-prod/deploy.sh prod-${GITHUB_SHA}"
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd /opt/langbot-cloud-prod
|
||||
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
|
||||
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
|
||||
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
|
||||
|
||||
rendered_compose=$(docker compose config)
|
||||
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
||||
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
|
||||
exit 4
|
||||
}
|
||||
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
|
||||
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
|
||||
exit 5
|
||||
}
|
||||
|
||||
update_env() {
|
||||
local key=$1 value=$2
|
||||
python3 - "$key" "$value" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
||||
path = Path('.env')
|
||||
key, value = sys.argv[1:]
|
||||
lines = path.read_text().splitlines()
|
||||
updated = False
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith(f'{key}='):
|
||||
lines[index] = f'{key}={value}'
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
lines.append(f'{key}={value}')
|
||||
temporary = Path('.env.tmp')
|
||||
temporary.write_text('\n'.join(lines) + '\n')
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(path)
|
||||
PY
|
||||
}
|
||||
update_env LANGBOT_IMAGE_TAG "$TAG"
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if docker compose pull postgres redis migrate plugin-runtime core; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 5 ]; then
|
||||
echo "docker compose pull failed after $attempt attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
delay=$((attempt * 10))
|
||||
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
|
||||
sleep "$delay"
|
||||
done
|
||||
docker compose up -d postgres redis
|
||||
for _ in $(seq 1 60); do
|
||||
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
|
||||
|
||||
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
|
||||
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
|
||||
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
|
||||
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
|
||||
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
|
||||
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
|
||||
GRANT USAGE ON SCHEMA public TO langbot_runtime;
|
||||
SQL
|
||||
|
||||
docker compose --profile tools run --rm migrate
|
||||
|
||||
docker compose up -d --remove-orphans plugin-runtime core
|
||||
for _ in $(seq 1 90); do
|
||||
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
|
||||
docker compose ps
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker compose logs --tail=200 core plugin-runtime >&2
|
||||
exit 1
|
||||
@@ -1,161 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: langbot-cloud-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: langbot
|
||||
POSTGRES_USER: langbot_operator
|
||||
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
networks: [internal]
|
||||
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
container_name: langbot-cloud-redis
|
||||
restart: unless-stopped
|
||||
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
networks: [internal]
|
||||
|
||||
migrate:
|
||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
||||
profiles: [tools]
|
||||
command: [uv, run, langbot, migrate, --cloud]
|
||||
environment: &core-env
|
||||
TZ: Asia/Shanghai
|
||||
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
|
||||
SYSTEM__EDITION: cloud
|
||||
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
|
||||
SYSTEM__JWT__SECRET: ${JWT_SECRET}
|
||||
SYSTEM__LIMITATION__MAX_BOTS: "2"
|
||||
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
|
||||
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
|
||||
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
|
||||
API__WEBHOOK_PREFIX: https://cloud.langbot.app
|
||||
API__WEBUI_URL: https://cloud.langbot.app
|
||||
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
|
||||
DATABASE__USE: postgresql
|
||||
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
|
||||
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
|
||||
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
|
||||
VDB__USE: pgvector
|
||||
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
|
||||
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
|
||||
PLUGIN__ENABLE: "true"
|
||||
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
|
||||
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
|
||||
PLUGIN__WORKER__MAX_CPUS: "0.25"
|
||||
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
|
||||
PLUGIN__WORKER__MAX_PIDS: "128"
|
||||
PLUGIN__WORKER__MAX_WORKERS: "16"
|
||||
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
|
||||
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
|
||||
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
|
||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
||||
# Cloud v2 currently grants no managed Box capability. Keep the shared
|
||||
# runtime deployed but disable Core integration until a hard-quota-capable
|
||||
# backend can satisfy the fail-closed Cloud readiness contract.
|
||||
BOX__ENABLED: "false"
|
||||
BOX__BACKEND: nsjail
|
||||
BOX__RUNTIME__ENDPOINT: ws://box:5410
|
||||
BOX__ADMISSION__REQUIRED: "true"
|
||||
BOX__ADMISSION__LOGICAL_SESSION_ID: global
|
||||
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
|
||||
BOX__ADMISSION__MAX_SESSIONS: "1"
|
||||
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
|
||||
BOX__ADMISSION__CPUS: "0.25"
|
||||
BOX__ADMISSION__MEMORY_MB: "256"
|
||||
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
|
||||
BOX__LOCAL__HOST_ROOT: /app/data/box
|
||||
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
|
||||
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
|
||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
||||
MCP__STDIO__ENABLED: "false"
|
||||
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
|
||||
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
|
||||
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
|
||||
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
|
||||
SPACE__URL: https://space.langbot.app
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
networks: [internal]
|
||||
|
||||
plugin-runtime:
|
||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
||||
container_name: langbot-cloud-plugin-runtime
|
||||
restart: unless-stopped
|
||||
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
|
||||
environment:
|
||||
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
|
||||
volumes:
|
||||
- plugin-data:/app/data
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroup: host
|
||||
privileged: true
|
||||
expose: ["5400"]
|
||||
networks: [internal]
|
||||
|
||||
box:
|
||||
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
|
||||
container_name: langbot-cloud-box
|
||||
restart: unless-stopped
|
||||
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
|
||||
environment:
|
||||
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
|
||||
LANGBOT_BOX_ROOT: /app/data/box
|
||||
volumes:
|
||||
- box-data:/app/data/box
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroup: host
|
||||
privileged: true
|
||||
expose: ["5410"]
|
||||
networks: [internal]
|
||||
|
||||
core:
|
||||
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
|
||||
container_name: langbot-cloud-core
|
||||
restart: unless-stopped
|
||||
environment: *core-env
|
||||
volumes:
|
||||
- core-data:/app/data
|
||||
- box-data:/app/data/box
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
redis: {condition: service_healthy}
|
||||
plugin-runtime: {condition: service_started}
|
||||
box: {condition: service_started}
|
||||
expose: ["5300"]
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
networks: [internal, shared-network]
|
||||
|
||||
networks:
|
||||
internal:
|
||||
shared-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
plugin-data:
|
||||
box-data:
|
||||
core-data:
|
||||
@@ -1,166 +0,0 @@
|
||||
# LangBot 多租户数据库迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
|
||||
|
||||
## 迁移序列
|
||||
|
||||
```
|
||||
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
|
||||
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
|
||||
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
|
||||
0012_plugin_installation_identity → 插件实例租户绑定
|
||||
0013_tenant_pgvector → RAG 向量存储隔离
|
||||
0014_cloud_directory_projection → Cloud 控制平面同步
|
||||
0015_cloud_core_collaboration → 协作和权限功能
|
||||
```
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 备份(必须)
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
|
||||
|
||||
# PostgreSQL
|
||||
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### 2. 执行迁移
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 执行迁移
|
||||
python -m langbot.pkg.persistence.migration upgrade head
|
||||
|
||||
# 验证
|
||||
python -m langbot.pkg.persistence.migration current
|
||||
# 预期: 0015_cloud_core_collaboration
|
||||
|
||||
# 启动服务
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 3. OSS 单租户自动迁移
|
||||
|
||||
迁移会自动:
|
||||
- 创建默认 Workspace(名称:"Default Workspace")
|
||||
- 第一个用户成为 Owner
|
||||
- 所有现有资源绑定到该 Workspace
|
||||
|
||||
### 4. 验证检查
|
||||
|
||||
```bash
|
||||
# 检查 Workspace
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
|
||||
print(f"Workspace: {ws[1]} ({ws[0]})")
|
||||
|
||||
# 检查资源绑定
|
||||
bot_count = conn.execute(sa.text(
|
||||
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
|
||||
)).scalar()
|
||||
print(f"Bots: {bot_count}")
|
||||
EOF
|
||||
```
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 完全回滚(丢失多租户数据)
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 2. 恢复备份
|
||||
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
|
||||
|
||||
# 3. 回退代码
|
||||
git checkout v4.10.x
|
||||
pip install -e .
|
||||
|
||||
# 4. 启动
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 降级迁移(保留数据但移除多租户)
|
||||
|
||||
```bash
|
||||
# 警告:会移除 Workspace 表但保留资源
|
||||
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移后无法登录
|
||||
|
||||
```bash
|
||||
# 检查用户 UUID
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
|
||||
for u in users:
|
||||
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
|
||||
EOF
|
||||
```
|
||||
|
||||
### Q: 资源看不见了
|
||||
|
||||
检查 Workspace 上下文:
|
||||
```bash
|
||||
# 前端请求需要带 X-Workspace-ID header
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Workspace-ID: $WORKSPACE_UUID" \
|
||||
http://localhost:5200/api/v1/platform/bots
|
||||
```
|
||||
|
||||
### Q: 迁移速度慢
|
||||
|
||||
```bash
|
||||
# SQLite 优化
|
||||
sqlite3 ~/.langbot/data/langbot.db << EOF
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
VACUUM;
|
||||
EOF
|
||||
```
|
||||
|
||||
## 性能调优
|
||||
|
||||
### PostgreSQL 索引
|
||||
|
||||
```sql
|
||||
-- 迁移后创建
|
||||
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
|
||||
ON model_providers(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_bots_workspace
|
||||
ON bots(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
|
||||
ON pipelines(workspace_uuid);
|
||||
```
|
||||
|
||||
## 预估时间
|
||||
|
||||
- SQLite < 100MB: 2-5 分钟
|
||||
- SQLite 100MB-1GB: 5-15 分钟
|
||||
- PostgreSQL: < 5 分钟(取决于数据量)
|
||||
|
||||
## 支持
|
||||
|
||||
问题反馈:https://github.com/langbot-app/LangBot/issues
|
||||
|
||||
**版本**: 1.0
|
||||
**最后更新**: 2026-07-30
|
||||
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'/image/<path:image_key>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
|
||||
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> RequestContext:
|
||||
) -> None:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
@@ -168,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
entitlement_revision=request_context.entitlement_revision,
|
||||
)
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||
pipeline = await run_in_workspace_uow(
|
||||
|
||||
@@ -56,14 +56,6 @@ class KnowledgeService:
|
||||
require_workspace_uuid(context)
|
||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||
# Legacy internal KB creation is removed.
|
||||
limitation = (
|
||||
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
|
||||
)
|
||||
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
|
||||
if max_knowledge_bases >= 0:
|
||||
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
|
||||
if len(knowledge_bases) >= max_knowledge_bases:
|
||||
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
|
||||
|
||||
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
|
||||
if not knowledge_engine_plugin_id:
|
||||
|
||||
@@ -138,14 +138,8 @@ class VerifiedCloudDeployment:
|
||||
if plugin_worker.get('require_hard_limits') is not True:
|
||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||
box_config = config.get('box', {})
|
||||
box_enabled = box_config.get('enabled')
|
||||
if box_enabled is False:
|
||||
# Explicitly disabling Box removes the sandbox surface entirely and
|
||||
# therefore does not weaken tenant isolation. Validate the strict
|
||||
# runtime/admission contract only when the surface is enabled.
|
||||
return
|
||||
if box_enabled is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
|
||||
if box_config.get('enabled') is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||
if box_config.get('backend') != 'nsjail':
|
||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||
|
||||
@@ -186,14 +186,9 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
# At the final key
|
||||
if key in current:
|
||||
if isinstance(current[key], list):
|
||||
# Convert comma-separated values while preserving the
|
||||
# element type declared by a non-empty config default.
|
||||
items = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
if current[key]:
|
||||
exemplar = current[key][0]
|
||||
current[key] = [convert_value(item, exemplar) for item in items]
|
||||
else:
|
||||
current[key] = items
|
||||
# Convert comma-separated string to list
|
||||
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
elif isinstance(current[key], dict):
|
||||
# Skip dict types
|
||||
pass
|
||||
|
||||
@@ -892,4 +892,4 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.info('Telegram adapter stopped')
|
||||
self.msg_stream_id.clear()
|
||||
self._form_action_titles.clear()
|
||||
return True
|
||||
return True
|
||||
@@ -529,7 +529,7 @@ class ModelManager:
|
||||
model['uuid']: model
|
||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
||||
}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -602,7 +602,6 @@ class ModelManager:
|
||||
existing = existing_rerank_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
await self.ap.rerank_models_service.create_rerank_model(
|
||||
context,
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
@@ -623,9 +622,7 @@ class ModelManager:
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.rerank_models_service.update_rerank_model(
|
||||
context, space_model.uuid, dict(desired)
|
||||
)
|
||||
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
|
||||
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
|
||||
return await self._run(operation, session=session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _invitation_lock(self, lock_key: str):
|
||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
||||
async def _invitation_lock(self, token_digest: str):
|
||||
"""Serialize one token while retaining only active lock entries."""
|
||||
|
||||
async with self._invitation_locks_guard:
|
||||
entry = self._invitation_locks.get(lock_key)
|
||||
entry = self._invitation_locks.get(token_digest)
|
||||
if entry is None:
|
||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||
self._invitation_locks[lock_key] = entry
|
||||
self._invitation_locks[token_digest] = entry
|
||||
entry.users += 1
|
||||
|
||||
await entry.lock.acquire()
|
||||
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
|
||||
async with self._invitation_locks_guard:
|
||||
entry.users -= 1
|
||||
if entry.users == 0:
|
||||
self._invitation_locks.pop(lock_key, None)
|
||||
self._invitation_locks.pop(token_digest, None)
|
||||
|
||||
async def revoke_invitation(
|
||||
self,
|
||||
|
||||
@@ -105,7 +105,6 @@ system:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
max_knowledge_bases: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
|
||||
@@ -34,7 +34,6 @@ class _Rows:
|
||||
def _app():
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
rag_mgr=SimpleNamespace(
|
||||
get_all_knowledge_base_details=AsyncMock(return_value=[]),
|
||||
get_knowledge_base_details=AsyncMock(return_value=None),
|
||||
@@ -120,21 +119,6 @@ async def test_create_validates_schema_and_binds_context():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_enforces_workspace_knowledge_base_limit():
|
||||
app = _app()
|
||||
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
|
||||
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
|
||||
service = KnowledgeService(app)
|
||||
|
||||
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
|
||||
await service.create_knowledge_base(
|
||||
CONTEXT,
|
||||
{'knowledge_engine_plugin_id': 'author/engine'},
|
||||
)
|
||||
app.rag_mgr.create_knowledge_base.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_guessed_uuid_and_scopes_reload():
|
||||
app = _app()
|
||||
|
||||
@@ -862,12 +862,12 @@ class TestModelProviderServiceScanProviderModels:
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models(
|
||||
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
|
||||
return_value=[{'name': 'Qwen3-Reranker-8B'}]
|
||||
)
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
|
||||
|
||||
assert result['models'][0]['type'] == 'rerank'
|
||||
assert result['models'][0]['already_added'] is True
|
||||
|
||||
|
||||
@@ -228,23 +228,10 @@ async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
|
||||
)
|
||||
|
||||
|
||||
async def test_cloud_runtime_allows_explicitly_disabled_box():
|
||||
config = _cloud_config()
|
||||
config['box']['enabled'] = False
|
||||
|
||||
deployment = await resolve_deployment(
|
||||
instance_uuid='instance-a',
|
||||
instance_config=config,
|
||||
entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
assert isinstance(deployment, VerifiedCloudDeployment)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('mutate', 'message'),
|
||||
[
|
||||
(lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
|
||||
(lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
|
||||
(lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
|
||||
(
|
||||
|
||||
@@ -152,19 +152,6 @@ class TestApplyEnvOverridesToConfig:
|
||||
|
||||
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
|
||||
|
||||
def test_override_integer_list_preserves_item_type(self):
|
||||
"""Comma-separated overrides inherit the existing list item type."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}}
|
||||
env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768]
|
||||
assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions'])
|
||||
|
||||
def test_override_list_value_empty_items(self):
|
||||
"""Test that empty items in comma-separated list are filtered."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -114,10 +114,9 @@ async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_mod
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
|
||||
await model_mgr.sync_new_models_from_space()
|
||||
|
||||
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
{
|
||||
'uuid': 'rerank-model-uuid',
|
||||
'name': 'Qwen3-Reranker-8B',
|
||||
@@ -1110,4 +1109,4 @@ def test_provider_not_found_error_str():
|
||||
error = provider_errors.ProviderNotFoundError('test-provider')
|
||||
|
||||
assert str(error) == 'Provider test-provider not found'
|
||||
assert error.provider_name == 'test-provider'
|
||||
assert error.provider_name == 'test-provider'
|
||||
Reference in New Issue
Block a user