feat(campaigns): AI template translator via Gemini Flash

New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.

Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
  1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
  2. Don't translate Mustache {{vars}}
  3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
     Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
  4. Preserve emojis (🎁  🤝 🪂  ⏭️ )
  5. Keep the warm informal tone (tu in FR, you in EN)
  6. Translate only visible text inside elements (paragraphs, buttons,
     alt attributes, link text)
  7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
  editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
  (409 Conflict otherwise — protects against accidental clobber)

API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })

Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
  when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
  flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
  • Shows source → target template names
  • Info banner explaining what's preserved (HTML, vars, brands, emojis)
  • Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
  "Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
  in the selector dropdown

UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 07:21:45 -04:00
parent d716e69ef6
commit 1b399f65eb
3 changed files with 259 additions and 1 deletions

View File

@ -127,6 +127,16 @@ export function previewTemplate (name, { html, vars } = {}) {
})
}
// Translate the source template to a target language via Gemini.
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
// override=true required if the target already exists.
export function translateTemplate (srcName, targetName, { override = false } = {}) {
return hubFetch(
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
{ method: 'POST', body: { override } },
)
}
// Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject } = {}) {

View File

@ -24,6 +24,10 @@
<q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm" @click="openPreview">
<q-tooltip>Voir le HTML rendu (substitué) tel que reçu par le destinataire</q-tooltip>
</q-btn>
<q-btn flat color="purple-7" icon="translate" label="Traduire (AI)" class="q-mr-sm"
:disable="!aiTranslateTargetName" @click="aiTranslateOpen = true">
<q-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
</q-btn>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn>
@ -97,6 +101,43 @@
</q-card>
</q-dialog>
<!-- AI translate dialog -->
<q-dialog v-model="aiTranslateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-purple-1 text-purple-9">
<q-icon name="translate" class="q-mr-sm" />
<div class="text-h6">Traduire avec Gemini AI</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="q-mb-md">
Le contenu de <strong>{{ currentName }}</strong> sera traduit vers
<strong>{{ aiTranslateTargetName }}</strong> via Gemini Flash.
</div>
<q-banner class="bg-grey-2 text-grey-8 q-mb-md" rounded dense>
<q-icon name="info" class="q-mr-xs" />
Le AI préserve la structure HTML, les variables <code>{{ '{{...}}' }}</code>,
les URLs, les noms de marque (TARGO, Giftbit) et les emojis.
Il traduit seulement le texte visible.
</q-banner>
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<q-icon name="warning" class="q-mr-xs" />
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
La traduction va l'écraser (backup automatique avant).
<q-toggle v-model="aiTranslateOverride" label="Confirmer l'écrasement" class="q-mt-sm" />
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="purple-7" icon="translate" label="Traduire maintenant"
:loading="aiTranslating"
:disable="targetTemplateExists && !aiTranslateOverride"
@click="doAiTranslate" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
@ -142,7 +183,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
previewTemplate, testSendTemplate } from 'src/api/campaigns'
previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
const route = useRoute()
const router = useRouter()
@ -231,6 +272,50 @@ async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
// AI translation (Gemini via hub)
// Auto-detect source language from the template name suffix (-fr / -en) and
// compute the target name with the OPPOSITE suffix.
const aiTranslateOpen = ref(false)
const aiTranslating = ref(false)
const aiTranslateOverride = ref(false)
const aiTranslateTargetName = computed(() => {
const m = (currentName.value || '').match(/^(.+)-(fr|en)$/)
if (!m) return ''
const opposite = m[2] === 'fr' ? 'en' : 'fr'
return `${m[1]}-${opposite}`
})
const targetTemplateExists = computed(() =>
!!aiTranslateTargetName.value && !!templates.value.find(t => t.name === aiTranslateTargetName.value),
)
async function doAiTranslate () {
if (!aiTranslateTargetName.value) return
aiTranslating.value = true
try {
const r = await translateTemplate(currentName.value, aiTranslateTargetName.value, {
override: aiTranslateOverride.value,
})
aiTranslateOpen.value = false
aiTranslateOverride.value = false
await loadAvailableTemplates()
$q.notify({
type: 'positive',
message: `Traduction terminée : ${r.source}${r.target}`,
caption: `${r.src_bytes}${r.out_bytes} octets (${r.from_lang}${r.to_lang})`,
timeout: 6000,
actions: [
{ label: 'Ouvrir', color: 'white', handler: () => { onSelectTemplate(r.target) } },
],
})
} catch (e) {
$q.notify({ type: 'negative', message: 'Échec traduction: ' + e.message, timeout: 6000 })
} finally {
aiTranslating.value = false
}
}
// New template creation
const newTemplateOpen = ref(false)
const creating = ref(false)

View File

