Wizard translations

This commit is contained in:
Kurvaz 2026-01-20 13:18:14 -07:00
parent 0c67a90e77
commit ea5874d7fa
13 changed files with 908 additions and 25 deletions

View file

@ -6,6 +6,7 @@ ALTER TABLE story_entries ADD COLUMN original_input TEXT;
-- Add translation columns to characters
ALTER TABLE characters ADD COLUMN translated_name TEXT;
ALTER TABLE characters ADD COLUMN translated_description TEXT;
ALTER TABLE characters ADD COLUMN translated_relationship TEXT;
ALTER TABLE characters ADD COLUMN translation_language TEXT;
-- Add translation columns to locations

View file

@ -195,6 +195,24 @@
icon: Languages,
description: "Translates world state elements",
},
{
id: "translation:suggestions",
label: "Translate Suggestions",
icon: Languages,
description: "Translates plot suggestions",
},
{
id: "translation:actionChoices",
label: "Translate Choices",
icon: Languages,
description: "Translates action choices",
},
{
id: "translation:wizard",
label: "Translate Wizard",
icon: Languages,
description: "Translates wizard content",
},
] as const;
// State
@ -235,6 +253,9 @@
"translation:narration": "translation",
"translation:input": "translation",
"translation:ui": "translation",
"translation:suggestions": "translation",
"translation:actionChoices": "translation",
"translation:wizard": "translation",
};
function getReasoningIndex(value?: string): number {

View file

@ -178,10 +178,26 @@
story.pov,
story.tense,
);
ui.setSuggestions(result.suggestions, story.currentStory?.id);
// Translate suggestions if enabled
let finalSuggestions = result.suggestions;
const translationSettings = settings.translationSettings;
if (TranslationService.shouldTranslate(translationSettings)) {
try {
finalSuggestions = await aiService.translateSuggestions(
result.suggestions,
translationSettings.targetLanguage,
);
log("Suggestions translated");
} catch (error) {
log("Suggestion translation failed (non-fatal):", error);
}
}
ui.setSuggestions(finalSuggestions, story.currentStory?.id);
log(
"Suggestions refreshed:",
result.suggestions.length,
finalSuggestions.length,
"with",
activeLorebookEntries.length,
"active lorebook entries",
@ -189,7 +205,7 @@
// Emit SuggestionsReady event
emitSuggestionsReady(
result.suggestions.map((s) => ({ text: s.text, type: s.type })),
finalSuggestions.map((s) => ({ text: s.text, type: s.type })),
);
} catch (error) {
log("Failed to generate suggestions:", error);
@ -305,10 +321,26 @@
story.pov,
activeLorebookEntries,
);
ui.setActionChoices(result.choices, story.currentStory?.id);
// Translate action choices if enabled
let finalChoices = result.choices;
const translationSettings = settings.translationSettings;
if (TranslationService.shouldTranslate(translationSettings)) {
try {
finalChoices = await aiService.translateActionChoices(
result.choices,
translationSettings.targetLanguage,
);
log("Action choices translated");
} catch (error) {
log("Action choices translation failed (non-fatal):", error);
}
}
ui.setActionChoices(finalChoices, story.currentStory?.id);
log(
"Action choices generated:",
result.choices.length,
finalChoices.length,
"with",
activeLorebookEntries.length,
"active lorebook entries",

View file

@ -13,6 +13,8 @@
type GeneratedOpening,
type Tense,
} from "$lib/services/ai/scenario";
import { aiService } from "$lib/services/ai";
import { TranslationService } from "$lib/services/ai/translation";
import {
type ImportedEntry,
type LorebookImportResult,
@ -76,6 +78,7 @@
// Step 3: Setting
let settingSeed = $state("");
let expandedSetting = $state<ExpandedSetting | null>(null);
let expandedSettingTranslated = $state<ExpandedSetting | null>(null); // Translated version for display
let isExpandingSetting = $state(false);
let settingError = $state<string | null>(null);
let settingElaborationGuidance = $state("");
@ -85,7 +88,9 @@
// Step 4: Protagonist/Characters
let protagonist = $state<GeneratedProtagonist | null>(null);
let protagonistTranslated = $state<GeneratedProtagonist | null>(null); // Translated version for display
let supportingCharacters = $state<GeneratedCharacter[]>([]);
let supportingCharactersTranslated = $state<GeneratedCharacter[]>([]); // Translated version for display
let isGeneratingProtagonist = $state(false);
let isGeneratingCharacters = $state(false);
let isElaboratingCharacter = $state(false);
@ -158,12 +163,21 @@
let storyTitle = $state("");
let openingGuidance = $state("");
let generatedOpening = $state<GeneratedOpening | null>(null);
let generatedOpeningTranslated = $state<GeneratedOpening | null>(null); // Translated version for display
let isGeneratingOpening = $state(false);
let isRefiningOpening = $state(false);
let openingError = $state<string | null>(null);
let isEditingOpening = $state(false);
let openingDraft = $state("");
// Derived display variables - use translated version when available, fall back to original
const expandedSettingDisplay = $derived(expandedSettingTranslated ?? expandedSetting);
const protagonistDisplay = $derived(protagonistTranslated ?? protagonist);
const supportingCharactersDisplay = $derived(
supportingCharactersTranslated.length > 0 ? supportingCharactersTranslated : supportingCharacters
);
const generatedOpeningDisplay = $derived(generatedOpeningTranslated ?? generatedOpening);
// Check if API key is configured
const needsApiKey = $derived(settings.needsApiKey);
@ -285,6 +299,20 @@
);
clearSettingEditState();
settingElaborationGuidance = "";
// Translate setting content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (expandedSetting && TranslationService.shouldTranslate(translationSettings)) {
try {
expandedSettingTranslated = await translateExpandedSetting(expandedSetting, translationSettings.targetLanguage);
console.log("[Wizard] Setting translated for display");
} catch (translationError) {
console.error("Setting translation failed (non-fatal):", translationError);
expandedSettingTranslated = null;
}
} else {
expandedSettingTranslated = null;
}
} catch (error) {
console.error("Failed to expand setting:", error);
settingError =
@ -321,6 +349,20 @@
);
clearSettingEditState();
settingElaborationGuidance = "";
// Translate setting content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (expandedSetting && TranslationService.shouldTranslate(translationSettings)) {
try {
expandedSettingTranslated = await translateExpandedSetting(expandedSetting, translationSettings.targetLanguage);
console.log("[Wizard] Refined setting translated for display");
} catch (translationError) {
console.error("Setting translation failed (non-fatal):", translationError);
expandedSettingTranslated = null;
}
} else {
expandedSettingTranslated = null;
}
} catch (error) {
console.error("Failed to refine setting:", error);
settingError =
@ -424,6 +466,20 @@
customGenre || undefined,
settings.servicePresetAssignments["wizard:protagonistGeneration"],
);
// Translate protagonist content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (protagonist && TranslationService.shouldTranslate(translationSettings)) {
try {
protagonistTranslated = await translateProtagonist(protagonist, translationSettings.targetLanguage);
console.log("[Wizard] Protagonist translated for display");
} catch (translationError) {
console.error("Protagonist translation failed (non-fatal):", translationError);
protagonistTranslated = null;
}
} else {
protagonistTranslated = null;
}
} catch (error) {
console.error("Failed to generate protagonist:", error);
protagonistError =
@ -575,6 +631,20 @@
);
showManualInput = false;
characterElaborationGuidance = "";
// Translate protagonist content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (protagonist && TranslationService.shouldTranslate(translationSettings)) {
try {
protagonistTranslated = await translateProtagonist(protagonist, translationSettings.targetLanguage);
console.log("[Wizard] Elaborated character translated for display");
} catch (translationError) {
console.error("Character translation failed (non-fatal):", translationError);
protagonistTranslated = null;
}
} else {
protagonistTranslated = null;
}
} catch (error) {
console.error("Failed to elaborate character:", error);
protagonistError =
@ -602,6 +672,20 @@
characterElaborationGuidance.trim() || undefined,
);
characterElaborationGuidance = "";
// Translate protagonist content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (protagonist && TranslationService.shouldTranslate(translationSettings)) {
try {
protagonistTranslated = await translateProtagonist(protagonist, translationSettings.targetLanguage);
console.log("[Wizard] Refined character translated for display");
} catch (translationError) {
console.error("Character translation failed (non-fatal):", translationError);
protagonistTranslated = null;
}
} else {
protagonistTranslated = null;
}
} catch (error) {
console.error("Failed to refine character:", error);
protagonistError =
@ -637,6 +721,24 @@
customGenre || undefined,
settings.servicePresetAssignments["wizard:supportingCharacters"],
);
// Translate supporting characters if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (supportingCharacters.length > 0 && TranslationService.shouldTranslate(translationSettings)) {
try {
supportingCharactersTranslated = await Promise.all(
supportingCharacters.map((char) =>
translateSupportingCharacter(char, translationSettings.targetLanguage)
)
);
console.log("[Wizard] Supporting characters translated for display");
} catch (translationError) {
console.error("Supporting characters translation failed (non-fatal):", translationError);
supportingCharactersTranslated = [];
}
} else {
supportingCharactersTranslated = [];
}
} catch (error) {
console.error("Failed to generate characters:", error);
} finally {
@ -809,6 +911,62 @@
settings.servicePresetAssignments["wizard:openingGeneration"],
lorebookContext,
);
// Translate wizard content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (generatedOpening && TranslationService.shouldTranslate(translationSettings)) {
try {
// Build flat fields object for batch translation
const fields: Record<string, string> = {
scene: generatedOpening.scene,
title: generatedOpening.title,
};
if (generatedOpening.initialLocation?.name) {
fields.locName = generatedOpening.initialLocation.name;
}
if (generatedOpening.initialLocation?.description) {
fields.locDesc = generatedOpening.initialLocation.description;
}
// Single batch translation call
const translated = await aiService.translateWizardBatch(fields, translationSettings.targetLanguage);
// Debug: log what we got back from batch translation
console.log("[Wizard] Opening batch translation result:", {
inputFields: Object.keys(fields),
outputFields: Object.keys(translated),
hasLocName: !!translated.locName,
hasLocDesc: !!translated.locDesc,
locName: translated.locName,
locDesc: translated.locDesc?.substring(0, 100),
});
generatedOpeningTranslated = {
...generatedOpening,
scene: translated.scene || generatedOpening.scene,
title: translated.title || generatedOpening.title,
initialLocation: generatedOpening.initialLocation
? {
name: translated.locName || generatedOpening.initialLocation.name,
description: translated.locDesc || generatedOpening.initialLocation.description,
}
: generatedOpening.initialLocation,
};
console.log("[Wizard] Opening translated for display", {
hasInitialLocation: !!generatedOpeningTranslated.initialLocation,
locName: generatedOpeningTranslated.initialLocation?.name,
locDesc: generatedOpeningTranslated.initialLocation?.description?.substring(0, 50),
originalLocName: generatedOpening.initialLocation?.name,
translatedLocName: translated.locName,
translatedLocDesc: translated.locDesc?.substring(0, 50),
});
} catch (translationError) {
console.error("Opening translation failed (non-fatal):", translationError);
generatedOpeningTranslated = null;
}
} else {
generatedOpeningTranslated = null;
}
} catch (error) {
console.error("Failed to generate opening:", error);
openingError =
@ -896,6 +1054,45 @@
lorebookContext,
);
clearOpeningEditState();
// Translate wizard content if enabled (store in separate variable for display)
const translationSettings = settings.translationSettings;
if (generatedOpening && TranslationService.shouldTranslate(translationSettings)) {
try {
// Build flat fields object for batch translation
const fields: Record<string, string> = {
scene: generatedOpening.scene,
title: generatedOpening.title,
};
if (generatedOpening.initialLocation?.name) {
fields.locName = generatedOpening.initialLocation.name;
}
if (generatedOpening.initialLocation?.description) {
fields.locDesc = generatedOpening.initialLocation.description;
}
// Single batch translation call
const translated = await aiService.translateWizardBatch(fields, translationSettings.targetLanguage);
generatedOpeningTranslated = {
...generatedOpening,
scene: translated.scene || generatedOpening.scene,
title: translated.title || generatedOpening.title,
initialLocation: generatedOpening.initialLocation
? {
name: translated.locName || generatedOpening.initialLocation.name,
description: translated.locDesc || generatedOpening.initialLocation.description,
}
: generatedOpening.initialLocation,
};
console.log("[Wizard] Refined opening translated for display");
} catch (translationError) {
console.error("Opening translation failed (non-fatal):", translationError);
generatedOpeningTranslated = null;
}
} else {
generatedOpeningTranslated = null;
}
} catch (error) {
console.error("Failed to refine opening:", error);
openingError =
@ -1034,10 +1231,74 @@
: [],
}));
// Build translations object if we have translations
const translationSettings = settings.translationSettings;
let translations: {
language: string;
openingScene?: string;
protagonist?: { name?: string; description?: string };
startingLocation?: { name?: string; description?: string };
characters?: { [originalName: string]: { name?: string; description?: string; relationship?: string } };
} | undefined;
if (TranslationService.shouldTranslate(translationSettings)) {
const targetLanguage = translationSettings.targetLanguage;
translations = { language: targetLanguage };
// Opening scene translation
if (generatedOpeningTranslated?.scene) {
translations.openingScene = generatedOpeningTranslated.scene;
}
// Protagonist translation
if (protagonistTranslated) {
translations.protagonist = {
name: protagonistTranslated.name,
description: protagonistTranslated.description,
};
}
// Starting location translation (from opening's initialLocation)
if (generatedOpeningTranslated?.initialLocation) {
translations.startingLocation = {
name: generatedOpeningTranslated.initialLocation.name,
description: generatedOpeningTranslated.initialLocation.description,
};
}
// Supporting characters translation - key by processed name (after placeholder replacement)
if (supportingCharactersTranslated.length > 0) {
translations.characters = {};
for (let i = 0; i < supportingCharacters.length; i++) {
const processed = processedCharacters[i];
const translated = supportingCharactersTranslated[i];
// Use processed name as key since createStoryFromWizard looks up by processed name
if (processed?.name && translated) {
translations.characters[processed.name] = {
name: translated.name,
description: translated.description,
relationship: translated.relationship,
};
}
}
}
// Debug: log what translations we're passing
console.log("[Wizard] Translations being passed to createStoryFromWizard:", {
hasOpeningScene: !!translations.openingScene,
hasProtagonist: !!translations.protagonist,
hasStartingLocation: !!translations.startingLocation,
startingLocation: translations.startingLocation,
characterCount: translations.characters ? Object.keys(translations.characters).length : 0,
characters: translations.characters,
});
}
const newStory = await story.createStoryFromWizard({
...storyData,
importedEntries:
processedEntries.length > 0 ? processedEntries : undefined,
translations,
});
await story.loadStory(newStory.id);
@ -1045,6 +1306,100 @@
onClose();
}
// Translation helper functions - using batch translation for efficiency
async function translateExpandedSetting(setting: ExpandedSetting, targetLanguage: string): Promise<ExpandedSetting> {
// Build flat fields object for batch translation
const fields: Record<string, string> = {
name: setting.name,
description: setting.description,
};
if (setting.atmosphere) fields.atmosphere = setting.atmosphere;
if (setting.themes?.length) fields.themes = setting.themes.join(", ");
if (setting.potentialConflicts?.length) fields.conflicts = setting.potentialConflicts.join("; ");
// Add key locations with indexed keys
(setting.keyLocations || []).forEach((loc, i) => {
fields[`loc_${i}_name`] = loc.name;
if (loc.description) fields[`loc_${i}_desc`] = loc.description;
});
// Single batch translation call
const translated = await aiService.translateWizardBatch(fields, targetLanguage);
// Reconstruct key locations
const translatedLocations = (setting.keyLocations || []).map((loc, i) => ({
name: translated[`loc_${i}_name`] || loc.name,
description: translated[`loc_${i}_desc`] || loc.description,
}));
return {
...setting,
name: translated.name || setting.name,
description: translated.description || setting.description,
atmosphere: translated.atmosphere || setting.atmosphere,
keyLocations: translatedLocations,
themes: translated.themes
? translated.themes.split(",").map((t) => t.trim()).filter(Boolean)
: setting.themes,
potentialConflicts: translated.conflicts
? translated.conflicts.split(";").map((c) => c.trim()).filter(Boolean)
: setting.potentialConflicts,
};
}
async function translateProtagonist(char: GeneratedProtagonist, targetLanguage: string): Promise<GeneratedProtagonist> {
// Build flat fields object for batch translation
const fields: Record<string, string> = {
name: char.name,
description: char.description,
};
if (char.background) fields.background = char.background;
if (char.motivation) fields.motivation = char.motivation;
if (char.traits?.length) fields.traits = char.traits.join(", ");
// Single batch translation call
const translated = await aiService.translateWizardBatch(fields, targetLanguage);
return {
...char,
name: translated.name || char.name,
description: translated.description || char.description,
background: translated.background || char.background,
motivation: translated.motivation || char.motivation,
traits: translated.traits
? translated.traits.split(",").map((t) => t.trim()).filter(Boolean)
: char.traits,
};
}
async function translateSupportingCharacter(char: GeneratedCharacter, targetLanguage: string): Promise<GeneratedCharacter> {
// Build flat fields object for batch translation
const fields: Record<string, string> = {
name: char.name,
description: char.description,
};
if (char.role) fields.role = char.role;
if (char.relationship) fields.relationship = char.relationship;
if (char.traits?.length) fields.traits = char.traits.join(", ");
// Single batch translation call
const translated = await aiService.translateWizardBatch(fields, targetLanguage);
return {
...char,
name: translated.name || char.name,
description: translated.description || char.description,
role: translated.role || char.role,
relationship: translated.relationship || char.relationship,
traits: translated.traits
? translated.traits.split(",").map((t) => t.trim()).filter(Boolean)
: char.traits,
};
}
// Step titles
const stepTitles = [
"Choose Your Mode",
@ -1643,7 +1998,7 @@
{:else if currentStep === 4}
<Step4Setting
{settingSeed}
{expandedSetting}
expandedSetting={expandedSettingDisplay}
{settingElaborationGuidance}
{isExpandingSetting}
{settingError}
@ -1675,8 +2030,8 @@
{:else if currentStep === 5}
<Step5Characters
{selectedMode}
{expandedSetting}
{protagonist}
expandedSetting={expandedSettingDisplay}
protagonist={protagonistDisplay}
{manualCharacterName}
{manualCharacterDescription}
{manualCharacterBackground}
@ -1703,8 +2058,8 @@
/>
{:else if currentStep === 6}
<Step6SupportingCast
{protagonist}
{supportingCharacters}
protagonist={protagonistDisplay}
supportingCharacters={supportingCharactersDisplay}
{showSupportingCharacterForm}
{editingSupportingCharacterIndex}
{supportingCharacterName}
@ -1779,7 +2134,7 @@
<Step9Opening
{storyTitle}
{openingGuidance}
{generatedOpening}
generatedOpening={generatedOpeningDisplay}
{isGeneratingOpening}
{isRefiningOpening}
{isEditingOpening}
@ -1793,8 +2148,8 @@
{customGenre}
{selectedPOV}
{selectedTense}
{expandedSetting}
{protagonist}
expandedSetting={expandedSettingDisplay}
protagonist={protagonistDisplay}
importedEntriesCount={importedEntries.length}
onTitleChange={(v) => (storyTitle = v)}
onGuidanceChange={(v) => (openingGuidance = v)}

View file

@ -481,9 +481,9 @@
<Star class="h-2.5 w-2.5" />
Protagonist
</span>
{:else if character.relationship}
{:else if character.relationship || character.translatedRelationship}
<span class="text-xs text-surface-500"
>{character.relationship}</span
>{character.translatedRelationship ?? character.relationship}</span
>
{/if}
</div>

View file

@ -140,16 +140,16 @@
<span class="text-sm font-medium">Current Location</span>
</div>
<h4 class="mt-1 break-words font-medium text-surface-100">
{story.currentLocation.name}
{story.currentLocation.translatedName ?? story.currentLocation.name}
</h4>
{#if story.currentLocation.description}
{#if story.currentLocation.description || story.currentLocation.translatedDescription}
<div
class="mt-1 space-y-2 rounded-md bg-surface-800/40"
class:max-h-24={currentIsCollapsed && currentNeedsCollapse}
class:overflow-hidden={currentIsCollapsed && currentNeedsCollapse}
>
<p class="break-words text-sm text-surface-400">
{story.currentLocation.description}
{story.currentLocation.translatedDescription ?? story.currentLocation.description}
</p>
</div>
{/if}

View file

@ -1317,6 +1317,79 @@ class AIService {
const translationService = new TranslationService(provider, presetId);
return await translationService.translateUIElements(items, targetLanguage);
}
/**
* Translate suggestions (creative writing plot suggestions).
*/
async translateSuggestions<T extends { text: string; type?: string }>(
suggestions: T[],
targetLanguage: string
): Promise<T[]> {
log('translateSuggestions called', {
count: suggestions.length,
targetLanguage,
});
const presetId = settings.getServicePresetId('translation:suggestions');
const provider = this.getProviderForProfile(settings.getPresetConfig(presetId, 'Translation').profileId);
const translationService = new TranslationService(provider, presetId);
return await translationService.translateSuggestions(suggestions, targetLanguage);
}
/**
* Translate action choices (adventure mode).
*/
async translateActionChoices<T extends { text: string; type?: string }>(
choices: T[],
targetLanguage: string
): Promise<T[]> {
log('translateActionChoices called', {
count: choices.length,
targetLanguage,
});
const presetId = settings.getServicePresetId('translation:actionChoices');
const provider = this.getProviderForProfile(settings.getPresetConfig(presetId, 'Translation').profileId);
const translationService = new TranslationService(provider, presetId);
return await translationService.translateActionChoices(choices, targetLanguage);
}
/**
* Translate wizard content (settings, characters, openings).
*/
async translateWizardContent(
content: string,
targetLanguage: string
): Promise<TranslationResult> {
log('translateWizardContent called', {
contentLength: content.length,
targetLanguage,
});
const presetId = settings.getServicePresetId('translation:wizard');
const provider = this.getProviderForProfile(settings.getPresetConfig(presetId, 'Translation').profileId);
const translationService = new TranslationService(provider, presetId);
return await translationService.translateWizardContent(content, targetLanguage);
}
/**
* Batch translate wizard content - all fields in one API call.
* Much more efficient than calling translateWizardContent for each field.
*/
async translateWizardBatch(
fields: Record<string, string>,
targetLanguage: string
): Promise<Record<string, string>> {
log('translateWizardBatch called', {
fieldCount: Object.keys(fields).length,
targetLanguage,
});
const presetId = settings.getServicePresetId('translation:wizard');
const provider = this.getProviderForProfile(settings.getPresetConfig(presetId, 'Translation').profileId);
const translationService = new TranslationService(provider, presetId);
return await translationService.translateWizardBatch(fields, targetLanguage);
}
}
export const aiService = new AIService();

View file

@ -259,6 +259,295 @@ export class TranslationService {
}
}
/**
* Module 4: Translate suggestions (creative writing plot suggestions)
*/
async translateSuggestions<T extends { text: string; type?: string }>(
suggestions: T[],
targetLanguage: string
): Promise<T[]> {
if (suggestions.length === 0) return [];
// Skip if target is English
if (targetLanguage === 'en') {
return suggestions;
}
log('translateSuggestions called', {
count: suggestions.length,
targetLanguage,
});
const promptContext: PromptContext = {
mode: 'adventure',
pov: 'second',
tense: 'present',
protagonistName: 'the protagonist',
};
const systemPrompt = promptService.renderPrompt('translate-suggestions', promptContext, {
targetLanguage: this.getLanguageName(targetLanguage),
});
const userPrompt = promptService.renderUserPrompt('translate-suggestions', promptContext, {
suggestionsJson: JSON.stringify(suggestions, null, 2),
});
try {
const response = await this.provider.generateResponse({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: this.temperature,
maxTokens: this.maxTokens,
extraBody: this.extraBody,
});
const parsed = tryParseJsonWithHealing<T[]>(response.content);
if (!parsed || !Array.isArray(parsed)) {
log('Failed to parse suggestions translation response');
return suggestions;
}
log('Suggestions translated', {
inputCount: suggestions.length,
outputCount: parsed.length,
});
return parsed;
} catch (error) {
log('Suggestions translation failed:', error);
return suggestions;
}
}
/**
* Module 5: Translate action choices (adventure mode)
*/
async translateActionChoices<T extends { text: string; type?: string }>(
choices: T[],
targetLanguage: string
): Promise<T[]> {
if (choices.length === 0) return [];
// Skip if target is English
if (targetLanguage === 'en') {
return choices;
}
log('translateActionChoices called', {
count: choices.length,
targetLanguage,
});
const promptContext: PromptContext = {
mode: 'adventure',
pov: 'second',
tense: 'present',
protagonistName: 'the protagonist',
};
const systemPrompt = promptService.renderPrompt('translate-action-choices', promptContext, {
targetLanguage: this.getLanguageName(targetLanguage),
});
const userPrompt = promptService.renderUserPrompt('translate-action-choices', promptContext, {
choicesJson: JSON.stringify(choices, null, 2),
});
try {
const response = await this.provider.generateResponse({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: this.temperature,
maxTokens: this.maxTokens,
extraBody: this.extraBody,
});
const parsed = tryParseJsonWithHealing<T[]>(response.content);
if (!parsed || !Array.isArray(parsed)) {
log('Failed to parse action choices translation response');
return choices;
}
log('Action choices translated', {
inputCount: choices.length,
outputCount: parsed.length,
});
return parsed;
} catch (error) {
log('Action choices translation failed:', error);
return choices;
}
}
/**
* Module 6: Translate wizard content (settings, characters, openings)
*/
async translateWizardContent(
content: string,
targetLanguage: string
): Promise<TranslationResult> {
// Skip if target is English
if (targetLanguage === 'en') {
return { translatedContent: content };
}
log('translateWizardContent called', {
contentLength: content.length,
targetLanguage,
});
const promptContext: PromptContext = {
mode: 'adventure',
pov: 'second',
tense: 'present',
protagonistName: 'the protagonist',
};
const systemPrompt = promptService.renderPrompt('translate-wizard-content', promptContext, {
targetLanguage: this.getLanguageName(targetLanguage),
});
const userPrompt = promptService.renderUserPrompt('translate-wizard-content', promptContext, {
content,
});
try {
const response = await this.provider.generateResponse({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: this.temperature,
maxTokens: this.maxTokens,
extraBody: this.extraBody,
});
log('Wizard content translated', {
originalLength: content.length,
translatedLength: response.content.length,
});
return { translatedContent: response.content.trim() };
} catch (error) {
log('Wizard content translation failed:', error);
throw error;
}
}
/**
* Module 7: Batch translate wizard content (all fields in one API call)
* Takes a flat object of labeled strings and returns translations for all.
*/
async translateWizardBatch(
fields: Record<string, string>,
targetLanguage: string
): Promise<Record<string, string>> {
// Skip if target is English
if (targetLanguage === 'en') {
return fields;
}
// Filter out empty fields
const nonEmptyFields: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
if (value && value.trim()) {
nonEmptyFields[key] = value;
}
}
// If no fields to translate, return original
if (Object.keys(nonEmptyFields).length === 0) {
return fields;
}
log('translateWizardBatch called', {
fieldCount: Object.keys(nonEmptyFields).length,
targetLanguage,
});
const languageName = this.getLanguageName(targetLanguage);
// Build numbered format for more reliable parsing
const fieldKeys = Object.keys(nonEmptyFields);
const inputLines = fieldKeys.map((key, i) => `[${i + 1}] ${nonEmptyFields[key]}`);
const inputText = inputLines.join('\n\n');
const systemPrompt = `You are a translator. Translate each numbered item to ${languageName}.
RULES:
- Output ALL ${fieldKeys.length} items with their numbers
- Keep the exact format: [number] translated text
- For names/proper nouns: translate phonetically or keep as-is, but ALWAYS include the item
- Do not skip any items, do not add explanations
Example input:
[1] Hello world
[2] John Smith
Example output:
[1] Hola mundo
[2] John Smith`;
const userPrompt = `Translate these ${fieldKeys.length} items to ${languageName}:\n\n${inputText}`;
try {
const response = await this.provider.generateResponse({
model: this.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: this.temperature,
maxTokens: this.maxTokens,
extraBody: this.extraBody,
});
// Parse the response back into a record
const result: Record<string, string> = { ...fields }; // Start with original (fallback)
const responseText = response.content.trim();
// Parse [number] content format - more flexible regex
const itemRegex = /\[(\d+)\]\s*([\s\S]*?)(?=\n*\[\d+\]|$)/g;
let match;
while ((match = itemRegex.exec(responseText)) !== null) {
const index = parseInt(match[1], 10) - 1; // Convert to 0-based
const value = match[2].trim();
if (index >= 0 && index < fieldKeys.length && value) {
result[fieldKeys[index]] = value;
}
}
// Debug: log if we didn't parse all expected fields
const parsedCount = fieldKeys.filter(k => result[k] !== fields[k]).length;
if (parsedCount < fieldKeys.length) {
log('Batch translation parsing may have missed fields', {
expected: fieldKeys,
parsedCount,
responsePreview: responseText.substring(0, 500),
});
}
log('Wizard batch translated', {
inputFields: Object.keys(nonEmptyFields).length,
outputFields: Object.keys(result).length,
});
return result;
} catch (error) {
log('Wizard batch translation failed:', error);
throw error;
}
}
/**
* Check if translation should be performed based on settings
*/

