Skip to content

AI Translation

Translation in the backend is managed directly via AI. We have integrated Netwrix´s OpenAI agents to source the translations.

We have three actions:

  • One action translates the whole document, rewriting even the translations that were already present
  • One action translates only missing fields, so only blank fields in the language(s) meant to be translated
  • The last action is associated to each field itself, but it is mostly limited by how much Sanity allows us to access

We are going to explain only the main DocumentTranslate.jsx action, because it encompasses all helpers that are used in the other two actions and it has the same overall structure as them, so understanding this one leads to understanding how the other two work.

Both document translation actions are included in the document action list in sanity.config.js

sanity.config.ts
document: {
actions: (prev, context) => {
// Algolia-specific types
const algoliaTypes = [
...
]
// For Algolia types: Replace publish/delete actions
if (algoliaTypes.includes(context.schemaType)) {
...
return [
CustomCreateRecordAlgolia,
CustomUnpublishAlgolia,
...remainingActions,
CustomDeleteRecordAlgolia,
DocumentTranslate,
DocumentTranslateMissing,
]
}
// For all other types: Just add translate actions
return [
...prev,
DocumentTranslate,
DocumentTranslateMissing,
]
},
}

So we add them to the existing document actions.

We cannot include them in the top document bar (where the Sanity AI’s default translate action was) because of limitations in accessing Sanity’s UI.

This is a very long file but it’s divided into sections and uses numerous helpers from other files to be more legible.

First thing the action does is opening a dialog that will prompt the user to choose which language they want to use as a base language and into which languages they want to translate the text.

This is done through the dialog that Sanity offers for its document actions, so when we make the return statement for our action, we need to include that dialog component in there.

utils/aiAssist/DocumentTranslate.jsx
return {
label: 'Translate Document',
onHandle,
dialog: openDialog ? {type: 'custom', component: <DialogComponent />} : null,
}

This dialog will receive our translateDocument function as a prop and it can return to it the chosen languages:

utils/aiAssist/DocumentTranslate.jsx
const handleConfirm = (languages) => {
translateDocument(languages)
}
const DialogComponent = () => (
<TranslationDialog onClose={() => setOpenDialog(false)} onConfirm={handleConfirm} />
)
utils/aiAssist/TranslationDialog.jsx
export const TranslationDialog = ({onClose, onConfirm}) => {
const [fromLang, setFromLang] = useState('en')
const [toLangs, setToLangs] = useState(languages.filter(lang => lang.id !== 'en').map(lang => lang.id))
const allLangs = languages.map(lang => ({code: lang.id, label: lang.title}))
const toggleLang = (code) => {
setToLangs((prev) => (prev.includes(code) ? prev.filter((l) => l !== code) : [...prev, code]))
}
return (
...
)
}

localization dialog

So we saw in the return statement above when we were setting up our document action, that we have an onHandle function. This is what Sanity executes actually when executing the function, so it would go to:

utils/aiAssist/DocumentTranslate.jsx
const onHandle = () => {
setOpenDialog(true)
}

The dialog opens, we select languages and then we go into our translateDocument proper, where the translation actually starts.

First thing we do is closing the dialog and setting our loading state to true.

utils/aiAssist/DocumentTranslate.jsx
const translateDocument = async ({fromLang, toLangs}) => {
setOpenDialog(false)
setLoading(true)
devLog('🟢 Translating from', fromLang, 'to', toLangs)
...
}

Then we extract our translatable fields, and for that we use our first helper function. This will identify all fields that, by their structure, are deemed as translatable, which would be fields that contain an object with language keys inside, like:

'title': {
'en': 'This is a title',
'es': 'Esto es un título'
...
}

This is a recursive method that will extract translatable fields even if they are deeply nested.

After extracting them, it determines which fields need to be translated from all the fields it found. So for this action, all fields need to be translated, because we are translating the whole document.

If we were in the missing fields action, we would here filter and get only the fields that are empty.

utils/aiAssist/DocumentTranslate.jsx
try {
const {draft, published} = props
const rawFields = draft || published
const docId = rawFields._id
const allFields = extractTranslatableFields(rawFields)
const untranslated = allFields
if (!untranslated.length) {
devLog('✅ Nothing to translate.')
setLoading(false)
return
}
...
}

When all of this is done, we can start with the portable text party.

Portable text consists of deeply nested arrays that contain a lot of formatting information about the text itself, and on top of that we have a bunch of custom blocks that are included in our portable text editor, so we need to account for them.

utils/aiAssist/DocumentTranslate.jsx
const simplified = flattenPortableText(untranslated, fromLang)

And our flatten helper will find out what type of block we are dealing with and strip it to its most basic elements so we can send it to be translated.

In our helper we’ll basically match every block with its helper function, that will determine if the block’s elements have text that needs to be added to the flattened array. So this is the first of our four helper functions present in each block file.

But there’s also another helper here, inside toSlimBlocks, that comes from our block file and will transform the block from its complex form into the plain object we need to send to the AI.

