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/server.js
CHANGED
|
@@ -1397,6 +1397,10 @@ function toI18next(value) {
|
|
|
1397
1397
|
if (isIcuPluralOrSelect(value)) return value;
|
|
1398
1398
|
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
1399
1399
|
}
|
|
1400
|
+
function toRuby(value) {
|
|
1401
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1402
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
1403
|
+
}
|
|
1400
1404
|
function placeholdersMatch(source, translation) {
|
|
1401
1405
|
const a = extractPlaceholders(source).sort();
|
|
1402
1406
|
const b = extractPlaceholders(translation).sort();
|
|
@@ -1556,7 +1560,7 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
|
|
|
1556
1560
|
const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
|
|
1557
1561
|
done += batchResults.length;
|
|
1558
1562
|
hooks.onBatchComplete?.(done, total, batchResults, locale);
|
|
1559
|
-
}, signal);
|
|
1563
|
+
}, signal, (raw, batchSize) => hooks.onMalformedReply?.(raw, batchSize, locale));
|
|
1560
1564
|
allResults.push(...localeResults);
|
|
1561
1565
|
if (!signal?.aborted) hooks.onLocaleDone?.(locale);
|
|
1562
1566
|
}
|
|
@@ -2706,6 +2710,84 @@ var angularXliff = {
|
|
|
2706
2710
|
}
|
|
2707
2711
|
};
|
|
2708
2712
|
|
|
2713
|
+
// src/server/adapters/rails-yaml.ts
|
|
2714
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set(["true", "false", "yes", "no", "on", "off", "null", "y", "n"]);
|
|
2715
|
+
function yamlString(s) {
|
|
2716
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t") + '"';
|
|
2717
|
+
}
|
|
2718
|
+
function yamlKey(k) {
|
|
2719
|
+
if (RESERVED_KEYS.has(k.toLowerCase())) return yamlString(k);
|
|
2720
|
+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(k) ? k : yamlString(k);
|
|
2721
|
+
}
|
|
2722
|
+
function yamlMap(node, indent, level) {
|
|
2723
|
+
const pad = " ".repeat(indent * level);
|
|
2724
|
+
const lines = [];
|
|
2725
|
+
for (const key of Object.keys(node).sort()) {
|
|
2726
|
+
const v = node[key];
|
|
2727
|
+
if (v && typeof v === "object") {
|
|
2728
|
+
lines.push(`${pad}${yamlKey(key)}:`);
|
|
2729
|
+
lines.push(...yamlMap(v, indent, level + 1));
|
|
2730
|
+
} else {
|
|
2731
|
+
lines.push(`${pad}${yamlKey(key)}: ${yamlString(String(v))}`);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return lines;
|
|
2735
|
+
}
|
|
2736
|
+
var DEFAULT_LOCALE_CASE8 = "bcp47-hyphen";
|
|
2737
|
+
var railsYaml = {
|
|
2738
|
+
name: "rails-yaml",
|
|
2739
|
+
capabilities: {
|
|
2740
|
+
plural: "native",
|
|
2741
|
+
select: "lossy",
|
|
2742
|
+
nesting: "nested",
|
|
2743
|
+
metadata: false,
|
|
2744
|
+
placeholderStyle: "named",
|
|
2745
|
+
fileGrouping: "per-locale"
|
|
2746
|
+
},
|
|
2747
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE8,
|
|
2748
|
+
export(state, output) {
|
|
2749
|
+
const warnings = [];
|
|
2750
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE8));
|
|
2751
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
2752
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
2753
|
+
const files = [];
|
|
2754
|
+
for (const locale of state.config.locales) {
|
|
2755
|
+
const flat = {};
|
|
2756
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
2757
|
+
if (entry.plural) {
|
|
2758
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2759
|
+
if (!forms) continue;
|
|
2760
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
2761
|
+
const body2 = forms[cat];
|
|
2762
|
+
if (body2 !== void 0) flat[`${key}.${cat}`] = toRuby(body2);
|
|
2763
|
+
}
|
|
2764
|
+
} else {
|
|
2765
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2766
|
+
if (raw === null) continue;
|
|
2767
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
2768
|
+
warnings.push({
|
|
2769
|
+
code: "lossy-plural",
|
|
2770
|
+
key,
|
|
2771
|
+
locale,
|
|
2772
|
+
message: "rails-yaml cannot represent ICU plural/select; written unconverted"
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
flat[key] = toRuby(raw);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
const { tree: nested, collisions } = nestKeys(flat);
|
|
2779
|
+
for (const c of collisions) {
|
|
2780
|
+
warnings.push({ code: "key-collision", key: c, locale, message: "key is both a leaf and a parent; dropped from nested output" });
|
|
2781
|
+
}
|
|
2782
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
|
|
2783
|
+
const body = [`${yamlKey(token)}:`, ...yamlMap(nested, indent, 1)].join("\n");
|
|
2784
|
+
files.push({ path: resolvePath(output.path, token), contents: finalNewline ? body + "\n" : body });
|
|
2785
|
+
}
|
|
2786
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
2787
|
+
return { files, warnings };
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2709
2791
|
// src/server/adapters/index.ts
|
|
2710
2792
|
function resolvePath(template, locale, namespace = "") {
|
|
2711
2793
|
return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
|
|
@@ -2739,7 +2821,8 @@ function getRegistry() {
|
|
|
2739
2821
|
[gettextPo.name]: gettextPo,
|
|
2740
2822
|
[appleStringsdict.name]: appleStringsdict,
|
|
2741
2823
|
[vueI18nJson.name]: vueI18nJson,
|
|
2742
|
-
[angularXliff.name]: angularXliff
|
|
2824
|
+
[angularXliff.name]: angularXliff,
|
|
2825
|
+
[railsYaml.name]: railsYaml
|
|
2743
2826
|
};
|
|
2744
2827
|
}
|
|
2745
2828
|
function getAdapter(name) {
|
|
@@ -2786,6 +2869,7 @@ function buildSystemPrompt(hasPluralItems) {
|
|
|
2786
2869
|
"- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
|
|
2787
2870
|
"- 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.",
|
|
2788
2871
|
"- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
|
|
2872
|
+
`- 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.`,
|
|
2789
2873
|
"- Match the register and capitalization conventions of the target language and of UI microcopy.",
|
|
2790
2874
|
"- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations."
|
|
2791
2875
|
];
|
|
@@ -2869,6 +2953,24 @@ var BATCH_SCHEMA = {
|
|
|
2869
2953
|
};
|
|
2870
2954
|
|
|
2871
2955
|
// src/server/ai/batch.ts
|
|
2956
|
+
var MalformedReplyError = class extends Error {
|
|
2957
|
+
constructor(raw) {
|
|
2958
|
+
super("Model reply was not valid translation JSON.");
|
|
2959
|
+
this.raw = raw;
|
|
2960
|
+
this.name = "MalformedReplyError";
|
|
2961
|
+
}
|
|
2962
|
+
raw;
|
|
2963
|
+
};
|
|
2964
|
+
function parseReplyItems(text) {
|
|
2965
|
+
let parsed;
|
|
2966
|
+
try {
|
|
2967
|
+
parsed = JSON.parse(text);
|
|
2968
|
+
} catch {
|
|
2969
|
+
throw new MalformedReplyError(text);
|
|
2970
|
+
}
|
|
2971
|
+
if (!Array.isArray(parsed.items)) throw new MalformedReplyError(text);
|
|
2972
|
+
return parsed.items;
|
|
2973
|
+
}
|
|
2872
2974
|
function chunk(items, size) {
|
|
2873
2975
|
const out = [];
|
|
2874
2976
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
@@ -2904,15 +3006,28 @@ function validatePlural(req, forms) {
|
|
|
2904
3006
|
function validateReply(req, item) {
|
|
2905
3007
|
return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
|
|
2906
3008
|
}
|
|
2907
|
-
async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
|
|
3009
|
+
async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, onMalformedReply) {
|
|
3010
|
+
const failBatch = (batch) => batch.map((req) => ({ id: req.id, error: "Model returned malformed JSON for this string." }));
|
|
3011
|
+
async function resolveBatch(batch, isRetry = false) {
|
|
3012
|
+
let reply;
|
|
3013
|
+
try {
|
|
3014
|
+
reply = await callBatch(batch, signal);
|
|
3015
|
+
} catch (err) {
|
|
3016
|
+
if (!(err instanceof MalformedReplyError)) throw err;
|
|
3017
|
+
onMalformedReply?.(err.raw, batch.length);
|
|
3018
|
+
if (signal?.aborted) return failBatch(batch);
|
|
3019
|
+
if (batch.length === 1) return isRetry ? failBatch(batch) : resolveBatch(batch, true);
|
|
3020
|
+
const mid = Math.ceil(batch.length / 2);
|
|
3021
|
+
return [...await resolveBatch(batch.slice(0, mid)), ...await resolveBatch(batch.slice(mid))];
|
|
3022
|
+
}
|
|
3023
|
+
const byId = new Map(reply.map((r) => [r.id, r]));
|
|
3024
|
+
return batch.map((req) => validateReply(req, byId.get(req.id)));
|
|
3025
|
+
}
|
|
2908
3026
|
const results = [];
|
|
2909
3027
|
const total = reqs.length;
|
|
2910
3028
|
for (const batch of chunk(reqs, Math.max(1, batchSize))) {
|
|
2911
3029
|
if (signal?.aborted) break;
|
|
2912
|
-
const
|
|
2913
|
-
const byId = new Map(reply.map((r) => [r.id, r]));
|
|
2914
|
-
const batchResults = [];
|
|
2915
|
-
for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
|
|
3030
|
+
const batchResults = await resolveBatch(batch);
|
|
2916
3031
|
results.push(...batchResults);
|
|
2917
3032
|
onBatchComplete?.(results.length, total, batchResults);
|
|
2918
3033
|
}
|
|
@@ -2937,8 +3052,8 @@ var AnthropicProvider = class {
|
|
|
2937
3052
|
supportsVision() {
|
|
2938
3053
|
return true;
|
|
2939
3054
|
}
|
|
2940
|
-
translate(reqs, onBatchComplete, signal) {
|
|
2941
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
3055
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
3056
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
2942
3057
|
}
|
|
2943
3058
|
// Build the user message as content blocks: each unique key's screenshot is
|
|
2944
3059
|
// sent once (a key recurs once per target locale in a batch — dedupe by key),
|
|
@@ -2985,13 +3100,8 @@ var AnthropicProvider = class {
|
|
|
2985
3100
|
output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
|
|
2986
3101
|
messages: [{ role: "user", content }]
|
|
2987
3102
|
}, { signal });
|
|
2988
|
-
const text = res.content.find((b) => b.type === "text")?.text ?? "
|
|
2989
|
-
|
|
2990
|
-
const parsed = JSON.parse(text);
|
|
2991
|
-
return parsed.items ?? [];
|
|
2992
|
-
} catch {
|
|
2993
|
-
return [];
|
|
2994
|
-
}
|
|
3103
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "";
|
|
3104
|
+
return parseReplyItems(text);
|
|
2995
3105
|
}
|
|
2996
3106
|
};
|
|
2997
3107
|
|
|
@@ -3025,8 +3135,8 @@ var OpenAIProvider = class {
|
|
|
3025
3135
|
supportsVision() {
|
|
3026
3136
|
return true;
|
|
3027
3137
|
}
|
|
3028
|
-
translate(reqs, onBatchComplete, signal) {
|
|
3029
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
3138
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
3139
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
3030
3140
|
}
|
|
3031
3141
|
// User content as an array of parts: each unique key's screenshot once (as an
|
|
3032
3142
|
// image_url data URL), then the batch prompt text describing every item.
|
|
@@ -3075,13 +3185,8 @@ var OpenAIProvider = class {
|
|
|
3075
3185
|
{ role: "user", content: this.buildUserContent(batch) }
|
|
3076
3186
|
]
|
|
3077
3187
|
}, { signal });
|
|
3078
|
-
const text = res.choices?.[0]?.message?.content ?? "
|
|
3079
|
-
|
|
3080
|
-
const parsed = JSON.parse(text);
|
|
3081
|
-
return parsed.items ?? [];
|
|
3082
|
-
} catch {
|
|
3083
|
-
return [];
|
|
3084
|
-
}
|
|
3188
|
+
const text = res.choices?.[0]?.message?.content ?? "";
|
|
3189
|
+
return parseReplyItems(text);
|
|
3085
3190
|
}
|
|
3086
3191
|
};
|
|
3087
3192
|
|
|
@@ -3126,8 +3231,8 @@ var BedrockProvider = class {
|
|
|
3126
3231
|
supportsVision() {
|
|
3127
3232
|
return !this.isMeta();
|
|
3128
3233
|
}
|
|
3129
|
-
translate(reqs, onBatchComplete, signal) {
|
|
3130
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
3234
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
3235
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
3131
3236
|
}
|
|
3132
3237
|
buildContentBlocks(batch) {
|
|
3133
3238
|
const blocks = [];
|
|
@@ -3191,13 +3296,8 @@ var BedrockProvider = class {
|
|
|
3191
3296
|
const blocks = res.output?.message?.content ?? [];
|
|
3192
3297
|
const tool = blocks.find((b) => b.toolUse)?.toolUse;
|
|
3193
3298
|
if (tool?.input?.items) return tool.input.items;
|
|
3194
|
-
const text = blocks.find((b) => b.text)?.text ?? "
|
|
3195
|
-
|
|
3196
|
-
const parsed = JSON.parse(text);
|
|
3197
|
-
return parsed.items ?? [];
|
|
3198
|
-
} catch {
|
|
3199
|
-
return [];
|
|
3200
|
-
}
|
|
3299
|
+
const text = blocks.find((b) => b.text)?.text ?? "";
|
|
3300
|
+
return parseReplyItems(text);
|
|
3201
3301
|
}
|
|
3202
3302
|
};
|
|
3203
3303
|
|
|
@@ -3317,8 +3417,8 @@ var ClaudeCodeProvider = class {
|
|
|
3317
3417
|
supportsVision() {
|
|
3318
3418
|
return false;
|
|
3319
3419
|
}
|
|
3320
|
-
translate(reqs, onBatchComplete, signal) {
|
|
3321
|
-
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
3420
|
+
translate(reqs, onBatchComplete, signal, onMalformedReply) {
|
|
3421
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
|
|
3322
3422
|
}
|
|
3323
3423
|
async complete(req) {
|
|
3324
3424
|
const systemParts = [req.system, `Respond with valid JSON matching this schema: ${JSON.stringify(req.schema)}`];
|
|
@@ -3341,12 +3441,7 @@ var ClaudeCodeProvider = class {
|
|
|
3341
3441
|
throw err;
|
|
3342
3442
|
}
|
|
3343
3443
|
if (signal?.aborted) return [];
|
|
3344
|
-
|
|
3345
|
-
const parsed = JSON.parse(stripFences(result));
|
|
3346
|
-
return parsed.items ?? [];
|
|
3347
|
-
} catch {
|
|
3348
|
-
return [];
|
|
3349
|
-
}
|
|
3444
|
+
return parseReplyItems(stripFences(result));
|
|
3350
3445
|
}
|
|
3351
3446
|
};
|
|
3352
3447
|
|
|
@@ -3370,6 +3465,102 @@ function makeProvider(ai) {
|
|
|
3370
3465
|
}
|
|
3371
3466
|
}
|
|
3372
3467
|
|
|
3468
|
+
// src/server/ai/pricing.ts
|
|
3469
|
+
var PRICE_TABLE = [
|
|
3470
|
+
["claude-fable-5", 10, 50],
|
|
3471
|
+
["claude-mythos-5", 10, 50],
|
|
3472
|
+
// Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
|
|
3473
|
+
// the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
|
|
3474
|
+
["claude-opus-4-1", 15, 75],
|
|
3475
|
+
["claude-opus-4-0", 15, 75],
|
|
3476
|
+
["claude-opus-4-2025", 15, 75],
|
|
3477
|
+
["claude-opus-4", 5, 25],
|
|
3478
|
+
["claude-sonnet-4", 3, 15],
|
|
3479
|
+
["claude-haiku-4", 1, 5],
|
|
3480
|
+
["claude-3-5-haiku", 0.8, 4],
|
|
3481
|
+
["gpt-5.5-pro", 30, 180],
|
|
3482
|
+
["gpt-5.5", 5, 30],
|
|
3483
|
+
["gpt-5.4-pro", 30, 180],
|
|
3484
|
+
["gpt-5.4-mini", 0.75, 4.5],
|
|
3485
|
+
["gpt-5.4-nano", 0.2, 1.25],
|
|
3486
|
+
["gpt-5.4", 2.5, 15],
|
|
3487
|
+
["gpt-5.3-codex", 1.75, 14]
|
|
3488
|
+
];
|
|
3489
|
+
var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
|
|
3490
|
+
function bareModelId(model) {
|
|
3491
|
+
let id = model.trim().toLowerCase();
|
|
3492
|
+
const slash = id.lastIndexOf("/");
|
|
3493
|
+
if (slash !== -1) id = id.slice(slash + 1);
|
|
3494
|
+
const anth = id.lastIndexOf("anthropic.");
|
|
3495
|
+
if (anth !== -1) id = id.slice(anth + "anthropic.".length);
|
|
3496
|
+
return id;
|
|
3497
|
+
}
|
|
3498
|
+
function resolvePricing(ai) {
|
|
3499
|
+
if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
|
|
3500
|
+
return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
|
|
3501
|
+
}
|
|
3502
|
+
if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
|
|
3503
|
+
const id = bareModelId(ai.model);
|
|
3504
|
+
let best;
|
|
3505
|
+
for (const row of PRICE_TABLE) {
|
|
3506
|
+
if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
|
|
3507
|
+
}
|
|
3508
|
+
return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/server/ai/estimate.ts
|
|
3512
|
+
var CJK_RE = /[ -鿿가-豈-]/g;
|
|
3513
|
+
function estimateTokens(text) {
|
|
3514
|
+
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
3515
|
+
return Math.ceil((text.length - cjk) / 4 + cjk / 2);
|
|
3516
|
+
}
|
|
3517
|
+
var EXPANSION = 1.2;
|
|
3518
|
+
var ITEM_REPLY_OVERHEAD = 16;
|
|
3519
|
+
var FORM_REPLY_OVERHEAD = 8;
|
|
3520
|
+
function estimateOutputTokens(req) {
|
|
3521
|
+
const translated = Math.ceil(estimateTokens(req.source) * EXPANSION);
|
|
3522
|
+
if (req.plural) {
|
|
3523
|
+
return ITEM_REPLY_OVERHEAD + req.plural.categories.length * (translated + FORM_REPLY_OVERHEAD);
|
|
3524
|
+
}
|
|
3525
|
+
return ITEM_REPLY_OVERHEAD + translated;
|
|
3526
|
+
}
|
|
3527
|
+
function estimateTranslation(state, ai, opts) {
|
|
3528
|
+
const reqs = selectRequests(state, opts);
|
|
3529
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
3530
|
+
for (const r of reqs) {
|
|
3531
|
+
let group = byLocale.get(r.targetLocale);
|
|
3532
|
+
if (!group) {
|
|
3533
|
+
group = [];
|
|
3534
|
+
byLocale.set(r.targetLocale, group);
|
|
3535
|
+
}
|
|
3536
|
+
group.push(r);
|
|
3537
|
+
}
|
|
3538
|
+
const perLocale = [];
|
|
3539
|
+
for (const [locale, group] of byLocale) {
|
|
3540
|
+
let inputTokens2 = 0;
|
|
3541
|
+
let outputTokens2 = 0;
|
|
3542
|
+
const batches = chunk(group, Math.max(1, ai.batchSize));
|
|
3543
|
+
for (const batch of batches) {
|
|
3544
|
+
const system = buildSystemPrompt(batch.some((r) => r.plural !== void 0));
|
|
3545
|
+
inputTokens2 += estimateTokens(system) + estimateTokens(buildBatchPrompt(batch));
|
|
3546
|
+
for (const r of batch) outputTokens2 += estimateOutputTokens(r);
|
|
3547
|
+
}
|
|
3548
|
+
perLocale.push({ locale, requests: group.length, batches: batches.length, inputTokens: inputTokens2, outputTokens: outputTokens2 });
|
|
3549
|
+
}
|
|
3550
|
+
const inputTokens = perLocale.reduce((n, l) => n + l.inputTokens, 0);
|
|
3551
|
+
const outputTokens = perLocale.reduce((n, l) => n + l.outputTokens, 0);
|
|
3552
|
+
const pricing = resolvePricing(ai);
|
|
3553
|
+
return {
|
|
3554
|
+
requests: reqs.length,
|
|
3555
|
+
batches: perLocale.reduce((n, l) => n + l.batches, 0),
|
|
3556
|
+
perLocale,
|
|
3557
|
+
inputTokens,
|
|
3558
|
+
outputTokens,
|
|
3559
|
+
pricing,
|
|
3560
|
+
estimatedCost: pricing ? (inputTokens * pricing.inputPerMTok + outputTokens * pricing.outputPerMTok) / 1e6 : null
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3373
3564
|
// src/server/log.ts
|
|
3374
3565
|
import { appendFileSync, readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
|
|
3375
3566
|
import { resolve as resolve6 } from "path";
|
|
@@ -3970,7 +4161,9 @@ function coerceAi(raw) {
|
|
|
3970
4161
|
contextBatchSize: typeof a.contextBatchSize === "number" && a.contextBatchSize > 0 ? a.contextBatchSize : void 0,
|
|
3971
4162
|
contextConcurrency: typeof a.contextConcurrency === "number" && a.contextConcurrency > 0 ? a.contextConcurrency : void 0,
|
|
3972
4163
|
vision: typeof a.vision === "boolean" ? a.vision : void 0,
|
|
3973
|
-
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0
|
|
4164
|
+
promptStyle: PROMPT_STYLES.includes(a.promptStyle) ? a.promptStyle : void 0,
|
|
4165
|
+
inputPricePerMTok: typeof a.inputPricePerMTok === "number" && a.inputPricePerMTok >= 0 ? a.inputPricePerMTok : void 0,
|
|
4166
|
+
outputPricePerMTok: typeof a.outputPricePerMTok === "number" && a.outputPricePerMTok >= 0 ? a.outputPricePerMTok : void 0
|
|
3974
4167
|
};
|
|
3975
4168
|
}
|
|
3976
4169
|
function coerceProfiles(raw) {
|
|
@@ -4009,6 +4202,10 @@ function aiConfigError(ai) {
|
|
|
4009
4202
|
if (!(a.endpoint === null || a.endpoint === void 0 || typeof a.endpoint === "string")) return "ai.endpoint must be a string or null";
|
|
4010
4203
|
if (!(a.region === void 0 || a.region === null || typeof a.region === "string")) return "ai.region must be a string or null";
|
|
4011
4204
|
if (typeof a.batchSize !== "number") return "ai.batchSize must be a number";
|
|
4205
|
+
for (const f of ["inputPricePerMTok", "outputPricePerMTok"]) {
|
|
4206
|
+
const v = a[f];
|
|
4207
|
+
if (!(v === void 0 || v === null || typeof v === "number" && v >= 0)) return `ai.${f} must be a non-negative number`;
|
|
4208
|
+
}
|
|
4012
4209
|
return null;
|
|
4013
4210
|
}
|
|
4014
4211
|
|
|
@@ -4651,6 +4848,19 @@ function createApi(deps) {
|
|
|
4651
4848
|
},
|
|
4652
4849
|
onLocaleDone: (locale) => {
|
|
4653
4850
|
void stream.writeSSE({ event: "locale-done", data: JSON.stringify({ locale }) });
|
|
4851
|
+
},
|
|
4852
|
+
// Record the raw reply so an unparseable model response is diagnosable
|
|
4853
|
+
// from the activity log instead of vanishing into per-item errors.
|
|
4854
|
+
onMalformedReply: (raw, batchSize, locale) => {
|
|
4855
|
+
console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
|
|
4856
|
+
appendLog(projectRoot, {
|
|
4857
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4858
|
+
kind: "translate",
|
|
4859
|
+
summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
|
|
4860
|
+
model: aiCfg.model,
|
|
4861
|
+
locale,
|
|
4862
|
+
raw
|
|
4863
|
+
});
|
|
4654
4864
|
}
|
|
4655
4865
|
}, aiCfg.concurrency, signal);
|
|
4656
4866
|
if (!signal?.aborted) {
|
|
@@ -4683,7 +4893,19 @@ function createApi(deps) {
|
|
|
4683
4893
|
}
|
|
4684
4894
|
const { skipped } = attachScreenshotsForProvider(toTranslate, s, dirname3(resolve9(deps.statePath)), provider.supportsVision());
|
|
4685
4895
|
if (skipped) console.warn(`Model "${aiCfg.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
4686
|
-
const results = await runLocaleParallel(toTranslate, provider, {
|
|
4896
|
+
const results = await runLocaleParallel(toTranslate, provider, {
|
|
4897
|
+
onMalformedReply: (raw, batchSize, locale) => {
|
|
4898
|
+
console.error(`[translate] malformed model reply (${locale}, batch of ${batchSize})${batchSize > 1 ? " \u2014 splitting batch and retrying" : ""}`);
|
|
4899
|
+
appendLog(projectRoot, {
|
|
4900
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4901
|
+
kind: "translate",
|
|
4902
|
+
summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
|
|
4903
|
+
model: aiCfg.model,
|
|
4904
|
+
locale,
|
|
4905
|
+
raw
|
|
4906
|
+
});
|
|
4907
|
+
}
|
|
4908
|
+
}, aiCfg.concurrency);
|
|
4687
4909
|
const latest = load();
|
|
4688
4910
|
({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
|
|
4689
4911
|
const entry = {
|
|
@@ -4709,6 +4931,13 @@ function createApi(deps) {
|
|
|
4709
4931
|
}
|
|
4710
4932
|
return c.json({ requested: reqs.length, written, errors });
|
|
4711
4933
|
}));
|
|
4934
|
+
app.post("/translate/estimate", async (c) => {
|
|
4935
|
+
const body = await c.req.json().catch(() => ({}));
|
|
4936
|
+
const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
|
|
4937
|
+
const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
|
|
4938
|
+
const ai = loadLocalSettings(projectRoot).ai;
|
|
4939
|
+
return c.json(estimateTranslation(load(), ai, { onlyMissing: body.onlyMissing ?? true, keys, locales }));
|
|
4940
|
+
});
|
|
4712
4941
|
app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
|
|
4713
4942
|
app.post("/scan", async (c) => {
|
|
4714
4943
|
const s = load();
|