feat(dm-dashboard): Wave 8 — spell generator + Open5e equipment/magicitems + UX polish
Wave 8 closes the high-value gaps from the original spec while staying
inside MVP scope (encounters/weapons/items still future work).
Spell generator (mirrors the NPC pattern end-to-end)
- src/lib/ai/types.ts: spellParamsSchema, spellResultSchema, Open5eSpell
- src/lib/ai/openrouter.ts: generateSpell using llama-3.3-70b:free for FREE
- src/lib/ai/kimi.ts: generateSpell using moonshot-v1-8k with json_schema
response_format for PRO
- src/pages/api/dm/ai/generate-spell.ts: auth → rate-limit → optional
/spells/ Open5e reference for balance → AI generate → Zod validate →
increment counter. Spells are returned to client and not persisted
(spec only requires NPC persistence for PRO).
- src/components/dm/AiSpellForm.astro: form (level, school, classes,
tone, suggested name) + inline result card rendering with copy-JSON
- src/components/dm/AiKindSwitcher.astro: segmented NPC | Spell control
wrapping both forms; default tab is NPC
Open5e Reference: equipment + magic items tabs
- src/lib/open5e/client.ts: searchEquipment, getEquipmentItem,
searchMagicItems, getMagicItem (plus EquipmentItem / MagicItem types)
- src/lib/client/open5e-ui.ts: Tab type extended to four, search() and
selectItem() dispatch all four
- src/components/dm/Open5eReference.astro: two more tab buttons; new
renderEquipmentCard/Detail and renderMagicItemCard/Detail; filter
dropdown hidden for new tabs (search-by-name only)
- src/lib/client/translation.ts: TranslationType union now includes
"equipment" | "magicitem"
- src/lib/ai/openrouter.ts: translateOpen5eContent type widened (export
Open5eContentType)
- src/pages/api/dm/translate.ts: ALLOWED_TYPES adds equipment/magicitem
and dispatches to the right Open5e fetcher
UX polish
- src/components/dm/AiQuotaExhausted.astro: dedicated state shown to
authenticated FREE users with remaining=0 — replaces the form with
reset-time message and Boosty upgrade hint
- src/pages/{dm,ru/dm}/index.astro: render AiQuotaExhausted when the
guard passes; otherwise render the kind switcher
- src/middleware/index.ts: remove [Middleware] debug console.logs that
were noisy in prod logs (one line per request)
Verified locally: tsc 0 errors, lint 0 errors, vitest 339/339 passing,
npm run build succeeds without auth env vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import AiNpcForm from "./AiNpcForm.astro";
|
||||
import AiSpellForm from "./AiSpellForm.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
---
|
||||
|
||||
<div data-testid="ai-kind-switcher">
|
||||
<div class="inline-flex rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] p-1 mb-4" role="tablist" aria-label="Тип генератора">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="ai-kind-npc"
|
||||
aria-selected="true"
|
||||
aria-controls="ai-kind-panel-npc"
|
||||
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
|
||||
data-kind="npc"
|
||||
data-testid="ai-kind-tab-npc"
|
||||
>
|
||||
{T.aiKindNpc}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="ai-kind-spell"
|
||||
aria-selected="false"
|
||||
aria-controls="ai-kind-panel-spell"
|
||||
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
|
||||
data-kind="spell"
|
||||
data-testid="ai-kind-tab-spell"
|
||||
>
|
||||
{T.aiKindSpell}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="ai-kind-panel-npc" role="tabpanel" aria-labelledby="ai-kind-npc">
|
||||
<AiNpcForm />
|
||||
</div>
|
||||
<div id="ai-kind-panel-spell" role="tabpanel" aria-labelledby="ai-kind-spell" class="hidden">
|
||||
<AiSpellForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const npcTab = document.getElementById("ai-kind-npc");
|
||||
const spellTab = document.getElementById("ai-kind-spell");
|
||||
const npcPanel = document.getElementById("ai-kind-panel-npc");
|
||||
const spellPanel = document.getElementById("ai-kind-panel-spell");
|
||||
if (!npcTab || !spellTab || !npcPanel || !spellPanel) return;
|
||||
|
||||
const activeClasses =
|
||||
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
|
||||
const inactiveClasses =
|
||||
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
|
||||
|
||||
function setKind(kind) {
|
||||
const isNpc = kind === "npc";
|
||||
npcTab.className = isNpc ? activeClasses : inactiveClasses;
|
||||
spellTab.className = isNpc ? inactiveClasses : activeClasses;
|
||||
npcTab.setAttribute("aria-selected", String(isNpc));
|
||||
spellTab.setAttribute("aria-selected", String(!isNpc));
|
||||
npcPanel.classList.toggle("hidden", !isNpc);
|
||||
spellPanel.classList.toggle("hidden", isNpc);
|
||||
}
|
||||
|
||||
npcTab.addEventListener("click", () => setKind("npc"));
|
||||
spellTab.addEventListener("click", () => setKind("spell"));
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import DmCard from "./DmCard.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
|
||||
export interface Props {
|
||||
resetAt: Date | string | null;
|
||||
}
|
||||
|
||||
const { resetAt } = Astro.props;
|
||||
|
||||
function minutesUntil(target: Date | string | null): number {
|
||||
if (!target) return 60;
|
||||
const t = typeof target === "string" ? new Date(target) : target;
|
||||
const diffMs = t.getTime() - Date.now();
|
||||
return Math.max(1, Math.ceil(diffMs / 60000));
|
||||
}
|
||||
|
||||
const minutes = minutesUntil(resetAt);
|
||||
const resetMsg = T.aiQuotaResetsIn.replace("{minutes}", String(minutes));
|
||||
---
|
||||
|
||||
<DmCard padding="lg" dataTestid="ai-quota-exhausted">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="text-4xl" aria-hidden="true">⏳</div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{T.aiQuotaExhausted}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{resetMsg}</p>
|
||||
<p class="text-sm text-[var(--text-cream)] pt-2 border-t border-[var(--border-color)]">
|
||||
{T.aiQuotaUpgradeHint}
|
||||
</p>
|
||||
</div>
|
||||
</DmCard>
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
import DmCard from "./DmCard.astro";
|
||||
import DmButton from "./DmButton.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
|
||||
export interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const { disabled = false } = Astro.props;
|
||||
|
||||
const selectClasses =
|
||||
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] appearance-none cursor-pointer";
|
||||
|
||||
const inputClasses =
|
||||
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)]";
|
||||
|
||||
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
|
||||
---
|
||||
|
||||
<DmCard dataTestid="ai-spell-form-card">
|
||||
<form id="ai-spell-form" class="space-y-4" data-testid="ai-spell-form">
|
||||
<div>
|
||||
<label for="spell-name" class={labelClasses}>{T.spellFormName}</label>
|
||||
<input
|
||||
id="spell-name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder=""
|
||||
class={inputClasses}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="spell-level" class={labelClasses}>{T.spellFormLevel}</label>
|
||||
<select id="spell-level" name="level" class={selectClasses} required disabled={disabled}>
|
||||
<option value="0">{T.spellCantripLabel}</option>
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="spell-school" class={labelClasses}>{T.spellFormSchool}</label>
|
||||
<select id="spell-school" name="school" class={selectClasses} required disabled={disabled}>
|
||||
<option value="" disabled selected>{T.aiFormSelectOption}</option>
|
||||
<option value="Evocation">{T.schoolEvocation}</option>
|
||||
<option value="Abjuration">{T.schoolAbjuration}</option>
|
||||
<option value="Conjuration">{T.schoolConjuration}</option>
|
||||
<option value="Divination">{T.schoolDivination}</option>
|
||||
<option value="Enchantment">{T.schoolEnchantment}</option>
|
||||
<option value="Illusion">{T.schoolIllusion}</option>
|
||||
<option value="Necromancy">{T.schoolNecromancy}</option>
|
||||
<option value="Transmutation">{T.schoolTransmutation}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="spell-classes" class={labelClasses}>{T.spellFormClasses}</label>
|
||||
<input
|
||||
id="spell-classes"
|
||||
name="classes"
|
||||
type="text"
|
||||
placeholder="Wizard, Sorcerer"
|
||||
class={inputClasses}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="spell-tone" class={labelClasses}>{T.spellFormTone}</label>
|
||||
<select id="spell-tone" name="tone" class={selectClasses} disabled={disabled}>
|
||||
<option value="">{T.aiFormSelectOption}</option>
|
||||
<option value="Serious">{T.toneSerious}</option>
|
||||
<option value="Humorous">{T.toneHumorous}</option>
|
||||
<option value="Dark">{T.toneDark}</option>
|
||||
<option value="Heroic">{T.toneHeroic}</option>
|
||||
<option value="Mysterious">{T.toneMysterious}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="spell-use-reference"
|
||||
name="useOpen5eReference"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-[var(--border-color)] bg-[var(--bg-input)] text-[var(--accent)] accent-[var(--accent)] focus:ring-[var(--gold)] focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--bg-primary)] cursor-pointer"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label for="spell-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
|
||||
{T.aiFormUseReference}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<DmButton
|
||||
id="ai-spell-submit"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="w-full"
|
||||
disabled={disabled}
|
||||
dataTestid="ai-spell-submit"
|
||||
>
|
||||
{T.spellFormSubmit}
|
||||
</DmButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="ai-spell-status"
|
||||
class="text-sm text-center min-h-[1.5rem]"
|
||||
aria-live="polite"
|
||||
data-testid="ai-spell-status"
|
||||
></div>
|
||||
</form>
|
||||
</DmCard>
|
||||
|
||||
<div id="ai-spell-result" class="mt-4 hidden" data-testid="ai-spell-result"></div>
|
||||
|
||||
<script is:inline define:vars={{
|
||||
generatingLabel: T.spellGenerating,
|
||||
submitLabel: T.spellGenerate,
|
||||
rateLimitTemplate: T.spellErrorRateLimit,
|
||||
genericError: T.spellErrorGeneric,
|
||||
labels: {
|
||||
level: T.spellLabelLevel,
|
||||
school: T.spellLabelSchool,
|
||||
castingTime: T.spellLabelCastingTime,
|
||||
range: T.spellLabelRange,
|
||||
components: T.spellLabelComponents,
|
||||
duration: T.spellLabelDuration,
|
||||
classes: T.spellLabelClasses,
|
||||
description: T.spellLabelDescription,
|
||||
higherLevels: T.spellLabelHigherLevels,
|
||||
cantrip: T.spellCantripLabel,
|
||||
copy: T.spellCopyJson,
|
||||
copied: T.spellCopied,
|
||||
},
|
||||
}}>
|
||||
(function () {
|
||||
const form = document.getElementById("ai-spell-form");
|
||||
const submitBtn = document.getElementById("ai-spell-submit");
|
||||
const statusEl = document.getElementById("ai-spell-status");
|
||||
const resultEl = document.getElementById("ai-spell-result");
|
||||
if (!form || !submitBtn || !statusEl || !resultEl) return;
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(s ?? "");
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatLevel(level) {
|
||||
return level === 0 ? labels.cantrip : `${level} ${labels.level}`;
|
||||
}
|
||||
|
||||
function renderSpell(spell) {
|
||||
const json = JSON.stringify(spell, null, 2);
|
||||
const higherLevelsBlock = spell.higher_levels
|
||||
? `<div class="mt-3">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.higherLevels)}</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed">${escapeHtml(spell.higher_levels)}</p>
|
||||
</div>`
|
||||
: "";
|
||||
resultEl.innerHTML = `
|
||||
<div class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5" data-testid="ai-spell-card">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">${escapeHtml(spell.name)}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(formatLevel(spell.level))} · ${escapeHtml(spell.school)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id="spell-copy-btn"
|
||||
class="inline-flex items-center justify-center px-4 py-1.5 text-sm font-semibold rounded-lg bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--gold)] hover:text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] transition-all"
|
||||
data-testid="spell-copy-btn"
|
||||
>${escapeHtml(labels.copy)}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time)}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.castingTime)}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.range)}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.range)}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.components)}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.components)}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.duration)}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.classes)}</div>
|
||||
<p class="text-sm text-[var(--text-primary)]">${escapeHtml(spell.classes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.description)}</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed whitespace-pre-line">${escapeHtml(spell.description)}</p>
|
||||
</div>
|
||||
${higherLevelsBlock}
|
||||
</div>
|
||||
`;
|
||||
resultEl.classList.remove("hidden");
|
||||
const copyBtn = document.getElementById("spell-copy-btn");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(json);
|
||||
const originalText = copyBtn.textContent || "";
|
||||
copyBtn.textContent = labels.copied;
|
||||
setTimeout(() => { copyBtn.textContent = originalText; }, 1500);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message, type) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className =
|
||||
"text-sm text-center min-h-[1.5rem] " +
|
||||
(type === "error" ? "text-[var(--danger)]" : "text-[var(--text-secondary)]");
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
isSubmitting = loading;
|
||||
submitBtn.disabled = loading;
|
||||
submitBtn.textContent = loading ? generatingLabel : submitLabel;
|
||||
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const nameValue = String(formData.get("name") ?? "").trim();
|
||||
const classesValue = String(formData.get("classes") ?? "").trim();
|
||||
const toneValue = String(formData.get("tone") ?? "").trim();
|
||||
|
||||
const body = {
|
||||
level: parseInt(String(formData.get("level")), 10),
|
||||
school: String(formData.get("school")),
|
||||
useOpen5eReference: formData.get("useOpen5eReference") === "on",
|
||||
};
|
||||
if (nameValue) body.name = nameValue;
|
||||
if (classesValue) body.classes = classesValue;
|
||||
if (toneValue) body.tone = toneValue;
|
||||
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dm/ai/generate-spell", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After");
|
||||
const minutes = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 60) : 60;
|
||||
setStatus(rateLimitTemplate.replace("{minutes}", String(minutes)), "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus(genericError, "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.spell) {
|
||||
setStatus(genericError, "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("");
|
||||
renderSpell(result.spell);
|
||||
window.dispatchEvent(new CustomEvent("spell-generated", { detail: result.spell, bubbles: true }));
|
||||
} catch {
|
||||
setStatus(genericError, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -31,6 +31,30 @@ import DmButton from "./DmButton.astro";
|
||||
>
|
||||
Заклинания
|
||||
</button>
|
||||
<button
|
||||
id="o5-tab-equipment"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="o5-panel-equipment"
|
||||
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
|
||||
data-tab="equipment"
|
||||
data-testid="ref-tab-equipment"
|
||||
>
|
||||
Снаряжение
|
||||
</button>
|
||||
<button
|
||||
id="o5-tab-magicitems"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="o5-panel-magicitems"
|
||||
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
|
||||
data-tab="magicitems"
|
||||
data-testid="ref-tab-magicitems"
|
||||
>
|
||||
Магические
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and filters -->
|
||||
@@ -225,18 +249,41 @@ import DmButton from "./DmButton.astro";
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
interface EquipmentItem {
|
||||
key: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
cost?: string | number | Record<string, unknown>;
|
||||
weight?: string | number;
|
||||
damage?: string | Record<string, unknown>;
|
||||
properties?: string[] | Record<string, unknown>[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
interface MagicItem {
|
||||
key: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
rarity?: string;
|
||||
requires_attunement?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
type AnyItem = Monster | Spell | EquipmentItem | MagicItem;
|
||||
type ItemType = "creature" | "spell" | "equipment" | "magicitem";
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: Record<string, unknown> | null): T {
|
||||
function mergeWithTranslation<T extends AnyItem>(item: T, translated: Record<string, unknown> | null): T {
|
||||
if (!translated) return item;
|
||||
return { ...item, ...translated } as T;
|
||||
}
|
||||
|
||||
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
|
||||
function translateButtonHtml(key: string, type: ItemType, isTranslated: boolean): string {
|
||||
const label = isTranslated ? "Переведено" : "Перевести";
|
||||
const disabled = isTranslated ? "disabled" : "";
|
||||
return `
|
||||
@@ -339,6 +386,91 @@ import DmButton from "./DmButton.astro";
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatCost(cost: EquipmentItem["cost"]): string {
|
||||
if (cost == null) return "—";
|
||||
if (typeof cost === "string" || typeof cost === "number") return String(cost);
|
||||
if (typeof cost === "object") {
|
||||
const quantity = (cost as Record<string, unknown>).quantity;
|
||||
const unit = (cost as Record<string, unknown>).unit;
|
||||
if (quantity != null && unit != null) return `${quantity} ${unit}`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function renderEquipmentCard(item: EquipmentItem): string {
|
||||
const cost = formatCost(item.cost);
|
||||
return `
|
||||
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
|
||||
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
|
||||
${escapeHtml(item.name)}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
${escapeHtml(item.category || "—")} · ${escapeHtml(cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-xs text-[var(--text-secondary)]">Вес ${escapeHtml(String(item.weight ?? "—"))}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEquipmentDetail(item: EquipmentItem, isTranslated: boolean): string {
|
||||
const cost = formatCost(item.cost);
|
||||
let html = `
|
||||
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.category || "—")}</p>
|
||||
${translateButtonHtml(item.key, "equipment", isTranslated)}
|
||||
<div class="grid grid-cols-2 gap-3 mt-4">
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(cost)}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">Стоимость</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(String(item.weight ?? "—"))}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">Вес</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (item.desc) {
|
||||
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMagicItemCard(item: MagicItem): string {
|
||||
return `
|
||||
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
|
||||
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
|
||||
${escapeHtml(item.name)}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMagicItemDetail(item: MagicItem, isTranslated: boolean): string {
|
||||
let html = `
|
||||
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}</p>
|
||||
${translateButtonHtml(item.key, "magicitem", isTranslated)}
|
||||
`;
|
||||
if (item.requires_attunement) {
|
||||
html += `<p class="text-sm text-[var(--gold)] mt-2 italic">Требует настройки: ${escapeHtml(item.requires_attunement)}</p>`;
|
||||
}
|
||||
if (item.desc) {
|
||||
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSpellDetail(spell: Spell, isTranslated: boolean): string {
|
||||
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
|
||||
let html = `
|
||||
@@ -391,27 +523,53 @@ import DmButton from "./DmButton.astro";
|
||||
const detailBg = container.querySelector("#o5-detail-bg")!;
|
||||
const tabMonsters = container.querySelector("#o5-tab-monsters")!;
|
||||
const tabSpells = container.querySelector("#o5-tab-spells")!;
|
||||
const tabEquipment = container.querySelector("#o5-tab-equipment")!;
|
||||
const tabMagicitems = container.querySelector("#o5-tab-magicitems")!;
|
||||
const retryBtn = container.querySelector("#o5-retry")!;
|
||||
|
||||
const tabTypeMap: Record<string, ItemType> = {
|
||||
monsters: "creature",
|
||||
spells: "spell",
|
||||
equipment: "equipment",
|
||||
magicitems: "magicitem",
|
||||
};
|
||||
|
||||
const manager = new Open5eUIManager(render);
|
||||
|
||||
function buildFilterOptions() {
|
||||
const options = manager.state.tab === "monsters" ? CR_OPTIONS : LEVEL_OPTIONS;
|
||||
filterSelect.innerHTML = options
|
||||
.map((o) => `<option value="${o.value}">${o.label}</option>`)
|
||||
.join("");
|
||||
const tab = manager.state.tab;
|
||||
if (tab === "monsters") {
|
||||
filterSelect.innerHTML = CR_OPTIONS.map(
|
||||
(o) => `<option value="${o.value}">${o.label}</option>`,
|
||||
).join("");
|
||||
filterSelect.classList.remove("hidden");
|
||||
} else if (tab === "spells") {
|
||||
filterSelect.innerHTML = LEVEL_OPTIONS.map(
|
||||
(o) => `<option value="${o.value}">${o.label}</option>`,
|
||||
).join("");
|
||||
filterSelect.classList.remove("hidden");
|
||||
} else {
|
||||
filterSelect.innerHTML = "";
|
||||
filterSelect.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabs() {
|
||||
const isMonsters = manager.state.tab === "monsters";
|
||||
tabMonsters.setAttribute("aria-selected", String(isMonsters));
|
||||
tabSpells.setAttribute("aria-selected", String(!isMonsters));
|
||||
|
||||
const tab = manager.state.tab;
|
||||
const activeClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-[var(--gold)] text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
|
||||
const inactiveClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
|
||||
|
||||
tabMonsters.className = isMonsters ? activeClasses : inactiveClasses;
|
||||
tabSpells.className = isMonsters ? inactiveClasses : activeClasses;
|
||||
const tabs: Array<[Element, string]> = [
|
||||
[tabMonsters, "monsters"],
|
||||
[tabSpells, "spells"],
|
||||
[tabEquipment, "equipment"],
|
||||
[tabMagicitems, "magicitems"],
|
||||
];
|
||||
for (const [el, tabName] of tabs) {
|
||||
const isActive = tab === tabName;
|
||||
el.setAttribute("aria-selected", String(isActive));
|
||||
el.className = isActive ? activeClasses : inactiveClasses;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -425,16 +583,34 @@ import DmButton from "./DmButton.astro";
|
||||
errorEl.classList.toggle("hidden", !state.error);
|
||||
if (state.error) errorTextEl.textContent = state.error;
|
||||
emptyEl.classList.toggle("hidden", state.results.length > 0 || state.loading || !!state.error);
|
||||
resultsContainer.setAttribute("data-testid", state.tab === "monsters" ? "ref-monsters-list" : "ref-spells-list");
|
||||
const listTestid: Record<string, string> = {
|
||||
monsters: "ref-monsters-list",
|
||||
spells: "ref-spells-list",
|
||||
equipment: "ref-equipment-list",
|
||||
magicitems: "ref-magicitems-list",
|
||||
};
|
||||
resultsContainer.setAttribute("data-testid", listTestid[state.tab] ?? "ref-results-list");
|
||||
|
||||
const paginated = manager.getPaginatedResults();
|
||||
resultsContainer.innerHTML = "";
|
||||
|
||||
function renderCard(tab: typeof state.tab, item: AnyItem): string {
|
||||
if (tab === "monsters") return renderMonsterCard(item as Monster);
|
||||
if (tab === "spells") return renderSpellCard(item as Spell);
|
||||
if (tab === "equipment") return renderEquipmentCard(item as EquipmentItem);
|
||||
return renderMagicItemCard(item as MagicItem);
|
||||
}
|
||||
|
||||
function renderDetail(tab: typeof state.tab, item: AnyItem, translated: boolean): string {
|
||||
if (tab === "monsters") return renderMonsterDetail(item as Monster, translated);
|
||||
if (tab === "spells") return renderSpellDetail(item as Spell, translated);
|
||||
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, translated);
|
||||
return renderMagicItemDetail(item as MagicItem, translated);
|
||||
}
|
||||
|
||||
if (paginated.length > 0) {
|
||||
for (const item of paginated) {
|
||||
const cardHtml = state.tab === "monsters"
|
||||
? renderMonsterCard(item)
|
||||
: renderSpellCard(item);
|
||||
const cardHtml = renderCard(state.tab, item);
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = cardHtml;
|
||||
const card = wrapper.firstElementChild as HTMLElement;
|
||||
@@ -462,13 +638,11 @@ import DmButton from "./DmButton.astro";
|
||||
}
|
||||
|
||||
if (state.selectedItem) {
|
||||
const type = state.tab === "monsters" ? "creature" : "spell";
|
||||
const type = tabTypeMap[state.tab];
|
||||
const cached = getCachedTranslation(state.selectedItem.key, type);
|
||||
const isTranslated = cached !== null;
|
||||
const item = mergeWithTranslation(state.selectedItem, cached);
|
||||
detailContent.innerHTML = state.tab === "monsters"
|
||||
? renderMonsterDetail(item as Monster, isTranslated)
|
||||
: renderSpellDetail(item as Spell, isTranslated);
|
||||
const item = mergeWithTranslation(state.selectedItem as AnyItem, cached);
|
||||
detailContent.innerHTML = renderDetail(state.tab, item, isTranslated);
|
||||
detailOverlay.classList.remove("hidden");
|
||||
detailClose.focus();
|
||||
} else {
|
||||
@@ -488,6 +662,14 @@ import DmButton from "./DmButton.astro";
|
||||
manager.setTab("spells");
|
||||
buildFilterOptions();
|
||||
});
|
||||
tabEquipment.addEventListener("click", () => {
|
||||
manager.setTab("equipment");
|
||||
buildFilterOptions();
|
||||
});
|
||||
tabMagicitems.addEventListener("click", () => {
|
||||
manager.setTab("magicitems");
|
||||
buildFilterOptions();
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
manager.setQueryDebounced(searchInput.value.trim());
|
||||
@@ -524,12 +706,20 @@ import DmButton from "./DmButton.astro";
|
||||
const btn = (e.target as HTMLElement).closest("[data-translate-btn]") as HTMLButtonElement | null;
|
||||
if (!btn || btn.disabled) return;
|
||||
const slug = btn.dataset.slug;
|
||||
const type = btn.dataset.type as "creature" | "spell";
|
||||
const type = btn.dataset.type as ItemType;
|
||||
if (!slug || !type) return;
|
||||
handleTranslate(slug, type, btn);
|
||||
});
|
||||
|
||||
async function handleTranslate(slug: string, type: "creature" | "spell", btn: HTMLButtonElement) {
|
||||
function renderSelectedDetail(item: AnyItem, isTranslated: boolean): string {
|
||||
const tab = manager.state.tab;
|
||||
if (tab === "monsters") return renderMonsterDetail(item as Monster, isTranslated);
|
||||
if (tab === "spells") return renderSpellDetail(item as Spell, isTranslated);
|
||||
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, isTranslated);
|
||||
return renderMagicItemDetail(item as MagicItem, isTranslated);
|
||||
}
|
||||
|
||||
async function handleTranslate(slug: string, type: ItemType, btn: HTMLButtonElement) {
|
||||
const originalText = btn.textContent?.trim() ?? "Перевести";
|
||||
btn.textContent = "Перевод...";
|
||||
btn.disabled = true;
|
||||
@@ -543,10 +733,8 @@ import DmButton from "./DmButton.astro";
|
||||
const state = manager.state;
|
||||
if (state.selectedItem && state.selectedItem.key === slug) {
|
||||
const isTranslated = translated !== null;
|
||||
const item = mergeWithTranslation(state.selectedItem, translated);
|
||||
detailContent.innerHTML = state.tab === "monsters"
|
||||
? renderMonsterDetail(item as Monster, isTranslated)
|
||||
: renderSpellDetail(item as Spell, isTranslated);
|
||||
const item = mergeWithTranslation(state.selectedItem as AnyItem, translated);
|
||||
detailContent.innerHTML = renderSelectedDetail(item, isTranslated);
|
||||
}
|
||||
} catch {
|
||||
btn.textContent = "Ошибка перевода";
|
||||
|
||||
@@ -88,6 +88,9 @@ export const dmTranslations = {
|
||||
aiTitle: "Генератор ИИ",
|
||||
aiSignInCta: "Войдите, чтобы использовать ИИ",
|
||||
aiPlaceholder: "Генератор ИИ появится здесь",
|
||||
aiQuotaExhausted: "Лимит ИИ-генераций исчерпан.",
|
||||
aiQuotaResetsIn: "Новые генерации будут доступны через {minutes} мин.",
|
||||
aiQuotaUpgradeHint: "Хотите больше? Поддержите проект на Boosty — получите PRO с 100 генерациями/час.",
|
||||
|
||||
// AI Form
|
||||
aiFormRace: "Раса",
|
||||
@@ -141,6 +144,47 @@ export const dmTranslations = {
|
||||
npcErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
|
||||
npcErrorValidation: "Проверьте введённые данные.",
|
||||
|
||||
// AI segmented control
|
||||
aiKindNpc: "NPC",
|
||||
aiKindSpell: "Заклинание",
|
||||
|
||||
// AI Spell Form
|
||||
spellFormName: "Подсказка имени (опционально)",
|
||||
spellFormLevel: "Уровень заклинания",
|
||||
spellFormSchool: "Школа",
|
||||
spellFormClasses: "Классы (опционально)",
|
||||
spellFormTone: "Тон",
|
||||
spellFormSubmit: "Сгенерировать заклинание",
|
||||
spellGenerating: "Генерация...",
|
||||
spellGenerate: "Сгенерировать заклинание",
|
||||
spellErrorGeneric: "Ошибка генерации заклинания.",
|
||||
spellErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
|
||||
|
||||
// Spell schools
|
||||
schoolAbjuration: "Ограждение",
|
||||
schoolConjuration: "Призыв",
|
||||
schoolDivination: "Прорицание",
|
||||
schoolEnchantment: "Очарование",
|
||||
schoolEvocation: "Воплощение",
|
||||
schoolIllusion: "Иллюзия",
|
||||
schoolNecromancy: "Некромантия",
|
||||
schoolTransmutation: "Преобразование",
|
||||
|
||||
// Spell result card
|
||||
spellLabelLevel: "Уровень",
|
||||
spellLabelSchool: "Школа",
|
||||
spellLabelCastingTime: "Время накладывания",
|
||||
spellLabelRange: "Дистанция",
|
||||
spellLabelComponents: "Компоненты",
|
||||
spellLabelDuration: "Длительность",
|
||||
spellLabelClasses: "Классы",
|
||||
spellLabelDescription: "Описание",
|
||||
spellLabelHigherLevels: "На больших уровнях",
|
||||
spellCopyJson: "Копировать JSON",
|
||||
spellCopied: "Скопировано",
|
||||
spellRegenerate: "Перегенерировать",
|
||||
spellCantripLabel: "Заговор",
|
||||
|
||||
// AI History
|
||||
historyTitle: "История генераций",
|
||||
historyEmptyPro: "Нет сохранённых NPC",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {
|
||||
npcParamsSchema,
|
||||
npcResultSchema,
|
||||
spellParamsSchema,
|
||||
spellResultSchema,
|
||||
type NPCParams,
|
||||
type NPCResult,
|
||||
type Open5eMonster,
|
||||
type SpellParams,
|
||||
type SpellResult,
|
||||
type Open5eSpell,
|
||||
} from "./types";
|
||||
|
||||
const KIMI_API_URL = "https://api.moonshot.ai/v1/chat/completions";
|
||||
@@ -119,6 +124,182 @@ export class KimiClientError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const spellJsonSchema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
level: { type: "integer" },
|
||||
school: { type: "string" },
|
||||
casting_time: { type: "string" },
|
||||
range: { type: "string" },
|
||||
components: { type: "string" },
|
||||
duration: { type: "string" },
|
||||
classes: { type: "string" },
|
||||
description: { type: "string" },
|
||||
higher_levels: { type: "string" },
|
||||
},
|
||||
required: [
|
||||
"name",
|
||||
"level",
|
||||
"school",
|
||||
"casting_time",
|
||||
"range",
|
||||
"components",
|
||||
"duration",
|
||||
"classes",
|
||||
"description",
|
||||
],
|
||||
};
|
||||
|
||||
function buildSpellSystemPrompt(): string {
|
||||
return [
|
||||
"You are a Dungeons & Dragons 5e spell designer.",
|
||||
"You MUST respond with valid JSON only. Do NOT wrap the response in markdown code blocks.",
|
||||
"Do NOT include any explanatory text outside the JSON object.",
|
||||
"The JSON must exactly match the provided schema.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatSpellReference(spell: Open5eSpell): string {
|
||||
const parts: string[] = [`Open5e Reference Spell: ${spell.name}`];
|
||||
if (spell.level !== undefined) parts.push(`Level: ${spell.level}`);
|
||||
if (spell.school) parts.push(`School: ${spell.school}`);
|
||||
if (spell.casting_time) parts.push(`Casting time: ${spell.casting_time}`);
|
||||
if (spell.range) parts.push(`Range: ${spell.range}`);
|
||||
if (spell.duration) parts.push(`Duration: ${spell.duration}`);
|
||||
if (spell.components) parts.push(`Components: ${spell.components}`);
|
||||
if (spell.desc) {
|
||||
const desc = Array.isArray(spell.desc) ? spell.desc.join(" ") : spell.desc;
|
||||
parts.push(`Description: ${desc}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
|
||||
const lines: string[] = [
|
||||
`Generate a D&D 5e spell with the following parameters:`,
|
||||
`- Level: ${params.level}`,
|
||||
`- School: ${params.school}`,
|
||||
];
|
||||
if (params.classes) lines.push(`- Classes: ${params.classes}`);
|
||||
if (params.tone) lines.push(`- Tone: ${params.tone}`);
|
||||
if (params.name) lines.push(`- Suggested name: ${params.name}`);
|
||||
|
||||
if (reference) {
|
||||
lines.push("");
|
||||
lines.push("Use the following Open5e spell as a balance reference. Do not exceed its power band:");
|
||||
lines.push(formatSpellReference(reference));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Respond with a single JSON object matching this schema:");
|
||||
lines.push(JSON.stringify(spellJsonSchema, null, 2));
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function generateSpell(
|
||||
params: SpellParams,
|
||||
reference?: Open5eSpell
|
||||
): Promise<SpellResult> {
|
||||
spellParamsSchema.parse(params);
|
||||
|
||||
const apiKey = process.env.KIMI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new KimiClientError("KIMI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(KIMI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: KIMI_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: buildSpellSystemPrompt() },
|
||||
{ role: "user", content: buildSpellUserPrompt(params, reference) },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "spell_result",
|
||||
strict: true,
|
||||
schema: spellJsonSchema,
|
||||
},
|
||||
},
|
||||
temperature: 0.7,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => undefined);
|
||||
if (response.status === 429) {
|
||||
throw new KimiClientError("Kimi API rate limit exceeded", response.status, body);
|
||||
}
|
||||
throw new KimiClientError(
|
||||
`Kimi API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let data: {
|
||||
choices?: Array<{ message?: { content?: string | null } }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
throw new KimiClientError("Kimi API returned invalid JSON", undefined, responseText);
|
||||
}
|
||||
|
||||
if (data.error?.message) {
|
||||
throw new KimiClientError(`Kimi API error: ${data.error.message}`);
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new KimiClientError("Kimi API returned empty content");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new KimiClientError("Kimi API returned invalid JSON", undefined, content);
|
||||
}
|
||||
|
||||
const validation = spellResultSchema.safeParse(parsed);
|
||||
if (!validation.success) {
|
||||
throw new KimiClientError(
|
||||
`Kimi API spell validation failed: ${validation.error.message}`,
|
||||
undefined,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
return validation.data;
|
||||
} catch (error) {
|
||||
if (error instanceof KimiClientError) throw error;
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new KimiClientError("Kimi API request timed out after 10s");
|
||||
}
|
||||
throw new KimiClientError(
|
||||
error instanceof Error ? error.message : "Unknown Kimi API error",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateNPC(
|
||||
params: NPCParams,
|
||||
reference?: Open5eMonster
|
||||
|
||||
+129
-2
@@ -1,4 +1,13 @@
|
||||
import { npcResultSchema, type NPCResult, type NPCParams, type Open5eMonster } from "@/lib/ai/types";
|
||||
import {
|
||||
npcResultSchema,
|
||||
spellResultSchema,
|
||||
type NPCResult,
|
||||
type NPCParams,
|
||||
type Open5eMonster,
|
||||
type SpellResult,
|
||||
type SpellParams,
|
||||
type Open5eSpell,
|
||||
} from "@/lib/ai/types";
|
||||
|
||||
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const DEFAULT_MODEL = "llama-3.3-70b:free";
|
||||
@@ -45,9 +54,51 @@ function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildSpellSystemPrompt(): string {
|
||||
return "You are a creative D&D spell designer. Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
|
||||
}
|
||||
|
||||
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
|
||||
const schema = JSON.stringify({
|
||||
name: "string (unique evocative spell name)",
|
||||
level: "integer 0-9 (0 = cantrip)",
|
||||
school: "string (Evocation, Abjuration, Conjuration, Divination, Enchantment, Illusion, Necromancy, Transmutation)",
|
||||
casting_time: "string (e.g. '1 action', '1 bonus action', '1 reaction')",
|
||||
range: "string (e.g. 'Self', '30 feet', '120 feet')",
|
||||
components: "string (e.g. 'V, S', 'V, S, M (a pinch of dust)')",
|
||||
duration: "string (e.g. 'Instantaneous', 'Concentration, up to 1 minute')",
|
||||
classes: "string (comma-separated: e.g. 'Wizard, Sorcerer')",
|
||||
description: "string (2-4 sentences, mechanically clear, evocative)",
|
||||
higher_levels: "string (optional, scaling description; omit or empty if not applicable)",
|
||||
});
|
||||
|
||||
let prompt = `Generate a D&D 5e spell with the following parameters:\n`;
|
||||
prompt += `- Level: ${params.level}\n`;
|
||||
prompt += `- School: ${params.school}\n`;
|
||||
if (params.classes) prompt += `- Classes: ${params.classes}\n`;
|
||||
if (params.tone) prompt += `- Tone: ${params.tone}\n`;
|
||||
if (params.name) prompt += `- Suggested name: ${params.name}\n`;
|
||||
|
||||
if (reference) {
|
||||
prompt += `\nUse this reference SRD spell for balance and structure:\n`;
|
||||
prompt += `- Name: ${reference.name}\n`;
|
||||
if (reference.level !== undefined) prompt += `- Level: ${reference.level}\n`;
|
||||
if (reference.school) prompt += `- School: ${reference.school}\n`;
|
||||
if (reference.casting_time) prompt += `- Casting time: ${reference.casting_time}\n`;
|
||||
if (reference.range) prompt += `- Range: ${reference.range}\n`;
|
||||
if (reference.duration) prompt += `- Duration: ${reference.duration}\n`;
|
||||
prompt += `Stay within the same power band; do not exceed reference damage/effect.\n`;
|
||||
}
|
||||
|
||||
prompt += `\nRespond with valid JSON matching this schema:\n${schema}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export type Open5eContentType = "creature" | "spell" | "equipment" | "magicitem";
|
||||
|
||||
export async function translateOpen5eContent(
|
||||
content: Record<string, unknown>,
|
||||
type: "creature" | "spell",
|
||||
type: Open5eContentType,
|
||||
model: string = DEFAULT_MODEL
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
@@ -202,3 +253,79 @@ export async function generateNPC(
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateSpell(
|
||||
params: SpellParams,
|
||||
reference?: Open5eSpell,
|
||||
model: string = DEFAULT_MODEL
|
||||
): Promise<SpellResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is not set");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro",
|
||||
"X-Title": "Randify.pro DM Dashboard",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: buildSpellSystemPrompt() },
|
||||
{ role: "user", content: buildSpellUserPrompt(params, reference) },
|
||||
],
|
||||
temperature: 0.8,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("Rate limited by OpenRouter (429)");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Empty response from OpenRouter");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in OpenRouter response: ${content}`);
|
||||
}
|
||||
|
||||
const validated = spellResultSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
throw new Error(
|
||||
`Spell schema validation failed: ${validated.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return validated.data;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error("OpenRouter request timed out after 10s", { cause: err });
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,46 @@ export const npcResultSchema = z.object({
|
||||
|
||||
export type NPCResult = z.infer<typeof npcResultSchema>;
|
||||
|
||||
export const spellParamsSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
level: z.number().int().min(0).max(9),
|
||||
school: z.string(),
|
||||
classes: z.string().optional(),
|
||||
tone: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SpellParams = z.infer<typeof spellParamsSchema>;
|
||||
|
||||
export const spellResultSchema = z.object({
|
||||
name: z.string(),
|
||||
level: z.number().int().min(0).max(9),
|
||||
school: z.string(),
|
||||
casting_time: z.string(),
|
||||
range: z.string(),
|
||||
components: z.string(),
|
||||
duration: z.string(),
|
||||
classes: z.string(),
|
||||
description: z.string(),
|
||||
higher_levels: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SpellResult = z.infer<typeof spellResultSchema>;
|
||||
|
||||
export interface Open5eSpell {
|
||||
name: string;
|
||||
level?: number;
|
||||
school?: string;
|
||||
casting_time?: string;
|
||||
range?: string;
|
||||
components?: string;
|
||||
duration?: string;
|
||||
desc?: string | string[];
|
||||
higher_level?: string | string[];
|
||||
ritual?: boolean;
|
||||
concentration?: boolean;
|
||||
classes?: Array<{ name: string }> | string[];
|
||||
}
|
||||
|
||||
export interface Open5eMonster {
|
||||
name: string;
|
||||
size?: string;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
searchMonsters,
|
||||
searchSpells,
|
||||
searchEquipment,
|
||||
searchMagicItems,
|
||||
getMonster,
|
||||
getSpell,
|
||||
getEquipmentItem,
|
||||
getMagicItem,
|
||||
} from "@/lib/open5e/client";
|
||||
|
||||
export type Tab = "monsters" | "spells";
|
||||
export type Tab = "monsters" | "spells" | "equipment" | "magicitems";
|
||||
|
||||
export interface Open5eItem {
|
||||
key: string;
|
||||
@@ -101,11 +105,15 @@ export class Open5eUIManager {
|
||||
? { challenge_rating_decimal: this.state.monsterCrFilter }
|
||||
: undefined;
|
||||
results = await searchMonsters(this.state.query, filters);
|
||||
} else {
|
||||
} else if (this.state.tab === "spells") {
|
||||
const filters = this.state.spellLevelFilter
|
||||
? { level: this.state.spellLevelFilter }
|
||||
: undefined;
|
||||
results = await searchSpells(this.state.query, filters);
|
||||
} else if (this.state.tab === "equipment") {
|
||||
results = await searchEquipment(this.state.query);
|
||||
} else {
|
||||
results = await searchMagicItems(this.state.query);
|
||||
}
|
||||
this.state.results = results;
|
||||
this.state.totalPages = Math.max(
|
||||
@@ -148,8 +156,12 @@ export class Open5eUIManager {
|
||||
try {
|
||||
if (this.state.tab === "monsters") {
|
||||
this.state.selectedItem = await getMonster(key);
|
||||
} else {
|
||||
} else if (this.state.tab === "spells") {
|
||||
this.state.selectedItem = await getSpell(key);
|
||||
} else if (this.state.tab === "equipment") {
|
||||
this.state.selectedItem = await getEquipmentItem(key);
|
||||
} else {
|
||||
this.state.selectedItem = await getMagicItem(key);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.state.error = err instanceof Error ? err.message : "Failed to load details";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type TranslationType = "creature" | "spell" | "equipment" | "magicitem";
|
||||
|
||||
export interface TranslationEntry {
|
||||
slug: string;
|
||||
type: "creature" | "spell";
|
||||
type: TranslationType;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -21,14 +23,14 @@ if (CHANNEL) {
|
||||
|
||||
export function getCachedTranslation(
|
||||
slug: string,
|
||||
type: "creature" | "spell",
|
||||
type: TranslationType,
|
||||
): Record<string, unknown> | null {
|
||||
return CACHE.get(`${type}:${slug}`)?.data ?? null;
|
||||
}
|
||||
|
||||
export function setCachedTranslation(
|
||||
slug: string,
|
||||
type: "creature" | "spell",
|
||||
type: TranslationType,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const entry: TranslationEntry = {
|
||||
@@ -43,7 +45,7 @@ export function setCachedTranslation(
|
||||
|
||||
export async function fetchTranslation(
|
||||
slug: string,
|
||||
type: "creature" | "spell",
|
||||
type: TranslationType,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const cached = getCachedTranslation(slug, type);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -170,3 +170,105 @@ export async function getSpell(key: string): Promise<Spell> {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EquipmentItem {
|
||||
name: string;
|
||||
key: string;
|
||||
category?: string;
|
||||
cost?: string | number | Record<string, unknown>;
|
||||
weight?: string | number;
|
||||
damage?: string | Record<string, unknown>;
|
||||
properties?: string[] | Record<string, unknown>[];
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export async function searchEquipment(
|
||||
query: string,
|
||||
filters?: SearchFilters
|
||||
): Promise<EquipmentItem[]> {
|
||||
const cacheKey = `open5e:v2:equipment:${query}`;
|
||||
const cached = getCached<EquipmentItem[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = buildUrl("equipment/", query, filters);
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set("fields", "name,key,category,cost,weight");
|
||||
|
||||
try {
|
||||
const data = await apiFetch<{ results: EquipmentItem[] }>(urlObj.toString());
|
||||
setCached(cacheKey, data.results);
|
||||
return data.results;
|
||||
} catch (err) {
|
||||
const stale = getCached<EquipmentItem[]>(cacheKey);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEquipmentItem(key: string): Promise<EquipmentItem> {
|
||||
const cacheKey = `open5e:v2:equipment:item:${key}`;
|
||||
const cached = getCached<EquipmentItem>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `${API_BASE}equipment/${key}/`;
|
||||
|
||||
try {
|
||||
const data = await apiFetch<EquipmentItem>(url);
|
||||
setCached(cacheKey, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const stale = getCached<EquipmentItem>(cacheKey);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MagicItem {
|
||||
name: string;
|
||||
key: string;
|
||||
type?: string;
|
||||
rarity?: string;
|
||||
requires_attunement?: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
export async function searchMagicItems(
|
||||
query: string,
|
||||
filters?: SearchFilters
|
||||
): Promise<MagicItem[]> {
|
||||
const cacheKey = `open5e:v2:magicitems:${query}`;
|
||||
const cached = getCached<MagicItem[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = buildUrl("magicitems/", query, filters);
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set("fields", "name,key,type,rarity");
|
||||
|
||||
try {
|
||||
const data = await apiFetch<{ results: MagicItem[] }>(urlObj.toString());
|
||||
setCached(cacheKey, data.results);
|
||||
return data.results;
|
||||
} catch (err) {
|
||||
const stale = getCached<MagicItem[]>(cacheKey);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMagicItem(key: string): Promise<MagicItem> {
|
||||
const cacheKey = `open5e:v2:magicitem:${key}`;
|
||||
const cached = getCached<MagicItem>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `${API_BASE}magicitems/${key}/`;
|
||||
|
||||
try {
|
||||
const data = await apiFetch<MagicItem>(url);
|
||||
setCached(cacheKey, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const stale = getCached<MagicItem>(cacheKey);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-15
@@ -8,28 +8,17 @@ import { eq } from 'drizzle-orm';
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
context.locals.user = null;
|
||||
|
||||
const cookieHeader = context.request.headers.get('cookie');
|
||||
console.log('[Middleware] Cookie header:', cookieHeader ? cookieHeader.slice(0, 50) + '...' : 'none');
|
||||
|
||||
const token = context.cookies.get('auth_token')?.value;
|
||||
if (!token) {
|
||||
console.log('[Middleware] No auth_token cookie found');
|
||||
return next();
|
||||
}
|
||||
|
||||
console.log('[Middleware] auth_token cookie found');
|
||||
if (!token) return next();
|
||||
|
||||
try {
|
||||
await verifyToken(token);
|
||||
const session = await getSession(token);
|
||||
if (!session) {
|
||||
console.log('[Middleware] Session validation failed: no session in DB');
|
||||
context.cookies.delete('auth_token', { path: '/' });
|
||||
return next();
|
||||
}
|
||||
|
||||
console.log('[Middleware] Session validation succeeded');
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -42,13 +31,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
...user,
|
||||
tier: (user.tier ?? 'free') as 'free' | 'pro',
|
||||
};
|
||||
console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name });
|
||||
} else {
|
||||
console.log('[Middleware] No user found for session');
|
||||
context.cookies.delete('auth_token', { path: '/' });
|
||||
}
|
||||
} catch {
|
||||
console.log('[Middleware] Session validation failed: invalid token');
|
||||
context.cookies.delete('auth_token', { path: '/' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { jsonResponse, handleCorsPreflight, createCorsResponse } from "@/lib/cors";
|
||||
import { checkRateLimit, incrementGenerationCounter, getRetryAfterSeconds } from "@/lib/rate-limit";
|
||||
import { generateSpell as generateOpenRouterSpell } from "@/lib/ai/openrouter";
|
||||
import { generateSpell as generateKimiSpell } from "@/lib/ai/kimi";
|
||||
import type { Open5eSpell } from "@/lib/ai/types";
|
||||
import { searchSpells } from "@/lib/open5e/client";
|
||||
import { spellParamsSchema, spellResultSchema, type SpellResult } from "@/lib/ai/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const generateRequestSchema = spellParamsSchema.extend({
|
||||
useOpen5eReference: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function getOrigin(request: Request): string | null {
|
||||
return request.headers.get("origin");
|
||||
}
|
||||
|
||||
function requireAuth(locals: App.Locals, origin: string | null): Response | null {
|
||||
if (!locals.user) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401, origin);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchOpen5eReference(level: number, school: string): Promise<Open5eSpell | null> {
|
||||
try {
|
||||
const spells = await searchSpells(school, { level: String(level) });
|
||||
if (spells.length > 0) {
|
||||
return spells[0] as unknown as Open5eSpell;
|
||||
}
|
||||
const fallback = await searchSpells("", { level: String(level) });
|
||||
if (fallback.length > 0) {
|
||||
return fallback[0] as unknown as Open5eSpell;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const origin = getOrigin(request);
|
||||
|
||||
const authError = requireAuth(locals, origin);
|
||||
if (authError) return authError;
|
||||
|
||||
const user = locals.user!;
|
||||
const tier = user.tier;
|
||||
|
||||
const rateLimit = await checkRateLimit(user.id, tier);
|
||||
if (!rateLimit.allowed) {
|
||||
return createCorsResponse(
|
||||
JSON.stringify({ error: "Rate limit exceeded" }),
|
||||
429,
|
||||
origin,
|
||||
{ "Content-Type": "application/json", "Retry-After": String(getRetryAfterSeconds()) },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: "Invalid JSON" }, 400, origin);
|
||||
}
|
||||
|
||||
const parsed = generateRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonResponse(
|
||||
{ error: "Validation failed", details: parsed.error.format() },
|
||||
400,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
const { name, level, school, classes, tone, useOpen5eReference } = parsed.data;
|
||||
|
||||
let reference: Open5eSpell | null = null;
|
||||
if (useOpen5eReference) {
|
||||
reference = await fetchOpen5eReference(level, school);
|
||||
}
|
||||
|
||||
const aiClient = tier === "pro" ? generateKimiSpell : generateOpenRouterSpell;
|
||||
const modelName = tier === "pro" ? "moonshot-v1-8k" : "llama-3.3-70b:free";
|
||||
|
||||
let spell: SpellResult;
|
||||
try {
|
||||
spell = await aiClient({ name, level, school, classes, tone }, reference ?? undefined);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResponse(
|
||||
{ error: "AI generation failed", details: message },
|
||||
502,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
const validation = spellResultSchema.safeParse(spell);
|
||||
if (!validation.success) {
|
||||
return jsonResponse(
|
||||
{ error: "Invalid AI response", details: validation.error.message },
|
||||
502,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await incrementGenerationCounter(user.id, modelName);
|
||||
} catch {
|
||||
// best-effort: counter increment failures should not block the response
|
||||
}
|
||||
|
||||
const response: { spell: SpellResult; reference?: Open5eSpell } = { spell };
|
||||
if (reference) response.reference = reference;
|
||||
return jsonResponse(response, 200, origin);
|
||||
};
|
||||
|
||||
export const OPTIONS: APIRoute = async ({ request }) => {
|
||||
const origin = getOrigin(request);
|
||||
return handleCorsPreflight(origin);
|
||||
};
|
||||
@@ -3,12 +3,20 @@ import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
|
||||
import { db } from "@/db/client";
|
||||
import { translations } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getMonster, getSpell } from "@/lib/open5e/client";
|
||||
import { translateOpen5eContent } from "@/lib/ai/openrouter";
|
||||
import {
|
||||
getMonster,
|
||||
getSpell,
|
||||
getEquipmentItem,
|
||||
getMagicItem,
|
||||
} from "@/lib/open5e/client";
|
||||
import {
|
||||
translateOpen5eContent,
|
||||
type Open5eContentType,
|
||||
} from "@/lib/ai/openrouter";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const ALLOWED_TYPES = new Set(["creature", "spell"]);
|
||||
const ALLOWED_TYPES = new Set(["creature", "spell", "equipment", "magicitem"]);
|
||||
|
||||
function getOrigin(request: Request): string | null {
|
||||
return request.headers.get("origin");
|
||||
@@ -30,7 +38,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
if (!ALLOWED_TYPES.has(type)) {
|
||||
return jsonResponse(
|
||||
{ error: `Invalid type. Allowed: creature, spell` },
|
||||
{ error: `Invalid type. Allowed: creature, spell, equipment, magicitem` },
|
||||
400,
|
||||
origin
|
||||
);
|
||||
@@ -59,11 +67,13 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
let original: Record<string, unknown>;
|
||||
try {
|
||||
if (type === "creature") {
|
||||
const monster = await getMonster(slug);
|
||||
original = monster as unknown as Record<string, unknown>;
|
||||
original = (await getMonster(slug)) as unknown as Record<string, unknown>;
|
||||
} else if (type === "spell") {
|
||||
original = (await getSpell(slug)) as unknown as Record<string, unknown>;
|
||||
} else if (type === "equipment") {
|
||||
original = (await getEquipmentItem(slug)) as unknown as Record<string, unknown>;
|
||||
} else {
|
||||
const spell = await getSpell(slug);
|
||||
original = spell as unknown as Record<string, unknown>;
|
||||
original = (await getMagicItem(slug)) as unknown as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -76,7 +86,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
let translated: Record<string, unknown>;
|
||||
try {
|
||||
translated = await translateOpen5eContent(original, type as "creature" | "spell");
|
||||
translated = await translateOpen5eContent(original, type as Open5eContentType);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResponse(
|
||||
|
||||
@@ -8,7 +8,8 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
|
||||
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
|
||||
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
|
||||
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
|
||||
import ImportModal from "@/components/dm/ImportModal.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
||||
@@ -18,6 +19,7 @@ let quota = null;
|
||||
if (user) {
|
||||
quota = await getRemainingQuota(user.id, user.tier);
|
||||
}
|
||||
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
|
||||
---
|
||||
|
||||
<DmLayout>
|
||||
@@ -58,9 +60,7 @@ if (user) {
|
||||
{T.ai}
|
||||
</h2>
|
||||
</div>
|
||||
{Astro.locals.user ? (
|
||||
<AiNpcForm />
|
||||
) : (
|
||||
{!user ? (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
|
||||
@@ -72,6 +72,10 @@ if (user) {
|
||||
</a>
|
||||
</div>
|
||||
</DmCard>
|
||||
) : quotaExhausted ? (
|
||||
<AiQuotaExhausted resetAt={quota!.resetAt} />
|
||||
) : (
|
||||
<AiKindSwitcher />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
|
||||
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
|
||||
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
|
||||
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
|
||||
import ImportModal from "@/components/dm/ImportModal.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
||||
@@ -18,6 +19,7 @@ let quota = null;
|
||||
if (user) {
|
||||
quota = await getRemainingQuota(user.id, user.tier);
|
||||
}
|
||||
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
|
||||
---
|
||||
|
||||
<DmLayout>
|
||||
@@ -58,14 +60,16 @@ if (user) {
|
||||
{T.ai}
|
||||
</h2>
|
||||
</div>
|
||||
{Astro.locals.user ? (
|
||||
<AiNpcForm />
|
||||
) : (
|
||||
{!user ? (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
|
||||
</div>
|
||||
</DmCard>
|
||||
) : quotaExhausted ? (
|
||||
<AiQuotaExhausted resetAt={quota!.resetAt} />
|
||||
) : (
|
||||
<AiKindSwitcher />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user