comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
194 lines • 8.24 kB
JavaScript
/**
* Translations for the HUMAN-facing text this process emits.
*
* SCOPE — read this before adding a key.
*
* Almost everything this repo emits is read by an LLM, not a person: the 89 panel tool
* definitions, the ~610 `.describe()` parameter docs, `PANEL_SYSTEM_APPEND`, the per-backend
* system prompts, `src/tools/vocabulary.ts`, `retired-redirect.ts`, and the refusal modules.
* Those are prompt engineering — their exact English wording is what the model routes on, and
* `check:vocabulary` gates it in CI. **None of it may be translated.** Doing so degrades
* model behaviour silently, and no test in this repo would catch it.
*
* What belongs here is the ~150 strings a PERSON reads:
* - the CLI (`renderCliHelp`, the connect/tunnel banners, `setup` output)
* - the `say` bridge frames that render as chat bubbles in the panel
* - `ready-banner.ts`, `start-failure-notice.ts`
* - the two HTML pages in `panel-console-http.ts`
*
* TWO AUDIENCES, TWO LOCALES. The CLI writes to a terminal owned by whoever launched the
* process, so it follows the environment. A `say` frame renders inside a specific panel tab,
* whose language is that user's own setting — and those strings NAME PANEL CONTROLS
* ("Settings → OpenRouter"), so they must be in the same language as the control they point
* at, or the instruction is useless. Hence `tr()` (process locale) and `trFor()` (a given
* panel's locale) are separate calls rather than one ambient default.
*/
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/** Same codes ComfyUI ships, so the panel's language list and ours cannot disagree. */
export const LOCALES = [
"en", "zh", "zh-TW", "ru", "ja", "ko", "fr", "es", "pt-BR", "tr", "ar", "fa",
];
const SUPPORTED = new Set(LOCALES);
/** Resolve arbitrary input to a shipped locale, or null. Mirrors the panel's rules. */
export function resolveLocale(input) {
if (!input)
return null;
// Env values arrive as "ko_KR.UTF-8" / "pt_BR.utf8" — strip encoding and normalise.
const raw = String(input).trim().split(".")[0].replace("_", "-");
if (!raw)
return null;
if (SUPPORTED.has(raw))
return raw;
// Script matters more than region for Chinese. We ship `zh` (Simplified) and `zh-TW`
// (Traditional); Hong Kong and Macau write Traditional, so plain base-matching to `zh`
// would hand those users the wrong script — legible-ish, but visibly wrong, and the kind
// of thing that reads as "they didn't think about us".
const TRADITIONAL = /^zh-(HK|MO|Hant)/i;
if (TRADITIONAL.test(raw))
return "zh-TW";
const base = raw.split("-")[0];
if (SUPPORTED.has(base))
return base;
const lower = raw.toLowerCase();
for (const code of SUPPORTED) {
if (code.toLowerCase() === lower || code.toLowerCase() === base.toLowerCase())
return code;
}
// Base not shipped standalone but a regional variant is: pt-PT -> pt-BR, zh-HK -> zh-TW.
for (const code of LOCALES) {
if (code.toLowerCase().startsWith(`${base.toLowerCase()}-`))
return code;
}
return null;
}
/**
* The locale for terminal output. `--lang` beats the app-specific env var, which beats the
* POSIX locale chain. Explicit intent wins over ambient configuration at every step.
*/
export function processLocale(argv = process.argv) {
const flagIdx = argv.findIndex((a) => a === "--lang" || a.startsWith("--lang="));
if (flagIdx !== -1) {
const raw = argv[flagIdx].includes("=") ? argv[flagIdx].split("=")[1] : argv[flagIdx + 1];
const hit = resolveLocale(raw);
if (hit)
return hit;
}
for (const env of ["COMFYUI_MCP_LANG", "LC_ALL", "LC_MESSAGES", "LANG"]) {
const hit = resolveLocale(process.env[env]);
if (hit)
return hit;
}
return "en";
}
/** locale -> flat key map. Loaded lazily and cached; `en` is always empty (fallbacks inline). */
const catalogs = new Map();
function localesDir() {
// Resolves from dist/i18n/ at runtime and src/i18n/ under vitest — walk up to the package
// root either way rather than assuming which one we are in.
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "locales");
if (existsSync(candidate))
return candidate;
dir = dirname(dir);
}
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "locales");
}
function flatten(obj, prefix = "", out = {}) {
if (!obj || typeof obj !== "object")
return out;
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v))
flatten(v, key, out);
else if (typeof v === "string")
out[key] = v;
}
return out;
}
function catalogFor(locale) {
const cached = catalogs.get(locale);
if (cached)
return cached;
let loaded = {};
// English needs no file: every call site carries its English text as the fallback.
if (locale !== "en") {
try {
const p = join(localesDir(), locale, "main.json");
if (existsSync(p))
loaded = flatten(JSON.parse(readFileSync(p, "utf8")));
}
catch {
// A malformed or unreadable catalog means English, never a crash. This text is printed
// during startup and while reporting other failures — it must not be able to add one.
loaded = {};
}
}
catalogs.set(locale, loaded);
return loaded;
}
const pluralRules = new Map();
function pluralCategory(locale, n) {
try {
let r = pluralRules.get(locale);
if (!r) {
r = new Intl.PluralRules(locale);
pluralRules.set(locale, r);
}
return r.select(n);
}
catch {
return n === 1 ? "one" : "other";
}
}
function pickForm(fallback, n) {
if (typeof fallback === "string")
return fallback;
if (!fallback || typeof fallback !== "object")
return "";
return fallback[pluralCategory("en", n)] ?? fallback.other ?? Object.values(fallback)[0] ?? "";
}
/**
* Translate for a specific locale.
*
* `fallback` is the English source text and stays at the call site, so the code reads as prose
* and a missing translation degrades to correct English rather than a raw key. Counted strings
* pass `{ count }` and take an object fallback (`{ one: "…", other: "…" }`); the category comes
* from `Intl.PluralRules`, which knows that Korean has one form and Russian four.
*/
export function trFor(locale, key, fallback, vars) {
const resolved = resolveLocale(locale) ?? "en";
const cat = catalogFor(resolved);
let out;
if (vars && typeof vars.count === "number") {
const c = pluralCategory(resolved, vars.count);
out = cat[`${key}_${c}`] ?? cat[`${key}_other`] ?? cat[key];
if (out === undefined)
out = pickForm(fallback, vars.count);
}
else {
out = cat[key] ?? (typeof fallback === "string" ? fallback : pickForm(fallback, 0));
}
out = out ?? "";
// ONE pass. A loop of `split(...).join(...)` per variable expands placeholders that appear
// INSIDE an already-substituted value — a pod named `{id}` would be rewritten by the next
// variable's turn, destroying the user's own data. A single pass never revisits its output.
if (vars) {
out = out.replace(/\{(\w+)\}/g, (whole, name) => Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole);
}
return out;
}
let cachedProcessLocale = null;
/** Translate for the TERMINAL — CLI help, banners, startup errors. */
export function tr(key, fallback, vars) {
cachedProcessLocale ??= processLocale();
return trFor(cachedProcessLocale, key, fallback, vars);
}
/** Test seam: forget the memoised process locale and every loaded catalog. */
export function __resetI18nForTest() {
cachedProcessLocale = null;
catalogs.clear();
}
//# sourceMappingURL=index.js.map