View file

@ -353,8 +353,8 @@ class DatabaseService {
const db = await this.getDb();
const now = Date.now();
await db.execute(
`INSERT INTO story_entries (id, story_id, type, content, parent_id, position, created_at, metadata, branch_id, reasoning)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO story_entries (id, story_id, type, content, parent_id, position, created_at, metadata, branch_id, reasoning, translated_content, translation_language, original_input)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
entry.id,
entry.storyId,
@ -366,6 +366,9 @@ class DatabaseService {
entry.metadata ? JSON.stringify(entry.metadata) : null,
entry.branchId || null,
entry.reasoning || null,
entry.translatedContent || null,
entry.translationLanguage || null,
entry.originalInput || null,
]
);
return { ...entry, createdAt: now };
@ -494,8 +497,8 @@ class DatabaseService {
async addCharacter(character: Character): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO characters (id, story_id, name, description, relationship, traits, visual_descriptors, portrait, status, metadata, branch_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO characters (id, story_id, name, description, relationship, traits, visual_descriptors, portrait, status, metadata, branch_id, translated_name, translated_description, translated_relationship, translation_language)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
character.id,
character.storyId,
@ -508,6 +511,10 @@ class DatabaseService {
character.status,
character.metadata ? JSON.stringify(character.metadata) : null,
character.branchId || null,
character.translatedName || null,
character.translatedDescription || null,
character.translatedRelationship || null,
character.translationLanguage || null,
]
);
}
@ -568,8 +575,8 @@ class DatabaseService {
async addLocation(location: Location): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO locations (id, story_id, name, description, visited, current, connections, metadata, branch_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO locations (id, story_id, name, description, visited, current, connections, metadata, branch_id, translated_name, translated_description, translation_language)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
location.id,
location.storyId,
@ -580,6 +587,9 @@ class DatabaseService {
JSON.stringify(location.connections),
location.metadata ? JSON.stringify(location.metadata) : null,
location.branchId || null,
location.translatedName || null,
location.translatedDescription || null,
location.translationLanguage || null,
]
);
}
@ -1525,6 +1535,7 @@ private mapEmbeddedImage(row: any): EmbeddedImage {
// Translation fields
translatedName: row.translated_name || null,
translatedDescription: row.translated_description || null,
translatedRelationship: row.translated_relationship || null,
translationLanguage: row.translation_language || null,
};
}

View file

@ -802,6 +802,8 @@ export const CONTEXT_PLACEHOLDERS: ContextPlaceholder[] = [
// Translation service
{ id: 'content', name: 'Content', token: 'content', category: 'service', description: 'Text content to translate' },
{ id: 'elements-json', name: 'Elements JSON', token: 'elementsJson', category: 'service', description: 'JSON array of UI elements to translate (with id, text, type)' },
{ id: 'suggestions-json', name: 'Suggestions JSON', token: 'suggestionsJson', category: 'service', description: 'JSON array of plot suggestions to translate' },
{ id: 'choices-json', name: 'Choices JSON', token: 'choicesJson', category: 'service', description: 'JSON array of action choices to translate' },
];
/**
@ -2760,6 +2762,63 @@ Respond with a JSON array in the same format with translated text.`,
userContent: `{{elementsJson}}`,
};
const translateSuggestionsTemplate: PromptTemplate = {
id: 'translate-suggestions',
name: 'Translate Suggestions',
category: 'service',
description: 'Translates creative writing plot suggestions',
content: `You are translating plot suggestions for interactive fiction to {{targetLanguage}}.
Translate the JSON array of suggestions below. For each item:
- Translate the "text" field (the suggestion content)
- Keep the "type" field unchanged (action, dialogue, revelation, twist)
- Preserve character names and proper nouns
- Maintain the tone and creative intent
Respond with a JSON array in the same format with translated text.`,
userContent: `{{suggestionsJson}}`,
};
const translateActionChoicesTemplate: PromptTemplate = {
id: 'translate-action-choices',
name: 'Translate Action Choices',
category: 'service',
description: 'Translates adventure mode action choices',
content: `You are translating action choices for an interactive adventure game to {{targetLanguage}}.
Translate the JSON array of action choices below. For each item:
- Translate the "text" field (the action description)
- Keep the "type" field unchanged (do, say, think, or custom)
- Preserve character names and proper nouns
- Match the tone and style (casual, urgent, dramatic, etc.)
Respond with a JSON array in the same format with translated text.`,
userContent: `{{choicesJson}}`,
};
const translateWizardContentTemplate: PromptTemplate = {
id: 'translate-wizard-content',
name: 'Translate Wizard Content',
category: 'service',
description: 'Translates story wizard generated content',
content: `You are translating story content for a creative writing wizard to {{targetLanguage}}.
The content may include:
- Setting descriptions and world-building details
- Character names, descriptions, backgrounds, and traits
- Story openings and scene descriptions
- Location names and descriptions
Rules:
1. Preserve the original meaning, tone, and creative style
2. Keep proper nouns and character names in their original form (do not translate names)
3. Maintain formatting (paragraphs, lists, etc.)
4. Do not add, remove, or interpret content
Respond with ONLY the translated text, no explanations.`,
userContent: `{{content}}`,
};
// ============================================================================
// COMBINED PROMPT TEMPLATES
// ============================================================================
@ -2792,6 +2851,9 @@ export const PROMPT_TEMPLATES: PromptTemplate[] = [
translateNarrationTemplate,
translateInputTemplate,
translateUITemplate,
translateSuggestionsTemplate,
translateActionChoicesTemplate,
translateWizardContentTemplate,
// Wizard prompts
settingExpansionPromptTemplate,
settingRefinementPromptTemplate,

View file

@ -1257,6 +1257,9 @@ class SettingsStore {
'translation:narration': 'translation',
'translation:input': 'translation',
'translation:ui': 'translation',
'translation:suggestions': 'translation',
'translation:actionChoices': 'translation',
'translation:wizard': 'translation',
});
serviceSpecificSettings = $state<ServiceSpecificSettings>(getDefaultServiceSpecificSettings());

View file

@ -2618,6 +2618,14 @@ async createStoryFromWizard(data: {
systemPrompt: string;
characters: Partial<Character>[];
importedEntries?: ImportedEntry[];
// Translation data (optional)
translations?: {
language: string;
openingScene?: string;
protagonist?: { name?: string; description?: string };
startingLocation?: { name?: string; description?: string };
characters?: { [originalName: string]: { name?: string; description?: string; relationship?: string } };
};
}): Promise<Story> {
log('createStoryFromWizard called', {
title: data.title,
@ -2658,6 +2666,7 @@ settings: {
// Add protagonist
if (data.protagonist.name) {
const protagonistTranslation = data.translations?.protagonist;
const protagonist: Character = {
id: crypto.randomUUID(),
storyId,
@ -2670,6 +2679,9 @@ settings: {
visualDescriptors: data.protagonist.visualDescriptors ?? [],
portrait: data.protagonist.portrait ?? null,
branchId: null, // New stories start on main branch
translatedName: protagonistTranslation?.name ?? null,
translatedDescription: protagonistTranslation?.description ?? null,
translationLanguage: protagonistTranslation ? data.translations?.language ?? null : null,
};
await database.addCharacter(protagonist);
log('Added protagonist:', protagonist.name);
@ -2677,6 +2689,13 @@ settings: {
// Add starting location
if (data.startingLocation.name) {
const locationTranslation = data.translations?.startingLocation;
log('Starting location translation data:', {
hasTranslations: !!data.translations,
hasStartingLocation: !!locationTranslation,
translatedName: locationTranslation?.name,
translatedDesc: locationTranslation?.description?.substring(0, 50),
});
const location: Location = {
id: crypto.randomUUID(),
storyId,
@ -2687,9 +2706,18 @@ settings: {
connections: [],
metadata: { source: 'wizard' },
branchId: null, // New stories start on main branch
translatedName: locationTranslation?.name ?? null,
translatedDescription: locationTranslation?.description ?? null,
translationLanguage: locationTranslation ? data.translations?.language ?? null : null,
};
log('Location object being stored:', {
name: location.name,
translatedName: location.translatedName,
translatedDesc: location.translatedDescription?.substring(0, 50),
translationLanguage: location.translationLanguage,
});
await database.addLocation(location);
log('Added starting location:', location.name);
log('Added starting location:', location.name, 'with translation:', !!location.translatedDescription);
}
// Add initial items
@ -2712,6 +2740,7 @@ settings: {
// Add supporting characters
for (const charData of data.characters) {
if (!charData.name) continue;
const charTranslation = data.translations?.characters?.[charData.name];
const character: Character = {
id: crypto.randomUUID(),
storyId,
@ -2724,6 +2753,10 @@ settings: {
visualDescriptors: charData.visualDescriptors ?? [],
portrait: charData.portrait ?? null,
branchId: null, // New stories start on main branch
translatedName: charTranslation?.name ?? null,
translatedDescription: charTranslation?.description ?? null,
translatedRelationship: charTranslation?.relationship ?? null,
translationLanguage: charTranslation ? data.translations?.language ?? null : null,
};
await database.addCharacter(character);
log('Added supporting character:', character.name);
@ -2742,6 +2775,8 @@ settings: {
position: 0,
metadata: { source: 'wizard', tokenCount, timeStart: { ...baseTime }, timeEnd: { ...baseTime } },
branchId: null,
translatedContent: data.translations?.openingScene ?? null,
translationLanguage: data.translations?.openingScene ? data.translations?.language ?? null : null,
});
log('Added opening scene');
}

View file

@ -147,6 +147,7 @@ export interface Character {
// Translation fields
translatedName?: string | null;
translatedDescription?: string | null;
translatedRelationship?: string | null;
translationLanguage?: string | null;
}