Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ LAYA_ADMIN_USERNAME=admin
# Set a password of at least 10 characters, or use LAYA_ADMIN_PASSWORD_HASH instead.
LAYA_ADMIN_PASSWORD=''
# LAYA_ADMIN_PASSWORD_HASH='$argon2id$...'
LAYA_PUBLIC_ORIGIN=https://console.example.com
LAYA_SESSION_HOURS=12
LAYA_MAX_LOADED_MODELS=1
# LAYA_DEVICE=cpu
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@ sh scripts/check-upstream.sh

## 管理员与配置

在 `.env` 中设置 `LAYA_ADMIN_USERNAME` 和至少 10 个字符的 `LAYA_ADMIN_PASSWORD`,服务启动时会在内存中生成 Argon2id 哈希用于登录校验。也可以不设置明文密码,改用 `.venv/bin/python scripts/hash-password.py` 生成 `LAYA_ADMIN_PASSWORD_HASH`;两者必须且只能设置一个。哈希值用单引号包住,确保 Docker Compose 按字面保留 `$`。`LAYA_PUBLIC_ORIGIN` 也必须填写;生产环境必须是 HTTPS 来源,例如 `https://console.example.com`。本地 HTTP 测试需要 `LAYA_ALLOW_INSECURE_LOCAL=1`。`.env` 已被 Git 忽略,不要提交实际密码。
Docker Compose 只传入当前 shell 或部署平台设置的 `LAYA_ADMIN_USERNAME` `LAYA_ADMIN_PASSWORD`,不需要 `.env` 文件。密码至少 10 个字符;服务启动时会在内存中生成 Argon2id 哈希用于登录校验。管理操作仍要求会话和 CSRF token。其他运行参数使用后端默认值。`.env.example` 仅供本地热更新脚本使用,不要提交实际密码。

```sh
cp .env.example .env
python3.12 -m venv .venv
.venv/bin/python -m pip install -e 'backend[test]'
# 编辑 .env,填写 LAYA_ADMIN_USERNAME 和 LAYA_ADMIN_PASSWORD
```

## 模型文件
Expand All @@ -48,33 +46,36 @@ LAYA_MODEL_DIR=models .venv/bin/python scripts/smoke-real-model.py

在 GitHub 仓库的 **Settings → Secrets and variables → Actions** 配置 `DOCKERHUB_USERNAME` 和 `DOCKERHUB_TOKEN`(需要有 `1panel/laya-server` 的推送权限)。在 **Actions → Build and push LAYA SERVER → Run workflow** 输入版本标签。正式发布时可同时勾选 `latest`;测试标签保持关闭。工作流会检出被忽略的上游 v0.3.7 源码并校验 SHA,运行后端测试,再构建及推送镜像。

拉取已发布镜像并启动:
在当前 shell 中设置变量,拉取已发布镜像并启动:

```sh
cp .env.example .env
# 编辑 .env,配置管理员账号、密码和 LAYA_PUBLIC_ORIGIN
LAYA_IMAGE_TAG=dev docker compose pull
LAYA_IMAGE_TAG=dev docker compose up -d
export LAYA_ADMIN_USERNAME=admin
export LAYA_ADMIN_PASSWORD='replace-with-a-password-of-at-least-10-characters'
export LAYA_IMAGE_TAG=dev
docker compose pull
docker compose up -d
```

也可用本地已检出的上游源码构建:

```sh
sh scripts/check-upstream.sh
docker build -t 1panel/laya-server:dev .
LAYA_IMAGE_TAG=dev docker compose up -d
export LAYA_IMAGE_TAG=dev
docker compose up -d
```

如使用 `latest`,直接执行:

```sh
unset LAYA_IMAGE_TAG
docker compose pull
docker compose up -d
```

Compose 只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷;更新容器不会丢失数据库。构建机器需要能访问 PyPI、PyTorch CPU 包索引、npm registry 和 Hugging Face;已构建镜像启动时无需拉取源码、依赖或模型。
Compose 从启动它的进程环境传入管理员配置,只启动一个应用服务并将 `127.0.0.1:8080` 暴露给宿主机。公网入口需由外部反向代理提供 HTTPS,并将请求转发到该端口。SQLite 数据在 `laya-data` 卷;更新容器不会丢失数据库。构建机器需要能访问 PyPI、PyTorch CPU 包索引、npm registry 和 Hugging Face;已构建镜像启动时无需拉取源码、依赖或模型。

