Add fallback manual AI prompt and use translations key

This commit is contained in:
Tero Halla-aho 2025-11-29 20:57:46 +02:00
parent c3a958231f
commit 54edd9e2e8
3 changed files with 158 additions and 19 deletions

View file

@ -8,10 +8,17 @@ type LocaleFields = { title: string; teaser: string; description: string };
const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv'];
function loadApiKey() {
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
const keyPath = path.join(process.cwd(), 'creds', 'openai.key');
if (process.env.OPENAI_TRANSLATIONS_KEY) return process.env.OPENAI_TRANSLATIONS_KEY;
const newKeyPath = path.join(process.cwd(), 'creds', 'openai-translations.key');
try {
return fs.readFileSync(keyPath, 'utf8').trim();
return fs.readFileSync(newKeyPath, 'utf8').trim();
} catch {
// ignore
}
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
const fallbackPath = path.join(process.cwd(), 'creds', 'openai.key');
try {
return fs.readFileSync(fallbackPath, 'utf8').trim();
} catch {
return null;
}
@ -96,13 +103,13 @@ export async function POST(req: Request) {
if (!res.ok) {
const errText = await res.text();
return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: 500 });
return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: res.status || 500 });
}
const data = await res.json();
content = data?.choices?.[0]?.message?.content ?? '';
} catch (err) {
return NextResponse.json({ error: 'AI request failed' }, { status: 500 });
} catch (err: any) {
return NextResponse.json({ error: 'AI request failed', detail: err?.message }, { status: 500 });
}
const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content;

