@braze/sanity-plugin-ai-translation 0.2.0 → 0.2.1

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/index.js CHANGED
@@ -1,24 +1,27 @@
1
1
  import { useClient, defineType, defineField, pathToString, definePlugin } from "sanity";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, ArrowLeftIcon, EditIcon, InfoOutlineIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
- import { createContext, useContext, useEffect, useState, useReducer, useMemo } from "react";
5
- import { Card, Flex, Stack, Text, Spinner, Button, Box, Checkbox, Select, TextInput, TextArea, Heading, Inline, Tooltip, Badge, Grid, Label, Code } from "@sanity/ui";
3
+ import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, WarningOutlineIcon, InfoOutlineIcon, ArrowLeftIcon, EditIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
4
+ import { createContext, useContext, useEffect, useState, useReducer, useCallback, useMemo } from "react";
5
+ import { Card, Flex, Stack, Text, Spinner, Button, Box, Tooltip, Checkbox, Select, TextInput, TextArea, Heading, Inline, Badge, Grid, Radio, Label, Code } from "@sanity/ui";
6
6
  import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
7
7
  import defaults from "lodash.defaults";
8
8
  import { toPlainText } from "@portabletext/react";
9
- import { diffPatch } from "@sanity/diff-patch";
9
+ import cloneDeep from "lodash.clonedeep";
10
+ import get from "lodash.get";
11
+ import set from "lodash.set";
10
12
  const TranslationContext = createContext({
11
13
  document: null,
12
14
  config: null,
13
15
  overallStatus: "idle",
14
16
  selectedTargetLanguages: [],
15
17
  translationJobs: /* @__PURE__ */ new Map(),
16
- shouldTranslateDiffs: !1,
17
18
  shouldOverwriteFreshTranslations: !1,
18
19
  shouldTranslateReferences: !1,
19
20
  maxReferenceDepth: 1,
20
21
  references: [],
21
22
  translationMetadata: null,
23
+ translationMode: "sanity-only",
24
+ openaiValidated: !1,
22
25
  dispatch: () => {
23
26
  }
24
27
  }), useTranslationContext = () => {
@@ -28,7 +31,7 @@ const TranslationContext = createContext({
28
31
  "useTranslationContext must be used within a TranslationContextProvider with document and config props"
29
32
  );
30
33
  return context;
31
- }, TRANSLATION_METADATA_TYPE = "translation.metadata", UPDATE_EXISTING_TRANSLATION_PROMPT = "***IMPORTANT:** This document has an existing, but out-of-date translation. Please review the existing translation before processing and change as little as possible. Existing translation: $extantTranslation", undraftId = (id) => id.replace(/^drafts\./, ""), draftId = (id) => `drafts.${undraftId(id)}`, TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
34
+ }, TRANSLATION_METADATA_TYPE = "translation.metadata", undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
32
35
  const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
33
36
  params: {
34
37
  documentId: undraftId(documentId),
@@ -81,6 +84,102 @@ const TranslationContext = createContext({
81
84
  isLoggingEnabled() && console.timeEnd(`[AI Translation] ${label}`);
82
85
  }
83
86
  };
87
+ async function validateSuggestion(client, suggestion) {
88
+ return suggestion.type === "glossaryTerm" ? validateGlossaryTerm(client, suggestion) : suggestion.type === "protectedPhrase" ? validateProtectedPhrase(client, suggestion) : suggestion.type === "globalPrompt" || suggestion.type === "localePrompt" ? validatePrompt(suggestion) : { valid: !0, status: "valid" };
89
+ }
90
+ async function validateGlossaryTerm(client, suggestion) {
91
+ const { sourceValue, targetValue, sourceLocale, targetLocale } = suggestion;
92
+ if (!sourceValue || !targetValue || !sourceLocale || !targetLocale)
93
+ return {
94
+ valid: !1,
95
+ status: "invalid",
96
+ reason: "Missing required fields for glossary term"
97
+ };
98
+ if (sourceValue.trim() === targetValue.trim())
99
+ return {
100
+ valid: !1,
101
+ status: "invalid",
102
+ reason: `Source and target are identical ("${sourceValue}") - this should be a protected phrase instead`
103
+ };
104
+ const protectedPhrases = await client.fetch('*[_id == "translation-settings"][0].protectedPhrases');
105
+ if (protectedPhrases?.some((phrase) => phrase.toLowerCase() === sourceValue.toLowerCase()))
106
+ return {
107
+ valid: !1,
108
+ status: "invalid",
109
+ reason: `Cannot add "${sourceValue}" to glossary because it is already a protected phrase. Protected phrases are never translated. If you want to create a translation mapping for this term, please remove it from protected phrases first by editing Translation Settings.`
110
+ };
111
+ if (targetValue && protectedPhrases?.some((phrase) => phrase.toLowerCase() === targetValue.toLowerCase()))
112
+ return {
113
+ valid: !1,
114
+ status: "invalid",
115
+ reason: `Cannot use "${targetValue}" as a translation target because it is a protected phrase. Protected phrases should not appear as translation targets.`
116
+ };
117
+ const existingTerm = await client.fetch(
118
+ `*[_type == "translationGlossaryTerm" && count(translations[_key == $sourceLocale && value == $sourceValue]) > 0][0] {
119
+ translations
120
+ }`,
121
+ { sourceLocale, sourceValue }
122
+ );
123
+ if (existingTerm) {
124
+ const existingTarget = existingTerm.translations.find((t) => t._key === targetLocale);
125
+ if (existingTarget?.value === targetValue)
126
+ return {
127
+ valid: !0,
128
+ status: "warning",
129
+ reason: `This translation already exists in the glossary: "${sourceValue}" \u2192 "${targetValue}"`
130
+ };
131
+ if (existingTarget && existingTarget.value !== targetValue)
132
+ return {
133
+ valid: !0,
134
+ status: "warning",
135
+ reason: `"${sourceValue}" already has a different translation in the glossary: "${existingTarget.value}". Applying this will update it to "${targetValue}".`
136
+ };
137
+ }
138
+ return { valid: !0, status: "valid" };
139
+ }
140
+ async function validateProtectedPhrase(client, suggestion) {
141
+ const phrase = suggestion.description.trim();
142
+ if (!phrase)
143
+ return {
144
+ valid: !1,
145
+ status: "invalid",
146
+ reason: "Protected phrase cannot be empty"
147
+ };
148
+ if ((await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.some((p) => p.toLowerCase() === phrase.toLowerCase()))
149
+ return {
150
+ valid: !0,
151
+ status: "warning",
152
+ reason: `"${phrase}" is already in protected phrases`
153
+ };
154
+ const glossaryTermWithPhrase = await client.fetch(
155
+ `*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0][0] {
156
+ _id,
157
+ translations
158
+ }`,
159
+ { phrase }
160
+ );
161
+ if (glossaryTermWithPhrase) {
162
+ const matchingTranslations = glossaryTermWithPhrase.translations.filter((t) => t.value.toLowerCase() === phrase.toLowerCase()).map((t) => t._key).join(", ");
163
+ return {
164
+ valid: !1,
165
+ status: "invalid",
166
+ reason: `Cannot add "${phrase}" as a protected phrase because it exists in the glossary as a translation (${matchingTranslations}). If you want to prevent this term from being translated, please remove it from the glossary first by editing Translation Settings.`
167
+ };
168
+ }
169
+ return { valid: !0, status: "valid" };
170
+ }
171
+ function validatePrompt(suggestion) {
172
+ const update = suggestion.description.trim();
173
+ return update ? update.toLowerCase().includes("has been") || update.toLowerCase().includes("was changed") || update.toLowerCase().includes("corrected to") ? {
174
+ valid: !0,
175
+ status: "warning",
176
+ reason: "This update may be describing past changes rather than adding new rules"
177
+ } : { valid: !0, status: "valid" } : {
178
+ valid: !1,
179
+ status: "invalid",
180
+ reason: "Prompt update cannot be empty"
181
+ };
182
+ }
84
183
  async function analyzeFeedback({
85
184
  client,
86
185
  sourceLanguage,
@@ -108,8 +207,25 @@ async function analyzeFeedback({
108
207
  currentDocument
109
208
  }), analysis = await analyzeWithAI(client, analysisPrompt);
110
209
  devLog.info("AI analysis complete", { analysis });
111
- const suggestions = convertAnalysisToSuggestions(analysis);
112
- return devLog.info("Generated suggestions", { count: suggestions.length }), suggestions;
210
+ const suggestions = convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage);
211
+ devLog.info("Generated suggestions", { count: suggestions.length }), onProgress?.({ stage: "analyzing", message: "Validating suggestions..." });
212
+ const validatedSuggestions = await Promise.all(
213
+ suggestions.map(async (suggestion) => {
214
+ const validation = await validateSuggestion(client, suggestion);
215
+ return {
216
+ ...suggestion,
217
+ warning: validation.reason,
218
+ validationStatus: validation.status,
219
+ // Only auto-approve valid suggestions; invalid ones cannot be approved
220
+ approved: validation.status === "valid"
221
+ };
222
+ })
223
+ );
224
+ return devLog.info("Suggestions validated", {
225
+ valid: validatedSuggestions.filter((s) => s.validationStatus === "valid").length,
226
+ warnings: validatedSuggestions.filter((s) => s.validationStatus === "warning").length,
227
+ invalid: validatedSuggestions.filter((s) => s.validationStatus === "invalid").length
228
+ }), validatedSuggestions;
113
229
  }
114
230
  async function applySuggestions({
115
231
  client,
@@ -217,11 +333,67 @@ function buildAnalysisPrompt({
217
333
  "",
218
334
  "Be conservative: only suggest changes when there is clear evidence.",
219
335
  "If in doubt, prefer locale-specific updates over global updates.",
220
- "Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts."
336
+ "Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts.",
337
+ "",
338
+ "CONCRETE EXAMPLES:",
339
+ "",
340
+ "Example 1 - Brand name protection:",
341
+ 'User feedback: "Braze should never be translated"',
342
+ "Correct output:",
343
+ "{",
344
+ ' "protectedPhrasesToAdd": ["Braze"],',
345
+ ' "reasoning": "User explicitly stated Braze is a brand name that should not be translated"',
346
+ "}",
347
+ "WRONG: Do not add this to glossary or prompts.",
348
+ "",
349
+ "Example 2 - Terminology correction:",
350
+ 'Edit analysis: User changed "discussion" to "chat" consistently in French translation',
351
+ "Correct output:",
352
+ "{",
353
+ ` "glossaryTermsToAdd": [{"sourceLocale": "${sourceLanguage}", "sourceValue": "discussion", "targetLocale": "${targetLanguage}", "targetValue": "chat"}],`,
354
+ ' "reasoning": "User consistently changed discussion\u2192chat, indicating preferred terminology"',
355
+ "}",
356
+ "WRONG: Do not add to protected phrases (values differ) or prompts (specific mapping).",
357
+ "",
358
+ "Example 3 - Style guidance:",
359
+ 'User feedback: "Translation is too formal, use casual tone"',
360
+ "Correct output:",
361
+ "{",
362
+ ` "localePromptUpdates": "Use a casual, conversational tone when translating to ${targetLanguage}. Avoid overly formal language.",`,
363
+ ' "reasoning": "This is style guidance specific to this language pair"',
364
+ "}",
365
+ "WRONG: Do not make global (applies only to this target language).",
366
+ "",
367
+ "Example 4 - Technical term in English:",
368
+ `User feedback: "Keep technical terms like 'API' and 'webhook' in English"`,
369
+ "Correct output:",
370
+ "{",
371
+ ` "localePromptUpdates": "When translating to ${targetLanguage}, keep technical terms like 'API', 'webhook', and similar technical jargon in English.",`,
372
+ ' "reasoning": "This is language-pair-specific guidance about handling technical terms"',
373
+ "}",
374
+ 'WRONG: Do not add "API" or "webhook" to protected phrases (already covered by prompt guidance).',
375
+ "",
376
+ "Example 5 - Multiple issues:",
377
+ 'User feedback: "MyApp is our product name. Also, use more concise language."',
378
+ "Correct output:",
379
+ "{",
380
+ ' "protectedPhrasesToAdd": ["MyApp"],',
381
+ ` "localePromptUpdates": "Use concise language when translating to ${targetLanguage}. Prefer shorter phrases over lengthy explanations.",`,
382
+ ' "reasoning": "MyApp is a product name (protected). Conciseness is a style preference for this language pair (locale prompt)."',
383
+ "}",
384
+ "",
385
+ "Example 6 - Identical source and target:",
386
+ 'Edit analysis: User kept "dashboard" as "dashboard" in Spanish',
387
+ "Correct output:",
388
+ "{",
389
+ ' "protectedPhrasesToAdd": ["dashboard"],',
390
+ ' "reasoning": "User kept the term unchanged, indicating it should not be translated"',
391
+ "}",
392
+ "WRONG: Do not add to glossary when source === target."
221
393
  ), parts.join(`
222
394
  `);
223
395
  }
224
- function convertAnalysisToSuggestions(analysis) {
396
+ function convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage) {
225
397
  const suggestions = [];
226
398
  if (analysis.globalPromptUpdates && suggestions.push({
227
399
  id: crypto.randomUUID(),
@@ -248,9 +420,10 @@ function convertAnalysisToSuggestions(analysis) {
248
420
  type: "glossaryTerm",
249
421
  description: `${term.sourceValue} \u2192 ${term.targetValue}`,
250
422
  approved: !0,
251
- sourceLocale: term.sourceLocale,
423
+ // Use the provided source/target languages instead of relying on AI to provide them
424
+ sourceLocale: sourceLanguage,
252
425
  sourceValue: term.sourceValue,
253
- targetLocale: term.targetLocale,
426
+ targetLocale: targetLanguage,
254
427
  targetValue: term.targetValue
255
428
  });
256
429
  return suggestions;
@@ -297,13 +470,35 @@ function parseAIResponse(responseText) {
297
470
  }
298
471
  }
299
472
  async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage, suggestions, onProgress) {
300
- devLog.info("Applying improvements from suggestions", { count: suggestions.length });
473
+ devLog.info("Applying improvements from suggestions", {
474
+ count: suggestions.length,
475
+ targetLanguage,
476
+ suggestions: suggestions.map((s) => ({
477
+ type: s.type,
478
+ approved: s.approved,
479
+ sourceLocale: s.sourceLocale,
480
+ targetLocale: s.targetLocale,
481
+ sourceValue: s.sourceValue,
482
+ targetValue: s.targetValue
483
+ }))
484
+ });
301
485
  const settingsId = "translation-settings";
302
486
  await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
303
487
  _id: settingsId,
304
488
  _type: "translationSettings"
305
489
  });
306
- const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTerms = suggestions.filter(
490
+ const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTermsBeforeFilter = suggestions.filter((s) => s.type === "glossaryTerm");
491
+ devLog.info("Glossary terms before filtering", {
492
+ count: glossaryTermsBeforeFilter.length,
493
+ terms: glossaryTermsBeforeFilter.map((s) => ({
494
+ sourceLocale: s.sourceLocale,
495
+ sourceValue: s.sourceValue,
496
+ targetLocale: s.targetLocale,
497
+ targetValue: s.targetValue,
498
+ approved: s.approved
499
+ }))
500
+ });
501
+ const glossaryTerms = suggestions.filter(
307
502
  (s) => s.type === "glossaryTerm" && s.sourceLocale?.trim() && s.sourceValue?.trim() && s.targetLocale?.trim() && s.targetValue?.trim()
308
503
  );
309
504
  if (devLog.info("Filtered suggestions by type", {
@@ -314,7 +509,7 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
314
509
  }), (globalPrompts.length > 0 || localePrompts.length > 0) && (onProgress?.({
315
510
  stage: "applying",
316
511
  message: globalPrompts.length > 0 && localePrompts.length > 0 ? "Updating global and locale translation prompts..." : globalPrompts.length > 0 ? "Updating global translation prompt..." : `Updating ${targetLanguage} translation prompt...`
317
- }), updates.push(updatePromptsWithTransform(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
512
+ }), updates.push(updatePromptsWithTemplate(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
318
513
  onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
319
514
  const phrases = protectedPhrases.map((s) => s.description);
320
515
  devLog.debug("Applying protected phrases", { phrases }), updates.push(addProtectedPhrases(client, settingsId, phrases));
@@ -331,14 +526,17 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
331
526
  }));
332
527
  devLog.debug("Applying glossary terms", { terms }), updates.push(addGlossaryTerms(client, terms));
333
528
  }
334
- updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), devLog.info("All improvements applied successfully");
529
+ updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), onProgress?.({ stage: "applying", message: "Recording changes..." }), await recordChangeHistory(
530
+ client,
531
+ settingsId,
532
+ targetLanguage,
533
+ suggestions.filter((s) => s.approved)
534
+ ), devLog.info("All improvements applied successfully");
335
535
  }
336
- async function updatePromptsWithTransform(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
337
- if (devLog.info("Updating prompts with transform action", {
536
+ async function updatePromptsWithTemplate(client, _schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
537
+ if (devLog.info("Updating prompts with deterministic template", {
338
538
  settingsId,
339
539
  targetLanguage,
340
- targetLanguageType: typeof targetLanguage,
341
- targetLanguageValue: targetLanguage,
342
540
  hasGlobalUpdates: globalPrompts.length > 0,
343
541
  hasLocaleUpdates: localePrompts.length > 0
344
542
  }), !targetLanguage)
@@ -347,91 +545,56 @@ async function updatePromptsWithTransform(client, schemaId, settingsId, targetLa
347
545
  globalPrompts: globalPrompts.length,
348
546
  localePrompts: localePrompts.length
349
547
  }), new Error("targetLanguage is required but was undefined");
350
- const targets = [];
548
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
351
549
  if (globalPrompts.length > 0) {
352
- const combinedGlobalUpdate = globalPrompts.map((s) => s.description).join(`
353
-
550
+ const currentGlobalPrompt = currentSettings?.globalPrompt || "", updates = globalPrompts.map((s) => s.description.trim()).filter(Boolean), newGlobalPrompt = [
551
+ currentGlobalPrompt,
552
+ currentGlobalPrompt ? `
553
+ ` : "",
554
+ `## User Feedback (${timestamp})`,
555
+ ...updates.map((update) => `- ${update}`)
556
+ ].filter(Boolean).join(`
354
557
  `);
355
- targets.push({
356
- path: "globalPrompt",
357
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
358
-
359
- IMPORTANT: The text below is NEW GUIDANCE that should be INCORPORATED INTO the existing prompt as additional rules/instructions for future translations. DO NOT apply or execute these rules on the prompt text itself.
360
-
361
- Your task: Merge the new guidance below into the existing prompt text. Treat the new guidance as rules that will guide future translations, not as transformations to apply to the current text.
362
-
363
- Integration guidelines:
364
- - Add the new guidance as additional instructions/rules in the prompt
365
- - If it conflicts with existing guidance, update or replace the conflicting parts
366
- - If it's already covered, don't add redundant text
367
- - Maintain clear structure and consistent tone
368
- - Preserve all existing guidance that is still relevant
369
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
370
-
371
- New guidance to incorporate:
372
-
373
- \`\`\`
374
- ${combinedGlobalUpdate}
375
- \`\`\``
376
- }), devLog.debug("Added global prompt target", { combinedUpdate: combinedGlobalUpdate });
558
+ await client.patch(settingsId).set({ globalPrompt: newGlobalPrompt }).commit(), devLog.info("Global prompt updated", {
559
+ previousLength: currentGlobalPrompt.length,
560
+ newLength: newGlobalPrompt.length,
561
+ addedUpdates: updates.length
562
+ });
377
563
  }
378
564
  if (localePrompts.length > 0) {
379
- const combinedLocaleUpdate = localePrompts.map((s) => s.description).join(`
380
-
381
- `), existingLocalePrompts = await client.fetch(
382
- "*[_id == $settingsId][0].localePrompts",
383
- { settingsId }
384
- ), hasExistingLocalePrompt = existingLocalePrompts?.some((lp) => lp._key === targetLanguage);
385
- devLog.debug("Checking existing locale prompt", {
386
- targetLanguage,
387
- hasExistingLocalePrompt,
388
- existingCount: existingLocalePrompts?.length || 0
389
- }), hasExistingLocalePrompt || (devLog.info("Creating new locale prompt entry", { targetLanguage }), await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
390
- {
391
- _type: "internationalizedArrayTextValue",
392
- _key: targetLanguage,
393
- value: ""
394
- // Start with empty value, transform will fill it
395
- }
396
- ]).commit());
397
- const localePromptPath = ["localePrompts", { _key: targetLanguage }, "value"];
398
- targets.push({
399
- // @ts-expect-error
400
- path: localePromptPath,
401
- instruction: `You are updating a translation prompt document that contains rules and guidance for future AI translations.
402
-
403
- IMPORTANT: The text below is NEW GUIDANCE that should be INCORPORATED INTO the existing prompt as additional rules/instructions for future translations. DO NOT apply or execute these rules on the prompt text itself.
404
-
405
- Your task: Merge the new guidance below into the existing prompt text. Treat the new guidance as rules that will guide future translations, not as transformations to apply to the current text.
406
-
407
- Integration guidelines:
408
- - Add the new guidance as additional instructions/rules in the prompt
409
- - If it conflicts with existing guidance, update or replace the conflicting parts
410
- - If it's already covered, don't add redundant text
411
- - Maintain clear structure and consistent tone
412
- - Preserve all existing guidance that is still relevant
413
- - Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
414
-
415
- New guidance to incorporate:
416
-
417
- \`\`\`
418
- ${combinedLocaleUpdate}
419
- \`\`\``
420
- }), devLog.debug("Added locale prompt target", {
421
- targetLanguage,
422
- localePromptPath,
423
- combinedUpdate: combinedLocaleUpdate
424
- });
565
+ const updates = localePrompts.map((s) => s.description.trim()).filter(Boolean), existingLocalePrompts = currentSettings?.localePrompts || [], existingLocalePrompt = existingLocalePrompts.find((lp) => lp._key === targetLanguage);
566
+ if (existingLocalePrompt) {
567
+ const currentValue = existingLocalePrompt.value || "", newValue = [
568
+ currentValue,
569
+ currentValue ? `
570
+ ` : "",
571
+ `## User Feedback (${timestamp})`,
572
+ ...updates.map((update) => `- ${update}`)
573
+ ].filter(Boolean).join(`
574
+ `), localeIndex = existingLocalePrompts.findIndex((lp) => lp._key === targetLanguage);
575
+ await client.patch(settingsId).set({ [`localePrompts[${localeIndex}].value`]: newValue }).commit(), devLog.info("Locale prompt updated", {
576
+ targetLanguage,
577
+ previousLength: currentValue.length,
578
+ newLength: newValue.length,
579
+ addedUpdates: updates.length
580
+ });
581
+ } else {
582
+ const newValue = [`## User Feedback (${timestamp})`, ...updates.map((update) => `- ${update}`)].filter(Boolean).join(`
583
+ `);
584
+ await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
585
+ {
586
+ _type: "internationalizedArrayTextValue",
587
+ _key: targetLanguage,
588
+ value: newValue
589
+ }
590
+ ]).commit(), devLog.info("Locale prompt created", {
591
+ targetLanguage,
592
+ length: newValue.length,
593
+ addedUpdates: updates.length
594
+ });
595
+ }
425
596
  }
426
- const result = await client.agent.action.transform({
427
- schemaId,
428
- documentId: settingsId,
429
- instruction: "Update translation prompts based on user feedback to improve future translations.",
430
- target: targets,
431
- forcePublishedWrite: !0
432
- });
433
- devLog.info("Prompts updated via transform action", {
434
- resultId: result._id,
597
+ devLog.info("Prompts updated successfully", {
435
598
  globalUpdated: globalPrompts.length > 0,
436
599
  localeUpdated: localePrompts.length > 0
437
600
  });
@@ -440,8 +603,14 @@ async function addProtectedPhrases(client, settingsId, phrases) {
440
603
  devLog.info("Adding protected phrases", { settingsId, phrasesCount: phrases.length, phrases });
441
604
  const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
442
605
  devLog.debug("Fetched current protected phrases", { currentCount: currentPhrases?.length });
443
- const newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
444
- if (newPhrases.length === 0) {
606
+ let newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
607
+ const conflictingPhrases = [];
608
+ for (const phrase of newPhrases)
609
+ await client.fetch(
610
+ 'count(*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0]) > 0',
611
+ { phrase }
612
+ ) && (conflictingPhrases.push(phrase), devLog.warn("Skipping protected phrase due to glossary conflict", { phrase }));
613
+ if (newPhrases = newPhrases.filter((phrase) => !conflictingPhrases.includes(phrase)), newPhrases.length === 0) {
445
614
  devLog.info("No new protected phrases to add (all already exist)");
446
615
  return;
447
616
  }
@@ -449,7 +618,13 @@ async function addProtectedPhrases(client, settingsId, phrases) {
449
618
  devLog.info("Protected phrases added", { count: newPhrases.length, phrases: newPhrases, resultId: result._id });
450
619
  }
451
620
  async function addGlossaryTerms(client, terms) {
452
- const validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
621
+ const protectedPhrasesLower = (await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.map((p) => p.toLowerCase()) || [], validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
622
+ sourceValue,
623
+ targetValue
624
+ }), !1) : protectedPhrasesLower.includes(sourceValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because source value is a protected phrase", {
625
+ sourceValue,
626
+ targetValue
627
+ }), !1) : protectedPhrasesLower.includes(targetValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because target value is a protected phrase", {
453
628
  sourceValue,
454
629
  targetValue
455
630
  }), !1) : !0);
