feat: standardize DeerFlow offline deployment

This commit is contained in:
吉鑫升 committed 2026-08-27 22:06:43 +08:00
1 parent 1764d0d5f2
commit d9a94245fc
37 files changed
+3761 -2

No files matched your search

+17 -2
View File
@@ -65,7 +65,10 @@ offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
# Canonical DeerFlow-only offline release assets. The base wheelhouse is
# versioned so a clean checkout can reproduce the fully offline bundle.
!offline-backend-20260512/wheelhouse/
!offline-backend-20260512/wheelhouse/**
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
@@ -119,7 +122,19 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
# Keep only the canonical DeerFlow-only deployment implementation and the
# three shared build inputs it consumes. Other historical deploy variants
# remain local-only.
deploy/*
!deploy/linux-docker-offline-deerflow-only/
!deploy/linux-docker-offline-deerflow-only/**
deploy/linux-docker-offline-deerflow-only/**/__pycache__/
deploy/linux-docker-offline-deerflow-only/**/*.pyc
!deploy/linux-docker-offline/
deploy/linux-docker-offline/*
!deploy/linux-docker-offline/Dockerfile.backend
!deploy/linux-docker-offline/Dockerfile.frontend
!deploy/linux-docker-offline/sanitize_release_sources.py
.history
# Cloned third-party repos (local only)
+14
View File
@@ -11,6 +11,20 @@ This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI
Managed together via shell scripts in `scripts/`.
## Canonical Offline Release
All future Linux AMD64 offline releases must use
`deploy/linux-docker-offline-deerflow-only/build-release.sh`. This is the
repository's canonical DeerFlow-only deployment path; do not package the
historical DeerFlow + WeKnora deployment variant. The canonical bundle uses
`config/deploy.env` as its only hand-edited configuration source and includes
the `aiomysql` database adapter, persistent `source/wheelhouse` extension
support, multi-model configuration, and the Office document runtime.
Follow `docs/DeerFlow纯离线标准发版与部署.md` for source synchronization,
building, verification, installation, source-only updates, rollback, and
artifact retention.
## Commands
### Full Application (from repo root)
@@ -0,0 +1,13 @@
# Fast, deterministic code refresh for a previously built runtime whose
# dependency manifests are byte-identical to the current source snapshot.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER root
RUN find /app/backend -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
COPY . /app/backend
WORKDIR /app/backend
# Keep the base image's PATH, PYTHONPATH, ENTRYPOINT and CMD. The caller must
# compare pyproject.toml, uv.lock, requirements-offline.txt and the harness
# pyproject before using this path.
@@ -0,0 +1,10 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER root
RUN find /app/backend -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
COPY . /app/backend
WORKDIR /app/backend
# Python、Office、浏览器和系统依赖全部继承自固定基线镜像。
# build-source-update.sh 与 APPLY_SOURCE_UPDATE.sh 会双重校验依赖清单。
@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS builder
WORKDIR /workspace/frontend-web
RUN corepack enable && corepack prepare pnpm@10.26.2 --activate
COPY frontend-web/package.json frontend-web/pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY frontend-web/ ./
ENV VITE_ASSET_BASE=/
RUN pnpm build
FROM scratch AS artifact
COPY --from=builder /workspace/frontend-web/dist/ /
@@ -0,0 +1,9 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
USER root
RUN find /usr/share/nginx/html -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
COPY frontend-dist/ /usr/share/nginx/html/
COPY nginx/deerflow.conf /etc/nginx/conf.d/default.conf
# Nginx 运行时继承自固定基线镜像;这里只替换编译后的前端静态文件。
@@ -0,0 +1,6 @@
# Refresh the unmounted fallback runtime-config.js in an already compiled
# frontend image. The compiled application assets must come from the exact same
# source snapshot; only this plain runtime file is replaced after sanitization.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
COPY frontend-web/public/runtime-config.js /usr/share/nginx/html/runtime-config.js
@@ -0,0 +1,49 @@
# DeerFlow 纯项目完全离线一键部署包
本包面向 Linux `x86_64/amd64`,只包含构建时工作区中的 DeerFlow 前端、后端镜像、命名智能体、技能和一键部署文件。版本来源、镜像标签和构建时间见 `VERSION.json`。
明确不包含:WeKnora UI/App、ParadeDB、Redis、DocReader、独立远程 Sandbox 镜像及 WeKnora 预置 Skills。DeerFlow 默认使用后端容器内的本地沙箱;该沙箱已经内置 Word、Excel、PowerPoint、OpenDocument 和 PDF 处理依赖。DeerFlow 中依赖 WeKnora 的 LLMWiki 连接和本地 Wiki 同步会被运行配置强制关闭。
“完全离线”是指解压、Docker 镜像加载和应用启动不访问镜像仓库、npm、pip 或 Git。实际问答仍需服务器能访问你在 `config/deploy.env` 中填写的外部 MySQL 和模型服务;模型权重不在本包内。
## 最短部署流程
```bash
sha256sum -c deerflow-linux-amd64-offline-*.tar.gz.sha256
tar -xzf deerflow-linux-amd64-offline-*.tar.gz
cd deerflow-linux-amd64-offline-*
vi config/deploy.env
sudo ./OFFLINE_INSTALL.sh --start
```
唯一需要人工修改的运行配置文件是 `config/deploy.env`。必须填写访问地址、外部 MySQL 和至少一个 DeerFlow 模型服务;支持用连续编号配置 1~20 个模型。新增离线 Python 依赖时只需向 `source/wheelhouse` 放入 wheel,并在该目录的 `requirements.txt` 增加一行。
```bash
sudo ./OFFLINE_INSTALL.sh --start
sudo ./OFFLINE_INSTALL.sh --stop
sudo ./OFFLINE_INSTALL.sh --restart
sudo ./OFFLINE_INSTALL.sh --status
sudo ./OFFLINE_INSTALL.sh --logs
sudo ./OFFLINE_INSTALL.sh --logs deerflow-backend
sudo ./OFFLINE_INSTALL.sh --office-check
```
`--stop` 只停止容器,不删除镜像、容器、网络或数据卷。再次启动会检查两个镜像,已存在时跳过 `docker load`。
MySQL ORM 固定使用 `aiomysql` 兼容组。`--start` 与 `--restart` 都只从包内 wheelhouse 安装依赖,不访问 pip 仓库;依赖安装在持久卷中,容器重建后仍保留。
详细文档按顺序阅读:
1. `docs/01-部署前准备.md`
2. `docs/02-deploy.env配置说明.md`
3. `docs/03-一键部署与启停.md`
4. `docs/04-技能与智能体位置.md`
5. `docs/05-数据备份与恢复.md`
6. `docs/06-故障排查与校验.md`
7. `docs/07-升级与版本回退.md`
8. `docs/08-离线验收清单.md`
9. `docs/09-后续增量更新发版手册.md`
10. `docs/10-Office文档处理与自检.md`
11. `docs/11-只替换前后端源码.md`
12. `docs/12-直接覆盖源码并重启.md`
13. `docs/13-wheelhouse离线新增依赖与多模型.md`
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 在可联网的 Linux amd64 构建机上执行:
# ./deploy/linux-docker-offline-deerflow-only/build-release.sh WORKSPACE BUILD_ROOT
WORKSPACE="${1:?workspace path is required}"
BUILD_ROOT="${2:?build root is required}"
WORKSPACE="$(realpath "$WORKSPACE")"
BUILD_ROOT="$(realpath -m "$BUILD_ROOT")"
[[ -f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/OFFLINE_INSTALL.sh" ]] || {
echo "invalid DeerFlow workspace: $WORKSPACE" >&2
exit 2
}
[[ -f "$WORKSPACE/offline-backend-20260512/backend/pyproject.toml" ]] || exit 2
[[ -f "$WORKSPACE/frontend-web/package.json" ]] || exit 2
case "$BUILD_ROOT" in
/|/opt|/opt/codex-build|"$WORKSPACE")
echo "unsafe build root: $BUILD_ROOT" >&2
exit 2
;;
esac
STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}"
DEERFLOW_COMMIT="${DEERFLOW_COMMIT_OVERRIDE:-}"
if [[ -z "$DEERFLOW_COMMIT" ]] && git -C "$WORKSPACE" rev-parse HEAD >/dev/null 2>&1; then
DEERFLOW_COMMIT="$(git -C "$WORKSPACE" rev-parse HEAD)"
fi
[[ "$DEERFLOW_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]] || {
echo "set DEERFLOW_COMMIT_OVERRIDE to the 40-character source commit" >&2
exit 2
}
DEERFLOW_SHORT="${DEERFLOW_COMMIT:0:8}"
SOURCE_DIRTY="${DEERFLOW_DIRTY_OVERRIDE:-false}"
[[ "$SOURCE_DIRTY" == "true" || "$SOURCE_DIRTY" == "false" ]] || exit 2
RELEASE_NAME="deerflow-linux-amd64-offline-${STAMP}"
STAGE="$BUILD_ROOT/stage/$RELEASE_NAME"
DIST="$BUILD_ROOT/dist"
CONTEXT_ROOT="$BUILD_ROOT/context/$RELEASE_NAME"
BACKEND_CONTEXT="$CONTEXT_ROOT/backend"
FRONTEND_CONTEXT="$CONTEXT_ROOT/frontend-root"
for target in "$STAGE" "$CONTEXT_ROOT"; do
if [[ -e "$target" ]]; then
resolved="$(realpath "$target")"
case "$resolved" in
"$BUILD_ROOT/stage/$RELEASE_NAME"|"$BUILD_ROOT/context/$RELEASE_NAME") rm -rf -- "$resolved" ;;
*) echo "refusing to replace unexpected path: $resolved" >&2; exit 2 ;;
esac
fi
done
mkdir -p "$STAGE" "$DIST" "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT/frontend-web" \
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx"
echo "[1/7] Assemble sanitized DeerFlow-only release"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/." "$STAGE/"
mkdir -p \
"$STAGE/config/deerflow" \
"$STAGE/config/frontend" \
"$STAGE/agents-skills/deerflow-agents" \
"$STAGE/agents-skills/deerflow-skills" \
"$STAGE/agents-skills/deerflow-knowledge" \
"$STAGE/images" \
"$STAGE/source/wheelhouse" \
"$STAGE/data/generated" \
"$STAGE/data/state"
cp -a "$WORKSPACE/offline-backend-20260512/backend/." "$BACKEND_CONTEXT/"
cp -a "$WORKSPACE/frontend-web/." "$FRONTEND_CONTEXT/frontend-web/"
cp -a "$WORKSPACE/deploy/linux-docker-offline/nginx/." \
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx/"
rm -rf -- \
"$BACKEND_CONTEXT/.venv" \
"$BACKEND_CONTEXT/.deer-flow" \
"$BACKEND_CONTEXT/.pytest_cache" \
"$BACKEND_CONTEXT/.ruff_cache" \
"$FRONTEND_CONTEXT/frontend-web/node_modules" \
"$FRONTEND_CONTEXT/frontend-web/dist" \
"$FRONTEND_CONTEXT/frontend-web/.vite"
find "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT" -type d -name __pycache__ -prune -exec rm -rf -- {} +
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" backend "$BACKEND_CONTEXT"
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" frontend "$FRONTEND_CONTEXT/frontend-web"
# 运行时直接挂载的后端源码。用户后续只需覆盖这三个目录并重启。
mkdir -p "$STAGE/source/backend" "$STAGE/source/frontend/dist"
cp -a "$BACKEND_CONTEXT/app" "$STAGE/source/backend/app"
cp -a "$BACKEND_CONTEXT/packages" "$STAGE/source/backend/packages"
cp -a "$BACKEND_CONTEXT/tests" "$STAGE/source/backend/tests"
cp -a "$BACKEND_CONTEXT/config.yaml" "$STAGE/config/deerflow/config.base.yaml"
cp -a "$BACKEND_CONTEXT/extensions_config.json" "$STAGE/config/deerflow/extensions_config.json"
cp -a "$FRONTEND_CONTEXT/frontend-web/public/runtime-config.js" "$STAGE/config/frontend/runtime-config.base.js"
cp -a "$WORKSPACE/offline-backend-20260512/backend/skills/." "$STAGE/agents-skills/deerflow-skills/"
cp -a "$WORKSPACE/offline-backend-20260512/wheelhouse/." "$STAGE/source/wheelhouse/"
[[ -f "$STAGE/source/wheelhouse/aiomysql-0.3.2-py3-none-any.whl" ]] || {
echo "aiomysql wheel is missing from source/wheelhouse" >&2
exit 2
}
# 预置项目自带的命名智能体;32 位十六进制目录是用户运行数据,不迁移。
AGENT_ROOT="$WORKSPACE/offline-backend-20260512/backend/.deer-flow/agents"
if [[ -d "$AGENT_ROOT" ]]; then
while IFS= read -r -d '' agent_dir; do
agent_name="$(basename "$agent_dir")"
if [[ ! "$agent_name" =~ ^[0-9a-fA-F]{32}$ ]]; then
cp -a "$agent_dir" "$STAGE/agents-skills/deerflow-agents/"
fi
done < <(find "$AGENT_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)
fi
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/docs/." "$STAGE/docs/"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/README_OFFLINE.md" "$STAGE/README_OFFLINE.md"
printf '%s\n' '把需要预置的本地知识文件放在此目录;本包不导入旧部署业务数据。' \
> "$STAGE/agents-skills/deerflow-knowledge/README.txt"
find "$STAGE" -type d -name __pycache__ -prune -exec rm -rf -- {} +
echo "[2/7] Build current DeerFlow linux/amd64 images"
BACKEND_IMAGE="cmzs/deerflow-backend-offline:${STAMP}-${DEERFLOW_SHORT}"
FRONTEND_IMAGE="cmzs/deerflow-frontend-offline:${STAMP}-${DEERFLOW_SHORT}"
if [[ -n "${DEERFLOW_BACKEND_RUNTIME_BASE:-}" ]]; then
[[ -n "${DEERFLOW_BASE_SOURCE_ROOT:-}" ]] || {
echo "DEERFLOW_BASE_SOURCE_ROOT is required with DEERFLOW_BACKEND_RUNTIME_BASE" >&2
exit 2
}
DEERFLOW_BASE_SOURCE_ROOT="$(realpath "$DEERFLOW_BASE_SOURCE_ROOT")"
for manifest in pyproject.toml uv.lock requirements-offline.txt packages/harness/pyproject.toml; do
cmp -s "$BACKEND_CONTEXT/$manifest" "$DEERFLOW_BASE_SOURCE_ROOT/$manifest" || {
echo "dependency manifest differs from runtime base: $manifest" >&2
exit 2
}
done
docker image inspect "$DEERFLOW_BACKEND_RUNTIME_BASE" >/dev/null
docker build --platform linux/amd64 --network host --provenance=false \
--build-arg "BASE_IMAGE=$DEERFLOW_BACKEND_RUNTIME_BASE" \
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.backend-code-refresh" \
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
else
docker build --platform linux/amd64 --network host --provenance=false \
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.backend" \
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
fi
if [[ -n "${DEERFLOW_FRONTEND_RUNTIME_BASE:-}" ]]; then
docker image inspect "$DEERFLOW_FRONTEND_RUNTIME_BASE" >/dev/null
docker build --platform linux/amd64 --network host --provenance=false \
--build-arg "BASE_IMAGE=$DEERFLOW_FRONTEND_RUNTIME_BASE" \
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.frontend-runtime-config-refresh" \
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
else
docker build --platform linux/amd64 --network host --provenance=false \
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.frontend" \
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
fi
# 从刚构建的前端镜像取出与镜像完全一致的 dist,作为宿主机挂载初始内容。
FRONTEND_EXPORT_CONTAINER="$(docker create "$FRONTEND_IMAGE")"
cleanup_frontend_export() {
docker rm -f "$FRONTEND_EXPORT_CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup_frontend_export EXIT
docker cp "$FRONTEND_EXPORT_CONTAINER:/usr/share/nginx/html/." \
"$STAGE/source/frontend/dist/"
docker rm "$FRONTEND_EXPORT_CONTAINER" >/dev/null
FRONTEND_EXPORT_CONTAINER=""
trap - EXIT
echo "[3/7] Validate image contents and architecture"
docker image inspect "$BACKEND_IMAGE" "$FRONTEND_IMAGE" >/dev/null
[[ "$(docker image inspect -f '{{.Architecture}}' "$BACKEND_IMAGE")" == "amd64" ]] || exit 2
[[ "$(docker image inspect -f '{{.Architecture}}' "$FRONTEND_IMAGE")" == "amd64" ]] || exit 2
docker run --rm --network none "$BACKEND_IMAGE" python -c \
'import app.gateway.app, deerflow; print("backend import smoke passed")'
docker run --rm --network none "$BACKEND_IMAGE" \
/opt/venv/bin/python /app/backend/scripts/office_runtime_selfcheck.py
echo "[4/7] Write version metadata"
cat > "$STAGE/images/deerflow-image-list.txt" <<EOF
$BACKEND_IMAGE
$FRONTEND_IMAGE
EOF
cat > "$STAGE/VERSION.json" <<EOF
{
"release": "$RELEASE_NAME",
"built_at": "$(date --iso-8601=seconds)",
"platform": "linux/amd64",
"deerflow_commit": "$DEERFLOW_COMMIT",
"deerflow_source_includes_worktree_changes": $SOURCE_DIRTY,
"packaging_revision": "compose-v1-office-v1-aiomysql-wheelhouse-multimodel-v1",
"office_runtime_included": true,
"weknora_included": false,
"images": {
"deerflow_backend": "$BACKEND_IMAGE",
"deerflow_frontend": "$FRONTEND_IMAGE"
}
}
EOF
# 固定源码覆盖允许继承的运行时依赖。后续 build-source-update.sh 只要发现
# 任一锁定文件变化就拒绝生成源码包,要求重新制作完整依赖基线。
python3 - "$WORKSPACE" "$STAGE/SOURCE_BASELINE.json" "$RELEASE_NAME" \
"$DEERFLOW_COMMIT" "$BACKEND_IMAGE" "$FRONTEND_IMAGE" <<'PY'
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
workspace = Path(sys.argv[1]).resolve()
output = Path(sys.argv[2])
locked_paths = (
"offline-backend-20260512/backend/pyproject.toml",
"offline-backend-20260512/backend/uv.lock",
"offline-backend-20260512/backend/requirements-offline.txt",
"offline-backend-20260512/backend/packages/harness/pyproject.toml",
"offline-backend-20260512/backend/Dockerfile.backend-offline",
"deploy/linux-docker-offline/Dockerfile.backend",
)
locked: dict[str, str] = {}
for relative in locked_paths:
path = workspace / relative
if not path.is_file():
raise SystemExit(f"runtime dependency lock input is missing: {relative}")
locked[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
data = {
"format_version": 1,
"runtime_base_release": sys.argv[3],
"runtime_base_commit": sys.argv[4],
"backend_image": sys.argv[5],
"frontend_image": sys.argv[6],
"locked_files": locked,
}
output.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
echo "[5/7] Export exactly two offline images"
docker save "$BACKEND_IMAGE" "$FRONTEND_IMAGE" | gzip -1 > \
"$STAGE/images/deerflow-images-linux-amd64.tar.gz"
gzip -t "$STAGE/images/deerflow-images-linux-amd64.tar.gz"
echo "[6/7] Generate checksums and run static release checks"
(
cd "$STAGE"
sha256sum images/deerflow-images-linux-amd64.tar.gz VERSION.json SOURCE_BASELINE.json > SHA256SUMS
)
chmod 0755 "$STAGE/OFFLINE_INSTALL.sh" "$STAGE/UPDATE_INSTALL.sh" \
"$STAGE/scripts/"*.py
find "$STAGE/data" -mindepth 1 -type f -delete
[[ "$(grep -cvE '^[[:space:]]*(#|$)' "$STAGE/images/deerflow-image-list.txt")" == "2" ]] || exit 2
python3 "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/test_release_renderer.py" "$WORKSPACE"
# source/ 是供用户直接覆盖的 DeerFlow 项目源码,可能包含未启用的可选
# WeKnora 适配代码;纯部署校验只禁止实际服务、镜像和部署文件。
if find "$STAGE" -path "$STAGE/source" -prune -o \
\( -iname '*weknora*' -o -iname '*paradedb*' -o -iname '*docreader*' \) \
-print | grep -q .; then
echo "WeKnora-related deployment artifact found in pure DeerFlow stage" >&2
exit 2
fi
if grep -RIlE --exclude-dir=source \
'wechatopenai/weknora|paradedb/paradedb|redis:7\.0-alpine' "$STAGE" | grep -q .; then
echo "WeKnora image/service reference found in pure DeerFlow stage" >&2
exit 2
fi
echo "[7/7] Create final archive"
FINAL="$DIST/$RELEASE_NAME.tar.gz"
tar -C "$(dirname "$STAGE")" -cf - "$(basename "$STAGE")" | gzip -1 > "$FINAL"
gzip -t "$FINAL"
(
cd "$DIST"
sha256sum "$(basename "$FINAL")" > "$(basename "$FINAL").sha256"
)
printf 'FINAL=%s\n' "$FINAL"
printf 'SIZE_BYTES=%s\n' "$(stat -c %s "$FINAL")"
printf 'SHA256=%s\n' "$(sha256sum "$FINAL" | awk '{print $1}')"
@@ -0,0 +1,308 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 在 Linux amd64 发版机上执行:
# ./build-source-update.sh WORKSPACE CURRENT_DEPLOY_DIR OUTPUT_ROOT [all|backend|frontend]
#
# CURRENT_DEPLOY_DIR 必须是当前正在使用的完整包解压目录(VERSION.json 会随每次
# 源码更新向前推进)。首次使用当前 20260827 基线时,还需把同目录下保存的
# SOURCE_BASELINE.json 放进该部署目录。
WORKSPACE="${1:?workspace path is required}"
BASE_BUNDLE="${2:?current DeerFlow deployment directory is required}"
OUTPUT_ROOT="${3:?output root is required}"
COMPONENTS="${4:-all}"
WORKSPACE="$(realpath "$WORKSPACE")"
BASE_BUNDLE="$(realpath "$BASE_BUNDLE")"
OUTPUT_ROOT="$(realpath -m "$OUTPUT_ROOT")"
case "$COMPONENTS" in
all|backend|frontend) ;;
*) echo "components must be all, backend or frontend" >&2; exit 2 ;;
esac
[[ -f "$WORKSPACE/offline-backend-20260512/backend/pyproject.toml" ]] || exit 2
[[ -f "$WORKSPACE/frontend-web/package.json" ]] || exit 2
[[ -f "$BASE_BUNDLE/VERSION.json" ]] || { echo "base bundle lacks VERSION.json" >&2; exit 2; }
[[ -f "$BASE_BUNDLE/SOURCE_BASELINE.json" ]] || {
echo "base bundle lacks SOURCE_BASELINE.json; use the immutable baseline sidecar" >&2
exit 2
}
case "$OUTPUT_ROOT" in
/|/opt|/opt/codex-build|"$WORKSPACE"|"$BASE_BUNDLE")
echo "unsafe output root: $OUTPUT_ROOT" >&2
exit 2
;;
esac
mapfile -t BASE_VALUES < <(python3 - "$BASE_BUNDLE/VERSION.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
print(d["release"])
print(d["images"]["deerflow_backend"])
print(d["images"]["deerflow_frontend"])
PY
)
BASE_RELEASE="${BASE_VALUES[0]}"
BASE_BACKEND_IMAGE="${BASE_VALUES[1]}"
BASE_FRONTEND_IMAGE="${BASE_VALUES[2]}"
for value in "$BASE_RELEASE" "$BASE_BACKEND_IMAGE" "$BASE_FRONTEND_IMAGE"; do
[[ "$value" =~ ^[A-Za-z0-9._:/,+-]+$ ]] || { echo "unsafe base metadata value: $value" >&2; exit 2; }
done
ensure_base_images() {
if docker image inspect "$BASE_BACKEND_IMAGE" "$BASE_FRONTEND_IMAGE" >/dev/null 2>&1; then
return
fi
local archive="$BASE_BUNDLE/images/deerflow-images-linux-amd64.tar.gz"
[[ -f "$archive" ]] || {
echo "base images are not loaded and full image archive is unavailable: $archive" >&2
exit 2
}
echo "Loading saved base images once..."
gzip -dc "$archive" | docker load
docker image inspect "$BASE_BACKEND_IMAGE" "$BASE_FRONTEND_IMAGE" >/dev/null
}
ensure_base_images
[[ "$(docker image inspect -f '{{.Architecture}}' "$BASE_BACKEND_IMAGE")" == "amd64" ]] || exit 2
[[ "$(docker image inspect -f '{{.Architecture}}' "$BASE_FRONTEND_IMAGE")" == "amd64" ]] || exit 2
echo "[1/7] Verify immutable dependency baseline"
python3 - "$WORKSPACE" "$BASE_BUNDLE/SOURCE_BASELINE.json" <<'PY'
from __future__ import annotations
import hashlib, json, sys
from pathlib import Path
workspace = Path(sys.argv[1]).resolve()
baseline = json.load(open(sys.argv[2], encoding="utf-8"))
if baseline.get("format_version") != 1:
raise SystemExit("unsupported SOURCE_BASELINE.json format")
for relative, expected in baseline.get("locked_files", {}).items():
path = (workspace / relative).resolve()
try:
path.relative_to(workspace)
except ValueError:
raise SystemExit(f"unsafe locked path: {relative}")
if not path.is_file():
raise SystemExit(f"runtime dependency file is missing: {relative}")
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual != expected:
raise SystemExit(
f"runtime dependency changed: {relative}\n"
"source-only update is refused; create a new full dependency baseline"
)
print("immutable runtime dependency baseline passed")
PY
STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}"
SOURCE_COMMIT="${DEERFLOW_COMMIT_OVERRIDE:-}"
if [[ -z "$SOURCE_COMMIT" ]] && git -C "$WORKSPACE" rev-parse HEAD >/dev/null 2>&1; then
SOURCE_COMMIT="$(git -C "$WORKSPACE" rev-parse HEAD)"
fi
[[ "$SOURCE_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]] || {
echo "set DEERFLOW_COMMIT_OVERRIDE to the 40-character source commit" >&2
exit 2
}
SOURCE_SHORT="${SOURCE_COMMIT:0:8}"
if [[ -n "$(git -C "$WORKSPACE" status --porcelain --untracked-files=normal -- \
offline-backend-20260512/backend frontend-web 2>/dev/null || true)" ]]; then
SOURCE_DIRTY=true
else
SOURCE_DIRTY=false
fi
if [[ -n "${DEERFLOW_DIRTY_OVERRIDE:-}" ]]; then
SOURCE_DIRTY="$DEERFLOW_DIRTY_OVERRIDE"
fi
[[ "$SOURCE_DIRTY" == "true" || "$SOURCE_DIRTY" == "false" ]] || exit 2
UPDATE_NAME="deerflow-source-update-${STAMP}-${SOURCE_SHORT}"
TARGET_RELEASE="deerflow-source-${STAMP}-${SOURCE_SHORT}"
UPDATE_DIR="$OUTPUT_ROOT/stage/$UPDATE_NAME"
CONTEXT_ROOT="$OUTPUT_ROOT/context/$UPDATE_NAME"
DIST="$OUTPUT_ROOT/dist"
for target in "$UPDATE_DIR" "$CONTEXT_ROOT"; do
if [[ -e "$target" ]]; then
resolved="$(realpath "$target")"
case "$resolved" in
"$OUTPUT_ROOT/stage/$UPDATE_NAME"|"$OUTPUT_ROOT/context/$UPDATE_NAME") rm -rf -- "$resolved" ;;
*) echo "refusing to replace unexpected path: $resolved" >&2; exit 2 ;;
esac
fi
done
mkdir -p "$UPDATE_DIR/docker" "$UPDATE_DIR/payload/images" "$UPDATE_DIR/payload/config/deerflow" \
"$UPDATE_DIR/payload/config/frontend" "$UPDATE_DIR/source" "$UPDATE_DIR/runtime" \
"$CONTEXT_ROOT" "$DIST"
BACKEND_CHANGED=false
FRONTEND_CHANGED=false
[[ "$COMPONENTS" == "all" || "$COMPONENTS" == "backend" ]] && BACKEND_CHANGED=true
[[ "$COMPONENTS" == "all" || "$COMPONENTS" == "frontend" ]] && FRONTEND_CHANGED=true
TARGET_BACKEND_IMAGE="$BASE_BACKEND_IMAGE"
TARGET_FRONTEND_IMAGE="$BASE_FRONTEND_IMAGE"
[[ "$BACKEND_CHANGED" == "false" ]] || TARGET_BACKEND_IMAGE="cmzs/deerflow-backend-offline:src-${STAMP}-${SOURCE_SHORT}"
[[ "$FRONTEND_CHANGED" == "false" ]] || TARGET_FRONTEND_IMAGE="cmzs/deerflow-frontend-offline:src-${STAMP}-${SOURCE_SHORT}"
BASE_BACKEND_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$BASE_BACKEND_IMAGE")"
BASE_FRONTEND_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$BASE_FRONTEND_IMAGE")"
echo "[2/7] Assemble sanitized source payload"
BACKEND_CONTEXT="$CONTEXT_ROOT/backend"
FRONTEND_CONTEXT_ROOT="$CONTEXT_ROOT/frontend-root"
mkdir -p "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT_ROOT/frontend-web"
cp -a "$WORKSPACE/offline-backend-20260512/backend/." "$BACKEND_CONTEXT/"
cp -a "$WORKSPACE/frontend-web/." "$FRONTEND_CONTEXT_ROOT/frontend-web/"
rm -rf -- "$BACKEND_CONTEXT/.venv" "$BACKEND_CONTEXT/.deer-flow" \
"$BACKEND_CONTEXT/.pytest_cache" "$BACKEND_CONTEXT/.ruff_cache" \
"$BACKEND_CONTEXT/skills/custom" \
"$FRONTEND_CONTEXT_ROOT/frontend-web/node_modules" "$FRONTEND_CONTEXT_ROOT/frontend-web/dist" \
"$FRONTEND_CONTEXT_ROOT/frontend-web/.vite"
mkdir -p "$BACKEND_CONTEXT/skills/custom"
find "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT_ROOT" -type d -name __pycache__ -prune -exec rm -rf -- {} +
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" backend "$BACKEND_CONTEXT"
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" frontend \
"$FRONTEND_CONTEXT_ROOT/frontend-web"
compare_backend_manifests() {
local cid temp manifest failed=false
temp="$(mktemp -d)"
cid="$(docker create "$BASE_BACKEND_IMAGE")"
for manifest in pyproject.toml uv.lock requirements-offline.txt packages/harness/pyproject.toml; do
mkdir -p "$temp/$(dirname "$manifest")"
if ! docker cp "$cid:/app/backend/$manifest" "$temp/$manifest" >/dev/null \
|| ! cmp -s "$BACKEND_CONTEXT/$manifest" "$temp/$manifest"; then
echo "backend dependency manifest differs from active base: $manifest" >&2
failed=true
fi
done
docker rm -f "$cid" >/dev/null 2>&1 || true
rm -rf -- "$temp"
[[ "$failed" == "false" ]] || {
echo "source-only update refused; create a new full dependency baseline" >&2
exit 2
}
}
[[ "$BACKEND_CHANGED" == "false" ]] || compare_backend_manifests
if [[ "$BACKEND_CHANGED" == "true" ]]; then
cp -a "$BACKEND_CONTEXT" "$UPDATE_DIR/source/backend"
fi
if [[ "$FRONTEND_CHANGED" == "true" ]]; then
cp -a "$FRONTEND_CONTEXT_ROOT/frontend-web" "$UPDATE_DIR/source/frontend"
fi
if [[ "$BACKEND_CHANGED" == "true" ]]; then
cp -a "$BACKEND_CONTEXT/config.yaml" "$UPDATE_DIR/payload/config/deerflow/config.base.yaml"
cp -a "$BACKEND_CONTEXT/extensions_config.json" "$UPDATE_DIR/payload/config/deerflow/extensions_config.json"
else
cp -a "$BASE_BUNDLE/config/deerflow/config.base.yaml" "$UPDATE_DIR/payload/config/deerflow/config.base.yaml"
cp -a "$BASE_BUNDLE/config/deerflow/extensions_config.json" \
"$UPDATE_DIR/payload/config/deerflow/extensions_config.json"
fi
if [[ "$FRONTEND_CHANGED" == "true" ]]; then
cp -a "$FRONTEND_CONTEXT_ROOT/frontend-web/public/runtime-config.js" \
"$UPDATE_DIR/payload/config/frontend/runtime-config.base.js"
else
cp -a "$BASE_BUNDLE/config/frontend/runtime-config.base.js" \
"$UPDATE_DIR/payload/config/frontend/runtime-config.base.js"
fi
echo "[3/7] Compile frontend assets when requested"
if [[ "$FRONTEND_CHANGED" == "true" ]]; then
mkdir -p "$UPDATE_DIR/runtime/frontend-dist" "$UPDATE_DIR/runtime/nginx"
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 --network host --provenance=false \
--target artifact --output "type=local,dest=$UPDATE_DIR/runtime/frontend-dist" \
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.frontend-assets" \
"$FRONTEND_CONTEXT_ROOT"
[[ -s "$UPDATE_DIR/runtime/frontend-dist/index.html" ]] || {
echo "frontend build did not produce index.html" >&2
exit 2
}
cp -a "$WORKSPACE/deploy/linux-docker-offline/nginx/deerflow.conf" \
"$UPDATE_DIR/runtime/nginx/deerflow.conf"
fi
echo "[4/7] Add offline overlay builders and target metadata"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.backend-source-overlay" \
"$UPDATE_DIR/docker/"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.frontend-dist-overlay" \
"$UPDATE_DIR/docker/"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/source-update-template/APPLY_SOURCE_UPDATE.sh" \
"$UPDATE_DIR/APPLY_SOURCE_UPDATE.sh"
cat > "$UPDATE_DIR/SOURCE_UPDATE_MANIFEST.env" <<EOF
FORMAT_VERSION=1
BASE_RELEASE=$BASE_RELEASE
TARGET_RELEASE=$TARGET_RELEASE
BASE_BACKEND_IMAGE=$BASE_BACKEND_IMAGE
BASE_FRONTEND_IMAGE=$BASE_FRONTEND_IMAGE
BASE_BACKEND_IMAGE_ID=$BASE_BACKEND_IMAGE_ID
BASE_FRONTEND_IMAGE_ID=$BASE_FRONTEND_IMAGE_ID
TARGET_BACKEND_IMAGE=$TARGET_BACKEND_IMAGE
TARGET_FRONTEND_IMAGE=$TARGET_FRONTEND_IMAGE
BACKEND_CHANGED=$BACKEND_CHANGED
FRONTEND_CHANGED=$FRONTEND_CHANGED
SOURCE_COMMIT=$SOURCE_COMMIT
SOURCE_DIRTY=$SOURCE_DIRTY
EOF
mapfile -t RUNTIME_BASE < <(python3 - "$BASE_BUNDLE/SOURCE_BASELINE.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
print(d["runtime_base_release"])
print(d["runtime_base_commit"])
PY
)
cat > "$UPDATE_DIR/payload/VERSION.json" <<EOF
{
"release": "$TARGET_RELEASE",
"built_at": "$(date --iso-8601=seconds)",
"platform": "linux/amd64",
"deerflow_commit": "$SOURCE_COMMIT",
"deerflow_source_includes_worktree_changes": $SOURCE_DIRTY,
"runtime_base_release": "${RUNTIME_BASE[0]}",
"runtime_base_commit": "${RUNTIME_BASE[1]}",
"packaging_revision": "source-overlay-v1",
"update_type": "source-only",
"office_runtime_included": true,
"weknora_included": false,
"images": {
"deerflow_backend": "$TARGET_BACKEND_IMAGE",
"deerflow_frontend": "$TARGET_FRONTEND_IMAGE"
}
}
EOF
printf '%s\n%s\n' "$TARGET_BACKEND_IMAGE" "$TARGET_FRONTEND_IMAGE" \
> "$UPDATE_DIR/payload/images/deerflow-image-list.txt"
echo "[5/7] Validate source update structure"
chmod 0755 "$UPDATE_DIR/APPLY_SOURCE_UPDATE.sh"
if find "$UPDATE_DIR" -type l -print -quit | grep -q .; then
echo "source update stage contains a symbolic link" >&2
exit 2
fi
grep -Fq "$TARGET_BACKEND_IMAGE" "$UPDATE_DIR/payload/VERSION.json"
grep -Fq "$TARGET_FRONTEND_IMAGE" "$UPDATE_DIR/payload/VERSION.json"
[[ "$(grep -cvE '^[[:space:]]*(#|$)' "$UPDATE_DIR/payload/images/deerflow-image-list.txt")" == "2" ]]
echo "[6/7] Generate content checksums"
(
cd "$UPDATE_DIR"
find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum > SHA256SUMS
sha256sum -c SHA256SUMS >/dev/null
)
echo "[7/7] Create source-only update archive"
FINAL="$DIST/$UPDATE_NAME.tar.gz"
tar -C "$(dirname "$UPDATE_DIR")" -cf - "$(basename "$UPDATE_DIR")" | gzip -1 > "$FINAL"
gzip -t "$FINAL"
(
cd "$DIST"
sha256sum "$(basename "$FINAL")" > "$(basename "$FINAL").sha256"
)
printf 'FINAL=%s\n' "$FINAL"
printf 'SIZE_BYTES=%s\n' "$(stat -c %s "$FINAL")"
printf 'SHA256=%s\n' "$(sha256sum "$FINAL" | awk '{print $1}')"
@@ -0,0 +1,45 @@
# 01 部署前准备
## 服务器要求
- Linux x86_64/amd64。
- Docker Engine 和独立命令 `docker-compose` 已安装且运行正常;脚本优先使用它,并兼容 Compose v2 插件作为回退。
- 建议至少 8 核 CPU、16 GiB 内存、20 GiB 可用磁盘;镜像加载期间需要同时容纳压缩包、解压目录和 Docker 镜像层。
- 可访问外部 MySQL 8.x;部署账号需要连接、建库和建表权限。
- 可从 Docker 容器访问 OpenAI 兼容的 DeerFlow 对话模型接口。
目标机部署过程不需要访问互联网、镜像仓库、npm 或 pip。`PUBLIC_HOST` 对应的前端和后端端口需要在防火墙放行。
本包不包含 WeKnora 以及其 PostgreSQL、Redis、DocReader、独立远程 Sandbox 镜像,也不包含模型权重。默认本地沙箱位于 DeerFlow 后端容器内,Office/PDF 依赖已经随该镜像离线交付。
## 部署前检查
```bash
uname -m # 应为 x86_64
docker version
docker-compose version # 目标服务器使用此命令
docker info
df -h
```
还需要系统命令:`bash`、`tar`、`gzip`、`sha256sum`、`curl`。部署用户可以直接使用 Docker;否则命令前加 `sudo`。
准备并确认以下网络方向:
- 浏览器 → `FRONTEND_PORT`。
- 浏览器 → `BACKEND_PORT`(前端运行时会直连 Gateway)。
- DeerFlow 后端容器 → 外部 MySQL。
- DeerFlow 后端容器 → OpenAI 兼容模型接口。
- 如果启用 Tavily、Serper 或业务接口,还需按实际调用方向访问相应地址。
同一服务器上的服务不能从容器里使用 `127.0.0.1` 访问宿主机。应使用宿主机内网 IP;Docker 支持 `host-gateway` 时也可使用 `host.docker.internal`。
## 文件校验
把压缩包和同名 `.sha256` 放在同一目录:
```bash
sha256sum -c deerflow-linux-amd64-offline-*.tar.gz.sha256
```
显示 `OK` 后再解压。校验失败不要继续部署,应重新传输文件。
@@ -0,0 +1,86 @@
# 02 deploy.env 配置说明
唯一人工配置源:
```text
config/deploy.env
```
`data/generated/` 下的 Compose 环境、DeerFlow `config.yaml` 和前端 `runtime-config.js` 每次启动都会自动生成,不要修改。
必须修改:
```dotenv
PUBLIC_HOST=10.10.10.10
FRONTEND_PORT=5174
BACKEND_PORT=8001
MYSQL_HOST=10.10.10.20
MYSQL_PORT=3306
MYSQL_DATABASE=deerflow
MYSQL_USER=deerflow
MYSQL_PASSWORD=your-real-password
DEERFLOW_MODEL_COUNT=2
DEERFLOW_MODEL_1_NAME=qwen3-32b
DEERFLOW_MODEL_1_DISPLAY_NAME=通用模型
DEERFLOW_MODEL_1_MODEL=qwen3-32b
DEERFLOW_MODEL_1_BASE_URL=http://10.10.10.25:8000/v1
DEERFLOW_MODEL_1_API_KEY=your-real-key-1
DEERFLOW_MODEL_1_SUPPORTS_VISION=false
DEERFLOW_MODEL_1_SUPPORTS_THINKING=false
DEERFLOW_MODEL_2_NAME=qwen-vl
DEERFLOW_MODEL_2_DISPLAY_NAME=视觉模型
DEERFLOW_MODEL_2_MODEL=qwen-vl
DEERFLOW_MODEL_2_BASE_URL=http://10.10.10.26:8000/v1
DEERFLOW_MODEL_2_API_KEY=your-real-key-2
DEERFLOW_MODEL_2_SUPPORTS_VISION=true
DEERFLOW_MODEL_2_SUPPORTS_THINKING=false
```
同宿主机 MySQL 不要填 `127.0.0.1`;应填宿主机内网 IP,或在 Docker 的 `host-gateway` 正常时填写 `host.docker.internal`。
字段解释:
| 字段 | 必填 | 说明 |
|---|---:|---|
| `PUBLIC_HOST` | 是 | 用户访问的服务器 IP 或域名,只填主机名,不带协议和路径 |
| `FRONTEND_PORT` | 是 | 页面端口,默认示例 `5174` |
| `BACKEND_PORT` | 是 | Gateway 端口,默认示例 `8001`,不能与前端端口相同 |
| `MYSQL_HOST` | 是 | 外部 MySQL 主机名、内网 IP 或 `host.docker.internal` |
| `MYSQL_PORT` | 是 | MySQL 监听端口 |
| `MYSQL_DATABASE` | 是 | 仅允许字母、数字和下划线;账号需有建库/建表权限 |
| `MYSQL_USER` / `MYSQL_PASSWORD` | 是 | DeerFlow 数据库账号和密码 |
| `DEERFLOW_MODEL_COUNT` | 是 | 启用的模型数量,范围 1~20;第 1 个模型是系统默认模型 |
| `DEERFLOW_MODEL_N_NAME` | 是 | DeerFlow 内部唯一名称,不能与其他模型重复 |
| `DEERFLOW_MODEL_N_DISPLAY_NAME` | 否 | 页面显示名称,留空时使用 `NAME` |
| `DEERFLOW_MODEL_N_MODEL` | 否 | 发给模型服务的真实 model id,留空时使用 `NAME` |
| `DEERFLOW_MODEL_N_BASE_URL` | 是 | OpenAI 兼容根地址,通常以 `/v1` 结尾 |
| `DEERFLOW_MODEL_N_API_KEY` | 是 | 对应模型接口 Key;内网无鉴权时也需填写接口接受的占位值 |
| `DEERFLOW_MODEL_N_SUPPORTS_VISION` | 否 | 是否支持图片输入,默认 `false` |
| `DEERFLOW_MODEL_N_SUPPORTS_THINKING` | 否 | 是否支持 thinking,默认 `false` |
| `DEERFLOW_MODEL_N_SUPPORTS_REASONING_EFFORT` | 否 | 是否接受 reasoning effort,默认 `false` |
| `DEERFLOW_MODEL_N_REQUEST_TIMEOUT` | 否 | 单次请求超时秒数,默认 `600`,范围 1~3600 |
| `DEERFLOW_MODEL_N_MAX_RETRIES` | 否 | 模型请求重试次数,默认 `2`,范围 0~10 |
| `DEERFLOW_MODEL_N_STREAM_USAGE` | 否 | 是否在流式响应中统计用量,默认 `true` |
| `DEERFLOW_MODEL_N_MAX_TOKENS` | 否 | 最大输出 token;留空表示由接口默认值决定 |
| `DEERFLOW_MODEL_N_TEMPERATURE` | 否 | 温度,范围 0~2;留空表示由接口默认值决定 |
| `DEERFLOW_MODEL_N_USE_RESPONSES_API` | 否 | OpenAI Responses API 开关,普通兼容接口保持 `false` |
`N` 从 1 开始连续编号。例如 `DEERFLOW_MODEL_COUNT=3` 时,必须完整填写模型 1、2、3;模型 4 之后即使保留示例也会被忽略。要增加模型,复制最后一个模型块、把编号加 1,并同步修改 `DEERFLOW_MODEL_COUNT`。要删除模型,按连续编号整理后减小 `COUNT`。
所有模型默认使用 `langchain_openai:ChatOpenAI`。如果确实需要自定义 provider,可增加 `DEERFLOW_MODEL_N_USE=包路径:类名`;对应 Python 包必须已经内置,或通过 `source/wheelhouse` 离线安装。
Tavily、Serper、任务深链和所有 `VITE_*` 业务接口均为可选项,不使用就保持空值。示例保留地址和 `example` 密钥会被启动脚本拒绝,防止误部署。
任务深链不接真实系统时保持 `TASK_DEEPLINK_MOCK_MODE=true`。接真实系统时改为 `false`,并填写对应的 `TASK_*_URL` 与前端业务 `VITE_*` 地址。URL 必须是完整 `http://` 或 `https://` 地址,不要在 URL 内嵌账号密码,也不要附带查询参数。
`deploy.env` 是简单 `KEY=VALUE` 文件:一个配置占一行,不要在等号两侧加空格,不支持多行值;值包含 `#` 时不要写行尾注释。修改后执行 `--restart`,脚本会重新渲染并覆盖 `data/generated/`,然后重建前后端容器,因此不要直接编辑生成文件。`--restart` 不执行数据库预检、不加载镜像;首次部署仍使用 `--start`。
运行时生成的 MySQL SQLAlchemy 地址固定为 `mysql+aiomysql://...`。用户名和密码会自动 URL 编码,不要自己在 `MYSQL_PASSWORD` 中把 `@`、`#` 等字符改成 `%xx`。
DeerFlow 登录 JWT 密钥首次启动自动随机生成到 `data/state/runtime-secrets.json`,无需人工配置;该文件应随数据库和数据目录一起备份。
本包没有 WeKnora 配置项。运行配置始终把 WeKnora 地址置空,并关闭 LLMWiki 本地同步和向量索引。
@@ -0,0 +1,46 @@
# 03 一键部署与启停
```bash
tar -xzf deerflow-linux-amd64-offline-*.tar.gz
cd deerflow-linux-amd64-offline-*
cp -a config/deploy.env config/deploy.env.before-edit
vi config/deploy.env
sudo ./OFFLINE_INSTALL.sh --start
```
启动会依次执行:检查镜像、必要时校验并加载离线镜像、校验 `deploy.env`、生成运行配置、使用 aiomysql 预检 MySQL、从 `source/wheelhouse` 安装已声明的离线依赖、使用 `docker-compose up -d --no-build` 启动、等待前后端健康。脚本不使用 Compose v2 专属的 `--pull never`;镜像缺失时会先从包内加载,因此不会现场构建、联网下载 pip 包或拉取镜像。
第一次加载镜像耗时取决于磁盘速度。出现“所需 DeerFlow 镜像已存在”表示标签完全匹配,脚本会跳过 `docker load`。启动完成后会打印页面地址,也可以手工检查:
```bash
curl -f http://127.0.0.1:8001/health
curl -I http://127.0.0.1:5174/
sudo ./OFFLINE_INSTALL.sh --status
```
如果修改过端口,请把示例端口替换成 `deploy.env` 中的值。
```bash
# 只停止,保留镜像和数据
sudo ./OFFLINE_INSTALL.sh --stop
# 修改 deploy.env 后重新渲染并启动
sudo ./OFFLINE_INSTALL.sh --restart
sudo ./OFFLINE_INSTALL.sh --status
sudo ./OFFLINE_INSTALL.sh --logs
sudo ./OFFLINE_INSTALL.sh --logs deerflow-backend
# 不连接数据库,单独验证沙箱中的 Word/Excel/PPT/PDF 依赖
sudo ./OFFLINE_INSTALL.sh --office-check
```
`--logs` 会持续跟踪日志,按 `Ctrl+C` 只退出日志查看,不会停止服务。前端服务名为 `deerflow-frontend`,后端服务名为 `deerflow-backend`。
`--restart` 会重新校验/渲染 `deploy.env`、检查 wheelhouse 指纹、验证挂载源码,然后强制重建前后端容器并等待健康。它不执行 MySQL 预检,不执行 `docker load/build/pull/rmi`,不删除网络和卷。容器重建是为了让新增挂载、依赖和环境变量可靠生效;数据库、用户文件、浏览器上下文和 wheelhouse 安装结果都在持久卷中保留。仅修改智能体、技能或知识文件后也使用 `--restart`。
脚本没有删除命令。需要卸载时应先完整备份,然后由运维人员明确执行 Compose `down` 或卷清理,不要把它与日常停止混用。
## 开机自启
Compose 服务设置为 `restart: unless-stopped`。手工执行 `--stop` 后 Docker 不会自动拉起它,需要恢复时执行 `--start`;如果只是重启服务器且此前没有手工停止,Docker 服务启动后通常会自动恢复容器。
@@ -0,0 +1,62 @@
# 04 技能与智能体位置
## 智能体
```text
agents-skills/deerflow-agents/<agent-name>/config.yaml
agents-skills/deerflow-agents/<agent-name>/SOUL.md
```
运行时挂载到 `/app/backend/.deer-flow/agents`。
每个智能体使用独立、可读的目录名。`config.yaml` 定义名称、模型、工具和技能等配置,`SOUL.md` 定义角色提示词。额外模板或资源放在同一目录内并使用相对路径。不要使用 32 位十六进制目录名,这类名称保留给运行时用户智能体。
```text
agents-skills/deerflow-agents/my-agent/
├── config.yaml
└── SOUL.md
```
## 技能
```text
agents-skills/deerflow-skills/public/<skill-name>/SKILL.md
agents-skills/deerflow-skills/custom/<skill-name>/SKILL.md
```
技能需要脚本时放在对应技能目录的 `scripts/` 下。运行时整个目录挂载到 `/app/backend/skills`。
推荐结构:
```text
agents-skills/deerflow-skills/custom/my-skill/
├── SKILL.md
├── scripts/
├── references/
└── assets/
```
`SKILL.md` 是入口说明;脚本放 `scripts/`,资料放 `references/`,静态模板放 `assets/`。技能中如果写死了环境 URL/IP,应在迁移前替换成目标环境地址。
## 本地知识文件
```text
agents-skills/deerflow-knowledge/
```
运行时挂载到 `/app/backend/knowledge`。修改上述目录后执行 `--restart`。不要进入镜像内部改文件;升级前备份整个 `agents-skills/`。
本地知识目录只是随容器挂载的文件位置,不等同于 WeKnora 知识库,也不会自动创建向量库。具体智能体或技能需要通过自身配置读取这些文件。
更新后检查:
```bash
find agents-skills/deerflow-agents -maxdepth 2 -type f | sort
find agents-skills/deerflow-skills -maxdepth 3 -type f | sort
sudo ./OFFLINE_INSTALL.sh --restart
sudo ./OFFLINE_INSTALL.sh --logs deerflow-backend
```
YAML 或 Markdown 格式错误通常会出现在后端日志中。上线前先复制原目录作为回退副本,不要在运行中的容器文件系统里直接修改,重建容器会丢失该修改。
本发行包没有 `agents-skills/weknora-skills/`,也不会启动 WeKnora。
@@ -0,0 +1,32 @@
# 05 数据备份与恢复
需要成套备份:
- `config/deploy.env`。
- `data/state/runtime-secrets.json`。
- `agents-skills/`。
- 外部 MySQL 中的 DeerFlow 数据库。
- Docker 卷 `cmzs-deerflow_deerflow-data`、`cmzs-deerflow_deerflow-browser-context`、`cmzs-deerflow_deerflow-users`。
备份前执行 `sudo ./OFFLINE_INSTALL.sh --stop`,数据库和卷恢复完成后再执行 `--start`。不要重新生成丢失的 JWT 密钥后继续使用旧登录数据。
## 卷备份示例
先确认实际卷名:
```bash
docker volume ls --filter name=cmzs-deerflow
```
以 `deerflow-data` 为例导出到当前目录:
```bash
docker run --rm \
-v cmzs-deerflow_deerflow-data:/source:ro \
-v "$PWD":/backup \
alpine:3.20 sh -c 'cd /source && tar -czf /backup/deerflow-data.tar.gz .'
```
纯离线目标机不一定有 `alpine:3.20`,该命令仅在镜像已存在时使用;否则使用运维环境已有的备份工具。MySQL 应使用 `mysqldump` 或单位数据库备份平台独立备份。
恢复时必须保证应用已停止,先恢复数据库,再恢复三个卷、`agents-skills/`、`deploy.env` 和 `runtime-secrets.json`,最后执行 `--start`。恢复后检查用户登录、智能体列表、历史会话和文件访问。
@@ -0,0 +1,54 @@
# 06 故障排查与校验
外层包校验:
```bash
sha256sum -c deerflow-linux-amd64-offline-*.tar.gz.sha256
```
包内镜像校验:
```bash
sha256sum -c SHA256SUMS
```
常用排查:
```bash
sudo ./OFFLINE_INSTALL.sh --status
sudo ./OFFLINE_INSTALL.sh --logs deerflow-backend
docker image inspect "$(head -n 1 images/deerflow-image-list.txt)"
```
查看两个容器的退出码和健康状态:
```bash
docker-compose --project-name cmzs-deerflow \
--env-file data/generated/compose.env \
-f compose/docker-compose.yml ps -a
```
- MySQL 预检失败:检查监听地址、账号来源网段、建库权限和防火墙。生成地址应以 `mysql+aiomysql://` 开头,可用 `grep '^MYSQL_DATABASE_URL=' data/generated/compose.env` 确认(不要把含密码的结果发给他人)。
- 日志出现 `No module named aiomysql`:确认 `source/wheelhouse/aiomysql-0.3.2-py3-none-any.whl` 和 `requirements.txt` 中的 aiomysql 兼容组三行都存在,然后执行 `--restart`。
- 日志出现 `ping() missing ... reconnect`:不要只升级 aiomysql;恢复 `aiomysql==0.3.2`、`PyMySQL==1.2.0`、`SQLAlchemy==2.0.51` 三行及对应 wheel,再执行 `--restart`。
- wheelhouse 报 `No matching distribution found`:缺少传递依赖 wheel,或 wheel 不是 Python 3.12/Linux amd64 版本;按文档 13 补齐。
- Docker 创建网络失败并提示 `iptables` 不存在:先修复宿主机 Docker/iptables 安装,不要修改包内文件绕过。
- 模型调用失败:确认模型地址能从容器访问;宿主机服务不要填 `127.0.0.1`。
- 再次启动仍执行 `docker load`:说明镜像标签缺失或镜像清单不匹配。
- 页面能打开但请求失败:确认浏览器能访问 `BACKEND_PORT`,并检查 `data/generated/frontend/runtime-config.js` 中由 `PUBLIC_HOST` 生成的地址。
- 端口已占用:用 `ss -lntp` 查占用,修改两个端口后执行 `--restart`。
- 容器反复重启:查看后端最后 300 行日志,重点检查 MySQL 表创建、配置 YAML 和模型配置。
- 技能/智能体没有出现:核对目录层级、文件权限和名称,再重启后端。
- Word、Excel 或 PPT 上传后没有生成 Markdown:先执行 `sudo ./OFFLINE_INSTALL.sh --office-check`;通过后再检查后端上传日志和 `uploads.auto_convert_documents` 是否为 `true`。
`--stop` 后镜像应继续存在;脚本不会执行 `docker rmi` 或删除数据卷。
## 安全和内容检查
`config/deploy.env` 和 `data/state/runtime-secrets.json` 权限应为 `600`。不要把解压后的真实配置目录上传到公共文件服务器,也不要把运行时生成文件重新制作成分发包。
```bash
grep -vE '^[[:space:]]*(#|$)' images/deerflow-image-list.txt
```
清单应严格只有 DeerFlow Backend 和 Frontend,不应看到 WeKnora、ParadeDB、Redis 或 DocReader 镜像。
@@ -0,0 +1,51 @@
# 07 升级与版本回退
日常只修改 `.py`、`.tsx`、`.ts`、样式和页面资源时,优先使用 `11-只替换前后端源码.md` 的源码覆盖层机制。它不传输 Docker 基础层,在部署服务器上基于已保存镜像离线生成很薄的新镜像。
涉及 Compose、部署控制文件或普通镜像增量包时,使用本包自带的通用增量机制,不再解压一套完整基线包:
```bash
tar -xzf deerflow-update-*.tar.gz
cd /当前正在使用的DeerFlow部署目录
sudo ./UPDATE_INSTALL.sh --apply /增量包解压目录
```
增量更新只加载发生变化的镜像并更新白名单控制文件,保留现有 `config/deploy.env`、JWT 密钥、智能体、custom 技能、知识文件、Docker 卷和旧镜像。详细制作规范、变更分类及校验清单见 `09-后续增量更新发版手册.md`。
应用时会把旧控制文件备份到 `data/updates/backups/`。需要回退时:
```bash
sudo ./UPDATE_INSTALL.sh --rollback \
/当前部署目录/data/updates/backups/<对应备份目录>
```
该操作恢复旧镜像引用和控制文件;不会反向恢复数据库。若新版本执行过不兼容数据库迁移,必须同时恢复升级前数据库备份。
## 完整基线包切换(仅用于跨大版本或基线损坏)
不同版本完整离线包解压到不同目录,不要覆盖旧目录。升级前备份旧包的 `config/deploy.env`、`data/state/runtime-secrets.json`、`agents-skills/`、外部 MySQL 和三个 Docker 卷。
推荐步骤:
```bash
cd /旧包目录
sudo ./OFFLINE_INSTALL.sh --stop
cd /新包目录
# 按旧配置逐项填写新版本 deploy.env;不要直接覆盖,以免漏掉新增字段
vi config/deploy.env
# 复制经人工确认的智能体、技能、知识文件
sudo ./OFFLINE_INSTALL.sh --start
```
同一个 Compose 项目名会复用现有数据卷。新版本首次启动可能自动创建或迁移数据库表,必须先完成数据库备份。不要在没有数据库备份的情况下来回切换应用版本。
回退时停止新包,在数据库兼容且已备份的前提下启动旧包。若新版本执行了不可逆数据库迁移,应先恢复升级前数据库和卷快照,再启动旧包。
```bash
cat VERSION.json
cat images/deerflow-image-list.txt
sha256sum -c SHA256SUMS
```
旧镜像不会被 `--stop` 删除,因此通常可以直接回退;只有运维人员明确清理镜像后才需要重新加载旧包镜像。
@@ -0,0 +1,43 @@
# 08 离线验收清单
## 安装前
- 外层 `.sha256` 校验为 `OK`。
- `uname -m` 为 `x86_64`。
- Docker Engine、`docker-compose` 正常。
- 外部 MySQL 和模型接口可从服务器/容器网络访问。
- `config/deploy.env` 中没有 `192.0.2.*` 或 `example` 示例值。
## 首次启动
```bash
sudo ./OFFLINE_INSTALL.sh --start
sudo ./OFFLINE_INSTALL.sh --status
curl -f http://127.0.0.1:<BACKEND_PORT>/health
curl -I http://127.0.0.1:<FRONTEND_PORT>/
sudo ./OFFLINE_INSTALL.sh --office-check
```
确认两个容器均为运行/健康状态,浏览器能打开页面,登录和一次最小模型问答成功。
## 停止与再次启动
```bash
sudo ./OFFLINE_INSTALL.sh --stop
docker image inspect "$(sed -n '1p' images/deerflow-image-list.txt)"
docker image inspect "$(sed -n '2p' images/deerflow-image-list.txt)"
sudo ./OFFLINE_INSTALL.sh --start
```
停止后两个镜像仍能 inspect;再次启动日志应显示跳过 `docker load`,容器重新健康,历史数据仍在。
## 功能与内容
- 智能体目录可见,命名智能体能加载。
- 自定义技能放入 `agents-skills/deerflow-skills/custom/` 后重启可见。
- 前后端端口与 `deploy.env` 一致。
- 未配置的业务接口保持空值,不意外访问旧 UAT/生产地址。
- 镜像清单只有两个 DeerFlow 镜像,不存在 WeKnora 相关服务。
- Office 自检输出 `OFFICE_RUNTIME_SELF_CHECK=PASS`,Word、Excel、PowerPoint 样例均能生成并重新解析。
验收完成后保留:离线包、同名 `.sha256`、实际 `deploy.env` 的受控备份、数据库/卷备份和验收记录。
@@ -0,0 +1,141 @@
# 09 后续增量更新发版手册
本文约定以当前完整离线包作为“基线包”。基线只安装一次;后续常规版本发布为 `deerflow-update-<基线>-to-<目标版本>.tar.gz`,不再重复制作并传输整套前后端基础包。
如果日常更新仅为前后端源码,优先使用 `11-只替换前后端源码.md` 的源码覆盖包。源码覆盖包不执行 `docker save`,因此不会重复携带基础镜像层;本文的通用镜像增量机制保留给依赖、Compose 或控制文件变化。
## 一、哪些内容需要重新打包
| 变更内容 | 增量包包含内容 | 不需要重复包含 |
|---|---|---|
| 仅文档、Compose、渲染脚本 | `payload/` 文件 | 任意 Docker 镜像 |
| 仅技能、命名智能体 | 经审查的增量文件和说明 | 前后端镜像;不得覆盖用户 custom 数据 |
| 仅前端源码 | 新前端镜像 | 后端镜像、数据库、数据卷 |
| 仅后端源码,依赖清单未变 | 新后端代码镜像 | 前端镜像、数据库、数据卷 |
| Python/系统依赖或基础镜像变化 | 新后端完整镜像 | 前端镜像及整套基线目录 |
| 前后端都变化 | 两个新镜像 | 配置、历史数据和旧镜像 |
判断后端依赖是否变化时,至少比较:
```text
offline-backend-20260512/backend/pyproject.toml
offline-backend-20260512/backend/uv.lock
offline-backend-20260512/backend/requirements-offline.txt
offline-backend-20260512/backend/packages/harness/pyproject.toml
deploy/linux-docker-offline/Dockerfile.backend
```
只要其中任一文件变化,就按“后端依赖变化”构建,禁止把新代码强行覆盖到旧依赖镜像。
## 二、增量包固定结构
```text
deerflow-update-<base>-to-<target>/
├── UPDATE_MANIFEST.env
├── SHA256SUMS
├── images/
│ └── update-images-linux-amd64.tar.gz # 没有镜像变化时可省略
└── payload/
├── VERSION.json
├── images/deerflow-image-list.txt
├── OFFLINE_INSTALL.sh # 有变化才放
├── UPDATE_INSTALL.sh # 有变化才放
├── compose/ # 有变化才放
├── scripts/ # 有变化才放
├── config/deerflow/ # 模板变化才放
├── config/frontend/ # 模板变化才放
└── docs/ # 发版说明和迁移说明
```
`UPDATE_MANIFEST.env` 示例:
```dotenv
FORMAT_VERSION=1
BASE_RELEASE=deerflow-linux-amd64-offline-20260826-xxxxxx
TARGET_RELEASE=deerflow-linux-amd64-offline-20260915-xxxxxx
TARGET_BACKEND_IMAGE=cmzs/deerflow-backend-offline:20260915-xxxxxx-abcdef12
TARGET_FRONTEND_IMAGE=cmzs/deerflow-frontend-offline:20260826-xxxxxx-b2c60bb2
```
即使某一端镜像没有变化,目标清单也必须写出它当前继续使用的完整标签。`payload/VERSION.json` 与 `payload/images/deerflow-image-list.txt` 必须使用同一组标签。
## 三、发版机制作步骤
1. 固定源码 commit,记录 `git rev-parse HEAD`,确认不误带未提交文件。
2. 对照上表判断变更组件;依赖变化时重建完整后端镜像,源码变化且依赖完全一致时可以使用代码刷新 Dockerfile。
3. 只导出发生变化的镜像。例如仅后端变化:
```bash
docker image inspect <新后端镜像>
docker save <新后端镜像> | gzip -1 > images/update-images-linux-amd64.tar.gz
```
4. 在 `payload/` 放入新的 `VERSION.json`、两行镜像清单和必要控制文件。严禁放入下列路径:
```text
config/deploy.env
data/
agents-skills/deerflow-agents/ 中的用户智能体
agents-skills/deerflow-skills/custom/
agents-skills/deerflow-knowledge/
```
5. 如果新增 `deploy.env` 配置项,只更新 `config/deploy.env.example` 或提供 `config/deploy.env.additions.example`,并在发版说明中写明;更新脚本永远不会自动覆盖真实 `deploy.env`。
6. 生成包内校验:
```bash
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
```
7. 打包并生成外层校验:
```bash
tar -czf deerflow-update-<base>-to-<target>.tar.gz deerflow-update-<base>-to-<target>/
sha256sum deerflow-update-<base>-to-<target>.tar.gz \
> deerflow-update-<base>-to-<target>.tar.gz.sha256
```
8. 在无外网环境至少验证:外层校验、包内校验、镜像 `docker load`、`linux/amd64` 架构、Office 自检、配置渲染以及旧镜像仍存在。
说明:Docker 标准 `save/load` 会携带目标镜像需要的层;依赖变化时后端增量包仍可能较大,但它不再重复包含未变化的前端镜像、配置目录和整套基线包。纯控制文件或技能更新可以非常小。
## 四、部署机应用步骤
```bash
sha256sum -c deerflow-update-*.tar.gz.sha256
tar -xzf deerflow-update-*.tar.gz
cd /当前DeerFlow基线部署目录
sudo ./UPDATE_INSTALL.sh --apply /增量包解压目录
```
脚本顺序为:校验基线版本和 SHA256 → 备份控制文件 → `docker-compose stop` → 加载变更镜像 → 应用白名单文件 → 使用原 `deploy.env` 重新渲染并启动。它不执行 `down`、`rmi` 或 `volume rm`。
如果希望先应用、人工检查后再启动:
```bash
sudo ./UPDATE_INSTALL.sh --apply /增量包解压目录 --no-start
sudo ./OFFLINE_INSTALL.sh --office-check
sudo ./OFFLINE_INSTALL.sh --start
```
## 五、回退与版本链
每个增量包只允许从清单指定的 `BASE_RELEASE` 应用,不能跳版本。连续升级必须按顺序执行:`A → B → C`。如果要从 A 直接到 C,应另外制作 `A → C` 更新包或交付新的完整基线。
控制文件回退命令:
```bash
sudo ./UPDATE_INSTALL.sh --rollback \
/当前部署目录/data/updates/backups/<A_before_B_时间>
```
`--stop` 和增量更新都保留旧镜像,因此正常回退无需再次 `docker load`。数据库回退不由脚本自动完成:每次涉及表结构或数据迁移的发版,都必须提供迁移说明、升级前备份和明确的回退兼容结论。
## 六、每次发版必须记录
- 基线版本、目标版本和源码 commit。
- 新旧前后端镜像标签、镜像 ID、架构和 SHA256。
- 是否修改依赖、`deploy.env` 字段、数据库结构、Compose 或数据目录。
- 本次包含的技能/智能体文件及覆盖策略。
- 升级命令、验证结果、回退命令和数据库回退要求。
@@ -0,0 +1,53 @@
# 10 Office 文档处理与自检
本包的默认沙箱是 DeerFlow 后端容器内的 `LocalSandboxProvider`。Office 依赖必须安装在后端镜像的 `/opt/venv` 和操作系统层;只安装在宿主机上没有作用。
## 已内置能力
- Word:DOC、DOCX、ODT。
- Excel:XLS、XLSX、ODS。
- PowerPoint:PPT、PPTX、ODP。
- PDF:文本抽取和 Markdown 转换。
- Python:MarkItDown、python-docx、openpyxl、pandas、python-pptx、xlrd、lxml、mammoth。
- 系统工具:LibreOffice Headless、Pandoc、Poppler、`file`、中文 Noto 字体。
现代 DOCX/XLSX/PPTX 直接由 Python 组件读取;旧 DOC/XLS/PPT 和 OpenDocument 文件先用独立 LibreOffice 临时配置转换为现代格式,再提取为 Markdown。独立临时配置可避免多个上传任务同时转换时争用 LibreOffice 用户目录锁。
`config/deerflow/config.base.yaml` 默认设置:
```yaml
uploads:
auto_convert_documents: true
pdf_converter: auto
```
运行配置由脚本生成,正常部署无需再手工修改 YAML。
## 不依赖数据库的自检
```bash
sudo ./OFFLINE_INSTALL.sh --office-check
```
脚本会在后端镜像中生成最小 DOCX、XLSX、PPTX,再分别转换并检查标记文本,同时检查 LibreOffice、Pandoc、Poppler、`file` 和中文字体。通过时输出:
```text
OFFICE_RUNTIME_SELF_CHECK=PASS
```
该命令只检查镜像和沙箱依赖,不读取 MySQL,也不启动前后端服务。
## 上传后的处理路径
1. 原文件保存到当前用户、当前会话的 `uploads/`。
2. 支持的文档自动转换为同名 `.md`。
3. Agent 同时收到原文件和 Markdown 虚拟路径。
4. Agent 优先读取 `.md` 做总结、问答和内容抽取;需要生成/修改文件时,可在本地沙箱使用上述 Python 库和系统工具,产物应写入 `/mnt/user-data/outputs/`。
如果自检通过但某个文件失败,应查看:
```bash
sudo ./OFFLINE_INSTALL.sh --logs deerflow-backend
```
损坏、加密、带复杂宏或受密码保护的 Office 文件可能仍无法自动解析。此时原文件会保留,但不会保证生成有效 Markdown;应先解除密码或另存为标准 DOCX/XLSX/PPTX 后重新上传。
@@ -0,0 +1,172 @@
# 11 只替换前后端源码
本流程专门用于日常持续修改源码。完整离线包及其两个镜像作为固定“依赖基线”长期保存,后续更新包不再包含 Docker 镜像基础层,只包含:
- 脱敏后的完整后端源码;
- 脱敏后的完整前端源码;
- 发版机编译好的前端 `dist` 静态文件;
- 两个很小的离线覆盖层 Dockerfile;
- 目标版本、镜像标签、SHA-256 和一键应用脚本。
部署机收到源码更新包后,以本机已经加载的旧镜像为 `FROM`,离线生成新的代码层镜像。Python、Office、LibreOffice、Pandoc、字体、浏览器、Nginx 等依赖都沿用固定基线,不重新安装,也不执行 `docker pull`。
## 一、固定基线
当前固定依赖基线为:
```text
deerflow-linux-amd64-offline-20260827-010119.tar.gz
源码提交:8d3eeaac45a79daf31f6ee6e0bf18ba6bc7c4e0b
```
正式包、`.sha256` 和 `SOURCE_BASELINE.json` 必须长期保留。`latest` 链接可以以后指向其他版本,但 `base` 链接和带时间戳的文件不得覆盖或删除。
`SOURCE_BASELINE.json` 锁定以下后端运行依赖文件:
```text
offline-backend-20260512/backend/pyproject.toml
offline-backend-20260512/backend/uv.lock
offline-backend-20260512/backend/requirements-offline.txt
offline-backend-20260512/backend/packages/harness/pyproject.toml
offline-backend-20260512/backend/Dockerfile.backend-offline
deploy/linux-docker-offline/Dockerfile.backend
```
任意锁定文件发生变化,源码增量构建会立即终止。此时必须制作新的完整依赖基线,不能强行覆盖,否则很容易出现源码已经引用新包、镜像里却没有该依赖的问题。
## 二、制作源码更新包
在能够完成前端构建的 Linux amd64 发版机上执行:
```bash
cd /源码根目录
# 前后端都更新
sudo ./deploy/linux-docker-offline-deerflow-only/build-source-update.sh \
/源码根目录 \
/当前正在运行的DeerFlow部署目录 \
/opt/deerflow-source-build \
all
```
只更新后端:
```bash
sudo ./deploy/linux-docker-offline-deerflow-only/build-source-update.sh \
/源码根目录 /当前部署目录 /opt/deerflow-source-build backend
```
只更新前端:
```bash
sudo ./deploy/linux-docker-offline-deerflow-only/build-source-update.sh \
/源码根目录 /当前部署目录 /opt/deerflow-source-build frontend
```
构建结果位于:
```text
/opt/deerflow-source-build/dist/deerflow-source-update-<时间>-<commit前8位>.tar.gz
```
制作脚本会完成以下检查:
1. 当前部署版本和当前镜像标签可解析;
2. 当前镜像确实存在且为 `linux/amd64`;
3. `SOURCE_BASELINE.json` 中的所有依赖文件哈希完全一致;
4. 后端四个 Python 依赖清单与正在使用的后端镜像逐字节一致;
5. `config.yaml`、`extensions_config.json` 和 `runtime-config.js` 已脱敏;
6. 前端 `pnpm build` 成功并生成 `dist/index.html`;
7. 包内和包外 SHA-256、gzip 完整性正确。
前端浏览器不能直接运行 TSX/TypeScript,因此前端源码仍会保存在更新包中供审计,但部署时实际覆盖的是同一次构建产生的 `dist`。前端依赖安装只发生在发版机的构建缓存层,不会进入部署机,也不会在部署时访问网络。
## 三、部署机一键应用
把源码更新包放到部署服务器,先校验并解压:
```bash
sha256sum -c deerflow-source-update-*.tar.gz.sha256
tar -xzf deerflow-source-update-*.tar.gz
cd deerflow-source-update-*
```
只检查版本链、校验值和基线镜像,不切换服务:
```bash
sudo ./APPLY_SOURCE_UPDATE.sh --check /当前正在运行的DeerFlow部署目录
```
一键应用并启动:
```bash
sudo ./APPLY_SOURCE_UPDATE.sh --apply /当前正在运行的DeerFlow部署目录
```
希望应用后暂不启动:
```bash
sudo ./APPLY_SOURCE_UPDATE.sh --apply /当前正在运行的DeerFlow部署目录 --no-start
cd /当前正在运行的DeerFlow部署目录
sudo ./OFFLINE_INSTALL.sh --start
```
应用顺序为:校验全部文件 → 校验当前版本和镜像 ID → 离线构建代码层镜像 → 后端导入与 Office 自检 → 备份控制文件 → `docker-compose stop` → 切换镜像标签 → 使用原 `deploy.env` 渲染 → 启动和健康检查。
旧服务会一直运行到新覆盖层镜像构建和自检完成,停机时间仅发生在最后切换阶段。脚本不执行 `down`、`rmi`、`volume rm`,不会删除旧镜像和已加载镜像。
## 四、保留与不覆盖的内容
源码更新不会覆盖:
- `config/deploy.env`;
- `data/state/runtime-secrets.json`;
- 外部 MySQL 数据;
- Docker 命名卷;
- 用户智能体目录;
- `skills/custom/`;
- 本地知识文件;
- 已加载的旧镜像。
后端 `config.yaml`、`extensions_config.json` 和前端 `runtime-config.js` 只更新脱敏模板,启动时仍由唯一配置源 `config/deploy.env` 重新生成实际运行配置。
技能、智能体或知识文件若需要单独发版,继续按 `04-技能与智能体位置.md` 指定目录投放,不要混入普通源码覆盖流程。
## 五、连续更新和回退
每次更新都以“服务器当前正在运行的部署目录”为基线制作,版本按顺序推进:
```text
完整依赖基线 A → 源码版本 B → 源码版本 C → 源码版本 D
```
每个源码包都带完整后端源码和完整前端静态文件,不是二进制差分;但必须按照清单中的 `BASE_RELEASE` 应用,防止误把旧包覆盖到错误版本。
应用前的控制文件备份位于:
```text
/当前部署目录/data/updates/backups/
```
源码更新备份与原 `UPDATE_INSTALL.sh` 兼容,回退命令:
```bash
cd /当前部署目录
sudo ./UPDATE_INSTALL.sh --rollback \
/当前部署目录/data/updates/backups/<对应备份目录>
```
旧镜像始终保留,因此回退不需要重新 `docker load`。如果某次源码修改同时包含数据库结构或不可逆数据迁移,必须先备份数据库,并另行提供数据库回退步骤;源码脚本不会自动回滚数据库。
## 六、什么时候不能用源码替换
出现以下任一情况必须重新制作完整依赖基线:
- Python 依赖、`uv.lock` 或 harness 依赖变化;
- 新增或修改 apt/system package;
- 修改 Office、LibreOffice、Pandoc、字体或浏览器运行依赖;
- 更换 Python 基础镜像、CPU 架构;
- 修改 Docker 数据卷布局并需要迁移数据;
- 当前固定基线镜像或 `SOURCE_BASELINE.json` 丢失。
普通 `.py`、`.tsx`、`.ts`、`.css`、前端 npm 业务依赖、页面资源和后端业务逻辑更新可以继续使用源码覆盖包。前端 npm 依赖会在发版机重新编译进静态文件,不要求部署机安装 Node.js。
@@ -0,0 +1,66 @@
# 直接覆盖源码并重启(最简单方式)
当前部署已经把前后台代码作为宿主机目录挂载到容器。日常修改源码时不需要重新打镜像,也不需要重新加载离线镜像。
## 需要替换的目录
在 DeerFlow 部署目录中只替换以下内容:
```text
source/
├── backend/
│ ├── app/ # 替换为 offline-backend-20260512/backend/app
│ ├── packages/ # 替换为 offline-backend-20260512/backend/packages
│ └── tests/ # 替换为 offline-backend-20260512/backend/tests
├── frontend/
│ └── dist/ # 替换为 frontend-web 执行 pnpm build 后的 dist
└── wheelhouse/ # 离线 Python wheel 与 requirements.txt;日常源码替换不要删除
```
注意:后端目录名是 `tests`,不是 `test`。
## 前端怎么生成 dist
在开发或构建机器执行:
```bash
cd frontend-web
pnpm install
pnpm build
```
只把生成的 `frontend-web/dist/` 内容上传到部署目录的 `source/frontend/dist/`。服务器不需要 Node.js,也不会在启动时安装 npm 依赖。
## 替换完成后重启
进入服务器当前 DeerFlow 部署目录:
```bash
sudo ./OFFLINE_INSTALL.sh --restart
```
该脚本只执行以下操作:
1. 检查四个代码目录和前端 `index.html`;
2. 从 `deploy.env` 重新生成数据库、模型和前端运行配置;
3. 检查 `source/wheelhouse`,仅在内容变化时离线安装依赖;
4. 使用一次性容器检查后端 Python 导入;
5. 依次重建并重启后端、前端容器;
6. 等待两个服务健康。
它不会执行数据库预检,不会 `docker load`,不会构建、拉取或删除镜像,也不会修改 `config/deploy.env`、数据库业务数据、技能、智能体或知识文件。容器会重建以应用新增的挂载和环境变量,但数据库卷、用户数据卷、浏览器上下文卷和 Python 依赖卷均保留。
## 哪些修改不能这样更新
如果修改了以下内容,不能只覆盖源码:
- 后端 `pyproject.toml`、`uv.lock`、`requirements-offline.txt` 中涉及无法通过纯 Python wheel 提供的依赖;
- 系统软件、Python/Office/浏览器依赖;
- Nginx 镜像运行环境;
- Dockerfile 或容器启动命令。
这些修改涉及系统运行环境,需要重新制作基础镜像包。新增普通纯 Python/C-extension wheel 时,可按 `docs/13-wheelhouse离线新增依赖与多模型.md` 操作,无需重做镜像;单纯修改后端 `app/packages/tests` 和前端页面代码可以直接覆盖。
## 建议的安全替换方式
上传时先放到临时目录,确认完整后再改名覆盖,避免服务运行期间读到上传一半的文件。替换之前可复制一份旧目录作为备份;若新代码有问题,恢复旧目录后再次运行 `OFFLINE_INSTALL.sh --restart`。
@@ -0,0 +1,98 @@
# wheelhouse 离线新增依赖与多模型配置
## 一、数据库固定使用 aiomysql
部署脚本生成的连接地址为:
```text
mysql+aiomysql://用户名:编码后的密码@主机:端口/数据库?charset=utf8mb4
```
镜像和 `source/wheelhouse` 都预置兼容组:
```text
aiomysql==0.3.2
PyMySQL==1.2.0
SQLAlchemy==2.0.51
```
这三个版本必须作为整体保留,避免出现 SQLAlchemy 的 `ping(reconnect)` 适配错误。人工只填写 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_DATABASE`、`MYSQL_USER`、`MYSQL_PASSWORD`;不要直接编辑生成的数据库 URL。
## 二、以后怎样离线增加 Python 依赖
部署目录:
```text
source/wheelhouse/
├── requirements.txt
├── README.txt
├── *.whl
└── SHA256SUMS
```
后端镜像是 Python 3.12、Linux amd64。新增的 wheel 必须匹配该平台;常见可用标签是 `py3-none-any`、`cp312-manylinux2014_x86_64` 或兼容的 manylinux x86_64。Windows wheel、macOS wheel、ARM64 wheel、源码包 `.tar.gz` 均不能使用。
在联网的 Linux amd64/Python 3.12 机器下载示例:
```bash
mkdir -p new-wheels
python3.12 -m pip download \
--only-binary=:all: \
--platform manylinux2014_x86_64 \
--implementation cp \
--python-version 312 \
--abi cp312 \
--dest new-wheels \
'your-package==1.2.3'
```
将 `new-wheels/` 中目标包及其全部依赖的 `.whl` 上传到 `source/wheelhouse/`,然后在 `source/wheelhouse/requirements.txt` 增加:
```text
your-package==1.2.3
```
执行:
```bash
./OFFLINE_INSTALL.sh --restart
```
脚本只使用 `--no-index --find-links /app/wheelhouse`,不会访问互联网。它会计算 `requirements.txt` 和所有 wheel 的指纹;内容未变化时跳过安装,内容变化时安装到持久卷 `deerflow-python-vendor`。重建、停止或重启容器不会丢失已安装依赖,也不会修改基础镜像。
如果安装时报 `No matching distribution found`,通常是缺少传递依赖 wheel、wheel 平台不匹配,或 `requirements.txt` 中版本与文件名不一致。补齐后再次执行 `--restart` 即可;安装失败时后端不会被切换到半完成的新进程。
## 三、配置多个模型
`config/deploy.env` 是唯一配置源。示例文件已经给出三套模型块,使用:
```dotenv
DEERFLOW_MODEL_COUNT=2
DEERFLOW_MODEL_1_NAME=general-model
DEERFLOW_MODEL_1_DISPLAY_NAME=通用模型
DEERFLOW_MODEL_1_MODEL=provider-real-model-id
DEERFLOW_MODEL_1_BASE_URL=http://10.10.10.25:8000/v1
DEERFLOW_MODEL_1_API_KEY=replace-with-real-key-1
DEERFLOW_MODEL_1_SUPPORTS_VISION=false
DEERFLOW_MODEL_1_SUPPORTS_THINKING=false
DEERFLOW_MODEL_2_NAME=vision-model
DEERFLOW_MODEL_2_DISPLAY_NAME=视觉模型
DEERFLOW_MODEL_2_MODEL=provider-vision-model-id
DEERFLOW_MODEL_2_BASE_URL=http://10.10.10.26:8000/v1
DEERFLOW_MODEL_2_API_KEY=replace-with-real-key-2
DEERFLOW_MODEL_2_SUPPORTS_VISION=true
DEERFLOW_MODEL_2_SUPPORTS_THINKING=false
```
规则:
1. 支持 1~20 个模型,编号必须从 1 连续排列;
2. 第 1 个模型是默认模型;
3. `NAME` 必须唯一;`MODEL` 是上游接口实际接收的 model id;
4. 每个模型可使用不同地址和 API Key;
5. `DISPLAY_NAME` 只影响页面显示;
6. 修改完成后执行 `./OFFLINE_INSTALL.sh --restart` 即可重新生成 `config.yaml` 并生效,无需手工改 YAML。
完整的超时、重试、最大 token、温度、视觉、thinking 和 Responses API 字段见 `docs/02-deploy.env配置说明.md`。
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$BUNDLE_DIR/compose/docker-compose.yml"
GENERATED_ENV="$BUNDLE_DIR/data/generated/compose.env"
DEPLOY_ENV="$BUNDLE_DIR/config/deploy.env"
STATE_DIR="$BUNDLE_DIR/data/state"
IMAGE_LIST="$BUNDLE_DIR/images/deerflow-image-list.txt"
IMAGE_ARCHIVE="$BUNDLE_DIR/images/deerflow-images-linux-amd64.tar.gz"
PROJECT_NAME="cmzs-deerflow"
COMPOSE_CMD=()
say() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"; }
die() { printf '错误: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
用法:
./OFFLINE_INSTALL.sh # 等同 --start
./OFFLINE_INSTALL.sh --start # 按 deploy.env 渲染并启动
./OFFLINE_INSTALL.sh --stop # 只停止,不删除容器、镜像、网络或数据卷
./OFFLINE_INSTALL.sh --restart # 直接加载 source/ 下最新前后台代码并重启
./OFFLINE_INSTALL.sh --status # 查看服务状态与访问地址
./OFFLINE_INSTALL.sh --logs [服务名]
./OFFLINE_INSTALL.sh --office-check # 不连接数据库,检查 Word/Excel/PPT 沙箱能力
EOF
}
detect_compose() {
# 目标离线服务器使用独立的 docker-compose 命令;同时保留 Compose v2
# 插件回退,便于同一部署包在两类 Docker 主机上使用。
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
elif docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
else
die "未安装 docker-compose,也没有 docker compose 插件"
fi
"${COMPOSE_CMD[@]}" version >/dev/null 2>&1 || die "Docker Compose 不可用"
}
require_tools() {
command -v docker >/dev/null 2>&1 || die "未安装 Docker"
docker info >/dev/null 2>&1 || die "Docker 服务不可用,或当前用户无权访问 Docker"
detect_compose
command -v gzip >/dev/null 2>&1 || die "缺少 gzip"
command -v sha256sum >/dev/null 2>&1 || die "缺少 sha256sum"
command -v curl >/dev/null 2>&1 || die "缺少 curl"
}
compose() {
[[ -f "$GENERATED_ENV" ]] || die "尚未生成运行配置,请先执行 --start"
"${COMPOSE_CMD[@]}" --project-name "$PROJECT_NAME" --env-file "$GENERATED_ENV" -f "$COMPOSE_FILE" "$@"
}
images_missing() {
local image
while IFS= read -r image; do
[[ -z "$image" || "$image" == \#* ]] && continue
docker image inspect "$image" >/dev/null 2>&1 || return 0
done < "$IMAGE_LIST"
return 1
}
load_images_if_missing() {
if ! images_missing; then
say "所需 DeerFlow 镜像已存在,跳过校验与 docker load"
return 0
fi
say "校验 DeerFlow 离线镜像文件"
(cd "$BUNDLE_DIR" && sha256sum -c SHA256SUMS)
say "加载 DeerFlow 前端和后端镜像"
gzip -dc "$IMAGE_ARCHIVE" | docker load
}
backend_image() {
sed -n '/^[^#[:space:]]/ {p;q;}' "$IMAGE_LIST"
}
render_runtime() {
mkdir -p "$STATE_DIR" "$BUNDLE_DIR/data/generated"
chmod 0600 "$DEPLOY_ENV"
say "校验 deploy.env 并生成运行配置"
docker run --rm --network none \
-v "$BUNDLE_DIR:/bundle" \
"$(backend_image)" python /bundle/scripts/render_runtime.py /bundle
}
install_wheelhouse() {
[[ -d "$BUNDLE_DIR/source/wheelhouse" ]] || die "缺少 source/wheelhouse"
[[ -f "$BUNDLE_DIR/source/wheelhouse/requirements.txt" ]] || \
die "缺少 source/wheelhouse/requirements.txt"
say "检查 source/wheelhouse 并离线安装新增 Python 依赖"
compose run --rm --no-deps deerflow-backend \
/opt/venv/bin/python /app/offline-deploy/install_wheelhouse.py \
/app/wheelhouse /app/backend/.deer-flow/python-vendor
}
preflight_mysql() {
say "检查 DeerFlow 外部 MySQL"
docker run --rm --network host \
--add-host host.docker.internal:host-gateway \
-v "$BUNDLE_DIR:/bundle:ro" \
"$(backend_image)" python /bundle/scripts/preflight_mysql.py /bundle/config/deploy.env
}
generated_value() {
local key="$1"
sed -n "s/^${key}=//p" "$GENERATED_ENV" | tail -n 1
}
wait_http() {
local name="$1" url="$2" attempts="${3:-90}" code i
for ((i=1; i<=attempts; i++)); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
if [[ "$code" =~ ^(200|204|301|302)$ ]]; then
printf '%s 就绪(HTTP %s)\n' "$name" "$code"
return 0
fi
sleep 3
done
compose ps || true
compose logs --tail=160 || true
die "$name 启动超时: $url"
}
start_all() {
[[ -f "$DEPLOY_ENV" ]] || die "缺少 $DEPLOY_ENV"
load_images_if_missing
render_runtime
preflight_mysql
install_wheelhouse
say "启动 DeerFlow(禁止在线拉取和现场构建)"
# 镜像已由 load_images_if_missing 校验并加载。这里不用 Compose v2 专属的
# `--pull never`,以兼容服务器上的 docker-compose v1。
compose up -d --no-build
wait_http "DeerFlow 后端" "http://127.0.0.1:$(generated_value BACKEND_PORT)/health" 120
wait_http "DeerFlow 页面" "http://127.0.0.1:$(generated_value FRONTEND_PORT)/" 60
say "部署完成"
printf 'DeerFlow: http://%s:%s\n' "$(generated_value PUBLIC_HOST)" "$(generated_value FRONTEND_PORT)"
compose ps
}
stop_all() {
if [[ ! -f "$GENERATED_ENV" ]]; then
say "没有已生成的运行配置,无需停止"
return 0
fi
say "只停止容器;保留容器、镜像、网络和全部数据卷"
compose stop
}
restart_all() {
[[ -f "$DEPLOY_ENV" ]] || die "缺少 $DEPLOY_ENV"
[[ -s "$GENERATED_ENV" ]] || die "缺少运行配置,请先执行 --start"
[[ -d "$BUNDLE_DIR/source/backend/app" ]] || die "缺少 source/backend/app"
[[ -d "$BUNDLE_DIR/source/backend/packages" ]] || die "缺少 source/backend/packages"
[[ -d "$BUNDLE_DIR/source/backend/tests" ]] || die "缺少 source/backend/tests"
[[ -s "$BUNDLE_DIR/source/frontend/dist/index.html" ]] || \
die "缺少 source/frontend/dist/index.html;请先完成前端 pnpm build"
# --restart 不做 MySQL 预检,也不 load/build/pull 镜像;但会重新渲染
# deploy.env,让新增模型和配置修改可随同源码在一次重启中生效。
render_runtime
install_wheelhouse
say "检查并加载 source/ 下最新前后台代码"
compose run --rm --no-deps deerflow-backend /opt/venv/bin/python -c \
'import app.gateway.app, deerflow; print("后端源码检查通过")'
say "重建并重启后端容器(保留镜像、数据库和数据卷)"
compose up -d --no-build --no-deps --force-recreate deerflow-backend
wait_http "DeerFlow 后端" \
"http://127.0.0.1:$(generated_value BACKEND_PORT)/health" 90
say "重启前端"
compose up -d --no-build --no-deps --force-recreate deerflow-frontend
wait_http "DeerFlow 页面" \
"http://127.0.0.1:$(generated_value FRONTEND_PORT)/" 45
say "最新源码已生效"
compose ps
}
status_all() {
if [[ ! -f "$GENERATED_ENV" ]]; then
say "尚未执行过 --start"
return 0
fi
compose ps
printf '\nDeerFlow: http://%s:%s\n' "$(generated_value PUBLIC_HOST)" "$(generated_value FRONTEND_PORT)"
}
office_check() {
load_images_if_missing
say "检查本地沙箱的 Word / Excel / PowerPoint / PDF 处理能力"
docker run --rm --network none "$(backend_image)" \
/opt/venv/bin/python /app/backend/scripts/office_runtime_selfcheck.py
}
main() {
local mode="${1:---start}"
require_tools
case "$mode" in
--start) start_all ;;
--stop) stop_all ;;
--restart) restart_all ;;
--status) status_all ;;
--office-check) office_check ;;
--logs)
shift
if [[ $# -gt 0 ]]; then compose logs --tail=200 -f "$1"; else compose logs --tail=200 -f; fi
;;
--help|-h) usage ;;
*) usage; die "未知参数: $mode" ;;
esac
}
main "$@"
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKUP_ROOT="$BUNDLE_DIR/data/updates/backups"
HISTORY_FILE="$BUNDLE_DIR/data/updates/history.log"
say() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"; }
die() { printf '错误: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
用法:
./UPDATE_INSTALL.sh --apply /path/to/deerflow-update-目录
./UPDATE_INSTALL.sh --apply /path/to/deerflow-update-目录 --no-start
./UPDATE_INSTALL.sh --rollback /本部署目录/data/updates/backups/备份目录名
更新包必须先解压。脚本不会覆盖 config/deploy.env、data/state、业务数据卷、
自定义智能体、custom 技能或本地知识文件。
EOF
}
current_release() {
sed -n 's/^[[:space:]]*"release":[[:space:]]*"\([^"]*\)".*/\1/p' \
"$BUNDLE_DIR/VERSION.json" | head -n 1
}
read_manifest() {
local manifest="$1" key value
FORMAT_VERSION=""
BASE_RELEASE=""
TARGET_RELEASE=""
TARGET_BACKEND_IMAGE=""
TARGET_FRONTEND_IMAGE=""
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue
case "$key" in
FORMAT_VERSION|BASE_RELEASE|TARGET_RELEASE|TARGET_BACKEND_IMAGE|TARGET_FRONTEND_IMAGE) ;;
*) die "UPDATE_MANIFEST.env 含未知字段: $key" ;;
esac
[[ "$value" =~ ^[A-Za-z0-9._:/,-]+$ ]] || die "更新清单字段 $key 含非法字符"
printf -v "$key" '%s' "$value"
done < "$manifest"
[[ "$FORMAT_VERSION" == "1" ]] || die "不支持的增量包格式: $FORMAT_VERSION"
[[ -n "$BASE_RELEASE" && -n "$TARGET_RELEASE" ]] || die "更新清单缺少版本字段"
[[ -n "$TARGET_BACKEND_IMAGE" && -n "$TARGET_FRONTEND_IMAGE" ]] || die "更新清单缺少镜像字段"
}
validate_payload() {
local payload="$1" path relative
[[ -d "$payload" ]] || die "更新包缺少 payload 目录"
if find "$payload" -type l -print -quit | grep -q .; then
die "payload 不允许包含符号链接"
fi
while IFS= read -r -d '' path; do
relative="${path#"$payload"/}"
case "$relative" in
OFFLINE_INSTALL.sh|UPDATE_INSTALL.sh|README_OFFLINE.md|VERSION.json|\
compose/*|scripts/*|docs/*|config/deerflow/*|config/frontend/*|\
config/deploy.env.example|config/deploy.env.additions.example|\
images/deerflow-image-list.txt) ;;
*) die "更新包试图写入受保护路径: $relative" ;;
esac
done < <(find "$payload" -type f -print0)
}
backup_controls() {
local current="$1" target="$2" backup
backup="$BACKUP_ROOT/${current}_before_${target}_$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup"
tar -C "$BUNDLE_DIR" -czf "$backup/control-files.tar.gz" \
VERSION.json README_OFFLINE.md OFFLINE_INSTALL.sh UPDATE_INSTALL.sh \
compose scripts docs config/deploy.env.example config/deerflow config/frontend \
images/deerflow-image-list.txt
cp -a "$BUNDLE_DIR/config/deploy.env" "$backup/deploy.env.snapshot"
printf '%s\n' "$backup"
}
apply_update() {
local update_dir="$1" start_after="$2" current backup image_archive
update_dir="$(realpath "$update_dir")"
[[ -d "$update_dir" ]] || die "更新目录不存在: $update_dir"
[[ -f "$update_dir/UPDATE_MANIFEST.env" ]] || die "缺少 UPDATE_MANIFEST.env"
[[ -f "$update_dir/SHA256SUMS" ]] || die "缺少 SHA256SUMS"
read_manifest "$update_dir/UPDATE_MANIFEST.env"
current="$(current_release)"
[[ "$current" == "$BASE_RELEASE" ]] || die "当前版本 $current 与更新包基线 $BASE_RELEASE 不一致"
say "校验增量更新包"
(cd "$update_dir" && sha256sum -c SHA256SUMS)
validate_payload "$update_dir/payload"
backup="$(backup_controls "$current" "$TARGET_RELEASE")"
say "控制文件备份:$backup"
"$BUNDLE_DIR/OFFLINE_INSTALL.sh" --stop
image_archive="$update_dir/images/update-images-linux-amd64.tar.gz"
if [[ -f "$image_archive" ]]; then
say "加载本次变更镜像;保留原镜像用于回退"
gzip -dc "$image_archive" | docker load
fi
docker image inspect "$TARGET_BACKEND_IMAGE" "$TARGET_FRONTEND_IMAGE" >/dev/null \
|| die "目标镜像不完整"
say "应用白名单内的控制文件;保留 deploy.env 和业务数据"
cp -a "$update_dir/payload/." "$BUNDLE_DIR/"
chmod 0755 "$BUNDLE_DIR/OFFLINE_INSTALL.sh" "$BUNDLE_DIR/UPDATE_INSTALL.sh"
find "$BUNDLE_DIR/scripts" -maxdepth 1 -type f -name '*.py' -exec chmod 0755 {} +
grep -Fq "$TARGET_BACKEND_IMAGE" "$BUNDLE_DIR/VERSION.json" || die "VERSION.json 后端镜像不匹配"
grep -Fq "$TARGET_FRONTEND_IMAGE" "$BUNDLE_DIR/VERSION.json" || die "VERSION.json 前端镜像不匹配"
printf '%s APPLY %s -> %s backup=%s\n' "$(date --iso-8601=seconds)" "$current" "$TARGET_RELEASE" "$backup" \
>> "$HISTORY_FILE"
if [[ "$start_after" == "true" ]]; then
"$BUNDLE_DIR/OFFLINE_INSTALL.sh" --start
else
say "增量更新已应用但未启动;确认后执行 ./OFFLINE_INSTALL.sh --start"
fi
}
rollback_update() {
local backup="$1" resolved current previous
resolved="$(realpath "$backup")"
case "$resolved" in
"$BACKUP_ROOT"/*) ;;
*) die "只能回退本部署目录 data/updates/backups 下的备份" ;;
esac
[[ -f "$resolved/control-files.tar.gz" ]] || die "备份不完整: $resolved"
current="$(current_release)"
"$BUNDLE_DIR/OFFLINE_INSTALL.sh" --stop
tar -xzf "$resolved/control-files.tar.gz" -C "$BUNDLE_DIR"
previous="$(current_release)"
printf '%s ROLLBACK %s -> %s source=%s\n' "$(date --iso-8601=seconds)" "$current" "$previous" "$resolved" \
>> "$HISTORY_FILE"
say "控制文件和旧镜像引用已恢复;正在启动旧版本"
"$BUNDLE_DIR/OFFLINE_INSTALL.sh" --start
}
main() {
mkdir -p "$BACKUP_ROOT"
case "${1:-}" in
--apply)
[[ $# -ge 2 && $# -le 3 ]] || { usage; exit 2; }
[[ "${3:-}" == "" || "${3:-}" == "--no-start" ]] || die "未知参数: ${3:-}"
apply_update "$2" "$([[ "${3:-}" == "--no-start" ]] && echo false || echo true)"
;;
--rollback)
[[ $# -eq 2 ]] || { usage; exit 2; }
rollback_update "$2"
;;
--help|-h|"") usage ;;
*) usage; die "未知参数: $1" ;;
esac
}
main "$@"
@@ -0,0 +1,70 @@
version: "2.4"
services:
deerflow-backend:
image: ${DEERFLOW_BACKEND_IMAGE}
restart: unless-stopped
env_file:
- ../data/generated/compose.env
environment:
BACKEND_PORT: "8001"
DEER_FLOW_PROJECT_ROOT: /app/backend
DEER_FLOW_HOME: /app/backend/.deer-flow
DEER_FLOW_CONFIG_PATH: /app/backend/config.yaml
DEER_FLOW_EXTENSIONS_CONFIG_PATH: /app/backend/extensions_config.json
DEER_FLOW_SKILLS_PATH: /app/backend/skills
# source/wheelhouse 安装到持久卷;放在最前面可覆盖镜像内的旧依赖。
PYTHONPATH: /app/backend/.deer-flow/python-vendor:/app/backend:/app/backend/packages/harness
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:8001"
volumes:
# 直接替换宿主机 source/backend 下的源码后,重启容器即可生效。
- ../source/backend/app:/app/backend/app:ro
- ../source/backend/packages:/app/backend/packages:ro
- ../source/backend/tests:/app/backend/tests:ro
- ../source/wheelhouse:/app/wheelhouse:ro
- ../scripts/install_wheelhouse.py:/app/offline-deploy/install_wheelhouse.py:ro
- ../data/generated/deerflow/config.yaml:/app/backend/config.yaml:ro
- ../config/deerflow/extensions_config.json:/app/backend/extensions_config.json:ro
- ../agents-skills/deerflow-agents:/app/backend/.deer-flow/agents
- ../agents-skills/deerflow-skills:/app/backend/skills
- ../agents-skills/deerflow-knowledge:/app/backend/knowledge
- deerflow-data:/app/backend/.deer-flow/data
- deerflow-browser-context:/app/backend/.deer-flow/browser-context
- deerflow-users:/app/backend/.deer-flow/users
- deerflow-python-vendor:/app/backend/.deer-flow/python-vendor
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts:
- host.docker.internal:host-gateway
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health"]
interval: 20s
timeout: 8s
retries: 10
start_period: 90s
deerflow-frontend:
image: ${DEERFLOW_FRONTEND_IMAGE}
restart: unless-stopped
ports:
- "${FRONTEND_PORT}:80"
volumes:
# 前端只接收已经执行 pnpm build 生成的 dist 目录。
- ../source/frontend/dist:/usr/share/nginx/html:ro
- ../data/generated/frontend/runtime-config.js:/usr/share/nginx/html/runtime-config.js:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/health"]
interval: 20s
timeout: 5s
retries: 5
start_period: 10s
depends_on:
deerflow-backend:
condition: service_healthy
volumes:
deerflow-data:
deerflow-browser-context:
deerflow-users:
deerflow-python-vendor:
@@ -0,0 +1,113 @@
# ================================================================
# DeerFlow 纯项目完全离线部署:唯一人工配置文件
# ================================================================
# 规则:
# 1. 只修改本文件;data/generated/ 下全部由启动脚本生成。
# 2. 示例使用 RFC 5737 保留地址,必须替换后才能启动。
# 3. PUBLIC_HOST 只填 IP/域名;接口地址必须包含 http:// 或 https://。
# 4. 值中不要包含空格、换行或 $ 字符。
# ---------- 全局访问地址与端口 ----------
PUBLIC_HOST=192.0.2.10
FRONTEND_PORT=5174
BACKEND_PORT=8001
# ---------- DeerFlow 外部 MySQL ----------
MYSQL_HOST=192.0.2.20
MYSQL_PORT=3306
MYSQL_DATABASE=deerflow
MYSQL_USER=deerflow
MYSQL_PASSWORD=ExampleMysql@123
# ---------- DeerFlow 模型列表(1~20 个;第 1 个是默认模型) ----------
# 只需要一个模型时把 COUNT 改成 1,后面的示例块会被忽略。
DEERFLOW_MODEL_COUNT=3
DEERFLOW_MODEL_1_NAME=qwen3-32b
DEERFLOW_MODEL_1_DISPLAY_NAME=通用模型
DEERFLOW_MODEL_1_MODEL=qwen3-32b
DEERFLOW_MODEL_1_BASE_URL=http://192.0.2.25:8000/v1
DEERFLOW_MODEL_1_API_KEY=sk-example-model-1
DEERFLOW_MODEL_1_SUPPORTS_VISION=false
DEERFLOW_MODEL_1_SUPPORTS_THINKING=false
DEERFLOW_MODEL_1_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_1_REQUEST_TIMEOUT=600
DEERFLOW_MODEL_1_MAX_RETRIES=2
DEERFLOW_MODEL_1_STREAM_USAGE=true
DEERFLOW_MODEL_1_MAX_TOKENS=
DEERFLOW_MODEL_1_TEMPERATURE=
DEERFLOW_MODEL_1_USE_RESPONSES_API=false
DEERFLOW_MODEL_2_NAME=vision-model
DEERFLOW_MODEL_2_DISPLAY_NAME=视觉模型
DEERFLOW_MODEL_2_MODEL=vision-model
DEERFLOW_MODEL_2_BASE_URL=http://192.0.2.25:8001/v1
DEERFLOW_MODEL_2_API_KEY=sk-example-model-2
DEERFLOW_MODEL_2_SUPPORTS_VISION=true
DEERFLOW_MODEL_2_SUPPORTS_THINKING=false
DEERFLOW_MODEL_2_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_2_REQUEST_TIMEOUT=600
DEERFLOW_MODEL_2_MAX_RETRIES=2
DEERFLOW_MODEL_2_STREAM_USAGE=true
DEERFLOW_MODEL_2_MAX_TOKENS=8192
DEERFLOW_MODEL_2_TEMPERATURE=0.7
DEERFLOW_MODEL_2_USE_RESPONSES_API=false
DEERFLOW_MODEL_3_NAME=thinking-model
DEERFLOW_MODEL_3_DISPLAY_NAME=推理模型
DEERFLOW_MODEL_3_MODEL=thinking-model
DEERFLOW_MODEL_3_BASE_URL=http://192.0.2.25:8002/v1
DEERFLOW_MODEL_3_API_KEY=sk-example-model-3
DEERFLOW_MODEL_3_SUPPORTS_VISION=false
DEERFLOW_MODEL_3_SUPPORTS_THINKING=true
DEERFLOW_MODEL_3_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_3_REQUEST_TIMEOUT=1200
DEERFLOW_MODEL_3_MAX_RETRIES=2
DEERFLOW_MODEL_3_STREAM_USAGE=true
DEERFLOW_MODEL_3_MAX_TOKENS=16384
DEERFLOW_MODEL_3_TEMPERATURE=0.6
DEERFLOW_MODEL_3_USE_RESPONSES_API=false
# ---------- 可选搜索服务(不用就留空) ----------
TAVILY_API_KEY=
SERPER_API_KEY=
# ---------- 任务深链后端(默认使用内置模拟数据) ----------
TASK_DEEPLINK_MOCK_MODE=true
# 示例:http://192.0.2.50:9000/api/task/detail
TASK_COP_DETAIL_URL=
TASK_PURPOSE_DETAIL_URL=
TASK_DELETE_URL=
TASK_TREEMAP_EDIT_URL=
TASK_TREEMAP_NEXT_URL=
TASK_ACTION_DETAIL_URL=
TASK_ACTION_HISTORY_URL=
TASK_DETAIL_3Q_URL=
# ---------- 前端可选业务系统接口(不用就留空) ----------
# 示例:http://192.0.2.60:9000/api
VITE_NOTE_API_BASE_URL=
VITE_API_BASE_URL=
VITE_8BF_TASK_API_BASE_URL=
VITE_8BF_ROUNDTABLE_BACK_URL=
VITE_HOST_NAVIGATE_ORIGIN=
VITE_AIPI_CHAT_URL=
VITE_AIPI_GENERATE_CHAT_URL=
VITE_AIPI_TASK_CHAT_URL=
VITE_AIPI_KNOWLEDGE_URL=
VITE_AIPI_B1COH_URL=
VITE_AIPI_B2ACTOR_URL=
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=
VITE_AIPI_MOPECHAT_URL=
VITE_AIPI_BACKENP_URL=
VITE_NOTEBOOK_API_BASE_URL=
VITE_AIPI_FRONTEND_URL=
VITE_AIPI_CONSUMER_URL=
VITE_AIPI_PRESENT_URL=
VITE_AIPI_KNOWES_URL=
VITE_AUTH_BASE_URL=
VITE_AIPI_PGLINKTO_URL=
VITE_AIPI_XKLINKTO_URL=
VITE_SENTIMENT_AGENT_API_URL=
VITE_SENTIMENT_AGENT_AUTH_USERNAME=
VITE_SENTIMENT_AGENT_AUTH_PASSWORD=
@@ -0,0 +1,98 @@
# 与 deploy.env 内容相同;用于误修改后的示例恢复。
# 唯一人工配置文件仍是 config/deploy.env。
PUBLIC_HOST=192.0.2.10
FRONTEND_PORT=5174
BACKEND_PORT=8001
MYSQL_HOST=192.0.2.20
MYSQL_PORT=3306
MYSQL_DATABASE=deerflow
MYSQL_USER=deerflow
MYSQL_PASSWORD=ExampleMysql@123
DEERFLOW_MODEL_COUNT=3
DEERFLOW_MODEL_1_NAME=qwen3-32b
DEERFLOW_MODEL_1_DISPLAY_NAME=通用模型
DEERFLOW_MODEL_1_MODEL=qwen3-32b
DEERFLOW_MODEL_1_BASE_URL=http://192.0.2.25:8000/v1
DEERFLOW_MODEL_1_API_KEY=sk-example-model-1
DEERFLOW_MODEL_1_SUPPORTS_VISION=false
DEERFLOW_MODEL_1_SUPPORTS_THINKING=false
DEERFLOW_MODEL_1_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_1_REQUEST_TIMEOUT=600
DEERFLOW_MODEL_1_MAX_RETRIES=2
DEERFLOW_MODEL_1_STREAM_USAGE=true
DEERFLOW_MODEL_1_MAX_TOKENS=
DEERFLOW_MODEL_1_TEMPERATURE=
DEERFLOW_MODEL_1_USE_RESPONSES_API=false
DEERFLOW_MODEL_2_NAME=vision-model
DEERFLOW_MODEL_2_DISPLAY_NAME=视觉模型
DEERFLOW_MODEL_2_MODEL=vision-model
DEERFLOW_MODEL_2_BASE_URL=http://192.0.2.25:8001/v1
DEERFLOW_MODEL_2_API_KEY=sk-example-model-2
DEERFLOW_MODEL_2_SUPPORTS_VISION=true
DEERFLOW_MODEL_2_SUPPORTS_THINKING=false
DEERFLOW_MODEL_2_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_2_REQUEST_TIMEOUT=600
DEERFLOW_MODEL_2_MAX_RETRIES=2
DEERFLOW_MODEL_2_STREAM_USAGE=true
DEERFLOW_MODEL_2_MAX_TOKENS=8192
DEERFLOW_MODEL_2_TEMPERATURE=0.7
DEERFLOW_MODEL_2_USE_RESPONSES_API=false
DEERFLOW_MODEL_3_NAME=thinking-model
DEERFLOW_MODEL_3_DISPLAY_NAME=推理模型
DEERFLOW_MODEL_3_MODEL=thinking-model
DEERFLOW_MODEL_3_BASE_URL=http://192.0.2.25:8002/v1
DEERFLOW_MODEL_3_API_KEY=sk-example-model-3
DEERFLOW_MODEL_3_SUPPORTS_VISION=false
DEERFLOW_MODEL_3_SUPPORTS_THINKING=true
DEERFLOW_MODEL_3_SUPPORTS_REASONING_EFFORT=false
DEERFLOW_MODEL_3_REQUEST_TIMEOUT=1200
DEERFLOW_MODEL_3_MAX_RETRIES=2
DEERFLOW_MODEL_3_STREAM_USAGE=true
DEERFLOW_MODEL_3_MAX_TOKENS=16384
DEERFLOW_MODEL_3_TEMPERATURE=0.6
DEERFLOW_MODEL_3_USE_RESPONSES_API=false
TAVILY_API_KEY=
SERPER_API_KEY=
TASK_DEEPLINK_MOCK_MODE=true
TASK_COP_DETAIL_URL=
TASK_PURPOSE_DETAIL_URL=
TASK_DELETE_URL=
TASK_TREEMAP_EDIT_URL=
TASK_TREEMAP_NEXT_URL=
TASK_ACTION_DETAIL_URL=
TASK_ACTION_HISTORY_URL=
TASK_DETAIL_3Q_URL=
VITE_NOTE_API_BASE_URL=
VITE_API_BASE_URL=
VITE_8BF_TASK_API_BASE_URL=
VITE_8BF_ROUNDTABLE_BACK_URL=
VITE_HOST_NAVIGATE_ORIGIN=
VITE_AIPI_CHAT_URL=
VITE_AIPI_GENERATE_CHAT_URL=
VITE_AIPI_TASK_CHAT_URL=
VITE_AIPI_KNOWLEDGE_URL=
VITE_AIPI_B1COH_URL=
VITE_AIPI_B2ACTOR_URL=
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=
VITE_AIPI_MOPECHAT_URL=
VITE_AIPI_BACKENP_URL=
VITE_NOTEBOOK_API_BASE_URL=
VITE_AIPI_FRONTEND_URL=
VITE_AIPI_CONSUMER_URL=
VITE_AIPI_PRESENT_URL=
VITE_AIPI_KNOWES_URL=
VITE_AUTH_BASE_URL=
VITE_AIPI_PGLINKTO_URL=
VITE_AIPI_XKLINKTO_URL=
VITE_SENTIMENT_AGENT_API_URL=
VITE_SENTIMENT_AGENT_AUTH_USERNAME=
VITE_SENTIMENT_AGENT_AUTH_PASSWORD=
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Install operator-selected offline wheels into the persistent vendor path."""
from __future__ import annotations
import hashlib
import inspect
import json
import subprocess
import sys
from pathlib import Path
def digest_inputs(wheelhouse: Path, requirements: Path) -> str:
digest = hashlib.sha256()
inputs = [requirements, *sorted(wheelhouse.glob("*.whl"), key=lambda path: path.name.lower())]
for path in inputs:
digest.update(path.name.encode("utf-8"))
digest.update(b"\0")
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def has_requirements(path: Path) -> bool:
return any(line.strip() and not line.lstrip().startswith("#") for line in path.read_text(encoding="utf-8-sig").splitlines())
def verify_aiomysql_bundle(vendor: Path, requirements: Path) -> None:
if "aiomysql" not in requirements.read_text(encoding="utf-8-sig").lower():
return
sys.path.insert(0, str(vendor))
import aiomysql # noqa: F401
import pymysql # noqa: F401
from sqlalchemy.dialects.mysql.aiomysql import AsyncAdapt_aiomysql_connection
reconnect = inspect.signature(AsyncAdapt_aiomysql_connection.ping).parameters.get("reconnect")
if reconnect is None or reconnect.default is not False:
raise RuntimeError("SQLAlchemy aiomysql 适配器不兼容:ping(reconnect=False) 校验失败")
def main() -> int:
wheelhouse = Path(sys.argv[1] if len(sys.argv) > 1 else "/app/wheelhouse").resolve()
vendor = Path(
sys.argv[2] if len(sys.argv) > 2 else "/app/backend/.deer-flow/python-vendor"
).resolve()
requirements = wheelhouse / "requirements.txt"
if not wheelhouse.is_dir():
raise SystemExit(f"wheelhouse 目录不存在: {wheelhouse}")
if not requirements.is_file():
raise SystemExit(f"wheelhouse 缺少 requirements.txt: {requirements}")
if not has_requirements(requirements):
print("wheelhouse/requirements.txt 没有启用的依赖,跳过安装。")
return 0
fingerprint = digest_inputs(wheelhouse, requirements)
marker = vendor / ".wheelhouse-state.json"
vendor.mkdir(parents=True, exist_ok=True)
try:
state = json.loads(marker.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
state = {}
if state.get("fingerprint") == fingerprint and any(vendor.iterdir()):
verify_aiomysql_bundle(vendor, requirements)
print("wheelhouse 内容未变化,持久依赖已就绪。")
return 0
command = [
sys.executable,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-index",
"--find-links",
str(wheelhouse),
"--target",
str(vendor),
"--upgrade",
"-r",
str(requirements),
]
print("检测到 wheelhouse 变化,正在从本地 wheel 离线安装……")
subprocess.run(command, check=True)
verify_aiomysql_bundle(vendor, requirements)
marker.write_text(
json.dumps({"format_version": 1, "fingerprint": fingerprint}, ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
print("wheelhouse 依赖安装完成。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Check/create the configured DeerFlow MySQL database before Compose starts."""
from __future__ import annotations
import asyncio
import re
import sys
from pathlib import Path
import aiomysql
from render_runtime import load_env
async def check(path: Path) -> None:
env = load_env(path)
host = env["MYSQL_HOST"].strip()
port = int(env["MYSQL_PORT"])
user = env["MYSQL_USER"].strip()
password = env["MYSQL_PASSWORD"]
database = env["MYSQL_DATABASE"].strip()
if not re.fullmatch(r"[A-Za-z0-9_]+", database):
raise RuntimeError("MYSQL_DATABASE 只能包含字母、数字和下划线")
connection = await aiomysql.connect(host=host, port=port, user=user, password=password, autocommit=True)
try:
async with connection.cursor() as cursor:
await cursor.execute(
f"CREATE DATABASE IF NOT EXISTS `{database}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
await connection.select_db(database)
async with connection.cursor() as cursor:
await cursor.execute("SELECT 1")
assert (await cursor.fetchone())[0] == 1
finally:
connection.close()
def main() -> int:
try:
asyncio.run(check(Path(sys.argv[1])))
print("DeerFlow 外部 MySQL 连接检查通过。")
return 0
except Exception:
print("MySQL 预检失败:无法连接或自动创建 DeerFlow 数据库;请检查地址、端口、账号权限和防火墙。", file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Validate deploy.env and render the DeerFlow-only runtime configuration."""
from __future__ import annotations
import json
import os
import re
import secrets
import stat
import sys
from pathlib import Path
from urllib.parse import quote, urlsplit
import yaml
DOC_NETS = ("192.0.2.", "198.51.100.", "203.0.113.")
TRUE_VALUES = {"1", "true", "yes", "on"}
FALSE_VALUES = {"0", "false", "no", "off"}
FRONTEND_OPTIONAL_URL_KEYS = (
"VITE_NOTE_API_BASE_URL",
"VITE_API_BASE_URL",
"VITE_8BF_TASK_API_BASE_URL",
"VITE_8BF_ROUNDTABLE_BACK_URL",
"VITE_HOST_NAVIGATE_ORIGIN",
"VITE_AIPI_CHAT_URL",
"VITE_AIPI_GENERATE_CHAT_URL",
"VITE_AIPI_TASK_CHAT_URL",
"VITE_AIPI_KNOWLEDGE_URL",
"VITE_AIPI_B1COH_URL",
"VITE_AIPI_B2ACTOR_URL",
"VITE_AIPI_KNOWLEDGE_IFRAME_API_URL",
"VITE_AIPI_MOPECHAT_URL",
"VITE_AIPI_BACKENP_URL",
"VITE_NOTEBOOK_API_BASE_URL",
"VITE_AIPI_FRONTEND_URL",
"VITE_AIPI_CONSUMER_URL",
"VITE_AIPI_PRESENT_URL",
"VITE_AIPI_KNOWES_URL",
"VITE_AUTH_BASE_URL",
"VITE_AIPI_PGLINKTO_URL",
"VITE_AIPI_XKLINKTO_URL",
"VITE_SENTIMENT_AGENT_API_URL",
)
TASK_URL_KEYS = {
"cop_task_detail_url": "TASK_COP_DETAIL_URL",
"purpose_detail_url": "TASK_PURPOSE_DETAIL_URL",
"delete_url": "TASK_DELETE_URL",
"treemap_edit_url": "TASK_TREEMAP_EDIT_URL",
"treemap_next_url": "TASK_TREEMAP_NEXT_URL",
"action_detail_url": "TASK_ACTION_DETAIL_URL",
"action_history_url": "TASK_ACTION_HISTORY_URL",
"detail_3q_url": "TASK_DETAIL_3Q_URL",
}
def fail(message: str) -> None:
raise ValueError(message)
def load_env(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for lineno, original in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
line = original.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
fail(f"{path.name}:{lineno} 不是 KEY=VALUE 格式")
key, raw = line.split("=", 1)
key = key.strip()
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
fail(f"{path.name}:{lineno} 配置名不合法: {key!r}")
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}:
raw = raw[1:-1]
values[key] = raw
return values
def required(env: dict[str, str], key: str) -> str:
value = env.get(key, "").strip()
if not value:
fail(f"{key} 必须填写")
if "\n" in value or "\r" in value or "$" in value or any(ch.isspace() for ch in value):
fail(f"{key} 不能包含空格、换行或 $ 字符")
if "example" in value.lower() or value.startswith(DOC_NETS):
fail(f"{key} 仍是示例值,请按实际环境修改")
return value
def optional(env: dict[str, str], key: str) -> str:
value = env.get(key, "").strip()
if "\n" in value or "\r" in value or "$" in value or any(ch.isspace() for ch in value):
fail(f"{key} 不能包含空格、换行或 $ 字符")
if value and ("example" in value.lower() or value.startswith(DOC_NETS)):
fail(f"{key} 仍是示例值,请按实际环境修改或留空")
return value
def integer(env: dict[str, str], key: str, minimum: int, maximum: int) -> int:
try:
number = int(required(env, key))
except ValueError:
fail(f"{key} 必须是整数")
if not minimum <= number <= maximum:
fail(f"{key} 必须在 {minimum}~{maximum} 之间")
return number
def boolean(env: dict[str, str], key: str) -> bool:
value = required(env, key).lower()
if value in TRUE_VALUES:
return True
if value in FALSE_VALUES:
return False
fail(f"{key} 必须填写 true 或 false")
def optional_boolean(env: dict[str, str], key: str, default: bool = False) -> bool:
value = env.get(key, "").strip()
if not value:
return default
if value.lower() in TRUE_VALUES:
return True
if value.lower() in FALSE_VALUES:
return False
fail(f"{key} 必须填写 true 或 false")
def optional_integer(
env: dict[str, str], key: str, minimum: int, maximum: int, default: int | None = None
) -> int | None:
value = env.get(key, "").strip()
if not value:
return default
try:
number = int(value)
except ValueError:
fail(f"{key} 必须是整数")
if not minimum <= number <= maximum:
fail(f"{key} 必须在 {minimum}~{maximum} 之间")
return number
def optional_number(
env: dict[str, str], key: str, minimum: float, maximum: float, default: float | None = None
) -> float | None:
value = env.get(key, "").strip()
if not value:
return default
try:
number = float(value)
except ValueError:
fail(f"{key} 必须是数字")
if not minimum <= number <= maximum:
fail(f"{key} 必须在 {minimum}~{maximum} 之间")
return number
def service_url(env: dict[str, str], key: str, *, allow_blank: bool = False) -> str:
value = optional(env, key) if allow_blank else required(env, key)
if not value and allow_blank:
return ""
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
fail(f"{key} 必须是完整的 http(s) 地址")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
fail(f"{key} 不能包含账号、密码、查询参数或锚点")
if parsed.hostname.startswith(DOC_NETS):
fail(f"{key} 仍是文档示例地址")
return value.rstrip("/")
def render_models(env: dict[str, str]) -> tuple[list[dict[str, object]], dict[str, str]]:
"""Render one or more OpenAI-compatible models from deploy.env.
New bundles use DEERFLOW_MODEL_COUNT plus numbered fields. The legacy
DEERFLOW_LLM_* block remains accepted so an existing deploy.env can be
upgraded without an all-at-once configuration rewrite.
"""
count_raw = env.get("DEERFLOW_MODEL_COUNT", "").strip()
if not count_raw:
name = required(env, "DEERFLOW_LLM_MODEL")
api_key = required(env, "DEERFLOW_LLM_API_KEY")
return (
[
{
"name": name,
"display_name": name,
"use": "langchain_openai:ChatOpenAI",
"model": name,
"api_key": "$DEERFLOW_LLM_API_KEY",
"base_url": service_url(env, "DEERFLOW_LLM_BASE_URL"),
"request_timeout": 600.0,
"max_retries": 2,
"stream_usage": True,
"supports_vision": boolean(env, "DEERFLOW_LLM_SUPPORTS_VISION"),
"supports_thinking": boolean(env, "DEERFLOW_LLM_SUPPORTS_THINKING"),
"supports_reasoning_effort": False,
}
],
{"DEERFLOW_LLM_API_KEY": api_key},
)
try:
count = int(count_raw)
except ValueError:
fail("DEERFLOW_MODEL_COUNT 必须是整数")
if not 1 <= count <= 20:
fail("DEERFLOW_MODEL_COUNT 必须在 1~20 之间")
models: list[dict[str, object]] = []
secrets: dict[str, str] = {}
names: set[str] = set()
for index in range(1, count + 1):
prefix = f"DEERFLOW_MODEL_{index}"
name = required(env, f"{prefix}_NAME")
if name in names:
fail(f"{prefix}_NAME 与前面的模型重名: {name}")
names.add(name)
display_name = env.get(f"{prefix}_DISPLAY_NAME", "").strip() or name
provider_model = env.get(f"{prefix}_MODEL", "").strip() or name
provider = env.get(f"{prefix}_USE", "").strip() or "langchain_openai:ChatOpenAI"
if any(ch.isspace() for ch in provider) or "$" in provider:
fail(f"{prefix}_USE 不能包含空格或 $ 字符")
api_key_name = f"{prefix}_API_KEY"
api_key = required(env, api_key_name)
secrets[api_key_name] = api_key
model: dict[str, object] = {
"name": name,
"display_name": display_name,
"use": provider,
"model": provider_model,
"api_key": f"${api_key_name}",
"base_url": service_url(env, f"{prefix}_BASE_URL"),
"request_timeout": optional_number(env, f"{prefix}_REQUEST_TIMEOUT", 1, 3600, 600.0),
"max_retries": optional_integer(env, f"{prefix}_MAX_RETRIES", 0, 10, 2),
"stream_usage": optional_boolean(env, f"{prefix}_STREAM_USAGE", True),
"supports_vision": optional_boolean(env, f"{prefix}_SUPPORTS_VISION"),
"supports_thinking": optional_boolean(env, f"{prefix}_SUPPORTS_THINKING"),
"supports_reasoning_effort": optional_boolean(env, f"{prefix}_SUPPORTS_REASONING_EFFORT"),
}
max_tokens = optional_integer(env, f"{prefix}_MAX_TOKENS", 1, 1000000)
temperature = optional_number(env, f"{prefix}_TEMPERATURE", 0, 2)
if max_tokens is not None:
model["max_tokens"] = max_tokens
if temperature is not None:
model["temperature"] = temperature
if optional_boolean(env, f"{prefix}_USE_RESPONSES_API"):
model["use_responses_api"] = True
model["output_version"] = "responses/v1"
models.append(model)
return models, secrets
def dotenv_text(values: dict[str, object]) -> str:
lines = ["# 自动生成;唯一人工配置源是 config/deploy.env。"]
for key, raw_value in values.items():
value = "true" if raw_value is True else "false" if raw_value is False else str(raw_value)
if "\n" in value or "\r" in value or "$" in value or any(ch.isspace() for ch in value):
fail(f"自动生成的 {key} 含 Docker env 不支持的字符")
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def set_js_property(text: str, key: str, value: str) -> str:
rendered = f" {key}: {json.dumps(value, ensure_ascii=False)},"
pattern = re.compile(rf"^\s*(?://\s*)?{re.escape(key)}\s*:.*$", re.MULTILINE)
if pattern.search(text):
return pattern.sub(rendered, text, count=1)
end = text.rfind("};")
if end < 0:
fail("frontend runtime-config.js 模板格式不正确")
return text[:end] + rendered + "\n" + text[end:]
def stable_secrets(path: Path) -> dict[str, str]:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
else:
data = {"deerflow_auth_jwt_secret": secrets.token_hex(32)}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
if set(data) != {"deerflow_auth_jwt_secret"} or len(data["deerflow_auth_jwt_secret"]) != 64:
fail("data/state/runtime-secrets.json 损坏;请从备份恢复")
return data
def render(bundle: Path) -> None:
env = load_env(bundle / "config" / "deploy.env")
public_host = required(env, "PUBLIC_HOST")
if "://" in public_host or "/" in public_host:
fail("PUBLIC_HOST 只填 IP 或域名,不要带协议、路径")
frontend_port = integer(env, "FRONTEND_PORT", 1, 65535)
backend_port = integer(env, "BACKEND_PORT", 1, 65535)
if frontend_port == backend_port:
fail("FRONTEND_PORT 与 BACKEND_PORT 不能重复")
mysql_host = required(env, "MYSQL_HOST")
if mysql_host in {"127.0.0.1", "localhost", "::1"}:
fail("MYSQL_HOST 不能填容器内回环地址;宿主机 MySQL 请填 host.docker.internal 或宿主机内网 IP")
mysql_port = integer(env, "MYSQL_PORT", 1, 65535)
mysql_database = required(env, "MYSQL_DATABASE")
if not re.fullmatch(r"[A-Za-z0-9_]+", mysql_database):
fail("MYSQL_DATABASE 只能包含字母、数字和下划线")
mysql_user = required(env, "MYSQL_USER")
mysql_password = required(env, "MYSQL_PASSWORD")
models, model_secrets = render_models(env)
tavily_api_key = optional(env, "TAVILY_API_KEY")
serper_api_key = optional(env, "SERPER_API_KEY")
task_mock_mode = boolean(env, "TASK_DEEPLINK_MOCK_MODE")
task_urls = {key: service_url(env, env_key, allow_blank=True) for key, env_key in TASK_URL_KEYS.items()}
frontend_urls = {key: service_url(env, key, allow_blank=True) for key in FRONTEND_OPTIONAL_URL_KEYS}
sentiment_username = optional(env, "VITE_SENTIMENT_AGENT_AUTH_USERNAME")
sentiment_password = optional(env, "VITE_SENTIMENT_AGENT_AUTH_PASSWORD")
version = json.loads((bundle / "VERSION.json").read_text(encoding="utf-8"))
images = version["images"]
generated = bundle / "data" / "generated"
secrets_data = stable_secrets(bundle / "data" / "state" / "runtime-secrets.json")
mysql_url = (
"mysql+aiomysql://"
f"{quote(mysql_user, safe='')}:{quote(mysql_password, safe='')}@"
f"{mysql_host}:{mysql_port}/{mysql_database}?charset=utf8mb4"
)
frontend_url = f"http://{public_host}:{frontend_port}"
backend_url = f"http://{public_host}:{backend_port}"
compose_values: dict[str, object] = {
"COMPOSE_PROJECT_NAME": "cmzs-deerflow",
"DEERFLOW_BACKEND_IMAGE": images["deerflow_backend"],
"DEERFLOW_FRONTEND_IMAGE": images["deerflow_frontend"],
"PUBLIC_HOST": public_host,
"FRONTEND_PORT": frontend_port,
"BACKEND_PORT": backend_port,
"MYSQL_DATABASE_URL": mysql_url,
"GATEWAY_CORS_ORIGINS": frontend_url,
"GATEWAY_ENABLE_DOCS": "false",
"GATEWAY_WORKERS": "1",
"AUTH_JWT_SECRET": secrets_data["deerflow_auth_jwt_secret"],
"DEER_FLOW_OFFLINE_MODE": "true",
"SCHEDULER_INSTALL_DEPS": "false",
"SCHEDULED_TASKS_ENABLED": "true",
"ROUNDTABLE_JOB_GATEWAY": "http://127.0.0.1:8001",
"TAVILY_API_KEY": tavily_api_key,
"SERPER_API_KEY": serper_api_key,
}
compose_values.update(model_secrets)
generated.mkdir(parents=True, exist_ok=True)
compose_env = generated / "compose.env"
compose_env.write_text(dotenv_text(compose_values), encoding="utf-8")
os.chmod(compose_env, stat.S_IRUSR | stat.S_IWUSR)
config = yaml.safe_load((bundle / "config" / "deerflow" / "config.base.yaml").read_text(encoding="utf-8"))
config.setdefault("gateway", {})["port"] = 8001
config.setdefault("database", {}).update(
{"backend": "mysql", "mysql_url": "$MYSQL_DATABASE_URL", "sqlite_dir": ".deer-flow/data"}
)
config["checkpointer"] = {"type": "sqlite", "connection_string": ".deer-flow/data/checkpoints.db"}
config["models"] = models
config["vision"] = {}
config["deep_research"] = {"image_generation": {"enabled": False}}
# 本发行包不包含 WeKnora;显式清空连接并关闭本地 Wiki 同步/向量索引。
llmwiki = config.setdefault("llmwiki", {})
llmwiki["weknora"] = {"api_base_url": "", "web_base_url": "", "admin_email": "", "admin_password": ""}
llmwiki["local_wiki_index"] = {
"enabled": False,
"frontend_base_url": frontend_url,
"auto_sync": False,
"embedding": {"enabled": False, "provider": "openai", "model": "", "base_url": "", "api_key": "", "dimensions": 1024},
}
task_deeplink = config.setdefault("task_deeplink", {})
for flag in ("test", "delete_test", "action_test", "cop_task_detail_test"):
task_deeplink[flag] = task_mock_mode
task_deeplink.update(task_urls)
deerflow_dir = generated / "deerflow"
deerflow_dir.mkdir(parents=True, exist_ok=True)
(deerflow_dir / "config.yaml").write_text(
yaml.safe_dump(config, allow_unicode=True, sort_keys=False, width=120), encoding="utf-8"
)
runtime_js = (bundle / "config" / "frontend" / "runtime-config.base.js").read_text(encoding="utf-8")
runtime_js = set_js_property(runtime_js, "VITE_BACKEND_BASE_URL", backend_url)
runtime_js = set_js_property(runtime_js, "VITE_LANGGRAPH_BASE_URL", f"{backend_url}/api")
runtime_js = set_js_property(runtime_js, "VITE_CANVAS_LANGGRAPH_BASE_URL", f"{backend_url}/api")
runtime_js = set_js_property(runtime_js, "VITE_WEKNORA_BASE_URL", "")
runtime_js = set_js_property(runtime_js, "VITE_USE_REAL_DATA", "false" if task_mock_mode else "true")
runtime_js = set_js_property(runtime_js, "VITE_MOCK_COP_TASK_DETAIL", "true" if task_mock_mode else "false")
runtime_js = set_js_property(
runtime_js,
"VITE_SENTIMENT_AGENT_VISIBLE",
"true" if frontend_urls["VITE_SENTIMENT_AGENT_API_URL"] else "false",
)
for key, value in frontend_urls.items():
runtime_js = set_js_property(runtime_js, key, value)
runtime_js = set_js_property(runtime_js, "VITE_SENTIMENT_AGENT_AUTH_USERNAME", sentiment_username)
runtime_js = set_js_property(runtime_js, "VITE_SENTIMENT_AGENT_AUTH_PASSWORD", sentiment_password)
frontend_dir = generated / "frontend"
frontend_dir.mkdir(parents=True, exist_ok=True)
(frontend_dir / "runtime-config.js").write_text(runtime_js, encoding="utf-8")
print("配置校验通过,纯 DeerFlow 运行时文件已从 config/deploy.env 重新生成。")
print(f"DeerFlow: {frontend_url}")
def main() -> int:
try:
render(Path(sys.argv[1] if len(sys.argv) > 1 else "/bundle").resolve())
return 0
except Exception as exc:
print(f"配置错误: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
set -Eeuo pipefail
UPDATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
say() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"; }
die() { printf '错误: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
用法:
./APPLY_SOURCE_UPDATE.sh --check /当前DeerFlow部署目录
./APPLY_SOURCE_UPDATE.sh --apply /当前DeerFlow部署目录
./APPLY_SOURCE_UPDATE.sh --apply /当前DeerFlow部署目录 --no-start
本脚本只基于服务器已经存在的基线镜像构建源码覆盖层,不联网、不执行
docker pull,不删除旧镜像、数据卷、deploy.env、自定义智能体、custom 技能或知识文件。
EOF
}
current_release() {
sed -n 's/^[[:space:]]*"release":[[:space:]]*"\([^"]*\)".*/\1/p' "$1/VERSION.json" | head -n 1
}
read_manifest() {
local manifest="$1" key value
FORMAT_VERSION=""
BASE_RELEASE=""
TARGET_RELEASE=""
BASE_BACKEND_IMAGE=""
BASE_FRONTEND_IMAGE=""
BASE_BACKEND_IMAGE_ID=""
BASE_FRONTEND_IMAGE_ID=""
TARGET_BACKEND_IMAGE=""
TARGET_FRONTEND_IMAGE=""
BACKEND_CHANGED=""
FRONTEND_CHANGED=""
SOURCE_COMMIT=""
SOURCE_DIRTY=""
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue
case "$key" in
FORMAT_VERSION|BASE_RELEASE|TARGET_RELEASE|BASE_BACKEND_IMAGE|BASE_FRONTEND_IMAGE|\
BASE_BACKEND_IMAGE_ID|BASE_FRONTEND_IMAGE_ID|TARGET_BACKEND_IMAGE|TARGET_FRONTEND_IMAGE|\
BACKEND_CHANGED|FRONTEND_CHANGED|SOURCE_COMMIT|SOURCE_DIRTY) ;;
*) die "SOURCE_UPDATE_MANIFEST.env 含未知字段: $key" ;;
esac
[[ "$value" =~ ^[A-Za-z0-9._:/,+-]+$ ]] || die "更新清单字段 $key 含非法字符"
printf -v "$key" '%s' "$value"
done < "$manifest"
[[ "$FORMAT_VERSION" == "1" ]] || die "不支持的源码更新格式: $FORMAT_VERSION"
[[ -n "$BASE_RELEASE" && -n "$TARGET_RELEASE" ]] || die "更新清单缺少版本"
[[ -n "$BASE_BACKEND_IMAGE" && -n "$BASE_FRONTEND_IMAGE" ]] || die "更新清单缺少基线镜像"
[[ -n "$TARGET_BACKEND_IMAGE" && -n "$TARGET_FRONTEND_IMAGE" ]] || die "更新清单缺少目标镜像"
[[ "$BACKEND_CHANGED" =~ ^(true|false)$ && "$FRONTEND_CHANGED" =~ ^(true|false)$ ]] \
|| die "组件变化标记必须是 true 或 false"
[[ "$SOURCE_DIRTY" =~ ^(true|false)$ ]] || die "SOURCE_DIRTY 必须是 true 或 false"
}
validate_bundle() {
[[ -f "$UPDATE_DIR/SOURCE_UPDATE_MANIFEST.env" ]] || die "缺少 SOURCE_UPDATE_MANIFEST.env"
[[ -f "$UPDATE_DIR/SHA256SUMS" ]] || die "缺少 SHA256SUMS"
if find "$UPDATE_DIR" -type l -print -quit | grep -q .; then
die "源码更新包不允许包含符号链接"
fi
(cd "$UPDATE_DIR" && sha256sum -c SHA256SUMS)
read_manifest "$UPDATE_DIR/SOURCE_UPDATE_MANIFEST.env"
[[ -f "$UPDATE_DIR/payload/VERSION.json" ]] || die "缺少 payload/VERSION.json"
[[ -f "$UPDATE_DIR/payload/images/deerflow-image-list.txt" ]] || die "缺少目标镜像清单"
if [[ "$BACKEND_CHANGED" == "true" ]]; then
[[ -f "$UPDATE_DIR/source/backend/pyproject.toml" ]] || die "缺少后端源码"
[[ -f "$UPDATE_DIR/docker/Dockerfile.backend-source-overlay" ]] || die "缺少后端覆盖 Dockerfile"
fi
if [[ "$FRONTEND_CHANGED" == "true" ]]; then
[[ -s "$UPDATE_DIR/runtime/frontend-dist/index.html" ]] || die "缺少前端编译产物"
[[ -f "$UPDATE_DIR/docker/Dockerfile.frontend-dist-overlay" ]] || die "缺少前端覆盖 Dockerfile"
[[ -f "$UPDATE_DIR/runtime/nginx/deerflow.conf" ]] || die "缺少 Nginx 配置"
fi
}
image_id() {
docker image inspect -f '{{.Id}}' "$1" 2>/dev/null || true
}
verify_base() {
local deploy_dir="$1" current actual_backend_id actual_frontend_id
[[ -f "$deploy_dir/VERSION.json" && -x "$deploy_dir/OFFLINE_INSTALL.sh" ]] \
|| die "不是有效的 DeerFlow 部署目录: $deploy_dir"
current="$(current_release "$deploy_dir")"
[[ "$current" == "$BASE_RELEASE" ]] \
|| die "当前版本 $current 与源码更新基线 $BASE_RELEASE 不一致"
actual_backend_id="$(image_id "$BASE_BACKEND_IMAGE")"
actual_frontend_id="$(image_id "$BASE_FRONTEND_IMAGE")"
[[ "$actual_backend_id" == "$BASE_BACKEND_IMAGE_ID" ]] \
|| die "后端基线镜像缺失或镜像 ID 不匹配: $BASE_BACKEND_IMAGE"
[[ "$actual_frontend_id" == "$BASE_FRONTEND_IMAGE_ID" ]] \
|| die "前端基线镜像缺失或镜像 ID 不匹配: $BASE_FRONTEND_IMAGE"
}
compare_backend_dependencies() {
local cid temp manifest failed=false
temp="$(mktemp -d)"
cid="$(docker create "$BASE_BACKEND_IMAGE")"
for manifest in pyproject.toml uv.lock requirements-offline.txt packages/harness/pyproject.toml; do
mkdir -p "$temp/$(dirname "$manifest")"
if ! docker cp "$cid:/app/backend/$manifest" "$temp/$manifest" >/dev/null \
|| ! cmp -s "$UPDATE_DIR/source/backend/$manifest" "$temp/$manifest"; then
printf '后端依赖清单已变化,不能执行源码覆盖: %s\n' "$manifest" >&2
failed=true
fi
done
docker rm -f "$cid" >/dev/null 2>&1 || true
rm -rf -- "$temp"
[[ "$failed" == "false" ]] || die "请制作新的完整依赖基线"
}
build_images() {
if [[ "$BACKEND_CHANGED" == "true" ]]; then
say "基于已保存的后端镜像构建源码覆盖层(全程离线)"
compare_backend_dependencies
docker build --pull=false --platform linux/amd64 --network none --provenance=false \
--build-arg "BASE_IMAGE=$BASE_BACKEND_IMAGE" \
-f "$UPDATE_DIR/docker/Dockerfile.backend-source-overlay" \
-t "$TARGET_BACKEND_IMAGE" "$UPDATE_DIR/source/backend"
[[ "$(docker image inspect -f '{{.Architecture}}' "$TARGET_BACKEND_IMAGE")" == "amd64" ]] \
|| die "新后端镜像不是 linux/amd64"
docker run --rm --network none "$TARGET_BACKEND_IMAGE" python -c \
'import app.gateway.app, deerflow; print("backend source overlay smoke passed")'
docker run --rm --network none "$TARGET_BACKEND_IMAGE" \
/opt/venv/bin/python /app/backend/scripts/office_runtime_selfcheck.py
fi
if [[ "$FRONTEND_CHANGED" == "true" ]]; then
say "基于已保存的前端镜像替换静态文件(全程离线)"
docker build --pull=false --platform linux/amd64 --network none --provenance=false \
--build-arg "BASE_IMAGE=$BASE_FRONTEND_IMAGE" \
-f "$UPDATE_DIR/docker/Dockerfile.frontend-dist-overlay" \
-t "$TARGET_FRONTEND_IMAGE" "$UPDATE_DIR/runtime"
[[ "$(docker image inspect -f '{{.Architecture}}' "$TARGET_FRONTEND_IMAGE")" == "amd64" ]] \
|| die "新前端镜像不是 linux/amd64"
docker run --rm --network none --entrypoint sh "$TARGET_FRONTEND_IMAGE" \
-c 'test -s /usr/share/nginx/html/index.html'
fi
docker image inspect "$TARGET_BACKEND_IMAGE" "$TARGET_FRONTEND_IMAGE" >/dev/null \
|| die "目标源码覆盖镜像不完整"
}
backup_controls() {
local deploy_dir="$1" backup_root backup
backup_root="$deploy_dir/data/updates/backups"
backup="$backup_root/${BASE_RELEASE}_before_${TARGET_RELEASE}_$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup"
local items=(VERSION.json config/deerflow config/frontend images/deerflow-image-list.txt)
[[ -f "$deploy_dir/SOURCE_BASELINE.json" ]] && items+=(SOURCE_BASELINE.json)
tar -C "$deploy_dir" -czf "$backup/control-files.tar.gz" "${items[@]}"
cp -a "$deploy_dir/config/deploy.env" "$backup/deploy.env.snapshot"
printf '%s\n' "$backup"
}
restore_controls() {
local deploy_dir="$1" backup="$2"
tar -xzf "$backup/control-files.tar.gz" -C "$deploy_dir"
}
apply_update() {
local deploy_dir="$1" start_after="$2" backup history_file
deploy_dir="$(realpath "$deploy_dir")"
verify_base "$deploy_dir"
build_images
backup="$(backup_controls "$deploy_dir")"
history_file="$deploy_dir/data/updates/history.log"
say "控制文件备份:$backup"
"$deploy_dir/OFFLINE_INSTALL.sh" --stop
cp -a "$UPDATE_DIR/payload/." "$deploy_dir/"
printf '%s SOURCE_APPLY %s -> %s commit=%s backup=%s\n' \
"$(date --iso-8601=seconds)" "$BASE_RELEASE" "$TARGET_RELEASE" "$SOURCE_COMMIT" "$backup" \
>> "$history_file"
if [[ "$start_after" == "false" ]]; then
say "源码更新已应用但未启动;确认后执行 ./OFFLINE_INSTALL.sh --start"
return
fi
if ! "$deploy_dir/OFFLINE_INSTALL.sh" --start; then
say "新版本启动失败,自动恢复旧控制文件并尝试启动旧版本"
restore_controls "$deploy_dir" "$backup"
"$deploy_dir/OFFLINE_INSTALL.sh" --start || true
die "源码更新启动失败;旧镜像仍保留,备份位于 $backup"
fi
say "源码增量更新完成:$BASE_RELEASE -> $TARGET_RELEASE"
}
main() {
local action="${1:-}" deploy_dir="${2:-}" start_after=true
[[ "$action" == "--check" || "$action" == "--apply" ]] || { usage; exit 2; }
[[ -n "$deploy_dir" ]] || { usage; exit 2; }
[[ $# -le 3 ]] || { usage; exit 2; }
[[ "${3:-}" == "" || "${3:-}" == "--no-start" ]] || die "未知参数: ${3:-}"
[[ "${3:-}" != "--no-start" ]] || start_after=false
validate_bundle
deploy_dir="$(realpath "$deploy_dir")"
verify_base "$deploy_dir"
if [[ "$action" == "--check" ]]; then
say "源码更新包、版本链和基线镜像校验通过"
exit 0
fi
apply_update "$deploy_dir" "$start_after"
}
main "$@"
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Static QA for the pure DeerFlow release sanitizer and renderer."""
from __future__ import annotations
import json
import shutil
import sys
import tempfile
from pathlib import Path
import yaml
def main() -> int:
workspace = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
common = workspace / "deploy" / "linux-docker-offline"
pure = workspace / "deploy" / "linux-docker-offline-deerflow-only"
sys.path.insert(0, str(common))
from sanitize_release_sources import sanitize_backend, sanitize_frontend
with tempfile.TemporaryDirectory(prefix="deerflow-only-release-qa-") as temporary:
root = Path(temporary)
bundle = root / "bundle"
shutil.copytree(pure / "release-template", bundle)
backend = root / "backend"
backend.mkdir()
source_backend = workspace / "offline-backend-20260512" / "backend"
shutil.copy2(source_backend / "config.yaml", backend / "config.yaml")
shutil.copy2(source_backend / "extensions_config.json", backend / "extensions_config.json")
sanitize_backend(backend)
frontend = root / "frontend"
(frontend / "public").mkdir(parents=True)
source_frontend = workspace / "frontend-web" / "public" / "runtime-config.js"
shutil.copy2(source_frontend, frontend / "public" / "runtime-config.js")
sanitize_frontend(frontend)
sanitized_runtime = (frontend / "public" / "runtime-config.js").read_text(encoding="utf-8")
assert "ch1.b.uat.4.cn" not in sanitized_runtime
assert "47.88.25.99" not in sanitized_runtime
assert "gw.y.sit.4.cn" not in sanitized_runtime
(bundle / "config" / "deerflow").mkdir(parents=True)
(bundle / "config" / "frontend").mkdir(parents=True)
shutil.copy2(backend / "config.yaml", bundle / "config" / "deerflow" / "config.base.yaml")
shutil.copy2(
backend / "extensions_config.json",
bundle / "config" / "deerflow" / "extensions_config.json",
)
shutil.copy2(
frontend / "public" / "runtime-config.js",
bundle / "config" / "frontend" / "runtime-config.base.js",
)
(bundle / "VERSION.json").write_text(
json.dumps(
{
"weknora_included": False,
"images": {
"deerflow_backend": "qa/deerflow-backend:1",
"deerflow_frontend": "qa/deerflow-frontend:1",
},
}
),
encoding="utf-8",
)
env_path = bundle / "config" / "deploy.env"
env_text = env_path.read_text(encoding="utf-8")
env_text = env_text.replace("192.0.2.10", "10.20.30.10")
env_text = env_text.replace("192.0.2.20", "10.20.30.20")
env_text = env_text.replace("192.0.2.25", "10.20.30.25")
env_text = env_text.replace("sk-example-model-", "qa-model-key-")
env_text = env_text.replace("ExampleMysql@123", "QaMysql@123")
env_path.write_text(env_text, encoding="utf-8")
sys.path.insert(0, str(bundle / "scripts"))
from render_runtime import render
render(bundle)
generated = bundle / "data" / "generated"
config = yaml.safe_load((generated / "deerflow" / "config.yaml").read_text(encoding="utf-8"))
assert len(config["models"]) == 3
assert config["uploads"]["auto_convert_documents"] is True
assert config["models"][0]["api_key"] == "$DEERFLOW_MODEL_1_API_KEY"
assert config["models"][1]["supports_vision"] is True
assert config["models"][2]["supports_thinking"] is True
assert config["database"]["backend"] == "mysql"
assert config["llmwiki"]["weknora"]["api_base_url"] == ""
assert config["llmwiki"]["local_wiki_index"]["enabled"] is False
runtime = (generated / "frontend" / "runtime-config.js").read_text(encoding="utf-8")
assert 'VITE_BACKEND_BASE_URL: "http://10.20.30.10:8001"' in runtime
assert 'VITE_LANGGRAPH_BASE_URL: "http://10.20.30.10:8001/api"' in runtime
assert 'VITE_CANVAS_LANGGRAPH_BASE_URL: "http://10.20.30.10:8001/api"' in runtime
assert 'VITE_USE_REAL_DATA: "false"' in runtime
assert 'VITE_SENTIMENT_AGENT_AUTH_PASSWORD: ""' in runtime
assert 'VITE_SENTIMENT_AGENT_VISIBLE: "false"' in runtime
compose_env = (generated / "compose.env").read_text(encoding="utf-8")
assert "MYSQL_DATABASE_URL=mysql+aiomysql://" in compose_env
assert "DEERFLOW_MODEL_1_API_KEY=qa-model-key-1" in compose_env
assert "DEERFLOW_MODEL_2_API_KEY=qa-model-key-2" in compose_env
assert "DEERFLOW_MODEL_3_API_KEY=qa-model-key-3" in compose_env
assert "WEKNORA" not in compose_env.upper()
install_script = (bundle / "OFFLINE_INSTALL.sh").read_text(encoding="utf-8")
assert "command -v docker-compose" in install_script
assert "--office-check" in install_script
compose = yaml.safe_load((bundle / "compose" / "docker-compose.yml").read_text(encoding="utf-8"))
assert str(compose["version"]) == "2.4"
backend_volumes = compose["services"]["deerflow-backend"]["volumes"]
frontend_volumes = compose["services"]["deerflow-frontend"]["volumes"]
assert "../source/backend/app:/app/backend/app:ro" in backend_volumes
assert "../source/backend/packages:/app/backend/packages:ro" in backend_volumes
assert "../source/backend/tests:/app/backend/tests:ro" in backend_volumes
assert "../source/wheelhouse:/app/wheelhouse:ro" in backend_volumes
assert "deerflow-python-vendor:/app/backend/.deer-flow/python-vendor" in backend_volumes
assert "../source/frontend/dist:/usr/share/nginx/html:ro" in frontend_volumes
assert "deerflow-python-vendor" in compose["volumes"]
assert (bundle / "scripts" / "install_wheelhouse.py").is_file()
assert (bundle / "source" / "wheelhouse" / "requirements.txt").is_file()
assert (bundle / "UPDATE_INSTALL.sh").is_file()
print("pure DeerFlow release sanitizer and renderer QA passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,132 @@
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
FROM ${PYTHON_IMAGE} AS builder
ARG APT_MIRROR=mirrors.aliyun.com
ARG UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/
ARG UV_VERSION=0.7.20
ARG UV_NO_CACHE=1
ARG UV_COMPILE_BYTECODE=0
ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_INDEX_URL=${UV_INDEX_URL} \
UV_LINK_MODE=copy \
UV_NO_CACHE=${UV_NO_CACHE} \
UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /build/backend
RUN if [ -n "${APT_MIRROR}" ]; then \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
sed -i "s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
fi \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip install --no-cache-dir -i "${UV_INDEX_URL}" "uv==${UV_VERSION}"
ARG UV_HTTP_TIMEOUT=300
ARG UV_CONCURRENT_DOWNLOADS=4
ARG UV_CONCURRENT_INSTALLS=4
ARG UV_CONCURRENT_BUILDS=2
ENV UV_HTTP_TIMEOUT=${UV_HTTP_TIMEOUT} \
UV_CONCURRENT_DOWNLOADS=${UV_CONCURRENT_DOWNLOADS} \
UV_CONCURRENT_INSTALLS=${UV_CONCURRENT_INSTALLS} \
UV_CONCURRENT_BUILDS=${UV_CONCURRENT_BUILDS}
COPY pyproject.toml uv.lock README.md requirements-offline.txt ./
COPY packages ./packages
RUN python -m venv /opt/venv \
&& /opt/venv/bin/python -m pip install --no-cache-dir --upgrade -i "${UV_INDEX_URL}" pip setuptools wheel \
&& sed -E '/--hash=/d; s/[[:space:]]+\\$//; s|^-e[[:space:]]+./packages/harness$|./packages/harness|' requirements-offline.txt > /tmp/requirements-docker.txt \
&& /opt/venv/bin/pip install --no-cache-dir -i "${UV_INDEX_URL}" -r /tmp/requirements-docker.txt \
&& /opt/venv/bin/pip install --no-cache-dir -i "${UV_INDEX_URL}" \
"aiomysql==0.3.2" \
"PyMySQL==1.2.0" \
"SQLAlchemy==2.0.51" \
"python-docx==1.2.0" \
&& /opt/venv/bin/python -c 'import aiomysql, docx, lxml, mammoth, markitdown, openpyxl, pandas, pptx, pymysql, sqlalchemy, xlrd; print("aiomysql and Office Python dependencies OK")'
COPY . .
# Only runtime material is copied into the final image. Development env files,
# tests and repository documentation are deliberately excluded from that copy.
RUN find /build/backend -maxdepth 2 -type f -name '.env*' -delete \
&& rm -rf \
/build/backend/.pytest_cache \
/build/backend/tests \
/build/backend/packages/harness/tests \
/build/backend/docs \
/build/backend/CLAUDE.md \
/build/backend/AGENTS.md \
/build/backend/README.md
FROM ${PYTHON_IMAGE} AS runtime
ARG APT_MIRROR=mirrors.aliyun.com
ENV PATH=/opt/venv/bin:$PATH \
PYTHONPATH=/app/backend:/app/backend/packages/harness \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DEER_FLOW_PROJECT_ROOT=/app/backend \
DEER_FLOW_HOME=/app/backend/.deer-flow \
DEER_FLOW_CONFIG_PATH=/app/backend/config.yaml \
DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/backend/extensions_config.json \
DEER_FLOW_SKILLS_PATH=/app/backend/skills \
BACKEND_PORT=8001
WORKDIR /app/backend
RUN if [ -n "${APT_MIRROR}" ]; then \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
sed -i "s|security.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
fi \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
docker.io \
file \
fontconfig \
fonts-liberation \
fonts-noto-cjk \
libmagic1 \
libreoffice-calc \
libreoffice-impress \
libreoffice-writer \
nodejs \
npm \
pandoc \
poppler-utils \
tini \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /build/backend /app/backend
# Fail the image build if the local sandbox cannot process the default Office
# formats. The production deployment uses LocalSandboxProvider, so these are
# the exact Python modules and native tools available to agent bash commands.
RUN /opt/venv/bin/python -c 'import aiomysql, docx, lxml, mammoth, markitdown, openpyxl, pandas, pptx, pymysql, sqlalchemy, xlrd' \
&& command -v soffice >/dev/null \
&& command -v pandoc >/dev/null \
&& fc-list :lang=zh | grep -q .
EXPOSE 8001
ENTRYPOINT ["tini", "--"]
CMD ["sh", "-c", "cd /app/backend && exec uvicorn app.gateway.app:app --host 0.0.0.0 --port ${BACKEND_PORT:-8001}"]
@@ -0,0 +1,24 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /workspace/frontend-web
RUN corepack enable && corepack prepare pnpm@10.26.2 --activate
COPY frontend-web/package.json frontend-web/pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY frontend-web/ ./
ENV VITE_ASSET_BASE=/
RUN pnpm build
FROM nginx:1.28-alpine
COPY deploy/linux-docker-offline/nginx/deerflow.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /workspace/frontend-web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=20s --timeout=5s --start-period=10s --retries=5 \
CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Create secret-free build inputs for the offline release.
The customized working tree contains environment-specific development values.
They must never become defaults in an exported image or release template.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml
SECRET_KEY = re.compile(
r"(?:api[_-]?key|password|passwd|secret|access[_-]?token|auth[_-]?(?:token|password))$",
re.IGNORECASE,
)
URL_KEY = re.compile(r"(?:^|_)(?:url|uri|endpoint)$|base_url$", re.IGNORECASE)
SUSPICIOUS_VALUE = re.compile(
r"(?:\bsk-[A-Za-z0-9_.-]{12,}|\bBearer\s+[A-Za-z0-9_.-]{12,})"
)
def scrub_mapping(value: object) -> object:
if isinstance(value, dict):
result: dict[object, object] = {}
for key, child in value.items():
key_text = str(key)
# Names such as ``require_password`` are boolean feature flags,
# not credentials. Only redact scalar string/null secret values.
if SECRET_KEY.search(key_text) and (isinstance(child, str) or child is None):
result[key] = ""
elif URL_KEY.search(key_text) and (isinstance(child, str) or child is None):
result[key] = ""
else:
result[key] = scrub_mapping(child)
return result
if isinstance(value, list):
return [scrub_mapping(item) for item in value]
if isinstance(value, str) and SUSPICIOUS_VALUE.search(value):
return ""
return value
def sanitize_backend(root: Path) -> None:
config_path = root / "config.yaml"
config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig"))
config = scrub_mapping(config)
if not isinstance(config, dict):
raise RuntimeError("DeerFlow config.yaml must contain a mapping")
# The renderer recreates these sections exclusively from deploy.env.
config["models"] = []
config["vision"] = {}
config["deep_research"] = {"image_generation": {"enabled": False}}
config["database"] = {
"backend": "sqlite",
"sqlite_dir": ".deer-flow/data",
"mysql_url": "",
}
config["checkpointer"] = {
"type": "sqlite",
"connection_string": ".deer-flow/data/checkpoints.db",
}
config["llmwiki"] = {
"weknora": {
"api_base_url": "",
"web_base_url": "",
"admin_email": "",
"admin_password": "",
},
"local_wiki_index": {"enabled": False, "auto_sync": False},
}
task = config.setdefault("task_deeplink", {})
if isinstance(task, dict):
for flag in ("test", "delete_test", "action_test", "cop_task_detail_test"):
task[flag] = True
for key in tuple(task):
if str(key).endswith("_url"):
task[key] = ""
memory = config.get("memory")
if isinstance(memory, dict) and isinstance(memory.get("hindsight"), dict):
memory["hindsight"]["api_url"] = ""
memory["hindsight"]["api_key"] = ""
rendered = yaml.safe_dump(config, allow_unicode=True, sort_keys=False, width=120)
if SUSPICIOUS_VALUE.search(rendered):
raise RuntimeError("sanitized DeerFlow config still contains a credential-like value")
config_path.write_text(rendered, encoding="utf-8")
extensions_path = root / "extensions_config.json"
extensions = json.loads(extensions_path.read_text(encoding="utf-8-sig"))
extensions_path.write_text(
json.dumps(scrub_mapping(extensions), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for env_path in root.glob(".env*"):
if env_path.is_file():
env_path.unlink()
def sanitize_frontend(root: Path) -> None:
for env_path in root.glob(".env*"):
if env_path.is_file():
env_path.unlink()
runtime_path = root / "public" / "runtime-config.js"
text = runtime_path.read_text(encoding="utf-8-sig")
property_pattern = re.compile(
r'^(?P<prefix>\s*)(?P<comment>//\s*)?(?P<key>VITE_[A-Z0-9_]+)\s*:\s*(?P<quote>["\'])(?P<value>.*?)(?P=quote)(?P<comma>\s*,?\s*)$',
re.MULTILINE,
)
def replace(match: re.Match[str]) -> str:
key = match.group("key")
value = match.group("value")
sensitive_key = re.search(
r"(?:URL|URI|HOST|ENDPOINT|PASSWORD|USERNAME|TOKEN|SECRET|AUTH)", key
)
absolute_value = re.match(r"^(?:https?|wss?)://", value, re.IGNORECASE)
credential_value = SUSPICIOUS_VALUE.search(value)
safe_brand_asset = key.startswith("VITE_BRAND_") and bool(value) and not absolute_value
if (sensitive_key and not safe_brand_asset) or absolute_value or credential_value:
value = ""
quote = match.group("quote")
comment = match.group("comment") or ""
return f'{match.group("prefix")}{comment}{key}: {quote}{value}{quote}{match.group("comma")}'
text = property_pattern.sub(replace, text)
for match in property_pattern.finditer(text):
value = match.group("value")
if re.match(r"^(?:https?|wss?)://", value, re.IGNORECASE) or SUSPICIOUS_VALUE.search(value):
raise RuntimeError(f"sanitized runtime-config still contains a configured value: {match.group('key')}")
runtime_path.write_text(text, encoding="utf-8")
def main() -> int:
if len(sys.argv) != 3 or sys.argv[1] not in {"backend", "frontend"}:
print("usage: sanitize_release_sources.py backend|frontend PATH", file=sys.stderr)
return 2
root = Path(sys.argv[2]).resolve()
if sys.argv[1] == "backend":
sanitize_backend(root)
else:
sanitize_frontend(root)
print(f"sanitized {sys.argv[1]} release source: {root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,218 @@
# DeerFlow 纯离线标准发版与部署
本文定义本仓库后续唯一的 Linux AMD64 离线发版方式。每次发版都必须从最新
`master` 构建,使用
`deploy/linux-docker-offline-deerflow-only/build-release.sh`,最终包只包含
DeerFlow 后端和前端两张镜像,不包含 WeKnora 服务或镜像。
## 1. 标准能力基线
标准离线包必须同时具备以下能力:
- `config/deploy.env` 是唯一需要人工修改的配置源;生成文件不得手工修改。
- MySQL 使用 SQLAlchemy 异步 URL `mysql+aiomysql://`。
- 基础兼容版本为 `aiomysql 0.3.2`、`PyMySQL 1.2.0`、
`SQLAlchemy 2.0.51`。
- `deploy.env` 支持 1~20 个模型;编号为 1 的模型是默认模型。
- `source/wheelhouse` 支持完全离线增加 Python 依赖,安装结果保存在 Docker
named volume 中。
- 后端镜像内置 DOC/DOCX、XLS/XLSX、PPT/PPTX、PDF 处理能力,以及
LibreOffice、Pandoc 和中文字体。
- `OFFLINE_INSTALL.sh --stop` 只停止容器,不删除已加载镜像、数据目录或
named volume。
- `OFFLINE_INSTALL.sh --restart` 不重新 load 镜像;它重新渲染配置、检测
wheelhouse、校验源码并重建容器,使覆盖后的前后端源码生效。
- 启动脚本兼容 `docker-compose` 和 `docker compose`。
## 2. 仓库中的标准目录
| 路径 | 用途 |
|---|---|
| `deploy/linux-docker-offline-deerflow-only/` | 标准构建脚本、运行模板、测试和交付文档 |
| `deploy/linux-docker-offline/Dockerfile.backend` | 后端基础镜像构建文件 |
| `deploy/linux-docker-offline/Dockerfile.frontend` | 前端基础镜像构建文件 |
| `deploy/linux-docker-offline/sanitize_release_sources.py` | 发布源码清理器 |
| `offline-backend-20260512/wheelhouse/` | 随包携带的 Linux AMD64 / Python 3.12 基础 wheel |
| `offline-backend-20260512/backend/` | 后端源码 |
| `frontend-web/` | 前端源码 |
不要使用历史的 DeerFlow + WeKnora 全量部署模板发本项目的标准包。
## 3. 每次发版前更新源码
发版工作区必须没有未提交的业务源码修改。未跟踪的本地构建产物不得混入包中。
```bash
git switch master
git fetch origin master
git merge --ff-only origin/master
git status --short --branch
git log -1 --format='%H %ad %s' --date=iso-strict
```
必须记录最终提交的完整 SHA。构建服务器如果使用不带 `.git` 的源码快照,应通过
`DEERFLOW_COMMIT_OVERRIDE` 写入同一个 SHA。
## 4. wheelhouse 维护
基础 wheel 位于 `offline-backend-20260512/wheelhouse/`。最终包构建时会把它们
复制到 `source/wheelhouse/`,并保留运行模板中的 `requirements.txt` 和说明文件。
增加依赖时:
1. 下载与 Linux AMD64、Python 3.12 匹配的 wheel,以及它的全部传递依赖。
2. 把 wheel 放入 `offline-backend-20260512/wheelhouse/`。
3. 在
`deploy/linux-docker-offline-deerflow-only/release-template/source/wheelhouse/requirements.txt`
中加入精确版本,例如 `some-package==1.2.3`。
4. 重新生成并核对 `offline-backend-20260512/wheelhouse/SHA256SUMS`。
5. 运行发布渲染测试,再构建完整包。
不能放源码包、Windows wheel、macOS wheel,不能只放顶层依赖而遗漏传递依赖。
部署完成后临时增加依赖时,也可以直接把 wheel 放进已解压交付目录的
`source/wheelhouse/`,更新其中的 `requirements.txt`,然后执行:
```bash
./OFFLINE_INSTALL.sh --restart
```
安装器按 `requirements.txt + wheel 文件` 的哈希判断是否需要重新安装,全程使用
`--no-index`,不会访问互联网。
## 5. 构建标准完整包
推荐在联网的 Linux AMD64 Docker 构建服务器执行。以下示例把构建工作区和输出
放在 `/opt/codex-build`,不会修改现网部署目录:
```bash
RELEASE_STAMP="$(date +%Y%m%d-%H%M%S)"
COMMIT_SHA="$(git rev-parse HEAD)"
SHORT_SHA="$(git rev-parse --short=8 HEAD)"
BUILD_ROOT="/opt/codex-build/deerflow-full-${RELEASE_STAMP}-${SHORT_SHA}"
RELEASE_STAMP="$RELEASE_STAMP" \
DEERFLOW_COMMIT_OVERRIDE="$COMMIT_SHA" \
DEERFLOW_DIRTY_OVERRIDE=false \
bash deploy/linux-docker-offline-deerflow-only/build-release.sh \
"$(pwd)" "$BUILD_ROOT"
```
不要设置 `DEERFLOW_FRONTEND_RUNTIME_BASE` 来复用旧前端镜像,除非本次只修改
`runtime-config.js`。从新 `master` 正式发版时必须从当前前端源码完整构建。
输出文件为:
```text
${BUILD_ROOT}/dist/deerflow-linux-amd64-offline-${RELEASE_STAMP}.tar.gz
${BUILD_ROOT}/dist/deerflow-linux-amd64-offline-${RELEASE_STAMP}.tar.gz.sha256
```
## 6. 发版前强制校验
```bash
python3 -m py_compile \
deploy/linux-docker-offline-deerflow-only/release-template/scripts/render_runtime.py \
deploy/linux-docker-offline-deerflow-only/release-template/scripts/preflight_mysql.py \
deploy/linux-docker-offline-deerflow-only/release-template/scripts/install_wheelhouse.py
python3 deploy/linux-docker-offline-deerflow-only/test_release_renderer.py "$(pwd)"
bash -n deploy/linux-docker-offline-deerflow-only/build-release.sh
cd "${BUILD_ROOT}/dist"
sha256sum -c "deerflow-linux-amd64-offline-${RELEASE_STAMP}.tar.gz.sha256"
gzip -t "deerflow-linux-amd64-offline-${RELEASE_STAMP}.tar.gz"
```
还必须确认:
- `images/deerflow-image-list.txt` 恰好只有 DeerFlow 后端、前端两行。
- `VERSION.json` 的提交 SHA 与本次 `master` 一致。
- 包含 `source/wheelhouse`、`scripts/install_wheelhouse.py` 和第 13 章文档。
- 后端镜像能导入 `aiomysql`、`PyMySQL`、`SQLAlchemy`。
- 后端镜像 Office 自检输出 `OFFICE_RUNTIME_SELF_CHECK=PASS`。
- 配置渲染后数据库驱动为 `mysql+aiomysql`。
## 7. 新机器完全离线部署
把 `.tar.gz` 和 `.sha256` 一起复制到目标服务器后执行:
```bash
sha256sum -c deerflow-linux-amd64-offline-*.tar.gz.sha256
tar -xzf deerflow-linux-amd64-offline-*.tar.gz
cd deerflow-linux-amd64-offline-*
cp config/deploy.env.example config/deploy.env
vi config/deploy.env
./OFFLINE_INSTALL.sh --start
```
至少修改:
- `PUBLIC_HOST`、前后端端口;
- MySQL 地址、端口、数据库名、用户名、密码;
- `DEERFLOW_MODEL_COUNT`;
- 每个 `DEERFLOW_MODEL_N_*` 模型块;
- 实际需要启用的搜索、认证和外部业务接口配置。
禁止直接修改 `data/generated/compose.env`、生成后的 `config.yaml` 或前端
`runtime-config.js`;它们会在下次启动或重启时被 `deploy.env` 重新生成。
常用命令:
```bash
./OFFLINE_INSTALL.sh --start
./OFFLINE_INSTALL.sh --restart
./OFFLINE_INSTALL.sh --stop
./OFFLINE_INSTALL.sh --status
```
## 8. 只替换源码的更新方式
后端可以覆盖交付目录中的:
```text
source/backend/app/
source/backend/packages/
source/backend/tests/
```
前端先在构建机执行 `pnpm build`,再覆盖:
```text
source/frontend/dist/
```
覆盖完成后只执行:
```bash
./OFFLINE_INSTALL.sh --restart
```
如果 Python 依赖或系统依赖发生变化,不能只替换源码:Python wheel 应同步更新
`source/wheelhouse`;LibreOffice、Pandoc、字体或系统库变化必须重新构建后端镜像并
发布新的完整包。
## 9. 数据、停止与回退
- `--stop` 不删除镜像、数据或依赖 volume。
- 业务数据和生成配置保存在交付目录的 `data/`,升级前必须整体备份。
- 新版本应解压到新的版本目录,不要直接覆盖旧版本目录。
- 回退时停止新目录,在旧目录执行 `./OFFLINE_INSTALL.sh --start`。
- 不执行 `docker compose down -v`,除非明确要销毁数据并已经完成备份。
服务器交付目录建议统一使用 `/opt/deerflow-offline-delivery/`;至少保留当前版本和
上一个已验证版本,并保留各自的 SHA256 文件。
## 10. Git 提交流程
部署逻辑变化必须同时更新本文件及交付包内对应文档。提交前执行:
```bash
git status --short
git diff --check
python3 deploy/linux-docker-offline-deerflow-only/test_release_renderer.py "$(pwd)"
```
禁止提交真实 `deploy.env`、数据库密码、模型 API Key、运行数据、生成配置、
Docker 镜像 tar 或最终离线 `.tar.gz`。仓库中的 `deploy.env` 只能包含明确的假地址
和示例凭据。