如果由现有的 1Panel 反向代理提供公网 HTTPS,将域名请求转发到宿主机的 `127.0.0.1:8080`,并确保 `LAYA_PUBLIC_ORIGIN` 与实际 HTTPS 域名一致。反向代理不属于本项目的应用容器。
如果由现有的 1Panel 反向代理提供公网 HTTPS,将域名请求转发到宿主机的 `127.0.0.1:8080`。登录时服务根据请求是否为 HTTPS 设置 Cookie 的 `Secure` 标记;请让反向代理正确转发协议。反向代理不属于本项目的应用容器。

### 本地开发(热更新)

Expand All @@ -95,7 +96,7 @@ pnpm install --frozen-lockfile
pnpm dev
```

打开 `http://127.0.0.1:5173` 登录。Vite 将 `/internal` 和 `/v1` 代理到本地 8000 端口;生产环境仍由同一个 FastAPI 容器提供构建后的前端。若只想用一个本地进程,可先在 `frontend/` 运行 `pnpm build`,再把 `LAYA_PUBLIC_ORIGIN` 设为 `http://127.0.0.1:8000` 启动 Uvicorn 并打开 8000 端口。
打开 `http://127.0.0.1:5173` 登录。Vite 将 `/internal` 和 `/v1` 代理到本地 8000 端口;生产环境仍由同一个 FastAPI 容器提供构建后的前端。若只想用一个本地进程,可先在 `frontend/` 运行 `pnpm build`,再启动 Uvicorn 并打开 8000 端口。

控制台支持简体中文、English 和繁體中文。登录页及登录后的顶部栏均可切换语言;首次访问按浏览器语言选择,手动选择会保存在当前浏览器中。

Expand Down
24 changes: 12 additions & 12 deletions README_es.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,18 @@ sh scripts/check-upstream.sh

## Administrador y configuración

Define `LAYA_ADMIN_USERNAME` y `LAYA_ADMIN_PASSWORD` (de al menos 10 caracteres) en `.env`; al iniciar el servicio se genera en memoria un hash Argon2id para validar el inicio de sesión. También puedes omitir la contraseña en claro y generar `LAYA_ADMIN_PASSWORD_HASH` con `.venv/bin/python scripts/hash-password.py`; se debe establecer exactamente una de las dos. Envuelve el valor del hash entre comillas simples para que Docker Compose conserve `$` literalmente. `LAYA_PUBLIC_ORIGIN` también es obligatorio; en producción debe ser un origen HTTPS, por ejemplo `https://console.example.com`. Para pruebas locales por HTTP se necesita `LAYA_ALLOW_INSECURE_LOCAL=1`. `.env` está ignorado por Git: no confirmes (commit) contraseñas reales.
Docker Compose recibe solo `LAYA_ADMIN_USERNAME` y `LAYA_ADMIN_PASSWORD` del shell o de la plataforma de despliegue; no necesita un archivo `.env`. La contraseña debe tener al menos 10 caracteres. Las operaciones administrativas siguen requiriendo sesión y token CSRF. Los demás parámetros usan los valores predeterminados del backend. `.env.example` se usa solo para el script de desarrollo local; no confirmes contraseñas reales.

```sh
cp .env.example .env
python3.12 -m venv .venv
.venv/bin/python -m pip install -e 'backend[test]'
# Edita .env y rellena LAYA_ADMIN_USERNAME y LAYA_ADMIN_PASSWORD
```

## Archivos de modelo