@@ -466,32 +641,47 @@ async function addGlossaryTerms(client, terms) {
466
641
  }`,
467
642
  { sourceLocale, sourceValue }
468
643
  );
469
- existingTerm ? existingTerm.translations.some((t) => t._key === targetLocale) ? devLog.info("Glossary term already has this translation", {
470
- sourceLocale,
471
- sourceValue,
472
- targetLocale,
473
- targetValue
474
- }) : (await client.patch(existingTerm._id).append("translations", [
475
- {
476
- _type: "internationalizedArrayStringValue",
477
- _key: targetLocale,
478
- value: targetValue
479
- }
480
- ]).commit(), devLog.info("Updated existing glossary term", { sourceLocale, sourceValue, targetLocale, targetValue })) : (await client.create({
481
- _type: "translationGlossaryTerm",
482
- translations: [
483
- {
484
- _type: "internationalizedArrayStringValue",
485
- _key: sourceLocale,
486
- value: sourceValue
487
- },
644
+ if (existingTerm) {
645
+ const existingTargetIndex = existingTerm.translations.findIndex((t) => t._key === targetLocale), existingTargetValue = existingTargetIndex >= 0 ? existingTerm.translations[existingTargetIndex].value : null;
646
+ existingTargetIndex === -1 ? (await client.patch(existingTerm._id).append("translations", [
488
647
  {
489
648
  _type: "internationalizedArrayStringValue",
490
649
  _key: targetLocale,
491
650
  value: targetValue
492
651
  }
493
- ]
494
- }), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue }));
652
+ ]).commit(), devLog.info("Added target language to existing glossary term", {
653
+ sourceLocale,
654
+ sourceValue,
655
+ targetLocale,
656
+ targetValue
657
+ })) : existingTargetValue !== targetValue ? (await client.patch(existingTerm._id).set({ [`translations[${existingTargetIndex}].value`]: targetValue }).commit(), devLog.info("Updated existing glossary term translation", {
658
+ sourceLocale,
659
+ sourceValue,
660
+ targetLocale,
661
+ oldValue: existingTargetValue,
662
+ newValue: targetValue
663
+ })) : devLog.info("Glossary term already has this exact translation", {
664
+ sourceLocale,
665
+ sourceValue,
666
+ targetLocale,
667
+ targetValue
668
+ });
669
+ } else
670
+ await client.create({
671
+ _type: "translationGlossaryTerm",
672
+ translations: [
673
+ {
674
+ _type: "internationalizedArrayStringValue",
675
+ _key: sourceLocale,
676
+ value: sourceValue
677
+ },
678
+ {
679
+ _type: "internationalizedArrayStringValue",
680
+ _key: targetLocale,
681
+ value: targetValue
682
+ }
683
+ ]
684
+ }), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue });
495
685
  }
496
686
  devLog.info("Glossary terms processed", {
497
687
  totalRequested: terms.length,
@@ -499,6 +689,20 @@ async function addGlossaryTerms(client, terms) {
499
689
  skipped: terms.length - validTerms.length
500
690
  });
501
691
  }
692
+ async function recordChangeHistory(client, settingsId, targetLanguage, suggestions) {
693
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString(), historyEntries = suggestions.map((suggestion) => ({
694
+ _type: "object",
695
+ _key: crypto.randomUUID(),
696
+ timestamp,
697
+ changeType: suggestion.type,
698
+ description: suggestion.description,
699
+ locale: suggestion.type === "localePrompt" ? targetLanguage : void 0
700
+ }));
701
+ historyEntries.length !== 0 && (await client.patch(settingsId).setIfMissing({ changeHistory: [] }).append("changeHistory", historyEntries).commit(), devLog.info("Change history recorded", {
702
+ count: historyEntries.length,
703
+ timestamp
704
+ }));
705
+ }
502
706
  const ICON_SIZE = 64, progressIconMap = {
503
707
  in_progress: /* @__PURE__ */ jsx(Spinner, {}),
504
708
  completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
@@ -521,7 +725,7 @@ const ICON_SIZE = 64, progressIconMap = {
521
725
  localePrompt: "Locale Prompt",
522
726
  protectedPhrase: "Protected Phrase",
523
727
  glossaryTerm: "Glossary Term"
524
- }, createNewSuggestion = (type = "protectedPhrase") => {
728
+ }, createNewSuggestion = (type = "protectedPhrase", sourceLanguage, targetLanguage) => {
525
729
  const baseSuggestion = {
526
730
  id: crypto.randomUUID(),
527
731
  type,
@@ -531,11 +735,18 @@ const ICON_SIZE = 64, progressIconMap = {
531
735
  return type === "glossaryTerm" ? {
532
736
  ...baseSuggestion,
533
737
  sourceValue: "",
534
- targetValue: ""
738
+ targetValue: "",
739
+ sourceLocale: sourceLanguage,
740
+ targetLocale: targetLanguage
535
741
  } : baseSuggestion;
536
- }, SuggestionReviewList = ({ suggestions, onSuggestionsChange }) => {
742
+ }, SuggestionReviewList = ({
743
+ suggestions,
744
+ onSuggestionsChange,
745
+ sourceLanguage,
746
+ targetLanguage
747
+ }) => {
537
748
  const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
538
- const updated = suggestions.map((s) => s.id === id ? { ...s, approved: !s.approved } : s);
749
+ const updated = suggestions.map((s) => s.id === id && s.validationStatus !== "invalid" ? { ...s, approved: !s.approved } : s);
539
750
  onSuggestionsChange(updated);
540
751
  }, handleTypeChange = (id, newType) => {
541
752
  const updated = suggestions.map((s) => {
@@ -547,7 +758,9 @@ const ICON_SIZE = 64, progressIconMap = {
547
758
  ...s,
548
759
  type: newType,
549
760
  sourceValue: match ? match[1].trim() : "",
550
- targetValue: match ? match[2].trim() : ""
761
+ targetValue: match ? match[2].trim() : "",
762
+ sourceLocale: sourceLanguage,
763
+ targetLocale: targetLanguage
551
764
  };
552
765
  }
553
766
  return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
@@ -568,6 +781,7 @@ const ICON_SIZE = 64, progressIconMap = {
568
781
  ...s,
569
782
  sourceValue: newSource,
570
783
  description: `${newSource} \u2192 ${s.targetValue || ""}`
784
+ // Preserve sourceLocale and targetLocale
571
785
  } : s
572
786
  );
573
787
  onSuggestionsChange(updated);
@@ -577,6 +791,7 @@ const ICON_SIZE = 64, progressIconMap = {
577
791
  ...s,
578
792
  targetValue: newTarget,
579
793
  description: `${s.sourceValue || ""} \u2192 ${newTarget}`
794
+ // Preserve sourceLocale and targetLocale
580
795
  } : s
581
796
  );
582
797
  onSuggestionsChange(updated);
@@ -584,7 +799,7 @@ const ICON_SIZE = 64, progressIconMap = {
584
799
  const updated = suggestions.filter((s) => s.id !== id);
585
800
  onSuggestionsChange(updated);
586
801
  }, handleAddSuggestion = (type) => {
587
- const newSuggestion = createNewSuggestion(type);
802
+ const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
588
803
  onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
589
804
  };
590
805
  return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
@@ -611,16 +826,24 @@ const ICON_SIZE = 64, progressIconMap = {
611
826
  ) })
612
827
  ] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
613
828
  suggestions.map((suggestion) => {
614
- const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt";
829
+ const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt", hasWarning = suggestion.warning && suggestion.validationStatus !== "valid", isInvalid = suggestion.validationStatus === "invalid";
615
830
  return /* @__PURE__ */ jsx(
616
831
  Card,
617
832
  {
618
833
  padding: 4,
619
834
  border: !0,
620
- tone: suggestion.approved ? "default" : "transparent",
835
+ tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
621
836
  style: { opacity: suggestion.approved ? 1 : 0.5 },
622
837
  children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
623
- /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
838
+ /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: isInvalid ? /* @__PURE__ */ jsx(
839
+ Tooltip,
840
+ {
841
+ content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "This suggestion cannot be applied because it conflicts with existing settings. See the error message below for details." }) }),
842
+ placement: "top",
843
+ portal: !0,
844
+ children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Checkbox, { checked: !1, disabled: !0, readOnly: !0 }) })
845
+ }
846
+ ) : /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
624
847
  /* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
625
848
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
626
849
  /* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
@@ -629,6 +852,7 @@ const ICON_SIZE = 64, progressIconMap = {
629
852
  fontSize: 1,
630
853
  value: suggestion.type,
631
854
  onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
855
+ "aria-label": "Suggestion type",
632
856
  children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
633
857
  }
634
858
  ) }),
@@ -640,31 +864,42 @@ const ICON_SIZE = 64, progressIconMap = {
640
864
  icon: TrashIcon,
641
865
  fontSize: 1,
642
866
  padding: 2,
643
- onClick: () => handleRemove(suggestion.id)
867
+ onClick: () => handleRemove(suggestion.id),
868
+ "aria-label": "Remove suggestion",
869
+ title: "Remove suggestion"
644
870
  }
645
871
  )
646
872
  ] }),
647
- isGlossaryTerm ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
648
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
649
- TextInput,
650
- {
651
- fontSize: 1,
652
- placeholder: "Source term",
653
- value: suggestion.sourceValue || "",
654
- onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value)
655
- }
656
- ) }),
657
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "\u2192" }),
658
- /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
659
- TextInput,
660
- {
661
- fontSize: 1,
662
- placeholder: "Target term",
663
- value: suggestion.targetValue || "",
664
- onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value)
665
- }
666
- ) })
667
- ] }) }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
873
+ isGlossaryTerm ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
874
+ (suggestion.sourceLocale || suggestion.targetLocale) && /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
875
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.sourceLocale ? `Source (${suggestion.sourceLocale})` : "Source" }) }),
876
+ /* @__PURE__ */ jsx(Box, { style: { width: "20px" } }),
877
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.targetLocale ? `Target (${suggestion.targetLocale})` : "Target" }) })
878
+ ] }),
879
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
880
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
881
+ TextInput,
882
+ {
883
+ fontSize: 1,
884
+ placeholder: "Source term",
885
+ value: suggestion.sourceValue || "",
886
+ onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value),
887
+ "aria-label": `Source term in ${suggestion.sourceLocale || "source language"}`
888
+ }
889
+ ) }),
890
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, "aria-hidden": "true", children: "\u2192" }),
891
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
892
+ TextInput,
893
+ {
894
+ fontSize: 1,
895
+ placeholder: "Target term",
896
+ value: suggestion.targetValue || "",
897
+ onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value),
898
+ "aria-label": `Target term in ${suggestion.targetLocale || "target language"}`
899
+ }
900
+ ) })
901
+ ] })
902
+ ] }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
668
903
  TextArea,
669
904
  {
670
905
  fontSize: 1,
@@ -673,7 +908,8 @@ const ICON_SIZE = 64, progressIconMap = {
673
908
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
674
909
  onFocus: () => setEditingId(suggestion.id),
675
910
  onBlur: () => setEditingId(null),
676
- placeholder: "Enter prompt guidance..."
911
+ placeholder: "Enter prompt guidance...",
912
+ "aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
677
913
  }
678
914
  ) }) : /* @__PURE__ */ jsx(
679
915
  TextInput,
@@ -681,9 +917,17 @@ const ICON_SIZE = 64, progressIconMap = {
681
917
  fontSize: 1,
682
918
  value: suggestion.description,
683
919
  onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
684
- placeholder: "Enter protected phrase..."
920
+ placeholder: "Enter protected phrase...",
921
+ "aria-label": "Protected phrase"
685
922
  }
686
- )
923
+ ),
924
+ hasWarning && /* @__PURE__ */ jsx(Card, { padding: 3, tone: isInvalid ? "critical" : "caution", border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 2, children: [
925
+ /* @__PURE__ */ jsx(Text, { size: 1, children: isInvalid ? /* @__PURE__ */ jsx(WarningOutlineIcon, {}) : /* @__PURE__ */ jsx(InfoOutlineIcon, {}) }),
926
+ /* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
927
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: isInvalid ? "Invalid Suggestion" : "Warning" }),
928
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: suggestion.warning })
929
+ ] })
930
+ ] }) })
687
931
  ] })
688
932
  ] }) })
689
933
  },
@@ -705,7 +949,7 @@ const ICON_SIZE = 64, progressIconMap = {
705
949
  }, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
706
950
  const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
707
951
  documentId: document._id
708
- }), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), handleShouldIncludeEditsChange = () => {
952
+ }), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), [sourceLanguage, setSourceLanguage] = useState(""), [targetLanguage, setTargetLanguage] = useState(""), handleShouldIncludeEditsChange = () => {
709
953
  setShouldIncludeEdits(!shouldIncludeEdits);
710
954
  }, handleCancel = () => {
711
955
  setIsDialogOpen(!1);
@@ -727,14 +971,16 @@ const ICON_SIZE = 64, progressIconMap = {
727
971
  throw new Error(
728
972
  "No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
729
973
  );
730
- const { sourceSnapshot, targetSnapshot, sourceLanguage } = translationHistory2;
974
+ const { sourceSnapshot, targetSnapshot, sourceLanguage: srcLang } = translationHistory2;
731
975
  if (!sourceSnapshot || !targetSnapshot)
732
976
  throw new Error("Translation snapshots are missing from history.");
977
+ const srcLanguage = srcLang || "en-us", tgtLanguage = document[config.languageFieldName];
978
+ setSourceLanguage(srcLanguage), setTargetLanguage(tgtLanguage);
733
979
  const generatedSuggestions = await analyzeFeedback({
734
980
  client,
735
981
  schemaId: config.schemaId,
736
- sourceLanguage: sourceLanguage || "en-us",
737
- targetLanguage: document[config.languageFieldName],
982
+ sourceLanguage: srcLanguage,
983
+ targetLanguage: tgtLanguage,
738
984
  userFeedback: feedback || void 0,
739
985
  shouldIncludeEdits,
740
986
  sourceSnapshot,
@@ -788,7 +1034,15 @@ const ICON_SIZE = 64, progressIconMap = {
788
1034
  /* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
789
1035
  /* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
790
1036
  ] }),
791
- /* @__PURE__ */ jsx(SuggestionReviewList, { suggestions, onSuggestionsChange: setSuggestions }),
1037
+ /* @__PURE__ */ jsx(
1038
+ SuggestionReviewList,
1039
+ {
1040
+ suggestions,
1041
+ onSuggestionsChange: setSuggestions,
1042
+ sourceLanguage,
1043
+ targetLanguage
1044
+ }
1045
+ ),
792
1046
  error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
793
1047
  /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
794
1048
  /* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
@@ -896,6 +1150,33 @@ const ICON_SIZE = 64, progressIconMap = {
896
1150
  )
897
1151
  ] })
898
1152
  ] });
1153
+ }, validateOpenAIApiKey = async (apiKey, model = "gpt-5") => {
1154
+ try {
1155
+ const response = await fetch("https://api.openai.com/v1/models", {
1156
+ method: "GET",
1157
+ headers: {
1158
+ Authorization: `Bearer ${apiKey}`
1159
+ }
1160
+ });
1161
+ if (!response.ok)
1162
+ return {
1163
+ valid: !1,
1164
+ error: (await response.json()).error?.message || "Invalid API key"
1165
+ };
1166
+ const availableModels = (await response.json()).data.map((m) => m.id);
1167
+ return availableModels.includes(model) ? {
1168
+ valid: !0,
1169
+ modelAvailable: availableModels
1170
+ } : {
1171
+ valid: !1,
1172
+ error: `Model ${model} not available. Try: ${availableModels.slice(0, 5).join(", ")}`
1173
+ };
1174
+ } catch (error) {
1175
+ return {
1176
+ valid: !1,
1177
+ error: error instanceof Error ? error.message : "Network error"
1178
+ };
1179
+ }
899
1180
  }, translationReducer = (context, action) => {
900
1181
  const logContext = {
901
1182
  documentId: context.document?._id,
@@ -915,10 +1196,15 @@ const ICON_SIZE = 64, progressIconMap = {
915
1196
  ...context,
916
1197
  selectedTargetLanguages: action.payload
917
1198
  };
918
- case "SET_SHOULD_TRANSLATE_DIFFS":
1199
+ case "SET_TRANSLATION_MODE":
1200
+ return {
1201
+ ...context,
1202
+ translationMode: action.payload
1203
+ };
1204
+ case "SET_OPENAI_VALIDATED":
919
1205
  return {
920
1206
  ...context,
921
- shouldTranslateDiffs: action.payload
1207
+ openaiValidated: action.payload
922
1208
  };
923
1209
  case "SET_SHOULD_TRANSLATE_REFERENCES":
924
1210
  return {
@@ -963,18 +1249,44 @@ const ICON_SIZE = 64, progressIconMap = {
963
1249
  overallStatus: "idle",
964
1250
  translationMetadata: null,
965
1251
  selectedTargetLanguages: [],
966
- shouldTranslateDiffs: !1,
967
1252
  shouldOverwriteFreshTranslations: !1,
968
1253
  shouldTranslateReferences: !1,
969
1254
  maxReferenceDepth: 1,
970
1255
  references: [],
971
- translationJobs: /* @__PURE__ */ new Map()
1256
+ translationJobs: /* @__PURE__ */ new Map(),
1257
+ translationMode: config?.openai?.defaultMode || "sanity-only",
1258
+ openaiValidated: !1
972
1259
  }, [context, dispatch] = useReducer(translationReducer, initialContext);
973
- return devLog.debug("Translation context state initialized", {
1260
+ return useEffect(() => {
1261
+ (async () => {
1262
+ if (config?.openai?.enabled && config?.openai?.apiKey)
1263
+ try {
1264
+ const validation = await validateOpenAIApiKey(config.openai.apiKey, config.openai.model || "gpt-5");
1265
+ dispatch({
1266
+ type: "SET_OPENAI_VALIDATED",
1267
+ payload: validation.valid
1268
+ }), validation.valid ? devLog.info("OpenAI API key validated successfully", {
1269
+ model: config.openai.model || "gpt-5",
1270
+ component: "TranslationContextProvider"
1271
+ }) : devLog.error("OpenAI API key validation failed", {
1272
+ error: validation.error,
1273
+ component: "TranslationContextProvider"
1274
+ });
1275
+ } catch (error) {
1276
+ devLog.error("OpenAI validation error", error instanceof Error ? error : new Error(String(error)), {
1277
+ component: "TranslationContextProvider"
1278
+ }), dispatch({
1279
+ type: "SET_OPENAI_VALIDATED",
1280
+ payload: !1
1281
+ });
1282
+ }
1283
+ })();
1284
+ }, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), devLog.debug("Translation context state initialized", {
974
1285
  documentId: document._id,
975
1286
  overallStatus: context.overallStatus,
976
1287
  selectedLanguages: context.selectedTargetLanguages,
977
- shouldTranslateDiffs: context.shouldTranslateDiffs,
1288
+ translationMode: context.translationMode,
1289
+ openaiEnabled: config?.openai?.enabled,
978
1290
  component: "TranslationContextProvider"
979
1291
  }), /* @__PURE__ */ jsx(TranslationContext.Provider, { value: { ...context, dispatch }, children });
980
1292
  }, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
@@ -1077,26 +1389,98 @@ const ICON_SIZE = 64, progressIconMap = {
1077
1389
  title: "Translation Settings",
1078
1390
  icon: TranslateIcon,
1079
1391
  type: "document",
1392
+ groups: [
1393
+ {
1394
+ name: "general",
1395
+ title: "General",
1396
+ default: !0
1397
+ },
1398
+ {
1399
+ name: "history",
1400
+ title: "History"
1401
+ }
1402
+ ],
1080
1403
  fields: [
1081
1404
  defineField({
1082
1405
  name: "globalPrompt",
1083
1406
  title: "Global Translation Prompt",
1084
1407
  description: "A prompt that will given to the Sanity AI Agent for all translation tasks.",
1085
1408
  type: "text",
1086
- rows: 5
1409
+ rows: 5,
1410
+ group: "general"
1087
1411
  }),
1088
1412
  defineField({
1089
1413
  name: "localePrompts",
1090
1414
  title: "Locale Prompts",
1091
1415
  description: "Prompts that will be given to the Sanity AI Agent for each locale.",
1092
- type: "internationalizedArrayText"
1416
+ type: "internationalizedArrayText",
1417
+ group: "general"
1093
1418
  }),
1094
1419
  defineField({
1095
1420
  name: "protectedPhrases",
1096
1421
  title: "Protected Phrases",
1097
1422
  description: "Phrases that will never be translated.",
1098
1423
  type: "array",
1099
- of: [{ type: "string" }]
1424
+ of: [{ type: "string" }],
1425
+ group: "general"
1426
+ }),
1427
+ defineField({
1428
+ name: "changeHistory",
1429
+ title: "Change History",
1430
+ description: "History of changes made via user feedback.",
1431
+ type: "array",
1432
+ readOnly: !0,
1433
+ group: "history",
1434
+ of: [
1435
+ {
1436
+ type: "object",
1437
+ fields: [
1438
+ defineField({
1439
+ name: "timestamp",
1440
+ title: "Timestamp",
1441
+ type: "datetime"
1442
+ }),
1443
+ defineField({
1444
+ name: "changeType",
1445
+ title: "Change Type",
1446
+ type: "string",
1447
+ options: {
1448
+ list: [
1449
+ { title: "Global Prompt", value: "globalPrompt" },
1450
+ { title: "Locale Prompt", value: "localePrompt" },
1451
+ { title: "Protected Phrase", value: "protectedPhrase" },
1452
+ { title: "Glossary Term", value: "glossaryTerm" }
1453
+ ]
1454
+ }
1455
+ }),
1456
+ defineField({
1457
+ name: "description",
1458
+ title: "Description",
1459
+ type: "text",
1460
+ rows: 3
1461
+ }),
1462
+ defineField({
1463
+ name: "locale",
1464
+ title: "Locale",
1465
+ type: "string",
1466
+ description: "Target locale for locale-specific changes"
1467
+ })
1468
+ ],
1469
+ preview: {
1470
+ select: {
1471
+ timestamp: "timestamp",
1472
+ changeType: "changeType",
1473
+ description: "description"
1474
+ },
1475
+ prepare({ timestamp, changeType, description }) {
1476
+ return {
1477
+ title: `${changeType} - ${new Date(timestamp).toLocaleDateString()}`,
1478
+ subtitle: description
1479
+ };
1480
+ }
1481
+ }
1482
+ }
1483
+ ]
1100
1484
  })
1101
1485
  ],
1102
1486
  preview: {
@@ -1104,7 +1488,81 @@ const ICON_SIZE = 64, progressIconMap = {
1104
1488
  title: "Translation Settings"
1105
1489
  })
1106
1490
  }
1107
- }), DEFAULTS = {
1491
+ });
1492
+ class Semaphore {
1493
+ permits;
1494
+ queue = [];
1495
+ constructor(permits) {
1496
+ this.permits = permits;
1497
+ }
1498
+ /**
1499
+ * Acquire a permit from the semaphore
1500
+ * If no permits are available, the caller will wait until one becomes available
1501
+ */
1502
+ async acquire() {
1503
+ return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
1504
+ this.queue.push(resolve);
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Release a permit back to the semaphore
1509
+ * If there are waiting callers, one will be woken up
1510
+ */
1511
+ release() {
1512
+ const next = this.queue.shift();
1513
+ next ? next() : this.permits++;
1514
+ }
1515
+ /**
1516
+ * Execute a function with semaphore protection
1517
+ * Automatically acquires and releases the permit
1518
+ */
1519
+ async withPermit(fn) {
1520
+ await this.acquire();
1521
+ try {
1522
+ return await fn();
1523
+ } finally {
1524
+ this.release();
1525
+ }
1526
+ }
1527
+ /**
1528
+ * Get the number of available permits
1529
+ */
1530
+ availablePermits() {
1531
+ return this.permits;
1532
+ }
1533
+ /**
1534
+ * Get the number of waiting callers
1535
+ */
1536
+ queueLength() {
1537
+ return this.queue.length;
1538
+ }
1539
+ /**
1540
+ * Update the number of permits
1541
+ * If increasing permits, waiting callers will be notified
1542
+ * If decreasing permits below current available, available permits will be reduced
1543
+ */
1544
+ updatePermits(newPermits) {
1545
+ const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
1546
+ if (difference > 0) {
1547
+ for (let i = 0; i < difference && this.queue.length > 0; i++) {
1548
+ const next = this.queue.shift();
1549
+ next && next();
1550
+ }
1551
+ const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
1552
+ remainingIncrease > 0 && (this.permits += remainingIncrease);
1553
+ } else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
1554
+ }
1555
+ }
1556
+ function documentToPlainText(document) {
1557
+ if (!document) return "";
1558
+ const result = {};
1559
+ for (const [key, value] of Object.entries(document))
1560
+ Array.isArray(value) && value.some(
1561
+ (item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
1562
+ ) ? result[key] = toPlainText(value) : result[key] = value;
1563
+ return JSON.stringify(result);
1564
+ }
1565
+ const DEFAULTS = {
1108
1566
  windowSlack: 2,
1109
1567
  minTokenMatchRatio: 1,
1110
1568
  stopwords: [
@@ -1154,7 +1612,7 @@ function freqMap(tokens) {
1154
1612
  for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
1155
1613
  return m;
1156
1614
  }
1157
- function matchGlossaryTerm(term, source, options) {
1615
+ function matchTermInDocument(term, source, options) {
1158
1616
  const opts = { ...DEFAULTS, ...options ?? {} }, termTokens = prepareTermTokens(term, opts);
1159
1617
  if (termTokens.length === 0)
1160
1618
  return { matched: !1, term, reason: "No substantive tokens after filtering." };
@@ -1197,15 +1655,6 @@ function matchGlossaryTerm(term, source, options) {
1197
1655
  }
1198
1656
  return { matched: !1, term, reason: "No window satisfied the token threshold." };
1199
1657
  }
1200
- function documentToPlainText(document) {
1201
- if (!document) return "";
1202
- const result = {};
1203
- for (const [key, value] of Object.entries(document))
1204
- Array.isArray(value) && value.some(
1205
- (item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
1206
- ) ? result[key] = toPlainText(value) : result[key] = value;
1207
- return JSON.stringify(result);
1208
- }
1209
1658
  const fetchTranslationPrompts = async ({
1210
1659
  client,
1211
1660
  sourceLanguage,
@@ -1235,12 +1684,15 @@ const fetchTranslationPrompts = async ({
1235
1684
  return !from || !to ? null : { from, to };
1236
1685
  }).filter((item) => item !== null);
1237
1686
  sourceDocument && (relevantTranslations = relevantTranslations.filter(
1238
- ({ from }) => termAppearsInDocument(from, sourceDocumentString)
1687
+ ({ from }) => glossaryTermAppearsInDocument(from, sourceDocumentString)
1239
1688
  )), glossaryTermsByLocale[targetLanguage] = relevantTranslations;
1240
1689
  }
1241
- return {
1690
+ let filteredProtectedPhrases = translationSettings2?.protectedPhrases;
1691
+ return sourceDocument && filteredProtectedPhrases && (filteredProtectedPhrases = filteredProtectedPhrases.filter(
1692
+ (phrase) => protectedPhraseAppearsInDocument(phrase, sourceDocumentString)
1693
+ )), {
1242
1694
  globalPrompt: translationSettings2?.globalPrompt,
1243
- protectedPhrases: translationSettings2?.protectedPhrases,
1695
+ protectedPhrases: filteredProtectedPhrases,
1244
1696
  localeInstructions: Object.fromEntries(
1245
1697
  targetLanguages.map((locale) => [
1246
1698
  locale,
@@ -1251,9 +1703,14 @@ const fetchTranslationPrompts = async ({
1251
1703
  ])
1252
1704
  )
1253
1705
  };
1254
- }, termAppearsInDocument = (term, documentString) => {
1706
+ }, glossaryTermAppearsInDocument = (term, documentString) => {
1255
1707
  const baseTerm = term.split(":")[0].trim(), source = preprocessSource(documentString);
1256
- return matchGlossaryTerm(baseTerm, source, {
1708
+ return matchTermInDocument(baseTerm, source, {
1709
+ windowSlack: 10
1710
+ }).matched;
1711
+ }, protectedPhraseAppearsInDocument = (phrase, documentString) => {
1712
+ const source = preprocessSource(documentString);
1713
+ return matchTermInDocument(phrase, source, {
1257
1714
  windowSlack: 10
1258
1715
  }).matched;
1259
1716
  }, formatGlossaryTermInstruction = (terms) => {
@@ -1268,10 +1725,14 @@ const fetchTranslationPrompts = async ({
1268
1725
  "- Match the source casing when possible; only adjust when required for correct grammar.",
1269
1726
  "- If the source term meaning is absent, do not introduce a translation from this list.",
1270
1727
  "- When a term includes a colon, translate the text before the colon for general use; use the text after the colon when the feminine form is required.",
1271
- "- Do not remove important words or phrases from the source text when they are essential to the meaning."
1728
+ "- Do not remove important words or phrases from the source text when they are essential to the meaning.",
1729
+ "- Never add punctuation or symbols that are not present in the source text.",
1730
+ "- Never apply glossary terms in code examples."
1272
1731
  ];
1273
1732
  return terms.forEach(({ from, to }) => {
1274
- instructions.push(`When text conveys the same meaning as "${from}", translate it as "${to}".`);
1733
+ instructions.push(
1734
+ `When text conveys the same meaning as "${from}", translate it as "${to}" unless the term is used in a code example.`
1735
+ );
1275
1736
  }), instructions.join(`
