06d01a3bce
Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.
- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning
Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
234 lines
10 KiB
PHP
234 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
$dashboardPage = 'chat';
|
|
$dashboardTitle = 'Spør korpuset';
|
|
$dashboardLead = 'Still juridiske spørsmål. Svar streames med kildehenvisninger til ditt eget korpus og delt Do Better Norge-pakke.';
|
|
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
|
?>
|
|
<div class="disclaimer" role="note" style="margin-bottom:1rem;">
|
|
⚖ Juridisk informasjon og forberedelsesstøtte — ikke endelig juridisk rådgivning. Bekreft alltid med advokat.
|
|
</div>
|
|
|
|
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
|
|
<div id="chatLog" class="chat-log" aria-live="polite">
|
|
<div class="chat-empty">Still ditt første spørsmål. Det kan handle om barnerett, barnevern, EMD, arbeidsrett — alt som finnes i ditt korpus.</div>
|
|
</div>
|
|
|
|
<form id="chatForm" class="chat-form" autocomplete="off">
|
|
<textarea id="chatInput" rows="2" required placeholder="f.eks. «Hva sier barnevernloven § 4-12 om plassering uten samtykke?»"></textarea>
|
|
<button type="submit" class="dash-btn dash-btn--primary" id="chatSendBtn">Send</button>
|
|
</form>
|
|
</section>
|
|
|
|
<style>
|
|
.chat-log { flex: 1; overflow-y: auto; padding: 0.25rem 0.25rem 1rem; display: flex; flex-direction: column; gap: 1rem; min-height: 40vh; max-height: 65vh; }
|
|
.chat-empty { text-align: center; padding: 3rem 1rem; color: rgba(22, 19, 15, 0.5); font-style: italic; }
|
|
.chat-msg { display: flex; flex-direction: column; gap: 0.4rem; max-width: 88%; }
|
|
.chat-msg--user { align-self: flex-end; }
|
|
.chat-msg--ai { align-self: flex-start; }
|
|
.chat-bubble {
|
|
padding: 0.85rem 1.05rem; border-radius: var(--dash-radius); line-height: 1.55; font-size: 0.95rem;
|
|
white-space: pre-wrap; word-wrap: break-word;
|
|
}
|
|
.chat-msg--user .chat-bubble { background: var(--dbn-blue); color: #fff; }
|
|
.chat-msg--ai .chat-bubble { background: #fff; border: 1px solid var(--dbn-line); }
|
|
.chat-sources { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
|
.chat-source-chip {
|
|
font-size: 0.78rem; padding: 0.2rem 0.55rem; border-radius: 999px;
|
|
background: rgba(0, 32, 91, 0.07); color: var(--dbn-blue); text-decoration: none;
|
|
border: 1px solid rgba(0, 32, 91, 0.15);
|
|
}
|
|
.chat-source-chip:hover { background: rgba(0, 32, 91, 0.13); }
|
|
.chat-actions { display: flex; gap: 0.5rem; }
|
|
.chat-actions button { font-size: 0.8rem; padding: 0.25rem 0.55rem; }
|
|
.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 0.6rem; padding-top: 0.75rem; border-top: 1px solid var(--dbn-line); }
|
|
.chat-form textarea {
|
|
padding: 0.7rem 0.85rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
|
|
font: inherit; resize: vertical; min-height: 3rem; max-height: 8rem; background: #fff;
|
|
}
|
|
.chat-meta { font-size: 0.72rem; color: rgba(22, 19, 15, 0.5); margin-top: 0.2rem; }
|
|
.chat-thinking { font-style: italic; color: rgba(22, 19, 15, 0.5); }
|
|
</style>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
const api = window.DBN_DASHBOARD.apiBase;
|
|
const $log = document.getElementById('chatLog');
|
|
const $form = document.getElementById('chatForm');
|
|
const $input = document.getElementById('chatInput');
|
|
const $send = document.getElementById('chatSendBtn');
|
|
|
|
const history = []; // [{role, content}]
|
|
|
|
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
|
|
|
function clearEmpty() {
|
|
const empty = $log.querySelector('.chat-empty');
|
|
if (empty) empty.remove();
|
|
}
|
|
function appendUser(text) {
|
|
clearEmpty();
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chat-msg chat-msg--user';
|
|
wrap.innerHTML = '<div class="chat-bubble">' + safe(text) + '</div>';
|
|
$log.appendChild(wrap);
|
|
$log.scrollTop = $log.scrollHeight;
|
|
}
|
|
function appendAi() {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chat-msg chat-msg--ai';
|
|
wrap.innerHTML =
|
|
'<div class="chat-bubble"><span class="chat-stream"></span><span class="chat-thinking">tenker…</span></div>'
|
|
+ '<div class="chat-sources" hidden></div>'
|
|
+ '<div class="chat-actions" hidden>'
|
|
+ '<button class="dash-btn chat-save" type="button">💾 Lagre i korpus</button>'
|
|
+ '<button class="dash-btn chat-copy" type="button">📋 Kopier</button>'
|
|
+ '</div>'
|
|
+ '<div class="chat-meta" hidden></div>';
|
|
$log.appendChild(wrap);
|
|
$log.scrollTop = $log.scrollHeight;
|
|
return wrap;
|
|
}
|
|
|
|
async function ask(question) {
|
|
appendUser(question);
|
|
history.push({ role: 'user', content: question });
|
|
const aiNode = appendAi();
|
|
const stream = aiNode.querySelector('.chat-stream');
|
|
const thinking = aiNode.querySelector('.chat-thinking');
|
|
const sources = aiNode.querySelector('.chat-sources');
|
|
const actions = aiNode.querySelector('.chat-actions');
|
|
const meta = aiNode.querySelector('.chat-meta');
|
|
|
|
$send.disabled = true;
|
|
$input.disabled = true;
|
|
|
|
let answer = '';
|
|
try {
|
|
const resp = await fetch(api + '/chat-stream.php', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ question, history: history.slice(0, -1) }),
|
|
});
|
|
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
|
|
|
|
const reader = resp.body.getReader();
|
|
const dec = new TextDecoder('utf-8');
|
|
let buffer = '';
|
|
let firstToken = true;
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += dec.decode(value, { stream: true });
|
|
|
|
// Parse SSE frames: blocks separated by "\n\n"
|
|
let idx;
|
|
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
const frame = buffer.slice(0, idx);
|
|
buffer = buffer.slice(idx + 2);
|
|
let evName = 'message';
|
|
let data = '';
|
|
frame.split('\n').forEach(line => {
|
|
if (line.startsWith('event:')) evName = line.slice(6).trim();
|
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
});
|
|
if (!data) continue;
|
|
let payload;
|
|
try { payload = JSON.parse(data); } catch (_) { continue; }
|
|
|
|
if (evName === 'token') {
|
|
if (firstToken) { thinking.remove(); firstToken = false; }
|
|
answer += payload.t || '';
|
|
stream.textContent = answer;
|
|
$log.scrollTop = $log.scrollHeight;
|
|
} else if (evName === 'done') {
|
|
history.push({ role: 'assistant', content: answer });
|
|
renderSources(sources, payload.sources || []);
|
|
meta.hidden = false;
|
|
meta.textContent =
|
|
(payload.chunks_used || 0) + ' passasjer · '
|
|
+ (payload.model || 'auto') + ' · '
|
|
+ (payload.response_time_ms || 0) + ' ms';
|
|
actions.hidden = false;
|
|
wireActions(aiNode, question, answer);
|
|
} else if (evName === 'fail') {
|
|
thinking.textContent = '❌ ' + (payload.message || 'Feil');
|
|
thinking.style.color = 'var(--dbn-red)';
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
thinking.textContent = '❌ ' + err.message;
|
|
thinking.style.color = 'var(--dbn-red)';
|
|
} finally {
|
|
$send.disabled = false;
|
|
$input.disabled = false;
|
|
$input.focus();
|
|
}
|
|
}
|
|
|
|
function renderSources(container, sources) {
|
|
if (!sources.length) return;
|
|
container.hidden = false;
|
|
container.innerHTML = sources.map(s => {
|
|
const label = s.title + (s.section ? ' (§ ' + s.section + ')' : '');
|
|
if (s.document_id > 0) {
|
|
return '<a class="chat-source-chip" href="/dashboard/document.php?id=' + s.document_id + '">'
|
|
+ safe(label) + '</a>';
|
|
}
|
|
if (s.source_url) {
|
|
return '<a class="chat-source-chip" href="' + safe(s.source_url) + '" target="_blank" rel="noopener">'
|
|
+ safe(label) + ' ↗</a>';
|
|
}
|
|
return '<span class="chat-source-chip">' + safe(label) + '</span>';
|
|
}).join('');
|
|
}
|
|
|
|
function wireActions(node, question, answer) {
|
|
node.querySelector('.chat-copy').addEventListener('click', () => {
|
|
navigator.clipboard.writeText(answer).then(() => {
|
|
const btn = node.querySelector('.chat-copy');
|
|
const orig = btn.textContent;
|
|
btn.textContent = '✓ Kopiert';
|
|
setTimeout(() => btn.textContent = orig, 1400);
|
|
});
|
|
});
|
|
node.querySelector('.chat-save').addEventListener('click', () => {
|
|
// Re-uses existing corpus-save.js dialog (loaded by layout footer)
|
|
const dialog = document.getElementById('save-corpus-dialog');
|
|
const titleField = document.getElementById('save-corpus-title');
|
|
const tagsField = document.getElementById('save-corpus-tags');
|
|
if (!dialog || !titleField) {
|
|
alert('Lagre-dialog ikke tilgjengelig.');
|
|
return;
|
|
}
|
|
titleField.value = question.slice(0, 80);
|
|
tagsField.value = 'chat,answer';
|
|
// Hand-off contract used by corpus-save.js: data-pending-content
|
|
dialog.dataset.pendingContent = 'Q: ' + question + '\n\nA: ' + answer;
|
|
dialog.dataset.pendingTool = 'dashboard-chat';
|
|
dialog.showModal();
|
|
});
|
|
}
|
|
|
|
$form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const q = $input.value.trim();
|
|
if (!q) return;
|
|
$input.value = '';
|
|
ask(q);
|
|
});
|
|
|
|
$input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
$form.dispatchEvent(new Event('submit', { cancelable: true }));
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|