utils/aiAssist/utilsForTranslation.js
export const flattenPortableText = (untranslated, fromLang) => {
const simplified = untranslated
.flatMap(({path, object}) => {
...
// CASE 2: Portable Text → split into individual blocks for parallel translation
if (Array.isArray(source) && source.some((b) => b && (b._type === 'block' || b._type === 'text_links' || ...))) {
const slim = toSlimBlocks(source)
// Split into individual blocks that have translatable text
const blockResults = []
for (const slimBlock of slim) {
let hasText = false
if (slimBlock._type === 'block') {
hasText = slimBlock.children.some((c) => c.text && c.text.trim())
} else if (slimBlock._type === 'text_links') {
hasText = hasTextLinksTranslatableText(slimBlock)
}
...
if (hasText) {
blockResults.push({
path: [...path, {_key: slimBlock._key}],
object: {
[fromLang]: [slimBlock],
},
isPortableTextBlock: true,
parentPath: path,
})
}
}
return blockResults
}
// Other shapes: skip for now (or extend later)
return []
})
return simplified
}

Once we have all our text prepared to be translated, the first thing we need to do is chunking it to optimize translation times:

utils/aiAssist/DocumentTranslate.jsx
// Each field becomes its own chunk for individual translation
const chunks = simplified.map(field => [field])

For our chunking, since we have potentially long portable text blocks, we decided on chunking each field on its own, so we make more calls but our calls are short and to the point.

We get all our keys from our environment variables and first thing we do is fetching our translation style guide from Sanity, since it’s open for the users to better capture Netwrix’s style:

utils/aiAssist/DocumentTranslate.jsx
const OPENAI_URL = import.meta.env.SANITY_STUDIO_OPEN_AI_URL
const OPENAI_KEY = import.meta.env.SANITY_STUDIO_OPEN_AI_KEY
const OPENAI_MODEL = import.meta.env.SANITY_STUDIO_OPEN_AI_MODEL
// Fetch translation style guide from Sanity
let styleGuide = ''
try {
const translationSettings = await client.fetch(`*[_type == "translation"][0].translation_style_guide`)
styleGuide = translationSettings || ''
if (styleGuide) {
devLog('📝 Using translation style guide')
}
} catch (err) {
devWarn('⚠️ Could not fetch translation style guide:', err)
}

Then we need to actually fetch the translation from OpenAI for each of our chunks, so we can map them and act on each of them. We’ll make a POST query to our agent and we’ll send our prompt with it:

utils/aiAssist/DocumentTranslate.jsx
const tasks = chunks.map((chunk, index) => async () => {
devLog(`🧠 Translating batch ${index + 1}/${chunks.length}...`)
// Step 1: Get translation from AI
const response = await axios.post(
OPENAI_URL,
{
model: OPENAI_MODEL,
messages: [
{
role: 'user',
content: getSystemPrompt(fromLang, toLangs, chunk, styleGuide),
},
],
temperature: 0.3,
max_tokens: 8000,
},
{headers: {
"Content-Type": 'application/json',
"Ocp-Apim-Subscription-Key": `${OPENAI_KEY}`}},
)
devLog(response.data)
let content = response.data.choices[0].message.content.trim()
content = content.replace(/```json|```/g, '').trim()
devLog(content)
...
})

Once our agent returns the translated objects, we can clean them up and proceed to the patching into Sanity, but first, this action is actually ran in batches of ten translations:

utils/aiAssist/DocumentTranslate.jsx
await runBatched(tasks, 10, (current, total) => {
const percentage = Math.round((current / total) * 100)
// Update the progress toast
toast.push({
id: progressToastId.current,
status: 'info',
title: 'Translation in progress',
description: `${percentage}% complete`,
duration: 999999,
})
})

So we have a helper to aid us in batching these translations, and we’ll make ten parallel calls (you can change this frequency from the function) to our agent before continuing with our way, and we’ll update our progress toast while we do this.

Patching would be simple except for our Portable Text, so we need another helper to handle this:

utils/aiAssist/DocumentTranslate.jsx
try {
const translated = JSON.parse(clean)
devLog(`✅ Batch ${index + 1} translated (${translated.length} items)`)
// Step 2: Immediately patch this batch to Sanity
devLog(`💾 Patching batch ${index + 1}/${chunks.length}...`)
await patchTranslatedBatch({
translated,
client,
docId,
allFields,
toLangs,
fromLang,
})
devLog(`✅ Batch ${index + 1} committed to Sanity`)
} catch (err) {
devError(`⚠️ Error processing batch ${index + 1}:`, err)
throw err
}

This helper called patchTranslatedBatch will get each translated object, check if it is portable text or a regular field, and:

  • If it is a regular field, patch it using the field’s key
  • If it is portable text, determine if it’s a regular block and patch it using the key, or if it’s a custom block, in which case it will make use of applySlimTranslation to determine which block is it and how it needs to re-hydrate it. This will make use of the last of our four block methods, in charge of reconstructing that block to its original form and adding the translated text into it.

And that is mostly it, to clean up we need to update our toast until we don’t need it anymore and complete our action:

utils/aiAssist/DocumentTranslate.jsx
if (progressToastId.current) {
toast.push({
id: progressToastId.current,
status: 'success',
title: 'Translation complete!',
description: 'All selected languages have been updated.',
duration: 4000,
})
progressToastId.current = null
} else {
toast.push({
status: 'success',
title: 'Translation complete!',
description: 'All selected languages have been updated.',
})
}
devLog('🎉 Translations complete!')

Since our translation runs in batches, the progress that was finished before reloading the page will be conserved, because those fields will already be patched into Sanity, but the translation will need to be ran once again to finish it, because it will have stopped with the reload.