diff --git a/README.md b/README.md index 817c5a2..bc7a6b5 100644 --- a/README.md +++ b/README.md @@ -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. -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. diff --git a/index.html b/index.html index ade5307..c97d958 100644 --- a/index.html +++ b/index.html @@ -184,6 +184,22 @@ if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`); 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 = {}) { const line = document.createElement('li'); line.dataset.role = role; @@ -659,7 +675,7 @@ adminJson(`${profile.username} · profil och biografi`, profile); adminTraceOffset = 0; 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); } function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) { @@ -748,7 +764,7 @@ older.disabled = true; older.textContent = 'Hämtar äldre samtal…'; 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); 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 +1016,53 @@ messageField.disabled = false; 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() { - 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(); - latestAssistantText = ''; + latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.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(); if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') { voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'}); @@ -1166,7 +1223,7 @@ debugRefreshBusy = true; debugStatus.textContent = 'Hämtar revisionsloggad spårning…'; try { - const trace = await request('/api/admin/self/trace?limit=200'); + const trace = await getAdminTrace(null, 200, 0); renderDecisionTrace(trace); debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`; } catch (error) { diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 6455f06..5713a65 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -19,6 +19,7 @@ grep -q "request('/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 -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="message" name="message"' 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="play-layout"' 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 'JSON.stringify(group, null, 2)' index.html grep -q 'debug-enabled' index.html