mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 20:20:08 +08:00
refactor(i18n): content-hash component keys and normalize frontend lookup
Backend locale keys for components now use a hybrid format:
components.{norm_name}.{field_path}.{sha256[:8]}
e.g. components.prompttemplate.display_name.8cd80ebe
The 8-char hash suffix is derived from the English source value, so any
change to a component string produces a new key, forcing GP to issue a
fresh translation. The human-readable prefix keeps keys debuggable.
Component names are normalized (spaces removed, lowercased) in both the
key prefix and the runtime hash computation, so renames like
"PromptTemplate" → "Prompt Template" don't break existing translations.
Frontend syncNodeTranslations() now builds a normalized lookup map so
that nodes stored with old-style type names (e.g. "PromptTemplate") are
correctly resolved against the live registry key ("Prompt Template"),
fixing untranslated Prompt Template nodes in starter template flows.
Also adds chunked uploading to upload_backend_strings.py to avoid
read timeouts when pushing large payloads to GP.
This commit is contained in:
@ -4,11 +4,16 @@ Walks the lfx.components package, reads class-level display_name/description
|
||||
and field-level display_names directly from component class definitions
|
||||
(no running server needed), and writes a flat GP-compatible JSON file.
|
||||
|
||||
Output format (flat dot-notation, same as frontend en.json):
|
||||
"components.ChatInput.display_name": "Chat Input"
|
||||
"components.ChatInput.description": "Get chat inputs from the Playground."
|
||||
"components.ChatInput.inputs.input_value.display_name": "Input Text"
|
||||
"components.ChatInput.outputs.message.display_name": "Chat Message"
|
||||
Output format — hybrid key: human-readable path + content-hash suffix:
|
||||
"components.chatinput.display_name.a1b2c3d4": "Chat Input"
|
||||
"components.chatinput.description.f9e8d7c6": "Get chat inputs from the Playground."
|
||||
"components.chatinput.inputs.input_value.display_name.12345678": "Input Text"
|
||||
"components.chatinput.outputs.message.display_name.abcdef01": "Chat Message"
|
||||
|
||||
The norm_name is the component registry key lowercased with spaces removed.
|
||||
The 8-char suffix is SHA-256(english_value)[:8]. When an English string
|
||||
changes, its hash changes, the old key is orphaned, and GP issues a fresh
|
||||
translation for the new key on the next upload/download cycle.
|
||||
|
||||
Usage:
|
||||
# From repo root with the backend virtualenv active:
|
||||
@ -21,6 +26,7 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import pkgutil
|
||||
@ -36,6 +42,24 @@ def _safe_key(name: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower()
|
||||
|
||||
|
||||
def _normalize_component_key(name: str) -> str:
|
||||
"""Normalize component name: remove spaces and lowercase.
|
||||
|
||||
Keeps the key prefix stable across renames like "PromptTemplate" → "Prompt Template".
|
||||
"""
|
||||
return name.replace(" ", "").lower()
|
||||
|
||||
|
||||
def _content_hash(english: str) -> str:
|
||||
"""Return first 8 hex chars of SHA-256(english) — used as the key suffix."""
|
||||
return hashlib.sha256(english.encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def _component_field_key(norm_name: str, field_path: str, english: str) -> str:
|
||||
"""Build locale key: components.{norm_name}.{field_path}.{sha256[:8]}"""
|
||||
return f"components.{norm_name}.{field_path}.{_content_hash(english)}"
|
||||
|
||||
|
||||
def collect_strings() -> dict[str, str]:
|
||||
"""Walk lfx.components and extract all translatable display_name strings."""
|
||||
try:
|
||||
@ -80,11 +104,13 @@ def collect_strings() -> dict[str, str]:
|
||||
continue
|
||||
seen_names.add(component_key)
|
||||
|
||||
norm_key = _normalize_component_key(component_key)
|
||||
|
||||
# Tier 1 — component-level
|
||||
flat[f"components.{component_key}.display_name"] = display_name
|
||||
flat[_component_field_key(norm_key, "display_name", display_name)] = display_name
|
||||
description = getattr(cls, "description", "") or ""
|
||||
if isinstance(description, str) and description:
|
||||
flat[f"components.{component_key}.description"] = description
|
||||
flat[_component_field_key(norm_key, "description", description)] = description
|
||||
|
||||
# Tier 2 — input field display_names, info, and placeholder
|
||||
for inp in getattr(cls, "inputs", []) or []:
|
||||
@ -94,11 +120,11 @@ def collect_strings() -> dict[str, str]:
|
||||
field_placeholder = getattr(inp, "placeholder", None)
|
||||
if isinstance(field_name, str) and field_name:
|
||||
if isinstance(field_display, str) and field_display:
|
||||
flat[f"components.{component_key}.inputs.{field_name}.display_name"] = field_display
|
||||
flat[_component_field_key(norm_key, f"inputs.{field_name}.display_name", field_display)] = field_display
|
||||
if isinstance(field_info, str) and field_info:
|
||||
flat[f"components.{component_key}.inputs.{field_name}.info"] = field_info
|
||||
flat[_component_field_key(norm_key, f"inputs.{field_name}.info", field_info)] = field_info
|
||||
if isinstance(field_placeholder, str) and field_placeholder:
|
||||
flat[f"components.{component_key}.inputs.{field_name}.placeholder"] = field_placeholder
|
||||
flat[_component_field_key(norm_key, f"inputs.{field_name}.placeholder", field_placeholder)] = field_placeholder
|
||||
|
||||
# Tier 2 — output display_names and info
|
||||
for out in getattr(cls, "outputs", []) or []:
|
||||
@ -107,9 +133,9 @@ def collect_strings() -> dict[str, str]:
|
||||
out_info = getattr(out, "info", None)
|
||||
if isinstance(out_name, str) and out_name:
|
||||
if isinstance(out_display, str) and out_display:
|
||||
flat[f"components.{component_key}.outputs.{out_name}.display_name"] = out_display
|
||||
flat[_component_field_key(norm_key, f"outputs.{out_name}.display_name", out_display)] = out_display
|
||||
if isinstance(out_info, str) and out_info:
|
||||
flat[f"components.{component_key}.outputs.{out_name}.info"] = out_info
|
||||
flat[_component_field_key(norm_key, f"outputs.{out_name}.info", out_info)] = out_info
|
||||
|
||||
# Tier 3 — starter project names & descriptions (auto-discovered from JSON files)
|
||||
starter_count = 0
|
||||
|
||||
@ -20,20 +20,27 @@ from gp_client import BASE_URL, GP_INSTANCE, TARGET_LANGS, get_headers, list_bun
|
||||
|
||||
DEFAULT_SOURCE = Path(__file__).parent.parent.parent / "src/backend/base/langflow/locales/en.json"
|
||||
GP_BACKEND_BUNDLE = os.getenv("GP_BACKEND_BUNDLE", "langflow-backend")
|
||||
REQUEST_TIMEOUT = 30
|
||||
REQUEST_TIMEOUT = 60
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
|
||||
def upload_backend_strings(strings: dict, lang: str = "en") -> dict:
|
||||
def upload_backend_strings(strings: dict, lang: str = "en") -> None:
|
||||
"""Upload strings in chunks to avoid server read timeouts on large payloads."""
|
||||
url = f"{BASE_URL}/{GP_INSTANCE}/v2/bundles/{GP_BACKEND_BUNDLE}/{lang}"
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=get_headers(url, "PUT", strings),
|
||||
json=strings,
|
||||
verify=False, # noqa: S501
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
items = list(strings.items())
|
||||
total_chunks = (len(items) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
for i in range(0, len(items), CHUNK_SIZE):
|
||||
chunk = dict(items[i : i + CHUNK_SIZE])
|
||||
chunk_num = i // CHUNK_SIZE + 1
|
||||
print(f" Uploading chunk {chunk_num}/{total_chunks} ({len(chunk)} strings)...")
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=get_headers(url, "PUT", chunk),
|
||||
json=chunk,
|
||||
verify=False, # noqa: S501
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def create_backend_bundle(source_lang: str = "en") -> dict:
|
||||
@ -71,9 +78,9 @@ def main() -> None:
|
||||
else:
|
||||
print(f"Bundle '{GP_BACKEND_BUNDLE}' already exists, skipping creation.")
|
||||
|
||||
print(f"Uploading strings to GP bundle '{GP_BACKEND_BUNDLE}'...")
|
||||
result = upload_backend_strings(strings)
|
||||
print(f"Done: {result}")
|
||||
print(f"Uploading {len(strings)} strings to GP bundle '{GP_BACKEND_BUNDLE}' in chunks of {CHUNK_SIZE}...")
|
||||
upload_backend_strings(strings)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,9 +3,28 @@
|
||||
Loads flat JSON locale files from langflow/locales/ and provides a translate()
|
||||
function used to substitute component display_names in API responses.
|
||||
|
||||
Locale files use the same flat dot-notation format as the frontend en.json:
|
||||
"components.ChatInput.display_name": "Chat Input"
|
||||
"components.ChatInput.inputs.role.display_name": "Role"
|
||||
## Component key scheme
|
||||
|
||||
Component strings use a hybrid key: human-readable path + content hash suffix.
|
||||
|
||||
"components.{norm_name}.{field_path}.{sha256[:8]}"
|
||||
|
||||
Examples:
|
||||
"components.prompttemplate.display_name.a1b2c3d4": "Prompt Template"
|
||||
"components.prompttemplate.inputs.template.display_name.f9e8d7c6": "Template"
|
||||
"components.chatinput.outputs.message.display_name.12345678": "Chat Message"
|
||||
|
||||
The norm_name is the component registry key with spaces removed and lowercased
|
||||
("Prompt Template" → "prompttemplate"), making it stable across space/case renames.
|
||||
|
||||
The 8-char SHA-256 suffix is derived from the English value. When a string
|
||||
changes (e.g. "Prompt Template" → "New Prompt Template"), the hash suffix
|
||||
changes, the old key becomes orphaned, and the new key is picked up by GP on
|
||||
the next upload/translate/download cycle — guaranteeing fresh translations.
|
||||
|
||||
Other namespaces keep human-readable keys:
|
||||
"starter_flows.{slug}.name"
|
||||
"notes.{hash}"
|
||||
|
||||
Fallback chain: requested locale → "en" → raw default string.
|
||||
"""
|
||||
@ -13,6 +32,7 @@ Fallback chain: requested locale → "en" → raw default string.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
@ -21,7 +41,6 @@ from typing import Any
|
||||
|
||||
_LOCALES_DIR = Path(__file__).parent.parent / "locales"
|
||||
|
||||
# { "en": { "components.ChatInput.display_name": "Chat Input", ... }, "fr": {...} }
|
||||
_translations: dict[str, dict[str, str]] = {}
|
||||
_translations_lock = threading.Lock()
|
||||
|
||||
@ -80,6 +99,41 @@ def _safe_flow_key(name: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower()
|
||||
|
||||
|
||||
def normalize_component_key(name: str) -> str:
|
||||
"""Normalize a component name for frontend lookup and locale key prefixes.
|
||||
|
||||
Removes all whitespace and lowercases, so that "Prompt Template",
|
||||
"PromptTemplate", and "prompt template" all map to "prompttemplate".
|
||||
Used both as the locale key prefix and in the frontend syncNodeTranslations()
|
||||
to resolve stored node.data.type values against the live registry.
|
||||
"""
|
||||
return name.replace(" ", "").lower()
|
||||
|
||||
|
||||
def _content_hash(english: str) -> str:
|
||||
"""Return the first 8 hex chars of SHA-256(english).
|
||||
|
||||
Used as a suffix on component locale keys so that any change to the English
|
||||
source string produces a new key, forcing GP to issue a fresh translation.
|
||||
"""
|
||||
return hashlib.sha256(english.encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def component_field_key(norm_name: str, field_path: str, english: str) -> str:
|
||||
"""Build the full locale key for a component field.
|
||||
|
||||
Format: components.{norm_name}.{field_path}.{sha256[:8]}
|
||||
|
||||
Args:
|
||||
norm_name: Normalized component name (no spaces, lowercase).
|
||||
field_path: Dot-separated path to the field, e.g.
|
||||
"display_name", "inputs.template.display_name",
|
||||
"outputs.message.display_name".
|
||||
english: The current English source string (used to compute the hash).
|
||||
"""
|
||||
return f"components.{norm_name}.{field_path}.{_content_hash(english)}"
|
||||
|
||||
|
||||
def translate_starter_flows(flow_reads: list, locale: str) -> list:
|
||||
"""Return copies of flow_reads with name/description translated for locale."""
|
||||
result = []
|
||||
@ -95,7 +149,6 @@ def translate_starter_flows(flow_reads: list, locale: str) -> list:
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def translate_flow_notes(nodes: list[dict], locale: str) -> list[dict]:
|
||||
"""Return a copy of nodes with note node descriptions translated for locale.
|
||||
|
||||
@ -121,10 +174,13 @@ def translate_component_dict(all_types: dict[str, Any], locale: str) -> dict[str
|
||||
"""Return a copy of all_types with display_names substituted for locale.
|
||||
|
||||
Never mutates the original dict (which is the shared component cache).
|
||||
Only translates:
|
||||
Translates:
|
||||
- component-level display_name and description
|
||||
- template field display_names (inputs)
|
||||
- output display_names
|
||||
- template field display_names, info, and placeholders (inputs)
|
||||
- output display_names and info
|
||||
|
||||
Keys are hybrid: human-readable path + content hash suffix.
|
||||
See module docstring for key format details.
|
||||
|
||||
Args:
|
||||
all_types: The cached component dict from get_and_cache_all_types_dict()
|
||||
@ -138,36 +194,50 @@ def translate_component_dict(all_types: dict[str, Any], locale: str) -> dict[str
|
||||
result[category] = {}
|
||||
for name, data in components.items():
|
||||
translated: dict[str, Any] = {**data}
|
||||
norm = normalize_component_key(name)
|
||||
|
||||
# Tier 1 — component-level strings
|
||||
translated["display_name"] = translate(
|
||||
f"components.{name}.display_name", locale, data.get("display_name", "")
|
||||
)
|
||||
translated["description"] = translate(f"components.{name}.description", locale, data.get("description", ""))
|
||||
display_name_en = data.get("display_name", "")
|
||||
description_en = data.get("description", "")
|
||||
if display_name_en:
|
||||
translated["display_name"] = translate(
|
||||
component_field_key(norm, "display_name", display_name_en),
|
||||
locale,
|
||||
display_name_en,
|
||||
)
|
||||
if description_en:
|
||||
translated["description"] = translate(
|
||||
component_field_key(norm, "description", description_en),
|
||||
locale,
|
||||
description_en,
|
||||
)
|
||||
|
||||
# Tier 2 — template field display_names and info (inputs serialised as dicts)
|
||||
# Tier 2 — template field display_names, info, and placeholders
|
||||
if "template" in data and isinstance(data["template"], dict):
|
||||
translated["template"] = {**data["template"]}
|
||||
for field_name, field in data["template"].items():
|
||||
if isinstance(field, dict):
|
||||
field_updates = {}
|
||||
if "display_name" in field:
|
||||
field_display_en = field.get("display_name", "")
|
||||
field_info_en = field.get("info", "")
|
||||
field_placeholder_en = field.get("placeholder", "")
|
||||
if field_display_en:
|
||||
field_updates["display_name"] = translate(
|
||||
f"components.{name}.inputs.{field_name}.display_name",
|
||||
component_field_key(norm, f"inputs.{field_name}.display_name", field_display_en),
|
||||
locale,
|
||||
field["display_name"],
|
||||
field_display_en,
|
||||
)
|
||||
if "info" in field and field["info"]:
|
||||
if field_info_en:
|
||||
field_updates["info"] = translate(
|
||||
f"components.{name}.inputs.{field_name}.info",
|
||||
component_field_key(norm, f"inputs.{field_name}.info", field_info_en),
|
||||
locale,
|
||||
field["info"],
|
||||
field_info_en,
|
||||
)
|
||||
if "placeholder" in field and field["placeholder"]:
|
||||
if field_placeholder_en:
|
||||
field_updates["placeholder"] = translate(
|
||||
f"components.{name}.inputs.{field_name}.placeholder",
|
||||
component_field_key(norm, f"inputs.{field_name}.placeholder", field_placeholder_en),
|
||||
locale,
|
||||
field["placeholder"],
|
||||
field_placeholder_en,
|
||||
)
|
||||
if field_updates:
|
||||
translated["template"][field_name] = {**field, **field_updates}
|
||||
@ -177,20 +247,22 @@ def translate_component_dict(all_types: dict[str, Any], locale: str) -> dict[str
|
||||
translated["outputs"] = []
|
||||
for out in data["outputs"]:
|
||||
out_name = out.get("name", "")
|
||||
out_updates = {
|
||||
"display_name": translate(
|
||||
f"components.{name}.outputs.{out_name}.display_name",
|
||||
out_display_en = out.get("display_name", "")
|
||||
out_info_en = out.get("info", "")
|
||||
out_updates = {}
|
||||
if out_display_en:
|
||||
out_updates["display_name"] = translate(
|
||||
component_field_key(norm, f"outputs.{out_name}.display_name", out_display_en),
|
||||
locale,
|
||||
out.get("display_name", ""),
|
||||
),
|
||||
}
|
||||
if out.get("info"):
|
||||
out_updates["info"] = translate(
|
||||
f"components.{name}.outputs.{out_name}.info",
|
||||
locale,
|
||||
out["info"],
|
||||
out_display_en,
|
||||
)
|
||||
translated["outputs"].append({**out, **out_updates})
|
||||
if out_info_en:
|
||||
out_updates["info"] = translate(
|
||||
component_field_key(norm, f"outputs.{out_name}.info", out_info_en),
|
||||
locale,
|
||||
out_info_en,
|
||||
)
|
||||
translated["outputs"].append({**out, **out_updates} if out_updates else out)
|
||||
|
||||
result[category][name] = translated
|
||||
return result
|
||||
|
||||
@ -1298,16 +1298,29 @@ export function recomputeComponentsToUpdateIfNeeded(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a component key: strip spaces, lowercase. Mirrors backend normalize_component_key(). */
|
||||
function normalizeComponentKey(name: string): string {
|
||||
return name.replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function syncNodeTranslations(): void {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const { data: typesData, types } = useTypesStore.getState();
|
||||
|
||||
// Build normalized lookup: normalize(registryKey) → registryKey
|
||||
// This lets us find "Prompt Template" in the registry when nodeType is "PromptTemplate".
|
||||
const normalizedToRegistryKey: Record<string, string> = {};
|
||||
for (const category of Object.values(typesData)) {
|
||||
for (const registryKey of Object.keys(category as Record<string, unknown>)) {
|
||||
normalizedToRegistryKey[normalizeComponentKey(registryKey)] = registryKey;
|
||||
}
|
||||
}
|
||||
|
||||
let noteIndex = 0;
|
||||
const updatedNodes = nodes.map((node) => {
|
||||
const nodeType = node.data.type;
|
||||
const category = types[nodeType];
|
||||
|
||||
// Skip note nodes — translations are handled by useGetNoteTranslationsQuery
|
||||
if (node.type === "noteNode") {
|
||||
@ -1315,10 +1328,20 @@ export function syncNodeTranslations(): void {
|
||||
return node;
|
||||
}
|
||||
|
||||
// Skip custom/group nodes not in the types catalog
|
||||
if (!category || !typesData[category]?.[nodeType]) return node;
|
||||
// Resolve category: try exact match first, then normalized match
|
||||
const category =
|
||||
types[nodeType] ?? types[normalizedToRegistryKey[normalizeComponentKey(nodeType)] ?? ""];
|
||||
|
||||
const freshDef = typesData[category][nodeType];
|
||||
// Resolve registry key: exact match first, then normalized match
|
||||
const registryKey =
|
||||
typesData[category]?.[nodeType] !== undefined
|
||||
? nodeType
|
||||
: (normalizedToRegistryKey[normalizeComponentKey(nodeType)] ?? nodeType);
|
||||
|
||||
// Skip custom/group nodes not in the types catalog
|
||||
if (!category || !typesData[category]?.[registryKey]) return node;
|
||||
|
||||
const freshDef = typesData[category][registryKey];
|
||||
|
||||
// Update input field display_names
|
||||
const updatedTemplate = { ...node.data.node!.template };
|
||||
|
||||
Reference in New Issue
Block a user