View file

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../components/I18nProvider';
import type { Locale } from '../../../lib/i18n';
@ -58,6 +58,8 @@ export default function NewListingPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isAuthed, setIsAuthed] = useState(false);
const [aiResponse, setAiResponse] = useState('');
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
const [aiLoading, setAiLoading] = useState(false);
const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle');
@ -142,6 +144,31 @@ export default function NewListingPage() {
return 'partial';
}
const aiPrompt = useMemo(() => {
const payload = {
task: 'Translate this localization file for a holiday rental listing.',
instructions: [
'Preserve meaning, tone, numbers, and any markup.',
'Return valid JSON only with the same keys.',
'Fill missing translations; keep existing text unchanged.',
],
sourceLocale: currentLocale,
targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale),
locales: SUPPORTED_LOCALES.reduce(
(acc, loc) => ({
...acc,
[loc]: {
title: translations[loc].title,
teaser: translations[loc].teaser,
description: translations[loc].description,
},
}),
{} as Record<Locale, LocaleFields>
),
};
return JSON.stringify(payload, null, 2);
}, [translations, currentLocale]);
async function autoTranslate() {
if (aiLoading) return;
setMessage(null);
@ -178,6 +205,45 @@ export default function NewListingPage() {
}
}
function applyAiResponse() {
setMessage(null);
setError(null);
try {
const parsed = JSON.parse(aiResponse);
const locales = parsed?.locales || parsed;
if (!locales || typeof locales !== 'object') {
throw new Error('Missing locales');
}
const next = { ...translations };
SUPPORTED_LOCALES.forEach((loc) => {
if (locales[loc]) {
const incoming = locales[loc];
next[loc] = {
title: typeof incoming.title === 'string' && incoming.title ? incoming.title : next[loc].title,
teaser: typeof incoming.teaser === 'string' && incoming.teaser ? incoming.teaser : next[loc].teaser,
description: typeof incoming.description === 'string' && incoming.description ? incoming.description : next[loc].description,
};
}
});
setTranslations(next);
setMessage(t('aiApplySuccess'));
} catch (err) {
setError(t('aiApplyError'));
}
}
async function copyAiPrompt() {
try {
if (!navigator?.clipboard) throw new Error('clipboard unavailable');
await navigator.clipboard.writeText(aiPrompt);
setCopyStatus('copied');
setTimeout(() => setCopyStatus('idle'), 1500);
} catch (err) {
setCopyStatus('error');
setTimeout(() => setCopyStatus('idle'), 2000);
}
}
function parseImages(): ImageInput[] {
return selectedImages.map((img) => ({
data: img.dataUrl,
@ -378,6 +444,45 @@ export default function NewListingPage() {
</button>
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('aiHelperNote')}</span>
</div>
<div style={{ marginTop: 12, display: 'grid', gap: 6 }}>
<strong>{t('aiManualLead')}</strong>
<div style={{ display: 'grid', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<span>{t('aiPromptLabel')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{copyStatus === 'copied' ? (
<span style={{ color: '#34d399', fontSize: 13 }}>{t('aiPromptCopied')}</span>
) : null}
{copyStatus === 'error' ? <span style={{ color: '#f87171', fontSize: 13 }}>{t('aiCopyError')}</span> : null}
<button
type="button"
className="button secondary"
onClick={copyAiPrompt}
style={{ minHeight: 0, padding: '8px 12px' }}
>
{t('aiCopyPrompt')}
</button>
</div>
</div>
<textarea value={aiPrompt} readOnly rows={10} style={{ fontFamily: 'monospace' }} />
</div>
<label style={{ display: 'grid', gap: 6 }}>
<span>{t('aiResponseLabel')}</span>
<textarea
value={aiResponse}
onChange={(e) => setAiResponse(e.target.value)}
rows={6}
placeholder='{"locales":{"fi":{"title":"..."}}}'
style={{ fontFamily: 'monospace' }}
/>
</label>
<div style={{ display: 'flex', gap: 10, marginTop: 4, flexWrap: 'wrap' }}>
<button type="button" className="button secondary" onClick={() => applyAiResponse()}>
{t('aiApply')}
</button>
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('aiHelperNote')}</span>
</div>
</div>
</div>
</div>
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>

View file

@ -146,8 +146,17 @@ const baseMessages = {
aiAutoTranslate: 'Auto-translate missing languages',
aiAutoTranslating: 'Translating…',
aiAutoSuccess: 'Translations updated with AI.',
aiAutoError: 'AI translation failed. Please try again.',
aiHelperNote: 'Uses your OpenAI key to fill missing locales.',
aiAutoError: 'AI translation failed. Please try again or use manual mode below.',
aiHelperNote: 'Uses the OpenAI translations key to fill missing locales.',
aiManualLead: 'If auto-translate fails, copy this prompt to your AI assistant and paste the JSON reply below.',
aiPromptLabel: 'Prompt to send to AI',
aiCopyPrompt: 'Copy prompt',
aiPromptCopied: 'Copied',
aiCopyError: 'Copy failed',
aiResponseLabel: 'Paste AI response (JSON)',
aiApply: 'Apply AI response',
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
aiApplySuccess: 'Translations updated from AI response.',
translationMissing: 'Add at least one language with a title and description.',
loginToCreate: 'Please log in first to create a listing.',
slugLabel: 'Slug',
@ -288,14 +297,23 @@ const baseMessages = {
localeReady: 'Valmis',
localePartial: 'Kesken',
localeMissing: 'Puuttuu',
aiHelperTitle: 'AI-käännösapu',
aiHelperLead: 'Anna tekoälyn täydentää muut kielet puolestasi.',
aiAutoExplain: 'Täytä tekstit yhdellä kielellä ja paina nappia; AI täyttää loput kielet.',
aiAutoTranslate: 'Käännä puuttuvat kielet',
aiAutoTranslating: 'Käännetään…',
aiAutoSuccess: 'Käännökset päivitetty AI:lla.',
aiAutoError: 'Käännös epäonnistui. Yritä uudelleen.',
aiHelperNote: 'Käyttää OpenAI-avaintasi puuttuvien kielten täyttämiseen.',
aiHelperTitle: 'AI-käännösapu',
aiHelperLead: 'Anna tekoälyn täydentää muut kielet puolestasi.',
aiAutoExplain: 'Täytä tekstit yhdellä kielellä ja paina nappia; AI täyttää loput kielet.',
aiAutoTranslate: 'Käännä puuttuvat kielet',
aiAutoTranslating: 'Käännetään…',
aiAutoSuccess: 'Käännökset päivitetty AI:lla.',
aiAutoError: 'Käännös epäonnistui. Yritä uudelleen tai käytä manuaalitilaa alla.',
aiHelperNote: 'Käyttää OpenAI:n käännösavainta puuttuvien kielten täyttämiseen.',
aiManualLead: 'Jos automaattinen käännös ei toimi, kopioi prompti tekoälylle ja liitä JSON-vastaus alle.',
aiPromptLabel: 'Prompti tekoälylle',
aiCopyPrompt: 'Kopioi prompti',
aiPromptCopied: 'Kopioitu',
aiCopyError: 'Kopiointi epäonnistui',
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
aiApply: 'Käytä AI-vastausta',
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
ctaViewSample: 'Katso esimerkkikohde',
ctaHealth: 'Tarkista health-päätepiste',
@ -563,8 +581,17 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
aiAutoTranslate: 'Översätt saknade språk',
aiAutoTranslating: 'Översätter…',
aiAutoSuccess: 'Översättningar uppdaterades med AI.',
aiAutoError: 'AI-översättning misslyckades. Försök igen.',
aiHelperNote: 'Använder din OpenAI-nyckel för att fylla saknade språk.',
aiAutoError: 'AI-översättning misslyckades. Försök igen eller använd manuellt läge nedan.',
aiHelperNote: 'Använder OpenAI:s översättningsnyckel för att fylla saknade språk.',
aiManualLead: 'Om autokorrespondens misslyckas, kopiera prompten till din AI och klistra in JSON-svaret nedan.',
aiPromptLabel: 'Prompt till AI',
aiCopyPrompt: 'Kopiera prompt',
aiPromptCopied: 'Kopierad',
aiCopyError: 'Kopiering misslyckades',
aiResponseLabel: 'Klistra in AI-svar (JSON)',
aiApply: 'Använd AI-svar',
aiApplyError: 'Kunde inte läsa AI-svaret. Se till att det är giltig JSON med locales-nyckel.',
aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.',
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
slugChecking: 'Kontrollerar tillgänglighet…',
slugAvailable: 'Sluggen är ledig',