@@ -27,6 +27,14 @@
# 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 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-player ) : not ( . admin-story-reply ) { display : none ; }
# admin-content . admin-simple-conversation article > . admin-story-player ,
# admin-content . admin-simple-conversation article > . admin-story-reply { display : block ; margin-block : .85 rem ; white-space : pre-wrap ; }
# admin-content . admin-simple-conversation article > . admin-story-player { font-weight : 650 ; }
# 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 ; }
# player-search { display : flex ; flex-wrap : wrap ; align-items : end ; gap : .5 rem ; }
@@ -166,6 +174,8 @@
let voiceDisclosureAcknowledged = false ;
let adminOffset = 0 ;
const adminPageSize = 50 ;
let adminTraceOffset = 0 ;
let adminSimpleConversation = false ;
let presenceTimer = null ;
let playerMessagesPrimed = false ;
const seenPlayerMessageIds = new Set ( ) ;
@@ -176,6 +186,22 @@
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
return body ;
}
async function graphqlQuery ( query , variables = { } ) {
const response = await request ( '/graphql' , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' } ,
body : JSON . stringify ( { query , variables } ) ,
} ) ;
if ( response . errors ? . length ) throw new Error ( response . errors [ 0 ] . message || 'GraphQL-förfrågan misslyckades' ) ;
return response . data ;
}
async function getAdminTrace ( accountId = null , limit = 200 , offset = 0 ) {
const data = await graphqlQuery (
'query AdminPlayerTrace($accountId: String, $limit: Int!, $offset: Int!) { adminPlayerTrace(accountId: $accountId, limit: $limit, offset: $offset) }' ,
{ accountId , limit , offset } ,
) ;
return data . adminPlayerTrace ;
}
function appendLine ( role , content , options = { } ) {
const line = document . createElement ( 'li' ) ;
line . dataset . role = role ;
@@ -615,6 +641,22 @@
search . addEventListener ( 'submit' , event => { event . preventDefault ( ) ; loadAdminPlayers ( field . value , 0 ) ; } ) ;
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 ) ;
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' ) ;
for ( const player of result . players ) {
const item = adminElement ( 'li' , undefined , list ) ;
@@ -633,23 +675,144 @@
adminElement ( 'h2' , 'Spelarprofil' ) ;
const profile = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } ` ) ;
adminJson ( ` ${ profile . username } · profil och biografi ` , profile ) ;
const trace = await request ( ` /api/admin/players/ ${ encodeURIComponent ( accountId ) } /trace?limit=200 ` ) ;
const entries = [
... trace . events . map ( event => ( { kind : 'Världshändelse' , at : event . recorded _at , order : event . commit _sequence , data : event } ) ) ,
... trace . player _messages . map ( message => ( { kind : 'Privat spelarmeddelande' , at : message . recorded _at , order : message . sequence , data : message } ) ) ,
... trace . ai _interactions . map ( interaction => ( { kind : ` AI ${ interaction . prompt _id } · ${ interaction . outcome } ` , at : interaction . requested _at , order : interaction . correlation _id , data : interaction } ) )
] . sort ( ( a , b ) => String ( a . at ) . localeCompare ( String ( b . at ) ) ) ;
adminElement ( 'h2' , 'Kronologiskt spelarspår ') ;
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.' ) ;
for ( const [ index , entry ] of entries . entries ( ) ) {
const status = entry . data . execution _mode || entry . data . metadata ? . decision || entry . data . outcome || 'status okänd' ;
adminJson ( ` ${ index + 1 } . ${ entry . kind } · ${ status } · ${ entry . at } · ${ entry . order } ` , entry . data ) ;
if ( entry . data . external _call && Object . keys ( entry . data . external _call ) . length > 0 ) {
const call = entry . data . external _call ;
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 ) ;
adminTraceOffset = 0 ;
adminSimpleConversation = false ;
const trace = await getAdminTrace ( accountId , 200 , 0 ) ;
renderAdminConversations ( trace , accountId ) ;
}
function renderAdminConversations ( trace , accountId , existingRoot , hasMoreOverride ) {
const root = existingRoot || adminElement ( 'section ') ;
root . replaceChildren ( ) ;
const groups = new Map ( ) ;
const groupFor = ( correlationId , at ) => {
const key = correlationId || ` unlinked: ${ at || groups . size } ` ;
if ( ! groups . has ( key ) ) groups . set ( key , { correlationId , events : [ ] , messages : [ ] , interactions : [ ] , at } ) ;
return groups . get ( key ) ;
} ;
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 ) {
const playerLine = adminElement ( 'p' , ` Spelaren: ${ payload . player _message } ` , card ) ;
playerLine . className = 'admin-story-player' ;
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 ) {
const playerLine = adminElement ( 'p' , ` Spelaren: ${ failedTurnInput } ` , card ) ;
playerLine . className = 'admin-story-player' ;
}
}
for ( const message of group . messages ) {
const playerLine = adminElement ( 'p' , ` Spelare: ${ message . message } ` , card ) ;
playerLine . className = 'admin-story-player' ;
}
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 getAdminTrace ( accountId , trace . limit || 200 , adminTraceOffset ) ;
const merge = ( key , idKey ) => [ ... ( trace [ key ] || [ ] ) , ... ( next [ key ] || [ ] ) ] . filter ( ( item , index , all ) => all . findIndex ( candidate => candidate [ idKey ] === item [ idKey ] ) === index ) ;
renderAdminConversations ( { events : merge ( 'events' , 'event_id' ) , ai _interactions : merge ( 'ai_interactions' , 'id' ) , player _messages : merge ( 'player_messages' , 'id' ) , limit : next . limit , offset : next . offset } , accountId , root , next . has _more ) ;
} ) ;
}
}
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 ( ) ;
@@ -865,12 +1028,53 @@
messageField . disabled = false ;
messageField . focus ( ) ;
}
function savedDialogueLine ( line ) {
const element = document . createElement ( 'li' ) ;
element . dataset . role = line . role === 'user' ? 'user' : line . role === 'system' ? 'system' : 'assistant' ;
element . textContent = ` ${ line . speaker || ( line . role === 'user' ? 'Du' : 'Berättelsen' ) } : ${ line . content } ` ;
return element ;
}
function dialogueLinesFromPage ( page ) {
return page . edges . flatMap ( edge => edge . node . lines ) ;
}
function addOlderDialogueControl ( cursor ) {
const item = document . createElement ( 'li' ) ;
item . dataset . role = 'system' ;
const button = document . createElement ( 'button' ) ;
button . type = 'button' ;
button . textContent = 'Hämta äldre konversation' ;
button . addEventListener ( 'click' , async ( ) => {
button . disabled = true ;
button . textContent = 'Hämtar äldre konversation…' ;
try {
const page = ( await graphqlQuery (
'query OlderDialogue($before: String!) { dialogue(first: 50, before: $before) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }' ,
{ before : cursor } ,
) ) . dialogue ;
const oldLines = dialogueLinesFromPage ( page ) ;
const fragment = document . createDocumentFragment ( ) ;
for ( const line of oldLines ) fragment . append ( savedDialogueLine ( line ) ) ;
item . remove ( ) ;
transcript . prepend ( fragment ) ;
if ( page . pageInfo . hasPreviousPage && page . pageInfo . startCursor ) addOlderDialogueControl ( page . pageInfo . startCursor ) ;
} catch ( error ) {
button . disabled = false ;
button . textContent = ` Kunde inte hämta äldre repliker: ${ error . message } . Försök igen ` ;
}
} ) ;
item . append ( button ) ;
transcript . prepend ( item ) ;
}
async function loadSavedDialogue ( ) {
const lines = await request ( '/api/dialogue' ) ;
const page = ( await graphqlQuery (
'query CurrentDialogue { dialogue(first: 100) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }' ,
) ) . dialogue ;
const lines = dialogueLinesFromPage ( page ) ;
transcript . replaceChildren ( ) ;
latestAssistantText = '' ;
latestAssistantText = [ ... lines ] . reverse ( ) . find ( line => line . role === 'assistant' ) ? . content || '' ;
latestPlayerAction = [ ... lines ] . reverse ( ) . find ( line => line . role === 'user' ) ? . content || '' ;
for ( const line of lines ) appendLine ( line . role === 'user' ? 'user' : line . role === 'system' ? 'system' : 'assistant' , line . content , { speaker : line . speaker , speak : false } ) ;
for ( const line of lines ) transcript . append ( savedDialogueLine ( line ) ) ;
if ( page . pageInfo . hasPreviousPage && page . pageInfo . startCursor ) addOlderDialogueControl ( page . pageInfo . startCursor ) ;
refreshActionTools ( ) ;
if ( lines . length > 0 && latestAssistantText && location . pathname !== '/admin' ) {
voiceController ? . speak ( latestAssistantText , { role : 'narrator' , voiceIdentity : 'neutral' } ) ;
@@ -1031,7 +1235,7 @@
debugRefreshBusy = true ;
debugStatus . textContent = 'Hämtar revisionsloggad spårning…' ;
try {
const trace = await request ( '/api/admin/self/trace?limit=200' ) ;
const trace = await getAdminTrace ( null , 200 , 0 ) ;
renderDecisionTrace ( trace ) ;
debugStatus . textContent = ` Visar ${ debugTrace . childElementCount } korrelerade händelsegrupper. Uppdaterad ${ new Date ( ) . toLocaleTimeString ( 'sv-SE' ) } . ` ;
} catch ( error ) {