抱脸部署酒馆

Reno 于 2025-07-01 发布

环境变量

变量名 示例值 描述
USERNAME user 必填,登录用户名
PASSWORD password 必填,登录密码
HF_TOKEN hf_**** 必填,HuggingFace令牌
SYNC_INTERVAL 36000 选填,同步间隔,单位:秒

Dockerfile

FROM node:20-alpine

ARG APP_HOME=/home/node/app
ENV USER=node

ENV VENV_PATH=/opt/venv
ENV PATH="$VENV_PATH/bin:$PATH"

WORKDIR ${APP_HOME}

RUN apk add --no-cache \
        tini \
        git \
        python3 \
        bash \
        dos2unix \
        findutils \
        tar \
        gcompat

RUN python3 -m venv $VENV_PATH \
    && pip install --no-cache-dir --upgrade pip \
    && pip install --no-cache-dir huggingface_hub

ENV NODE_ENV=production
ENV USERNAME="admin"
ENV PASSWORD="password"
ENV FORCE_COLOR=1
ENV GITHUB_NAME=sillytavern
ENV GITHUB_REPO=sillytavern

RUN git clone --depth=1 https://github.com/${GITHUB_NAME}/${GITHUB_REPO}.git . \
    && npm install --no-audit --no-fund --loglevel=error \
    && npm cache clean --force

COPY config.yaml ./
COPY entrypoint.sh ./
RUN dos2unix entrypoint.sh && chmod +x entrypoint.sh

RUN mkdir -p config data plugins logs public/scripts/extensions/third-party \
    && mv config.yaml ./config/ \
    && ln -s ./config/config.yaml config.yaml \
    && chown -R ${USER}:${USER} ${APP_HOME} ${VENV_PATH}

USER ${USER}

EXPOSE 7860

ENTRYPOINT ["tini", "--"]

CMD ["./entrypoint.sh"]

entrypoint.sh

#!/usr/bin/env bash
set -euo pipefail
set -a

APP_BASE_DIR="/home/node/app"
USERNAME="${USERNAME:-admin}"
PASSWORD="${PASSWORD:-password}"
HF_TOKEN="${HF_TOKEN:-}"
DATASET_ID="${DATASET_ID:-}"
KEEP_BACKUPS="${KEEP_BACKUPS:-10}"
SYNC_INTERVAL="${SYNC_INTERVAL:-3600}"
DIRS_TO_BACKUP=( "data" "config" "plugins" "public/scripts/extensions/third-party" )

log() {
  echo "[$(date +'%F %T')] $*"
}

init_backup() {
  if [[ -n "${DATASET_ID:-}" ]]; then
    log "📁 使用提供的 DATASET_ID=${DATASET_ID}"
    return 0
  fi
  if [[ -z "${HF_TOKEN:-}" ]]; then
    log "⚠️ 未设置 HF_TOKEN,跳过同步。"
    return 1
  fi

  USER_ID=$(python3 <<'PY'
import os, sys
from huggingface_hub import HfApi
try:
    print(HfApi(token=os.getenv("HF_TOKEN")).whoami().get("name"))
except Exception:
    sys.exit(1)
PY
  )
  if [[ -z "$USER_ID" ]]; then
    log "❌ 无法从 HF_TOKEN 获取用户名,跳过同步。"
    return 1
  fi

  DATASET_ID="${USER_ID}/data"
  log "✅ 默认 DATASET_ID 已设置为 ${DATASET_ID}"
}

prep_repo() {
  log "🔍 准备 HuggingFace 仓库..."
  python3 <<'PY'
import os
from huggingface_hub import HfApi
from huggingface_hub.utils import HfHubHTTPError

api = HfApi(token=os.getenv("HF_TOKEN"))
repo_id = os.getenv("DATASET_ID")
try:
    api.repo_info(repo_id=repo_id, repo_type="dataset")
except HfHubHTTPError as e:
    if e.response.status_code == 404:
        print(f"仓库 '{repo_id}' 未找到。将创建为私有仓库。")
        api.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
    else:
        raise
PY
  log "✅ 仓库已准备就绪。"
}

restore_latest() {
  log "🔄 检查要恢复的备份..."
  python3 <<'PY'
import os, sys, tarfile, tempfile
from huggingface_hub import HfApi

api = HfApi(token=os.getenv("HF_TOKEN"))
repo_id = os.getenv("DATASET_ID")

files = api.list_repo_files(repo_id=repo_id, repo_type="dataset")
backups = sorted([f for f in files if f.endswith(".tar.gz")])

if not backups:
    print("未找到远程备份。跳过恢复。")
    sys.exit(0)

latest_backup = backups[-1]
print(f"找到最新备份: {latest_backup}。正在恢复...")

with tempfile.TemporaryDirectory() as temp_dir:
    path = api.hf_hub_download(
        repo_id=repo_id,
        filename=latest_backup,
        repo_type="dataset",
        local_dir=temp_dir
    )
    with tarfile.open(path, "r:gz") as t:
        t.extractall(os.getenv("APP_BASE_DIR"))
PY
  log "✅ 恢复检查完成。"
}

