Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66cc7f8628 | ||
|
|
196ff1429b | ||
|
|
9a8eb9ae85 | ||
|
|
b0832f122d | ||
|
|
3a9d02bc64 |
@@ -10,5 +10,5 @@
|
||||
- Helm charts are published to `harbor.k3s.k0.nu/ai-spel`; Argo CD uses the existing `ai-spel` project and namespace.
|
||||
- Player-facing speech uses the local Piper WebAssembly adapter when the player has activated audio. The separate `ai-spel-static` Nginx service serves pinned Swedish Piper models and ONNX/Piper runtime files from same-origin `/static/piper-wasm-v1/` URLs with immutable caching. Game text is synthesized on-device and must never be sent to a TTS provider.
|
||||
- This pipeline bundles only the Piper worker/client glue. Model/runtime downloads, image builds, and deployment belong to `ai-spel/ai-spel-static` and the existing `large` runner. Never build or run these assets locally.
|
||||
- Browser local AI loads its client adapter, Wllama runtime, WebAssembly, Gemma model, manifest, YAML prompt, and license resources only from the same-origin `/static/wllama-gemma3-v2/` path owned by `ai-spel/ai-spel-static`. Do not add browser CDN, npm, or model-hub fallbacks. The player must be shown and accept applicable model terms before the model downloads or runs. Acceptance, model failure, or local-AI opt-out must never block play. Incompatible asset changes require a new immutable static path and a coordinated frontend release.
|
||||
- Browser local AI is paused by default behind `FEATURE_FLAGS.localAI` in `index.html`. When disabled, do not load its adapter or any Wllama/WebAssembly/Gemma assets. If it is deliberately enabled in a later change, it must load the adapter, runtime, model, manifest, YAML prompt, and license resources only from the same-origin `/static/wllama-gemma3-v2/` path owned by `ai-spel/ai-spel-static`. Do not add browser CDN, npm, or model-hub fallbacks. The player must be shown and accept applicable model terms before the model downloads or runs. Acceptance, model failure, or local-AI opt-out must never block play. Incompatible asset changes require a new immutable static path and a coordinated frontend release.
|
||||
- Local model output is advisory only. Preserve the original player input, send local hints only as untrusted context to Rust, and label offline replies as unsaved. Local diagnostic logging is opt-in, browser-local, exportable/deletable, and expires after 30 days.
|
||||
|
||||
@@ -10,7 +10,7 @@ The dialogue also offers **Gör om senaste** and **Torrkör senaste**. Both rese
|
||||
|
||||
If the player asks to find or approach someone and no NPC is present, the direct dialogue scene may propose one person and an opening line. Rust creates that NPC only for the explicit request and commits the first exchange idempotently; a later turn continues the saved conversation. Clear requests about the current location are answered from its saved facts without asking the player to restate what they want to inspect.
|
||||
|
||||
The separate `/admin` route provides an authenticated operations view for player search/profile/trace, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. All privileged data comes from the Rust `/api/admin/...` API; the browser never connects directly to PostgreSQL. Backend endpoints require `accounts.is_admin=true`. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace.
|
||||
The separate `/admin` route provides an authenticated operations view for player search/profile/trace, NPC selection and editing, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. NPC editing covers identity and voice, typed profile fields plus future JSON properties, existing memory reconstructions, and owned items. The UI displays current position and commitments for context. Every NPC read and write goes through Rust, checks `accounts.is_admin=true`, validates domain limits, and writes an audit record in the same transaction as each mutation; the browser never connects directly to PostgreSQL. Profile and memory writes use expected-current values to detect stale edits. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace.
|
||||
|
||||
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced. A clearly labeled **Skicka** button beside the microphone submits typed text; Enter also submits while Shift+Enter inserts a newline. Typed text, speech transcriptions, and the send button all use the same submission flow.
|
||||
|
||||
@@ -39,3 +39,6 @@ CI needs repository Action secrets named `HARBOR_USERNAME` and `HARBOR_PASSWORD`
|
||||
The first Argo Application is described in `deploy/argocd/application.yaml`. The backend ingress routes `/api`, `/version`, and health checks to Rust while serving the static client at `/`. Both services share the same origin so the session cookie remains same-origin.
|
||||
|
||||
Builds, checks, and runtime verification happen in the pipeline and Kubernetes. Do not run the app, tests, container images, or binaries locally.
|
||||
# Browser-local AI status
|
||||
|
||||
Gemma/Wllama assistance is currently paused. `FEATURE_FLAGS.localAI` in `index.html` is `false`; while disabled, the client does not load the adapter, download model/runtime assets, run local inference, or send local hints. Re-enabling it requires an explicit code change and the normal frontend release pipeline. This does not affect the regular Rust-backed dialogue flow.
|
||||
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: ai-spel-frontend
|
||||
description: Static web frontend for the Swedish life simulator
|
||||
type: application
|
||||
version: 0.1.67
|
||||
appVersion: "0.1.67"
|
||||
version: 0.1.69
|
||||
appVersion: "0.1.69"
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR 0004: Pause browser-local AI by default
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Decision
|
||||
|
||||
Browser-local Gemma/Wllama assistance is disabled by the `FEATURE_FLAGS.localAI` flag in `index.html`. The adapter is loaded dynamically only when the flag is true. When it is false, the browser does not load the adapter, request model/runtime assets, initialize local inference, attach local hints to dialogue requests, or produce local offline replies. The normal text dialogue remains available through the Rust API.
|
||||
|
||||
## Rationale
|
||||
|
||||
Pause model execution while the rest of the game and narrator experience is stabilized. Keep the implementation available for a deliberate future reactivation without downloading model resources or sending local inference hints in the meantime.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Re-enabling local AI requires an explicit code change and frontend release.
|
||||
- Tests and the static frontend check must enforce the default-off behavior and lazy loading.
|
||||
- Existing Gemma asset publication and documentation may remain in place; the browser does not request those assets while the flag is off.
|
||||
+146
-8
@@ -104,6 +104,7 @@
|
||||
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
|
||||
<nav id="admin-toolbar" aria-label="Administrationsvyer">
|
||||
<button type="button" data-admin-view="players">Spelare</button>
|
||||
<button type="button" data-admin-view="npcs">NPC:er</button>
|
||||
<button type="button" data-admin-view="prompts">Prompter</button>
|
||||
<button type="button" data-admin-view="rules">Luau-regler</button>
|
||||
<button type="button" data-admin-view="logging">Driftloggar</button>
|
||||
@@ -116,8 +117,25 @@
|
||||
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · Statik <span id="static-version">hämtar version…</span> · API <span id="api-version">hämtar version…</span></footer>
|
||||
<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>
|
||||
<script src="/voice.js?v=__FRONTEND_VERSION__"></script>
|
||||
<script src="/static/wllama-gemma3-v2/local-ai.js"></script>
|
||||
<script>
|
||||
// Local Gemma/Wllama assistance is paused until explicitly re-enabled here.
|
||||
// Keep the adapter unloaded when false so it cannot download or initialize.
|
||||
const FEATURE_FLAGS = Object.freeze({localAI: false});
|
||||
let localAiAdapterPromise = null;
|
||||
function loadLocalAiAdapter() {
|
||||
if (!FEATURE_FLAGS.localAI) return Promise.resolve(null);
|
||||
if (window.localGameAI) return Promise.resolve(window.localGameAI);
|
||||
if (!localAiAdapterPromise) {
|
||||
localAiAdapterPromise = new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/' + 'wllama-gemma3-v2/local-ai.js';
|
||||
script.onload = () => resolve(window.localGameAI || null);
|
||||
script.onerror = () => resolve(null);
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
return localAiAdapterPromise;
|
||||
}
|
||||
const auth = document.querySelector('#auth');
|
||||
const play = document.querySelector('#play');
|
||||
const playLayout = document.querySelector('#play-layout');
|
||||
@@ -163,7 +181,7 @@
|
||||
line.dataset.role = role;
|
||||
line.textContent = `${options.speaker || (role === 'user' ? 'Du' : 'Berättelsen')}: ${content}`;
|
||||
transcript.append(line);
|
||||
if (role === 'user' || role === 'assistant') window.localGameAI?.record({type: 'dialogue', role, content});
|
||||
if (FEATURE_FLAGS.localAI && (role === 'user' || role === 'assistant')) window.localGameAI?.record({type: 'dialogue', role, content});
|
||||
line.scrollIntoView({block: 'end'});
|
||||
if (role === 'assistant') {
|
||||
latestAssistantText = content;
|
||||
@@ -227,7 +245,7 @@
|
||||
if (authVoiceStatus && !auth.hidden && message) authVoiceStatus.textContent = message;
|
||||
}
|
||||
window.addEventListener('local-ai-status', event => {
|
||||
if (!currentAccount || currentAccount.is_admin || play.hidden) return;
|
||||
if (!FEATURE_FLAGS.localAI || !currentAccount || currentAccount.is_admin || play.hidden) return;
|
||||
const detail = typeof event.detail === 'string' ? {text: event.detail} : event.detail;
|
||||
const statusAttribute = detail.type === 'activity' ? 'data-local-ai-activity' : 'data-local-ai-status';
|
||||
let statusLine = transcript.querySelector(`li[${statusAttribute}]`);
|
||||
@@ -633,6 +651,120 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
async function loadAdminNpcs(query = '', offset = 0) {
|
||||
adminContent.replaceChildren();
|
||||
const form = adminElement('form');
|
||||
const label = adminElement('label', 'Sök NPC eller värld', form);
|
||||
const input = document.createElement('input'); input.type = 'search'; input.maxLength = 100; input.value = query; label.append(input);
|
||||
const search = adminElement('button', 'Sök', form); search.type = 'submit';
|
||||
form.addEventListener('submit', event => { event.preventDefault(); loadAdminNpcs(input.value, 0); });
|
||||
const result = await request(`/api/admin/npcs?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`);
|
||||
adminElement('p', `${result.total} NPC:er. Välj en för att granska och redigera profil, minne och ägodelar.`);
|
||||
const list = adminElement('ul');
|
||||
for (const npc of result.npcs) {
|
||||
const item = adminElement('li', undefined, list);
|
||||
const button = adminElement('button', `${npc.name} · ${npc.world} · ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}`, item);
|
||||
button.type = 'button'; button.addEventListener('click', () => openAdminNpc(npc.id));
|
||||
}
|
||||
const paging = adminElement('div');
|
||||
const previous = adminElement('button', 'Föregående sida', paging); previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminNpcs(query, Math.max(0, offset - 50)));
|
||||
const next = adminElement('button', 'Nästa sida', paging); next.type = 'button'; next.disabled = offset + result.npcs.length >= result.total; next.addEventListener('click', () => loadAdminNpcs(query, offset + 50));
|
||||
}
|
||||
async function openAdminNpc(npcId) {
|
||||
adminContent.replaceChildren();
|
||||
const back = adminElement('button', 'Tillbaka till NPC-listan'); back.type = 'button'; back.addEventListener('click', () => loadAdminNpcs());
|
||||
const npc = await request(`/api/admin/npcs/${encodeURIComponent(npcId)}`);
|
||||
adminElement('h2', `${npc.name} · ${npc.world}`);
|
||||
adminElement('p', `Plats: ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}. ID och position visas som referens och ändras inte i den här vyn.`);
|
||||
|
||||
adminElement('h3', 'Namn, bakgrund och röstidentitet');
|
||||
const identityForm = adminElement('form');
|
||||
const nameLabel = adminElement('label', 'Namn ', identityForm);
|
||||
const nameInput = document.createElement('input'); nameInput.required = true; nameInput.maxLength = 120; nameInput.value = npc.name; nameLabel.append(nameInput);
|
||||
const biographyLabel = adminElement('label', 'Bakgrund ', identityForm);
|
||||
const biographyInput = document.createElement('textarea'); biographyInput.required = true; biographyInput.maxLength = 4000; biographyInput.rows = 5; biographyInput.value = npc.biography; biographyLabel.append(biographyInput);
|
||||
const voiceLabel = adminElement('label', 'Röstidentitet ', identityForm);
|
||||
const voiceSelect = document.createElement('select');
|
||||
for (const value of ['masculine', 'feminine', 'neutral']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === npc.voice_identity; voiceSelect.append(option); }
|
||||
voiceLabel.append(voiceSelect);
|
||||
const identitySave = adminElement('button', 'Spara identitet', identityForm); identitySave.type = 'submit';
|
||||
const identityStatus = adminElement('p'); identityStatus.setAttribute('role', 'status');
|
||||
identityForm.addEventListener('submit', async event => {
|
||||
event.preventDefault(); identitySave.disabled = true;
|
||||
try {
|
||||
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/identity`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_name: npc.name, expected_biography: npc.biography, expected_voice_identity: npc.voice_identity, name: nameInput.value, biography: biographyInput.value, voice_identity: voiceSelect.value})});
|
||||
identityStatus.textContent = 'Identiteten sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
|
||||
} catch (error) { identityStatus.textContent = `Identiteten sparades inte: ${error.message}`; }
|
||||
finally { identitySave.disabled = false; }
|
||||
});
|
||||
|
||||
adminElement('h3', 'Egenskaper och framtida profilfält');
|
||||
adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.');
|
||||
const profileForm = adminElement('form');
|
||||
const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm);
|
||||
const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput);
|
||||
const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit';
|
||||
const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status');
|
||||
profileForm.addEventListener('submit', async event => {
|
||||
event.preventDefault(); profileSave.disabled = true;
|
||||
try {
|
||||
const profile = JSON.parse(profileInput.value);
|
||||
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/profile`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_profile: npc.profile, profile})});
|
||||
profileStatus.textContent = 'Egenskaperna sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
|
||||
} catch (error) { profileStatus.textContent = `Egenskaperna sparades inte: ${error.message}`; }
|
||||
finally { profileSave.disabled = false; }
|
||||
});
|
||||
|
||||
adminElement('h3', 'Ägodelar');
|
||||
const possessionsForm = adminElement('form');
|
||||
const possessionsLabel = adminElement('label', 'En ägodel per rad: namn | antal', possessionsForm);
|
||||
const possessionsInput = document.createElement('textarea'); possessionsInput.rows = 8; possessionsInput.cols = 60; possessionsInput.value = npc.possessions.map(item => `${item.name} | ${item.quantity}`).join('\n'); possessionsLabel.append(possessionsInput);
|
||||
const possessionsSave = adminElement('button', 'Ersätt ägodelar', possessionsForm); possessionsSave.type = 'submit';
|
||||
const possessionsStatus = adminElement('p'); possessionsStatus.setAttribute('role', 'status');
|
||||
possessionsForm.addEventListener('submit', async event => {
|
||||
event.preventDefault(); possessionsSave.disabled = true;
|
||||
try {
|
||||
const possessions = possessionsInput.value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { const [name, amount] = line.split('|'); return {name: name?.trim() || '', quantity: Number(amount?.trim() || 1)}; });
|
||||
const expected = npc.possessions.map(item => ({name: item.name, quantity: item.quantity}));
|
||||
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/possessions`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected, possessions})});
|
||||
possessionsStatus.textContent = 'Ägodelarna ersattes och revisionsloggades.';
|
||||
} catch (error) { possessionsStatus.textContent = `Ägodelarna sparades inte: ${error.message}`; }
|
||||
finally { possessionsSave.disabled = false; }
|
||||
});
|
||||
|
||||
adminElement('h3', 'Minnesrekonstruktioner');
|
||||
adminElement('p', 'Varje ändring behåller minnespostens källa och läggs i revisionsloggen. Ändringar påverkar NPC:ns aktiva minne direkt.');
|
||||
if (npc.memories.length === 0) adminElement('p', 'Inga sparade minnen för den här NPC:n.');
|
||||
for (const memory of npc.memories) {
|
||||
const section = adminElement('section');
|
||||
adminElement('h4', `${memory.character_name} · ${memory.layer} · ${memory.active ? 'aktivt' : 'inaktivt'} · ${memory.visibility}`, section);
|
||||
adminElement('p', `Källa ${memory.source_event_id}; informationsväg ${memory.information_source}; prioritet ${memory.priority}.`, section);
|
||||
const form = adminElement('form', undefined, section);
|
||||
const label = adminElement('label', 'Minnestext', form);
|
||||
const text = document.createElement('textarea'); text.rows = 4; text.cols = 80; text.required = true; text.maxLength = 3000; text.value = memory.text; label.append(text);
|
||||
const priorityLabel = adminElement('label', ' Prioritet 0–100 ', form);
|
||||
const priority = document.createElement('input'); priority.type = 'number'; priority.min = '0'; priority.max = '100'; priority.value = memory.priority; priorityLabel.append(priority);
|
||||
const activeLabel = adminElement('label', undefined, form);
|
||||
const active = document.createElement('input'); active.type = 'checkbox'; active.checked = memory.active; activeLabel.append(active, document.createTextNode(' Aktivt minne'));
|
||||
const visibilityLabel = adminElement('label', ' Synlighet ', form);
|
||||
const visibility = document.createElement('select');
|
||||
for (const value of ['private', 'confidential', 'shareable']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === memory.visibility; visibility.append(option); }
|
||||
visibilityLabel.append(visibility);
|
||||
const save = adminElement('button', 'Spara minnesändring', form); save.type = 'submit';
|
||||
const status = adminElement('p', undefined, section); status.setAttribute('role', 'status');
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); save.disabled = true;
|
||||
try {
|
||||
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_text: memory.text, expected_priority: memory.priority, expected_active: memory.active, expected_visibility: memory.visibility, text: text.value, priority: Number(priority.value), active: active.checked, visibility: visibility.value})});
|
||||
status.textContent = 'Minnet sparades och revisionsloggades. Ladda om NPC:n för senaste version.';
|
||||
} catch (error) { status.textContent = `Minnet sparades inte: ${error.message}`; }
|
||||
finally { save.disabled = false; }
|
||||
});
|
||||
}
|
||||
adminElement('h3', 'Löften och åtaganden');
|
||||
if (npc.commitments.length === 0) adminElement('p', 'Inga registrerade åtaganden.');
|
||||
for (const commitment of npc.commitments) adminJson(`${commitment.status} · ${commitment.created_at}`, commitment);
|
||||
}
|
||||
async function loadAdminPrompts() {
|
||||
adminContent.replaceChildren();
|
||||
const result = await request('/api/admin/prompts');
|
||||
@@ -751,7 +883,10 @@
|
||||
currentAccount = await request('/api/me');
|
||||
gameState = await request('/api/world');
|
||||
showPlay();
|
||||
if (!currentAccount.is_admin) window.localGameAI?.start(currentAccount.id);
|
||||
if (!currentAccount.is_admin && FEATURE_FLAGS.localAI) {
|
||||
const localAI = await loadLocalAiAdapter();
|
||||
localAI?.start(currentAccount.id);
|
||||
}
|
||||
if (!gameState.character) {
|
||||
transcript.replaceChildren();
|
||||
appendInitialPrompt();
|
||||
@@ -918,7 +1053,7 @@
|
||||
presenceTimer = null;
|
||||
playerMessagesPrimed = false;
|
||||
seenPlayerMessageIds.clear();
|
||||
window.localGameAI?.stop();
|
||||
if (FEATURE_FLAGS.localAI) window.localGameAI?.stop();
|
||||
await request('/api/logout', {method: 'POST'});
|
||||
if (location.pathname !== '/admin') appendLine('assistant', 'Du har loggat ut. Logga in igen för att fortsätta spela.');
|
||||
characterDraft = null;
|
||||
@@ -974,7 +1109,7 @@
|
||||
document.querySelector('#dry-run-action').disabled = !enabled;
|
||||
}
|
||||
async function handleGameMessage(message, actionMode = 'normal') {
|
||||
if (actionMode === 'normal' && /^lokal ai(?:\s|$)/i.test(message.trim())) {
|
||||
if (FEATURE_FLAGS.localAI && actionMode === 'normal' && /^lokal ai(?:\s|$)/i.test(message.trim())) {
|
||||
const response = await window.localGameAI?.settings(message);
|
||||
appendLine('user', message);
|
||||
appendLine('assistant', response || 'Kommandon: “lokal ai villkor”, “lokal ai godkänn villkor”, “lokal ai avvisa villkor”, “lokal ai på/av”, “lokal ai loggar på/av”, “lokal ai exportera loggar” och “lokal ai radera loggar”.');
|
||||
@@ -1014,7 +1149,7 @@
|
||||
: 'Berättaren arbetar med din avsikt. Den avgör om en plats, scen eller person behöver etableras och fortsätter sedan berättelsen.');
|
||||
try {
|
||||
let clientAssistance = null;
|
||||
if (actionMode === 'normal' && currentAccount && !currentAccount.is_admin && window.localGameAI) {
|
||||
if (FEATURE_FLAGS.localAI && actionMode === 'normal' && currentAccount && !currentAccount.is_admin && window.localGameAI) {
|
||||
try {
|
||||
const history = [...transcript.querySelectorAll('li')].map(line => line.textContent).filter(Boolean);
|
||||
const assisted = await window.localGameAI.assist(message, history, gameState?.world || {});
|
||||
@@ -1074,7 +1209,9 @@
|
||||
messageField.value = message;
|
||||
try {
|
||||
const history = [...transcript.querySelectorAll('li')].map(line => line.textContent).filter(Boolean);
|
||||
const localReply = await window.localGameAI?.offline(message, history, gameState?.world || {});
|
||||
const localReply = FEATURE_FLAGS.localAI
|
||||
? await window.localGameAI?.offline(message, history, gameState?.world || {})
|
||||
: null;
|
||||
if (localReply) appendLine('assistant', `Lokalt, osparat förslag: ${localReply}`, {speaker: 'Lokal hjälp'});
|
||||
else appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`);
|
||||
} catch (_) { appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`); }
|
||||
@@ -1221,6 +1358,7 @@
|
||||
adminContent.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
if (button.dataset.adminView === 'players') await loadAdminPlayers();
|
||||
else if (button.dataset.adminView === 'npcs') await loadAdminNpcs();
|
||||
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
|
||||
else if (button.dataset.adminView === 'rules') await loadAdminRules();
|
||||
else if (button.dataset.adminView === 'logging') await loadAdminLogging();
|
||||
|
||||
@@ -5,7 +5,10 @@ test -s index.html
|
||||
grep -q '<html lang="sv">' index.html
|
||||
grep -q 'id="frontend-version">__FRONTEND_VERSION__' index.html
|
||||
grep -q '<script src="/voice.js?v=__FRONTEND_VERSION__"></script>' index.html
|
||||
grep -q '<script src="/static/wllama-gemma3-v2/local-ai.js"></script>' index.html
|
||||
grep -q 'const FEATURE_FLAGS = Object.freeze({localAI: false})' index.html
|
||||
grep -q 'if (!FEATURE_FLAGS.localAI) return Promise.resolve(null)' index.html
|
||||
grep -q "script.src = '/static/' + 'wllama-gemma3-v2/local-ai.js'" index.html
|
||||
! grep -q '<script src="/static/wllama-gemma3-v2/local-ai.js"></script>' index.html
|
||||
grep -q '<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>' index.html
|
||||
grep -q 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0' nginx/default.conf
|
||||
grep -q 'Cross-Origin-Embedder-Policy "require-corp"' nginx/default.conf
|
||||
@@ -47,7 +50,8 @@ grep -q 'WebKit versions before Safari 27' voice.js
|
||||
grep -q "voiceIdentity: speakingNpc.voice_identity" index.html
|
||||
grep -q "role: 'narrator', voiceIdentity: 'neutral'" index.html
|
||||
grep -q 'COPY voice.js /usr/share/nginx/html/voice.js' Dockerfile
|
||||
grep -Fq 'localGameAI?.start(currentAccount.id)' index.html
|
||||
grep -Fq 'if (!currentAccount.is_admin && FEATURE_FLAGS.localAI)' index.html
|
||||
grep -Fq 'localAI?.start(currentAccount.id)' index.html
|
||||
grep -q "type === 'terms-required'" index.html
|
||||
grep -q 'Gemma-villkor' index.html
|
||||
grep -q 'lokal ai godkänn villkor' index.html
|
||||
@@ -101,6 +105,14 @@ grep -q "request('/api/admin/rules'" index.html
|
||||
grep -q "request('/api/admin/settings/logging'" index.html
|
||||
grep -q 'data-admin-view="logging"' index.html
|
||||
grep -q '/api/admin/audit?limit=200' index.html
|
||||
grep -q 'data-admin-view="npcs"' index.html
|
||||
grep -q '/api/admin/npcs?q=' index.html
|
||||
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/profile' index.html
|
||||
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/identity' index.html
|
||||
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}' index.html
|
||||
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/possessions' index.html
|
||||
grep -q 'Egenskaper och framtida profilfält' index.html
|
||||
grep -q 'Minnesrekonstruktioner' index.html
|
||||
grep -q 'entry.data.external_call' index.html
|
||||
grep -q 'Externt tjänsteanrop' index.html
|
||||
grep -q "request('/api/world/start'" index.html
|
||||
|
||||
@@ -5,9 +5,12 @@ const test = require('node:test');
|
||||
|
||||
const page = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
test('local AI adapter comes from the static service and starts after authenticated game entry', () => {
|
||||
assert.match(page, /<script src="\/static\/wllama-gemma3-v2\/local-ai\.js"><\/script>/);
|
||||
assert.match(page, /showPlay\(\);\s*if \(!currentAccount\.is_admin\) window\.localGameAI\?\.start\(currentAccount\.id\)/);
|
||||
test('local AI is paused by default and adapter loading is gated by its feature flag', () => {
|
||||
assert.match(page, /const FEATURE_FLAGS = Object\.freeze\(\{localAI: false\}\)/);
|
||||
assert.match(page, /if \(!FEATURE_FLAGS\.localAI\) return Promise\.resolve\(null\)/);
|
||||
assert.match(page, /script\.src = '\/static\/' \+ 'wllama-gemma3-v2\/local-ai\.js'/);
|
||||
assert.doesNotMatch(page, /<script src="\/static\/wllama-gemma3-v2\/local-ai\.js"><\/script>/);
|
||||
assert.match(page, /if \(!currentAccount\.is_admin && FEATURE_FLAGS\.localAI\)[\s\S]*?loadLocalAiAdapter\(\)/);
|
||||
});
|
||||
|
||||
test('Gemma license links are shown in the accessible game dialogue and consent can be withdrawn', () => {
|
||||
|
||||
Reference in New Issue
Block a user