Compare commits

...
4 Commits
Author SHA1 Message Date
Gitea Actions 4cb41cdeca Release 0.1.35 [auto-version-bump] 2026-10-15 18:07:56 +02:00
Ein Anderssono f189adb5d6 Add dialog-based player messaging
Build and publish frontend / publish (push) Successful in 41s
2026-10-15 18:07:39 +02:00
Gitea Actions 7acc61978d Release 0.1.34 [auto-version-bump] 2026-10-15 16:01:36 +02:00
Ein Anderssono f322df540d Show approved biography context in dialogue
Build and publish frontend / publish (push) Successful in 43s
2026-10-15 16:01:11 +02:00
4 changed files with 128 additions and 4 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.33 0.1.35
+2 -2
View File
@@ -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.33 version: 0.1.35
appVersion: "0.1.33" appVersion: "0.1.35"
+121 -1
View File
@@ -95,6 +95,9 @@
let voiceDisclosureAcknowledged = false; let voiceDisclosureAcknowledged = false;
let adminOffset = 0; let adminOffset = 0;
const adminPageSize = 50; const adminPageSize = 50;
let presenceTimer = null;
let playerMessagesPrimed = false;
const seenPlayerMessageIds = new Set();
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(path, {credentials: 'same-origin', ...options}); const response = await fetch(path, {credentials: 'same-origin', ...options});
@@ -117,6 +120,24 @@
} }
return line; return line;
} }
function biographyContextText(context) {
if (!context || typeof context !== 'object') return '';
const details = [];
if (typeof context.current_situation === 'string' && context.current_situation.trim()) {
details.push(`Nuvarande situation: ${context.current_situation.trim()}.`);
}
for (const [key, label] of [
['skills', 'Färdigheter'],
['possessions', 'Ägodelar som nämnts'],
['obligations', 'Åtaganden'],
['past_events', 'Tidigare händelser'],
['known_people', 'Kända personer'],
]) {
const values = context[key];
if (Array.isArray(values) && values.length) details.push(`${label}: ${values.join(', ')}.`);
}
return details.length ? `\n\nStartuppgifter att granska: ${details.join(' ')}` : '';
}
function explainDialogueError(error) { function explainDialogueError(error) {
const code = String(error?.message || ''); const code = String(error?.message || '');
const known = { const known = {
@@ -290,6 +311,8 @@
messageField.focus(); messageField.focus();
} }
function showAdmin() { function showAdmin() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = null;
voiceController.stopAll(); voiceController.stopAll();
auth.hidden = true; auth.hidden = true;
play.hidden = true; play.hidden = true;
@@ -361,6 +384,7 @@
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`); const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
const entries = [ const entries = [
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})), ...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
...trace.player_messages.map(message => ({kind: 'Privat spelarmeddelande', at: message.recorded_at, order: message.sequence, data: message})),
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction})) ...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))); ].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Kronologiskt spelarspår'); adminElement('h2', 'Kronologiskt spelarspår');
@@ -501,8 +525,22 @@
} }
await loadSavedDialogue(); await loadSavedDialogue();
setStage('game'); setStage('game');
startPlayerPresence();
announcePlayerChatHelp();
}
function announcePlayerChatHelp() {
const key = 'ai-spel.player-chat-help.v1';
try {
if (localStorage.getItem(key) === 'true') return;
localStorage.setItem(key, 'true');
} catch (_) { /* The help remains available through dialogue questions. */ }
appendLine('assistant', 'Du kan fråga vilka andra spelare som är här, säga “Säg till Robin: hej” för att skriva direkt till en spelare eller be mig visa meddelanden från spelare.');
} }
async function handleLogout() { async function handleLogout() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = null;
playerMessagesPrimed = false;
seenPlayerMessageIds.clear();
await request('/api/logout', {method: 'POST'}); 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.'); if (location.pathname !== '/admin') appendLine('assistant', 'Du har loggat ut. Logga in igen för att fortsätta spela.');
characterDraft = null; characterDraft = null;
@@ -542,7 +580,7 @@
body: JSON.stringify({input: message, current_draft: characterDraft}), body: JSON.stringify({input: message, current_draft: characterDraft}),
}); });
characterDraft = proposal.draft; characterDraft = proposal.draft;
appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}\n\nSkriv ändringar eller “godkänn”.`); appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}${biographyContextText(characterDraft.life_context)}\n\nSkriv ändringar eller “godkänn”.`);
} catch (error) { } catch (error) {
appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`); appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`);
} }
@@ -569,6 +607,7 @@
else await proposeWorld(message); else await proposeWorld(message);
return; return;
} }
if (await handlePlayerMessageCommand(message)) return;
const idempotencyKey = pendingDialogue?.message === message const idempotencyKey = pendingDialogue?.message === message
? pendingDialogue.idempotencyKey ? pendingDialogue.idempotencyKey
: crypto.randomUUID(); : crypto.randomUUID();
@@ -649,6 +688,87 @@
if (!play.hidden && !messageField.disabled) messageField.focus(); if (!play.hidden && !messageField.disabled) messageField.focus();
} }
} }
async function handlePlayerMessageCommand(message) {
const listPlayers = /^(?:vilka (?:andra )?spelare är här|finns det andra spelare här)\??$/i.test(message.trim());
const sendMatch = /^(?:säg|skriv|skicka) till ([^:]{1,80}):\s*(.{1,4000})$/is.exec(message.trim());
const readMessages = /^(?:visa|läs upp) (?:de senaste )?meddelanden från spelare\.?$/i.test(message.trim());
if (!listPlayers && !sendMatch && !readMessages) return false;
appendLine('user', message);
try {
if (listPlayers) {
const players = await request('/api/players/nearby');
appendLine('assistant', players.length
? `Här finns ${players.map(player => player.name).join(', ')} att prata med.`
: 'Just nu finns inga andra inloggade spelare på platsen.');
return true;
}
if (readMessages) {
const messages = await request('/api/player/messages');
const recent = messages.slice(-10);
if (recent.length === 0) appendLine('assistant', 'Du har inga sparade meddelanden från andra spelare.');
else for (const entry of recent) {
seenPlayerMessageIds.add(entry.id);
const isIncoming = entry.direction === 'incoming';
appendLine(isIncoming ? 'assistant' : 'user', entry.message, isIncoming
? {speaker: entry.sender_name, voice: {characterId: entry.sender_character_id, voiceIdentity: entry.sender_voice_identity}}
: {speaker: 'Du', speak: false});
}
return true;
}
const players = await request('/api/players/nearby');
const targetName = sendMatch[1].trim();
const matches = players.filter(player => player.name.localeCompare(targetName, 'sv', {sensitivity: 'base'}) === 0);
if (matches.length === 0) {
appendLine('assistant', `Jag hittar ingen inloggad spelare som heter ${targetName} på platsen. Fråga vilka andra spelare som är här.`);
return true;
}
if (matches.length > 1) {
appendLine('assistant', `Flera spelare heter ${targetName}. Säg vilket namn de använder i spelet.`);
return true;
}
const key = pendingPlayerMessage?.text === sendMatch[2] && pendingPlayerMessage?.target === matches[0].character_id
? pendingPlayerMessage.key
: crypto.randomUUID();
pendingPlayerMessage = {text: sendMatch[2], target: matches[0].character_id, key};
const result = await request(`/api/players/${encodeURIComponent(matches[0].character_id)}/messages`, {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({message: sendMatch[2], idempotency_key: key}),
});
pendingPlayerMessage = null;
appendLine('assistant', `Ditt meddelande skickades till ${matches[0].name}.`);
return Boolean(result.message_id);
} catch (error) {
messageField.value = message;
appendLine('assistant', `Spelarmeddelandet kunde inte hanteras: ${explainDialogueError(error)}`);
return true;
}
}
let pendingPlayerMessage = null;
async function startPlayerPresence() {
if (presenceTimer) clearInterval(presenceTimer);
const refresh = async (announceNewMessages) => {
if (play.hidden || stage !== 'game') return;
try {
await request('/api/presence/heartbeat', {method: 'POST'});
const messages = await request('/api/player/messages');
for (const entry of messages) {
if (seenPlayerMessageIds.has(entry.id)) continue;
if (announceNewMessages && entry.direction === 'incoming'
&& (submitBusy || ['LISTENING', 'PROCESSING', 'SPEAKING'].includes(voiceController.getState()))) continue;
seenPlayerMessageIds.add(entry.id);
if (announceNewMessages && entry.direction === 'incoming') {
appendLine('assistant', entry.message, {
speaker: entry.sender_name,
voice: {characterId: entry.sender_character_id, voiceIdentity: entry.sender_voice_identity},
});
}
}
playerMessagesPrimed = true;
} catch (_) { /* Presence is a hint; the text game remains usable if it is unavailable. */ }
};
await refresh(playerMessagesPrimed);
presenceTimer = setInterval(() => refresh(true), 30_000);
}
document.querySelector('#message-form').addEventListener('submit', async (event) => { document.querySelector('#message-form').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
await submitMessage(messageField.value); await submitMessage(messageField.value);
+4
View File
@@ -30,6 +30,10 @@ grep -q "event.key === 'Enter' && !event.shiftKey" index.html
grep -q "request('/api/character/draft'" index.html grep -q "request('/api/character/draft'" index.html
grep -q "request('/api/world/draft'" index.html grep -q "request('/api/world/draft'" index.html
grep -q "request('/api/dialogue'" index.html grep -q "request('/api/dialogue'" index.html
grep -q "request('/api/presence/heartbeat'" index.html
grep -q "request('/api/players/nearby'" index.html
grep -q "request('/api/player/messages'" index.html
grep -q "Säg till Robin" index.html
grep -q "result.intent === 'move' || result.intent === 'meet'" index.html grep -q "result.intent === 'move' || result.intent === 'meet'" index.html
grep -q 'dialogue_temporarily_unavailable:' index.html grep -q 'dialogue_temporarily_unavailable:' index.html
grep -q 'id="admin-view"' index.html grep -q 'id="admin-view"' index.html