@ -45,6 +45,16 @@ const sse = require('./sse')
// save time so the send-worker only ever reads pre-compiled HTML.
const mjml2html = require('mjml')
// AI service (Gemini Flash) — used by the template translation endpoint to
// convert email content between FR ↔ EN while preserving HTML structure.
// Lazy require because lib/ai.js requires AI_API_KEY which may not be set
// in dev environments — we only fail when the endpoint is actually called.
let aiLib = null
function getAi () {
if (!aiLib) aiLib = require('./ai')
return aiLib
}
const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
@ -1147,6 +1157,159 @@ async function handle (req, res, method, path) {
}
}
// POST /campaigns/templates/:name/translate-to/:targetName — translate the
// source template's HTML content via Gemini Flash and save as targetName.
// Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis.
// Body (optional): { override: true } to overwrite an existing target.
const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/)
if (tplTranslate && method === 'POST') {
const srcName = tplTranslate[1]
const targetName = tplTranslate[2]
if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) {
return json(res, 400, { error: 'invalid source or target template name' })
}
if (srcName === targetName) {
return json(res, 400, { error: 'source and target must differ' })
}
// Detect direction from name suffix (-fr → translate to en, -en → fr)
// Falls back to the explicit target language inferred from target name.
const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr'
const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr')
let srcHtml
try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') }
catch { return json(res, 404, { error: 'source template not found' }) }
const body = await parseBody(req).catch(() => ({}))
const targetHtmlPath = templatePath(targetName)
if (fs.existsSync(targetHtmlPath) && !body?.override) {
return json(res, 409, {
error: 'target template already exists',
hint: 'POST again with { "override": true } to overwrite',
})
}
const langNames = { fr: 'French Canadian (français du Québec)', en: 'English' }
const systemPrompt = [
`You are a professional bilingual translator for TARGO, a Quebec-based`,
`fiber Internet ISP. You translate marketing email content between`,
`French Canadian and English while strictly preserving HTML structure.`,
``,
`STRICT RULES — DO NOT VIOLATE:`,
`1. PRESERVE every HTML tag, attribute, class, inline style, and Outlook`,
` conditional comment (<!--[if mso]><![endif]-->) byte-for-byte.`,
`2. PRESERVE Mustache variables: {{firstname}}, {{amount}}, {{gift_url}},`,
` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}, etc.`,
` DO NOT translate the content inside {{ }}.`,
`3. PRESERVE URLs (href, src), email addresses, phone numbers, hex colors,`,
` CSS values, font names, brand names (TARGO, Gigafibre, Giftbit, Mailjet,`,
` Amazon, IGA, Tim Hortons, etc.).`,
`4. PRESERVE all emojis as-is (🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰).`,
`5. KEEP the warm conversational tone — use "tu" (informal) in French and`,
` "you" (informal) in English. Marketing-friendly, not corporate.`,
`6. TRANSLATE only the visible text content inside elements: paragraphs,`,
` headings, button labels, link text, alt attributes.`,
`7. Output the COMPLETE translated HTML document (starting from <!doctype`,
` html> or <html> to the closing </html>). NO explanation, NO markdown`,
` code fence, NO commentary — just the HTML.`,
].join('\n')
const userPrompt = `Translate the following ${langNames[srcLang]} email HTML to ${langNames[tgtLang]}. ` +
`Apply the rules from the system prompt strictly.\n\n${srcHtml}`
let translated
try {
// Non-JSON mode (we want raw HTML back), high token limit (~35 KB output)
translated = await getAi().aiCall(systemPrompt, userPrompt, {
jsonMode: false,
maxTokens: 32768,
temperature: 0.2, // low temp = stable, less creative translation
})
} catch (e) {
return json(res, 502, { error: 'AI translation failed', detail: e.message })
}
// Sanity checks — refuse to save garbled output
if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) {
return json(res, 502, {
error: 'AI returned truncated or invalid output',
src_bytes: srcHtml.length,
out_bytes: typeof translated === 'string' ? translated.length : 0,
})
}
// Strip optional markdown code fences if Gemini still added them despite the prompt
translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim()
// Backup existing target before overwriting
try {
if (fs.existsSync(targetHtmlPath)) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`))
const jsonPath = templateJsonPath(targetName)
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
}
} catch (e) { log('translate backup failed:', e.message) }
// Write translated HTML
fs.writeFileSync(targetHtmlPath, translated, 'utf8')
// Regenerate the Unlayer design JSON via the converter so the editor
// can reload the translated template visually. Inline equivalent of
// running scripts/convert-html-to-unlayer.js for this target.
const bodyMatch = translated.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated
const preheaderMatch = innerHtml.match(/<div[^>]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
const design = {
counters: { u_row: 1, u_column: 1, u_content_html: 1 },
body: {
id: 'BODY-1',
rows: [{
id: 'ROW-1', cells: [1],
columns: [{
id: 'COL-1',
contents: [{
id: 'HTML-1', type: 'html',
values: {
html: innerHtml, hideDesktop: false, displayCondition: null,
containerPadding: '0px',
_meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' },
selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
},
}],
values: { _meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' } },
}],
values: {
displayCondition: null, columns: false, backgroundColor: '', padding: '0px',
_meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
},
}],
values: {
contentWidth: '600px',
fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" },
textColor: '#1B2E24',
backgroundColor: '#F5FAF7',
preheaderText: preheader,
linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true },
_meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
},
},
schemaVersion: 12,
}
fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8')
log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) → ${targetName} (${translated.length}b ${tgtLang})`)
return json(res, 200, {
source: srcName,
target: targetName,
from_lang: srcLang,
to_lang: tgtLang,
src_bytes: srcHtml.length,
out_bytes: translated.length,
saved: true,
})
}
// POST /campaigns/templates/:name/test-send — send ONE rendered email to a
// specific address for visual QA. Uses the campaign Mailjet sender +
// throttle. NOT linked to any campaign — purely for template testing.