@braze/sanity-plugin-ai-translation 0.2.87 → 0.3.0
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/README.md +47 -14
- package/dist/_chunks-es/retry.js +142 -0
- package/dist/_chunks-es/retry.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +140 -401
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +87 -0
- package/dist/server.js +144 -0
- package/dist/server.js.map +1 -0
- package/package.json +23 -14
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getPublishedId, pathToString,
|
|
1
|
+
import { useClient, getPublishedId, pathToString, useWorkspace, validateDocument, defineType, defineField, definePlugin } from "sanity";
|
|
2
2
|
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { WarningOutlineIcon, ErrorOutlineIcon, CheckmarkIcon, SyncIcon, PanelRightIcon, SearchIcon, CircleIcon, ArrowRightIcon, ResetIcon, TranslateIcon } from "@sanity/icons";
|
|
4
|
-
import { createContext, useReducer, useRef,
|
|
4
|
+
import { createContext, useEffect, useReducer, useRef, useContext, useMemo, useState, useCallback } from "react";
|
|
5
|
+
import { devLog, retryWithBackoff, isNetworkError, isSanityMutationError } from "./_chunks-es/retry.js";
|
|
5
6
|
import defaults from "lodash.defaults";
|
|
6
7
|
import { Card, Stack, Flex, Text, Box, Heading, Button, Spinner, Badge, Inline, Radio, Checkbox, Grid, TextInput, Select, Label, Code, Tooltip } from "@sanity/ui";
|
|
7
8
|
import { IntentLink } from "sanity/router";
|
|
@@ -11,59 +12,55 @@ import { uuid } from "@sanity/uuid";
|
|
|
11
12
|
import cloneDeep from "lodash.clonedeep";
|
|
12
13
|
import get from "lodash.get";
|
|
13
14
|
import set from "lodash.set";
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
15
|
+
const DEFAULT_TRANSLATION_FUNCTION_NAME = "ai-translation", DEFAULT_MODEL = "gpt-5", TRANSLATION_FUNCTION_TIMEOUT_MS = 9e5, isOpenAIConfigured = (openaiConfig) => !!(openaiConfig?.enabled && typeof openaiConfig.stackId == "string" && openaiConfig.stackId.trim() !== ""), supportsFunctionInvoke = (client) => typeof client.functions?.invoke == "function", toNumber = (value) => typeof value == "number" && Number.isFinite(value) ? value : 0, parseResult = (result, functionName) => {
|
|
16
|
+
const value = result ?? {};
|
|
17
|
+
if (typeof value.content != "string")
|
|
18
|
+
throw new Error(`OpenAI translation function "${functionName}" returned no translated content`);
|
|
19
|
+
const tokensUsed = value.tokensUsed ?? {};
|
|
20
|
+
return {
|
|
21
|
+
content: value.content,
|
|
22
|
+
tokensUsed: {
|
|
23
|
+
prompt: toNumber(tokensUsed.prompt),
|
|
24
|
+
completion: toNumber(tokensUsed.completion),
|
|
25
|
+
reasoning: toNumber(tokensUsed.reasoning),
|
|
26
|
+
total: toNumber(tokensUsed.total),
|
|
27
|
+
cached: toNumber(tokensUsed.cached)
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}, invokeTranslationFunction = async ({
|
|
31
|
+
client,
|
|
32
|
+
openaiConfig,
|
|
33
|
+
messages
|
|
34
|
+
}) => {
|
|
35
|
+
if (!isOpenAIConfigured(openaiConfig))
|
|
36
|
+
throw new Error("OpenAI mode requires openai.enabled and openai.stackId");
|
|
37
|
+
if (!supportsFunctionInvoke(client))
|
|
38
|
+
throw new Error("OpenAI mode requires @sanity/client 8.1 or newer in Studio to invoke the translation function");
|
|
39
|
+
const functionName = openaiConfig.functionName || DEFAULT_TRANSLATION_FUNCTION_NAME, payload = {
|
|
40
|
+
model: openaiConfig.model || DEFAULT_MODEL,
|
|
41
|
+
messages,
|
|
42
|
+
reasoningEffort: openaiConfig.reasoningEffort
|
|
43
|
+
};
|
|
44
|
+
devLog.debug("Invoking OpenAI translation function", {
|
|
45
|
+
functionName,
|
|
46
|
+
stackId: openaiConfig.stackId,
|
|
47
|
+
model: payload.model,
|
|
48
|
+
reasoningEffort: payload.reasoningEffort,
|
|
49
|
+
messages: messages.map((m) => ({ role: m.role, contentLength: m.content.length }))
|
|
50
|
+
});
|
|
51
|
+
let result;
|
|
40
52
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
if (!response.ok)
|
|
48
|
-
return {
|
|
49
|
-
valid: !1,
|
|
50
|
-
error: (await response.json()).error?.message || "Invalid API key"
|
|
51
|
-
};
|
|
52
|
-
const availableModels = (await response.json()).data.map((m) => m.id);
|
|
53
|
-
return availableModels.includes(model) ? {
|
|
54
|
-
valid: !0,
|
|
55
|
-
modelAvailable: availableModels
|
|
56
|
-
} : {
|
|
57
|
-
valid: !1,
|
|
58
|
-
error: `Model ${model} not available. Try: ${availableModels.slice(0, 5).join(", ")}`
|
|
59
|
-
};
|
|
53
|
+
result = await client.functions.invoke(
|
|
54
|
+
functionName,
|
|
55
|
+
{ event: { data: payload }, stackId: openaiConfig.stackId, timeout: TRANSLATION_FUNCTION_TIMEOUT_MS },
|
|
56
|
+
{ sync: !0 }
|
|
57
|
+
);
|
|
60
58
|
} catch (error) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
error: error instanceof Error ? error.message : "Network error"
|
|
64
|
-
};
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`OpenAI translation function "${functionName}" failed: ${message}`);
|
|
65
61
|
}
|
|
66
|
-
|
|
62
|
+
return parseResult(result, functionName);
|
|
63
|
+
}, undraftId = (id) => id.replace(/^drafts\./, ""), draftId = (id) => `drafts.${undraftId(id)}`, activeTranslations = /* @__PURE__ */ new Map(), registerActiveTranslation = (documentId, releaseId) => {
|
|
67
64
|
const entry = {
|
|
68
65
|
state: {
|
|
69
66
|
documentId,
|
|
@@ -102,7 +99,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
102
99
|
try {
|
|
103
100
|
subscriber(entry.state);
|
|
104
101
|
} catch (error) {
|
|
105
|
-
|
|
102
|
+
devLog.error("Error in active translation subscriber", error);
|
|
106
103
|
}
|
|
107
104
|
}, forceCleanupActiveTranslation = (documentId) => {
|
|
108
105
|
activeTranslations.delete(documentId);
|
|
@@ -253,7 +250,14 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
253
250
|
supportedLanguages: config.supportedLanguages?.map((l) => l.id),
|
|
254
251
|
component: "TranslationContextProvider"
|
|
255
252
|
});
|
|
256
|
-
const defaultReleaseTitle = generateDefaultReleaseTitle(document),
|
|
253
|
+
const defaultReleaseTitle = generateDefaultReleaseTitle(document), client = useClient({ apiVersion: "vX" }), openaiConfigured = isOpenAIConfigured(config?.openai), openaiAvailable = openaiConfigured && supportsFunctionInvoke(client);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
openaiConfigured && !openaiAvailable && devLog.warn("OpenAI mode is configured but the Studio client cannot invoke functions", {
|
|
256
|
+
requires: "@sanity/client 8.1 or newer",
|
|
257
|
+
component: "TranslationContextProvider"
|
|
258
|
+
});
|
|
259
|
+
}, [openaiConfigured, openaiAvailable]);
|
|
260
|
+
const activeTranslation = getActiveTranslation(document._id), initialContext = {
|
|
257
261
|
document,
|
|
258
262
|
config,
|
|
259
263
|
// If there's an active translation, restore its state
|
|
@@ -266,8 +270,8 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
266
270
|
selectedReferenceIds: /* @__PURE__ */ new Set(),
|
|
267
271
|
// Restore jobs from active translation if available
|
|
268
272
|
translationJobs: activeTranslation?.translationJobs ?? /* @__PURE__ */ new Map(),
|
|
269
|
-
translationMode: config
|
|
270
|
-
openaiValidated:
|
|
273
|
+
translationMode: openaiAvailable && config.openai?.defaultMode || "sanity-only",
|
|
274
|
+
openaiValidated: openaiAvailable,
|
|
271
275
|
releaseConfig: {
|
|
272
276
|
mode: "new",
|
|
273
277
|
newReleaseTitle: defaultReleaseTitle
|
|
@@ -302,30 +306,6 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
302
306
|
unsubscribe();
|
|
303
307
|
};
|
|
304
308
|
}, [document._id]), useEffect(() => {
|
|
305
|
-
(async () => {
|
|
306
|
-
if (config?.openai?.enabled && config?.openai?.apiKey)
|
|
307
|
-
try {
|
|
308
|
-
const validation = await validateOpenAIApiKey(config.openai.apiKey, config.openai.model || "gpt-5");
|
|
309
|
-
dispatch({
|
|
310
|
-
type: "SET_OPENAI_VALIDATED",
|
|
311
|
-
payload: validation.valid
|
|
312
|
-
}), validation.valid ? devLog.info("OpenAI API key validated successfully", {
|
|
313
|
-
model: config.openai.model || "gpt-5",
|
|
314
|
-
component: "TranslationContextProvider"
|
|
315
|
-
}) : devLog.error("OpenAI API key validation failed", {
|
|
316
|
-
error: validation.error,
|
|
317
|
-
component: "TranslationContextProvider"
|
|
318
|
-
});
|
|
319
|
-
} catch (error) {
|
|
320
|
-
devLog.error("OpenAI validation error", error instanceof Error ? error : new Error(String(error)), {
|
|
321
|
-
component: "TranslationContextProvider"
|
|
322
|
-
}), dispatch({
|
|
323
|
-
type: "SET_OPENAI_VALIDATED",
|
|
324
|
-
payload: !1
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
})();
|
|
328
|
-
}, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), useEffect(() => {
|
|
329
309
|
const currentRefsKey = context.references.map((ref) => ref._id).sort().join(",");
|
|
330
310
|
if (currentRefsKey !== lastProcessedRefsRef.current) {
|
|
331
311
|
if (context.references.length > 0) {
|
|
@@ -361,7 +341,12 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
361
341
|
"useTranslationContext must be used within a TranslationContextProvider with document and config props"
|
|
362
342
|
);
|
|
363
343
|
return context;
|
|
364
|
-
}
|
|
344
|
+
};
|
|
345
|
+
async function fetchSourceDocument(client, documentId, perspective) {
|
|
346
|
+
const publishedId = undraftId(documentId), [draft, published] = await client.getDocuments([draftId(publishedId), publishedId]);
|
|
347
|
+
return perspective === "published" ? published : draft ?? published;
|
|
348
|
+
}
|
|
349
|
+
const checkSourceDrafts = async ({
|
|
365
350
|
client,
|
|
366
351
|
referenceIds
|
|
367
352
|
}) => {
|
|
@@ -449,10 +434,10 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
449
434
|
return devLog.debug("No stale drafts found", { sourceDocumentId }), [];
|
|
450
435
|
const draftMap = new Map(drafts.map((d) => [d._id, d])), results = [];
|
|
451
436
|
for (const entry of deduped) {
|
|
452
|
-
const
|
|
437
|
+
const draftId2 = `drafts.${entry.targetId}`, draft = draftMap.get(draftId2);
|
|
453
438
|
draft && results.push({
|
|
454
439
|
targetId: entry.targetId,
|
|
455
|
-
draftId,
|
|
440
|
+
draftId: draftId2,
|
|
456
441
|
_type: draft._type,
|
|
457
442
|
title: draft.title || draft.name,
|
|
458
443
|
sourceDocumentId: entry.sourceDocumentId,
|
|
@@ -498,7 +483,7 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
498
483
|
translationHistory: []
|
|
499
484
|
});
|
|
500
485
|
} catch (error) {
|
|
501
|
-
throw
|
|
486
|
+
throw devLog.error("Error creating translation metadata", error, { documentId: sourceDocument._id }), error;
|
|
502
487
|
}
|
|
503
488
|
}, checkDocumentConflicts = async ({
|
|
504
489
|
client,
|
|
@@ -587,115 +572,6 @@ const isLoggingEnabled = () => process.env.NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLAT
|
|
|
587
572
|
}
|
|
588
573
|
return allConflicts;
|
|
589
574
|
};
|
|
590
|
-
function isNetworkError(error) {
|
|
591
|
-
if (!error) return !1;
|
|
592
|
-
if (error instanceof TypeError) {
|
|
593
|
-
const message = error.message.toLowerCase();
|
|
594
|
-
return message.includes("fetch") || message.includes("network") || message.includes("connection") || message.includes("failed to fetch") || message.includes("networkerror");
|
|
595
|
-
}
|
|
596
|
-
if (error && typeof error == "object" && "code" in error) {
|
|
597
|
-
const code = error.code;
|
|
598
|
-
return ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH"].includes(code);
|
|
599
|
-
}
|
|
600
|
-
if (error && typeof error == "object" && "response" in error) {
|
|
601
|
-
const response = error.response;
|
|
602
|
-
if (response?.status)
|
|
603
|
-
return response.status >= 500 || response.status === 429;
|
|
604
|
-
}
|
|
605
|
-
if (error instanceof Error) {
|
|
606
|
-
const message = error.message.toLowerCase();
|
|
607
|
-
if (/\b(50[0-9]|429)\b/.test(message) || message.includes("rate limit"))
|
|
608
|
-
return !0;
|
|
609
|
-
}
|
|
610
|
-
return !1;
|
|
611
|
-
}
|
|
612
|
-
function isSanityMutationError(error) {
|
|
613
|
-
if (!error) return !1;
|
|
614
|
-
if (error instanceof Error) {
|
|
615
|
-
const message = error.message.toLowerCase();
|
|
616
|
-
return [
|
|
617
|
-
"validation",
|
|
618
|
-
"schema",
|
|
619
|
-
"invalid document",
|
|
620
|
-
"invalid field",
|
|
621
|
-
"type mismatch",
|
|
622
|
-
"reference",
|
|
623
|
-
"patch",
|
|
624
|
-
"mutation",
|
|
625
|
-
"document structure",
|
|
626
|
-
"required field",
|
|
627
|
-
"field type",
|
|
628
|
-
"sanity"
|
|
629
|
-
].some((pattern) => message.includes(pattern));
|
|
630
|
-
}
|
|
631
|
-
if (error && typeof error == "object") {
|
|
632
|
-
const errorObj = error;
|
|
633
|
-
if (("statusCode" in errorObj || "response" in errorObj || "details" in errorObj) && "statusCode" in errorObj) {
|
|
634
|
-
const statusCode = errorObj.statusCode;
|
|
635
|
-
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429)
|
|
636
|
-
return !0;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
return !1;
|
|
640
|
-
}
|
|
641
|
-
const DEFAULT_RETRY_OPTIONS = {
|
|
642
|
-
maxAttempts: 2,
|
|
643
|
-
initialDelayMs: 0,
|
|
644
|
-
maxDelayMs: 5e3,
|
|
645
|
-
backoffMultiplier: 2,
|
|
646
|
-
retryableErrors: isNetworkError,
|
|
647
|
-
logRetries: !0
|
|
648
|
-
};
|
|
649
|
-
function calculateDelay(attempt, options) {
|
|
650
|
-
const delay = options.initialDelayMs * options.backoffMultiplier ** attempt;
|
|
651
|
-
return Math.min(delay, options.maxDelayMs);
|
|
652
|
-
}
|
|
653
|
-
function sleep(ms) {
|
|
654
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
655
|
-
}
|
|
656
|
-
async function retryWithBackoff(operation, options = {}) {
|
|
657
|
-
const opts = {
|
|
658
|
-
...DEFAULT_RETRY_OPTIONS,
|
|
659
|
-
...options,
|
|
660
|
-
retryableErrors: options.retryableErrors || DEFAULT_RETRY_OPTIONS.retryableErrors
|
|
661
|
-
};
|
|
662
|
-
let lastError;
|
|
663
|
-
const maxAttempts = opts.maxAttempts + 1;
|
|
664
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++)
|
|
665
|
-
try {
|
|
666
|
-
const result = await operation();
|
|
667
|
-
return attempt > 1 && opts.logRetries && devLog.info(`Operation succeeded after ${attempt} attempts`, {
|
|
668
|
-
...opts.context,
|
|
669
|
-
attempt,
|
|
670
|
-
totalAttempts: attempt
|
|
671
|
-
}), result;
|
|
672
|
-
} catch (error) {
|
|
673
|
-
if (lastError = error, !opts.retryableErrors(error))
|
|
674
|
-
throw opts.logRetries && devLog.warn("Error is not retryable, aborting retries", {
|
|
675
|
-
...opts.context,
|
|
676
|
-
attempt,
|
|
677
|
-
error: error instanceof Error ? error.message : String(error)
|
|
678
|
-
}), error;
|
|
679
|
-
if (attempt >= maxAttempts)
|
|
680
|
-
throw opts.logRetries && devLog.error(
|
|
681
|
-
`Operation failed after ${maxAttempts} attempts`,
|
|
682
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
683
|
-
{
|
|
684
|
-
...opts.context,
|
|
685
|
-
totalAttempts: maxAttempts
|
|
686
|
-
}
|
|
687
|
-
), error;
|
|
688
|
-
const delay = calculateDelay(attempt - 1, opts);
|
|
689
|
-
opts.logRetries && devLog.warn(`Operation failed, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`, {
|
|
690
|
-
...opts.context,
|
|
691
|
-
attempt,
|
|
692
|
-
totalAttempts: maxAttempts,
|
|
693
|
-
delayMs: delay,
|
|
694
|
-
error: error instanceof Error ? error.message : String(error)
|
|
695
|
-
}), delay > 0 && await sleep(delay);
|
|
696
|
-
}
|
|
697
|
-
throw lastError;
|
|
698
|
-
}
|
|
699
575
|
class Semaphore {
|
|
700
576
|
permits;
|
|
701
577
|
queue = [];
|
|
@@ -1662,127 +1538,7 @@ class TransformRegistry {
|
|
|
1662
1538
|
}), matches[0];
|
|
1663
1539
|
}
|
|
1664
1540
|
}
|
|
1665
|
-
const globalTransformRegistry = new TransformRegistry(),
|
|
1666
|
-
const model = request.model || "gpt-5", requestBody = {
|
|
1667
|
-
model: model === "gpt-5" ? "gpt-5.1" : model,
|
|
1668
|
-
// Map gpt-5 to gpt-5.1, use others as-is
|
|
1669
|
-
input: request.messages
|
|
1670
|
-
// Uses 'input' instead of 'messages'
|
|
1671
|
-
};
|
|
1672
|
-
request.reasoningEffort && (requestBody.reasoning = {
|
|
1673
|
-
effort: request.reasoningEffort,
|
|
1674
|
-
summary: "auto"
|
|
1675
|
-
// Enable automatic reasoning summaries
|
|
1676
|
-
}), devLog.debug("OpenAI GPT-5 Request", {
|
|
1677
|
-
model: requestBody.model,
|
|
1678
|
-
reasoningEffort: request.reasoningEffort,
|
|
1679
|
-
messages: request.messages.map((m) => ({
|
|
1680
|
-
role: m.role,
|
|
1681
|
-
contentLength: m.content.length,
|
|
1682
|
-
contentPreview: m.content.substring(0, 500) + (m.content.length > 500 ? "..." : "")
|
|
1683
|
-
}))
|
|
1684
|
-
}), devLog.debug("OpenAI GPT-5 Full Prompt", {
|
|
1685
|
-
messages: request.messages
|
|
1686
|
-
});
|
|
1687
|
-
const response = await retryWithBackoff(
|
|
1688
|
-
async () => {
|
|
1689
|
-
const fetchResponse = await fetch("https://api.openai.com/v1/responses", {
|
|
1690
|
-
method: "POST",
|
|
1691
|
-
headers: {
|
|
1692
|
-
"Content-Type": "application/json",
|
|
1693
|
-
Authorization: `Bearer ${apiKey}`
|
|
1694
|
-
},
|
|
1695
|
-
body: JSON.stringify(requestBody)
|
|
1696
|
-
});
|
|
1697
|
-
if (!fetchResponse.ok) {
|
|
1698
|
-
const errorMessage = `GPT-5 API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
|
|
1699
|
-
throw errorObj.response = { status: fetchResponse.status }, errorObj;
|
|
1700
|
-
}
|
|
1701
|
-
return fetchResponse;
|
|
1702
|
-
},
|
|
1703
|
-
{
|
|
1704
|
-
maxAttempts: 2,
|
|
1705
|
-
initialDelayMs: 0,
|
|
1706
|
-
retryableErrors: isNetworkError,
|
|
1707
|
-
context: { model: requestBody.model, endpoint: "responses" }
|
|
1708
|
-
}
|
|
1709
|
-
);
|
|
1710
|
-
if (!response.ok) {
|
|
1711
|
-
const error = await response.json();
|
|
1712
|
-
throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
|
|
1713
|
-
}
|
|
1714
|
-
const result = await response.json(), tokensUsed = {
|
|
1715
|
-
prompt: result.usage?.input_tokens || 0,
|
|
1716
|
-
completion: result.usage?.output_tokens || 0,
|
|
1717
|
-
reasoning: result.usage?.output_tokens_details?.reasoning_tokens || 0,
|
|
1718
|
-
total: result.usage?.total_tokens || 0,
|
|
1719
|
-
cached: result.usage?.input_tokens_details?.cached_tokens || 0
|
|
1720
|
-
};
|
|
1721
|
-
let content = "";
|
|
1722
|
-
if (result.output && Array.isArray(result.output)) {
|
|
1723
|
-
const messageItem = result.output.find((item) => item.type === "message");
|
|
1724
|
-
messageItem?.content && Array.isArray(messageItem.content) && (content = messageItem.content.find((item) => item.type === "output_text")?.text || "");
|
|
1725
|
-
}
|
|
1726
|
-
return content || (content = result.output_text || result.text || ""), {
|
|
1727
|
-
content,
|
|
1728
|
-
tokensUsed
|
|
1729
|
-
};
|
|
1730
|
-
}, callChatCompletionsAPI = async (apiKey, request) => {
|
|
1731
|
-
const model = request.model || "gpt-4o", requestBody = {
|
|
1732
|
-
model,
|
|
1733
|
-
messages: request.messages,
|
|
1734
|
-
temperature: request.temperature ?? 0.3
|
|
1735
|
-
};
|
|
1736
|
-
devLog.debug("OpenAI Chat Completions Request", {
|
|
1737
|
-
model,
|
|
1738
|
-
temperature: request.temperature ?? 0.3,
|
|
1739
|
-
messages: request.messages.map((m) => ({
|
|
1740
|
-
role: m.role,
|
|
1741
|
-
contentLength: m.content.length,
|
|
1742
|
-
contentPreview: m.content.substring(0, 500) + (m.content.length > 500 ? "..." : "")
|
|
1743
|
-
}))
|
|
1744
|
-
}), devLog.debug("OpenAI Chat Completions Full Prompt", {
|
|
1745
|
-
messages: request.messages
|
|
1746
|
-
});
|
|
1747
|
-
const response = await retryWithBackoff(
|
|
1748
|
-
async () => {
|
|
1749
|
-
const fetchResponse = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1750
|
-
method: "POST",
|
|
1751
|
-
headers: {
|
|
1752
|
-
"Content-Type": "application/json",
|
|
1753
|
-
Authorization: `Bearer ${apiKey}`
|
|
1754
|
-
},
|
|
1755
|
-
body: JSON.stringify(requestBody)
|
|
1756
|
-
});
|
|
1757
|
-
if (!fetchResponse.ok) {
|
|
1758
|
-
const errorMessage = `OpenAI API error: ${(await fetchResponse.json()).error?.message || "Unknown error"}`, errorObj = new Error(errorMessage);
|
|
1759
|
-
throw errorObj.response = { status: fetchResponse.status }, errorObj;
|
|
1760
|
-
}
|
|
1761
|
-
return fetchResponse;
|
|
1762
|
-
},
|
|
1763
|
-
{
|
|
1764
|
-
maxAttempts: 2,
|
|
1765
|
-
initialDelayMs: 0,
|
|
1766
|
-
retryableErrors: isNetworkError,
|
|
1767
|
-
context: { model, endpoint: "chat/completions" }
|
|
1768
|
-
}
|
|
1769
|
-
);
|
|
1770
|
-
if (!response.ok) {
|
|
1771
|
-
const error = await response.json();
|
|
1772
|
-
throw new Error(`OpenAI API error: ${error.error?.message || "Unknown error"}`);
|
|
1773
|
-
}
|
|
1774
|
-
const result = await response.json(), usage = result.usage || {}, tokensUsed = {
|
|
1775
|
-
prompt: usage.prompt_tokens || 0,
|
|
1776
|
-
completion: usage.completion_tokens || 0,
|
|
1777
|
-
reasoning: usage.reasoning_tokens || 0,
|
|
1778
|
-
total: usage.total_tokens || 0,
|
|
1779
|
-
cached: usage.prompt_tokens_details?.cached_tokens || 0
|
|
1780
|
-
};
|
|
1781
|
-
return {
|
|
1782
|
-
content: result.choices[0].message.content || "",
|
|
1783
|
-
tokensUsed
|
|
1784
|
-
};
|
|
1785
|
-
}, deserializeDocument = (originalDoc, reviewedText) => {
|
|
1541
|
+
const globalTransformRegistry = new TransformRegistry(), deserializeDocument = (originalDoc, reviewedText) => {
|
|
1786
1542
|
devLog.debug("Raw GPT-5 response received", {
|
|
1787
1543
|
length: reviewedText.length,
|
|
1788
1544
|
preview: reviewedText.substring(0, 1e3),
|
|
@@ -2382,6 +2138,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
|
|
|
2382
2138
|
}
|
|
2383
2139
|
}, getOpenAITranslation = async ({
|
|
2384
2140
|
dispatch,
|
|
2141
|
+
client,
|
|
2385
2142
|
sourceDocument,
|
|
2386
2143
|
languageFieldName,
|
|
2387
2144
|
translationPrompts,
|
|
@@ -2410,16 +2167,16 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
|
|
|
2410
2167
|
targetLanguage,
|
|
2411
2168
|
translationPrompts,
|
|
2412
2169
|
errorContext
|
|
2413
|
-
}), response = await
|
|
2170
|
+
}), response = await invokeTranslationFunction({
|
|
2171
|
+
client,
|
|
2172
|
+
openaiConfig,
|
|
2414
2173
|
messages: [
|
|
2415
2174
|
{
|
|
2416
2175
|
role: "developer",
|
|
2417
2176
|
content: "You are a translation quality reviewer. Review and refine translations while preserving structure."
|
|
2418
2177
|
},
|
|
2419
2178
|
{ role: "user", content: prompt }
|
|
2420
|
-
]
|
|
2421
|
-
model: openaiConfig.model,
|
|
2422
|
-
reasoningEffort: openaiConfig.reasoningEffort
|
|
2179
|
+
]
|
|
2423
2180
|
});
|
|
2424
2181
|
openaiResponseContent = response.content, devLog.info("OpenAI translation response received", {
|
|
2425
2182
|
...logContext,
|
|
@@ -2727,25 +2484,18 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2727
2484
|
languageFieldName
|
|
2728
2485
|
});
|
|
2729
2486
|
const documentAfterUnset = unsetDocumentFields(sourceDocument, safeUnsetFields), { preserveFieldPatterns } = normalizeUnsetFields(safeUnsetFields);
|
|
2730
|
-
let
|
|
2487
|
+
let preservedValues = {};
|
|
2731
2488
|
if (preserveFieldPatterns.length > 0 && existingTranslationId)
|
|
2732
2489
|
try {
|
|
2733
2490
|
const publishedTarget = await client.getDocument(existingTranslationId);
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
preservedFields: Object.keys(preserveValues)
|
|
2743
|
-
}));
|
|
2744
|
-
} else
|
|
2745
|
-
devLog.debug("Published target not found for preserveExisting fields", {
|
|
2746
|
-
documentId: sourceDocument._id,
|
|
2747
|
-
existingTranslationId
|
|
2748
|
-
});
|
|
2491
|
+
publishedTarget ? (preservedValues = filterExcludedFields(publishedTarget, preserveFieldPatterns).excludedValues, Object.keys(preservedValues).length > 0 && devLog.info("Preserving fields from published target during re-translation", {
|
|
2492
|
+
documentId: sourceDocument._id,
|
|
2493
|
+
existingTranslationId,
|
|
2494
|
+
preservedFields: Object.keys(preservedValues)
|
|
2495
|
+
})) : devLog.debug("Published target not found for preserveExisting fields", {
|
|
2496
|
+
documentId: sourceDocument._id,
|
|
2497
|
+
existingTranslationId
|
|
2498
|
+
});
|
|
2749
2499
|
} catch (error) {
|
|
2750
2500
|
devLog.warn("Failed to fetch published target for preserveExisting fields", {
|
|
2751
2501
|
documentId: sourceDocument._id,
|
|
@@ -2764,10 +2514,10 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2764
2514
|
documentType: sourceDocument._type,
|
|
2765
2515
|
excludeFields: safeExcludeFields,
|
|
2766
2516
|
excludeFieldsLength: safeExcludeFields?.length ?? 0,
|
|
2767
|
-
sourceHasTitle: "title" in
|
|
2768
|
-
sourceTitleValue:
|
|
2517
|
+
sourceHasTitle: "title" in documentAfterUnset,
|
|
2518
|
+
sourceTitleValue: documentAfterUnset.title
|
|
2769
2519
|
});
|
|
2770
|
-
const { filteredDocument, excludedValues } = filterExcludedFields(
|
|
2520
|
+
const { filteredDocument, excludedValues } = filterExcludedFields(documentAfterUnset, safeExcludeFields);
|
|
2771
2521
|
devLog.info("After filtering excluded fields", {
|
|
2772
2522
|
documentId: sourceDocument._id,
|
|
2773
2523
|
filteredHasTitle: "title" in filteredDocument,
|
|
@@ -2830,7 +2580,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2830
2580
|
}
|
|
2831
2581
|
}
|
|
2832
2582
|
case "openai-only": {
|
|
2833
|
-
if (!openaiConfig
|
|
2583
|
+
if (!isOpenAIConfigured(openaiConfig))
|
|
2834
2584
|
throw new Error("OpenAI is not configured but openai-only mode was selected");
|
|
2835
2585
|
setJobStatus(
|
|
2836
2586
|
dispatch,
|
|
@@ -2842,6 +2592,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2842
2592
|
);
|
|
2843
2593
|
const result = await getOpenAITranslation({
|
|
2844
2594
|
dispatch,
|
|
2595
|
+
client,
|
|
2845
2596
|
sourceDocument: documentToTranslate,
|
|
2846
2597
|
targetLanguage,
|
|
2847
2598
|
languageFieldName,
|
|
@@ -2861,32 +2612,39 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2861
2612
|
};
|
|
2862
2613
|
if (translatedDoc = (await performTranslation()).document, !translatedDoc)
|
|
2863
2614
|
return null;
|
|
2864
|
-
let transformedResult;
|
|
2865
|
-
Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
|
|
2866
|
-
documentId: sourceDocument._id,
|
|
2867
|
-
documentType: sourceDocument._type
|
|
2868
|
-
}), transformedResult = restoreTransforms({
|
|
2869
|
-
translatedDocument: translatedDoc,
|
|
2870
|
-
originalStructure,
|
|
2871
|
-
registry: globalTransformRegistry,
|
|
2872
|
-
schemaType: sourceDocument._type
|
|
2873
|
-
})) : transformedResult = translatedDoc;
|
|
2874
2615
|
const excludedValuesToRestore = { ...excludedValues };
|
|
2875
|
-
delete excludedValuesToRestore[languageFieldName]
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2616
|
+
delete excludedValuesToRestore[languageFieldName];
|
|
2617
|
+
const finalizeDocument = (translated) => {
|
|
2618
|
+
let transformedResult;
|
|
2619
|
+
Object.keys(originalStructure).length > 0 ? (devLog.debug("Restoring field transforms after translation", {
|
|
2620
|
+
documentId: sourceDocument._id,
|
|
2621
|
+
documentType: sourceDocument._type
|
|
2622
|
+
}), transformedResult = restoreTransforms({
|
|
2623
|
+
translatedDocument: translated,
|
|
2624
|
+
originalStructure,
|
|
2625
|
+
registry: globalTransformRegistry,
|
|
2626
|
+
schemaType: sourceDocument._type
|
|
2627
|
+
})) : transformedResult = translated;
|
|
2628
|
+
const withoutUnsetFields = unsetDocumentFields(transformedResult, safeUnsetFields);
|
|
2629
|
+
devLog.info("Before restoring excluded fields", {
|
|
2630
|
+
documentId: sourceDocument._id,
|
|
2631
|
+
excludedFieldCount: Object.keys(excludedValues).length,
|
|
2632
|
+
excludedValuesKeys: Object.keys(excludedValues),
|
|
2633
|
+
languageFieldExcluded: languageFieldName in excludedValues,
|
|
2634
|
+
transformedResultHasTitle: "title" in withoutUnsetFields,
|
|
2635
|
+
transformedResultTitle: withoutUnsetFields.title
|
|
2636
|
+
});
|
|
2637
|
+
const finalDoc2 = restoreExcludedFields(
|
|
2638
|
+
restoreExcludedFields(withoutUnsetFields, excludedValuesToRestore),
|
|
2639
|
+
preservedValues
|
|
2640
|
+
);
|
|
2641
|
+
return devLog.info("After restoring excluded fields", {
|
|
2642
|
+
documentId: sourceDocument._id,
|
|
2643
|
+
finalDocHasTitle: "title" in finalDoc2,
|
|
2644
|
+
finalDocTitle: finalDoc2.title,
|
|
2645
|
+
preservedFields: Object.keys(preservedValues)
|
|
2646
|
+
}), finalDoc2;
|
|
2647
|
+
}, finalDoc = finalizeDocument(translatedDoc), targetDocumentPublishedId = existingTranslationId || uuid(), targetDocumentVersionId = `versions.${releaseId}.${targetDocumentPublishedId}`, newDoc = {
|
|
2890
2648
|
...finalDoc,
|
|
2891
2649
|
_id: targetDocumentVersionId
|
|
2892
2650
|
};
|
|
@@ -2925,15 +2683,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
2925
2683
|
const errorContext = `Previous translation attempt failed with Sanity validation error: ${previousError.message}. Please ensure the translated document structure exactly matches the source document structure.`;
|
|
2926
2684
|
if (translatedDoc = (await performTranslation(errorContext)).document, !translatedDoc)
|
|
2927
2685
|
throw new Error("Translation retry failed: no document returned");
|
|
2928
|
-
let retryTransformedResult;
|
|
2929
|
-
Object.keys(originalStructure).length > 0 ? retryTransformedResult = restoreTransforms({
|
|
2930
|
-
translatedDocument: translatedDoc,
|
|
2931
|
-
originalStructure,
|
|
2932
|
-
registry: globalTransformRegistry,
|
|
2933
|
-
schemaType: sourceDocument._type
|
|
2934
|
-
}) : retryTransformedResult = translatedDoc;
|
|
2935
2686
|
const retryNewDoc = {
|
|
2936
|
-
...
|
|
2687
|
+
...finalizeDocument(translatedDoc),
|
|
2937
2688
|
_id: targetDocumentVersionId
|
|
2938
2689
|
};
|
|
2939
2690
|
return await retryWithBackoff(
|
|
@@ -3224,9 +2975,11 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3224
2975
|
}
|
|
3225
2976
|
try {
|
|
3226
2977
|
await translationSemaphore.withPermit(async () => {
|
|
3227
|
-
const document = await
|
|
3228
|
-
|
|
3229
|
-
|
|
2978
|
+
const document = await fetchSourceDocument(
|
|
2979
|
+
client,
|
|
2980
|
+
documentId,
|
|
2981
|
+
sourcePerspective ?? "drafts"
|
|
2982
|
+
);
|
|
3230
2983
|
if (document || devLog.error("\u274C SOURCE DOCUMENT NOT FOUND", { documentId }), document && releaseId) {
|
|
3231
2984
|
const versionedTargetDocumentId = await translateDocument({
|
|
3232
2985
|
dispatch,
|
|
@@ -3415,12 +3168,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3415
3168
|
hasRun.current || (hasRun.current = !0, (async () => {
|
|
3416
3169
|
onResult(documentId, { isValidating: !0, errors: [] });
|
|
3417
3170
|
try {
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
if (perspective === "published" ? doc = await client.fetch("*[_id == $publishedId][0]", { publishedId }) : doc = await client.fetch(
|
|
3421
|
-
"*[_id in [$draftId, $publishedId]] | order(_id desc) [0]",
|
|
3422
|
-
{ draftId, publishedId }
|
|
3423
|
-
), !doc) {
|
|
3171
|
+
const doc = await fetchSourceDocument(client, documentId, perspective);
|
|
3172
|
+
if (!doc) {
|
|
3424
3173
|
devLog.warn("Document not found for validation", { documentId, perspective }), onResult(documentId, { isValidating: !1, errors: [] });
|
|
3425
3174
|
return;
|
|
3426
3175
|
}
|
|
@@ -3436,14 +3185,13 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3436
3185
|
component: "DocumentValidator"
|
|
3437
3186
|
}), onResult(documentId, { isValidating: !1, errors });
|
|
3438
3187
|
} catch (error) {
|
|
3439
|
-
devLog.error(
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
), onResult(documentId, { isValidating: !1, errors: [] });
|
|
3188
|
+
devLog.error("Failed to validate document", error instanceof Error ? error : new Error(String(error)), {
|
|
3189
|
+
documentId,
|
|
3190
|
+
component: "DocumentValidator"
|
|
3191
|
+
}), onResult(documentId, { isValidating: !1, errors: [] });
|
|
3444
3192
|
}
|
|
3445
3193
|
})());
|
|
3446
|
-
}, [documentId,
|
|
3194
|
+
}, [documentId, perspective, onResult, client, workspace]), null;
|
|
3447
3195
|
}, PreflightView = () => {
|
|
3448
3196
|
const {
|
|
3449
3197
|
document,
|
|
@@ -3605,9 +3353,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3605
3353
|
}, []), sourceRefDraftIds = useMemo(
|
|
3606
3354
|
() => new Set(sourceRefsWithDrafts.map((d) => d.publishedId)),
|
|
3607
3355
|
[sourceRefsWithDrafts]
|
|
3608
|
-
), isLoading = someStillValidating || isSourceDraftCheckLoading || isDraftCheckLoading || isCheckingConflicts, hasStaleDrafts = staleDrafts.length > 0, draftsDismissed = draftAction === "continue", canStartTranslation = !isLoading && !hasValidationErrors && !conflictContext && (!hasStaleDrafts || draftsDismissed),
|
|
3609
|
-
setConflictContext(null);
|
|
3610
|
-
}, []), handleReplaceConflict = useCallback(() => {
|
|
3356
|
+
), isLoading = someStillValidating || isSourceDraftCheckLoading || isDraftCheckLoading || isCheckingConflicts, hasStaleDrafts = staleDrafts.length > 0, draftsDismissed = draftAction === "continue", canStartTranslation = !isLoading && !hasValidationErrors && !conflictContext && (!hasStaleDrafts || draftsDismissed), handleReplaceConflict = useCallback(() => {
|
|
3611
3357
|
conflictContext && startTranslation(!0, conflictContext.releaseId);
|
|
3612
3358
|
}, [startTranslation, conflictContext]), handleSkipConflicts = useCallback(() => {
|
|
3613
3359
|
conflictContext && startTranslation(!1, conflictContext.releaseId, conflictContext.conflicts);
|
|
@@ -3617,7 +3363,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3617
3363
|
{
|
|
3618
3364
|
conflicts: conflictContext.conflicts,
|
|
3619
3365
|
totalTranslations,
|
|
3620
|
-
onCancel:
|
|
3366
|
+
onCancel: handleBack,
|
|
3621
3367
|
onReplace: handleReplaceConflict,
|
|
3622
3368
|
onSkipConflicts: handleSkipConflicts
|
|
3623
3369
|
}
|
|
@@ -3711,14 +3457,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
3711
3457
|
] }),
|
|
3712
3458
|
/* @__PURE__ */ jsx(DocumentOpenerButton, { documentId: draft.targetId, documentType: draft._type })
|
|
3713
3459
|
] }, draft.draftId)) }) }) }),
|
|
3714
|
-
/* @__PURE__ */ jsx(
|
|
3715
|
-
Button,
|
|
3716
|
-
{
|
|
3717
|
-
mode: "ghost",
|
|
3718
|
-
text: "Continue Anyway",
|
|
3719
|
-
onClick: () => setDraftAction("continue")
|
|
3720
|
-
}
|
|
3721
|
-
)
|
|
3460
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", text: "Continue Anyway", onClick: () => setDraftAction("continue") })
|
|
3722
3461
|
] })
|
|
3723
3462
|
] }) }),
|
|
3724
3463
|
/* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
|
|
@@ -4207,12 +3946,12 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
|
|
|
4207
3946
|
] }) });
|
|
4208
3947
|
}, TranslationModePanel = () => {
|
|
4209
3948
|
const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
|
|
4210
|
-
if (!config?.openai
|
|
3949
|
+
if (!isOpenAIConfigured(config?.openai))
|
|
4211
3950
|
return null;
|
|
4212
3951
|
if (!openaiValidated)
|
|
4213
3952
|
return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
4214
|
-
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI
|
|
4215
|
-
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "
|
|
3953
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Unavailable" }),
|
|
3954
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This Studio's Sanity client cannot invoke the translation function. OpenAI mode requires @sanity/client 8.1 or newer." })
|
|
4216
3955
|
] }) });
|
|
4217
3956
|
const handleModeChange = (mode) => {
|
|
4218
3957
|
dispatch({
|