1276
1737
  `);
1277
1738
  }, findKeyValues = (obj, targetKey) => {
@@ -1298,12 +1759,19 @@ const fetchTranslationPrompts = async ({
1298
1759
  try {
1299
1760
  const doc = await client.fetch("*[_id == $documentId][0]", { documentId });
1300
1761
  if (!doc) {
1301
- console.error(documentId, "not found");
1762
+ devLog.error("Document not found", new Error("Document not found"), {
1763
+ documentId,
1764
+ component: "localizeReferences"
1765
+ });
1302
1766
  return;
1303
1767
  }
1304
1768
  const targetLanguage = doc[languageFieldName];
1305
1769
  if (!targetLanguage) {
1306
- console.error(documentId, "no value found for language field:", languageFieldName);
1770
+ devLog.error("No value found for language field", new Error("Missing language field"), {
1771
+ documentId,
1772
+ languageFieldName,
1773
+ component: "localizeReferences"
1774
+ });
1307
1775
  return;
1308
1776
  }
1309
1777
  const refs = findKeyValues(doc, "_ref");
@@ -1311,94 +1779,46 @@ const fetchTranslationPrompts = async ({
1311
1779
  return;
1312
1780
  await updateReferences(documentId, client, refs, targetLanguage);
1313
1781
  } catch (error) {
1314
- console.error("Error localizing references:", error);
1782
+ devLog.error("Error localizing references", error instanceof Error ? error : new Error(String(error)), {
1783
+ documentId,
1784
+ component: "localizeReferences"
1785
+ });
1315
1786
  }
1316
1787
  }, updateReferences = async (documentId, client, refs, targetLanguage) => {
1317
- const patch = client.patch(documentId);
1318
1788
  if (!refs?.length)
1319
1789
  return [];
1790
+ const refIds = refs.map((r) => undraftId(r.value)), translations = await client.fetch(
1791
+ `*[_type == $translationMetadataSchemaType && count(translations[value._ref in $refIds]) > 0]
1792
+ .translations[value._ref in $refIds]{
1793
+ "sourceId": value._ref,
1794
+ "targetId": ^.translations[_key == $targetLanguage][0].value._ref
1795
+ }[defined(targetId)]`,
1796
+ {
1797
+ translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
1798
+ refIds,
1799
+ targetLanguage
1800
+ }
1801
+ ), translationMap = /* @__PURE__ */ new Map();
1802
+ for (const t of translations)
1803
+ t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
1804
+ const patch = client.patch(documentId);
1805
+ let hasChanges = !1;
1320
1806
  for (const ref of refs) {
1321
- const newId = await client.fetch(
1322
- "(*[_type == $translationMetadataSchemaType && references($sourceId)].translations[_key == $targetLanguage].value._ref)[0]",
1323
- {
1324
- translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
1325
- sourceId: undraftId(ref.value),
1326
- targetLanguage
1327
- }
1328
- );
1807
+ const sourceId = undraftId(ref.value), newId = translationMap.get(sourceId);
1329
1808
  newId && (patch.set({ [pathToString(ref.path)]: undraftId(newId) }), patch.set({ [`${pathToString(ref.path.slice(0, -1))}._weak`]: !0 }), patch.set({
1330
1809
  [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
1331
1810
  _type: "reference",
1332
1811
  _weak: !1
1333
1812
  }
1334
- }), await patch.commit());
1335
- }
1336
- };
1337
- class Semaphore {
1338
- permits;
1339
- queue = [];
1340
- constructor(permits) {
1341
- this.permits = permits;
1342
- }
1343
- /**
1344
- * Acquire a permit from the semaphore
1345
- * If no permits are available, the caller will wait until one becomes available
1346
- */
1347
- async acquire() {
1348
- return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
1349
- this.queue.push(resolve);
1350
- });
1351
- }
1352
- /**
1353
- * Release a permit back to the semaphore
1354
- * If there are waiting callers, one will be woken up
1355
- */
1356
- release() {
1357
- const next = this.queue.shift();
1358
- next ? next() : this.permits++;
1813
+ }), hasChanges = !0);
1359
1814
  }
1360
- /**
1361
- * Execute a function with semaphore protection
1362
- * Automatically acquires and releases the permit
1363
- */
1364
- async withPermit(fn) {
1365
- await this.acquire();
1366
- try {
1367
- return await fn();
1368
- } finally {
1369
- this.release();
1370
- }
1371
- }
1372
- /**
1373
- * Get the number of available permits
1374
- */
1375
- availablePermits() {
1376
- return this.permits;
1377
- }
1378
- /**
1379
- * Get the number of waiting callers
1380
- */
1381
- queueLength() {
1382
- return this.queue.length;
1383
- }
1384
- /**
1385
- * Update the number of permits
1386
- * If increasing permits, waiting callers will be notified
1387
- * If decreasing permits below current available, available permits will be reduced
1388
- */
1389
- updatePermits(newPermits) {
1390
- const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
1391
- if (difference > 0) {
1392
- for (let i = 0; i < difference && this.queue.length > 0; i++) {
1393
- const next = this.queue.shift();
1394
- next && next();
1395
- }
1396
- const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
1397
- remainingIncrease > 0 && (this.permits += remainingIncrease);
1398
- } else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
1399
- }
1400
- }
1401
- const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
1815
+ hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
1816
+ documentId,
1817
+ count: refs.length,
1818
+ translatedCount: translationMap.size,
1819
+ component: "updateReferences"
1820
+ }));
1821
+ }, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
1402
1822
  for (const sourceDocument of sourceDocuments)
1403
1823
  for (const lang of targetLanguages) {
1404
1824
  const payload = {
@@ -1413,21 +1833,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1413
1833
  payload
1414
1834
  });
1415
1835
  }
1416
- }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1417
- (translation) => translation._key === targetLanguage
1418
- )?.value._ref || null, determineTranslationTarget = ({
1419
- translationMetadata,
1420
- targetLanguage
1421
- }) => {
1422
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
1423
- return existingTranslationId ? {
1424
- // Update existing translation
1425
- operation: "createOrReplace",
1426
- _id: existingTranslationId
1427
- } : {
1428
- // Create new translation
1429
- operation: "create"
1430
- };
1431
1836
  }, getTranslationMetadata = async ({
1432
1837
  client,
1433
1838
  sourceDocument,
@@ -1462,6 +1867,52 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1462
1867
  } catch (error) {
1463
1868
  throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
1464
1869
  }
1870
+ }, updateTranslationMetadata = async ({
1871
+ client,
1872
+ targetLanguage,
1873
+ sourceDocument,
1874
+ sourceLanguage,
1875
+ newDocument,
1876
+ translationMetadata,
1877
+ isNewTranslation
1878
+ }) => {
1879
+ try {
1880
+ const refToNewTranslation = {
1881
+ _key: targetLanguage,
1882
+ _type: "internationalizedArrayReferenceValue",
1883
+ value: {
1884
+ _type: "reference",
1885
+ _ref: undraftId(newDocument._id),
1886
+ _weak: !0,
1887
+ _strengthenOnPublish: {
1888
+ _type: "reference",
1889
+ _weak: !1
1890
+ }
1891
+ }
1892
+ }, newTranslationHistory = {
1893
+ _key: targetLanguage,
1894
+ language: targetLanguage,
1895
+ sourceId: undraftId(sourceDocument._id),
1896
+ sourceRev: sourceDocument._rev,
1897
+ sourceLanguage,
1898
+ sourceSnapshot: JSON.stringify(sourceDocument),
1899
+ targetId: newDocument._id,
1900
+ targetRev: newDocument._rev,
1901
+ targetSnapshot: JSON.stringify(newDocument)
1902
+ }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1903
+ isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1904
+ const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1905
+ translationMetadata.translationHistory?.some(
1906
+ (history) => history.language === targetLanguage
1907
+ ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
1908
+ } catch (error) {
1909
+ throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
1910
+ sourceDocumentId: sourceDocument._id,
1911
+ targetLanguage,
1912
+ newDocumentId: newDocument._id,
1913
+ component: "updateTranslationMetadata"
1914
+ }), error;
1915
+ }
1465
1916
  }, setJobStatus = (dispatch, _id, language, status, payload) => {
1466
1917
  dispatch({
1467
1918
  type: "SET_TRANSLATION_JOB_STATUS",
@@ -1471,45 +1922,44 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1471
1922
  status
1472
1923
  })
1473
1924
  });
1474
- }, translateDocumentInPlace = async ({
1475
- client,
1476
- schemaId,
1477
- dispatch,
1478
- sourceDocument,
1479
- languageFieldName,
1480
- targetLanguage,
1481
- targetId,
1482
- patch
1925
+ }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1926
+ (translation) => translation._key === targetLanguage
1927
+ )?.value._ref || null, determineTranslationTarget = ({
1928
+ translationMetadata,
1929
+ targetLanguage
1483
1930
  }) => {
1484
- try {
1485
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating"), await client.agent.action.translate({
1486
- schemaId,
1487
- documentId: targetId,
1488
- toLanguage: {
1489
- title: targetLanguage,
1490
- id: targetLanguage
1491
- },
1492
- fromLanguage: {
1493
- title: sourceDocument[languageFieldName],
1494
- id: sourceDocument[languageFieldName]
1495
- },
1496
- languageFieldPath: [languageFieldName],
1497
- styleGuide: `
1498
- **IMPORTANT:** Much of this document is already translated into the target language. The original version of this document in the source language
1499
- was updated. The following patch instruction shows precisely how the source document was edited: $patchInstruction. Your job is to update this
1500
- translation to including matching changes.
1501
- `,
1502
- styleGuideParams: {
1503
- patchInstruction: JSON.stringify(patch, null, 2)
1504
- }
1505
- });
1506
- } catch (error) {
1507
- const errorMessage = error instanceof Error ? error.message : String(error);
1508
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1509
- errorMessage
1510
- }), console.error("Error translating document:", error);
1511
- return;
1512
- }
1931
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
1932
+ return existingTranslationId ? {
1933
+ // Update existing translation
1934
+ operation: "createOrReplace",
1935
+ _id: existingTranslationId
1936
+ } : {
1937
+ // Create new translation
1938
+ operation: "create"
1939
+ };
1940
+ }, analyzeDocumentContent = (document) => {
1941
+ let hasCodeBlocks = !1, hasInlineCode = !1, codeBlockCount = 0, inlineCodeCount = 0;
1942
+ const hasMarkdown = !1;
1943
+ let hasPortableText = !1, estimatedFieldCount = 0;
1944
+ const traverse = (value, depth = 0) => {
1945
+ value != null && (depth > 0 && typeof value == "object" && estimatedFieldCount++, Array.isArray(value) ? value.forEach((item) => {
1946
+ item && typeof item == "object" && "_type" in item && item._type === "code" && (hasCodeBlocks = !0, codeBlockCount++, hasPortableText = !0), item && typeof item == "object" && "_type" in item && item._type === "block" && "children" in item && Array.isArray(item.children) && (hasPortableText = !0, item.children.forEach((child) => {
1947
+ child && typeof child == "object" && "marks" in child && Array.isArray(child.marks) && child.marks.includes("code") && (hasInlineCode = !0, inlineCodeCount++);
1948
+ })), traverse(item, depth + 1);
1949
+ }) : typeof value == "object" && Object.values(value).forEach((val) => traverse(val, depth + 1)));
1950
+ };
1951
+ traverse(document);
1952
+ let complexity = "simple";
1953
+ return estimatedFieldCount > 50 ? complexity = "complex" : estimatedFieldCount > 20 && (complexity = "moderate"), {
1954
+ hasCodeBlocks,
1955
+ hasInlineCode,
1956
+ codeBlockCount,
1957
+ inlineCodeCount,
1958
+ hasMarkdown,
1959
+ hasPortableText,
1960
+ complexity,
1961
+ estimatedFieldCount
1962
+ };
1513
1963
  }, assembleLanguageSpecs = ({
1514
1964
  sourceDocument,
1515
1965
  languageFieldName,
@@ -1528,6 +1978,441 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1528
1978
  title: targetLanguage
1529
1979
  }
1530
1980
  };
1981
+ }, callGPT5 = async (apiKey, request) => {
1982
+ const model = request.model || "gpt-5", isGPT5 = model === "gpt-5", useStreaming = !!request.onReasoningChunk, requestBody = {
1983
+ model,
1984
+ messages: request.messages,
1985
+ stream: useStreaming
1986
+ };
1987
+ isGPT5 || (requestBody.temperature = request.temperature ?? 0.3), isGPT5 && (requestBody.reasoning_effort = request.reasoningEffort || "medium", requestBody.verbosity = request.verbosity || "medium");
1988
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
1989
+ method: "POST",
1990
+ headers: {
1991
+ "Content-Type": "application/json",
1992
+ Authorization: `Bearer ${apiKey}`
1993
+ },
1994
+ body: JSON.stringify(requestBody)
1995
+ });
1996
+ if (!response.ok) {
1997
+ const error = await response.json();
1998
+ throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
1999
+ }
2000
+ if (useStreaming && response.body && request.onReasoningChunk)
2001
+ return await handleStreamingResponse(response, request.onReasoningChunk, model);
2002
+ const result = await response.json(), usage = result.usage || {}, tokensUsed = {
2003
+ prompt: usage.prompt_tokens || 0,
2004
+ completion: usage.completion_tokens || 0,
2005
+ reasoning: usage.reasoning_tokens || 0,
2006
+ // New in GPT-5
2007
+ total: usage.total_tokens || 0
2008
+ }, cost = calculateGPT5Cost(tokensUsed, request.model || "gpt-5"), messageContent = result.choices[0].message.content;
2009
+ let reasoningSummary, actualContent = messageContent;
2010
+ const reasoningMatch = messageContent.match(/^<reasoning>([\s\S]*?)<\/reasoning>\n\n([\s\S]*)$/i);
2011
+ return reasoningMatch && (reasoningSummary = reasoningMatch[1].trim(), actualContent = reasoningMatch[2]), {
2012
+ content: actualContent,
2013
+ tokensUsed,
2014
+ cost,
2015
+ reasoningSummary
2016
+ };
2017
+ }, handleStreamingResponse = async (response, onReasoningChunk, model) => {
2018
+ if (!response.body)
2019
+ throw new Error("Response body is null");
2020
+ const reader = response.body.getReader(), decoder = new TextDecoder();
2021
+ let fullContent = "", inReasoning = !1, reasoningBuffer = "", contentBuffer = "", tokensUsed = { prompt: 0, completion: 0, reasoning: 0, total: 0 };
2022
+ try {
2023
+ for (; ; ) {
2024
+ const { done, value } = await reader.read();
2025
+ if (done) break;
2026
+ const lines = decoder.decode(value, { stream: !0 }).split(`
2027
+ `).filter((line) => line.trim() !== "");
2028
+ for (const line of lines)
2029
+ if (line.startsWith("data: ")) {
2030
+ const data = line.slice(6);
2031
+ if (data === "[DONE]") continue;
2032
+ try {
2033
+ const parsed = JSON.parse(data);
2034
+ parsed.usage && (tokensUsed = {
2035
+ prompt: parsed.usage.prompt_tokens || 0,
2036
+ completion: parsed.usage.completion_tokens || 0,
2037
+ reasoning: parsed.usage.reasoning_tokens || 0,
2038
+ total: parsed.usage.total_tokens || 0
2039
+ });
2040
+ const delta = parsed.choices?.[0]?.delta?.content;
2041
+ if (delta)
2042
+ if (fullContent += delta, delta.includes("<reasoning>")) {
2043
+ inReasoning = !0;
2044
+ const parts = delta.split("<reasoning>");
2045
+ reasoningBuffer += parts[1] || "";
2046
+ } else if (delta.includes("</reasoning>")) {
2047
+ inReasoning = !1;
2048
+ const parts = delta.split("</reasoning>");
2049
+ reasoningBuffer += parts[0] || "", contentBuffer += parts[1] || "";
2050
+ } else inReasoning ? (reasoningBuffer += delta, onReasoningChunk(reasoningBuffer)) : contentBuffer += delta;
2051
+ } catch {
2052
+ }
2053
+ }
2054
+ }
2055
+ } finally {
2056
+ reader.releaseLock();
2057
+ }
2058
+ const cost = calculateGPT5Cost(tokensUsed, model);
2059
+ return {
2060
+ content: contentBuffer || fullContent,
2061
+ tokensUsed,
2062
+ cost,
2063
+ reasoningSummary: reasoningBuffer || void 0
2064
+ };
2065
+ }, calculateGPT5Cost = (tokens, model) => {
2066
+ const pricing = {
2067
+ "gpt-5": {
2068
+ input: 2e-6,
2069
+ // $0.002 per 1k input tokens
2070
+ output: 1e-5,
2071
+ // $0.010 per 1k output tokens
2072
+ reasoning: 5e-6
2073
+ // $0.005 per 1k reasoning tokens
2074
+ },
2075
+ "gpt-4o": {
2076
+ input: 5e-6,
2077
+ output: 14999999999999999e-21,
2078
+ reasoning: 0
2079
+ // No reasoning tokens in GPT-4
2080
+ },
2081
+ "gpt-4-turbo": {
2082
+ input: 1e-5,
2083
+ output: 29999999999999997e-21,
2084
+ reasoning: 0
2085
+ },
2086
+ "gpt-4": {
2087
+ input: 29999999999999997e-21,
2088
+ output: 59999999999999995e-21,
2089
+ reasoning: 0
2090
+ }
2091
+ }, modelPricing = pricing[model] || pricing["gpt-5"];
2092
+ return tokens.prompt * modelPricing.input + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
2093
+ }, deserializeDocument = (originalDoc, reviewedText) => {
2094
+ const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
2095
+ return applyTextChanges(result, parsed), result;
2096
+ }, parseReviewFormat = (text) => {
2097
+ const root = [], stack = [], lines = text.split(`
2098
+ `);
2099
+ for (const line of lines) {
2100
+ const trimmed = line.trim();
2101
+ if (!trimmed) continue;
2102
+ const openMatch = trimmed.match(/^<(field|block|span)([^>]*)>(.*)$/);
2103
+ if (openMatch) {
2104
+ const [, tagName, attrsStr, content] = openMatch, attributes = parseAttributes(attrsStr), node = {
2105
+ type: tagName,
2106
+ name: attributes.name,
2107
+ text: content ? unescapeXml(content) : void 0,
2108
+ attributes,
2109
+ children: []
2110
+ }, closingTag = `</${tagName}>`;
2111
+ if (content.endsWith(closingTag.replace(/[<>]/g, ""))) {
2112
+ const textContent = content.substring(0, content.lastIndexOf("<"));
2113
+ if (node.text = unescapeXml(textContent), stack.length > 0) {
2114
+ const parent = stack[stack.length - 1];
2115
+ parent && parent.children.push(node);
2116
+ } else
2117
+ root.push(node);
2118
+ } else if (content.includes(closingTag))
2119
+ if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
2120
+ const parent = stack[stack.length - 1];
2121
+ parent && parent.children.push(node);
2122
+ } else
2123
+ root.push(node);
2124
+ else
2125
+ stack.push(node);
2126
+ continue;
2127
+ }
2128
+ if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
2129
+ const node = stack.pop();
2130
+ if (node)
2131
+ if (stack.length > 0) {
2132
+ const parent = stack[stack.length - 1];
2133
+ parent && parent.children.push(node);
2134
+ } else
2135
+ root.push(node);
2136
+ }
2137
+ }
2138
+ return root;
2139
+ }, parseAttributes = (attrStr) => {
2140
+ const attrs = {}, attrRegex = /(\w+)="([^"]*)"/g;
2141
+ let match = attrRegex.exec(attrStr);
2142
+ for (; match !== null; )
2143
+ attrs[match[1]] = unescapeXml(match[2]), match = attrRegex.exec(attrStr);
2144
+ return attrs;
2145
+ }, unescapeXml = (str) => str.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&"), applyTextChanges = (doc, parsedNodes) => {
2146
+ for (const node of parsedNodes)
2147
+ if (node.type === "field" && node.name) {
2148
+ const path = [node.name];
2149
+ applyFieldChanges(doc, path, node);
2150
+ }
2151
+ }, applyFieldChanges = (doc, path, node) => {
2152
+ if (node.attributes.type === "array") {
2153
+ node.children.forEach((child, idx) => {
2154
+ applyFieldChanges(doc, [...path, idx], child);
2155
+ });
2156
+ return;
2157
+ }
2158
+ if (node.attributes.type === "object") {
2159
+ for (const child of node.children)
2160
+ child.name && applyFieldChanges(doc, [...path, child.name], child);
2161
+ return;
2162
+ }
2163
+ if (node.children.some((c) => c.type === "block")) {
2164
+ const fieldValue = get(doc, path);
2165
+ Array.isArray(fieldValue) && node.children.forEach((blockNode, idx) => {
2166
+ blockNode.type === "block" && applyBlockChanges(fieldValue[idx], blockNode);
2167
+ });
2168
+ return;
2169
+ }
2170
+ node.text !== void 0 && !node.attributes.readonly && set(doc, path, node.text);
2171
+ }, applyBlockChanges = (block, blockNode) => {
2172
+ if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
2173
+ devLog.warn("Block key mismatch, skipping", {
2174
+ expected: block._key,
2175
+ found: blockNode.attributes._key,
2176
+ component: "deserializeDocument"
2177
+ });
2178
+ return;
2179
+ }
2180
+ const children = block.children;
2181
+ Array.isArray(children) && blockNode.children.forEach((spanNode, idx) => {
2182
+ const childItem = children[idx];
2183
+ spanNode.type === "span" && childItem && applySpanChanges(childItem, spanNode);
2184
+ });
2185
+ }, applySpanChanges = (span, spanNode) => {
2186
+ if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
2187
+ devLog.warn("Span key mismatch, skipping", {
2188
+ expected: span._key,
2189
+ found: spanNode.attributes._key,
2190
+ component: "deserializeDocument"
2191
+ });
2192
+ return;
2193
+ }
2194
+ spanNode.text !== void 0 && (span.text = spanNode.text);
2195
+ }, serializeDocument = (doc) => {
2196
+ const lines = [], serialize = (value, path = [], indent = 0) => {
2197
+ const indentStr = " ".repeat(indent);
2198
+ if (path.length === 0) {
2199
+ if (isPlainObject(value))
2200
+ for (const [key, val] of Object.entries(value))
2201
+ !key.startsWith("_") && key !== "language" && serialize(val, [key], indent);
2202
+ return;
2203
+ }
2204
+ const fieldName = path[path.length - 1];
2205
+ if (Array.isArray(value)) {
2206
+ value.some((item) => item?._type === "block") ? (lines.push(`${indentStr}<field name="${fieldName}">`), value.forEach((block, idx) => {
2207
+ serializeBlock(block, [...path, idx], indent + 1, lines);
2208
+ }), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
2209
+ serialize(item, [...path, idx], indent + 1);
2210
+ }), lines.push(`${indentStr}</field>`));
2211
+ return;
2212
+ }
2213
+ if (isPlainObject(value)) {
2214
+ lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
2215
+ for (const [key, val] of Object.entries(value))
2216
+ key.startsWith("_") || serialize(val, [...path, key], indent + 1);
2217
+ lines.push(`${indentStr}</field>`);
2218
+ return;
2219
+ }
2220
+ if (typeof value == "string") {
2221
+ value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2222
+ return;
2223
+ }
2224
+ (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
2225
+ };
2226
+ return serialize(doc), lines.join(`
2227
+ `);
2228
+ }, serializeBlock = (block, path, indent, lines) => {
2229
+ const indentStr = " ".repeat(indent), metadata = {};
2230
+ block._type && (metadata._type = block._type), block._key && (metadata._key = block._key), block.style && (metadata.style = block.style), block.listItem && (metadata.listItem = block.listItem), block.level && (metadata.level = block.level), block.markDefs && (metadata.markDefs = block.markDefs);
2231
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(typeof v == "string" ? v : JSON.stringify(v))}"`).join(" ");
2232
+ lines.push(`${indentStr}<block ${metaStr}>`), Array.isArray(block.children) && block.children.forEach((child, idx) => {
2233
+ serializeSpan(child, [...path, "children", idx], indent + 1, lines);
2234
+ }), lines.push(`${indentStr}</block>`);
2235
+ }, serializeSpan = (span, _path, indent, lines) => {
2236
+ const indentStr = " ".repeat(indent);
2237
+ if (!span || typeof span != "object") return;
2238
+ const metadata = {};
2239
+ span._type && (metadata._type = span._type), span._key && (metadata._key = span._key), span.marks && Array.isArray(span.marks) && (metadata.marks = span.marks.join(","));
2240
+ const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(String(v))}"`).join(" "), text = typeof span.text == "string" ? span.text : "";
2241
+ metaStr ? lines.push(`${indentStr}<span ${metaStr}>${escapeXml(text)}</span>`) : lines.push(`${indentStr}<span>${escapeXml(text)}</span>`);
2242
+ }, escapeXml = (str) => str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;"), isPlainObject = (val) => typeof val == "object" && val !== null && !Array.isArray(val), validateStructureIntegrity = (original, modified) => {
2243
+ const errors = [], criticalFields = ["_id", "_type", "_rev", "_createdAt", "_updatedAt"];
2244
+ for (const field of criticalFields)
2245
+ original[field] !== modified[field] && errors.push({
2246
+ path: [field],
2247
+ expected: original[field],
2248
+ actual: modified[field],
2249
+ type: "metadata_changed"
2250
+ });
2251
+ return validateStructureRecursive(original, modified, [], errors), {
2252
+ valid: errors.length === 0,
2253
+ errors
2254
+ };
2255
+ }, validateStructureRecursive = (original, modified, path, errors) => {
2256
+ if (typeof original != typeof modified) {
2257
+ errors.push({
2258
+ path,
2259
+ expected: typeof original,
2260
+ actual: typeof modified,
2261
+ type: "type_mismatch"
2262
+ });
2263
+ return;
2264
+ }
2265
+ if (Array.isArray(original)) {
2266
+ if (!Array.isArray(modified)) {
2267
+ errors.push({
2268
+ path,
2269
+ expected: "array",
2270
+ actual: typeof modified,
2271
+ type: "type_mismatch"
2272
+ });
2273
+ return;
2274
+ }
2275
+ if (original.length !== modified.length) {
2276
+ errors.push({
2277
+ path,
2278
+ expected: `length ${original.length}`,
2279
+ actual: `length ${modified.length}`,
2280
+ type: "type_mismatch"
2281
+ });
2282
+ return;
2283
+ }
2284
+ original.forEach((item, idx) => {
2285
+ validateStructureRecursive(item, modified[idx], [...path, String(idx)], errors);
2286
+ });
2287
+ return;
2288
+ }
2289
+ if (typeof original == "object" && original !== null && typeof modified == "object" && modified !== null) {
2290
+ const origKeys = Object.keys(original).sort(), modKeys = Object.keys(modified).sort();
2291
+ for (const key of origKeys)
2292
+ key in modified || errors.push({
2293
+ path: [...path, key],
2294
+ expected: "present",
2295
+ actual: "missing",
2296
+ type: "missing"
2297
+ });
2298
+ for (const key of modKeys)
2299
+ key in original || errors.push({
2300
+ path: [...path, key],
2301
+ expected: "absent",
2302
+ actual: "present",
2303
+ type: "extra"
2304
+ });
2305
+ const origObj = original, modObj = modified;
2306
+ for (const key of origKeys)
2307
+ key in modObj && (key.startsWith("_") || key === "marks" || key === "style" || key === "markDefs" ? JSON.stringify(origObj[key]) !== JSON.stringify(modObj[key]) && errors.push({
2308
+ path: [...path, key],
2309
+ expected: origObj[key],
2310
+ actual: modObj[key],
2311
+ type: "metadata_changed"
2312
+ }) : key !== "text" && validateStructureRecursive(origObj[key], modObj[key], [...path, key], errors));
2313
+ }
2314
+ }, assembleTranslationPrompt = (context, components) => {
2315
+ const sections = [
2316
+ {
2317
+ id: "agentDirectives",
2318
+ content: buildAgentDirectivesSection(context),
2319
+ required: !0,
2320
+ priority: 0
2321
+ },
2322
+ {
2323
+ id: "criticalRules",
2324
+ content: buildCriticalRulesSection(context),
2325
+ required: !0,
2326
+ priority: 1
2327
+ }
2328
+ ];
2329
+ components.glossaryTerms?.trim() && sections.push({
2330
+ id: "glossaryTerms",
2331
+ content: buildGlossarySection(components.glossaryTerms),
2332
+ required: !1,
2333
+ priority: 3
2334
+ });
2335
+ const styleSection = buildStyleGuideSection(components.globalPrompt, components.localePrompt);
2336
+ styleSection && sections.push({
2337
+ id: "styleGuide",
2338
+ content: styleSection,
2339
+ required: !1,
2340
+ priority: 4
2341
+ }), sections.sort((a, b) => a.priority - b.priority);
2342
+ let styleGuide = "";
2343
+ const omittedSections = [], truncatedSections = [];
2344
+ for (const section of sections) {
2345
+ const prefix = styleGuide.length > 0 ? `
2346
+
2347
+ ---
2348
+
2349
+ ` : "", availableCharacters = 2e3 - (styleGuide.length + prefix.length);
2350
+ if (availableCharacters <= 0) {
2351
+ if (section.required) {
2352
+ const truncatedContent = truncateSection(section.content, 0);
2353
+ truncatedContent && (styleGuide += prefix + truncatedContent, truncatedSections.push(section.id));
2354
+ } else
2355
+ omittedSections.push(section.id);
2356
+ continue;
2357
+ }
2358
+ if (section.content.length <= availableCharacters) {
2359
+ styleGuide += prefix + section.content;
2360
+ continue;
2361
+ }
2362
+ if (section.required) {
2363
+ const truncatedContent = truncateSection(section.content, availableCharacters);
2364
+ styleGuide += prefix + truncatedContent, truncatedSections.push(section.id);
2365
+ } else
2366
+ omittedSections.push(section.id);
2367
+ }
2368
+ return {
2369
+ styleGuide,
2370
+ omittedSections,
2371
+ truncatedSections
2372
+ };
2373
+ }, truncateSection = (content, limit) => {
2374
+ if (limit <= 0)
2375
+ return "";
2376
+ if (content.length <= limit)
2377
+ return content;
2378
+ if (limit <= 1)
2379
+ return content.slice(0, limit);
2380
+ const ellipsis = "\u2026";
2381
+ if (limit <= ellipsis.length)
2382
+ return content.slice(0, limit);
2383
+ const sliceEnd = limit - ellipsis.length, sliced = content.slice(0, sliceEnd), trimmed = sliced.replace(/\s+\S*$/, "").trimEnd();
2384
+ return `${trimmed.length >= sliceEnd / 2 ? trimmed : sliced}${ellipsis}`;
2385
+ }, buildAgentDirectivesSection = (context) => {
2386
+ const { targetLanguage, sourceLanguage } = context;
2387
+ return `# AGENT WORKFLOW DIRECTIVES
2388
+
2389
+ - Use only the provided ${sourceLanguage || "the source language"} source; never fetch outside context.
2390
+ - Scan each field once, then move directly into translation work.
2391
+ - Plan updates before editing so document structure remains intact.
2392
+ - Default to medium reasoning effort and escalate only for ambiguous passages.
2393
+ - Output just the ${targetLanguage || "the target language"} translation and mirror the source formatting exactly.`;
2394
+ }, buildCriticalRulesSection = (context) => {
2395
+ const { sourceLanguage, targetLanguage } = context, fromLanguage = sourceLanguage || "the source language", toLanguage = targetLanguage || "the target language";
2396
+ return `# CRITICAL TRANSLATION RULES
2397
+
2398
+ Follow these without exception:
2399
+
2400
+ 1. Translate from ${fromLanguage} into ${toLanguage}; every output token must be ${toLanguage} unless flagged as protected.
2401
+ 2. Preserve meaning exactly\u2014no additions, deletions, or tone shifts.
2402
+ 3. Maintain all layout, markdown, rich-text marks, and data structure.
2403
+ 4. Never invent metadata or field values that are absent in the source.
2404
+ 5. Leave technical tokens (code, IDs, filenames) untouched.
2405
+ 6. Keep punctuation, casing, and spacing unless ${toLanguage} grammar forces a change.`;
2406
+ }, buildGlossarySection = (glossaryTerms) => `# TERMINOLOGY RULES
2407
+
2408
+ Apply these mappings immediately after protected phrases and before style preferences.
2409
+
2410
+ ${glossaryTerms}`, buildStyleGuideSection = (globalPrompt, localePrompt) => {
2411
+ const components = [];
2412
+ return (globalPrompt || localePrompt) && (components.push("# TRANSLATION STYLE GUIDE (LOWEST PRIORITY)"), components.push(
2413
+ "Apply these directions only after honoring critical rules, protected phrases, and glossary terminology."
2414
+ ), components.push("")), globalPrompt && (components.push("## General Guidelines"), components.push(globalPrompt), components.push("")), localePrompt && (components.push("## Language-Specific Guidelines"), components.push(localePrompt)), components.length > 0 ? components.join(`
2415
+ `) : null;
1531
2416
  }, translateEntireDocument = async ({
1532
2417
  dispatch,
1533
2418
  client,
@@ -1535,17 +2420,175 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1535
2420
  sourceDocument,
1536
2421
  targetLanguage,
1537
2422
  languageFieldName,
1538
- translationTarget,
2423
+ translationTarget,
2424
+ translationPrompts,
2425
+ translationMode,
2426
+ openaiConfig,
2427
+ releaseId,
2428
+ isNewTranslation
2429
+ }) => {
2430
+ const logContext = {
2431
+ documentId: sourceDocument._id,
2432
+ targetLanguage,
2433
+ schemaId,
2434
+ operation: translationTarget.operation,
2435
+ targetId: translationTarget._id,
2436
+ translationMode,
2437
+ releaseId,
2438
+ isNewTranslation
2439
+ };
2440
+ devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
2441
+ try {
2442
+ let versionedDocumentId, baseDocumentId;
2443
+ if (isNewTranslation) {
2444
+ const { _id, _createdAt, _updatedAt, _rev, ...docWithoutMeta } = sourceDocument, newTranslationDoc = {
2445
+ ...docWithoutMeta,
2446
+ _type: sourceDocument._type,
2447
+ [languageFieldName]: targetLanguage
2448
+ };
2449
+ devLog.info("Creating new translation document in release", logContext), baseDocumentId = translationTarget._id || `new-translation-${Date.now()}`, await client.action({
2450
+ actionType: "sanity.action.document.version.create",
2451
+ publishedId: baseDocumentId,
2452
+ document: {
2453
+ ...newTranslationDoc,
2454
+ _type: sourceDocument._type,
2455
+ _id: `versions.${releaseId}.${baseDocumentId}`
2456
+ }
2457
+ }), versionedDocumentId = `versions.${releaseId}.${baseDocumentId}`, devLog.info("New translation version created in release", {
2458
+ ...logContext,
2459
+ versionedDocumentId,
2460
+ baseDocumentId
2461
+ });
2462
+ } else {
2463
+ const existingTranslationId = translationTarget._id;
2464
+ if (!existingTranslationId)
2465
+ throw new Error("Translation target ID not found for update operation");
2466
+ devLog.info("Creating version of existing translation in release", {
2467
+ ...logContext,
2468
+ existingTranslationId
2469
+ });
2470
+ const existingDoc = await client.getDocument(existingTranslationId);
2471
+ if (!existingDoc)
2472
+ throw new Error(`Existing translation document not found: ${existingTranslationId}`);
2473
+ await client.action({
2474
+ actionType: "sanity.action.document.version.create",
2475
+ publishedId: existingTranslationId,
2476
+ document: {
2477
+ ...existingDoc,
2478
+ _id: `versions.${releaseId}.${existingTranslationId}`
2479
+ }
2480
+ }), versionedDocumentId = `versions.${releaseId}.${existingTranslationId}`, baseDocumentId = existingTranslationId, devLog.info("Existing translation version created", {
2481
+ ...logContext,
2482
+ versionedDocumentId
2483
+ });
2484
+ const { _id, _createdAt, _updatedAt, _rev, ...sourceContent } = sourceDocument;
2485
+ await client.createOrReplace({
2486
+ ...sourceContent,
2487
+ _id: versionedDocumentId,
2488
+ [languageFieldName]: targetLanguage
2489
+ }), devLog.info("Source content cloned onto version", {
2490
+ ...logContext,
2491
+ versionedDocumentId
2492
+ });
2493
+ }
2494
+ let translatedDocument;
2495
+ switch (translationMode) {
2496
+ case "openai-only": {
2497
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2498
+ throw new Error("OpenAI is not configured but openai-only mode was selected");
2499
+ devLog.info("Using OpenAI-only translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai");
2500
+ const versionedDoc = await client.getDocument(versionedDocumentId);
2501
+ if (!versionedDoc)
2502
+ throw new Error(`Versioned document not found: ${versionedDocumentId}`);
2503
+ const translatedDoc = await translateInPlaceWithOpenAI({
2504
+ dispatch,
2505
+ sourceDocument,
2506
+ targetDocument: versionedDoc,
2507
+ targetLanguage,
2508
+ languageFieldName,
2509
+ translationPrompts,
2510
+ openaiConfig,
2511
+ isReview: !1
2512
+ });
2513
+ translatedDoc && (translatedDocument = await client.createOrReplace({
2514
+ ...translatedDoc,
2515
+ _id: versionedDocumentId
2516
+ }));
2517
+ break;
2518
+ }
2519
+ case "sanity-with-review":
2520
+ if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
2521
+ throw new Error("OpenAI is not configured but sanity-with-review mode was selected");
2522
+ if (devLog.info("Using Sanity + OpenAI review", logContext), translatedDocument = await translateWithSanityAgent({
2523
+ dispatch,
2524
+ client,
2525
+ schemaId,
2526
+ sourceDocument,
2527
+ targetLanguage,
2528
+ languageFieldName,
2529
+ versionedDocumentId,
2530
+ translationPrompts
2531
+ }), translatedDocument) {
2532
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, "reviewing_with_openai");
2533
+ const reviewedDoc = await translateInPlaceWithOpenAI({
2534
+ dispatch,
2535
+ sourceDocument,
2536
+ targetDocument: translatedDocument,
2537
+ targetLanguage,
2538
+ languageFieldName,
2539
+ translationPrompts,
2540
+ openaiConfig,
2541
+ isReview: !0
2542
+ });
2543
+ reviewedDoc && (translatedDocument = await client.createOrReplace({
2544
+ ...reviewedDoc,
2545
+ _id: versionedDocumentId
2546
+ }));
2547
+ }
2548
+ break;
2549
+ default:
2550
+ devLog.info("Using Sanity-only translation", logContext), translatedDocument = await translateWithSanityAgent({
2551
+ dispatch,
2552
+ client,
2553
+ schemaId,
2554
+ sourceDocument,
2555
+ targetLanguage,
2556
+ languageFieldName,
2557
+ versionedDocumentId,
2558
+ translationPrompts
2559
+ });
2560
+ break;
2561
+ }
2562
+ return devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), translatedDocument ? {
2563
+ ...translatedDocument,
2564
+ _id: baseDocumentId
2565
+ } : void 0;
2566
+ } catch (error) {
2567
+ const errorMessage = error instanceof Error ? error.message : String(error);
2568
+ devLog.error(
2569
+ "translateEntireDocument failed",
2570
+ error instanceof Error ? error : new Error(String(error)),
2571
+ logContext
2572
+ ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
2573
+ errorMessage
2574
+ }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2575
+ return;
2576
+ }
2577
+ }, translateWithSanityAgent = async ({
2578
+ dispatch,
2579
+ client,
2580
+ schemaId,
2581
+ sourceDocument,
2582
+ targetLanguage,
2583
+ languageFieldName,
2584
+ versionedDocumentId,
1539
2585
  translationPrompts
1540
2586
  }) => {
1541
2587
  const logContext = {
1542
2588
  documentId: sourceDocument._id,
1543
2589
  targetLanguage,
1544
- schemaId,
1545
- operation: translationTarget.operation,
1546
- targetId: translationTarget._id
2590
+ versionedDocumentId
1547
2591
  };
1548
- devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
1549
2592
  try {
1550
2593
  const { toLanguage, fromLanguage } = assembleLanguageSpecs({
1551
2594
  sourceDocument,
@@ -1557,104 +2600,263 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1557
2600
  toLanguage,
1558
2601
  fromLanguage
1559
2602
  });
1560
- let extantTranslation = "";
1561
- translationTarget._id && (devLog.debug("Checking for existing translation", {
2603
+ const documentAnalysis = analyzeDocumentContent(sourceDocument);
2604
+ devLog.debug("Document analysis completed", {
1562
2605
  ...logContext,
1563
- targetId: translationTarget._id
1564
- }), extantTranslation = await client.withConfig({
1565
- perspective: "raw"
1566
- }).fetch('*[_id in $ids] | order((_id match "drafts*") desc)[0]._id', {
1567
- ids: [undraftId(translationTarget._id), draftId(translationTarget._id)]
1568
- }), devLog.info("Extant translation found - will preserve existing content where possible", {
2606
+ analysis: documentAnalysis
2607
+ }), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
2608
+ const { styleGuide, omittedSections, truncatedSections } = assembleTranslationPrompt(
2609
+ {
2610
+ documentAnalysis,
2611
+ isUpdate: !1,
2612
+ // Always fresh translation in a version
2613
+ targetLanguage,
2614
+ sourceLanguage: sourceDocument[languageFieldName]
2615
+ },
2616
+ {
2617
+ globalPrompt: translationPrompts.globalPrompt,
2618
+ localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
2619
+ glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
2620
+ protectedPhrases: translationPrompts.protectedPhrases
2621
+ }
2622
+ );
2623
+ devLog.info("Structured style guide generated", {
1569
2624
  ...logContext,
1570
- hasExtantTranslation: !!extantTranslation
1571
- })), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
1572
- const styleGuide = [
1573
- translationPrompts.globalPrompt,
1574
- translationPrompts.localeInstructions[targetLanguage]?.prompt,
1575
- translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
1576
- extantTranslation ? UPDATE_EXISTING_TRANSLATION_PROMPT : null
1577
- ].filter(Boolean).join(`
1578
-
1579
- `);
1580
- devLog.info("Style guide generated", {
2625
+ styleGuideLength: styleGuide.length,
2626
+ omittedSections,
2627
+ truncatedSections
2628
+ }), (omittedSections.length > 0 || truncatedSections.length > 0) && devLog.warn("Style guide adjusted to fit character limit", {
1581
2629
  ...logContext,
1582
- styleGuide
2630
+ omittedSections,
2631
+ truncatedSections
1583
2632
  });
1584
2633
  const newDocument = await client.agent.action.translate({
1585
2634
  schemaId,
1586
2635
  toLanguage,
1587
2636
  fromLanguage,
1588
- documentId: sourceDocument._id,
1589
- targetDocument: translationTarget,
2637
+ documentId: versionedDocumentId,
2638
+ // Use versioned document ID
1590
2639
  languageFieldPath: [languageFieldName],
1591
2640
  styleGuide,
1592
- styleGuideParams: {
1593
- extantTranslation
1594
- },
1595
2641
  protectedPhrases: translationPrompts.protectedPhrases || [],
1596
2642
  conditionalPaths: {
1597
2643
  defaultHidden: !1,
1598
2644
  defaultReadOnly: !1
1599
2645
  }
1600
2646
  });
1601
- return devLog.info("Document translation completed", {
2647
+ return devLog.info("Sanity agent translation completed", {
1602
2648
  ...logContext,
1603
2649
  newDocumentId: newDocument._id,
1604
2650
  newDocumentRev: newDocument._rev
1605
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), newDocument;
2651
+ }), newDocument;
1606
2652
  } catch (error) {
1607
2653
  const errorMessage = error instanceof Error ? error.message : String(error);
1608
2654
  devLog.error(
1609
- "translateEntireDocument failed",
2655
+ "Sanity agent translation failed",
1610
2656
  error instanceof Error ? error : new Error(String(error)),
1611
2657
  logContext
1612
2658
  ), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
1613
2659
  errorMessage
1614
- }), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
2660
+ });
1615
2661
  return;
1616
2662
  }