Los pesos de los modelos no se distribuyen con el repositorio ni con la imagen. `scripts/download-models.py` fija el repositorio de Hugging Face en el commit `1c5edc17a7acd8701df6fc341c0d179f1c62c982` y coloca los tres checkpoints en `/models/english`, `/models/multilingual` y `/models/typed-decisions` del volumen de modelos persistente. Cada directorio debe contener como mínimo `rl_agent_config.json`, `model.safetensors`, `tokenizer/` y `encoder/` del paquete de modelo upstream. La presencia de los archivos se comprueba con `GET /health/ready`; si faltan, la inferencia devuelve `503 MODEL_UNAVAILABLE`. Sigue siendo necesario hacer una prueba de humo de inferencia para confirmar que los modelos son realmente compatibles.
El repositorio no incluye pesos. El Dockerfile descarga el checkpoint **multilingual** desde el commit fijo de Hugging Face `1c5edc17a7acd8701df6fc341c0d179f1c62c982` durante la compilación y lo incorpora a `/opt/models/multilingual` en la imagen final. La compilación comprueba la inferencia sin conexión en inglés y chino. Al arrancar el contenedor no hay que descargar ni montar modelos. `GET /health/ready` comprueba los archivos incluidos.

La inferencia de modelos de este proyecto no se descarga automáticamente al arrancar la aplicación; prepara el volumen de modelos antes del arranque. Un proceso de aplicación solo carga los modelos necesarios y, de forma predeterminada, conserva como máximo uno en memoria; ajusta el valor con `LAYA_MAX_LOADED_MODELS`. Este valor controla cuántos modelos mantiene la caché, no cuántas solicitudes se procesan simultáneamente. El servicio no define ranuras adicionales de concurrencia de inferencia; la concurrencia real depende del grupo de hilos de ejecución, de los modelos y de los recursos de la máquina. La imagen de inferencia por CPU usa la wheel de PyTorch para CPU; si despliegas en GPU, cambia a la base de PyTorch adecuada para tu dispositivo y valida el resultado.
La imagen publicada usa `LAYA_MODEL_PROFILE=multilingual`: `model=auto` y `model=multilingual` utilizan ese checkpoint; solicitar `english` o `typed-decisions` devuelve `422 MODEL_NOT_AVAILABLE`. `LAYA_MAX_LOADED_MODELS` controla cuántos modelos permanecen en memoria, no el número de solicitudes simultáneas. La imagen usa PyTorch para CPU y el workflow construye `linux/amd64`.

En local, instala primero las dependencias de ejecución upstream y el checkout de Laya ignorado por Git, descarga los tres modelos en sus versiones fijas y ejecuta una prueba de humo con solicitudes reales que cubra inglés, selección explícita en chino, enrutado automático en chino y typed-decisions:

Expand All @@ -46,17 +44,19 @@ La descarga de modelos y la ejecución dependen de PyTorch, Transformers, Safete

## Arranque

Antes del primer arranque, construye la imagen y descarga los modelos de versión fija en el volumen de modelos; después inicia la aplicación:
Exporta las variables desde el shell y arranca la imagen publicada:

```sh
docker compose build
docker compose run --rm app python /app/scripts/download-models.py
export LAYA_ADMIN_USERNAME=admin
export LAYA_ADMIN_PASSWORD='replace-with-a-password-of-at-least-10-characters'
export LAYA_IMAGE_TAG=dev
docker compose pull
docker compose up -d
```

Compose solo inicia un servicio de aplicación y expone `127.0.0.1:8080` al host. La entrada de acceso público debe proveer HTTPS mediante un proxy inverso externo que reenvíe las solicitudes a ese puerto. Los datos de SQLite están en el volumen `laya-data` y los modelos en el volumen `laya-models`. La máquina de compilación necesita acceso a PyPI, al índice de paquetes PyTorch para CPU y al registry de npm; al arrancar una imagen ya compilada no hace falta descargar código fuente ni dependencias.
Compose transmite las variables del proceso que lo ejecuta, inicia un único servicio y expone `127.0.0.1:8080` al host. Un proxy inverso externo debe proporcionar HTTPS y reenviar las solicitudes a ese puerto. Los datos de SQLite se conservan en el volumen `laya-data`. La máquina de compilación necesita acceso a PyPI, al índice de PyTorch para CPU, al registry de npm y a Hugging Face; la imagen ya compilada no descarga código, dependencias ni modelos al arrancar.

