From 5b98f55bc77166de6cb71b27799fac890101fb7b Mon Sep 17 00:00:00 2001 From: Lupus <66938500+Lupus7477@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:22:15 +0100 Subject: [PATCH] feat: add optional AI-based name generation for map entities --- package-lock.json | 6 - .../dynamic/editors/cultures-editor.js | 44 ++ .../dynamic/editors/religions-editor.js | 74 ++++ .../modules/dynamic/editors/states-editor.js | 64 +++ public/modules/ui/ai-generator.js | 3 +- public/modules/ui/burg-editor.js | 12 + public/modules/ui/burgs-overview.js | 34 ++ public/modules/ui/lakes-editor.js | 12 + public/modules/ui/options.js | 87 ++++ public/modules/ui/provinces-editor.js | 27 ++ public/modules/ui/rivers-editor.js | 12 + public/modules/ui/rivers-overview.js | 34 ++ public/modules/ui/routes-editor.js | 20 + public/modules/ui/routes-overview.js | 33 ++ public/modules/ui/tools.js | 184 ++++++++ public/modules/ui/zones-editor.js | 37 ++ src/index.html | 80 ++++ src/modules/ai-name-generator.ts | 419 ++++++++++++++++++ src/modules/ai-providers.ts | 216 +++++++++ src/modules/index.ts | 2 + 20 files changed, 1393 insertions(+), 7 deletions(-) create mode 100644 src/modules/ai-name-generator.ts create mode 100644 src/modules/ai-providers.ts diff --git a/package-lock.json b/package-lock.json index 3396b8f5..dfad0ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1353,7 +1353,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1394,7 +1393,6 @@ "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", @@ -1876,7 +1874,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2163,7 +2160,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2475,7 +2471,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2551,7 +2546,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/public/modules/dynamic/editors/cultures-editor.js b/public/modules/dynamic/editors/cultures-editor.js index dee807db..81e0069f 100644 --- a/public/modules/dynamic/editors/cultures-editor.js +++ b/public/modules/dynamic/editors/cultures-editor.js @@ -59,6 +59,7 @@ function insertEditorHtml() { + @@ -87,6 +88,7 @@ function addListeners() { byId("culturesManuallyCancel").on("click", () => exitCulturesManualAssignment()); byId("culturesEditNamesBase").on("click", editNamesbase); byId("culturesAdd").on("click", enterAddCulturesMode); + byId("culturesRegenerateNamesAi").on("click", regenerateCultureNamesAi); byId("culturesExport").on("click", downloadCulturesCsv); byId("culturesImport").on("click", () => byId("culturesCSVToLoad").click()); byId("culturesCSVToLoad").on("change", uploadCulturesData); @@ -190,6 +192,7 @@ function culturesEditorAddLines() { + @@ -236,6 +239,7 @@ function culturesEditorAddLines() { $body.querySelectorAll("fill-box").forEach($el => $el.on("click", cultureChangeColor)); $body.querySelectorAll("div > input.cultureName").forEach($el => $el.on("input", cultureChangeName)); $body.querySelectorAll("div > span.icon-cw").forEach($el => $el.on("click", cultureRegenerateName)); + $body.querySelectorAll("div > span.icon-robot").forEach($el => $el.on("click", cultureRegenerateNameAi)); $body.querySelectorAll("div > input.cultureExpan").forEach($el => $el.on("change", cultureChangeExpansionism)); $body.querySelectorAll("div > select.cultureType").forEach($el => $el.on("change", cultureChangeType)); $body.querySelectorAll("div > select.cultureBase").forEach($el => $el.on("change", cultureChangeBase)); @@ -354,6 +358,20 @@ function cultureRegenerateName() { pack.cultures[cultureId].name = name; } +async function cultureRegenerateNameAi() { + const cultureId = +this.parentNode.dataset.id; + const $line = this.parentNode; + try { + const name = await AiNames.generateName("culture", cultureId); + $line.querySelector("input.cultureName").value = name; + $line.dataset.name = name; + pack.cultures[cultureId].name = name; + pack.cultures[cultureId].code = abbreviate(name, pack.cultures.map(c => c.code)); + } catch (err) { + if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000); + } +} + function cultureChangeExpansionism() { const culture = +this.parentNode.dataset.id; this.parentNode.dataset.expansionism = this.value; @@ -850,6 +868,32 @@ function closeCulturesEditor() { exitAddCultureMode(); } +async function regenerateCultureNamesAi() { + const elements = Array.from($body.querySelectorAll(":scope > div")); + const unlocked = elements.filter(el => { + const id = +el.dataset.id; + return id > 0 && !pack.cultures[id].lock; + }); + if (!unlocked.length) return; + + tip("Generating AI names...", false, "info"); + + try { + for (const el of unlocked) { + const cultureId = +el.dataset.id; + const names = await AiNames.generateNames("culture", cultureId, 1); + const name = names[0] || Names.getCulture(cultureId); + el.querySelector("input.cultureName").value = name; + el.dataset.name = name; + pack.cultures[cultureId].name = name; + pack.cultures[cultureId].code = abbreviate(name, pack.cultures.map(c => c.code)); + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } +} + async function uploadCulturesData() { const file = this.files[0]; this.value = ""; diff --git a/public/modules/dynamic/editors/religions-editor.js b/public/modules/dynamic/editors/religions-editor.js index effa90cf..649f43c1 100644 --- a/public/modules/dynamic/editors/religions-editor.js +++ b/public/modules/dynamic/editors/religions-editor.js @@ -74,6 +74,7 @@ function insertEditorHtml() { + @@ -100,6 +101,7 @@ function addListeners() { byId("religionsManuallyCancel").on("click", () => exitReligionsManualAssignment()); byId("religionsAdd").on("click", enterAddReligionMode); byId("religionsExport").on("click", downloadReligionsCsv); + byId("religionsRegenerateNamesAi").on("click", regenerateReligionNamesAi); byId("religionsRecalculate").on("click", () => recalculateReligions(true)); } @@ -196,12 +198,14 @@ function religionsEditorAddLines() { + + @@ -236,10 +240,12 @@ function religionsEditorAddLines() { }); $body.querySelectorAll("fill-box").forEach(el => el.on("click", religionChangeColor)); $body.querySelectorAll("div > input.religionName").forEach(el => el.on("input", religionChangeName)); + $body.querySelectorAll("div > span.icon-robot").forEach(el => el.on("click", religionRegenerateNameAi)); $body.querySelectorAll("div > select.religionType").forEach(el => el.on("change", religionChangeType)); $body.querySelectorAll("div > input.religionForm").forEach(el => el.on("input", religionChangeForm)); $body.querySelectorAll("div > input.religionDeity").forEach(el => el.on("input", religionChangeDeity)); $body.querySelectorAll("div > span.icon-arrows-cw").forEach(el => el.on("click", regenerateDeity)); + $body.querySelectorAll("div > span.icon-robot-deity").forEach(el => el.on("click", regenerateDeityAi)); $body.querySelectorAll("div > div.religionPopulation").forEach(el => el.on("click", changePopulation)); $body.querySelectorAll("div > select.religionExtent").forEach(el => el.on("change", religionChangeExtent)); $body.querySelectorAll("div > input.religionExpantion").forEach(el => el.on("change", religionChangeExpansionism)); @@ -363,6 +369,21 @@ function religionChangeName() { ); } +async function religionRegenerateNameAi() { + const religionId = +this.parentNode.dataset.id; + const $line = this.parentNode; + const cultureId = pack.religions[religionId].culture; + try { + const name = await AiNames.generateName("religion", cultureId); + $line.querySelector("input.religionName").value = name; + $line.dataset.name = name; + pack.religions[religionId].name = name; + pack.religions[religionId].code = abbreviate(name, pack.religions.map(r => r.code)); + } catch (err) { + if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000); + } +} + function religionChangeType() { const religionId = +this.parentNode.dataset.id; this.parentNode.dataset.type = this.value; @@ -390,6 +411,23 @@ function regenerateDeity() { this.nextElementSibling.value = deity; } +async function regenerateDeityAi() { + const religionId = +this.parentNode.dataset.id; + const religion = pack.religions[religionId]; + const cultureId = religion.culture; + try { + const deity = await AiNames.generateName("deity", cultureId, { + religionType: religion.type, + religionForm: religion.form + }); + this.parentNode.dataset.deity = deity; + pack.religions[religionId].deity = deity; + this.nextElementSibling.value = deity; + } catch (err) { + if (err.message !== "No API key configured") tip("AI deity generation failed: " + err.message, false, "error", 4000); + } +} + function changePopulation() { const religionId = +this.parentNode.dataset.id; const religion = pack.religions[religionId]; @@ -821,6 +859,42 @@ function closeReligionsEditor() { exitAddReligionMode(); } +async function regenerateReligionNamesAi() { + const elements = Array.from($body.querySelectorAll(":scope > div")); + const unlocked = elements.filter(el => { + const id = +el.dataset.id; + return id > 0 && !pack.religions[id].lock; + }); + if (!unlocked.length) return; + + const byCulture = new Map(); + for (const el of unlocked) { + const religionId = +el.dataset.id; + const culture = pack.religions[religionId].culture; + if (!byCulture.has(culture)) byCulture.set(culture, []); + byCulture.get(culture).push({el, religionId}); + } + + tip("Generating AI names...", false, "info"); + + try { + for (const [culture, religions] of byCulture) { + const names = await AiNames.generateNames("religion", culture, religions.length); + for (let i = 0; i < religions.length; i++) { + const name = names[i] || Religions.getRandomName(culture); + const {el, religionId} = religions[i]; + el.querySelector("input.religionName").value = name; + el.dataset.name = name; + pack.religions[religionId].name = name; + pack.religions[religionId].code = abbreviate(name, pack.religions.map(r => r.code)); + } + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } +} + function updateLockStatus() { if (customization) return; diff --git a/public/modules/dynamic/editors/states-editor.js b/public/modules/dynamic/editors/states-editor.js index 6d93a919..ad42b140 100644 --- a/public/modules/dynamic/editors/states-editor.js +++ b/public/modules/dynamic/editors/states-editor.js @@ -78,6 +78,7 @@ function insertEditorHtml() { + @@ -106,6 +107,7 @@ function addListeners() { byId("statesManuallyCancel").on("click", () => exitStatesManualAssignment(false)); byId("statesAdd").on("click", enterAddStateMode); byId("statesMerge").on("click", openStateMergeDialog); + byId("statesRegenerateNamesAi").on("click", regenerateStateNamesAi); byId("statesExport").on("click", downloadStatesCsv); $body.on("click", event => { @@ -400,9 +402,11 @@ function editStateName(state) { // add listeners byId("stateNameEditorShortCulture").on("click", regenerateShortNameCulture); byId("stateNameEditorShortRandom").on("click", regenerateShortNameRandom); + byId("stateNameEditorShortAi").on("click", regenerateShortNameAi); byId("stateNameEditorAddForm").on("click", addCustomForm); byId("stateNameEditorCustomForm").on("change", addCustomForm); byId("stateNameEditorFullRegenerate").on("click", regenerateFullName); + byId("stateNameEditorFullAi").on("click", regenerateFullNameAi); function regenerateShortNameCulture() { const state = +stateNameEditor.dataset.state; @@ -417,6 +421,16 @@ function editStateName(state) { byId("stateNameEditorShort").value = name; } + async function regenerateShortNameAi() { + const state = +stateNameEditor.dataset.state; + const culture = pack.states[state].culture; + try { + byId("stateNameEditorShort").value = await AiNames.generateName("state", culture); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function addCustomForm() { const value = stateNameEditorCustomForm.value; const addModeActive = stateNameEditorCustomForm.style.display === "inline-block"; @@ -440,6 +454,19 @@ function editStateName(state) { } } + async function regenerateFullNameAi() { + const state = +stateNameEditor.dataset.state; + const culture = pack.states[state].culture; + const short = byId("stateNameEditorShort").value; + const form = byId("stateNameEditorSelectForm").value; + try { + const name = await AiNames.generateName("stateFullName", culture, {form: form || "State", stateName: short}); + byId("stateNameEditorFull").value = name; + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function applyNameChange(s) { const nameInput = byId("stateNameEditorShort"); const formSelect = byId("stateNameEditorSelectForm"); @@ -1492,6 +1519,43 @@ function downloadStatesCsv() { downloadFile(csvData, name); } +async function regenerateStateNamesAi() { + const elements = Array.from($body.querySelectorAll(":scope > div")); + const unlocked = elements.filter(el => { + const id = +el.dataset.id; + return id > 0 && !pack.states[id].lock; + }); + if (!unlocked.length) return; + + const byCulture = new Map(); + for (const el of unlocked) { + const stateId = +el.dataset.id; + const culture = pack.states[stateId].culture; + if (!byCulture.has(culture)) byCulture.set(culture, []); + byCulture.get(culture).push({el, stateId}); + } + + tip("Generating AI names...", false, "info"); + + try { + for (const [culture, states] of byCulture) { + const names = await AiNames.generateNames("state", culture, states.length); + for (let i = 0; i < states.length; i++) { + const name = names[i] || Names.getState(Names.getCultureShort(culture), culture); + const {el, stateId} = states[i]; + const s = pack.states[stateId]; + s.name = el.dataset.name = name; + el.querySelector("input.stateName").value = name; + s.fullName = s.formName ? `${s.formName} of ${name}` : name; + labels.select("[data-id='" + stateId + "']").text(s.fullName); + } + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } +} + function closeStatesEditor() { if (customization === 2) exitStatesManualAssignment(true); if (customization === 3) exitAddStateMode(); diff --git a/public/modules/ui/ai-generator.js b/public/modules/ui/ai-generator.js index 8ef13cf6..8662a23a 100644 --- a/public/modules/ui/ai-generator.js +++ b/public/modules/ui/ai-generator.js @@ -87,8 +87,9 @@ async function generateWithAnthropic({key, model, prompt, temperature, onContent async function generateWithOllama({key, model, prompt, temperature, onContent}) { const ollamaModelName = key; // for Ollama, 'key' is the actual model name entered by the user + const ollamaHost = localStorage.getItem("fmg-ai-ollama-host") || "http://localhost:11434"; - const response = await fetch("http://localhost:11434/api/generate", { + const response = await fetch(`${ollamaHost}/api/generate`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ diff --git a/public/modules/ui/burg-editor.js b/public/modules/ui/burg-editor.js index 4232d239..b57e8cd5 100644 --- a/public/modules/ui/burg-editor.js +++ b/public/modules/ui/burg-editor.js @@ -29,6 +29,7 @@ function editBurg(id) { byId("burgType").on("change", changeType); byId("burgCulture").on("change", changeCulture); byId("burgNameReCulture").on("click", generateNameCulture); + byId("burgNameAi").on("click", generateNameAi); byId("burgPopulation").on("change", changePopulation); burgBody.querySelectorAll(".burgFeature").forEach(el => el.on("click", toggleFeature)); byId("burgLinkOpen").on("click", openBurgLink); @@ -149,6 +150,17 @@ function editBurg(id) { changeName(); } + async function generateNameAi() { + const id = +elSelected.attr("data-id"); + const culture = pack.burgs[id].culture; + try { + burgName.value = await AiNames.generateName("burg", culture); + changeName(); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function changePopulation() { const id = +elSelected.attr("data-id"); const burg = pack.burgs[id]; diff --git a/public/modules/ui/burgs-overview.js b/public/modules/ui/burgs-overview.js index 5b061fd4..b99cb816 100644 --- a/public/modules/ui/burgs-overview.js +++ b/public/modules/ui/burgs-overview.js @@ -30,6 +30,7 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) { byId("burgsFilterCulture").addEventListener("change", burgsOverviewAddLines); byId("burgsSearch").addEventListener("input", burgsOverviewAddLines); byId("regenerateBurgNames").addEventListener("click", regenerateNames); + byId("regenerateBurgNamesAi").addEventListener("click", regenerateNamesAi); byId("addNewBurg").addEventListener("click", enterAddBurgMode); byId("burgsExport").addEventListener("click", downloadBurgsData); byId("burgNamesImport").addEventListener("click", renameBurgsInBulk); @@ -233,6 +234,39 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) { }); } + async function regenerateNamesAi() { + const elements = Array.from(body.querySelectorAll(":scope > div")); + const unlocked = elements.filter(el => !pack.burgs[+el.dataset.id].lock); + if (!unlocked.length) return; + + // Group burgs by culture for batch generation + const byCulture = new Map(); + for (const el of unlocked) { + const burgId = +el.dataset.id; + const culture = pack.burgs[burgId].culture; + if (!byCulture.has(culture)) byCulture.set(culture, []); + byCulture.get(culture).push({el, burgId}); + } + + tip("Generating AI names...", false, "info"); + + try { + for (const [culture, burgs] of byCulture) { + const names = await AiNames.generateNames("burg", culture, burgs.length); + for (let i = 0; i < burgs.length; i++) { + const name = names[i] || Names.getCulture(culture); + const {el, burgId} = burgs[i]; + el.querySelector(".burgName").value = name; + pack.burgs[burgId].name = el.dataset.name = name; + burgLabels.select("[data-id='" + burgId + "']").text(name); + } + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function enterAddBurgMode() { if (this.classList.contains("pressed")) return exitAddBurgMode(); customization = 3; diff --git a/public/modules/ui/lakes-editor.js b/public/modules/ui/lakes-editor.js index 55f3fb5b..90047f5b 100644 --- a/public/modules/ui/lakes-editor.js +++ b/public/modules/ui/lakes-editor.js @@ -26,6 +26,7 @@ function editLake() { byId("lakeName").on("input", changeName); byId("lakeNameCulture").on("click", generateNameCulture); byId("lakeNameRandom").on("click", generateNameRandom); + byId("lakeNameAi").on("click", generateNameAi); byId("lakeGroup").on("change", changeLakeGroup); byId("lakeGroupAdd").on("click", toggleNewGroupInput); byId("lakeGroupName").on("change", createNewGroup); @@ -140,6 +141,17 @@ function editLake() { lake.name = lakeName.value = Names.getBase(rand(nameBases.length - 1)); } + async function generateNameAi() { + const lake = getLake(); + const cells = Array.from(pack.cells.i.filter(i => pack.cells.f[i] === lake.i)); + const culture = cells.length ? pack.cells.culture[cells[0]] : 0; + try { + lake.name = lakeName.value = await AiNames.generateName("lake", culture); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function selectLakeGroup() { const lake = getLake(); diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js index 07d77cb0..cfd620cc 100644 --- a/public/modules/ui/options.js +++ b/public/modules/ui/options.js @@ -137,6 +137,11 @@ optionsContent.addEventListener("input", event => { else if (id === "themeHueInput") changeThemeHue(value); else if (id === "themeColorInput") changeDialogsTheme(themeColorInput.value, transparencyInput.value); else if (id === "transparencyInput") changeDialogsTheme(themeColorInput.value, value); + else if (id === "aiNamesCustomPrompt") localStorage.setItem("fmg-ai-custom-prompt", value); + else if (id === "aiNamesLanguageOverride") localStorage.setItem("fmg-ai-language-override", value); + else if (id === "aiNamesOllamaHost") localStorage.setItem("fmg-ai-ollama-host", value); + else if (id === "aiNamesTemperature") localStorage.setItem("fmg-ai-temperature", value); + else if (id === "aiNamesKey") saveAiKey(); }); optionsContent.addEventListener("change", event => { @@ -149,6 +154,7 @@ optionsContent.addEventListener("change", event => { else if (id === "eraInput") changeEra(); else if (id === "stateLabelsModeInput") options.stateLabelsMode = value; else if (id === "azgaarAssistant") toggleAssistant(); + else if (id === "aiNamesModel") changeAiModel(value); }); optionsContent.addEventListener("click", event => { @@ -164,6 +170,7 @@ optionsContent.addEventListener("click", event => { else if (id === "themeColorRestore") restoreDefaultThemeColor(); else if (id === "loadGoogleTranslateButton") loadGoogleTranslate(); else if (id === "resetLanguage") resetLanguage(); + else if (id === "aiNamesKeyHelp") openAiKeyHelp(); }); function mapSizeInputChange() { @@ -191,6 +198,86 @@ function restoreDefaultCanvasSize() { fitMapToScreen(); } +// AI Name Generation settings +const AI_MODELS = { + "gpt-4o-mini": "openai", + "chatgpt-4o-latest": "openai", + "gpt-4o": "openai", + "gpt-4-turbo": "openai", + o3: "openai", + "o3-mini": "openai", + "o3-pro": "openai", + "o4-mini": "openai", + "claude-opus-4-20250514": "anthropic", + "claude-sonnet-4-20250514": "anthropic", + "claude-3-5-haiku-latest": "anthropic", + "claude-3-5-sonnet-latest": "anthropic", + "claude-3-opus-latest": "anthropic", + "ollama (local models)": "ollama" +}; + +function initAiSettings() { + const modelSelect = byId("aiNamesModel"); + if (!modelSelect) return; + + modelSelect.options.length = 0; + Object.keys(AI_MODELS).forEach(m => modelSelect.options.add(new Option(m, m))); + modelSelect.value = localStorage.getItem("fmg-ai-model") || "gpt-4o-mini"; + if (!modelSelect.value || !AI_MODELS[modelSelect.value]) modelSelect.value = "gpt-4o-mini"; + + const provider = AI_MODELS[modelSelect.value]; + byId("aiNamesKey").value = localStorage.getItem(`fmg-ai-kl-${provider}`) || ""; + byId("aiNamesOllamaHost").value = localStorage.getItem("fmg-ai-ollama-host") || "http://localhost:11434"; + byId("aiNamesTemperature").value = localStorage.getItem("fmg-ai-temperature") || "1"; + byId("aiNamesLanguageOverride").value = localStorage.getItem("fmg-ai-language-override") || ""; + byId("aiNamesCustomPrompt").value = localStorage.getItem("fmg-ai-custom-prompt") || ""; + + // show/hide ollama host based on selected model + const ollamaRow = byId("aiNamesOllamaHost").closest("tr"); + ollamaRow.style.display = provider === "ollama" ? "" : "none"; +} + +function changeAiModel(value) { + localStorage.setItem("fmg-ai-model", value); + const provider = AI_MODELS[value]; + + // load key for the selected provider + byId("aiNamesKey").value = localStorage.getItem(`fmg-ai-kl-${provider}`) || ""; + + // show/hide ollama host row + const ollamaRow = byId("aiNamesOllamaHost").closest("tr"); + ollamaRow.style.display = provider === "ollama" ? "" : "none"; +} + +function saveAiKey() { + const model = byId("aiNamesModel").value; + const provider = AI_MODELS[model]; + const key = byId("aiNamesKey").value; + localStorage.setItem(`fmg-ai-kl-${provider}`, key); +} + +function openAiKeyHelp() { + const model = byId("aiNamesModel").value; + const provider = AI_MODELS[model]; + const links = { + openai: "https://platform.openai.com/account/api-keys", + anthropic: "https://console.anthropic.com/account/keys", + ollama: "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Ollama-text-generation" + }; + openURL(links[provider] || links.openai); +} + +// Initialize AI settings when Options tab is first shown +{ + const optionsTab = byId("optionsTab"); + if (optionsTab) { + const origClick = optionsTab.onclick; + optionsTab.addEventListener("click", function () { + initAiSettings(); + }); + } +} + // on map creation function applyGraphSize() { graphWidth = +mapWidthInput.value; diff --git a/public/modules/ui/provinces-editor.js b/public/modules/ui/provinces-editor.js index 35121b94..6f854a9d 100644 --- a/public/modules/ui/provinces-editor.js +++ b/public/modules/ui/provinces-editor.js @@ -542,6 +542,8 @@ function editProvinces() { byId("provinceNameEditorShortRandom").on("click", regenerateShortNameRandom); byId("provinceNameEditorAddForm").on("click", addCustomForm); byId("provinceNameEditorFullRegenerate").on("click", regenerateFullName); + byId("provinceNameEditorShortAi").on("click", regenerateShortNameAi); + byId("provinceNameEditorFullAi").on("click", regenerateFullNameAi); function regenerateShortNameCulture() { const province = +provinceNameEditor.dataset.province; @@ -576,6 +578,31 @@ function editProvinces() { } } + async function regenerateShortNameAi() { + const province = +provinceNameEditor.dataset.province; + const culture = pack.cells.culture[pack.provinces[province].center]; + try { + const name = await AiNames.generateName("province", culture, {form: pack.provinces[province].formName}); + byId("provinceNameEditorShort").value = name; + } catch (err) { + tip("AI generation failed: " + err.message, true, "error", 4000); + } + } + + async function regenerateFullNameAi() { + const short = byId("provinceNameEditorShort").value; + const form = byId("provinceNameEditorSelectForm").value; + if (!form || !short) { regenerateFullName(); return; } + try { + const province = +provinceNameEditor.dataset.province; + const culture = pack.cells.culture[pack.provinces[province].center]; + const fullName = await AiNames.generateName("provinceFullName", culture, {form, stateName: short}); + byId("provinceNameEditorFull").value = fullName; + } catch (err) { + tip("AI generation failed: " + err.message, true, "error", 4000); + } + } + function applyNameChange(p) { p.name = byId("provinceNameEditorShort").value; p.formName = byId("provinceNameEditorSelectForm").value; diff --git a/public/modules/ui/rivers-editor.js b/public/modules/ui/rivers-editor.js index a00d488b..ab46dab2 100644 --- a/public/modules/ui/rivers-editor.js +++ b/public/modules/ui/rivers-editor.js @@ -45,6 +45,7 @@ function editRiver(id) { byId("riverType").on("input", changeType); byId("riverNameCulture").on("click", generateNameCulture); byId("riverNameRandom").on("click", generateNameRandom); + byId("riverNameAi").on("click", generateNameAi); byId("riverMainstem").on("change", changeParent); byId("riverSourceWidth").on("input", changeSourceWidth); byId("riverWidthFactor").on("input", changeWidthFactor); @@ -212,6 +213,17 @@ function editRiver(id) { if (r) r.name = riverName.value = Names.getBase(rand(nameBases.length - 1)); } + async function generateNameAi() { + const r = getRiver(); + if (!r) return; + const culture = pack.cells.culture[r.mouth]; + try { + r.name = riverName.value = await AiNames.generateName("river", culture); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } + function changeParent() { const r = getRiver(); r.parent = +this.value; diff --git a/public/modules/ui/rivers-overview.js b/public/modules/ui/rivers-overview.js index c062424f..36a5384b 100644 --- a/public/modules/ui/rivers-overview.js +++ b/public/modules/ui/rivers-overview.js @@ -25,6 +25,7 @@ function overviewRivers() { byId("riverCreateNew").on("click", createRiver); byId("riversBasinHighlight").on("click", toggleBasinsHightlight); byId("riversExport").on("click", downloadRiversData); + byId("riversRegenerateNamesAi").on("click", regenerateRiverNamesAi); byId("riversRemoveAll").on("click", triggerAllRiversRemove); byId("riversSearch").on("input", riversOverviewAddLines); @@ -215,4 +216,37 @@ function overviewRivers() { rivers.selectAll("*").remove(); riversOverviewAddLines(); } + + async function regenerateRiverNamesAi() { + const elements = Array.from(body.querySelectorAll(":scope > div")); + if (!elements.length) return; + + const byCulture = new Map(); + for (const el of elements) { + const riverId = +el.dataset.id; + const river = pack.rivers.find(r => r.i === riverId); + if (!river || river.lock) continue; + const mouth = river.mouth; + const culture = mouth != null ? pack.cells.culture[mouth] : 0; + if (!byCulture.has(culture)) byCulture.set(culture, []); + byCulture.get(culture).push({el, river}); + } + + tip("Generating AI names...", false, "info"); + + try { + for (const [culture, items] of byCulture) { + const names = await AiNames.generateNames("river", culture, items.length); + for (let i = 0; i < items.length; i++) { + const name = names[i] || Rivers.getName(items[i].river.mouth); + const {el, river} = items[i]; + river.name = el.dataset.name = name; + el.querySelector(".riverName").textContent = name; + } + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } } diff --git a/public/modules/ui/routes-editor.js b/public/modules/ui/routes-editor.js index 5556c812..314b26d7 100644 --- a/public/modules/ui/routes-editor.js +++ b/public/modules/ui/routes-editor.js @@ -49,6 +49,7 @@ function editRoute(id) { byId("routeGroupEdit").on("click", editRouteGroups); byId("routeEditStyle").on("click", editRouteGroupStyle); byId("routeGenerateName").on("click", generateName); + byId("routeGenerateNameAi").on("click", generateNameAi); function getRoute() { const routeId = +elSelected.attr("id").slice(5); @@ -351,6 +352,25 @@ function editRoute(id) { route.name = routeName.value = Routes.generateName(route); } + async function generateNameAi() { + const route = getRoute(); + const connectedBurgs = []; + for (const [_x, _y, cellId] of route.points) { + const burgId = pack.cells.burg[cellId]; + if (burgId && pack.burgs[burgId]) connectedBurgs.push(pack.burgs[burgId].name); + } + const culture = pack.cells.culture[route.points[0][2]] || 0; + try { + const name = await AiNames.generateName("route", culture, { + routeGroup: route.group, + connectedBurgs: connectedBurgs.slice(0, 3) + }); + route.name = routeName.value = name; + } catch (err) { + if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000); + } + } + function showRouteElevationProfile() { const route = getRoute(); const length = rn(route.length * distanceScale); diff --git a/public/modules/ui/routes-overview.js b/public/modules/ui/routes-overview.js index 883df3de..fd1ab7aa 100644 --- a/public/modules/ui/routes-overview.js +++ b/public/modules/ui/routes-overview.js @@ -23,6 +23,7 @@ function overviewRoutes() { byId("routesOverviewRefresh").on("click", routesOverviewAddLines); byId("routesCreateNew").on("click", createRoute); byId("routesExport").on("click", downloadRoutesData); + byId("routesRegenerateNamesAi").on("click", regenerateRouteNamesAi); byId("routesLockAll").on("click", toggleLockAll); byId("routesRemoveAll").on("click", triggerAllRoutesRemove); byId("routesSearch").on("input", routesOverviewAddLines); @@ -190,4 +191,36 @@ function overviewRoutes() { } }); } + + async function regenerateRouteNamesAi() { + const elements = Array.from(body.querySelectorAll(":scope > div")); + if (!elements.length) return; + + const byCulture = new Map(); + for (const el of elements) { + const routeId = +el.dataset.id; + const route = pack.routes.find(r => r.i === routeId); + if (!route || route.lock) continue; + const culture = route.points?.[0] ? pack.cells.culture[route.points[0][2]] : 0; + if (!byCulture.has(culture)) byCulture.set(culture, []); + byCulture.get(culture).push({el, route}); + } + + tip("Generating AI names...", false, "info"); + + try { + for (const [culture, items] of byCulture) { + const names = await AiNames.generateNames("route", culture, items.length); + for (let i = 0; i < items.length; i++) { + const name = names[i] || Routes.generateName(items[i].route); + const {el, route} = items[i]; + route.name = el.dataset.name = name; + el.querySelector("div[data-tip='Route name']").textContent = name; + } + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } } diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index 34c1cd12..0b98663d 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -96,6 +96,7 @@ function processFeatureRegeneration(event, button) { else if (button === "regenerateIce") regenerateIce(); else if (button === "regenerateMarkers") regenerateMarkers(); else if (button === "regenerateZones") regenerateZones(event); + else if (button === "regenerateAiNames") regenerateAllAiNames(); } async function openEmblemEditor() { @@ -583,6 +584,189 @@ function regenerateZones(event) { } } +async function regenerateAllAiNames() { + const {states, cultures, burgs, rivers, religions, routes, zones} = pack; + + tip("Regenerating all names with AI...", false, "info"); + + try { + // Cultures + for (const c of cultures) { + if (!c.i || c.removed || c.lock) continue; + const names = await AiNames.generateNames("culture", c.i, 1); + if (names[0]) c.name = names[0]; + } + + // States + const byCulture = new Map(); + for (const s of states) { + if (!s.i || s.removed || s.lock) continue; + if (!byCulture.has(s.culture)) byCulture.set(s.culture, []); + byCulture.get(s.culture).push(s); + } + for (const [culture, items] of byCulture) { + const names = await AiNames.generateNames("state", culture, items.length); + for (let i = 0; i < items.length; i++) { + const s = items[i]; + s.name = names[i] || s.name; + } + } + + // State full names (batched - 1 API call per culture) + for (const [culture, items] of byCulture) { + const withForm = items.filter(s => s.formName); + const withoutForm = items.filter(s => !s.formName); + for (const s of withoutForm) s.fullName = s.name; + if (withForm.length) { + const inputs = withForm.map(s => ({shortName: s.name, form: s.formName})); + const fullNames = await AiNames.generateFullNamesBatch(inputs, culture); + for (let i = 0; i < withForm.length; i++) { + withForm[i].fullName = fullNames[i] || `${withForm[i].formName} of ${withForm[i].name}`; + } + } + } + + // Burgs + const burgsByCulture = new Map(); + for (const b of burgs) { + if (!b.i || b.removed || b.lock) continue; + if (!burgsByCulture.has(b.culture)) burgsByCulture.set(b.culture, []); + burgsByCulture.get(b.culture).push(b); + } + for (const [culture, items] of burgsByCulture) { + const names = await AiNames.generateNames("burg", culture, items.length); + for (let i = 0; i < items.length; i++) { + items[i].name = names[i] || items[i].name; + } + } + + // Rivers + const riversByCulture = new Map(); + for (const r of rivers) { + if (!r.i || r.lock) continue; + const culture = r.mouth != null ? pack.cells.culture[r.mouth] : 0; + if (!riversByCulture.has(culture)) riversByCulture.set(culture, []); + riversByCulture.get(culture).push(r); + } + for (const [culture, items] of riversByCulture) { + const names = await AiNames.generateNames("river", culture, items.length); + for (let i = 0; i < items.length; i++) { + items[i].name = names[i] || items[i].name; + } + } + + // Religions (names + deities in one batch per culture) + const relByCulture = new Map(); + for (const r of religions) { + if (!r.i || r.removed || r.lock) continue; + if (!relByCulture.has(r.culture)) relByCulture.set(r.culture, []); + relByCulture.get(r.culture).push(r); + } + for (const [culture, items] of relByCulture) { + const inputs = items.map(r => ({type: r.type, form: r.form})); + const results = await AiNames.generateReligionsBatch(inputs, culture); + for (let i = 0; i < items.length; i++) { + if (results[i]) { + if (results[i].name) items[i].name = results[i].name; + items[i].deity = results[i].deity; + } + } + } + + // Routes (batched by culture, chunked for large maps) + const CHUNK_SIZE = 50; + const routesByCulture = new Map(); + for (const route of routes) { + if (!route.points || route.lock) continue; + const culture = route.points[0] ? pack.cells.culture[route.points[0][2]] : 0; + if (!routesByCulture.has(culture)) routesByCulture.set(culture, []); + routesByCulture.get(culture).push(route); + } + for (const [culture, items] of routesByCulture) { + for (let start = 0; start < items.length; start += CHUNK_SIZE) { + const chunk = items.slice(start, start + CHUNK_SIZE); + const names = await AiNames.generateNames("route", culture, chunk.length); + for (let i = 0; i < chunk.length; i++) { + chunk[i].name = names[i] || chunk[i].name; + } + } + } + + // Zones (translate types + generate context-aware descriptions) + if (zones.length) { + const uniqueTypes = [...new Set(zones.map(z => z.type))]; + const translatedTypes = await AiNames.translateTerms(uniqueTypes, 0); + const typeMap = Object.fromEntries(uniqueTypes.map((t, i) => [t, translatedTypes[i]])); + + for (const z of zones) { + z.type = typeMap[z.type] || z.type; + } + + // Build context for each zone (biome + nearest burg) + const zoneInputs = zones.map(z => { + const cells = z.cells || []; + const centerCell = cells[Math.floor(cells.length / 2)] || cells[0]; + + let biome = "unknown"; + if (centerCell != null && pack.cells.biome[centerCell] != null) { + biome = biomesData.name[pack.cells.biome[centerCell]] || "unknown"; + } + + let nearBurg = ""; + for (const cellId of cells) { + const burgId = pack.cells.burg[cellId]; + if (burgId && pack.burgs[burgId]) { nearBurg = pack.burgs[burgId].name; break; } + } + + return {type: z.type, biome, nearBurg}; + }); + + const descriptions = await AiNames.generateZoneDescriptionsBatch(zoneInputs, 0); + for (let i = 0; i < zones.length; i++) { + if (descriptions[i]) zones[i].name = descriptions[i]; + } + } + + // Provinces + const provinces = pack.provinces; + const provsByCulture = new Map(); + for (const p of provinces) { + if (!p.i || p.removed || p.lock) continue; + const culture = pack.cells.culture[p.center]; + if (!provsByCulture.has(culture)) provsByCulture.set(culture, []); + provsByCulture.get(culture).push(p); + } + for (const [culture, items] of provsByCulture) { + const names = await AiNames.generateNames("province", culture, items.length); + for (let i = 0; i < items.length; i++) { + items[i].name = names[i] || items[i].name; + } + } + + // Province full names (batched - 1 API call per culture) + for (const [culture, items] of provsByCulture) { + const withForm = items.filter(p => p.formName); + const withoutForm = items.filter(p => !p.formName); + for (const p of withoutForm) p.fullName = p.name; + if (withForm.length) { + const inputs = withForm.map(p => ({shortName: p.name, form: p.formName})); + const fullNames = await AiNames.generateFullNamesBatch(inputs, culture); + for (let i = 0; i < withForm.length; i++) { + withForm[i].fullName = fullNames[i] || `${withForm[i].formName} of ${withForm[i].name}`; + } + } + } + + // Redraw labels + drawStateLabels(); + drawBurgLabels(); + + tip("All AI names regenerated successfully", true, "success", 4000); + } catch (error) { + tip("AI name regeneration failed: " + error.message, true, "error", 5000); + } +} + function unpressClickToAddButton() { addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed")); restoreDefaultEvents(); diff --git a/public/modules/ui/zones-editor.js b/public/modules/ui/zones-editor.js index 85888c4e..8b675dfc 100644 --- a/public/modules/ui/zones-editor.js +++ b/public/modules/ui/zones-editor.js @@ -29,6 +29,7 @@ function editZones() { byId("zonesManuallyApply").on("click", applyZonesManualAssignent); byId("zonesManuallyCancel").on("click", cancelZonesManualAssignent); byId("zonesAdd").on("click", addZonesLayer); + byId("zonesRegenerateNamesAi").on("click", regenerateZoneNamesAi); byId("zonesExport").on("click", downloadZonesData); byId("zonesRemove").on("click", e => e.target.classList.toggle("pressed")); @@ -45,6 +46,7 @@ function editZones() { } if (ev.target.closest("fill-box")) changeFill(ev.target.closest("fill-box").getAttribute("fill"), zone); + else if (ev.target.classList.contains("icon-robot")) generateZoneNameAi(zone, line); else if (ev.target.classList.contains("zonePopulation")) changePopulation(zone); else if (ev.target.classList.contains("zoneRemove")) zoneRemove(zone); else if (ev.target.classList.contains("zoneHide")) toggleVisibility(zone); @@ -94,6 +96,7 @@ function editZones() { }"> +
${cells.length}
@@ -402,6 +405,17 @@ function editZones() { zones.select("#zone" + zone.i).attr("data-description", value); } + async function generateZoneNameAi(zone, line) { + try { + const name = await AiNames.generateName("zone", 0, {zoneType: zone.type}); + zone.name = name; + zones.select("#zone" + zone.i).attr("data-description", name); + line.querySelector("input.zoneName").value = name; + } catch (err) { + if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000); + } + } + function changeType(zone, value) { zone.type = value; zones.select("#zone" + zone.i).attr("data-type", value); @@ -492,4 +506,27 @@ function editZones() { } }); } + + async function regenerateZoneNamesAi() { + const elements = Array.from(body.querySelectorAll(":scope > div.states")); + if (!elements.length) return; + + tip("Generating AI names...", false, "info"); + + try { + const names = await AiNames.generateNames("zone", 0, elements.length); + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + const zone = pack.zones.find(z => z.i === +el.dataset.id); + if (!zone) continue; + const name = names[i] || zone.name; + zone.name = name; + zones.select("#zone" + zone.i).attr("data-description", name); + el.querySelector("input.zoneName").value = name; + } + tip("AI names generated successfully", true, "success", 3000); + } catch (error) { + tip(error.message, true, "error", 4000); + } + } } diff --git a/src/index.html b/src/index.html index e5426654..6e9b3d16 100644 --- a/src/index.html +++ b/src/index.html @@ -1667,6 +1667,7 @@ + @@ -2076,6 +2077,59 @@ +

AI Name Generation:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AI Model + +
API Key + + + +
Ollama Host + +
Temperature + +
Language override + +
Custom prompt + +
+
+
@@ -2906,6 +2967,7 @@ class="icon-book pointer" >
+ 🔊 @@ -3011,6 +3073,7 @@ 🔊 +
@@ -3455,6 +3518,11 @@ data-tip="Generate random name for the burg" class="icon-globe pointer" > +
@@ -4498,6 +4566,7 @@ class="icon-book pointer" > +
@@ -4608,6 +4677,7 @@ data-tick="0" class="icon-arrows-cw pointer" > +
@@ -4773,6 +4843,7 @@ class="icon-book pointer" > +
@@ -4844,6 +4915,7 @@ data-tip="Click to re-generate full name" class="icon-arrows-cw pointer" > +
+ @@ -5444,6 +5517,11 @@ data-tip="Regenerate burg names based on assigned culture" class="icon-retweet" > + +