1617
- }, updateTranslationMetadata = async ({
1618
- client,
1619
- targetLanguage,
2663
+ }, translateInPlaceWithOpenAI = async ({
2664
+ dispatch,
1620
2665
  sourceDocument,
1621
- sourceLanguage,
1622
- newDocument,
1623
- translationMetadata,
1624
- isNewTranslation
2666
+ targetDocument,
2667
+ targetLanguage,
2668
+ languageFieldName,
2669
+ translationPrompts,
2670
+ openaiConfig,
2671
+ isReview
1625
2672
  }) => {
2673
+ const sourceLanguage = sourceDocument[languageFieldName], logContext = {
2674
+ sourceId: sourceDocument._id,
2675
+ targetId: targetDocument._id,
2676
+ targetLanguage,
2677
+ sourceLanguage,
2678
+ isReview
2679
+ };
1626
2680
  try {
1627
- const refToNewTranslation = {
1628
- _key: targetLanguage,
1629
- _type: "internationalizedArrayReferenceValue",
1630
- value: {
1631
- _type: "reference",
1632
- _ref: undraftId(newDocument._id),
1633
- _weak: !0,
1634
- _strengthenOnPublish: {
1635
- _type: "reference",
1636
- _weak: !1
1637
- }
1638
- }
1639
- }, newTranslationHistory = {
1640
- _key: targetLanguage,
1641
- language: targetLanguage,
1642
- sourceId: undraftId(sourceDocument._id),
1643
- sourceRev: sourceDocument._rev,
2681
+ const sourceText = serializeDocument(sourceDocument), targetText = serializeDocument(targetDocument);
2682
+ devLog.debug("Documents serialized for OpenAI", {
2683
+ ...logContext,
2684
+ sourceLength: sourceText.length,
2685
+ targetLength: targetText.length
2686
+ });
2687
+ const prompt = isReview ? buildReviewPrompt({
2688
+ sourceText,
2689
+ translatedText: targetText,
1644
2690
  sourceLanguage,
1645
- sourceSnapshot: JSON.stringify(sourceDocument),
1646
- targetId: newDocument._id,
1647
- targetRev: newDocument._rev,
1648
- targetSnapshot: JSON.stringify(newDocument)
1649
- }, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
1650
- isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
1651
- const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
1652
- translationMetadata.translationHistory?.some(
1653
- (history) => history.language === targetLanguage
1654
- ) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
2691
+ targetLanguage,
2692
+ translationPrompts
2693
+ }) : buildTranslationPrompt({
2694
+ sourceText,
2695
+ sourceLanguage,
2696
+ targetLanguage,
2697
+ translationPrompts
2698
+ });
2699
+ devLog.debug("Calling GPT-5", {
2700
+ ...logContext,
2701
+ promptLength: prompt.length
2702
+ });
2703
+ const response = await callGPT5(openaiConfig.apiKey, {
2704
+ messages: [
2705
+ {
2706
+ role: "system",
2707
+ content: isReview ? "You are a translation quality reviewer. Review and refine translations while preserving structure." : "You are a professional translator. Translate documents while preserving their exact structure."
2708
+ },
2709
+ { role: "user", content: prompt }
2710
+ ],
2711
+ model: openaiConfig.model,
2712
+ reasoningEffort: openaiConfig.reasoningEffort,
2713
+ verbosity: openaiConfig.verbosity,
2714
+ onReasoningChunk: (reasoning) => {
2715
+ const status2 = isReview ? "reviewing_with_openai" : "translating_with_openai";
2716
+ setJobStatus(dispatch, sourceDocument._id, targetLanguage, status2, {
2717
+ reasoningProgress: reasoning
2718
+ });
2719
+ }
2720
+ });
2721
+ devLog.info("OpenAI operation completed", {
2722
+ ...logContext,
2723
+ tokensUsed: response.tokensUsed.total,
2724
+ cost: response.cost
2725
+ });
2726
+ const result = deserializeDocument(targetDocument, response.content), validation = validateStructureIntegrity(targetDocument, result);
2727
+ if (!validation.valid) {
2728
+ devLog.error("Structure validation failed", {
2729
+ ...logContext,
2730
+ errors: validation.errors
2731
+ });
2732
+ const unexpectedErrors = validation.errors.filter(
2733
+ (err) => !(err.path.length === 1 && err.path[0] === languageFieldName)
2734
+ );
2735
+ if (unexpectedErrors.length > 0)
2736
+ throw new Error(
2737
+ `Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
2738
+ );
2739
+ }
2740
+ devLog.info("Structure validation passed", logContext);
2741
+ const status = isReview ? "reviewing_with_openai" : "translating_with_openai";
2742
+ return setJobStatus(dispatch, sourceDocument._id, targetLanguage, status, {
2743
+ tokensUsed: response.tokensUsed.total,
2744
+ estimatedCost: response.cost,
2745
+ reasoningSummary: response.reasoningSummary
2746
+ }), result;
1655
2747
  } catch (error) {
1656
- throw console.error("Error updating translation metadata:", error), error;
2748
+ throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
1657
2749
  }
2750
+ }, buildTranslationPrompt = ({
2751
+ sourceText,
2752
+ sourceLanguage,
2753
+ targetLanguage,
2754
+ translationPrompts
2755
+ }) => {
2756
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2757
+ return `# TRANSLATION TASK
2758
+
2759
+ Translate this ${sourceLanguage} document into ${targetLanguage}.
2760
+
2761
+ ## Guidelines
2762
+
2763
+ ${globalPrompt ? `### General Guidelines
2764
+ ${globalPrompt}
2765
+ ` : ""}
2766
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2767
+ ${localePrompt}
2768
+ ` : ""}
2769
+ ${glossaryTerms ? `### Required Terminology
2770
+ ${glossaryTerms}
2771
+ ` : ""}
2772
+ ${protectedPhrases?.length ? `### Protected Phrases (do not translate)
2773
+ ${protectedPhrases.join(", ")}
2774
+ ` : ""}
2775
+
2776
+ ## Source Document (${sourceLanguage})
2777
+
2778
+ \`\`\`
2779
+ ${sourceText}
2780
+ \`\`\`
2781
+
2782
+ ## Instructions
2783
+
2784
+ 1. Translate all text content into ${targetLanguage}
2785
+ 2. Follow the guidelines and terminology requirements above
2786
+ 3. Preserve protected phrases exactly as they appear
2787
+ 4. Return the document in THE EXACT SAME FORMAT
2788
+ 5. **CRITICAL**: Only modify text content between tags
2789
+ 6. Never add, remove, or change any tags, attributes, or structure
2790
+
2791
+ If you used significant reasoning to make translation decisions, you may include a brief summary:
2792
+ <reasoning>
2793
+ Brief summary of key translation decisions and reasoning
2794
+ </reasoning>
2795
+
2796
+ Then return the translated document:`;
2797
+ }, buildReviewPrompt = ({
2798
+ sourceText,
2799
+ translatedText,
2800
+ sourceLanguage,
2801
+ targetLanguage,
2802
+ translationPrompts
2803
+ }) => {
2804
+ const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
2805
+ return `# TRANSLATION QUALITY REVIEW
2806
+
2807
+ Review this ${targetLanguage} translation and return a corrected version.
2808
+
2809
+ ## Review Criteria
2810
+
2811
+ 1. **Accuracy**: Does it accurately convey the ${sourceLanguage} source meaning?
2812
+ 2. **Terminology**: Are glossary terms used correctly?
2813
+ 3. **Protected Phrases**: Are protected phrases preserved?
2814
+ 4. **Fluency**: Is it natural and fluent in ${targetLanguage}?
2815
+ 5. **Style**: Does it follow the style guide?
2816
+ 6. **Completeness**: Is all content translated?
2817
+
2818
+ ## Guidelines
2819
+
2820
+ ${globalPrompt ? `### General Guidelines
2821
+ ${globalPrompt}
2822
+ ` : ""}
2823
+ ${localePrompt ? `### ${targetLanguage} Guidelines
2824
+ ${localePrompt}
2825
+ ` : ""}
2826
+ ${glossaryTerms ? `### Required Terminology
2827
+ ${glossaryTerms}
2828
+ ` : ""}
2829
+ ${protectedPhrases?.length ? `### Protected Phrases (must preserve)
2830
+ ${protectedPhrases.join(", ")}
2831
+ ` : ""}
2832
+
2833
+ ## Source Document (${sourceLanguage})
2834
+
2835
+ \`\`\`
2836
+ ${sourceText}
2837
+ \`\`\`
2838
+
2839
+ ## Current Translation (${targetLanguage})
2840
+
2841
+ \`\`\`
2842
+ ${translatedText}
2843
+ \`\`\`
2844
+
2845
+ ## Instructions
2846
+
2847
+ 1. Review the translation for quality issues
2848
+ 2. Make corrections where needed to improve accuracy, fluency, or adherence to guidelines
2849
+ 3. Keep good translations as-is - only fix what needs fixing
2850
+ 4. Return the corrected translation in THE EXACT SAME FORMAT
2851
+ 5. **CRITICAL**: Only modify text content between tags
2852
+ 6. Never add, remove, or change any tags, attributes, or structure
2853
+
2854
+ If you made corrections or have insights about the translation quality, include a brief summary:
2855
+ <reasoning>
2856
+ Brief summary of review findings and corrections made
2857
+ </reasoning>
2858
+
2859
+ Then return the corrected translation document:`;
1658
2860
  }, translateDocument = async ({
1659
2861
  dispatch,
1660
2862
  client,
@@ -1663,15 +2865,17 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1663
2865
  languageFieldName,
1664
2866
  shouldOverwriteFreshTranslations,
1665
2867
  schemaId,
1666
- shouldTranslateDiffs,
1667
- translationPrompts
2868
+ translationPrompts,
2869
+ translationMode,
2870
+ openaiConfig,
2871
+ releaseId
1668
2872
  }) => {
1669
2873
  const logContext = {
1670
2874
  documentId: sourceDocument._id,
1671
2875
  targetLanguage,
1672
2876
  schemaId,
1673
- shouldTranslateDiffs,
1674
- shouldOverwriteFreshTranslations
2877
+ shouldOverwriteFreshTranslations,
2878
+ translationMode
1675
2879
  };
1676
2880
  devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
1677
2881
  try {
@@ -1711,48 +2915,20 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1711
2915
  operation: translationTarget.operation,
1712
2916
  isNewTranslation
1713
2917
  });
1714
- let newDocument;
1715
- if (!isNewTranslation && shouldTranslateDiffs) {
1716
- devLog.info("Starting diff-based translation", logContext);
1717
- const { currentTargetId, patch } = await patchTranslationTarget({
1718
- client,
1719
- currentSourceDocument: sourceDocument,
1720
- sourceLanguage,
1721
- translationMetadata,
1722
- targetLanguage
1723
- });
1724
- if (!currentTargetId) {
1725
- devLog.warn("No target ID found for diff translation", logContext), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1726
- return;
1727
- }
1728
- devLog.debug("Patching translation target", {
1729
- ...logContext,
1730
- currentTargetId,
1731
- hasPatch: !!patch
1732
- }), newDocument = await translateDocumentInPlace({
1733
- client,
1734
- schemaId,
1735
- dispatch,
1736
- sourceDocument,
1737
- languageFieldName,
1738
- targetLanguage,
1739
- targetId: currentTargetId,
1740
- patch
1741
- });
1742
- } else
1743
- devLog.info("Starting full document translation", {
1744
- ...logContext,
1745
- translationTarget: translationTarget.operation
1746
- }), newDocument = await translateEntireDocument({
1747
- dispatch,
1748
- client,
1749
- schemaId,
1750
- sourceDocument,
1751
- targetLanguage,
1752
- languageFieldName,
1753
- translationTarget,
1754
- translationPrompts
1755
- });
2918
+ const newDocument = await translateEntireDocument({
2919
+ dispatch,
2920
+ client,
2921
+ schemaId,
2922
+ sourceDocument,
2923
+ targetLanguage,
2924
+ languageFieldName,
2925
+ translationTarget,
2926
+ translationPrompts,
2927
+ translationMode,
2928
+ openaiConfig,
2929
+ releaseId,
2930
+ isNewTranslation
2931
+ });
1756
2932
  if (!newDocument)
1757
2933
  throw devLog.error("Failed to create translation document", void 0, logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd(), new Error("Failed to create translation document");
1758
2934
  return devLog.debug("Translation document created", {
@@ -1779,30 +2955,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1779
2955
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
1780
2956
  return;
1781
2957
  }
1782
- }, patchTranslationTarget = async ({
1783
- client,
1784
- currentSourceDocument,
1785
- translationMetadata,
1786
- targetLanguage
1787
- }) => {
1788
- const sourceSnapshotString = translationMetadata.translationHistory.find(
1789
- (h) => undraftId(h.sourceId) === undraftId(currentSourceDocument._id)
1790
- )?.sourceSnapshot;
1791
- if (!sourceSnapshotString)
1792
- return {};
1793
- const sourceSnapshot = JSON.parse(sourceSnapshotString), targetId = translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref;
1794
- if (!targetId)
1795
- return {};
1796
- const currentTargetId = await client.fetch(
1797
- '*[_id in $ids] | order((_id match "drafts*") desc)[0]._id',
1798
- { ids: [undraftId(targetId), draftId(targetId)] }
1799
- );
1800
- if (!currentTargetId)
1801
- return console.log(`No document found for ${targetId}`), {};
1802
- const patch = diffPatch(sourceSnapshot, currentSourceDocument, {
1803
- id: draftId(currentTargetId)
1804
- });
1805
- return { currentTargetId, patch };
1806
2958
  }, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
1807
2959
  client,
1808
2960
  sourceDocument,
@@ -1813,17 +2965,19 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1813
2965
  schemaId,
1814
2966
  references = [],
1815
2967
  maxConcurrentTranslationJobs,
1816
- shouldTranslateDiffs
2968
+ translationMode,
2969
+ openaiConfig
1817
2970
  }) => {
1818
2971
  const logContext = {
1819
2972
  documentId: sourceDocument._id,
1820
2973
  targetLanguages,
1821
2974
  referencesCount: references.length,
1822
2975
  maxConcurrentJobs: maxConcurrentTranslationJobs,
1823
- shouldTranslateDiffs,
2976
+ translationMode,
1824
2977
  shouldOverwriteFreshTranslations
1825
2978
  };
1826
2979
  devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
2980
+ let releaseId;
1827
2981
  try {
1828
2982
  const sourceLanguage = sourceDocument[languageFieldName];
1829
2983
  if (!sourceLanguage)
@@ -1835,6 +2989,22 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1835
2989
  type: "SET_OVERALL_STATUS",
1836
2990
  payload: "translating"
1837
2991
  });
2992
+ const releaseTitle = `[AI Translation] ${sourceDocument.title || sourceDocument._id.split("-").slice(0, 2).join("-")} (${sourceDocument._type}) ${sourceLanguage} \u2192 ${targetLanguages.join(", ")}`, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
2993
+ devLog.info("Creating Content Release for translation batch", {
2994
+ ...logContext,
2995
+ releaseTitle
2996
+ });
2997
+ const { releaseId: newReleaseId } = await client.releases.create({
2998
+ metadata: {
2999
+ title: releaseTitle,
3000
+ description: releaseDescription,
3001
+ releaseType: "asap"
3002
+ }
3003
+ });
3004
+ releaseId = newReleaseId, devLog.info("Content Release created", {
3005
+ ...logContext,
3006
+ releaseId
3007
+ });
1838
3008
  const sourceDocuments = [
1839
3009
  {
1840
3010
  _id: sourceDocument._id,
@@ -1866,7 +3036,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1866
3036
  }).fetch("*[_id == $id][0]", {
1867
3037
  id: documentId
1868
3038
  });
1869
- if (document) {
3039
+ if (document && releaseId) {
1870
3040
  const targetDocumentId = await translateDocument({
1871
3041
  dispatch,
1872
3042
  client,
@@ -1875,8 +3045,10 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1875
3045
  languageFieldName,
1876
3046
  shouldOverwriteFreshTranslations,
1877
3047
  schemaId,
1878
- shouldTranslateDiffs,
1879
- translationPrompts
3048
+ translationPrompts,
3049
+ translationMode,
3050
+ openaiConfig,
3051
+ releaseId
1880
3052
  });
1881
3053
  targetDocumentId && targetDocumentIds.push(targetDocumentId);
1882
3054
  }
@@ -1924,11 +3096,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1924
3096
  failed
1925
3097
  }), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : (devLog.info("Document set translation completed successfully", logContext), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
1926
3098
  } catch (error) {
1927
- throw devLog.error(
3099
+ if (devLog.error(
1928
3100
  "translateDocumentSet failed with unexpected error",
1929
3101
  error instanceof Error ? error : new Error(String(error)),
1930
3102
  logContext
1931
- ), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
3103
+ ), releaseId)
3104
+ try {
3105
+ await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
3106
+ ...logContext,
3107
+ releaseId
3108
+ });
3109
+ } catch (cleanupError) {
3110
+ devLog.error(
3111
+ "Failed to cleanup release",
3112
+ cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
3113
+ logContext
3114
+ );
3115
+ }
3116
+ throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
1932
3117
  }