Si un proxy inverso 1Panel existente provee el HTTPS público, reenvía las solicitudes del dominio a `127.0.0.1:8080` del host y asegúrate de que `LAYA_PUBLIC_ORIGIN` coincida con el dominio HTTPS real. El proxy inverso no forma parte del contenedor de aplicación de este proyecto.
Si un proxy inverso 1Panel existente provee el HTTPS público, reenvía las solicitudes del dominio a `127.0.0.1:8080` del host. Durante el inicio de sesión, el servicio establece el atributo `Secure` de las cookies según si la solicitud usa HTTPS; configura el proxy para que transmita correctamente el protocolo. El proxy inverso no forma parte del contenedor de aplicación de este proyecto.

### Desarrollo local (recarga en caliente)

Expand All @@ -75,7 +75,7 @@ cd frontend
pnpm dev
```

Abre `http://127.0.0.1:5173` e inicia sesión. Vite redirige `/internal` y `/v1` al puerto local 8000; en producción el frontend compilado lo sigue sirviendo el mismo contenedor de FastAPI. Si solo quieres un proceso local, ejecuta primero `pnpm build` en `frontend/`, establece `LAYA_PUBLIC_ORIGIN` en `http://127.0.0.1:8000`, arranca Uvicorn y abre el puerto 8000.
Abre `http://127.0.0.1:5173` e inicia sesión. Vite redirige `/internal` y `/v1` al puerto local 8000; en producción el frontend compilado lo sigue sirviendo el mismo contenedor de FastAPI. Si solo quieres un proceso local, ejecuta primero `pnpm build` en `frontend/`, arranca Uvicorn y abre el puerto 8000.

La consola admite chino simplificado, inglés y chino tradicional. Puedes cambiar el idioma tanto en la página de inicio de sesión como en la barra superior una vez dentro; en la primera visita se elige el idioma del navegador y la elección manual se guarda en el navegador actual.

