|
|
|
@@ -28,7 +28,48 @@
|
|
|
|
|
#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-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: .85rem; 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: 32rem; overflow: auto; }
|
|
|
|
|
.json-view { box-sizing: border-box; margin-block: .65rem; padding: .65rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; font: .88rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
|
|
.json-tree-tools { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-end: .6rem; }
|
|
|
|
|
.json-tree-tools button { font-size: .88rem; padding: .3rem .55rem; }
|
|
|
|
|
.json-tree, .json-children { display: grid; gap: .3rem; }
|
|
|
|
|
.json-children { margin-inline-start: .7rem; padding-inline-start: .65rem; border-inline-start: 2px solid #cbd5e1; }
|
|
|
|
|
.json-node { min-width: 0; }
|
|
|
|
|
.json-node > summary { cursor: pointer; padding: .3rem .45rem; border-radius: .25rem; background: #e2e8f0; overflow-wrap: anywhere; }
|
|
|
|
|
.json-node > summary:focus-visible, .json-tree-tools button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
|
|
|
|
|
.json-label { color: #075985; font-weight: 750; }
|
|
|
|
|
.json-kind { color: #475569; font-size: .86em; }
|
|
|
|
|
.json-entry { display: grid; grid-template-columns: minmax(8rem, .35fr) minmax(0, 1fr); gap: .5rem; padding: .2rem .45rem; border-radius: .2rem; 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: .25rem .65rem; color: #475569; font-style: italic; }
|
|
|
|
|
.json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; }
|
|
|
|
|
.json-long-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
|
|
|
|
|
.json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
|
|
@media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } }
|
|
|
|
|
@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; }
|
|
|
|
|
}
|
|
|
|
|
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
|
|
|
|
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
|
|
|
|
body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; }
|
|
|
|
@@ -168,6 +209,7 @@
|
|
|
|
|
let adminOffset = 0;
|
|
|
|
|
const adminPageSize = 50;
|
|
|
|
|
let adminTraceOffset = 0;
|
|
|
|
|
let adminSimpleConversation = false;
|
|
|
|
|
let presenceTimer = null;
|
|
|
|
|
let playerMessagesPrimed = false;
|
|
|
|
|
const seenPlayerMessageIds = new Set();
|
|
|
|
@@ -178,6 +220,22 @@
|
|
|
|
|
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
async function graphqlQuery(query, variables = {}) {
|
|
|
|
|
const response = await request('/graphql', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'content-type': 'application/json'},
|
|
|
|
|
body: JSON.stringify({query, variables}),
|
|
|
|
|
});
|
|
|
|
|
if (response.errors?.length) throw new Error(response.errors[0].message || 'GraphQL-förfrågan misslyckades');
|
|
|
|
|
return response.data;
|
|
|
|
|
}
|
|
|
|
|
async function getAdminTrace(accountId = null, limit = 200, offset = 0) {
|
|
|
|
|
const data = await graphqlQuery(
|
|
|
|
|
'query AdminPlayerTrace($accountId: String, $limit: Int!, $offset: Int!) { adminPlayerTrace(accountId: $accountId, limit: $limit, offset: $offset) }',
|
|
|
|
|
{accountId, limit, offset},
|
|
|
|
|
);
|
|
|
|
|
return data.adminPlayerTrace;
|
|
|
|
|
}
|
|
|
|
|
function appendLine(role, content, options = {}) {
|
|
|
|
|
const line = document.createElement('li');
|
|
|
|
|
line.dataset.role = role;
|
|
|
|
@@ -598,10 +656,154 @@
|
|
|
|
|
parent.append(element);
|
|
|
|
|
return element;
|
|
|
|
|
}
|
|
|
|
|
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 addLeaf = (parent, label, 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';
|
|
|
|
|
preview.textContent = `${item.slice(0, 100)}${item.length > 100 ? '…' : ''}`;
|
|
|
|
|
summary.append(preview);
|
|
|
|
|
const fullText = document.createElement('div');
|
|
|
|
|
fullText.className = 'json-long-text';
|
|
|
|
|
fullText.textContent = item;
|
|
|
|
|
details.append(summary, fullText);
|
|
|
|
|
parent.append(details);
|
|
|
|
|
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 item === 'string' ? JSON.stringify(item) : String(item);
|
|
|
|
|
row.append(valueNode);
|
|
|
|
|
parent.append(row);
|
|
|
|
|
};
|
|
|
|
|
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) {
|
|
|
|
|
const details = adminElement('details', undefined, parent);
|
|
|
|
|
adminElement('summary', title, details);
|
|
|
|
|
adminElement('pre', JSON.stringify(value, null, 2), details);
|
|
|
|
|
renderJsonTree(adminElement('div', undefined, details), value);
|
|
|
|
|
return details;
|
|
|
|
|
}
|
|
|
|
|
async function loadAdminPlayers(query = '', offset = 0) {
|
|
|
|
@@ -652,7 +854,8 @@
|
|
|
|
|
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
|
|
|
|
|
adminJson(`${profile.username} · profil och biografi`, profile);
|
|
|
|
|
adminTraceOffset = 0;
|
|
|
|
|
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200&offset=0`);
|
|
|
|
|
adminSimpleConversation = false;
|
|
|
|
|
const trace = await getAdminTrace(accountId, 200, 0);
|
|
|
|
|
renderAdminConversations(trace, accountId);
|
|
|
|
|
}
|
|
|
|
|
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
|
|
|
|
@@ -670,7 +873,21 @@
|
|
|
|
|
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; }
|
|
|
|
|
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));
|
|
|
|
@@ -680,9 +897,15 @@
|
|
|
|
|
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; }
|
|
|
|
|
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');
|
|
|
|
@@ -699,9 +922,15 @@
|
|
|
|
|
}
|
|
|
|
|
if (!showedPlayerInput) {
|
|
|
|
|
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
|
|
|
|
|
if (failedTurnInput) adminElement('p', `Spelaren: ${failedTurnInput}`, card);
|
|
|
|
|
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 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);
|
|
|
|
@@ -719,13 +948,13 @@
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
const older = adminElement('button', adminSimpleConversation ? 'Visa äldre berättarrepliker' : 'Visa äldre samtalsturer', root);
|
|
|
|
|
older.type = 'button';
|
|
|
|
|
older.addEventListener('click', async () => {
|
|
|
|
|
older.disabled = true;
|
|
|
|
|
older.textContent = 'Hämtar äldre samtal…';
|
|
|
|
|
adminTraceOffset += trace.limit || 200;
|
|
|
|
|
const next = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=${trace.limit || 200}&offset=${adminTraceOffset}`);
|
|
|
|
|
const 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);
|
|
|
|
|
});
|
|
|
|
@@ -814,7 +1043,7 @@
|
|
|
|
|
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 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 => {
|
|
|
|
@@ -977,12 +1206,53 @@
|
|
|
|
|
messageField.disabled = false;
|
|
|
|
|
messageField.focus();
|
|
|
|
|
}
|
|
|
|
|
function savedDialogueLine(line) {
|
|
|
|
|
const element = document.createElement('li');
|
|
|
|
|
element.dataset.role = line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant';
|
|
|
|
|
element.textContent = `${line.speaker || (line.role === 'user' ? 'Du' : 'Berättelsen')}: ${line.content}`;
|
|
|
|
|
return element;
|
|
|
|
|
}
|
|
|
|
|
function dialogueLinesFromPage(page) {
|
|
|
|
|
return page.edges.flatMap(edge => edge.node.lines);
|
|
|
|
|
}
|
|
|
|
|
function addOlderDialogueControl(cursor) {
|
|
|
|
|
const item = document.createElement('li');
|
|
|
|
|
item.dataset.role = 'system';
|
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
button.type = 'button';
|
|
|
|
|
button.textContent = 'Hämta äldre konversation';
|
|
|
|
|
button.addEventListener('click', async () => {
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
button.textContent = 'Hämtar äldre konversation…';
|
|
|
|
|
try {
|
|
|
|
|
const page = (await graphqlQuery(
|
|
|
|
|
'query OlderDialogue($before: String!) { dialogue(first: 50, before: $before) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
|
|
|
|
|
{before: cursor},
|
|
|
|
|
)).dialogue;
|
|
|
|
|
const oldLines = dialogueLinesFromPage(page);
|
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
|
for (const line of oldLines) fragment.append(savedDialogueLine(line));
|
|
|
|
|
item.remove();
|
|
|
|
|
transcript.prepend(fragment);
|
|
|
|
|
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
button.textContent = `Kunde inte hämta äldre repliker: ${error.message}. Försök igen`;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
item.append(button);
|
|
|
|
|
transcript.prepend(item);
|
|
|
|
|
}
|
|
|
|
|
async function loadSavedDialogue() {
|
|
|
|
|
const lines = await request('/api/dialogue');
|
|
|
|
|
const page = (await graphqlQuery(
|
|
|
|
|
'query CurrentDialogue { dialogue(first: 100) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
|
|
|
|
|
)).dialogue;
|
|
|
|
|
const lines = dialogueLinesFromPage(page);
|
|
|
|
|
transcript.replaceChildren();
|
|
|
|
|
latestAssistantText = '';
|
|
|
|
|
latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.content || '';
|
|
|
|
|
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
|
|
|
|
|
for (const line of lines) appendLine(line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant', line.content, {speaker: line.speaker, speak: false});
|
|
|
|
|
for (const line of lines) transcript.append(savedDialogueLine(line));
|
|
|
|
|
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
|
|
|
|
|
refreshActionTools();
|
|
|
|
|
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
|
|
|
|
|
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
|
|
|
|
@@ -1070,9 +1340,9 @@
|
|
|
|
|
const addValue = (name, value) => {
|
|
|
|
|
const valueHeading = document.createElement('strong');
|
|
|
|
|
valueHeading.textContent = name;
|
|
|
|
|
const pre = document.createElement('pre');
|
|
|
|
|
pre.textContent = typeof value === 'string' ? value : JSON.stringify(value ?? 'Ingen uppgift sparad', null, 2);
|
|
|
|
|
card.append(valueHeading, pre);
|
|
|
|
|
const json = document.createElement('div');
|
|
|
|
|
renderJsonTree(json, value === undefined ? 'Ingen uppgift sparad' : value);
|
|
|
|
|
card.append(valueHeading, json);
|
|
|
|
|
};
|
|
|
|
|
addValue('Indata', input);
|
|
|
|
|
addValue('Utdata', output);
|
|
|
|
@@ -1130,10 +1400,10 @@
|
|
|
|
|
const raw = document.createElement('details');
|
|
|
|
|
raw.className = 'trace-raw';
|
|
|
|
|
const rawSummary = document.createElement('summary');
|
|
|
|
|
rawSummary.textContent = 'Visa all rå spårdata';
|
|
|
|
|
const pre = document.createElement('pre');
|
|
|
|
|
pre.textContent = JSON.stringify(group, null, 2);
|
|
|
|
|
raw.append(rawSummary, pre);
|
|
|
|
|
rawSummary.textContent = 'Visa hela spårdata';
|
|
|
|
|
const json = document.createElement('div');
|
|
|
|
|
renderJsonTree(json, group);
|
|
|
|
|
raw.append(rawSummary, json);
|
|
|
|
|
details.append(summary, chain, raw);
|
|
|
|
|
debugTrace.append(details);
|
|
|
|
|
}
|
|
|
|
@@ -1143,7 +1413,7 @@
|
|
|
|
|
debugRefreshBusy = true;
|
|
|
|
|
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
|
|
|
|
|
try {
|
|
|
|
|
const trace = await request('/api/admin/self/trace?limit=200');
|
|
|
|
|
const trace = await getAdminTrace(null, 200, 0);
|
|
|
|
|
renderDecisionTrace(trace);
|
|
|
|
|
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
|
|
|
|
|
} catch (error) {
|
|
|
|
|