/* Browser-only, best-effort assistance. All executable/model/prompt assets come from our static service. */ (function (global) { const base = '/static/wllama-gemma3-v1/'; const dbName = 'ai-spel-local-assistance-v1'; let enginePromise; let loadedEngine; let downloadAbort; let manifestPromise; let accountId = null; let termsPresentedForAccount = null; let termsNoticeShownForAccount = null; let lastProgressAt = 0; let lastProgressPercent = -1; let enabled = localStorage.getItem('ai-spel-local-ai-enabled') !== 'false'; let logging = localStorage.getItem('ai-spel-local-ai-logs') === 'true'; function announce(text) { global.dispatchEvent(new CustomEvent('local-ai-status', {detail: text})); } async function getManifest() { if (!manifestPromise) manifestPromise = fetch(`${base}manifest.json`, {cache: 'no-cache', credentials: 'same-origin'}).then(async response => { if (!response.ok) throw new Error(`static_manifest_${response.status}`); const value = await response.json(); for (const url of [value.model?.url, value.runtime?.module, value.runtime?.wasm, value.prompt?.url, value.license?.terms, value.license?.user_terms, value.license?.prohibited_use_policy, value.license?.notice, value.license?.source, value.license?.runtime, value.license?.llama_cpp_runtime]) { const parsed = new URL(url, location.origin); if (parsed.origin !== location.origin || !parsed.pathname.startsWith('/static/')) throw new Error('local_ai_asset_outside_static'); } if (!/^[a-f0-9]{64}$/.test(value.license?.acceptance_id || '')) throw new Error('local_ai_license_acceptance_id_invalid'); return value; }); return manifestPromise; } async function engine() { if (!enginePromise) enginePromise = (async () => { const manifest = await getManifest(); lastProgressAt = 0; lastProgressPercent = -1; announce(`Hämtar Gemma 3 till enheten: 0 av ${manifest.model.bytes} byte.`); const {Wllama} = await import(manifest.runtime.module); const wasm = manifest.runtime.wasm; downloadAbort = new AbortController(); const instance = new Wllama({default: wasm}, {allowOffline: true}); await instance.loadModelFromUrl(manifest.model.url, { n_ctx: 2048, n_batch: 128, n_gpu_layers: 0, useCache: true, signal: downloadAbort.signal, progressCallback: ({loaded, total}) => { const percent = Math.min(100, Math.floor((loaded / (total || manifest.model.bytes)) * 100)); const now = Date.now(); if (percent === 100 || (percent > lastProgressPercent && now - lastProgressAt >= 1000)) { lastProgressAt = now; lastProgressPercent = percent; announce(`Hämtar Gemma 3 till enheten: ${percent} procent (${loaded} av ${total || manifest.model.bytes} byte).`); } }, }); announce('Den lokala språkmodellen är redo.'); loadedEngine = {instance, manifest}; return loadedEngine; })().catch(async error => { enginePromise = undefined; await log({type: 'model_initialization', static_base: base, error: String(error.message || error)}); if (enabled) announce(`Lokal AI är inte tillgänglig: ${String(error.message || error)}. Spelet fortsätter med berättartjänsten.`); throw error; }); return enginePromise; } async function promptText(manifest) { const response = await fetch(manifest.prompt.url, {cache: 'force-cache', credentials: 'same-origin'}); if (!response.ok) throw new Error(`static_prompt_${response.status}`); return response.text(); } function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => request.result.createObjectStore('records', {keyPath: 'id', autoIncrement: true}); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function log(record) { if (!logging) return; try { const db = await openDb(); const tx = db.transaction('records', 'readwrite'); const store = tx.objectStore('records'); store.add({...record, account_id: accountId, at: new Date().toISOString()}); const request = store.getAll(); request.onsuccess = () => { const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; for (const item of request.result) if (Date.parse(item.at) < cutoff) store.delete(item.id); }; } catch (_) { /* Local diagnostics never affect play. */ } } async function infer(task, payload, abortSignal) { const started = performance.now(); const {instance, manifest} = loadedEngine || await engine(); let system = ''; try { system = await promptText(manifest); const result = await instance.createChatCompletion({messages: [ {role: 'system', content: system}, {role: 'user', content: JSON.stringify({task, ...payload})}, ], max_tokens: 300, temperature: 0.2, top_k: 40, top_p: 0.9, abortSignal}); const raw = typeof result === 'string' ? result : result.choices?.[0]?.message?.content || ''; let parsed; try { parsed = JSON.parse(raw.match(/\{[\s\S]*\}/)?.[0] || raw); } catch (_) { parsed = {}; } await log({task, input: payload, prompt_id: manifest.prompt.id, prompt_version: manifest.prompt.version, model: manifest.model.id, model_sha256: manifest.model.sha256, prompt: system, response: raw, elapsed_ms: Math.round(performance.now() - started)}); return parsed; } catch (error) { await log({task, input: payload, prompt_id: manifest.prompt.id, prompt_version: manifest.prompt.version, model: manifest.model.id, model_sha256: manifest.model.sha256, prompt: system, error: String(error.message || error), elapsed_ms: Math.round(performance.now() - started)}); throw error; } } async function start(currentAccountId) { const nextAccountId = String(currentAccountId || ''); if (accountId !== nextAccountId) { termsPresentedForAccount = null; termsNoticeShownForAccount = null; } accountId = nextAccountId; if (!enabled) return; try { const manifest = await getManifest(); const key = acceptanceKey(manifest); if (localStorage.getItem(key) !== manifest.license.acceptance_id) { await presentTerms(manifest); return; } if (!('indexedDB' in global)) { announce('Den här webbläsaren saknar lokal lagring som behövs för Gemma. Spelet fortsätter utan lokal AI.'); return; } await engine(); } catch (error) { announce(`Villkorsinformationen eller den lokala modellen kunde inte hämtas från static-servern: ${String(error.message || error)}. Spelet fungerar utan lokal AI.`); } } function acceptanceKey(manifest) { return `ai-spel-gemma-terms:${encodeURIComponent(accountId || 'no-account')}:${manifest.license.acceptance_id}`; } async function presentTerms(manifest, repeat = false) { if (!accountId || (termsNoticeShownForAccount === accountId && !repeat)) return; termsPresentedForAccount = accountId; termsNoticeShownForAccount = accountId; announce({ type: 'terms-required', text: 'Den lokala Gemma 3-hjälpmodellen är frivillig. Innan modellen hämtas eller körs behöver du läsa och godkänna Gemma-villkoren och policyn för förbjuden användning. Textspelet fungerar även om du avstår.', user_terms: manifest.license.user_terms, terms: manifest.license.terms, policy: manifest.license.prohibited_use_policy, notice: manifest.license.notice, command: 'Skriv “lokal ai godkänn villkor” efter att du läst dokumenten och bekräftar att du har rättslig behörighet att ingå avtalet, eller “lokal ai avvisa villkor”.', }); } async function boundedInfer(task, payload, timeoutMs) { const controller = new AbortController(); let timer; try { return await Promise.race([ infer(task, payload, controller.signal), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('local_assistance_timeout')); }, timeoutMs); }), ]); } finally { clearTimeout(timer); } } async function assist(message, transcript, worldSnapshot) { if (!enabled || !loadedEngine || !await isAccepted()) return {}; return boundedInfer('prepare_turn', {player_message: message.slice(0, 1200), recent_dialogue: transcript.slice(-12).map(line => line.slice(0, 240)), world_snapshot: JSON.stringify(worldSnapshot).slice(0, 1200)}, 5000); } async function offline(message, transcript, worldSnapshot) { if (!enabled || !loadedEngine || !await isAccepted()) return ''; const result = await boundedInfer('offline_help', {player_message: message.slice(0, 1200), recent_dialogue: transcript.slice(-12).map(line => line.slice(0, 240)), world_snapshot: JSON.stringify(worldSnapshot).slice(0, 1200)}, 10000); return String(result.offline_reply || 'Jag kan inte skapa ett lokalt förslag med den information som finns sparad.'); } async function settings(command) { const normalized = command.trim().toLocaleLowerCase('sv'); if (normalized === 'lokal ai loggar på') { logging = true; localStorage.setItem('ai-spel-local-ai-logs', 'true'); return 'Lokala AI-loggar är på. De sparas bara i den här webbläsaren.'; } if (normalized === 'lokal ai loggar av') { logging = false; localStorage.setItem('ai-spel-local-ai-logs', 'false'); return 'Lokala AI-loggar är av.'; } if (normalized === 'lokal ai av') { enabled = false; localStorage.setItem('ai-spel-local-ai-enabled', 'false'); downloadAbort?.abort(); loadedEngine?.instance.exit?.(); loadedEngine = undefined; return 'Lokala AI-hjälpfunktioner är av. Spelet använder berättartjänsten.'; } if (normalized === 'lokal ai villkor') { try { const manifest = await getManifest(); await presentTerms(manifest, true); return 'Villkoren och policyn visas här ovanför. Modellen hämtas inte förrän du uttryckligen godkänner dem.'; } catch (_) { return 'Villkorsinformationen kunde inte hämtas från static-servern. Spelet fortsätter utan lokal AI; försök igen senare.'; } } if (normalized === 'lokal ai godkänn villkor') { let manifest; try { manifest = await getManifest(); } catch (_) { return 'Villkorsinformationen kunde inte hämtas. Ingen Gemma-modell hämtas eller körs.'; } if (!accountId || termsPresentedForAccount !== accountId) { await presentTerms(manifest, true); return 'Läs Gemma-villkoren och policyn som visas här ovanför. Skriv sedan “lokal ai godkänn villkor” för att bekräfta.'; } localStorage.setItem(acceptanceKey(manifest), manifest.license.acceptance_id); enabled = true; localStorage.setItem('ai-spel-local-ai-enabled', 'true'); announce('Godkännandet sparades för det här kontot och den här versionen av villkoren. Hämtar nu modellen från static-servern.'); try { await engine(); return 'Tack. Godkännandet sparades på den här enheten och modellen hämtas nu från static-servern.'; } catch (_) { return 'Godkännandet sparades. Modellen kunde inte starta just nu; spelet fungerar fortfarande utan lokal AI.'; } } if (normalized === 'lokal ai avvisa villkor') { const manifest = await getManifest(); localStorage.removeItem(acceptanceKey(manifest)); enabled = false; localStorage.setItem('ai-spel-local-ai-enabled', 'false'); downloadAbort?.abort(); loadedEngine?.instance.exit?.(); loadedEngine = undefined; return 'Du har avböjt Gemma-villkoren. Modellen hämtas eller körs inte; spelet använder berättartjänsten.'; } if (normalized === 'lokal ai på') { enabled = true; localStorage.setItem('ai-spel-local-ai-enabled', 'true'); await start(accountId); try { return localStorage.getItem(acceptanceKey(await getManifest())) ? 'Lokala AI-hjälpfunktioner aktiveras.' : 'Läs och godkänn Gemma-villkoren innan modellen hämtas eller körs.'; } catch (_) { return 'Static-servern kunde inte lämna villkoren. Spelet fortsätter utan lokal AI.'; } } if (normalized === 'lokal ai exportera loggar') { const db = await openDb(); const records = await new Promise((resolve, reject) => { const req = db.transaction('records').objectStore('records').getAll(); req.onsuccess = () => resolve(req.result.filter(item => item.account_id === accountId)); req.onerror = () => reject(req.error); }); const blob = new Blob([JSON.stringify(records, null, 2)], {type: 'application/json'}); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `ai-spel-local-ai-loggar-${new Date().toISOString().slice(0, 10)}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 60_000); return 'Lokala AI-loggar för kontot har exporterats som JSON-fil.'; } if (normalized === 'lokal ai radera loggar') { const db = await openDb(); await new Promise((resolve, reject) => { const store = db.transaction('records', 'readwrite').objectStore('records'); const req = store.getAll(); req.onsuccess = () => { for (const item of req.result) if (item.account_id === accountId) store.delete(item.id); resolve(); }; req.onerror = () => reject(req.error); }); return 'Lokala AI-loggar för kontot har raderats.'; } return null; } async function isAccepted() { if (!accountId) return false; try { const manifest = await getManifest(); return localStorage.getItem(acceptanceKey(manifest)) === manifest.license.acceptance_id; } catch (_) { return false; } } function stop() { downloadAbort?.abort(); loadedEngine?.instance.exit?.(); loadedEngine = undefined; enginePromise = undefined; accountId = null; termsPresentedForAccount = null; termsNoticeShownForAccount = null; } global.localGameAI = {start, stop, assist, offline, settings, record: entry => log(entry)}; })(window);