feat(dashboard): add corpus dashboard at /dashboard/
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>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$dashboardPage = 'upload';
|
||||
$dashboardTitle = 'Last opp dokumenter';
|
||||
$dashboardLead = 'Last opp PDF, DOCX, eller TXT — eller lim inn tekst eller en URL. Innholdet chunkes, embedes og indekseres i din private korpus.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Velg kilde</h2>
|
||||
<div class="dash-card__actions">
|
||||
<button class="dash-btn upload-mode is-active" data-mode="file">Fil</button>
|
||||
<button class="dash-btn upload-mode" data-mode="text">Lim inn tekst</button>
|
||||
<button class="dash-btn upload-mode" data-mode="url">URL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="upFileForm" class="upload-form" enctype="multipart/form-data" style="display:grid; gap:0.85rem;">
|
||||
<label class="upload-drop" id="upDrop">
|
||||
<input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt" hidden>
|
||||
<span class="upload-drop__icon">📥</span>
|
||||
<strong>Slipp filen her, eller klikk for å bla</strong>
|
||||
<small>PDF · DOCX · TXT · maks 8 MB · automatisk OCR for skannede PDF-er</small>
|
||||
</label>
|
||||
<div class="upload-meta">
|
||||
<label>Tittel<input name="title" placeholder="Auto fra filnavn om tom"></label>
|
||||
<label>Kategori<input name="category" placeholder="f.eks. barnevern, familierett"></label>
|
||||
<label>Tagger<input name="tags" placeholder="komma,separert,liste"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Last opp og indekser</button>
|
||||
</form>
|
||||
|
||||
<form id="upTextForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
||||
<label>Tittel<input name="title" required placeholder="Gi notatet en tittel"></label>
|
||||
<label>Innhold<textarea name="content" rows="12" required placeholder="Lim inn tekst her — minst 30 tegn"></textarea></label>
|
||||
<div class="upload-meta">
|
||||
<label>Kategori<input name="category" placeholder="f.eks. notat"></label>
|
||||
<label>Tagger<input name="tags"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Lagre i korpus</button>
|
||||
</form>
|
||||
|
||||
<form id="upUrlForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
||||
<label>URL<input name="url" type="url" required placeholder="https://lovdata.no/dokument/…"></label>
|
||||
<label>Tittel<input name="title" placeholder="Tom = bruker URL"></label>
|
||||
<div class="upload-meta">
|
||||
<label>Kategori<input name="category"></label>
|
||||
<label>Tagger<input name="tags"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Hent og indekser</button>
|
||||
<small style="color:rgba(22,19,15,0.55);">URLer kjøres i bakgrunnen — sjekk «Dokumenter» for status.</small>
|
||||
</form>
|
||||
|
||||
<div id="upStatus" class="upload-status" hidden></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.upload-mode { background: #fff; }
|
||||
.upload-mode.is-active { background: var(--dbn-blue); color: #fff; border-color: var(--dbn-blue); }
|
||||
.upload-drop {
|
||||
display: grid; gap: 0.4rem; place-items: center; text-align: center;
|
||||
padding: 2.5rem 1rem; border: 2px dashed var(--dbn-line); border-radius: var(--dash-radius);
|
||||
cursor: pointer; background: #fcfaf5; transition: border-color 150ms, background 150ms;
|
||||
}
|
||||
.upload-drop:hover, .upload-drop.is-drag { border-color: var(--dbn-blue); background: #fff; }
|
||||
.upload-drop__icon { font-size: 2.5rem; opacity: 0.5; }
|
||||
.upload-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.85rem; }
|
||||
.upload-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: rgba(22, 19, 15, 0.7); }
|
||||
.upload-form input, .upload-form textarea {
|
||||
padding: 0.55rem 0.7rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
|
||||
font: inherit; background: #fff; width: 100%;
|
||||
}
|
||||
.upload-form textarea { font-family: "IBM Plex Sans", system-ui, sans-serif; resize: vertical; min-height: 8rem; }
|
||||
.upload-status {
|
||||
margin-top: 1.25rem; padding: 0.85rem 1rem; border-radius: var(--dash-radius-sm);
|
||||
background: #fcfaf5; border: 1px solid var(--dbn-line); font-size: 0.9rem;
|
||||
}
|
||||
.upload-status--ok { border-color: rgba(15, 118, 110, 0.4); background: rgba(15, 118, 110, 0.05); }
|
||||
.upload-status--err { border-color: rgba(186, 12, 47, 0.4); background: rgba(186, 12, 47, 0.05); color: var(--dbn-red); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
|
||||
const forms = {
|
||||
file: document.getElementById('upFileForm'),
|
||||
text: document.getElementById('upTextForm'),
|
||||
url: document.getElementById('upUrlForm'),
|
||||
};
|
||||
const status = document.getElementById('upStatus');
|
||||
|
||||
document.querySelectorAll('.upload-mode').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.upload-mode').forEach(b => b.classList.remove('is-active'));
|
||||
btn.classList.add('is-active');
|
||||
const mode = btn.dataset.mode;
|
||||
for (const m in forms) forms[m].hidden = (m !== mode);
|
||||
status.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Drag-drop wiring for file
|
||||
const drop = document.getElementById('upDrop');
|
||||
const fileInput = document.getElementById('upFile');
|
||||
if (drop && fileInput) {
|
||||
drop.addEventListener('click', () => fileInput.click());
|
||||
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => {
|
||||
e.preventDefault(); drop.classList.add('is-drag');
|
||||
}));
|
||||
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => {
|
||||
e.preventDefault(); drop.classList.remove('is-drag');
|
||||
}));
|
||||
drop.addEventListener('drop', e => {
|
||||
if (e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
drop.querySelector('strong').textContent = e.dataTransfer.files[0].name;
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) {
|
||||
drop.querySelector('strong').textContent = fileInput.files[0].name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(html, kind) {
|
||||
status.hidden = false;
|
||||
status.className = 'upload-status' + (kind ? ' upload-status--' + kind : '');
|
||||
status.innerHTML = html;
|
||||
}
|
||||
|
||||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||||
|
||||
forms.file.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(forms.file);
|
||||
if (!fileInput.files.length) { setStatus('Velg en fil først.', 'err'); return; }
|
||||
setStatus('Laster opp og indekserer…');
|
||||
fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
forms.text.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const payload = { kind: 'text' };
|
||||
new FormData(forms.text).forEach((v, k) => payload[k] = v);
|
||||
setStatus('Indekserer…');
|
||||
fetch(api + '/upload.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
forms.url.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const payload = { kind: 'url' };
|
||||
new FormData(forms.url).forEach((v, k) => payload[k] = v);
|
||||
setStatus('Køer URL for henting…');
|
||||
fetch(api + '/upload.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
function handleResult(data) {
|
||||
if (data.status === 'ready') {
|
||||
setStatus('✅ Indeksert! '
|
||||
+ (data.chunks || 0) + ' passasjer lagt til. '
|
||||
+ '<a href="/dashboard/document.php?id=' + data.document_id + '">Åpne dokument</a> · '
|
||||
+ '<a href="/dashboard/documents.php">Se alle</a>', 'ok');
|
||||
['file', 'text', 'url'].forEach(m => forms[m].reset());
|
||||
if (drop) drop.querySelector('strong').textContent = 'Slipp filen her, eller klikk for å bla';
|
||||
} else if (data.status === 'pending') {
|
||||
setStatus('📥 Lagt i kø. '
|
||||
+ '<a href="/dashboard/documents.php">Følg fremdriften i Dokumenter</a>.', 'ok');
|
||||
pollUntilDone(data.document_id);
|
||||
} else if (data.status === 'error') {
|
||||
setStatus('❌ ' + safe(data.error?.message || 'Indeksering feilet.'), 'err');
|
||||
} else if (!data.ok) {
|
||||
setStatus('❌ ' + safe(data.error?.message || 'Opplasting feilet.'), 'err');
|
||||
} else {
|
||||
setStatus('Uventet svar.', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function pollUntilDone(docId) {
|
||||
let tries = 0;
|
||||
const tick = () => {
|
||||
if (++tries > 40) return;
|
||||
fetch(api + '/ingest-status.php?ids=' + docId, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const s = (data.statuses || []).find(x => x.id === docId);
|
||||
if (!s) return;
|
||||
if (s.status === 'ready') {
|
||||
setStatus('✅ Bakgrunnsjobb ferdig. '
|
||||
+ s.chunk_count + ' passasjer indeksert. '
|
||||
+ '<a href="/dashboard/document.php?id=' + docId + '">Åpne dokument</a>', 'ok');
|
||||
return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
setStatus('❌ ' + safe(s.error_message || 'Bakgrunnsjobb feilet.'), 'err');
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
})
|
||||
.catch(() => setTimeout(tick, 4000));
|
||||
};
|
||||
setTimeout(tick, 3000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
Reference in New Issue
Block a user