Add redo and dry-run dialogue actions
Build and publish frontend / publish (push) Successful in 55s

This commit is contained in:
Ein Anderssono
2026-10-19 03:29:21 +02:00
parent de742f4079
commit a235d71c21
2 changed files with 40 additions and 16 deletions
+38 -16
View File
@@ -80,7 +80,7 @@
<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>
<div id="message-tools"><button type="submit" id="send-message">Skicka</button> <button type="button" id="microphone" aria-pressed="false" aria-describedby="voice-status">Starta mikrofon</button></div>
<div id="message-tools"><button type="submit" id="send-message">Skicka</button> <button type="button" id="microphone" aria-pressed="false" aria-describedby="voice-status">Starta mikrofon</button> <button type="button" id="redo-action" disabled>Gör om senaste</button> <button type="button" id="dry-run-action" disabled>Torrkör senaste</button></div>
<span id="message-help" class="sr-only">Tryck på Skicka eller Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
</form>
<p id="voice-status" role="status" aria-live="polite"></p>
@@ -136,6 +136,7 @@
let submitBusy = false;
let pendingDialogue = null;
let latestAssistantText = '';
let latestPlayerAction = '';
let latestAssistantVoice = {role: 'narrator', voiceIdentity: 'neutral'};
let speechRecoveryElement = null;
const voiceSettingsKey = 'ai-spel.voice-settings.v2';
@@ -449,6 +450,7 @@
: next === 'world-draft'
? 'Ändra startförslaget eller skriv “godkänn”'
: 'Skriv vad du vill göra eller fråga';
refreshActionTools();
messageField.focus();
}
function showAuth() {
@@ -683,7 +685,9 @@
const lines = await request('/api/dialogue');
transcript.replaceChildren();
latestAssistantText = '';
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speaker: line.speaker, speak: false});
refreshActionTools();
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
} else if (lines.length === 0 && gameState?.world) {
@@ -904,7 +908,12 @@
messageField.disabled = false;
messageField.focus();
}
async function handleGameMessage(message) {
function refreshActionTools() {
const enabled = stage === 'game' && Boolean(latestPlayerAction) && !submitBusy;
document.querySelector('#redo-action').disabled = !enabled;
document.querySelector('#dry-run-action').disabled = !enabled;
}
async function handleGameMessage(message, actionMode = 'normal') {
if (/^logga ut[.!]?$/i.test(message.trim())) {
appendLine('user', message);
await handleLogout();
@@ -924,12 +933,13 @@
else await proposeWorld(message);
return;
}
if (await handlePlayerMessageCommand(message)) return;
const idempotencyKey = pendingDialogue?.message === message
if (actionMode === 'normal' && await handlePlayerMessageCommand(message)) return;
if (actionMode === 'normal') latestPlayerAction = message;
const idempotencyKey = actionMode === 'normal' && pendingDialogue?.message === message
? pendingDialogue.idempotencyKey
: crypto.randomUUID();
pendingDialogue = {message, idempotencyKey};
appendLine('user', message);
if (actionMode === 'normal') pendingDialogue = {message, idempotencyKey};
appendLine('user', message, actionMode === 'dry_run' ? {speaker: 'Torrkörning'} : actionMode === 'redo' ? {speaker: 'Gör om'} : {});
messageField.disabled = true;
try {
let result;
@@ -938,7 +948,7 @@
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}),
body: JSON.stringify({message, expected_world_version: gameState.world.version, idempotency_key: idempotencyKey, forget_previous_turn: actionMode !== 'normal', dry_run: actionMode === 'dry_run'}),
});
break;
} catch (error) {
@@ -947,22 +957,27 @@
if (!gameState.world) throw new Error('world_not_available');
}
}
gameState.world.version = result.world_version;
gameState.world.world_time = result.world_time;
pendingDialogue = null;
if (result.intent === 'move' || result.intent === 'meet') {
if (result.status !== 'dry_run') {
gameState.world.version = result.world_version;
gameState.world.world_time = result.world_time;
pendingDialogue = null;
}
if (result.status !== 'dry_run' && (result.intent === 'move' || result.intent === 'meet')) {
try {
const refreshed = await request('/api/world');
if (refreshed.world) gameState = refreshed;
} catch (_) { /* The committed dialogue stays successful if this optional view refresh fails. */ }
}
const speakingNpc = ['speak', 'meet'].includes(result.intent) ? gameState.world.npc : null;
const speakingNpc = result.status !== 'dry_run' && ['speak', 'meet'].includes(result.intent) ? gameState.world.npc : null;
for (const ruleName of result.debug_rules_created || []) {
appendLine('assistant', ruleName, {speaker: 'Nya regler', speak: false});
}
appendLine('assistant', result.reply, speakingNpc
appendLine('assistant', result.reply, result.status === 'dry_run'
? {speaker: 'Torrkörning', voice: {role: 'narrator', voiceIdentity: 'neutral'}}
: speakingNpc
? {speaker: speakingNpc.name, voice: {characterId: speakingNpc.id, voiceIdentity: speakingNpc.voice_identity}}
: {speaker: 'Berättaren', voice: {role: 'narrator', voiceIdentity: 'neutral'}});
refreshActionTools();
} catch (error) {
messageField.value = message;
appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`);
@@ -987,11 +1002,11 @@
if (location.pathname !== '/admin') voiceController.speak(status.textContent, {role: 'narrator', voiceIdentity: 'neutral'});
}
});
async function submitMessage(input, source = 'text') {
async function submitMessage(input, source = 'text', actionMode = 'normal') {
if (submitBusy || messageField.disabled) return;
const message = String(input || '').trim();
if (!message) return;
if (handleVoiceCommand(message, source)) { messageField.value = ''; return; }
if (actionMode === 'normal' && handleVoiceCommand(message, source)) { messageField.value = ''; return; }
submitBusy = true;
refreshMicrophone();
voiceController.stopListening();
@@ -1003,11 +1018,12 @@
messageField.value = '';
try {
if (stage === 'biography') await handleBiography(message);
else await handleGameMessage(message);
else await handleGameMessage(message, actionMode);
} catch (error) {
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
} finally {
submitBusy = false;
refreshActionTools();
refreshMicrophone();
if (currentAccount?.is_admin) await refreshDecisionTrace();
if (!play.hidden && !messageField.disabled) messageField.focus();
@@ -1098,6 +1114,12 @@
event.preventDefault();
await submitMessage(messageField.value);
});
document.querySelector('#redo-action').addEventListener('click', () => {
if (latestPlayerAction) submitMessage(latestPlayerAction, 'button', 'redo');
});
document.querySelector('#dry-run-action').addEventListener('click', () => {
if (latestPlayerAction) submitMessage(latestPlayerAction, 'button', 'dry_run');
});
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
adminContent.setAttribute('aria-busy', 'true');
try {