Expand All @@ -90,7 +90,7 @@ curl -X POST https://console.example.com/v1/systemone \
-d '{"state":{"message":"I was charged twice"},"questions":{"refund":{"type":"noul","instructions":"Does the customer ask for a refund?"}}}'
```

En la solicitud, `state` puede ser una cadena, un objeto JSON o un array; `questions` es un mapa de identificadores de pregunta no vacío que admite `noul`, `choice` y `score`; `model` admite `auto` (predeterminado), `english`, `multilingual` o `typed-decisions`. La respuesta conserva `answers`, `model` y `usage` de upstream. Los errores usan `detail.code` y `detail.message`. Una clave no válida devuelve 401, un fallo de validación 422 y un modelo no disponible 503. El Playground de la consola usa la sesión de administrador y se registra como una fuente de consumo independiente.
En la solicitud, `state` puede ser una cadena, un objeto JSON o un array; `questions` es un mapa no vacío que admite `noul`, `choice` y `score`. En la imagen publicada, `model` admite `auto` (predeterminado) o `multilingual`; pedir otro modelo devuelve `422 MODEL_NOT_AVAILABLE`. El desarrollo local admite los tres modelos si se han descargado. La respuesta conserva `answers`, `model` y `usage` de upstream. Los errores usan `detail.code` y `detail.message`. Una clave no válida devuelve 401, un fallo de validación 422 y un modelo no disponible 503. El Playground de la consola usa la sesión de administrador y se registra como una fuente de consumo independiente.

## Datos y mantenimiento

Expand Down
14 changes: 2 additions & 12 deletions backend/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ class Settings:
admin_username: str
admin_password_hash: str
database_path: Path
public_origin: str
secure_cookie: bool
session_hours: int
model_dir: Path
device: str | None
Expand All @@ -24,28 +22,20 @@ def from_env(cls) -> "Settings":
username = os.environ.get("LAYA_ADMIN_USERNAME", "")
password = os.environ.get("LAYA_ADMIN_PASSWORD", "")
password_hash = os.environ.get("LAYA_ADMIN_PASSWORD_HASH", "")
origin = os.environ.get("LAYA_PUBLIC_ORIGIN", "").rstrip("/")
if not username or not origin:
raise RuntimeError("Set LAYA_ADMIN_USERNAME and LAYA_PUBLIC_ORIGIN")
if not username:
raise RuntimeError("Set LAYA_ADMIN_USERNAME")
if bool(password) == bool(password_hash):
raise RuntimeError("Set exactly one of LAYA_ADMIN_PASSWORD or LAYA_ADMIN_PASSWORD_HASH")
if password:
if len(password) < 10:
raise RuntimeError("LAYA_ADMIN_PASSWORD must contain at least 10 characters")
password_hash = PasswordHasher().hash(password)
if not origin.startswith(("https://", "http://")):
raise RuntimeError("LAYA_PUBLIC_ORIGIN must be an absolute HTTP(S) origin")
if origin.startswith("http://") and os.environ.get("LAYA_ALLOW_INSECURE_LOCAL") != "1":
raise RuntimeError("HTTP origin requires LAYA_ALLOW_INSECURE_LOCAL=1")
if any(char in origin.split("://", 1)[1] for char in "/?#"):
raise RuntimeError("LAYA_PUBLIC_ORIGIN must not contain a path, query, or fragment")
model_profile = os.environ.get("LAYA_MODEL_PROFILE", "all")
if model_profile not in ("all", "multilingual"):
raise RuntimeError("LAYA_MODEL_PROFILE must be all or multilingual")
return cls(
username, password_hash,
Path(os.environ.get("LAYA_DATABASE_PATH", "/data/laya.sqlite3")),
origin, origin.startswith("https://"),
int(os.environ.get("LAYA_SESSION_HOURS", "12")),
Path(os.environ.get("LAYA_MODEL_DIR", "/models")),
os.environ.get("LAYA_DEVICE") or None,
Expand Down
9 changes: 2 additions & 7 deletions backend/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ def validation_error(request: Request, exc: RequestValidationError) -> JSONRespo
"code": "VALIDATION_ERROR", "message": "Request validation failed", "errors": fields,
}})

def require_origin(request: Request) -> None:
if request.headers.get("origin") != settings.public_origin:
raise error(403, "ORIGIN_MISMATCH", "Request origin is not allowed")

def current_session(request: Request) -> str:
token = request.cookies.get(SESSION_COOKIE)
if not token:
Expand All @@ -69,7 +65,6 @@ def current_session(request: Request) -> str:
Session = Annotated[str, Depends(current_session)]

def require_csrf(request: Request, session: Session, x_csrf_token: Annotated[str | None, Header()] = None) -> None:
require_origin(request)
cookie = request.cookies.get(CSRF_COOKIE)
if not cookie or not x_csrf_token or not hmac.compare_digest(cookie, x_csrf_token):
raise error(403, "CSRF_INVALID", "CSRF token is invalid")
Expand Down Expand Up @@ -163,7 +158,6 @@ def ready() -> dict[str, str]:

@app.post("/internal/auth/login")
def login(payload: LoginRequest, request: Request, response: Response) -> dict[str, str]:
require_origin(request)
source = request.client.host if request.client else "unknown"
since = (datetime.now(timezone.utc) - timedelta(minutes=15)).isoformat(timespec="seconds")
with db.connect() as connection:
Expand All @@ -188,7 +182,8 @@ def login(payload: LoginRequest, request: Request, response: Response) -> dict[s
expires = (datetime.now(timezone.utc) + timedelta(hours=settings.session_hours)).isoformat(timespec="seconds")
with db.connect() as connection:
connection.execute("INSERT INTO sessions VALUES (?,?,?)", (digest(session), digest(csrf), expires))
cookie_options = {"secure": settings.secure_cookie, "samesite": "lax", "path": "/", "max_age": settings.session_hours * 3600}
is_https = request.url.scheme == "https" or request.headers.get("origin", "").startswith("https://")
cookie_options = {"secure": is_https, "samesite": "lax", "path": "/", "max_age": settings.session_hours * 3600}
response.set_cookie(SESSION_COOKIE, session, httponly=True, **cookie_options)
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, **cookie_options)
return {"username": settings.admin_username}
Expand Down
Loading
Loading