feat: add frontend environment reference generation

- Introduced `frontend-env.reference.json` and `frontend-env.reference.md` to document frontend environment variables.
- Implemented `env-reference.mjs` script to extract and generate environment variable metadata from `web/env.ts`.
- Added tests for environment reference generation in `env-reference.spec.ts`.
This commit is contained in:
zhaohao1004 2026-04-21 21:26:28 +08:00
parent b7666af311
commit 1b49059231
11 changed files with 11529 additions and 4038 deletions

6
.gitignore vendored
View File

@ -245,3 +245,9 @@ scripts/stress-test/reports/
.qoder/*
.eslintcache
docker/.codex/
docker/.claude/
docker/defaults
docker/openspec
docker/AGENTS.md

View File

@ -0,0 +1,431 @@
"""Generate a backend env reference from the authoritative config model.
This module derives backend env input metadata from ``DifyConfig`` instead of
grepping individual files. The exported reference intentionally captures only
code-defined semantics and fallback defaults; it does not attempt to represent
deployment defaults or runtime-effective values.
"""
from __future__ import annotations
import inspect
import json
import logging
import re
from collections import defaultdict
from enum import Enum
from pathlib import Path
from types import UnionType
from typing import Any, TypedDict, get_args, get_origin
from pydantic import AliasChoices, BaseModel
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings
from .app_config import DifyConfig
_REPO_ROOT = Path(__file__).resolve().parents[2]
_API_ROOT = Path(__file__).resolve().parents[1]
_DOCS_ROOT = _API_ROOT / "docs"
_JSON_OUTPUT = _DOCS_ROOT / "backend-env.reference.json"
_MARKDOWN_OUTPUT = _DOCS_ROOT / "backend-env.reference.md"
_SENSITIVE_SUFFIXES = (
"_PASSWORD",
"_SECRET",
"_TOKEN",
"_API_KEY",
"_ACCESS_KEY",
"_SECRET_KEY",
"_PRIVATE_KEY",
)
logger = logging.getLogger(__name__)
_DESCRIPTION_REWRITES = {
"Duration in minutes for which a account deletion token remains valid": (
"Duration in minutes for which an account deletion token remains valid."
),
"whether to enable education identity": "Whether to enable education identity.",
(
"Granularity for async workflow scheduler, sometime, few users could block the queue "
"due to some time-consuming tasks, to avoid this, workflow can be suspended if needed, "
"to achievethis, a time-based checker is required, every granularity seconds, "
"the checker will check the workflow queue and suspend the workflow"
): (
"Granularity for the async workflow scheduler. Some users could block the queue with "
"time-consuming tasks, so workflows can be suspended when needed. A time-based checker "
"runs every granularity seconds to inspect the queue and suspend workflows."
),
(
"Base URL for file preview or download, used for frontend display and multi-model "
"inputsUrl is signed and has expiration time."
): (
"Base URL for file preview or download, used for frontend display and multi-model "
"inputs. The URL is signed and has an expiration time."
),
}
class BackendEnvVariableReference(TypedDict):
name: str
accepted_names: list[str]
group: str
type: str
description: str
code_default: Any | None
required: bool
applies_when: str | None
class BackendEnvReference(TypedDict):
schema_version: str
artifact_policy: str
authority: dict[str, str]
resolution: dict[str, list[str]]
variables: list[BackendEnvVariableReference]
def _config_classes() -> list[type[BaseSettings]]:
return [
cls
for cls in DifyConfig.__mro__[1:]
if inspect.isclass(cls)
and issubclass(cls, BaseSettings)
and cls is not BaseSettings
and cls.__module__.startswith("configs.")
]
def _owner_class_for_field(field_name: str) -> type[BaseSettings] | None:
for cls in _config_classes():
if field_name in getattr(cls, "__annotations__", {}):
return cls
return None
def _normalize_name(name: str) -> str:
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).replace("_", "-").lower()
def _group_for_owner(owner: type[BaseSettings]) -> str:
module_parts = owner.__module__.removeprefix("configs.").split(".")
if module_parts[-1].endswith("_config"):
module_parts = module_parts[:-1]
return ".".join([*module_parts, _normalize_name(owner.__name__.removesuffix("Config"))])
def _accepted_names(field_name: str, field_info: FieldInfo) -> list[str]:
alias = field_info.validation_alias
if isinstance(alias, AliasChoices):
names = [str(choice) for choice in alias.choices]
elif isinstance(alias, str):
names = [alias]
else:
names = [field_name]
if field_name not in names:
names.append(field_name)
return names
def _type_name(annotation: Any) -> str:
origin = get_origin(annotation)
if origin is None:
if annotation in {str, Any}:
return "string"
if annotation is bool:
return "boolean"
if annotation is int:
return "integer"
if annotation is float:
return "float"
if annotation is type(None):
return "null"
if inspect.isclass(annotation):
if issubclass(annotation, Enum):
return "enum"
if issubclass(annotation, str):
return "string"
if issubclass(annotation, bool):
return "boolean"
if issubclass(annotation, int):
return "integer"
if issubclass(annotation, float):
return "float"
return getattr(annotation, "__name__", str(annotation))
if origin is UnionType or str(origin).endswith("Union"):
args = [arg for arg in get_args(annotation) if arg is not type(None)]
rendered = " | ".join(_type_name(arg) for arg in args) if args else "null"
if len(args) != len(get_args(annotation)):
return f"{rendered} | null"
return rendered
if str(origin).endswith("Literal"):
values = ", ".join(repr(value) for value in get_args(annotation))
return f"literal[{values}]"
if str(origin).endswith("Annotated"):
args = get_args(annotation)
return _type_name(args[0]) if args else "annotated"
if origin in {list, tuple, set}:
args = get_args(annotation)
item_type = _type_name(args[0]) if args else "any"
return f"{origin.__name__}[{item_type}]"
return str(annotation)
def _serialize_default(value: Any) -> Any | None:
if value is None:
return None
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, Enum):
return value.value
if isinstance(value, Path):
return str(value)
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [_serialize_default(item) for item in value]
if isinstance(value, dict):
return {str(key): _serialize_default(item) for key, item in value.items()}
return str(value)
def _markdown_cell(value: Any | None) -> str:
if value is None:
return ""
text = str(value)
normalized = " ".join(text.split())
return normalized.replace("|", "\\|")
def _markdown_code_cell(value: Any | None, *, empty: str = "") -> str:
text = _markdown_cell(value)
if not text:
return empty
return f"`{text.replace('`', '\\`')}`"
def _render_code_default(value: Any | None) -> str:
if value is None:
return _markdown_code_cell(json.dumps("", ensure_ascii=False))
if isinstance(value, str):
return _markdown_code_cell(json.dumps(" ".join(value.split()), ensure_ascii=False))
return _markdown_code_cell(json.dumps(value, ensure_ascii=False))
def _normalize_description(description: str) -> str:
normalized = " ".join(description.split())
if not normalized:
return ""
rewritten = _DESCRIPTION_REWRITES.get(normalized, normalized)
rewritten = re.sub(r"(?<=[.!?])(?=[A-Z])", " ", rewritten)
rewritten = re.sub(r"(?<=\w),(?=[A-Za-z])", ", ", rewritten)
rewritten = re.sub(r"(?<=:)(?=https?://)", " ", rewritten)
rewritten = re.sub(r"(?<=\w)\((?=e\.g\.,)", " (", rewritten)
return rewritten
def _render_group_applicability_notes(variables: list[BackendEnvVariableReference]) -> list[str]:
applies_when_groups: dict[str, list[str]] = defaultdict(list)
for variable in variables:
applies_when = variable["applies_when"]
if applies_when:
applies_when_groups[applies_when].append(variable["name"])
if not applies_when_groups:
return []
if len(applies_when_groups) == 1 and len(next(iter(applies_when_groups.values()))) == len(variables):
applies_when = next(iter(applies_when_groups))
return [f"> Applies when: {_markdown_code_cell(applies_when)}", ""]
lines = ["Applies when:"]
for applies_when, names in sorted(applies_when_groups.items()):
joined_names = ", ".join(f"`{name}`" for name in sorted(names))
lines.append(f"- {joined_names}: {_markdown_code_cell(applies_when)}")
lines.append("")
return lines
def _provider_applies_when(owner: type[BaseSettings], field_name: str) -> str | None:
source_file = Path(inspect.getsourcefile(owner) or "")
source_name = source_file.name
storage_map = {
"amazon_s3_storage_config.py": "STORAGE_TYPE=s3",
"aliyun_oss_storage_config.py": "STORAGE_TYPE=aliyun-oss",
"azure_blob_storage_config.py": "STORAGE_TYPE=azure-blob",
"baidu_obs_storage_config.py": "STORAGE_TYPE=baidu-obs",
"clickzetta_volume_storage_config.py": "STORAGE_TYPE=clickzetta-volume",
"google_cloud_storage_config.py": "STORAGE_TYPE=google-storage",
"huawei_obs_storage_config.py": "STORAGE_TYPE=huawei-obs",
"oci_storage_config.py": "STORAGE_TYPE=oci-storage",
"opendal_storage_config.py": "STORAGE_TYPE=opendal",
"supabase_storage_config.py": "STORAGE_TYPE=supabase",
"tencent_cos_storage_config.py": "STORAGE_TYPE=tencent-cos",
"volcengine_tos_storage_config.py": "STORAGE_TYPE=volcengine-tos",
}
if field_name == "STORAGE_LOCAL_PATH":
return "STORAGE_TYPE=local"
if source_name in storage_map:
return storage_map[source_name]
vector_map = {
"analyticdb_config.py": "VECTOR_STORE=analyticdb",
"baidu_vector_config.py": "VECTOR_STORE=baidu_vector",
"chroma_config.py": "VECTOR_STORE=chroma",
"clickzetta_config.py": "VECTOR_STORE=clickzetta",
"couchbase_config.py": "VECTOR_STORE=couchbase",
"elasticsearch_config.py": "VECTOR_STORE=elasticsearch",
"hologres_config.py": "VECTOR_STORE=hologres",
"huawei_cloud_config.py": "VECTOR_STORE=huawei-cloud",
"iris_config.py": "VECTOR_STORE=iris",
"lindorm_config.py": "VECTOR_STORE=lindorm",
"matrixone_config.py": "VECTOR_STORE=matrixone",
"milvus_config.py": "VECTOR_STORE=milvus",
"myscale_config.py": "VECTOR_STORE=myscale",
"oceanbase_config.py": "VECTOR_STORE=oceanbase",
"opengauss_config.py": "VECTOR_STORE=opengauss",
"opensearch_config.py": "VECTOR_STORE=opensearch",
"oracle_config.py": "VECTOR_STORE=oracle",
"pgvector_config.py": "VECTOR_STORE=pgvector",
"pgvectors_config.py": "VECTOR_STORE=pgvectors",
"qdrant_config.py": "VECTOR_STORE=qdrant",
"relyt_config.py": "VECTOR_STORE=relyt",
"tablestore_config.py": "VECTOR_STORE=tablestore",
"tencent_vector_config.py": "VECTOR_STORE=tencent",
"tidb_on_qdrant_config.py": "VECTOR_STORE=tidb_on_qdrant",
"tidb_vector_config.py": "VECTOR_STORE=tidb_vector",
"upstash_config.py": "VECTOR_STORE=upstash",
"vastbase_vector_config.py": "VECTOR_STORE=vastbase",
"vikingdb_config.py": "VECTOR_STORE=vikingdb",
"weaviate_config.py": "VECTOR_STORE=weaviate",
"alibabacloud_mysql_config.py": "VECTOR_STORE=alibabacloud-mysql",
}
applies_when = vector_map.get(source_name)
if (
applies_when
and source_name == "elasticsearch_config.py"
and ("CLOUD" in field_name or field_name in {"ELASTICSEARCH_API_KEY", "ELASTICSEARCH_CA_CERTS"})
):
return f"{applies_when}; ELASTICSEARCH_USE_CLOUD=true"
return applies_when
def build_backend_env_reference() -> BackendEnvReference:
variables: list[BackendEnvVariableReference] = []
for field_name, field_info in sorted(DifyConfig.model_fields.items()):
if not field_name.isupper():
continue
owner = _owner_class_for_field(field_name)
if owner is None:
continue
variables.append(
{
"name": field_name,
"accepted_names": _accepted_names(field_name, field_info),
"group": _group_for_owner(owner),
"type": _type_name(field_info.annotation),
"description": field_info.description or "",
"code_default": None if field_info.is_required() else _serialize_default(field_info.default),
"required": field_info.is_required(),
"applies_when": _provider_applies_when(owner, field_name),
}
)
return {
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {
"kind": "backend-code-defaults",
"source_root": "api/configs",
"model": "configs.app_config.DifyConfig",
},
"resolution": {
"precedence": [
"init_settings",
"process_env",
"remote_settings",
"dotenv",
"file_secrets",
"toml",
"code_default",
]
},
"variables": variables,
}
def render_backend_env_reference_markdown(reference: BackendEnvReference) -> str:
grouped: dict[str, list[BackendEnvVariableReference]] = defaultdict(list)
for variable in reference["variables"]:
grouped[variable["group"]].append(variable)
lines = [
"# Backend Env Reference",
"",
"> Generated from `api/configs/**/*.py`. Do not edit manually.",
"",
"This reference documents backend env input semantics and code defaults only.",
"Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded.",
"",
"## Value Resolution Order",
"",
"```text",
" > ".join(reference["resolution"]["precedence"]),
"```",
"",
"Code defaults are fallback values only. Runtime process environment, remote settings, and dotenv values can override them.",
"",
]
for group in sorted(grouped):
lines.extend([f"## `{group}`", ""])
lines.extend(_render_group_applicability_notes(grouped[group]))
lines.append("| Name | Type | Default | Accepted Env Names | Description |")
lines.append("| --- | --- | --- | --- | --- |")
for variable in grouped[group]:
code_default = _render_code_default(variable["code_default"])
aliases = _markdown_code_cell(", ".join(variable["accepted_names"]))
description = _markdown_cell(_normalize_description(variable["description"]))
variable_type = _markdown_code_cell(variable["type"])
lines.append(
f"| `{variable['name']}` | {variable_type} | {code_default} | {aliases} | {description} |"
)
lines.append("")
return "\n".join(lines)
def write_backend_env_reference(
json_output: Path = _JSON_OUTPUT,
markdown_output: Path = _MARKDOWN_OUTPUT,
) -> tuple[Path, Path]:
reference = build_backend_env_reference()
json_output.parent.mkdir(parents=True, exist_ok=True)
markdown_output.parent.mkdir(parents=True, exist_ok=True)
json_output.write_text(json.dumps(reference, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
markdown_output.write_text(render_backend_env_reference_markdown(reference) + "\n", encoding="utf-8")
return json_output, markdown_output
def main() -> None:
json_output, markdown_output = write_backend_env_reference()
logger.info("Wrote %s", json_output.relative_to(_REPO_ROOT))
logger.info("Wrote %s", markdown_output.relative_to(_REPO_ROOT))
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,251 @@
import json
from configs.env_reference import (
build_backend_env_reference,
render_backend_env_reference_markdown,
)
def test_backend_env_reference_uses_backend_authority() -> None:
reference = build_backend_env_reference()
assert reference["authority"]["source_root"] == "api/configs"
assert reference["authority"]["model"] == "configs.app_config.DifyConfig"
assert reference["resolution"]["precedence"][-1] == "code_default"
def test_backend_env_reference_includes_aliases_and_defaults() -> None:
reference = build_backend_env_reference()
variables = {variable["name"]: variable for variable in reference["variables"]}
files_url = variables["FILES_URL"]
redis_host = variables["REDIS_HOST"]
assert "CONSOLE_API_URL" in files_url["accepted_names"]
assert redis_host["code_default"] == "localhost"
assert redis_host["group"] == "middleware.cache.redis"
assert "source_location" not in redis_host
assert "sensitive" not in redis_host
def test_backend_env_reference_excludes_computed_and_nested_fields() -> None:
reference = build_backend_env_reference()
names = {variable["name"] for variable in reference["variables"]}
assert "SQLALCHEMY_DATABASE_URI" not in names
assert "normalized_pubsub_redis_url" not in names
assert "project" not in names
def test_backend_env_reference_marks_provider_applicability() -> None:
reference = build_backend_env_reference()
variables = {variable["name"]: variable for variable in reference["variables"]}
assert variables["S3_ACCESS_KEY"]["applies_when"] == "STORAGE_TYPE=s3"
assert variables["STORAGE_LOCAL_PATH"]["applies_when"] == "STORAGE_TYPE=local"
def test_backend_env_reference_markdown_explains_code_default_scope() -> None:
reference = build_backend_env_reference()
markdown = render_backend_env_reference_markdown(reference)
assert "Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded." in markdown
assert "Code defaults are fallback values only." in markdown
assert "`REDIS_HOST`" in markdown
assert "| Name | Type | Default | Accepted Env Names | Description |" in markdown
assert "Code Default" not in markdown
assert "Required |" not in markdown
assert "Applies When |" not in markdown
assert "Source |" not in markdown
def test_backend_env_reference_markdown_normalizes_multiline_cells() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "EXAMPLE_ENV",
"accepted_names": ["EXAMPLE_ENV", "EXAMPLE_ALIAS"],
"group": "test.group",
"type": "string | null",
"description": "line one\nline two | extra",
"code_default": "value\nwith newline",
"applies_when": "MODE=demo\nENABLED=true",
"required": False,
}
],
}
)
assert "line one line two \\| extra" in markdown
assert "> Applies when: `MODE=demo ENABLED=true`" in markdown
assert "`string \\| null`" in markdown
assert '`"value with newline"`' in markdown
assert "\nline two" not in markdown
def test_backend_env_reference_markdown_groups_partial_applicability_notes() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "S3_ACCESS_KEY",
"accepted_names": ["S3_ACCESS_KEY"],
"group": "storage.s3",
"type": "string",
"description": "Access key",
"code_default": None,
"required": False,
"applies_when": "STORAGE_TYPE=s3",
},
{
"name": "S3_SECRET_KEY",
"accepted_names": ["S3_SECRET_KEY"],
"group": "storage.s3",
"type": "string",
"description": "Secret key",
"code_default": None,
"required": False,
"applies_when": "STORAGE_TYPE=s3",
},
{
"name": "STORAGE_ENDPOINT",
"accepted_names": ["STORAGE_ENDPOINT"],
"group": "storage.s3",
"type": "string | null",
"description": "Endpoint override",
"code_default": None,
"required": False,
"applies_when": None,
},
],
}
)
assert "Applies when:" in markdown
assert "- `S3_ACCESS_KEY`, `S3_SECRET_KEY`: `STORAGE_TYPE=s3`" in markdown
assert "Applies When |" not in markdown
def test_backend_env_reference_markdown_normalizes_awkward_descriptions() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "ENTERPRISE_ENABLED",
"accepted_names": ["ENTERPRISE_ENABLED"],
"group": "enterprise.feature",
"type": "boolean",
"description": (
"Enable or disable enterprise-level features.Before using, please contact "
"business@dify.ai by email to inquire about licensing matters."
),
"code_default": False,
"required": False,
"applies_when": None,
},
{
"name": "FILES_URL",
"accepted_names": ["FILES_URL", "CONSOLE_API_URL"],
"group": "feature.file-access",
"type": "string",
"description": (
"Base URL for file preview or download, used for frontend display and "
"multi-model inputsUrl is signed and has expiration time."
),
"code_default": "",
"required": False,
"applies_when": None,
},
],
}
)
assert "features. Before using, please contact business@dify.ai" in markdown
assert "multi-model inputs. The URL is signed and has an expiration time." in markdown
def test_backend_env_reference_markdown_renders_missing_defaults_explicitly() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "SENTRY_DSN",
"accepted_names": ["SENTRY_DSN"],
"group": "extra.sentry",
"type": "string | null",
"description": "Sentry DSN",
"code_default": None,
"required": False,
"applies_when": None,
}
],
}
)
row = '| `SENTRY_DSN` | `string \\| null` | `""` | `SENTRY_DSN` | Sentry DSN |'
assert row in markdown
assert row.count(" | ") == 4
def test_backend_env_reference_markdown_keeps_code_default_column_styling_consistent() -> None:
markdown = render_backend_env_reference_markdown(
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
"resolution": {"precedence": ["process_env", "code_default"]},
"variables": [
{
"name": "EMPTY_DEFAULT",
"accepted_names": ["EMPTY_DEFAULT"],
"group": "test.group",
"type": "string | null",
"description": "Empty default placeholder",
"code_default": None,
"required": False,
"applies_when": None,
},
{
"name": "STRING_DEFAULT",
"accepted_names": ["STRING_DEFAULT"],
"group": "test.group",
"type": "string",
"description": "Concrete string default",
"code_default": "value",
"required": False,
"applies_when": None,
},
],
}
)
assert '| `EMPTY_DEFAULT` | `string \\| null` | `""` | `EMPTY_DEFAULT` | Empty default placeholder |' in markdown
assert '| `STRING_DEFAULT` | `string` | `"value"` | `STRING_DEFAULT` | Concrete string default |' in markdown
def test_backend_env_reference_is_json_serializable() -> None:
reference = build_backend_env_reference()
rendered = json.dumps(reference)
assert '"schema_version": "1"' in rendered
assert '"resolution"' in rendered
assert '"source_location"' not in rendered
assert '"sensitive"' not in rendered

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,655 @@
{
"schema_version": "1",
"artifact_policy": "committed-generated-artifact",
"authority": {
"kind": "frontend-env-schema",
"source_root": "web",
"model": "web/env.ts"
},
"variables": [
{
"name": "NEXT_PUBLIC_ALLOW_EMBED",
"accepted_names": [
"NEXT_PUBLIC_ALLOW_EMBED"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "allowEmbed"
},
{
"name": "NEXT_PUBLIC_ALLOW_INLINE_STYLES",
"accepted_names": [
"NEXT_PUBLIC_ALLOW_INLINE_STYLES"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "Allow inline style attributes in Markdown rendering. Self-hosted opt-in for workflows using styled Jinja2 templates.",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "allowInlineStyles"
},
{
"name": "NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME",
"accepted_names": [
"NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "Allow rendering unsafe URLs which have \"data:\" scheme.",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "allowUnsafeDataScheme"
},
{
"name": "NEXT_PUBLIC_AMPLITUDE_API_KEY",
"accepted_names": [
"NEXT_PUBLIC_AMPLITUDE_API_KEY"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The API key of amplitude",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "amplitudeApiKey"
},
{
"name": "NEXT_PUBLIC_API_PREFIX",
"accepted_names": [
"NEXT_PUBLIC_API_PREFIX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "apiPrefix"
},
{
"name": "NEXT_PUBLIC_BASE_PATH",
"accepted_names": [
"NEXT_PUBLIC_BASE_PATH"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The base path for the application",
"code_default": "",
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "basePath"
},
{
"name": "NEXT_PUBLIC_BATCH_CONCURRENCY",
"accepted_names": [
"NEXT_PUBLIC_BATCH_CONCURRENCY"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "number of concurrency",
"code_default": 5,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "batchConcurrency"
},
{
"name": "NEXT_PUBLIC_COOKIE_DOMAIN",
"accepted_names": [
"NEXT_PUBLIC_COOKIE_DOMAIN"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "cookieDomain"
},
{
"name": "NEXT_PUBLIC_CSP_WHITELIST",
"accepted_names": [
"NEXT_PUBLIC_CSP_WHITELIST"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "cspWhitelist"
},
{
"name": "NEXT_PUBLIC_DEPLOY_ENV",
"accepted_names": [
"NEXT_PUBLIC_DEPLOY_ENV"
],
"runtime": "client",
"visibility": "browser-public",
"type": "literal[\"DEVELOPMENT\", \"PRODUCTION\", \"TESTING\"]",
"description": "For production release, change this to PRODUCTION",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "deployEnv"
},
{
"name": "NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON",
"accepted_names": [
"NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "disableUploadImageAsIcon"
},
{
"name": "NEXT_PUBLIC_EDITION",
"accepted_names": [
"NEXT_PUBLIC_EDITION"
],
"runtime": "client",
"visibility": "browser-public",
"type": "literal[\"SELF_HOSTED\", \"CLOUD\"]",
"description": "The deployment edition, SELF_HOSTED",
"code_default": "SELF_HOSTED",
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "edition"
},
{
"name": "NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX",
"accepted_names": [
"NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "Enable inline LaTeX rendering with single dollar signs ($...$) Default is false for security reasons to prevent conflicts with regular text",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "enableSingleDollarLatex"
},
{
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL",
"accepted_names": [
"NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": true,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "enableWebsiteFirecrawl"
},
{
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER",
"accepted_names": [
"NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": true,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "enableWebsiteJinareader"
},
{
"name": "NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL",
"accepted_names": [
"NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "enableWebsiteWatercrawl"
},
{
"name": "NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH",
"accepted_names": [
"NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "The maximum number of tokens for segmentation",
"code_default": 4000,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "indexingMaxSegmentationTokensLength"
},
{
"name": "NEXT_PUBLIC_IS_MARKETPLACE",
"accepted_names": [
"NEXT_PUBLIC_IS_MARKETPLACE"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "isMarketplace"
},
{
"name": "NEXT_PUBLIC_LOOP_NODE_MAX_COUNT",
"accepted_names": [
"NEXT_PUBLIC_LOOP_NODE_MAX_COUNT"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "Maximum loop count in the workflow",
"code_default": 100,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "loopNodeMaxCount"
},
{
"name": "NEXT_PUBLIC_MAINTENANCE_NOTICE",
"accepted_names": [
"NEXT_PUBLIC_MAINTENANCE_NOTICE"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "maintenanceNotice"
},
{
"name": "NEXT_PUBLIC_MARKETPLACE_API_PREFIX",
"accepted_names": [
"NEXT_PUBLIC_MARKETPLACE_API_PREFIX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The API PREFIX for MARKETPLACE",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "marketplaceApiPrefix"
},
{
"name": "NEXT_PUBLIC_MARKETPLACE_URL_PREFIX",
"accepted_names": [
"NEXT_PUBLIC_MARKETPLACE_URL_PREFIX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The URL for MARKETPLACE",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "marketplaceUrlPrefix"
},
{
"name": "NEXT_PUBLIC_MAX_ITERATIONS_NUM",
"accepted_names": [
"NEXT_PUBLIC_MAX_ITERATIONS_NUM"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "The maximum number of iterations for agent setting",
"code_default": 99,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "maxIterationsNum"
},
{
"name": "NEXT_PUBLIC_MAX_PARALLEL_LIMIT",
"accepted_names": [
"NEXT_PUBLIC_MAX_PARALLEL_LIMIT"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "Maximum number of Parallelism branches in the workflow",
"code_default": 10,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "maxParallelLimit"
},
{
"name": "NEXT_PUBLIC_MAX_TOOLS_NUM",
"accepted_names": [
"NEXT_PUBLIC_MAX_TOOLS_NUM"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "Maximum number of tools in the agent/workflow",
"code_default": 10,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "maxToolsNum"
},
{
"name": "NEXT_PUBLIC_MAX_TREE_DEPTH",
"accepted_names": [
"NEXT_PUBLIC_MAX_TREE_DEPTH"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "The maximum number of tree node depth for workflow",
"code_default": 50,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "maxTreeDepth"
},
{
"name": "NEXT_PUBLIC_PUBLIC_API_PREFIX",
"accepted_names": [
"NEXT_PUBLIC_PUBLIC_API_PREFIX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from console or api domain. example: http://udify.app/api",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "publicApiPrefix"
},
{
"name": "NEXT_PUBLIC_SENTRY_DSN",
"accepted_names": [
"NEXT_PUBLIC_SENTRY_DSN"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "SENTRY",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "sentryDsn"
},
{
"name": "NEXT_PUBLIC_SITE_ABOUT",
"accepted_names": [
"NEXT_PUBLIC_SITE_ABOUT"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "siteAbout"
},
{
"name": "NEXT_PUBLIC_SOCKET_URL",
"accepted_names": [
"NEXT_PUBLIC_SOCKET_URL"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "socketUrl"
},
{
"name": "NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS",
"accepted_names": [
"NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "supportEmailAddress"
},
{
"name": "NEXT_PUBLIC_SUPPORT_MAIL_LOGIN",
"accepted_names": [
"NEXT_PUBLIC_SUPPORT_MAIL_LOGIN"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "supportMailLogin"
},
{
"name": "NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS",
"accepted_names": [
"NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "The timeout for the text generation in millisecond",
"code_default": 60000,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "textGenerationTimeoutMs"
},
{
"name": "NEXT_PUBLIC_TOP_K_MAX_VALUE",
"accepted_names": [
"NEXT_PUBLIC_TOP_K_MAX_VALUE"
],
"runtime": "client",
"visibility": "browser-public",
"type": "integer",
"description": "The maximum number of top-k value for RAG.",
"code_default": 10,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "topKMaxValue"
},
{
"name": "NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON",
"accepted_names": [
"NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON"
],
"runtime": "client",
"visibility": "browser-public",
"type": "boolean",
"description": "Disable Upload Image as WebApp icon default is false",
"code_default": false,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "uploadImageAsIcon"
},
{
"name": "NEXT_PUBLIC_WEB_PREFIX",
"accepted_names": [
"NEXT_PUBLIC_WEB_PREFIX"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "webPrefix"
},
{
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskFieldIdEmail"
},
{
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskFieldIdEnvironment"
},
{
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskFieldIdPlan"
},
{
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskFieldIdVersion"
},
{
"name": "NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskFieldIdWorkspaceId"
},
{
"name": "NEXT_PUBLIC_ZENDESK_WIDGET_KEY",
"accepted_names": [
"NEXT_PUBLIC_ZENDESK_WIDGET_KEY"
],
"runtime": "client",
"visibility": "browser-public",
"type": "string",
"description": "",
"code_default": null,
"required": false,
"injection_mode": "body-dataset",
"dataset_key": "zendeskWidgetKey"
},
{
"name": "INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH",
"accepted_names": [
"INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH"
],
"runtime": "server",
"visibility": "server-only",
"type": "integer",
"description": "Maximum length of segmentation tokens for indexing",
"code_default": 4000,
"required": false,
"injection_mode": "process-env",
"dataset_key": null
},
{
"name": "NEXT_TELEMETRY_DISABLED",
"accepted_names": [
"NEXT_TELEMETRY_DISABLED"
],
"runtime": "server",
"visibility": "server-only",
"type": "boolean",
"description": "Disable Next.js Telemetry (https://nextjs.org/telemetry)",
"code_default": null,
"required": false,
"injection_mode": "process-env",
"dataset_key": null
},
{
"name": "PORT",
"accepted_names": [
"PORT"
],
"runtime": "server",
"visibility": "server-only",
"type": "integer",
"description": "",
"code_default": 3000,
"required": false,
"injection_mode": "process-env",
"dataset_key": null
},
{
"name": "TEXT_GENERATION_TIMEOUT_MS",
"accepted_names": [
"TEXT_GENERATION_TIMEOUT_MS"
],
"runtime": "server",
"visibility": "server-only",
"type": "integer",
"description": "The timeout for the text generation in millisecond",
"code_default": 60000,
"required": false,
"injection_mode": "process-env",
"dataset_key": null
}
]
}

View File

@ -0,0 +1,64 @@
# Frontend Env Reference
> Generated from `web/env.ts`. Do not edit manually.
This reference documents frontend application env semantics and code defaults only.
Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.
Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.
## Browser-Public Variables
| Name | Visibility | Type | Default | Injection | Dataset Key | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `NEXT_PUBLIC_ALLOW_EMBED` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowEmbed` | Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking |
| `NEXT_PUBLIC_ALLOW_INLINE_STYLES` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowInlineStyles` | Allow inline style attributes in Markdown rendering. Self-hosted opt-in for workflows using styled Jinja2 templates. |
| `NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME` | `browser-public` | `boolean` | `false` | `body-dataset` | `allowUnsafeDataScheme` | Allow rendering unsafe URLs which have "data:" scheme. |
| `NEXT_PUBLIC_AMPLITUDE_API_KEY` | `browser-public` | `string` | `""` | `body-dataset` | `amplitudeApiKey` | The API key of amplitude |
| `NEXT_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `apiPrefix` | The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api |
| `NEXT_PUBLIC_BASE_PATH` | `browser-public` | `string` | `""` | `body-dataset` | `basePath` | The base path for the application |
| `NEXT_PUBLIC_BATCH_CONCURRENCY` | `browser-public` | `integer` | `5` | `body-dataset` | `batchConcurrency` | number of concurrency |
| `NEXT_PUBLIC_COOKIE_DOMAIN` | `browser-public` | `string` | `""` | `body-dataset` | `cookieDomain` | When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. |
| `NEXT_PUBLIC_CSP_WHITELIST` | `browser-public` | `string` | `""` | `body-dataset` | `cspWhitelist` | CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP |
| `NEXT_PUBLIC_DEPLOY_ENV` | `browser-public` | `literal["DEVELOPMENT", "PRODUCTION", "TESTING"]` | `""` | `body-dataset` | `deployEnv` | For production release, change this to PRODUCTION |
| `NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON` | `browser-public` | `boolean` | `false` | `body-dataset` | `disableUploadImageAsIcon` | |
| `NEXT_PUBLIC_EDITION` | `browser-public` | `literal["SELF_HOSTED", "CLOUD"]` | `"SELF_HOSTED"` | `body-dataset` | `edition` | The deployment edition, SELF_HOSTED |
| `NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX` | `browser-public` | `boolean` | `false` | `body-dataset` | `enableSingleDollarLatex` | Enable inline LaTeX rendering with single dollar signs ($...$) Default is false for security reasons to prevent conflicts with regular text |
| `NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL` | `browser-public` | `boolean` | `true` | `body-dataset` | `enableWebsiteFirecrawl` | |
| `NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER` | `browser-public` | `boolean` | `true` | `body-dataset` | `enableWebsiteJinareader` | |
| `NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL` | `browser-public` | `boolean` | `false` | `body-dataset` | `enableWebsiteWatercrawl` | |
| `NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH` | `browser-public` | `integer` | `4000` | `body-dataset` | `indexingMaxSegmentationTokensLength` | The maximum number of tokens for segmentation |
| `NEXT_PUBLIC_IS_MARKETPLACE` | `browser-public` | `boolean` | `false` | `body-dataset` | `isMarketplace` | |
| `NEXT_PUBLIC_LOOP_NODE_MAX_COUNT` | `browser-public` | `integer` | `100` | `body-dataset` | `loopNodeMaxCount` | Maximum loop count in the workflow |
| `NEXT_PUBLIC_MAINTENANCE_NOTICE` | `browser-public` | `string` | `""` | `body-dataset` | `maintenanceNotice` | |
| `NEXT_PUBLIC_MARKETPLACE_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `marketplaceApiPrefix` | The API PREFIX for MARKETPLACE |
| `NEXT_PUBLIC_MARKETPLACE_URL_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `marketplaceUrlPrefix` | The URL for MARKETPLACE |
| `NEXT_PUBLIC_MAX_ITERATIONS_NUM` | `browser-public` | `integer` | `99` | `body-dataset` | `maxIterationsNum` | The maximum number of iterations for agent setting |
| `NEXT_PUBLIC_MAX_PARALLEL_LIMIT` | `browser-public` | `integer` | `10` | `body-dataset` | `maxParallelLimit` | Maximum number of Parallelism branches in the workflow |
| `NEXT_PUBLIC_MAX_TOOLS_NUM` | `browser-public` | `integer` | `10` | `body-dataset` | `maxToolsNum` | Maximum number of tools in the agent/workflow |
| `NEXT_PUBLIC_MAX_TREE_DEPTH` | `browser-public` | `integer` | `50` | `body-dataset` | `maxTreeDepth` | The maximum number of tree node depth for workflow |
| `NEXT_PUBLIC_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `publicApiPrefix` | The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from console or api domain. example: http://udify.app/api |
| `NEXT_PUBLIC_SENTRY_DSN` | `browser-public` | `string` | `""` | `body-dataset` | `sentryDsn` | SENTRY |
| `NEXT_PUBLIC_SITE_ABOUT` | `browser-public` | `string` | `""` | `body-dataset` | `siteAbout` | |
| `NEXT_PUBLIC_SOCKET_URL` | `browser-public` | `string` | `""` | `body-dataset` | `socketUrl` | |
| `NEXT_PUBLIC_SUPPORT_EMAIL_ADDRESS` | `browser-public` | `string` | `""` | `body-dataset` | `supportEmailAddress` | |
| `NEXT_PUBLIC_SUPPORT_MAIL_LOGIN` | `browser-public` | `boolean` | `false` | `body-dataset` | `supportMailLogin` | |
| `NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS` | `browser-public` | `integer` | `60000` | `body-dataset` | `textGenerationTimeoutMs` | The timeout for the text generation in millisecond |
| `NEXT_PUBLIC_TOP_K_MAX_VALUE` | `browser-public` | `integer` | `10` | `body-dataset` | `topKMaxValue` | The maximum number of top-k value for RAG. |
| `NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON` | `browser-public` | `boolean` | `false` | `body-dataset` | `uploadImageAsIcon` | Disable Upload Image as WebApp icon default is false |
| `NEXT_PUBLIC_WEB_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `webPrefix` | |
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdEmail` | |
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdEnvironment` | |
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdPlan` | |
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdVersion` | |
| `NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskFieldIdWorkspaceId` | |
| `NEXT_PUBLIC_ZENDESK_WIDGET_KEY` | `browser-public` | `string` | `""` | `body-dataset` | `zendeskWidgetKey` | |
## Server-Only Variables
| Name | Visibility | Type | Default | Injection | Dataset Key | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH` | `server-only` | `integer` | `4000` | `process-env` | | Maximum length of segmentation tokens for indexing |
| `NEXT_TELEMETRY_DISABLED` | `server-only` | `boolean` | `""` | `process-env` | | Disable Next.js Telemetry (https://nextjs.org/telemetry) |
| `PORT` | `server-only` | `integer` | `3000` | `process-env` | | |
| `TEXT_GENERATION_TIMEOUT_MS` | `server-only` | `integer` | `60000` | `process-env` | | The timeout for the text generation in millisecond |

View File

@ -30,6 +30,7 @@
"dev:inspect": "next dev --inspect",
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
"dev:vinext": "vinext dev",
"env:reference": "node ./scripts/env-reference.mjs",
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
"gen-icons": "pnpm --filter @dify/iconify-collections generate && node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
"i18n:check": "tsx ./scripts/check-i18n.js",

View File

@ -0,0 +1,73 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildFrontendEnvReference, renderFrontendEnvReferenceMarkdown } from '../env-reference.mjs'
describe('frontend env reference', () => {
it('should derive frontend authority metadata from web/env.ts only', () => {
// Arrange
const reference = buildFrontendEnvReference()
const variables = Object.fromEntries(reference.variables.map(variable => [variable.name, variable]))
// Assert
expect(reference.authority.source_root).toBe('web')
expect(reference.authority.model).toBe('web/env.ts')
expect(variables.NEXT_PUBLIC_API_PREFIX).toBeDefined()
expect(variables.HONO_PROXY_HOST).toBeUndefined()
expect(variables.HONO_CONSOLE_API_PROXY_TARGET).toBeUndefined()
})
it('should export browser-public dataset metadata for client env variables', () => {
// Arrange
const reference = buildFrontendEnvReference()
const variable = reference.variables.find(item => item.name === 'NEXT_PUBLIC_API_PREFIX')
// Assert
expect(variable).toEqual({
name: 'NEXT_PUBLIC_API_PREFIX',
accepted_names: ['NEXT_PUBLIC_API_PREFIX'],
runtime: 'client',
visibility: 'browser-public',
type: 'string',
description: 'The base URL of console application, refers to the Console base URL of WEB service if console domain is different from api or web app domain. example: http://cloud.dify.ai/console/api',
code_default: null,
required: false,
injection_mode: 'body-dataset',
dataset_key: 'apiPrefix',
})
})
it('should export server-only process env metadata for server variables', () => {
// Arrange
const reference = buildFrontendEnvReference()
const variable = reference.variables.find(item => item.name === 'PORT')
// Assert
expect(variable).toEqual({
name: 'PORT',
accepted_names: ['PORT'],
runtime: 'server',
visibility: 'server-only',
type: 'integer',
description: '',
code_default: 3000,
required: false,
injection_mode: 'process-env',
dataset_key: null,
})
})
it('should render markdown that excludes deploy defaults and explains the scope', () => {
// Arrange
const markdown = renderFrontendEnvReferenceMarkdown(buildFrontendEnvReference())
// Assert
expect(markdown).toContain('> Generated from `web/env.ts`. Do not edit manually.')
expect(markdown).toContain('Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.')
expect(markdown).toContain('Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.')
expect(markdown).toContain('| `NEXT_PUBLIC_API_PREFIX` | `browser-public` | `string` | `""` | `body-dataset` | `apiPrefix` |')
expect(markdown).toContain('| `PORT` | `server-only` | `integer` | `3000` | `process-env` | | |')
expect(markdown).not.toContain('HONO_PROXY_HOST')
})
})

View File

@ -0,0 +1,377 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const envSourcePath = path.join(projectRoot, 'env.ts')
const docsRoot = path.join(projectRoot, 'docs')
const jsonOutputPath = path.join(docsRoot, 'frontend-env.reference.json')
const markdownOutputPath = path.join(docsRoot, 'frontend-env.reference.md')
/**
* @typedef {'client' | 'server'} FrontendEnvRuntime
* @typedef {'browser-public' | 'server-only'} FrontendEnvVisibility
* @typedef {'body-dataset' | 'process-env'} FrontendEnvInjectionMode
*
* @typedef {{
* name: string
* accepted_names: string[]
* runtime: FrontendEnvRuntime
* visibility: FrontendEnvVisibility
* type: string
* description: string
* code_default: string | number | boolean | null
* required: boolean
* injection_mode: FrontendEnvInjectionMode
* dataset_key: string | null
* }} FrontendEnvVariableReference
*
* @typedef {{
* schema_version: string
* artifact_policy: string
* authority: {
* kind: string
* source_root: string
* model: string
* }
* variables: FrontendEnvVariableReference[]
* }} FrontendEnvReference
*/
const CLIENT_SCHEMA_START = 'const clientSchema = {'
const CLIENT_SCHEMA_END = '} satisfies ClientSchema'
const SERVER_SCHEMA_START = ' server: {'
const SERVER_SCHEMA_END = ' },\n client: clientSchema,'
const RUNTIME_ENV_START = ' experimental__runtimeEnv: {'
const RUNTIME_ENV_END = ' },\n emptyStringAsUndefined: true,'
const COMMENT_START = '/**'
const COMMENT_END = '*/'
const PROPERTY_PATTERN = /^\s*([A-Z][A-Z0-9_]+):\s*(.+),\s*$/
const DATASET_PATTERN = /getRuntimeEnvFromBody\('([^']+)'\)/
const DEFAULT_PATTERN = /\.default\(([^)]*)\)/
const ENUM_PATTERN = /z\.enum\(\[(.+?)\]\)/
const STRING_LITERAL_PATTERN = /^(['"])(.*)\1$/
/**
* @param {string} source
* @param {string} startMarker
* @param {string} endMarker
*/
function extractBlock(source, startMarker, endMarker) {
const startIndex = source.indexOf(startMarker)
if (startIndex === -1)
throw new Error(`Missing start marker: ${startMarker}`)
const contentStart = startIndex + startMarker.length
const endIndex = source.indexOf(endMarker, contentStart)
if (endIndex === -1)
throw new Error(`Missing end marker: ${endMarker}`)
return source.slice(contentStart, endIndex)
}
/**
* @param {string} comment
*/
function normalizeDescription(comment) {
return comment
.split('\n')
.map((line) => {
const trimmedLine = line.trim()
if (trimmedLine === '/**' || trimmedLine === '*/')
return ''
return trimmedLine
.replace(/^\/\*\*\s?/, '')
.replace(/^\*\s?/, '')
.replace(/\s*\*\/$/, '')
})
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* @param {string} block
*/
function parseSchemaEntries(block) {
/** @type {{ name: string, expression: string, description: string }[]} */
const entries = []
const lines = block.split('\n')
/** @type {string[]} */
let commentBuffer = []
let isCollectingComment = false
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine)
continue
if (trimmedLine.startsWith(COMMENT_START)) {
isCollectingComment = true
commentBuffer = [trimmedLine]
if (trimmedLine.endsWith(COMMENT_END))
isCollectingComment = false
continue
}
if (isCollectingComment) {
commentBuffer.push(trimmedLine)
if (trimmedLine.endsWith(COMMENT_END))
isCollectingComment = false
continue
}
const propertyMatch = line.match(PROPERTY_PATTERN)
if (!propertyMatch)
continue
const [, name, expression] = propertyMatch
entries.push({
name,
expression: expression.trim(),
description: normalizeDescription(commentBuffer.join('\n')),
})
commentBuffer = []
}
return entries
}
/**
* @param {string} block
*/
function parseRuntimeDatasetKeys(block) {
/** @type {Map<string, string>} */
const datasetKeys = new Map()
for (const line of block.split('\n')) {
const propertyMatch = line.match(PROPERTY_PATTERN)
if (!propertyMatch)
continue
const [, name, expression] = propertyMatch
const datasetMatch = expression.match(DATASET_PATTERN)
if (datasetMatch)
datasetKeys.set(name, datasetMatch[1])
}
return datasetKeys
}
/**
* @param {string} literal
* @returns {string | number | boolean | null}
*/
function parseDefaultLiteral(literal) {
const trimmedLiteral = literal.trim()
if (!trimmedLiteral)
return null
if (trimmedLiteral === 'true')
return true
if (trimmedLiteral === 'false')
return false
if (/^-?\d+$/.test(trimmedLiteral))
return Number(trimmedLiteral)
const stringMatch = trimmedLiteral.match(STRING_LITERAL_PATTERN)
if (stringMatch)
return stringMatch[2]
return null
}
/**
* @param {string} expression
*/
function inferType(expression) {
if (expression.includes('coercedBoolean'))
return 'boolean'
if (expression.includes('coercedNumber'))
return 'integer'
const enumMatch = expression.match(ENUM_PATTERN)
if (enumMatch) {
const values = Array.from(enumMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g))
.map(match => match[1] || match[2])
return `literal[${values.map(value => JSON.stringify(value)).join(', ')}]`
}
if (expression.includes('z.email(') || expression.includes('z.url(') || expression.includes('z.string('))
return 'string'
if (expression.includes('z.literal(')) {
const literalMatch = expression.match(/z\.literal\(([^)]*)\)/)
if (literalMatch)
return `literal[${JSON.stringify(parseDefaultLiteral(literalMatch[1]))}]`
}
return 'unknown'
}
/**
* @param {string} expression
*/
function inferDefault(expression) {
const defaultMatch = expression.match(DEFAULT_PATTERN)
if (!defaultMatch)
return null
return parseDefaultLiteral(defaultMatch[1])
}
/**
* @param {string} expression
*/
function inferRequired(expression) {
return !expression.includes('.optional()') && !expression.includes('.default(')
}
/**
* @param {ReturnType<typeof parseSchemaEntries>[number]} entry
* @param {Map<string, string>} datasetKeys
* @returns {FrontendEnvVariableReference}
*/
function toClientVariable(entry, datasetKeys) {
return {
name: entry.name,
accepted_names: [entry.name],
runtime: 'client',
visibility: 'browser-public',
type: inferType(entry.expression),
description: entry.description,
code_default: inferDefault(entry.expression),
required: inferRequired(entry.expression),
injection_mode: 'body-dataset',
dataset_key: datasetKeys.get(entry.name) || null,
}
}
/**
* @param {ReturnType<typeof parseSchemaEntries>[number]} entry
* @returns {FrontendEnvVariableReference}
*/
function toServerVariable(entry) {
return {
name: entry.name,
accepted_names: [entry.name],
runtime: 'server',
visibility: 'server-only',
type: inferType(entry.expression),
description: entry.description,
code_default: inferDefault(entry.expression),
required: inferRequired(entry.expression),
injection_mode: 'process-env',
dataset_key: null,
}
}
/**
* @param {string | number | boolean | null} value
*/
function renderDefault(value) {
if (value === null)
return '`""`'
return `\`${JSON.stringify(value)}\``
}
/**
* @param {string | null} value
*/
function markdownCodeCell(value) {
if (!value)
return ''
return `\`${String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`')}\``
}
/**
* @param {string} value
*/
function markdownCell(value) {
return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
}
/**
* @param {FrontendEnvReference} reference
*/
export function renderFrontendEnvReferenceMarkdown(reference) {
/** @type {Record<FrontendEnvRuntime, FrontendEnvVariableReference[]>} */
const grouped = {
client: [],
server: [],
}
for (const variable of reference.variables)
grouped[variable.runtime].push(variable)
const lines = [
'# Frontend Env Reference',
'',
'> Generated from `web/env.ts`. Do not edit manually.',
'',
'This reference documents frontend application env semantics and code defaults only.',
'Deploy-time defaults, `.env.example`, Docker files, and runtime-effective values are intentionally excluded.',
'Only env declared in `web/env.ts` is included. Dev-only tooling env outside that file is excluded.',
'',
]
for (const runtime of ['client', 'server']) {
const variables = grouped[/** @type {FrontendEnvRuntime} */ (runtime)]
if (!variables.length)
continue
lines.push(runtime === 'client' ? '## Browser-Public Variables' : '## Server-Only Variables')
lines.push('')
lines.push('| Name | Visibility | Type | Default | Injection | Dataset Key | Description |')
lines.push('| --- | --- | --- | --- | --- | --- | --- |')
for (const variable of variables) {
lines.push(
`| \`${variable.name}\` | ${markdownCodeCell(variable.visibility)} | ${markdownCodeCell(variable.type)} | ${renderDefault(variable.code_default)} | ${markdownCodeCell(variable.injection_mode)} | ${markdownCodeCell(variable.dataset_key)} | ${markdownCell(variable.description)} |`,
)
}
lines.push('')
}
return lines.join('\n')
}
export function buildFrontendEnvReference() {
const source = readFileSync(envSourcePath, 'utf8')
const clientEntries = parseSchemaEntries(extractBlock(source, CLIENT_SCHEMA_START, CLIENT_SCHEMA_END))
const serverEntries = parseSchemaEntries(extractBlock(source, SERVER_SCHEMA_START, SERVER_SCHEMA_END))
const datasetKeys = parseRuntimeDatasetKeys(extractBlock(source, RUNTIME_ENV_START, RUNTIME_ENV_END))
return {
schema_version: '1',
artifact_policy: 'committed-generated-artifact',
authority: {
kind: 'frontend-env-schema',
source_root: 'web',
model: 'web/env.ts',
},
variables: [
...clientEntries.map(entry => toClientVariable(entry, datasetKeys)),
...serverEntries.map(toServerVariable),
],
}
}
export function writeFrontendEnvReference() {
const reference = buildFrontendEnvReference()
mkdirSync(docsRoot, { recursive: true })
writeFileSync(jsonOutputPath, `${JSON.stringify(reference, null, 2)}\n`, 'utf8')
writeFileSync(markdownOutputPath, `${renderFrontendEnvReferenceMarkdown(reference)}\n`, 'utf8')
return {
jsonOutputPath,
markdownOutputPath,
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { jsonOutputPath: jsonOutput, markdownOutputPath: markdownOutput } = writeFrontendEnvReference()
console.log(`Wrote ${path.relative(projectRoot, jsonOutput)}`)
console.log(`Wrote ${path.relative(projectRoot, markdownOutput)}`)
}