@@ -17,6 +17,7 @@
# dialogue li { margin-block : 1 rem ; white-space : pre-wrap ; overflow-wrap : anywhere ; }
# dialogue li { margin-block : 1 rem ; 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 : .75 rem ; border-inline-start : .2 rem solid currentColor ; }
# dialogue li [ data-role = "assistant" ] { padding-inline-start : .75 rem ; border-inline-start : .2 rem solid currentColor ; }
# dialogue li [ data-role = "system" ] { padding-inline-start : .75 rem ; border-inline-start : .2 rem dashed currentColor ; font-size : .92 em ; }
# message { width : 100 % ; min-height : 3.5 rem ; box-sizing : border-box ; }
# message { width : 100 % ; min-height : 3.5 rem ; box-sizing : border-box ; }
# message-tools { display : flex ; flex-wrap : wrap ; align-items : center ; gap : .5 rem ; }
# message-tools { display : flex ; flex-wrap : wrap ; align-items : center ; gap : .5 rem ; }
# voice-status { min-height : 1.5 em ; }
# voice-status { min-height : 1.5 em ; }
@@ -26,6 +27,12 @@
# dialogue li [ data-role = "voice-controls" ] fieldset { display : grid ; gap : .5 rem ; margin-block : .5 rem ; }
# dialogue li [ data-role = "voice-controls" ] fieldset { display : grid ; gap : .5 rem ; margin-block : .5 rem ; }
# admin-content { display : grid ; gap : 1 rem ; }
# admin-content { display : grid ; gap : 1 rem ; }
# admin-content section , # admin-content article { border : 1 px solid currentColor ; border-radius : .35 rem ; padding : .75 rem ; }
# admin-content section , # admin-content article { border : 1 px solid currentColor ; border-radius : .35 rem ; padding : .75 rem ; }
# admin-content article > button { display : block ; max-width : 100 % ; margin-block : .5 rem ; text-align : start ; white-space : normal ; }
# admin-content article > . admin-story-reply { display : none ; }
# admin-content . admin-simple-conversation > h2 ,
# 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 > . admin-story-reply { display : block ; margin-block : .85 rem ; white-space : pre-wrap ; }
# admin-content pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
# admin-content pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
# admin-toolbar { display : flex ; flex-wrap : wrap ; gap : .5 rem ; margin-block : 1 rem ; }
# admin-toolbar { display : flex ; flex-wrap : wrap ; gap : .5 rem ; margin-block : 1 rem ; }
# player-search { display : flex ; flex-wrap : wrap ; align-items : end ; gap : .5 rem ; }
# player-search { display : flex ; flex-wrap : wrap ; align-items : end ; gap : .5 rem ; }
@@ -80,7 +87,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 >
@@ -103,6 +110,7 @@
< p > Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.< / p >
< p > Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.< / p >
< nav id = "admin-toolbar" aria-label = "Administrationsvyer" >
< nav id = "admin-toolbar" aria-label = "Administrationsvyer" >
< button type = "button" data-admin-view = "players" > Spelare< / button >
< button type = "button" data-admin-view = "players" > Spelare< / button >
< button type = "button" data-admin-view = "npcs" > NPC:er< / button >
< button type = "button" data-admin-view = "prompts" > Prompter< / button >
< button type = "button" data-admin-view = "prompts" > Prompter< / button >
< button type = "button" data-admin-view = "rules" > Luau-regler< / button >
< button type = "button" data-admin-view = "rules" > Luau-regler< / button >
< button type = "button" data-admin-view = "logging" > Driftloggar< / button >
< button type = "button" data-admin-view = "logging" > Driftloggar< / button >
@@ -116,6 +124,24 @@
< 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 >
< script >
// Local Gemma/Wllama assistance is paused until explicitly re-enabled here.
// Keep the adapter unloaded when false so it cannot download or initialize.
const FEATURE _FLAGS = Object . freeze ( { localAI : false } ) ;
let localAiAdapterPromise = null ;
function loadLocalAiAdapter ( ) {
if ( ! FEATURE _FLAGS . localAI ) return Promise . resolve ( null ) ;
if ( window . localGameAI ) return Promise . resolve ( window . localGameAI ) ;
if ( ! localAiAdapterPromise ) {
localAiAdapterPromise = new Promise ( ( resolve ) => {
const script = document . createElement ( 'script' ) ;
script . src = '/static/' + 'wllama-gemma3-v2/local-ai.js' ;
script . onload = ( ) => resolve ( window . localGameAI || null ) ;
script . onerror = ( ) => resolve ( null ) ;
document . head . append ( script ) ;
} ) ;
}
return localAiAdapterPromise ;
}
const auth = document . querySelector ( '#auth' ) ;
const auth = document . querySelector ( '#auth' ) ;
const play = document . querySelector ( '#play' ) ;
const play = document . querySelector ( '#play' ) ;
const playLayout = document . querySelector ( '#play-layout' ) ;
const playLayout = document . querySelector ( '#play-layout' ) ;
@@ -136,6 +162,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' ;
@@ -145,6 +172,8 @@
let voiceDisclosureAcknowledged = false ;
let voiceDisclosureAcknowledged = false ;
let adminOffset = 0 ;
let adminOffset = 0 ;
const adminPageSize = 50 ;
const adminPageSize = 50 ;
let adminTraceOffset = 0 ;
let adminSimpleConversation = false ;
let presenceTimer = null ;
let presenceTimer = null ;
let playerMessagesPrimed = false ;
let playerMessagesPrimed = false ;
const seenPlayerMessageIds = new Set ( ) ;
const seenPlayerMessageIds = new Set ( ) ;
@@ -160,6 +189,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 ( FEATURE _FLAGS . localAI && ( 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 +200,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 +252,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 ( ! FEATURE _FLAGS . localAI || ! 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 +523,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 ( ) {
@@ -548,6 +623,22 @@
search . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminPlayers ( field . value , 0 ) ; } ) ;
search . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminPlayers ( field . value , 0 ) ; } ) ;
const result = await request ( ` /api/admin/players?q= ${ encodeURIComponent ( query ) } &limit= ${ adminPageSize } &offset= ${ offset } ` ) ;
const result = await request ( ` /api/admin/players?q= ${ encodeURIComponent ( query ) } &limit= ${ adminPageSize } &offset= ${ offset } ` ) ;
adminElement ( 'p' , ` ${ result . total } spelare. Visar ${ result . offset + 1 } – ${ Math . min ( result . total , result . offset + result . players . length ) } . ` , adminContent ) ;
adminElement ( 'p' , ` ${ result . total } spelare. Visar ${ result . offset + 1 } – ${ Math . min ( result . total , result . offset + result . players . length ) } . ` , adminContent ) ;
if ( result . players . length > 0 ) {
const chooser = adminElement ( 'form' ) ;
const chooserLabel = adminElement ( 'label' , 'Välj konto att följa' , chooser ) ;
const chooserSelect = document . createElement ( 'select' ) ;
chooserSelect . required = true ;
for ( const player of result . players ) {
const option = document . createElement ( 'option' ) ;
option . value = player . account _id ;
option . textContent = ` ${ player . username } · ${ player . character _name || 'ingen karaktär' } ` ;
chooserSelect . append ( option ) ;
}
chooserLabel . append ( chooserSelect ) ;
const chooserButton = adminElement ( 'button' , 'Visa konto och konversationer' , chooser ) ;
chooserButton . type = 'submit' ;
chooser . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; openAdminPlayer ( chooserSelect . value ) ; } ) ;
}
const list = adminElement ( 'ul' ) ;
const list = adminElement ( 'ul' ) ;
for ( const player of result . players ) {
for ( const player of result . players ) {
const item = adminElement ( 'li' , undefined , list ) ;
const item = adminElement ( 'li' , undefined , list ) ;
@@ -566,23 +657,248 @@
adminElement ( 'h2' , 'Spelarprofil' ) ;
adminElement ( 'h2' , 'Spelarprofil' ) ;
const profile = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } ` ) ;
const profile = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } ` ) ;
adminJson ( ` ${ profile . username } · profil och biografi ` , profile ) ;
adminJson ( ` ${ profile . username } · profil och biografi ` , profile ) ;
const trace = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } /trace?limit=200 ` ) ;
adminTraceOffset = 0 ;
const entries = [
adminSimpleConversation = false ;
... trace . events . map ( event => ( { kind : 'Världshändelse' , at : event . recorded _at , order : event . commit _sequence , data : event } ) ) ,
const trace = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } /trace?limit=200&offset=0 ` ) ;
... trace . player _messages . map ( message => ( { kind : 'Privat spelarmeddelande' , at : message . recorded _at , order : message . sequence , data : message } ) ) ,
renderAdminConversations ( trace , accountId ) ;
... 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 ) ) ) ;
function renderAdminConversations ( trace , accountId , existingRoot , hasMoreOverride ) {
adminElement ( 'h2' , 'Kronologiskt spelarspår ') ;
const root = existingRoot || adminElement ( 'section ') ;
adminElement ( 'p' , 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.' ) ;
root . replaceChildren ( ) ;
for ( const [ index , entry ] of entries . entries ( ) ) {
const groups = new Map ( ) ;
const status = entry . data . execution _mode || entry . data . metadata ? . decision || entry . data . outcome || 'status okänd' ;
const groupFor = ( correlationId , at ) => {
adminJson ( ` ${ index + 1 } . ${ entry . kind } · ${ status } · ${ entry . at } · ${ entry . order } ` , entry . data ) ;
const key = correlationId || ` unlinked: ${ at || groups . size } ` ;
if ( entry . data . external _call && Object . keys ( entry . data . external _call ) . length > 0 ) {
if ( ! groups . has ( key ) ) groups . set ( key , { correlationId , events : [ ] , messages : [ ] , interactions : [ ] , at } ) ;
const call = entry . data . external _call ;
return groups . get ( key ) ;
const outcome = call . error || ` HTTP ${ call . response _status ? ? 'okänt' } ` ;
} ;
adminJson ( ` Externt tjänsteanrop · ${ call . service || 'okänd tjänst' } · ${ outcome } · ${ call . duration _ms ? ? '?' } ms ` , call ) ;
for ( const event of trace . events || [ ] ) groupFor ( event . correlation _id , event . recorded _at ) . events . push ( event ) ;
for ( const message of trace . player _messages || [ ] ) groupFor ( message . correlation _id , message . recorded _at ) . messages . push ( message ) ;
for ( const interaction of trace . ai _interactions || [ ] ) groupFor ( interaction . correlation _id , interaction . requested _at ) . interactions . push ( interaction ) ;
const conversations = [ ... groups . values ( ) ] . sort ( ( a , b ) => String ( a . at ) . localeCompare ( String ( b . at ) ) ) ;
adminElement ( 'h2' , 'Samtal och beslut' , root ) ;
adminElement ( 'p' , ` Visar ${ conversations . length } korrelerade samtalsturer från den här sidan. Välj en berättarreplik eller ett AI-svar för att öppna hela registrerade beslutskedjan. Registreringstid, världstid och commitordning visas var för sig. ` , root ) ;
const readingToggle = adminElement ( 'button' , adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation' , root ) ;
readingToggle . type = 'button' ;
readingToggle . setAttribute ( 'aria-pressed' , String ( adminSimpleConversation ) ) ;
readingToggle . addEventListener ( 'click' , ( ) => {
adminSimpleConversation = ! adminSimpleConversation ;
root . classList . toggle ( 'admin-simple-conversation' , adminSimpleConversation ) ;
readingToggle . textContent = adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation' ;
readingToggle . setAttribute ( 'aria-pressed' , String ( adminSimpleConversation ) ) ;
} ) ;
root . classList . toggle ( 'admin-simple-conversation' , adminSimpleConversation ) ;
if ( conversations . length === 0 ) {
const empty = adminElement ( 'p' , 'Det finns inga sparade berättarrepliker för kontot ännu.' , root ) ;
empty . className = 'admin-story-empty' ;
return ;
}
for ( const [ index , group ] of conversations . entries ( ) ) {
group . events . sort ( ( a , b ) => ( a . commit _sequence ? ? 0 ) - ( b . commit _sequence ? ? 0 ) ) ;
group . messages . sort ( ( a , b ) => ( a . sequence ? ? 0 ) - ( b . sequence ? ? 0 ) ) ;
group . interactions . sort ( ( a , b ) => String ( a . requested _at ) . localeCompare ( String ( b . requested _at ) ) ) ;
const card = adminElement ( 'article' , undefined , root ) ;
adminElement ( 'h3' , ` Tur ${ index + 1 } · ${ group . at || 'tid saknas' } · ${ group . correlationId || 'utan korrelations-ID' } ` , card ) ;
let showedPlayerInput = false ;
for ( const event of group . events ) {
const payload = event . payload || { } ;
if ( payload . player _message ) { adminElement ( 'p' , ` Spelaren: ${ payload . player _message } ` , card ) ; showedPlayerInput = true ; }
const reply = payload . reply || payload . npc _reply ;
if ( reply ) {
const story = adminElement ( 'p' , ` Berättelsen: ${ reply } ` , card ) ;
story . className = 'admin-story-reply' ;
const button = adminElement ( 'button' , ` Berättaren ${ payload . npc _reply ? ' / NPC' : '' } : ${ reply } ` , card ) ;
button . type = 'button' ;
button . setAttribute ( 'aria-expanded' , 'false' ) ;
const detail = adminElement ( 'section' , undefined , card ) ; detail . hidden = true ;
button . addEventListener ( 'click' , ( ) => {
detail . hidden = ! detail . hidden ;
button . setAttribute ( 'aria-expanded' , String ( ! detail . hidden ) ) ;
if ( ! detail . hidden && ! detail . dataset . loaded ) {
renderDecisionProvenance ( group , event , detail ) ;
detail . dataset . loaded = 'true' ;
}
} ) ;
}
}
if ( ! showedPlayerInput ) {
const failedTurnInput = [ ... group . interactions ] . reverse ( ) . map ( latestPlayerText ) . find ( Boolean ) ;
if ( failedTurnInput ) adminElement ( 'p' , ` Spelaren: ${ failedTurnInput } ` , card ) ;
}
for ( const message of group . messages ) adminElement ( 'p' , ` Spelarmeddelande: ${ message . message } ` , card ) ;
for ( const interaction of group . interactions ) {
if ( ! interaction . response ) continue ;
const button = adminElement ( 'button' , ` AI-svar · ${ interaction . prompt _id } · ${ interaction . outcome } : ${ interaction . response } ` , card ) ;
button . type = 'button' ; button . setAttribute ( 'aria-expanded' , 'false' ) ;
const detail = adminElement ( 'section' , undefined , card ) ; detail . hidden = true ;
button . addEventListener ( 'click' , ( ) => {
detail . hidden = ! detail . hidden ; button . setAttribute ( 'aria-expanded' , String ( ! detail . hidden ) ) ;
if ( ! detail . hidden && ! detail . dataset . loaded ) { renderDecisionProvenance ( group , null , detail , interaction ) ; detail . dataset . loaded = 'true' ; }
} ) ;
}
if ( group . events . length === 0 && group . interactions . every ( item => ! item . response ) ) {
adminElement ( 'p' , 'Ingen färdig replik. Öppna turens tekniska spår:' , card ) ;
for ( const interaction of group . interactions ) adminJson ( ` ${ interaction . prompt _id } · ${ interaction . outcome } ` , interaction , card ) ;
}
}
}
}
const pageHasMore = hasMoreOverride ? ? trace . has _more ? ? ( Math . max ( trace . events ? . length || 0 , trace . ai _interactions ? . length || 0 , trace . player _messages ? . length || 0 ) >= ( trace . limit || 200 ) ) ;
if ( pageHasMore ) {
const older = adminElement ( 'button' , adminSimpleConversation ? 'Visa äldre berättarrepliker' : 'Visa äldre samtalsturer' , root ) ;
older . type = 'button' ;
older . addEventListener ( 'click' , async ( ) => {
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 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 ) ;
} ) ;
}
}
function latestPlayerText ( interaction ) {
const messages = interaction . rendered _messages ;
if ( ! Array . isArray ( messages ) ) return '' ;
const userMessage = [ ... messages ] . reverse ( ) . find ( message => message ? . role === 'user' ) ;
if ( ! userMessage ) return '' ;
if ( typeof userMessage . content === 'string' ) return userMessage . content ;
return userMessage . content == null ? '' : JSON . stringify ( userMessage . content ) ;
}
function renderDecisionProvenance ( group , selectedEvent , container , selectedInteraction ) {
adminElement ( 'h4' , 'Beslutsspår' , container ) ;
if ( group . correlationId ) adminElement ( 'p' , ` Korrelations-ID: ${ group . correlationId } ` , container ) ;
const interactions = selectedInteraction ? [ selectedInteraction ] : group . interactions ;
for ( const interaction of interactions ) {
const ai = adminElement ( 'section' , undefined , container ) ;
ai . className = 'trace-step' ; ai . dataset . engine = 'ai' ;
adminElement ( 'h5' , ` AI · ${ interaction . prompt _id } v ${ interaction . prompt _version } · ${ interaction . outcome } ` , ai ) ;
adminJson ( 'YAML-prompt och renderad kontext' , { prompt _yaml : interaction . prompt _yaml , rendered _messages : interaction . rendered _messages } , ai ) ;
adminJson ( 'Externt tjänsteanrop · förfrågan, svar och metadata' , { external _call : interaction . external _call , response : interaction . response , outcome : interaction . outcome , metadata : interaction . metadata } , ai ) ;
}
const events = selectedEvent ? [ selectedEvent ] : group . events ;
for ( const event of events ) {
const payload = event . payload || { } ;
const details = payload . details || { } ;
const luau = details . luau _trace || payload . luau _trace ;
const rust = details . rust _validation || payload . rust _validation ;
if ( luau ) { const step = adminElement ( 'section' , undefined , container ) ; step . className = 'trace-step' ; step . dataset . engine = 'luau' ; adminJson ( 'Luau · regelindata och effektförslag' , luau , step ) ; }
if ( rust ) { const step = adminElement ( 'section' , undefined , container ) ; step . className = 'trace-step' ; step . dataset . engine = 'rust' ; adminJson ( 'Rust · validering och commit' , rust , step ) ; }
const commit = adminElement ( 'section' , undefined , container ) ; commit . className = 'trace-step' ; commit . dataset . engine = 'rust' ;
adminJson ( ` Rust · ${ event . commit _status || 'händelse' } · världstid ${ event . world _time ? ? 'okänd' } · commit ${ event . commit _sequence ? ? 'saknas' } ` , event , commit ) ;
}
if ( ! interactions . length && ! events . length ) adminElement ( 'p' , 'Beslutsspår saknas för denna post.' , container ) ;
}
async function loadAdminNpcs ( query = '' , offset = 0 ) {
adminContent . replaceChildren ( ) ;
const form = adminElement ( 'form' ) ;
const label = adminElement ( 'label' , 'Sök NPC eller värld' , form ) ;
const input = document . createElement ( 'input' ) ; input . type = 'search' ; input . maxLength = 100 ; input . value = query ; label . append ( input ) ;
const search = adminElement ( 'button' , 'Sök' , form ) ; search . type = 'submit' ;
form . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminNpcs ( input . value , 0 ) ; } ) ;
const result = await request ( ` /api/admin/npcs?q= ${ encodeURIComponent ( query ) } &limit=50&offset= ${ offset } ` ) ;
adminElement ( 'p' , ` ${ result . total } NPC:er. Välj en för att granska och redigera profil, minne och ägodelar. ` ) ;
const list = adminElement ( 'ul' ) ;
for ( const npc of result . npcs ) {
const item = adminElement ( 'li' , undefined , list ) ;
const button = adminElement ( 'button' , ` ${ npc . name } · ${ npc . world } · ${ npc . location } ${ npc . scene ? ` · ${ npc . scene } ` : '' } ` , item ) ;
button . type = 'button' ; button . addEventListener ( 'click' , ( ) => openAdminNpc ( npc . id ) ) ;
}
const paging = adminElement ( 'div' ) ;
const previous = adminElement ( 'button' , 'Föregående sida' , paging ) ; previous . type = 'button' ; previous . disabled = offset <= 0 ; previous . addEventListener ( 'click' , ( ) => loadAdminNpcs ( query , Math . max ( 0 , offset - 50 ) ) ) ;
const next = adminElement ( 'button' , 'Nästa sida' , paging ) ; next . type = 'button' ; next . disabled = offset + result . npcs . length >= result . total ; next . addEventListener ( 'click' , ( ) => loadAdminNpcs ( query , offset + 50 ) ) ;
}
async function openAdminNpc ( npcId ) {
adminContent . replaceChildren ( ) ;
const back = adminElement ( 'button' , 'Tillbaka till NPC-listan' ) ; back . type = 'button' ; back . addEventListener ( 'click' , ( ) => loadAdminNpcs ( ) ) ;
const npc = await request ( ` /api/admin/npcs/ ${ encodeURIComponent ( npcId ) } ` ) ;
adminElement ( 'h2' , ` ${ npc . name } · ${ npc . world } ` ) ;
adminElement ( 'p' , ` Plats: ${ npc . location } ${ npc . scene ? ` · ${ npc . scene } ` : '' } . ID och position visas som referens och ändras inte i den här vyn. ` ) ;
adminElement ( 'h3' , 'Namn, bakgrund och röstidentitet' ) ;
const identityForm = adminElement ( 'form' ) ;
const nameLabel = adminElement ( 'label' , 'Namn ' , identityForm ) ;
const nameInput = document . createElement ( 'input' ) ; nameInput . required = true ; nameInput . maxLength = 120 ; nameInput . value = npc . name ; nameLabel . append ( nameInput ) ;
const biographyLabel = adminElement ( 'label' , 'Bakgrund ' , identityForm ) ;
const biographyInput = document . createElement ( 'textarea' ) ; biographyInput . required = true ; biographyInput . maxLength = 4000 ; biographyInput . rows = 5 ; biographyInput . value = npc . biography ; biographyLabel . append ( biographyInput ) ;
const voiceLabel = adminElement ( 'label' , 'Röstidentitet ' , identityForm ) ;
const voiceSelect = document . createElement ( 'select' ) ;
for ( const value of [ 'masculine' , 'feminine' , 'neutral' ] ) { const option = document . createElement ( 'option' ) ; option . value = value ; option . textContent = value ; option . selected = value === npc . voice _identity ; voiceSelect . append ( option ) ; }
voiceLabel . append ( voiceSelect ) ;
const identitySave = adminElement ( 'button' , 'Spara identitet' , identityForm ) ; identitySave . type = 'submit' ;
const identityStatus = adminElement ( 'p' ) ; identityStatus . setAttribute ( 'role' , 'status' ) ;
identityForm . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ; identitySave . disabled = true ;
try {
await request ( ` /api/admin/npcs/ ${ encodeURIComponent ( npcId ) } /identity ` , { method : 'PUT' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { expected _name : npc . name , expected _biography : npc . biography , expected _voice _identity : npc . voice _identity , name : nameInput . value , biography : biographyInput . value , voice _identity : voiceSelect . value } ) } ) ;
identityStatus . textContent = 'Identiteten sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.' ;
} catch ( error ) { identityStatus . textContent = ` Identiteten sparades inte: ${ error . message } ` ; }
finally { identitySave . disabled = false ; }
} ) ;
adminElement ( 'h3' , 'Egenskaper och framtida profilfält' ) ;
adminElement ( 'p' , 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.' ) ;
const profileForm = adminElement ( 'form' ) ;
const profileLabel = adminElement ( 'label' , 'NPC-profil i JSON' , profileForm ) ;
const profileInput = document . createElement ( 'textarea' ) ; profileInput . rows = 18 ; profileInput . cols = 80 ; profileInput . required = true ; profileInput . value = JSON . stringify ( npc . profile , null , 2 ) ; profileLabel . append ( profileInput ) ;
const profileSave = adminElement ( 'button' , 'Spara egenskaper' , profileForm ) ; profileSave . type = 'submit' ;
const profileStatus = adminElement ( 'p' ) ; profileStatus . setAttribute ( 'role' , 'status' ) ;
profileForm . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ; profileSave . disabled = true ;
try {
const profile = JSON . parse ( profileInput . value ) ;
await request ( ` /api/admin/npcs/ ${ encodeURIComponent ( npcId ) } /profile ` , { method : 'PUT' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { expected _profile : npc . profile , profile } ) } ) ;
profileStatus . textContent = 'Egenskaperna sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.' ;
} catch ( error ) { profileStatus . textContent = ` Egenskaperna sparades inte: ${ error . message } ` ; }
finally { profileSave . disabled = false ; }
} ) ;
adminElement ( 'h3' , 'Ägodelar' ) ;
const possessionsForm = adminElement ( 'form' ) ;
const possessionsLabel = adminElement ( 'label' , 'En ägodel per rad: namn | antal' , possessionsForm ) ;
const possessionsInput = document . createElement ( 'textarea' ) ; possessionsInput . rows = 8 ; possessionsInput . cols = 60 ; possessionsInput . value = npc . possessions . map ( item => ` ${ item . name } | ${ item . quantity } ` ) . join ( '\n' ) ; possessionsLabel . append ( possessionsInput ) ;
const possessionsSave = adminElement ( 'button' , 'Ersätt ägodelar' , possessionsForm ) ; possessionsSave . type = 'submit' ;
const possessionsStatus = adminElement ( 'p' ) ; possessionsStatus . setAttribute ( 'role' , 'status' ) ;
possessionsForm . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ; possessionsSave . disabled = true ;
try {
const possessions = possessionsInput . value . split ( '\n' ) . map ( line => line . trim ( ) ) . filter ( Boolean ) . map ( line => { const [ name , amount ] = line . split ( '|' ) ; return { name : name ? . trim ( ) || '' , quantity : Number ( amount ? . trim ( ) || 1 ) } ; } ) ;
const expected = npc . possessions . map ( item => ( { name : item . name , quantity : item . quantity } ) ) ;
await request ( ` /api/admin/npcs/ ${ encodeURIComponent ( npcId ) } /possessions ` , { method : 'PUT' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { expected , possessions } ) } ) ;
possessionsStatus . textContent = 'Ägodelarna ersattes och revisionsloggades.' ;
} catch ( error ) { possessionsStatus . textContent = ` Ägodelarna sparades inte: ${ error . message } ` ; }
finally { possessionsSave . disabled = false ; }
} ) ;
adminElement ( 'h3' , 'Minnesrekonstruktioner' ) ;
adminElement ( 'p' , 'Varje ändring behåller minnespostens källa och läggs i revisionsloggen. Ändringar påverkar NPC:ns aktiva minne direkt.' ) ;
if ( npc . memories . length === 0 ) adminElement ( 'p' , 'Inga sparade minnen för den här NPC:n.' ) ;
for ( const memory of npc . memories ) {
const section = adminElement ( 'section' ) ;
adminElement ( 'h4' , ` ${ memory . character _name } · ${ memory . layer } · ${ memory . active ? 'aktivt' : 'inaktivt' } · ${ memory . visibility } ` , section ) ;
adminElement ( 'p' , ` Källa ${ memory . source _event _id } ; informationsväg ${ memory . information _source } ; prioritet ${ memory . priority } . ` , section ) ;
const form = adminElement ( 'form' , undefined , section ) ;
const label = adminElement ( 'label' , 'Minnestext' , form ) ;
const text = document . createElement ( 'textarea' ) ; text . rows = 4 ; text . cols = 80 ; text . required = true ; text . maxLength = 3000 ; text . value = memory . text ; label . append ( text ) ;
const priorityLabel = adminElement ( 'label' , ' Prioritet 0– 100 ' , form ) ;
const priority = document . createElement ( 'input' ) ; priority . type = 'number' ; priority . min = '0' ; priority . max = '100' ; priority . value = memory . priority ; priorityLabel . append ( priority ) ;
const activeLabel = adminElement ( 'label' , undefined , form ) ;
const active = document . createElement ( 'input' ) ; active . type = 'checkbox' ; active . checked = memory . active ; activeLabel . append ( active , document . createTextNode ( ' Aktivt minne' ) ) ;
const visibilityLabel = adminElement ( 'label' , ' Synlighet ' , form ) ;
const visibility = document . createElement ( 'select' ) ;
for ( const value of [ 'private' , 'confidential' , 'shareable' ] ) { const option = document . createElement ( 'option' ) ; option . value = value ; option . textContent = value ; option . selected = value === memory . visibility ; visibility . append ( option ) ; }
visibilityLabel . append ( visibility ) ;
const save = adminElement ( 'button' , 'Spara minnesändring' , form ) ; save . type = 'submit' ;
const status = adminElement ( 'p' , undefined , section ) ; status . setAttribute ( 'role' , 'status' ) ;
form . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ; save . disabled = true ;
try {
await request ( ` /api/admin/npcs/ ${ encodeURIComponent ( npcId ) } /memories/ ${ encodeURIComponent ( memory . id ) } ` , { method : 'PUT' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { expected _text : memory . text , expected _priority : memory . priority , expected _active : memory . active , expected _visibility : memory . visibility , text : text . value , priority : Number ( priority . value ) , active : active . checked , visibility : visibility . value } ) } ) ;
status . textContent = 'Minnet sparades och revisionsloggades. Ladda om NPC:n för senaste version.' ;
} catch ( error ) { status . textContent = ` Minnet sparades inte: ${ error . message } ` ; }
finally { save . disabled = false ; }
} ) ;
}
adminElement ( 'h3' , 'Löften och åtaganden' ) ;
if ( npc . commitments . length === 0 ) adminElement ( 'p' , 'Inga registrerade åtaganden.' ) ;
for ( const commitment of npc . commitments ) adminJson ( ` ${ commitment . status } · ${ commitment . created _at } ` , commitment ) ;
}
}
async function loadAdminPrompts ( ) {
async function loadAdminPrompts ( ) {
adminContent . replaceChildren ( ) ;
adminContent . replaceChildren ( ) ;
@@ -643,15 +959,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 \n Värld: ${ worldDraft . name } \n Startplats: ${ worldDraft . location _name } . ${ worldDraft . location _description } \n Startbelopp: ${ worldDraft . starting _balance _sek } kronor. ${ worldDraft . offer _name } kostar ${ worldDraft . offer _price _sek } kronor. \n \n Skriv ändringar eller “godkänn”. ` ) ;
appendLine ( 'assistant' , ` ${ proposal . message } \n \n Värld: ${ worldDraft . name } \n Startplats: ${ worldDraft . location _name } . ${ worldDraft . location _description } \n Startbelopp: ${ worldDraft . starting _balance _sek } kronor. ${ worldDraft . offer _name } kostar ${ worldDraft . offer _price _sek } kronor. \n \n Skriv ä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 +983,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 +991,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 +1004,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 +1018,10 @@
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 && FEATURE _FLAGS . localAI ) {
const localAI = await loadLocalAiAdapter ( ) ;
localAI ? . start ( currentAccount . id ) ;
}
if ( ! gameState . character ) {
if ( ! gameState . character ) {
transcript . replaceChildren ( ) ;
transcript . replaceChildren ( ) ;
appendInitialPrompt ( ) ;
appendInitialPrompt ( ) ;
@@ -787,10 +1114,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 +1188,7 @@
presenceTimer = null ;
presenceTimer = null ;
playerMessagesPrimed = false ;
playerMessagesPrimed = false ;
seenPlayerMessageIds . clear ( ) ;
seenPlayerMessageIds . clear ( ) ;
if ( FEATURE _FLAGS . localAI ) 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 +1221,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 +1229,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 \n Förslag: ${ characterDraft . name } , ${ characterDraft . age } år. \n ${ characterDraft . biography } ${ biographyContextText ( characterDraft . life _context ) } \n \n Skriv ändringar eller “godkänn”. ` ) ;
appendLine ( 'assistant' , ` ${ proposal . message } \n \n Förslag: ${ characterDraft . name } , ${ characterDraft . age } år. \n ${ characterDraft . biography } ${ biographyContextText ( characterDraft . life _context ) } \n \n Skriv ä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 ( FEATURE _FLAGS . localAI && 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 +1269,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 ( FEATURE _FLAGS . localAI && 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 +1311,45 @@
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 = FEATURE _FLAGS . localAI
? await window . localGameAI ? . offline ( message , history , gameState ? . world || { } )
: null ;
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 +1371,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 +1387,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,10 +1483,17 @@
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 {
if ( button . dataset . adminView === 'players' ) await loadAdminPlayers ( ) ;
if ( button . dataset . adminView === 'players' ) await loadAdminPlayers ( ) ;
else if ( button . dataset . adminView === 'npcs' ) await loadAdminNpcs ( ) ;
else if ( button . dataset . adminView === 'prompts' ) await loadAdminPrompts ( ) ;
else if ( button . dataset . adminView === 'prompts' ) await loadAdminPrompts ( ) ;
else if ( button . dataset . adminView === 'rules' ) await loadAdminRules ( ) ;
else if ( button . dataset . adminView === 'rules' ) await loadAdminRules ( ) ;
else if ( button . dataset . adminView === 'logging' ) await loadAdminLogging ( ) ;
else if ( button . dataset . adminView === 'logging' ) await loadAdminLogging ( ) ;