@@ -27,7 +27,31 @@
# 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 pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
# 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 , # debug-trace pre { white-space : pre-wrap ; overflow-wrap : anywhere ; max-height : 32 rem ; overflow : auto ; }
. json-view { box-sizing : border-box ; margin-block : .65 rem ; padding : .8 rem ; border : 1 px solid #cbd5e1 ; border-radius : .4 rem ; background : #f8fafc ; color : #1e293b ; tab-size : 2 ; font : .84 rem / 1.55 ui-monospace , SFMono-Regular , Menlo , monospace ; }
. json-token-key { color : #075985 ; font-weight : 700 ; }
. json-token-string { color : #166534 ; }
. json-token-number { color : #6d28d9 ; font-weight : 650 ; }
. json-token-boolean { color : #b45309 ; font-weight : 750 ; }
. json-token-null { color : #be123c ; font-style : italic ; }
. json-token-punctuation { color : #64748b ; }
@ media ( prefers-color-scheme : dark ) {
. json-view { border-color : #475569 ; background : #111827 ; color : #e5e7eb ; }
. json-token-key { color : #7dd3fc ; }
. json-token-string { color : #86efac ; }
. json-token-number { color : #c4b5fd ; }
. json-token-boolean { color : #fcd34d ; }
. json-token-null { color : #fda4af ; }
. json-token-punctuation { color : #94a3b8 ; }
}
# 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 ; }
body . debug-layout { max-width : none ; margin : 0 ; padding : .5 rem ; height : 100 vh ; overflow : hidden ; }
body . debug-layout { max-width : none ; margin : 0 ; padding : .5 rem ; height : 100 vh ; overflow : hidden ; }
@@ -104,6 +128,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 >
@@ -165,6 +190,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 ( ) ;
@@ -175,6 +202,22 @@
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
if ( ! response . ok ) throw new Error ( body ? . error || ` HTTP ${ response . status } ` ) ;
return body ;
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 = { } ) {
function appendLine ( role , content , options = { } ) {
const line = document . createElement ( 'li' ) ;
const line = document . createElement ( 'li' ) ;
line . dataset . role = role ;
line . dataset . role = role ;
@@ -595,10 +638,43 @@
parent . append ( element ) ;
parent . append ( element ) ;
return element ;
return element ;
}
}
function appendFormattedJson ( pre , value , stringAsText = false ) {
pre . className = 'json-view' ;
pre . setAttribute ( 'aria-label' , 'Formaterat JSON-svar' ) ;
const code = document . createElement ( 'code' ) ;
if ( stringAsText && typeof value === 'string' ) {
code . textContent = value ;
pre . replaceChildren ( code ) ;
return pre ;
}
const source = JSON . stringify ( value , null , '\t' ) ? ? 'null' ;
const tokens = /(\"(?:\\.|[^\"\\])*\"(?=\s*:))|(\"(?:\\.|[^\"\\])*\")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],:])|(\s+)/g ;
let offset = 0 ;
for ( const match of source . matchAll ( tokens ) ) {
if ( match . index > offset ) code . append ( document . createTextNode ( source . slice ( offset , match . index ) ) ) ;
const token = match [ 0 ] ;
if ( match [ 7 ] ) {
code . append ( document . createTextNode ( token ) ) ;
} else {
const span = document . createElement ( 'span' ) ;
span . className = match [ 1 ] ? 'json-token-key'
: match [ 2 ] ? 'json-token-string'
: match [ 3 ] ? 'json-token-number'
: match [ 4 ] ? 'json-token-boolean'
: match [ 5 ] ? 'json-token-null' : 'json-token-punctuation' ;
span . textContent = token ;
code . append ( span ) ;
}
offset = match . index + token . length ;
}
if ( offset < source . length ) code . append ( document . createTextNode ( source . slice ( offset ) ) ) ;
pre . replaceChildren ( code ) ;
return pre ;
}
function adminJson ( title , value , parent = adminContent ) {
function adminJson ( title , value , parent = adminContent ) {
const details = adminElement ( 'details' , undefined , parent ) ;
const details = adminElement ( 'details' , undefined , parent ) ;
adminElement ( 'summary' , title , details ) ;
adminElement ( 'summary' , title , details ) ;
adminElement ( 'pre' , JSON . stringify ( value , null , 2 ) , details ) ;
appendFormattedJson ( adminElement ( 'pre' , undefined , details ) , value ) ;
return details ;
return details ;
}
}
async function loadAdminPlayers ( query = '' , offset = 0 ) {
async function loadAdminPlayers ( query = '' , offset = 0 ) {
@@ -614,6 +690,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 ) ;
@@ -632,23 +724,258 @@
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 getAdminTrace ( accountId , 200 , 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 ) {
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 ( ) ;
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 ( ) ;
@@ -750,12 +1077,53 @@
messageField . disabled = false ;
messageField . disabled = false ;
messageField . focus ( ) ;
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 ( ) {
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 ( ) ;
transcript . replaceChildren ( ) ;
latestAssistantText = '' ;
latestAssistantText = [ ... lines ] . reverse ( ) . find ( line => line . role === 'assistant' ) ? . content || '' ;
latestPlayerAction = [ ... lines ] . reverse ( ) . find ( line => line . role === 'user' ) ? . 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 ( ) ;
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' } ) ;
@@ -844,7 +1212,7 @@
const valueHeading = document . createElement ( 'strong' ) ;
const valueHeading = document . createElement ( 'strong' ) ;
valueHeading . textContent = name ;
valueHeading . textContent = name ;
const pre = document . createElement ( 'pre' ) ;
const pre = document . createElement ( 'pre' ) ;
pre . textContent = typeof value === 'string' ? value : JSON . stringify ( value ? ? 'Ingen uppgift sparad' , null , 2 ) ;
appendFormattedJson ( pre , value ? ? 'Ingen uppgift sparad' , true ) ;
card . append ( valueHeading , pre ) ;
card . append ( valueHeading , pre ) ;
} ;
} ;
addValue ( 'Indata' , input ) ;
addValue ( 'Indata' , input ) ;
@@ -905,7 +1273,7 @@
const rawSummary = document . createElement ( 'summary' ) ;
const rawSummary = document . createElement ( 'summary' ) ;
rawSummary . textContent = 'Visa all rå spårdata' ;
rawSummary . textContent = 'Visa all rå spårdata' ;
const pre = document . createElement ( 'pre' ) ;
const pre = document . createElement ( 'pre' ) ;
pre . textContent = JSON . stringify ( group , null , 2 ) ;
appendFormattedJson ( pre , group ) ;
raw . append ( rawSummary , pre ) ;
raw . append ( rawSummary , pre ) ;
details . append ( summary , chain , raw ) ;
details . append ( summary , chain , raw ) ;
debugTrace . append ( details ) ;
debugTrace . append ( details ) ;
@@ -916,7 +1284,7 @@
debugRefreshBusy = true ;
debugRefreshBusy = true ;
debugStatus . textContent = 'Hämtar revisionsloggad spårning…' ;
debugStatus . textContent = 'Hämtar revisionsloggad spårning…' ;
try {
try {
const trace = await request ( '/api/admin/self/trace?limit=200' ) ;
const trace = await getAdminTrace ( null , 200 , 0 ) ;
renderDecisionTrace ( trace ) ;
renderDecisionTrace ( trace ) ;
debugStatus . textContent = ` Visar ${ debugTrace . childElementCount } korrelerade händelsegrupper. Uppdaterad ${ new Date ( ) . toLocaleTimeString ( 'sv-SE' ) } . ` ;
debugStatus . textContent = ` Visar ${ debugTrace . childElementCount } korrelerade händelsegrupper. Uppdaterad ${ new Date ( ) . toLocaleTimeString ( 'sv-SE' ) } . ` ;
} catch ( error ) {
} catch ( error ) {
@@ -1243,6 +1611,7 @@
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 ( ) ;