Add Gemma license gate and publish local model assets
Build and publish static assets / publish (push) Failing after 2s

This commit is contained in:
Ein Anderssono
2026-10-19 12:02:45 +02:00
parent 440652214c
commit 73c0c0b8ba
16 changed files with 699 additions and 7 deletions
+237
View File
@@ -0,0 +1,237 @@
/* 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);