420 lines
17 KiB
Python
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())
|