@@ -27,7 +27,55 @@
# 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 : .65 rem ; border : 1 px solid #cbd5e1 ; border-radius : .4 rem ; background : #f8fafc ; color : #1e293b ; font : .88 rem / 1.55 ui-monospace , SFMono-Regular , Menlo , monospace ; }
. json-tree-tools { display : flex ; flex-wrap : wrap ; gap : .4 rem ; margin-block-end : .6 rem ; }
. json-tree-tools button { font-size : .88 rem ; padding : .3 rem .55 rem ; }
. json-tree , . json-children { display : grid ; gap : .3 rem ; }
. json-children { margin-inline-start : .7 rem ; padding-inline-start : .65 rem ; border-inline-start : 2 px solid #cbd5e1 ; }
. json-node { min-width : 0 ; }
. json-node > summary { cursor : pointer ; padding : .3 rem .45 rem ; border-radius : .25 rem ; background : #e2e8f0 ; overflow-wrap : anywhere ; }
. json-node > summary : focus-visible , . json-tree-tools button : focus-visible { outline : 3 px solid #2563eb ; outline-offset : 2 px ; }
. json-label { color : #075985 ; font-weight : 750 ; }
. json-kind { color : #475569 ; font-size : .86 em ; }
. json-entry { display : grid ; grid-template-columns : minmax ( 8 rem , .35 fr ) minmax ( 0 , 1 fr ) ; gap : .5 rem ; padding : .2 rem .45 rem ; border-radius : .2 rem ; overflow-wrap : anywhere ; }
. json-entry : nth-child ( even ) { background : #f1f5f9 ; }
. json-key { color : #075985 ; font-weight : 700 ; }
. json-value-string { color : #166534 ; white-space : pre-wrap ; }
. json-value-number { color : #6d28d9 ; font-weight : 650 ; }
. json-value-boolean { color : #b45309 ; font-weight : 750 ; }
. json-value-null { color : #be123c ; font-style : italic ; }
. json-empty { padding : .25 rem .65 rem ; color : #475569 ; font-style : italic ; }
. json-long-value { border-inline-start : 2 px solid #94a3b8 ; margin-inline-start : .5 rem ; }
. json-long-value > summary , . json-raw-value > summary { cursor : pointer ; padding : .3 rem .45 rem ; overflow-wrap : anywhere ; }
. json-long-text { margin : .2 rem .5 rem .5 rem ; padding : .5 rem ; max-height : 24 rem ; overflow : auto ; background : #e2e8f0 ; white-space : pre-wrap ; overflow-wrap : anywhere ; }
. json-raw-value { margin-inline-start : .5 rem ; color : #475569 ; font-size : .9 em ; }
. json-raw-value > . json-long-text { border-inline-start : 2 px solid #94a3b8 ; }
. admin-ai-response { margin : .35 rem 0 .75 rem ; }
. admin-ai-response-text { white-space : pre-wrap ; overflow-wrap : anywhere ; }
. admin-ai-response-code { max-width : 100 % ; overflow : auto ; padding : .65 rem ; border-inline-start : 2 px solid #94a3b8 ; white-space : pre-wrap ; overflow-wrap : anywhere ; }
@ media ( max-width : 42rem ) { . json-entry { grid-template-columns : 1 fr ; gap : .05 rem ; } }
@ media ( prefers-color-scheme : dark ) {
. json-view { border-color : #475569 ; background : #111827 ; color : #e5e7eb ; }
. json-children { border-inline-start-color : #475569 ; }
. json-node > summary { background : #263244 ; }
. json-label , . json-key { color : #7dd3fc ; }
. json-kind , . json-empty { color : #cbd5e1 ; }
. json-entry : nth-child ( even ) , . json-long-text { background : #1f2937 ; }
. json-value-string { color : #86efac ; }
. json-value-number { color : #c4b5fd ; }
. json-value-boolean { color : #fcd34d ; }
. json-value-null { color : #fda4af ; }
. json-raw-value { color : #cbd5e1 ; }
}
# 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 +152,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 +214,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 +226,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,12 +662,226 @@
parent . append ( element ) ;
parent . append ( element ) ;
return element ;
return element ;
}
}
function decodeEscapedSequences ( text ) {
const source = String ( text ) . replace ( /\\(?:\r\n|\n|\r)[ \t]*/g , '' ) ;
const escapes = {
'0' : '\0' , a : '\x07' , b : '\b' , t : '\t' , n : '\n' , v : '\x0b' ,
f : '\f' , r : '\r' , e : '\x1b' , ' ' : ' ' , '"' : '"' , "'" : "'" ,
'/' : '/' , '\\' : '\\' , N : '\u0085' , _ : '\u00a0' , L : '\u2028' , P : '\u2029' ,
} ;
return source . replace ( /\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g , ( sequence , escape ) => {
if ( Object . hasOwn ( escapes , escape ) ) return escapes [ escape ] ;
if ( /^x[0-9a-fA-F]{2}$/ . test ( escape ) ) return String . fromCharCode ( Number . parseInt ( escape . slice ( 1 ) , 16 ) ) ;
if ( /^u[0-9a-fA-F]{4}$/ . test ( escape ) ) return String . fromCharCode ( Number . parseInt ( escape . slice ( 1 ) , 16 ) ) ;
if ( /^U[0-9a-fA-F]{8}$/ . test ( escape ) ) {
const point = Number . parseInt ( escape . slice ( 1 ) , 16 ) ;
if ( point <= 0x10ffff && ! ( point >= 0xd800 && point <= 0xdfff ) ) return String . fromCodePoint ( point ) ;
}
return sequence ;
} ) ;
}
function renderJsonTree ( container , value ) {
container . className = 'json-view' ;
container . setAttribute ( 'aria-label' , 'Utfällbart JSON-svar' ) ;
const tree = document . createElement ( 'div' ) ;
tree . className = 'json-tree' ;
const nodeValues = new WeakMap ( ) ;
const controls = document . createElement ( 'div' ) ;
controls . className = 'json-tree-tools' ;
const expand = document . createElement ( 'button' ) ;
expand . type = 'button' ;
expand . textContent = 'Öppna alla block' ;
const collapse = document . createElement ( 'button' ) ;
collapse . type = 'button' ;
collapse . textContent = 'Fäll ihop nästlade block' ;
controls . append ( expand , collapse ) ;
container . replaceChildren ( controls , tree ) ;
const isGroup = item => item !== null && typeof item === 'object' ;
const groupSummary = item => Array . isArray ( item )
? ` Lista · ${ item . length } ${ item . length === 1 ? 'post' : 'poster' } `
: ` Objekt · ${ Object . keys ( item ) . length } fält ` ;
const addRawText = ( parent , value ) => {
const raw = document . createElement ( 'details' ) ;
raw . className = 'json-raw-value' ;
const rawSummary = document . createElement ( 'summary' ) ;
rawSummary . textContent = 'Visa lagrad text med escape-tecken' ;
const rawText = document . createElement ( 'div' ) ;
rawText . className = 'json-long-text' ;
rawText . textContent = value ;
raw . append ( rawSummary , rawText ) ;
parent . append ( raw ) ;
} ;
const addLeaf = ( parent , label , item ) => {
const readable = typeof item === 'string' ? decodeEscapedSequences ( item ) : item ;
if ( typeof item === 'string' && item . length > 180 ) {
const details = document . createElement ( 'details' ) ;
details . className = 'json-long-value' ;
const summary = document . createElement ( 'summary' ) ;
summary . append ( document . createTextNode ( ` ${ label ? ` ${ label } · ` : '' } Text · ${ item . length } tecken · ` ) ) ;
const preview = document . createElement ( 'span' ) ;
preview . className = 'json-value-string' ;
const previewText = readable . slice ( 0 , 100 ) ;
preview . textContent = ` ${ previewText } ${ readable . length > 100 ? '…' : '' } ` ;
summary . append ( preview ) ;
const fullText = document . createElement ( 'div' ) ;
fullText . className = 'json-long-text' ;
fullText . textContent = readable ;
details . append ( summary , fullText ) ;
parent . append ( details ) ;
if ( readable !== item ) addRawText ( parent , item ) ;
return ;
}
const row = document . createElement ( 'div' ) ;
row . className = 'json-entry' ;
if ( label ) {
const key = document . createElement ( 'span' ) ;
key . className = 'json-key' ;
key . textContent = label ;
row . append ( key ) ;
}
const valueNode = document . createElement ( 'span' ) ;
const type = item === null ? 'null' : typeof item ;
valueNode . className = ` json-value- ${ type === 'boolean' ? 'boolean' : type } ` ;
valueNode . textContent = typeof readable === 'string' ? readable : String ( readable ) ;
row . append ( valueNode ) ;
parent . append ( row ) ;
if ( typeof item === 'string' && readable !== item ) addRawText ( parent , item ) ;
} ;
const populate = ( details , item , depth ) => {
if ( details . dataset . loaded === 'true' ) return ;
details . dataset . loaded = 'true' ;
const children = document . createElement ( 'div' ) ;
children . className = 'json-children' ;
if ( depth >= 64 ) {
const note = document . createElement ( 'div' ) ;
note . className = 'json-empty' ;
note . textContent = 'Ytterligare nästling döljs efter 64 nivåer.' ;
children . append ( note ) ;
} else {
const entries = Array . isArray ( item )
? item . map ( ( child , index ) => [ ` [ ${ index } ] ` , child ] )
: Object . entries ( item ) ;
if ( entries . length === 0 ) {
const empty = document . createElement ( 'div' ) ;
empty . className = 'json-empty' ;
empty . textContent = Array . isArray ( item ) ? 'Listan är tom.' : 'Objektet saknar fält.' ;
children . append ( empty ) ;
}
for ( const [ key , child ] of entries ) {
if ( isGroup ( child ) ) {
const nested = document . createElement ( 'details' ) ;
nested . className = 'json-node' ;
nodeValues . set ( nested , child ) ;
nested . dataset . depth = String ( depth + 1 ) ;
const nestedSummary = document . createElement ( 'summary' ) ;
const label = document . createElement ( 'span' ) ;
label . className = 'json-label' ;
label . textContent = key ;
const kind = document . createElement ( 'span' ) ;
kind . className = 'json-kind' ;
kind . textContent = ` · ${ groupSummary ( child ) } ` ;
nestedSummary . append ( label , kind ) ;
nested . append ( nestedSummary ) ;
nested . addEventListener ( 'toggle' , ( ) => {
if ( nested . open ) populate ( nested , child , depth + 1 ) ;
} ) ;
children . append ( nested ) ;
} else {
addLeaf ( children , key , child ) ;
}
}
}
details . append ( children ) ;
} ;
if ( isGroup ( value ) ) {
const root = document . createElement ( 'details' ) ;
root . className = 'json-node json-root' ;
root . open = true ;
const summary = document . createElement ( 'summary' ) ;
const label = document . createElement ( 'span' ) ;
label . className = 'json-label' ;
label . textContent = 'Innehåll' ;
const kind = document . createElement ( 'span' ) ;
kind . className = 'json-kind' ;
kind . textContent = ` · ${ groupSummary ( value ) } ` ;
summary . append ( label , kind ) ;
root . append ( summary ) ;
root . addEventListener ( 'toggle' , ( ) => {
if ( root . open ) populate ( root , value , 0 ) ;
} ) ;
populate ( root , value , 0 ) ;
tree . append ( root ) ;
expand . addEventListener ( 'click' , ( ) => {
const openAll = details => {
details . open = true ;
if ( details === root || details . dataset . loaded === 'true' ) {
const childGroups = [ ... details . querySelectorAll ( ':scope > .json-children > details.json-node' ) ] ;
for ( const child of childGroups ) {
if ( child . dataset . loaded !== 'true' ) {
if ( nodeValues . has ( child ) ) populate ( child , nodeValues . get ( child ) , Number ( child . dataset . depth || 1 ) ) ;
}
openAll ( child ) ;
}
}
} ;
openAll ( root ) ;
} ) ;
collapse . addEventListener ( 'click' , ( ) => {
for ( const nested of tree . querySelectorAll ( 'details.json-node:not(.json-root)' ) ) nested . open = false ;
root . open = true ;
} ) ;
} else {
addLeaf ( tree , '' , value ) ;
expand . disabled = true ;
collapse . disabled = true ;
}
return container ;
}
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 ) ;
renderJsonTree ( adminElement ( 'div ' , undefined , details ) , value ) ;
return details ;
return details ;
}
}
function parseAiResponseSegments ( response ) {
const text = String ( response ? ? '' ) ;
const fencedJson = /```json\s*([\s\S]*?)```/gi ;
const segments = [ ] ;
let cursor = 0 ;
for ( const match of text . matchAll ( fencedJson ) ) {
const index = match . index ? ? cursor ;
if ( index > cursor ) segments . push ( { type : 'text' , value : text . slice ( cursor , index ) } ) ;
const source = match [ 1 ] . trim ( ) ;
try {
segments . push ( { type : 'json' , value : JSON . parse ( source ) } ) ;
} catch ( _ ) {
segments . push ( { type : 'code' , value : source } ) ;
}
cursor = index + match [ 0 ] . length ;
}
if ( cursor < text . length || segments . length === 0 ) segments . push ( { type : 'text' , value : text . slice ( cursor ) } ) ;
return segments ;
}
function renderAdminAiResponse ( parent , response ) {
const segments = parseAiResponseSegments ( response ) ;
if ( ! segments . some ( segment => segment . type === 'json' || segment . type === 'code' ) ) return false ;
const output = adminElement ( 'section' , undefined , parent ) ;
output . className = 'admin-ai-response' ;
for ( const segment of segments ) {
if ( segment . type === 'json' ) {
adminJson ( 'JSON-svar · formaterat' , segment . value , output ) ;
} else if ( segment . type === 'code' ) {
const code = adminElement ( 'pre' , segment . value , output ) ;
code . className = 'admin-ai-response-code' ;
} else if ( segment . value . trim ( ) ) {
const text = adminElement ( 'p' , decodeEscapedSequences ( segment . value . trim ( ) ) , output ) ;
text . className = 'admin-ai-response-text' ;
}
}
return true ;
}
async function loadAdminPlayers ( query = '' , offset = 0 ) {
async function loadAdminPlayers ( query = '' , offset = 0 ) {
adminOffset = offset ;
adminOffset = offset ;
adminContent . replaceChildren ( ) ;
adminContent . replaceChildren ( ) ;
@@ -614,6 +895,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 +929,261 @@
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 responseHasJson = parseAiResponseSegments ( interaction . response ) . some ( segment => segment . type === 'json' || segment . type === 'code' ) ;
const responseButtonLabel = ` AI-svar · ${ interaction . prompt _id } · ${ interaction . outcome } ${ responseHasJson ? ' · JSON' : ` : ${ interaction . response } ` } ` ;
const button = adminElement ( 'button' , responseButtonLabel , card ) ;
button . type = 'button' ; button . setAttribute ( 'aria-expanded' , 'false' ) ;
if ( responseHasJson ) renderAdminAiResponse ( card , interaction . response ) ;
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 , '\t' ) ; 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 +1285,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' } ) ;
@@ -843,9 +1419,9 @@
const addValue = ( name , value ) => {
const addValue = ( name , value ) => {
const valueHeading = document . createElement ( 'strong' ) ;
const valueHeading = document . createElement ( 'strong' ) ;
valueHeading . textContent = name ;
valueHeading . textContent = name ;
const pre = document . createElement ( 'pre ' ) ;
const json = document . createElement ( 'div ' ) ;
pre . textContent = typeof value === 'string' ? value : JSON . stringify ( value ? ? 'Ingen uppgift sparad' , null , 2 ) ;
renderJsonTree ( json , value === undefined ? 'Ingen uppgift sparad' : value ) ;
card . append ( valueHeading , pre ) ;
card . append ( valueHeading , json ) ;
} ;
} ;
addValue ( 'Indata' , input ) ;
addValue ( 'Indata' , input ) ;
addValue ( 'Utdata' , output ) ;
addValue ( 'Utdata' , output ) ;
@@ -903,10 +1479,10 @@
const raw = document . createElement ( 'details' ) ;
const raw = document . createElement ( 'details' ) ;
raw . className = 'trace-raw' ;
raw . className = 'trace-raw' ;
const rawSummary = document . createElement ( 'summary' ) ;
const rawSummary = document . createElement ( 'summary' ) ;
rawSummary . textContent = 'Visa all rå spårdata' ;
rawSummary . textContent = 'Visa hela spårdata' ;
const pre = document . createElement ( 'pre ' ) ;
const json = document . createElement ( 'div ' ) ;
pre . textContent = JSON . stringify ( group , null , 2 ) ;
renderJsonTree ( json , group ) ;
raw . append ( rawSummary , pre ) ;
raw . append ( rawSummary , json ) ;
details . append ( summary , chain , raw ) ;
details . append ( summary , chain , raw ) ;
debugTrace . append ( details ) ;
debugTrace . append ( details ) ;
}
}
@@ -916,7 +1492,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 +1819,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 ( ) ;