Files
AtreidesAssetsFree/client/pages/ai.js
T
2026-10-29 11:57:29 +01:00

608 lines
24 KiB
JavaScript

// Generated, do not edit. sha256:9d5e374482da80f40653036b5e206e6d6ff98a8d9dbf55c030df645a418ea367
import {
listAiToolCalls, countAiToolCalls, lastAiToolCallAt, aiToolCallMessages,
} from '../data/ai-audit.js';
import { tableViewCfg, getPref, setPref } from '../data/preferences.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtInt } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { alertHtml } from '../ui/alert.js';
import { renderTable } from '../ui/table.js';
import { refreshAttention } from '../ui/attention.js';
import { CHEVRON, COPY, CHECK } from '../ui/icons.js';
import { expanders } from '../ui/expand.js';
import * as dialog from '../ui/dialog.js';
import { cachedPortfolioName } from '../portfolio.js';
import { CONFIG } from '../../shared/config.js';
function moment(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'medium' });
}
function dag(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(localeTag(), { dateStyle: 'short' });
}
function deelGroep(velden) {
if (velden == null) return t('ai.sharedUnknown');
return velden.length ? t('ai.sharedSome') : t('ai.sharedNothing');
}
function gedeeld(velden) {
if (velden == null) return `<span class="t-muted">${t('ai.sharedUnknown')}</span>`;
if (!velden.length) return `<span class="t-muted">${t('ai.sharedNothing')}</span>`;
return velden.map(v => `<code>${esc(v)}</code>`).join(', ');
}
function notitie(json) {
if (!json) return '';
try {
const args = JSON.parse(json);
if (args && typeof args.note === 'string' && args.note) return esc(args.note);
} catch { }
return '';
}
async function koppelGegevens() {
try {
const res = await fetch('/api/mcp/config');
if (!res.ok) return null;
const cfg = await res.json();
return cfg && typeof cfg.snippet === 'string' ? cfg : null;
} catch { return null; }
}
async function dryTools() {
try {
const res = await fetch('/api/mcp/tools');
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data.tools) ? data.tools : [];
} catch { return []; }
}
const scopeTekst = scope => {
try { return t(`ai.dryScope.${scope}`); } catch { return scope; }
};
const gezieneVerwijzingen = new Set();
const VERWIJZING = /\b[a-z]+:[0-9a-f]{6}\b/g;
function onthoudVerwijzingen(tekst) {
for (const m of String(tekst || '').matchAll(VERWIJZING)) gezieneVerwijzingen.add(m[0]);
}
const VERWIJZING_VELDEN = new Set(['handle']);
function dryArgVelden(tool) {
const schema = tool && tool.inputSchema;
const props = schema && schema.properties ? Object.entries(schema.properties) : [];
if (!props.length) return '';
const verplicht = new Set(Array.isArray(schema.required) ? schema.required : []);
const velden = props.map(([naam, spec]) => {
const nodig = verplicht.has(naam);
const label = `${esc(naam)}${nodig ? ` <span class="t-muted">(${t('ai.dryArgRequired')})</span>` : ''}`;
const bediening = Array.isArray(spec.enum)
? `<select class="input" data-arg="${esc(naam)}">
<option value="">${t('ai.dryArgPick')}</option>
${spec.enum.map(w => `<option value="${esc(w)}">${esc(w)}</option>`).join('')}
</select>`
: tekstVeld(naam, spec);
return `<label class="form-field">
<span class="form-label">${label}</span>
${bediening}
</label>`;
}).join('');
const hulp = props.some(([naam]) => VERWIJZING_VELDEN.has(naam))
? `<div class="t-body-sm t-muted" style="margin-top:0.4rem">${
gezieneVerwijzingen.size ? t('ai.dryArgHandleSome') : t('ai.dryArgHandleNone')}</div>`
: '';
return `<div class="t-body-sm t-muted" style="margin-top:0.6rem">${t('ai.dryArgs')}</div>
<div class="flex" style="gap:0.5rem;align-items:flex-end;margin-top:0.3rem;flex-wrap:wrap">${velden}</div>
${hulp}`;
}
function tekstVeld(naam, spec) {
const suggesties = VERWIJZING_VELDEN.has(naam) ? [...gezieneVerwijzingen].sort() : [];
const lijstId = `dry-lijst-${naam}`;
return `<input type="text" class="input" data-arg="${esc(naam)}"
${spec.maxLength ? `maxlength="${Number(spec.maxLength)}"` : ''}
${suggesties.length ? `list="${esc(lijstId)}"` : ''}
placeholder="${esc(spec.description || '')}">
${suggesties.length
? `<datalist id="${esc(lijstId)}">${suggesties.map(v => `<option value="${esc(v)}"></option>`).join('')}</datalist>`
: ''}`;
}
function dryBlok(tools, expandAttr) {
if (!tools.length) return '';
const opties = tools.map(x =>
`<option value="${esc(x.name)}">${esc(x.title || x.name)} (${scopeTekst(x.scope)})</option>`).join('');
return `
<details class="card-flat ai-sec" style="margin-bottom:0.8rem" ${expandAttr}>
<summary><span class="chev-btn">${CHEVRON}</span><span>${t('ai.dryTitle')}</span></summary>
<div style="margin-top:0.6rem">
<div class="t-body-sm t-muted">${t('ai.dryIntro')}</div>
<div class="flex" style="gap:0.5rem;align-items:flex-end;margin-top:0.6rem;flex-wrap:wrap">
<label class="form-field">
<span class="form-label">${t('ai.dryPick')}</span>
<select id="dry-tool" class="input">${opties}</select>
</label>
<button type="button" class="btn btn-secondary btn-sm btn--field" id="dry-run">${t('ai.dryRun')}</button>
<span class="t-body-sm t-muted" id="dry-melding"></span>
</div>
<div id="dry-args">${dryArgVelden(tools[0])}</div>
<div id="dry-uitslag" style="margin-top:0.8rem"></div>
</div>
</details>`;
}
function dryUitslag(r) {
const detail = r.detail ? `<div class="t-body-sm t-muted">${esc(r.detail)}</div>` : '';
return `
<div class="flex" style="gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<div><div class="t-body-sm t-muted">${t('ai.result')}</div><div>${
r.outcome === 'error' ? `<span class="t-neg">${t('ai.error')}</span>` : t(`ai.${r.outcome}`)}</div></div>
<div><div class="t-body-sm t-muted">${t('ai.shared')}</div><div>${gedeeld(r.fieldsShared)}</div></div>
<div><div class="t-body-sm t-muted">${t('ai.characters')}</div><div>${
r.charactersOut == null ? '' : fmtInt(r.charactersOut)}</div></div>
</div>
${detail}
<div class="t-body-sm t-muted" style="margin-top:0.5rem">${t('ai.dryResult')}</div>
${tekstBlok(r.text)}`;
}
function tekstBlok(tekst) {
return `<pre style="overflow-x:auto;white-space:pre-wrap"><code>${esc(tekst)}</code></pre>`;
}
const gesprekLade = (inhoud) => `<div class="ai-gesprek">${inhoud}</div>`;
function gesprekBlok(regels) {
if (!regels.length) return `<div class="t-body-sm t-muted">${t('ai.noConversation')}</div>`;
const blokken = regels.map(m => {
const label = t(m.kind === 'request' ? 'ai.msgRequest' : 'ai.msgResponse');
return `<div style="margin-bottom:0.6rem">
<div class="t-body-sm t-muted">${label}, ${esc(moment(m.occurredAt))}</div>
${tekstBlok(m.text)}
</div>`;
}).join('');
return blokken;
}
function bindDry(el, tools) {
const knop = el.querySelector('#dry-run');
if (!knop) return;
const keuze = el.querySelector('#dry-tool');
const melding = el.querySelector('#dry-melding');
const uitslag = el.querySelector('#dry-uitslag');
const argVak = el.querySelector('#dry-args');
const toolVan = naam => tools.find(x => x.name === naam) || null;
keuze.addEventListener('change', () => {
argVak.innerHTML = dryArgVelden(toolVan(keuze.value));
uitslag.innerHTML = '';
melding.textContent = '';
});
function werkSuggestiesBij() {
for (const veld of argVak.querySelectorAll('[data-arg]')) {
if (!VERWIJZING_VELDEN.has(veld.dataset.arg)) continue;
const lijstId = `dry-lijst-${veld.dataset.arg}`;
let lijst = argVak.querySelector(`#${lijstId}`);
if (!lijst) {
lijst = document.createElement('datalist');
lijst.id = lijstId;
veld.after(lijst);
veld.setAttribute('list', lijstId);
}
lijst.innerHTML = [...gezieneVerwijzingen].sort()
.map(v => `<option value="${esc(v)}"></option>`).join('');
}
}
function argumenten() {
const uit = {};
for (const veld of argVak.querySelectorAll('[data-arg]')) {
const w = veld.value.trim();
if (w) uit[veld.dataset.arg] = w;
}
return uit;
}
function ontbreekt(tool) {
const nodig = tool?.inputSchema?.required;
if (!Array.isArray(nodig)) return false;
const ingevuld = argumenten();
return nodig.some(naam => !ingevuld[naam]);
}
knop.addEventListener('click', async () => {
if (ontbreekt(toolVan(keuze.value))) { melding.textContent = t('ai.dryNeedsArg'); return; }
knop.disabled = true;
melding.textContent = t('ai.dryBusy');
try {
const res = await fetch('/api/mcp/dry-run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: keuze.value, arguments: argumenten() }),
});
if (!res.ok) throw new Error(t('ai.dryFail'));
const r = await res.json();
uitslag.innerHTML = dryUitslag(r);
melding.textContent = '';
const voor = gezieneVerwijzingen.size;
onthoudVerwijzingen(r.text);
if (gezieneVerwijzingen.size !== voor) werkSuggestiesBij();
} catch (e) {
melding.textContent = e.message || t('ai.dryFail');
} finally {
knop.disabled = false;
}
});
}
const cel = (kop, waarde) =>
`<span class="ai-stat"><span class="t-body-sm t-muted">${kop}</span><span>${waarde}</span></span>`;
function schuifCel(kop, id, aan, hint) {
return cel(kop,
`<span class="toggle-switch" id="${id}-wrap" role="switch" aria-checked="${aan ? 'true' : 'false'}"`
+ ` title="${esc(hint)}" style="cursor:pointer">`
+ `<input type="checkbox" id="${id}" tabindex="-1" disabled${aan ? ' checked' : ''}>`
+ `<span class="toggle-track"></span></span>`);
}
function tellerCellen(telling, laatste) {
if (!telling) return '';
return cel(t('ai.total'), fmtInt(telling.total))
+ cel(t('ai.ok'), fmtInt(telling.byOutcome.ok))
+ cel(t('ai.refused'), fmtInt(telling.byOutcome.refused))
+ cel(t('ai.error'), fmtInt(telling.byOutcome.error))
+ cel(t('ai.lastAt'), esc(moment(laatste)));
}
function bedieningsBlok(standen, telling, laatste) {
const { actief, lezen, schrijven, besturen, onbewerkt } = standen;
return `<span class="ai-stats">`
+ `<span class="ai-schuiven">`
+ schuifCel(t('ai.activeHead'), 'ai-active', actief, actief ? t('ai.activeOn') : t('ai.activeOff'))
+ schuifCel(t('ai.switchHead'), 'ai-read', lezen, lezen ? t('ai.switchOn') : t('ai.switchOff'))
+ schuifCel(t('ai.writeHead'), 'ai-write', schrijven, schrijven ? t('ai.writeOn') : t('ai.writeOff'))
+ schuifCel(t('ai.controlHead'), 'ai-control', besturen, besturen ? t('ai.controlOn') : t('ai.controlOff'))
+ schuifCel(t('ai.rawHead'), 'ai-raw', onbewerkt, onbewerkt ? t('ai.rawOn') : t('ai.rawOff'))
+ `</span>`
+ `<span class="ai-tellers">${tellerCellen(telling, laatste)}</span>`
+ `</span>`;
}
function precisieBlok(standen) {
const { emmerAandeel, emmerTotaal, emmerBedrag, emmerAantal } = standen;
return `
<div class="mt-md">
<div class="t-label">${t('ai.precisionTitle')}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.precisionIntro')}</div>
<span class="ai-schuiven mt-sm">
${schuifCel(t('ai.sharesHead'), 'ai-bshares', emmerAandeel,
emmerAandeel ? t('ai.sharesOn') : t('ai.sharesOff'))}
${schuifCel(t('ai.totalsHead'), 'ai-btotals', emmerTotaal,
emmerTotaal ? t('ai.totalsOn') : t('ai.totalsOff'))}
${schuifCel(t('ai.amountsHead'), 'ai-bamounts', emmerBedrag,
emmerBedrag ? t('ai.amountsOn') : t('ai.amountsOff'))}
${schuifCel(t('ai.quantitiesHead'), 'ai-bquantities', emmerAantal,
emmerAantal ? t('ai.quantitiesOn') : t('ai.quantitiesOff'))}
</span>
<span class="form-hint t-neg" id="ai-bshares-stand"></span>
<span class="form-hint t-neg" id="ai-btotals-stand"></span>
<span class="form-hint t-neg" id="ai-bamounts-stand"></span>
<span class="form-hint t-neg" id="ai-bquantities-stand"></span>
<div class="t-body-sm t-muted mt-sm">${t('ai.precisionCombo')}</div>
</div>`;
}
function koppelBlok(cfg, expandAttr, bediening, standen) {
if (!cfg) return `<div class="t-body-sm t-muted">${t('ai.connectFail')}</div>`;
return `
<details class="card-flat ai-sec" style="margin-bottom:0.8rem" ${expandAttr}>
<summary>
<span class="chev-btn">${CHEVRON}</span>
<span>${t('ai.connectTitle')}</span>
<span class="form-hint t-neg" id="ai-active-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-read-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-write-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-control-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-raw-stand" style="margin-top:0"></span>
${bediening}
</summary>
<div style="margin-top:0.6rem">
<div class="t-body-sm t-muted">${esc(portefeuilleRegel())}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.revoke')}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.rawHint')}</div>
${precisieBlok(standen)}
<div class="t-body-sm t-muted mt-md">${t('ai.connectIntro')}</div>
<div class="codeblock">
<div class="codeblock-tools">
<button type="button" class="codeblock-copy" id="mcp-config-copy"
aria-label="${esc(t('ai.connectCopy'))}" title="${esc(t('ai.connectCopy'))}">${COPY}</button>
<span class="codeblock-tag">JSON</span>
</div>
<pre><code id="mcp-config">${esc(cfg.snippet)}</code></pre>
</div>
<span class="t-body-sm t-muted" id="mcp-config-melding"></span>
<div class="t-body-sm t-muted" style="margin-top:0.6rem">
${t('ai.connectFile')} ${t('ai.connectRestart')} ${t('ai.controlHint')}
</div>
</div>
</details>`;
}
function bindSchuif(el, { wrapId, vakId, standId, scope, key, aanTekst, uitTekst, faalTekst, bevestigAan = null }) {
const wrap = el.querySelector(`#${wrapId}`);
if (!wrap) return;
const vak = el.querySelector(`#${vakId}`);
const stand = el.querySelector(`#${standId}`);
const toon = (aan, melding = '') => {
if (vak) vak.checked = aan;
wrap.setAttribute('aria-checked', aan ? 'true' : 'false');
wrap.title = aan ? aanTekst : uitTekst;
if (stand) stand.textContent = melding;
};
wrap.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const aan = wrap.getAttribute('aria-checked') !== 'true';
if (aan && bevestigAan && !await bevestigAan()) return;
toon(aan);
try {
await setPref(scope, key, aan ? 1 : 0);
} catch {
toon(!aan, faalTekst);
}
});
}
function bindSchuiven(el) {
bindSchuif(el, {
wrapId: 'ai-active-wrap', vakId: 'ai-active', standId: 'ai-active-stand',
scope: 'ai', key: 'enabled',
aanTekst: t('ai.activeOn'), uitTekst: t('ai.activeOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-read-wrap', vakId: 'ai-read', standId: 'ai-read-stand',
scope: 'ai', key: 'allow_read',
aanTekst: t('ai.switchOn'), uitTekst: t('ai.switchOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-write-wrap', vakId: 'ai-write', standId: 'ai-write-stand',
scope: 'ai', key: 'allow_write',
aanTekst: t('ai.writeOn'), uitTekst: t('ai.writeOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-control-wrap', vakId: 'ai-control', standId: 'ai-control-stand',
scope: 'ai', key: 'allow_control',
aanTekst: t('ai.controlOn'), uitTekst: t('ai.controlOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-raw-wrap', vakId: 'ai-raw', standId: 'ai-raw-stand',
scope: 'ai', key: 'allow_raw',
aanTekst: t('ai.rawOn'), uitTekst: t('ai.rawOff'), faalTekst: t('ai.switchFail'),
bevestigAan: () => dialog.confirm({
message: t('ai.rawConfirm').replace('{naam}', cachedPortfolioName() || '?'),
danger: true,
}),
});
for (const [id, key] of [
['ai-bshares', 'bucket_shares'],
['ai-btotals', 'bucket_totals'],
['ai-bamounts', 'bucket_amounts'],
['ai-bquantities', 'bucket_quantities'],
]) {
const stam = key.replace('bucket_', '');
bindSchuif(el, {
wrapId: `${id}-wrap`, vakId: id, standId: `${id}-stand`,
scope: 'ai', key,
aanTekst: t(`ai.${stam}On`), uitTekst: t(`ai.${stam}Off`), faalTekst: t('ai.switchFail'),
});
}
}
function portefeuilleRegel() {
const naam = cachedPortfolioName();
return naam ? t('ai.scope').replace('{naam}', naam) : t('ai.rawHint');
}
const BEVESTIGING_MS = 2000;
function bindKopieer(el, cfg) {
const knop = el.querySelector('#mcp-config-copy');
if (!knop || !cfg) return;
const melding = el.querySelector('#mcp-config-melding');
let terug = null;
const herstel = () => {
knop.classList.remove('is-done');
knop.innerHTML = COPY;
knop.setAttribute('aria-label', t('ai.connectCopy'));
knop.setAttribute('title', t('ai.connectCopy'));
if (melding) melding.textContent = '';
};
knop.addEventListener('click', () => {
navigator.clipboard.writeText(cfg.snippet)
.then(() => {
knop.classList.add('is-done');
knop.innerHTML = CHECK;
knop.setAttribute('aria-label', t('ai.connectCopied'));
knop.setAttribute('title', t('ai.connectCopied'));
if (melding) melding.textContent = '';
})
.catch(() => {
knop.classList.remove('is-done');
knop.innerHTML = COPY;
if (melding) melding.textContent = t('ai.connectCopyFail');
})
.finally(() => { clearTimeout(terug); terug = setTimeout(herstel, BEVESTIGING_MS); });
});
}
let live = null;
let liveGebonden = false;
const gesprekken = new Map();
function bindLive() {
if (liveGebonden) return;
liveGebonden = true;
document.addEventListener('ai-activity-changed', async () => {
if (!live || !live.el || !live.el.isConnected) { live = null; return; }
let regels, telling, laatste;
try {
[regels, telling, laatste] = await Promise.all([
listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(),
]);
} catch { return; }
if (!live.el.isConnected) { live = null; return; }
live.tabel.refresh(regels);
const tellers = live.el.querySelector('.ai-tellers');
if (tellers) tellers.innerHTML = tellerCellen(telling, laatste);
await markeerGezien(regels);
});
}
async function markeerGezien(regels) {
const hoogste = regels.reduce((n, r) => Math.max(n, Number(r.id) || 0), 0);
if (!hoogste) return;
try {
await setPref('ai', 'seen_call_id', hoogste);
await refreshAttention();
} catch { }
}
export async function render(el) {
let regels, telling, laatste;
try {
[regels, telling, laatste] = await Promise.all([
listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(),
]);
} catch (err) {
el.innerHTML = alertHtml({ kind: 'error', body: err.message });
return;
}
const cfg = await koppelGegevens();
const tools = await dryTools();
const lezen = await getPref('ai', 'allow_read', 1).then(v => v !== 0).catch(() => true);
const uitBijTwijfel = (sleutel) => getPref('ai', sleutel, 0).then(v => v === 1).catch(() => false);
const aanBijTwijfel = (sleutel) => getPref('ai', sleutel, 1).then(v => v !== 0).catch(() => true);
const standen = {
actief: await getPref('ai', 'enabled', 1).then(v => v !== 0).catch(() => true),
lezen,
schrijven: await uitBijTwijfel('allow_write'),
besturen: await uitBijTwijfel('allow_control'),
onbewerkt: await uitBijTwijfel('allow_raw'),
emmerAandeel: await aanBijTwijfel('bucket_shares'),
emmerTotaal: await aanBijTwijfel('bucket_totals'),
emmerBedrag: await aanBijTwijfel('bucket_amounts'),
emmerAantal: await aanBijTwijfel('bucket_quantities'),
};
const expand = await expanders('ai', { connect: !telling.total, dryRun: false });
if (!telling.total) {
el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, null), standen)}${dryBlok(tools, expand.attr('dryRun'))}
${alertHtml({ kind: 'neutral', body: t('ai.notConnected'),
extra: `<div class="t-body-sm t-muted" style="margin-top:0.5rem">${t('ai.howTo')}</div>` })}`;
bindSchuiven(el);
bindKopieer(el, cfg);
bindDry(el, tools);
expand.bind(el);
return;
}
el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, telling, laatste), standen)}`
+ `${dryBlok(tools, expand.attr('dryRun'))}<div id="tbl"></div>`;
bindSchuiven(el);
bindKopieer(el, cfg);
bindDry(el, tools);
expand.bind(el);
gesprekken.clear();
const tabel = renderTable(el.querySelector('#tbl'), {
columns: [
{
key: 'when', label: t('ai.when'), sortable: true,
sortValue: r => r.occurredAt || '',
render: r => esc(moment(r.occurredAt)),
group: { value: r => dag(r.occurredAt), label: v => v },
},
{
key: 'what', label: t('ai.what'), sortable: true, text: true,
sortValue: r => r.tool || '',
render: r => {
const note = notitie(r.arguments);
const detail = r.detail ? `<div class="t-body-sm t-muted">${esc(r.detail)}</div>` : '';
const noteRegel = note ? `<div class="t-body-sm t-muted">"${note}"</div>` : '';
return `<code>${esc(r.tool)}</code>${noteRegel}${detail}`;
},
group: { value: r => r.tool || '', label: v => v },
},
{
key: 'who', label: t('ai.who'), sortable: true,
sortValue: r => r.client || '',
render: r => (r.client ? esc(r.client) : `<span class="t-muted">${t('ai.unknownClient')}</span>`),
group: { value: r => r.client || t('ai.unknownClient'), label: v => v },
},
{
key: 'shared', label: t('ai.shared'), sortable: true, text: true,
sortValue: r => (r.fieldsShared == null ? -1 : r.fieldsShared.length),
render: r => gedeeld(r.fieldsShared),
group: { value: r => deelGroep(r.fieldsShared), label: v => v },
},
{
key: 'characters', label: t('ai.characters'), align: 'num', sortable: true,
sortValue: r => r.charactersOut ?? -1,
render: r => (r.charactersOut == null ? '' : fmtInt(r.charactersOut)),
},
{
key: 'result', label: t('ai.result'), sortable: true,
sortValue: r => r.outcome || '',
render: r => (r.outcome === 'error'
? `<span class="t-neg">${t(`ai.${r.outcome}`)}</span>` : t(`ai.${r.outcome}`)),
group: { value: r => t(`ai.${r.outcome}`), label: v => v },
},
],
rows: regels,
idKey: 'id',
rowExpansion: (row) => gesprekLade(gesprekken.get(row.id) ?? `<div class="t-body-sm t-muted">${t('ai.conversation')}</div>`),
onExpansionMount: async (row, host) => {
if (gesprekken.has(row.id)) { host.innerHTML = gesprekLade(gesprekken.get(row.id)); return; }
try {
gesprekken.set(row.id, gesprekBlok(await aiToolCallMessages(row.id)));
} catch {
host.innerHTML = gesprekLade(`<div class="t-body-sm t-muted">${t('ai.conversationFail')}</div>`);
return;
}
host.innerHTML = gesprekLade(gesprekken.get(row.id));
},
...(await tableViewCfg('ai', { key: 'when', dir: 'desc' })),
noneLabel: t('table.none'),
collapsibleGroups: true,
emptyText: t('ai.noRows'),
});
live = { el, tabel };
bindLive();
await markeerGezien(regels);
}