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:
RamGopalSrikar
2026-04-17 14:53:02 -04:00
parent 1ac614e82b
commit 0e2f2215e4
11 changed files with 11204 additions and 46076 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 };