diff --git a/src/components/dm/AiKindSwitcher.astro b/src/components/dm/AiKindSwitcher.astro new file mode 100644 index 0000000..496797c --- /dev/null +++ b/src/components/dm/AiKindSwitcher.astro @@ -0,0 +1,69 @@ +--- +import AiNpcForm from "./AiNpcForm.astro"; +import AiSpellForm from "./AiSpellForm.astro"; +import { dmTranslations as T } from "@/i18n/dm-translations"; +--- + +
+
+ + +
+ +
+ +
+ +
+ + diff --git a/src/components/dm/AiQuotaExhausted.astro b/src/components/dm/AiQuotaExhausted.astro new file mode 100644 index 0000000..355def8 --- /dev/null +++ b/src/components/dm/AiQuotaExhausted.astro @@ -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)); +--- + + +
+ +

+ {T.aiQuotaExhausted} +

+

{resetMsg}

+

+ {T.aiQuotaUpgradeHint} +

+
+
diff --git a/src/components/dm/AiSpellForm.astro b/src/components/dm/AiSpellForm.astro new file mode 100644 index 0000000..b71916d --- /dev/null +++ b/src/components/dm/AiSpellForm.astro @@ -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"; +--- + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {T.spellFormSubmit} + +
+ +
+
+
+ + + + diff --git a/src/components/dm/Open5eReference.astro b/src/components/dm/Open5eReference.astro index a54fd8a..2baa545 100644 --- a/src/components/dm/Open5eReference.astro +++ b/src/components/dm/Open5eReference.astro @@ -31,6 +31,30 @@ import DmButton from "./DmButton.astro"; > Заклинания + + @@ -225,18 +249,41 @@ import DmButton from "./DmButton.astro"; desc?: string; } + interface EquipmentItem { + key: string; + name: string; + category?: string; + cost?: string | number | Record; + weight?: string | number; + damage?: string | Record; + properties?: string[] | Record[]; + 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(item: T, translated: Record | null): T { + function mergeWithTranslation(item: T, translated: Record | 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).quantity; + const unit = (cost as Record).unit; + if (quantity != null && unit != null) return `${quantity} ${unit}`; + } + return "—"; + } + + function renderEquipmentCard(item: EquipmentItem): string { + const cost = formatCost(item.cost); + return ` +
+
+

+ ${escapeHtml(item.name)} +

+

+ ${escapeHtml(item.category || "—")} · ${escapeHtml(cost)} +

+
+
+

Вес ${escapeHtml(String(item.weight ?? "—"))}

+
+
+ `; + } + + function renderEquipmentDetail(item: EquipmentItem, isTranslated: boolean): string { + const cost = formatCost(item.cost); + let html = ` +

${escapeHtml(item.name)}

+

${escapeHtml(item.category || "—")}

+ ${translateButtonHtml(item.key, "equipment", isTranslated)} +
+
+

${escapeHtml(cost)}

+

Стоимость

+
+
+

${escapeHtml(String(item.weight ?? "—"))}

+

Вес

+
+
+ `; + if (item.desc) { + html += `

${escapeHtml(item.desc)}

`; + } + return html; + } + + function renderMagicItemCard(item: MagicItem): string { + return ` +
+
+

+ ${escapeHtml(item.name)} +

+

+ ${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")} +

+
+
+ `; + } + + function renderMagicItemDetail(item: MagicItem, isTranslated: boolean): string { + let html = ` +

${escapeHtml(item.name)}

+

${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}

+ ${translateButtonHtml(item.key, "magicitem", isTranslated)} + `; + if (item.requires_attunement) { + html += `

Требует настройки: ${escapeHtml(item.requires_attunement)}

`; + } + if (item.desc) { + html += `

${escapeHtml(item.desc)}

`; + } + 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 = { + 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) => ``) - .join(""); + const tab = manager.state.tab; + if (tab === "monsters") { + filterSelect.innerHTML = CR_OPTIONS.map( + (o) => ``, + ).join(""); + filterSelect.classList.remove("hidden"); + } else if (tab === "spells") { + filterSelect.innerHTML = LEVEL_OPTIONS.map( + (o) => ``, + ).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 = { + 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 = "Ошибка перевода"; diff --git a/src/i18n/dm-translations.ts b/src/i18n/dm-translations.ts index c8035eb..71b9c91 100644 --- a/src/i18n/dm-translations.ts +++ b/src/i18n/dm-translations.ts @@ -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", diff --git a/src/lib/ai/kimi.ts b/src/lib/ai/kimi.ts index c77029f..8bf3ead 100644 --- a/src/lib/ai/kimi.ts +++ b/src/lib/ai/kimi.ts @@ -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 { + 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 diff --git a/src/lib/ai/openrouter.ts b/src/lib/ai/openrouter.ts index dcdf17a..7f4a905 100644 --- a/src/lib/ai/openrouter.ts +++ b/src/lib/ai/openrouter.ts @@ -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, - type: "creature" | "spell", + type: Open5eContentType, model: string = DEFAULT_MODEL ): Promise> { 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 { + 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); + } +} diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index c77a46a..be72c32 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -29,6 +29,46 @@ export const npcResultSchema = z.object({ export type NPCResult = z.infer; +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; + +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; + +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; diff --git a/src/lib/client/open5e-ui.ts b/src/lib/client/open5e-ui.ts index fadaa7f..6f2df1d 100644 --- a/src/lib/client/open5e-ui.ts +++ b/src/lib/client/open5e-ui.ts @@ -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"; diff --git a/src/lib/client/translation.ts b/src/lib/client/translation.ts index 7eff1e9..5d02e1e 100644 --- a/src/lib/client/translation.ts +++ b/src/lib/client/translation.ts @@ -1,6 +1,8 @@ +export type TranslationType = "creature" | "spell" | "equipment" | "magicitem"; + export interface TranslationEntry { slug: string; - type: "creature" | "spell"; + type: TranslationType; data: Record; timestamp: number; } @@ -21,14 +23,14 @@ if (CHANNEL) { export function getCachedTranslation( slug: string, - type: "creature" | "spell", + type: TranslationType, ): Record | null { return CACHE.get(`${type}:${slug}`)?.data ?? null; } export function setCachedTranslation( slug: string, - type: "creature" | "spell", + type: TranslationType, data: Record, ): void { const entry: TranslationEntry = { @@ -43,7 +45,7 @@ export function setCachedTranslation( export async function fetchTranslation( slug: string, - type: "creature" | "spell", + type: TranslationType, ): Promise | null> { const cached = getCachedTranslation(slug, type); if (cached) return cached; diff --git a/src/lib/open5e/client.ts b/src/lib/open5e/client.ts index 7badac0..dea4744 100644 --- a/src/lib/open5e/client.ts +++ b/src/lib/open5e/client.ts @@ -170,3 +170,105 @@ export async function getSpell(key: string): Promise { throw err; } } + +export interface EquipmentItem { + name: string; + key: string; + category?: string; + cost?: string | number | Record; + weight?: string | number; + damage?: string | Record; + properties?: string[] | Record[]; + desc?: string; +} + +export async function searchEquipment( + query: string, + filters?: SearchFilters +): Promise { + const cacheKey = `open5e:v2:equipment:${query}`; + const cached = getCached(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(cacheKey); + if (stale) return stale; + throw err; + } +} + +export async function getEquipmentItem(key: string): Promise { + const cacheKey = `open5e:v2:equipment:item:${key}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${API_BASE}equipment/${key}/`; + + try { + const data = await apiFetch(url); + setCached(cacheKey, data); + return data; + } catch (err) { + const stale = getCached(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 { + const cacheKey = `open5e:v2:magicitems:${query}`; + const cached = getCached(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(cacheKey); + if (stale) return stale; + throw err; + } +} + +export async function getMagicItem(key: string): Promise { + const cacheKey = `open5e:v2:magicitem:${key}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const url = `${API_BASE}magicitems/${key}/`; + + try { + const data = await apiFetch(url); + setCached(cacheKey, data); + return data; + } catch (err) { + const stale = getCached(cacheKey); + if (stale) return stale; + throw err; + } +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 0893871..49e7450 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -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: '/' }); } diff --git a/src/pages/api/dm/ai/generate-spell.ts b/src/pages/api/dm/ai/generate-spell.ts new file mode 100644 index 0000000..159479f --- /dev/null +++ b/src/pages/api/dm/ai/generate-spell.ts @@ -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 { + 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); +}; diff --git a/src/pages/api/dm/translate.ts b/src/pages/api/dm/translate.ts index 3567cbb..46505fa 100644 --- a/src/pages/api/dm/translate.ts +++ b/src/pages/api/dm/translate.ts @@ -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; try { if (type === "creature") { - const monster = await getMonster(slug); - original = monster as unknown as Record; + original = (await getMonster(slug)) as unknown as Record; + } else if (type === "spell") { + original = (await getSpell(slug)) as unknown as Record; + } else if (type === "equipment") { + original = (await getEquipmentItem(slug)) as unknown as Record; } else { - const spell = await getSpell(slug); - original = spell as unknown as Record; + original = (await getMagicItem(slug)) as unknown as Record; } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -76,7 +86,7 @@ export const GET: APIRoute = async ({ request }) => { let translated: Record; 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( diff --git a/src/pages/dm/index.astro b/src/pages/dm/index.astro index 336c9d2..4da1b4a 100644 --- a/src/pages/dm/index.astro +++ b/src/pages/dm/index.astro @@ -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; --- @@ -58,9 +60,7 @@ if (user) { {T.ai} - {Astro.locals.user ? ( - - ) : ( + {!user ? (

{T.aiSignInCta}

@@ -72,6 +72,10 @@ if (user) {
+ ) : quotaExhausted ? ( + + ) : ( + )} diff --git a/src/pages/ru/dm/index.astro b/src/pages/ru/dm/index.astro index b638099..ad31704 100644 --- a/src/pages/ru/dm/index.astro +++ b/src/pages/ru/dm/index.astro @@ -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; --- @@ -58,14 +60,16 @@ if (user) { {T.ai} - {Astro.locals.user ? ( - - ) : ( + {!user ? (

{T.aiSignInCta}

+ ) : quotaExhausted ? ( + + ) : ( + )}