Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0c2fb22c | ||
|
|
50ec861bba | ||
|
|
d6469b6a3a | ||
|
|
51ab6dd23f | ||
|
|
344996233f |
@@ -26,7 +26,7 @@ System voice commands require the prefix `röstkommando`: `upprepa`, `avbryt`, `
|
|||||||
|
|
||||||
On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The frontend resumes the audio context during a trusted tap or key press and then reads generated replies automatically. Voice settings include **Testa ljudet**; if a game reply does not start, a **Läs senaste repliken** button appears in the dialogue. First use can take longer while the voice model downloads; progress and errors are announced in the dialogue. Actual iPhone/iPad playback remains a release acceptance check and must not be claimed until tested on a device.
|
On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The frontend resumes the audio context during a trusted tap or key press and then reads generated replies automatically. Voice settings include **Testa ljudet**; if a game reply does not start, a **Läs senaste repliken** button appears in the dialogue. First use can take longer while the voice model downloads; progress and errors are announced in the dialogue. Actual iPhone/iPad playback remains a release acceptance check and must not be claimed until tested on a device.
|
||||||
|
|
||||||
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
|
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/graphql`, `/version`, and `/health` to the Rust service. Player dialogue is read through the authenticated, cursor-paginated GraphQL `dialogue(first, before)` query. Admin conversation traces use the audited `adminPlayerTrace(accountId, limit, offset)` GraphQL query. Writes remain explicit Rust command routes. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
|
||||||
|
|
||||||
The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag.
|
The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag.
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
|||||||
name: ai-spel-frontend
|
name: ai-spel-frontend
|
||||||
description: Static web frontend for the Swedish life simulator
|
description: Static web frontend for the Swedish life simulator
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.71
|
version: 0.1.73
|
||||||
appVersion: "0.1.71"
|
appVersion: "0.1.73"
|
||||||
+79
-10
@@ -31,8 +31,10 @@
|
|||||||
#admin-content article > .admin-story-reply { display: none; }
|
#admin-content article > .admin-story-reply { display: none; }
|
||||||
#admin-content .admin-simple-conversation > h2,
|
#admin-content .admin-simple-conversation > h2,
|
||||||
#admin-content .admin-simple-conversation > p:not(.admin-story-empty),
|
#admin-content .admin-simple-conversation > p:not(.admin-story-empty),
|
||||||
#admin-content .admin-simple-conversation article > :not(.admin-story-reply) { display: none; }
|
#admin-content .admin-simple-conversation article > :not(.admin-story-player):not(.admin-story-reply) { display: none; }
|
||||||
|
#admin-content .admin-simple-conversation article > .admin-story-player,
|
||||||
#admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; }
|
#admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; }
|
||||||
|
#admin-content .admin-simple-conversation article > .admin-story-player { font-weight: 650; }
|
||||||
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
#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; }
|
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
||||||
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
||||||
@@ -184,6 +186,22 @@
|
|||||||
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
async function graphqlQuery(query, variables = {}) {
|
||||||
|
const response = await request('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: JSON.stringify({query, variables}),
|
||||||
|
});
|
||||||
|
if (response.errors?.length) throw new Error(response.errors[0].message || 'GraphQL-förfrågan misslyckades');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
async function getAdminTrace(accountId = null, limit = 200, offset = 0) {
|
||||||
|
const data = await graphqlQuery(
|
||||||
|
'query AdminPlayerTrace($accountId: String, $limit: Int!, $offset: Int!) { adminPlayerTrace(accountId: $accountId, limit: $limit, offset: $offset) }',
|
||||||
|
{accountId, limit, offset},
|
||||||
|
);
|
||||||
|
return data.adminPlayerTrace;
|
||||||
|
}
|
||||||
function appendLine(role, content, options = {}) {
|
function appendLine(role, content, options = {}) {
|
||||||
const line = document.createElement('li');
|
const line = document.createElement('li');
|
||||||
line.dataset.role = role;
|
line.dataset.role = role;
|
||||||
@@ -659,7 +677,7 @@
|
|||||||
adminJson(`${profile.username} · profil och biografi`, profile);
|
adminJson(`${profile.username} · profil och biografi`, profile);
|
||||||
adminTraceOffset = 0;
|
adminTraceOffset = 0;
|
||||||
adminSimpleConversation = false;
|
adminSimpleConversation = false;
|
||||||
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=0`);
|
const trace = await getAdminTrace(accountId, 200, 0);
|
||||||
renderAdminConversations(trace, accountId);
|
renderAdminConversations(trace, accountId);
|
||||||
}
|
}
|
||||||
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
|
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
|
||||||
@@ -701,7 +719,11 @@
|
|||||||
let showedPlayerInput = false;
|
let showedPlayerInput = false;
|
||||||
for (const event of group.events) {
|
for (const event of group.events) {
|
||||||
const payload = event.payload || {};
|
const payload = event.payload || {};
|
||||||
if (payload.player_message) { adminElement('p', `Spelaren: ${payload.player_message}`, card); showedPlayerInput = true; }
|
if (payload.player_message) {
|
||||||
|
const playerLine = adminElement('p', `Spelaren: ${payload.player_message}`, card);
|
||||||
|
playerLine.className = 'admin-story-player';
|
||||||
|
showedPlayerInput = true;
|
||||||
|
}
|
||||||
const reply = payload.reply || payload.npc_reply;
|
const reply = payload.reply || payload.npc_reply;
|
||||||
if (reply) {
|
if (reply) {
|
||||||
const story = adminElement('p', `Berättelsen: ${reply}`, card);
|
const story = adminElement('p', `Berättelsen: ${reply}`, card);
|
||||||
@@ -722,9 +744,15 @@
|
|||||||
}
|
}
|
||||||
if (!showedPlayerInput) {
|
if (!showedPlayerInput) {
|
||||||
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
|
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
|
||||||
if (failedTurnInput) adminElement('p', `Spelaren: ${failedTurnInput}`, card);
|
if (failedTurnInput) {
|
||||||
|
const playerLine = adminElement('p', `Spelaren: ${failedTurnInput}`, card);
|
||||||
|
playerLine.className = 'admin-story-player';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const message of group.messages) {
|
||||||
|
const playerLine = adminElement('p', `Spelare: ${message.message}`, card);
|
||||||
|
playerLine.className = 'admin-story-player';
|
||||||
}
|
}
|
||||||
for (const message of group.messages) adminElement('p', `Spelarmeddelande: ${message.message}`, card);
|
|
||||||
for (const interaction of group.interactions) {
|
for (const interaction of group.interactions) {
|
||||||
if (!interaction.response) continue;
|
if (!interaction.response) continue;
|
||||||
const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card);
|
const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card);
|
||||||
@@ -748,7 +776,7 @@
|
|||||||
older.disabled = true;
|
older.disabled = true;
|
||||||
older.textContent = 'Hämtar äldre samtal…';
|
older.textContent = 'Hämtar äldre samtal…';
|
||||||
adminTraceOffset += trace.limit || 200;
|
adminTraceOffset += trace.limit || 200;
|
||||||
const next = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=${trace.limit || 200}&offset=${adminTraceOffset}`);
|
const next = await getAdminTrace(accountId, trace.limit || 200, adminTraceOffset);
|
||||||
const merge = (key, idKey) => [...(trace[key] || []), ...(next[key] || [])].filter((item, index, all) => all.findIndex(candidate => candidate[idKey] === item[idKey]) === index);
|
const merge = (key, idKey) => [...(trace[key] || []), ...(next[key] || [])].filter((item, index, all) => all.findIndex(candidate => candidate[idKey] === item[idKey]) === index);
|
||||||
renderAdminConversations({events: merge('events', 'event_id'), ai_interactions: merge('ai_interactions', 'id'), player_messages: merge('player_messages', 'id'), limit: next.limit, offset: next.offset}, accountId, root, next.has_more);
|
renderAdminConversations({events: merge('events', 'event_id'), ai_interactions: merge('ai_interactions', 'id'), player_messages: merge('player_messages', 'id'), limit: next.limit, offset: next.offset}, accountId, root, next.has_more);
|
||||||
});
|
});
|
||||||
@@ -1000,12 +1028,53 @@
|
|||||||
messageField.disabled = false;
|
messageField.disabled = false;
|
||||||
messageField.focus();
|
messageField.focus();
|
||||||
}
|
}
|
||||||
|
function savedDialogueLine(line) {
|
||||||
|
const element = document.createElement('li');
|
||||||
|
element.dataset.role = line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant';
|
||||||
|
element.textContent = `${line.speaker || (line.role === 'user' ? 'Du' : 'Berättelsen')}: ${line.content}`;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function dialogueLinesFromPage(page) {
|
||||||
|
return page.edges.flatMap(edge => edge.node.lines);
|
||||||
|
}
|
||||||
|
function addOlderDialogueControl(cursor) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.dataset.role = 'system';
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = 'Hämta äldre konversation';
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Hämtar äldre konversation…';
|
||||||
|
try {
|
||||||
|
const page = (await graphqlQuery(
|
||||||
|
'query OlderDialogue($before: String!) { dialogue(first: 50, before: $before) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
|
||||||
|
{before: cursor},
|
||||||
|
)).dialogue;
|
||||||
|
const oldLines = dialogueLinesFromPage(page);
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const line of oldLines) fragment.append(savedDialogueLine(line));
|
||||||
|
item.remove();
|
||||||
|
transcript.prepend(fragment);
|
||||||
|
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
|
||||||
|
} catch (error) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = `Kunde inte hämta äldre repliker: ${error.message}. Försök igen`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
item.append(button);
|
||||||
|
transcript.prepend(item);
|
||||||
|
}
|
||||||
async function loadSavedDialogue() {
|
async function loadSavedDialogue() {
|
||||||
const lines = await request('/api/dialogue');
|
const page = (await graphqlQuery(
|
||||||
|
'query CurrentDialogue { dialogue(first: 100) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
|
||||||
|
)).dialogue;
|
||||||
|
const lines = dialogueLinesFromPage(page);
|
||||||
transcript.replaceChildren();
|
transcript.replaceChildren();
|
||||||
latestAssistantText = '';
|
latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.content || '';
|
||||||
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
|
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
|
||||||
for (const line of lines) appendLine(line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant', line.content, {speaker: line.speaker, speak: false});
|
for (const line of lines) transcript.append(savedDialogueLine(line));
|
||||||
|
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
|
||||||
refreshActionTools();
|
refreshActionTools();
|
||||||
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
|
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
|
||||||
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
|
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
|
||||||
@@ -1166,7 +1235,7 @@
|
|||||||
debugRefreshBusy = true;
|
debugRefreshBusy = true;
|
||||||
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
|
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
|
||||||
try {
|
try {
|
||||||
const trace = await request('/api/admin/self/trace?limit=200');
|
const trace = await getAdminTrace(null, 200, 0);
|
||||||
renderDecisionTrace(trace);
|
renderDecisionTrace(trace);
|
||||||
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
|
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ grep -q "request('/version')" index.html
|
|||||||
grep -q 'id="static-version">hämtar version…' index.html
|
grep -q 'id="static-version">hämtar version…' index.html
|
||||||
grep -q "request('/static/version.json', {cache: 'no-store'})" index.html
|
grep -q "request('/static/version.json', {cache: 'no-store'})" index.html
|
||||||
grep -Fq "credentials: 'same-origin'" index.html
|
grep -Fq "credentials: 'same-origin'" index.html
|
||||||
|
grep -q "request('/graphql'" index.html
|
||||||
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
|
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
|
||||||
grep -q 'id="message" name="message"' index.html
|
grep -q 'id="message" name="message"' index.html
|
||||||
grep -q 'id="auth-intro"' index.html
|
grep -q 'id="auth-intro"' index.html
|
||||||
@@ -95,7 +96,8 @@ grep -q 'dialogue_temporarily_unavailable:' index.html
|
|||||||
grep -q 'id="admin-view"' index.html
|
grep -q 'id="admin-view"' index.html
|
||||||
grep -q 'id="play-layout"' index.html
|
grep -q 'id="play-layout"' index.html
|
||||||
grep -q 'id="debug-panel"' index.html
|
grep -q 'id="debug-panel"' index.html
|
||||||
grep -q "request('/api/admin/self/trace?limit=200')" index.html
|
grep -q "query AdminPlayerTrace" index.html
|
||||||
|
grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html
|
||||||
grep -q 'function renderDecisionTrace' index.html
|
grep -q 'function renderDecisionTrace' index.html
|
||||||
grep -q 'JSON.stringify(group, null, 2)' index.html
|
grep -q 'JSON.stringify(group, null, 2)' index.html
|
||||||
grep -q 'debug-enabled' index.html
|
grep -q 'debug-enabled' index.html
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ test('selected replies reveal AI, Luau and Rust provenance without injecting HTM
|
|||||||
|
|
||||||
test('account trace can be paged until all stored turns are loaded', () => {
|
test('account trace can be paged until all stored turns are loaded', () => {
|
||||||
assert.match(page, /Visa äldre samtalsturer/);
|
assert.match(page, /Visa äldre samtalsturer/);
|
||||||
assert.match(page, /\?limit=\$\{trace\.limit \|\| 200\}&offset=\$\{adminTraceOffset\}/);
|
assert.match(page, /query AdminPlayerTrace\(\$accountId: String, \$limit: Int!, \$offset: Int!\)/);
|
||||||
|
assert.match(page, /getAdminTrace\(accountId, trace\.limit \|\| 200, adminTraceOffset\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin can switch to a storyteller-only reading view and return to diagnostics', () => {
|
test('admin can switch to a storyteller-only reading view and return to diagnostics', () => {
|
||||||
assert.match(page, /Lättläst konversation/);
|
assert.match(page, /Lättläst konversation/);
|
||||||
assert.match(page, /Visa felsökningsspår/);
|
assert.match(page, /Visa felsökningsspår/);
|
||||||
assert.match(page, /root\.classList\.toggle\('admin-simple-conversation', adminSimpleConversation\)/);
|
assert.match(page, /root\.classList\.toggle\('admin-simple-conversation', adminSimpleConversation\)/);
|
||||||
|
assert.match(page, /playerLine\.className = 'admin-story-player'/);
|
||||||
assert.match(page, /story\.className = 'admin-story-reply'/);
|
assert.match(page, /story\.className = 'admin-story-reply'/);
|
||||||
assert.match(page, /article > :not\(\.admin-story-reply\)/);
|
assert.match(page, /article > :not\(\.admin-story-player\):not\(\.admin-story-reply\)/);
|
||||||
|
assert.match(page, /article > \.admin-story-player,\s*#admin-content \.admin-simple-conversation article > \.admin-story-reply/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user