环境变量
| 变量名 | 示例值 | 描述 |
|---|---|---|
| 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