256 lines
15 KiB
JavaScript
256 lines
15 KiB
JavaScript
/* Browser-only, best-effort assistance. All executable/model/prompt assets come from our static service. */
|
|
(function (global) {
|
|
const base = '/static/wllama-gemma3-v2/';
|
|
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 {};
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 körs på den här enheten för att förbereda handlingen.'});
|
|
try {
|
|
const result = await 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);
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 är klar. Originalhandlingen skickas nu vidare till berättartjänsten.'});
|
|
return result;
|
|
} catch (error) {
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 kunde inte förbereda handlingen. Originaltexten skickas ändå vidare till berättartjänsten.'});
|
|
throw error;
|
|
}
|
|
}
|
|
async function offline(message, transcript, worldSnapshot) {
|
|
if (!enabled || !loadedEngine || !await isAccepted()) return '';
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 arbetar offline med ett osparat hjälpförslag.'});
|
|
try {
|
|
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);
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 har skapat ett osparat hjälpförslag.'});
|
|
return String(result.offline_reply || 'Jag kan inte skapa ett lokalt förslag med den information som finns sparad.');
|
|
} catch (error) {
|
|
announce({type: 'activity', text: 'Lokal Gemma 3 kunde inte skapa ett offlineförslag.'});
|
|
throw error;
|
|
}
|
|
}
|
|
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');
|
|
if (!('indexedDB' in global)) return 'Ditt godkännande sparades. Webbläsaren saknar lokal lagring som krävs för Gemma, men spelet fungerar utan lokal AI.';
|
|
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.');
|
|
engine().catch(() => {});
|
|
return 'Tack. Godkännandet sparades på den här enheten och modellen hämtas nu från static-servern.';
|
|
}
|
|
if (normalized === 'lokal ai avvisa villkor') {
|
|
let manifest;
|
|
try { manifest = await getManifest(); }
|
|
catch (_) { enabled = false; localStorage.setItem('ai-spel-local-ai-enabled', 'false'); return 'Static-servern kunde inte läsa villkorsversionen. Ingen Gemma-modell startas; spelet fungerar utan lokal AI.'; }
|
|
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);
|