Files
deerflow-code/deploy/linux-docker-offline-deerflow-only/release-template/scripts/render_runtime.py
T

420 lines
17 KiB
Python

#!/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())