1933
3118
  }, ActionButtonBar = ({ setIsDialogOpen }) => {
1934
3119
  const {
@@ -1939,22 +3124,50 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1939
3124
  references,
1940
3125
  selectedTargetLanguages,
1941
3126
  shouldOverwriteFreshTranslations,
1942
- shouldTranslateDiffs
1943
- } = useTranslationContext(), client = useClient({ apiVersion: "vX" });
3127
+ translationMode
3128
+ } = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
3129
+ translateDocumentSet({
3130
+ sourceDocument: document,
3131
+ targetLanguages: selectedTargetLanguages,
3132
+ client,
3133
+ dispatch,
3134
+ languageFieldName: config.languageFieldName,
3135
+ schemaId: config.schemaId,
3136
+ references,
3137
+ shouldOverwriteFreshTranslations,
3138
+ maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
3139
+ maxReferenceDepth: config.maxReferenceDepth,
3140
+ translationMode,
3141
+ openaiConfig: config.openai
3142
+ });
3143
+ }, [
3144
+ document,
3145
+ selectedTargetLanguages,
3146
+ client,
3147
+ dispatch,
3148
+ config.languageFieldName,
3149
+ config.schemaId,
3150
+ config.maxConcurrentTranslationJobs,
3151
+ config.maxReferenceDepth,
3152
+ config.openai,
3153
+ references,
3154
+ shouldOverwriteFreshTranslations,
3155
+ translationMode
3156
+ ]), handleSelectAll = useCallback(() => {
3157
+ dispatch({
3158
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3159
+ payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
3160
+ });
3161
+ }, [dispatch, config.supportedLanguages, document.language]), handleSelectNone = useCallback(() => {
3162
+ dispatch({
3163
+ type: "SET_SELECTED_TARGET_LANGUAGES",
3164
+ payload: []
3165
+ });
3166
+ }, [dispatch]);
1944
3167
  return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
