glotfile 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/cli.js +346 -45
- package/dist/server/server.js +273 -44
- package/dist/ui/assets/index-3IIAIpZW.css +1 -0
- package/dist/ui/assets/{index-VdTDY_C8.js → index-pl7PaD7b.js} +19 -6
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-CqpESIEu.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -901,6 +901,10 @@ function toI18next(value) {
|
|
|
901
901
|
if (isIcuPluralOrSelect(value)) return value;
|
|
902
902
|
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
903
903
|
}
|
|
904
|
+
function toRuby(value) {
|
|
905
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
906
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
907
|
+
}
|
|
904
908
|
function placeholdersMatch(source, translation) {
|
|
905
909
|
const a = extractPlaceholders(source).sort();
|
|
906
910
|
const b = extractPlaceholders(translation).sort();
|
|
@@ -1565,6 +1569,95 @@ var init_angular_xliff = __esm({
|
|
|
1565
1569
|
}
|
|
1566
1570
|
});
|
|
1567
1571
|
|
|
1572
|
+
// src/server/adapters/rails-yaml.ts
|
|
1573
|
+
function yamlString(s) {
|
|
1574
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
|
|
1575
|
+
}
|
|
1576
|
+
function yamlKey(k) {
|
|
1577
|
+
if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
|
|
1578
|
+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
|
|
1579
|
+
}
|
|
1580
|
+
function yamlMap(node, indent, level) {
|
|
1581
|
+
const pad = " ".repeat(indent * level);
|
|
1582
|
+
const lines = [];
|
|
1583
|
+
for (const key of Object.keys(node).sort()) {
|
|
1584
|
+
const v = node[key];
|
|
1585
|
+
if (v && typeof v === "object") {
|
|
1586
|
+
lines.push(`${pad}${yamlKey(key)}:`);
|
|
1587
|
+
lines.push(...yamlMap(v, indent, level + 1));
|
|
1588
|
+
} else {
|
|
1589
|
+
lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return lines;
|
|
1593
|
+
}
|
|
1594
|
+
var RESERVED_KEYS, DEFAULT_LOCALE_CASE8, railsYaml;
|
|
1595
|
+
var init_rails_yaml = __esm({
|
|
1596
|
+
"src/server/adapters/rails-yaml.ts"() {
|
|
1597
|
+
"use strict";
|
|
1598
|
+
init_adapters();
|
|
1599
|
+
init_shared();
|
|
1600
|
+
init_options();
|
|
1601
|
+
init_placeholders();
|
|
1602
|
+
init_schema();
|
|
1603
|
+
RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
|
|
1604
|
+
DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
|
|
1605
|
+
railsYaml = {
|
|
1606
|
+
name: "rails-yaml",
|
|
1607
|
+
capabilities: {
|
|
1608
|
+
plural: "native",
|
|
1609
|
+
select: "lossy",
|
|
1610
|
+
nesting: "nested",
|
|
1611
|
+
metadata: false,
|
|
1612
|
+
placeholderStyle: "named",
|
|
1613
|
+
fileGrouping: "per-locale"
|
|
1614
|
+
},
|
|
1615
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE8,
|
|
1616
|
+
export(state, output) {
|
|
1617
|
+
const warnings = [];
|
|
1618
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
|
|
1619
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
1620
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
1621
|
+
const files = [];
|
|
1622
|
+
for (const locale of state.config.locales) {
|
|
1623
|
+
const flat = {};
|
|
1624
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
1625
|
+
if (entry.plural) {
|
|
1626
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1627
|
+
if (!forms) continue;
|
|
1628
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
1629
|
+
const body2 = forms[cat];
|
|
1630
|
+
if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
|
|
1631
|
+
}
|
|
1632
|
+
} else {
|
|
1633
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1634
|
+
if (raw === null) continue;
|
|
1635
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
1636
|
+
warnings.push({
|
|
1637
|
+
code: "lossy-plural",
|
|
1638
|
+
key,
|
|
1639
|
+
locale,
|
|
1640
|
+
message: "rails-yaml cannot represent ICU plural/select; written unconverted"
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
flat[key] = toRuby(raw);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const { tree: nested, collisions } = nestKeys(flat);
|
|
1647
|
+
for (const c of collisions) {
|
|
1648
|
+
warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
|
|
1649
|
+
}
|
|
1650
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
|
|
1651
|
+
const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
|
|
1652
|
+
files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
|
|
1653
|
+
}
|
|
1654
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1655
|
+
return { files, warnings };
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1568
1661
|
// src/server/adapters/index.ts
|
|
1569
1662
|
function resolvePath(template, locale, namespace = "") {
|
|
1570
1663
|
return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
|
|
@@ -1597,7 +1690,8 @@ function getRegistry() {
|
|
|
1597
1690
|
[gettextPo.name]: gettextPo,
|
|
1598
1691
|
[appleStringsdict.name]: appleStringsdict,
|
|
1599
1692
|
[vueI18nJson.name]: vueI18nJson,
|
|
1600
|
-
[angularXliff.name]: angularXliff
|
|
1693
|
+
[angularXliff.name]: angularXliff,
|
|
1694
|
+
[railsYaml.name]: railsYaml
|
|
1601
1695
|
};
|
|
1602
1696
|
}
|
|
1603
1697
|
function getAdapter(name) {
|
|
@@ -1617,6 +1711,7 @@ var init_adapters = __esm({
|
|
|
1617
1711
|
init_apple_stringsdict();
|
|
1618
1712
|
init_vue_i18n_json();
|
|
1619
1713
|
init_angular_xliff();
|
|
1714
|
+
init_rails_yaml();
|
|
1620
1715
|
}
|
|
1621
1716
|
});
|
|
1622
1717
|
|
|
@@ -1758,6 +1853,7 @@ function buildSystemPrompt(hasPluralItems) {
|
|
|
1758
1853
|
"- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
|
|
1759
1854
|
"- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
|
|
1760
1855
|
"- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
|
|
1856
|
+
`- Quotation marks inside a translation MUST use the target language's typographic quote characters (e.g. \u201EGerman\u201C, \xABFrench\xBB, \u201CEnglish\u201D, \u2019 for apostrophes). Never emit a raw ASCII double-quote (") inside a translated string \u2014 it corrupts the JSON reply. If the source uses ASCII quotes, convert them to the target language's typographic quotes.`,
|
|
1761
1857
|
"- Match the register and capitalization conventions of the target language and of UI microcopy.",
|
|
1762
1858
|
"- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
|
|
1763
1859
|
];
|
|
@@ -1847,6 +1943,16 @@ var init_provider = __esm({
|
|
|
1847
1943
|
});
|
|
1848
1944
|
|
|
1849
1945
|
// src/server/ai/batch.ts
|
|
1946
|
+
function parseReplyItems(text) {
|
|
1947
|
+
let parsed;
|
|
1948
|
+
try {
|
|
1949
|
+
parsed = JSON.parse(text);
|
|
1950
|
+
} catch {
|
|
1951
|
+
throw new MalformedReplyError(text);
|
|
1952
|
+
}
|
|
1953
|
+
if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
|
|
1954
|
+
return parsed.items;
|
|
1955
|
+
}
|
|
1850
1956
|
function chunk(items, size) {
|
|
1851
1957
|
const out = [];
|
|
1852
1958
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -1882,24 +1988,46 @@ function validatePlural(req, forms) {
|
|
|
1882
1988
|
function validateReply(req, item) {
|
|
1883
1989
|
return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
|
|
1884
1990
|
}
|
|
1885
|
-
async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
|
|
1991
|
+
async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
|
|
1992
|
+
const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
|
|
1993
|
+
async function resolveBatch(batch, isRetry = false) {
|
|
1994
|
+
let reply;
|
|
1995
|
+
try {
|
|
1996
|
+
reply = await callBatch(batch, signal);
|
|
1997
|
+
} catch (err) {
|
|
1998
|
+
if (!(err instanceof MalformedReplyError)) throw err;
|
|
1999
|
+
onMalformedReply?.(err.raw, batch.length);
|
|
2000
|
+
if (signal?.aborted) return failBatch(batch);
|
|
2001
|
+
if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
|
|
2002
|
+
const mid = Math.ceil(batch.length / 2);
|
|
2003
|
+
return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
|
|
2004
|
+
}
|
|
2005
|
+
const byId = new Map(reply.map((r) => [r.id, r]));
|
|
2006
|
+
return batch.map((req) => validateReply(req, byId.get(req.id)));
|
|
2007
|
+
}
|
|
1886
2008
|
const results = [];
|
|
1887
2009
|
const total = reqs.length;
|
|
1888
2010
|
for (const batch of chunk(reqs, Math.max(1, batchSize))) {
|
|
1889
2011
|
if (signal?.aborted) break;
|
|
1890
|
-
const
|
|
1891
|
-
const byId = new Map(reply.map((r) => [r.id, r]));
|
|
1892
|
-
const batchResults = [];
|
|
1893
|
-
for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
|
|
2012
|
+
const batchResults = await resolveBatch(batch);
|
|
1894
2013
|
results.push(...batchResults);
|
|
1895
2014
|
onBatchComplete?.(results.length, total, batchResults);
|
|
1896
2015
|
}
|
|
1897
2016
|
return results;
|
|
1898
2017
|
}
|
|
2018
|
+
var MalformedReplyError;
|
|
1899
2019
|
var init_batch = __esm({
|
|
1900
2020
|
"src/server/ai/batch.ts"() {
|
|
1901
2021
|
"use strict";
|
|
1902
2022
|
init_placeholders();
|
|
2023
|
+
MalformedReplyError = class extends Error {
|
|
2024
|
+
constructor(raw) {
|
|
2025
|
+
super("Model reply was not valid translation JSON.");
|
|
2026
|
+
this.raw = raw;
|
|
2027
|
+
this.name = "MalformedReplyError";
|
|
2028
|
+
}
|
|
2029
|
+
raw;
|
|
2030
|
+
};
|
|
1903
2031
|
}
|
|
1904
2032
|
});
|
|
1905
2033
|
|
|
@@ -1928,8 +2056,8 @@ var init_anthropic = __esm({
|
|
|
1928
2056
|
supportsVision() {
|
|
1929
2057
|
return true;
|
|
1930
2058
|
}
|
|
1931
|
-
translate(reqs, onBatchComplete, signal) {
|
|
1932
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2059
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
2060
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
1933
2061
|
}
|
|
1934
2062
|
// Build the user message as content blocks: each unique key's screenshot is
|
|
1935
2063
|
// sent once (a key recurs once per target locale in a batch — dedupe by key),
|
|
@@ -1976,13 +2104,8 @@ var init_anthropic = __esm({
|
|
|
1976
2104
|
output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
|
|
1977
2105
|
messages: [{ role: "user", content }]
|
|
1978
2106
|
}, { signal });
|
|
1979
|
-
const text = res.content.find((b) => b.type === "text")?.text ?? "
|
|
1980
|
-
|
|
1981
|
-
const parsed = JSON.parse(text);
|
|
1982
|
-
return parsed.items ?? [];
|
|
1983
|
-
} catch {
|
|
1984
|
-
return [];
|
|
1985
|
-
}
|
|
2107
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "";
|
|
2108
|
+
return parseReplyItems(text);
|
|
1986
2109
|
}
|
|
1987
2110
|
};
|
|
1988
2111
|
}
|
|
@@ -2024,8 +2147,8 @@ var init_openai = __esm({
|
|
|
2024
2147
|
supportsVision() {
|
|
2025
2148
|
return true;
|
|
2026
2149
|
}
|
|
2027
|
-
translate(reqs, onBatchComplete, signal) {
|
|
2028
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2150
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
2151
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
2029
2152
|
}
|
|
2030
2153
|
// User content as an array of parts: each unique key's screenshot once (as an
|
|
2031
2154
|
// image_url data URL), then the batch prompt text describing every item.
|
|
@@ -2074,13 +2197,8 @@ var init_openai = __esm({
|
|
|
2074
2197
|
{ role: "user", content: this.buildUserContent(batch) }
|
|
2075
2198
|
]
|
|
2076
2199
|
}, { signal });
|
|
2077
|
-
const text = res.choices?.[0]?.message?.content ?? "
|
|
2078
|
-
|
|
2079
|
-
const parsed = JSON.parse(text);
|
|
2080
|
-
return parsed.items ?? [];
|
|
2081
|
-
} catch {
|
|
2082
|
-
return [];
|
|
2083
|
-
}
|
|
2200
|
+
const text = res.choices?.[0]?.message?.content ?? "";
|
|
2201
|
+
return parseReplyItems(text);
|
|
2084
2202
|
}
|
|
2085
2203
|
};
|
|
2086
2204
|
}
|
|
@@ -2133,8 +2251,8 @@ var init_bedrock = __esm({
|
|
|
2133
2251
|
supportsVision() {
|
|
2134
2252
|
return !this.isMeta();
|
|
2135
2253
|
}
|
|
2136
|
-
translate(reqs, onBatchComplete, signal) {
|
|
2137
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2254
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
2255
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
2138
2256
|
}
|
|
2139
2257
|
buildContentBlocks(batch) {
|
|
2140
2258
|
const blocks = [];
|
|
@@ -2198,13 +2316,8 @@ var init_bedrock = __esm({
|
|
|
2198
2316
|
const blocks = res.output?.message?.content ?? [];
|
|
2199
2317
|
const tool = blocks.find((b) => b.toolUse)?.toolUse;
|
|
2200
2318
|
if (tool?.input?.items) return tool.input.items;
|
|
2201
|
-
const text = blocks.find((b) => b.text)?.text ?? "
|
|
2202
|
-
|
|
2203
|
-
const parsed = JSON.parse(text);
|
|
2204
|
-
return parsed.items ?? [];
|
|
2205
|
-
} catch {
|
|
2206
|
-
return [];
|
|
2207
|
-
}
|
|
2319
|
+
const text = blocks.find((b) => b.text)?.text ?? "";
|
|
2320
|
+
return parseReplyItems(text);
|
|
2208
2321
|
}
|
|
2209
2322
|
};
|
|
2210
2323
|
}
|
|
@@ -2347,8 +2460,8 @@ var init_claudecode = __esm({
|
|
|
2347
2460
|
supportsVision() {
|
|
2348
2461
|
return false;
|
|
2349
2462
|
}
|
|
2350
|
-
translate(reqs, onBatchComplete, signal) {
|
|
2351
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2463
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
2464
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
2352
2465
|
}
|
|
2353
2466
|
async complete(req) {
|
|
2354
2467
|
const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
|
|
@@ -2371,12 +2484,7 @@ var init_claudecode = __esm({
|
|
|
2371
2484
|
throw err;
|
|
2372
2485
|
}
|
|
2373
2486
|
if (signal?.aborted) return [];
|
|
2374
|
-
|
|
2375
|
-
const parsed = JSON.parse(stripFences(result));
|
|
2376
|
-
return parsed.items ?? [];
|
|
2377
|
-
} catch {
|
|
2378
|
-
return [];
|
|
2379
|
-
}
|
|
2487
|
+
return parseReplyItems(stripFences(result));
|
|
2380
2488
|
}
|
|
2381
2489
|
};
|
|
2382
2490
|
}
|
|
@@ -2457,7 +2565,9 @@ function coerceAi(raw) {
|
|
|
2457
2565
|
contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
|
|
2458
2566
|
contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
|
|
2459
2567
|
vision: typeof a.vision === "boolean" ? a.vision : void 0,
|
|
2460
|
-
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
|
|
2568
|
+
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
|
|
2569
|
+
inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
|
|
2570
|
+
outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
|
|
2461
2571
|
};
|
|
2462
2572
|
}
|
|
2463
2573
|
function coerceProfiles(raw) {
|
|
@@ -2496,6 +2606,10 @@ function aiConfigError(ai) {
|
|
|
2496
2606
|
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
2497
2607
|
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
2498
2608
|
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
2609
|
+
for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
|
|
2610
|
+
const v = a[f];
|
|
2611
|
+
if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
|
|
2612
|
+
}
|
|
2499
2613
|
return null;
|
|
2500
2614
|
}
|
|
2501
2615
|
var EDITOR_IDS, isEditorId, DEFAULT_AI, DEFAULT_EDITOR, settingsPath;
|
|
@@ -2658,7 +2772,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
|
|
|
2658
2772
|
const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
|
|
2659
2773
|
done += batchResults.length;
|
|
2660
2774
|
hooks.onBatchComplete?.(done, total, batchResults, locale);
|
|
2661
|
-
}, signal);
|
|
2775
|
+
}, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
|
|
2662
2776
|
allResults.push(...localeResults);
|
|
2663
2777
|
if (!signal?.aborted) hooks.onLocaleDone?.(locale);
|
|
2664
2778
|
}
|
|
@@ -2711,6 +2825,118 @@ var init_run = __esm({
|
|
|
2711
2825
|
}
|
|
2712
2826
|
});
|
|
2713
2827
|
|
|
2828
|
+
// src/server/ai/pricing.ts
|
|
2829
|
+
function bareModelId(model) {
|
|
2830
|
+
let id = model.trim().toLowerCase();
|
|
2831
|
+
const slash = id.lastIndexOf("/");
|
|
2832
|
+
if (slash !== -1) id = id.slice(slash + 1);
|
|
2833
|
+
const anth = id.lastIndexOf("anthropic.");
|
|
2834
|
+
if (anth !== -1) id = id.slice(anth + "anthropic.".length);
|
|
2835
|
+
return id;
|
|
2836
|
+
}
|
|
2837
|
+
function resolvePricing(ai) {
|
|
2838
|
+
if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
|
|
2839
|
+
return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
|
|
2840
|
+
}
|
|
2841
|
+
if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
|
|
2842
|
+
const id = bareModelId(ai.model);
|
|
2843
|
+
let best;
|
|
2844
|
+
for (const row of PRICE_TABLE) {
|
|
2845
|
+
if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
|
|
2846
|
+
}
|
|
2847
|
+
return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
|
|
2848
|
+
}
|
|
2849
|
+
var PRICE_TABLE, FREE_PROVIDERS;
|
|
2850
|
+
var init_pricing = __esm({
|
|
2851
|
+
"src/server/ai/pricing.ts"() {
|
|
2852
|
+
"use strict";
|
|
2853
|
+
PRICE_TABLE = [
|
|
2854
|
+
["claude-fable-5", 10, 50],
|
|
2855
|
+
["claude-mythos-5", 10, 50],
|
|
2856
|
+
// Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
|
|
2857
|
+
// the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
|
|
2858
|
+
["claude-opus-4-1", 15, 75],
|
|
2859
|
+
["claude-opus-4-0", 15, 75],
|
|
2860
|
+
["claude-opus-4-2025", 15, 75],
|
|
2861
|
+
["claude-opus-4", 5, 25],
|
|
2862
|
+
["claude-sonnet-4", 3, 15],
|
|
2863
|
+
["claude-haiku-4", 1, 5],
|
|
2864
|
+
["claude-3-5-haiku", 0.8, 4],
|
|
2865
|
+
["gpt-5.5-pro", 30, 180],
|
|
2866
|
+
["gpt-5.5", 5, 30],
|
|
2867
|
+
["gpt-5.4-pro", 30, 180],
|
|
2868
|
+
["gpt-5.4-mini", 0.75, 4.5],
|
|
2869
|
+
["gpt-5.4-nano", 0.2, 1.25],
|
|
2870
|
+
["gpt-5.4", 2.5, 15],
|
|
2871
|
+
["gpt-5.3-codex", 1.75, 14]
|
|
2872
|
+
];
|
|
2873
|
+
FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
// src/server/ai/estimate.ts
|
|
2878
|
+
function estimateTokens(text) {
|
|
2879
|
+
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
2880
|
+
return Math.ceil((text.length - cjk) / 4 + cjk / 2);
|
|
2881
|
+
}
|
|
2882
|
+
function estimateOutputTokens(req) {
|
|
2883
|
+
const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
|
|
2884
|
+
if (req.plural) {
|
|
2885
|
+
return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
|
|
2886
|
+
}
|
|
2887
|
+
return ITEM_REPLY_OVERHEAD + translated;
|
|
2888
|
+
}
|
|
2889
|
+
function estimateTranslation(state, ai, opts) {
|
|
2890
|
+
const reqs = selectRequests(state, opts);
|
|
2891
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
2892
|
+
for (const r of reqs) {
|
|
2893
|
+
let group = byLocale.get(r.targetLocale);
|
|
2894
|
+
if (!group) {
|
|
2895
|
+
group = [];
|
|
2896
|
+
byLocale.set(r.targetLocale, group);
|
|
2897
|
+
}
|
|
2898
|
+
group.push(r);
|
|
2899
|
+
}
|
|
2900
|
+
const perLocale = [];
|
|
2901
|
+
for (const [locale, group] of byLocale) {
|
|
2902
|
+
let inputTokens2 = 0;
|
|
2903
|
+
let outputTokens2 = 0;
|
|
2904
|
+
const batches = chunk(group, Math.max(1, ai.batchSize));
|
|
2905
|
+
for (const batch of batches) {
|
|
2906
|
+
const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
|
|
2907
|
+
inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
|
|
2908
|
+
for (const r of batch) outputTokens2 += estimateOutputTokens(r);
|
|
2909
|
+
}
|
|
2910
|
+
perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
|
|
2911
|
+
}
|
|
2912
|
+
const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
|
|
2913
|
+
const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
|
|
2914
|
+
const pricing = resolvePricing(ai);
|
|
2915
|
+
return {
|
|
2916
|
+
requests: reqs.length,
|
|
2917
|
+
batches: perLocale.reduce((n, l) => n + l.batches, 0),
|
|
2918
|
+
perLocale,
|
|
2919
|
+
inputTokens,
|
|
2920
|
+
outputTokens,
|
|
2921
|
+
pricing,
|
|
2922
|
+
estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
var CJK_RE, EXPANSION, ITEM_REPLY_OVERHEAD, FORM_REPLY_OVERHEAD;
|
|
2926
|
+
var init_estimate = __esm({
|
|
2927
|
+
"src/server/ai/estimate.ts"() {
|
|
2928
|
+
"use strict";
|
|
2929
|
+
init_run();
|
|
2930
|
+
init_provider();
|
|
2931
|
+
init_batch();
|
|
2932
|
+
init_pricing();
|
|
2933
|
+
CJK_RE = /[ -鿿가-豈-]/g;
|
|
2934
|
+
EXPANSION = 1.2;
|
|
2935
|
+
ITEM_REPLY_OVERHEAD = 16;
|
|
2936
|
+
FORM_REPLY_OVERHEAD = 8;
|
|
2937
|
+
}
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2714
2940
|
// src/server/log.ts
|
|
2715
2941
|
import { appendFileSync, readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
|
|
2716
2942
|
import { resolve as resolve5 } from "path";
|
|
@@ -5053,6 +5279,19 @@ function createApi(deps) {
|
|
|
5053
5279
|
},
|
|
5054
5280
|
onLocaleDone: (locale) => {
|
|
5055
5281
|
void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
|
|
5282
|
+
},
|
|
5283
|
+
// Record the raw reply so an unparseable model response is diagnosable
|
|
5284
|
+
// from the activity log instead of vanishing into per-item errors.
|
|
5285
|
+
onMalformedReply: (raw, batchSize, locale) => {
|
|
5286
|
+
console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
|
|
5287
|
+
appendLog(projectRoot, {
|
|
5288
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5289
|
+
kind: "translate",
|
|
5290
|
+
summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
|
|
5291
|
+
model: aiCfg.model,
|
|
5292
|
+
locale,
|
|
5293
|
+
raw
|
|
5294
|
+
});
|
|
5056
5295
|
}
|
|
5057
5296
|
}, aiCfg.concurrency, signal);
|
|
5058
5297
|
if (!signal?.aborted) {
|
|
@@ -5085,7 +5324,19 @@ function createApi(deps) {
|
|
|
5085
5324
|
}
|
|
5086
5325
|
const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
|
|
5087
5326
|
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
5088
|
-
const results = await runLocaleParallel(toTranslate, provider, {
|
|
5327
|
+
const results = await runLocaleParallel(toTranslate, provider, {
|
|
5328
|
+
onMalformedReply: (raw, batchSize, locale) => {
|
|
5329
|
+
console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
|
|
5330
|
+
appendLog(projectRoot, {
|
|
5331
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5332
|
+
kind: "translate",
|
|
5333
|
+
summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
|
|
5334
|
+
model: aiCfg.model,
|
|
5335
|
+
locale,
|
|
5336
|
+
raw
|
|
5337
|
+
});
|
|
5338
|
+
}
|
|
5339
|
+
}, aiCfg.concurrency);
|
|
5089
5340
|
const latest = load();
|
|
5090
5341
|
({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
|
|
5091
5342
|
const entry = {
|
|
@@ -5111,6 +5362,13 @@ function createApi(deps) {
|
|
|
5111
5362
|
}
|
|
5112
5363
|
return c.json({ requested: reqs.length, written, errors });
|
|
5113
5364
|
}));
|
|
5365
|
+
app.post("/translate/estimate", async (c) => {
|
|
5366
|
+
const body = await c.req.json().catch(() => ({}));
|
|
5367
|
+
const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
|
|
5368
|
+
const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
|
|
5369
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
5370
|
+
return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
|
|
5371
|
+
});
|
|
5114
5372
|
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
5115
5373
|
app.post("/scan", async (c) => {
|
|
5116
5374
|
const s = load();
|
|
@@ -5277,6 +5535,7 @@ var init_api = __esm({
|
|
|
5277
5535
|
init_ai();
|
|
5278
5536
|
init_run();
|
|
5279
5537
|
init_provider();
|
|
5538
|
+
init_estimate();
|
|
5280
5539
|
init_log();
|
|
5281
5540
|
init_schema();
|
|
5282
5541
|
init_run3();
|
|
@@ -5436,6 +5695,7 @@ init_ai();
|
|
|
5436
5695
|
init_local_settings();
|
|
5437
5696
|
init_run();
|
|
5438
5697
|
init_provider();
|
|
5698
|
+
init_estimate();
|
|
5439
5699
|
init_log();
|
|
5440
5700
|
init_scan();
|
|
5441
5701
|
init_scanner();
|
|
@@ -5573,6 +5833,7 @@ function parseArgs(argv) {
|
|
|
5573
5833
|
} else if (flag === "--empty-source") args.emptySource = true;
|
|
5574
5834
|
else if (flag === "--unused") args.unused = true;
|
|
5575
5835
|
else if (flag === "--write") args.write = true;
|
|
5836
|
+
else if (flag === "--estimate") args.estimate = true;
|
|
5576
5837
|
}
|
|
5577
5838
|
return args;
|
|
5578
5839
|
}
|
|
@@ -5626,6 +5887,31 @@ async function runExport(args) {
|
|
|
5626
5887
|
async function runTranslate(args) {
|
|
5627
5888
|
const state = loadState(args.statePath);
|
|
5628
5889
|
const projectRoot = dirname5(resolve11(args.statePath));
|
|
5890
|
+
if (args.estimate) {
|
|
5891
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
5892
|
+
const est = estimateTranslation(state, ai, {
|
|
5893
|
+
onlyMissing: args.all ? false : args.onlyMissing ?? true,
|
|
5894
|
+
locales: args.locales,
|
|
5895
|
+
keyGlob: args.keyGlob
|
|
5896
|
+
});
|
|
5897
|
+
if (!est.requests) {
|
|
5898
|
+
console.log("Nothing to translate.");
|
|
5899
|
+
return;
|
|
5900
|
+
}
|
|
5901
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
5902
|
+
console.log(`Estimate for ${fmt(est.requests)} request(s) in ${fmt(est.batches)} batch(es) \u2014 ${ai.provider} \xB7 ${ai.model}`);
|
|
5903
|
+
for (const l of est.perLocale) {
|
|
5904
|
+
console.log(` ${l.locale.padEnd(8)} ${fmt(l.requests).padStart(7)} req ${fmt(l.batches).padStart(5)} batch(es) ~${fmt(l.inputTokens)} in / ~${fmt(l.outputTokens)} out tokens`);
|
|
5905
|
+
}
|
|
5906
|
+
console.log(`Totals: ~${fmt(est.inputTokens)} input / ~${fmt(est.outputTokens)} output tokens`);
|
|
5907
|
+
if (est.pricing) {
|
|
5908
|
+
const cost = est.estimatedCost;
|
|
5909
|
+
console.log(`Estimated cost: ~$${cost >= 0.1 ? cost.toFixed(2) : cost.toFixed(4)} (\xB120%, ${est.pricing.source} pricing $${est.pricing.inputPerMTok}/$${est.pricing.outputPerMTok} per MTok)`);
|
|
5910
|
+
} else {
|
|
5911
|
+
console.log("No pricing known for this model \u2014 set inputPricePerMTok/outputPricePerMTok in your AI settings for a dollar estimate.");
|
|
5912
|
+
}
|
|
5913
|
+
return;
|
|
5914
|
+
}
|
|
5629
5915
|
const reqs = selectRequests(state, {
|
|
5630
5916
|
// Default to translating only empty values; --all forces a full re-translate
|
|
5631
5917
|
// (overwriting existing translations). --only missing stays as a no-op alias.
|
|
@@ -5658,6 +5944,20 @@ async function runTranslate(args) {
|
|
|
5658
5944
|
errors.push(...batchApplied.errors);
|
|
5659
5945
|
saveState(args.statePath, state);
|
|
5660
5946
|
process.stdout.write(`\r ${done}/${total} translated`);
|
|
5947
|
+
},
|
|
5948
|
+
// Record the raw reply so an unparseable model response is diagnosable
|
|
5949
|
+
// from the activity log instead of vanishing into per-item errors.
|
|
5950
|
+
onMalformedReply: (raw, batchSize, locale) => {
|
|
5951
|
+
console.error(`
|
|
5952
|
+
malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
|
|
5953
|
+
appendLog(projectRoot, {
|
|
5954
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5955
|
+
kind: "translate",
|
|
5956
|
+
summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
|
|
5957
|
+
model: ai.model,
|
|
5958
|
+
locale,
|
|
5959
|
+
raw
|
|
5960
|
+
});
|
|
5661
5961
|
}
|
|
5662
5962
|
});
|
|
5663
5963
|
process.stdout.write("\n");
|
|
@@ -5896,9 +6196,10 @@ var COMMAND_HELP = {
|
|
|
5896
6196
|
},
|
|
5897
6197
|
translate: {
|
|
5898
6198
|
summary: "AI-translate missing strings into your target locales (writes back to the state file).",
|
|
5899
|
-
usage: "glotfile translate [--all] [--locale <list>] [--key <glob>]",
|
|
6199
|
+
usage: "glotfile translate [--all] [--estimate] [--locale <list>] [--key <glob>]",
|
|
5900
6200
|
options: [
|
|
5901
6201
|
["--all", "Re-translate every string, not just empty values"],
|
|
6202
|
+
["--estimate", "Print batches, tokens and estimated cost without translating"],
|
|
5902
6203
|
["--locale <list>", "Comma-separated target locales (alias: --locales)"],
|
|
5903
6204
|
["--key <glob>", "Only keys matching this glob"]
|
|
5904
6205
|
]
|