Files
ai-spel-frontend/index.html
T
Ein Anderssono 5a44d3d4f4
Build and publish frontend / publish (push) Successful in 45s
Show external service traces in admin player view
2026-10-15 08:50:48 +02:00

444 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Svensk livssimulator</title>
<style>
:root { font: 1rem/1.6 system-ui, sans-serif; }
body { max-width: 52rem; margin: 0 auto; padding: 1rem; }
h1, h2 { line-height: 1.2; }
form { display: grid; gap: .75rem; max-width: 32rem; }
label { display: grid; gap: .25rem; font-weight: 600; }
input, textarea, button { font: inherit; padding: .65rem; }
button { justify-self: start; cursor: pointer; }
#dialogue { list-style: none; padding: 0; margin: 0; }
#dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
#dialogue li[data-role="user"] { font-weight: 650; }
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
#admin-content { display: grid; gap: 1rem; }
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
[hidden] { display: none !important; }
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
</style>
</head>
<body>
<main>
<section id="auth" aria-labelledby="auth-heading">
<h1 id="auth-heading">Svensk livssimulator</h1>
<p>Skapa en vuxen person och utforska en bestående värld genom dialog. Inga bilder används.</p>
<form id="auth-form">
<label>Användarnamn <input name="username" required minlength="3" maxlength="40" autocomplete="username"></label>
<label>Lösenord <input name="password" type="password" required minlength="12" maxlength="256" autocomplete="current-password"></label>
<div><button type="submit" name="intent" value="login">Logga in</button> <button type="submit" name="intent" value="register">Skapa konto</button></div>
</form>
</section>
<section id="play" aria-label="Spelets dialog" hidden>
<h1 class="sr-only">Dialog</h1>
<ol id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite" aria-relevant="additions text"></ol>
<form id="message-form">
<label for="message" id="message-label">Skriv ett meddelande</label>
<textarea id="message" name="message" maxlength="4000" aria-describedby="message-help" autocomplete="off"></textarea>
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
</form>
</section>
<section id="admin-view" aria-labelledby="admin-heading" hidden>
<h1 id="admin-heading">Administration</h1>
<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="prompts">Prompter</button>
<button type="button" data-admin-view="rules">Luau-regler</button>
<button type="button" data-admin-view="audit">Revisionslogg</button>
<button type="button" id="admin-logout">Logga ut</button>
</nav>
<div id="admin-content" aria-live="polite" aria-busy="false"></div>
</section>
</main>
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
<script>
const auth = document.querySelector('#auth');
const play = document.querySelector('#play');
const adminView = document.querySelector('#admin-view');
const adminContent = document.querySelector('#admin-content');
const transcript = document.querySelector('#dialogue');
const messageField = document.querySelector('#message');
const messageLabel = document.querySelector('#message-label');
let stage = 'auth';
let characterDraft = null;
let worldDraft = null;
let gameState = null;
let submitBusy = false;
let pendingDialogue = null;
let adminOffset = 0;
const adminPageSize = 50;
async function request(path, options = {}) {
const response = await fetch(path, {credentials: 'same-origin', ...options});
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
return body;
}
function appendLine(role, content) {
const line = document.createElement('li');
line.dataset.role = role;
line.textContent = `${role === 'user' ? 'Du' : 'Berättelsen'}: ${content}`;
transcript.append(line);
line.scrollIntoView({block: 'end'});
return line;
}
function setStage(next) {
stage = next;
messageField.disabled = false;
messageField.value = '';
messageLabel.textContent = next === 'biography'
? 'Berätta fritt om personen du vill spela'
: next === 'world-start'
? 'Skriv “försök igen” för att försöka skapa startplatsen'
: next === 'world-draft'
? 'Ändra startförslaget eller skriv “godkänn”'
: 'Skriv vad du vill göra eller fråga';
messageField.focus();
}
function showAuth() {
stage = 'auth';
play.hidden = true;
adminView.hidden = true;
auth.hidden = false;
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
document.querySelector('#version-footer').hidden = false;
}
function showPlay() {
auth.hidden = true;
adminView.hidden = true;
play.hidden = false;
document.querySelector('#version-footer').hidden = true;
messageField.focus();
}
function showAdmin() {
auth.hidden = true;
play.hidden = true;
adminView.hidden = false;
document.querySelector('#version-footer').hidden = true;
}
function adminElement(tag, text, parent = adminContent) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
parent.append(element);
return element;
}
function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details);
return details;
}
async function loadAdminPlayers(query = '', offset = 0) {
adminOffset = offset;
adminContent.replaceChildren();
const search = adminElement('form');
search.id = 'player-search';
const label = adminElement('label', 'Sök efter användarnamn eller karaktär', search);
const field = document.createElement('input');
field.type = 'search'; field.name = 'q'; field.value = query; field.maxLength = 100;
label.append(field);
const submit = adminElement('button', 'Sök', search); submit.type = 'submit';
search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); });
const result = await request(`/api/admin/players?q=${encodeURIComponent(query)}&limit=${adminPageSize}&offset=${offset}`);
adminElement('p', `${result.total} spelare. Visar ${result.offset + 1}–${Math.min(result.total, result.offset + result.players.length)}.`, adminContent);
const list = adminElement('ul');
for (const player of result.players) {
const item = adminElement('li', undefined, list);
const button = adminElement('button', `${player.username} · ${player.character_name || 'ingen karaktär'} · världsversion ${player.world_version ?? 'saknas'}`, item);
button.type = 'button'; button.addEventListener('click', () => openAdminPlayer(player.account_id));
}
const paging = adminElement('div');
const previous = adminElement('button', 'Föregående sida', paging);
previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminPlayers(query, Math.max(0, offset - adminPageSize)));
const next = adminElement('button', 'Nästa sida', paging);
next.type = 'button'; next.disabled = offset + result.players.length >= result.total; next.addEventListener('click', () => loadAdminPlayers(query, offset + adminPageSize));
}
async function openAdminPlayer(accountId) {
adminContent.replaceChildren();
const back = adminElement('button', 'Tillbaka till spelare'); back.type = 'button'; back.addEventListener('click', () => loadAdminPlayers('', adminOffset));
adminElement('h2', 'Spelarprofil');
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
adminJson(`${profile.username} · profil och biografi`, profile);
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
const entries = [
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction}))
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Kronologiskt spelarspår');
adminElement('p', 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.');
for (const [index, entry] of entries.entries()) {
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd';
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data);
if (entry.data.external_call && Object.keys(entry.data.external_call).length > 0) {
const call = entry.data.external_call;
const outcome = call.error || `HTTP ${call.response_status ?? 'okänt'}`;
adminJson(`Externt tjänsteanrop · ${call.service || 'okänd tjänst'} · ${outcome} · ${call.duration_ms ?? '?'} ms`, call);
}
}
}
async function loadAdminPrompts() {
adminContent.replaceChildren();
const result = await request('/api/admin/prompts');
adminElement('h2', 'Prompttillgångar');
for (const prompt of result.prompts) adminJson(`${prompt.id} · version ${prompt.version}`, prompt.yaml);
}
async function loadAdminRules() {
adminContent.replaceChildren();
const result = await request('/api/admin/rules');
adminElement('h2', 'Luau-regler');
for (const rule of result.rules) adminJson(`${rule.rule_key} · v${rule.version} · ${rule.active ? 'aktiv' : 'inaktiv'} · ${rule.source_hash}`, {source: rule.source, validation_results: rule.validation_results, created_at: rule.created_at});
}
async function loadAdminAudit() {
adminContent.replaceChildren();
const result = await request('/api/admin/audit?limit=200');
adminElement('h2', 'Revisionslogg');
for (const entry of result.entries) adminJson(`${entry.created_at} · ${entry.action} · ${entry.admin_account_id || entry.operator_identity}`, entry);
}
async function openAdmin() {
showAdmin();
adminContent.setAttribute('aria-busy', 'true');
try { await loadAdminPlayers(); }
catch (error) { adminContent.replaceChildren(); adminElement('p', `Administrationsvyn kunde inte öppnas: ${error.message}`); }
finally { adminContent.setAttribute('aria-busy', 'false'); }
}
function appendInitialPrompt() {
appendLine('assistant', 'Välkommen. Skriv fritt om personen du vill spela. Jag föreslår en vuxen biografi som du kan ändra innan du godkänner den.');
setStage('biography');
}
async function proposeWorld(message) {
stage = 'world-start';
messageField.disabled = true;
appendLine('user', message);
appendLine('assistant', 'Jag tar fram ett förslag på startplats och startläge…');
try {
const proposal = await request('/api/world/draft', {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({input: message, current_draft: worldDraft}),
});
worldDraft = proposal.draft;
const senses = worldDraft.location_senses;
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nLjud: ${senses.sound}\nDoft: ${senses.smell}\nLuft och temperatur: ${senses.air_and_temperature}\nBeröring och ytor: ${senses.touch_and_surfaces}\nOrientering: ${senses.spatial_orientation}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
} catch (error) {
appendLine('assistant', `Förslaget kunde inte tas fram: ${error.message}. Skriv “försök igen” när du vill försöka på nytt.`);
}
setStage('world-draft');
messageField.disabled = false;
messageField.focus();
}
async function approveWorldDraft(message) {
if (!worldDraft) {
appendLine('assistant', 'Det finns inget världsförslag att godkänna. Skriv “försök igen” först.');
return;
}
appendLine('user', message);
messageField.disabled = true;
try {
gameState = await request('/api/world/start', {
method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(worldDraft),
});
worldDraft = null;
await loadSavedDialogue();
setStage('game');
appendLine('assistant', 'Du kan fråga om platsen, dina saker eller din ekonomi, vänta, handla, prata med någon som finns här eller skriva vart du vill gå.');
} catch (error) {
appendLine('assistant', `Världsförslaget kunde inte sparas: ${error.message}. Förslaget finns kvar så att du kan försöka igen.`);
}
messageField.disabled = false;
messageField.focus();
}
async function loadSavedDialogue() {
const lines = await request('/api/dialogue');
transcript.replaceChildren();
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content);
if (lines.length === 0 && gameState?.world) {
const place = gameState.world.location;
const senses = place.senses || {};
appendLine('assistant', `Du börjar på ${place.name}. ${place.description} Ljud: ${senses.sound || 'Inget särskilt ljud beskrivet.'} Doft: ${senses.smell || 'Ingen särskild doft beskriven.'} Luft och temperatur: ${senses.air_and_temperature || 'Inte beskrivet.'} Beröring och ytor: ${senses.touch_and_surfaces || 'Inte beskrivet.'} Orientering: ${senses.spatial_orientation || 'Inte beskrivet.'}`);
}
}
async function openGame() {
gameState = await request('/api/world');
showPlay();
if (!gameState.character) {
transcript.replaceChildren();
appendInitialPrompt();
return;
}
characterDraft = null;
if (!gameState.world) {
worldDraft = null;
appendLine('assistant', 'Nu föreslår jag en startplats som passar biografin. Du kan ändra förslaget innan något sparas. Skriv “börja”.');
setStage('world-draft');
return;
}
await loadSavedDialogue();
setStage('game');
}
async function handleLogout() {
await request('/api/logout', {method: 'POST'});
characterDraft = null;
gameState = null;
transcript.replaceChildren();
showAuth();
}
async function handleBiography(message) {
if (/^(godkänn|jag godkänner|godkänn biografin)[.!]?$/i.test(message.trim())) {
if (!characterDraft) {
appendLine('assistant', 'Jag har inget biografiutkast att godkänna än. Berätta först om personen du vill spela.');
return;
}
appendLine('user', message);
messageField.disabled = true;
appendLine('assistant', 'Sparar den godkända karaktären…');
try {
await request('/api/character', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(characterDraft)});
characterDraft = null;
await openGame();
} catch (error) {
if (error.message === 'character_already_exists') {
await openGame();
} else {
appendLine('assistant', `Karaktären kunde inte sparas: ${error.message}. Utkastet finns kvar så att du kan försöka igen.`);
setStage('biography');
}
}
return;
}
appendLine('user', message);
messageField.disabled = true;
try {
const proposal = await request('/api/character/draft', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({input: message, current_draft: characterDraft}),
});
characterDraft = proposal.draft;
appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}\n\nSkriv ändringar eller “godkänn”.`);
} catch (error) {
appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`);
}
messageField.disabled = false;
messageField.focus();
}
async function handleGameMessage(message) {
if (/^logga ut[.!]?$/i.test(message.trim())) {
appendLine('user', message);
await handleLogout();
return;
}
if (stage === 'world-start') {
if (/^försök igen[.!]?$/i.test(message.trim())) {
await proposeWorld(message);
} else {
appendLine('user', message);
appendLine('assistant', 'Startplatsen är inte skapad än. Skriv “försök igen” när du vill försöka på nytt.');
}
return;
}
if (stage === 'world-draft') {
if (/^godkänn[.!]?$/i.test(message.trim())) await approveWorldDraft(message);
else await proposeWorld(message);
return;
}
const idempotencyKey = pendingDialogue?.message === message
? pendingDialogue.idempotencyKey
: crypto.randomUUID();
pendingDialogue = {message, idempotencyKey};
appendLine('user', message);
messageField.disabled = true;
try {
let result;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
result = await request('/api/dialogue', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({message, expected_world_version: gameState.world.version, idempotency_key: idempotencyKey}),
});
break;
} catch (error) {
if (error.message !== 'world_version_conflict' || attempt > 0) throw error;
gameState = await request('/api/world');
if (!gameState.world) throw new Error('world_not_available');
}
}
gameState.world.version = result.world_version;
gameState.world.world_time = result.world_time;
pendingDialogue = null;
appendLine('assistant', result.reply);
} catch (error) {
messageField.value = message;
appendLine('assistant', `Meddelandet kunde inte behandlas: ${error.message}. Texten ligger kvar i inmatningen; skicka igen för att försöka på nytt.`);
}
messageField.disabled = false;
messageField.focus();
}
document.querySelector('#auth-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const intent = event.submitter?.value || 'login';
try {
await request(`/api/${intent}`, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({username: data.get('username'), password: data.get('password')})});
form.reset();
if (location.pathname === '/admin') await openAdmin(); else await openGame();
} catch (error) {
window.alert(`Kunde inte logga in: ${error.message}`);
}
});
document.querySelector('#message-form').addEventListener('submit', async (event) => {
event.preventDefault();
if (submitBusy || messageField.disabled) return;
const message = messageField.value.trim();
if (!message) return;
submitBusy = true;
messageField.value = '';
try {
if (stage === 'biography') await handleBiography(message);
else await handleGameMessage(message);
} catch (error) {
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
} finally {
submitBusy = false;
if (!play.hidden && !messageField.disabled) messageField.focus();
}
});
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
adminContent.setAttribute('aria-busy', 'true');
try {
if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules();
else await loadAdminAudit();
} catch (error) { adminContent.replaceChildren(); adminElement('p', `Vyn kunde inte läsas: ${error.message}`); }
finally { adminContent.setAttribute('aria-busy', 'false'); }
}));
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
messageField.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
document.querySelector('#message-form').requestSubmit();
}
});
request('/version').then(({version}) => {
document.querySelector('#api-version').textContent = version;
}).catch(() => {
document.querySelector('#api-version').textContent = 'otillgänglig';
});
request('/api/me').then(() => location.pathname === '/admin' ? openAdmin() : openGame()).catch(showAuth);
</script>
</body>
</html>