1945
3168
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
1946
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1947
- dispatch({
1948
- type: "SET_SELECTED_TARGET_LANGUAGES",
1949
- payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
1950
- });
1951
- }, children: "Select All" }),
1952
- /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
1953
- dispatch({
1954
- type: "SET_SELECTED_TARGET_LANGUAGES",
1955
- payload: []
1956
- });
1957
- }, children: "Select None" })
3169
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
3170
+ /* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
1958
3171
  ] }),
1959
3172
  /* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
1960
3173
  /* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
@@ -1964,21 +3177,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
1964
3177
  mode: "ghost",
1965
3178
  tone: "positive",
1966
3179
  size: 2,
1967
- onClick: () => {
1968
- translateDocumentSet({
1969
- sourceDocument: document,
1970
- targetLanguages: selectedTargetLanguages,
1971
- client,
1972
- dispatch,
1973
- languageFieldName: config.languageFieldName,
1974
- schemaId: config.schemaId,
1975
- references,
1976
- shouldOverwriteFreshTranslations,
1977
- maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
1978
- maxReferenceDepth: config.maxReferenceDepth,
1979
- shouldTranslateDiffs
1980
- });
1981
- },
3180
+ onClick: handleTranslate,
1982
3181
  disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
1983
3182
  children: "Translate"
1984
3183
  }
@@ -2023,6 +3222,88 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2023
3222
  documentId: document._id
2024
3223
  });
2025
3224
  return /* @__PURE__ */ jsx(Card, { padding: 4, muted: !0, border: !0, children: /* @__PURE__ */ jsx(Grid, { columns: [1, 2], gap: 3, children: config?.supportedLanguages.map((lang) => /* @__PURE__ */ jsx(LanguageCheckbox, { lang, isLoading: loading }, lang.id)) }) });
