mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 15:17:23 +01:00
Merge 5b98f55bc7 into 3f9a7702d4
This commit is contained in:
commit
cb2ab1a1dc
20 changed files with 1393 additions and 7 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function insertEditorHtml() {
|
|||
</div>
|
||||
<button id="culturesEditNamesBase" data-tip="Edit a database used for names generation" class="icon-font"></button>
|
||||
<button id="culturesAdd" data-tip="Add a new culture. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button id="culturesRegenerateNamesAi" data-tip="Regenerate culture names using AI" class="icon-robot"></button>
|
||||
<button id="culturesExport" data-tip="Download cultures-related data" class="icon-download"></button>
|
||||
<button id="culturesImport" data-tip="Upload cultures-related data" class="icon-upload"></button>
|
||||
<button id="culturesRecalculate" data-tip="Recalculate cultures based on current values of growth-related attributes" class="icon-retweet"></button>
|
||||
|
|
@ -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() {
|
|||
<input data-tip="Culture name. Click and type to change" class="cultureName" style="width: 7em"
|
||||
value="${c.name}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Regenerate culture name" class="icon-cw hiddenIcon" style="visibility: hidden"></span>
|
||||
<span data-tip="Generate culture name with AI" class="icon-robot hiddenIcon" style="visibility: hidden"></span>
|
||||
<select data-tip="Culture type. Defines growth model. Click to change"
|
||||
class="cultureType">${getTypeOptions(c.type)}</select>
|
||||
<span data-tip="Click to re-generate names for burgs with this culture assigned" class="icon-arrows-cw hide"></span>
|
||||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function insertEditorHtml() {
|
|||
</div>
|
||||
<button id="religionsAdd" data-tip="Add a new religion. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button id="religionsExport" data-tip="Download religions-related data" class="icon-download"></button>
|
||||
<button id="religionsRegenerateNamesAi" data-tip="Regenerate religion names using AI" class="icon-robot"></button>
|
||||
<button id="religionsRecalculate" data-tip="Recalculate religions based on current values of growth-related attributes" class="icon-retweet"></button>
|
||||
<span data-tip="Allow religion center, extent, and expansionism changes to take an immediate effect">
|
||||
<input id="religionsAutoChange" class="checkbox" type="checkbox" />
|
||||
|
|
@ -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() {
|
|||
<fill-box fill="${r.color}"></fill-box>
|
||||
<input data-tip="Religion name. Click and type to change" class="religionName" style="width: 11em"
|
||||
value="${r.name}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Generate religion name with AI" class="icon-robot hiddenIcon" style="visibility: hidden"></span>
|
||||
<select data-tip="Religion type" class="religionType" style="width: 5em">
|
||||
${getTypeOptions(r.type)}
|
||||
</select>
|
||||
<input data-tip="Religion form" class="religionForm" style="width: 6em"
|
||||
value="${r.form}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Click to re-generate supreme deity" class="icon-arrows-cw hide"></span>
|
||||
<span data-tip="Generate deity name with AI" class="icon-robot-deity hiddenIcon hide" style="visibility: hidden"></span>
|
||||
<input data-tip="Religion supreme deity" class="religionDeity hide" style="width: 17em"
|
||||
value="${r.deity || ""}" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Religion area" style="padding-right: 4px" class="icon-map-o hide"></span>
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function insertEditorHtml() {
|
|||
<button id="statesManuallyCancel" data-tip="Cancel assignment" class="icon-cancel"></button>
|
||||
</div>
|
||||
|
||||
<button id="statesRegenerateNamesAi" data-tip="Regenerate state names using AI" class="icon-robot"></button>
|
||||
<button id="statesAdd" data-tip="Add a new state. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button id="statesMerge" data-tip="Merge several states into one" class="icon-layer-group"></button>
|
||||
<button id="statesExport" data-tip="Save state-related data as a text file (.csv)" class="icon-download"></button>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}">
|
||||
<fill-box fill="${color}"></fill-box>
|
||||
<input data-tip="Zone description. Click and type to change" style="width: 11em" class="zoneName" value="${name}" autocorrect="off" spellcheck="false">
|
||||
<span data-tip="Generate zone name with AI" class="icon-robot hiddenIcon" style="visibility: hidden"></span>
|
||||
<input data-tip="Zone type. Click and type to change" class="zoneType" value="${type}">
|
||||
<span data-tip="Cells count" class="icon-check-empty hide"></span>
|
||||
<div data-tip="Cells count" class="stateCells hide">${cells.length}</div>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1667,6 +1667,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<i data-tip="Regenerate map name" onclick="Names.getMapName(true)" class="icon-arrows-cw"></i>
|
||||
<i id="mapNameAi" data-tip="Generate map name with AI" class="icon-robot pointer" style="cursor:pointer" onclick="AiNames.generateName('map', 0).then(n => { mapName.value = n; }).catch(e => tip(e.message, true, 'error', 4000))"></i>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
|
@ -2076,6 +2077,59 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
<p data-tip="AI name generation settings. Applied immediately to all AI name generation">AI Name Generation:</p>
|
||||
<table>
|
||||
<tr data-tip="Select AI model for name generation">
|
||||
<td></td>
|
||||
<td>AI Model</td>
|
||||
<td>
|
||||
<select id="aiNamesModel"></select>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr data-tip="Enter API key for selected AI provider. For Ollama enter the model name (e.g. llama3.2:3b)">
|
||||
<td></td>
|
||||
<td>API Key</td>
|
||||
<td>
|
||||
<input id="aiNamesKey" placeholder="Enter API key" class="long" type="text" />
|
||||
</td>
|
||||
<td>
|
||||
<i id="aiNamesKeyHelp" data-tip="Click to see usage instructions" class="icon-help-circled pointer"></i>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-tip="Ollama server address. Change if running on a different host or port">
|
||||
<td></td>
|
||||
<td>Ollama Host</td>
|
||||
<td>
|
||||
<input id="aiNamesOllamaHost" placeholder="http://localhost:11434" class="long" type="text" />
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr data-tip="Temperature controls randomness. Higher = more creative, lower = more predictable">
|
||||
<td></td>
|
||||
<td>Temperature</td>
|
||||
<td>
|
||||
<input id="aiNamesTemperature" type="number" min="0" max="2" step="0.1" value="1" style="width: 4em" />
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr data-tip="Override the language for all AI-generated names. Leave empty to use culture-based language automatically">
|
||||
<td></td>
|
||||
<td>Language override</td>
|
||||
<td>
|
||||
<input id="aiNamesLanguageOverride" placeholder="e.g. German, Japanese" class="long" type="text" />
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr data-tip="Additional instructions appended to every AI name generation prompt">
|
||||
<td></td>
|
||||
<td>Custom prompt</td>
|
||||
<td colspan="2">
|
||||
<textarea id="aiNamesCustomPrompt" placeholder="e.g. Names should sound soft and melodic" rows="2" style="width: 100%"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="configureWorld"
|
||||
|
|
@ -2202,6 +2256,12 @@
|
|||
Rivers
|
||||
</button>
|
||||
<button id="regenerateRoutes" data-tip="Click to regenerate all unlocked routes">Routes</button>
|
||||
<button
|
||||
id="regenerateAiNames"
|
||||
data-tip="Regenerate ALL unlocked entity names using AI. Uses culture-based prompts for each entity type"
|
||||
>
|
||||
AI Names
|
||||
</button>
|
||||
<button
|
||||
id="regenerateStates"
|
||||
data-tip="Click to regenerate non-locked states. Emblems and military forces will be regenerated as well, burgs will remain as they are, but capitals will be different"
|
||||
|
|
@ -2821,6 +2881,7 @@
|
|||
class="icon-book pointer"
|
||||
></span>
|
||||
<span id="riverNameRandom" data-tip="Generate random name for the river" class="icon-globe pointer"></span>
|
||||
<span id="riverNameAi" data-tip="Generate name with AI" class="icon-robot pointer"></span>
|
||||
<input id="riverName" data-tip="Type to rename the river" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Speak the name. You can change voice and language in options" class="speaker">🔊</span>
|
||||
</div>
|
||||
|
|
@ -2906,6 +2967,7 @@
|
|||
class="icon-book pointer"
|
||||
></span>
|
||||
<span id="lakeNameRandom" data-tip="Generate random name for the lake" class="icon-globe pointer"></span>
|
||||
<span id="lakeNameAi" data-tip="Generate name with AI" class="icon-robot pointer"></span>
|
||||
<input id="lakeName" data-tip="Type to rename the lake" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Speak the name. You can change voice and language in options" class="speaker">🔊</span>
|
||||
</div>
|
||||
|
|
@ -3011,6 +3073,7 @@
|
|||
<input id="routeName" data-tip="Type to rename the route" autocorrect="off" spellcheck="false" />
|
||||
<span data-tip="Speak the name. You can change voice and language in options" class="speaker">🔊</span>
|
||||
<span id="routeGenerateName" data-tip="Generate route name" class="icon-globe pointer"></span>
|
||||
<span id="routeGenerateNameAi" data-tip="Generate route name with AI" class="icon-robot pointer"></span>
|
||||
</div>
|
||||
|
||||
<div data-tip="Select route group">
|
||||
|
|
@ -3455,6 +3518,11 @@
|
|||
data-tip="Generate random name for the burg"
|
||||
class="icon-globe pointer"
|
||||
></span>
|
||||
<span
|
||||
id="burgNameAi"
|
||||
data-tip="Generate name with AI"
|
||||
class="icon-robot pointer"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div data-tip="Select burg group. Groups defines burg icon, label size and style">
|
||||
|
|
@ -4498,6 +4566,7 @@
|
|||
class="icon-book pointer"
|
||||
></span>
|
||||
<span id="stateNameEditorShortRandom" data-tip="Generate random name" class="icon-globe pointer"></span>
|
||||
<span id="stateNameEditorShortAi" data-tip="Generate name with AI" class="icon-robot pointer"></span>
|
||||
</div>
|
||||
|
||||
<div data-tip="Select form name">
|
||||
|
|
@ -4608,6 +4677,7 @@
|
|||
data-tick="0"
|
||||
class="icon-arrows-cw pointer"
|
||||
></span>
|
||||
<span id="stateNameEditorFullAi" data-tip="Generate full name with AI" class="icon-robot pointer"></span>
|
||||
</div>
|
||||
|
||||
<div data-tip="Uncheck to not update state label on name change" style="padding-block: 0.2em">
|
||||
|
|
@ -4773,6 +4843,7 @@
|
|||
class="icon-book pointer"
|
||||
></span>
|
||||
<span id="provinceNameEditorShortRandom" data-tip="Generate random name" class="icon-globe pointer"></span>
|
||||
<span id="provinceNameEditorShortAi" data-tip="Generate name using AI" class="icon-fleur-de-lis pointer hiddenIcon"></span>
|
||||
</div>
|
||||
|
||||
<div data-tip="Select form name">
|
||||
|
|
@ -4844,6 +4915,7 @@
|
|||
data-tip="Click to re-generate full name"
|
||||
class="icon-arrows-cw pointer"
|
||||
></span>
|
||||
<span id="provinceNameEditorFullAi" data-tip="Generate full name using AI" class="icon-fleur-de-lis pointer hiddenIcon"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -5001,6 +5073,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button id="zonesRegenerateNamesAi" data-tip="Regenerate zone names using AI" class="icon-robot"></button>
|
||||
<button id="zonesAdd" data-tip="Add new zone layer" class="icon-plus"></button>
|
||||
<button id="zonesExport" data-tip="Download zones-related data" class="icon-download"></button>
|
||||
|
||||
|
|
@ -5444,6 +5517,11 @@
|
|||
data-tip="Regenerate burg names based on assigned culture"
|
||||
class="icon-retweet"
|
||||
></button>
|
||||
<button
|
||||
id="regenerateBurgNamesAi"
|
||||
data-tip="Regenerate burg names using AI based on assigned culture"
|
||||
class="icon-robot"
|
||||
></button>
|
||||
<button id="addNewBurg" data-tip="Add a new burg. Hold Shift to add multiple" class="icon-plus"></button>
|
||||
<button
|
||||
id="burgsExport"
|
||||
|
|
@ -5522,6 +5600,7 @@
|
|||
data-tip="Save routes-related data as a text file (.csv)"
|
||||
class="icon-download"
|
||||
></button>
|
||||
<button id="routesRegenerateNamesAi" data-tip="Regenerate route names using AI" class="icon-robot"></button>
|
||||
<button id="routesLockAll" data-tip="Lock or unlock all routes" class="icon-lock"></button>
|
||||
<button id="routesRemoveAll" data-tip="Remove all routes" class="icon-trash"></button>
|
||||
<label for="routesSearch" data-tip="Filter by name or group" style="margin-left: 0.2em"
|
||||
|
|
@ -5583,6 +5662,7 @@
|
|||
data-tip="Save rivers-related data as a text file (.csv)"
|
||||
class="icon-download"
|
||||
></button>
|
||||
<button id="riversRegenerateNamesAi" data-tip="Regenerate river names using AI" class="icon-robot"></button>
|
||||
<button id="riversRemoveAll" data-tip="Remove all rivers" class="icon-trash"></button>
|
||||
<label for="riversSearch" data-tip="Filter by name, type or basin" style="margin-left: 0.2em"
|
||||
>Search: <input id="riversSearch" type="search"
|
||||
|
|
|
|||
419
src/modules/ai-name-generator.ts
Normal file
419
src/modules/ai-name-generator.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import {generateText, hasApiKey, getLanguageOverride, getCustomPrompt} from "./ai-providers";
|
||||
|
||||
export type EntityType = "burg" | "state" | "stateFullName" | "province" | "provinceFullName" | "river" | "lake" | "culture" | "religion" | "deity" | "marker" | "zone" | "route" | "map";
|
||||
|
||||
export interface EntityContext {
|
||||
form?: string;
|
||||
stateName?: string;
|
||||
length?: number;
|
||||
width?: number;
|
||||
discharge?: number;
|
||||
routeGroup?: string;
|
||||
connectedBurgs?: string[];
|
||||
religionType?: string;
|
||||
religionForm?: string;
|
||||
zoneType?: string;
|
||||
zoneBiome?: string;
|
||||
zoneNearBurg?: string;
|
||||
}
|
||||
|
||||
// Map nameBase index to language/culture name for prompt context
|
||||
const BASE_TO_LANGUAGE: Record<number, string> = {
|
||||
0: "German",
|
||||
1: "English",
|
||||
2: "French",
|
||||
3: "Italian",
|
||||
4: "Spanish",
|
||||
5: "Ruthenian/Slavic",
|
||||
6: "Nordic/Scandinavian",
|
||||
7: "Greek",
|
||||
8: "Roman/Latin",
|
||||
9: "Finnish",
|
||||
10: "Korean",
|
||||
11: "Chinese",
|
||||
12: "Japanese",
|
||||
13: "Portuguese",
|
||||
14: "Nahuatl/Aztec",
|
||||
15: "Hungarian",
|
||||
16: "Turkish",
|
||||
17: "Berber",
|
||||
18: "Arabic",
|
||||
19: "Inuit",
|
||||
20: "Basque",
|
||||
21: "Nigerian",
|
||||
22: "Celtic",
|
||||
23: "Mesopotamian",
|
||||
24: "Iranian/Persian",
|
||||
25: "Hawaiian",
|
||||
26: "Kannada/Indian",
|
||||
27: "Quechua/Andean",
|
||||
28: "Swahili/East African",
|
||||
29: "Vietnamese",
|
||||
30: "Cantonese/Chinese",
|
||||
31: "Mongolian",
|
||||
32: "Human Generic Fantasy",
|
||||
33: "Elven Fantasy",
|
||||
34: "Dark Elven Fantasy",
|
||||
35: "Dwarven Fantasy",
|
||||
36: "Goblin Fantasy",
|
||||
37: "Orcish Fantasy",
|
||||
38: "Giant Fantasy",
|
||||
39: "Draconic Fantasy",
|
||||
40: "Arachnid Fantasy",
|
||||
41: "Serpent Fantasy",
|
||||
42: "Levantine"
|
||||
};
|
||||
|
||||
const ENTITY_DESCRIPTIONS: Record<EntityType, string> = {
|
||||
burg: "a city or town",
|
||||
state: "a country or state",
|
||||
stateFullName: "the full official name of a country including its translated governmental form",
|
||||
province: "a province or administrative subdivision",
|
||||
provinceFullName: "the full official name of a province including its translated governmental form",
|
||||
river: "a river",
|
||||
lake: "a lake",
|
||||
culture: "a culture or people",
|
||||
religion: "a religion or faith",
|
||||
deity: "a supreme deity with an epithet (e.g. 'Arath, The Eternal')",
|
||||
marker: "a geographic landmark or point of interest",
|
||||
zone: "a geographic zone or region (short descriptive name, 2-4 words)",
|
||||
route: "a trade route or road",
|
||||
map: "a fantasy world or continent"
|
||||
};
|
||||
|
||||
function getLanguageName(base: number): string {
|
||||
return BASE_TO_LANGUAGE[base] || "Fantasy";
|
||||
}
|
||||
|
||||
export function getLanguageForCulture(cultureIndex: number): string {
|
||||
const override = getLanguageOverride();
|
||||
if (override) return override;
|
||||
const culture = pack.cultures[cultureIndex];
|
||||
if (!culture) return "Fantasy";
|
||||
return getLanguageName(culture.base);
|
||||
}
|
||||
|
||||
function getEffectiveLanguage(cultureIndex: number): string {
|
||||
const override = getLanguageOverride();
|
||||
if (override) return override;
|
||||
return getLanguageForCulture(cultureIndex);
|
||||
}
|
||||
|
||||
function buildContextString(entityType: EntityType, ctx?: EntityContext): string {
|
||||
if (!ctx) return "";
|
||||
const parts: string[] = [];
|
||||
|
||||
if (entityType === "state" && ctx.form) {
|
||||
parts.push(`This is a ${ctx.form}.`);
|
||||
}
|
||||
if (entityType === "stateFullName" || entityType === "provinceFullName") {
|
||||
if (ctx.stateName) parts.push(`The short name is "${ctx.stateName}".`);
|
||||
if (ctx.form) parts.push(`The governmental form is "${ctx.form}".`);
|
||||
}
|
||||
if (entityType === "province" && ctx.form) {
|
||||
parts.push(`This is a ${ctx.form}.`);
|
||||
}
|
||||
if (entityType === "river") {
|
||||
if (ctx.length) parts.push(`The river is ${ctx.length} km long.`);
|
||||
if (ctx.width) parts.push(`It is ${ctx.width} m wide at the mouth.`);
|
||||
if (ctx.discharge) parts.push(`It has a discharge of ${ctx.discharge} m³/s.`);
|
||||
}
|
||||
if (entityType === "route") {
|
||||
if (ctx.routeGroup) parts.push(`This is a ${ctx.routeGroup.replace(/s$/, "")}.`);
|
||||
if (ctx.connectedBurgs?.length) parts.push(`It connects ${ctx.connectedBurgs.join(" and ")}.`);
|
||||
}
|
||||
if ((entityType === "religion" || entityType === "deity") && ctx.religionType) {
|
||||
parts.push(`This is a ${ctx.religionType} ${ctx.religionForm || "religion"}.`);
|
||||
}
|
||||
if (entityType === "zone" && ctx.zoneType) {
|
||||
parts.push(`This is a ${ctx.zoneType} zone.`);
|
||||
if (ctx.zoneBiome) parts.push(`The affected area is ${ctx.zoneBiome}.`);
|
||||
if (ctx.zoneNearBurg) parts.push(`Near the settlement of ${ctx.zoneNearBurg}.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function buildFullNamePrompt(language: string, form: string, shortName: string): string {
|
||||
const customPrompt = getCustomPrompt();
|
||||
let prompt = `Translate the governmental form "${form}" into ${language} and combine it with the name "${shortName}" into a natural full official name. Use patterns like "Königreich Eldrida" or "Grafschaft Merkendorf". The result MUST be 2-4 words maximum. Keep the original short name intact. Reply with ONLY the full name, nothing else.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildSingleNamePrompt(entityType: EntityType, language: string, ctx?: EntityContext): string {
|
||||
if ((entityType === "stateFullName" || entityType === "provinceFullName") && ctx?.form && ctx?.stateName) {
|
||||
return buildFullNamePrompt(language, ctx.form, ctx.stateName);
|
||||
}
|
||||
|
||||
const entity = ENTITY_DESCRIPTIONS[entityType];
|
||||
const context = buildContextString(entityType, ctx);
|
||||
const customPrompt = getCustomPrompt();
|
||||
|
||||
let prompt = `Generate a single fantasy name for ${entity} in a ${language} linguistic style.`;
|
||||
if (context) prompt += ` ${context}`;
|
||||
prompt += ` Reply with ONLY the name, nothing else. No quotes, no explanation.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildBatchNamePrompt(entityType: EntityType, language: string, count: number, ctx?: EntityContext): string {
|
||||
const entity = ENTITY_DESCRIPTIONS[entityType];
|
||||
const context = buildContextString(entityType, ctx);
|
||||
const customPrompt = getCustomPrompt();
|
||||
|
||||
let prompt = `Generate ${count} unique fantasy names for ${entity}s in a ${language} linguistic style. All names MUST be unique — no duplicates allowed.`;
|
||||
if (context) prompt += ` ${context}`;
|
||||
prompt += ` Reply with ONLY the names, one per line. No numbering, no quotes, no explanation.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function openAiSetupDialog(): void {
|
||||
tip("No AI key configured. Go to Options tab → AI Name Generation to set up your AI provider.", true, "error", 6000);
|
||||
}
|
||||
|
||||
function cleanResult(result: string): string {
|
||||
return result.replace(/^["'\d.\s]+|["'\s]+$/g, "").split("\n")[0].trim();
|
||||
}
|
||||
|
||||
export async function generateAiName(entityType: EntityType, cultureIndex: number, ctx?: EntityContext): Promise<string> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
const prompt = buildSingleNamePrompt(entityType, language, ctx);
|
||||
const result = await generateText(prompt);
|
||||
return cleanResult(result);
|
||||
}
|
||||
|
||||
export async function generateAiNames(
|
||||
entityType: EntityType,
|
||||
cultureIndex: number,
|
||||
count: number,
|
||||
ctx?: EntityContext
|
||||
): Promise<string[]> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
const prompt = buildBatchNamePrompt(entityType, language, count, ctx);
|
||||
const result = await generateText(prompt);
|
||||
|
||||
const names = result
|
||||
.split("\n")
|
||||
.map(line => line.replace(/^["'\d.\s\-)+]+|["'\s]+$/g, "").trim())
|
||||
.filter(name => name.length > 0);
|
||||
|
||||
// Deduplicate: keep first occurrence, skip duplicates
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const name of names) {
|
||||
const key = name.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(name);
|
||||
}
|
||||
}
|
||||
return unique.slice(0, count);
|
||||
}
|
||||
|
||||
export async function translateTerms(terms: string[], cultureIndex: number): Promise<string[]> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
if (language === "English" || language === "Fantasy") return [...terms];
|
||||
|
||||
const customPrompt = getCustomPrompt();
|
||||
let prompt = `Translate each of the following terms into ${language}. Reply with ONLY the translations, one per line, in the same order. No numbering, no quotes, no explanation.\n${terms.join("\n")}`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
|
||||
const result = await generateText(prompt);
|
||||
const translated = result
|
||||
.split("\n")
|
||||
.map(line => line.replace(/^["'\d.\s\-)+]+|["'\s]+$/g, "").trim())
|
||||
.filter(t => t.length > 0);
|
||||
|
||||
return terms.map((original, i) => translated[i] || original);
|
||||
}
|
||||
|
||||
export interface ReligionInput {
|
||||
type: string;
|
||||
form: string;
|
||||
}
|
||||
|
||||
export interface ReligionResult {
|
||||
name: string;
|
||||
deity: string | null;
|
||||
}
|
||||
|
||||
const STRUCTURED_CHUNK = 30;
|
||||
|
||||
export async function generateReligionsBatch(
|
||||
religions: ReligionInput[],
|
||||
cultureIndex: number
|
||||
): Promise<ReligionResult[]> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
const customPrompt = getCustomPrompt();
|
||||
const results: ReligionResult[] = [];
|
||||
|
||||
for (let start = 0; start < religions.length; start += STRUCTURED_CHUNK) {
|
||||
const chunk = religions.slice(start, start + STRUCTURED_CHUNK);
|
||||
const religionList = chunk
|
||||
.map((r, i) => `${i + 1}. Type=${r.type}, Form=${r.form}`)
|
||||
.join("\n");
|
||||
|
||||
let prompt = `Generate names and supreme deities for ${chunk.length} fantasy religions in a ${language} linguistic style.
|
||||
The religion type and form MUST strongly influence the style of both the religion name and the deity name.
|
||||
For each religion, create a fitting religion name and a supreme deity with an epithet (e.g. "Arath, The Eternal").
|
||||
If the form is "Non-theism" or "Animism", write "none" for the deity.
|
||||
|
||||
${religionList}
|
||||
|
||||
Reply with one line per religion in the format: ReligionName | DeityName
|
||||
No numbering, no quotes, no explanation.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
|
||||
const result = await generateText(prompt);
|
||||
const parsed = result
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.includes("|"))
|
||||
.slice(0, chunk.length)
|
||||
.map(line => {
|
||||
const parts = line.split("|").map(s => s.replace(/^["'\d.\s\-)+]+|["'\s]+$/g, "").trim());
|
||||
const name = parts[0] || "";
|
||||
const deityRaw = parts[1] || "";
|
||||
return {
|
||||
name,
|
||||
deity: deityRaw.toLowerCase() === "none" || !deityRaw ? null : deityRaw
|
||||
};
|
||||
});
|
||||
|
||||
results.push(...parsed);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface ZoneInput {
|
||||
type: string;
|
||||
biome: string;
|
||||
nearBurg: string;
|
||||
}
|
||||
|
||||
export async function generateZoneDescriptionsBatch(
|
||||
zoneInputs: ZoneInput[],
|
||||
cultureIndex: number
|
||||
): Promise<string[]> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
const customPrompt = getCustomPrompt();
|
||||
const results: string[] = [];
|
||||
|
||||
for (let start = 0; start < zoneInputs.length; start += STRUCTURED_CHUNK) {
|
||||
const chunk = zoneInputs.slice(start, start + STRUCTURED_CHUNK);
|
||||
const zoneList = chunk
|
||||
.map((z, i) => `${i + 1}. Type="${z.type}", Biome="${z.biome}", NearSettlement="${z.nearBurg}"`)
|
||||
.join("\n");
|
||||
|
||||
let prompt = `Generate short descriptive names (2-4 words) for ${chunk.length} fantasy map zones in ${language}.
|
||||
These are NOT place names — they describe events or conditions affecting an area.
|
||||
The description must match the zone type and geographic context.
|
||||
Examples: for Flood near river → "Hochwasser am Silberfluss", for Disease near city → "Pest von Grünwald", for Avalanche in mountains → "Lawinenfeld am Nordpass".
|
||||
All names MUST be unique.
|
||||
|
||||
${zoneList}
|
||||
|
||||
Reply with ONLY the descriptions, one per line. No numbering, no quotes, no explanation.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
|
||||
const result = await generateText(prompt);
|
||||
const parsed = result
|
||||
.split("\n")
|
||||
.map(line => line.replace(/^["'\d.\s\-)+]+|["'\s]+$/g, "").trim())
|
||||
.filter(name => name.length > 0)
|
||||
.slice(0, chunk.length);
|
||||
|
||||
results.push(...parsed);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function generateFullNamesBatch(
|
||||
items: {shortName: string; form: string}[],
|
||||
cultureIndex: number
|
||||
): Promise<string[]> {
|
||||
if (!hasApiKey()) {
|
||||
openAiSetupDialog();
|
||||
throw new Error("No API key configured");
|
||||
}
|
||||
|
||||
const language = getEffectiveLanguage(cultureIndex);
|
||||
const customPrompt = getCustomPrompt();
|
||||
const results: string[] = [];
|
||||
|
||||
for (let start = 0; start < items.length; start += STRUCTURED_CHUNK) {
|
||||
const chunk = items.slice(start, start + STRUCTURED_CHUNK);
|
||||
const itemList = chunk
|
||||
.map((item, i) => `${i + 1}. Name="${item.shortName}", Form="${item.form}"`)
|
||||
.join("\n");
|
||||
|
||||
let prompt = `Translate each governmental form into ${language} and combine with the given name into a natural full official name. Use patterns like "Königreich Eldrida" or "Grafschaft Merkendorf". Each result MUST be 2-4 words. Keep the original short names intact.
|
||||
|
||||
${itemList}
|
||||
|
||||
Reply with ONLY the full names, one per line, in the same order. No numbering, no quotes, no explanation.`;
|
||||
if (customPrompt) prompt += ` Additional instructions: ${customPrompt}`;
|
||||
|
||||
const result = await generateText(prompt);
|
||||
const parsed = result
|
||||
.split("\n")
|
||||
.map(line => line.replace(/^["'\d.\s\-)+]+|["'\s]+$/g, "").trim())
|
||||
.filter(name => name.length > 0)
|
||||
.slice(0, chunk.length);
|
||||
|
||||
results.push(...parsed);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var generateWithAi: ((defaultPrompt: string, onApply: (result: string) => void) => void) | undefined;
|
||||
var AiNames: {
|
||||
generateName: (entityType: EntityType, cultureIndex: number, ctx?: EntityContext) => Promise<string>;
|
||||
generateNames: (entityType: EntityType, cultureIndex: number, count: number, ctx?: EntityContext) => Promise<string[]>;
|
||||
translateTerms: (terms: string[], cultureIndex: number) => Promise<string[]>;
|
||||
generateReligionsBatch: (religions: ReligionInput[], cultureIndex: number) => Promise<ReligionResult[]>;
|
||||
generateFullNamesBatch: (items: {shortName: string; form: string}[], cultureIndex: number) => Promise<string[]>;
|
||||
generateZoneDescriptionsBatch: (zones: ZoneInput[], cultureIndex: number) => Promise<string[]>;
|
||||
getLanguageForCulture: typeof getLanguageForCulture;
|
||||
};
|
||||
}
|
||||
|
||||
window.AiNames = {
|
||||
generateName: generateAiName,
|
||||
generateNames: generateAiNames,
|
||||
translateTerms,
|
||||
generateReligionsBatch,
|
||||
generateFullNamesBatch,
|
||||
generateZoneDescriptionsBatch,
|
||||
getLanguageForCulture
|
||||
};
|
||||
216
src/modules/ai-providers.ts
Normal file
216
src/modules/ai-providers.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
export interface AiGenerateOptions {
|
||||
key: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
temperature: number;
|
||||
onContent: (content: string) => void;
|
||||
}
|
||||
|
||||
export interface AiProvider {
|
||||
keyLink: string;
|
||||
generate: (options: AiGenerateOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
const SYSTEM_MESSAGE = "I'm working on my fantasy map.";
|
||||
|
||||
async function generateWithOpenAI({key, model, prompt, temperature, onContent}: AiGenerateOptions) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`
|
||||
};
|
||||
|
||||
const messages = [
|
||||
{role: "system", content: SYSTEM_MESSAGE},
|
||||
{role: "user", content: prompt}
|
||||
];
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({model, messages, temperature, stream: true})
|
||||
});
|
||||
|
||||
const getContent = (json: any) => {
|
||||
const content = json.choices?.[0]?.delta?.content;
|
||||
if (content) onContent(content);
|
||||
};
|
||||
|
||||
await handleStream(response, getContent);
|
||||
}
|
||||
|
||||
async function generateWithAnthropic({key, model, prompt, temperature, onContent}: AiGenerateOptions) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true"
|
||||
};
|
||||
|
||||
const messages = [{role: "user", content: prompt}];
|
||||
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({model, system: SYSTEM_MESSAGE, messages, temperature, max_tokens: 4096, stream: true})
|
||||
});
|
||||
|
||||
const getContent = (json: any) => {
|
||||
const content = json.delta?.text;
|
||||
if (content) onContent(content);
|
||||
};
|
||||
|
||||
await handleStream(response, getContent);
|
||||
}
|
||||
|
||||
async function generateWithOllama({key, model: _model, prompt, temperature, onContent}: AiGenerateOptions) {
|
||||
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(`${ollamaHost}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
model: ollamaModelName,
|
||||
prompt,
|
||||
system: SYSTEM_MESSAGE,
|
||||
options: {temperature},
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
|
||||
const getContent = (json: any) => {
|
||||
if (json.response) onContent(json.response);
|
||||
};
|
||||
|
||||
await handleStream(response, getContent);
|
||||
}
|
||||
|
||||
async function handleStream(response: Response, getContent: (json: any) => void) {
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Failed to generate (${response.status} ${response.statusText})`;
|
||||
try {
|
||||
const json = await response.json();
|
||||
errorMessage = json.error?.message || json.error || errorMessage;
|
||||
} catch {}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (line === "data: [DONE]") break;
|
||||
|
||||
try {
|
||||
const parsed = line.startsWith("data: ") ? JSON.parse(line.slice(6)) : JSON.parse(line);
|
||||
getContent(parsed);
|
||||
} catch (error) {
|
||||
ERROR && console.error("Failed to parse line:", line, error);
|
||||
}
|
||||
}
|
||||
|
||||
buffer = lines.at(-1) || "";
|
||||
}
|
||||
}
|
||||
|
||||
export const PROVIDERS: Record<string, AiProvider> = {
|
||||
openai: {
|
||||
keyLink: "https://platform.openai.com/account/api-keys",
|
||||
generate: generateWithOpenAI
|
||||
},
|
||||
anthropic: {
|
||||
keyLink: "https://console.anthropic.com/account/keys",
|
||||
generate: generateWithAnthropic
|
||||
},
|
||||
ollama: {
|
||||
keyLink: "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Ollama-text-generation",
|
||||
generate: generateWithOllama
|
||||
}
|
||||
};
|
||||
|
||||
export const DEFAULT_MODEL = "gpt-4o-mini";
|
||||
|
||||
export const MODELS: Record<string, string> = {
|
||||
"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"
|
||||
};
|
||||
|
||||
export function getStoredModel(): string {
|
||||
const stored = localStorage.getItem("fmg-ai-model");
|
||||
if (stored && MODELS[stored]) return stored;
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
export function getStoredProvider(): string {
|
||||
return MODELS[getStoredModel()];
|
||||
}
|
||||
|
||||
export function getStoredKey(): string {
|
||||
const provider = getStoredProvider();
|
||||
return localStorage.getItem(`fmg-ai-kl-${provider}`) || "";
|
||||
}
|
||||
|
||||
export function getStoredTemperature(): number {
|
||||
const stored = localStorage.getItem("fmg-ai-temperature");
|
||||
return stored ? parseFloat(stored) : 1;
|
||||
}
|
||||
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getStoredKey();
|
||||
}
|
||||
|
||||
export function getLanguageOverride(): string {
|
||||
return localStorage.getItem("fmg-ai-language-override") || "";
|
||||
}
|
||||
|
||||
export function getCustomPrompt(): string {
|
||||
return localStorage.getItem("fmg-ai-custom-prompt") || "";
|
||||
}
|
||||
|
||||
export function getOllamaHost(): string {
|
||||
return localStorage.getItem("fmg-ai-ollama-host") || "http://localhost:11434";
|
||||
}
|
||||
|
||||
export async function generateText(prompt: string): Promise<string> {
|
||||
const model = getStoredModel();
|
||||
const provider = getStoredProvider();
|
||||
const key = getStoredKey();
|
||||
const temperature = getStoredTemperature();
|
||||
|
||||
if (!key) throw new Error("No API key configured. Please set up your AI provider first.");
|
||||
|
||||
let result = "";
|
||||
await PROVIDERS[provider].generate({
|
||||
key,
|
||||
model,
|
||||
prompt,
|
||||
temperature,
|
||||
onContent: (content: string) => {
|
||||
result += content;
|
||||
}
|
||||
});
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
|
@ -18,3 +18,5 @@ import "./ice";
|
|||
import "./military-generator";
|
||||
import "./markers-generator";
|
||||
import "./fonts";
|
||||
import "./ai-providers";
|
||||
import "./ai-name-generator";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue