Add account conversation tracing to admin UI
Build and publish frontend / publish (push) Successful in 55s
Build and publish frontend / publish (push) Successful in 55s
This commit is contained in:
+127
-15
@@ -27,6 +27,7 @@
|
||||
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
|
||||
#admin-content { display: grid; gap: 1rem; }
|
||||
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
||||
#admin-content article > button { display: block; max-width: 100%; margin-block: .5rem; text-align: start; white-space: normal; }
|
||||
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
||||
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
||||
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
||||
@@ -166,6 +167,7 @@
|
||||
let voiceDisclosureAcknowledged = false;
|
||||
let adminOffset = 0;
|
||||
const adminPageSize = 50;
|
||||
let adminTraceOffset = 0;
|
||||
let presenceTimer = null;
|
||||
let playerMessagesPrimed = false;
|
||||
const seenPlayerMessageIds = new Set();
|
||||
@@ -615,6 +617,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 +651,117 @@
|
||||
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;
|
||||
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=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);
|
||||
if (conversations.length === 0) { adminElement('p', 'Det finns inga sparade dialogturer för kontot ännu.', root); return; }
|
||||
for (const [index, group] of conversations.entries()) {
|
||||
group.events.sort((a, b) => (a.commit_sequence ?? 0) - (b.commit_sequence ?? 0));
|
||||
group.messages.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
group.interactions.sort((a, b) => String(a.requested_at).localeCompare(String(b.requested_at)));
|
||||
const card = adminElement('article', undefined, root);
|
||||
adminElement('h3', `Tur ${index + 1} · ${group.at || 'tid saknas'} · ${group.correlationId || 'utan korrelations-ID'}`, card);
|
||||
let showedPlayerInput = false;
|
||||
for (const event of group.events) {
|
||||
const payload = event.payload || {};
|
||||
if (payload.player_message) { adminElement('p', `Spelaren: ${payload.player_message}`, card); showedPlayerInput = true; }
|
||||
const reply = payload.reply || payload.npc_reply;
|
||||
if (reply) {
|
||||
const button = adminElement('button', `Berättaren${payload.npc_reply ? ' / NPC' : ''}: ${reply}`, card);
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const detail = adminElement('section', undefined, card); detail.hidden = true;
|
||||
button.addEventListener('click', () => {
|
||||
detail.hidden = !detail.hidden;
|
||||
button.setAttribute('aria-expanded', String(!detail.hidden));
|
||||
if (!detail.hidden && !detail.dataset.loaded) {
|
||||
renderDecisionProvenance(group, event, detail);
|
||||
detail.dataset.loaded = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!showedPlayerInput) {
|
||||
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
|
||||
if (failedTurnInput) adminElement('p', `Spelaren: ${failedTurnInput}`, card);
|
||||
}
|
||||
for (const message of group.messages) adminElement('p', `Spelarmeddelande: ${message.message}`, card);
|
||||
for (const interaction of group.interactions) {
|
||||
if (!interaction.response) continue;
|
||||
const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card);
|
||||
button.type = 'button'; button.setAttribute('aria-expanded', 'false');
|
||||
const detail = adminElement('section', undefined, card); detail.hidden = true;
|
||||
button.addEventListener('click', () => {
|
||||
detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden));
|
||||
if (!detail.hidden && !detail.dataset.loaded) { renderDecisionProvenance(group, null, detail, interaction); detail.dataset.loaded = 'true'; }
|
||||
});
|
||||
}
|
||||
if (group.events.length === 0 && group.interactions.every(item => !item.response)) {
|
||||
adminElement('p', 'Ingen färdig replik. Öppna turens tekniska spår:', card);
|
||||
for (const interaction of group.interactions) adminJson(`${interaction.prompt_id} · ${interaction.outcome}`, interaction, card);
|
||||
}
|
||||
}
|
||||
const pageHasMore = hasMoreOverride ?? trace.has_more ?? (Math.max(trace.events?.length || 0, trace.ai_interactions?.length || 0, trace.player_messages?.length || 0) >= (trace.limit || 200));
|
||||
if (pageHasMore) {
|
||||
const older = adminElement('button', 'Visa äldre samtalsturer', root);
|
||||
older.type = 'button';
|
||||
older.addEventListener('click', async () => {
|
||||
older.disabled = true;
|
||||
older.textContent = 'Hämtar äldre samtal…';
|
||||
adminTraceOffset += trace.limit || 200;
|
||||
const next = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=${trace.limit || 200}&offset=${adminTraceOffset}`);
|
||||
const merge = (key, idKey) => [...(trace[key] || []), ...(next[key] || [])].filter((item, index, all) => all.findIndex(candidate => candidate[idKey] === item[idKey]) === index);
|
||||
renderAdminConversations({events: merge('events', 'event_id'), ai_interactions: merge('ai_interactions', 'id'), player_messages: merge('player_messages', 'id'), limit: next.limit, offset: next.offset}, accountId, root, next.has_more);
|
||||
});
|
||||
}
|
||||
}
|
||||
function latestPlayerText(interaction) {
|
||||
const messages = interaction.rendered_messages;
|
||||
if (!Array.isArray(messages)) return '';
|
||||
const userMessage = [...messages].reverse().find(message => message?.role === 'user');
|
||||
if (!userMessage) return '';
|
||||
if (typeof userMessage.content === 'string') return userMessage.content;
|
||||
return userMessage.content == null ? '' : JSON.stringify(userMessage.content);
|
||||
}
|
||||
function renderDecisionProvenance(group, selectedEvent, container, selectedInteraction) {
|
||||
adminElement('h4', 'Beslutsspår', container);
|
||||
if (group.correlationId) adminElement('p', `Korrelations-ID: ${group.correlationId}`, container);
|
||||
const interactions = selectedInteraction ? [selectedInteraction] : group.interactions;
|
||||
for (const interaction of interactions) {
|
||||
const ai = adminElement('section', undefined, container);
|
||||
ai.className = 'trace-step'; ai.dataset.engine = 'ai';
|
||||
adminElement('h5', `AI · ${interaction.prompt_id} v${interaction.prompt_version} · ${interaction.outcome}`, ai);
|
||||
adminJson('YAML-prompt och renderad kontext', {prompt_yaml: interaction.prompt_yaml, rendered_messages: interaction.rendered_messages}, ai);
|
||||
adminJson('Externt tjänsteanrop · förfrågan, svar och metadata', {external_call: interaction.external_call, response: interaction.response, outcome: interaction.outcome, metadata: interaction.metadata}, ai);
|
||||
}
|
||||
const events = selectedEvent ? [selectedEvent] : group.events;
|
||||
for (const event of events) {
|
||||
const payload = event.payload || {};
|
||||
const details = payload.details || {};
|
||||
const luau = details.luau_trace || payload.luau_trace;
|
||||
const rust = details.rust_validation || payload.rust_validation;
|
||||
if (luau) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'luau'; adminJson('Luau · regelindata och effektförslag', luau, step); }
|
||||
if (rust) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'rust'; adminJson('Rust · validering och commit', rust, step); }
|
||||
const commit = adminElement('section', undefined, container); commit.className = 'trace-step'; commit.dataset.engine = 'rust';
|
||||
adminJson(`Rust · ${event.commit_status || 'händelse'} · världstid ${event.world_time ?? 'okänd'} · commit ${event.commit_sequence ?? 'saknas'}`, event, commit);
|
||||
}
|
||||
if (!interactions.length && !events.length) adminElement('p', 'Beslutsspår saknas för denna post.', container);
|
||||
}
|
||||
async function loadAdminNpcs(query = '', offset = 0) {
|
||||
adminContent.replaceChildren();
|
||||
|
||||
Reference in New Issue
Block a user