3225
+ }, TranslationModePanel = () => {
3226
+ const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
3227
+ if (!config?.openai?.enabled || !config?.openai?.apiKey)
3228
+ return null;
3229
+ if (!openaiValidated)
3230
+ return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3231
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Configuration" }),
3232
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating API key..." })
3233
+ ] }) });
3234
+ const handleModeChange = (mode) => {
3235
+ dispatch({
3236
+ type: "SET_TRANSLATION_MODE",
3237
+ payload: mode
3238
+ });
3239
+ };
3240
+ return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
3241
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3242
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
3243
+ /* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
3244
+ ] }),
3245
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3246
+ /* @__PURE__ */ jsx(
3247
+ Button,
3248
+ {
3249
+ type: "button",
3250
+ mode: translationMode === "sanity-only" ? "ghost" : "bleed",
3251
+ tone: translationMode === "sanity-only" ? "primary" : "default",
3252
+ onClick: () => handleModeChange("sanity-only"),
3253
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3254
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-only", readOnly: !0 }),
3255
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3256
+ /* @__PURE__ */ jsx(Text, { children: "Sanity Agent Only" }),
3257
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Fast, reliable, and included with Sanity." })
3258
+ ] })
3259
+ ] })
3260
+ }
3261
+ ),
3262
+ /* @__PURE__ */ jsx(
3263
+ Button,
3264
+ {
3265
+ type: "button",
3266
+ mode: translationMode === "openai-only" ? "ghost" : "bleed",
3267
+ tone: translationMode === "openai-only" ? "primary" : "default",
3268
+ onClick: () => handleModeChange("openai-only"),
3269
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3270
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
3271
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3272
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3273
+ /* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
3274
+ /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
3275
+ ] }),
3276
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
3277
+ ] })
3278
+ ] })
3279
+ }
3280
+ ),
3281
+ /* @__PURE__ */ jsx(
3282
+ Button,
3283
+ {
3284
+ type: "button",
3285
+ mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
3286
+ tone: translationMode === "sanity-with-review" ? "primary" : "default",
3287
+ onClick: () => handleModeChange("sanity-with-review"),
3288
+ children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3289
+ /* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
3290
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3291
+ /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
3292
+ /* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
3293
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
3294
+ ] }),
3295
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
3296
+ ] })
3297
+ ] })
3298
+ }
3299
+ )
3300
+ ] }),
3301
+ translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3302
+ "\u{1F4B0} ",
3303
+ /* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
3304
+ " You'll be charged for API usage based on tokens processed."
3305
+ ] }) })
3306
+ ] }) });
2026
3307
  }, assembleReferences = async ({
2027
3308
  client,
2028
3309
  sourceDocument,
@@ -2047,7 +3328,14 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2047
3328
  typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
2048
3329
  }
2049
3330
  } catch (error) {
2050
- console.error("assembleReferences: Error finding references in document:", error);
3331
+ devLog.error(
3332
+ "Error finding references in document",
3333
+ error instanceof Error ? error : new Error(String(error)),
3334
+ {
3335
+ documentId: document._id,
3336
+ component: "assembleReferences"
3337
+ }
3338
+ );
2051
3339
  }
2052
3340
  if (allRefIds.size === 0)
2053
3341
  break;
@@ -2066,16 +3354,27 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2066
3354
  }
2067
3355
  documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
2068
3356
  } else
2069
- documentCache.set(refId, null), console.warn(`assembleReferences: Document with _id "${refId}" not found`);
3357
+ documentCache.set(refId, null), devLog.warn(`Document with _id "${refId}" not found`, {
3358
+ refId,
3359
+ component: "assembleReferences"
3360
+ });
2070
3361
  }
2071
3362
  currentDepthDocuments = nextDepthDocuments;
2072
3363
  } catch (fetchError) {
2073
3364
  const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
2074
- errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), console.error("assembleReferences: Failed to batch fetch documents:", error.message);
3365
+ errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), devLog.error("Failed to batch fetch documents", error, {
3366
+ refIdCount: refIdArray.length,
3367
+ depth,
3368
+ component: "assembleReferences"
3369
+ });
2075
3370
  break;
2076
3371
  }
2077
3372
  }
2078
- errors.length > 0 && console.warn(`assembleReferences: Encountered ${errors.length} error(s) while fetching references`);
3373
+ errors.length > 0 && devLog.warn(`Encountered ${errors.length} error(s) while fetching references`, {
3374
+ errorCount: errors.length,
3375
+ errors: errors.map((e) => ({ refId: e.refId, message: e.error.message })),
3376
+ component: "assembleReferences"
3377
+ });
2079
3378
  const result = [];
2080
3379
  for (const refId of references) {
2081
3380
  const cachedDoc = documentCache.get(refId);
@@ -2160,7 +3459,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2160
3459
  shouldTranslateReferences ? /* @__PURE__ */ jsx(Flex, { padding: 2, justify: "flex-end", children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : `${references.length} translatable references found.` }) }) : null
2161
3460
  ] });
