@braze/sanity-plugin-ai-translation 0.2.75 → 0.2.77

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.d.ts CHANGED
@@ -284,7 +284,6 @@ declare interface OpenAIApiCallEvent extends TelemetryEventBase {
284
284
  total: number;
285
285
  cached: number;
286
286
  };
287
- cost: number;
288
287
  durationMs: number;
289
288
  };
290
289
  }
@@ -694,8 +693,6 @@ declare interface TranslationJobCompletedEvent extends TelemetryEventBase {
694
693
  targetLanguage: string;
695
694
  translationMode: TranslationMode;
696
695
  durationMs: number;
697
- tokensUsed?: number;
698
- estimatedCost?: number;
699
696
  };
700
697
  }
701
698
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { getPublishedId, pathToString, useClient, defineType, defineField, definePlugin } from "sanity";
1
+ import { getPublishedId, pathToString, useClient, useValidationStatus, defineType, defineField, definePlugin } from "sanity";
2
2
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
- import { WarningOutlineIcon, SyncIcon, SearchIcon, CheckmarkIcon, ErrorOutlineIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
3
+ import { WarningOutlineIcon, SyncIcon, SearchIcon, ErrorOutlineIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, ResetIcon, PanelRightIcon, TranslateIcon } from "@sanity/icons";
4
4
  import { createContext, useReducer, useRef, useEffect, useContext, useMemo, useState, useCallback } from "react";
5
5
  import defaults from "lodash.defaults";
6
6
  import { Card, Stack, Flex, Text, Box, Heading, Button, Spinner, Inline, Checkbox, Grid, Radio, TextInput, Badge, Select, Label, Code, Tooltip } from "@sanity/ui";
@@ -10,6 +10,7 @@ import cloneDeep from "lodash.clonedeep";
10
10
  import get from "lodash.get";
11
11
  import set from "lodash.set";
12
12
  import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
13
+ import { IntentLink } from "sanity/router";
13
14
  const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true" || process.env.SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING === "true", devLog = {
14
15
  info: (message, context) => {
15
16
  isLoggingEnabled() && console.info("[AI Translation]", message, context ? JSON.stringify(context, null, 2) : "");
@@ -870,14 +871,11 @@ const fetchTranslationPrompts = async ({
870
871
  languageFieldName
871
872
  }) => {
872
873
  try {
873
- const doc = await retryWithBackoff(
874
- () => client.fetch("*[_id == $documentId][0]", { documentId }),
875
- {
876
- maxAttempts: 3,
877
- initialDelayMs: 1e3,
878
- context: { operation: "localizeReferences.fetchDocument", documentId }
879
- }
880
- );
874
+ const doc = await retryWithBackoff(() => client.fetch("*[_id == $documentId][0]", { documentId }), {
875
+ maxAttempts: 3,
876
+ initialDelayMs: 1e3,
877
+ context: { operation: "localizeReferences.fetchDocument", documentId }
878
+ });
881
879
  if (!doc) {
882
880
  devLog.error("Document not found", new Error("Document not found"), {
883
881
  documentId,
@@ -904,20 +902,19 @@ const fetchTranslationPrompts = async ({
904
902
  component: "localizeReferences"
905
903
  });
906
904
  }
907
- }, updateReferences = async (documentId, client, refs, sourceLanguage, targetLanguage) => {
905
+ }, updateReferences = async (documentId, client, refs, _sourceLanguage, targetLanguage) => {
908
906
  if (!refs?.length)
909
907
  return [];
910
908
  const refIds = refs.map((r) => getPublishedId(r.value)), translations = await retryWithBackoff(
911
909
  () => client.fetch(
912
910
  `*[_type == $translationMetadataSchemaType && array::intersects(translations[].value._ref, $refIds)] {
913
- "sourceId": translations[_key == $sourceLanguage][0].value._ref,
911
+ "allSourceRefs": translations[_key != $targetLanguage][].value._ref,
914
912
  "targetId": translations[_key == $targetLanguage][0].value._ref,
915
- "targetType": translations[_key == $sourceLanguage][0].value -> _type
913
+ "targetType": translations[_key == $targetLanguage][0].value -> _type
916
914
  }`,
917
915
  {
918
916
  translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
919
917
  refIds,
920
- sourceLanguage,
921
918
  targetLanguage
922
919
  }
923
920
  ),
@@ -928,16 +925,21 @@ const fetchTranslationPrompts = async ({
928
925
  }
929
926
  ), translationMap = /* @__PURE__ */ new Map();
930
927
  for (const t of translations)
931
- t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
928
+ if (t.targetId)
929
+ for (const sourceRef of t.allSourceRefs ?? [])
930
+ sourceRef && translationMap.set(sourceRef, { targetId: t.targetId, targetType: t.targetType });
932
931
  const patch = client.patch(documentId);
933
932
  let hasChanges = !1;
934
933
  for (const ref of refs) {
935
- const sourceId = getPublishedId(ref.value), newId = translationMap.get(sourceId);
936
- newId && (patch.set({ [pathToString(ref.path)]: getPublishedId(newId) }), patch.set({
934
+ const sourceId = getPublishedId(ref.value), match = translationMap.get(sourceId);
935
+ if (!match)
936
+ continue;
937
+ const newId = getPublishedId(match.targetId);
938
+ patch.set({ [pathToString(ref.path)]: newId }), patch.set({
937
939
  [`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
938
- type: translations.find((t) => t.targetId === getPublishedId(newId))?.targetType || void 0
940
+ type: match.targetType || void 0
939
941
  }
940
- }), hasChanges = !0);
942
+ }), hasChanges = !0;
941
943
  }
942
944
  hasChanges && (await retryWithBackoff(
943
945
  async () => {
@@ -1595,7 +1597,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1595
1597
  reasoning: result.usage?.output_tokens_details?.reasoning_tokens || 0,
1596
1598
  total: result.usage?.total_tokens || 0,
1597
1599
  cached: result.usage?.input_tokens_details?.cached_tokens || 0
1598
- }, cost = calculateCost(tokensUsed, model);
1600
+ };
1599
1601
  let content = "";
1600
1602
  if (result.output && Array.isArray(result.output)) {
1601
1603
  const messageItem = result.output.find((item) => item.type === "message");
@@ -1603,8 +1605,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1603
1605
  }
1604
1606
  return content || (content = result.output_text || result.text || ""), {
1605
1607
  content,
1606
- tokensUsed,
1607
- cost
1608
+ tokensUsed
1608
1609
  };
1609
1610
  }, callChatCompletionsAPI = async (apiKey, request) => {
1610
1611
  const model = request.model || "gpt-4o", requestBody = {
@@ -1656,56 +1657,11 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1656
1657
  reasoning: usage.reasoning_tokens || 0,
1657
1658
  total: usage.total_tokens || 0,
1658
1659
  cached: usage.prompt_tokens_details?.cached_tokens || 0
1659
- }, cost = calculateCost(tokensUsed, model);
1660
+ };
1660
1661
  return {
1661
1662
  content: result.choices[0].message.content || "",
1662
- tokensUsed,
1663
- cost
1663
+ tokensUsed
1664
1664
  };
1665
- }, calculateCost = (tokens, model) => {
1666
- const pricing = {
1667
- "gpt-5": {
1668
- input: 125e-8,
1669
- output: 1e-5,
1670
- reasoning: 0
1671
- },
1672
- "gpt-5.1": {
1673
- input: 125e-8,
1674
- output: 1e-5,
1675
- reasoning: 0
1676
- },
1677
- "gpt-5-mini": {
1678
- input: 25e-8,
1679
- output: 2e-6,
1680
- reasoning: 0
1681
- },
1682
- "gpt-5-nano": {
1683
- input: 50000000000000004e-24,
1684
- output: 40000000000000003e-23,
1685
- reasoning: 0
1686
- },
1687
- "gpt-5-pro": {
1688
- input: 15e-6,
1689
- output: 12e-5,
1690
- reasoning: 0
1691
- },
1692
- "gpt-4o": {
1693
- input: 25e-7,
1694
- output: 1e-5,
1695
- reasoning: 0
1696
- },
1697
- "gpt-4-turbo": {
1698
- input: 1e-5,
1699
- output: 3e-5,
1700
- reasoning: 0
1701
- },
1702
- "gpt-4": {
1703
- input: 3e-5,
1704
- output: 6e-5,
1705
- reasoning: 0
1706
- }
1707
- }, modelPricing = pricing[model] || pricing["gpt-5"], cachedInputRate = model === "gpt-5.1" ? 0.125 / 1e6 : modelPricing.input;
1708
- return Math.max(0, tokens.prompt - tokens.cached) * modelPricing.input + tokens.cached * cachedInputRate + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
1709
1665
  }, deserializeDocument = (originalDoc, reviewedText) => {
1710
1666
  devLog.debug("Raw GPT-5 response received", {
1711
1667
  length: reviewedText.length,
@@ -2348,7 +2304,6 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2348
2304
  openaiResponseContent = response.content, devLog.info("OpenAI translation response received", {
2349
2305
  ...logContext,
2350
2306
  tokensUsed: response.tokensUsed,
2351
- cost: response.cost,
2352
2307
  responseLength: response.content.length,
2353
2308
  responsePreview: response.content.substring(0, 500)
2354
2309
  });
@@ -2380,13 +2335,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2380
2335
  originalValue: field.originalValue,
2381
2336
  translatedValue: field.translatedValue
2382
2337
  }))
2383
- })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2384
- tokensUsed: response.tokensUsed.total,
2385
- estimatedCost: response.cost
2386
- }), {
2387
- document: result,
2388
- tokensUsed: response.tokensUsed.total,
2389
- cost: response.cost
2338
+ })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai"), {
2339
+ document: result
2390
2340
  };
2391
2341
  } catch (error) {
2392
2342
  const err = error instanceof Error ? error : new Error(String(error));
@@ -2722,7 +2672,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2722
2672
  documentId: sourceDocument._id,
2723
2673
  documentType: sourceDocument._type
2724
2674
  }), documentToTranslate = filteredDocument;
2725
- let translatedDoc, translationMetrics = {}, previousError = null;
2675
+ let translatedDoc, previousError = null;
2726
2676
  const performTranslation = async (errorContext) => {
2727
2677
  switch (translationMode) {
2728
2678
  case "sanity-only": {
@@ -2782,19 +2732,14 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2782
2732
  telemetryConfig
2783
2733
  });
2784
2734
  return result ? {
2785
- document: result.document,
2786
- tokensUsed: result.tokensUsed,
2787
- cost: result.cost
2735
+ document: result.document
2788
2736
  } : { document: null };
2789
2737
  }
2790
2738
  default:
2791
2739
  throw new Error(`Invalid translation mode: ${translationMode}`);
2792
2740
  }
2793
- }, translationResult = await performTranslation();
2794
- if (translatedDoc = translationResult.document, translationMetrics = {
2795
- tokensUsed: translationResult.tokensUsed,
2796
- cost: translationResult.cost
2797
- }, !translatedDoc)
2741
+ };
2742
+ if (translatedDoc = (await performTranslation()).document, !translatedDoc)
2798
2743
  return null;
2799
2744
  let transformedResult;
2800
2745
  Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
@@ -2857,13 +2802,9 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2857
2802
  targetLanguage,
2858
2803
  error: error instanceof Error ? error.message : String(error)
2859
2804
  }), previousError = error instanceof Error ? error : new Error(String(error));
2860
- const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`, retryResult = await performTranslation(errorContext);
2861
- if (translatedDoc = retryResult.document, !translatedDoc)
2805
+ const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`;
2806
+ if (translatedDoc = (await performTranslation(errorContext)).document, !translatedDoc)
2862
2807
  throw new Error("Translation retry failed: no document returned");
2863
- translationMetrics = {
2864
- tokensUsed: retryResult.tokensUsed,
2865
- cost: retryResult.cost
2866
- };
2867
2808
  let retryTransformedResult;
2868
2809
  Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
2869
2810
  translatedDocument: translatedDoc,
@@ -2895,17 +2836,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2895
2836
  }
2896
2837
  }
2897
2838
  ), {
2898
- document: retryNewDoc,
2899
- tokensUsed: translationMetrics.tokensUsed,
2900
- cost: translationMetrics.cost
2839
+ document: retryNewDoc
2901
2840
  };
2902
2841
  }
2903
2842
  throw error;
2904
2843
  }
2905
2844
  return {
2906
- document: newDoc,
2907
- tokensUsed: translationMetrics.tokensUsed,
2908
- cost: translationMetrics.cost
2845
+ document: newDoc
2909
2846
  };
2910
2847
  }, translateDocument = async ({
2911
2848
  dispatch,
@@ -2937,7 +2874,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2937
2874
  client,
2938
2875
  sourceDocument,
2939
2876
  sourceLanguage
2940
- }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await handleTranslation({
2877
+ }), existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = (await handleTranslation({
2941
2878
  dispatch,
2942
2879
  client,
2943
2880
  schemaId,
@@ -2954,7 +2891,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2954
2891
  replaceExisting,
2955
2892
  mainSourceDocumentId,
2956
2893
  telemetryConfig
2957
- }), newDocument = translationResult?.document;
2894
+ }))?.document;
2958
2895
  if (!newDocument) {
2959
2896
  const failureError = new Error("Translation returned null");
2960
2897
  devLog.error("Translation failed: no document returned", failureError, logContext), setJobStatus(
@@ -3005,9 +2942,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3005
2942
  targetLanguage,
3006
2943
  "completed",
3007
2944
  {
3008
- targetId: newDocument._id,
3009
- tokensUsed: translationResult?.tokensUsed,
3010
- estimatedCost: translationResult?.cost
2945
+ targetId: newDocument._id
3011
2946
  },
3012
2947
  mainSourceDocumentId
3013
2948
  ), devLog.info("Translation completed successfully", {
@@ -3022,9 +2957,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3022
2957
  sourceLanguage,
3023
2958
  targetLanguage,
3024
2959
  translationMode,
3025
- durationMs: Date.now() - jobStartTime,
3026
- tokensUsed: translationResult?.tokensUsed,
3027
- estimatedCost: translationResult?.cost
2960
+ durationMs: Date.now() - jobStartTime
3028
2961
  }
3029
2962
  }), devLog.timeEnd("translateDocument"), devLog.groupEnd(), newDocument._id;
3030
2963
  } catch (error) {
@@ -3342,7 +3275,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3342
3275
  ] })
3343
3276
  ] })
3344
3277
  ] }) });
3345
- }, ActionButtonBar = ({ setIsDialogOpen }) => {
3278
+ }, ActionButtonBar = ({ setIsDialogOpen, hasValidationErrors }) => {
3346
3279
  const {
3347
3280
  document,
3348
3281
  config,
@@ -3474,7 +3407,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3474
3407
  tone: "positive",
3475
3408
  size: 2,
3476
3409
  onClick: handleTranslate,
3477
- disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus) || releaseConfig.mode === "existing" && !releaseConfig.existingReleaseId || isCheckingConflicts,
3410
+ disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus) || releaseConfig.mode === "existing" && !releaseConfig.existingReleaseId || isCheckingConflicts || hasValidationErrors,
3478
3411
  children: isCheckingConflicts ? "Checking..." : "Translate"
3479
3412
  }
3480
3413
  )
@@ -3920,13 +3853,106 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3920
3853
  ] })
3921
3854
  }
3922
3855
  )
3856
+ ] })
3857
+ ] }) });
3858
+ }, DocumentValidator = ({ documentId, documentType, onResult }) => {
3859
+ const publishedId = getPublishedId(documentId), { isValidating, validation } = useValidationStatus(publishedId, documentType);
3860
+ return useEffect(() => {
3861
+ const errors = validation.filter((v) => v.level === "error");
3862
+ onResult(documentId, { isValidating, errors });
3863
+ }, [isValidating, validation, documentId, onResult]), null;
3864
+ }, ValidationPanel = ({ onValidationChange }) => {
3865
+ const { document, references, selectedReferenceIds, shouldTranslateReferences } = useTranslationContext(), [results, setResults] = useState(/* @__PURE__ */ new Map()), documentsToValidate = useMemo(() => {
3866
+ const docs = [
3867
+ {
3868
+ _id: document._id,
3869
+ _type: document._type,
3870
+ title: document.title || document.name || document._id
3871
+ }
3872
+ ];
3873
+ if (shouldTranslateReferences)
3874
+ for (const ref of references)
3875
+ selectedReferenceIds.has(ref._id) && docs.push(ref);
3876
+ return docs;
3877
+ }, [document, references, selectedReferenceIds, shouldTranslateReferences]), handleResult = useCallback((id, result) => {
3878
+ setResults((prev) => {
3879
+ const next = new Map(prev);
3880
+ return next.set(id, result), next;
3881
+ });
3882
+ }, []), { shouldBlock, someStillValidating, documentsWithErrors } = useMemo(() => {
3883
+ const allValidated = documentsToValidate.every((doc) => results.has(doc._id)), validating = [...results.values()].some((r) => r.isValidating) || !allValidated, docsWithErrors = [];
3884
+ for (const doc of documentsToValidate) {
3885
+ const result = results.get(doc._id);
3886
+ result && result.errors.length > 0 && docsWithErrors.push({ ...doc, errors: result.errors });
3887
+ }
3888
+ const hasErrors = docsWithErrors.length > 0;
3889
+ return {
3890
+ shouldBlock: validating || hasErrors,
3891
+ someStillValidating: validating,
3892
+ documentsWithErrors: docsWithErrors
3893
+ };
3894
+ }, [documentsToValidate, results]);
3895
+ useEffect(() => {
3896
+ onValidationChange(shouldBlock);
3897
+ }, [shouldBlock, onValidationChange]), useEffect(() => {
3898
+ documentsWithErrors.length > 0 && devLog.warn("Documents have validation errors that block translation", {
3899
+ documentsWithErrors: documentsWithErrors.map((d) => ({
3900
+ id: d._id,
3901
+ type: d._type,
3902
+ errorCount: d.errors.length
3903
+ })),
3904
+ component: "ValidationPanel"
3905
+ });
3906
+ }, [documentsWithErrors]);
3907
+ const totalErrors = useMemo(
3908
+ () => documentsWithErrors.reduce((sum, doc) => sum + doc.errors.length, 0),
3909
+ [documentsWithErrors]
3910
+ );
3911
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
3912
+ documentsToValidate.map((doc) => /* @__PURE__ */ jsx(DocumentValidator, { documentId: doc._id, documentType: doc._type, onResult: handleResult }, doc._id)),
3913
+ someStillValidating && documentsWithErrors.length === 0 && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, padding: 2, children: [
3914
+ /* @__PURE__ */ jsx(Spinner, { muted: !0 }),
3915
+ /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating documents\u2026" })
3923
3916
  ] }),
3924
- translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3925
- "\u{1F4B0} ",
3926
- /* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
3927
- " You'll be charged for API usage based on tokens processed."
3917
+ documentsWithErrors.length > 0 && /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 4, border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
3918
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [
3919
+ /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(ErrorOutlineIcon, {}) }),
3920
+ /* @__PURE__ */ jsxs(Text, { weight: "semibold", children: [
3921
+ totalErrors,
3922
+ " validation error",
3923
+ totalErrors !== 1 ? "s" : "",
3924
+ " found in ",
3925
+ documentsWithErrors.length,
3926
+ " ",
3927
+ "document",
3928
+ documentsWithErrors.length !== 1 ? "s" : ""
3929
+ ] })
3930
+ ] }),
3931
+ /* @__PURE__ */ jsx(Text, { size: 1, children: "Documents must pass validation before they can be translated. Click a document to open it and fix the errors." }),
3932
+ /* @__PURE__ */ jsx(Card, { padding: 3, border: !0, radius: 2, children: /* @__PURE__ */ jsx(Box, { style: { maxHeight: "300px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 3, children: documentsWithErrors.map((doc) => {
3933
+ const publishedId = getPublishedId(doc._id);
3934
+ return /* @__PURE__ */ jsxs(Flex, { align: "center", justify: "space-between", gap: 2, children: [
3935
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, flex: 1, style: { minWidth: 0 }, children: [
3936
+ /* @__PURE__ */ jsx(Text, { size: 1, children: /* @__PURE__ */ jsx(
3937
+ IntentLink,
3938
+ {
3939
+ intent: "edit",
3940
+ params: { id: publishedId, type: doc._type },
3941
+ style: { color: "inherit" },
3942
+ children: doc.title || doc._id
3943
+ }
3944
+ ) }),
3945
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: doc._type })
3946
+ ] }),
3947
+ /* @__PURE__ */ jsxs(Badge, { tone: "critical", fontSize: 0, children: [
3948
+ doc.errors.length,
3949
+ " error",
3950
+ doc.errors.length !== 1 ? "s" : ""
3951
+ ] })
3952
+ ] }, doc._id);
3953
+ }) }) }) })
3928
3954
  ] }) })
3929
- ] }) });
3955
+ ] });
3930
3956
  }, assembleReferences = async ({
3931
3957
  client,
3932
3958
  sourceDocument,
@@ -4180,32 +4206,34 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
4180
4206
  !isLoadingReferences && references.length > 0 && showSelectionPanel && /* @__PURE__ */ jsx(ReferenceSelectionPanel, {})
4181
4207
  ] }) : null
4182
4208
  ] });
4183
- }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
4184
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4185
- /* @__PURE__ */ jsx(Label, { children: "Languages" }),
4186
- /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
4187
- ] }),
4188
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4189
- /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
4190
- /* @__PURE__ */ jsx(TranslationModePanel, {})
4191
- ] }),
4192
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4193
- /* @__PURE__ */ jsx(Label, { children: "Content Release" }),
4194
- /* @__PURE__ */ jsx(ReleaseSelectionPanel, {})
4195
- ] }),
4196
- /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4197
- /* @__PURE__ */ jsx(Label, { children: "Settings" }),
4198
- /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
4199
- ] }),
4200
- /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
4201
- ] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
4202
- const { overallStatus, translationJobs, translationMode, dispatch, document } = useTranslationContext(), headline = useMemo(() => overallStatus === "completed" ? "Translation completed" : overallStatus === "error" ? "Translation failed" : "Translation in progress", [overallStatus]), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), isTerminalState = overallStatus === "completed" || overallStatus === "error", handleReset = useCallback(() => {
4209
+ }, WriteSettingsPanel = () => /* @__PURE__ */ jsx(ReferenceConfigPanel, {}), TranslationConfigView = ({ setIsDialogOpen }) => {
4210
+ const [hasValidationErrors, setHasValidationErrors] = useState(!0), handleValidationChange = useCallback((shouldBlock) => {
4211
+ setHasValidationErrors(shouldBlock);
4212
+ }, []);
4213
+ return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
4214
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4215
+ /* @__PURE__ */ jsx(Label, { children: "Languages" }),
4216
+ /* @__PURE__ */ jsx(LanguageSelectionPanel, {})
4217
+ ] }),
4218
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4219
+ /* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
4220
+ /* @__PURE__ */ jsx(TranslationModePanel, {})
4221
+ ] }),
4222
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4223
+ /* @__PURE__ */ jsx(Label, { children: "Content Release" }),
4224
+ /* @__PURE__ */ jsx(ReleaseSelectionPanel, {})
4225
+ ] }),
4226
+ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4227
+ /* @__PURE__ */ jsx(Label, { children: "Settings" }),
4228
+ /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
4229
+ ] }),
4230
+ /* @__PURE__ */ jsx(ValidationPanel, { onValidationChange: handleValidationChange }),
4231
+ /* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen, hasValidationErrors })
4232
+ ] });
4233
+ }, TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
4234
+ const { overallStatus, translationJobs, dispatch, document } = useTranslationContext(), headline = useMemo(() => overallStatus === "completed" ? "Translation completed" : overallStatus === "error" ? "Translation failed" : "Translation in progress", [overallStatus]), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), isTerminalState = overallStatus === "completed" || overallStatus === "error", handleReset = useCallback(() => {
4203
4235
  forceCleanupActiveTranslation(document._id), dispatch({ type: "RESET_TRANSLATION_STATE" });
4204
- }, [dispatch, document._id]), openaiStats = useMemo(() => {
4205
- if (translationMode === "sanity-only") return null;
4206
- 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);
4207
- return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
4208
- }, [jobs, translationMode]);
4236
+ }, [dispatch, document._id]);
4209
4237
  return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
4210
4238
  /* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
4211
4239
  /* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
@@ -4226,12 +4254,6 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
4226
4254
  job._id?.slice(-6) || "unknown"
4227
4255
  ] }),
4228
4256
  !["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
4229
- job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
4230
- job.tokensUsed.toLocaleString(),
4231
- " tokens (~$",
4232
- job.estimatedCost?.toFixed(4),
4233
- ")"
4234
- ] }) : null,
4235
4257
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
4236
4258
  "Error: ",
4237
4259
  job.errorMessage
@@ -4253,22 +4275,6 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
4253
4275
  ] })
4254
4276
  ] }) }) }, job._id)) }) }) })
4255
4277
  ] }),
4256
- openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
4257
- /* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
4258
- /* @__PURE__ */ jsxs(Inline, { space: 4, children: [
4259
- /* @__PURE__ */ jsxs(Text, { size: 1, children: [
4260
- "Total Tokens: ",
4261
- /* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
4262
- ] }),
4263
- /* @__PURE__ */ jsxs(Text, { size: 1, children: [
4264
- "Total Cost: ",
4265
- /* @__PURE__ */ jsxs("strong", { children: [
4266
- "$",
4267
- openaiStats.totalCost.toFixed(4)
4268
- ] })
4269
- ] })
4270
- ] })
4271
- ] }) }),
4272
4278
  isTerminalState && /* @__PURE__ */ jsx(Flex, { justify: "flex-end", children: /* @__PURE__ */ jsx(Button, { mode: "ghost", tone: "primary", text: "Translate More", icon: ResetIcon, onClick: handleReset }) })
4273
4279
  ] });
4274
4280
  }, ProgressBar = ({ progress, max = 100 }) => {