do_backup() {
  local valid_dirs=()
  for dir in "${DIRS_TO_BACKUP[@]}"; do
    if [[ -d "${APP_BASE_DIR}/${dir}" ]]; then
      valid_dirs+=("$dir")
    fi
  done

  if [[ ${#valid_dirs[@]} -eq 0 ]]; then
    log "⚠️ 没有有效的目录可供备份,跳过此周期。"
    return
  fi

  log "⬆️ 开始备份过程..."
  local ts; ts=$(date +%Y%m%d_%H%M%S)
  local fname="sillytavern_backup_${ts}.tar.gz"
  local tmp_file; tmp_file=$(mktemp)

  tar -czf "$tmp_file" -C "$APP_BASE_DIR" "${valid_dirs[@]}"
  
  python3 <<PY
import os
from huggingface_hub import HfApi
api = HfApi(token=os.getenv("HF_TOKEN"))
repo_id = os.getenv("DATASET_ID")
print(f"正在上传 {os.path.basename("$fname")}...")
api.upload_file(
    path_or_fileobj="$tmp_file",
    path_in_repo="$fname",
    repo_id=repo_id,
    repo_type="dataset"
)
print("正在清理旧备份...")
files = api.list_repo_files(repo_id=repo_id, repo_type="dataset")
backups = sorted([f for f in files if f.endswith(".tar.gz")])
keep = int(os.getenv("KEEP_BACKUPS", "10"))
if len(backups) > keep:
    for old_backup in backups[:-keep]:
        print(f"正在删除旧备份: {old_backup}")
        api.delete_file(path_in_repo=old_backup, repo_id=repo_id, repo_type="dataset")
    api.super_squash_history(repo_id=repo_id, repo_type="dataset")
PY
  rm -f "$tmp_file"
  log "✅ 备份周期完成。"
}

sync_loop() {
  while true; do
    do_backup
    log "⏳ 下次同步将在 ${SYNC_INTERVAL} 秒后进行。"
    sleep "${SYNC_INTERVAL}"
  done
}

main() {
  cd "$APP_BASE_DIR" || { log "❌ 致命错误: 无法访问 APP_BASE_DIR 位于 $APP_BASE_DIR"; exit 1; }
  
  trap 'kill 0' SIGTERM SIGINT

  log "🚀 sillytavern 启动器正在启动..."

  if [[ -f "config/config.yaml" ]]; then
    sed -i -e "s/username: .*/username: \"${USERNAME}\"/" \
           -e "s/password: .*/password: \"${PASSWORD}\"/" "config/config.yaml"
  fi

  if init_backup; then
    log "🔗 已为仓库启用同步服务: $DATASET_ID"
    prep_repo
    restore_latest
    sync_loop &
  fi

  log "🟢 正在启动 sillytavern 服务器..."
  exec node server.js --listen "$@"
}

main "$@"

config.yaml

dataRoot: ./data
listen: true
listenAddress:
  ipv4: 0.0.0.0
  ipv6: '[::]'
protocol:
    ipv4: true
    ipv6: false
dnsPreferIPv6: false
browserLaunch:
  enabled: true
  browser: 'default'
  hostname: 'auto'
  port: -1
  avoidLocalhost: false
port: 7860
ssl:
  enabled: false
  certPath: "./certs/cert.pem"
  keyPath: "./certs/privkey.pem"
whitelistMode: false
enableForwardedWhitelist: true
whitelist:
  - ::1
  - 127.0.0.1
whitelistDockerHosts: true
basicAuthMode: true
basicAuthUser:
  username: "user"
  password: "password"
enableCorsProxy: false
requestProxy:
  enabled: false
  url: "socks5://username:password@example.com:1080"
  bypass:
    - localhost
    - 127.0.0.1
enableUserAccounts: false
enableDiscreetLogin: false
autheliaAuth: false
perUserBasicAuth: false
sessionTimeout: -1
disableCsrfProtection: false
securityOverride: false
logging:
  enableAccessLog: true
  minLogLevel: 0
rateLimiting:
  preferRealIpHeader: false
backups:
  common:
    numberOfBackups: 50
  chat:
    enabled: true
    checkIntegrity: true
    maxTotalBackups: -1
    throttleInterval: 10000
thumbnails:
  enabled: true
  format: "jpg"
  quality: 95
  dimensions: { 'bg': [160, 90], 'avatar': [96, 144] }
performance:
  lazyLoadCharacters: false
  memoryCacheCapacity: '100mb'
  useDiskCache: true
allowKeysExposure: false
skipContentCheck: false
whitelistImportDomains:
  - localhost
  - cdn.discordapp.com
  - files.catbox.moe
  - raw.githubusercontent.com
  - char-archive.evulid.cc
requestOverrides: []
extensions:
  enabled: true
  autoUpdate: true
  models:
    autoDownload: true
    classification: Cohee/distilbert-base-uncased-go-emotions-onnx
    captioning: Xenova/vit-gpt2-image-captioning
    embedding: Cohee/jina-embeddings-v2-base-en
    speechToText: Xenova/whisper-small
    textToSpeech: Xenova/speecht5_tts
enableDownloadableTokenizers: true
promptPlaceholder: "[Start a new chat]"
openai:
  randomizeUserId: false
  captionSystemPrompt: ""
deepl:
  formality: default
mistral:
  enablePrefix: false
ollama:
  keepAlive: -1
  batchSize: -1
claude:
  enableSystemPromptCache: false
  cachingAtDepth: -1
  extendedTTL: false
gemini:
  apiVersion: 'v1beta'
enableServerPlugins: false
enableServerPluginsAutoUpdate: true