2162
3461
  }, WriteSettingsPanel = () => {
2163
- const { dispatch, shouldOverwriteFreshTranslations, shouldTranslateDiffs } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
3462
+ const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
2164
3463
  dispatch({
2165
3464
  type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
2166
3465
  payload: !shouldOverwriteFreshTranslations
@@ -2191,38 +3490,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2191
3490
  ] })
2192
3491
  ] })
2193
3492
  }
2194
- ) }),
2195
- /* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
2196
- Button,
2197
- {
2198
- type: "button",
2199
- mode: shouldTranslateDiffs ? "ghost" : "bleed",
2200
- size: 2,
2201
- onClick: () => {
2202
- dispatch({
2203
- type: "SET_SHOULD_TRANSLATE_DIFFS",
2204
- payload: !shouldTranslateDiffs
2205
- });
2206
- },
2207
- tone: shouldTranslateDiffs ? "primary" : "neutral",
2208
- children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
2209
- /* @__PURE__ */ jsx(
2210
- Checkbox,
2211
- {
2212
- checked: shouldTranslateDiffs,
2213
- onChange: handleShouldOverwriteFreshTranslationsChange,
2214
- label: shouldTranslateDiffs ? "Disable" : "Enable"
2215
- }
2216
- ),
2217
- /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
2218
- /* @__PURE__ */ jsxs(Inline, { space: 1, children: [
2219
- /* @__PURE__ */ jsx(Text, { children: "Translate Diffs" }),
2220
- /* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Experimental" })
2221
- ] }),
2222
- /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Only re-translate content that has changed in the source document." })
2223
- ] })
2224
- ] })
2225
- }
2226
3493
  ) })
2227
3494
  ] });
2228
3495
  }, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
@@ -2230,16 +3497,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2230
3497
  /* @__PURE__ */ jsx(Label, { children: "Languages" }),
2231
3498
  /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
2232
3499
  ] }),
3500
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3501
+ /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
3502
+ /* @__PURE__ */ jsx(TranslationModePanel, {})
3503
+ ] }),
2233
3504
  /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
2234
3505
  /* @__PURE__ */ jsx(Label, { children: "Settings" }),
2235
3506
  /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
2236
3507
  ] }),
2237
3508
  /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
2238
3509
  ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
2239
- const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
3510
+ const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
2240
3511
  () => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
2241
3512
  [overallStatus]
2242
- ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]);
3513
+ ), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
3514
+ if (translationMode === "sanity-only") return null;
3515
+ const completedJobs = jobs.filter((j) => j.status === "completed"), totalTokens = completedJobs.reduce((sum, job) => sum + (job.tokensUsed || 0), 0), totalCost = completedJobs.reduce((sum, job) => sum + (job.estimatedCost || 0), 0);
3516
+ return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
3517
+ }, [jobs, translationMode]);
2243
3518
  return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
2244
3519
  /* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
2245
3520
  /* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
@@ -2259,6 +3534,25 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2259
3534
  " \u2014 ",
2260
3535
  job._id.slice(-6)
2261
3536
  ] }),
3537
+ !["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3538
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
3539
+ job.reasoningProgress && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3540
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "\u{1F914} GPT-5 Reasoning" }),
3541
+ /* @__PURE__ */ jsx(Text, { size: 1, style: { fontStyle: "italic" }, children: job.reasoningProgress })
3542
+ ] }) })
3543
+ ] }),
3544
+ job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3545
+ /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
3546
+ job.tokensUsed.toLocaleString(),
3547
+ " tokens (~$",
3548
+ job.estimatedCost?.toFixed(4),
3549
+ ")"
3550
+ ] }),
3551
+ job.reasoningSummary && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3552
+ "\u{1F4A1} ",
3553
+ job.reasoningSummary
3554
+ ] }) })
3555
+ ] }) : null,
2262
3556
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
2263
3557
  "Error: ",
2264
3558
  job.errorMessage
@@ -2279,7 +3573,23 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2279
3573
  ) : null
2280
3574
  ] })
2281
3575
  ] }) }) }, job._id)) }) }) })
2282
- ] })
3576
+ ] }),
3577
+ openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
3578
+ /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
3579
+ /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
3580
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3581
+ "\u{1F4CA} Total Tokens: ",
3582
+ /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
3583
+ ] }),
3584
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3585
+ "\u{1F4B0} Total Cost: ",
3586
+ /* @__PURE__ */ jsxs("strong", { children: [
3587
+ "$",
3588
+ openaiStats.totalCost.toFixed(4)
3589
+ ] })
3590
+ ] })
3591
+ ] })
3592
+ ] }) })
2283
3593
  ] });
2284
3594
  }, ProgressBar = ({ progress, max = 100 }) => {
2285
3595
  const percentage = Math.min(progress / max * 100, 100);
@@ -2338,7 +3648,18 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
2338
3648
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
2339
3649
  children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
2340
3650
  }
2341
- ), STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
3651
+ ), getStatusLabel = (status) => ({
3652
+ idle: "Idle",
3653
+ queued: "Queued...",
3654
+ preparing_translation: "Preparing translation...",
3655
+ translating: "Translating with Sanity Agent...",
3656
+ translating_with_openai: "Translating with GPT-5...",
3657
+ reviewing_with_openai: "Reviewing translation with GPT-5...",
3658
+ updating_metadata: "Saving metadata...",
3659
+ error: "Error",
3660
+ skipped: "Skipped",
3661
+ completed: "Completed"
3662
+ })[status] || status, STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
2342
3663
  "translating",
2343
3664
  "linking_references",
2344
3665
  "completed"