|
|
@@ -17,6 +17,7 @@
|
|
|
|
#dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
|
#dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
|
#dialogue li[data-role="user"] { font-weight: 650; }
|
|
|
|
#dialogue li[data-role="user"] { font-weight: 650; }
|
|
|
|
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
|
|
|
|
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
|
|
|
|
|
|
|
|
#dialogue li[data-role="system"] { padding-inline-start: .75rem; border-inline-start: .2rem dashed currentColor; font-size: .92em; }
|
|
|
|
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
|
|
|
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
|
|
|
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
|
|
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
|
|
#voice-status { min-height: 1.5em; }
|
|
|
|
#voice-status { min-height: 1.5em; }
|
|
|
@@ -80,7 +81,7 @@
|
|
|
|
<form id="message-form">
|
|
|
|
<form id="message-form">
|
|
|
|
<label for="message" id="message-label">Skriv ett meddelande</label>
|
|
|
|
<label for="message" id="message-label">Skriv ett meddelande</label>
|
|
|
|
<textarea id="message" name="message" maxlength="4000" aria-describedby="message-help" autocomplete="off"></textarea>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
</form>
|
|
|
|
<p id="voice-status" role="status" aria-live="polite"></p>
|
|
|
|
<p id="voice-status" role="status" aria-live="polite"></p>
|
|
|
@@ -115,6 +116,7 @@
|
|
|
|
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · Statik <span id="static-version">hämtar version…</span> · API <span id="api-version">hämtar version…</span></footer>
|
|
|
|
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · Statik <span id="static-version">hämtar version…</span> · API <span id="api-version">hämtar version…</span></footer>
|
|
|
|
<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>
|
|
|
|
<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>
|
|
|
|
<script src="/voice.js?v=__FRONTEND_VERSION__"></script>
|
|
|
|
<script src="/voice.js?v=__FRONTEND_VERSION__"></script>
|
|
|
|
|
|
|
|
<script src="/static/wllama-gemma3-v2/local-ai.js"></script>
|
|
|
|
<script>
|
|
|
|
<script>
|
|
|
|
const auth = document.querySelector('#auth');
|
|
|
|
const auth = document.querySelector('#auth');
|
|
|
|
const play = document.querySelector('#play');
|
|
|
|
const play = document.querySelector('#play');
|
|
|
@@ -136,6 +138,7 @@
|
|
|
|
let submitBusy = false;
|
|
|
|
let submitBusy = false;
|
|
|
|
let pendingDialogue = null;
|
|
|
|
let pendingDialogue = null;
|
|
|
|
let latestAssistantText = '';
|
|
|
|
let latestAssistantText = '';
|
|
|
|
|
|
|
|
let latestPlayerAction = '';
|
|
|
|
let latestAssistantVoice = {role: 'narrator', voiceIdentity: 'neutral'};
|
|
|
|
let latestAssistantVoice = {role: 'narrator', voiceIdentity: 'neutral'};
|
|
|
|
let speechRecoveryElement = null;
|
|
|
|
let speechRecoveryElement = null;
|
|
|
|
const voiceSettingsKey = 'ai-spel.voice-settings.v2';
|
|
|
|
const voiceSettingsKey = 'ai-spel.voice-settings.v2';
|
|
|
@@ -160,6 +163,7 @@
|
|
|
|
line.dataset.role = role;
|
|
|
|
line.dataset.role = role;
|
|
|
|
line.textContent = `${options.speaker || (role === 'user' ? 'Du' : 'Berättelsen')}: ${content}`;
|
|
|
|
line.textContent = `${options.speaker || (role === 'user' ? 'Du' : 'Berättelsen')}: ${content}`;
|
|
|
|
transcript.append(line);
|
|
|
|
transcript.append(line);
|
|
|
|
|
|
|
|
if (role === 'user' || role === 'assistant') window.localGameAI?.record({type: 'dialogue', role, content});
|
|
|
|
line.scrollIntoView({block: 'end'});
|
|
|
|
line.scrollIntoView({block: 'end'});
|
|
|
|
if (role === 'assistant') {
|
|
|
|
if (role === 'assistant') {
|
|
|
|
latestAssistantText = content;
|
|
|
|
latestAssistantText = content;
|
|
|
@@ -170,6 +174,24 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return line;
|
|
|
|
return line;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let activityStatusLine = null;
|
|
|
|
|
|
|
|
function setActivityStatus(message) {
|
|
|
|
|
|
|
|
if (!message) return;
|
|
|
|
|
|
|
|
if (!activityStatusLine?.isConnected) {
|
|
|
|
|
|
|
|
activityStatusLine = document.createElement('li');
|
|
|
|
|
|
|
|
activityStatusLine.dataset.role = 'system';
|
|
|
|
|
|
|
|
activityStatusLine.dataset.activityStatus = 'true';
|
|
|
|
|
|
|
|
activityStatusLine.setAttribute('role', 'status');
|
|
|
|
|
|
|
|
activityStatusLine.setAttribute('aria-live', 'polite');
|
|
|
|
|
|
|
|
transcript.append(activityStatusLine);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
activityStatusLine.textContent = `Berättelsen: ${message}`;
|
|
|
|
|
|
|
|
activityStatusLine.scrollIntoView({block: 'end'});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function clearActivityStatus() {
|
|
|
|
|
|
|
|
activityStatusLine?.remove();
|
|
|
|
|
|
|
|
activityStatusLine = null;
|
|
|
|
|
|
|
|
}
|
|
|
|
function biographyContextText(context) {
|
|
|
|
function biographyContextText(context) {
|
|
|
|
if (!context || typeof context !== 'object') return '';
|
|
|
|
if (!context || typeof context !== 'object') return '';
|
|
|
|
const details = [];
|
|
|
|
const details = [];
|
|
|
@@ -204,6 +226,32 @@
|
|
|
|
const authVoiceStatus = document.querySelector('#auth-voice-status');
|
|
|
|
const authVoiceStatus = document.querySelector('#auth-voice-status');
|
|
|
|
if (authVoiceStatus && !auth.hidden && message) authVoiceStatus.textContent = message;
|
|
|
|
if (authVoiceStatus && !auth.hidden && message) authVoiceStatus.textContent = message;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener('local-ai-status', event => {
|
|
|
|
|
|
|
|
if (!currentAccount || currentAccount.is_admin || play.hidden) return;
|
|
|
|
|
|
|
|
const detail = typeof event.detail === 'string' ? {text: event.detail} : event.detail;
|
|
|
|
|
|
|
|
const statusAttribute = detail.type === 'activity' ? 'data-local-ai-activity' : 'data-local-ai-status';
|
|
|
|
|
|
|
|
let statusLine = transcript.querySelector(`li[${statusAttribute}]`);
|
|
|
|
|
|
|
|
if (!statusLine) {
|
|
|
|
|
|
|
|
statusLine = appendLine(detail.type === 'terms-required' ? 'assistant' : 'system', detail.text || '', {
|
|
|
|
|
|
|
|
speaker: 'Lokal AI', speak: detail.type === 'terms-required',
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
statusLine.setAttribute(statusAttribute, 'true');
|
|
|
|
|
|
|
|
statusLine.setAttribute('role', 'status');
|
|
|
|
|
|
|
|
statusLine.setAttribute('aria-live', 'polite');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
statusLine.replaceChildren(document.createTextNode(`Lokal AI: ${detail.text || ''}`));
|
|
|
|
|
|
|
|
if (detail.type === 'terms-required') {
|
|
|
|
|
|
|
|
statusLine.append(document.createTextNode(' Läs dokumenten: '));
|
|
|
|
|
|
|
|
for (const [label, url] of [['Spelvillkor för Gemma', detail.user_terms], ['Gemma-villkor', detail.terms], ['policy för förbjuden användning', detail.policy], ['modellinformation', detail.notice]]) {
|
|
|
|
|
|
|
|
if (!url) continue;
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
|
|
link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer';
|
|
|
|
|
|
|
|
link.textContent = label;
|
|
|
|
|
|
|
|
statusLine.append(link, document.createTextNode('. '));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
statusLine.append(document.createElement('br'), document.createTextNode(detail.command || ''));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
function updateSpeechProgress(message = '') {
|
|
|
|
function updateSpeechProgress(message = '') {
|
|
|
|
const container = document.querySelector('#speech-progress-container');
|
|
|
|
const container = document.querySelector('#speech-progress-container');
|
|
|
|
const progress = document.querySelector('#speech-progress');
|
|
|
|
const progress = document.querySelector('#speech-progress');
|
|
|
@@ -449,6 +497,7 @@
|
|
|
|
: next === 'world-draft'
|
|
|
|
: next === 'world-draft'
|
|
|
|
? 'Ändra startförslaget eller skriv “godkänn”'
|
|
|
|
? 'Ändra startförslaget eller skriv “godkänn”'
|
|
|
|
: 'Skriv vad du vill göra eller fråga';
|
|
|
|
: 'Skriv vad du vill göra eller fråga';
|
|
|
|
|
|
|
|
refreshActionTools();
|
|
|
|
messageField.focus();
|
|
|
|
messageField.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function showAuth() {
|
|
|
|
function showAuth() {
|
|
|
@@ -643,15 +692,17 @@
|
|
|
|
stage = 'world-start';
|
|
|
|
stage = 'world-start';
|
|
|
|
messageField.disabled = true;
|
|
|
|
messageField.disabled = true;
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('assistant', 'Jag tar fram ett förslag på startplats och startläge…');
|
|
|
|
setActivityStatus('Berättaren ber AI ta fram ett förslag på startvärld och en plats som passar din biografi.');
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const proposal = await request('/api/world/draft', {
|
|
|
|
const proposal = await request('/api/world/draft', {
|
|
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
|
|
body: JSON.stringify({input: message, current_draft: worldDraft}),
|
|
|
|
body: JSON.stringify({input: message, current_draft: worldDraft}),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
worldDraft = proposal.draft;
|
|
|
|
worldDraft = proposal.draft;
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
|
|
|
|
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
appendLine('assistant', `Förslaget kunde inte tas fram: ${error.message}. Skriv “försök igen” när du vill försöka på nytt.`);
|
|
|
|
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');
|
|
|
|
setStage('world-draft');
|
|
|
@@ -665,6 +716,7 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('user', message);
|
|
|
|
messageField.disabled = true;
|
|
|
|
messageField.disabled = true;
|
|
|
|
|
|
|
|
setActivityStatus('Sparar det godkända världsförslaget och skapar din startposition.');
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
gameState = await request('/api/world/start', {
|
|
|
|
gameState = await request('/api/world/start', {
|
|
|
|
method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(worldDraft),
|
|
|
|
method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(worldDraft),
|
|
|
@@ -672,8 +724,10 @@
|
|
|
|
worldDraft = null;
|
|
|
|
worldDraft = null;
|
|
|
|
await loadSavedDialogue();
|
|
|
|
await loadSavedDialogue();
|
|
|
|
setStage('game');
|
|
|
|
setStage('game');
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
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å.');
|
|
|
|
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) {
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
appendLine('assistant', `Världsförslaget kunde inte sparas: ${error.message}. Förslaget finns kvar så att du kan försöka igen.`);
|
|
|
|
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.disabled = false;
|
|
|
@@ -683,7 +737,9 @@
|
|
|
|
const lines = await request('/api/dialogue');
|
|
|
|
const lines = await request('/api/dialogue');
|
|
|
|
transcript.replaceChildren();
|
|
|
|
transcript.replaceChildren();
|
|
|
|
latestAssistantText = '';
|
|
|
|
latestAssistantText = '';
|
|
|
|
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speaker: line.speaker, speak: false});
|
|
|
|
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});
|
|
|
|
|
|
|
|
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'});
|
|
|
|
} else if (lines.length === 0 && gameState?.world) {
|
|
|
|
} else if (lines.length === 0 && gameState?.world) {
|
|
|
@@ -695,6 +751,7 @@
|
|
|
|
currentAccount = await request('/api/me');
|
|
|
|
currentAccount = await request('/api/me');
|
|
|
|
gameState = await request('/api/world');
|
|
|
|
gameState = await request('/api/world');
|
|
|
|
showPlay();
|
|
|
|
showPlay();
|
|
|
|
|
|
|
|
if (!currentAccount.is_admin) window.localGameAI?.start(currentAccount.id);
|
|
|
|
if (!gameState.character) {
|
|
|
|
if (!gameState.character) {
|
|
|
|
transcript.replaceChildren();
|
|
|
|
transcript.replaceChildren();
|
|
|
|
appendInitialPrompt();
|
|
|
|
appendInitialPrompt();
|
|
|
@@ -787,10 +844,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
for (const interaction of orderedInteractions) {
|
|
|
|
for (const interaction of orderedInteractions) {
|
|
|
|
const external = interaction.external_call || {};
|
|
|
|
const external = interaction.external_call || {};
|
|
|
|
addStep('ai', `${interaction.prompt_id} · v${interaction.prompt_version} · ${interaction.outcome}`,
|
|
|
|
const isCompaction = interaction.prompt_id === 'dialogue-context-compaction';
|
|
|
|
|
|
|
|
addStep('ai', `${isCompaction ? 'Kontextkomprimering' : interaction.prompt_id} · v${interaction.prompt_version} · ${interaction.outcome}`,
|
|
|
|
{prompt_yaml: interaction.prompt_yaml, rendered_messages: interaction.rendered_messages, provider: external.service, model: external.model, thinking_effort: external.thinking_effort, provider_request: external.request},
|
|
|
|
{prompt_yaml: interaction.prompt_yaml, rendered_messages: interaction.rendered_messages, provider: external.service, model: external.model, thinking_effort: external.thinking_effort, provider_request: external.request},
|
|
|
|
{response: interaction.response, outcome: interaction.outcome, provider_status: external.response_status, provider_response: external.response_body, provider_error: external.error, duration_ms: external.duration_ms, rust_interpretation_and_validation: interaction.metadata},
|
|
|
|
{response: interaction.response, outcome: interaction.outcome, provider_status: external.response_status, provider_response: external.response_body, provider_error: external.error, duration_ms: external.duration_ms, rust_interpretation_and_validation: interaction.metadata},
|
|
|
|
'AI-förslag och providertrafik. Förslaget är inte i sig ett fastställt världsfaktum.');
|
|
|
|
isCompaction
|
|
|
|
|
|
|
|
? 'Äldre privat dialog kondenseras; kontrollera källhändelser, prompt, modellsvar, sammanfattningshash och sparad sekvens. Sammanfattningen är inte kanonisk världssanning.'
|
|
|
|
|
|
|
|
: 'AI-förslag och providertrafik. Förslaget är inte i sig ett fastställt världsfaktum.');
|
|
|
|
const metadata = interaction.metadata || {};
|
|
|
|
const metadata = interaction.metadata || {};
|
|
|
|
if (metadata.validation_inputs || metadata.validation_results || metadata.decision?.includes?.('rule')) {
|
|
|
|
if (metadata.validation_inputs || metadata.validation_results || metadata.decision?.includes?.('rule')) {
|
|
|
|
addStep('luau', `Regelutkast och tester · ${metadata.rule_key || metadata.activity_key || interaction.prompt_id}`,
|
|
|
|
addStep('luau', `Regelutkast och tester · ${metadata.rule_key || metadata.activity_key || interaction.prompt_id}`,
|
|
|
@@ -858,6 +918,7 @@
|
|
|
|
presenceTimer = null;
|
|
|
|
presenceTimer = null;
|
|
|
|
playerMessagesPrimed = false;
|
|
|
|
playerMessagesPrimed = false;
|
|
|
|
seenPlayerMessageIds.clear();
|
|
|
|
seenPlayerMessageIds.clear();
|
|
|
|
|
|
|
|
window.localGameAI?.stop();
|
|
|
|
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;
|
|
|
@@ -890,6 +951,7 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('user', message);
|
|
|
|
messageField.disabled = true;
|
|
|
|
messageField.disabled = true;
|
|
|
|
|
|
|
|
setActivityStatus('Berättaren ber AI skapa eller uppdatera ett biografiförslag utifrån det du skrev.');
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const proposal = await request('/api/character/draft', {
|
|
|
|
const proposal = await request('/api/character/draft', {
|
|
|
|
method: 'POST',
|
|
|
|
method: 'POST',
|
|
|
@@ -897,14 +959,27 @@
|
|
|
|
body: JSON.stringify({input: message, current_draft: characterDraft}),
|
|
|
|
body: JSON.stringify({input: message, current_draft: characterDraft}),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
characterDraft = proposal.draft;
|
|
|
|
characterDraft = proposal.draft;
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
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”.`);
|
|
|
|
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) {
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
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.`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
messageField.disabled = false;
|
|
|
|
messageField.disabled = false;
|
|
|
|
messageField.focus();
|
|
|
|
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 (actionMode === 'normal' && /^lokal ai(?:\s|$)/i.test(message.trim())) {
|
|
|
|
|
|
|
|
const response = await window.localGameAI?.settings(message);
|
|
|
|
|
|
|
|
appendLine('user', message);
|
|
|
|
|
|
|
|
appendLine('assistant', response || 'Kommandon: “lokal ai villkor”, “lokal ai godkänn villkor”, “lokal ai avvisa villkor”, “lokal ai på/av”, “lokal ai loggar på/av”, “lokal ai exportera loggar” och “lokal ai radera loggar”.');
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
if (/^logga ut[.!]?$/i.test(message.trim())) {
|
|
|
|
if (/^logga ut[.!]?$/i.test(message.trim())) {
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('user', message);
|
|
|
|
await handleLogout();
|
|
|
|
await handleLogout();
|
|
|
@@ -924,21 +999,40 @@
|
|
|
|
else await proposeWorld(message);
|
|
|
|
else await proposeWorld(message);
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (await handlePlayerMessageCommand(message)) return;
|
|
|
|
if (actionMode === 'normal' && await handlePlayerMessageCommand(message)) return;
|
|
|
|
const idempotencyKey = pendingDialogue?.message === message
|
|
|
|
if (actionMode === 'normal') latestPlayerAction = message;
|
|
|
|
|
|
|
|
const idempotencyKey = actionMode === 'normal' && pendingDialogue?.message === message
|
|
|
|
? pendingDialogue.idempotencyKey
|
|
|
|
? pendingDialogue.idempotencyKey
|
|
|
|
: crypto.randomUUID();
|
|
|
|
: crypto.randomUUID();
|
|
|
|
pendingDialogue = {message, idempotencyKey};
|
|
|
|
if (actionMode === 'normal') pendingDialogue = {message, idempotencyKey};
|
|
|
|
appendLine('user', message);
|
|
|
|
appendLine('user', message, actionMode === 'dry_run' ? {speaker: 'Torrkörning'} : actionMode === 'redo' ? {speaker: 'Gör om'} : {});
|
|
|
|
messageField.disabled = true;
|
|
|
|
messageField.disabled = true;
|
|
|
|
|
|
|
|
setActivityStatus(actionMode === 'dry_run'
|
|
|
|
|
|
|
|
? 'Torrkör handlingen utan att spara eller göra framsteg.'
|
|
|
|
|
|
|
|
: actionMode === 'redo'
|
|
|
|
|
|
|
|
? 'Gör om den senaste handlingen med aktuella regler.'
|
|
|
|
|
|
|
|
: 'Berättaren arbetar med din avsikt. Den avgör om en plats, scen eller person behöver etableras och fortsätter sedan berättelsen.');
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
|
|
|
|
let clientAssistance = null;
|
|
|
|
|
|
|
|
if (actionMode === 'normal' && currentAccount && !currentAccount.is_admin && window.localGameAI) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const history = [...transcript.querySelectorAll('li')].map(line => line.textContent).filter(Boolean);
|
|
|
|
|
|
|
|
const assisted = await window.localGameAI.assist(message, history, gameState?.world || {});
|
|
|
|
|
|
|
|
clientAssistance = {
|
|
|
|
|
|
|
|
intent_hint: String(assisted.intent_hint || '').slice(0, 500),
|
|
|
|
|
|
|
|
warning: String(assisted.warning || '').slice(0, 500),
|
|
|
|
|
|
|
|
excerpts: Array.isArray(assisted.excerpts) ? assisted.excerpts.filter(item => typeof item === 'string' && item.length <= 500 && history.some(line => line.includes(item))).slice(0, 3) : [],
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
if (clientAssistance.warning) appendLine('system', `Lokal textkontroll: ${clientAssistance.warning}`, {speaker: 'Lokal AI', speak: false});
|
|
|
|
|
|
|
|
} catch (_) { /* Local assistance is optional; the original input always proceeds. */ }
|
|
|
|
|
|
|
|
}
|
|
|
|
let result;
|
|
|
|
let result;
|
|
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
result = await request('/api/dialogue', {
|
|
|
|
result = await request('/api/dialogue', {
|
|
|
|
method: 'POST',
|
|
|
|
method: 'POST',
|
|
|
|
headers: {'content-type': 'application/json'},
|
|
|
|
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', client_assistance: clientAssistance}),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
break;
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
@@ -947,25 +1041,43 @@
|
|
|
|
if (!gameState.world) throw new Error('world_not_available');
|
|
|
|
if (!gameState.world) throw new Error('world_not_available');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
gameState.world.version = result.world_version;
|
|
|
|
if (result.status !== 'dry_run') {
|
|
|
|
gameState.world.world_time = result.world_time;
|
|
|
|
gameState.world.version = result.world_version;
|
|
|
|
pendingDialogue = null;
|
|
|
|
gameState.world.world_time = result.world_time;
|
|
|
|
if (result.intent === 'move' || result.intent === 'meet') {
|
|
|
|
pendingDialogue = null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (result.status !== 'dry_run' && (result.intent === 'move' || result.intent === 'meet')) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const refreshed = await request('/api/world');
|
|
|
|
const refreshed = await request('/api/world');
|
|
|
|
if (refreshed.world) gameState = refreshed;
|
|
|
|
if (refreshed.world) gameState = refreshed;
|
|
|
|
} catch (_) { /* The committed dialogue stays successful if this optional view refresh fails. */ }
|
|
|
|
} catch (_) { /* The committed dialogue stays successful if this optional view refresh fails. */ }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const speakingNpc = ['speak', 'meet'].includes(result.intent) ? gameState.world.npc : null;
|
|
|
|
clearActivityStatus();
|
|
|
|
|
|
|
|
const speakingNpc = result.status !== 'dry_run' && ['speak', 'meet'].includes(result.intent) ? gameState.world.npc : null;
|
|
|
|
|
|
|
|
for (const change of result.world_changes || []) {
|
|
|
|
|
|
|
|
appendLine('system', change, {speaker: 'Världsändring', speak: false});
|
|
|
|
|
|
|
|
}
|
|
|
|
for (const ruleName of result.debug_rules_created || []) {
|
|
|
|
for (const ruleName of result.debug_rules_created || []) {
|
|
|
|
appendLine('assistant', ruleName, {speaker: 'Nya regler', speak: false});
|
|
|
|
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: speakingNpc.name, voice: {characterId: speakingNpc.id, voiceIdentity: speakingNpc.voice_identity}}
|
|
|
|
: {speaker: 'Berättaren', voice: {role: 'narrator', voiceIdentity: 'neutral'}});
|
|
|
|
: {speaker: 'Berättaren', voice: {role: 'narrator', voiceIdentity: 'neutral'}});
|
|
|
|
|
|
|
|
if (result.context_compaction?.notice) {
|
|
|
|
|
|
|
|
appendLine('system', result.context_compaction.notice, {speaker: 'Kontext', speak: false});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
refreshActionTools();
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
clearActivityStatus();
|
|
|
|
messageField.value = message;
|
|
|
|
messageField.value = message;
|
|
|
|
appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`);
|
|
|
|
try {
|
|
|
|
|
|
|
|
const history = [...transcript.querySelectorAll('li')].map(line => line.textContent).filter(Boolean);
|
|
|
|
|
|
|
|
const localReply = await window.localGameAI?.offline(message, history, gameState?.world || {});
|
|
|
|
|
|
|
|
if (localReply) appendLine('assistant', `Lokalt, osparat förslag: ${localReply}`, {speaker: 'Lokal hjälp'});
|
|
|
|
|
|
|
|
else appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`);
|
|
|
|
|
|
|
|
} catch (_) { appendLine('assistant', `${explainDialogueError(error)} Ditt meddelande ligger kvar i inmatningen.`); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
messageField.disabled = false;
|
|
|
|
messageField.disabled = false;
|
|
|
|
messageField.focus();
|
|
|
|
messageField.focus();
|
|
|
@@ -987,11 +1099,11 @@
|
|
|
|
if (location.pathname !== '/admin') voiceController.speak(status.textContent, {role: 'narrator', voiceIdentity: 'neutral'});
|
|
|
|
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;
|
|
|
|
if (submitBusy || messageField.disabled) return;
|
|
|
|
const message = String(input || '').trim();
|
|
|
|
const message = String(input || '').trim();
|
|
|
|
if (!message) return;
|
|
|
|
if (!message) return;
|
|
|
|
if (handleVoiceCommand(message, source)) { messageField.value = ''; return; }
|
|
|
|
if (actionMode === 'normal' && handleVoiceCommand(message, source)) { messageField.value = ''; return; }
|
|
|
|
submitBusy = true;
|
|
|
|
submitBusy = true;
|
|
|
|
refreshMicrophone();
|
|
|
|
refreshMicrophone();
|
|
|
|
voiceController.stopListening();
|
|
|
|
voiceController.stopListening();
|
|
|
@@ -1003,11 +1115,12 @@
|
|
|
|
messageField.value = '';
|
|
|
|
messageField.value = '';
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
if (stage === 'biography') await handleBiography(message);
|
|
|
|
if (stage === 'biography') await handleBiography(message);
|
|
|
|
else await handleGameMessage(message);
|
|
|
|
else await handleGameMessage(message, actionMode);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
|
|
|
|
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
|
|
|
|
} finally {
|
|
|
|
} finally {
|
|
|
|
submitBusy = false;
|
|
|
|
submitBusy = false;
|
|
|
|
|
|
|
|
refreshActionTools();
|
|
|
|
refreshMicrophone();
|
|
|
|
refreshMicrophone();
|
|
|
|
if (currentAccount?.is_admin) await refreshDecisionTrace();
|
|
|
|
if (currentAccount?.is_admin) await refreshDecisionTrace();
|
|
|
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
|
|
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
|
|
@@ -1098,6 +1211,12 @@
|
|
|
|
event.preventDefault();
|
|
|
|
event.preventDefault();
|
|
|
|
await submitMessage(messageField.value);
|
|
|
|
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 () => {
|
|
|
|
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
|
|
|
|
adminContent.setAttribute('aria-busy', 'true');
|
|
|
|
adminContent.setAttribute('aria-busy', 'true');
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|